The comments were the spec, and the code had drifted — 2026-08-25 security review #396

Merged
enricobuehler merged 7 commits from worktree-security-review-fixes into main 2026-08-26 18:56:35 +00:00
117 changed files with 4134 additions and 883 deletions
+5 -1
View File
@@ -29,7 +29,11 @@ jobs:
announce:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
# SHA-pinned, unlike the plain `@v4` the build workflows use: this job holds
# UPDATE_MANIFEST_KEY — the Ed25519 key every host pins to decide whether an update is real —
# and a tag is mutable, so whoever can move it runs code in front of that key. Same style as
# the appleboy pins in deploy-services.yml; the trailing comment is the release it resolves to.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
+6 -4
View File
@@ -115,10 +115,12 @@ jobs:
git nodejs rust clang cmake ninja nasm pkgconf python vulkan-headers \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive || echo "::warning::pacman guard failed (stale image db?) — proceeding with baked packages"
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
# Arch ships bun in [extra], so the bootstrap takes the pacman-signed package instead of
# piping bun.sh's installer into root's shell — this job builds and publishes the package,
# and the installer would be upstream code choosing bytes we then ship. Kept behind the
# `command -v` guard rather than folded into the list above: the image's baked bun is not
# in pacman's db, so `--needed` cannot see it and would re-download bun on every run.
command -v bun >/dev/null || pacman -S --noconfirm --needed bun
bun --version
# THE BUILDER'S FFmpeg IS PART OF THE PACKAGE CONTRACT, not merely a build detail.
+17 -1
View File
@@ -220,9 +220,25 @@ jobs:
run: |
# bun builds AND runs the console. Baked into the rust-ci image; bootstrap here too so the
# job stays green against the PREVIOUS image (docker.yml bootstrap lag).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb (BUN_BIN, below),
# so an install script piped into root's shell is upstream code choosing bytes we then
# publish under REGISTRY_TOKEN. Not in Debian/Ubuntu, so a pin is the only option here.
# ONE bun across the repo: same version as rpm.yml and windows-host.yml, and the same
# asset + sum as rpm.yml (windows pins bun-windows-x64.zip, so its sum differs) — bump
# all three together (the sums are in the release's SHASUMS256.txt). `-baseline` on
# purpose: it needs no AVX2, so the bun we ship starts on every x86-64 box — something the
# auto-detecting installer never promised, since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
apt-get install -y --no-install-recommends unzip
curl -fsSL https://bun.sh/install | bash
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
export PATH="$HOME/.bun/bin:$PATH"
cd web
+23 -4
View File
@@ -215,8 +215,17 @@ jobs:
# device" (see packaging/flatpak/prune-windows-lock.py). The committed Cargo.lock is
# untouched; cargo --offline only needs sources for the crates it compiles.
run: |
# PINNED to a commit and checked by SHA-256. `master` is a mutable ref, and this is
# third-party python executed in the SAME job that holds FLATPAK_GPG_PRIVATE_KEY — it
# chooses which crate sources the signed build vendors, so an upstream push (or a bad
# day at raw.githubusercontent) would be picking bytes we then sign. Bump both together:
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o /tmp/flatpak-cargo-generator.py \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
echo "$GEN_SHA /tmp/flatpak-cargo-generator.py" | sha256sum -c - \
|| { echo "::error::flatpak-cargo-generator.py sha256 mismatch at $GEN_REF"; exit 1; }
python3 packaging/flatpak/prune-windows-lock.py Cargo.lock /tmp/Cargo.flatpak.lock
python3 /tmp/flatpak-cargo-generator.py /tmp/Cargo.flatpak.lock \
-o packaging/flatpak/cargo-sources.json
@@ -413,10 +422,14 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — the same repo
# secret nix.yml publishes with (packaging/nix/README.md). Gated with the rest below: no
# pinned host key, no deploy, never a first-contact-trusts-anything push.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -euo pipefail
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_* not set — skipping repo deploy (bundle still published)."
if [ -z "${FLATPAK_GPG_PRIVATE_KEY:-}" ] || [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "::warning::FLATPAK_GPG_PRIVATE_KEY/DEPLOY_*/DEPLOY_KNOWN_HOSTS not set — skipping repo deploy (bundle still published). See packaging/nix/README.md for the host key."
exit 0
fi
# 1) Import the signing key into a throwaway keyring; sign the repo.
@@ -481,7 +494,13 @@ jobs:
# objects so clients mid-update aren't broken; the fresh signed summary advertises latest.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# FLATPAK_GPG_PRIVATE_KEY and ships the signed OSTree repo, so `accept-new` — which trusts
# the first key it ever sees, and every run starts with an empty known_hosts, so EVERY run
# is a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. The guard above skips the deploy when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
# All idempotent — retried because the runner's link to unom-1 drops TCP dials under
# load (the same flake that hits docker.yml's deploy-docs with "dial tcp: i/o timeout").
+13 -3
View File
@@ -235,16 +235,19 @@ jobs:
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
# unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"`. Gated here with
# the rest: no pinned host key, no publish — never a first-contact-trusts-anything deploy.
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
# repo deploy does, so this workflow stays green through setup.
run: |
set -eu
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ] && [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
echo "go=true" >> "$GITHUB_OUTPUT"
else
echo "go=false" >> "$GITHUB_OUTPUT"
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST/DEPLOY_KNOWN_HOSTS not set — skipping the binary cache publish (see packaging/nix/README.md)."
fi
- name: Build the publishable packages
@@ -269,6 +272,7 @@ jobs:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
@@ -313,7 +317,13 @@ jobs:
# unom-1 drops TCP dials under load.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
# Pin unom-1's host key instead of trusting whoever answers first. This step is holding
# NIX_CACHE_SIGNING_KEY and ships the signed cache, so `accept-new` — which trusts the
# first key it ever sees, and every run starts with an empty known_hosts, so EVERY run is
# a first contact — would hand the deploy key and the publish to anything that won the
# race for the address. Preflight above skips the publish when the secret is unset.
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts; chmod 600 ~/.ssh/known_hosts
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
+16 -2
View File
@@ -119,10 +119,24 @@ jobs:
dnf -y install gamescope || true
# bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# This job holds RPM_GPG_PRIVATE_KEY, and the spec VENDORS this very binary into
# punktfunk-web, so an install script piped into root's shell is upstream code running in
# front of the signing key AND choosing bytes we then sign. Same discipline as
# windows-host.yml's bun pin. Bump BUN_VER and BUN_SHA together (the sums are published in
# the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we
# ship starts on every x86-64 box — something the auto-detecting installer never promised,
# since it reads the BUILDER's CPU, not the user's.
command -v bun >/dev/null || {
dnf -y install unzip
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
BUN_VER=bun-v1.3.14
BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/$BUN_VER/bun-linux-x64-baseline.zip"
echo "$BUN_SHA /tmp/bun.zip" | sha256sum -c -
unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp
install -m0755 /tmp/bun /usr/local/bin/bun
}
bun --version
- uses: actions/cache@v4
Generated
+2 -2
View File
@@ -1959,9 +1959,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.18"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
+4 -1
View File
@@ -82,7 +82,10 @@ us beyond the download itself.
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
public key is baked into the script rather than fetched from the feed. The manifest also names
the feed it was signed for and carries a monotonic publish serial, both inside the signed bytes,
so a genuinely-signed manifest replayed from another channel — or an older one put back — is
refused too.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
+13 -3
View File
@@ -46,16 +46,26 @@ ENV RUSTUP_HOME=/usr/local/rustup \
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
&& rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \
&& cargo install cargo-ndk --locked \
# Version-pinned like every other tool baked in here: unpinned, a rebuild months apart
# silently bakes a different cargo-ndk, and this one drives the shipped Android .so builds.
# crates.io is append-only with a checksummed index, so the version IS the pin. Bump freely.
&& cargo install cargo-ndk@4.1.2 --locked \
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \
&& chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \
&& rustc --version && cargo ndk --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# actions/checkout (and every other JS action: cache, upload-artifact) execs `node` INSIDE
+19 -10
View File
@@ -53,21 +53,30 @@ RUN pacman -Syu --noconfirm --needed \
# below. It does NOT affect the gamescope companion leg — that is meson + its own linker,
# and its `-static-libstdc++` link is untouched.
mold \
&& pacman -Scc --noconfirm
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored
# as their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1); it's AUR-only on Arch, so
# bootstrap the official binary — once, here, instead of per run.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
&& rm -rf /root/.bun \
# bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored as
# their runtime (PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1) — so these bytes end up inside the
# package arch.yml signs and publishes. Arch ships bun in [extra], so take the
# pacman-signed package (pacman verifies package signatures by default) instead of piping
# bun.sh's installer into root's shell, which would be upstream code choosing them. Same
# call as arch.yml's bootstrap guard. It rides THIS transaction rather than a later layer
# on purpose: -Syu refreshes the db in the same step that installs, so a cache-hit rebuild
# can never resolve bun against a stale snapshot the mirrors no longer carry.
bun \
&& pacman -Scc --noconfirm \
&& bun --version
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job
+27 -6
View File
@@ -17,8 +17,8 @@ RUN dnf -y install \
"https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
&& dnf -y install \
# rpmbuild + source-tarball tooling; nodejs runs the Gitea Actions JS (checkout/cache) only
# — the punktfunk-web console builds AND runs on bun (installed below); unzip is for the bun
# installer.
# — the punktfunk-web console builds AND runs on bun (installed below); unzip extracts the
# pinned bun zip.
rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \
# build toolchain + bindgen
gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \
@@ -43,8 +43,22 @@ RUN dnf -y install \
# Nitro `bun`-preset .output, served by `Bun.serve` with TLS — HTTP/1.1 over TLS). The
# RPM vendors THIS bun binary. Not in Fedora repos; install the official standalone binary to a
# system PATH dir so the rpmbuild `%build`/`%install` (run as any uid) find it.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`. The spec
# VENDORS this very binary into punktfunk-web, so the installer would be upstream code choosing
# bytes rpm.yml then signs with RPM_GPG_PRIVATE_KEY. ONE bun across the repo: same version, asset
# and sum as rpm.yml, deb.yml and rust-ci.Dockerfile — bump BUN_VERSION and BUN_SHA together (the
# sums are in the release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun
# we ship starts on every x86-64 box — something the auto-detecting installer never promised, since
# it reads the BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub — the zerocopy path links a fixed set of cuXxx driver symbols, but CI has
@@ -78,9 +92,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -3
View File
@@ -47,7 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# BSD-2 openh264 crate, NOT FFmpeg libx264) are all LGPL-compatible.
# Sourced from the official FFmpeg GitHub mirror by release tag, NOT ffmpeg.org: the CI build network
# can't reach ffmpeg.org (curl times out) but reaches github.com fine. The `nX.Y` tag pins the version
# (n8.0 -> libavcodec 62); bump it to move FFmpeg. Immutable-tag clone, so no separate checksum needed.
# (n8.0 -> libavcodec 62); bump it to move FFmpeg — together with the commit SHA it is pinned to below.
#
# STAYING ON 8.0 THROUGH THE 2026-08-08 FFmpeg-9 BUMP IS DELIBERATE. `ffmpeg-next` moved to 9, but a
# crate major is a CEILING (ffmpeg-sys-next 9 spans libavcodec 56..63), so an 8.0 tree still compiles
@@ -57,16 +57,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# re-qualify the encode stack for every Ubuntu user and buy none of them anything, so it is its own
# change — and it drags NVHDR_TAG and the soname assertion below along with it.
ARG FFMPEG_TAG=n8.0
# The COMMIT that tag points at. A git tag is MUTABLE — upstream can move one, and unlike a branch
# nobody would notice — and these .so's are BUNDLED into the host .deb every Ubuntu user installs.
# The clone below asserts HEAD against this, so a moved tag fails the build loudly instead of
# shipping. Same shape as the bun/sccache sha256 pins: a mismatch stops the build, it does not
# silently "fix" itself. Bump alongside FFMPEG_TAG:
# git ls-remote --tags https://github.com/FFmpeg/FFmpeg.git 'refs/tags/<new-tag>^{}'
# Take the `^{}` line: these are ANNOTATED tags, so the bare ref is the tag OBJECT and the peeled
# `^{}` is the commit — the commit is what a clone leaves at HEAD, and what this compares against.
ARG FFMPEG_SHA=140fd653aed8cad774f991ba083e2d01e86420c7
# nv-codec-headers must MATCH the FFmpeg version: its `master` is NVENC SDK 13, which renamed
# NV_ENC_CLOCK_TIMESTAMP_SET.countingType -> countingTypeLSB and won't compile against FFmpeg 8.0's
# nvenc.c. Pin the last SDK-12 tag (has the field FFmpeg 8.0 expects). Bump alongside FFMPEG_TAG.
ARG NVHDR_TAG=n12.2.72.0
# Commit for NVHDR_TAG, asserted after checkout — see FFMPEG_SHA above for why and how to bump:
# git ls-remote --tags https://github.com/FFmpeg/nv-codec-headers.git 'refs/tags/<new-tag>^{}'
ARG NVHDR_SHA=c69278340ab1d5559c7d7bf0edf615dc33ddbba7
RUN set -eux; \
# nv-codec-headers: the NVENC/NVDEC headers FFmpeg's --enable-nvenc needs (headers only, no lib —
# the driver is dlopen'd at runtime). Installs ffnvcodec.pc under /usr/local/lib/pkgconfig.
git clone --depth 1 --branch "$NVHDR_TAG" https://github.com/FFmpeg/nv-codec-headers.git /tmp/nvhdr; \
test "$(git -C /tmp/nvhdr rev-parse HEAD)" = "$NVHDR_SHA" \
|| { echo "error: nv-codec-headers $NVHDR_TAG is not $NVHDR_SHA — tag moved upstream" >&2; exit 1; }; \
make -C /tmp/nvhdr install PREFIX=/usr/local; \
git clone --depth 1 --branch "$FFMPEG_TAG" https://github.com/FFmpeg/FFmpeg.git /tmp/ffmpeg; \
test "$(git -C /tmp/ffmpeg rev-parse HEAD)" = "$FFMPEG_SHA" \
|| { echo "error: FFmpeg $FFMPEG_TAG is not $FFMPEG_SHA — tag moved upstream" >&2; exit 1; }; \
cd /tmp/ffmpeg; \
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig ./configure \
--prefix=/opt/ffmpeg \
@@ -98,9 +114,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and
+26 -5
View File
@@ -11,7 +11,7 @@
FROM ubuntu:26.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer
# toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip extracts the pinned bun zip
build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \
# mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this
# image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below.
@@ -34,8 +34,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# bun — builds the punktfunk-web console in deb.yml (which runs the web build in THIS image).
# ci.yml's web/docs jobs use the oven/bun image instead, so this is only for the deb job.
RUN curl -fsSL https://bun.sh/install | bash \
&& install -m0755 /root/.bun/bin/bun /usr/local/bin/bun \
#
# A PINNED release asset, checked by SHA-256 — never `curl https://bun.sh/install | bash`.
# build-web-deb.sh VENDORS this very binary into the punktfunk-web .deb, so the installer would be
# upstream code choosing bytes a signing job then publishes. ONE bun across the repo: same version,
# asset and sum as deb.yml and rpm.yml — bump BUN_VERSION and BUN_SHA together (the sums are in the
# release's SHASUMS256.txt). `-baseline` on purpose: it needs no AVX2, so the bun we ship starts on
# every x86-64 box — something the auto-detecting installer never promised, since it reads the
# BUILDER's CPU, not the user's.
ARG BUN_VERSION=1.3.14
ARG BUN_SHA=a063908ae08b7852ca10939bbdc6ceed3ddabce8fb9402dce83d65d73b36e6c7
RUN curl -fsSL -o /tmp/bun.zip \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" \
&& echo "${BUN_SHA} /tmp/bun.zip" | sha256sum -c - \
&& unzip -q -o -j /tmp/bun.zip '*/bun' -d /tmp \
&& install -m0755 /tmp/bun /usr/local/bin/bun \
&& rm -f /tmp/bun.zip /tmp/bun \
&& bun --version
# libcuda link stub: the NVIDIA userspace library (no kernel module needed) provides
@@ -60,9 +74,16 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN,
# see .gitea/workflows — the env lives there so dev use of this image stays uncached).
# musl build: one static binary serves the Ubuntu and Fedora images alike.
# Checked by SHA-256, like the bun pin: sccache is RUSTC_WRAPPER, so it sits in front of every
# rustc invocation that produces a SHIPPED binary. Bump SCCACHE_VERSION and SCCACHE_SHA together —
# upstream publishes the sum as <asset>.tar.gz.sha256 next to the release asset.
ARG SCCACHE_VERSION=0.10.0
RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
ARG SCCACHE_SHA=1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
RUN curl -fsSL -o /tmp/sccache.tar.gz \
"https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
&& echo "${SCCACHE_SHA} /tmp/sccache.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/sccache.tar.gz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \
&& rm -f /tmp/sccache.tar.gz \
&& sccache --version
# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps).
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
@@ -165,6 +166,36 @@ fun RequestAccessPrompt(
}
}
/**
* A `punktfunk://` link that named a saved host by its label or its address rather than by its
* stable id: both are guessable, and the activity is exported, so the dial happens on the user's
* tap instead of on the link's say-so. A link that names the id — every shortcut Punktfunk itself
* emits — never reaches this prompt.
*/
@Composable
fun LinkConnectPrompt(
target: PendingLinkConnect,
onConnect: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
title = "Open this link?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Connect", primary = true, onClick = onConnect),
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("A link asks to connect to ${target.host.name} (${target.host.address}).")
target.launch?.let { PromptText("It also asks the host to launch “$it”.") }
PromptText(
"It names the host by its label or address, which anything that can open a link " +
"could guess. Shortcuts made in Punktfunk name the host's id and connect " +
"without asking.",
)
}
}
/**
* The no-PIN "request access" wait: the connect is parked on the host until the operator approves
* this device. Cancel returns the UI immediately — the caller trips the per-attempt flag so a late
@@ -144,7 +144,7 @@ fun App(forceGamepadUi: Boolean = false) {
activity.pendingDeepLink = null
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return@LaunchedEffect
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(context).all())
val sameHost = target is HostResolution.Known && target.host.id == live.hostId
val sameHost = target is HostResolution.Record && target.host.id == live.hostId
if (!sameHost) {
Toast.makeText(
context,
@@ -3,12 +3,14 @@ package io.unom.punktfunk
import androidx.compose.runtime.Composable
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
/**
* Everything `ConnectScreen` puts ON TOP of whichever home it drew — the trust and pairing
* ceremony, the parked "Waiting for approval…", the console's host options, the speed test, the
* edit form, the local-network rationale, and finally the connect takeover.
* ceremony, a link's connect confirmation, the parked "Waiting for approval…", the console's host
* options, the speed test, the edit form, the local-network rationale, and finally the connect
* takeover.
*
* They live together because their ORDER is the contract: this is a stack of siblings in one tree,
* so the last one drawn is the one on top, and [ConnectOverlay] is last on purpose — a dial can
@@ -33,6 +35,11 @@ internal fun ConnectPrompts(
/** The PIN ceremony completed with this host fingerprint — save as paired, then dial. */
onPaired: (PendingTrust, String) -> Unit,
onRequestAccess: (PendingTrust) -> Unit,
// ---- a link that named a saved host by a guessable reference ----------------------------
/** Non-null while such a link waits for the OK that turns it into a plain dial. */
pendingLinkConnect: PendingLinkConnect?,
onConfirmLinkConnect: (PendingLinkConnect) -> Unit,
onDismissLinkConnect: () -> Unit,
// ---- the parked no-PIN request ----------------------------------------------------------
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
@@ -89,6 +96,14 @@ internal fun ConnectPrompts(
}
}
pendingLinkConnect?.let { plc ->
LinkConnectPrompt(
target = plc,
onConnect = { onConfirmLinkConnect(plc) },
onDismiss = onDismissLinkConnect,
)
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
}
@@ -38,6 +38,7 @@ import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.PendingLinkConnect
import io.unom.punktfunk.models.PendingTrust
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
@@ -284,6 +285,8 @@ fun ConnectScreen(
// A trust decision awaiting the user (first-connect TOFU / fp changed / PIN pairing / the
// request-access-or-PIN choice).
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A `punktfunk://` link that named a saved host by a guessable reference, awaiting the OK.
var pendingLinkConnect by remember { mutableStateOf<PendingLinkConnect?>(null) }
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host being edited (name / address / port / MAC).
@@ -673,8 +676,10 @@ fun ConnectScreen(
}
}
when (val resolved = DeepLinks.resolveHost(link, savedHosts)) {
// Known AND pinned is the one-click contract: do exactly what tapping its card does.
is HostResolution.Known -> {
// A saved record. Pinned AND named by its (unguessable) id is the one-click contract:
// do exactly what tapping its card does. Named by anything a web page could guess —
// its label, its address — the same dial waits for a tap on the confirmation.
is HostResolution.Record -> {
// A pin that contradicts the stored one is the link being stale or lying. Hard
// refusal: this is the one case where doing what the card does would be wrong.
if (link.pinConflict(resolved.host)) {
@@ -691,6 +696,10 @@ fun ConnectScreen(
)
return@LaunchedEffect
}
if (resolved is HostResolution.Confirm) {
pendingLinkConnect = PendingLinkConnect(resolved.host, profileRef, link.launch)
return@LaunchedEffect
}
connect(
resolved.host.address, resolved.host.port,
oneOffProfile = profileRef, launch = link.launch,
@@ -821,6 +830,15 @@ fun ConnectScreen(
doConnect(pt.host, pt.port, pt.name, fp, pt.profile, pt.launch)
},
onRequestAccess = { pt -> pendingTrust = null; requestAccess(pt) },
pendingLinkConnect = pendingLinkConnect,
onConfirmLinkConnect = { plc ->
pendingLinkConnect = null
connect(
plc.host.address, plc.host.port,
oneOffProfile = plc.profile, launch = plc.launch,
)
},
onDismissLinkConnect = { pendingLinkConnect = null },
awaitingHostName = awaiting?.target?.name,
onCancelApproval = {
awaiting?.cancelled?.set(true)
@@ -874,7 +874,7 @@ class MainActivity : ComponentActivity() {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
return target is HostResolution.Record && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
@@ -331,10 +331,11 @@ object SkiaConsole {
}
/**
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
* (the same dial the console's own Launch takes); anything that would need a trust decision
* is a notice here — a link may never establish trust, and the console's Pair screen is
* reached from the host's tile, not from a URL.
* A `punktfunk://` link while the console is up. Named-by-id and pinned is the one-click
* contract (the same dial the console's own Launch takes); anything that would need a trust
* decision — or that named the host by a guessable label or address — is a notice here. A link
* may never establish trust, the console's Pair screen is reached from the host's tile rather
* than from a URL, and the console draws no prompt this shell could ask a question through.
*/
fun handleDeepLink(url: String) {
if (handle == 0L) return
@@ -357,7 +358,7 @@ object SkiaConsole {
}
}
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
is io.unom.punktfunk.kit.link.HostResolution.Record -> {
val kh = resolved.host
if (link.pinConflict(kh)) {
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
@@ -367,6 +368,10 @@ object SkiaConsole {
notice("Pair with ${kh.name} first — a link can't establish trust.")
return
}
if (resolved is io.unom.punktfunk.kit.link.HostResolution.Confirm) {
notice("A link can only dial ${kh.name} by its id — open it from the list.")
return
}
launch(
JSONObject()
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
import io.unom.punktfunk.kit.security.KnownHost
/** Bottom-bar destinations (the immersive stream view is shown full-screen, outside the bar). */
enum class Tab(val label: String, val icon: ImageVector) {
@@ -37,6 +38,22 @@ data class PendingTrust(
enum class Kind { TRUST_NEW, FP_CHANGED, PAIR, REQUEST_ACCESS }
}
/**
* A `punktfunk://` link that named a saved host by something GUESSABLE — its display name or its
* address — instead of by its stable record id, waiting for the user's OK before it dials.
*
* MainActivity is exported with a BROWSABLE `punktfunk://` filter, so any app or web page can emit
* `punktfunk://connect/Gaming%20PC?launch=steam:570`; guessing a label must not be enough to start
* a stream and boot a game. A link that names the record id (the shortcuts this app emits) still
* connects on its own. [profile] and [launch] are the link's, carried across the detour exactly as
* [PendingTrust] carries them.
*/
data class PendingLinkConnect(
val host: KnownHost,
val profile: String? = null,
val launch: String? = null,
)
/**
* A stream session that just opened, and the state the stream screen needs about it.
*
@@ -154,15 +154,22 @@ object DeepLinks {
/**
* Resolve a link's host reference against the local store, in the documented order: stable
* record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
* the recovery path — a self-emitted shortcut that outlived the record it was written from
* still lands on the right box (degraded to the confirmation sheet).
* record id → unique case-insensitive name → `addr[:port]` literal, then the `host=` recovery
* parameter — a self-emitted shortcut that outlived the record it was written from still lands
* on the right box.
*
* Only the record id is UNGUESSABLE, so only the record id resolves to [HostResolution.Known],
* the silent one-click contract. A display name comes from an mDNS instance name or a user
* label ("Gaming PC"), and an address is a LAN address: any zero-permission app or web page can
* emit `punktfunk://connect/Gaming%20PC` and would otherwise start a stream (and launch a
* title) on a guess. Those all resolve to [HostResolution.Confirm] — the same host, behind the
* user's OK.
*/
fun resolveHost(link: DeepLink, hosts: List<KnownHost>): HostResolution {
hosts.firstOrNull { it.id == link.hostRef }?.let { return HostResolution.Known(it) }
val byName = hosts.filter { it.name.equals(link.hostRef, ignoreCase = true) }
when (byName.size) {
1 -> return HostResolution.Known(byName[0])
1 -> return HostResolution.Confirm(byName[0])
0 -> Unit
else -> return HostResolution.Ambiguous
}
@@ -173,7 +180,7 @@ object DeepLinks {
val literal = if (looksLikeAddress(link.hostRef)) parseAddrPort(link.hostRef) else null
for ((addr, port) in listOfNotNull(literal, link.host)) {
hosts.firstOrNull { it.address == addr && it.port == port }
?.let { return HostResolution.Known(it) }
?.let { return HostResolution.Confirm(it) }
}
val fallback = literal ?: link.host ?: return HostResolution.Unresolvable
return HostResolution.Unknown(fallback.first, fallback.second, link.name, link.fp)
@@ -429,8 +436,23 @@ sealed interface DeepLinkResult {
/** What the local host store made of a link's references. */
sealed interface HostResolution {
/** A record we already trust (subject to [DeepLink.pinConflict]). */
data class Known(val host: KnownHost) : HostResolution
/** A saved record — [Known] may act on its own, [Confirm] only once the user says so. */
sealed interface Record : HostResolution {
val host: KnownHost
}
/**
* A record we already trust, named by its stable (unguessable) id: the one-click contract,
* subject to [DeepLink.pinConflict].
*/
data class Known(override val host: KnownHost) : Record
/**
* The same record, but named by something GUESSABLE — its display name, its address, or the
* `host=` recovery parameter. A link may not start a stream on a guess, so this one goes to
* the confirmation the front-end shows: same dial, one tap later.
*/
data class Confirm(override val host: KnownHost) : Record
/**
* No record, but the link says where to dial: the confirmation sheet's input, from which the
@@ -70,7 +70,9 @@ class DeepLinkVectorTest {
* Resolution and emission — the half the vector file can't cover, because it depends on what is in
* THIS device's host store. The rules are the one-click contract in resolution form: an id beats a
* name beats an address, an ambiguous name refuses rather than guesses, and a link whose record is
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying.
* gone still lands on the confirmation sheet via `host=`+`fp=` instead of dying. Only the id — the
* one reference nothing can guess — dials on its own; a name or an address resolves to the same
* host behind a confirmation.
*/
class DeepLinkResolutionTest {
private val fp = "a".repeat(64)
@@ -86,20 +88,47 @@ class DeepLinkResolutionTest {
@Test
fun idBeatsNameBeatsAddress() {
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Known).host)
assertEquals(desk, (resolve("punktfunk://connect/${desk.id}") as HostResolution.Record).host)
assertEquals(desk, (resolve("punktfunk://connect/desk") as HostResolution.Record).host)
assertEquals(desk, (resolve("punktfunk://connect/192.168.1.50") as HostResolution.Record).host)
assertEquals(
desk,
(resolve("punktfunk://connect/192.168.1.50:9777") as HostResolution.Record).host,
)
// Two hosts answer to "Couch" — refuse with a notice, never pick one.
assertEquals(HostResolution.Ambiguous, resolve("punktfunk://connect/couch"))
}
/**
* The record id is a UUID nothing can guess; a display name ("Gaming PC") and a LAN address are
* guesses any web page can make. So the id — and only the id — is the silent one-click dial;
* everything else that finds a saved host stops at [HostResolution.Confirm].
*/
@Test
fun onlyTheRecordIdDialsWithoutAsking() {
assertEquals(HostResolution.Known(desk), resolve("punktfunk://connect/${desk.id}"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/desk"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/DESK"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50"))
assertEquals(HostResolution.Confirm(desk), resolve("punktfunk://connect/192.168.1.50:9777"))
// …including the `host=` recovery path, exactly as its own doc always claimed.
assertEquals(
HostResolution.Confirm(desk),
resolve("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
)
// A launch id doesn't buy a name any authority it didn't have.
assertEquals(
HostResolution.Confirm(desk),
resolve("punktfunk://connect/desk?launch=steam:570"),
)
}
@Test
fun aStaleIdRecoversThroughTheHostParameter() {
val stale = "00000000-0000-4000-8000-000000000000"
assertEquals(
desk,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Known).host,
(resolve("punktfunk://connect/$stale?host=192.168.1.50") as HostResolution.Record).host,
)
// …but a stale id is NOT a hostname: dialing "00000000-…" would be a confusing dead end
// rather than the recovery the grammar specifies.
@@ -24,8 +24,9 @@ struct ContentView: View {
/// connect to resolve the session's `EffectiveSettings`, and edited by the settings surface.
@ObservedObject private var profiles = ProfileStore.shared
@StateObject private var discovery = HostDiscovery()
// The dev auto-connect hook writes these three, so they stay observed here; every OTHER
// stream setting reaches a session through `EffectiveSettings`, resolved once per connect.
// The dev auto-connect hook (DEBUG-only see `autoConnectIfAsked`) writes these three, so
// they stay observed here; every OTHER stream setting reaches a session through
// `EffectiveSettings`, resolved once per connect.
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -52,6 +53,29 @@ struct ContentView: View {
/// a live session is already up. Surfaced as an informational alert (distinct from the
/// "Connection failed" one, which is for actual connect errors).
@State private var deepLinkNotice: String?
/// A `punktfunk://` deep link that named a saved host by something GUESSABLE its display
/// name, its address, or the `host=` recovery parameter instead of by its stable record id.
/// Anything that can open a URL can guess "Gaming PC", so the link's action waits for this
/// confirmation; a link that names the id (every shortcut this app emits) still runs on its own.
private struct DeepLinkConfirm {
let host: StoredHost
let launch: String?
let profile: ProfileSelection
/// A `browse` link: open the host's library instead of dialing it.
let browse: Bool
var actionTitle: String { browse ? "Open Library" : "Connect" }
var message: String {
let asked = browse
? "open \(host.displayName)'s game library"
: "connect to \(host.displayName)"
+ (launch.map { " and launch \u{201C}\($0)\u{201D}" } ?? "")
return "A link asked to \(asked). It names the host by its label or address, which "
+ "anything that can open a link could guess — a shortcut made in Punktfunk names "
+ "the host's id and opens without asking."
}
}
@State private var deepLinkConfirm: DeepLinkConfirm?
#if os(iOS)
/// Owns the Live Activity for the running session (Lock Screen / Dynamic Island). Driven from
/// the session model's published state below; iPhone/iPad only.
@@ -193,6 +217,29 @@ struct ContentView: View {
} message: {
Text(deepLinkNotice ?? "")
}
// A link that named a saved host by a guessable reference: the dial (or the library)
// happens on the user's word rather than on the link's.
.alert(
"Open this link?",
isPresented: deepLinkConfirmPresented,
presenting: deepLinkConfirm
) { confirm in
Button(confirm.actionTitle) { runDeepLinkConfirm(confirm) }
Button("Cancel", role: .cancel) {}
} message: { confirm in
Text(confirm.message)
}
}
/// The confirmed link's action: exactly what a `.known` (id-referenced) link would have done,
/// one tap later.
private func runDeepLinkConfirm(_ confirm: DeepLinkConfirm) {
deepLinkConfirm = nil
if confirm.browse {
libraryTarget = LibraryTarget(host: confirm.host, profile: confirm.profile)
} else {
connect(confirm.host, launchID: confirm.launch, profile: confirm.profile)
}
}
private var driven: some View {
@@ -482,6 +529,12 @@ struct ContentView: View {
set: { if !$0 { deepLinkNotice = nil } })
}
private var deepLinkConfirmPresented: Binding<Bool> {
Binding(
get: { deepLinkConfirm != nil && !consolePromptShowing },
set: { if !$0 { deepLinkConfirm = nil } })
}
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
/// tvOS, whose alerts the focus engine drives natively.
private var consolePromptShowing: Bool {
@@ -558,6 +611,20 @@ struct ContentView: View {
},
])
}
if let confirm = deepLinkConfirm {
return GamepadPrompt(
id: "link-confirm",
title: "Open this link?",
message: confirm.message,
actions: [
GamepadPromptAction(id: "go", title: confirm.actionTitle, isPrimary: true) {
runDeepLinkConfirm(confirm)
},
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
deepLinkConfirm = nil
},
])
}
if let notice = deepLinkNotice {
return GamepadPrompt(
id: "cant-open",
@@ -654,11 +721,13 @@ struct ContentView: View {
/// (design/client-deep-links.md): a stable id, a unique host name or an `addr[:port]`, with
/// `fp`/`host` recovery parameters and a one-off `profile`.
///
/// The security posture is the parser's plus three rules that live here, and none of them
/// The security posture is the parser's plus four rules that live here, and none of them
/// bends: a URL never pairs and never trusts on its own (an unknown host becomes a
/// confirmation, not a connect), never preempts a live session (same host focus, different
/// host say so; NEVER tear one down on a background tap), and carries only references a
/// profile it can't honor refuses with a notice rather than streaming with the wrong settings.
/// confirmation, not a connect), never dials on a GUESSABLE reference (only the stable record
/// id connects unattended a label or an address becomes a confirmation), never preempts a
/// live session (same host focus, different host say so; NEVER tear one down on a
/// background tap), and carries only references a profile it can't honor refuses with a
/// notice rather than streaming with the wrong settings.
private func handleDeepLink(_ url: URL) {
let link: DeepLink
do {
@@ -703,8 +772,12 @@ struct ContentView: View {
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
let resolution = link.resolveHost(in: store.hosts)
switch resolution {
// A saved record. `.known` (named by its unguessable id) dials straight away; `.confirm`
// (named by its label or its address, which anything that can open a URL could guess)
// takes the same dial one tap later.
case .known(let host), .confirm(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
@@ -718,10 +791,19 @@ struct ContentView: View {
}
return // deep-linked to the host we're already on nothing to do
}
if case .confirm = resolution {
deepLinkConfirm = DeepLinkConfirm(
host: host, launch: link.launch, profile: selection, browse: false)
return
}
connect(host, launchID: link.launch, profile: selection)
case .unknown(let address, let port, let name, let fp):
// Never a silent connect: hand the address, claimed name and pin to the add sheet so
// the user makes the trust decision with their eyes on it.
// Never a silent connect an unsaved host is a trust decision, and a link is not
// where it gets made. This only NAMES what the link pointed at; adding the host is a
// deliberate trip to the + button, where the fingerprint is on screen. (Linux, Android
// and Windows instead pre-fill their trust prompt from the link; the outcome is the
// same nothing connects until a person looks at it but the sheet is not seeded
// here, so don't read this as doing that.)
guard model.phase == .idle else {
deepLinkNotice = "Already streaming. End that session first."
return
@@ -765,8 +847,10 @@ struct ContentView: View {
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
let resolution = link.resolveHost(in: store.hosts)
switch resolution {
// Same rule as a connect link: only the record id opens on the link's own say-so.
case .known(let host), .confirm(let host):
guard !link.pinConflict(with: host) else {
deepLinkNotice = "That link's fingerprint doesn't match the identity saved for "
+ "\(host.displayName). It's out of date, or it isn't pointing where it says."
@@ -780,6 +864,11 @@ struct ContentView: View {
}
return // browsing the host we're already streaming nothing to do
}
if case .confirm = resolution {
deepLinkConfirm = DeepLinkConfirm(
host: host, launch: nil, profile: selection, browse: true)
return
}
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
@@ -1425,7 +1514,12 @@ struct ContentView: View {
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope| overrides the
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
///
/// DEBUG-ONLY, and compiled out of a release build: it streams to whatever host an
/// environment variable names with the trust prompt auto-confirmed, which is a dev lever
/// (`swift run`, the shot harness), never something a shipped app should answer to.
private func autoConnectIfAsked() {
#if DEBUG
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
!target.isEmpty, model.phase == .idle
else { return }
@@ -1460,5 +1554,6 @@ struct ContentView: View {
effective.bitrateKbps = v
}
model.connect(to: host, effective: effective, gamepad: pad, autoTrust: true)
#endif
}
}
@@ -1,11 +1,12 @@
// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM),
// generated once and stored in the data-protection Keychain (with a legacy file-keychain
// fallback for unsigned builds see `query(dataProtection:)`). The certificate's fingerprint is how
// hosts recognize this client after PIN pairing losing the key un-pairs this Mac from
// every host, so the pair is presented on every connect but never regenerated once
// stored. That invariant drives the error handling below: a Keychain that *refuses
// access* (locked, ACL denied) is an error, not a first run minting a replacement
// would silently shadow the durable identity and break every existing pairing.
// generated once and stored in the data-protection Keychain, this-device-only (with a legacy
// file-keychain fallback for unsigned builds see `query(dataProtection:)`). The certificate's
// fingerprint is how hosts recognize this client after PIN pairing losing the key un-pairs this
// Mac from every host, so the pair is presented on every connect but never regenerated once
// stored (and never leaves this device: see `add`). That invariant drives the error handling
// below: a Keychain that *refuses access* (locked, ACL denied) is an error, not a first run
// minting a replacement would silently shadow the durable identity and break every existing
// pairing.
import Foundation
import PunktfunkKit
@@ -115,6 +116,16 @@ final class ClientIdentityStore: @unchecked Sendable {
if case .denied(errSecMissingEntitlement) = result {
return read(dataProtection: false)
}
// An item added before the this-device-only switch keeps the accessibility class it was
// added with it would keep riding backups forever, because the identity is never
// regenerated. Re-stamp it on the way past (best-effort: a refusal just leaves the old
// class, and the identity still reads).
if case .found = result {
SecItemUpdate(
Self.query(dataProtection: true) as CFDictionary,
[kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
as CFDictionary)
}
return result
}
@@ -141,9 +152,12 @@ final class ClientIdentityStore: @unchecked Sendable {
else { return errSecParam }
var add = Self.query(dataProtection: true)
add[kSecValueData as String] = data
// After-first-unlock so a background reconnect can still read it; the access-group
// entitlement (not a per-binary ACL) gates it, so it survives rebuilds prompt-free.
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
// After-first-unlock so a background reconnect can still read it, THIS DEVICE ONLY so it
// never rides an encrypted backup or a device migration: this key is the whole credential
// a host pairs with, and a restored backup would silently re-pair the restoring device
// with every host. The access-group entitlement (not a per-binary ACL) gates it, so it
// still survives rebuilds prompt-free.
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecMissingEntitlement else { return status }
// Ad-hoc / unsigned build: persist to the legacy file keychain instead.
@@ -327,16 +327,22 @@ public struct DeepLink: Equatable, Sendable {
}
/// Resolve this link's host reference against the local store, in the documented order:
/// stable record id unique case-insensitive name `addr[:port]` literal. The `host=`
/// parameter is the recovery path a self-emitted shortcut that outlived the record it was
/// written from still lands on the right box (degraded to the confirmation sheet).
/// stable record id unique case-insensitive name `addr[:port]` literal, then the `host=`
/// recovery path a self-emitted shortcut that outlived the record it was written from still
/// lands on the right box.
///
/// Only the record id is UNGUESSABLE, so only the record id resolves to `.known`, the silent
/// one-click contract. A display name is an mDNS instance name or a user label ("Gaming PC")
/// and an address is a LAN address: anything that can open a URL can guess those, and a guess
/// must not be able to start a stream (or launch a title). They resolve to `.confirm` the
/// same host, behind the user's OK.
public func resolveHost(in hosts: [StoredHost]) -> HostResolution {
let reference = hostRef.lowercased()
if let match = hosts.first(where: { $0.id.uuidString.lowercased() == reference }) {
return .known(match)
}
let byName = hosts.filter { !$0.name.isEmpty && $0.name.lowercased() == reference }
if byName.count == 1 { return .known(byName[0]) }
if byName.count == 1 { return .confirm(byName[0]) }
if byName.count > 1 { return .ambiguous }
// `addr[:port]` literal, then the `host=` recovery parameter both matched the way every
// other per-host lookup in the client matches. The literal is only considered when the
@@ -347,7 +353,7 @@ public struct DeepLink: Equatable, Sendable {
if let match = hosts.first(where: {
$0.address == candidate.address && $0.port == candidate.port
}) {
return .known(match)
return .confirm(match)
}
}
guard let target = literal ?? host else { return .unresolvable }
@@ -356,8 +362,13 @@ public struct DeepLink: Equatable, Sendable {
/// What the local host store made of a link's references.
public enum HostResolution: Equatable, Sendable {
/// A record we already have (subject to `pinConflict`).
/// A record we already have, named by its stable (unguessable) id: the one-click contract
/// (subject to `pinConflict`).
case known(StoredHost)
/// The same record, named by something GUESSABLE its display name, its address, or the
/// `host=` recovery parameter. A link may not act on a guess, so this one waits for the
/// user's confirmation; past that it is the `.known` path exactly.
case confirm(StoredHost)
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing flow proceeds under the user's eyes. Never an auto-connect.
case unknown(address: String, port: UInt16, name: String?, fp: String?)
@@ -207,7 +207,8 @@ final class SharedFoundationTests: XCTestCase {
}
/// Resolution order id beats a unique name beats an address plus the two refusals a
/// front-end must surface rather than guess through.
/// front-end must surface rather than guess through, and the rule that keeps a guessable
/// reference from dialing: only the record id resolves to `.known`.
func testDeepLinkHostResolution() throws {
let desk = StoredHost(
id: UUID(uuidString: "11111111-2222-4333-8444-555555555555")!,
@@ -222,14 +223,21 @@ final class SharedFoundationTests: XCTestCase {
XCTAssertEqual(
try resolve("punktfunk://connect/11111111-2222-4333-8444-555555555555"), .known(desk))
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .known(desk))
// The id is a UUID nothing can guess; a display name and a LAN address are guesses any web
// page can make. So the id and only the id dials unattended; everything else that finds
// a saved host stops at the confirmation.
XCTAssertEqual(try resolve("punktfunk://connect/desk"), .confirm(desk))
XCTAssertEqual(try resolve("punktfunk://connect/DESK"), .confirm(desk))
XCTAssertEqual(try resolve("punktfunk://connect/couch"), .ambiguous)
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .known(desk))
// A stale id with the recovery parameter: the address finds the record anyway.
XCTAssertEqual(try resolve("punktfunk://connect/192.168.1.50:9777"), .confirm(desk))
XCTAssertEqual(
try resolve("punktfunk://connect/desk?launch=steam:570"), .confirm(desk))
// A stale id with the recovery parameter: the address finds the record anyway and, being
// an address, behind the confirmation exactly as its own doc always said.
XCTAssertEqual(
try resolve(
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
.known(desk))
.confirm(desk))
// Nothing local matches: the sheet gets the address, the claimed name and the pin which
// is what makes a first connect verified rather than blind trust-on-first-use.
XCTAssertEqual(
+88 -20
View File
@@ -50,7 +50,7 @@ mod cli {
punktfunk the Punktfunk client, headless
punktfunk discover [--json] [--timeout SECS]
punktfunk pair <host[:port]> [--pin N] [--name LABEL]
punktfunk pair <host[:port]> [--pin N|-] [--name LABEL]
punktfunk hosts list [--probe] [--json]
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
punktfunk hosts forget <host-ref>
@@ -58,7 +58,7 @@ punktfunk — the Punktfunk client, headless
punktfunk library <host-ref> [--json]
punktfunk launch <host-ref> [--game ID] [--profile REF] [--request-access]
[--exec] [--fullscreen]
punktfunk open <punktfunk://…>
punktfunk open <punktfunk://…> [--yes]
punktfunk reachable <host-ref>
punktfunk speed-test <host-ref>
punktfunk profiles list [--json]
@@ -98,7 +98,10 @@ address with `punktfunk hosts add` and it shows in `hosts list --probe`."
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
--pin N the PIN the host is showing; without it the command asks, and
refuses (exit 6) when there is no terminal to ask on
refuses (exit 6) when there is no terminal to ask on. The value
sits on argv, which every local user can read (/proc/*/cmdline)
--pin - read the PIN from stdin instead (one line) what a script or
another program should use, so the secret never hits argv
--name LABEL the label the host files this device under
(default: this machine's name)
@@ -187,7 +190,12 @@ Same parser and same refusal rules as clicking the link in a shell: a
contradicted fingerprint refuses and says so, an ambiguous name refuses
rather than guessing, and an unknown host is never trusted from a URL
that is a decision for a person, at a surface that can show the fingerprint
(exit 6 points at `punktfunk pair`). --exec as in launch."
(exit 6 points at `punktfunk pair`). --exec as in launch.
A link that names its host by the stable record id opens straight away. One
that names it by label or address is a guess anything could make, so it asks
first; --yes answers for a script, and without a terminal it refuses (exit 6)
rather than opening unasked."
}
"reachable" => {
"\
@@ -272,6 +280,11 @@ from the config directory for a true factory reset."
/// Resolve a host reference the way every other surface does: stable id, then a unique
/// name, then `addr[:port]` (design/client-deep-links.md §2). Sharing `resolve_host` is
/// what keeps `punktfunk launch desk` and `punktfunk://connect/desk` from disagreeing.
///
/// [`HostResolution::Confirm`] — a guessable reference — is accepted WITHOUT a prompt here,
/// and only here: this reference is an argument the user typed in their own terminal, so
/// there is nobody else to confirm it with. The guessable-reference rule exists for URLs
/// handed to us by someone else; that path is `open`, which does ask.
fn resolve(reference: &str) -> Result<(KnownHosts, usize), u8> {
let known = KnownHosts::load();
let link = DeepLink {
@@ -279,7 +292,7 @@ from the config directory for a true factory reset."
..Default::default()
};
match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => Ok((known, i)),
HostResolution::Known(i) | HostResolution::Confirm(i) => Ok((known, i)),
HostResolution::Ambiguous => {
eprintln!(
"more than one saved host is called \"{reference}\" — use its address or id"
@@ -459,31 +472,34 @@ from the config directory for a true factory reset."
})
}
/// `pair <host[:port]> [--pin N]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// `pair <host[:port]> [--pin N|-]` — the SPAKE2 ceremony. Without `--pin` it prompts, which
/// is the interactive shape; with one it is scriptable. Refuses rather than prompting when
/// stdin isn't a terminal and no PIN was given: a pairing that silently blocks a CI job
/// forever is worse than an exit code.
///
/// `--pin -` reads the PIN from stdin instead. A value on argv is readable by every local
/// user (`/proc/*/cmdline` is world-readable on every distro we target) and the PIN is the
/// only secret binding the ceremony to the operator's intent, so programmatic callers — the
/// Decky backend among them — pipe it in rather than spelling it on the command line.
fn pair(args: &[String]) -> u8 {
let Some(target) = positional(args, 0) else {
eprintln!("usage: punktfunk pair <host[:port]> [--pin N]");
eprintln!("usage: punktfunk pair <host[:port]> [--pin N|-]");
return UNRESOLVED;
};
let (addr, port) = split_host_port(&target);
let pin = match value(args, "--pin") {
Some(p) => p,
let pin = match value(args, "--pin").as_deref() {
Some("-") => read_pin(None),
Some(p) => Some(p.to_string()),
None if is_tty() => read_pin(Some(&addr)),
None => {
if !is_tty() {
eprintln!("no --pin and no terminal to ask on");
return NEEDS_INTERACTION;
}
eprint!("PIN shown on {addr}: ");
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
return NEEDS_INTERACTION;
}
line.trim().to_string()
eprintln!("no --pin and no terminal to ask on");
return NEEDS_INTERACTION;
}
};
let Some(pin) = pin else {
eprintln!("no PIN on stdin");
return NEEDS_INTERACTION;
};
let identity = match trust::load_or_create_identity() {
Ok(i) => i,
Err(e) => {
@@ -898,6 +914,31 @@ from the config directory for a true factory reset."
);
match outcome {
Ok(PlanOutcome::Connect(plan)) => run_plan(*plan, has(args, "--exec"), false),
// The link named the host by something GUESSABLE — its label, its address — rather
// than by its record id. A URL handed to us by someone else may not dial on a guess,
// so a person says yes first. `--yes` is the scripted escape (and the only way in
// without a terminal to ask on).
Ok(PlanOutcome::ConfirmConnect(plan)) => {
if !has(args, "--yes") {
if !is_tty() {
eprintln!(
"that link names {} by label or address, not by its id — re-run with \
--yes to open it",
plan.host.name
);
return NEEDS_INTERACTION;
}
eprint!("Connect to {} ({})? [y/N] ", plan.host.name, plan.host.addr);
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err()
|| !line.trim().eq_ignore_ascii_case("y")
{
eprintln!("cancelled");
return OK;
}
}
run_plan(*plan, has(args, "--exec"), false)
}
// A URL may never pair or trust on its own — that is a decision for a person, at a
// surface that can show them the fingerprint.
Ok(PlanOutcome::ConfirmUnknown(u)) => {
@@ -1049,7 +1090,11 @@ from the config directory for a true factory reset."
..Default::default()
};
let (addr, port) = match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
// Nothing is dialled and no title is launched, so a guessable reference needs no
// confirmation — it only picks which address to send one probe packet to.
HostResolution::Known(i) | HostResolution::Confirm(i) => {
(known.hosts[i].addr.clone(), known.hosts[i].port)
}
_ => split_host_port(&reference),
};
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
@@ -1220,6 +1265,19 @@ from the config directory for a true factory reset."
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
/// One line of PIN from stdin — prompted when we're asking a person, silent for `--pin -`
/// (a pipe from another program). `None` on a read error or an empty line (EOF), which the
/// caller turns into [`NEEDS_INTERACTION`] rather than sending an empty PIN to the host.
fn read_pin(prompt_for: Option<&str>) -> Option<String> {
if let Some(addr) = prompt_for {
eprint!("PIN shown on {addr}: ");
}
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok()?;
let pin = line.trim();
(!pin.is_empty()).then(|| pin.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1250,6 +1308,16 @@ from the config directory for a true factory reset."
Some("10.0.0.1".into())
);
assert_eq!(positional(&argv(&["--json"]), 0), None);
// `--pin -` (the PIN comes down stdin, never argv): the lone dash is that flag's
// VALUE, not the host to pair with.
assert_eq!(
positional(&argv(&["--pin", "-", "desk"]), 0),
Some("desk".into())
);
assert_eq!(
value(&argv(&["desk", "--pin", "-"]), "--pin"),
Some("-".into())
);
}
#[test]
+18 -4
View File
@@ -407,11 +407,17 @@ def _cli_argv() -> list[str] | None:
return [str(sibling)] if sibling.exists() else None
async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
async def _run_cli(
args: list[str], timeout: float = 20.0, stdin_text: str | None = None
) -> tuple[int, str, str]:
"""Run the headless CLI, returning ``(returncode, stdout, stderr)``. SEPARATE pipes: stdout
is the machine interface (JSON/TSV) and stderr carries the log lines, and merging them would
corrupt every payload. ``(-1, "", "")`` when no client is installed or the call times out.
``stdin_text`` is written to the child and the pipe closed the way a secret reaches the CLI,
because argv does not qualify: ``/proc/*/cmdline`` is world-readable, so every process on the
Deck can read a flag's value. See :meth:`Plugin.pair`.
The same ``_flatpak_env`` repair the client runs needed applies here unchanged Decky's
PyInstaller ``LD_LIBRARY_PATH`` leak breaks the flatpak's libcurl whatever binary inside the
sandbox is being started."""
@@ -422,10 +428,12 @@ async def _run_cli(args: list[str], timeout: float = 20.0) -> tuple[int, str, st
try:
proc = await asyncio.create_subprocess_exec(
*prefix, *args,
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_flatpak_env(),
)
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
payload = stdin_text.encode() if stdin_text is not None else None
out, err = await asyncio.wait_for(proc.communicate(payload), timeout=timeout)
rc = proc.returncode if proc.returncode is not None else -1
return (
rc,
@@ -761,21 +769,27 @@ class Plugin:
return await _cli_json(["hosts", "list", "--probe", "--json"], timeout=30.0)
async def pair(self, addr: str, port: int, pin: str, name: str = "Steam Deck") -> dict:
"""The PIN ceremony (``punktfunk pair <addr:port> --pin N --name LABEL``).
"""The PIN ceremony (``punktfunk pair <addr:port> --pin - --name LABEL``).
The operator arms pairing on the host, which shows a 4-digit PIN; entering it here
verifies the host end to end and pins its fingerprint, so every later connect is silent.
``{ok: True}``, or ``{ok: False, error}`` where ``refused`` is a wrong PIN or a host
that isn't armed, and ``unreachable`` is a host that never answered.
The PIN goes down the child's STDIN (``--pin -``), never on argv: it is the only secret
binding the ceremony to the operator's intent, and a value on the command line is readable
by every local process for as long as the call runs (~100 s here) long enough for anyone
on the Deck to complete the pairing with their own keypair instead.
The budget is generous because the ceremony waits on a person at the other end."""
rc, out, err = await _run_cli(
[
"pair", f"{addr}:{int(port)}",
"--pin", str(pin).strip(),
"--pin", "-",
"--name", name,
],
timeout=100.0,
stdin_text=f"{str(pin).strip()}\n",
)
if rc == 0:
fp = ""
+49 -4
View File
@@ -612,10 +612,10 @@ impl AppModel {
}
/// Route a `punktfunk://` URL (design/client-deep-links.md §4.1). Parsing, host/profile
/// resolution and every refusal rule live in the shared brain (`plan_from_link`); this is
/// only the GTK end of it — turn the outcome into the same messages a card click raises,
/// so a link gets the identical wake, trust and error surfaces and NOT a second connect
/// path of its own.
/// resolution and every refusal rule — including "only a stable record id may dial
/// unattended" — live in the shared brain (`plan_from_link`); this is only the GTK end of
/// it: turn the outcome into the same messages a card click raises, so a link gets the
/// identical wake, trust and error surfaces and NOT a second connect path of its own.
fn open_deep_link(&mut self, url: &str, sender: &ComponentSender<AppModel>) {
use pf_client_core::deeplink;
use pf_client_core::orchestrate::{plan_from_link, PlanOutcome};
@@ -660,6 +660,51 @@ impl AppModel {
AppMsg::Connect(req)
});
}
Ok(PlanOutcome::ConfirmConnect(plan)) => {
// The link named this (saved, pinned) host by its LABEL or its ADDRESS rather
// than by its record id. `x-scheme-handler/punktfunk` is registered by our
// .desktop, so any web page can hand us such a URL and both of those are
// guessable — the dial waits for a person. Deliberately not the PIN ceremony
// below: this host is already pinned, and re-pairing it would throw that away.
if self.busy {
return self.toast("A session is already running — end it first.");
}
let req = ConnectRequest {
name: plan.host.name.clone(),
addr: plan.host.addr.clone(),
port: plan.host.port,
fp_hex: plan.host.fp_hex.clone(),
pair_optional: false,
launch: plan.launch.clone().map(|id| (id.clone(), id)),
mac: plan.host.mac.clone(),
profile: plan.profile_override.clone(),
};
let mut body = format!("A link asks to connect to {} ({}).", req.name, req.addr);
if let Some((id, _)) = &req.launch {
body.push_str(&format!("\n\nIt also asks the host to launch “{id}”."));
}
body.push_str(
"\n\nIt names the host by its label or address, which anything that can \
open a link could guess. A link that names the host's id connects without \
asking.",
);
let dialog = adw::AlertDialog::new(Some("Open this link?"), Some(&body));
dialog.add_responses(&[("cancel", "Cancel"), ("connect", "Connect")]);
dialog.set_response_appearance("connect", adw::ResponseAppearance::Suggested);
dialog.set_close_response("cancel");
let sender = sender.clone();
let wake = plan.wake;
dialog.connect_response(Some("connect"), move |_, _| {
// The same two messages the `Connect` arm raises, so the confirmed link
// gets the identical wake / trust / error surfaces a card click gets.
sender.input(if wake {
AppMsg::WakeConnect(req.clone())
} else {
AppMsg::Connect(req.clone())
});
});
dialog.present(Some(&self.window));
}
Ok(PlanOutcome::ConfirmUnknown(unknown)) => {
// Known-but-unpinned, or not known at all: the link may not pair and may not
// trust on its own, so it opens the ordinary ceremony under the user's eyes —
+127 -52
View File
@@ -326,6 +326,13 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
// later instance over WM_COPYDATA) and this poll pulls them onto the UI thread. Thread-fed
// state must be root state, like the pad count below.
let (deep_link, set_deep_link) = cx.use_async_state(Option::<String>::None);
// A link that named its host by something GUESSABLE (its label, its address, the `host=`
// recovery parameter) rather than by the stable record id: `Some(plan)` arms the "Open this
// link?" confirmation built at the bottom of this function. The plan is byte-for-byte the one
// an id-referenced link carries, so confirming runs the identical dial one click later. Root
// state like every other dialog flag in this shell.
let (link_confirm, set_link_confirm) =
cx.use_async_state(Option::<Box<pf_client_core::orchestrate::ConnectPlan>>::None);
cx.use_effect((), {
let set_deep_link = set_deep_link.clone();
move || {
@@ -363,16 +370,18 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let (library, set_library) = cx.use_async_state(library::LibraryState::default());
// Continuous LAN discovery (spawned once).
// Route an arriving link. Parsing, host and profile resolution and every refusal rule live
// in the shared brain (`plan_from_link`); this is only the WinUI end — turn the outcome into
// the same call a tile click makes, so a link gets the identical wake, trust and error
// surfaces rather than a second connect path of its own.
// Route an arriving link. Parsing, host and profile resolution and every refusal rule
// including "only a stable record id may dial unattended" — live in the shared brain
// (`plan_from_link`); this is only the WinUI end — turn the outcome into the same call a tile
// click makes, so a link gets the identical wake, trust and error surfaces rather than a
// second connect path of its own.
cx.use_effect(deep_link.clone(), {
let (ctx, set_screen, set_status, set_deep_link) = (
let (ctx, set_screen, set_status, set_deep_link, set_link_confirm) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
set_deep_link.clone(),
set_link_confirm.clone(),
);
let screen_now = screen.clone();
move || {
@@ -403,41 +412,16 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
);
use pf_client_core::orchestrate::PlanOutcome;
match plan {
Ok(PlanOutcome::Connect(p)) => {
let target = Target {
name: p.host.name.clone(),
addr: p.host.addr.clone(),
port: p.host.port,
fp_hex: p.host.fp_hex.clone(),
pair_optional: false,
mac: p.host.mac.clone(),
mgmt_port: p.host.mgmt_port,
profile: p.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
// With a MAC it takes the dial first wake path, so a sleeping host wakes
// instead of erroring — exactly what clicking its tile would do. The
// link's `launch=` id must reach the session (`--launch`) — this arm used
// to drop it, so a game link opened a plain desktop session.
match (p.launch.clone(), p.wake && !target.mac.is_empty()) {
(Some(id), true) => {
connect::initiate_launch_waking(
&ctx,
target,
id,
&set_screen,
&set_status,
);
}
(Some(id), false) => {
connect::initiate_launch(&ctx, target, id, &set_screen, &set_status);
}
(None, true) => {
connect::initiate_waking(&ctx, target, &set_screen, &set_status)
}
(None, false) => connect::initiate(&ctx, target, &set_screen, &set_status),
}
}
Ok(PlanOutcome::Connect(p)) => dial_link(&ctx, &p, &set_screen, &set_status),
// The link named a saved, pinned host by its LABEL or its ADDRESS rather than
// by its record id. This app registers the `punktfunk` scheme (AppxManifest's
// windows.protocol / the installer's URL Protocol key), so any web page can
// hand us such a URL, and both of those references are guessable — it may not
// dial on its own. Arm the confirmation instead; OK runs `dial_link` on the
// very same plan, so the confirmed link and an id-referenced one are one code
// path. Deliberately NOT the PIN ceremony below: this host is already pinned,
// and re-pairing it would throw that pin away.
Ok(PlanOutcome::ConfirmConnect(p)) => set_link_confirm.call(Some(p)),
// Known but never pinned, or not known at all: a link may not pair and may not
// trust on its own, so it opens the ordinary PIN ceremony seeded with what the
// link CLAIMED — name shown as claimed, the fingerprint pre-filling the pin so
@@ -729,19 +713,110 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
Screen::Stream => stream::session_page(ctx, &hud),
};
// The "Open this link?" confirmation for a guessable-reference link (see `link_confirm`).
// It lives at ROOT, not on a page: a link can arrive over WM_COPYDATA while any screen is
// up, and a WinUI ContentDialog is a popup rather than a visual child, so it rides above
// whatever is showing. Same discipline as the shell's other dialogs — ALWAYS MOUNTED, with
// `is_open` doing the arming, in a stable trailing slot (unmounting a ContentDialog trips
// the reactor backend's phantom-child bookkeeping; see hosts.rs's forget confirmation).
let link_dialog: Element = {
let pending = link_confirm;
// Name the host AND the game, because that is the whole point of asking: it's what
// lets someone tell their own shortcut from a link a web page just handed them.
let content = pending
.as_ref()
.map(|p| {
let mut s = format!(
"A link asks to connect to {} ({}).",
p.host.name, p.host.addr
);
if let Some(id) = &p.launch {
s.push_str(&format!(
"\n\nIt also asks the host to launch \u{201c}{id}\u{201d}."
));
}
s.push_str(
"\n\nIt names the host by its label or address, which anything that can open \
a link could guess. Shortcuts made in Punktfunk name the host's id and \
connect without asking.",
);
s
})
.unwrap_or_default();
let (ctx2, ss, st, sc) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
set_link_confirm.clone(),
);
ContentDialog::new("Open this link?")
.content(content)
.primary_button_text("Connect")
.close_button_text("Cancel")
.is_open(pending.is_some())
.on_closed(move |r: ContentDialogResult| {
sc.call(None);
// Cancel (and Escape, which WinUI also reports as `None`) does nothing at all.
if r == ContentDialogResult::Primary
&& let Some(plan) = &pending
{
dial_link(&ctx2, plan, &ss, &st);
}
})
.into()
};
// The Stream screen is a plain status card (the session child owns the real stream window);
// it's shown without the navigation entrance tween. Everything else slides + fades in.
if matches!(screen, Screen::Stream) {
return body;
let page: Element = if matches!(screen, Screen::Stream) {
body
} else {
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
};
grid(vec![page, link_dialog]).into()
}
/// Run a resolved link plan: the same four calls a host tile's click makes, so a link gets the
/// identical wake, trust and error surfaces rather than a second connect path of its own. Shared
/// by the two outcomes that dial — `PlanOutcome::Connect` (the link named the stable record id)
/// and a confirmed `PlanOutcome::ConfirmConnect` — so the confirmation is one click in front of
/// this, never a second implementation of it.
fn dial_link(
ctx: &Arc<AppCtx>,
plan: &pf_client_core::orchestrate::ConnectPlan,
set_screen: &AsyncSetState<Screen>,
set_status: &AsyncSetState<String>,
) {
let target = Target {
name: plan.host.name.clone(),
addr: plan.host.addr.clone(),
port: plan.host.port,
fp_hex: plan.host.fp_hex.clone(),
pair_optional: false,
mac: plan.host.mac.clone(),
mgmt_port: plan.host.mgmt_port,
profile: plan.profile_override.clone(),
launch: None, // routed explicitly below (initiate_launch*)
};
// With a MAC it takes the dial first wake path, so a sleeping host wakes instead of
// erroring — exactly what clicking its tile would do. The link's `launch=` id must reach
// the session (`--launch`) — this used to drop it, so a game link opened a plain desktop
// session.
match (plan.launch.clone(), plan.wake && !target.mac.is_empty()) {
(Some(id), true) => {
connect::initiate_launch_waking(ctx, target, id, set_screen, set_status);
}
(Some(id), false) => connect::initiate_launch(ctx, target, id, set_screen, set_status),
(None, true) => connect::initiate_waking(ctx, target, set_screen, set_status),
(None, false) => connect::initiate(ctx, target, set_screen, set_status),
}
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
}
+174 -12
View File
@@ -1921,19 +1921,15 @@ mod tests {
}
}
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 0); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
// profile_tier_level(1, 0): general_profile_space u(2), tier u(1),
// profile_idc u(5), 32 compatibility flags, progressive/interlaced/
// non-packed/frame-only, 43 constraint/reserved bits (all zero for every
// profile branch the parser takes), inbld/reserved bit, level u(8).
/// profile_tier_level()'s general block: general_profile_space u(2), tier u(1),
/// profile_idc u(5), 32 compatibility flags, progressive/interlaced/non-packed/
/// frame-only, 43 constraint/reserved bits (all zero for every profile branch the
/// parser takes), inbld/reserved bit, level u(8). The per-sub-layer tail follows
/// only when max_sub_layers_minus1 > 0.
fn ptl_general(s: &mut BitSink, profile_idc: u8, level_idc: u32) {
s.bits(2, 0);
s.bit(0);
s.bits(5, u32::from(o.profile_idc));
s.bits(5, u32::from(profile_idc));
s.bits(32, 0);
s.bit(1); // general_progressive_source_flag
s.bit(0); // general_interlaced_source_flag
@@ -1942,7 +1938,15 @@ mod tests {
s.bits(31, 0);
s.bits(12, 0); // 43 zero bits total
s.bit(0); // general_inbld_flag / reserved
s.bits(8, o.level_idc); // general_level_idc
s.bits(8, level_idc); // general_level_idc
}
fn synth_sps(o: &SpsOpts) -> Vec<u8> {
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 0); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
ptl_general(&mut s, o.profile_idc, o.level_idc);
s.ue(0); // sps_seq_parameter_set_id
s.ue(o.chroma_format_idc);
@@ -3579,4 +3583,162 @@ mod tests {
assert!(plan.picture.is_idr);
assert!(plan.warnings.is_empty(), "{:?}", plan.warnings);
}
// ------- vendored-parser bounds regressions (deviations 9-11) -------
const VPS_NUT: u8 = 32;
const SPS_NUT: u8 = 33;
const PPS_NUT: u8 = 34;
/// Trailing bits, so the parser keeps reading past the guarded field instead of
/// stopping short — a truncated NALU would be an error for the wrong reason.
fn padded(mut s: BitSink) -> Vec<u8> {
for _ in 0..64 {
s.bits(8, 0xff);
}
s.finish()
}
/// Deviation 9: `{vps,sps}_max_sub_layers_minus1` is u(3), so 7 is representable,
/// but 7.4.3.1/7.4.3.2 stop at 6 — and the sub-layer arrays the parser then walks
/// are six and seven deep. Both param sets are now a parse error, not a panic.
/// This is also what keeps the planner's own
/// `max_num_reorder_pics[max_sub_layers_minus1]` reads in bounds.
#[test]
fn a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic() {
let mut s = BitSink::new();
s.bits(4, 0); // vps_video_parameter_set_id
s.bit(1); // vps_base_layer_internal_flag
s.bit(1); // vps_base_layer_available_flag
s.bits(6, 0); // vps_max_layers_minus1
s.bits(3, 7); // vps_max_sub_layers_minus1 — one past the spec's 6
s.bit(1); // vps_temporal_id_nesting_flag
s.bits(16, 0xffff); // vps_reserved_0xffff_16bits
ptl_general(&mut s, 1, 120);
let vps = h265_nalu(VPS_NUT, &padded(s));
assert!(matches!(
H265Planner::new().plan_au(&vps),
Err(PlanError::Parse(_))
));
let mut s = BitSink::new();
s.bits(4, 0); // sps_video_parameter_set_id
s.bits(3, 7); // sps_max_sub_layers_minus1
s.bit(1); // sps_temporal_id_nesting_flag
ptl_general(&mut s, 1, 120);
let sps = h265_nalu(SPS_NUT, &padded(s));
assert!(matches!(
H265Planner::new().plan_au(&sps),
Err(PlanError::Parse(_))
));
}
/// The PPS fields ahead of the two this section attacks, all zero.
fn pps_prefix() -> BitSink {
let mut s = BitSink::new();
s.ue(0); // pps_pic_parameter_set_id
s.ue(0); // pps_seq_parameter_set_id
s.bits(2, 0); // dependent_slice_segments_enabled / output_flag_present
s.bits(3, 0); // num_extra_slice_header_bits
s.bits(2, 0); // sign_data_hiding_enabled / cabac_init_present
s.ue(0); // num_ref_idx_l0_default_active_minus1
s.ue(0); // num_ref_idx_l1_default_active_minus1
s.se(0); // init_qp_minus26
s.bits(3, 0); // constrained_intra_pred / transform_skip / cu_qp_delta_enabled
s.se(0); // pps_cb_qp_offset
s.se(0); // pps_cr_qp_offset
s.bits(4, 0); // chroma_qp_offsets / weighted_pred / weighted_bipred / bypass
s
}
/// The PPS fields after the tile block, all zero, plus rbsp_trailing_bits().
fn pps_tail(mut s: BitSink) -> Vec<u8> {
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
s.bits(2, 0); // pps_scaling_list_data_present / lists_modification_present
s.ue(0); // log2_parallel_merge_level_minus2
s.bits(2, 0); // slice_segment_header_extension / pps_extension_present
s.finish()
}
/// Deviation 10: equation 7-42 subtracts `scaling_list_pred_matrix_id_delta` from
/// matrixId in u32. Unbounded, it underflows into an out-of-bounds read of the
/// six-entry scaling lists; 7.4.5 caps it at matrixId / (sizeId == 3 ? 3 : 1).
#[test]
fn a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic() {
let mut s = pps_prefix();
s.bits(2, 0); // tiles_enabled / entropy_coding_sync_enabled
s.bits(2, 0); // loop_filter_across_slices / deblocking_filter_control_present
s.bit(1); // pps_scaling_list_data_present_flag
s.bit(0); // scaling_list_pred_mode_flag[0][0]
s.ue(1); // scaling_list_pred_matrix_id_delta[0][0] — refMatrixId = 0 - 1
let mut au = synth_sps(&SpsOpts::default());
au.extend(h265_nalu(PPS_NUT, &padded(s)));
assert!(matches!(
H265Planner::new().plan_au(&au),
Err(PlanError::Parse(_))
));
}
/// Deviation 11: the tile counts were bounded by the picture's CTB size only, so a
/// wide-enough SPS let them run past the width and height arrays. Table A.8 caps
/// them at 20 columns and 22 rows for every level.
#[test]
fn a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic() {
// 2048x2048 luma with 64x64 CTBs: 32 CTBs each way, so the picture bound
// alone would admit 31 tile columns and rows.
let sps = SpsOpts {
width: 2048,
height: 2048,
..Default::default()
};
for (columns, rows) in [(25, 0), (0, 25)] {
let mut s = pps_prefix();
s.bit(1); // tiles_enabled_flag
s.bit(0); // entropy_coding_sync_enabled_flag
s.ue(columns); // num_tile_columns_minus1
s.ue(rows); // num_tile_rows_minus1
s.bit(1); // uniform_spacing_flag
let mut au = synth_sps(&sps);
au.extend(h265_nalu(PPS_NUT, &padded(s)));
assert!(
matches!(H265Planner::new().plan_au(&au), Err(PlanError::Parse(_))),
"{columns} columns / {rows} rows must be refused"
);
}
}
/// The tile ceiling's two downstream sites, both reached from one slice header:
/// the entry-point maximum multiplied the two tile counts in u8 (20 x 22 overflows
/// it), and `entry_point_offset_minus1` is 32 deep however large that maximum is.
#[test]
fn a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic() {
let sps = SpsOpts {
width: 2048,
height: 2048,
..Default::default()
};
let mut s = pps_prefix();
s.bit(1); // tiles_enabled_flag
s.bit(0); // entropy_coding_sync_enabled_flag
s.ue(19); // num_tile_columns_minus1 — Table A.8's ceiling, and legal here
s.ue(21); // num_tile_rows_minus1
s.bit(1); // uniform_spacing_flag
s.bit(0); // loop_filter_across_tiles_enabled_flag
let mut au = synth_sps(&sps);
au.extend(h265_nalu(PPS_NUT, &pps_tail(s)));
let mut s = BitSink::new();
s.bit(1); // first_slice_segment_in_pic_flag
s.bit(0); // no_output_of_prior_pics_flag
s.ue(0); // slice_pic_parameter_set_id
s.ue(2); // slice_type: I
s.se(0); // slice_qp_delta
s.ue(35); // num_entry_point_offsets — 440 tiles would allow it, 32 slots do not
au.extend(h265_nalu(IDR_W_RADL, &padded(s)));
assert!(matches!(
H265Planner::new().plan_au(&au),
Err(PlanError::Parse(_))
));
}
}
+103
View File
@@ -105,5 +105,108 @@ in the future."
AV1 has its own `read_su`, and the H.26x `se(v)` callers all pass positive widths — so
it is left to upstream rather than widened into this deviation.
9. `src/codec/h265/parser.rs``parse_vps` and `parse_sps`: reject
`{vps,sps}_max_sub_layers_minus1 > 6` immediately after the read. The element is
`u(3)`, so 7 is representable, but 7.4.3.1 and 7.4.3.2 both bound it at 6 — and
every array the parser then walks with it is sized for the spec, not for the field:
`profile_tier_level()`'s `sub_layer_*` flags are `[_; 6]` and the sub-layer ordering
arrays are `[_; 7]`. A ~20-byte VPS NALU with the field set to 7 panicked inside
`parse_profile_tier_level` with an index-out-of-bounds before any picture was
decoded, on every reconnect. Bound at the spec's 6, not at the arrays' 5/6, because
the two agree there — a conformant stream is never refused. This is also what keeps
punktfunk's own `sps.max_num_reorder_pics[max_sub_layers_minus1]` and
`sps.max_dec_pic_buffering_minus1[max_sub_layers_minus1]` reads in bounds
(`pf-bitstream` h265.rs, and the `pf-vaadec` / `pf-dxvadec` / `pf-vkdecode` picture
builders downstream of it) — they all take their `Sps` from this parser, so the
parse-time check is the single choke point and none of them needs its own guard.
Regression-tested in `pf-bitstream`
(`a_param_set_claiming_eight_sub_layers_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
10. `src/codec/h265/parser.rs``parse_scaling_list_data`: read
`scaling_list_pred_matrix_id_delta` with `read_ue_max(matrixId / factor)` instead of
an unbounded `read_ue`, which is exactly the range 7.4.5 permits (`0` to
`matrixId / ( sizeId == 3 ? 3 : 1 )`). Equation 7-42 subtracts
`delta * factor` from `matrixId` in `u32`: unbounded it underflows — a debug panic
on the subtraction, and in release a `~4e9` index into the six-entry
`scaling_list_{4x4,8x8,16x16,32x32}`. Reachable from a PPS with
`pps_scaling_list_data_present_flag` set, or the equivalent SPS flag. `factor` moved
a few lines up so the bound and equation 7-42 share one definition; the same bound
also makes `delta * factor` unable to overflow. Regression-tested in `pf-bitstream`
(`a_scaling_list_predicting_from_a_negative_matrix_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
11. `src/codec/h265/parser.rs` — the tile syntax, three edits, all one defect. `parse_pps`
bounded `num_tile_{columns,rows}_minus1` by the picture only
(`pic_{width,height}_in_ctbs_y - 1`, which reaches 2110 on a legal SPS) while using
them to index `column_width_minus1` / `row_height_minus1`. A PPS on a 2048x2048 SPS
asking for 26 tile columns panicked. Annex A is the real bound: A.4.1 requires
`num_tile_columns_minus1 < MaxTileCols` and `num_tile_rows_minus1 < MaxTileRows`,
and Table A.8 peaks at 20 and 22 (levels 6, 6.1, 6.2). So:
- new `MAX_TILE_COLUMNS` / `MAX_TILE_ROWS` consts (20, 22), and the counts are read
with `min(picture bound, const - 1)`;
- the arrays grew from `[u32; 19]` / `[u32; 21]` to those consts. Upstream sized them
one short of Table A.8 — the code stores the running remainder in
`column_width_minus1[num_tile_columns_minus1]`, so the last tile needs a slot —
which means bounding at the arrays would have refused a conformant 20-column
stream. Growing by one entry each costs nothing and lets the guard be the spec's
number rather than an implementation artefact. The upstream test asserting
`[0; 19]` / `[0; 21]` follows the consts now.
Raising the ceiling to the spec's exposed two further panics downstream, in
`parse_slice_header`'s entry-point block (both reachable before this change too, at
the arrays' old 19x21 ceiling):
- the `num_entry_point_offsets` maximum computed
`(num_tile_columns_minus1 + 1) * (num_tile_rows_minus1 + 1) - 1` in `u8`, which
overflows above 256 tiles — 20x22 is 440. Widened to `u32`, matching the sibling
branch two lines down;
- `num_entry_point_offsets` was then bounded by that maximum while
`entry_point_offset_minus1` is `[u32; 32]`, so a slice claiming 35 entry points
indexed past it. Clamped to the array, the same way deviation 7 handles the
long-term arrays. 7.4.7.1 puts no 32-entry cap on the element, so this refuses a
conformant stream with more than 32 entry points — notably 4K wavefront
(`entropy_coding_sync_enabled_flag`) streams, which carry one offset per CTB row.
An error beats a panic, but the real fix is upstream sizing that array from the
stream.
Regression-tested in `pf-bitstream`
(`a_pps_with_more_tiles_than_any_level_allows_is_a_parse_error_not_a_panic`,
`a_slice_claiming_more_entry_points_than_the_header_holds_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
12. `src/codec/av1/parser.rs``parse_tile_info`: the two non-uniform tile loops
(`uniform_tile_spacing_flag == 0`) run until `start_sb` reaches `sb_cols` / `sb_rows`
while filling `width_in_sbs_minus_1` / `height_in_sbs_minus_1`, which are
`MAX_TILE_COLS` / `MAX_TILE_ROWS` (64) deep. Each iteration advances `start_sb` by at
least one superblock, so a frame wide or tall enough — 4096 mi columns is 256
superblocks — walks 256 entries into a 64-entry array. The uniform branch already
checks `tile_cols > MAX_TILE_COLS` after the fact and is genuinely bounded before it
(`tile_cols_log2 <= max_log2_tile_cols <= 6`); the non-uniform branch had neither.
Guarded at the top of each loop body, returning the same
`"Invalid tile_{cols,rows} {n}"` the uniform branch does. 64 is the spec's own
ceiling (`MAX_TILE_COLS` / `MAX_TILE_ROWS` in 3, and a conformance requirement on
`TileCols` / `TileRows` in 5.11.1), so it is both the array bound and the legal one.
Regression-tested in the file's own test module
(`more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
13. `src/codec/h264/parser.rs``parse_sps`: reject a picture whose macroblock count
overflows `u32`, with the `checked_mul` idiom the frame-crop validation a few lines
below already uses. `max_dpb_frames()` computes
`max_dpb_mbs / (width_mb * height_mb)` (A.3.1); both dimensions are `ue(v)` read into
`u16`, so each reaches 65536 macroblocks and their product reaches 2^33. In debug
that is a multiply-overflow panic, in release it wraps — 65536 x 65536 wraps to
exactly zero — and the division that follows panics on a zero divisor. `max_dpb_frames()`
returns `usize`, not `Result`, and the DPB and `max_num_order_frames()` both call it,
so the check belongs at the parse boundary where an `Err` is available. Bounded at
the arithmetic limit rather than Table A-1's `MaxFS`: the level tables are the only
range H.264 gives these elements, this parser enforces no other level conformance at
parse time, and hardware decoders routinely accept a stream whose level_idc
understates its resolution. Regression-tested in the file's own test module
(`a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic`).
**Report upstream — not yet filed.**
Re-sync procedure: fetch the AOSP tree, re-apply this trim, diff `codec/` +
`bitstream_utils.rs` (expect near-zero conflicts), update the commit pin above.
@@ -2361,6 +2361,10 @@ impl Parser {
let mut i = 0;
while start_sb < sb_cols {
if i >= MAX_TILE_COLS {
return Err(format!("Invalid tile_cols {}", i + 1));
}
self.mi_col_starts[i] = start_sb << sb_shift;
let max_width = std::cmp::min(sb_cols - start_sb, max_tile_width_sb);
@@ -2387,6 +2391,10 @@ impl Parser {
let mut start_sb = 0;
let mut i = 0;
while start_sb < sb_rows {
if i >= MAX_TILE_ROWS {
return Err(format!("Invalid tile_rows {}", i + 1));
}
self.mi_row_starts[i] = start_sb << sb_shift;
let max_height = std::cmp::min(sb_rows - start_sb, max_tile_height_sb);
ti.height_in_sbs_minus_1[i] = r.read_ns(max_height.try_into().unwrap())?;
@@ -4291,4 +4299,39 @@ mod tests {
}
}
}
/// punktfunk deviation 12: the non-uniform tile loops are bounded by the frame's
/// superblock count, not by the 64-entry `width_in_sbs_minus_1` /
/// `height_in_sbs_minus_1` they fill, so a frame made of one-superblock tiles
/// walked off both. MAX_TILE_COLS / MAX_TILE_ROWS are the spec's own ceiling.
#[test]
fn more_non_uniform_tiles_than_the_spec_allows_is_a_parse_error_not_a_panic() {
use crate::codec::av1::parser::{SequenceHeaderObu, TileInfo};
use crate::codec::av1::reader::Reader;
use std::rc::Rc;
// All zeroes: uniform_tile_spacing_flag = 0, then every ns() read decodes to
// a one-superblock tile.
let data = [0u8; 128];
// 4096 mi columns is 256 superblocks, so the column loop runs 256 times.
let mut parser = Parser::default();
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
parser.mi_cols = 4096;
parser.mi_rows = 4096;
let err = parser
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
.unwrap_err();
assert!(err.starts_with("Invalid tile_cols"), "{err}");
// Four superblocks wide: the column loop finishes, the row loop overruns.
let mut parser = Parser::default();
parser.sequence_header = Some(Rc::new(SequenceHeaderObu::default()));
parser.mi_cols = 64;
parser.mi_rows = 4096;
let err = parser
.parse_tile_info(&mut Reader::new(&data), &mut TileInfo::default())
.unwrap_err();
assert!(err.starts_with("Invalid tile_rows"), "{err}");
}
}
@@ -2102,6 +2102,13 @@ impl Parser {
sps.pic_height_in_map_units_minus1 = r.read_ue()?;
sps.frame_mbs_only_flag = r.read_bit()?;
// max_dpb_frames() divides MaxDpbMbs by the frame's macroblock count (A.3.1).
// ue(v) admits 65536 macroblocks in each direction, and the u32 product of the
// two wraps to zero long before that.
let _ = (sps.width() / 16)
.checked_mul(sps.height() / 16)
.ok_or::<String>("Invalid picture size in macroblocks".into())?;
if !sps.frame_mbs_only_flag {
sps.mb_adaptive_frame_field_flag = r.read_bit()?;
}
@@ -3059,4 +3066,43 @@ mod tests {
assert_eq!(MaxLongTermFrameIdx::Idx(24), 24);
assert!(MaxLongTermFrameIdx::Idx(24) < 25);
}
/// punktfunk deviation 13: `max_dpb_frames()` divides MaxDpbMbs by the frame's
/// macroblock count (A.3.1), a u32 product that wraps to zero at the widest
/// picture `ue(v)` admits — 65536 x 65536 macroblocks is exactly 2^32. The SPS is
/// refused at parse time now, so the division always has a divisor.
#[test]
fn a_picture_whose_macroblock_count_overflows_is_a_parse_error_not_a_panic() {
use crate::codec::h264::nalu_writer::NaluWriter;
let mut buf = Vec::<u8>::new();
{
let mut w = NaluWriter::new(&mut buf, true);
w.write_header(3, 7).unwrap(); // nal_ref_idc = 3, SPS
w.write_u(8, 66u32).unwrap(); // profile_idc: Baseline, so no chroma block
w.write_u(8, 0u32).unwrap(); // constraint flags + reserved_zero_2bits
w.write_u(8, 51u32).unwrap(); // level_idc: 5.1
w.write_ue(0u32).unwrap(); // seq_parameter_set_id
w.write_ue(0u32).unwrap(); // log2_max_frame_num_minus4
w.write_ue(2u32).unwrap(); // pic_order_cnt_type: 2, nothing follows
w.write_ue(1u32).unwrap(); // max_num_ref_frames
w.write_f(1, 0u32).unwrap(); // gaps_in_frame_num_value_allowed_flag
w.write_ue(65535u32).unwrap(); // pic_width_in_mbs_minus1
w.write_ue(65535u32).unwrap(); // pic_height_in_map_units_minus1
w.write_f(1, 1u32).unwrap(); // frame_mbs_only_flag
w.write_f(1, 1u32).unwrap(); // direct_8x8_inference_flag
w.write_f(1, 0u32).unwrap(); // frame_cropping_flag
w.write_f(1, 0u32).unwrap(); // vui_parameters_present_flag
w.write_f(1, 1u32).unwrap(); // rbsp_stop_one_bit
while !w.aligned() {
w.write_f(1, 0u32).unwrap();
}
}
let mut cursor = Cursor::new(buf.as_slice());
let nalu = Nalu::next(&mut cursor).unwrap();
assert!(matches!(nalu.header.type_, NaluType::Sps));
let err = Parser::default().parse_sps(&nalu).unwrap_err();
assert!(err.starts_with("Invalid picture size"), "{err}");
}
}
@@ -42,6 +42,11 @@ const MAX_SHORT_TERM_REF_PIC_SETS: usize = 65;
// 7.4.3.2.1:
const MAX_LONG_TERM_REF_PIC_SETS: usize = 32;
// Table A.8: MaxTileCols and MaxTileRows peak at 20 and 22 at level 6.2, and A.4.1
// makes those a bitstream conformance requirement for every level.
const MAX_TILE_COLUMNS: usize = 20;
const MAX_TILE_ROWS: usize = 22;
// From table 7-5.
const DEFAULT_SCALING_LIST_0: [u8; 16] = [16; 16];
@@ -1239,10 +1244,10 @@ pub struct Pps {
pub uniform_spacing_flag: bool,
/// `column_width_minus1[ i ]` plus 1 specifies the width of the i-th tile
/// column in units of CTBs.
pub column_width_minus1: [u32; 19],
pub column_width_minus1: [u32; MAX_TILE_COLUMNS],
/// `row_height_minus1[ i ]` plus 1 specifies the height of the i-th tile row
/// in units of CTBs.
pub row_height_minus1: [u32; 21],
pub row_height_minus1: [u32; MAX_TILE_ROWS],
/// When set, specifies that in-loop filtering operations may be performed
/// across tile boundaries in pictures referring to the PPS. When not set,
/// specifies that in-loop filtering operations are not performed across
@@ -2238,6 +2243,15 @@ impl Parser {
..Default::default()
};
// u(3) can say 7, but 7.4.3.1 stops at 6 — and 7 walks off every sub-layer
// array below, starting with profile_tier_level()'s six-deep flags.
if vps.max_sub_layers_minus1 > 6 {
return Err(format!(
"Invalid max_sub_layers_minus1 {}",
vps.max_sub_layers_minus1
));
}
r.skip_bits(16)?; // vps_reserved_0xffff_16bits
let ptl = &mut vps.profile_tier_level;
@@ -2606,12 +2620,16 @@ impl Parser {
// in Table 7-5 and Table 7-6 for i = 0..Min( 63, ( 1 << ( 4 + (
// sizeId << 1 ) ) ) 1 ).
if !scaling_list_pred_mode_flag {
let scaling_list_pred_matrix_id_delta: u32 = r.read_ue()?;
// Equation 7-42's factor. 7.4.5 bounds the delta by
// matrixId / factor, which is what keeps refMatrixId at or above
// zero — unbounded it underflows into an out-of-bounds read.
let factor: u32 = if size_id == 3 { 3 } else { 1 };
let scaling_list_pred_matrix_id_delta: u32 =
r.read_ue_max(matrix_id as u32 / factor)?;
if scaling_list_pred_matrix_id_delta == 0 {
Self::fill_default_scaling_list(sl, size_id, matrix_id);
} else {
// Equation 7-42
let factor = if size_id == 3 { 3 } else { 1 };
let ref_matrix_id =
matrix_id as u32 - scaling_list_pred_matrix_id_delta * factor;
if size_id == 0 {
@@ -3104,6 +3122,14 @@ impl Parser {
..Default::default()
};
// See parse_vps(): 7.4.3.2 bounds this at 6, u(3) does not.
if sps.max_sub_layers_minus1 > 6 {
return Err(format!(
"Invalid max_sub_layers_minus1 {}",
sps.max_sub_layers_minus1
));
}
Self::parse_profile_tier_level(
&mut sps.profile_tier_level,
&mut r,
@@ -3485,8 +3511,16 @@ impl Parser {
// A mix of the rbsp data and the algorithm in 6.5.1
if pps.tiles_enabled_flag {
pps.num_tile_columns_minus1 = r.read_ue_max(sps.pic_width_in_ctbs_y - 1)?;
pps.num_tile_rows_minus1 = r.read_ue_max(sps.pic_height_in_ctbs_y - 1)?;
// 7.4.3.3.1 bounds these by the picture, Table A.8 bounds them by the
// level — and the level cap is the one the arrays below are sized for.
pps.num_tile_columns_minus1 = r.read_ue_max(std::cmp::min(
sps.pic_width_in_ctbs_y - 1,
MAX_TILE_COLUMNS as u32 - 1,
))?;
pps.num_tile_rows_minus1 = r.read_ue_max(std::cmp::min(
sps.pic_height_in_ctbs_y - 1,
MAX_TILE_ROWS as u32 - 1,
))?;
pps.uniform_spacing_flag = r.read_bit()?;
if !pps.uniform_spacing_flag {
pps.column_width_minus1[usize::from(pps.num_tile_columns_minus1)] =
@@ -4122,12 +4156,21 @@ impl Parser {
let max = if !pps.tiles_enabled_flag && pps.entropy_coding_sync_enabled_flag {
sps.pic_height_in_ctbs_y - 1
} else if pps.tiles_enabled_flag && !pps.entropy_coding_sync_enabled_flag {
u32::from((pps.num_tile_columns_minus1 + 1) * (pps.num_tile_rows_minus1 + 1) - 1)
// Widened: Table A.8 permits 20 x 22 tiles, whose product does not fit
// the u8 the tile counts are stored in.
(u32::from(pps.num_tile_columns_minus1) + 1)
* (u32::from(pps.num_tile_rows_minus1) + 1)
- 1
} else {
(u32::from(pps.num_tile_columns_minus1) + 1) * sps.pic_height_in_ctbs_y - 1
};
hdr.num_entry_point_offsets = r.read_ue_max(max)?;
// 7.4.7.1 puts no 32-entry cap on num_entry_point_offsets, but
// entry_point_offset_minus1 is that deep, so the array is the real bound.
hdr.num_entry_point_offsets = r.read_ue_max(std::cmp::min(
max,
hdr.entry_point_offset_minus1.len() as u32 - 1,
))?;
if hdr.num_entry_point_offsets > 0 {
hdr.offset_len_minus1 = r.read_ue_max(31)?;
for i in 0..hdr.num_entry_point_offsets as usize {
@@ -4189,6 +4232,8 @@ mod tests {
use crate::codec::h265::parser::NaluType;
use crate::codec::h265::parser::Parser;
use crate::codec::h265::parser::SliceType;
use crate::codec::h265::parser::MAX_TILE_COLUMNS;
use crate::codec::h265::parser::MAX_TILE_ROWS;
const STREAM_BEAR: &[u8] = include_bytes!("test_data/bear.h265");
const STREAM_BEAR_NUM_NALUS: usize = 35;
@@ -4718,8 +4763,8 @@ mod tests {
assert_eq!(pps.num_tile_rows_minus1, 0);
assert_eq!(pps.num_tile_columns_minus1, 0);
assert!(pps.uniform_spacing_flag);
assert_eq!(pps.column_width_minus1, [0; 19]);
assert_eq!(pps.row_height_minus1, [0; 21]);
assert_eq!(pps.column_width_minus1, [0; MAX_TILE_COLUMNS]);
assert_eq!(pps.row_height_minus1, [0; MAX_TILE_ROWS]);
assert!(pps.loop_filter_across_slices_enabled_flag);
assert!(pps.loop_filter_across_tiles_enabled_flag);
assert!(!pps.deblocking_filter_control_present_flag);
+63 -13
View File
@@ -314,9 +314,13 @@ impl DeepLink {
/// What the local host store made of a link's references.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum HostResolution {
/// Index into `KnownHosts::hosts` — a record we already trust (subject to
/// [`DeepLink::pin_conflict`]).
/// Index into `KnownHosts::hosts` — a record we already trust, named by its stable
/// (unguessable) id: the one-click contract (subject to [`DeepLink::pin_conflict`]).
Known(usize),
/// The same index, for a record named by something GUESSABLE — its display name, its
/// address, or the `host=` recovery parameter. A link may not act on a guess, so the
/// front-end confirms first; past that it is the [`HostResolution::Known`] path exactly.
Confirm(usize),
/// No record, but the link says where to dial: the confirmation sheet's input, from which
/// the normal pairing/TOFU flow proceeds under the user's eyes. Never an auto-connect.
Unknown {
@@ -332,9 +336,16 @@ pub enum HostResolution {
}
/// Resolve a link's host reference against the local store, in the documented order: stable
/// record id → unique case-insensitive name → `addr[:port]` literal. The `host=` parameter is
/// the recovery path — a self-emitted shortcut that outlived the record it was written from
/// still lands on the right box (degraded to the confirmation sheet).
/// record id → unique case-insensitive name → `addr[:port]` literal, then the `host=` recovery
/// path — a self-emitted shortcut that outlived the record it was written from still lands on
/// the right box.
///
/// Only the record id is UNGUESSABLE, so only the record id resolves to
/// [`HostResolution::Known`], the silent one-click contract. A display name is an mDNS instance
/// name or a user label ("Gaming PC") and an address is a LAN address: the `.desktop` files
/// register `x-scheme-handler/punktfunk`, so any web page can hand this resolver a guess, and a
/// guess must not be able to start a stream (or launch a title). Those resolve to
/// [`HostResolution::Confirm`] — the same host, behind the user's OK.
///
/// Returns an index rather than a borrow so callers can keep mutating the store (rekey,
/// touch-last-used) without fighting the borrow checker.
@@ -354,7 +365,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
.map(|(i, _)| i)
.collect();
match by_name.len() {
1 => return HostResolution::Known(by_name[0]),
1 => return HostResolution::Confirm(by_name[0]),
0 => {}
_ => return HostResolution::Ambiguous,
}
@@ -367,7 +378,7 @@ pub fn resolve_host(link: &DeepLink, known: &KnownHosts) -> HostResolution {
.flatten();
for candidate in [literal.clone(), link.host.clone()].into_iter().flatten() {
if let Some(i) = known.index_by_addr(&candidate.0, candidate.1) {
return HostResolution::Known(i);
return HostResolution::Confirm(i);
}
}
match literal.or_else(|| link.host.clone()) {
@@ -587,7 +598,9 @@ mod tests {
/// The one-click contract in resolution form: an id beats a name beats an address, an
/// ambiguous name refuses, and a link whose record is gone still lands on the
/// confirmation sheet via `host=`+`fp=` instead of dying.
/// confirmation sheet via `host=`+`fp=` instead of dying. Only the id — the one reference
/// nothing can guess — dials on its own; a name or an address finds the same host behind a
/// confirmation.
#[test]
fn host_resolution_order_and_recovery() {
let fp = "a".repeat(64);
@@ -619,20 +632,23 @@ mod tests {
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
HostResolution::Known(0)
);
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Known(0));
// A display name ("Gaming PC") and a LAN address are guesses any web page can make —
// the same host, but only behind a confirmation. See the test below.
assert_eq!(r("punktfunk://connect/desk"), HostResolution::Confirm(0));
assert_eq!(r("punktfunk://connect/couch"), HostResolution::Ambiguous);
assert_eq!(
r("punktfunk://connect/192.168.1.50"),
HostResolution::Known(0)
HostResolution::Confirm(0)
);
assert_eq!(
r("punktfunk://connect/192.168.1.50:9777"),
HostResolution::Known(0)
HostResolution::Confirm(0)
);
// A stale id with the recovery parameters: the address finds the record anyway.
// A stale id with the recovery parameters: the address finds the record anyway — and,
// being an address, behind the confirmation exactly as this function's doc always said.
assert_eq!(
r("punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50"),
HostResolution::Known(0)
HostResolution::Confirm(0)
);
// Nothing local matches: the sheet gets the address, the claimed name and the pin —
// which is what makes the first connect verified rather than blind TOFU.
@@ -680,6 +696,40 @@ mod tests {
assert!(!link.pin_conflict(&known.hosts[1]));
}
/// The record id is a UUID nothing can guess; a display name and a LAN address are guesses
/// any web page can make — and `x-scheme-handler/punktfunk` hands web pages this resolver.
/// So the id, and only the id, is the silent one-click dial.
#[test]
fn only_the_record_id_dials_without_asking() {
let fp = "a".repeat(64);
let known = KnownHosts {
hosts: vec![host(
"Desk",
"192.168.1.50",
"11111111-2222-4333-8444-555555555555",
&fp,
)],
};
let r = |url: &str| resolve_host(&parse(url).unwrap(), &known);
assert_eq!(
r("punktfunk://connect/11111111-2222-4333-8444-555555555555"),
HostResolution::Known(0)
);
for guess in [
"punktfunk://connect/desk",
"punktfunk://connect/DESK",
"punktfunk://connect/192.168.1.50",
"punktfunk://connect/192.168.1.50:9777",
// A launch id doesn't buy a name any authority it didn't have.
"punktfunk://connect/desk?launch=steam:570",
// …and neither does the `host=` recovery path.
"punktfunk://connect/00000000-0000-4000-8000-000000000000?host=192.168.1.50",
] {
assert_eq!(r(guess), HostResolution::Confirm(0), "{guess}");
}
}
/// Self-emitted links round-trip and carry all three references, so they survive both a
/// re-addressed host and a wiped store.
#[test]
+40 -6
View File
@@ -251,6 +251,13 @@ impl ConnectPlan {
#[derive(Clone, Debug, PartialEq)]
pub enum PlanOutcome {
Connect(Box<ConnectPlan>),
/// The same plan, for a link that named the host by something GUESSABLE — its display name,
/// its address, or the `host=` recovery parameter — rather than by its stable record id.
/// `x-scheme-handler/punktfunk` means any web page can emit such a link, so the front-end
/// asks first and then runs it exactly as [`PlanOutcome::Connect`]. NOT
/// [`PlanOutcome::ConfirmUnknown`]: this host is saved and pinned, and routing it through
/// the pairing ceremony would drop the pin it already has.
ConfirmConnect(Box<ConnectPlan>),
/// The link resolved to no local record. The front-end shows the confirmation sheet with
/// exactly this, and the normal pairing/TOFU flow proceeds under the user's eyes (§3.1).
ConfirmUnknown(Box<UnknownHost>),
@@ -316,7 +323,9 @@ impl PlanError {
/// Build a plan from a `punktfunk://` link against this device's stores — the shared half of
/// every platform's URL router (§4). The security rules of §3 live here, not in the shells:
/// no pairing, no silent trust, references resolved or refused.
/// no pairing, no silent trust, no dial on a GUESSABLE reference (only the stable record id
/// yields [`PlanOutcome::Connect`]; a name or an address yields
/// [`PlanOutcome::ConfirmConnect`]), references resolved or refused.
///
/// Preempting a live session is the one rule that stays with the caller: only the front-end
/// knows whether a session is running, and the answer ("focus it" / "end that one first")
@@ -341,8 +350,12 @@ pub fn plan_from_link(
_ => return Err(PlanError::UnknownProfile(reference.clone())),
}
}
match crate::deeplink::resolve_host(link, known) {
HostResolution::Known(i) => {
let resolution = crate::deeplink::resolve_host(link, known);
// A guessable reference (name / address / `host=`) reaches the same plan, but the front-end
// must put a person in front of it — see `PlanOutcome::ConfirmConnect`.
let confirm = matches!(resolution, HostResolution::Confirm(_));
match resolution {
HostResolution::Known(i) | HostResolution::Confirm(i) => {
let host = &known.hosts[i];
if link.pin_conflict(host) {
return Err(PlanError::PinConflict {
@@ -376,7 +389,11 @@ pub fn plan_from_link(
// window title (it names nothing that is trusted).
plan.host.name = link.name.clone().unwrap_or_else(|| plan.host.addr.clone());
}
Ok(PlanOutcome::Connect(Box::new(plan)))
Ok(if confirm {
PlanOutcome::ConfirmConnect(Box::new(plan))
} else {
PlanOutcome::Connect(Box::new(plan))
})
}
HostResolution::Unknown {
addr,
@@ -910,8 +927,8 @@ mod tests {
let plan =
|url: &str| plan_from_link(&deeplink::parse(url).unwrap(), &known, &catalog, &base);
// A known, pinned host with a matching (or absent) fp: a plain connect.
let out = plan("punktfunk://connect/Desk").unwrap();
// A known, pinned host named by its (unguessable) record id: a plain connect.
let out = plan("punktfunk://connect/11111111-2222-4333-8444-555555555555").unwrap();
match out {
PlanOutcome::Connect(p) => {
assert_eq!(p.host.addr, "192.168.1.50");
@@ -921,6 +938,23 @@ mod tests {
other => panic!("expected a connect, got {other:?}"),
}
// The SAME host named by its label — which any web page could guess, and
// `x-scheme-handler/punktfunk` lets one hand us: the same plan, but the shell must ask
// first. Deliberately not `ConfirmUnknown`: that would re-run the pairing ceremony on a
// host that is already pinned.
match plan("punktfunk://connect/Desk").unwrap() {
PlanOutcome::ConfirmConnect(p) => {
assert_eq!(p.host.addr, "192.168.1.50");
assert!(p.host.fp_hex.is_some());
}
other => panic!("expected a confirm-connect, got {other:?}"),
}
// …and by its address, launch id and all.
match plan("punktfunk://connect/192.168.1.50?launch=steam:570").unwrap() {
PlanOutcome::ConfirmConnect(p) => assert_eq!(p.launch.as_deref(), Some("steam:570")),
other => panic!("expected a confirm-connect, got {other:?}"),
}
// A lying/stale fingerprint never connects, and says which host it was about.
assert_eq!(
plan(&format!("punktfunk://connect/Desk?fp={}", "b".repeat(64))),
+33 -12
View File
@@ -102,14 +102,21 @@ async fn run(
}
}
ClipCoordCmd::RemoteOffer { seq, mimes } => {
client_seq = seq;
let res = if mimes.is_empty() {
backend.clear_offer()
} else {
backend.set_offer(&mimes)
};
if let Err(e) = res {
tracing::debug!(error = %e, "clipboard apply remote offer failed");
// Only while the client has sync on — with it off the device's grant was
// revoked (or it never enabled sync at all), and its content must not go
// on the host's real clipboard. Re-read HERE and not only at the sender:
// the access lifecycle task clears the flag from another task, which can
// land between the offer being queued and this arm running.
if clip_enabled.load(Ordering::SeqCst) {
client_seq = seq;
let res = if mimes.is_empty() {
backend.clear_offer()
} else {
backend.set_offer(&mimes)
};
if let Err(e) = res {
tracing::debug!(error = %e, "clipboard apply remote offer failed");
}
}
}
}
@@ -127,8 +134,16 @@ async fn run(
ClipEvent::Paste { mime, responder } => {
// A host app is pasting the client's offered content: pull that format from
// the client and hand it to the backend's responder. Off-task so the loop
// keeps serving.
tokio::spawn(fetch_into_pipe(conn.clone(), client_seq, mime, responder));
// keeps serving. The enable snapshot rides along like `serve_fetch`'s: a
// paste that raced a revoke must not fetch from the client, even though
// the selection it owned is being dropped in the same breath.
tokio::spawn(fetch_into_pipe(
conn.clone(),
client_seq,
mime,
responder,
clip_enabled.load(Ordering::SeqCst),
));
}
ClipEvent::Closed => break,
}
@@ -221,14 +236,20 @@ async fn serve_fetch(
}
/// Pull `mime` of the client's current offer (`seq`) over an outbound fetch stream and hand the bytes
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) responds with empty bytes
/// so the pasting host app gets an empty paste instead of hanging.
/// to the backend's paste `responder`. Any failure (timeout, decline, I/O) — or `enabled` being false,
/// the client having lost the clipboard since the paste started — responds with empty bytes so the
/// pasting host app gets an empty paste instead of hanging.
async fn fetch_into_pipe(
conn: quinn::Connection,
seq: u32,
mime: String,
responder: PasteResponder,
enabled: bool,
) {
if !enabled {
responder.respond(Vec::new()).await;
return;
}
let req = ClipFetch {
seq,
file_index: CLIP_FILE_INDEX_NONE,
+1
View File
@@ -68,6 +68,7 @@ pub enum ClipCoordCmd {
/// clipboard; when disabled, it drops any selection it owns and stops forwarding host copies.
SetEnabled(bool),
/// The client copied: install its offered wire MIMEs as a lazy host selection (empty = clear).
/// Ignored while sync is off, so a revoked device's content never reaches the host clipboard.
RemoteOffer { seq: u32, mimes: Vec<String> },
}
+2 -4
View File
@@ -1295,10 +1295,8 @@ impl Shell {
self.mesh_scrim[2],
self.mesh_scrim[3],
];
// SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 bytes — and `f32` has no padding
// or invalid bit patterns, so reading it as bytes is sound; the slice is copied by
// `Data::new_copy` before `uniforms` goes out of scope.
let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::<u8>(), 48) };
let words = uniforms.map(f32::to_ne_bytes);
let bytes = words.as_flattened();
match self.mesh.make_shader(Data::new_copy(bytes), &[], None) {
Some(shader) => {
let mut paint = crate::theme::shaded();
+6 -16
View File
@@ -488,27 +488,17 @@ impl VirtualPad {
code,
value,
};
// SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes
// (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and
// valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly
// `size_of::<InputEventRaw>()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev`
// outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local).
let bytes = unsafe {
std::slice::from_raw_parts(
&ev as *const _ as *const u8,
std::mem::size_of::<InputEventRaw>(),
)
};
// Best-effort: a full kernel queue drops the event; the next frame re-syncs state.
// SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for
// the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS
// exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return,
// so the buffer outlives the synchronous call and the read-only access cannot race or alias.
// the call). `write` READS exactly `size_of::<InputEventRaw>()` bytes from the live local `ev` —
// a `#[repr(C)]` struct of all-integer fields with no padding (timeval=16 + u16 + u16 + i32 = 24,
// the size asserted above), so every byte is initialized — and retains nothing past return, so
// `ev` outlives the synchronous call and the read-only access cannot race or alias.
let _ = unsafe {
libc::write(
self.fd.as_raw_fd(),
bytes.as_ptr() as *const libc::c_void,
bytes.len(),
&ev as *const _ as *const libc::c_void,
std::mem::size_of::<InputEventRaw>(),
)
};
}
+101 -53
View File
@@ -268,7 +268,14 @@ impl Drop for ServerThread {
}
}
/// Accept loop: serve each USB/IP connection with the vendored `usbip_sim::handler` until stopped.
/// Serve the ONE USB/IP connection an attachment is for — the kernel's single `vhci_hcd` attach
/// (our own in-process import, or the `usbip` CLI's) — with the vendored `usbip_sim::handler`,
/// then stop. The listener is dropped the moment that connection is accepted: it speaks
/// unauthenticated USB/IP on loopback, so keeping it open for the session lets ANY local user
/// import the device — reading the streaming client's live controller reports and issuing HID
/// SET_REPORT transfers at it. Nothing legitimate needs a second accept: every re-attach (kernel
/// module reload, pad re-plug, the in-process→CLI fallback) goes through [`attach_device`] again
/// and brings its own listener with it.
async fn run_server(
listener: std::net::TcpListener,
server: Arc<UsbIpServer>,
@@ -282,60 +289,62 @@ async fn run_server(
return;
}
};
loop {
tokio::select! {
_ = stop.notified() => break,
r = listener.accept() => match r {
Ok((mut sock, _)) => {
// URB replies are small and interleave with the kernel's next SUBMITs; without
// TCP_NODELAY the multi-interface request/response pattern collapses into
// ~40 ms Nagle/delayed-ACK stalls (observed as ~22 reports/s on the Puck's
// active hidraw against a 266 Hz source).
sock.set_nodelay(true).ok();
let server = server.clone();
let trace = super::usbip_trace::trace_prefix(&label);
let label = label.clone();
tokio::spawn(async move {
// The handler's Err arm used to be discarded. It is the *only* signal that
// we tore the connection down rather than the kernel — and the kernel's
// side of that (`recv xbuf`, `sendmsg failed`) reads identically either
// way, so throwing it away cost days of mis-attributed diagnosis.
let sink = trace.and_then(|prefix| {
match super::usbip_trace::open_trace(&prefix) {
Ok(s) => {
tracing::info!(prefix, "usbip byte trace armed");
Some(s)
}
Err(e) => {
tracing::warn!(error = %e, "usbip trace files unopenable — running untraced");
None
}
}
});
let res = match sink {
Some(s) => {
let mut traced = super::usbip_trace::TracedIo::wrap(sock, s);
usbip_sim::handler(&mut traced, server).await
}
None => usbip_sim::handler(&mut sock, server).await,
};
match res {
Ok(()) => tracing::debug!(label, "usbip connection closed by the kernel"),
Err(e) => tracing::warn!(
label,
error = %e,
"usbip server dropped the connection — the kernel will report this as a \
transfer error on whatever URB was in flight"
),
}
});
}
Err(e) => {
tracing::warn!(error = %e, "usbip accept error");
break;
}
let (mut sock, _) = tokio::select! {
_ = stop.notified() => return,
r = listener.accept() => match r {
Ok(peer) => peer,
Err(e) => {
tracing::warn!(error = %e, "usbip accept error");
return;
}
}
};
// The kernel has its socket; the port has no further legitimate caller. Closing it here is
// what keeps the device from being a local privilege boundary for the rest of the session.
drop(listener);
// URB replies are small and interleave with the kernel's next SUBMITs; without
// TCP_NODELAY the multi-interface request/response pattern collapses into
// ~40 ms Nagle/delayed-ACK stalls (observed as ~22 reports/s on the Puck's
// active hidraw against a 266 Hz source).
sock.set_nodelay(true).ok();
let trace = super::usbip_trace::trace_prefix(&label);
let conn = tokio::spawn(async move {
// The handler's Err arm used to be discarded. It is the *only* signal that
// we tore the connection down rather than the kernel — and the kernel's
// side of that (`recv xbuf`, `sendmsg failed`) reads identically either
// way, so throwing it away cost days of mis-attributed diagnosis.
let sink = trace.and_then(|prefix| match super::usbip_trace::open_trace(&prefix) {
Ok(s) => {
tracing::info!(prefix, "usbip byte trace armed");
Some(s)
}
Err(e) => {
tracing::warn!(error = %e, "usbip trace files unopenable — running untraced");
None
}
});
let res = match sink {
Some(s) => {
let mut traced = super::usbip_trace::TracedIo::wrap(sock, s);
usbip_sim::handler(&mut traced, server).await
}
None => usbip_sim::handler(&mut sock, server).await,
};
match res {
Ok(()) => tracing::debug!(label, "usbip connection closed by the kernel"),
Err(e) => tracing::warn!(
label,
error = %e,
"usbip server dropped the connection — the kernel will report this as a \
transfer error on whatever URB was in flight"
),
}
});
// Stay until the kernel closes the connection (or the attachment is dropped) — the runtime
// lives on this thread, so returning early would kill the task serving the device.
tokio::select! {
_ = stop.notified() => {}
_ = conn => {}
}
}
@@ -755,6 +764,45 @@ mod tests {
assert_eq!(ss, Some(8));
}
/// The emulation server serves exactly ONE USB/IP connection — the kernel's single `vhci_hcd`
/// attach — and the loopback port closes with it. Anything that can still reach that port
/// afterwards can import the device: read the streaming client's live controller reports and
/// write HID SET_REPORTs at it, with no authentication anywhere in USB/IP to stop it. Needs
/// neither root nor `vhci_hcd` — only the listener side is under test.
#[test]
fn usbip_server_serves_one_connection_then_closes_the_port() {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
listener.set_nonblocking(true).unwrap();
let report = Arc::new(Mutex::new(neutral_deck_report()));
let feedback = Arc::new(Mutex::new(SteamFeedback::default()));
let server =
ServerThread::spawn(listener, build_device(0, &report, &feedback), "test deck")
.expect("spawn the emulation server");
// The one legitimate importer (what `attach_in_process` does before handing the fd to
// `vhci_hcd`), held open for the rest of the test exactly as the kernel holds it.
let _kernel = connect_loopback(port).expect("the attach connects");
// Poll: the accept races the connect above (the connection sits in the listen backlog
// until the server thread picks it up), and the port closes only once it has.
let mut refused = false;
for _ in 0..100 {
match TcpStream::connect(("127.0.0.1", port)) {
Ok(_) => std::thread::sleep(Duration::from_millis(10)),
Err(_) => {
refused = true;
break;
}
}
}
assert!(
refused,
"a second USB/IP importer must find the port closed"
);
drop(server);
}
/// On-box smoke test (needs root + `vhci_hcd`): attach a virtual Deck, confirm `hid-steam` binds
/// it (the `Steam Deck` evdev appears) and that it tears down on drop. `#[ignore]`d in CI.
#[test]
+8 -4
View File
@@ -64,10 +64,14 @@ fn injector_service_thread(rx: std::sync::mpsc::Receiver<InputEvent>) {
batch.push(ev);
}
// The resolved input backend (PUNKTFUNK_INPUT_BACKEND, set per connect / mid-stream session
// switch) may have changed since we opened. Reopen against it so input FOLLOWS the active
// session instead of injecting into a stale, still-warm backend (e.g. the managed gamescope's
// EIS socket after the user switched to the KDE desktop).
// The resolved input backend (published by the host per connect / mid-stream session switch
// — `set_backend_id`) may have changed since we opened. Reopen against it so input FOLLOWS
// the active session instead of injecting into a stale, still-warm backend (e.g. the managed
// gamescope's EIS socket after the user switched to the KDE desktop).
//
// This runs once per BATCH, which is why the published value is a `RwLock` read and not a
// `getenv`: the old `PUNKTFUNK_INPUT_BACKEND` round-trip made this hot path a data race
// against the connect path's `setenv` (security-review 2026-08-25).
let want = default_backend();
if injector.is_some() && open_backend != Some(want) {
tracing::info!(
@@ -46,12 +46,13 @@ pub struct SendInputInjector {
touch_failed: bool,
}
// SAFETY: `SendInputInjector` holds only an `Option<HDESK>` (a desktop handle). The host creates
// and drives it from a single dedicated injector thread; the handle is opened, rebound, and closed
// on whichever thread owns the value, and the type is not `Sync`, so there is never concurrent
// access. A desktop `HDESK` is not thread-affine for ownership (`CloseDesktop` works from any
// thread; `SetThreadDesktop` rebinds the current thread), so transferring ownership via `Send` is
// sound.
// SAFETY: the only field that is not already `Send` is the `Option<HDESK>` (`touch_failed` is a
// `bool`, and `SyntheticTouch` is `Send` on its own — `Arc<Mutex<..>>` + `JoinHandle`, with the
// device handle carrying its own proof). The host creates and drives the injector from a single
// dedicated thread; the desktop handle is opened, rebound, and closed on whichever thread owns the
// value, and the type is not `Sync`, so there is never concurrent access. A desktop `HDESK` is not
// thread-affine for ownership (`CloseDesktop` works from any thread; `SetThreadDesktop` rebinds the
// current thread), so transferring ownership via `Send` is sound.
unsafe impl Send for SendInputInjector {}
impl SendInputInjector {
+124 -11
View File
@@ -136,8 +136,9 @@ impl AbsoluteAnchor {
/// The current absolute-coordinate anchor. A `RwLock` rather than an env var: the injector is
/// host-lifetime and lives behind a channel, so a *session* can only reach it through process
/// state — and process state that is typed and lock-guarded beats the `set_var` pattern the
/// backend-select still uses (security-review 2026-06-28 #7).
/// state — and process state that is typed and lock-guarded beats a `set_var`
/// (security-review 2026-06-28 #7). The backend-select was the last holdout on that pattern and has
/// since joined this one — see `SESSION_BACKEND`.
static ABSOLUTE_ANCHOR: std::sync::RwLock<Option<AbsoluteAnchor>> = std::sync::RwLock::new(None);
/// Anchor absolute coordinates at a specific output. `None` (the default) keeps the size-matched
@@ -173,6 +174,66 @@ pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
.clone()
}
/// The backend the LIVE SESSION resolved to, published by the host from
/// `pf_vdisplay::input_backend_id` when it routes a connect or a mid-stream Desktop↔Game switch.
///
/// A `RwLock` rather than an env var, for the reason [`ABSOLUTE_ANCHOR`] already gives: the injector
/// is host-lifetime and lives behind a channel, so a *session* can only reach it through process
/// state — and typed, lock-guarded process state beats `set_var`. This slot IS the backend-select
/// that doc pointed at as the last holdout. It was a process-environment write in
/// `pf_vdisplay::apply_input_env` read back here by `getenv`, and [`default_backend`] runs once per
/// input batch on the injector service thread: a `getenv` on a hot path racing a per-session
/// `setenv` is the `environ` data race, on a live streaming host, with no attacker needed
/// (security-review 2026-08-25).
///
/// Host-lifetime and last-write-wins, exactly as the env var was: the host serves one session's
/// input at a time, and nothing clears this when a session ends (the env value persisted too).
#[cfg(target_os = "linux")]
static SESSION_BACKEND: std::sync::RwLock<Option<Backend>> = std::sync::RwLock::new(None);
#[cfg(target_os = "linux")]
fn session_backend() -> Option<Backend> {
*SESSION_BACKEND.read().unwrap_or_else(|e| e.into_inner())
}
/// The [`Backend`] an id names, in every spelling the `PUNKTFUNK_INPUT_BACKEND` knob accepts.
/// Shared by that knob and by [`set_backend_id`], so the two can never drift apart.
#[cfg(target_os = "linux")]
fn backend_from_id(id: &str) -> Option<Backend> {
Some(match id.trim().to_ascii_lowercase().as_str() {
"wlr" | "wlroots" | "wlrvirtual" => Backend::WlrVirtual,
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => Backend::KwinFakeInput,
"libei" | "ei" | "portal" => Backend::Libei,
"gamescope" | "gamescope-ei" => Backend::GamescopeEi,
_ => return None,
})
}
/// Point input at the backend this session's VIDEO resolved to (the two must not diverge). `id` is
/// `pf_vdisplay::input_backend_id`'s verdict — `gamescope`/`kwin`/`libei`/`wlr`.
///
/// Threaded from the host rather than published through `PUNKTFUNK_INPUT_BACKEND`, the way
/// `VirtualDisplay::set_launch_command` took the launch command off the env before it. Call it
/// wherever a session's compositor is decided or re-decided; the operator-pinned
/// `PUNKTFUNK_COMPOSITOR` path deliberately does not, which is what leaves the operator's own knob
/// in charge there.
#[cfg(target_os = "linux")]
pub fn set_backend_id(id: &str) {
let Some(backend) = backend_from_id(id) else {
tracing::warn!(
value = id,
"unknown input backend id — leaving input routing alone"
);
return;
};
tracing::debug!(?backend, "input: session backend set");
*SESSION_BACKEND.write().unwrap_or_else(|e| e.into_inner()) = Some(backend);
}
/// The host routes input on every platform; only Linux has a backend to choose between.
#[cfg(not(target_os = "linux"))]
pub fn set_backend_id(_id: &str) {}
/// Pick the injection backend for the current session. gamescope hosts its own EIS server (no
/// portal), so a gamescope session injects directly into it. wlroots/Sway only implements the
/// ScreenCast portal (no RemoteDesktop), so libei can't run there — use the wlr virtual-input
@@ -182,18 +243,25 @@ pub fn absolute_anchor() -> Option<AbsoluteAnchor> {
/// Mutter's *direct* `org.gnome.Mutter.RemoteDesktop` API rather than the portal
/// (`libei_ei_source`), so it is headless-capable too: no interactive approval to answer.
/// `PUNKTFUNK_INPUT_BACKEND=wlr|kwin|libei|gamescope` overrides the auto-detection.
///
/// Resolution order, unchanged from when the session's pick arrived through the process env:
/// **the live session's published backend** ([`set_backend_id`]) — which the host writes from
/// `pf_vdisplay::input_backend_id`, and which used to be a `set_var` of `PUNKTFUNK_INPUT_BACKEND`
/// that overwrote the operator's own value — then the operator's `PUNKTFUNK_INPUT_BACKEND`, then
/// the `PUNKTFUNK_COMPOSITOR` pin, then the `XDG_CURRENT_DESKTOP` sniff. The env rungs are reached
/// only before any session has published (and on the operator-pinned path, which deliberately
/// publishes nothing), so this stays off `getenv` entirely once a stream is up — see
/// [`SESSION_BACKEND`].
#[cfg(target_os = "linux")]
pub fn default_backend() -> Backend {
if let Some(b) = session_backend() {
return b;
}
if let Ok(v) = std::env::var("PUNKTFUNK_INPUT_BACKEND") {
match v.trim().to_ascii_lowercase().as_str() {
"wlr" | "wlroots" | "wlrvirtual" => return Backend::WlrVirtual,
"kwin" | "fakeinput" | "fake_input" | "kwin-fake-input" => {
return Backend::KwinFakeInput
}
"libei" | "ei" | "portal" => return Backend::Libei,
"gamescope" | "gamescope-ei" => return Backend::GamescopeEi,
other => tracing::warn!(
value = other,
match backend_from_id(&v) {
Some(b) => return b,
None => tracing::warn!(
value = v.trim(),
"unknown PUNKTFUNK_INPUT_BACKEND — auto-detecting"
),
}
@@ -715,3 +783,48 @@ mod sendinput;
#[cfg(target_os = "linux")]
#[path = "inject/linux/wlr.rs"]
mod wlr;
#[cfg(all(test, target_os = "linux"))]
mod backend_select_tests {
use super::*;
/// The session's backend reaches the injector as a published VALUE ([`set_backend_id`]) and
/// outranks every env rung below it — which is exactly the precedence the old
/// `set_var("PUNKTFUNK_INPUT_BACKEND", ..)` had, since it OVERWROTE whatever was there. It is a
/// `RwLock` read now because [`default_backend`] runs once per input batch on the injector
/// service thread, and a `getenv` there raced the connect path's `setenv`
/// (security-review 2026-08-25).
///
/// Deliberately makes no claim about the environment: the point is that nothing needs to write
/// it, so the test does not write one either.
#[test]
fn the_session_backend_threads_through_instead_of_the_process_env() {
set_backend_id("gamescope");
assert_eq!(default_backend(), Backend::GamescopeEi);
// A mid-stream Game→Desktop switch re-publishes; input follows, with no env write.
set_backend_id("kwin");
assert_eq!(default_backend(), Backend::KwinFakeInput);
// An id nobody recognises must leave routing where it was rather than silently retargeting
// input at a backend the video side did not choose.
set_backend_id("not-a-backend");
assert_eq!(default_backend(), Backend::KwinFakeInput);
}
/// Every id `pf_vdisplay::input_backend_id` can emit must be one this crate accepts. The two are
/// halves of one contract across a crate boundary that no compiler checks — pf-vdisplay must not
/// depend on pf-inject (its manifest says so), so it emits `&'static str` and this pins the
/// receiving end. Its counterpart is pf-vdisplay's
/// `every_compositor_names_the_injector_backend_that_matches_it`.
#[test]
fn every_id_the_video_side_emits_maps_to_a_backend() {
for (id, want) in [
("gamescope", Backend::GamescopeEi),
("kwin", Backend::KwinFakeInput),
("libei", Backend::Libei),
("wlr", Backend::WlrVirtual),
] {
assert_eq!(backend_from_id(id), Some(want), "{id}");
}
assert_eq!(backend_from_id("not-a-backend"), None);
}
}
+116 -47
View File
@@ -5,6 +5,7 @@
//!
//! - [`config_dir`] resolves the per-host config directory (XDG / `%ProgramData%`, `PUNKTFUNK_CONFIG_DIR` override).
//! - [`create_private_dir`] makes it owner-private (0700 / restrictive DACL).
//! - [`create_secret_dir`] the same, minus the Windows `BUILTIN\Users` read grant.
//! - [`write_secret_file`] writes an owner-only secret (0600 / SYSTEM+Admins DACL).
#![forbid(unsafe_code)]
@@ -94,11 +95,32 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> {
{
let r = std::fs::create_dir_all(dir);
#[cfg(windows)]
restrict_dir_to_system_admins(dir, first_hardening_of(dir));
restrict_dir_to_system_admins(dir, first_hardening_of(dir), true);
r
}
}
/// [`create_private_dir`] without the Windows `BUILTIN\Users` read grant — for a subdirectory whose
/// *contents* are secrets rather than merely tamper-sensitive config: the host/service logs and the
/// client log bundles paired devices upload.
///
/// The config dir's `Users:(OI)(CI)(RX)` is deliberate (the tray reads `mgmt-endpoint` out of it),
/// but `(OI)` means every file born anywhere under it inherits that read — which left the logs
/// (webhook URLs, command lines) and the uploaded bundles readable by any local user, the latter
/// flatly contradicting the "reading them stays on the loopback-only bearer lane" split
/// `mgmt::client_logs` documents (security-review 2026-08-25). Unix behaviour is identical to
/// [`create_private_dir`] (0700 — the mode already excludes everyone else).
pub fn create_secret_dir(dir: &std::path::Path) -> std::io::Result<()> {
#[cfg(windows)]
{
let r = std::fs::create_dir_all(dir);
restrict_dir_to_system_admins(dir, first_hardening_of(dir), false);
r
}
#[cfg(not(windows))]
create_private_dir(dir)
}
/// Whether this is the first hardening pass of `dir` in this process — the pass that also does the
/// expensive recursive re-own.
///
@@ -136,7 +158,9 @@ pub fn restrict_existing_secret_file(path: &std::path::Path) {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
restrict_to_system_admins(path);
if let Err(e) = restrict_to_system_admins(path) {
tracing::warn!(path = %path.display(), error = %e, "icacls hardening did not succeed");
}
}
/// No-op off Windows: POSIX modes are set at creation by [`write_secret_file`] and a config dir a
@@ -158,12 +182,13 @@ fn icacls_path() -> String {
/// `punktfunk` dir or plant a `host.env`/`apps.json` that the privileged SYSTEM service then trusts
/// (LPE; security-review 2026-06-28 #3). This re-owns the dir to Administrators (defeating a
/// pre-creation), strips inheritance, and sets an explicit DACL: SYSTEM/Administrators/OWNER full
/// (object+container inherit so child files/dirs inherit it), and Users **read-only** (so existing
/// reads of non-secret config keep working but a local user can no longer write/plant). Secret files
/// are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (object+container inherit so child files/dirs inherit it), and — when `users_read` — Users
/// **read-only** (so existing reads of non-secret config keep working but a local user can no longer
/// write/plant). [`create_secret_dir`] passes `false` for a dir whose contents are all secrets, and
/// secret files are additionally locked to SYSTEM/Admins by [`write_secret_file`]. Hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path; never fatal.
#[cfg(windows)]
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool, users_read: bool) {
let icacls = icacls_path();
// Reset ownership to Administrators first, so a dir a non-admin may have pre-created can't keep
// OWNER control (an owner always retains WRITE_DAC and can put its access straight back).
@@ -183,24 +208,27 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
let status = std::process::Command::new(&icacls)
.arg(dir.as_os_str())
.args([
"/inheritance:r",
"/grant:r",
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) here, deliberately. It used to be granted
// `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even
// after the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and
// Administrators cover every account that legitimately writes here; a non-elevated
// manual run gets read-only config, which is the intended boundary rather than a
// regression — this directory drives command execution as SYSTEM.
"/grant:r",
"*S-1-5-32-545:(OI)(CI)(RX)", // BUILTIN\Users — read-only (no create/write → no plant)
])
// NO inheritable OWNER RIGHTS (`*S-1-3-4`) in the grant below, deliberately. It used to be
// granted `(OI)(CI)(F)`, which handed full control of every child object to whoever owned it —
// so a file a local user created before the hardening ran stayed writable by them even after
// the directory was re-owned (2026-08-05 review H-4, second half). SYSTEM and Administrators
// cover every account that legitimately writes here; a non-elevated manual run gets read-only
// config, which is the intended boundary rather than a regression — this directory drives
// command execution as SYSTEM.
let mut acl = std::process::Command::new(&icacls);
acl.arg(dir.as_os_str()).args([
"/inheritance:r",
"/grant:r",
"*S-1-5-18:(OI)(CI)(F)", // NT AUTHORITY\SYSTEM
"/grant:r",
"*S-1-5-32-544:(OI)(CI)(F)", // BUILTIN\Administrators
]);
if users_read {
// BUILTIN\Users — read-only (no create/write → no plant). `(OI)` reaches every FILE born
// under here as well, which is why [`create_secret_dir`] leaves this ACE off entirely.
acl.args(["/grant:r", "*S-1-5-32-545:(OI)(CI)(RX)"]);
}
let status = acl
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
@@ -219,18 +247,21 @@ fn restrict_dir_to_system_admins(dir: &std::path::Path, deep: bool) {
/// for the host private key and the persisted trust stores so a local unprivileged user can neither
/// read the key (impersonation) nor tamper with the paired allow-list (unauthorized pairing).
///
/// **Windows ordering caveat** (2026-08-05 review L-17): this is create-then-`icacls`, not
/// create-with-DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`, and this crate is
/// `#![forbid(unsafe_code)]` so it cannot call `CreateFileW` itself. The file therefore exists
/// briefly under its INHERITED ACL, and a failed `icacls` is a warning rather than an error.
/// **Windows ordering** (2026-08-05 review L-17, corrected by security-review 2026-08-25): the file
/// cannot be BORN with the right DACL — `std::fs::OpenOptions` cannot pass a `SECURITY_ATTRIBUTES`
/// and this crate is `#![forbid(unsafe_code)]`, so it cannot call `CreateFileW` itself. So it is
/// created EMPTY, `icacls`'d, and only then written — `install::set_web_password`'s ordering, and
/// the DACL step is **fatal**.
///
/// What makes that acceptable is the DIRECTORY, and only the directory: every caller writes into
/// the config dir, which [`create_private_dir`] now hardens unconditionally and BEFORE the first
/// read of anything in it (review H-4/M-1), granting `BUILTIN\Users` read-only and no create. The
/// inherited ACL a secret is born with is therefore already SYSTEM/Administrators-only, and the
/// `icacls` below is defence in depth rather than the thing standing between a local user and the
/// host key. Keep that ordering — if the directory hardening is ever moved back after a read, this
/// window becomes real again.
/// This used to write first and harden after, on the argument that the INHERITED ACL was already
/// SYSTEM/Administrators-only. It never was: [`restrict_dir_to_system_admins`] deliberately grants
/// `BUILTIN\Users` `(OI)(CI)(RX)` so non-secret config stays readable, and `(OI)` means every file
/// born in the config dir inherits that read. Every secret was therefore world-readable for the
/// life of the `icacls` child — long enough for a `ReadDirectoryChangesW` watcher (which the same
/// directory ACL permits by design) to take `native-key.pem`, `key.pem` and `mgmt-token`. The
/// `icacls` call is the ONLY control here, not defence in depth, which is also why a failure now
/// returns an error and unlinks the still-empty file instead of filling it with a secret anyone can
/// read. The open handle carries our own write access across the DACL change.
pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
@@ -241,6 +272,13 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
opts.mode(0o600);
}
let mut f = opts.open(path)?;
#[cfg(windows)]
if let Err(e) = restrict_to_system_admins(path) {
drop(f);
// Never leave a 0-byte secret behind: callers gate on "does it exist / is it non-empty".
let _ = std::fs::remove_file(path);
return Err(e);
}
f.write_all(contents)?;
f.flush()?;
#[cfg(unix)]
@@ -248,19 +286,18 @@ pub fn write_secret_file(path: &std::path::Path, contents: &[u8]) -> std::io::Re
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(windows)]
restrict_to_system_admins(path);
Ok(())
}
/// Best-effort Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
/// Windows DACL lockdown of a secret file: strip inherited ACEs and grant Full only to
/// SYSTEM, Administrators, and OWNER RIGHTS (the creating account — the SYSTEM service or a manually
/// running user keeps access). Without this the host key under the default Users-readable
/// `%ProgramData%` ACL is readable by ANY local user. Uses `icacls` with hard-coded SIDs
/// (locale-independent) via the absolute `%SystemRoot%` path (a privileged service must not trust
/// `PATH`). Never fatal — on failure the file is simply left at the inherited ACL (today's behaviour).
/// `PATH`). Reports failure to the caller: [`write_secret_file`] treats it as fatal (it is the only
/// control over the bytes it is about to write), [`restrict_existing_secret_file`] only warns.
#[cfg(windows)]
fn restrict_to_system_admins(path: &std::path::Path) {
fn restrict_to_system_admins(path: &std::path::Path) -> std::io::Result<()> {
let icacls = icacls_path();
let status = std::process::Command::new(icacls)
.arg(path.as_os_str())
@@ -275,14 +312,15 @@ fn restrict_to_system_admins(path: &std::path::Path) {
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {}
_ => tracing::warn!(
path = %path.display(),
"icacls hardening did not succeed — this secret may be readable by other local users"
),
.status()?;
if status.success() {
return Ok(());
}
Err(std::io::Error::other(format!(
"icacls could not restrict {} to SYSTEM/Administrators ({status}) — it would be readable \
by other local users",
path.display()
)))
}
#[cfg(test)]
@@ -321,4 +359,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// The unix half of the create-empty → harden → write ordering: a secret is never even briefly
/// group/world-readable, on the first write OR the truncate-and-rewrite one. (The Windows half —
/// the `icacls` step being fatal — can only be exercised on Windows.)
#[cfg(unix)]
#[test]
fn secrets_are_owner_only_on_create_and_on_rewrite() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("pf-paths-secret-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_secret_dir(&dir).unwrap();
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&dir), 0o700, "a secrets dir is owner-only");
let key = dir.join("key.pem");
write_secret_file(&key, b"-----BEGIN PRIVATE KEY-----\n").unwrap();
assert_eq!(mode(&key), 0o600);
write_secret_file(&key, b"rotated").unwrap();
assert_eq!(mode(&key), 0o600, "the rewrite path keeps 0600");
assert_eq!(std::fs::read(&key).unwrap(), b"rotated", "and truncates");
// A pre-existing world-readable file is tightened, not adopted.
let planted = dir.join("mgmt-token");
std::fs::write(&planted, b"old").unwrap();
std::fs::set_permissions(&planted, std::fs::Permissions::from_mode(0o644)).unwrap();
write_secret_file(&planted, b"new").unwrap();
assert_eq!(mode(&planted), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
}
+8 -5
View File
@@ -8,7 +8,7 @@
use anyhow::{bail, Context as _, Result};
use ash::vk;
use pf_client_core::video::{DmabufFrame, DrmFrameGuard};
use std::os::fd::{BorrowedFd, IntoRawFd as _};
use std::os::fd::{AsRawFd as _, BorrowedFd, IntoRawFd as _};
/// `fourcc('N','V','1','2')` — 8-bit 4:2:0 VAAPI output.
const DRM_FORMAT_NV12: u32 = 0x3231_564e;
@@ -297,14 +297,15 @@ fn plane_image(
.context("no importable memory type for dmabuf")?;
// Vulkan owns the fd it imports — dup so the decoder guard keeps the original.
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
// type and live for the call, and every builder struct is a local that outlives it.
// SAFETY: `fd` is open for the whole borrow — it is a plane fd of the caller's
// `DmabufFrame`, whose `DrmFrameGuard` (the thing that closes those fds) lives until
// `import` hands it to the `HwFrame`. The borrow ends at `try_clone_to_owned`, which dups.
let owned = unsafe { BorrowedFd::borrow_raw(fd) }
.try_clone_to_owned()
.context("dup dmabuf fd")?;
let mut import_info = vk::ImportMemoryFdInfoKHR::default()
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
.fd(owned.into_raw_fd());
.fd(owned.as_raw_fd());
let mut dedicated = vk::MemoryDedicatedAllocateInfo::default().image(image);
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
// type and live for the call, and every builder struct is a local that outlives it.
@@ -319,7 +320,9 @@ fn plane_image(
)
}
.context("import dmabuf memory")?;
// (On allocate_memory failure Vulkan still closed the dup'd fd — nothing leaks.)
// Vulkan takes the fd only on a SUCCESSFUL import, so release `owned` here and let it close
// the dup on every failure path above (`?` drops it) instead of leaking one fd per frame.
let _ = owned.into_raw_fd();
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
// type and live for the call, and every builder struct is a local that outlives it.
if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } {
+6 -10
View File
@@ -850,13 +850,14 @@ impl Presenter {
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[..12].copy_from_slice(rows.as_flattened());
pc[12] = mode;
pc[13] = peak;
// Crop: 1.0 unless the source image is a decode pool bigger than the picture.
pc[14] = uv_scale[0];
pc[15] = uv_scale[1];
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
let words = pc.map(f32::to_ne_bytes);
let bytes = words.as_flattened();
self.device.cmd_push_constants(
self.cmd_buf,
self.csc.pipeline_layout,
@@ -946,10 +947,11 @@ impl Presenter {
.and_then(|v| v.parse::<f32>().ok())
.unwrap_or(4.9); // ≈1000 nits over the 203-nit reference
let mut pc = [0f32; 16];
pc[..12].copy_from_slice(bytemuck_rows(&rows));
pc[..12].copy_from_slice(rows.as_flattened());
pc[12] = mode;
pc[13] = peak;
let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::<u8>(), 64);
let words = pc.map(f32::to_ne_bytes);
let bytes = words.as_flattened();
self.device.cmd_push_constants(
self.cmd_buf,
planar.pipeline_layout,
@@ -1024,12 +1026,6 @@ fn csc_depth_packing_or_8bit(raw: RawVkFormat) -> (u8, bool) {
})
}
/// Flatten the 3×vec4 rows for the push-constant block.
fn bytemuck_rows(rows: &[[f32; 4]; 3]) -> &[f32] {
// SAFETY: [[f32;4];3] is 12 contiguous f32s.
unsafe { std::slice::from_raw_parts(rows.as_ptr().cast::<f32>(), 12) }
}
#[cfg(test)]
mod tests {
use super::*;
+10 -4
View File
@@ -522,12 +522,18 @@ fn chroma_offsets(
/// `column_width_minus1` is `u32` in the parser and `u16` in libva; a value past
/// 65535 columns is impossible for any real picture, so saturating is honest here
/// and a wrap would not be.
fn narrow_19(src: &[u32; 19]) -> [u16; 19] {
std::array::from_fn(|i| u16::try_from(src[i]).unwrap_or(u16::MAX))
///
/// Takes a slice, not a fixed-size array: libva's 19/21 are a frozen ABI (see the
/// `offset_of!` asserts in `va_h265`), while the parser's arrays carry one extra
/// slot for the running remainder it stores at `[num_tile_*_minus1]`. Copying the
/// first 19/21 is what libva wants anyway — it derives the last tile itself — and
/// a slice means growing the parser side again cannot break this pair.
fn narrow_19(src: &[u32]) -> [u16; 19] {
std::array::from_fn(|i| u16::try_from(src.get(i).copied().unwrap_or(0)).unwrap_or(u16::MAX))
}
fn narrow_21(src: &[u32; 21]) -> [u16; 21] {
std::array::from_fn(|i| u16::try_from(src[i]).unwrap_or(u16::MAX))
fn narrow_21(src: &[u32]) -> [u16; 21] {
std::array::from_fn(|i| u16::try_from(src.get(i).copied().unwrap_or(0)).unwrap_or(u16::MAX))
}
#[cfg(test)]
+51 -18
View File
@@ -102,18 +102,18 @@ pub use session::{session_epoch, try_recover_session};
/// Gamescope-session routing (plan §W3).
#[path = "vdisplay/routing.rs"]
pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, preflight_takeover_privilege,
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
};
#[cfg(target_os = "linux")]
pub use routing::{
cancel_pending_tv_restore, dedicated_game_exited, focus_streamed_output,
gamescope_xwayland_cursor_targets, launch_into_gamescope_session, launch_is_nested,
steam_appid_from_launch, watch_steam_game_exit,
};
pub use routing::{
input_backend_id, managed_session_available, preflight_takeover_privilege,
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
};
/// Compositors punktfunk knows how to drive (plan §6).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -229,8 +229,9 @@ impl Compositor {
///
/// The **live session is the primary signal**, ahead of each backend's own probe. Those probes read
/// the process env (`XDG_CURRENT_DESKTOP` for Mutter, `WAYLAND_DISPLAY` for KWin's registry
/// handshake, `SWAYSOCK` for sway)env a host started *outside* the session (a `systemd --user`
/// unit, a TTY, ssh) never inherited. It is only retargeted at the live session on the connect path
/// handshake, `SWAYSOCK` for sway — that last one *only* as inherited, since nothing exports it any
/// more) — env a host started *outside* the session (a `systemd --user` unit, a TTY, ssh) never
/// inherited. It is only retargeted at the live session on the connect path
/// ([`apply_session_env`]), so enumerating before the first client connect reported "unavailable"
/// for the very desktop the operator was sitting in — while [`detect`], which scans `/proc`, marked
/// that same backend the default. The management API showed both badges on one row, and the answer
@@ -287,15 +288,47 @@ fn compositor_from_pin(v: &str) -> Option<Compositor> {
})
}
/// Serializes ALL process-global env mutation on the per-session setup path. `std::env::set_var`
/// concurrent with another thread's `set_var` (glibc `environ` realloc) is a data race = UB. With
/// the default concurrent native sessions each running `resolve_compositor` in its own
/// `spawn_blocking`, the per-session env retargeting would otherwise race and could crash the host
/// (security-review 2026-06-28 #7). Every env write on the setup path takes this lock; steady-state
/// streaming reads cached config, not env. This removes the memory-unsafety; the launch command is
/// additionally threaded per-session (`SessionContext.launch` → `set_launch_command`) so it never
/// rides the process env at all — the remaining knobs here (session retarget, gamescope sub-mode)
/// still carry a cross-session *value* confusion window inherent to a process-global env.
/// Serializes **pf-vdisplay's own** process-env readers and writers on the per-session setup path,
/// so two concurrent native sessions (each running `resolve_compositor` in its own
/// `spawn_blocking`) can't interleave the retarget with each other's reads
/// (security-review 2026-06-28 #7).
///
/// ## What it does NOT do
///
/// It does not make `set_var`/`remove_var` sound, and an earlier revision of this doc claimed it
/// did. `setenv(3)` grows and replaces the `environ` array and swaps value pointers under it;
/// `unsetenv(3)` shifts it. Any concurrent `getenv` anywhere in the process is a data race on that
/// array — and by the time this path runs the host is a live streaming session with tokio, QUIC,
/// mDNS, the HTTP API, capture threads and zbus tasks all up. None of glibc's own internals (`tzset`
/// for log timestamps, `getaddrinfo`, gettext), zbus, wayland-client or the Mesa ICD loader takes
/// this lock, and they cannot be made to. A client reconnect or a mid-stream Desktop↔Game switch is
/// enough to hit it: best case a torn value, worst case a use-after-free mid-session
/// (security-review 2026-08-25).
///
/// ## What actually fixes it
///
/// Not a wider lock — a shorter list. **Punktfunk no longer uses the process env as a channel to
/// itself.** Every value one part of the host computes for another travels as a value: the launch
/// command per session (`SessionContext.launch` → `set_launch_command`), the gamescope sub-mode
/// ([`resolve_gamescope_route`]'s return → `set_gamescope_route`), the injector backend
/// ([`input_backend_id`]'s return → `pf_inject::set_backend_id`, which retired the last
/// `PUNKTFUNK_INPUT_BACKEND` write — its reader `getenv`s once per input BATCH, so that one was the
/// sharpest edge of all), and `hyprctl`'s / `swaymsg`'s session handles, handed to those children
/// with `Command::env`.
///
/// What is left is [`apply_session_env`]'s four — `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`,
/// `WAYLAND_DISPLAY`, `XDG_CURRENT_DESKTOP`. These are the DESKTOP's variables, not ours: their
/// readers are wayland-client, zbus, libpipewire and the Mesa loader, which take them from the
/// process env and nowhere else, plus [`settle_desktop_portal`], which imports them into the
/// activation environment BY NAME out of ours. (Punktfunk sniffs `XDG_CURRENT_DESKTOP` too —
/// `mutter::is_available`, `detect`, `pf_inject::default_backend`'s last rung — but as a reader of
/// the desktop's value, not as a channel of its own.) Threading them means connecting
/// Wayland/D-Bus/PipeWire explicitly (`Connection::connect_to_socket`,
/// `zbus::conn::Builder::address`); "set them once before threads exist" does not apply, because
/// the values change per session — that is what this path is for.
///
/// The lock stays because ordering our own readers is still worth having, and because the writes
/// that remain must not also race each other. Read it as "the discipline", never as "the proof".
pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Run `f` with [`ENV_LOCK`] held. Use around any `set_var`/`remove_var` on the session-setup path.
@@ -726,7 +759,7 @@ pub fn gamescope_composites_cursor() -> bool {
/// `GAMESCOPE_BIN` wrapper (or PATH shim), so the flags are ours.
///
/// **Ask the resolved ROUTE, never the env.** This used to test the spawn-vs-attach term by reading
/// `PUNKTFUNK_GAMESCOPE_NODE`, which worked only while `apply_input_env` PUBLISHED its decision into
/// `PUNKTFUNK_GAMESCOPE_NODE`, which worked only while the routing PUBLISHED its decision into
/// that key. Phase 2.3 deleted the publication (routing.rs: "Nothing is written back to the two
/// knobs") and left the key as an operator override — rung 2 of a 6-rung ladder — so the session
/// that reaches [`GamescopeRoute::Attach`] at the ladder's rung 5 instead (a foreign gamescope on an
+1 -1
View File
@@ -134,7 +134,7 @@ pub trait VirtualDisplay: Send {
/// existing session / don't spawn a nested command ignore it; only gamescope's spawn path uses it).
fn set_launch_command(&mut self, _cmd: Option<String>) {}
/// Set the RESOLVED gamescope sub-mode for this session (from
/// [`apply_input_env`](crate::apply_input_env)). Carried on the backend instance for the same
/// [`resolve_gamescope_route`](crate::resolve_gamescope_route)). Carried on the backend instance for the same
/// reason as [`set_launch_command`](Self::set_launch_command) — it used to travel through
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, where the GameStream plane and the mid-session switch
/// watcher could overwrite one session's decision before another session's `create` read it.
@@ -68,7 +68,7 @@ pub struct GamescopeDisplay {
/// per-instance discipline as `cmd`, and for the same reason: it used to arrive through
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION`, which a concurrent connect could overwrite between
/// the decision and this session's `create`. `None` = nothing resolved it (a caller that never
/// ran `apply_input_env`); `create` then falls through to the bare spawn, the safe default.
/// ran `resolve_gamescope_route`); `create` then falls through to the bare spawn, the safe default.
route: Option<crate::GamescopeRoute>,
/// The topology-restore action the bare-spawn `create` prepared under `Topology::Exclusive` —
/// the release of this display's [`crate::panel_dpms`] darken hold — pending pickup by the
@@ -629,14 +629,14 @@ impl VirtualDisplay for GamescopeDisplay {
// full Steam-Deck-UI session headless at the client's resolution + refresh — so games SEE
// them (via the injected --nested-refresh + generated CVT modes, not the box's TV EDID) —
// and relaunch it when the client's mode changes. Reuses the node + EIS discovery below.
// THIS session's resolved sub-mode, handed over by the host from `apply_input_env`. It
// THIS session's resolved sub-mode, handed over by the host from `resolve_gamescope_route`. It
// used to be read out of the process env here, which meant a second connect could retarget
// this one between the decision and this line.
let (session_env, node_env) = match self.route.clone() {
Some(crate::GamescopeRoute::Managed { client }) => (Some(client), None),
Some(crate::GamescopeRoute::Attach { node }) => (None, Some(node)),
Some(crate::GamescopeRoute::Spawn) => (None, None),
// Nobody resolved a route (no `apply_input_env` on this path): bare spawn, which is
// Nobody resolved a route (no `resolve_gamescope_route` on this path): bare spawn, which is
// also what the ladder's own default arm picks.
None => (None, None),
};
@@ -1059,7 +1059,7 @@ fn steamos_session_present() -> bool {
/// Does this box have the infrastructure the MANAGED gamescope mode drives — Bazzite's
/// `gamescope-session-plus` or SteamOS's `gamescope-session`? The sub-mode ladder
/// ([`crate::apply_input_env`]) only defaults to managed when this is true; a plain
/// ([`crate::resolve_gamescope_route`]) only defaults to managed when this is true; a plain
/// distro (neither present) falls through to the bare-spawn path instead of the old behaviour of
/// defaulting to managed and then bailing on the missing session script.
pub fn managed_session_available() -> bool {
@@ -24,9 +24,9 @@
//! what made every stream after the first one fail on Hyprland — see [`StopGuard`].
//!
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or it is discovered from `$XDG_RUNTIME_DIR/hypr/`
//! and handed to each `hyprctl` child ([`hyprctl_command`]) — with the ScreenCast interface routed
//! to xdph (`scripts/headless/portals.conf`).
//!
//! The focus contract [`focus_output`] rests on is verified on **Hyprland 0.56.2** (2026-08-17,
//! headless probe against a real instance): `output create headless` leaves the new head
@@ -195,18 +195,19 @@ impl HyprlandDisplay {
}
}
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
/// `HYPRLAND_INSTANCE_SIGNATURE` (inherited from the session) **or** a discoverable instance socket
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
/// side-effect-free — safe on the enumeration path.
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by an
/// INHERITED `HYPRLAND_INSTANCE_SIGNATURE` **or** a discoverable instance socket under
/// `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env import,
/// unlike sway's `SWAYSOCK`). Cheap, side-effect-free — safe on the enumeration path.
///
/// Inherited is now all the signature can be: `apply_session_env` no longer exports it (the value
/// goes to the `hyprctl` children instead — see [`hyprctl_command`]), so this reads only what the
/// host was launched with. The socket scan below is the arm that matters, and it always was.
///
/// Both env reads take [`crate::with_env_lock`] — in ONE scope, so the pair is sampled from a single
/// consistent view. This runs on a management worker (`/host/compositors` → [`crate::available`])
/// concurrently with another connect's `apply_session_env`, which `set_var`s the signature for a
/// live Hyprland session and `remove_var`s it for anything else; a glibc `getenv` racing that
/// `setenv`/`unsetenv` is the `environ` realloc data race ENV_LOCK exists for. No caller holds the
/// lock (it is not reentrant), and the `read_dir` below deliberately runs outside it.
/// consistent view — which orders them against this crate's remaining env writers
/// (`apply_session_env`'s four survivors) and nothing else. No caller holds the lock (it is not
/// reentrant), and the `read_dir` below deliberately runs outside it.
pub fn is_available() -> bool {
let (sig, runtime) = crate::with_env_lock(|| {
(
@@ -887,12 +888,13 @@ const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
/// job to settle, so it is the slowest helper on this path — and its result is already ignored.
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
/// hard failure, but for dispatch commands it can print an error with status 0 — see
/// [`hyprctl_dispatch`].
/// Run `hyprctl <args>`, returning stdout. `hyprctl` needs `HYPRLAND_INSTANCE_SIGNATURE` to reach
/// the right instance socket; it is set on THIS CHILD ([`hyprctl_command`]) rather than exported
/// into the host's own environment. It exits non-zero on a hard failure, but for dispatch commands
/// it can print an error with status 0 — see [`hyprctl_dispatch`].
fn hyprctl(args: &[&str]) -> Result<String> {
let out = crate::proc::output_within(Command::new("hyprctl").args(args), HYPRCTL_BUDGET)
let mut cmd = hyprctl_command(args, crate::session::hypr_signature());
let out = crate::proc::output_within(&mut cmd, HYPRCTL_BUDGET)
.context("run hyprctl (is Hyprland installed?)")?;
if !out.status.success() {
bail!(
@@ -905,6 +907,24 @@ fn hyprctl(args: &[&str]) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// The `hyprctl` invocation, with the live instance signature threaded onto the child.
///
/// It used to ride the process env: `apply_session_env` `set_var`'d it per connect (and
/// `remove_var`'d it for a non-Hyprland session) so this child inherited it. Nothing outside
/// pf-vdisplay ever read it, so that bought a per-connect `setenv` — a data race with any `getenv`
/// on any other thread of a live streaming host — for a value one child needs. `Command::env` gives
/// it to exactly that child, the way `set_launch_command` carries the launch. `sig` is `None` when
/// there is no live Hyprland instance we can find: leave the child's env alone then, so an
/// inherited one (host started inside the session) still wins.
fn hyprctl_command(args: &[&str], sig: Option<String>) -> Command {
let mut cmd = Command::new("hyprctl");
cmd.args(args);
if let Some(sig) = sig {
cmd.env("HYPRLAND_INSTANCE_SIGNATURE", sig);
}
cmd
}
/// Serializes **write-the-selection → complete-the-handshake**, process-wide — see the wlroots
/// backend's `SELECTION_LOCK`. The xdph selection is likewise one per-user file, so a concurrent
/// write between ours and xdph's read would silently steer capture at the other session's output.
@@ -1544,6 +1564,34 @@ mod tests {
);
}
/// `HYPRLAND_INSTANCE_SIGNATURE` reaches `hyprctl` as a per-CHILD override, never as a `set_var`
/// on the host's own environment — that write was a `getenv` data race with every other thread
/// of a live session (security-review 2026-08-25). Pinning both arms: a discovered signature is
/// set on the child, and an undiscoverable one leaves the child's env untouched so an inherited
/// signature still reaches the compositor.
#[test]
fn the_instance_signature_travels_on_the_child_not_the_process_env() {
let overrides = |sig: Option<String>| -> Vec<(String, Option<String>)> {
hyprctl_command(&["-j", "version"], sig)
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect()
};
assert_eq!(
overrides(Some("abc123".to_string())),
[(
"HYPRLAND_INSTANCE_SIGNATURE".to_string(),
Some("abc123".to_string())
)]
);
assert!(overrides(None).is_empty());
}
#[test]
fn output_names_are_unique_and_prefixed() {
let a = next_output_name();
@@ -16,10 +16,11 @@
//! unplug since sway 1.8). See [`StopGuard`] — and the long root-cause note on `hyprland.rs`'s
//! copy, which is where this was measured.
//!
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
//! interface routed to xdpw (`scripts/headless/portals.conf`).
//! Requirements: the host can reach the sway session`SWAYSOCK` for swaymsg, inherited or
//! discovered and set on each child ([`swaymsg_command`]), plus the portal activation env
//! (`WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into `systemctl --user`, see
//! `scripts/headless/prepare-session.sh`), with the ScreenCast interface routed to xdpw
//! (`scripts/headless/portals.conf`).
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use anyhow::{anyhow, bail, Context, Result};
@@ -125,14 +126,19 @@ impl WlrootsDisplay {
}
}
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path.
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by an INHERITED
/// `SWAYSOCK` (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration
/// path.
///
/// Under [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
/// — and, when no sway session is live, `remove_var`s — this very key. A glibc `getenv` racing a
/// `setenv` is the `environ` realloc data race ENV_LOCK exists for, and it is UB whichever key each
/// side names. No caller holds the lock (the mutex is not reentrant).
/// Inherited is now all it can be: `apply_session_env` no longer exports this key (the value goes
/// to the `swaymsg` children instead — see [`swaymsg_command`]), so this can only ever report what
/// the host was launched with, never what we ourselves wrote. That is the honest half: a
/// `systemd --user` host inherits nothing, and [`crate::available`] covers it by asking the `/proc`
/// scan whether a wlroots session is live BEFORE it consults this probe.
///
/// Still under [`crate::with_env_lock`]: it orders the read against this crate's remaining env
/// writers (`apply_session_env`'s four survivors), which is all that lock has ever been able to do.
/// No caller holds it — the mutex is not reentrant.
pub fn is_available() -> bool {
crate::with_env_lock(|| std::env::var_os("SWAYSOCK")).is_some()
}
@@ -632,13 +638,30 @@ const SWAYMSG_BUDGET: Duration = Duration::from_secs(5);
/// settle, so it is the slowest helper on this path — and its result is already ignored.
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
/// A bare `swaymsg`, with the live sway IPC socket threaded onto the child.
///
/// `SWAYSOCK` used to ride the process env: `apply_session_env` `set_var`'d it per connect (and
/// `remove_var`'d it when nothing sway-shaped was live) so these children inherited it. Nothing
/// outside pf-vdisplay ever read it, so that bought a per-connect `setenv` — a data race with any
/// `getenv` on any other thread of a live streaming host — for a value two children need.
/// `Command::env` gives it to exactly those children, the way `set_launch_command` carries the
/// launch. `sock` is `None` when there is no sway IPC we can find: leave the child's env alone
/// then, so an inherited one (host started inside the session) still wins.
fn swaymsg_command(sock: Option<String>) -> Command {
let mut cmd = Command::new("swaymsg");
if let Some(sock) = sock {
cmd.env("SWAYSOCK", sock);
}
cmd
}
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
fn swaymsg(args: &[&str]) -> Result<String> {
let out =
crate::proc::output_within(Command::new("swaymsg").arg("--").args(args), SWAYMSG_BUDGET)
.context("run swaymsg (is sway installed?)")?;
let mut cmd = swaymsg_command(crate::session::sway_socket());
let out = crate::proc::output_within(cmd.arg("--").args(args), SWAYMSG_BUDGET)
.context("run swaymsg (is sway installed?)")?;
if !out.status.success() {
bail!(
"swaymsg {:?} failed: {}{}",
@@ -656,11 +679,9 @@ fn swaymsg(args: &[&str]) -> Result<String> {
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
let out = crate::proc::output_within(
Command::new("swaymsg").args(["-t", kind, "--raw"]),
SWAYMSG_BUDGET,
)
.context("run swaymsg (is sway installed?)")?;
let mut cmd = swaymsg_command(crate::session::sway_socket());
let out = crate::proc::output_within(cmd.args(["-t", kind, "--raw"]), SWAYMSG_BUDGET)
.context("run swaymsg (is sway installed?)")?;
if !out.status.success() {
bail!(
"swaymsg -t {kind} failed: {}",
@@ -1061,6 +1082,33 @@ mod tests {
assert_eq!(enable_argv("DP-1"), ["output", "DP-1", "enable"]);
}
/// `SWAYSOCK` reaches `swaymsg` as a per-CHILD override, never as a `set_var` on the host's own
/// environment — that write was a `getenv` data race with every other thread of a live session
/// (security-review 2026-08-25). Pinning both arms: a known socket is set on the child, and an
/// unknown one leaves the child's env untouched so an inherited `SWAYSOCK` still reaches sway.
#[test]
fn the_sway_socket_travels_on_the_child_not_the_process_env() {
let overrides = |sock: Option<String>| -> Vec<(String, Option<String>)> {
swaymsg_command(sock)
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect()
};
assert_eq!(
overrides(Some("/run/user/1000/sway-ipc.1000.42.sock".to_string())),
[(
"SWAYSOCK".to_string(),
Some("/run/user/1000/sway-ipc.1000.42.sock".to_string())
)]
);
assert!(overrides(None).is_empty());
}
fn head(connector: &str, enabled: bool) -> crate::monitors::PhysicalMonitor {
crate::monitors::PhysicalMonitor {
connector: connector.to_string(),
+57 -44
View File
@@ -1,5 +1,5 @@
//! Gamescope-session routing (plan §W3 — carved out of [`super`]): mode selection
//! ([`pick_gamescope_mode`]), input-env routing ([`apply_input_env`]), dedicated-game-session
//! ([`pick_gamescope_mode`]), injector-backend selection ([`input_backend_id`]), dedicated-game-session
//! decisions/launch ([`wants_dedicated_game_session`], [`launch_into_gamescope_session`]), and the
//! managed-session restore workers.
@@ -8,9 +8,9 @@ use super::*;
/// The RESOLVED gamescope sub-mode for one session, with the payload `GamescopeDisplay::create`
/// needs.
///
/// This is what [`apply_input_env`] hands back, and it is carried on the backend INSTANCE
/// This is what [`resolve_gamescope_route`] hands back, and it is carried on the backend INSTANCE
/// (`VirtualDisplay::set_gamescope_route`) exactly as `set_launch_command` carries the launch — not
/// through process env. The env knobs used to BE the channel: `apply_input_env` wrote
/// through process env. The env knobs used to BE the channel: `resolve_gamescope_route` wrote
/// `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` and `create` read them back, but the lock was released in
/// between, and the whole GameStream plane plus the mid-session switch watcher re-run the writer —
/// so session B's decision could overwrite session A's before A's `create` ever read it. They
@@ -79,7 +79,7 @@ fn pick_gamescope_mode(
/// The operator's gamescope overrides, sampled ONCE — at first use, and never written back.
///
/// `apply_input_env` used to both WRITE `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the
/// `resolve_gamescope_route` used to both WRITE `PUNKTFUNK_GAMESCOPE_NODE`/`_SESSION` (to publish the
/// sub-mode it chose) and READ them as operator overrides. Reading them live therefore fed the
/// ladder its own previous output: the Attach arm set `_NODE=auto`, and `node_env` sits at rung 2 of
/// [`pick_gamescope_mode`] — ABOVE `dedicated_launch` at rung 3 — so one Attach decision latched
@@ -102,7 +102,7 @@ struct OperatorGamescope {
managed: bool,
attach: bool,
/// The operator's `PUNKTFUNK_GAMESCOPE_NODE` VALUE, if set — the ladder needs its presence and
/// `apply_input_env` needs its content to build the route.
/// `resolve_gamescope_route` needs its content to build the route.
node: Option<String>,
/// Likewise `PUNKTFUNK_GAMESCOPE_SESSION` — the managed session flavour.
session: Option<String>,
@@ -137,26 +137,22 @@ fn operator_gamescope() -> &'static OperatorGamescope {
})
}
/// Route input to match the chosen video backend (they must not diverge), via the highest-priority
/// `PUNKTFUNK_INPUT_BACKEND` knob the injector honors.
/// The injector backend that matches a video backend the two must not diverge, so this is the one
/// place that decides. `pf_inject::set_backend_id` takes the answer; the caller publishes it
/// alongside [`resolve_gamescope_route`] whenever it routes a session.
///
/// For gamescope the sub-mode ladder ([`pick_gamescope_mode`]) selects **managed** (a host-managed
/// session at the client's mode — tears the TV's autologin down on connect, restored on a debounced
/// idle; only where session-plus/SteamOS actually exists), **attach** (mirror a running gamescope at
/// its own mode; explicit via `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the
/// fallback for a foreign gamescope on an infra-less box), or **bare spawn** (a per-session headless
/// gamescope nesting the session's launch command — the plain-distro default).
/// `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over all of it.
/// A `&'static str` because pf-vdisplay never depends on pf-inject (see the crate manifest's note),
/// and these four ids are already the `PUNKTFUNK_INPUT_BACKEND` vocabulary the injector parses.
///
/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to
/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and no
/// longer an env write precisely because two sessions connecting at once would otherwise clobber
/// each other's decision through the process env.
#[cfg(target_os = "linux")]
#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"]
pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<GamescopeRoute> {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backend = match chosen {
/// This used to be an `unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", ..) }` inside
/// `apply_input_env`, read back by `pf_inject::default_backend` — which runs once per input batch on
/// the injector service thread. A `getenv` on that hot path racing this per-session `setenv` is the
/// `environ` data race, on a live streaming host, reachable by nothing more exotic than a client
/// reconnect (security-review 2026-08-25). Handing back a value removes the write entirely; the
/// operator's own `PUNKTFUNK_INPUT_BACKEND` is still READ by the injector, and is no longer
/// overwritten by us.
pub fn input_backend_id(chosen: Compositor) -> &'static str {
match chosen {
Compositor::Gamescope => "gamescope",
// KWin: org_kde_kwin_fake_input — direct injection, no RemoteDesktop portal / approval
// dialog (headless, the krdpserver path), authorized by the host's shipped .desktop.
@@ -166,20 +162,26 @@ pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) -> Option<Gam
// Hyprland kept `zwlr_virtual_pointer_v1` + `zwp_virtual_keyboard_v1` (D4) — same wlr
// injector as sway/river, no code change.
Compositor::Wlroots | Compositor::Hyprland => "wlr",
};
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (lib.rs) serializing
// every process-env writer on the session-setup path; steady-state threads read cached
// config, not the environment.
unsafe { std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend) };
drop(_env_guard);
resolve_gamescope_route(chosen, dedicated_launch)
}
}
/// The gamescope sub-mode ladder ALONE — no input-backend env write.
/// The gamescope sub-mode ladder: **managed** (a host-managed session at the client's mode — tears
/// the TV's autologin down on connect, restored on a debounced idle; only where session-plus/SteamOS
/// actually exists), **attach** (mirror a running gamescope at its own mode; explicit via
/// `PUNKTFUNK_GAMESCOPE_ATTACH`/`PUNKTFUNK_GAMESCOPE_NODE`, or the fallback for a foreign gamescope
/// on an infra-less box), or **bare spawn** (a per-session headless gamescope nesting the session's
/// launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` forces managed over all
/// of it.
///
/// Split out for the operator-pinned path (`PUNKTFUNK_COMPOSITOR` set), which deliberately leaves
/// `PUNKTFUNK_INPUT_BACKEND` alone but still needs a route: without one, `create` would fall
/// through to a bare spawn on a box the operator pinned to the managed session.
/// Returns the resolved [`GamescopeRoute`] when `chosen` is gamescope — the caller must carry it to
/// the backend instance via `VirtualDisplay::set_gamescope_route`. It is a RETURN VALUE and not an
/// env write precisely because two sessions connecting at once would otherwise clobber each other's
/// decision through the process env.
///
/// Nothing here touches the injector: a caller routing a session pairs this with
/// [`input_backend_id`], while the operator-pinned (`PUNKTFUNK_COMPOSITOR`) path calls this ALONE —
/// it deliberately leaves input routing to the operator's own knob, but still needs a route, or
/// `create` would fall through to a bare spawn on a box pinned to the managed session.
#[cfg(target_os = "linux")]
#[must_use = "the resolved gamescope route must reach the backend instance (set_gamescope_route)"]
pub fn resolve_gamescope_route(
@@ -190,8 +192,8 @@ pub fn resolve_gamescope_route(
return None;
}
{
// Sampled inside — `operator_gamescope` takes ENV_LOCK itself, and `apply_input_env` has
// already dropped its guard before calling us (the mutex is not reentrant).
// Sampled inside — `operator_gamescope` takes ENV_LOCK itself, and no caller holds it (the
// mutex is not reentrant). Nothing on this path writes the env at all any more.
let ov = operator_gamescope();
let mode = pick_gamescope_mode(
dedicated_launch,
@@ -219,11 +221,6 @@ pub fn resolve_gamescope_route(
}
}
#[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) -> Option<GamescopeRoute> {
None
}
#[cfg(not(target_os = "linux"))]
pub fn resolve_gamescope_route(
_chosen: Compositor,
@@ -235,7 +232,7 @@ pub fn resolve_gamescope_route(
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
/// `auto` honestly). Computed at the handshake and threaded into [`resolve_gamescope_route`] /
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
use policy::GameSession;
@@ -262,7 +259,7 @@ pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
/// into the session — it would start twice. Takes the session's own resolved
/// [`GamescopeRoute`] (from [`apply_input_env`]) rather than re-reading process env, so a
/// [`GamescopeRoute`] (from [`resolve_gamescope_route`]) rather than re-reading process env, so a
/// concurrent session's routing decision cannot change this session's answer.
#[cfg(target_os = "linux")]
pub fn launch_is_nested(compositor: Compositor, route: Option<&GamescopeRoute>) -> bool {
@@ -562,7 +559,23 @@ mod tests {
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
}
/// The ladder must not be able to read back its own output. `apply_input_env`'s Attach arm used
/// The injector backend is a RETURN VALUE the caller hands to `pf_inject::set_backend_id`, never
/// a `set_var` of `PUNKTFUNK_INPUT_BACKEND` — that write raced `pf_inject::default_backend`'s
/// `getenv`, which runs once per input batch on the injector service thread
/// (security-review 2026-08-25). Pinning the whole table because pf-vdisplay cannot depend on
/// pf-inject: these four ids are one half of a cross-crate contract, and `pf-inject`'s
/// `every_id_the_video_side_emits_maps_to_a_backend` pins the other.
#[test]
fn every_compositor_names_the_injector_backend_that_matches_it() {
assert_eq!(input_backend_id(Compositor::Gamescope), "gamescope");
assert_eq!(input_backend_id(Compositor::Kwin), "kwin");
assert_eq!(input_backend_id(Compositor::Mutter), "libei");
// Hyprland shares sway's wlr virtual-input protocols (D4) — same injector, on purpose.
assert_eq!(input_backend_id(Compositor::Wlroots), "wlr");
assert_eq!(input_backend_id(Compositor::Hyprland), "wlr");
}
/// The ladder must not be able to read back its own output. `resolve_gamescope_route`'s Attach arm used
/// to write `PUNKTFUNK_GAMESCOPE_NODE=auto`, and `node_env` outranks `dedicated_launch` — so
/// while the override was read live, one Attach latched Attach for the host's lifetime and
/// silently overrode `game_session=dedicated`. Sampling once is what breaks the loop, and it is
+64 -33
View File
@@ -202,8 +202,10 @@ pub enum ActiveKind {
/// The session environment that points a backend at the [detected](detect_active_session) active
/// session: the Wayland socket (for the Wayland-protocol backends), the runtime dir + session bus
/// (for PipeWire capture + D-Bus / portal input), and the desktop name (for portal routing). The
/// host serves one session at a time, so [`apply_session_env`] writes these into the process env
/// per connect and every backend that reads them then opens against the live session.
/// host serves one session at a time, so [`apply_session_env`] writes the first four into the
/// process env per connect and every backend that reads them then opens against the live session.
/// The last two are a *description* of the session, not something exported — their readers are
/// `hyprctl` and `swaymsg`, which get them per spawn (see [`hypr_signature`] / [`sway_socket`]).
#[derive(Clone, Debug, Default)]
pub struct SessionEnv {
/// `WAYLAND_DISPLAY` of the live compositor (`None` for Gaming-attach / Mutter, which are
@@ -217,16 +219,16 @@ pub struct SessionEnv {
/// routing (xdph keys its Hyprland-specific behavior off `Hyprland`).
pub xdg_current_desktop: Option<String>,
/// `HYPRLAND_INSTANCE_SIGNATURE` of the live Hyprland instance (`Some` only for
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket;
/// [`apply_session_env`] exports it so the systemd-`--user` host works without inheriting the
/// session env. `None` for every other compositor.
/// [`ActiveKind::DesktopHyprland`]). `hyprctl` needs it to reach the right instance socket, and
/// it is handed to that child directly ([`hypr_signature`]) so the systemd-`--user` host works
/// without inheriting the session env. `None` for every other compositor.
pub hyprland_signature: Option<String>,
/// `SWAYSOCK` of the live sway instance (`Some` only for a sway [`ActiveKind::DesktopWlroots`]).
/// `swaymsg` needs it, and it was the LAST session variable the host could not derive: a
/// `systemd --user` host that never inherited the login shell's environment had no sway IPC at
/// all, so output enumeration and the chooser both failed. Derived from the detected compositor
/// PID like the Hyprland signature above. `None` on river (wlroots, but no sway IPC) and every
/// other compositor.
/// PID like the Hyprland signature above, and likewise handed straight to the child
/// ([`sway_socket`]). `None` on river (wlroots, but no sway IPC) and every other compositor.
pub sway_socket: Option<String>,
}
@@ -536,6 +538,35 @@ fn find_sway_socket(env: &EnvProbe, runtime: &str, uid: u32, pid: Option<u32>) -
cands.into_iter().next().map(|(_, p)| p)
}
/// The `HYPRLAND_INSTANCE_SIGNATURE` to hand a `hyprctl` child, resolved at spawn time.
///
/// [`apply_session_env`] used to export this so `hyprctl` inherited it — a `setenv` on the connect
/// path for a variable nothing outside this crate reads, i.e. a `getenv` race with every other
/// thread bought for nothing. The reader takes it by argument now (`Command::env`), the way
/// `set_launch_command` took the launch off the env before it. Resolving per spawn is also the more
/// truthful answer: a Hyprland↔sway switch cannot leave a stale export pointing `hyprctl` at a dead
/// instance, because there is no export.
#[cfg(target_os = "linux")]
pub(crate) fn hypr_signature() -> Option<String> {
let probe = EnvProbe::sample();
let runtime = default_runtime_dir(&probe);
find_hypr_signature(&probe, &runtime, crate::proc::current_uid())
}
/// The `SWAYSOCK` to hand a `swaymsg` child, resolved at spawn time — the sway counterpart of
/// [`hypr_signature`], off the process env for the same reason.
///
/// No compositor PID to match against here (detection's `/proc` scan is far too expensive to repeat
/// per `swaymsg`), so this takes [`find_sway_socket`]'s other two arms: a valid inherited value, else
/// the newest sway IPC socket we own. `None` on river (wlroots, no sway IPC) and when nothing
/// sway-shaped is listening — which is what keeps `swaymsg` from being aimed at a dead socket.
#[cfg(target_os = "linux")]
pub(crate) fn sway_socket() -> Option<String> {
let probe = EnvProbe::sample();
let runtime = default_runtime_dir(&probe);
find_sway_socket(&probe, &runtime, crate::proc::current_uid(), None)
}
#[cfg(not(target_os = "linux"))]
pub fn detect_active_session() -> ActiveSession {
ActiveSession::none()
@@ -578,16 +609,30 @@ fn find_wayland_socket(env: &EnvProbe, runtime: &str, uid: u32) -> Option<String
/// Write a detected session's [`SessionEnv`] into the process env so every backend (video capture
/// and input alike) that reads `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` /
/// `XDG_CURRENT_DESKTOP` at open time targets the live session. Serialized via [`ENV_LOCK`] so
/// concurrent session handshakes can't race the `set_var`s; the next connect re-detects and
/// `XDG_CURRENT_DESKTOP` at open time targets the live session; the next connect re-detects and
/// re-applies.
///
/// Those four are what remains because their readers can only take them from the process env:
/// wayland-client's `connect_to_env`, zbus's address lookup, libpipewire and the Mesa loader — plus
/// [`settle_desktop_portal`], which imports them into the systemd / D-Bus activation environment BY
/// NAME out of ours. Everything whose reader is code we own travels as a value instead: `hyprctl`'s
/// instance signature and `swaymsg`'s socket are handed to those children with `Command::env`
/// ([`hypr_signature`] / [`sway_socket`]), the launch command rides `set_launch_command`, and the
/// gamescope sub-mode rides `set_gamescope_route`.
///
/// [`ENV_LOCK`] orders these writes against this crate's own env readers. It does **not** make them
/// sound — see its doc — so shortening this list is the only thing that moves the needle.
#[cfg(target_os = "linux")]
pub fn apply_session_env(active: &ActiveSession) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let e = &active.env;
// SAFETY: `_env_guard` holds [`ENV_LOCK`] — the crate-wide discipline (see its doc in lib.rs)
// that serializes every process-env writer on the session-setup path; steady-state streaming
// threads read cached config, not the environment (security-review 2026-06-28 #7).
// SAFETY: PARTIAL, and deliberately not a proof. `_env_guard` holds [`ENV_LOCK`], which orders
// this against every env reader and writer *inside pf-vdisplay*, and steady-state streaming
// threads read cached config rather than the environment. Nothing else in the process takes
// that lock — not glibc, not zbus, not wayland-client, not the Mesa ICD loader — so each write
// below is still a race with a concurrent `getenv` elsewhere, as `setenv(3)` always is. These
// four survive only because their readers cannot be handed a value; every variable whose
// readers are ours has been moved off (security-review 2026-06-28 #7, 2026-08-25).
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", &e.xdg_runtime_dir);
std::env::set_var("DBUS_SESSION_BUS_ADDRESS", &e.dbus_session_bus_address);
@@ -597,25 +642,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if let Some(d) = &e.xdg_current_desktop {
std::env::set_var("XDG_CURRENT_DESKTOP", d);
}
// Hyprland: export the discovered instance signature so `hyprctl` reaches the live
// compositor (fixes G4 for the systemd `--user` host, which never inherited it). Only set
// when detection found a Hyprland session; a stale value from a previous connect is
// cleared otherwise so a Hyprland→sway switch can't leave `hyprctl` pointed at a dead
// instance.
match &e.hyprland_signature {
Some(sig) => std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", sig),
None => std::env::remove_var("HYPRLAND_INSTANCE_SIGNATURE"),
}
// sway: same treatment, and for the same reason — `swaymsg` (output enumeration, the
// capture chooser) is unreachable without it, so a systemd `--user` host that never
// inherited the login environment had no sway backend at all. Cleared when nothing
// sway-shaped is live, so a sway→Hyprland switch can't leave `swaymsg` aimed at a dead
// socket. `wlroots::is_available()` keys off this variable, so setting it here is also
// what makes the backend visible at all.
match &e.sway_socket {
Some(sock) => std::env::set_var("SWAYSOCK", sock),
None => std::env::remove_var("SWAYSOCK"),
}
// `HYPRLAND_INSTANCE_SIGNATURE` and `SWAYSOCK` were exported here too. Their only readers
// are `hyprctl` and `swaymsg`, both spawned by this crate, so they travel to those children
// as `Command::env` values now ([`hypr_signature`] / [`sway_socket`]): two fewer
// `setenv`/`unsetenv` calls per connect, and nothing left to go stale across a Hyprland↔sway
// switch — a child that is not spawned inherits no signature to be wrong about.
// NOTHING live ⇒ every session-scoped var still in the env is a leftover from a previous
// connect's retarget, and the availability probes read them: after a gnome-shell crash
// (observed 2026-07-10: SIGSEGV → GDM greeter) a stale `XDG_CURRENT_DESKTOP=GNOME` kept
@@ -908,8 +939,8 @@ mod tests {
}
/// river is the other wlroots desktop and ships no sway IPC. Reporting `None` is what keeps
/// `apply_session_env` from exporting a `SWAYSOCK` that points at nothing — an exported lie
/// would make `wlroots::is_available()` claim a backend that cannot answer.
/// [`sway_socket`] from handing `swaymsg` a `SWAYSOCK` that points at nothing — a made-up
/// socket would turn "no sway IPC here" into a connect that hangs or fails obscurely.
#[test]
fn no_sway_ipc_socket_reports_none() {
let rt = FakeRuntime::new("none", &[]);
+2 -2
View File
@@ -1047,13 +1047,13 @@ mod tests {
num_tile_rows_minus1: 2,
uniform_spacing_flag: false,
column_width_minus1: {
let mut w = [0u32; 19];
let mut w = [0u32; 20];
w[0] = 17;
w[1] = 12;
w
},
row_height_minus1: {
let mut h = [0u32; 21];
let mut h = [0u32; 22];
h[0] = 9;
h[1] = 8;
h[2] = 16;
+2 -2
View File
@@ -841,8 +841,8 @@ mod tests {
num_tile_columns_minus1: 0,
num_tile_rows_minus1: 0,
uniform_spacing_flag: true,
column_width_minus1: [0; 19],
row_height_minus1: [0; 21],
column_width_minus1: [0; 20],
row_height_minus1: [0; 22],
loop_filter_across_tiles_enabled_flag: true,
loop_filter_across_slices_enabled_flag: false,
deblocking_filter_control_present_flag: false,
+2 -2
View File
@@ -746,8 +746,8 @@ mod tests {
num_tile_columns_minus1: 0,
num_tile_rows_minus1: 0,
uniform_spacing_flag: true,
column_width_minus1: [0; 19],
row_height_minus1: [0; 21],
column_width_minus1: [0; 20],
row_height_minus1: [0; 22],
loop_filter_across_tiles_enabled_flag: true,
loop_filter_across_slices_enabled_flag: false,
deblocking_filter_control_present_flag: false,
+3 -9
View File
@@ -266,15 +266,9 @@ pub(crate) struct CudaApi {
cuIpcOpenMemHandle: unsafe extern "C" fn(*mut CUdeviceptr, CUipcMemHandle, c_uint) -> CUresult,
cuIpcCloseMemHandle: unsafe extern "C" fn(CUdeviceptr) -> CUresult,
}
// SAFETY: every field is a bare `extern "C" fn` address into the leaked, process-lifetime
// `libcuda` mapping (`cuda_api` `forget`s the `Library`, so it is never unloaded) — an immutable
// value with no interior mutability and no thread affinity. Moving the table to another thread
// cannot dangle (the code it points at stays mapped) or race (the fields are read-only).
unsafe impl Send for CudaApi {}
// SAFETY: as above — the table is a set of immutable fn-pointer addresses with no interior
// mutability, so concurrent shared reads from multiple threads cannot race; the driver entry
// points they address are themselves thread-safe.
unsafe impl Sync for CudaApi {}
// `Send`/`Sync` need no `unsafe impl`: every field is a bare fn pointer, which is already both.
// The addresses stay valid because `cuda_api` `forget`s the `Library`, so `libcuda` is never
// unloaded.
/// `CUresult` returned by the wrappers when `libcuda` isn't loaded (no NVIDIA driver). Non-zero so
/// the existing `ck()`/`!= 0` checks treat it as an ordinary driver error; distinct from any real
+4 -4
View File
@@ -7,7 +7,7 @@
#![allow(non_upper_case_globals)]
use anyhow::{bail, ensure, Result};
use std::os::raw::{c_int, c_void};
use std::os::raw::{c_char, c_int, c_void};
pub(crate) const GL_TEXTURE_2D: u32 = 0x0DE1;
pub(crate) const GL_TEXTURE_MIN_FILTER: u32 = 0x2801;
@@ -70,7 +70,7 @@ unsafe extern "C" {
pub(crate) fn glShaderSource(
shader: u32,
count: c_int,
string: *const *const i8,
string: *const *const c_char,
length: *const c_int,
);
pub(crate) fn glCompileShader(shader: u32);
@@ -80,7 +80,7 @@ unsafe extern "C" {
pub(crate) fn glAttachShader(program: u32, shader: u32);
pub(crate) fn glLinkProgram(program: u32);
pub(crate) fn glGetProgramiv(program: u32, pname: u32, params: *mut c_int);
pub(crate) fn glGetUniformLocation(program: u32, name: *const i8) -> c_int;
pub(crate) fn glGetUniformLocation(program: u32, name: *const c_char) -> c_int;
pub(crate) fn glUniform1i(location: c_int, v0: c_int);
pub(crate) fn glDeleteProgram(program: u32);
pub(crate) fn glTexSubImage2D(
@@ -152,7 +152,7 @@ pub(crate) unsafe fn compile_shader(kind: u32, src: &[u8]) -> Result<u32> {
unsafe {
let sh = glCreateShader(kind);
ensure!(sh != 0, "glCreateShader failed");
let ptr = src.as_ptr() as *const i8;
let ptr = src.as_ptr() as *const c_char;
let len = src.len() as c_int;
glShaderSource(sh, 1, &ptr, &len);
glCompileShader(sh);
+23 -15
View File
@@ -631,9 +631,9 @@ impl VkBridge {
let uv_off = y_pitch * height as u64;
let dst_size = uv_off + y_pitch * height.div_ceil(2) as u64;
// SAFETY: same structure and proofs as `import_linear` — `fd` is the caller's live dmabuf
// (dup'd by `import_src`), sizes are checked (`import_src` asserts the fd covers
// `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at least
// the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
// (dup'd by `import_src`), sizes are checked (every frame re-asserts that the imported src
// covers `offset + stride*height`; `ensure_dst(dst_size)` makes the exportable buffer at
// least the shader's whole write range, whose last word is `dst_size - 4`). The descriptor
// update binds the live cached src buffer and the live dst buffer WHOLE_SIZE; every
// `*Info`/array is a local outliving its synchronous call; `cmd`/`queue`/`fence` are this
// bridge's own single-thread handles. The dispatch covers ⌈w/32⌉×⌈h/16⌉ groups of 8×8
@@ -645,10 +645,15 @@ impl VkBridge {
if !self.src_cache.contains_key(&fd) {
let size = libc::lseek(fd, 0, libc::SEEK_END);
anyhow::ensure!(size > 0, "lseek(dmabuf)");
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
self.import_src(fd, size as u64)?;
}
let src_buffer = self.src_cache[&fd].buffer;
let (src_buffer, src_size) = {
let s = &self.src_cache[&fd];
(s.buffer, s.size)
};
// As in `import_linear`: this frame's chunk metadata, not the cached import's, decides
// how far the shader reads.
anyhow::ensure!(src_size >= span, "dmabuf smaller than frame span");
self.ensure_dst(dst_size)?;
self.ensure_csc()?;
let (dst_buffer, dst_cuda_ptr) = {
@@ -703,7 +708,8 @@ impl VkBridge {
(uv_off / 4) as u32,
(y_pitch / 4) as u32,
];
let push_bytes: &[u8] = std::slice::from_raw_parts(push.as_ptr().cast(), 28);
let push_words = push.map(u32::to_ne_bytes);
let push_bytes: &[u8] = push_words.as_flattened();
self.device.cmd_push_constants(
self.cmd,
csc.playout,
@@ -794,11 +800,11 @@ impl VkBridge {
// SAFETY: `fd` is the live dmabuf fd handed in by the caller (borrowed; `import_src` dup's it
// internally and Vulkan owns the dup). `libc::lseek` only queries the fd's size. The unsafe
// `import_src`/`ensure_dst` are called with a valid fd and a checked size. The bounds are
// proven: `import_src` asserts `size >= span` (so the cached `src_size >= span`),
// `copy_size = src_size.min(span)`, and `ensure_dst(copy_size)` makes `dst` at least
// `copy_size` — so the GPU `cmd_copy_buffer` of `copy_size` bytes reads/writes within both
// buffers, and the later CUDA pitched copy reading `[offset, span)` from `dst.cuda.ptr` (=
// `offset + stride*height = span <= copy_size`) stays inside the freshly-copied region. The
// proven: `src_size >= span` is re-checked below against THIS frame's `offset`/`stride`
// (an import cached on an earlier frame is not proof for this one), and `ensure_dst(span)`
// makes `dst` at least `span` — so the GPU `cmd_copy_buffer` of `span` bytes reads/writes
// within both buffers, and the later CUDA pitched copy reading `[offset, span)` from
// `dst.cuda.ptr` (= `offset + stride*height = span`) stays inside the copied region. The
// `*Info`/`region`/`cmds`/`submit` are locals that outlive the synchronous calls reading them.
// `cmd`/`queue`/`fence` are this bridge's own handles, used on this single thread only. The
// host-side `wait_for_fences` fully retires the Vulkan copy BEFORE CUDA reads the shared
@@ -809,15 +815,17 @@ impl VkBridge {
if !self.src_cache.contains_key(&fd) {
let size = libc::lseek(fd, 0, libc::SEEK_END);
anyhow::ensure!(size > 0, "lseek(dmabuf)");
anyhow::ensure!(size as u64 >= span, "dmabuf smaller than frame span");
self.import_src(fd, size as u64)?;
}
let (src_buffer, src_size) = {
let s = &self.src_cache[&fd];
(s.buffer, s.size)
};
let copy_size = src_size.min(span);
self.ensure_dst(copy_size)?;
// Per frame, not per import: `offset`/`stride` come from this frame's PipeWire chunk
// metadata, so a cached import can be smaller than the span they describe. Clamping
// the Vulkan copy instead would leave the CUDA de-stride below reading past `dst`.
anyhow::ensure!(src_size >= span, "dmabuf smaller than frame span");
self.ensure_dst(span)?;
let dst = self.dst.as_ref().unwrap();
// Record + submit the GPU copy, wait on the fence (GPU-GPU, sub-millisecond).
@@ -828,7 +836,7 @@ impl VkBridge {
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)
.context("begin cmd")?;
let region = vk::BufferCopy::default().size(copy_size);
let region = vk::BufferCopy::default().size(span);
self.device
.cmd_copy_buffer(self.cmd, src_buffer, dst.buffer, &[region]);
self.device
+32
View File
@@ -587,6 +587,18 @@ impl Config {
"shard_payload too large to fit a datagram (header + crypto overhead)",
));
}
// The floor the clamp helpers already bottom out at, enforced for the CLIENT: its value
// comes off the wire from the peer (`Welcome::shard_payload`) and sets the reassembler's
// per-frame shard floor ([`ReassemblerLimits::min_shard_bytes`]), so without this a
// hostile Welcome drops that floor to two bytes and we accept confetti-sized shards for
// the whole session. A host's value is always locally derived (every `shard_payload_for_*`
// helper clamps here, and a client's ACK only ever confirms a size the host proposed), so
// a hand-configured host below the floor stays legal.
if self.role == Role::Client && self.shard_payload < MIN_SHARD_PAYLOAD {
return Err(PunktfunkError::InvalidArg(
"negotiated shard_payload below MIN_SHARD_PAYLOAD",
));
}
if self.fec.max_data_per_block == 0 {
return Err(PunktfunkError::InvalidArg("max_data_per_block must be > 0"));
}
@@ -658,6 +670,26 @@ mod tests {
assert!(c.validate().is_ok());
}
/// The client's `shard_payload` is whatever the host's `Welcome` says, and the reassembler
/// takes its per-frame shard floor from it — so a sub-floor negotiated value must be refused
/// outright instead of quietly lowering that floor (a 2-byte shard is not a path this
/// protocol runs on: it can't carry the QUIC control plane either).
#[test]
fn rejects_negotiated_shard_payload_below_the_floor() {
let mut c = Config::p1_defaults(Role::Client);
c.shard_payload = 2;
assert!(c.validate().is_err());
c.shard_payload = MIN_SHARD_PAYLOAD - 2;
assert!(c.validate().is_err());
c.shard_payload = MIN_SHARD_PAYLOAD;
assert!(c.validate().is_ok());
// The reassembler's floor can no longer be dragged below the production one by a peer.
assert_eq!(
crate::packet::ReassemblerLimits::from_config(&c).min_shard_bytes,
MIN_SHARD_PAYLOAD
);
}
#[test]
fn rejects_oversized_shard_payload() {
let mut c = Config::p1_defaults(Role::Host);
@@ -155,7 +155,9 @@ impl ReassemblerLimits {
(max_data + (max_data * 90).div_ceil(100)).min(c.fec.scheme.max_total_shards());
ReassemblerLimits {
// `.min(c.shard_payload)`: never reject the session's own negotiated value — a
// hand-configured session below the production floor still reassembles itself.
// hand-configured session below the production floor still reassembles itself. It
// can't be a NEGOTIATED one: `Config::validate` refuses a sub-floor value on the
// client, which is the only side that takes it from the peer's `Welcome`.
min_shard_bytes: crate::config::MIN_SHARD_PAYLOAD.min(c.shard_payload),
max_shard_bytes: crate::config::max_shard_payload(),
max_data_shards: max_data,
@@ -381,9 +383,11 @@ impl Reassembler {
// final block first.
let slice_stream = hdr.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0;
let block_idx = hdr.block_index as usize;
// For a sentinel-opened frame the buffer must hold ANY final geometry the totals may
// later pin — the maximum the negotiated limits allow (the design's "allocate at
// max_frame_bytes"; the existing in-flight budget bounds the amplification).
// The most data shards any frame can carry under THIS packet's shard size: the ceiling the
// per-frame block caps below derive from, and the clamp on a frame's buffer extent. NOT
// what a frame is allocated at — that is only ever the extent its packets have PROVEN
// (see `need_shards`), which is what keeps one small datagram from committing the whole
// negotiated frame ceiling; the in-flight budget bounds the rest.
let total_data_max = lim.max_frame_bytes.div_ceil(shard_bytes).max(1);
// The per-frame FEC-block ceiling under THIS packet's shard size (geometry is
// per-frame: a shrunk shard needs more blocks for the same bytes, so a session-level
@@ -562,6 +562,51 @@ mod tests {
);
}
/// Adopting a punch does more than pick a destination: `connect`ing to the *observed source*
/// binds the data plane's full 5-tuple, so from that moment the kernel drops everything that
/// isn't the punched peer. That is what stops a second source on the same authenticated IP (a
/// co-NAT peer, another local process) from re-steering the video plane with a later punch or
/// feeding the session's reassembler — the punch race is only ever open until the first
/// accepted datagram fixes the tuple.
#[test]
fn a_punched_transport_only_accepts_the_punched_five_tuple() {
let peer = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let reported = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let host_addr = host_sock.local_addr().unwrap();
peer.send_to(PUNCH_MAGIC, host_addr).unwrap();
let (transport, punched) = UdpTransport::from_socket_punch(
host_sock,
&reported.local_addr().unwrap().to_string(),
std::net::IpAddr::from([127, 0, 0, 1]),
std::time::Duration::from_millis(500),
)
.unwrap();
assert!(punched, "the peer's punch must be adopted");
// Same authenticated IP, different port: a punch AND a payload, both after the tuple is
// fixed. Neither may be seen; the punched peer's datagram still must be.
let stray = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
stray.send_to(PUNCH_MAGIC, host_addr).unwrap();
stray.send_to(b"stray", host_addr).unwrap();
peer.send_to(b"real", host_addr).unwrap();
let mut got: Vec<Vec<u8>> = Vec::new();
for _ in 0..20 {
match transport.recv().unwrap() {
Some(p) => got.push(p),
None => std::thread::sleep(std::time::Duration::from_millis(5)),
}
}
assert_eq!(
got,
vec![b"real".to_vec()],
"only the punched 5-tuple may reach the session"
);
}
/// A punch from any source other than the QUIC-authenticated peer must be ignored: `PUNCH_MAGIC`
/// is a fixed public constant with no key or session id, so honouring an off-peer punch lets
/// anyone who lands an 8-byte datagram on the ephemeral data port steal (or redirect) the video
+27 -3
View File
@@ -109,9 +109,13 @@ fn bundle_id(unix_ms: u64, fp_hex: &str, name: &str) -> String {
}
impl ClientLogStore {
/// Open the store, creating `dir` (owner-private, best-effort) if missing.
/// Open the store, creating `dir` (owner-private, best-effort) if missing. `create_secret_dir`,
/// not `create_private_dir`: on Windows the latter grants `BUILTIN\Users` an inheritable read,
/// which every stored bundle then inherited — any local user could read them straight off disk,
/// which is not the "reading them stays on the loopback-only bearer lane" split `mgmt::client_logs`
/// documents (security-review 2026-08-25).
pub fn new(dir: PathBuf) -> std::sync::Arc<Self> {
if let Err(e) = pf_paths::create_private_dir(&dir) {
if let Err(e) = pf_paths::create_secret_dir(&dir) {
tracing::warn!(dir = %dir.display(), error = %e, "could not create client-logs dir");
}
std::sync::Arc::new(ClientLogStore { dir })
@@ -121,7 +125,9 @@ impl ClientLogStore {
/// Prunes that device's older bundles past [`KEEP_PER_DEVICE`] (best-effort).
pub fn save(&self, fp_hex: &str, device_name: &str, body: &[u8]) -> std::io::Result<String> {
let id = bundle_id(unix_ms_now(), fp_hex, device_name);
std::fs::write(self.dir.join(format!("{id}.log")), body)?;
// A bundle is whatever the client logged (addresses, host names) — owner-only, like the
// host's own secrets, so the dir ACL is not the only thing keeping it off a local user.
pf_paths::write_secret_file(&self.dir.join(format!("{id}.log")), body)?;
// Prune this device's older bundles. The fp16 field is position 2 of the stem, and ids
// sort chronologically because the timestamp leads.
let fp16: String = fp_hex
@@ -284,4 +290,22 @@ mod tests {
assert!(listed.iter().any(|m| m.id == other), "other device pruned");
let _ = std::fs::remove_dir_all(dir);
}
/// A bundle is stored owner-only — the unix half of "reading them stays on the loopback-only
/// bearer lane"; on Windows the same `write_secret_file` call applies the SYSTEM/Admins DACL.
#[cfg(unix)]
#[test]
fn stored_bundles_are_not_world_readable() {
use std::os::unix::fs::PermissionsExt;
let (s, dir) = store();
let id = s.save("abcdef0123456789", "Deck", b"secret log").unwrap();
let md = std::fs::metadata(dir.join(format!("{id}.log"))).unwrap();
assert_eq!(md.permissions().mode() & 0o077, 0, "bundle is owner-only");
assert_eq!(
std::fs::metadata(&dir).unwrap().permissions().mode() & 0o077,
0,
"so is the store dir"
);
let _ = std::fs::remove_dir_all(dir);
}
}
+102 -1
View File
@@ -1018,6 +1018,10 @@ fn terminate_blocking(shared: &LeaseShared) {
/// nothing — the exact failure a spawned pid was folded into the Windows ladder to fix. Resolved at
/// the moment of use rather than stored on the lease, so a report that has since gone stale, or a
/// pid the kernel has since recycled, contributes nothing.
///
/// And held to procscan's rule 1 like every other adopted process, because this is the one pid that
/// arrives from *outside* the host: a plugin names it, so nothing but the start-time floor stands
/// between `POST /game/end` and any process on the box — as SYSTEM, on Windows.
#[cfg(any(target_os = "linux", windows))]
fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
let pid = shared
@@ -1027,7 +1031,41 @@ fn reported_proc(shared: &LeaseShared) -> Option<crate::procscan::ProcRef> {
.and_then(crate::runstate::opinion)
.filter(|l| l.running)?
.pid?;
crate::procscan::resolve(pid)
let proc = crate::procscan::resolve(pid)?;
if let Some(min) = shared.launch_stamp {
let started = start_secs(proc);
if started + crate::procscan::START_SLACK_SECS < min {
return None; // predates this launch — never ours (rule 1)
}
}
Some(proc)
}
/// A resolved process's start time in seconds on the platform's process-start timeline — the scale
/// [`LeaseShared::launch_stamp`] is on, so the two can be compared.
///
/// [`crate::procscan::ProcRef::start`] is otherwise opaque and its units are per-platform, and the
/// matcher converts it inside each platform module rather than exposing it. Repeated here for the
/// one pid the matcher never sees; `a_reported_pid_that_predates_the_launch_is_never_a_target`
/// drives it against a real process, so the two cannot drift apart silently.
#[cfg(any(target_os = "linux", windows))]
fn start_secs(p: crate::procscan::ProcRef) -> f64 {
#[cfg(target_os = "linux")]
let per_sec = {
// SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and
// a non-positive answer (which is handled here) is its documented failure signal.
let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
// The same fallback the matcher takes: a non-positive answer would poison the comparison.
if ticks > 0 {
ticks as f64
} else {
100.0
}
};
// 100-nanosecond `FILETIME` ticks: the unit a Windows creation time is reported in.
#[cfg(windows)]
let per_sec = 10_000_000.0;
p.start as f64 / per_sec
}
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
@@ -1746,6 +1784,69 @@ mod tests {
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
}
/// 🛑 The pid a provider reports takes the same start-time floor every other adopted process
/// takes. It is the only pid that reaches the termination ladder from *outside* the host, so
/// that floor is the whole of what stands between a plugin and any process on the box.
///
/// Without it, a plugin that has a lease open reports the pid of anything it likes — security
/// tooling, sshd, the operator's editor — and `POST /game/end` walks the ladder over it: on
/// Windows the host is SYSTEM, so that reaches everything on the machine. The Playnite case is
/// untouched, because it reports a pid it just started; the second half is that case.
#[cfg(target_os = "linux")]
#[test]
fn a_reported_pid_that_predates_the_launch_is_never_a_target() {
const ID: &str = "playnite:reported-pid";
const PROVIDER: &str = "playnite-reported-pid";
// Stands in for whatever a plugin decides to name: it exists before either stamp below.
let mut victim = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn the process a plugin would point at");
crate::runstate::report(
PROVIDER,
[ID.to_string()].into_iter().collect(),
[(ID.to_string(), Some(victim.id()))].into_iter().collect(),
);
// A launch that happened a minute after that process started — so the report names
// something this session never launched.
let l = open(
LeaseRequest {
launch_stamp: launch_clock().map(|s| s + 60.0),
..req(ID, DetectSpec::default(), false)
},
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Reported));
assert!(
reported_proc(&l.shared()).is_none(),
"a reported pid that predates the launch must not be signallable"
);
drop(l);
// The Playnite case, unchanged: the pid the provider started for this launch. The stamp is
// taken *after* the process exists, which also pins the slack — an exact comparison would
// reject the real game (`crate::procscan::START_SLACK_SECS`).
let l = open(
LeaseRequest {
launch_stamp: launch_clock(),
..req(ID, DetectSpec::default(), false)
},
Box::new(|| {}),
);
assert_eq!(
reported_proc(&l.shared()).map(|p| p.pid),
Some(victim.id()),
"the pid a provider started for this launch is still what `End` aims at"
);
drop(l);
crate::runstate::forget(PROVIDER);
let _ = victim.kill();
let _ = victim.wait();
}
#[test]
fn an_untracked_lease_is_never_terminated() {
let l = open(
+80 -21
View File
@@ -27,13 +27,13 @@
//! 47999 (the PIN ceremony is HTTPS on nvhttp), so [`sync`] keeps the port closed until the
//! first pairing lands and tears it down when the last one is removed.
use super::{AppState, CONTROL_PORT};
use super::{AppState, LaunchSession, CONTROL_PORT};
use crate::inject::gamepad::GamepadManager;
use anyhow::{anyhow, Context, Result};
use punktfunk_core::input::{GamepadEvent, InputEvent};
use punktfunk_core::quic::{classify, GrantClass, HdrMeta, GRANT_ALL};
use rusty_enet::{Event, Host, HostSettings, Packet, PeerID};
use std::net::UdpSocket;
use std::net::{IpAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
@@ -336,11 +336,14 @@ pub(crate) fn sync(state: &Arc<AppState>) -> Result<()> {
/// `rusty_enet` 0.4.0 exposes no setter for `maximum_waiting_data` (the C default of 32 MiB of
/// per-peer reassembly), so an off-path LAN peer that connects on 47999 can pin ~32 MiB × the
/// `peer_limit` and occupy peer slots without ever authenticating — and the same unfiltered path
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Filtering at the
/// socket drops those datagrams BEFORE ENet allocates any per-peer state. The owner is read live
/// from `launch` on each receive: before `/launch` (owner `None`) the filter passes everything,
/// matching the plane's existing "trust the connect when no owner is captured" fallback used by
/// the `Event::Connect` arm below. security-review 2026-08-15 findings 2 and 13.
/// lets an on-path attacker spoof the owner's source to feed the tracked peer. Once the owner IS
/// known, filtering at the socket drops those datagrams before ENet allocates any per-peer state.
///
/// The owner is read live from `launch` on each receive, so this covers only the window where a
/// launch is recorded: before `/launch` (owner `None`) the filter passes everything, and what
/// keeps an unauthenticated peer from squatting a slot through that whole idle window is
/// [`accept_connect`] resetting the peer in the `Event::Connect` arm below.
/// security-review 2026-08-15 findings 2 and 13; 2026-08-25 finding 1.
struct OwnerFilteredSocket {
inner: UdpSocket,
state: Arc<AppState>,
@@ -379,6 +382,23 @@ impl rusty_enet::Socket for OwnerFilteredSocket {
}
}
/// Whether a fresh ENet connect may be admitted at all. A live `/launch` is the floor —
/// Moonlight connects the control stream only after the RTSP handshake, so there is no such
/// thing as a legitimate connect without one — and when the launching IP was captured on both
/// sides it must match, the same source-IP bind the RTSP/media plane applies
/// (`rtsp::authorized_launch`). Kept a free function so the gate the session actually runs is
/// the thing the tests exercise.
fn accept_connect(launch: Option<LaunchSession>, from: Option<IpAddr>) -> bool {
match (launch.map(|l| l.peer_ip), from) {
// No live `/launch`: nothing on this port is legitimate yet.
(None, _) => false,
// Launching IP known on both sides but mismatched → not the owner.
(Some(Some(want)), Some(got)) => want == got,
// The address couldn't be captured on one side → launch-present only.
_ => true,
}
}
/// Bind the ENet control host on 47999 and service it on a dedicated thread until `stop`.
fn spawn(state: Arc<AppState>) -> Result<Running> {
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
@@ -503,23 +523,28 @@ fn spawn(state: Arc<AppState>) -> Result<Running> {
match host.service() {
Ok(Some(event)) => match event {
Event::Connect { peer: p, .. } => {
// Track this peer as THE session peer only if it comes from the
// `/launch` owner's IP (when captured — `None` falls back to
// trusting the connect, the pre-teardown behavior). The tracked
// peer's disconnect now ENDS the session, so an unauthenticated
// LAN peer that connects+disconnects on 47999 must not be able to
// steal the slot and tear a live session down. Same source-IP
// bind the RTSP/media plane uses (security-review #4).
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
// Admit only the launch owner ([`accept_connect`]): the tracked
// peer's disconnect ENDS the session, so an unauthenticated LAN
// peer that connects+disconnects on 47999 must not be able to
// steal the slot and tear a live session down (security-review
// #4). A refused peer is RESET, not merely left untracked — the
// port is open the whole idle life of a paired host, and a peer
// ENet keeps alive with its own pings would otherwise hold one of
// four slots (plus its 32 MiB reassembly budget) indefinitely and
// starve every later session (security-review 2026-08-25 #1).
// `disconnect_now` frees the slot before the next tick and emits
// no `Disconnect` event for the arm below to read as a session end.
let launch = *state.launch.lock().unwrap();
let from = p.address().map(|a| a.ip());
if owner_ip.is_some() && from.is_some() && owner_ip != from {
tracing::warn!(
?from,
"control: peer connected from a non-owner IP — ignoring"
);
} else {
if accept_connect(launch, from) {
tracing::info!("control: client connected");
peer = Some(p.id());
} else {
tracing::warn!(
?from,
"control: peer connected without a matching /launch — refusing"
);
p.disconnect_now(0);
}
}
Event::Disconnect { peer: p, .. } => {
@@ -1194,6 +1219,40 @@ mod tests {
v
}
/// A live `/launch` whose owner IP is `peer_ip`.
fn launched(peer_ip: Option<std::net::IpAddr>) -> Option<super::LaunchSession> {
Some(super::LaunchSession {
gcm_key: [0; 16],
rikeyid: 0,
width: 1920,
height: 1080,
fps: 60,
appid: 1,
peer_ip,
owner_fp: None,
})
}
/// The ENet connect gate: 47999 is open the whole idle life of a paired host, so a connect
/// with no live `/launch` behind it is refused (it would otherwise squat one of four peer
/// slots until every later session fails), and a launched session admits only the owner's
/// IP — falling back to launch-present-only when either side's address is unknown, like the
/// RTSP plane's `authorized_launch`.
#[test]
fn connects_are_admitted_only_behind_a_matching_launch() {
let owner: std::net::IpAddr = "192.168.1.20".parse().unwrap();
let other: std::net::IpAddr = "192.168.1.99".parse().unwrap();
// Idle host (no /launch): every connect is refused, address known or not.
assert!(!super::accept_connect(None, Some(owner)));
assert!(!super::accept_connect(None, None));
// Launched with a captured owner IP: only that IP is the session peer.
assert!(super::accept_connect(launched(Some(owner)), Some(owner)));
assert!(!super::accept_connect(launched(Some(owner)), Some(other)));
// Address unknown on one side → launch-present only (the pre-existing fallback).
assert!(super::accept_connect(launched(Some(owner)), None));
assert!(super::accept_connect(launched(None), Some(other)));
}
#[test]
fn decodes_a_valid_rfi_range() {
assert_eq!(decode_rfi_range(&rfi_msg(40, 47)), Some((40, 47)));
+51 -3
View File
@@ -411,6 +411,7 @@ fn session_url_xml(st: &AppState, tag: &str) -> String {
async fn h_pair(
State(st): State<Arc<AppState>>,
peer: Option<Extension<PeerCertFingerprint>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let uniqueid = q.get("uniqueid").cloned().unwrap_or_default();
@@ -440,9 +441,17 @@ async fn h_pair(
_ => Ok(pair_error_xml()),
}
} else if phrase == Some("pairchallenge") {
// Reached only over the TLS port with the pinned host cert; the handshake is the
// proof, so acknowledge success.
Ok(paired_ok_xml())
// The ceremony's last step, which Moonlight makes over the TLS port with the cert phase 4
// has just pinned — so the pinned handshake is the proof, and only a pinned caller is told
// it is paired. Anyone else (the plain-HTTP listener, or an HTTPS peer presenting a cert
// that is not in the allow-list) gets the same answer an unpaired host gives, so this
// endpoint asserts no pairing the caller does not hold (security-review 2026-08-25).
if peer_is_paired(&peer, &st) {
Ok(paired_ok_xml())
} else {
tracing::warn!("pairchallenge rejected — client is not paired");
Ok(pair_error_xml())
}
} else if let Some(v) = q.get("clientchallenge") {
st.pairing.clientchallenge(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("serverchallengeresp") {
@@ -537,6 +546,45 @@ mod tests {
);
}
/// `pairchallenge` is the ceremony's last step and Moonlight makes it over the TLS port with
/// the cert phase 4 just pinned, so only a pinned caller is told it is paired. A plain-HTTP
/// scanner (no client cert at all) and an HTTPS peer with an unpinned cert both get the
/// unpaired answer — the endpoint must not assert a pairing the caller does not hold.
#[tokio::test]
async fn pairchallenge_answers_only_a_pinned_client() {
async fn challenge(
st: &Arc<AppState>,
peer: Option<Extension<PeerCertFingerprint>>,
) -> String {
let q = HashMap::from([("phrase".to_string(), "pairchallenge".to_string())]);
let resp = h_pair(State(st.clone()), peer, Query(q))
.await
.into_response();
let b = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
String::from_utf8(b.to_vec()).unwrap()
}
let st = test_state();
let der = b"pairchallenge-client-der".to_vec();
let peer = Some(Extension(PeerCertFingerprint(Some(fp_of(&der)))));
// Plain HTTP (no cert) and an unpinned HTTPS cert both answer as an unpaired host.
let plain = challenge(&st, None).await;
assert!(plain.contains("<paired>0</paired>"), "plain HTTP: {plain}");
let unpinned = challenge(&st, peer.clone()).await;
assert!(
unpinned.contains("<paired>0</paired>"),
"unpinned cert: {unpinned}"
);
// Once phase 4 has pinned the cert, the real client's last step still succeeds.
st.paired.lock().unwrap().push(der);
let pinned = challenge(&st, peer).await;
assert!(pinned.contains("<paired>1</paired>"), "pinned: {pinned}");
}
#[test]
fn gamestream_admission_policy_matrix() {
use crate::vdisplay::policy::ModeConflict;
@@ -16,7 +16,7 @@ use rsa::sha2::Sha256;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the operator submits it
@@ -28,7 +28,9 @@ use tokio::sync::Notify;
const MAX_PARKED_WAITERS: usize = 4;
pub struct PinGate {
pin: Mutex<Option<String>>,
/// The submitted PIN and when it was submitted — a PIN no handshake consumed within the
/// pairing window is discarded rather than held for the next one ([`PinGate::take`]).
pin: Mutex<Option<(String, Instant)>>,
notify: Notify,
/// Handshakes currently parked in [`take`](Self::take) — drives the management API's
/// `pin_pending` so a control pane knows when to prompt for the PIN.
@@ -62,7 +64,7 @@ impl PinGate {
);
return false;
}
*self.pin.lock().unwrap() = Some(pin);
*self.pin.lock().unwrap() = Some((pin, Instant::now()));
self.notify.notify_waiters();
true
}
@@ -98,8 +100,18 @@ impl PinGate {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(p) = self.pin.lock().unwrap().take() {
return Some(p);
// A PIN must not outlive the handshake it was typed for: the slot is global and
// unbound, so a PIN submitted with no handshake left to consume it (the submitter's
// waiter died between the management API's `awaiting_pin` check and the submit) used
// to sit here indefinitely and authenticate whoever knocked next — a PIN typed for
// one device pairing another (security-review 2026-08-25). Its shelf life is the same
// pairing window a handshake parks for, and a real client is ALREADY parked when the
// operator submits, so it takes the PIN within microseconds — never near this bound.
if let Some((p, at)) = self.pin.lock().unwrap().take() {
if at.elapsed() < timeout {
return Some(p);
}
tracing::warn!("pairing: discarding a PIN no handshake consumed in time");
}
if tokio::time::timeout_at(deadline, self.notify.notified())
.await
@@ -376,6 +388,20 @@ mod tests {
assert!(!pairing.pin.awaiting_pin());
}
/// A PIN nobody consumed must NOT authenticate the next handshake to arrive: the slot is
/// global and unbound, so a PIN typed for a device that gave up would otherwise pair whoever
/// knocked afterwards (security-review 2026-08-25).
#[tokio::test]
async fn unconsumed_pin_is_discarded() {
let pairing = Pairing::new();
pairing.pin.submit("1234".into()); // nothing parked — nobody takes it
tokio::time::sleep(Duration::from_millis(30)).await;
// Older than the window this handshake parks for: discarded, not handed over…
assert_eq!(pairing.pin.take(Duration::from_millis(5)).await, None);
// …and gone for good, so the handshake after it doesn't inherit it either.
assert_eq!(pairing.pin.take(Duration::from_millis(5)).await, None);
}
/// A pre-auth peer flood can park at most `MAX_PARKED_WAITERS` pairing handshakes; the next
/// `take` is refused immediately (returns `None` without parking), bounding the 300s-waiter DoS
/// (security-review 2026-06-28 #12).
+45 -7
View File
@@ -17,17 +17,22 @@ use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
const PING_PAYLOAD: &str = "0011223344556677";
// The RTSP listener is UNAUTHENTICATED (no TLS/pairing) and one-thread-per-connection, so bound
// every attacker-controllable dimension to deny a pre-auth slow-loris / memory-growth DoS: a hard
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, and
// size caps on the request headers + body (real GameStream RTSP messages are a few hundred bytes).
// cap on concurrent connections, a per-read timeout so a stalled peer can't pin a thread, a
// whole-request deadline so a dribbling one can't either, and size caps on the request headers +
// body (real GameStream RTSP messages are a few hundred bytes).
const MAX_RTSP_CONNS: usize = 8;
const RTSP_READ_TIMEOUT: Duration = Duration::from_secs(15);
// The per-read timeout bounds ONE read, not the request: a peer sending a byte just inside it
// resets the timeout forever and holds one of the eight slots for good. This bounds the whole
// message instead (security-review 2026-08-25) — a real client's request arrives in one segment.
const RTSP_REQUEST_DEADLINE: Duration = Duration::from_secs(30);
const MAX_RTSP_HEADER: usize = 16 * 1024;
const MAX_RTSP_BODY: usize = 64 * 1024;
const MAX_RTSP_MSG: usize = 128 * 1024;
@@ -90,13 +95,15 @@ struct Request {
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
let peer = stream.peer_addr().ok();
// A per-read timeout so a stalled/slow-loris peer can't pin this thread indefinitely.
// A per-read timeout so a stalled peer can't pin this thread, plus the whole-request
// deadline `read_message` enforces — a slow-loris defeats the first with the second absent.
let _ = stream.set_read_timeout(Some(RTSP_READ_TIMEOUT));
let deadline = Instant::now() + RTSP_REQUEST_DEADLINE;
let mut buf: Vec<u8> = Vec::new();
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
// response until EOF, so we answer one message and close the connection (which signals
// the end of the response). Session state lives in `AppState`, not the connection.
if let Some(req) = read_message(&mut stream, &mut buf)? {
if let Some(req) = read_message(&mut stream, &mut buf, deadline)? {
tracing::debug!(
method = %req.method,
cseq = %req.cseq,
@@ -114,9 +121,17 @@ fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
}
/// Read one complete RTSP message (headers + any Content-Length body) from the stream,
/// buffering across reads and leaving any pipelined remainder in `buf`.
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
/// buffering across reads and leaving any pipelined remainder in `buf`. Gives up at `deadline`
/// (the caller's whole-request budget), which the per-read timeout alone does not bound.
fn read_message(
stream: &mut TcpStream,
buf: &mut Vec<u8>,
deadline: Instant,
) -> Result<Option<Request>> {
loop {
if Instant::now() >= deadline {
anyhow::bail!("RTSP request deadline exceeded");
}
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
// Cap the header section even when the terminator IS present (a single oversized header
// block that fits a `\r\n\r\n` would otherwise skip the no-terminator cap below).
@@ -674,6 +689,29 @@ mod tests {
parse_announce(&body)
}
/// The listener is unauthenticated, so a request that never finishes must not hold one of the
/// eight connection slots: `read_message` gives up at the caller's deadline instead of resetting
/// its per-read timeout forever. An already-passed deadline is the deterministic stand-in for a
/// peer dribbling bytes — it bails without waiting on a socket that will never send.
#[test]
fn read_message_gives_up_at_the_request_deadline() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback");
let addr = listener.local_addr().expect("local addr");
let client = TcpStream::connect(addr).expect("connect"); // stays open, sends nothing
let (mut server, _) = listener.accept().expect("accept");
let mut buf: Vec<u8> = Vec::new();
// `Request` isn't `Debug`, so match rather than `expect_err`.
let err = match read_message(&mut server, &mut buf, Instant::now()) {
Err(e) => e,
Ok(_) => panic!("an elapsed deadline must end the request"),
};
assert!(
format!("{err:#}").contains("deadline"),
"unexpected error: {err:#}"
);
drop(client);
}
/// `x-nv-vqos[0].bitStreamFormat` → codec (0=H264, 1=HEVC, 2=AV1; missing = H264).
#[test]
fn announce_codec_selection() {
+11 -11
View File
@@ -289,7 +289,7 @@ fn run(
)];
let _prep = (!prep_cmds.is_empty()).then(|| crate::hooks::run_prep(&prep_cmds, &prep_env));
// Open the virtual-display source: pick the live compositor, normalize the session env
// (apply_session_env/apply_input_env — gamescope ATTACH/resize + KWin/Mutter retargeting,
// (apply_session_env + input/gamescope routing — ATTACH/resize + KWin/Mutter retargeting,
// exactly like the native plane), create a virtual output at the client mode, and capture it.
// Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a
// Desktop<->Game switch.
@@ -657,9 +657,9 @@ fn open_gs_mirror_source(
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
// A mirror streams an existing head — no gamescope sub-mode applies, so the resolved route is
// deliberately dropped here rather than carried.
let _ = crate::vdisplay::apply_input_env(compositor, false);
// Point input at the same backend the video landed on. A mirror streams an existing head, so no
// gamescope sub-mode applies and no route is resolved here at all.
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(compositor));
let mut vd = crate::vdisplay::open_mirror(compositor, connector)?;
// Cursor mode is the session's negotiated one: metadata where this encode path composites
// `frame.cursor`, otherwise let the compositor embed it (§7.5 — one resolver, per-backend
@@ -748,7 +748,7 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option<GsApp> {
}
/// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize
/// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter
/// the session env (apply_session_env + input/gamescope routing — ATTACH/resize, KWin/Mutter
/// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual
/// output at the client's mode, and capture it. Returns the capturer (it owns the output's keepalive;
/// the stateless VirtualDisplay factory is dropped here) plus the resolved compositor. An apps.json
@@ -817,16 +817,16 @@ fn open_gs_virtual_source(
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
let has_launch = launch.and_then(|t| t.command.as_deref()).is_some();
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
let r =
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
(crate::vdisplay::Compositor::Gamescope, r)
let c = crate::vdisplay::Compositor::Gamescope;
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
(c, crate::vdisplay::resolve_gamescope_route(c, true))
} else {
let c = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok)
.unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?;
let r = crate::vdisplay::apply_input_env(c, false);
(c, r)
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
(c, crate::vdisplay::resolve_gamescope_route(c, false))
}
}
};
@@ -849,7 +849,7 @@ fn open_gs_virtual_source(
// interactive-session spawner launches it by id instead.
vd.set_launch_command(launch.and_then(|t| t.command.clone()));
// This plane's resolved gamescope sub-mode, on the instance for the same reason as the launch
// command above — the GameStream and native planes both call `apply_input_env`, so publishing
// command above — the GameStream and native planes both resolve a route, so publishing
// through the process env let either retarget the other's `create`.
vd.set_gamescope_route(gamescope_route.clone());
// Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream
+317 -58
View File
@@ -23,8 +23,11 @@
//! firings are dropped with a warning, never queued unboundedly), per-hook `debounce_ms`, the
//! exec timeout + process-group kill. Trust model (RFC §9.1): `hooks.json` is
//! operator-privileged config in the DACL'd/0700 config dir; before executing a hook whose
//! command is a script *path*, the host verifies the file is owned by the operator (or root)
//! and not group/world-writable — the sshd/sudoers rule — and refuses loudly otherwise.
//! command is a script *path*, the host verifies that file — and every directory above it — is
//! owned by the operator (or root) and not group/world-writable — the sshd/sudoers rule — and
//! refuses loudly otherwise. Log lines name a hook by [`cmd_label`]/[`webhook_origin`], never by
//! its raw command line or URL: the tracing ring they land in is served over `GET /api/v1/logs`,
//! and a webhook path segment (Slack, Discord, ntfy, Teams) *is* the bearer credential.
use anyhow::Result;
use serde::{Deserialize, Serialize};
@@ -159,11 +162,17 @@ impl HooksConfig {
// Warn rather than reject (an internal-only `http://` receiver may be intentional).
if h.hmac_secret_file.is_some() && url.starts_with("http://") {
tracing::warn!(
%url,
url = %webhook_origin(url),
"webhook has an hmac_secret_file but is http:// — the signed body is sent in cleartext; prefer https://"
);
}
}
if let Some(p) = h.hmac_secret_file.as_deref() {
if let Some(why) = secret_file_complaint(p) {
tracing::warn!(path = %p.display(),
"webhook hmac_secret_file is {why} — it should be operator-owned and chmod 600");
}
}
if h.timeout_s == 0 || h.timeout_s > MAX_TIMEOUT_S {
return Err(at(&format!("`timeout_s` must be 1{MAX_TIMEOUT_S}")));
}
@@ -172,6 +181,33 @@ impl HooksConfig {
}
}
/// The documented `hmac_secret_file` hygiene check (see [`HookEntry::hmac_secret_file`]): the
/// secret should be operator-owned and private. Returns the complaint to warn about, `None` when
/// the file is fine (or absent — an unreadable secret is [`post_webhook`]'s fail-closed case, not
/// this one's). A warning, not a refusal: the operator asked for signing, and refusing here would
/// silently drop it.
#[cfg(unix)]
fn secret_file_complaint(path: &std::path::Path) -> Option<String> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
if meta.uid() != euid && meta.uid() != 0 {
return Some(format!(
"owned by uid {} (host runs as uid {euid})",
meta.uid()
));
}
(meta.mode() & 0o077 != 0)
.then(|| format!("group/world-accessible (mode {:o})", meta.mode() & 0o7777))
}
/// Windows: the SYSTEM/Admins-DACL'd config dir is the boundary (as for [`exec_path_check`]).
#[cfg(not(unix))]
fn secret_file_complaint(_path: &std::path::Path) -> Option<String> {
None
}
// ------------------------------------------------------------------------- store
/// The persisted hooks store — the [`crate::vdisplay::policy::DisplayPolicyStore`] recipe:
@@ -365,25 +401,53 @@ fn dispatch(
// ------------------------------------------------------------------------- exec action
/// Short, stable id for one hook action (`#1a2b3c4d`) — all a log line keeps of the part that can
/// carry a secret. The same id on every line about one firing, a different one for two hooks that
/// share a program or a webhook host, so an operator can still tell which fired. Process-lifetime
/// stable, like [`entry_key`]'s hash.
fn short_id(s: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut hasher);
format!("#{:08x}", hasher.finish() as u32)
}
/// What the `cmd` field of a log line carries about an operator command: the program's file name
/// plus its [`short_id`]. The arguments are dropped — a hook command line carries API tokens
/// (`curl -H "Authorization: …"`) as readily as a webhook URL does, and these lines land in the
/// tracing ring `GET /api/v1/logs` serves verbatim (security review 2026-08-24). A refusal still
/// names the offending *path*, through the [`exec_path_check`] error — that is what the operator
/// needs in order to fix it.
fn cmd_label(cmd: &str) -> String {
let prog = cmd
.split_whitespace()
.next()
.unwrap_or_default()
.trim_matches(['"', '\'']);
let file = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
format!("{file} {}", short_id(cmd))
}
fn fire_exec(
cmd: String,
ev: &crate::events::HostEvent,
timeout_s: u32,
sem: &std::sync::Arc<tokio::sync::Semaphore>,
) {
let label = cmd_label(&cmd);
let Ok(permit) = sem.clone().try_acquire_owned() else {
tracing::warn!(cmd = %cmd, "hook dropped — too many hook executions in flight");
tracing::warn!(cmd = %label, "hook dropped — too many hook executions in flight");
return;
};
if let Err(e) = exec_path_check(&cmd) {
tracing::error!(cmd = %cmd, "REFUSING hook command — {e}");
tracing::error!(cmd = %label, "REFUSING hook command — {e}");
return;
}
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
let env = flatten_env(ev);
let kind = ev.kind.name();
let timeout = Duration::from_secs(u64::from(timeout_s));
tracing::info!(cmd = %cmd, kind, "hook: running command");
tracing::info!(cmd = %label, kind, "hook: running command");
// Detached execution + off-thread reap (the `try_recover_session` recipe): the streaming
// planes never wait on operator code. The permit rides along and frees on thread exit.
std::thread::spawn(move || {
@@ -434,7 +498,9 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
/// The sshd/sudoers rule (RFC §9.1): refuse to run a command that references a script/binary which
/// is group/world-writable, or owned by neither the host user nor root — a world-writable hook
/// script is privilege-escalation bait. A bare command name (`systemctl`, `curl`) is left to PATH.
/// script is privilege-escalation bait. The same rule covers every directory above the script: a
/// writable parent is the same bait one level up, since whoever may rename an entry in it chooses
/// what runs. A bare command name (`systemctl`, `curl`) is left to PATH.
///
/// **This is a hygiene rule, not an authorization gate**, and the distinction matters: it
/// constrains *who owns the file being run*, never *what the command does*. `curl … | sh` and
@@ -449,43 +515,95 @@ fn flatten_env(ev: &crate::events::HostEvent) -> Vec<(String, String)> {
/// which is backwards: the script is the part an attacker can plant.
#[cfg(unix)]
fn exec_path_check(cmd: &str) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
if cmd.split_whitespace().next().is_none() {
let tokens = shell_tokens(cmd);
if tokens.is_empty() {
return Err("empty command".into());
}
// SAFETY: geteuid has no preconditions and touches no memory.
let euid = unsafe { libc::geteuid() };
for raw in cmd.split_whitespace() {
// Tolerate the quoting a hand-written command line carries — a path that is absolute only
// after unquoting is exactly as plantable as a bare one.
let token = raw.trim_matches(|c| c == '"' || c == '\'');
for token in &tokens {
if !token.starts_with('/') {
continue;
}
let meta = match std::fs::metadata(token) {
Ok(m) => m,
Err(_) => continue, // not an existing file — the shell will report it
};
if !meta.is_file() {
continue;
let path = std::path::Path::new(token);
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
continue; // not an existing file — the shell will report it
}
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{token} is owned by uid {} (host runs as uid {euid}) — hook scripts must be \
owned by the operator or root",
meta.uid()
));
}
if meta.mode() & 0o022 != 0 {
return Err(format!(
"{token} is group/world-writable (mode {:o}) — chmod go-w it first",
meta.mode() & 0o7777
));
// The script, then every directory up to `/`: an unchecked writable parent lets the
// attacker swap a perfectly-owned script out from under us, which is why sshd walks the
// whole chain rather than stat'ing the file alone.
for node in path.ancestors() {
let Ok(meta) = std::fs::metadata(node) else {
continue;
};
path_node_check(node, &meta, euid)?;
}
}
Ok(())
}
/// The ownership/mode rule [`exec_path_check`] applies to the script and to each directory above
/// it. A world-writable *directory* with the sticky bit set (`/tmp`) passes: there only an entry's
/// own owner can replace it, so the swap this rule exists to block is already impossible.
#[cfg(unix)]
fn path_node_check(
path: &std::path::Path,
meta: &std::fs::Metadata,
euid: u32,
) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
if meta.uid() != euid && meta.uid() != 0 {
return Err(format!(
"{} is owned by uid {} (host runs as uid {euid}) — a hook script and the directories \
holding it must be owned by the operator or root",
path.display(),
meta.uid()
));
}
let sticky_dir = meta.is_dir() && meta.mode() & 0o1000 != 0;
if meta.mode() & 0o022 != 0 && !sticky_dir {
return Err(format!(
"{} is group/world-writable (mode {:o}) — chmod go-w it first",
path.display(),
meta.mode() & 0o7777
));
}
Ok(())
}
/// Split a command line into tokens the way `/bin/sh` would *for the purpose of finding paths*:
/// whitespace separates, but a quoted or backslash-escaped run stays one token. Plain
/// `split_whitespace` turned `"/opt/my hooks/run.sh"` into two nonexistent tokens, so the check
/// above silently passed the case it most needs to catch — a script the shell really does run, at
/// a path with a space in it (security review 2026-08-24).
#[cfg(unix)]
fn shell_tokens(cmd: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut chars = cmd.chars();
while let Some(c) = chars.next() {
match quote {
Some(q) if c == q => quote = None,
// Inside '' a backslash is literal; inside "" and unquoted it escapes the next char.
Some('"') if c == '\\' => cur.extend(chars.next()),
Some(_) => cur.push(c),
None if c == '\\' => cur.extend(chars.next()),
None if c == '"' || c == '\'' => quote = Some(c),
None if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
None => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
/// Whether this process is running as `NT AUTHORITY\SYSTEM` (S-1-5-18) — i.e. as the SCM service
/// rather than as the operator's own console process.
///
@@ -591,6 +709,7 @@ fn run_hook_process(
) -> bool {
use std::io::Write;
use std::os::unix::process::CommandExt;
let label = cmd_label(cmd);
let mut c = std::process::Command::new("/bin/sh");
c.arg("-c")
.arg(cmd)
@@ -603,7 +722,7 @@ fn run_hook_process(
let mut child = match c.spawn() {
Ok(ch) => ch,
Err(e) => {
tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch");
tracing::error!(cmd = %label, error = %e, "hook command failed to launch");
return false;
}
};
@@ -616,13 +735,13 @@ fn run_hook_process(
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
tracing::warn!(cmd = %cmd, %status, "hook command exited non-zero");
tracing::warn!(cmd = %label, %status, "hook command exited non-zero");
}
return status.success();
}
Ok(None) => {
if Instant::now() >= deadline {
tracing::warn!(cmd = %cmd, timeout_s = timeout.as_secs(),
tracing::warn!(cmd = %label, timeout_s = timeout.as_secs(),
"hook command timed out — killing its process group");
#[cfg(target_os = "linux")]
{
@@ -638,7 +757,7 @@ fn run_hook_process(
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
tracing::warn!(cmd = %cmd, error = %e, "hook command wait failed");
tracing::warn!(cmd = %label, error = %e, "hook command wait failed");
return false;
}
}
@@ -658,6 +777,7 @@ fn run_hook_process(
timeout: Duration,
) -> bool {
use std::io::Write;
let label = cmd_label(cmd);
let stamp = format!(
"pf-hook-{}-{}.json",
std::process::id(),
@@ -668,12 +788,12 @@ fn run_hook_process(
);
let json_path = std::env::temp_dir().join(stamp);
if std::fs::write(&json_path, event_json).is_err() {
tracing::warn!(cmd = %cmd, "hook: could not write event JSON temp file");
tracing::warn!(cmd = %label, "hook: could not write event JSON temp file");
}
let cmdline = format!("{cmd} \"{}\"", json_path.display());
match crate::interactive::spawn_in_active_session(&cmdline, None) {
Ok(pid) => {
tracing::debug!(cmd = %cmd, pid, "hook command launched in the interactive session");
tracing::debug!(cmd = %label, pid, "hook command launched in the interactive session");
// No child handle on this path — wait out the timeout, then clean the temp file.
std::thread::sleep(timeout);
let _ = std::fs::remove_file(&json_path);
@@ -696,7 +816,7 @@ fn run_hook_process(
// there is no user there is nothing to run them as. A hook that must run without a
// logged-in user belongs in a service, not here.
tracing::warn!(
cmd = %cmd,
cmd = %label,
error = %format!("{e:#}"),
"hook SKIPPED: no interactive user session to run it in, and this host is SYSTEM — \
hooks run as the logged-in user by design and are never elevated to SYSTEM"
@@ -731,7 +851,7 @@ fn run_hook_process(
break;
}
None if Instant::now() >= deadline => {
tracing::warn!(cmd = %cmd, "hook command timed out — killing it");
tracing::warn!(cmd = %label, "hook command timed out — killing it");
let _ = child.kill();
let _ = child.wait();
break;
@@ -740,7 +860,9 @@ fn run_hook_process(
}
}
}
Err(e) => tracing::error!(cmd = %cmd, error = %e, "hook command failed to launch"),
Err(e) => {
tracing::error!(cmd = %label, error = %e, "hook command failed to launch")
}
}
let _ = std::fs::remove_file(&json_path);
ok
@@ -756,13 +878,14 @@ fn fire_webhook(
ev: &crate::events::HostEvent,
sem: &std::sync::Arc<tokio::sync::Semaphore>,
) {
let origin = webhook_origin(&url);
let Ok(permit) = sem.clone().try_acquire_owned() else {
tracing::warn!(url = %url, "webhook dropped — too many hook executions in flight");
tracing::warn!(url = %origin, "webhook dropped — too many hook executions in flight");
return;
};
let json = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
let kind = ev.kind.name();
tracing::info!(url = %url, kind, "hook: posting webhook");
tracing::info!(url = %origin, kind, "hook: posting webhook");
std::thread::spawn(move || {
post_webhook(&url, &json, secret_file.as_deref());
drop(permit);
@@ -777,13 +900,7 @@ fn fire_webhook(
/// legitimate self-hosting config. A best-effort textual + IP-literal check (no DNS resolution, so
/// not a full anti-rebinding defense; the operator-gated config already limits the threat).
fn webhook_host_is_internal(url: &str) -> bool {
// scheme://[userinfo@]host[:port]/... → the bare host.
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
let hostport = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let hostport = webhook_authority(url);
let host = if let Some(rest) = hostport.strip_prefix('[') {
rest.split(']').next().unwrap_or("") // [::1]:443 → ::1
} else {
@@ -808,7 +925,30 @@ fn webhook_host_is_internal(url: &str) -> bool {
}
}
/// `scheme://[userinfo@]host[:port]/...` → the bare `host[:port]`. Textual, no DNS.
fn webhook_authority(url: &str) -> &str {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or("");
authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority)
}
/// What the `url` field of a log line carries about a webhook: `scheme://host[:port]` plus the
/// URL's [`short_id`]. The path, query and any userinfo are dropped — for Slack, Discord, ntfy,
/// Teams, Zapier and Home Assistant the token IS a path segment, and these lines land in the
/// tracing ring `GET /api/v1/logs` serves verbatim (security review 2026-08-24).
fn webhook_origin(url: &str) -> String {
let scheme = url
.split_once("://")
.map(|(s, _)| format!("{s}://"))
.unwrap_or_default();
format!("{scheme}{} {}", webhook_authority(url), short_id(url))
}
fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
let origin = webhook_origin(url);
// TLS is verified (ureq's default rustls roots); redirects are never followed, so a
// compromised receiver can't bounce the POST cross-origin (RFC §9.5).
let agent: ureq::Agent = ureq::Agent::config_builder()
@@ -843,11 +983,13 @@ fn post_webhook(url: &str, json: &str, secret_file: Option<&std::path::Path>) {
}
}
match req.send(json) {
Ok(resp) => tracing::debug!(url, status = resp.status().as_u16(), "webhook delivered"),
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(url, status = code, "webhook rejected by receiver")
Ok(resp) => {
tracing::debug!(url = %origin, status = resp.status().as_u16(), "webhook delivered")
}
Err(e) => tracing::warn!(url, error = %e, "webhook delivery failed"),
Err(ureq::Error::StatusCode(code)) => {
tracing::warn!(url = %origin, status = code, "webhook rejected by receiver")
}
Err(e) => tracing::warn!(url = %origin, error = %e, "webhook delivery failed"),
}
}
@@ -890,17 +1032,18 @@ pub fn run_prep(cmds: &[PrepCmd], env: &[(String, String)]) -> PrepGuard {
if cmd.is_empty() {
continue;
}
let label = cmd_label(cmd);
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep command — {e}");
tracing::error!(cmd = %label, "REFUSING prep command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running");
tracing::info!(cmd = %label, "prep: running");
if run_hook_process(cmd, "{}", env, timeout) {
if let Some(u) = c.undo.as_deref().filter(|u| !u.trim().is_empty()) {
undo.push(u.to_string());
}
} else if c.undo.is_some() {
tracing::warn!(cmd = %cmd, "prep step failed — its undo is skipped");
tracing::warn!(cmd = %label, "prep step failed — its undo is skipped");
}
}
PrepGuard {
@@ -922,11 +1065,12 @@ impl Drop for PrepGuard {
// the one thread runs them sequentially.
std::thread::spawn(move || {
for cmd in undo.iter().rev() {
let label = cmd_label(cmd);
if let Err(e) = exec_path_check(cmd) {
tracing::error!(cmd = %cmd, "REFUSING prep undo command — {e}");
tracing::error!(cmd = %label, "REFUSING prep undo command — {e}");
continue;
}
tracing::info!(cmd = %cmd, "prep: running undo");
tracing::info!(cmd = %label, "prep: running undo");
run_hook_process(cmd, "{}", &env, timeout);
}
});
@@ -1267,4 +1411,119 @@ mod tests {
assert!(exec_path_check("systemctl suspend").is_ok());
assert!(exec_path_check("/nonexistent/definitely-not-here").is_ok());
}
/// The two holes the check used to have: a path with a space in it (whitespace splitting made
/// the check a no-op for exactly the paths the shell still runs), and a writable directory
/// above an otherwise-fine script.
#[cfg(unix)]
#[test]
fn ownership_check_sees_quoted_paths_and_writable_parents() {
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::Permissions::from_mode;
let dir = std::env::temp_dir().join(format!(
"pf-hook-parent-{}-{:p}",
std::process::id(),
&0u8 as *const u8
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, mode(0o755)).unwrap();
let script = dir.join("my hook.sh");
std::fs::write(&script, "#!/bin/sh\ntrue\n").unwrap();
std::fs::set_permissions(&script, mode(0o700)).unwrap();
let quoted = format!("\"{}\" arg", script.display());
assert!(exec_path_check(&quoted).is_ok(), "a sane quoted path runs");
std::fs::set_permissions(&script, mode(0o777)).unwrap();
assert!(
exec_path_check(&quoted).is_err(),
"world-writable script behind a quoted, space-bearing path must be refused"
);
assert!(
exec_path_check(&format!("'{}'", script.display())).is_err(),
"single quotes too"
);
assert!(
exec_path_check(&script.display().to_string().replace(' ', "\\ ")).is_err(),
"backslash-escaped spaces too"
);
// A writable parent defeats a perfectly-owned script — the attacker replaces the file.
std::fs::set_permissions(&script, mode(0o700)).unwrap();
std::fs::set_permissions(&dir, mode(0o777)).unwrap();
let err = exec_path_check(&quoted).expect_err("world-writable parent must be refused");
assert!(err.contains(&dir.display().to_string()), "names it: {err}");
std::fs::set_permissions(&dir, mode(0o755)).unwrap();
assert!(exec_path_check(&quoted).is_ok(), "chmod go-w fixes it");
let _ = std::fs::remove_dir_all(&dir);
}
/// The `hmac_secret_file` warning the field doc promises.
#[cfg(unix)]
#[test]
fn secret_file_permissions_are_complained_about() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"pf-hook-secret-{}-{:p}.key",
std::process::id(),
&0u8 as *const u8
));
std::fs::write(&path, b"s3cret").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(secret_file_complaint(&path).is_none(), "0600 is the ask");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let why = secret_file_complaint(&path).expect("a world-readable secret is warned about");
assert!(why.contains("644"), "the complaint names the mode: {why}");
// Missing/unreadable is post_webhook's fail-closed case, not a permissions complaint.
std::fs::remove_file(&path).unwrap();
assert!(secret_file_complaint(&path).is_none());
}
/// `GET /api/v1/logs` serves the tracing ring verbatim, so no log line may carry a webhook URL's
/// path (the bearer credential for Slack/Discord/ntfy) or a command's arguments — while still
/// saying which hook fired.
#[test]
fn log_labels_drop_the_credential_and_stay_identifiable() {
let slack = "https://hooks.slack.com/services/T0000/B0000/XXXXsecretXXXX";
let shown = webhook_origin(slack);
assert!(
shown.starts_with("https://hooks.slack.com "),
"origin: {shown}"
);
assert!(!shown.contains("XXXXsecretXXXX"), "token dropped: {shown}");
assert_ne!(
shown,
webhook_origin("https://hooks.slack.com/services/T1/B1/OTHER"),
"two hooks to one host stay distinguishable"
);
// userinfo, path and query all go; the port stays (it names the receiver, not the secret).
let creds = webhook_origin("https://user:pw@ha.local:8123/api/webhook/zzz?token=qqq");
assert!(creds.starts_with("https://ha.local:8123 "), "{creds}");
for secret in ["pw", "zzz", "qqq"] {
assert!(!creds.contains(secret), "{secret} leaked: {creds}");
}
// Command lines: the program's file name survives, its arguments don't.
let cmd = "/usr/local/bin/notify.sh --token=SEKRIT-zz 'Living Room'";
let label = cmd_label(cmd);
assert!(
label.starts_with("notify.sh #"),
"says which script ran: {label}"
);
assert!(!label.contains("SEKRIT"), "arguments dropped: {label}");
assert_eq!(
label,
cmd_label(cmd),
"stable across one firing's log lines"
);
assert_ne!(
label,
cmd_label("/usr/local/bin/notify.sh --token=SEKRIT-yy")
);
assert!(cmd_label("curl -H \"Authorization: Bearer t\" https://x/y").starts_with("curl #"));
}
}
+97 -17
View File
@@ -238,10 +238,23 @@ pub(crate) fn source_id_for(e: &CustomEntry) -> Option<&str> {
/// The stored entry a full **library id** refers to, or `None`. The art proxy resolves *any* id this
/// way before falling back to the legacy per-store branches (WP1.2), which is what lets a plugin's
/// entries be served regardless of what their ids look like.
///
/// Gated on what the library actually collects ([`super::collect_games`]), so a source the operator
/// switched OFF resolves to nothing here either. It used to resolve straight out of the catalog,
/// which let `GET /library/art/<id>/<kind>` — on the paired-cert allowlist — serve covers for
/// entries `GET /library` had already filtered away (security review 2026-08-25).
///
/// The per-entry **hide** is deliberately not applied: the console draws a hidden title's (dimmed)
/// cover so the operator can bring it back, and this resolver cannot see the caller's lane. Keeping
/// a hidden title's art off the paired-cert lane is a check for the route, which can.
pub fn entry_for_library_id(library_id: &str) -> Option<CustomEntry> {
load_custom()
let entry = load_custom()
.into_iter()
.find(|e| library_id_for(e) == library_id)
.find(|e| library_id_for(e) == library_id)?;
super::collect_games()
.iter()
.any(|g| g.id == library_id)
.then_some(entry)
}
/// Serve a stored entry's **local** art file for one [`ArtKind`] — the `library.json` branch of the
@@ -374,8 +387,9 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
// ------------------------------------------------------------------ providers (RFC §8)
/// The **operator-privileged field** set in a library payload, if the payload carries one — the
/// fields whose contents the host later executes as the host user.
/// The **operator-privileged field** in a library payload, if the payload carries one: `prep`, or a
/// launch kind that is not on [`UNPRIVILEGED_LAUNCH_KINDS`] — the fields whose contents the host
/// could later execute as the host user.
///
/// `prep` is run by [`crate::hooks::run_prep`] through `/bin/sh -c`, and a `command` launch is run
/// through `/bin/sh -c` (Linux) or `cmd.exe /c` (Windows). Both are documented at their execution
@@ -386,10 +400,8 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
/// Every launch kind on [`UNPRIVILEGED_LAUNCH_KINDS`] stays open to every lane — a provider plugin
/// can still publish its whole catalogue, it just cannot hand the host a program to run.
pub fn privileged_field(
launch: Option<&LaunchSpec>,
prep: &[crate::hooks::PrepCmd],
@@ -397,12 +409,39 @@ pub fn privileged_field(
if !prep.is_empty() {
return Some("prep");
}
if launch.is_some_and(|l| l.kind == "command") {
return Some("launch.kind = \"command\"");
if launch.is_some_and(|l| !UNPRIVILEGED_LAUNCH_KINDS.contains(&l.kind.as_str())) {
return Some("launch.kind");
}
None
}
/// The launch kinds any lane may publish: the host owns the whole command line and builds it from a
/// value it validates per kind (`launch.rs`), so the entry NAMES a title rather than carrying a
/// program to run. `plugin` is here for a different reason — its command is never stored at all: the
/// host asks the live plugin for one at launch time ([`crate::library::ask_plugin_launch`]), so the
/// stored entry on its own executes nothing.
///
/// An **allowlist**, deliberately, and the reason [`privileged_field`] reads the way round it does:
/// a kind added to `launch.rs` and forgotten here is operator-only until someone lists it on
/// purpose, which is the safe way to be wrong. It used to be a two-entry blocklist, and `gog` — an
/// exe plus arguments — sat outside it as a standing exec primitive for the plugin lane (security
/// review 2026-08-25). `gog` is listed now because `launch::gog_spawn` confines its exe to a GOG
/// install the host enumerates itself; `command` never is, because `cmd.exe /c` / `sh -c` is the
/// primitive this whole gate exists to withhold.
const UNPRIVILEGED_LAUNCH_KINDS: &[&str] = &[
"steam_appid",
"steam_ui",
"launcher_ui",
"lutris_id",
"heroic",
"epic",
"gog",
"aumid",
"xbox",
"playnite",
"plugin",
];
/// Provider ids are path segments, event sources, and console labels: keep them tame.
/// `manual` is reserved (it is the no-provider sentinel in `library.changed`).
pub fn validate_provider_name(provider: &str) -> Result<(), String> {
@@ -1120,9 +1159,9 @@ mod tests {
);
}
/// The field-authority rule behind the 2026-08-05 review's H-1: exactly the two fields the host
/// later hands to a shell are operator-only. Everything else — including every host-resolved
/// launch kind — stays open, so a provider plugin can publish its whole catalogue.
/// The field-authority rule behind the 2026-08-05 review's H-1: the fields the host later hands
/// to a shell are operator-only. Every host-resolved launch kind stays open, so a provider
/// plugin can publish its whole catalogue.
#[test]
fn privileged_field_is_command_execution_only() {
let cmd = LaunchSpec {
@@ -1138,10 +1177,7 @@ mod tests {
undo: None,
}];
assert_eq!(
privileged_field(Some(&cmd), &[]),
Some("launch.kind = \"command\"")
);
assert_eq!(privileged_field(Some(&cmd), &[]), Some("launch.kind"));
assert_eq!(privileged_field(None, &prep), Some("prep"));
assert_eq!(privileged_field(Some(&steam), &prep), Some("prep"));
// The ordinary provider catalogue: nothing privileged, so no lane is refused.
@@ -1149,6 +1185,50 @@ mod tests {
assert_eq!(privileged_field(None, &[]), None);
}
/// The gate is an ALLOWLIST, and this is the test that keeps it one: a launch kind nobody listed
/// is operator-privileged, so a kind added to `launch.rs` and forgotten in
/// `UNPRIVILEGED_LAUNCH_KINDS` fails closed instead of shipping as an open primitive. `gog` was
/// exactly that miss — an exe plus arguments that the two-entry blocklist never named (security
/// review 2026-08-25).
///
/// The listed set is pinned as well as the rule, so WIDENING it is a deliberate edit to this
/// test rather than a line that slips through in someone's diff.
#[test]
fn an_unlisted_launch_kind_is_operator_privileged() {
let kind = |k: &str| {
let spec = LaunchSpec {
kind: k.into(),
value: "x".into(),
};
privileged_field(Some(&spec), &[])
};
assert_eq!(
UNPRIVILEGED_LAUNCH_KINDS,
&[
"steam_appid",
"steam_ui",
"launcher_ui",
"lutris_id",
"heroic",
"epic",
"gog",
"aumid",
"xbox",
"playnite",
"plugin",
],
"widening this set hands the plugin lane a new launch kind — do it on purpose"
);
for k in UNPRIVILEGED_LAUNCH_KINDS {
assert_eq!(kind(k), None, "`{k}` is on the allowlist");
}
// A kind this host has never heard of — a newer host's vocabulary, or a plugin's invention.
assert_eq!(kind("brand_new_store"), Some("launch.kind"));
assert_eq!(kind(""), Some("launch.kind"));
// Casing is not a way in: the launch resolvers match the exact string.
assert_eq!(kind("GOG"), Some("launch.kind"));
}
#[test]
fn provider_name_and_payload_validation() {
assert!(validate_provider_name("romm").is_ok());
+106 -9
View File
@@ -339,8 +339,9 @@ fn windows_launch_for(spec: &LaunchSpec) -> Option<WinRecipe> {
// argv element (no shell, no cmd /c). Same pattern as the steam explorer fallback.
"epic" => epic_launch_uri(&spec.value)
.map(|uri| WinRecipe::handoff(format!("explorer.exe \"{uri}\""))),
// GOG: spawn the resolved game exe directly (host-derived from goggame-<id>.info), no Galaxy.
// ...and the one store recipe that is NOT a hand-off: the resolved exe is the game itself.
// GOG: spawn the game's own exe directly (no Galaxy) — the one store recipe that is NOT a
// hand-off. The triple comes from the plugin, so `gog_spawn` re-confines it to a GOG install
// the host finds itself.
"gog" => gog_spawn(&spec.value).map(|(cmdline, workdir)| WinRecipe::game(cmdline, workdir)),
// Xbox/Game Pass: activate the UWP/GDK package by its AUMID (<PFN>!<AppId>) via explorer's
// shell:AppsFolder — which runs in the interactive user session (UWP activation fails as
@@ -893,15 +894,40 @@ pub(crate) fn epic_launch_uri(value: &str) -> Option<String> {
))
}
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple the scanner
/// derived from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games are spawned
/// directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
/// Map a `gog` LaunchSpec value — the tab-separated `exe \t args \t workdir` spawn triple a GOG
/// library plugin derives from `goggame-<id>.info` — to a `(command line, working dir)`. GOG games
/// are spawned directly (no Galaxy), so the exe is quoted and the arguments ride verbatim.
///
/// The exe (and the working dir) is re-confined here to an install directory GOG's own registry
/// lists ([`gog_install_dirs`]). The plugin already confines it while parsing the manifest
/// (plugin-kit's `confinedJoin`, the port of the in-host scanner's `confined_join`) — but that runs
/// outside this host's trust boundary: the triple arrives over the provider API, and unchecked this
/// kind is an arbitrary exe-plus-arguments primitive for any lane that may publish an entry
/// (security review 2026-08-25). `None` ⇒ no GOG install owns that exe, so the entry has no recipe
/// and the launch fails the way any unresolvable one does.
#[cfg(windows)]
pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
gog_spawn_in(value, &gog_install_dirs())
}
/// The pure core of [`gog_spawn`] (unit-testable without a GOG install): `installs` is the set of
/// directories the exe and the working dir must sit inside.
#[cfg(windows)]
fn gog_spawn_in(value: &str, installs: &[String]) -> Option<(String, Option<PathBuf>)> {
let under = |p: &str| installs.iter().any(|dir| path_under(dir, p));
let mut parts = value.split('\t');
let exe = parts.next().filter(|s| !s.is_empty())?;
if !under(exe) {
tracing::warn!(
exe,
"gog launch: the exe is in no GOG install — refusing it"
);
return None;
}
let args = parts.next().unwrap_or("");
let workdir = parts.next().filter(|s| !s.is_empty()).map(PathBuf::from);
// An out-of-bounds working dir is dropped rather than refused: it only decides where the
// (confined) exe starts, and an entry that omits it already spawns without one.
let workdir = parts.next().filter(|s| under(s)).map(PathBuf::from);
let cmdline = if args.trim().is_empty() {
format!("\"{exe}\"")
} else {
@@ -910,6 +936,52 @@ pub(crate) fn gog_spawn(value: &str) -> Option<(String, Option<PathBuf>)> {
Some((cmdline, workdir))
}
/// Windows: every directory GOG's own registry names as an installed game's root
/// (`HKLM\SOFTWARE\WOW6432Node\GOG.com\Games\<productId>\PATH` — GOG is 32-bit, so the WOW view is
/// where it writes). This is the host's OWN enumeration of the store, which is the whole point: it
/// is what a `gog` launch value is checked against, and it is the same key the in-host scanner read
/// before the store moved to a plugin. Empty when GOG isn't installed, which refuses every `gog`
/// launch on that box.
#[cfg(windows)]
fn gog_install_dirs() -> Vec<String> {
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
let Ok(games) =
RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey(r"SOFTWARE\WOW6432Node\GOG.com\Games")
else {
return Vec::new();
};
games
.enum_keys()
.flatten()
.filter_map(|sub| {
let path: String = games.open_subkey(&sub).ok()?.get_value("PATH").ok()?;
let path = path.trim().to_string();
(!path.is_empty()).then_some(path)
})
.collect()
}
/// Whether `path` is `dir` itself or something inside it, compared the way Windows compares paths:
/// case-insensitively, either separator, and with `..` refused outright (it would climb straight
/// back out of the directory the prefix test just accepted — the component the in-host scanner's
/// `confined_join` refused for the same reason). A string test rather than [`Path::starts_with`],
/// which compares components case-SENSITIVELY: the install path a plugin sends need not be spelled
/// the way the registry spells it.
#[cfg(windows)]
fn path_under(dir: &str, path: &str) -> bool {
let norm = |s: &str| s.replace('/', "\\").trim_end_matches('\\').to_lowercase();
let (dir, path) = (norm(dir), norm(path));
if dir.is_empty() || path.split('\\').any(|c| c == "..") {
return false;
}
path == dir
|| path
.strip_prefix(&dir)
.is_some_and(|rest| rest.starts_with('\\'))
}
/// Launch a GameStream `apps.json` command (operator-typed, trusted — never client-set) into the
/// interactive Windows user session, AFTER capture is up (the host is SYSTEM). The Linux paths go
/// through the compositor-aware [`launch_session_command`] instead.
@@ -1341,13 +1413,38 @@ mod tests {
#[cfg(windows)]
#[test]
fn gog_spawn_parses_and_guards() {
let (cmd, wd) = gog_spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
let installs = ["C:\\Games\\W3".to_string(), "C:\\".to_string()];
let spawn = |v: &str| gog_spawn_in(v, &installs);
let (cmd, wd) = spawn("C:\\Games\\W3\\witcher3.exe\t--skip\tC:\\Games\\W3").unwrap();
assert_eq!(cmd, "\"C:\\Games\\W3\\witcher3.exe\" --skip");
assert_eq!(wd, Some(std::path::PathBuf::from("C:\\Games\\W3")));
let (cmd2, wd2) = gog_spawn("C:\\g.exe").unwrap();
let (cmd2, wd2) = spawn("C:\\g.exe").unwrap();
assert_eq!(cmd2, "\"C:\\g.exe\"");
assert!(wd2.is_none());
assert!(gog_spawn("").is_none());
assert!(spawn("").is_none());
}
/// The `gog` kind is an exe PLUS ARGUMENTS, and the triple reaches the host over the provider
/// API — so the exe has to be one the host itself found, not one the caller named (security
/// review 2026-08-25). Without this the kind is `cmd.exe /c <anything>` by another spelling.
#[cfg(windows)]
#[test]
fn gog_spawn_refuses_an_exe_outside_every_gog_install() {
let installs = ["C:\\Games\\W3".to_string()];
let spawn = |v: &str| gog_spawn_in(v, &installs);
assert!(spawn("C:\\Windows\\System32\\cmd.exe\t/c calc\tC:\\Games\\W3").is_none());
// A sibling whose name merely starts with the install path is not inside it.
assert!(spawn("C:\\Games\\W3x\\evil.exe").is_none());
// ...nor is anything that climbs back out of one.
assert!(spawn("C:\\Games\\W3\\..\\..\\Windows\\System32\\cmd.exe").is_none());
// No GOG install at all ⇒ nothing is launchable, rather than everything.
assert!(gog_spawn_in("C:\\Games\\W3\\witcher3.exe", &[]).is_none());
// Windows paths are case-insensitive and take either separator, so a plugin that spells the
// install dir differently to the registry still launches its own games.
assert!(spawn("c:/games/w3/bin/game.exe").is_some());
// An out-of-bounds working dir costs the working dir, not the launch.
let (_, wd) = spawn("C:\\Games\\W3\\witcher3.exe\t\tC:\\Windows\\System32").unwrap();
assert!(wd.is_none());
}
/// Moved here with `xbox_pfn` when the built-in scanners were removed: reducing a
@@ -15,16 +15,32 @@
//! the owning plugin at the moment of an actual launch. What that buys over letting the plugin write
//! `kind = "command"` straight into the library:
//!
//! * **A stolen plugin token is no longer command execution.** Planting an entry is not enough — the
//! host asks the *live registered plugin* what to run, authenticated with the per-boot secret only
//! that process knows. A plugin asked about an entry it never published answers 404 (this is why
//! the ask names the entry rather than trusting the payload), so a forged entry launches nothing.
//! * **An entry planted under someone ELSE'S provider launches nothing.** The provider is stamped by
//! the host from the reconcile URL, so the plugin asked is the entry's owner, and a plugin asked
//! about an entry it never published answers 404 (this is why the ask names the entry rather than
//! trusting the payload).
//! * **Nothing executable is ever persisted or served.** No command lands in `library.json`, and
//! `GET /library` has none to redact for a paired client.
//! * **No stale recipes.** The same reasoning as the `xbox` kind resolving its AUMID at launch time:
//! an emulator that moved, or a config the operator has since edited, is picked up on the next
//! launch instead of leaving an unlaunchable tile behind.
//!
//! What it does **not** buy — and this doc claimed it did until the 2026-08-25 review (H-1) — is a
//! barrier against a stolen plugin token. That one credential also reaches `PUT /api/v1/plugins/{id}`
//! for *any* id, and a registration names the loopback port and the per-boot secret the host will
//! dial: a holder stands up its own listener, registers a provider around it, reconciles a `plugin`
//! entry under that provider, and answers the ask with whatever it likes. The per-boot secret proves
//! the host reached whoever registered that id, not that they were entitled to it.
//!
//! There is no per-plugin credential to bind it to, either: the runner hosts every plugin as a fiber
//! in ONE bun process (`sdk/src/runner.ts` `import()`s each unit), reading one shared `plugin-token`,
//! so no token can name a single plugin. Nor would one help — this kind exists so that a plugin MAY
//! choose a command the host runs, so an id proven beyond doubt buys the same primitive. What is
//! load-bearing is the principal gap: on Windows the runner is LocalService and the host is SYSTEM,
//! so whatever can read the LocalService-readable `plugin-token` escalates through here. Closing it
//! means running the answer as the runner's principal (which gives up the session placement below)
//! or a process per plugin — a runner redesign, not a change to this transport.
//!
//! The host still *runs* the command, because only the host can put the process where the stream can
//! see it: on Linux the line is either gamescope's own argv (a bare-spawn session nests it) or a
//! spawn carrying the session's compositor env, and the returned child is what
@@ -319,8 +335,8 @@ mod tests {
#[test]
fn a_404_means_the_plugin_disowns_the_entry() {
// The forged-entry case: planting a library row is not enough, because the plugin that would
// have to answer for it never published one.
// The cross-provider case: a row planted under someone else's provider launches nothing,
// because that provider's plugin is the one asked and it never published the key.
let (port, server) = stub_plugin(404, r#"{"error":"no launchable entry \"forged\""}"#);
crate::mgmt::register_ui_for_test("stub-disowner", port, "s");
+46 -2
View File
@@ -113,8 +113,15 @@ pub(crate) async fn require_auth(
// sessions, or edit the library). Everything outside the allowlist requires the operator's bearer
// token. The fingerprint is attached by `serve_https` from the verified peer cert.
if let Some(PeerCertFingerprint(Some(fp))) = req.extensions().get::<PeerCertFingerprint>() {
// `effective`, not `is_paired`: the expiry-blind verb answers "is this device LISTED", which
// is the device roster's question, not an admission gate's (see the `native_pairing` module
// header's two-verbs contract). Authorizing on the listing would leave a guest whose access
// lapsed hours ago holding this lane for as long as the record sits in the store.
if cert_may_access(req.method(), req.uri().path())
&& st.native.as_ref().is_some_and(|n| n.is_paired(fp))
&& st
.native
.as_ref()
.is_some_and(|n| n.effective(fp, unix_now()).is_some())
{
return forward(req, next, AuthLane::Cert).await;
}
@@ -190,6 +197,12 @@ pub(crate) async fn require_auth(
/// What stays *out* of the list, and why:
/// - **hooks** — `hooks.json` runs operator commands on lifecycle events; writing it is arbitrary
/// command execution as the host user, and reading it can expose webhook credentials.
/// - **the host's log ring** (`GET /logs`) — it serves the host's own tracing at DEBUG and above,
/// unredacted, which is the *same* webhook credentials (for Slack, Discord, ntfy, Teams, Zapier
/// and Home Assistant the URL IS the bearer token) plus every command line the hook runner has
/// spawned. Withholding `/hooks` while handing the ring over left the carve-out above decorative
/// (2026-08-25 review H-2). A plugin still WRITES its own output — `POST /plugins/logs` below —
/// which is the direction it actually needs.
/// - **pairing administration** — arming/approving/denying/unpairing (and PIN visibility) decide
/// *which devices may stream*; a plugin defect must not be able to admit an attacker's device
/// or eject the operator's.
@@ -205,6 +218,14 @@ pub(crate) async fn require_auth(
/// its own entries — but the two operator-privileged FIELDS inside those payloads (`prep`, and
/// `launch.kind == "command"`) are refused to this lane in the handlers, via [`AuthLane`]. Route
/// reachability and field authority are separate questions and this gate only answers the first.
///
/// That field refusal is **not** a command-execution boundary today, and this doc used to read as
/// though it were: `PUT /plugins/{}` below lets this lane register *any* plugin id together with the
/// loopback port and per-boot secret the host will dial, and a `launch.kind == "plugin"` entry under
/// that id makes the host run whatever that listener answers
/// ([`crate::library::ask_plugin_launch`], 2026-08-25 review H-1). No narrowing here can fix that:
/// the runner hosts every plugin in ONE process on ONE shared token, so this gate cannot tell which
/// plugin is calling, and a plugin that proved its id would still be entitled to that primitive.
pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
// (method, path) pairs, `{}` matching exactly one path segment. Grouped as the route table is.
const ALLOWED: &[(&Method, &str)] = &[
@@ -215,7 +236,6 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool {
(&Method::GET, "/api/v1/local/summary"),
(&Method::GET, "/api/v1/compositors"),
(&Method::GET, "/api/v1/events"),
(&Method::GET, "/api/v1/logs"),
// The paired-device rosters: read-only. (DELETE is pairing administration — not listed.)
(&Method::GET, "/api/v1/clients"),
(&Method::GET, "/api/v1/native/clients"),
@@ -323,3 +343,27 @@ pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool {
pub(crate) fn token_eq(presented: &str, expected: &str) -> bool {
Sha256::digest(presented.as_bytes()) == Sha256::digest(expected.as_bytes())
}
/// Host wall clock, unix seconds — the clock every stored access deadline is expressed in, sampled
/// at each check (per-client-access design §4), same as `mgmt::native`'s copy.
fn unix_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
/// The log ring is the `/hooks` carve-out's back door: it carries the hook runner's webhook URLs
/// (which ARE the bearer credential for Slack/Discord/ntfy/Teams/Zapier/Home Assistant) and the
/// command lines it spawned, unredacted, to anyone who can `GET /logs`. A plugin only ever needs
/// the other direction — pin both halves so neither drifts back (2026-08-25 review H-2).
#[test]
fn the_plugin_lane_writes_logs_but_never_reads_them() {
assert!(!plugin_may_access(&Method::GET, "/api/v1/logs"));
assert!(plugin_may_access(&Method::POST, "/api/v1/plugins/logs"));
}
}
+6 -7
View File
@@ -15,8 +15,8 @@ use axum::Extension;
///
/// Both checks belong here rather than in the route gate: `PUT /library/provider/{p}` is a route a
/// provider plugin must be able to call — reconciling its own entry set is the whole point of a
/// scanner plugin — while `prep` / `launch.kind = "command"` inside that payload are the operator's
/// authority alone. Route reachability and field authority are separate questions.
/// scanner plugin — while `prep` / a `launch.kind` outside the host-resolved set inside that payload
/// are the operator's authority alone. Route reachability and field authority are separate questions.
///
/// `Some((reason, response))` is the refusal to return; `None` means the payload may proceed.
/// Deliberately not `Result<(), Response>`: the "error" here IS the response the handler sends, so
@@ -60,11 +60,10 @@ fn check_privileged_fields(
api_error(
StatusCode::FORBIDDEN,
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, xbox, lutris_id, \
heroic, playnite) \
instead"
"`{field}` can become a command the host runs as the host user, so it may \
only be set with the operator's admin token a plugin may publish entries \
with any host-resolved launch kind (steam_appid, steam_ui, launcher_ui, \
epic, gog, aumid, xbox, lutris_id, heroic, playnite) or `plugin` instead"
),
),
));
+13
View File
@@ -18,6 +18,19 @@
//! Auth: these routes carry no special handling — they are outside the [`super::auth::cert_may_access`]
//! read-only allowlist, so the middleware confines them to a **bearer + loopback** peer like every
//! other mutation. LAN clients have no business here.
//!
//! What that does NOT establish is *which* plugin is calling. `plugin-token` is one shared credential
//! for the whole runner, so a registration's id is asserted, never proven: any holder can claim any
//! id — including a live one's, whose port and secret it then replaces — and
//! [`PluginRegistry::upsert`] has no ownership check to apply. Harmless while this stays a phone
//! book, but the launch path reads it as an authority ([`ui_credential`] →
//! [`crate::library::ask_plugin_launch`] dials the registered port and runs what it answers), which
//! makes the shared token command execution (2026-08-25 review H-1). An ownership check here has
//! nothing to check against and cannot be given one: the runner imports every plugin into ONE bun
//! process (`sdk/src/runner.ts`), so there is no per-plugin process to mint a per-plugin token for,
//! and a plugin that proved its id would still be entitled to answer its own launch asks. The gap
//! that matters is the principal one (Windows: runner LocalService, host SYSTEM), and closing it is
//! a runner redesign — a process per plugin — not a guard in this registry.
use super::shared::*;
use crate::events::{emit, EventKind};
+3 -1
View File
@@ -1515,7 +1515,9 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/local/summary", true, false), // loopback-only, handled before the gates
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// The ring is unredacted host tracing — webhook URLs and hook command lines. Serving it to
// the plugin lane made the `/hooks` carve-out decorative (2026-08-25 review H-2).
("GET", "/api/v1/logs", false, false),
// ---- diagnostics: OPERATOR ONLY, both lanes denied. The verdicts name the host's user,
// its group layout and the state of its device nodes — a paired streaming client has no
// business enumerating any of that, and a plugin that wanted to would be asking for a map
+16 -14
View File
@@ -160,7 +160,10 @@ pub(super) fn resolve_compositor(
to get dedicated game sessions."
);
} else {
let route = crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(
Compositor::Gamescope,
));
let route = crate::vdisplay::resolve_gamescope_route(Compositor::Gamescope, true);
tracing::info!(
?route,
"dedicated game session — routing to a headless gamescope spawn at the client \
@@ -200,19 +203,18 @@ pub(super) fn resolve_compositor(
pf_host_config::config().compositor.as_deref(),
));
}
// Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). The
// route travels back to the caller as a VALUE and is carried on the backend instance — an
// operator pin skips the input retarget but still needs a route resolved, or `create` would
// fall through to a bare spawn on a box that was pinned to the managed session.
let route = if !overridden {
crate::vdisplay::apply_input_env(chosen, false)
} else {
// An operator pin deliberately leaves PUNKTFUNK_INPUT_BACKEND alone, but still needs a
// route resolved — otherwise `create` falls through to a bare spawn on a box pinned to
// the managed session.
crate::vdisplay::resolve_gamescope_route(chosen, false)
};
// Point input at the same backend the video landed on — as a published VALUE, not a
// `PUNKTFUNK_INPUT_BACKEND` `set_var`. An operator pin skips this, which is what leaves the
// operator's own knob in charge on a pinned box.
if !overridden {
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(chosen));
}
// The gamescope sub-mode (managed where the session infra exists, attach to a foreign
// gamescope, else per-session bare spawn). Resolved on BOTH paths and travelling back to the
// caller as a value carried on the backend instance: a pin skips the input retarget above
// but still needs a route, or `create` falls through to a bare spawn on a box that was
// pinned to the managed session.
let route = crate::vdisplay::resolve_gamescope_route(chosen, false);
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
match Compositor::from_pref(pref) {
Some(want) if want == chosen => {
+62 -12
View File
@@ -63,7 +63,8 @@ pub(super) async fn run(
clip: pf_clipboard::ClipCoord,
// Per-client access (design/per-client-access.md §5): the session's LIVE grant mask — the
// same atomic the datagram filter reads; the deadline/watch task folds console edits into
// it, so a `ClipControl` arriving after a mid-session revoke resolves against the new mask.
// it, so a `ClipControl` or `ClipOffer` arriving after a mid-session revoke resolves against
// the new mask.
session_grants: Arc<AtomicU32>,
// `AccessUpdate`s from the session's deadline/watch task (expiry warnings + mid-session
// grant edits) — this task is the control stream's sole writer, so they cross here.
@@ -77,6 +78,10 @@ pub(super) async fn run(
// Set once `clip_offer_rx` closes (coordinator gone / inert handle) so its `select!` branch
// stops firing on a perpetually-ready `None`.
let mut clip_offer_closed = false;
// Per-client-access enforcement drops for the messages this task gates (design §5.5): counted
// per class with one `warn!` on the first, so a client spamming a revoked plane can't turn the
// log into the DoS.
let denied = GrantDrops::new();
// Same discipline for the wire-MTU watcher's channel — its bounded lifetime ends mid-session
// on every healthy path.
let mut shard_change_closed = false;
@@ -353,16 +358,27 @@ pub(super) async fn run(
} else if let Ok(offer) = ClipOffer::decode(&msg) {
// The client copied: hand its lazy format list to the coordinator, which
// installs a host-side source that fetches from the client on host paste.
tracing::debug!(
seq = offer.seq,
kinds = offer.kinds.len(),
"clipboard offer from client"
);
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer {
seq: offer.seq,
mimes,
});
// Gated like the `ClipControl` above, against the LIVE mask: this is the
// WRITE half of the same permission — it puts a client-owned selection on the
// host's real desktop clipboard — so an offer arriving after a mid-session
// revoke is dropped, not installed.
if clip_offer_permitted(
session_grants.load(Ordering::Relaxed),
clip_enabled.load(Ordering::SeqCst),
) {
tracing::debug!(
seq = offer.seq,
kinds = offer.kinds.len(),
"clipboard offer from client"
);
let mimes = offer.kinds.iter().map(|k| k.mime.clone()).collect();
let _ = clip_cmd_tx.send(ClipCoordCmd::RemoteOffer {
seq: offer.seq,
mimes,
});
} else {
denied.note(GrantClass::Clipboard);
}
} else {
tracing::warn!("unknown control message — ignoring");
}
@@ -403,6 +419,13 @@ pub(super) async fn run(
// branch, the `clip_offer_closed` pattern.
match update {
Some(u) => {
// An edit that took CLIPBOARD away leaves the session up, so tell the
// coordinator too: the lifecycle task clearing `clip_enabled` only stops
// the host→client direction, while the selection it installed for this
// device stays on the host clipboard until `SetEnabled(false)` drops it.
if u.grants & punktfunk_core::quic::GRANT_CLIPBOARD == 0 {
let _ = clip_cmd_tx.send(ClipCoordCmd::SetEnabled(false));
}
if io::write_msg(&mut ctrl_send, &u.encode()).await.is_err() {
break;
}
@@ -515,13 +538,21 @@ fn resolve_clip_control(
}
}
/// Whether a client's [`ClipOffer`] may be installed on the host's real desktop clipboard: the
/// device's LIVE `CLIPBOARD` grant, ANDed with the sync state its last [`ClipControl`] resolved
/// (which already folded in the operator policy and backend availability). Both are read when the
/// offer arrives, so a revoke mid-session closes this direction as it closes the other one.
fn clip_offer_permitted(grants: u32, clip_enabled: bool) -> bool {
grants & punktfunk_core::quic::GRANT_CLIPBOARD != 0 && clip_enabled
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::quic::{
CLIP_FLAG_FILES, CLIP_POLICY_FILES, CLIP_POLICY_TEXT, CLIP_REASON_BACKEND_UNAVAILABLE,
CLIP_REASON_NOT_PERMITTED, CLIP_REASON_NO_FILES, CLIP_REASON_OK,
CLIP_REASON_POLICY_DISABLED,
CLIP_REASON_POLICY_DISABLED, GRANT_ALL, GRANT_CLIPBOARD,
};
const ON: ClipControl = ClipControl {
@@ -578,4 +609,23 @@ mod tests {
(true, both, CLIP_REASON_OK)
);
}
/// The client→host direction is gated by the same grant as the host→client one: an offer from
/// a device that never had CLIPBOARD is dropped, and so is one that arrives after a
/// mid-session revoke — the console edit lands in the live mask (and clears the enable flag
/// with it), and the very next offer resolves against the new one.
#[test]
fn clip_offer_needs_the_live_grant() {
// The normal case: granted and sync on.
assert!(clip_offer_permitted(GRANT_ALL, true));
// Never granted — nothing to install, whatever the client claims about sync.
assert!(!clip_offer_permitted(GRANT_ALL & !GRANT_CLIPBOARD, true));
assert!(!clip_offer_permitted(0, true));
// Revoked mid-session: the lifecycle task stored the edited mask and cleared the enable
// flag; both halves of that state refuse the offer on their own.
assert!(!clip_offer_permitted(GRANT_ALL & !GRANT_CLIPBOARD, false));
assert!(!clip_offer_permitted(GRANT_ALL, false));
}
}
+41 -31
View File
@@ -595,62 +595,72 @@ struct PadAudioSlots {
/// `(kinds, handle)` per running pad — `kinds` is the arrival's audio-caps mask, kept so
/// an identical re-arrival (they are re-sent against datagram loss) is a no-op.
slots: [Option<(u8, pad_audio::PadAudioHandle)>; MAX_WIRE_PADS],
/// Kind-change restarts spent per pad this session (R3). The trigger is a client-sent
/// arrival, so without a ceiling the client decides how many WASAPI captures the host opens.
restarts: [u8; MAX_WIRE_PADS],
/// Streamer starts spent per pad this session (R3) — the FIRST one included, because every
/// start is client-triggered (an arrival declares the kinds), so without a ceiling the client
/// decides how many WASAPI captures the host opens.
starts: [u8; MAX_WIRE_PADS],
}
/// R3: how many times one pad may change its declared audio kinds before the host stops
/// obliging. A real controller declares once at open and never again; the re-sent arrivals are
/// identical and take the no-op path above, so this is only reached by a client that keeps
/// changing its mind.
const MAX_PAD_AUDIO_RESTARTS: u8 = 8;
/// R3: how many pad-audio captures one pad may open in a session before the host stops obliging.
/// A real controller declares once at open and never again; the re-sent arrivals are identical
/// and take the no-op path above, so this is only reached by a client that keeps cycling — by
/// changing its declared kinds, or by alternating a declaring arrival with anything that stops
/// the streamer (a `want == 0` re-declare, a `GamepadRemove`), which spawns and tears down just
/// the same while never touching the kind-change arm.
const MAX_PAD_AUDIO_STARTS: u8 = 8;
impl PadAudioSlots {
fn new() -> PadAudioSlots {
PadAudioSlots {
slots: std::array::from_fn(|_| None),
restarts: [0; MAX_WIRE_PADS],
starts: [0; MAX_WIRE_PADS],
}
}
/// Idempotent spawn: same kinds → keep the running streamer; changed kinds → restart with
/// the new mask; not running → spawn (a slot without an endpoint stays empty — bounded
/// retries, since arrivals are only re-sent a few times per slot open). `edge` picks the
/// DualSense Edge identity for the Linux sink (ignored on Windows — endpoints are
/// pre-stamped).
/// retries, since arrivals are only re-sent a few times per slot open). Every capture that
/// actually opens spends from [`MAX_PAD_AUDIO_STARTS`]. `edge` picks the DualSense Edge
/// identity for the Linux sink (ignored on Windows — endpoints are pre-stamped).
fn ensure(&mut self, conn: &quinn::Connection, pad: u8, kinds: u8, edge: bool) {
let idx = pad as usize;
if idx >= MAX_WIRE_PADS {
return;
}
if let Some((have, _)) = &self.slots[idx] {
if *have == kinds {
return; // identical re-arrival — keep the running streamer
}
// R3: the restart trigger is a CLIENT-sent arrival, so the count is client-driven.
// Nothing bounded it: a client alternating its declared kinds could make the host
// tear down and re-spawn a WASAPI loopback capture indefinitely, each cycle paying a
// thread spawn and an endpoint activation. Cheap to bound, and a pad that has already
// changed its mind this many times in one session is not doing anything legitimate.
if self.restarts[idx] >= MAX_PAD_AUDIO_RESTARTS {
tracing::warn!(
pad = idx,
"pad-audio kinds changed again after {MAX_PAD_AUDIO_RESTARTS} restarts — \
ignoring; the streamer keeps its current kinds for this session"
);
return;
}
self.restarts[idx] += 1;
let running = self.slots[idx].as_ref().map(|(have, _)| *have);
if running == Some(kinds) {
return; // identical re-arrival — keep the running streamer
}
// R3: the trigger is a CLIENT-sent arrival, so the count is client-driven. Nothing
// bounded it: a client alternating its declared kinds — or alternating a declaring
// arrival with a stop (`want == 0`, `GamepadRemove`) and then declaring again, which
// never reaches the kind-change arm at all — could make the host tear down and re-spawn a
// WASAPI loopback capture indefinitely, each cycle paying a thread spawn and an endpoint
// activation. Cheap to bound, and a pad that has opened this many captures in one session
// is not doing anything legitimate. Gated BEFORE the stop below, so a pad at the ceiling
// keeps the streamer it has rather than losing it to the last request.
if self.starts[idx] >= MAX_PAD_AUDIO_STARTS {
tracing::warn!(
pad = idx,
"pad-audio streamer already started {MAX_PAD_AUDIO_STARTS} times — ignoring; the \
pad keeps whatever streamer it has for this session"
);
return;
}
if running.is_some() {
tracing::info!(
pad = idx,
restarts = self.restarts[idx],
starts = self.starts[idx],
"pad-audio kinds changed — restarting the streamer"
);
self.stop(idx);
}
let stop = Arc::new(AtomicBool::new(false));
if let Some(h) = pad_audio::spawn(conn.clone(), pad, kinds, edge, stop) {
// Charged only for a capture that actually opened: a slot with no provisioned
// endpoint spawns nothing, and the arrival re-sends against it must not spend the
// ceiling.
self.starts[idx] += 1;
self.slots[idx] = Some((kinds, h));
}
}
+112 -12
View File
@@ -50,15 +50,23 @@ pub(super) async fn pair_ceremony(
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
io::write_msg(
&mut send,
&PairChallenge {
spake_b,
confirm: confirms.host,
}
.encode(),
// Bounded: this write completes only when the CLIENT grants flow-control credit, so a client
// that advertises a tiny `stream_receive_window` could otherwise park the ceremony here for as
// long as it liked — on a host that serves pairings one at a time, and past the armed window's
// TTL (2026-08-25 review).
tokio::time::timeout(
PAIRING_TIMEOUT,
io::write_msg(
&mut send,
&PairChallenge {
spake_b,
confirm: confirms.host,
}
.encode(),
),
)
.await?;
.await
.map_err(|_| anyhow!("pairing timed out sending the challenge"))??;
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
@@ -71,9 +79,9 @@ pub(super) async fn pair_ceremony(
// signal to scope this to failures only (the client just disconnects).
//
// The armed window carries the operator's access choice for whoever completes this ceremony
// (design §5.7) — read it BEFORE the consume below wipes it with the rest of the window.
let access = np.armed_access();
np.disarm();
// (design §5.7) — read it BEFORE the consume wipes it with the rest of the window, and refuse
// outright if the window lapsed while we were writing.
let access = consume_window(np, pin)?;
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
.await
@@ -92,7 +100,13 @@ pub(super) async fn pair_ceremony(
} else {
tracing::warn!(name = %name, "pairing rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
// Bounded for the same reason as the challenge write above.
tokio::time::timeout(
PAIRING_TIMEOUT,
io::write_msg(&mut send, &PairResult { ok }.encode()),
)
.await
.map_err(|_| anyhow!("pairing timed out sending the result"))??;
let _ = send.finish();
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
@@ -101,3 +115,89 @@ pub(super) async fn pair_ceremony(
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
Ok(())
}
/// Consume the armed window this ceremony is running against: its access choice, plus the proof
/// that the window is STILL the one whose PIN we started with. `Err` ⇒ it lapsed while the ceremony
/// was in flight (expired, disarmed, or the operator re-armed) — mint nothing, and leave whatever
/// window is armed *now* untouched, so a stalling client can't wipe the operator's next one.
///
/// The order is the security property: the access choice is read BEFORE the PIN is re-checked, so
/// an expiry landing between the two reads fails CLOSED (no choice AND no PIN ⇒ refused). Reading
/// them the other way round would fail OPEN — [`NativePairing::add_with_access`] reads an absent
/// choice as the full/permanent default, which is exactly what an expired "controller only, 4
/// hours" window must never become.
fn consume_window(np: &NativePairing, pin: &str) -> Result<Option<crate::native_pairing::Access>> {
let access = np.armed_access();
anyhow::ensure!(
np.current_pin().as_deref() == Some(pin),
"the pairing window lapsed while the ceremony was running"
);
np.disarm();
Ok(access)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::native_pairing::Access;
use std::time::Duration;
fn temp(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"pf-native-ceremony-{tag}-{}.json",
std::process::id()
))
}
fn controller_4h() -> Access {
Access {
grants: punktfunk_core::quic::GRANT_PRESET_CONTROLLER_ONLY,
expires_unix: Some(4 * 3600),
}
}
/// A window that lapses mid-ceremony mints NOTHING. Without the re-check the ceremony read an
/// expired window as "no access choice", and `add_with_access(.., None)` turns that into full,
/// permanent control for a new fingerprint — the operator's "controller only, 4 h" silently
/// upgraded by a client that stalled the host's write past the TTL (2026-08-25 review).
#[test]
fn expired_window_mints_no_grant() {
let p = temp("expired");
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), None, false).unwrap();
// Live window: the operator's choice comes through, and the window is consumed.
let pin = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
assert_eq!(consume_window(&np, &pin).unwrap(), Some(controller_4h()));
assert!(np.current_pin().is_none(), "single-use: window consumed");
// Lapsed window (a zero TTL is already past by the time it is read): refuse, rather than
// fall through to the full/permanent default.
let pin = np.arm_for(Duration::ZERO, None, Some(controller_4h()));
assert!(consume_window(&np, &pin).is_err());
// A window the operator re-armed while the ceremony ran is somebody else's: refuse, and
// do NOT disarm it — otherwise a stalling client wipes every window that follows.
let stale = np.arm_for(Duration::ZERO, None, Some(controller_4h()));
// The PIN is random, so re-arm until it actually differs (1-in-10 000 otherwise).
let mut fresh = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
while fresh == stale {
fresh = np.arm_for(Duration::from_secs(60), None, Some(controller_4h()));
}
assert!(consume_window(&np, &stale).is_err());
assert_eq!(np.current_pin().as_deref(), Some(fresh.as_str()));
let _ = std::fs::remove_file(&p);
}
/// The CLI `--allow-pairing` window carries no access choice and no expiry: `None` there is the
/// legitimate full/permanent default, not an expired window, and must still pair.
#[test]
fn choiceless_window_still_pairs() {
let p = temp("choiceless");
let _ = std::fs::remove_file(&p);
let np = NativePairing::load_with(Some(p.clone()), Some("4321".into()), true).unwrap();
assert_eq!(consume_window(&np, "4321").unwrap(), None);
let _ = std::fs::remove_file(&p);
}
}
+10 -4
View File
@@ -2402,8 +2402,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if sw.compositor != compositor {
tracing::info!(from = compositor.id(), to = sw.compositor.id(), kind = ?sw.kind,
"session switch — rebuilding backend in place");
// Retarget the process env at the new session BEFORE opening the new backend (this
// thread is the only env writer; the watcher only snapshots).
// Retarget the process env at the new session BEFORE opening the new backend. Being
// the only WRITER is not safety: `setenv` races every concurrent `getenv` in the
// process — glibc's own internals, zbus, wayland-client, the Mesa loader — and none
// of them takes pf-vdisplay's `ENV_LOCK`. The four variables this still writes are
// the ones whose readers can only take them from the environment; see that lock's
// doc for what it does and does not buy (security-review 2026-08-25).
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
kind: sw.kind,
env: sw.env,
@@ -2411,7 +2415,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
});
// A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
// switched-to backend's normal sub-mode.
let switched_route = crate::vdisplay::apply_input_env(sw.compositor, false);
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(sw.compositor));
let switched_route = crate::vdisplay::resolve_gamescope_route(sw.compositor, false);
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
// point at the old session, so input would silently not land until a reconnect.
// Settle it (env push + KWin portal restart) before the injector reopens against it.
@@ -3290,7 +3295,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
crate::vdisplay::apply_session_env(&active);
// Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
let rebuilt_route = crate::vdisplay::apply_input_env(c, false);
crate::inject::set_backend_id(crate::vdisplay::input_backend_id(c));
let rebuilt_route = crate::vdisplay::resolve_gamescope_route(c, false);
if c != compositor {
if matches!(
c,
+19 -7
View File
@@ -120,15 +120,18 @@ pub fn main(args: &[String]) -> Result<()> {
/// stdout/stderr are redirected to `host.log` in the same dir.
pub fn service_log_path() -> PathBuf {
let dir = pf_paths::config_dir().join("logs");
// DACL-locked (Users read-only, no create) so a local user can't pre-plant SYSTEM log files as
// reparse points / hardlinks to redirect the SYSTEM service's writes (security-review #11).
let _ = pf_paths::create_private_dir(&dir);
// DACL-locked (no create) so a local user can't pre-plant SYSTEM log files as reparse points /
// hardlinks to redirect the SYSTEM service's writes (security-review #11). `create_secret_dir`,
// not `create_private_dir`: the config dir's inheritable `BUILTIN\Users:(RX)` reached these
// files too, and a host log carries webhook URLs and launched command lines — the operator
// reads them through the console, not off disk (security-review 2026-08-25).
let _ = pf_paths::create_secret_dir(&dir);
dir.join("service.log")
}
fn host_log_path() -> PathBuf {
let dir = pf_paths::config_dir().join("logs");
let _ = pf_paths::create_private_dir(&dir);
let _ = pf_paths::create_secret_dir(&dir);
dir.join("host.log")
}
@@ -769,7 +772,7 @@ fn open_log_handle(path: &std::path::Path) -> Result<HANDLE> {
/// reason.
fn web_log_path() -> PathBuf {
let dir = pf_paths::config_dir().join("logs");
let _ = pf_paths::create_private_dir(&dir);
let _ = pf_paths::create_secret_dir(&dir);
dir.join("web.log")
}
@@ -1894,8 +1897,17 @@ fn maybe_boot_loop_rollback(restarts: u32, attempted: &mut bool) {
);
return;
};
// Validity-only Authenticode check: the failed update's manifest pins are gone with it,
// and the cached file was fully verified (manifest sha256 + pins) when first downloaded.
// Validity-only Authenticode check — and validity is ALL it proves: that the file carries a
// cryptographically intact signature, not whose. It is deliberately not more than that, and the
// earlier claim here (that the cached file had been verified against "manifest sha256 + pins")
// overstated it: releases sign through Azure Artifact Signing, which mints a fresh leaf per
// request, so the manifest carries NO `authenticode_sha256` pins to check against and never
// has (see `update::windows`'s module docs). What actually binds these bytes is the SHA-256 in
// the Ed25519-signed manifest, checked when this installer was downloaded, plus the config-dir
// DACL (Users read-only, no create) that keeps `updates\` un-plantable by a local user.
// Pinning the publisher here needs `verify_authenticode` to compare something stable across
// leaf rotation — the signing subject or the issuing intermediate — which is a change to
// `update::windows`, not to this call site (security-review 2026-08-25).
if let Err(e) = crate::update::windows::verify_authenticode(&previous, &[]) {
tracing::error!(
installer = %previous.display(),
+13 -5
View File
@@ -90,10 +90,15 @@ The document is validated as a whole, and **one bad entry disables every hook**
hostname — are fine, so Home Assistant on another box on your network works as written.
- `timeout_s` must be 1600.
- If `hmac_secret_file` is set but unreadable, the host **skips** that POST rather than sending it
unsigned.
unsigned. It also *warns* (and still signs) when that file isn't owned by you or is readable by
anyone else — `chmod 600` it.
So check the log after editing the file: `journalctl --user -u punktfunk-host` on Linux, or the web
console's **Logs** page on either platform.
console's **Logs** page on either platform. Those lines name a hook by its webhook's
`scheme://host` or its command's program name, plus a short id — the URL path and the command's
arguments are left out, because that's where a Slack or ntfy token and an `Authorization:` header
live, and the **Logs** page is served over the API verbatim. The id is the same on every line about
one hook, so two hooks sharing a program or a webhook host stay apart.
A `run` command's shell one-liner vocabulary — the event flattened to env, values sanitized:
@@ -122,9 +127,12 @@ ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)
**Rules of the road:** hooks are fire-and-forget and bounded — at most 8 in flight (extra firings
are dropped with a log line, never queued), and a command that outlives its timeout is killed.
Hook commands run as the host user, so `hooks.json` is operator-privileged config. On Linux, when a
command starts with an **absolute path** to a script, the host checks that file is owned by you (or
root) and not group/world-writable, and refuses to run it — loudly, in the log — if it isn't. Write
the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
command names a script by **absolute path**, the host checks that file *and every directory above
it* is owned by you (or root) and not group/world-writable, and refuses to run it — loudly, in the
log — if it isn't: whoever can rename an entry in a directory chooses what runs out of it. (A
world-writable directory with the sticky bit, like `/tmp`, passes — there only an entry's own owner
can replace it.) Quoting is understood, so a path with a space in it is checked like any other.
Write the full path (`/home/me/.config/punktfunk/scripts/on-stream.sh`, not `~/…`) if you want that
check: the shell expands `~` and looks up PATH names like `makoctl` only afterwards, so those are
never checked. On Windows there is no per-script check — the ACL on the config directory is the
boundary.
+9 -1
View File
@@ -162,7 +162,7 @@ if you have a desktop client you already have it:
```sh
punktfunk hosts list --probe # saved hosts, each with a live reachability check
punktfunk pair <host>[:port] --pin 1234 # enrol this device with a host
punktfunk pair <host>[:port] --pin - # enrol this device with a host (PIN on stdin)
punktfunk library <host-ref> --json # the host's games, machine-readable
punktfunk launch <host-ref> --game <id> # stream, waking the host first if it's asleep
punktfunk open 'punktfunk://connect/<host-ref>'
@@ -174,6 +174,14 @@ link takes. There is also `hosts add` / `hosts forget`,
[`wake`](/docs/wake-on-lan#from-the-command-line), `reachable`, `profiles list` and `reset`; run
`punktfunk help <command>` for a verb's flags.
`--pin -` reads the PIN from stdin (`echo 1234 | punktfunk pair …`), which is the form to script.
`--pin 1234` still works, but the value sits on the command line, where every local user can read it
(`/proc/*/cmdline`); with neither flag, an interactive run asks for it.
`punktfunk open` connects on its own only when the link names its host by the stable record id. A
link naming it by label or address is a guess anything could make, so it asks first — and with no
terminal to ask on it refuses (exit 6) unless you pass `--yes`.
Exit codes are stable, so a script can branch without parsing prose: **0** ok, **2** connect failed,
**3** trust rejected (re-pair), **4** the renderer couldn't start, **5** nothing matched what you
named, **6** it needs a person (pairing, or an unknown host).
+20 -7
View File
@@ -102,7 +102,9 @@ punktfunk://connect/<host-ref>[?fp=<64-hex>][&host=<addr[:port]>][&launch=<id>][
```
`<host-ref>` is a saved host's stable record id, its name (unique, ignoring case), or `addr[:port]`,
resolved in that order; a name matching two saved hosts is refused rather than guessed.
resolved in that order; a name matching two saved hosts is refused rather than guessed. Only the
record id opens without asking — a name or an address is something any web page could guess, so a
link built on one confirms first.
| Parameter | Means |
|---|---|
@@ -132,6 +134,9 @@ punktfunk://connect/Living%20Room%20PC?launch=steam:570
punktfunk://connect/Living%20Room%20PC?profile=Work
```
All three name the host by label, so they ask before connecting. Put the record id in place of the
name — which is what **Copy link** writes — for a link that opens in one click.
## What a link can and can't do
The rule the grammar keeps: **a link may only do what clicking a card you already have could do,
@@ -141,12 +146,17 @@ minus every trust decision.**
page cannot shape your session beyond choosing among your own configurations.
- There is no `pair` route and never will be. `punktfunk://pair/...` is refused outright;
[pairing](/docs/pairing) stays something you do with the fingerprint on screen.
- Only the **stable record id** connects unattended. A link naming the host by its label or its
address — including through `host=` — reaches the same host, but behind an *Open this link?*
confirmation naming the host and anything it asks to launch: "Gaming PC" and a LAN address are
guesses anything that can open a URL could make. **Copy link** always writes the id, so a shortcut
you made keeps opening in one click.
- A link naming a host you don't know is never connected. When it carries an address — as
`<host-ref>` or `host=` — Linux and Android open the app's normal trust prompt, pre-filled with
that address and any `fp` the link carried, so the first connect is verified rather than blind.
Windows and the Apple apps show a notice naming the host, and you pair from the host list
yourself. A link with no address to fall back on — a bare name or a stale record id — is refused
with a notice.
`<host-ref>` or `host=` — Linux, Android and Windows open the app's normal trust prompt, pre-filled
with that address and any `fp` the link carried, so the first connect is verified rather than
blind. The Apple apps show a notice naming the host, and you add it from the host list yourself.
A link with no address to fall back on — a bare name or a stale record id — is refused with a
notice.
- An `fp` that contradicts the fingerprint already pinned for that host is a hard refusal with a
notice. Nothing connects.
- A `profile=` that names nothing on this device, or two profiles at once, refuses **before**
@@ -183,9 +193,12 @@ box, use the `punktfunk` CLI:
```bash
punktfunk profiles list # ids, names, how many settings each overrides
punktfunk open 'punktfunk://connect/Desk?profile=Work'
punktfunk open --yes 'punktfunk://connect/Desk?profile=Work'
```
`--yes` answers the confirmation a label- or address-referenced link raises; a link carrying the
record id needs no flag. Without a terminal to ask on and without `--yes`, `open` refuses (exit 6).
It ships in the Linux client packages and the Windows MSIX. The Flatpak has it too, inside the
sandbox — `flatpak run --command=punktfunk io.unom.Punktfunk`. See [Clients](/docs/clients) for the
rest of its verbs.
+7 -2
View File
@@ -733,7 +733,9 @@ themselves within a minute. The service commands need an **elevated** PowerShell
3. **The console page never loads.** The service restarts the console on any failure, so give it a
minute first. If it stays down, the console's own log says why — check
`%ProgramData%\punktfunk\logs\web.log` (and `service.log` next to it, which records every console
start and exit), then restart the service:
start and exit). Those files are readable only by Administrators and SYSTEM, so open them from an
**elevated** PowerShell — `Get-Content -Tail 50 $env:ProgramData\punktfunk\logs\web.log` — then
restart the service:
```powershell
punktfunk-host service restart
@@ -782,7 +784,10 @@ The same output also lands outside the console — on Linux in the journal
(`journalctl --user -u punktfunk-host`), on Windows in `%ProgramData%\punktfunk\logs\host.log` (plus
`service.log` for the service that supervises it). Those *do* follow the log level: raise it with
`RUST_LOG=debug` in [`host.env`](/docs/configuration) and restart the host. `RUST_LOG=info` is
already the default, so setting it changes nothing.
already the default, so setting it changes nothing. The Windows files are readable only by
Administrators and SYSTEM — a normal editor is refused even on an admin account, because Windows
hands it a filtered token — so open them from an **elevated** PowerShell, or stay on the **Logs**
page above, which needs no elevation.
None of that covers the **client** side. If the picture, the decoder or the presenter is what
failed, the Windows client keeps its own log at `%LOCALAPPDATA%\punktfunk\logs\client.log` (rotated
+19 -7
View File
@@ -79,13 +79,24 @@ sudo punktfunk-sysext install --from-file ~/punktfunk-known-good.raw # to go b
against mismatched system libraries — run `sudo punktfunk-sysext update` once and it fetches the
image built for the new base.
- **If it refuses the feed.** `refusing to install from an unsigned feed` means that Fedora major's
feed predates signing; it gets sealed on the next publish. To install from it anyway, accepting
unauthenticated images, `sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`.
The other message, `the feed's SHA256SUMS is NOT signed by packages@unom.io`, is not the same
thing — don't install; re-download the script and try again.
feed predates signing; it gets sealed on the next publish. `signed but UNBOUND` is the same kind
of thing one step on — a feed sealed before manifests carried their `# FEED`/`# SERIAL` header —
and it too is fixed by the next publish. To install from either anyway, accepting unauthenticated
images, `sudo env PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 bash punktfunk-sysext.sh install`.
The other three messages are not the same thing — don't install; re-download the script and try
again: `the feed's SHA256SUMS is NOT signed by packages@unom.io`, `this manifest was signed for
the feed '…'` (another channel's or another Fedora major's feed served as yours), and
`manifest serial … is older than the last accepted …` (a real but superseded manifest replayed).
- **If it refuses to downgrade.** `the feed's newest image (…) is OLDER than the installed …`
means the feed lost a build — `update` won't walk you back onto a superseded release without
being asked. For a deliberate rollback,
`sudo PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE=1 punktfunk-sysext update`, or re-install the image file
you kept above, which never consults the feed.
- The feed's checksum manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the
same one that signs the RPMs) and `punktfunk-sysext` verifies it against a key baked into the
script, so it needs `gpg` on the box.
script, so it needs `gpg` on the box. The manifest also names the feed it was signed for and
carries a publish serial, both inside the signed bytes, so a signed manifest from another
channel — or an older one put back — can't be replayed at yours.
### Restart after a Linux package update
@@ -121,8 +132,9 @@ silently — the service restarts at the end and the page reconnects by itself.
you're warned first: updating drops it.
Every attempt leaves a result in the card (and an installer log under
`C:\ProgramData\punktfunk\logs\update-<version>.log`)including across the restart, so a failed
update is never silent.
`C:\ProgramData\punktfunk\logs\update-<version>.log`readable only by Administrators and
SYSTEM, so open it from an **elevated** PowerShell) — including across the restart, so a
failed update is never silent.
If the newly installed host crash-loops, the service puts the previous installer back on its own
(the last two are kept) and says so in the card — you end up on the version you started from, not
+3 -1
View File
@@ -219,7 +219,9 @@ reach *Stopped*, and starts again); `punktfunk-host service status` shows the cu
The host writes to `%ProgramData%\punktfunk\logs\` — `service.log` for the service supervisor and
`host.log` for the streaming host itself. Each is rotated to `.old` at the next start once it passes
10 MB, one generation kept. The web console's **Logs** tab shows the same stream live. For more
10 MB, one generation kept. Both are readable only by Administrators and SYSTEM — a normal editor
can't open them even on an admin account — so read them from an **elevated** PowerShell, or use the
web console's **Logs** tab, which shows the same stream live and needs no elevation. For more
detail, set `RUST_LOG=debug` in `host.env` and restart the service.
### Updating
+9
View File
@@ -41,6 +41,15 @@ that's the walkthrough to hand a user. Packager-side facts:
OpenPGP signature from `packages@unom.io` (`AF245C506F4E4763`, the RPM signing key). The public
key is baked into `punktfunk-sysext.sh`; the script refuses a feed it can't verify
(`PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1` is the documented escape hatch for pre-signing feeds).
- `SHA256SUMS` opens with `# FEED <name>` and `# SERIAL <unix-ts>` **inside the signed bytes**, so
the signature says which feed and which publish it covers — a write:package token without the
signing key can otherwise copy a canary manifest into the stable path, or put last month's back,
and every box verifies it happily. `punktfunk-sysext` refuses a manifest that is unbound, is
bound to a different feed, or whose serial is below the highest it has accepted (persisted per
feed in `/var/lib/extensions/.punktfunk.serial-floor`, which survives `remove` on purpose).
`publish-sysext-feed.sh` stamps both on every publish; a feed published before binding existed
is bound by its next publish, or now with
`TOKEN=… bash packaging/bazzite/publish-sysext-feed.sh --seal f<ver>[-canary]`.
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
major rebase the old image is refused instead of merging soname-broken binaries; feeds exist
per Fedora major, from the same CI matrix as the RPM groups.
+40 -11
View File
@@ -65,6 +65,31 @@ read_manifest() {
grep -E '^[0-9a-f]{64} [^ ]+$' "$FETCHED" > "$SUMS" || :
}
# bind_manifest — stamp the feed name and a publish serial INTO the bytes about to be signed.
#
# A bare checksum list binds nothing: the signature proves "we signed these bytes", never "…for the
# stable feed, this month". So anyone who can WRITE the registry without holding the key — a leaked
# write:package token, the kind that sits in half our CI jobs — can copy this feed's manifest,
# signature and images into another channel's path, or put an old pair back, and every box verifies
# them happily and merges the result over /usr. punktfunk-sysext(8) refuses a manifest whose FEED is
# not the feed it fetched and one whose SERIAL is below the highest it has accepted, which is the
# same channel-binding + monotonic-serial pair the Rust updater enforces on its own manifest
# (crates/pf-update-check/src/manifest.rs). Comment lines, so a `#`-skipping checksum reader and the
# clients' image-line parsers are both unaffected by them.
bind_manifest() {
local serial prev
serial="$(date +%s)"
# Monotonic per feed, and never below what the feed already claims. Clients keep the highest
# serial they have accepted and refuse anything lower, so one runner with a fast clock would
# otherwise lock every box out of this feed until wall-clock caught up.
prev="$(sed -n 's/^# SERIAL //p' "$FETCHED" | head -n1)"
case "$prev" in ''|*[!0-9]*) prev=0 ;; esac
[ "$serial" -gt "$prev" ] || serial=$((prev + 1))
{ printf '# FEED %s\n# SERIAL %s\n' "$FEED" "$serial"; cat "$SUMS"; } > "$WORK/bound"
mv -f "$WORK/bound" "$SUMS"
echo "bound manifest to feed $FEED, serial $serial"
}
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
# returns 1 if no key is available; the caller decides whether that is survivable.
sign_manifest() {
@@ -119,22 +144,23 @@ if [ "$SEAL" = 1 ]; then
echo "$BASE/SHA256SUMS lists no images — refusing to seal it (feed needs a republish)" >&2
exit 1
fi
IMAGES="$(grep -c '' <"$SUMS")"
cmp -s "$SUMS" "$FETCHED" \
|| echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $IMAGES kept"
bind_manifest
if ! sign_manifest; then
require_signature # non-release: warn and leave the live feed exactly as it was
exit 0
fi
# The signature must cover the bytes a client actually downloads, so a manifest that normalizing
# changed gets re-published with it — otherwise the .asc would describe a file the registry does
# not have, which is the very failure this is repairing. Manifest first, signature second, and
# neither when the stored copy was already clean.
if ! cmp -s "$SUMS" "$FETCHED"; then
echo "normalizing $BASE/SHA256SUMS: $(grep -c '' <"$FETCHED") line(s) served, $(grep -c '' <"$SUMS") kept"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
fi
# The signature must cover the bytes a client actually downloads, and bind_manifest has just
# rewritten the header, so the manifest ALWAYS goes back up with its new signature — an .asc
# describing a file the registry does not have is the very failure this mode repairs. Manifest
# first, signature second: a client caught in the window sees an unsigned feed and refuses.
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
echo "sealed $BASE ($(grep -c '' <"$SUMS") image(s))"
echo "sealed $BASE ($IMAGES image(s))"
exit 0
fi
@@ -161,8 +187,11 @@ if [ "$KEEP" -gt 0 ]; then
done
fi
IMAGES="$(grep -c '' <"$SUMS")"
# Sign the finished manifest BEFORE anything is uploaded — a signing failure on a release must
# abort while the live feed is still whole, not halfway through being replaced.
bind_manifest
sign_manifest || require_signature
# Upload order keeps consumers consistent: image first, then the manifest referencing it, then its
@@ -182,4 +211,4 @@ fi
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"
echo "published $FNAME -> $BASE ($IMAGES image(s) in the feed)"
+76 -3
View File
@@ -27,6 +27,13 @@
# images they describe, so anything able to replace an image could replace its checksum too. The
# public key is baked in below rather than fetched, because a key fetched from the thing you are
# authenticating authenticates nothing.
# A signature alone still only says "we signed these bytes, once" — not which feed they were
# signed for, nor whether they are the current ones. So the manifest also carries a `# FEED` and a
# `# SERIAL` header INSIDE the signed bytes, and fetch_manifest refuses one whose FEED is not the
# feed it fetched, or whose SERIAL is below the highest this box has accepted. Without both, anyone
# who can write the registry WITHOUT the key (a leaked write:package token) copies the canary
# manifest + images into the stable path, or puts last month's back, and every box verifies it
# happily. Same two rules the Rust updater enforces (crates/pf-update-check/src/manifest.rs).
set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
@@ -34,6 +41,7 @@ CONF=/etc/punktfunk-sysext.conf
EXT_DIR=/var/lib/extensions
IMG="$EXT_DIR/punktfunk.raw"
SIDECAR="$EXT_DIR/.punktfunk.version"
FLOOR_FILE="$EXT_DIR/.punktfunk.serial-floor"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc
PF_TMP="$(mktemp -d)"; trap 'rm -rf "$PF_TMP"' EXIT
@@ -65,10 +73,33 @@ need_root() { [ "$(id -u)" = 0 ] || { echo "run as root (sudo)" >&2; exit 1; };
os_version_id() { . /etc/os-release; echo "${VERSION_ID%%.*}"; }
channel() { # shellcheck disable=SC1090
[ -f "$CONF" ] && . "$CONF"; echo "${CHANNEL:-stable}"; }
feed_url() {
# feed_name -> the feed this box reads: f43, f43-canary, … (Fedora major x channel). The publisher
# stamps this same name into the signed manifest, which is what makes the two comparable.
feed_name() {
local suffix=""
[ "$(channel)" = canary ] && suffix="-canary"
echo "$REGISTRY/f$(os_version_id)$suffix"
echo "f$(os_version_id)$suffix"
}
feed_url() { echo "$REGISTRY/$(feed_name)"; }
# The highest manifest serial ever accepted for a feed — the anti-rollback floor, persisted the way
# the Rust updater persists its own per-channel `serial_floor` (crates/punktfunk-host/src/update.rs).
# Per FEED, never global: serials are publish timestamps, so a canary publish would otherwise raise
# the floor above the next stable manifest and lock the stable channel out.
serial_floor() {
local v
v="$(sed -n "s/^$(feed_name) //p" "$FLOOR_FILE" 2>/dev/null | head -n1)"
case "$v" in ''|*[!0-9]*) echo 0 ;; *) echo "$v" ;; esac
}
# Raise it (never lower — the caller compares first). Best-effort and quiet on purpose: `status`
# runs unprivileged, and before the extensions dir exists at all, so it must report the feed rather
# than die — or nag — over not being able to record it. `2>/dev/null` comes BEFORE the redirect it
# is there to silence: redirections are set up left to right, so the other order still prints the
# shell's own "No such file or directory" to the terminal.
raise_serial_floor() {
local f; f="$(feed_name)"
{ grep -v "^$f " "$FLOOR_FILE" 2>/dev/null || :; echo "$f $1"; } 2>/dev/null > "$FLOOR_FILE.new" \
&& mv -f "$FLOOR_FILE.new" "$FLOOR_FILE" 2>/dev/null || :
}
# verify_manifest SUMS SIG -> 0 iff SIG is a good detached signature over SUMS by FEED_KEY.
@@ -91,7 +122,7 @@ verify_manifest() {
# Returns non-zero (having said why) rather than exiting, so `status` can report a bad feed
# instead of dying on it; install/update turn that into a hard stop.
fetch_manifest() {
local feed sums sig
local feed sums sig want got serial floor
feed="$(feed_url)"
sums="$PF_TMP/SHA256SUMS"; sig="$PF_TMP/SHA256SUMS.asc"
curl -fsSL -o "$sums" "$feed/SHA256SUMS" || { echo "cannot reach the feed $feed" >&2; return 1; }
@@ -117,6 +148,31 @@ fetch_manifest() {
echo "!! older than the rotation. Do not install; re-download punktfunk-sysext.sh and retry." >&2
return 1
fi
# Signed — by us, at some point, for something. The two facts a signature cannot carry on its own
# are stamped inside the document (see the Trust note at the top) and checked here.
want="$(feed_name)"
got="$(sed -n 's/^# FEED //p' "$sums" | head -n1)"
serial="$(sed -n 's/^# SERIAL //p' "$sums" | head -n1)"
case "$serial" in ''|*[!0-9]*) serial="" ;; esac
if [ -z "$got" ] || [ -z "$serial" ]; then
echo "!! the feed $feed is signed but UNBOUND: its manifest carries no '# FEED'/'# SERIAL'" >&2
echo "!! header, so the signature says nothing about which feed or which publish it covers." >&2
echo "!! (a feed published before binding existed; it is bound on the next publish, or now with" >&2
echo "!! TOKEN=… bash packaging/bazzite/publish-sysext-feed.sh --seal $want)" >&2
return 1
fi
if [ "$got" != "$want" ]; then
echo "!! this manifest was signed for the feed '$got', but it is being served as '$want' —" >&2
echo "!! another channel's (or another OS release's) feed is being replayed here. Refusing." >&2
return 1
fi
floor="$(serial_floor)"
if [ "$serial" -lt "$floor" ]; then
echo "!! manifest serial $serial is older than the last accepted $floor — refusing rollback." >&2
echo "!! An old but validly-signed manifest is being replayed at $feed." >&2
return 1
fi
if [ "$serial" -gt "$floor" ]; then raise_serial_floor "$serial"; fi
return 0
}
@@ -312,6 +368,21 @@ cmd_update() {
post_merge
return
fi
# Even a correctly bound, in-date manifest can offer only OLDER images (a mistaken republish, an
# over-eager prune). `latest` reports the newest the feed HAS, not the newest that ever shipped,
# so without this the box walks backwards onto a superseded — possibly known-vulnerable —
# release, silently. Rolling back stays possible; it just has to be asked for.
case "$cur" in
[0-9]*)
if [ "$ver" != "$cur" ] \
&& [ "$(printf '%s\n%s\n' "$ver" "$cur" | sort -V | tail -n1)" = "$cur" ] \
&& [ "${PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE:-0}" != 1 ]; then
echo "!! the feed's newest image ($ver) is OLDER than the installed $cur — refusing to" >&2
echo "!! downgrade. For a deliberate rollback:" >&2
echo "!! sudo PUNKTFUNK_SYSEXT_ALLOW_DOWNGRADE=1 punktfunk-sysext update" >&2
exit 1
fi ;;
esac
echo "updating: ${cur:-<none>} -> $ver"
# shellcheck disable=SC2086
do_install $l
@@ -347,6 +418,8 @@ cmd_remove() {
fi
fi
rm -f /etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop
# $FLOOR_FILE deliberately survives: it is anti-rollback state, not installation state, and a
# remove/re-install cycle is the obvious way to hand a box a replayed manifest it already refused.
rm -f "$IMG" "$SIDECAR" "$CONF"
systemd-sysext refresh 2>/dev/null || :
echo "punktfunk sysext removed (user config in ~/.config/punktfunk is untouched)."
+6
View File
@@ -194,6 +194,12 @@ One-time setup (mirrors any new unom DMZ service — see the deploy-infra notes)
1. **Secret** `FLATPAK_GPG_PRIVATE_KEY` on this repo = base64 of the armored private key
(`gpg --armor --export-secret-keys <fpr> | base64 -w0`). `DEPLOY_*` + `REGISTRY_TOKEN` already exist.
Also **`DEPLOY_KNOWN_HOSTS`** — unom-1's SSH host key, `ssh-keyscan -p "$DEPLOY_PORT"
"$DEPLOY_HOST"` — which the deploy `ssh`es against with `StrictHostKeyChecking=yes`. Every run
starts with an empty `known_hosts`, so `accept-new` made *every* run a first contact, handing the
deploy key and the GPG-signed repo to whatever won the race for the address. It gates the step
like the others: until it is set the deploy skips with a warning. Same secret and same host as
the Nix cache publish (see `packaging/nix/README.md`), so configuring either satisfies both.
2. **Edge Caddy** on home-reverse-proxy-1 (`/home/caddy/caddy/Caddyfile`, apply by hand + `./reload.sh`):
`flatpak.unom.io { reverse_proxy 192.168.50.50:3230 }`
3. **Port allowlist:** add `3230` to `caddy_target_ports` in `unom/infra` (proxmox/unom-1) + terraform apply.
+12 -1
View File
@@ -68,11 +68,22 @@ elif [ -f packaging/flatpak/cargo-sources.json ] && [ "${FORCE_GEN:-0}" != "1" ]
echo "==> reusing existing packaging/flatpak/cargo-sources.json (FORCE_GEN=1 to regenerate)"
else
echo "==> generating offline cargo-sources.json from Cargo.lock"
# PINNED to a commit and checked by SHA-256 — same ref+sum as .gitea/workflows/flatpak.yml, so
# the local build path and CI vendor crate sources with the identical script. `master` is a
# mutable ref, and this is third-party python that chooses which crate sources the build (a
# SIGNED one, in CI) vendors. Bump both together, here and in the workflow:
# curl -fsSL .../<new-sha>/cargo/flatpak-cargo-generator.py | sha256sum
GEN_REF=f03a673abe6ce189cea1c2857e2b44af2dd79d1f
GEN_SHA=b373c8ab1a05378ec5d8ed0645c7b127bcec7d2f7a1798694fbc627d570d856c
GEN=/tmp/flatpak-cargo-generator.py
if [ ! -f "$GEN" ]; then
curl -fsSL -o "$GEN" \
https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
"https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GEN_REF/cargo/flatpak-cargo-generator.py"
fi
# Verified on EVERY run, not just after a download: the branch above reuses whatever is already
# at that /tmp path — which on a shared box is a file this script did not write.
echo "$GEN_SHA $GEN" | sha256sum -c - \
|| { echo "error: $GEN does not match the pin for $GEN_REF — rm it and re-run" >&2; exit 1; }
# Needs python3 + aiohttp + tomlkit. On a host that lacks them (e.g. the Deck), generate on the
# Mac / a dev box instead and rsync the result next to the manifest (reused by the branch above).
# Prune the microsoft/windows-rs git crates first (punktfunk-client-windows only) — otherwise
+24 -3
View File
@@ -466,8 +466,8 @@ server; there is no cache daemon to run.
**One-time setup — in this order.** The publish step ends by fetching `nix.unom.io` to prove the
cache really answers (and answers **404**, not 403, for a path it does not hold), so stand the
service up *before* you set the secret that switches publishing on. The secret is the last step for
exactly that reason: until it exists the publish no-ops with a warning and `main` stays green,
service up *before* you set the secrets that switch publishing on. They are the last steps for
exactly that reason: until they exist the publish no-ops with a warning and `main` stays green,
the same way flatpak.yml's repo deploy does.
1. **Ingress — both halves live in `unom/infra`, and they must move together.** Neither the DNS
@@ -518,7 +518,28 @@ the same way flatpak.yml's repo deploy does.
docker run --rm nixos/nix nix --extra-experimental-features nix-command \
key generate-secret --key-name punktfunk-cache-1 # or anywhere with docker
```
4. Push to `main` touching the flake. The publish step also writes the public key to
4. **Deploy host key.** `DEPLOY_KNOWN_HOSTS` is a repo Actions secret holding unom-1's SSH host
key — `ssh-keyscan -p "$DEPLOY_PORT" "$DEPLOY_HOST"` — and the publish `ssh`es with
`StrictHostKeyChecking=yes` against it. Every run starts with an empty `known_hosts`, so
`accept-new` would make *every* run a first contact, handing `DEPLOY_SSH_KEY` and the signed
publish to whatever won the race for the address. It gates the step alongside
`NIX_CACHE_SIGNING_KEY`/`DEPLOY_HOST`: until it is set the publish skips with a warning.
Re-keyscan and re-set it if unom-1's host key ever changes, or the deploy fails closed.
Key it **port-agnostically**, because `ssh` looks a host key up by the exact string it dialled
and `DEPLOY_PORT` is a secret nobody re-reads when re-keying — a plain entry silently fails to
match once the port is not 22, and the failure looks like a host-key error rather than a
formatting one. One line covers both forms:
```
<DEPLOY_HOST>,[<DEPLOY_HOST>]:* ssh-ed25519 AAAA…
```
Pin **ed25519 only**. Pinning every type `ssh-keyscan` prints lets a host offering just RSA
satisfy the check on an RSA line, so the weakest pinned key is the one that decides; one modern
key is both stronger and shorter. `DEPLOY_HOST` is unom-1's public IP (Hetzner, since the
2026-07-12 cutover off proxmox), so scan that address — not a private one from an SSH config.
5. Push to `main` touching the flake. The publish step also writes the public key to
`https://nix.unom.io/punktfunk-cache.pub`, so users can always check the docs against the cache.
`scripts/setup-nix-cache.sh` walks through it interactively, and each stage detects work already
+29 -6
View File
@@ -30,6 +30,24 @@ fi
BASE="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}"
# Download ONE release asset and refuse to unpack it unless its bytes match the pinned SHA-256.
# This binary becomes RUSTC_WRAPPER — every compiler invocation in the release builds, including the
# jobs that hold the RPM/Flatpak signing keys, runs through it — and a GitHub release asset is
# MUTABLE at a fixed URL, so the version in the path vouches for nothing on its own.
# Usage: fetch <arch-triple> <sha256>; leaves the verified tarball in $TARBALL.
fetch() {
TARBALL="$(mktemp)"
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-$1.tar.gz" -o "$TARBALL"
# macOS has shasum but no sha256sum; the Linux images have both.
got="$(sha256sum "$TARBALL" 2>/dev/null || shasum -a 256 "$TARBALL")"
got="${got%% *}"
if [ "$got" != "$2" ]; then
echo "sccache $1 sha256 mismatch: got $got, pinned $2" >&2
echo "(bumping SCCACHE_VERSION? update the pins below it from the release's .sha256 files)" >&2
exit 1
fi
}
case "$(uname -s)" in
Darwin)
# The macOS runner is a LaunchAgent in the user's Aqua session, not root — install into the
@@ -38,12 +56,15 @@ case "$(uname -s)" in
DEST="$HOME/.local/bin"
mkdir -p "$DEST"
case "$(uname -m)" in
arm64|aarch64) ARCH=aarch64-apple-darwin ;;
*) ARCH=x86_64-apple-darwin ;;
arm64|aarch64) ARCH=aarch64-apple-darwin
SHA=5aba39252e2efa26bd76144f87ac59787d60fe567ab785e27e2a8c8190892eac ;;
*) ARCH=x86_64-apple-darwin
SHA=6d4a77802ec83607478df7b6338be28171e65e58a38a49497ebec1fbb300fce4 ;;
esac
fetch "$ARCH" "$SHA"
# bsdtar globs by default and does not accept --wildcards.
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-${ARCH}.tar.gz" \
| tar -xz --strip-components=1 -C "$DEST" '*/sccache'
tar -xz --strip-components=1 -C "$DEST" -f "$TARBALL" '*/sccache'
rm -f "$TARBALL"
chmod 0755 "$DEST/sccache"
PATH="$DEST:$PATH"
export PATH
@@ -56,8 +77,10 @@ case "$(uname -s)" in
# dance is needed. The musl build is static — one binary serves the Ubuntu, Fedora and Arch
# images alike.
DEST=/usr/local/bin
curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
| tar -xz --wildcards --strip-components=1 -C "$DEST" '*/sccache'
fetch x86_64-unknown-linux-musl \
1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b
tar -xz --wildcards --strip-components=1 -C "$DEST" -f "$TARBALL" '*/sccache'
rm -f "$TARBALL"
chmod 0755 "$DEST/sccache"
;;
esac
+1
View File
@@ -290,6 +290,7 @@
"pairing_approve_known_note": "Dieses Gerät war schon gekoppelt — sein bisheriger Zugriff ist vorausgefüllt.",
"pairing_approve_guest": "Als Gast freigeben",
"pairing_approve_guest_hint": "Nur Controller, läuft nach 4 Stunden ab.",
"pairing_password_help": "Ein gekoppeltes Gerät kann diesen Rechner steuern, deshalb wird das Passwort erneut gebraucht — eine Browser-Sitzung allein kann nichts koppeln.",
"access_level_label": "Zugriffsstufe",
"access_level_full": "Vollzugriff",
"access_level_controller": "Nur Controller",

Some files were not shown because too many files have changed in this diff Show More