diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7a587435..62b24bab8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -30,18 +33,57 @@ jobs: workspace: name: Workspace checks runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 45 steps: - name: Install system dependencies run: | set -euo pipefail + rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index + printf '%s\n' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot + apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ dbus \ git \ @@ -49,6 +91,7 @@ jobs: libgtk-4-dev \ libgtk4-layer-shell-dev \ pkg-config \ + ripgrep \ shellcheck \ xauth \ xvfb \ @@ -63,9 +106,34 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.96.1 --profile minimal --component rustfmt,clippy - echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" + account_home="$(getent passwd "$(id -u)" | cut -d: -f6)" + test -n "$account_home" + case "$account_home" in + /*) ;; + *) + echo "effective account home is not absolute: $account_home" >&2 + exit 1 + ;; + esac + # Keep the CI toolchain under the same account-owned home used by the installer + export HOME="$account_home" + export CARGO_HOME="$account_home/.cargo" + export RUSTUP_HOME="$account_home/.rustup" + printf '%s\n' \ + "CARGO_HOME=$CARGO_HOME" \ + "RUSTUP_HOME=$RUSTUP_HOME" \ + >> "$GITHUB_ENV" + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" -y --profile minimal --default-toolchain 1.96.1 \ + --component rustfmt,clippy --no-modify-path + rm -f "$rustup_init" + echo "${CARGO_HOME}/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" rustup default 1.96.1 cargo install cargo-audit --locked --version 0.22.0 @@ -92,6 +160,7 @@ jobs: shellcheck \ scripts/package-release.sh \ tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-test-placement.sh \ tests/check-no-personal-paths.sh \ crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib \ @@ -101,6 +170,7 @@ jobs: shellharden --check \ scripts/package-release.sh \ tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-test-placement.sh \ tests/check-no-personal-paths.sh @@ -110,11 +180,43 @@ jobs: - name: Check tracked paths for personal data run: tests/check-no-personal-paths.sh + - name: Check release workflow hardening + run: tests/check-release-hardening.sh + - name: Test release packaging helpers run: tests/package-release.sh + - name: Build SVG test helper + run: cargo build --package unixnotis-center --bin unixnotis-svg-renderer --all-features + - name: Run workspace tests - run: xvfb-run -a dbus-run-session -- cargo test --workspace --all-targets --all-features + run: | + set -euo pipefail + xvfb-run -a dbus-run-session -- \ + bash -c ' + set -euo pipefail + user_bus_dir="/run/user/$(id -u)" + stable_bus="${user_bus_dir}/bus" + session_bus="${DBUS_SESSION_BUS_ADDRESS#unix:path=}" + session_bus="${session_bus%%,guid=*}" + case "$session_bus" in + /*) ;; + *) + echo "dbus-run-session did not provide a filesystem bus address" >&2 + exit 1 + ;; + esac + mkdir -p "$user_bus_dir" + chmod 0700 "$user_bus_dir" + if [[ ! -e "$stable_bus" && ! -L "$stable_bus" ]]; then + ln -s -- "$session_bus" "$stable_bus" + cleanup_stable_bus() { + rm -f -- "$stable_bus" + } + trap cleanup_stable_bus EXIT + fi + cargo test --workspace --all-targets --all-features + ' - name: Run dependency audit run: cargo audit --deny warnings diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 243f27372..6493e849d 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -22,6 +22,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -31,18 +34,57 @@ jobs: mutation: name: Cargo mutants runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 120 steps: - name: Install system dependencies run: | set -euo pipefail + rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index + printf '%s\n' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot + apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ dbus \ git \ @@ -58,8 +100,16 @@ jobs: - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --profile minimal + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" -y --profile minimal --default-toolchain 1.96.1 \ + --no-modify-path + rm -f "$rustup_init" echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" rustup default 1.96.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d6d58034..da4a17389 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,8 @@ on: required: true type: string -permissions: - # The workflow builds archives only; publishing a GitHub Release stays a manual action - contents: read +# Jobs opt into only the token capabilities used by their own phase +permissions: {} concurrency: # A release tag should produce one archive set, so do not cancel a run already packaging it @@ -21,6 +20,9 @@ concurrency: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + DEBIAN_SNAPSHOT: 20260720T000000Z + RUSTUP_INIT_VERSION: 1.28.2 + RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c defaults: run: @@ -30,71 +32,203 @@ jobs: package: name: Build release tarball runs-on: ubuntu-24.04 - container: debian:trixie-slim + container: debian:trixie-slim@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd timeout-minutes: 45 + permissions: + contents: read steps: + - name: Validate release tag input + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'release tag must look like v1.1.0: %s\n' "$RELEASE_TAG" >&2 + exit 2 + fi + if [[ "$GITHUB_REF" != "refs/tags/${RELEASE_TAG}" ]]; then + printf 'workflow ref %s must match release tag %s\n' "$GITHUB_REF" "$RELEASE_TAG" >&2 + exit 2 + fi + - name: Install system dependencies run: | set -euo pipefail + # Immutable snapshots keep package resolution stable across release reruns + rm -f /etc/apt/sources.list + # The slim image bootstraps without a CA bundle; InRelease signatures still authenticate the immutable index + printf '%s\n' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie trixie-updates' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/debian.sources + # Snapshot signatures are expected to be expired when an old release is rebuilt + printf '%s\n' 'Acquire::Check-Valid-Until "false";' \ + > /etc/apt/apt.conf.d/99snapshot + apt-get update + verify_snapshot_index() { + local expected="$1" + local pattern="$2" + local path + path="$(find /var/lib/apt/lists -maxdepth 1 -type f -name "$pattern" -print -quit)" + test -n "$path" + printf '%s %s\n' "$expected" "$path" | sha256sum --check --strict + } + # Pin the bootstrap indexes so HTTP cannot replay an older signed snapshot + verify_snapshot_index \ + 98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie_InRelease" + verify_snapshot_index \ + bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7 \ + "*snapshot.debian.org_archive_debian_${DEBIAN_SNAPSHOT}_dists_trixie-updates_InRelease" + verify_snapshot_index \ + ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41 \ + "*snapshot.debian.org_archive_debian-security_${DEBIAN_SNAPSHOT}_dists_trixie-security_InRelease" + # Bootstrap the trust store over the signed snapshot index + apt-get install -y --no-install-recommends ca-certificates + sed -i 's#http://snapshot.debian.org#https://snapshot.debian.org#g' \ + /etc/apt/sources.list.d/debian.sources apt-get update apt-get install -y --no-install-recommends \ bash \ build-essential \ - ca-certificates \ curl \ + gettext-base \ git \ libgtk-4-dev \ libgtk4-layer-shell-dev \ pkg-config \ + python3 \ shellcheck \ xz-utils \ zstd - name: Check out repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + + - name: Trust checked-out repository + run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + - name: Verify release source commit + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag_ref="refs/tags/${RELEASE_TAG}" + tag_commit="$(git rev-parse "${tag_ref}^{commit}")" + checked_out_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" != "$GITHUB_SHA" || "$checked_out_commit" != "$GITHUB_SHA" ]]; then + printf 'release tag, workflow commit, and checkout must resolve to one commit\n' >&2 + exit 2 + fi - name: Install Rust toolchain run: | set -euo pipefail - curl --proto '=https' --tlsv1.2 -fsS https://sh.rustup.rs \ - | sh -s -- -y --profile minimal + rustup_init="${RUNNER_TEMP}/rustup-init" + curl --proto '=https' --tlsv1.2 -fsS \ + "https://static.rust-lang.org/rustup/archive/${RUSTUP_INIT_VERSION}/x86_64-unknown-linux-gnu/rustup-init" \ + -o "$rustup_init" + printf '%s %s\n' "$RUSTUP_INIT_SHA256" "$rustup_init" \ + | sha256sum --check --strict + chmod 0755 "$rustup_init" + "$rustup_init" \ + -y \ + --profile minimal \ + --default-toolchain 1.96.1 \ + --no-modify-path + rm -f "$rustup_init" echo "${HOME}/.cargo/bin" >> "${GITHUB_PATH}" source "${HOME}/.cargo/env" - rustup default 1.96.1 - cargo install shellharden --locked --version 4.3.2 - name: Check packaging script run: | shellcheck \ scripts/package-release.sh \ tests/package-release.sh \ - tests/check-no-personal-paths.sh - shellharden --check \ - scripts/package-release.sh \ - tests/package-release.sh \ + tests/check-release-hardening.sh \ tests/check-no-personal-paths.sh tests/check-no-personal-paths.sh + tests/check-release-hardening.sh tests/package-release.sh - name: Build package archive - # The script checks that Cargo's version matches the requested release tag env: # Environment transport prevents workflow input from becoming Bash source text RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'release tag must look like v1.1.0: %s\n' "$RELEASE_TAG" >&2 - exit 2 - fi + # The packager also binds the requested tag to Cargo's workspace version scripts/package-release.sh "$RELEASE_TAG" - - name: Upload package archive + - name: Upload unsigned package archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unixnotis-${{ inputs.tag }}-unsigned + path: | + dist/*.tar.zst + dist/*.sha256 + if-no-files-found: error + + sign: + name: Sign and attest release tarball + needs: package + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + # Only this job can request the short-lived signing identity + id-token: write + attestations: write + artifact-metadata: write + + steps: + - name: Download unsigned package archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unixnotis-${{ inputs.tag }}-unsigned + path: dist + + - name: Install artifact signer + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + with: + cosign-release: v3.1.2 + + - name: Sign package files + run: | + set -euo pipefail + for artifact in dist/*.tar.zst dist/*.sha256; do + # Each bundle carries the certificate, signature, and transparency proof + cosign sign-blob \ + --yes \ + --bundle "${artifact}.sigstore.json" \ + "$artifact" + done + + - name: Attest package provenance + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-path: | + dist/*.tar.zst + dist/*.sha256 + + - name: Upload signed package archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unixnotis-${{ inputs.tag }}-release path: | dist/*.tar.zst dist/*.sha256 + dist/*.sigstore.json if-no-files-found: error diff --git a/Cargo.lock b/Cargo.lock index 3474ccee2..23e5e93b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,24 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -282,9 +300,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" @@ -328,6 +346,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -352,7 +376,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -544,6 +568,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -565,7 +595,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -779,11 +809,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1233,7 +1262,7 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -1248,6 +1277,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "glib-build-tools" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86aebe63bb050d4918cb1d629880cb35fcba7ccda6f6fc0ec1beffdaa1b9d5c3" +dependencies = [ + "gio", +] + [[package]] name = "glib-macros" version = "0.21.5" @@ -1363,7 +1401,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1d422cce9367945916b7a5083eedf67b0a5380d326af1943a0b5cef9afb6e48" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "gdk4", "glib", "glib-sys", @@ -1452,6 +1490,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -1803,13 +1846,19 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.7.4", @@ -1821,7 +1870,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -1859,11 +1908,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.0", ] [[package]] @@ -1962,7 +2011,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -1981,7 +2030,7 @@ dependencies = [ [[package]] name = "noticenterctl" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "blake3", @@ -2010,7 +2059,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2098,6 +2147,39 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.21.5" @@ -2293,7 +2375,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -2389,7 +2471,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.11.1", + "bitflags 2.13.1", "num-traits", "rand 0.9.5", "rand_chacha 0.9.0", @@ -2509,31 +2591,35 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.0", "itertools", "kasuari", "lru", + "palette", + "serde", "strum", "thiserror 2.0.18", "unicode-segmentation", @@ -2543,9 +2629,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -2555,19 +2641,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -2575,17 +2672,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", + "bitflags 2.13.1", + "hashbrown 0.17.0", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", @@ -2598,7 +2696,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2607,7 +2705,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -2686,7 +2784,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -2784,6 +2882,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2949,6 +3048,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -2966,18 +3071,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -3071,6 +3176,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3100,7 +3218,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.1", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -3266,6 +3384,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -3448,6 +3581,36 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + [[package]] name = "typenum" version = "1.19.0" @@ -3483,6 +3646,31 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-security" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e4ddba1535dd35ed8b61c52166b7155d7f4e4b8847cec6f48e71dc66d8b5e50" +dependencies = [ + "unicode-normalization", + "unicode-script", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -3514,11 +3702,12 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unixnotis-center" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "async-channel", "blake3", + "chrono", "clap", "crossbeam-channel", "fast_image_resize", @@ -3534,6 +3723,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "tempfile", "tokio", "toml 0.8.23", "tracing", @@ -3547,8 +3737,10 @@ dependencies = [ [[package]] name = "unixnotis-core" -version = "1.2.0" +version = "1.3.0" dependencies = [ + "anyhow", + "blake3", "chrono", "image", "proptest", @@ -3559,52 +3751,70 @@ dependencies = [ "serde_repr", "shell-words", "thiserror 1.0.69", + "tokio", "toml 0.8.23", + "toml_edit 0.22.27", "tracing", "tracing-subscriber", + "unicode-width", "zbus", ] [[package]] name = "unixnotis-daemon" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", + "arc-swap", + "blake3", "chrono", "clap", "futures-util", + "gio", + "image", "indexmap", + "libc", + "notify", "rustix", "serde", "serde_json", + "shell-words", "tokio", "tracing", "tracing-subscriber", - "unicode-width", + "tree-sitter", + "tree-sitter-bash", + "unicode-security", "unixnotis-core", + "url", + "wait-timeout", "zbus", ] [[package]] name = "unixnotis-installer" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", - "chrono", "crossterm", + "libc", "ratatui", "rustix", "semver", "serde", "serde_json", + "sha2", + "tokio", "toml 0.8.23", "unicode-width", "unixnotis-core", + "wait-timeout", + "zbus", ] [[package]] name = "unixnotis-popups" -version = "1.2.0" +version = "1.3.0" dependencies = [ "anyhow", "async-channel", @@ -3617,6 +3827,7 @@ dependencies = [ "gtk4-layer-shell", "image", "proptest", + "rustix", "tokio", "tracing", "tracing-subscriber", @@ -3627,8 +3838,9 @@ dependencies = [ [[package]] name = "unixnotis-ui" -version = "1.2.0" +version = "1.3.0" dependencies = [ + "glib-build-tools", "gtk4", "notify", "serde", @@ -3839,7 +4051,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4252,7 +4464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 73178ef11..878d04219 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,13 @@ members = [ resolver = "2" [workspace.package] -version = "1.2.0" +version = "1.3.0" edition = "2021" license = "MIT" [workspace.dependencies] anyhow = "1" +arc-swap = "1.9" async-channel = "2" blake3 = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } @@ -33,6 +34,7 @@ serde_ignored = "0.1" sha2 = "0.10" tar = "0.4" toml = "0.8" +toml_edit = "0.22.27" thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time", "process", "io-util" ] } tracing = "0.1" @@ -42,28 +44,31 @@ wait-timeout = "0.2" zbus = { version = "4", default-features = false, features = ["tokio"] } gio = "0.21" -gdk-pixbuf = "0.21" gdk4-wayland = { version = "0.10.3", features = ["v4_18"] } glib = "0.21" -gtk = { package = "gtk4", version = "0.10" } +glib-build-tools = "0.21" +gtk = { package = "gtk4", version = "0.10", features = ["v4_18"] } gtk4-layer-shell = "0.7.1" indexmap = "2" libc = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "tiff", "webp", "ico"] } -ratatui = "0.30.0" +ratatui = "0.30.2" proptest = "1.11.0" crossterm = "0.29" data-url = "0.3" unicode-width = "0.2.2" -rustix = { version = "1.1", features = ["event", "fs", "process"] } +unicode-security = "0.1.2" +rustix = { version = "1.1", features = ["event", "fs", "process", "rand"] } resvg = { version = "0.47.0", default-features = false } semver = "1.0.28" shell-words = "1.1.1" +tree-sitter = "0.25" +tree-sitter-bash = "0.25.1" [workspace.metadata.unixnotis.installer] # Installer-managed binaries live in workspace metadata to avoid duplication between build and install logic. # This list is the single source of truth for unixnotis-installer binary deployment. -binaries = ["unixnotis-daemon", "unixnotis-popups", "unixnotis-center", "unixnotis-css-validate", "noticenterctl"] +binaries = ["unixnotis-daemon", "unixnotis-popups", "unixnotis-center", "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl"] [profile.release] # Keep release builds optimized across crate boundaries diff --git a/README.md b/README.md index 1885c76f3..6698aa755 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ git clone https://github.com/locainin/UnixNotis.wiki.git ## Features - Freedesktop.org notification daemon with history, rules, sound, and DND. -- Persistent DND state across daemon restarts. +- Persistent and timed DND state across daemon restarts. +- KDE-compatible inline replies in the control-center panel for live notifications that advertise reply support. - Control-center panel with widgets, notification list, and media controls. - Toast popup UI with configurable timeouts and styling. - D-Bus inhibit API for programmatic popup suppression. @@ -82,25 +83,10 @@ When launched from a downloaded release, the installer verifies the bundled bina them into `$HOME/.local/bin` instead of building from source. The TUI shows the installed version and reports when a newer GitHub release is available. -For a shareable configuration, theme, D-Bus, and service report, run: - -```sh -noticenterctl doctor -noticenterctl doctor --verbose -noticenterctl doctor --json -noticenterctl doctor --config "$HOME/path/to/config.toml" -noticenterctl css-check --config "$HOME/path/to/config.toml" -``` - -Verbose systemd reports include a sanitized, bounded window of up to 30 user-journal lines. -Review verbose output before posting it because application metadata can still be present. -Dinit, runit, s6-rc, manual, and unknown launches report service status without pretending that -the installed artifacts provide persistent logs. - Maintainers can build a local release archive manually: ```sh -scripts/package-release.sh v1.2.0 +scripts/package-release.sh v1.3.0 ``` ## Development diff --git a/crates/noticenterctl/Cargo.toml b/crates/noticenterctl/Cargo.toml index 6f250e692..9117d293e 100644 --- a/crates/noticenterctl/Cargo.toml +++ b/crates/noticenterctl/Cargo.toml @@ -21,7 +21,7 @@ toml.workspace = true url.workspace = true wait-timeout.workspace = true zbus.workspace = true -shell-words = "1" +shell-words.workspace = true unixnotis-core = { path = "../unixnotis-core" } [dev-dependencies] diff --git a/crates/noticenterctl/src/app/local.rs b/crates/noticenterctl/src/app/local.rs index f5546f89e..667351431 100644 --- a/crates/noticenterctl/src/app/local.rs +++ b/crates/noticenterctl/src/app/local.rs @@ -4,17 +4,21 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use crate::cli::{Command, PresetCommand}; +use crate::cli::{Command, PresetCommand, ThemeCommand}; pub(super) fn handle_local_command( command: Command, mut run_css: impl FnMut(Option) -> Result<()>, mut run_preset: impl FnMut(PresetCommand) -> Result<()>, + mut sync_session: impl FnMut(crate::cli::DoctorServiceManagerArg) -> Result<()>, + mut run_theme: impl FnMut(ThemeCommand) -> Result<()>, ) -> Result<()> { // Local commands remain available while the session bus or daemon is unavailable match command { Command::CssCheck { config } => run_css(config), Command::Preset { command } => run_preset(command).context("preset command failed"), + Command::SyncSessionEnvironment { service_manager } => sync_session(service_manager), + Command::Theme { command } => run_theme(command).context("theme command failed"), // The caller routes daemon-backed commands before reaching this helper _ => Ok(()), } diff --git a/crates/noticenterctl/src/app/runner.rs b/crates/noticenterctl/src/app/runner.rs index 167230dc0..4b31d3c6f 100644 --- a/crates/noticenterctl/src/app/runner.rs +++ b/crates/noticenterctl/src/app/runner.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use clap::Parser; -use unixnotis_core::ControlProxy; +use unixnotis_core::{ensure_control_api_version, log_session_bus_identity, ControlProxy}; use zbus::Connection; use crate::cli::{Args, Command}; @@ -13,10 +13,18 @@ pub fn run() -> Result<()> { // Parse CLI arguments before any daemon work starts let args = Args::parse(); let command = args.command; + // Semantic checks happen before runtime and D-Bus setup + command.validate()?; if command.is_synchronous() { // Preset and CSS work should not pay for an unused asynchronous runtime - handle_local_command(command, crate::css_check::run, crate::preset::run_preset)?; + handle_local_command( + command, + crate::css_check::run, + crate::preset::run_preset, + crate::session_environment::sync, + crate::theme::run, + )?; return Ok(()); } @@ -50,9 +58,15 @@ async fn run_async(command: Command) -> Result<()> { let connection = Connection::session() .await .context("connect to session bus")?; + log_session_bus_identity(&connection, "noticenterctl") + .await + .context("read noticenterctl session-bus identity")?; let proxy = ControlProxy::new(&connection) .await .context("connect to unixnotis control interface")?; + ensure_control_api_version(&proxy) + .await + .context("validate UnixNotis component version")?; crate::dbus::handle_command(&proxy, command).await } diff --git a/crates/noticenterctl/src/app/tests/local.rs b/crates/noticenterctl/src/app/tests/local.rs index e45f81576..47f51d70a 100644 --- a/crates/noticenterctl/src/app/tests/local.rs +++ b/crates/noticenterctl/src/app/tests/local.rs @@ -18,6 +18,8 @@ fn daemon_command_is_not_dispatched_to_local_handlers() { preset_called = true; Ok(()) }, + |_| Ok(()), + |_| Ok(()), ) .expect("ignore daemon command in local dispatcher"); @@ -34,6 +36,8 @@ fn local_handler_error_is_returned_to_the_caller() { Command::CssCheck { config: None }, |_| anyhow::bail!("CSS check failed"), |_| -> Result<()> { Ok(()) }, + |_| -> Result<()> { Ok(()) }, + |_| -> Result<()> { Ok(()) }, ); let error = result.expect_err("local command failure should be returned"); diff --git a/crates/noticenterctl/src/app/tests/runner.rs b/crates/noticenterctl/src/app/tests/runner.rs index 2c6caf6c4..952ba46df 100644 --- a/crates/noticenterctl/src/app/tests/runner.rs +++ b/crates/noticenterctl/src/app/tests/runner.rs @@ -18,6 +18,8 @@ fn handle_local_command_runs_css_check_branch() { Ok(()) }, |_| -> Result<()> { panic!("preset runner should not be called for css check") }, + |_| -> Result<()> { panic!("session runner should not be called for css check") }, + |_| -> Result<()> { panic!("theme runner should not be called for css check") }, ) .expect("css check should dispatch"); @@ -43,6 +45,8 @@ fn handle_local_command_runs_preset_branch_with_command_payload() { preset_called.set(true); Ok(()) }, + |_| -> Result<()> { panic!("session runner should not be called for preset command") }, + |_| -> Result<()> { panic!("theme runner should not be called for preset command") }, ) .expect("preset should dispatch"); diff --git a/crates/noticenterctl/src/cli/args.rs b/crates/noticenterctl/src/cli/args.rs index 9ed04788a..1312a77b9 100644 --- a/crates/noticenterctl/src/cli/args.rs +++ b/crates/noticenterctl/src/cli/args.rs @@ -104,4 +104,19 @@ pub enum PresetCommand { Inspect { input: String, }, + // Replace the local configuration and bundled scripts with current defaults + ResetConfig { + /// Skip the interactive confirmation prompt + #[arg(long)] + yes: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ThemeCommand { + // Export editable copies of the bundled theme into a new directory + ExportStock { + #[arg(long, value_name = "DIRECTORY")] + output: Option, + }, } diff --git a/crates/noticenterctl/src/cli/command.rs b/crates/noticenterctl/src/cli/command.rs index 03b174ca1..019c3bc7d 100644 --- a/crates/noticenterctl/src/cli/command.rs +++ b/crates/noticenterctl/src/cli/command.rs @@ -2,8 +2,9 @@ use std::path::PathBuf; use clap::Subcommand; -use super::args::{DndState, DoctorServiceManagerArg, PresetCommand}; +use super::args::{DndState, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; use super::{DebugLevelArg, InhibitScopeArg}; +use super::{DndClockTime, DndDuration}; #[derive(Subcommand, Debug)] pub enum Command { @@ -16,10 +17,16 @@ pub enum Command { }, // Close the panel if it is visible ClosePanel, + // Rebuild the daemon's desktop application index immediately + RefreshApplications, // Set or toggle Do Not Disturb mode Dnd { #[arg(value_enum)] state: DndState, + #[arg(long = "for", value_name = "DURATION", conflicts_with = "until")] + for_duration: Option, + #[arg(long, value_name = "HH:MM", conflicts_with = "for_duration")] + until: Option, }, // Clear active notifications and saved history Clear, @@ -33,6 +40,10 @@ pub enum Command { Dismiss { id: u32, }, + // Explain application identity and popup suppression for one active notification + ExplainNotification { + id: u32, + }, // List active notifications; full output requires diagnostic mode ListActive { #[arg(long)] @@ -71,24 +82,61 @@ pub enum Command { #[arg(long, value_name = "PATH")] config: Option, }, + // Import the compositor session environment and restart the installed user service + SyncSessionEnvironment { + #[arg(long, value_enum, default_value = "auto")] + service_manager: DoctorServiceManagerArg, + }, // Export, inspect, or import a shareable preset bundle Preset { #[command(subcommand)] command: PresetCommand, }, + // Export editable bundled theme files without changing the active configuration + Theme { + #[command(subcommand)] + command: ThemeCommand, + }, } impl Command { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + if let Self::Dnd { + state, + for_duration, + until, + } = self + { + let has_deadline = for_duration.is_some() || until.is_some(); + if has_deadline && !matches!(state, DndState::On) { + return Err(anyhow::anyhow!( + "--for and --until are valid only with `dnd on`" + )); + } + } + Ok(()) + } + pub(crate) const fn is_local_only(&self) -> bool { // Local-only commands should not fail just because D-Bus is unavailable matches!( self, - Self::CssCheck { .. } | Self::Doctor { .. } | Self::Preset { .. } + Self::CssCheck { .. } + | Self::Doctor { .. } + | Self::Preset { .. } + | Self::Theme { .. } + | Self::SyncSessionEnvironment { .. } ) } pub(crate) const fn is_synchronous(&self) -> bool { // Doctor uses local inputs but still needs asynchronous D-Bus and process timeouts - matches!(self, Self::CssCheck { .. } | Self::Preset { .. }) + matches!( + self, + Self::CssCheck { .. } + | Self::Preset { .. } + | Self::Theme { .. } + | Self::SyncSessionEnvironment { .. } + ) } } diff --git a/crates/noticenterctl/src/cli/dnd.rs b/crates/noticenterctl/src/cli/dnd.rs new file mode 100644 index 000000000..312bf1c08 --- /dev/null +++ b/crates/noticenterctl/src/cli/dnd.rs @@ -0,0 +1,127 @@ +//! Timed Do Not Disturb command value parsing and deadline resolution + +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use chrono::{Days, Local, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc}; + +// Relative durations stay bounded so persisted deadlines remain operationally useful +const MAX_DND_DURATION_SECONDS: u64 = 365 * 24 * 60 * 60; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DndDuration { + seconds: u64, +} + +impl DndDuration { + pub fn deadline(self) -> Result { + // The daemon receives one absolute timestamp so restarts do not reset the duration + Utc::now() + .timestamp() + .checked_add_unsigned(self.seconds) + .ok_or_else(|| anyhow!("DND duration exceeds the supported timestamp range")) + } +} + +impl FromStr for DndDuration { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + // The final ASCII byte selects the only supported duration unit + let Some(unit) = value.as_bytes().last().copied() else { + return Err("duration cannot be empty".to_string()); + }; + let multiplier = match unit { + b's' => 1, + b'm' => 60, + b'h' => 60 * 60, + b'd' => 24 * 60 * 60, + _ => return Err("duration must end in s, m, h, or d".to_string()), + }; + // Supported suffixes are one-byte ASCII, so this boundary is always valid + let digits = &value[..value.len() - 1]; + let amount = digits + .parse::() + .map_err(|_error| "duration must start with a positive integer".to_string())?; + // Checked multiplication rejects large values before the policy bound is applied + let seconds = amount + .checked_mul(multiplier) + .ok_or_else(|| "duration is too large".to_string())?; + if seconds == 0 || seconds > MAX_DND_DURATION_SECONDS { + return Err("duration must be between 1 second and 365 days".to_string()); + } + Ok(Self { seconds }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DndClockTime { + time: NaiveTime, +} + +impl DndClockTime { + pub fn deadline(self) -> Result { + // Resolve against the machine timezone because HH:MM is a local wall-clock value + let now = Local::now(); + let now_timestamp = now.timestamp(); + if let Some(deadline) = local_deadline_after(now.date_naive(), self.time, now_timestamp) { + // A repeated local hour may still have a second future occurrence today + return Ok(deadline); + } + + // A missing or elapsed occurrence today advances by one calendar day + let tomorrow = tomorrow_date(now.date_naive())?; + local_deadline_after(tomorrow, self.time, now_timestamp) + .ok_or_else(|| anyhow!("requested local time does not exist on the next calendar date")) + } +} + +impl FromStr for DndClockTime { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + let bytes = value.as_bytes(); + // Exact width avoids accepting locale-specific or partly parsed clock forms + if bytes.len() != 5 + || bytes[2] != b':' + || !bytes[..2].iter().all(u8::is_ascii_digit) + || !bytes[3..].iter().all(u8::is_ascii_digit) + { + return Err("time must use 24-hour HH:MM format".to_string()); + } + let time = NaiveTime::parse_from_str(value, "%H:%M") + .map_err(|_error| "time must use 24-hour HH:MM format".to_string())?; + Ok(Self { time }) + } +} + +fn tomorrow_date(today: NaiveDate) -> Result { + // Calendar addition remains correct across daylight-saving offset changes + today + .checked_add_days(Days::new(1)) + .ok_or_else(|| anyhow!("next DND date exceeds the supported calendar range")) +} + +fn local_deadline_after(date: NaiveDate, time: NaiveTime, after: i64) -> Option { + let local = Local.from_local_datetime(&date.and_time(time)); + match local { + LocalResult::Single(value) => future_timestamp(after, Some(value.timestamp()), None), + // Repeated hours expose both absolute instants for future filtering + LocalResult::Ambiguous(first, second) => { + future_timestamp(after, Some(first.timestamp()), Some(second.timestamp())) + } + // A skipped wall-clock time has no deadline on this date + LocalResult::None => None, + } +} + +pub(super) fn future_timestamp(after: i64, first: Option, second: Option) -> Option { + // Select by absolute time so repeated wall-clock hours remain correct + [first, second] + .into_iter() + .flatten() + .filter(|candidate| *candidate > after) + .min() +} diff --git a/crates/noticenterctl/src/cli/mod.rs b/crates/noticenterctl/src/cli/mod.rs index ff6919278..0aa906212 100644 --- a/crates/noticenterctl/src/cli/mod.rs +++ b/crates/noticenterctl/src/cli/mod.rs @@ -2,10 +2,12 @@ mod args; mod command; +mod dnd; -pub use args::{Args, DndState, DoctorServiceManagerArg, PresetCommand}; +pub use args::{Args, DndState, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; pub use args::{DebugLevelArg, InhibitScopeArg}; pub use command::Command; +pub use dnd::{DndClockTime, DndDuration}; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/cli/tests/args.rs b/crates/noticenterctl/src/cli/tests/args.rs index a838b1c7e..1b1755013 100644 --- a/crates/noticenterctl/src/cli/tests/args.rs +++ b/crates/noticenterctl/src/cli/tests/args.rs @@ -18,6 +18,13 @@ fn parses_open_panel_debug_default() { } } +#[test] +fn parses_refresh_applications() { + let args = Args::try_parse_from(["noticenterctl", "refresh-applications"]) + .expect("refresh command should parse"); + assert!(matches!(args.command, Command::RefreshApplications)); +} + #[test] fn parses_open_panel_debug_value() { // Verifies explicit debug values map to the requested verbosity @@ -36,13 +43,63 @@ fn parses_dnd_toggle() { // Confirms the value enum accepts the toggle state for DND commands let args = Args::try_parse_from(["noticenterctl", "dnd", "toggle"]).expect("parse args"); match args.command { - Command::Dnd { state } => { + Command::Dnd { + state, + for_duration, + until, + } => { assert!(matches!(state, DndState::Toggle)); + assert!(for_duration.is_none()); + assert!(until.is_none()); } other => panic!("unexpected command: {other:?}"), } } +#[test] +fn parses_timed_dnd_duration_and_clock_deadline() { + let duration = + Args::try_parse_from(["noticenterctl", "dnd", "on", "--for", "30m"]).expect("duration"); + assert!(matches!( + duration.command, + Command::Dnd { + state: DndState::On, + for_duration: Some(_), + until: None, + } + )); + + let until = + Args::try_parse_from(["noticenterctl", "dnd", "on", "--until", "08:00"]).expect("clock"); + assert!(matches!( + until.command, + Command::Dnd { + state: DndState::On, + for_duration: None, + until: Some(_), + } + )); +} + +#[test] +fn timed_dnd_options_conflict_and_require_on_state_semantically() { + assert!(Args::try_parse_from([ + "noticenterctl", + "dnd", + "on", + "--for", + "30m", + "--until", + "08:00" + ]) + .is_err()); + + let command = Args::try_parse_from(["noticenterctl", "dnd", "off", "--for", "30m"]) + .expect("syntax should parse") + .command; + assert!(command.validate().is_err()); +} + #[test] fn parses_explicit_clear_variants() { for (name, expected) in [ @@ -186,6 +243,18 @@ fn parses_preset_inspect() { } } +#[test] +fn parses_preset_reset_config_confirmation_flag() { + let args = Args::try_parse_from(["noticenterctl", "preset", "reset-config", "--yes"]) + .expect("parse reset-config"); + assert!(matches!( + args.command, + Command::Preset { + command: PresetCommand::ResetConfig { yes: true } + } + )); +} + #[test] fn parses_doctor_output_and_service_manager_options() { let args = Args::try_parse_from([ @@ -209,6 +278,24 @@ fn parses_doctor_output_and_service_manager_options() { )); } +#[test] +fn parses_session_environment_service_manager_without_shell_payloads() { + let args = Args::try_parse_from([ + "noticenterctl", + "sync-session-environment", + "--service-manager", + "runit", + ]) + .expect("parse session environment command"); + + assert!(matches!( + args.command, + Command::SyncSessionEnvironment { + service_manager: DoctorServiceManagerArg::Runit, + } + )); +} + #[test] fn doctor_and_css_check_accept_explicit_config_paths() { let doctor = Args::try_parse_from([ diff --git a/crates/noticenterctl/src/cli/tests/command.rs b/crates/noticenterctl/src/cli/tests/command.rs index b84cd2b00..a6c8c6dce 100644 --- a/crates/noticenterctl/src/cli/tests/command.rs +++ b/crates/noticenterctl/src/cli/tests/command.rs @@ -1,6 +1,6 @@ use clap::Parser; -use super::super::{Args, Command, DoctorServiceManagerArg, PresetCommand}; +use super::super::{Args, Command, DoctorServiceManagerArg, PresetCommand, ThemeCommand}; #[test] fn local_only_classification_distinguishes_local_and_control_commands() { @@ -18,6 +18,10 @@ fn local_only_classification_distinguishes_local_and_control_commands() { } } .is_local_only()); + assert!(Command::Theme { + command: ThemeCommand::ExportStock { output: None } + } + .is_local_only()); assert!(!Command::ClearActive.is_local_only()); } @@ -39,6 +43,10 @@ fn synchronous_classification_builds_a_runtime_only_when_needed() { } } .is_synchronous()); + assert!(Command::Theme { + command: ThemeCommand::ExportStock { output: None } + } + .is_synchronous()); assert!(!Command::Doctor { json: false, @@ -49,3 +57,23 @@ fn synchronous_classification_builds_a_runtime_only_when_needed() { .is_synchronous()); assert!(!Command::ClearActive.is_synchronous()); } + +#[test] +fn theme_export_stock_is_local_and_accepts_an_optional_directory() { + let args = Args::try_parse_from([ + "noticenterctl", + "theme", + "export-stock", + "--output", + "editable-theme", + ]) + .expect("theme export arguments should parse"); + + let Command::Theme { + command: ThemeCommand::ExportStock { output }, + } = args.command + else { + panic!("theme export command should be selected"); + }; + assert_eq!(output, Some("editable-theme".into())); +} diff --git a/crates/noticenterctl/src/cli/tests/dnd.rs b/crates/noticenterctl/src/cli/tests/dnd.rs new file mode 100644 index 000000000..8a3c1d11a --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/dnd.rs @@ -0,0 +1,43 @@ +use std::str::FromStr; + +use super::super::dnd::{future_timestamp, DndClockTime, DndDuration}; + +#[test] +fn duration_parser_accepts_supported_units_and_rejects_invalid_bounds() { + assert!(DndDuration::from_str("30m").is_ok()); + assert!(DndDuration::from_str("1h").is_ok()); + assert!(DndDuration::from_str("2d").is_ok()); + assert!(DndDuration::from_str("0m").is_err()); + assert!(DndDuration::from_str("30").is_err()); + assert!(DndDuration::from_str("366d").is_err()); +} + +#[test] +fn clock_parser_requires_exact_twenty_four_hour_time() { + assert!(DndClockTime::from_str("08:00").is_ok()); + assert!(DndClockTime::from_str("23:59").is_ok()); + assert!(DndClockTime::from_str("24:00").is_err()); + assert!(DndClockTime::from_str("8:00").is_err()); + assert!(DndClockTime::from_str("08:0").is_err()); + assert!(DndClockTime::from_str("8am").is_err()); +} + +#[test] +fn clock_deadline_resolves_to_a_future_occurrence() { + let now = chrono::Utc::now().timestamp(); + let deadline = DndClockTime::from_str("08:00") + .expect("valid clock") + .deadline() + .expect("next local occurrence"); + + assert!(deadline > now); + assert!(deadline <= now + 2 * 24 * 60 * 60); +} + +#[test] +fn future_timestamp_selects_the_next_absolute_occurrence() { + assert_eq!(future_timestamp(100, Some(200), None), Some(200)); + assert_eq!(future_timestamp(150, Some(100), Some(200)), Some(200)); + assert_eq!(future_timestamp(50, Some(200), Some(100)), Some(100)); + assert_eq!(future_timestamp(200, Some(100), Some(200)), None); +} diff --git a/crates/noticenterctl/src/cli/tests/help.rs b/crates/noticenterctl/src/cli/tests/help.rs new file mode 100644 index 000000000..cdebc3192 --- /dev/null +++ b/crates/noticenterctl/src/cli/tests/help.rs @@ -0,0 +1,58 @@ +use clap::{CommandFactory, Parser}; + +use super::super::Args; + +#[test] +fn root_help_lists_the_supported_command_groups() { + let help = Args::command().render_help().to_string(); + + assert!(help.contains("Usage:")); + assert!(help.contains("css-check")); + assert!(help.contains("doctor")); + assert!(help.contains("preset")); + assert!(help.contains("theme")); +} + +#[test] +fn command_help_lists_output_debug_and_preset_controls() { + for (arguments, expected) in [ + ( + vec!["noticenterctl", "doctor", "--help"], + vec!["--json", "--verbose", "--service-manager", "manual"], + ), + ( + vec!["noticenterctl", "open-panel", "--help"], + vec!["--debug", "critical", "verbose"], + ), + ( + vec!["noticenterctl", "preset", "--help"], + vec!["export", "import", "inspect", "reset-config"], + ), + ( + vec!["noticenterctl", "theme", "--help"], + vec!["export-stock"], + ), + ] { + let error = Args::try_parse_from(arguments).expect_err("help should stop parsing"); + let help = error.to_string(); + + for value in expected { + assert!(help.contains(value), "missing {value} in {help}"); + } + } +} + +#[test] +fn invalid_commands_and_dnd_values_are_rejected_by_the_parser() { + let command = Args::try_parse_from(["noticenterctl", "definitely-not-a-command"]) + .expect_err("unknown command should fail") + .to_string(); + assert!(command.contains("unrecognized subcommand")); + assert!(command.contains("definitely-not-a-command")); + + let dnd = Args::try_parse_from(["noticenterctl", "dnd", "maybe"]) + .expect_err("invalid DND state should fail") + .to_string(); + assert!(dnd.contains("invalid value")); + assert!(dnd.contains("maybe")); +} diff --git a/crates/noticenterctl/src/cli/tests/mod.rs b/crates/noticenterctl/src/cli/tests/mod.rs index 5c448cf44..ae7f8f06a 100644 --- a/crates/noticenterctl/src/cli/tests/mod.rs +++ b/crates/noticenterctl/src/cli/tests/mod.rs @@ -1,2 +1,4 @@ mod args; mod command; +mod dnd; +mod help; diff --git a/crates/noticenterctl/src/css_check/geometry/check.rs b/crates/noticenterctl/src/css_check/geometry/check.rs index 4a4ae66e4..71830892b 100644 --- a/crates/noticenterctl/src/css_check/geometry/check.rs +++ b/crates/noticenterctl/src/css_check/geometry/check.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; use super::super::files::format_display_path; use super::super::report::{CssCheckCategory, CssCheckDiagnostic}; @@ -31,8 +31,7 @@ pub(in crate::css_check) fn lint_geometry_css_files_with_config( } // Runtime theme overrides inject modern tokens that may never appear in the css files - let generated_tokens = - build_modern_theme_custom_properties(&config.theme, gtk_css_features_for_version(4, 16)); + let generated_tokens = build_modern_theme_custom_properties(&config.theme); // Runtime tokens are stitched in before the file scan so token-only themes do not hide // width pressure from the checker diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs new file mode 100644 index 000000000..b6498e1f1 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/edges.rs @@ -0,0 +1,103 @@ +//! CSS edge shorthand and single-length entry points + +use super::super::super::model::{HorizontalEdges, VerticalEdges}; +use super::tokenize::split_css_value_tokens; +use super::{parse_length_expression, CssCustomProperties, ResolvedCssValue}; + +// Length parsing stays local to the geometry parser so calc and var rules do not leak outward +pub(in crate::css_check::geometry) fn set_edge( + edge: &mut f32, + value: &str, + custom_properties: &CssCustomProperties, +) { + if let Some(parsed) = parse_single_length(value, custom_properties) { + *edge = parsed; + } +} + +pub(in crate::css_check::geometry) fn parse_box_edges( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + // CSS shorthands map to left and right edges based on token count + let values = parse_length_tokens(value, custom_properties); + match values.as_slice() { + [] => None, + [all] => Some(HorizontalEdges { + left: *all, + right: *all, + }), + [vertical, horizontal] => { + let _ = vertical; + Some(HorizontalEdges { + left: *horizontal, + right: *horizontal, + }) + } + [_, right, _, left] => Some(HorizontalEdges { + left: *left, + right: *right, + }), + [_, right, _] => Some(HorizontalEdges { + left: *right, + right: *right, + }), + _ => None, + } +} + +pub(in crate::css_check::geometry) fn parse_box_vertical_edges( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + // CSS shorthands map to top and bottom edges based on token count + let values = parse_length_tokens(value, custom_properties); + match values.as_slice() { + [] => None, + [all] => Some(VerticalEdges { + top: *all, + bottom: *all, + }), + [vertical, _horizontal] => Some(VerticalEdges { + top: *vertical, + bottom: *vertical, + }), + [top, _horizontal, bottom] => Some(VerticalEdges { + top: *top, + bottom: *bottom, + }), + [top, _, bottom, _left] => Some(VerticalEdges { + top: *top, + bottom: *bottom, + }), + _ => None, + } +} + +pub(in crate::css_check::geometry) fn parse_single_length( + value: &str, + custom_properties: &CssCustomProperties, +) -> Option { + let trimmed = value.trim(); + if let Some(parsed) = parse_length_expression(trimmed, custom_properties, 0) { + return parsed.into_length(); + } + + // Fall back to the first token so old shorthand behavior stays intact + split_css_value_tokens(trimmed) + .ok()? + .into_iter() + .find_map(|token| parse_length_expression(token, custom_properties, 0)) + .and_then(ResolvedCssValue::into_length) +} + +fn parse_length_tokens(value: &str, custom_properties: &CssCustomProperties) -> Vec { + // Four tokens are enough for the full CSS box shorthand + split_css_value_tokens(value) + .unwrap_or_default() + .into_iter() + .filter_map(|token| parse_length_expression(token, custom_properties, 0)) + .filter_map(ResolvedCssValue::into_length) + .take(4) + .collect() +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs new file mode 100644 index 000000000..d8f035974 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/expression.rs @@ -0,0 +1,241 @@ +//! Typed arithmetic parser for CSS length expressions + +use super::tokenize::consume_balanced_group; +use super::units::parse_atomic_value; +use super::CssCustomProperties; + +pub(in crate::css_check::geometry::parse) fn parse_length_expression( + value: &str, + custom_properties: &CssCustomProperties, + depth: usize, +) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || depth > 8 { + // Recursion limits keep broken variable loops from spinning forever + return None; + } + + LengthExpressionParser::new(trimmed, custom_properties, depth).parse() +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::css_check::geometry::parse) enum ResolvedCssValue { + // Length values may participate in compatible arithmetic and become geometry + Length(f32), + // Scalars are valid only as intermediate scale or divisor values + Scalar(f32), +} + +impl ResolvedCssValue { + pub(super) const fn into_length(self) -> Option { + match self { + Self::Length(value) => Some(value), + // Plain scalars only make sense while calc math is still in progress + Self::Scalar(_) => None, + } + } + + fn add(self, rhs: Self) -> Option { + // Addition cannot mix a dimensioned length with a scalar + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left + right)), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left + right)), + _ => None, + } + } + + fn subtract(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left - right)), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left - right)), + _ => None, + } + } + + fn multiply(self, rhs: Self) -> Option { + // Multiplication accepts one dimensioned side at most + match (self, rhs) { + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left * right)), + (Self::Length(length), Self::Scalar(scale)) + | (Self::Scalar(scale), Self::Length(length)) => Some(Self::Length(length * scale)), + _ => None, + } + } + + fn divide(self, rhs: Self) -> Option { + // Only scalar divisors preserve a valid CSS length dimension + match (self, rhs) { + (_, Self::Scalar(divisor)) if divisor.abs() < f32::EPSILON => None, + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left / right)), + (Self::Length(length), Self::Scalar(divisor)) => Some(Self::Length(length / divisor)), + _ => None, + } + } + + fn apply_sign(self, sign: f32) -> Self { + match self { + Self::Length(value) => Self::Length(value * sign), + Self::Scalar(value) => Self::Scalar(value * sign), + } + } + + pub(super) const fn min_with(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.min(right))), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.min(right))), + _ => None, + } + } + + pub(super) const fn max_with(self, rhs: Self) -> Option { + match (self, rhs) { + (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.max(right))), + (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.max(right))), + _ => None, + } + } + + pub(super) fn clamp_between(self, lower: Self, upper: Self) -> Option { + // clamp() keeps the value inside the two bounds once all three share one type + lower.max_with(self)?.min_with(upper) + } +} + +struct LengthExpressionParser<'a> { + input: &'a str, + cursor: usize, + // Resolved custom properties are passed in so var() can stay local to the tracked selector + custom_properties: &'a CssCustomProperties, + // Depth keeps broken recursive tokens from looping forever + depth: usize, +} + +impl<'a> LengthExpressionParser<'a> { + const fn new(input: &'a str, custom_properties: &'a CssCustomProperties, depth: usize) -> Self { + Self { + input, + cursor: 0, + custom_properties, + depth, + } + } + + fn parse(mut self) -> Option { + let value = self.parse_additive_expression()?; + self.skip_whitespace(); + // Partial parses are rejected so geometry only trusts whole expressions + (self.cursor == self.input.len()).then_some(value) + } + + fn parse_additive_expression(&mut self) -> Option { + let mut value = self.parse_multiplicative_expression()?; + loop { + self.skip_whitespace(); + if self.consume_char('+') { + // Addition stays left-associative like normal CSS calc evaluation + value = value.add(self.parse_multiplicative_expression()?)?; + continue; + } + if self.consume_char('-') { + value = value.subtract(self.parse_multiplicative_expression()?)?; + continue; + } + break; + } + Some(value) + } + + fn parse_multiplicative_expression(&mut self) -> Option { + // Multiplication binds more tightly than the additive parser above it + let mut value = self.parse_factor()?; + loop { + self.skip_whitespace(); + if self.consume_char('*') { + value = value.multiply(self.parse_factor()?)?; + continue; + } + if self.consume_char('/') { + value = value.divide(self.parse_factor()?)?; + continue; + } + break; + } + Some(value) + } + + fn parse_factor(&mut self) -> Option { + self.skip_whitespace(); + + // Repeated unary signs are folded before reading a group or atomic token + let mut sign = 1.0_f32; + loop { + if self.consume_char('+') { + self.skip_whitespace(); + continue; + } + if self.consume_char('-') { + sign *= -1.0; + self.skip_whitespace(); + continue; + } + break; + } + + if self.consume_char('(') { + let value = self.parse_additive_expression()?; + self.skip_whitespace(); + self.consume_char(')').then_some(value.apply_sign(sign)) + } else { + let token = self.consume_token()?; + parse_atomic_value(token, self.custom_properties, self.depth + 1) + .map(|value| value.apply_sign(sign)) + } + } + + fn consume_token(&mut self) -> Option<&'a str> { + self.skip_whitespace(); + // Cursor positions stay on UTF-8 boundaries because non-ASCII bytes are token content + let start = self.cursor; + let bytes = self.input.as_bytes(); + + while self.cursor < bytes.len() { + let byte = bytes[self.cursor]; + if byte.is_ascii_whitespace() || matches!(byte, b'+' | b'-' | b'*' | b'/' | b')') { + break; + } + + if byte == b'(' { + // Nested groups are consumed whole so inner operators do not split the token + self.cursor = consume_balanced_group(self.input, self.cursor)?; + continue; + } + + self.cursor += 1; + } + + (self.cursor > start).then(|| self.input[start..self.cursor].trim()) + } + + fn skip_whitespace(&mut self) { + // Character iteration handles every Unicode whitespace boundary safely + while let Some(ch) = self.input[self.cursor..].chars().next() { + if ch.is_whitespace() { + self.cursor += ch.len_utf8(); + } else { + break; + } + } + } + + fn consume_char(&mut self, expected: char) -> bool { + // Operators are consumed only when the next complete character matches + let Some(ch) = self.input[self.cursor..].chars().next() else { + return false; + }; + if ch != expected { + return false; + } + self.cursor += ch.len_utf8(); + true + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs index 9a7de6d49..7d80814c1 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/mod.rs @@ -1,337 +1,19 @@ -use super::super::model::{HorizontalEdges, VerticalEdges}; -use super::CssCustomProperties; +//! CSS length parsing split by shorthand, expression, token, and function logic +mod edges; +mod expression; mod resolve_calc; mod resolve_compare; mod resolve_var; mod tokenize; mod units; -#[cfg(test)] -#[path = "tests/cases.rs"] -mod tests; - -use self::tokenize::{consume_balanced_group, split_css_value_tokens}; -use self::units::parse_atomic_value; - -// Length parsing stays local to the geometry parser so calc and var rules do not leak outward -pub(in super::super) fn set_edge( - edge: &mut f32, - value: &str, - custom_properties: &CssCustomProperties, -) { - if let Some(parsed) = parse_single_length(value, custom_properties) { - *edge = parsed; - } -} - -pub(in super::super) fn parse_box_edges( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - // CSS shorthands map to left and right edges based on token count - let values = parse_length_tokens(value, custom_properties); - match values.as_slice() { - [] => None, - [all] => Some(HorizontalEdges { - left: *all, - right: *all, - }), - [vertical, horizontal] => { - let _ = vertical; - Some(HorizontalEdges { - left: *horizontal, - right: *horizontal, - }) - } - [_, right, _, left] => Some(HorizontalEdges { - left: *left, - right: *right, - }), - [_, right, _] => Some(HorizontalEdges { - left: *right, - right: *right, - }), - _ => None, - } -} - -pub(in super::super) fn parse_box_vertical_edges( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - // CSS shorthands map to top and bottom edges based on token count - let values = parse_length_tokens(value, custom_properties); - match values.as_slice() { - [] => None, - [all] => Some(VerticalEdges { - top: *all, - bottom: *all, - }), - [vertical, _horizontal] => Some(VerticalEdges { - top: *vertical, - bottom: *vertical, - }), - [top, _horizontal, bottom] => Some(VerticalEdges { - top: *top, - bottom: *bottom, - }), - [top, _, bottom, _left] => Some(VerticalEdges { - top: *top, - bottom: *bottom, - }), - _ => None, - } -} - -pub(in super::super) fn parse_single_length( - value: &str, - custom_properties: &CssCustomProperties, -) -> Option { - let trimmed = value.trim(); - if let Some(parsed) = parse_length_expression(trimmed, custom_properties, 0) { - return parsed.into_length(); - } - - // Fall back to the first token so old shorthand behavior stays intact - split_css_value_tokens(trimmed) - .into_iter() - .find_map(|token| parse_length_expression(token, custom_properties, 0)) - .and_then(ResolvedCssValue::into_length) -} - -fn parse_length_tokens(value: &str, custom_properties: &CssCustomProperties) -> Vec { - // Four tokens are enough for the full CSS box shorthand - split_css_value_tokens(value) - .into_iter() - .filter_map(|token| parse_length_expression(token, custom_properties, 0)) - .filter_map(ResolvedCssValue::into_length) - .take(4) - .collect() -} - -pub(super) fn parse_length_expression( - value: &str, - custom_properties: &CssCustomProperties, - depth: usize, -) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() || depth > 8 { - // Recursion limits keep broken variable loops from spinning forever - return None; - } - - LengthExpressionParser::new(trimmed, custom_properties, depth).parse() -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub(super) enum ResolvedCssValue { - Length(f32), - Scalar(f32), -} - -impl ResolvedCssValue { - const fn into_length(self) -> Option { - match self { - Self::Length(value) => Some(value), - // Plain scalars only make sense while calc math is still in progress - Self::Scalar(_) => None, - } - } - - fn add(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left + right)), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left + right)), - _ => None, - } - } - - fn subtract(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left - right)), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left - right)), - _ => None, - } - } - - fn multiply(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left * right)), - (Self::Length(length), Self::Scalar(scale)) - | (Self::Scalar(scale), Self::Length(length)) => Some(Self::Length(length * scale)), - _ => None, - } - } - - fn divide(self, rhs: Self) -> Option { - match (self, rhs) { - (_, Self::Scalar(divisor)) if divisor.abs() < f32::EPSILON => None, - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left / right)), - (Self::Length(length), Self::Scalar(divisor)) => Some(Self::Length(length / divisor)), - _ => None, - } - } - - fn apply_sign(self, sign: f32) -> Self { - match self { - Self::Length(value) => Self::Length(value * sign), - Self::Scalar(value) => Self::Scalar(value * sign), - } - } - - const fn min_with(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.min(right))), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.min(right))), - _ => None, - } - } - - const fn max_with(self, rhs: Self) -> Option { - match (self, rhs) { - (Self::Length(left), Self::Length(right)) => Some(Self::Length(left.max(right))), - (Self::Scalar(left), Self::Scalar(right)) => Some(Self::Scalar(left.max(right))), - _ => None, - } - } - - fn clamp_between(self, lower: Self, upper: Self) -> Option { - // clamp() keeps the value inside the two bounds once all three share one type - lower.max_with(self)?.min_with(upper) - } -} - -struct LengthExpressionParser<'a> { - input: &'a str, - cursor: usize, - // Resolved custom properties are passed in so var() can stay local to the tracked selector - custom_properties: &'a CssCustomProperties, - // Depth keeps broken recursive tokens from looping forever - depth: usize, -} - -impl<'a> LengthExpressionParser<'a> { - const fn new(input: &'a str, custom_properties: &'a CssCustomProperties, depth: usize) -> Self { - Self { - input, - cursor: 0, - custom_properties, - depth, - } - } - - fn parse(mut self) -> Option { - let value = self.parse_additive_expression()?; - self.skip_whitespace(); - // Partial parses are rejected so geometry only trusts whole expressions - (self.cursor == self.input.len()).then_some(value) - } - - fn parse_additive_expression(&mut self) -> Option { - let mut value = self.parse_multiplicative_expression()?; - loop { - self.skip_whitespace(); - if self.consume_char('+') { - // Addition stays left-associative like normal CSS calc evaluation - value = value.add(self.parse_multiplicative_expression()?)?; - continue; - } - if self.consume_char('-') { - value = value.subtract(self.parse_multiplicative_expression()?)?; - continue; - } - break; - } - Some(value) - } - - fn parse_multiplicative_expression(&mut self) -> Option { - let mut value = self.parse_factor()?; - loop { - self.skip_whitespace(); - if self.consume_char('*') { - value = value.multiply(self.parse_factor()?)?; - continue; - } - if self.consume_char('/') { - value = value.divide(self.parse_factor()?)?; - continue; - } - break; - } - Some(value) - } - - fn parse_factor(&mut self) -> Option { - self.skip_whitespace(); - - let mut sign = 1.0_f32; - loop { - if self.consume_char('+') { - self.skip_whitespace(); - continue; - } - if self.consume_char('-') { - sign *= -1.0; - self.skip_whitespace(); - continue; - } - break; - } - - if self.consume_char('(') { - let value = self.parse_additive_expression()?; - self.skip_whitespace(); - self.consume_char(')').then_some(value.apply_sign(sign)) - } else { - let token = self.consume_token()?; - parse_atomic_value(token, self.custom_properties, self.depth + 1) - .map(|value| value.apply_sign(sign)) - } - } - - fn consume_token(&mut self) -> Option<&'a str> { - self.skip_whitespace(); - let start = self.cursor; - let bytes = self.input.as_bytes(); - - while self.cursor < bytes.len() { - let byte = bytes[self.cursor]; - if byte.is_ascii_whitespace() || matches!(byte, b'+' | b'-' | b'*' | b'/' | b')') { - break; - } - - if byte == b'(' { - // Nested groups are consumed whole so inner operators do not split the token - self.cursor = consume_balanced_group(self.input, self.cursor)?; - continue; - } - - self.cursor += 1; - } - - (self.cursor > start).then(|| self.input[start..self.cursor].trim()) - } +use super::CssCustomProperties; - fn skip_whitespace(&mut self) { - while let Some(ch) = self.input[self.cursor..].chars().next() { - if ch.is_whitespace() { - self.cursor += ch.len_utf8(); - } else { - break; - } - } - } +pub(in super::super) use edges::set_edge; +pub(in super::super) use edges::{parse_box_edges, parse_box_vertical_edges, parse_single_length}; +pub(super) use expression::{parse_length_expression, ResolvedCssValue}; - fn consume_char(&mut self, expected: char) -> bool { - let Some(ch) = self.input[self.cursor..].chars().next() else { - return false; - }; - if ch != expected { - return false; - } - self.cursor += ch.len_utf8(); - true - } -} +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs index bb937e833..631638d19 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_compare.rs @@ -26,7 +26,7 @@ pub(super) fn resolve_compare_function( } let inner = trimmed.strip_prefix("clamp(")?.strip_suffix(')')?.trim(); - let args = split_top_level_list(inner, ','); + let args = split_top_level_list(inner, ',').ok()?; if args.len() != 3 { return None; } @@ -51,6 +51,7 @@ fn resolve_min_or_max( mode: CompareMode, ) -> Option { let mut values = split_top_level_list(inner, ',') + .ok()? .into_iter() .map(|value| parse_length_expression(value, custom_properties, depth + 1)) .collect::>>()? diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs index 2d5341360..e098aad53 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/resolve_var.rs @@ -15,7 +15,7 @@ pub(super) fn resolve_custom_property_value( .strip_prefix("var(")? .strip_suffix(')')? .trim(); - let (name, fallback) = split_top_level_once(inner, ','); + let (name, fallback) = split_top_level_once(inner, ',').ok()?; let name = name.trim(); if let Some(value) = custom_properties.get(name) { // Resolved properties recurse through the same parser so nested calc stays supported diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs deleted file mode 100644 index 2106fe6f2..000000000 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/cases.rs +++ /dev/null @@ -1,85 +0,0 @@ -#![allow( - clippy::float_cmp, - reason = "the parser returns exact decimal literals for these integer and finite CSS inputs" -)] - -use std::collections::HashMap; - -use super::{parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge}; - -#[test] -fn parse_single_length_resolves_calc_compare_and_var_fallbacks() { - let mut properties = HashMap::new(); - properties.insert("--base".to_string(), "calc(10px + 2px)".to_string()); - properties.insert("--chosen".to_string(), "var(--missing, 18px)".to_string()); - - assert_eq!(parse_single_length("var(--base)", &properties), Some(12.0)); - assert_eq!( - parse_single_length("var(--chosen)", &properties), - Some(18.0) - ); - assert_eq!( - parse_single_length("clamp(4px, max(12px, 14px), 20px)", &properties), - Some(14.0) - ); -} - -#[test] -fn parse_single_length_rejects_percentages_units_and_bad_math() { - let properties = HashMap::new(); - - assert_eq!(parse_single_length("80%", &properties), None); - assert_eq!(parse_single_length("1rem", &properties), None); - assert_eq!(parse_single_length("calc(10px / 0)", &properties), None); - assert_eq!(parse_single_length("calc(10px + 2)", &properties), None); -} - -#[test] -fn parse_box_edges_follows_css_horizontal_shorthand_rules() { - let properties = HashMap::new(); - - let one = parse_box_edges("3px", &properties).expect("one value"); - assert_eq!(one.left, 3.0); - assert_eq!(one.right, 3.0); - - let two = parse_box_edges("1px 4px", &properties).expect("two values"); - assert_eq!(two.left, 4.0); - assert_eq!(two.right, 4.0); - - let three = parse_box_edges("1px 4px 7px", &properties).expect("three values"); - assert_eq!(three.left, 4.0); - assert_eq!(three.right, 4.0); - - let four = parse_box_edges("1px 2px 3px 4px", &properties).expect("four values"); - assert_eq!(four.left, 4.0); - assert_eq!(four.right, 2.0); -} - -#[test] -fn parse_box_vertical_edges_follows_css_vertical_shorthand_rules() { - let properties = HashMap::new(); - - let two = parse_box_vertical_edges("6px 9px", &properties).expect("two values"); - assert_eq!(two.top, 6.0); - assert_eq!(two.bottom, 6.0); - - let three = parse_box_vertical_edges("1px 2px 3px", &properties).expect("three values"); - assert_eq!(three.top, 1.0); - assert_eq!(three.bottom, 3.0); - - let four = parse_box_vertical_edges("1px 2px 3px 4px", &properties).expect("four values"); - assert_eq!(four.top, 1.0); - assert_eq!(four.bottom, 3.0); -} - -#[test] -fn set_edge_leaves_existing_value_when_length_cannot_resolve() { - let properties = HashMap::new(); - let mut edge = 8.0; - - set_edge(&mut edge, "var(--missing)", &properties); - assert_eq!(edge, 8.0); - - set_edge(&mut edge, "12px", &properties); - assert_eq!(edge, 12.0); -} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs new file mode 100644 index 000000000..5c9d95a70 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/edges.rs @@ -0,0 +1,71 @@ +#![expect( + clippy::float_cmp, + reason = "the parser returns exact finite values for these integer CSS inputs" +)] + +use std::collections::HashMap; + +use super::super::{parse_box_edges, parse_box_vertical_edges, parse_single_length, set_edge}; + +#[test] +fn parse_single_length_uses_the_first_resolved_shorthand_token() { + let properties = HashMap::new(); + + assert_eq!(parse_single_length("invalid 12px", &properties), Some(12.0)); + assert_eq!(parse_single_length("80% 1rem", &properties), None); +} + +#[test] +fn parse_box_edges_follows_every_css_horizontal_shorthand_shape() { + let properties = HashMap::new(); + + let one = parse_box_edges("3px", &properties).expect("one value"); + assert_eq!((one.left, one.right), (3.0, 3.0)); + + let two = parse_box_edges("1px 4px", &properties).expect("two values"); + assert_eq!((two.left, two.right), (4.0, 4.0)); + + let three = parse_box_edges("1px 4px 7px", &properties).expect("three values"); + assert_eq!((three.left, three.right), (4.0, 4.0)); + + let four = parse_box_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!((four.left, four.right), (4.0, 2.0)); +} + +#[test] +fn parse_box_vertical_edges_follows_every_css_vertical_shorthand_shape() { + let properties = HashMap::new(); + + let one = parse_box_vertical_edges("3px", &properties).expect("one value"); + assert_eq!((one.top, one.bottom), (3.0, 3.0)); + + let two = parse_box_vertical_edges("6px 9px", &properties).expect("two values"); + assert_eq!((two.top, two.bottom), (6.0, 6.0)); + + let three = parse_box_vertical_edges("1px 2px 3px", &properties).expect("three values"); + assert_eq!((three.top, three.bottom), (1.0, 3.0)); + + let four = parse_box_vertical_edges("1px 2px 3px 4px", &properties).expect("four values"); + assert_eq!((four.top, four.bottom), (1.0, 3.0)); +} + +#[test] +fn malformed_or_oversized_shorthands_fail_closed() { + let properties = HashMap::new(); + + assert!(parse_box_edges("", &properties).is_none()); + assert!(parse_box_edges("1px 2px 3px 4px 5px", &properties).is_some()); + assert!(parse_box_vertical_edges("var(unterminated", &properties).is_none()); +} + +#[test] +fn set_edge_changes_only_resolved_lengths() { + let properties = HashMap::new(); + let mut edge = 8.0; + + set_edge(&mut edge, "var(--missing)", &properties); + assert_eq!(edge, 8.0); + + set_edge(&mut edge, "12px", &properties); + assert_eq!(edge, 12.0); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs new file mode 100644 index 000000000..903ccec23 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/expression.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +use super::super::{parse_length_expression, ResolvedCssValue}; + +#[test] +fn arithmetic_parser_preserves_precedence_and_parentheses() { + let properties = HashMap::new(); + + assert_eq!( + parse_length_expression("2 * 3px + 4px", &properties, 0), + Some(ResolvedCssValue::Length(10.0)) + ); + assert_eq!( + parse_length_expression("2 * (3px + 4px)", &properties, 0), + Some(ResolvedCssValue::Length(14.0)) + ); +} + +#[test] +fn arithmetic_parser_rejects_invalid_dimensions_and_division_by_zero() { + let properties = HashMap::new(); + + for expression in ["10px + 2", "10px * 2px", "10px / 0", "10px trailing"] { + assert!( + parse_length_expression(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} + +#[test] +fn arithmetic_parser_limits_recursive_resolution_depth() { + let properties = HashMap::new(); + + assert!(parse_length_expression("12px", &properties, 9).is_none()); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs new file mode 100644 index 000000000..550776f18 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/mod.rs @@ -0,0 +1,5 @@ +mod edges; +mod expression; +mod resolve_compare; +mod resolve_var; +mod units; diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs new file mode 100644 index 000000000..ebea960e9 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_compare.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use super::super::{resolve_compare::resolve_compare_function, ResolvedCssValue}; + +#[test] +fn comparison_functions_resolve_nested_length_arguments() { + let properties = HashMap::new(); + + assert_eq!( + resolve_compare_function("min(12px, 8px)", &properties, 0), + Some(ResolvedCssValue::Length(8.0)) + ); + assert_eq!( + resolve_compare_function("max(12px, 8px)", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); + assert_eq!( + resolve_compare_function("clamp(4px, max(12px, 14px), 20px)", &properties, 0), + Some(ResolvedCssValue::Length(14.0)) + ); +} + +#[test] +fn comparison_functions_reject_missing_or_mixed_arguments() { + let properties = HashMap::new(); + + for expression in [ + "min()", + "max(1px, 2)", + "clamp(1px, 2px)", + "clamp(1px, 2, 3px)", + ] { + assert!( + resolve_compare_function(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs new file mode 100644 index 000000000..cd324b0e9 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/resolve_var.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; + +use super::super::{resolve_var::resolve_custom_property_value, ResolvedCssValue}; + +#[test] +fn custom_property_resolution_prefers_the_defined_value() { + let properties = HashMap::from([("--width".to_string(), "calc(10px + 2px)".to_string())]); + + assert_eq!( + resolve_custom_property_value("var(--width, 30px)", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); +} + +#[test] +fn custom_property_resolution_uses_a_nested_fallback() { + let properties = HashMap::new(); + + assert_eq!( + resolve_custom_property_value("var(--missing, max(12px, 18px))", &properties, 0), + Some(ResolvedCssValue::Length(18.0)) + ); +} + +#[test] +fn custom_property_resolution_rejects_missing_or_malformed_fallbacks() { + let properties = HashMap::new(); + + for expression in ["var(--missing)", "var(--missing, 12px", "var()"] { + assert!( + resolve_custom_property_value(expression, &properties, 0).is_none(), + "{expression}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs new file mode 100644 index 000000000..172eedada --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/tokenize.rs @@ -0,0 +1,92 @@ +use super::{ + consume_balanced_group, split_css_value_tokens, split_top_level_list, split_top_level_once, + CssScanError, +}; + +#[test] +fn escaped_quotes_keep_separators_inside_the_same_string() { + let value = r#"var(--label, "quoted\",comma"), 12px"#; + + assert_eq!( + split_top_level_list(value, ',').expect("scan escaped quote"), + vec![r#"var(--label, "quoted\",comma")"#, "12px"] + ); +} + +#[test] +fn escaped_whitespace_does_not_split_a_css_value_token() { + assert_eq!( + split_css_value_tokens(r"10px label\ value 20px").expect("scan escaped space"), + vec!["10px", r"label\ value", "20px"] + ); +} + +#[test] +fn balanced_group_returns_the_byte_after_its_matching_parenthesis() { + let value = "calc(10px + var(--gap, 2px)) tail"; + let start = value.find('(').expect("opening parenthesis"); + + assert_eq!( + consume_balanced_group(value, start), + Some("calc(10px + var(--gap, 2px))".len()) + ); +} + +#[test] +fn top_level_once_ignores_nested_and_quoted_separators() { + let value = r#"--gap, min(10px, "20px,still-string")"#; + + assert_eq!( + split_top_level_once(value, ',').expect("scan var fallback"), + ("--gap", Some(r#" min(10px, "20px,still-string")"#)) + ); +} + +#[test] +fn top_level_once_returns_no_fallback_when_separator_is_absent() { + assert_eq!( + split_top_level_once("--gap", ',').expect("scan value without fallback"), + ("--gap", None) + ); +} + +#[test] +fn bracketed_separators_remain_inside_their_value() { + assert_eq!( + split_top_level_list("selector[data='a,b'], 12px", ',').expect("scan bracketed selector"), + vec!["selector[data='a,b']", "12px"] + ); + assert_eq!( + split_css_value_tokens("selector[data=value with-space] 12px") + .expect("scan bracketed whitespace"), + vec!["selector[data=value with-space]", "12px"] + ); +} + +#[test] +fn malformed_delimiters_and_strings_return_structured_errors() { + assert_eq!( + split_top_level_list("10px), 20px", ','), + Err(CssScanError::ClosingParenthesis(4)) + ); + assert_eq!( + split_css_value_tokens(r#"10px "unfinished"#), + Err(CssScanError::UnterminatedQuote) + ); + assert_eq!( + split_css_value_tokens("calc(10px"), + Err(CssScanError::UnterminatedGroup) + ); + assert_eq!( + split_css_value_tokens("selector[value"), + Err(CssScanError::UnterminatedGroup) + ); + assert_eq!( + split_css_value_tokens("selector]"), + Err(CssScanError::ClosingBracket(8)) + ); + assert_eq!( + split_css_value_tokens("value\\"), + Err(CssScanError::DanglingEscape) + ); +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs new file mode 100644 index 000000000..1a244d352 --- /dev/null +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tests/units.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +use super::super::{units::parse_atomic_value, ResolvedCssValue}; + +#[test] +fn atomic_values_distinguish_pixel_lengths_from_scalars() { + let properties = HashMap::new(); + + assert_eq!( + parse_atomic_value("12PX", &properties, 0), + Some(ResolvedCssValue::Length(12.0)) + ); + assert_eq!( + parse_atomic_value("2.5", &properties, 0), + Some(ResolvedCssValue::Scalar(2.5)) + ); +} + +#[test] +fn atomic_values_reject_percentages_and_unknown_units() { + let properties = HashMap::new(); + + for value in ["", "80%", "1rem", "unknown"] { + assert!( + parse_atomic_value(value, &properties, 0).is_none(), + "{value}" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs index 2f2d65c95..db94d0f73 100644 --- a/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs +++ b/crates/noticenterctl/src/css_check/geometry/parse/lengths/tokenize.rs @@ -1,173 +1,199 @@ -//! Token splitting helpers for geometry length parsing +//! Shared escape-aware scanner for geometry CSS token boundaries -pub(super) fn consume_balanced_group(input: &str, start: usize) -> Option { - let bytes = input.as_bytes(); - let mut cursor = start; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - while cursor < bytes.len() { - let ch = input[cursor..].chars().next()?; - cursor += ch.len_utf8(); - - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; - } +use thiserror::Error; - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => { - // Group ends only when both paren and bracket nesting are back at zero - paren_depth = paren_depth.saturating_sub(1); - if paren_depth == 0 && bracket_depth == 0 { - return Some(cursor); - } - } - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ => {} - } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct CssScanState { + quote: Option, + escaped: bool, + paren_depth: u32, + bracket_depth: u32, +} + +impl CssScanState { + const fn is_top_level(self) -> bool { + self.quote.is_none() && !self.escaped && self.paren_depth == 0 && self.bracket_depth == 0 } +} - None +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub(super) enum CssScanError { + #[error("CSS contains an unmatched closing parenthesis at byte {0}")] + ClosingParenthesis(usize), + #[error("CSS contains an unmatched closing bracket at byte {0}")] + ClosingBracket(usize), + #[error("CSS contains an unterminated quoted string")] + UnterminatedQuote, + #[error("CSS contains an unterminated group")] + UnterminatedGroup, + #[error("CSS contains a dangling escape")] + DanglingEscape, } -pub(super) fn split_css_value_tokens(value: &str) -> Vec<&str> { - let mut tokens = Vec::new(); - let mut start = None::; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in value.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - if start.is_none() { - start = Some(index); +pub(super) fn scan_css( + input: &str, + mut visitor: impl FnMut(usize, char, &CssScanState), +) -> Result<(), CssScanError> { + // Public scans always consume the full input and validate the final state + scan_css_until(input, |index, character, state| { + visitor(index, character, state); + false + }) +} + +fn scan_css_until( + input: &str, + mut visitor: impl FnMut(usize, char, &CssScanState) -> bool, +) -> Result<(), CssScanError> { + let mut state = CssScanState::default(); + + // Character indices preserve valid UTF-8 slice boundaries for every callback + for (index, character) in input.char_indices() { + if state.escaped { + // Escaped characters are data even when they look like delimiters + if visitor(index, character, &state) { + return Ok(()); } + state.escaped = false; continue; } - match ch { - '"' | '\'' => { - if start.is_none() { - start = Some(index); - } - in_string = Some(ch); + if let Some(quote) = state.quote { + // Quoted delimiters cannot change group depth or split top-level values + match character { + '\\' => state.escaped = true, + current if current == quote => state.quote = None, + _ => {} } - '(' => { - if start.is_none() { - start = Some(index); - } - paren_depth = paren_depth.saturating_add(1); + if visitor(index, character, &state) { + return Ok(()); } - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => { - if start.is_none() { - start = Some(index); - } - bracket_depth = bracket_depth.saturating_add(1); - } - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch.is_whitespace() && paren_depth == 0 && bracket_depth == 0 => { - if let Some(token_start) = start.take() { - // Top-level whitespace is the only real shorthand separator - tokens.push(value[token_start..index].trim()); - } + continue; + } + + // Group depth is updated before visitors inspect the current character + match character { + '\\' => state.escaped = true, + '"' | '\'' => state.quote = Some(character), + '(' => state.paren_depth += 1, + ')' => { + state.paren_depth = state + .paren_depth + .checked_sub(1) + .ok_or(CssScanError::ClosingParenthesis(index))?; } - _ => { - if start.is_none() { - start = Some(index); - } + '[' => state.bracket_depth += 1, + ']' => { + state.bracket_depth = state + .bracket_depth + .checked_sub(1) + .ok_or(CssScanError::ClosingBracket(index))?; } + _ => {} + } + if visitor(index, character, &state) { + return Ok(()); } } - if let Some(token_start) = start { - tokens.push(value[token_start..].trim()); + // Final-state checks turn malformed CSS into one consistent parse failure + if state.escaped { + return Err(CssScanError::DanglingEscape); } - - tokens - .into_iter() - .filter(|token| !token.is_empty()) - .collect() + if state.quote.is_some() { + return Err(CssScanError::UnterminatedQuote); + } + if state.paren_depth != 0 || state.bracket_depth != 0 { + return Err(CssScanError::UnterminatedGroup); + } + Ok(()) } -pub(super) fn split_top_level_once(input: &str, separator: char) -> (&str, Option<&str>) { - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in input.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; +pub(super) fn consume_balanced_group(input: &str, start: usize) -> Option { + // A subslice lets the shared scanner report offsets relative to the opening group + let remaining = input.get(start..)?; + let mut end = None; + scan_css_until(remaining, |index, character, state| { + if end.is_none() && character == ')' && state.is_top_level() { + end = Some(start + index + character.len_utf8()); + return true; } + false + }) + .ok()?; + end +} - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch == separator && paren_depth == 0 && bracket_depth == 0 => { - // Only the first top-level separator matters for var() fallback splitting - let right = index + ch.len_utf8(); - return (&input[..index], Some(&input[right..])); +pub(super) fn split_css_value_tokens(value: &str) -> Result, CssScanError> { + let mut tokens = Vec::new(); + let mut start = None; + // Only unquoted top-level whitespace ends one shorthand token + scan_css(value, |index, character, state| { + if character.is_whitespace() && state.is_top_level() { + if let Some(token_start) = start.take() { + let token = value[token_start..index].trim(); + if !token.is_empty() { + tokens.push(token); + } } - _ => {} + } else if start.is_none() { + start = Some(index); + } + })?; + if let Some(token_start) = start { + let token = value[token_start..].trim(); + if !token.is_empty() { + tokens.push(token); } } + Ok(tokens) +} - (input, None) +pub(super) fn split_top_level_once( + input: &str, + separator: char, +) -> Result<(&str, Option<&str>), CssScanError> { + let mut split = None; + // The first top-level separator owns the entire remaining fallback value + scan_css(input, |index, character, state| { + if split.is_none() && character == separator && state.is_top_level() { + split = Some(index); + } + })?; + Ok(split.map_or((input, None), |index| { + let right = index + separator.len_utf8(); + (&input[..index], Some(&input[right..])) + })) } -pub(super) fn split_top_level_list(input: &str, separator: char) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0usize; - let mut paren_depth = 0u32; - let mut bracket_depth = 0u32; - let mut in_string = None::; - - for (index, ch) in input.char_indices() { - if let Some(quote) = in_string { - if ch == quote { - in_string = None; - } - continue; +pub(super) fn split_top_level_list( + input: &str, + separator: char, +) -> Result, CssScanError> { + let mut split_points = Vec::new(); + // Nested functions and attribute selectors keep their internal separators + scan_css(input, |index, character, state| { + if character == separator && state.is_top_level() { + split_points.push(index); } + })?; - match ch { - '"' | '\'' => in_string = Some(ch), - '(' => paren_depth = paren_depth.saturating_add(1), - ')' => paren_depth = paren_depth.saturating_sub(1), - '[' => bracket_depth = bracket_depth.saturating_add(1), - ']' => bracket_depth = bracket_depth.saturating_sub(1), - _ if ch == separator && paren_depth == 0 && bracket_depth == 0 => { - // Top-level commas split function arguments without breaking nested math - let part = input[start..index].trim(); - if !part.is_empty() { - parts.push(part); - } - start = index + ch.len_utf8(); - } - _ => {} + let mut parts = Vec::new(); + let mut start = 0; + for index in split_points { + let part = input[start..index].trim(); + if !part.is_empty() { + parts.push(part); } + start = index + separator.len_utf8(); } - let tail = input[start..].trim(); if !tail.is_empty() { parts.push(tail); } - - parts + Ok(parts) } + +#[cfg(test)] +#[path = "tests/tokenize.rs"] +mod tests; diff --git a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs index c535a68b2..5fa0f7cb7 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/baselines.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use std::sync::OnceLock; use unixnotis_core::{ - build_modern_theme_custom_properties, gtk_css_features_for_version, Config, DEFAULT_BASE_CSS, - DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, + build_modern_theme_custom_properties, Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, + DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, }; use super::super::super::parse::{ @@ -48,10 +48,7 @@ pub(in crate::css_check) fn stock_geometry_model() -> &'static GeometryModel { static MODEL: OnceLock = OnceLock::new(); MODEL.get_or_init(|| { let mut model = GeometryModel::default(); - let generated_tokens = build_modern_theme_custom_properties( - &stock_config().theme, - gtk_css_features_for_version(4, 16), - ); + let generated_tokens = build_modern_theme_custom_properties(&stock_config().theme); let shared_custom_properties = collect_custom_property_scopes( &std::iter::once(generated_tokens.as_str()) .chain([ diff --git a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs index 2a1a0eda7..5cd62b164 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/classes.rs @@ -68,6 +68,13 @@ fn insert_hook_class(classes: &mut HashSet, class_name: &str) { const fn hook_unixnotis_classes() -> &'static [&'static str] { // Hook-only classes can be real live selectors before the stock theme gives them rules &[ + hooks::cut_corner::ROOT, + hooks::dnd_menu::ROOT, + hooks::dnd_menu::CONTENT, + hooks::dnd_menu::TITLE, + hooks::dnd_menu::CHOICE, + hooks::dnd_menu::INDEFINITE, + hooks::dnd_menu::SEPARATOR, hooks::panel_action::ROW, hooks::panel_action::GROUP, hooks::panel_action::ROOT, @@ -83,6 +90,9 @@ const fn hook_unixnotis_classes() -> &'static [&'static str] { hooks::panel_action::ICON_ONLY, hooks::panel_action::LABEL_HIDDEN, hooks::panel_shell::SUBTITLE, + hooks::panel_shell::SEARCH_MAGNIFIER, + hooks::panel_shell::SEARCH_CLEAR, + hooks::panel_shell::SEARCH_OWNED_ICONS, hooks::panel_shell::SEARCH_SHELL, hooks::panel_shell::SEARCH_ACCENT, hooks::panel_shell::SEARCH_STAR, @@ -134,9 +144,6 @@ const fn hook_unixnotis_classes() -> &'static [&'static str] { hooks::group_row::CHEVRON, hooks::empty_row::ROOT, hooks::empty_row::LABEL, - hooks::ghost_row::ROOT, - "unixnotis-stack-ghost-1", - "unixnotis-stack-ghost-2", "unixnotis-media-stack-player", "unixnotis-media-row-player", "unixnotis-media-card-player", diff --git a/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs index 6ab0a54db..4a39ad6c6 100644 --- a/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs +++ b/crates/noticenterctl/src/css_check/geometry/stock/tests/classes.rs @@ -35,7 +35,11 @@ fn decorative_theme_hooks_are_treated_as_known_public_classes() { assert!(classes.contains(".unixnotis-panel-edge-top")); assert!(classes.contains(".unixnotis-panel-rail-left")); + assert!(classes.contains(".unixnotis-cut-corner")); assert!(classes.contains(".unixnotis-panel-search-shell")); + assert!(classes.contains(".unixnotis-panel-search-magnifier")); + assert!(classes.contains(".unixnotis-panel-search-clear")); + assert!(classes.contains(".unixnotis-panel-search-owned-icons")); assert!(classes.contains(".unixnotis-quick-slider-segments")); assert!(classes.contains(".unixnotis-info-media")); assert!(classes.contains(".unixnotis-info-card-banner")); diff --git a/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs b/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs index 8a5f5e32c..27e4cebf3 100644 --- a/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs +++ b/crates/noticenterctl/src/css_check/geometry/tests/custom_properties.rs @@ -2,7 +2,7 @@ use super::super::collect_custom_property_scopes; use super::super::model::GeometryModel; use super::super::parse::collect_geometry_from_contents_with_properties; use super::super::test_support::collect_geometry_from_contents; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; #[test] fn geometry_can_follow_custom_property_lengths() { @@ -87,10 +87,7 @@ fn geometry_can_follow_generated_modern_theme_tokens() { // Generated override tokens need to behave the same way as tokens declared in files let css = format!( "{}\n.unixnotis-panel {{ padding: var(--unixnotis-panel-padding); }}\n.unixnotis-toggle {{ min-width: var(--unixnotis-toggle-min-width); padding: 10px calc(var(--unixnotis-panel-action-gap) * 2); border: 1px solid red; }}", - build_modern_theme_custom_properties( - &Config::default().theme, - gtk_css_features_for_version(4, 16), - ) + build_modern_theme_custom_properties(&Config::default().theme) ); let mut model = GeometryModel::default(); diff --git a/crates/noticenterctl/src/css_check/lint/directives.rs b/crates/noticenterctl/src/css_check/lint/directives.rs new file mode 100644 index 000000000..fff5950a4 --- /dev/null +++ b/crates/noticenterctl/src/css_check/lint/directives.rs @@ -0,0 +1,52 @@ +//! Narrow source directives for intentional CSS cascade overrides + +use std::ops::Range; + +const ALLOW_DUPLICATE_SELECTORS_START: &str = + "/* unixnotis-css-check allow-duplicate-selectors:start */"; +const ALLOW_DUPLICATE_SELECTORS_END: &str = + "/* unixnotis-css-check allow-duplicate-selectors:end */"; + +#[derive(Debug, Default)] +pub(super) struct DuplicateSelectorAllowlist { + ranges: Vec>, +} + +impl DuplicateSelectorAllowlist { + pub(super) fn from_source(source: &str) -> Self { + let mut ranges = Vec::new(); + let mut remaining = source; + + while let Some((_before_start, after_start)) = + remaining.split_once(ALLOW_DUPLICATE_SELECTORS_START) + { + let Some((allowed_source, after_end)) = + after_start.split_once(ALLOW_DUPLICATE_SELECTORS_END) + else { + // An incomplete directive must not hide the rest of a user stylesheet + break; + }; + // Slice lengths provide absolute offsets without letting malformed input overflow + let start = source + .len() + .checked_sub(after_start.len()) + .expect("directive slice belongs to source"); + let end = start + .checked_add(allowed_source.len()) + .expect("allowed directive range fits source"); + ranges.push(start..end); + // Splitting consumes one complete section and guarantees forward progress + remaining = after_end; + } + + Self { ranges } + } + + pub(super) fn contains(&self, offset: usize) -> bool { + self.ranges.iter().any(|range| range.contains(&offset)) + } +} + +#[cfg(test)] +#[path = "tests/directives.rs"] +mod tests; diff --git a/crates/noticenterctl/src/css_check/lint/mod.rs b/crates/noticenterctl/src/css_check/lint/mod.rs index 34a8f9458..6023c91c8 100644 --- a/crates/noticenterctl/src/css_check/lint/mod.rs +++ b/crates/noticenterctl/src/css_check/lint/mod.rs @@ -1,5 +1,6 @@ //! CSS declaration, selector, and compatibility lint rules +mod directives; mod runner; mod scan; mod values; diff --git a/crates/noticenterctl/src/css_check/lint/runner.rs b/crates/noticenterctl/src/css_check/lint/runner.rs index 86edc968c..616e7b51f 100644 --- a/crates/noticenterctl/src/css_check/lint/runner.rs +++ b/crates/noticenterctl/src/css_check/lint/runner.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use std::fs; use std::path::{Path, PathBuf}; -use unixnotis_core::{build_modern_theme_custom_properties, gtk_css_features_for_version, Config}; +use unixnotis_core::{build_modern_theme_custom_properties, Config}; use super::super::files::format_display_path; use super::super::geometry::{collect_custom_property_scopes, CssCustomPropertyScopes}; @@ -73,5 +73,5 @@ pub(in crate::css_check::lint) fn lint_css_contents_with_properties( } fn generated_theme_token_css(config: &Config) -> String { - build_modern_theme_custom_properties(&config.theme, gtk_css_features_for_version(4, 16)) + build_modern_theme_custom_properties(&config.theme) } diff --git a/crates/noticenterctl/src/css_check/lint/scan.rs b/crates/noticenterctl/src/css_check/lint/scan.rs index 01ddc85b2..6bc45050b 100644 --- a/crates/noticenterctl/src/css_check/lint/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/scan.rs @@ -5,17 +5,51 @@ use super::super::parse::{ next_css_block_with_offsets, normalize_selector, parse_css_declarations_with_offsets, should_recurse_at_rule, split_selectors, strip_css_comments, }; +use super::directives::DuplicateSelectorAllowlist; use super::values::{ line_column_for_offset, should_suppress_duplicate_property_warning, web_length_value_warning, }; use super::CssCheckLintFinding; +struct CssLintContext<'a> { + // Shared source data stays together while recursive at-rules adjust only their offsets + source_contents: &'a str, + custom_properties: &'a CssCustomPropertyScopes, + duplicate_selector_allowlist: &'a DuplicateSelectorAllowlist, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct LintOptions { + pub(super) honor_suppressions: bool, +} + +impl Default for LintOptions { + fn default() -> Self { + Self { + honor_suppressions: true, + } + } +} + pub(super) fn lint_css_contents_with_properties( contents: &str, custom_properties: &CssCustomPropertyScopes, +) -> Vec { + lint_css_contents_with_options(contents, custom_properties, LintOptions::default()) +} + +pub(super) fn lint_css_contents_with_options( + contents: &str, + custom_properties: &CssCustomPropertyScopes, + options: LintOptions, ) -> Vec { // One collection keeps source order stable across color and rule diagnostics let mut warnings = Vec::new(); + let duplicate_selector_allowlist = if options.honor_suppressions { + DuplicateSelectorAllowlist::from_source(contents) + } else { + DuplicateSelectorAllowlist::default() + }; // Strip comments first so block scanning stays honest let stripped = strip_css_comments(contents); @@ -52,12 +86,16 @@ pub(super) fn lint_css_contents_with_properties( // Selector repeats matter across the whole file let mut selector_seen: HashMap = HashMap::new(); + let lint_context = CssLintContext { + source_contents: &stripped, + custom_properties, + duplicate_selector_allowlist: &duplicate_selector_allowlist, + }; lint_css_block( - &stripped, &stripped, 0, None, - custom_properties, + &lint_context, &mut selector_seen, &mut warnings, ); @@ -70,10 +108,9 @@ mod tests; fn lint_css_block( contents: &str, - source_contents: &str, base_offset: usize, - context: Option, - custom_properties: &CssCustomPropertyScopes, + at_rule_context: Option, + lint_context: &CssLintContext<'_>, selector_seen: &mut HashMap, warnings: &mut Vec, ) { @@ -93,17 +130,16 @@ fn lint_css_block( if should_recurse_at_rule(&selector) { // At-rules still matter because duplicate selectors and bad layout values can // hide inside the nested block - let nested_context = match context.as_ref() { + let nested_context = match at_rule_context.as_ref() { Some(parent) => format!("{parent} {selector}"), None => selector.clone(), }; // Keep the at-rule in the warning so the scope still makes sense lint_css_block( &css_block.block, - source_contents, base_offset + css_block.block_start, Some(nested_context), - custom_properties, + lint_context, selector_seen, warnings, ); @@ -116,23 +152,26 @@ fn lint_css_block( if selector_part.is_empty() { continue; } - let key = match context.as_ref() { + let key = match at_rule_context.as_ref() { // At-rule scope is part of identity so media variants are not false duplicates Some(prefix) => format!("{prefix}::{selector_part}"), None => selector_part.clone(), }; let count = selector_seen.entry(key).or_insert(0); *count += 1; - if *count > 1 { + let selector_source_offset = base_offset + css_block.selector_start + selector_offset; + if *count > 1 + && !lint_context + .duplicate_selector_allowlist + .contains(selector_source_offset) + { // Point to the repeated selector rather than the opening block delimiter - let context_note = context + let context_note = at_rule_context .as_ref() .map(|ctx| format!(" within {ctx}")) .unwrap_or_default(); - let (lint_line, lint_column) = line_column_for_offset( - source_contents, - base_offset + css_block.selector_start + selector_offset, - ); + let (lint_line, lint_column) = + line_column_for_offset(lint_context.source_contents, selector_source_offset); warnings.push(CssCheckLintFinding { line: Some(lint_line), column: Some(lint_column), @@ -144,12 +183,12 @@ fn lint_css_block( } warnings.extend(lint_css_properties( - source_contents, + lint_context.source_contents, &selector, &css_block.block, base_offset + css_block.block_start, - context.as_deref(), - custom_properties, + at_rule_context.as_deref(), + lint_context.custom_properties, )); } } diff --git a/crates/noticenterctl/src/css_check/lint/tests/directives.rs b/crates/noticenterctl/src/css_check/lint/tests/directives.rs new file mode 100644 index 000000000..ff097fc53 --- /dev/null +++ b/crates/noticenterctl/src/css_check/lint/tests/directives.rs @@ -0,0 +1,49 @@ +use super::{ + DuplicateSelectorAllowlist, ALLOW_DUPLICATE_SELECTORS_END, ALLOW_DUPLICATE_SELECTORS_START, +}; + +#[test] +fn closed_duplicate_selector_directive_only_allows_its_own_range() { + let long_prefix = "x".repeat(ALLOW_DUPLICATE_SELECTORS_START.len() + 16); + let source = format!( + "{long_prefix}\n.before-marker {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_START}\n.inside {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}\n.after {{}}" + ); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(!allowlist.contains( + source + .find(".before-marker") + .expect("selector before marker") + )); + assert!(allowlist.contains(source.find(".inside").expect("inside selector"))); + assert!(!allowlist.contains(source.find(".after").expect("after selector"))); +} + +#[test] +fn multiple_closed_directives_allow_each_section_without_hiding_the_gap() { + let source = format!( + "{ALLOW_DUPLICATE_SELECTORS_START}\n.first {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}\n.between {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_START}\n.second {{}}\n\ + {ALLOW_DUPLICATE_SELECTORS_END}" + ); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(allowlist.contains(source.find(".first").expect("first allowed selector"))); + assert!(!allowlist.contains(source.find(".between").expect("selector between sections"))); + assert!(allowlist.contains(source.find(".second").expect("second allowed selector"))); +} + +#[test] +fn unclosed_duplicate_selector_directive_does_not_hide_later_rules() { + let source = format!(".outside {{}}\n{ALLOW_DUPLICATE_SELECTORS_START}\n.still-checked {{}}"); + let allowlist = DuplicateSelectorAllowlist::from_source(&source); + + assert!(!allowlist.contains( + source + .find(".still-checked") + .expect("selector after incomplete directive") + )); +} diff --git a/crates/noticenterctl/src/css_check/lint/tests/scan.rs b/crates/noticenterctl/src/css_check/lint/tests/scan.rs index 2897c4b9e..c79bdf93d 100644 --- a/crates/noticenterctl/src/css_check/lint/tests/scan.rs +++ b/crates/noticenterctl/src/css_check/lint/tests/scan.rs @@ -1,4 +1,4 @@ -use super::lint_css_contents_with_properties; +use super::{lint_css_contents_with_options, lint_css_contents_with_properties, LintOptions}; use crate::css_check::geometry::collect_custom_property_scopes; #[test] @@ -15,3 +15,112 @@ fn scanner_reports_duplicate_selectors_with_source_location() { assert_eq!(duplicate.line, Some(2)); assert!(duplicate.column.is_some()); } + +#[test] +fn nested_duplicate_selector_reports_its_absolute_source_location() { + let css = "@media (min-width: 1px) {\n .item { color: red; }\n .item { color: blue; }\n}"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate selector")) + .expect("nested duplicate selector should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(3), Some(3))); +} + +#[test] +fn grouped_duplicate_selector_reports_the_repeated_member_location() { + let css = ".a, .b { color: red; }\n.x, .b { color: blue; }"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate selector '.b'")) + .expect("grouped duplicate selector should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(2), Some(5))); +} + +#[test] +fn nested_duplicate_property_reports_its_absolute_source_location() { + let css = "@media (min-width: 1px) {\n .item {\n color: red;\n color: blue;\n }\n}"; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicate = findings + .iter() + .find(|finding| finding.message.contains("duplicate property 'color'")) + .expect("nested duplicate property should be reported"); + + assert_eq!((duplicate.line, duplicate.column), (Some(4), Some(5))); +} + +#[test] +fn scanner_suppresses_only_duplicates_inside_a_closed_override_section() { + let css = " + .item { color: red; } + /* unixnotis-css-check allow-duplicate-selectors:start */ + .item { color: blue; } + /* unixnotis-css-check allow-duplicate-selectors:end */ + .item { color: green; } + "; + let properties = collect_custom_property_scopes(css); + + let findings = lint_css_contents_with_properties(css, &properties); + let duplicates = findings + .iter() + .filter(|finding| finding.message.contains("duplicate selector")) + .collect::>(); + + assert_eq!(duplicates.len(), 1); + assert_eq!(duplicates[0].line, Some(6)); +} + +#[test] +fn shipped_css_assets_are_lint_clean() { + let assets = [ + unixnotis_core::DEFAULT_BASE_CSS, + unixnotis_core::DEFAULT_PANEL_CSS, + unixnotis_core::DEFAULT_POPUP_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_MEDIA_CSS, + ]; + let config = unixnotis_core::Config::default(); + let generated = unixnotis_core::build_modern_theme_custom_properties(&config.theme); + let combined = std::iter::once(generated.as_str()) + .chain(assets) + .collect::>() + .join("\n"); + let properties = collect_custom_property_scopes(&combined); + + for css in assets { + let findings = lint_css_contents_with_options( + css, + &properties, + LintOptions { + honor_suppressions: false, + }, + ); + + assert!(findings.is_empty(), "{findings:?}"); + } +} + +#[test] +fn current_stock_assets_contain_no_lint_suppressions() { + for css in [ + unixnotis_core::DEFAULT_BASE_CSS, + unixnotis_core::DEFAULT_PANEL_CSS, + unixnotis_core::DEFAULT_POPUP_CSS, + unixnotis_core::DEFAULT_WIDGETS_CSS, + unixnotis_core::DEFAULT_MEDIA_CSS, + ] { + assert!( + !css.contains("unixnotis-css-check allow-duplicate-selectors"), + "current stock CSS must not suppress repository lint findings" + ); + } +} diff --git a/crates/noticenterctl/src/css_check/policy.rs b/crates/noticenterctl/src/css_check/policy.rs index 0cf1d9f98..035d368fe 100644 --- a/crates/noticenterctl/src/css_check/policy.rs +++ b/crates/noticenterctl/src/css_check/policy.rs @@ -1,6 +1,6 @@ //! Shared css-check policy for GTK CSS support and geometry rules -use unixnotis_core::GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL; +use unixnotis_core::GTK_MIN_VERSION_LABEL; pub(super) fn is_horizontal_size_property(name: &str) -> bool { // Only width-driving properties belong here @@ -64,7 +64,7 @@ pub(super) fn parsing_error_hint(line_text: &str) -> Option { if trimmed.contains("var(") { // The minimum version note lives in one shared place so installer and checker stay aligned return Some(format!( - "custom properties need {GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL}, and the referenced token still has to expand to a valid value here" + "custom properties need {GTK_MIN_VERSION_LABEL}, and the referenced token still has to expand to a valid value here" )); } None diff --git a/crates/noticenterctl/src/css_check/tests/cases.rs b/crates/noticenterctl/src/css_check/tests/cases.rs index 25d8ce423..a9d64969a 100644 --- a/crates/noticenterctl/src/css_check/tests/cases.rs +++ b/crates/noticenterctl/src/css_check/tests/cases.rs @@ -2,8 +2,7 @@ use super::lint::test_support::lint_css_contents; use super::parse::{parse_css_declarations, split_selectors}; use super::runtime::panel_width_floor_warning; use unixnotis_core::{ - build_modern_theme_custom_properties, gtk_css_features_for_version, Config, ThemeConfig, - PANEL_RUNTIME_WIDTH_MIN, + build_modern_theme_custom_properties, Config, ThemeConfig, PANEL_RUNTIME_WIDTH_MIN, }; #[path = "files.rs"] @@ -80,10 +79,7 @@ fn lint_css_contents_warns_on_web_length_tokens_in_layout_props() { fn lint_css_contents_accepts_generated_modern_theme_tokens() { let css = format!( "{}\n.unixnotis-panel-card {{ border-radius: var(--unixnotis-card-radius); padding: calc(var(--unixnotis-panel-card-padding-y) + 2px) var(--unixnotis-panel-card-padding-x); }}", - build_modern_theme_custom_properties( - &ThemeConfig::default(), - gtk_css_features_for_version(4, 16), - ) + build_modern_theme_custom_properties(&ThemeConfig::default()) ); let warnings = lint_css_contents(&css); diff --git a/crates/noticenterctl/src/css_check/tests/command.rs b/crates/noticenterctl/src/css_check/tests/command.rs index e4fb2738d..e5c366f0b 100644 --- a/crates/noticenterctl/src/css_check/tests/command.rs +++ b/crates/noticenterctl/src/css_check/tests/command.rs @@ -5,6 +5,7 @@ use unixnotis_core::CURRENT_CONFIG_VERSION; use super::load_config_for_path; use crate::config_path::ConfigPathSource; +use crate::test_support::{test_env_lock, EnvGuard}; #[test] fn explicit_existing_config_is_loaded_instead_of_the_default() { @@ -66,7 +67,9 @@ fn missing_environment_config_is_rejected() { #[test] fn absent_default_config_uses_builtin_defaults() { + let _lock = test_env_lock(); let root = temporary_test_directory("missing-default"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); let config_path = root.join("missing.toml"); let config = load_config_for_path(&config_path, ConfigPathSource::Default) diff --git a/crates/noticenterctl/src/css_check/theme/tests/helpers.rs b/crates/noticenterctl/src/css_check/theme/tests/helpers.rs index bda79b365..e81bef48f 100644 --- a/crates/noticenterctl/src/css_check/theme/tests/helpers.rs +++ b/crates/noticenterctl/src/css_check/theme/tests/helpers.rs @@ -3,6 +3,8 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::fixture_file_contents; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(super) struct TempDirGuard { @@ -27,7 +29,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } pub(super) fn path(&self) -> &Path { diff --git a/crates/noticenterctl/src/dbus/client.rs b/crates/noticenterctl/src/dbus/client.rs index 023315db6..89d5095d3 100644 --- a/crates/noticenterctl/src/dbus/client.rs +++ b/crates/noticenterctl/src/dbus/client.rs @@ -1,8 +1,10 @@ use std::future::Future; use std::pin::Pin; -use anyhow::Result; -use unixnotis_core::{ControlProxy, InhibitorInfo, NotificationView, PanelDebugLevel}; +use anyhow::{anyhow, Result}; +use unixnotis_core::{ + ControlProxy, InhibitorInfo, NotificationDiagnosticsView, NotificationView, PanelDebugLevel, +}; use super::timeout::run_control_call; @@ -23,6 +25,9 @@ pub trait ControlClient { // Ask the panel to close fn close_panel(&self) -> ControlFuture<'_, ()>; + // Rebuild desktop application records without restarting the daemon + fn refresh_applications(&self) -> ControlFuture<'_, ()>; + // Remove every notification from both active and history areas fn clear_all(&self) -> ControlFuture<'_, ()>; @@ -35,6 +40,12 @@ pub trait ControlClient { // Remove one notification by its id fn dismiss(&self, id: u32) -> ControlFuture<'_, ()>; + // Fetch structured attribution and popup state for one active notification + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec>; + // Fetch the notifications that are active right now fn list_active(&self) -> ControlFuture<'_, Vec>; @@ -44,6 +55,9 @@ pub trait ControlClient { // Turn do-not-disturb on or off directly fn set_dnd(&self, enabled: bool) -> ControlFuture<'_, ()>; + // Enable do-not-disturb until one absolute deadline + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()>; + // Flip do-not-disturb to the opposite of what it is now fn toggle_dnd(&self) -> ControlFuture<'_, ()>; @@ -81,6 +95,10 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::close_panel(self))) } + fn refresh_applications(&self) -> ControlFuture<'_, ()> { + Box::pin(run_control_call(ControlProxy::refresh_applications(self))) + } + fn clear_all(&self) -> ControlFuture<'_, ()> { // Ask the daemon to clear everything it is holding Box::pin(run_control_call(ControlProxy::clear_all(self))) @@ -97,8 +115,29 @@ impl ControlClient for ControlProxy<'_> { } fn dismiss(&self, id: u32) -> ControlFuture<'_, ()> { - // Send the id so the daemon knows exactly which notification to remove - Box::pin(run_control_call(ControlProxy::dismiss(self, id))) + Box::pin(async move { + // Resolve one exact active generation before issuing the mutating call + let mut candidates = + run_control_call(ControlProxy::get_active_notification(self, id)).await?; + let notification = candidates + .pop() + .ok_or_else(|| anyhow!("notification {id} is not active"))?; + run_control_call(ControlProxy::dismiss_generation( + self, + notification.id, + notification.generation, + )) + .await + }) + } + + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec> { + Box::pin(run_control_call( + ControlProxy::get_notification_diagnostics(self, id), + )) } fn list_active(&self) -> ControlFuture<'_, Vec> { @@ -116,6 +155,13 @@ impl ControlClient for ControlProxy<'_> { Box::pin(run_control_call(ControlProxy::set_dnd(self, enabled))) } + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()> { + // Absolute timestamps keep CLI and panel deadlines consistent across daemon restarts + Box::pin(run_control_call(ControlProxy::set_dnd_until( + self, expires_at, + ))) + } + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { // Ask the daemon to flip do-not-disturb without the caller needing to know its current value Box::pin(run_control_call(ControlProxy::toggle_dnd(self))) diff --git a/crates/noticenterctl/src/dbus/commands.rs b/crates/noticenterctl/src/dbus/commands.rs index e0606f8b3..da52d3c32 100644 --- a/crates/noticenterctl/src/dbus/commands.rs +++ b/crates/noticenterctl/src/dbus/commands.rs @@ -4,8 +4,8 @@ use unixnotis_core::util; use crate::cli::{Command, DndState}; use crate::debug_logs::follow_debug_logs; use crate::output::{ - allow_full_output, print_inhibitors, print_notifications, warn_full_requires_diagnostic, - write_stderr, write_stdout, + allow_full_output, print_inhibitors, print_notification_diagnostics, print_notifications, + warn_full_requires_diagnostic, write_stderr, write_stdout, }; use super::client::ControlClient; @@ -19,6 +19,8 @@ pub(super) async fn handle_command_with_debug_logs( command: Command, mut follow_logs: impl FnMut() -> Result<()>, ) -> Result<()> { + // Keep library-level dispatch safe even when a caller bypasses the CLI runner + command.validate()?; // CLI forwards work to the daemon match command { Command::TogglePanel => { @@ -41,6 +43,9 @@ pub(super) async fn handle_command_with_debug_logs( // Explicit close avoids accidental toggles when the panel is hidden client.close_panel().await?; } + Command::RefreshApplications => { + client.refresh_applications().await?; + } Command::Clear | Command::ClearAll => { // Clear keeps legacy behavior: remove active notifications and saved history client.clear_all().await?; @@ -55,6 +60,13 @@ pub(super) async fn handle_command_with_debug_logs( // Dismiss targets a single notification by id client.dismiss(id).await?; } + Command::ExplainNotification { id } => { + let mut diagnostics = client.notification_diagnostics(id).await?; + let view = diagnostics + .pop() + .ok_or_else(|| anyhow::anyhow!("notification {id} is not active"))?; + print_notification_diagnostics(&view)?; + } Command::ListActive { full } => { let diagnostic_mode = util::diagnostic_mode(); let allow_full = allow_full_output(full, diagnostic_mode); @@ -74,10 +86,27 @@ pub(super) async fn handle_command_with_debug_logs( let notifications = client.list_history().await?; print_notifications("history", ¬ifications, allow_full)?; } - Command::Dnd { state } => match state { + Command::Dnd { + state, + for_duration, + until, + } => match state { DndState::On => { - // Explicit enable avoids ambiguous scripts - client.set_dnd(true).await?; + let expires_at = match (for_duration, until) { + (Some(duration), None) => Some(duration.deadline()?), + (None, Some(clock)) => Some(clock.deadline()?), + (None, None) => None, + // Clap rejects this pair, but keep dispatch defensive for direct tests + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!("--for and --until cannot be used together")); + } + }; + if let Some(expires_at) = expires_at { + client.set_dnd_until(expires_at).await?; + } else { + // Explicit enable without timing means indefinite DND + client.set_dnd(true).await?; + } } DndState::Off => { // Explicit disable avoids ambiguous scripts @@ -100,7 +129,11 @@ pub(super) async fn handle_command_with_debug_logs( let inhibitors = client.list_inhibitors().await?; print_inhibitors(&inhibitors)?; } - Command::CssCheck { .. } | Command::Doctor { .. } | Command::Preset { .. } => {} + Command::CssCheck { .. } + | Command::Doctor { .. } + | Command::Preset { .. } + | Command::Theme { .. } + | Command::SyncSessionEnvironment { .. } => {} } Ok(()) diff --git a/crates/noticenterctl/src/dbus/tests/commands.rs b/crates/noticenterctl/src/dbus/tests/commands.rs index b8fcf18f7..8de5a8d16 100644 --- a/crates/noticenterctl/src/dbus/tests/commands.rs +++ b/crates/noticenterctl/src/dbus/tests/commands.rs @@ -32,6 +32,10 @@ async fn panel_commands_dispatch_to_matching_control_calls() { (Command::TogglePanel, RecordedCall::TogglePanel), (Command::OpenPanel { debug: None }, RecordedCall::OpenPanel), (Command::ClosePanel, RecordedCall::ClosePanel), + ( + Command::RefreshApplications, + RecordedCall::RefreshApplications, + ), ]; for (command, expected) in cases { @@ -101,18 +105,24 @@ async fn dnd_commands_dispatch_to_matching_control_calls() { ( Command::Dnd { state: DndState::On, + for_duration: None, + until: None, }, RecordedCall::SetDnd(true), ), ( Command::Dnd { state: DndState::Off, + for_duration: None, + until: None, }, RecordedCall::SetDnd(false), ), ( Command::Dnd { state: DndState::Toggle, + for_duration: None, + until: None, }, RecordedCall::ToggleDnd, ), @@ -127,10 +137,59 @@ async fn dnd_commands_dispatch_to_matching_control_calls() { } } +#[tokio::test] +async fn timed_dnd_dispatches_one_future_absolute_deadline() { + use std::str::FromStr; + + let client = RecordingControlClient::default(); + let before = chrono::Utc::now().timestamp(); + handle_command( + &client, + Command::Dnd { + state: DndState::On, + for_duration: Some(crate::cli::DndDuration::from_str("30m").expect("valid duration")), + until: None, + }, + ) + .await + .expect("dispatch timed DND"); + let after = chrono::Utc::now().timestamp(); + + let calls = client.take_calls(); + let [RecordedCall::SetDndUntil(expires_at)] = calls.as_slice() else { + panic!("expected one timed DND call, got {calls:?}"); + }; + assert!(*expires_at >= before + 30 * 60); + assert!(*expires_at <= after + 30 * 60); +} + +#[tokio::test] +async fn timed_dnd_dispatch_rejects_non_on_state_without_calling_control() { + use std::str::FromStr; + + let client = RecordingControlClient::default(); + let result = handle_command( + &client, + Command::Dnd { + state: DndState::Off, + for_duration: Some(crate::cli::DndDuration::from_str("30m").expect("valid duration")), + until: None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(client.take_calls().is_empty()); +} + #[tokio::test] async fn notification_commands_dispatch_to_matching_control_calls() { let cases = [ (Command::Dismiss { id: 7 }, RecordedCall::Dismiss(7)), + ( + Command::ExplainNotification { id: 8 }, + RecordedCall::NotificationDiagnostics(8), + ), ( Command::ListActive { full: false }, RecordedCall::ListActive, diff --git a/crates/noticenterctl/src/dbus/tests/support.rs b/crates/noticenterctl/src/dbus/tests/support.rs index a8b255b9f..f8f6b553f 100644 --- a/crates/noticenterctl/src/dbus/tests/support.rs +++ b/crates/noticenterctl/src/dbus/tests/support.rs @@ -1,6 +1,8 @@ use std::cell::RefCell; -use unixnotis_core::{InhibitorInfo, NotificationView, PanelDebugLevel}; +use unixnotis_core::{ + InhibitorInfo, NotificationDiagnosticsView, NotificationView, PanelDebugLevel, +}; use super::super::client::{ControlClient, ControlFuture}; @@ -10,13 +12,16 @@ pub(super) enum RecordedCall { OpenPanel, OpenPanelDebug(PanelDebugLevel), ClosePanel, + RefreshApplications, ClearAll, ClearActive, ClearHistory, Dismiss(u32), + NotificationDiagnostics(u32), ListActive, ListHistory, SetDnd(bool), + SetDndUntil(i64), ToggleDnd, Inhibit { reason: String, scope: u32 }, Uninhibit(u64), @@ -78,6 +83,10 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::ClosePanel, ()) } + fn refresh_applications(&self) -> ControlFuture<'_, ()> { + self.record(RecordedCall::RefreshApplications, ()) + } + fn clear_all(&self) -> ControlFuture<'_, ()> { self.record(RecordedCall::ClearAll, ()) } @@ -94,6 +103,19 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::Dismiss(id), ()) } + fn notification_diagnostics( + &self, + id: u32, + ) -> ControlFuture<'_, Vec> { + self.record( + RecordedCall::NotificationDiagnostics(id), + vec![NotificationDiagnosticsView { + id, + ..NotificationDiagnosticsView::default() + }], + ) + } + fn list_active(&self) -> ControlFuture<'_, Vec> { self.record(RecordedCall::ListActive, Vec::new()) } @@ -106,6 +128,10 @@ impl ControlClient for RecordingControlClient { self.record(RecordedCall::SetDnd(enabled), ()) } + fn set_dnd_until(&self, expires_at: i64) -> ControlFuture<'_, ()> { + self.record(RecordedCall::SetDndUntil(expires_at), ()) + } + fn toggle_dnd(&self) -> ControlFuture<'_, ()> { self.record(RecordedCall::ToggleDnd, ()) } diff --git a/crates/noticenterctl/src/dbus/timeout.rs b/crates/noticenterctl/src/dbus/timeout.rs index 8d7f6d8d4..3e6705d27 100644 --- a/crates/noticenterctl/src/dbus/timeout.rs +++ b/crates/noticenterctl/src/dbus/timeout.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{anyhow, Result}; -const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(5); +const CONTROL_CALL_TIMEOUT: Duration = Duration::from_secs(2); pub(super) async fn run_control_call(call: impl Future>) -> Result { run_control_call_with_timeout(CONTROL_CALL_TIMEOUT, call).await diff --git a/crates/noticenterctl/src/doctor/checks/dbus.rs b/crates/noticenterctl/src/doctor/checks/dbus.rs deleted file mode 100644 index 6b6a6016c..000000000 --- a/crates/noticenterctl/src/doctor/checks/dbus.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Bounded session-bus and `UnixNotis` control checks - -use std::time::Duration; - -use unixnotis_core::{ControlProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; -use zbus::fdo::DBusProxy; -use zbus::names::BusName; -use zbus::Connection; - -use super::super::report::safe_doctor_text; -use super::super::report::{DoctorCheck, DoctorSeverity}; - -const DBUS_CHECK_TIMEOUT: Duration = Duration::from_secs(3); - -pub(in crate::doctor) struct DoctorBusResult { - pub checks: Vec, - pub control_owned: bool, - pub connected: bool, -} - -pub(in crate::doctor) async fn inspect_bus() -> DoctorBusResult { - // Every bus operation is bounded so doctor cannot hang on a broken session - let connection = match tokio::time::timeout(DBUS_CHECK_TIMEOUT, Connection::session()).await { - Ok(Ok(connection)) => connection, - Ok(Err(error)) => { - return unavailable_bus_result(format!("Session bus connection failed: {error}")); - } - Err(_) => return unavailable_bus_result("Session bus connection timed out".to_string()), - }; - - inspect_bus_connection(&connection).await -} - -pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBusResult { - let mut checks = vec![DoctorCheck::new( - "dbus.session", - "Session bus", - DoctorSeverity::Pass, - "Session bus connection succeeded", - )]; - // The daemon proxy is required for ownership checks but not for later service checks - let proxy = match tokio::time::timeout(DBUS_CHECK_TIMEOUT, DBusProxy::new(connection)).await { - Ok(Ok(proxy)) => proxy, - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - "dbus.proxy", - "Session bus proxy", - DoctorSeverity::Error, - "D-Bus daemon proxy construction failed", - ) - .details(safe_doctor_text(&error.to_string())), - ); - return DoctorBusResult { - checks, - control_owned: false, - connected: true, - }; - } - Err(_) => { - checks.push(DoctorCheck::new( - "dbus.proxy", - "Session bus proxy", - DoctorSeverity::Error, - "D-Bus daemon proxy construction timed out", - )); - return DoctorBusResult { - checks, - control_owned: false, - connected: true, - }; - } - }; - - // Notification and control names are separate readiness signals - let notifications_owned = check_owner( - &proxy, - NOTIFICATIONS_BUS_NAME, - "dbus.notifications-owner", - "Notification service", - &mut checks, - ) - .await; - if !notifications_owned { - // Missing the standard name means desktop applications have no notification target - checks.push( - DoctorCheck::new( - "dbus.notifications-readiness", - "Notification readiness", - DoctorSeverity::Error, - "No notification service owns org.freedesktop.Notifications", - ) - .hint("Start unixnotis-daemon and run doctor again"), - ); - } - - let control_owned = check_owner( - &proxy, - CONTROL_BUS_NAME, - "dbus.control-owner", - "UnixNotis control service", - &mut checks, - ) - .await; - if control_owned { - // Proxy and GetState checks run only after ownership is confirmed - inspect_control_proxy(connection, &mut checks).await; - } else { - checks.push( - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "UnixNotis control service has no owner", - ) - .hint("Check the selected service manager status below"), - ); - } - - DoctorBusResult { - checks, - control_owned, - connected: true, - } -} - -async fn check_owner( - proxy: &DBusProxy<'_>, - name: &'static str, - id: &'static str, - label: &'static str, - checks: &mut Vec, -) -> bool { - // Static names are validated here once before the bounded remote request - let bus_name = BusName::try_from(name).expect("static D-Bus name must be valid"); - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name)).await { - Ok(Ok(true)) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Pass, - format!("{name} has an owner"), - )); - true - } - Ok(Ok(false)) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Warning, - format!("{name} has no owner"), - )); - false - } - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Unable to inspect {name} ownership"), - ) - .details(safe_doctor_text(&error.to_string())), - ); - false - } - Err(_) => { - checks.push(DoctorCheck::new( - id, - label, - DoctorSeverity::Error, - format!("Ownership query for {name} timed out"), - )); - false - } - } -} - -async fn inspect_control_proxy(connection: &Connection, checks: &mut Vec) { - // Keep proxy construction distinct from GetState for precise failure reports - let control = - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, ControlProxy::new(connection)).await { - Ok(Ok(proxy)) => proxy, - Ok(Err(error)) => { - checks.push( - DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Error, - "Control proxy construction failed", - ) - .details(safe_doctor_text(&error.to_string())), - ); - return; - } - Err(_) => { - checks.push(DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Error, - "Control proxy construction timed out", - )); - return; - } - }; - checks.push(DoctorCheck::new( - "dbus.control-proxy", - "UnixNotis control proxy", - DoctorSeverity::Pass, - "Control proxy construction succeeded", - )); - - // GetState proves that the owner can serve the real control interface - match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_state()).await { - Ok(Ok(state)) => checks.push( - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Pass, - "GetState completed", - ) - .details(format!( - "DND: {}\nHistory entries: {}\nInhibitors: {}", - state.dnd_enabled, state.history_count, state.inhibitor_count - )) - .data("dnd_enabled", state.dnd_enabled) - .data("history_count", state.history_count) - .data("inhibitor_count", state.inhibitor_count), - ), - Ok(Err(error)) => checks.push(control_state_failure_check(&error)), - Err(_) => checks.push(DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "GetState timed out", - )), - } -} - -pub(super) fn control_state_failure_check(error: &zbus::Error) -> DoctorCheck { - // Access denial is expected when a development binary calls a strict installed daemon - if control_access_was_denied(error) { - return DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "UnixNotis control access denied", - ) - .details("The running daemon rejected this client") - .hint( - "Use the installed noticenterctl from the same installation as the daemon; uninstalled development binaries are intentionally rejected", - ); - } - - // Other failures retain the broker detail because they need different troubleshooting - DoctorCheck::new( - "dbus.control-state", - "UnixNotis control state", - DoctorSeverity::Error, - "GetState failed", - ) - .details(safe_doctor_text(&error.to_string())) -} - -fn control_access_was_denied(error: &zbus::Error) -> bool { - match error { - zbus::Error::MethodError(name, _, _) => { - name.as_str() == "org.freedesktop.DBus.Error.AccessDenied" - } - zbus::Error::FDO(error) => matches!(error.as_ref(), zbus::fdo::Error::AccessDenied(_)), - _ => false, - } -} - -pub(super) fn unavailable_bus_result(details: String) -> DoctorBusResult { - // Dependent checks become one note instead of a chain of misleading errors - DoctorBusResult { - checks: vec![ - DoctorCheck::new( - "dbus.session", - "Session bus", - DoctorSeverity::Error, - "Session bus is unavailable", - ) - .details(safe_doctor_text(&details)), - DoctorCheck::new( - "dbus.dependent-checks", - "D-Bus dependent checks", - DoctorSeverity::Note, - "Owner, proxy, and GetState checks could not run", - ), - ], - control_owned: false, - connected: false, - } -} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/classify.rs b/crates/noticenterctl/src/doctor/checks/dbus/classify.rs new file mode 100644 index 000000000..85d422203 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/classify.rs @@ -0,0 +1,37 @@ +//! Stable user-facing classification for control-call failures + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; + +pub(super) fn control_state_failure_check(error: &zbus::Error) -> DoctorCheck { + if control_access_was_denied(error) { + return DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "UnixNotis control access denied", + ) + .details("The running daemon rejected this client") + .hint( + "Use the installed noticenterctl from the same installation as the daemon; uninstalled development binaries are intentionally rejected", + ); + } + + DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "GetState failed", + ) + .details(safe_doctor_text(&error.to_string())) +} + +fn control_access_was_denied(error: &zbus::Error) -> bool { + match error { + zbus::Error::MethodError(name, _, _) => { + name.as_str() == "org.freedesktop.DBus.Error.AccessDenied" + } + zbus::Error::FDO(error) => matches!(error.as_ref(), zbus::fdo::Error::AccessDenied(_)), + _ => false, + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/control.rs b/crates/noticenterctl/src/doctor/checks/dbus/control.rs new file mode 100644 index 000000000..26602ea7c --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/control.rs @@ -0,0 +1,140 @@ +//! Control proxy, state, and composite UI readiness checks + +use unixnotis_core::ControlProxy; +use zbus::Connection; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::classify::control_state_failure_check; +use super::DBUS_CHECK_TIMEOUT; + +pub(super) async fn inspect_control(connection: &Connection) -> Vec { + let mut checks = Vec::new(); + let control = + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, ControlProxy::new(connection)).await { + Ok(Ok(proxy)) => proxy, + Ok(Err(error)) => { + checks.push( + DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Error, + "Control proxy construction failed", + ) + .details(safe_doctor_text(&error.to_string())), + ); + return checks; + } + Err(_) => { + checks.push(DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Error, + "Control proxy construction timed out", + )); + return checks; + } + }; + checks.push(DoctorCheck::new( + "dbus.control-proxy", + "UnixNotis control proxy", + DoctorSeverity::Pass, + "Control proxy construction succeeded", + )); + + checks.push(inspect_control_state(&control).await); + checks.push(inspect_ui_health(&control).await); + checks +} + +pub(super) fn unavailable_control_check() -> DoctorCheck { + DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "UnixNotis control service has no owner", + ) + .hint("Check the selected service manager status below") +} + +async fn inspect_control_state(control: &ControlProxy<'_>) -> DoctorCheck { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_state()).await { + Ok(Ok(state)) => DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Pass, + "GetState completed", + ) + .details(format!( + "DND: {}\nHistory entries: {}\nInhibitors: {}", + state.dnd_enabled, state.history_count, state.inhibitor_count + )) + .data("dnd_enabled", state.dnd_enabled) + .data("history_count", state.history_count) + .data("inhibitor_count", state.inhibitor_count), + Ok(Err(error)) => control_state_failure_check(&error), + Err(_) => DoctorCheck::new( + "dbus.control-state", + "UnixNotis control state", + DoctorSeverity::Error, + "GetState timed out", + ), + } +} + +async fn inspect_ui_health(control: &ControlProxy<'_>) -> DoctorCheck { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, control.get_ui_health()).await { + Ok(Ok(health)) => { + let healthy = health.center_process_running + && health.center_ready + && health.popups_process_running + && health.popups_ready; + DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + if healthy { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if healthy { + "Center and popup clients are ready" + } else { + "One or more UI clients are not ready" + }, + ) + .details(format!( + "Center process: {}\nCenter D-Bus client: {}\nPopup process: {}\nPopup D-Bus/GTK client: {}", + readiness_label(health.center_process_running), + readiness_label(health.center_ready), + readiness_label(health.popups_process_running), + readiness_label(health.popups_ready), + )) + .data("center_process_running", health.center_process_running) + .data("center_ready", health.center_ready) + .data("popups_process_running", health.popups_process_running) + .data("popups_ready", health.popups_ready) + } + Ok(Err(error)) => DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth failed", + ) + .details(safe_doctor_text(&error.to_string())), + Err(_) => DoctorCheck::new( + "dbus.ui-health", + "UnixNotis UI readiness", + DoctorSeverity::Error, + "GetUiHealth timed out", + ), + } +} + +const fn readiness_label(ready: bool) -> &'static str { + if ready { + "ready" + } else { + "not ready" + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/mod.rs b/crates/noticenterctl/src/doctor/checks/dbus/mod.rs new file mode 100644 index 000000000..60be68a33 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/mod.rs @@ -0,0 +1,101 @@ +//! Bounded session-bus and `UnixNotis` control inspection + +mod classify; +mod control; +mod owners; +mod session; + +use std::time::Duration; + +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::report::DoctorCheck; + +pub(super) const DBUS_CHECK_TIMEOUT: Duration = Duration::from_secs(3); + +pub(in crate::doctor) struct DoctorBusResult { + pub checks: Vec, + pub control_owned: bool, + pub connected: bool, +} + +pub(in crate::doctor) async fn inspect_bus() -> DoctorBusResult { + let session::SessionProbe { connection, checks } = session::probe_session().await; + let Some(connection) = connection else { + return DoctorBusResult { + checks, + control_owned: false, + connected: false, + }; + }; + debug_assert!( + checks.is_empty(), + "session probing must not report checks when a connection is available" + ); + inspect_bus_connection(&connection).await +} + +pub(super) async fn inspect_bus_connection(connection: &Connection) -> DoctorBusResult { + let checks = session::connected_checks(connection).await; + inspect_connected_bus(connection, checks).await +} + +async fn inspect_connected_bus( + connection: &Connection, + mut checks: Vec, +) -> DoctorBusResult { + let proxy = match session::build_bus_proxy(connection).await { + Ok(proxy) => proxy, + Err(check) => { + checks.push(check); + return DoctorBusResult { + checks, + control_owned: false, + connected: true, + }; + } + }; + inspect_owners_and_control(connection, &proxy, &mut checks).await +} + +async fn inspect_owners_and_control( + connection: &Connection, + proxy: &DBusProxy<'_>, + checks: &mut Vec, +) -> DoctorBusResult { + let notifications = owners::probe_notifications_owner(proxy).await; + let notification_owner = notifications.owner().map(ToOwned::to_owned); + checks.push(notifications.check); + if notification_owner.is_none() { + checks.push(owners::notification_readiness_failure()); + } + + let control_probe = owners::probe_control_owner(proxy).await; + let control_owned = control_probe.owner().is_some(); + let control_owner_name = control_probe.owner().map(ToOwned::to_owned); + checks.push(control_probe.check); + if let (Some(notification_owner), Some(control_owner_name)) = + (¬ification_owner, &control_owner_name) + { + checks.push(owners::shared_owner_check( + notification_owner, + control_owner_name, + )); + } + + if control_owned { + checks.extend(control::inspect_control(connection).await); + } else { + checks.push(control::unavailable_control_check()); + } + + DoctorBusResult { + checks: std::mem::take(checks), + control_owned, + connected: true, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/doctor/checks/dbus/owners.rs b/crates/noticenterctl/src/doctor/checks/dbus/owners.rs new file mode 100644 index 000000000..6ea440b85 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/owners.rs @@ -0,0 +1,164 @@ +//! Notification and control name ownership probes + +use zbus::fdo::DBusProxy; +use zbus::names::BusName; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::DBUS_CHECK_TIMEOUT; +use unixnotis_core::{CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; + +#[derive(Debug)] +pub(super) enum OwnerState { + Owned(String), + Unowned, + QueryFailed, + TimedOut, +} + +pub(super) struct OwnerProbe { + pub(super) state: OwnerState, + pub(super) check: DoctorCheck, +} + +impl OwnerProbe { + pub(super) fn owner(&self) -> Option<&str> { + match &self.state { + OwnerState::Owned(owner) => Some(owner), + OwnerState::Unowned | OwnerState::QueryFailed | OwnerState::TimedOut => None, + } + } +} + +pub(super) async fn probe_notifications_owner(proxy: &DBusProxy<'_>) -> OwnerProbe { + probe_owner( + proxy, + NOTIFICATIONS_BUS_NAME, + "dbus.notifications-owner", + "Notification service", + ) + .await +} + +pub(super) async fn probe_control_owner(proxy: &DBusProxy<'_>) -> OwnerProbe { + probe_owner( + proxy, + CONTROL_BUS_NAME, + "dbus.control-owner", + "UnixNotis control service", + ) + .await +} + +async fn probe_owner( + proxy: &DBusProxy<'_>, + name: &'static str, + id: &'static str, + label: &'static str, +) -> OwnerProbe { + let bus_name = BusName::try_from(name).expect("static D-Bus name must be valid"); + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.name_has_owner(bus_name.clone())).await { + Ok(Ok(true)) => read_owner(proxy, bus_name, name, id, label).await, + Ok(Ok(false)) => OwnerProbe { + state: OwnerState::Unowned, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Warning, + format!("{name} has no owner"), + ), + }, + Ok(Err(error)) => OwnerProbe { + state: OwnerState::QueryFailed, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Unable to inspect {name} ownership"), + ) + .details(safe_doctor_text(&error.to_string())), + }, + Err(_) => OwnerProbe { + state: OwnerState::TimedOut, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Ownership query for {name} timed out"), + ), + }, + } +} + +async fn read_owner( + proxy: &DBusProxy<'_>, + bus_name: BusName<'_>, + name: &'static str, + id: &'static str, + label: &'static str, +) -> OwnerProbe { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, proxy.get_name_owner(bus_name)).await { + Ok(Ok(owner)) => { + let owner = owner.to_string(); + OwnerProbe { + state: OwnerState::Owned(owner.clone()), + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Pass, + format!("{name} has an owner"), + ) + .data("owner", owner), + } + } + Ok(Err(error)) => OwnerProbe { + state: OwnerState::QueryFailed, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Unable to read {name} owner"), + ) + .details(safe_doctor_text(&error.to_string())), + }, + Err(_) => OwnerProbe { + state: OwnerState::TimedOut, + check: DoctorCheck::new( + id, + label, + DoctorSeverity::Error, + format!("Owner query for {name} timed out"), + ), + }, + } +} + +pub(super) fn notification_readiness_failure() -> DoctorCheck { + DoctorCheck::new( + "dbus.notifications-readiness", + "Notification readiness", + DoctorSeverity::Error, + "No notification service owns org.freedesktop.Notifications", + ) + .hint("Start unixnotis-daemon and run doctor again") +} + +pub(super) fn shared_owner_check(notification_owner: &str, control_owner: &str) -> DoctorCheck { + let owners_match = notification_owner == control_owner; + DoctorCheck::new( + "dbus.shared-owner", + "UnixNotis D-Bus ownership", + if owners_match { + DoctorSeverity::Pass + } else { + DoctorSeverity::Error + }, + if owners_match { + "Notification and control names share one owner" + } else { + "Notification and control names have different owners" + }, + ) + .data("notifications_owner", notification_owner) + .data("control_owner", control_owner) +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/session.rs b/crates/noticenterctl/src/doctor/checks/dbus/session.rs new file mode 100644 index 000000000..e0079d2f9 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/session.rs @@ -0,0 +1,100 @@ +//! Session connection, identity, and daemon-proxy probes + +use unixnotis_core::log_session_bus_identity; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::super::report::safe_doctor_text; +use super::super::super::report::{DoctorCheck, DoctorSeverity}; +use super::DBUS_CHECK_TIMEOUT; + +pub(super) struct SessionProbe { + pub(super) connection: Option, + pub(super) checks: Vec, +} + +pub(super) async fn probe_session() -> SessionProbe { + // A broken session environment must never make doctor hang + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, Connection::session()).await { + Ok(Ok(connection)) => SessionProbe { + connection: Some(connection), + checks: Vec::new(), + }, + Ok(Err(error)) => unavailable_probe(format!("Session bus connection failed: {error}")), + Err(_) => unavailable_probe("Session bus connection timed out".to_string()), + } +} + +pub(super) async fn connected_checks(connection: &Connection) -> Vec { + let mut checks = vec![DoctorCheck::new( + "dbus.session", + "Session bus", + DoctorSeverity::Pass, + "Session bus connection succeeded", + )]; + let identity_check = match log_session_bus_identity(connection, "noticenterctl doctor").await { + Ok(identity) => DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Pass, + "Session bus identity probe succeeded", + ) + .details(format!( + "Bus ID: {}\nUnique name: {}\nRuntime directory: {}", + identity.bus_id, identity.unique_name, identity.runtime_dir + )) + .data("bus_id", identity.bus_id) + .data("unique_name", identity.unique_name) + .data("runtime_dir", identity.runtime_dir), + Err(error) => DoctorCheck::new( + "dbus.identity", + "Session bus identity", + DoctorSeverity::Error, + "Session bus identity probe failed", + ) + .details(safe_doctor_text(&error.to_string())), + }; + checks.push(identity_check); + checks +} + +pub(super) async fn build_bus_proxy(connection: &Connection) -> Result, DoctorCheck> { + match tokio::time::timeout(DBUS_CHECK_TIMEOUT, DBusProxy::new(connection)).await { + Ok(Ok(proxy)) => Ok(proxy), + Ok(Err(error)) => Err(DoctorCheck::new( + "dbus.proxy", + "Session bus proxy", + DoctorSeverity::Error, + "D-Bus daemon proxy construction failed", + ) + .details(safe_doctor_text(&error.to_string()))), + Err(_) => Err(DoctorCheck::new( + "dbus.proxy", + "Session bus proxy", + DoctorSeverity::Error, + "D-Bus daemon proxy construction timed out", + )), + } +} + +pub(super) fn unavailable_probe(details: String) -> SessionProbe { + // Dependent checks collapse into one note instead of cascading misleading failures + SessionProbe { + connection: None, + checks: vec![ + DoctorCheck::new( + "dbus.session", + "Session bus", + DoctorSeverity::Error, + "Session bus is unavailable", + ) + .details(safe_doctor_text(&details)), + DoctorCheck::new( + "dbus.dependent-checks", + "D-Bus dependent checks", + DoctorSeverity::Note, + "Owner, proxy, and GetState checks could not run", + ), + ], + } +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs new file mode 100644 index 000000000..946915fc8 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/classify.rs @@ -0,0 +1,39 @@ +use crate::doctor::report::DoctorSeverity; + +use super::super::classify::control_state_failure_check; + +#[test] +fn access_denied_state_failure_explains_installed_client_requirements() { + let error = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( + "caller is not authorized for control operation".to_string(), + ))); + + let check = control_state_failure_check(&error); + + assert_eq!(check.id, "dbus.control-state"); + assert_eq!(check.severity, DoctorSeverity::Error); + assert_eq!(check.summary, "UnixNotis control access denied"); + assert_eq!( + check.details.as_deref(), + Some("The running daemon rejected this client") + ); + assert!(check + .hint + .as_deref() + .is_some_and(|hint| hint.contains("installed noticenterctl"))); + assert!(!check + .details + .as_deref() + .is_some_and(|details| details.contains("caller is not authorized"))); +} + +#[test] +fn non_authorization_state_failure_preserves_the_original_error() { + let error = zbus::Error::Failure("state unavailable".to_string()); + + let check = control_state_failure_check(&error); + + assert_eq!(check.summary, "GetState failed"); + assert_eq!(check.details.as_deref(), Some("state unavailable")); + assert!(check.hint.is_none()); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs new file mode 100644 index 000000000..748b1f622 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/control.rs @@ -0,0 +1,117 @@ +use crate::doctor::report::DoctorSeverity; +use unixnotis_core::CONTROL_BUS_NAME; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::ConnectionBuilder; + +use super::super::{control, inspect_bus_connection, owners}; +use super::support::{check_ids, connect, control_server, run_async, PrivateBroker}; + +#[test] +fn same_owner_and_healthy_ui_preserve_the_complete_check_sequence() { + run_async(async { + let broker = PrivateBroker::start(); + let _server = control_server(&broker.address, false, true).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert!(result.control_owned); + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.shared-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + let health = result + .checks + .iter() + .find(|check| check.id == "dbus.ui-health") + .expect("UI health check"); + assert_eq!(health.severity, DoctorSeverity::Pass); + assert!(health + .details + .as_deref() + .is_some_and(|details| details.contains("Popup D-Bus/GTK client: ready"))); + assert!(!health + .details + .as_deref() + .is_some_and(|details| details.contains("Popup GTK runtime:"))); + }); +} + +#[test] +fn access_denied_control_state_reports_mismatched_installation_guidance() { + run_async(async { + let broker = PrivateBroker::start(); + let _server = control_server(&broker.address, true, true).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + let state = result + .checks + .iter() + .find(|check| check.id == "dbus.control-state") + .expect("control state check"); + + assert_eq!(state.severity, DoctorSeverity::Error); + assert_eq!(state.summary, "UnixNotis control access denied"); + }); +} + +#[test] +fn control_owner_loss_between_probe_and_get_state_is_reported() { + run_async(async { + let broker = PrivateBroker::start(); + let server = control_server(&broker.address, false, true).await; + let client = connect(&broker.address).await; + let dbus = DBusProxy::new(&client).await.expect("create daemon proxy"); + let owner = owners::probe_control_owner(&dbus).await; + assert!(owner.owner().is_some()); + server + .release_name(CONTROL_BUS_NAME) + .await + .expect("release control name"); + drop(server); + let control_name = BusName::try_from(CONTROL_BUS_NAME).expect("static control name"); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while dbus + .name_has_owner(control_name.clone()) + .await + .expect("query control owner") + { + tokio::task::yield_now().await; + } + }) + .await + .expect("control owner should disappear"); + // A replacement without the UnixNotis interface prevents host activation from masking + // the owner-generation race on systems with the control service installed + let _replacement = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request replacement control name") + .build() + .await + .expect("connect replacement owner"); + + let checks = control::inspect_control(&client).await; + + assert_eq!( + checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + ["dbus.control-proxy", "dbus.control-state", "dbus.ui-health"] + ); + assert_eq!(checks[1].severity, DoctorSeverity::Error); + assert_eq!(checks[2].severity, DoctorSeverity::Error); + }); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs new file mode 100644 index 000000000..09e826793 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/mod.rs @@ -0,0 +1,5 @@ +mod classify; +mod control; +mod owners; +mod session; +mod support; diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs new file mode 100644 index 000000000..c6f485f75 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/owners.rs @@ -0,0 +1,127 @@ +use crate::doctor::report::DoctorSeverity; +use unixnotis_core::{CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; +use zbus::ConnectionBuilder; + +use super::super::inspect_bus_connection; +use super::support::{check_ids, connect, control_server, run_async, PrivateBroker, TestControl}; + +#[test] +fn no_bus_owners_preserve_the_complete_readiness_failure_sequence() { + run_async(async { + let broker = PrivateBroker::start(); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert!(!result.control_owned); + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.notifications-readiness", + "dbus.control-owner", + "dbus.control-state", + ] + ); + }); +} + +#[test] +fn notification_owner_without_control_owner_keeps_control_failure_last() { + run_async(async { + let broker = PrivateBroker::start(); + let _notifications = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(NOTIFICATIONS_BUS_NAME) + .expect("request notification bus name") + .build() + .await + .expect("connect notification service"); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.control-state", + ] + ); + }); +} + +#[test] +fn notification_gap_does_not_hide_healthy_control_checks() { + run_async(async { + let broker = PrivateBroker::start(); + let _control = control_server(&broker.address, false, false).await; + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.notifications-readiness", + "dbus.control-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + }); +} + +#[test] +fn different_owners_preserve_the_shared_owner_error_and_check_order() { + run_async(async { + let broker = PrivateBroker::start(); + let _control = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request control bus name") + .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state: false }) + .expect("register test control interface") + .build() + .await + .expect("connect control service"); + let _notifications = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .name(NOTIFICATIONS_BUS_NAME) + .expect("request notification bus name") + .build() + .await + .expect("connect separate notification service"); + let client = connect(&broker.address).await; + + let result = inspect_bus_connection(&client).await; + assert_eq!( + check_ids(&result), + [ + "dbus.session", + "dbus.identity", + "dbus.notifications-owner", + "dbus.control-owner", + "dbus.shared-owner", + "dbus.control-proxy", + "dbus.control-state", + "dbus.ui-health", + ] + ); + let ownership = result + .checks + .iter() + .find(|check| check.id == "dbus.shared-owner") + .expect("shared owner check"); + assert_eq!(ownership.severity, DoctorSeverity::Error); + }); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs new file mode 100644 index 000000000..c2b6bfaa4 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/session.rs @@ -0,0 +1,20 @@ +use crate::doctor::report::DoctorSeverity; + +use super::super::session::unavailable_probe; + +#[test] +fn unavailable_bus_preserves_the_complete_dependent_check_sequence() { + let result = unavailable_probe("connection refused".to_string()); + + assert!(result.connection.is_none()); + assert_eq!( + result + .checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + ["dbus.session", "dbus.dependent-checks"] + ); + assert_eq!(result.checks[0].severity, DoctorSeverity::Error); + assert_eq!(result.checks[1].severity, DoctorSeverity::Note); +} diff --git a/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs new file mode 100644 index 000000000..ca9c112f0 --- /dev/null +++ b/crates/noticenterctl/src/doctor/checks/dbus/tests/support.rs @@ -0,0 +1,159 @@ +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{ + ControlState, UiHealth, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME, +}; +use zbus::ConnectionBuilder; + +static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); + +pub(super) struct PrivateBroker { + child: Child, + socket: PathBuf, + pub(super) address: String, +} + +impl PrivateBroker { + pub(super) fn start() -> Self { + let socket = broker_socket(); + let listen_address = format!("unix:path={}", socket.display()); + // Resolve from protected roots because other tests may temporarily replace PATH + let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") + .expect("find dbus-daemon in a trusted system directory"); + let mut child = Command::new(daemon) + .args([ + "--session", + "--nofork", + "--nopidfile", + "--print-address=1", + &format!("--address={listen_address}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("start private D-Bus broker"); + let stdout = child.stdout.take().expect("capture private broker address"); + let mut address = String::new(); + BufReader::new(stdout) + .read_line(&mut address) + .expect("read private broker address"); + assert!( + address.trim().starts_with(&listen_address), + "private broker must listen on the requested socket" + ); + Self { + child, + socket, + address: address.trim().to_string(), + } + } +} + +impl Drop for PrivateBroker { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.socket); + if let Some(parent) = self.socket.parent() { + let _ = std::fs::remove_dir(parent); + } + } +} + +pub(super) struct TestControl { + pub(super) deny_state: bool, +} + +#[zbus::interface(name = "com.unixnotis.Control")] +impl TestControl { + fn get_state(&self) -> zbus::fdo::Result { + if self.deny_state { + return Err(zbus::fdo::Error::AccessDenied( + "test client denied".to_string(), + )); + } + Ok(ControlState { + dnd_enabled: true, + dnd_expires_at: 0, + history_count: 4, + inhibited: false, + inhibitor_count: 2, + }) + } + + fn get_ui_health(&self) -> zbus::fdo::Result { + Ok(UiHealth { + center_process_running: true, + center_ready: true, + popups_process_running: true, + popups_ready: true, + revision: 0, + }) + } +} + +pub(super) fn run_async(future: impl std::future::Future) { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build D-Bus test runtime") + .block_on(future); +} + +pub(super) async fn connect(address: &str) -> zbus::Connection { + ConnectionBuilder::address(address) + .expect("parse private broker address") + .build() + .await + .expect("connect to private broker") +} + +pub(super) async fn control_server( + address: &str, + deny_state: bool, + own_notifications: bool, +) -> zbus::Connection { + let connection = ConnectionBuilder::address(address) + .expect("parse private broker address") + .name(CONTROL_BUS_NAME) + .expect("request control bus name") + .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state }) + .expect("register test control interface") + .build() + .await + .expect("connect test control service"); + if own_notifications { + connection + .request_name(NOTIFICATIONS_BUS_NAME) + .await + .expect("request notification bus name"); + } + connection +} + +pub(super) fn check_ids(result: &super::super::DoctorBusResult) -> Vec<&str> { + result + .checks + .iter() + .map(|check| check.id.as_str()) + .collect() +} + +fn broker_socket() -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after the Unix epoch") + .as_nanos(); + let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-doctor-dbus-{}-{stamp}-{serial}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create private broker directory"); + root.join("bus.sock") +} diff --git a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs b/crates/noticenterctl/src/doctor/checks/tests/dbus.rs deleted file mode 100644 index e788a0be5..000000000 --- a/crates/noticenterctl/src/doctor/checks/tests/dbus.rs +++ /dev/null @@ -1,256 +0,0 @@ -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use super::super::dbus::*; -use crate::doctor::report::DoctorSeverity; -use unixnotis_core::{ControlState, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; -use zbus::ConnectionBuilder; - -static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); - -struct PrivateBroker { - child: Child, - socket: PathBuf, - address: String, -} - -impl PrivateBroker { - fn start() -> Self { - let socket = broker_socket(); - let listen_address = format!("unix:path={}", socket.display()); - // Other tests may temporarily replace PATH, so resolve the broker from fixed system roots - let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") - .expect("find dbus-daemon in a trusted system directory"); - let mut child = Command::new(daemon) - .args([ - "--session", - "--nofork", - "--nopidfile", - "--print-address=1", - &format!("--address={listen_address}"), - ]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("start private D-Bus broker"); - let stdout = child.stdout.take().expect("capture private broker address"); - let mut address = String::new(); - BufReader::new(stdout) - .read_line(&mut address) - .expect("read private broker address"); - assert!( - address.trim().starts_with(&listen_address), - "private broker must listen on the requested socket" - ); - - Self { - child, - socket, - address: address.trim().to_string(), - } - } -} - -impl Drop for PrivateBroker { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_file(&self.socket); - if let Some(parent) = self.socket.parent() { - let _ = std::fs::remove_dir(parent); - } - } -} - -struct TestControl { - deny_state: bool, -} - -#[zbus::interface(name = "com.unixnotis.Control")] -impl TestControl { - fn get_state(&self) -> zbus::fdo::Result { - if self.deny_state { - return Err(zbus::fdo::Error::AccessDenied( - "test client denied".to_string(), - )); - } - - Ok(ControlState { - dnd_enabled: true, - history_count: 4, - inhibited: false, - inhibitor_count: 2, - }) - } -} - -fn broker_socket() -> PathBuf { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock must be after the Unix epoch") - .as_nanos(); - let serial = NEXT_BROKER.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "unixnotis-doctor-dbus-{}-{stamp}-{serial}", - std::process::id() - )); - std::fs::create_dir_all(&root).expect("create private broker directory"); - root.join("bus.sock") -} - -async fn connect(address: &str) -> zbus::Connection { - ConnectionBuilder::address(address) - .expect("parse private broker address") - .build() - .await - .expect("connect to private broker") -} - -async fn control_server(address: &str, deny_state: bool) -> zbus::Connection { - let connection = ConnectionBuilder::address(address) - .expect("parse private broker address") - .name(CONTROL_BUS_NAME) - .expect("request control bus name") - .serve_at(CONTROL_OBJECT_PATH, TestControl { deny_state }) - .expect("register test control interface") - .build() - .await - .expect("connect test control service"); - connection - .request_name(NOTIFICATIONS_BUS_NAME) - .await - .expect("request notification bus name"); - connection -} - -#[test] -fn unavailable_bus_preserves_an_error_and_dependent_check_context() { - let result = unavailable_bus_result("connection refused".to_string()); - - assert!(!result.control_owned); - assert_eq!(result.checks.len(), 2); - assert_eq!(result.checks[0].severity, DoctorSeverity::Error); - assert_eq!(result.checks[1].severity, DoctorSeverity::Note); -} - -#[test] -fn access_denied_state_failure_explains_installed_client_requirements() { - let error = zbus::Error::FDO(Box::new(zbus::fdo::Error::AccessDenied( - "caller is not authorized for control operation".to_string(), - ))); - - let check = control_state_failure_check(&error); - - assert_eq!(check.id, "dbus.control-state"); - assert_eq!(check.severity, DoctorSeverity::Error); - assert_eq!(check.summary, "UnixNotis control access denied"); - assert_eq!( - check.details.as_deref(), - Some("The running daemon rejected this client") - ); - assert!(check - .hint - .as_deref() - .is_some_and(|hint| hint.contains("installed noticenterctl"))); - assert!(!check - .details - .as_deref() - .is_some_and(|details| details.contains("caller is not authorized"))); -} - -#[test] -fn non_authorization_state_failure_preserves_the_original_error() { - let error = zbus::Error::Failure("state unavailable".to_string()); - - let check = control_state_failure_check(&error); - - assert_eq!(check.summary, "GetState failed"); - assert_eq!(check.details.as_deref(), Some("state unavailable")); - assert!(check.hint.is_none()); -} - -#[test] -fn missing_bus_owners_report_both_readiness_failures() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(!result.control_owned); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.notifications-readiness" && check.severity == DoctorSeverity::Error - })); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.control-state" - && check.summary == "UnixNotis control service has no owner" - })); - }); -} - -#[test] -fn owned_control_service_runs_proxy_and_state_checks() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let _server = control_server(&broker.address, false).await; - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(result.control_owned); - assert!(result.checks.iter().any(|check| { - check.id == "dbus.control-proxy" && check.severity == DoctorSeverity::Pass - })); - let state = result - .checks - .iter() - .find(|check| check.id == "dbus.control-state") - .expect("control state check"); - assert_eq!(state.severity, DoctorSeverity::Pass); - assert_eq!(state.summary, "GetState completed"); - assert!(state - .details - .as_deref() - .is_some_and(|details| details.contains("History entries: 4"))); - }); -} - -#[test] -fn method_error_access_denial_uses_the_specific_client_guidance() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("build D-Bus test runtime"); - runtime.block_on(async { - let broker = PrivateBroker::start(); - let _server = control_server(&broker.address, true).await; - let client = connect(&broker.address).await; - - let result = inspect_bus_connection(&client).await; - - assert!(result.control_owned); - let state = result - .checks - .iter() - .find(|check| check.id == "dbus.control-state") - .expect("control state check"); - assert_eq!(state.severity, DoctorSeverity::Error); - assert_eq!(state.summary, "UnixNotis control access denied"); - assert_eq!( - state.details.as_deref(), - Some("The running daemon rejected this client") - ); - }); -} diff --git a/crates/noticenterctl/src/doctor/checks/tests/mod.rs b/crates/noticenterctl/src/doctor/checks/tests/mod.rs index ca74e29fb..e515969e5 100644 --- a/crates/noticenterctl/src/doctor/checks/tests/mod.rs +++ b/crates/noticenterctl/src/doctor/checks/tests/mod.rs @@ -1,4 +1,3 @@ mod config; mod css; -mod dbus; mod environment; diff --git a/crates/noticenterctl/src/doctor/logs/systemd.rs b/crates/noticenterctl/src/doctor/logs/systemd.rs index 7090eb0f5..de56b5c7f 100644 --- a/crates/noticenterctl/src/doctor/logs/systemd.rs +++ b/crates/noticenterctl/src/doctor/logs/systemd.rs @@ -7,7 +7,7 @@ use std::time::Duration; use crate::debug_logs::journal::{daemon_unit_from_env, recent_args}; use crate::system_tools; use tokio::io::AsyncReadExt; -use tokio::process::Command; +use unixnotis_core::CommandSpec; use super::super::report::safe_doctor_text; use super::super::report::{DoctorLogResult, DoctorLogSource}; @@ -60,11 +60,10 @@ pub(super) async fn read_recent_journal( unit: &str, ) -> Result { // Fixed trusted lookup prevents a PATH entry from impersonating journalctl - let path = system_tools::trusted_program_path("journalctl") - .ok_or_else(|| "journalctl was not found in trusted system directories".to_string())?; - let mut command = Command::new(path); + let spec = CommandSpec::direct("journalctl", recent_args(unit, JOURNAL_LINE_LIMIT)); + let mut command = system_tools::tokio_command_from_spec(&spec) + .map_err(|error| safe_doctor_text(&error.to_string()))?; command - .args(recent_args(unit, JOURNAL_LINE_LIMIT)) .stdout(Stdio::piped()) .stderr(Stdio::null()) .kill_on_drop(true); diff --git a/crates/noticenterctl/src/doctor/report/render.rs b/crates/noticenterctl/src/doctor/report/render.rs index 840170dc9..59e9517e2 100644 --- a/crates/noticenterctl/src/doctor/report/render.rs +++ b/crates/noticenterctl/src/doctor/report/render.rs @@ -3,6 +3,7 @@ use anyhow::Result; use super::model::{DoctorLogResult, DoctorReport}; +use super::text::safe_doctor_text; pub(super) fn render_json(report: &DoctorReport) -> Result { // Pretty JSON remains readable as an issue attachment while preserving the schema @@ -10,23 +11,30 @@ pub(super) fn render_json(report: &DoctorReport) -> Result { } pub(super) fn render_human(report: &DoctorReport) -> String { + // Human output is a terminal boundary for paths, errors, config keys, and logs + // Keep every free-form model value bounded and on one physical line // The heading includes both software and report schema versions let mut lines = vec![format!( "UnixNotis doctor {} (schema {})", - report.unixnotis_version, report.schema_version + safe_doctor_text(&report.unixnotis_version), + report.schema_version )]; // Input order is retained so related checks stay grouped predictably for check in &report.checks { lines.push(String::new()); - lines.push(check.label.to_uppercase()); - lines.push(format!("[{}] {}", check.severity.label(), check.summary)); + lines.push(safe_doctor_text(&check.label).to_uppercase()); + lines.push(format!( + "[{}] {}", + check.severity.label(), + safe_doctor_text(&check.summary) + )); // Optional context stays on plain lines for easy terminal copying if let Some(details) = &check.details { - lines.push(details.clone()); + lines.push(safe_doctor_text(details)); } if let Some(hint) = &check.hint { - lines.push(format!("Hint: {hint}")); + lines.push(format!("Hint: {}", safe_doctor_text(hint))); } } @@ -34,16 +42,20 @@ pub(super) fn render_human(report: &DoctorReport) -> String { lines.push(String::new()); lines.push("CONFIGURATION DIAGNOSTICS".to_string()); for diagnostic in &report.config_diagnostics { - lines.push(format!("[{:?}] {}", diagnostic.kind, diagnostic.message)); + lines.push(format!( + "[{:?}] {}", + diagnostic.kind, + safe_doctor_text(&diagnostic.message) + )); lines.push(format!("Code: {}", diagnostic.code)); if let Some(path) = &diagnostic.path { - lines.push(format!("Key: {path}")); + lines.push(format!("Key: {}", safe_doctor_text(path))); } if let Some(original) = &diagnostic.original { - lines.push(format!("Original: {original}")); + lines.push(format!("Original: {}", safe_doctor_text(original))); } if let Some(effective) = &diagnostic.effective { - lines.push(format!("Effective: {effective}")); + lines.push(format!("Effective: {}", safe_doctor_text(effective))); } } } @@ -63,14 +75,17 @@ pub(super) fn render_human(report: &DoctorReport) -> String { lines.push(format!("Source: {source:?}")); lines.push(format!("Limits: {line_limit} lines, {byte_limit} bytes")); lines.push(format!("Truncated: {truncated}")); - lines.extend(logs.iter().map(|line| format!(" {line}"))); + lines.extend( + logs.iter() + .map(|line| format!(" {}", safe_doctor_text(line))), + ); } DoctorLogResult::Unavailable { reason, hint, .. } => { // Unavailable sources explain the limitation without pretending collection failed lines.push("Persistent logs: unavailable".to_string()); - lines.push(reason.clone()); + lines.push(safe_doctor_text(reason)); if let Some(hint) = hint { - lines.push(format!("Hint: {hint}")); + lines.push(format!("Hint: {}", safe_doctor_text(hint))); } } } diff --git a/crates/noticenterctl/src/doctor/report/tests/render.rs b/crates/noticenterctl/src/doctor/report/tests/render.rs index 3f2769fa5..3ee830772 100644 --- a/crates/noticenterctl/src/doctor/report/tests/render.rs +++ b/crates/noticenterctl/src/doctor/report/tests/render.rs @@ -46,7 +46,7 @@ fn human_output_omits_machine_data_that_duplicates_curated_details() { let rendered = render_human(&report); - assert!(rendered.contains("Manager: systemd\nState: active")); + assert!(rendered.contains("Manager: systemd State: active")); assert!(!rendered.contains("manager: systemd")); assert!(!rendered.contains("active: true")); } @@ -80,6 +80,52 @@ fn human_output_renders_typed_configuration_diagnostics() { assert!(rendered.contains("Effective: 100")); } +#[test] +fn human_output_sanitizes_every_free_form_terminal_field() { + let report = DoctorReport::new( + vec![DoctorCheck::new( + "example", + "Example\nFORGED_CHECK_HEADING", + DoctorSeverity::Warning, + "Unsafe\u{1b}[31m summary", + ) + .details("detail\nFORGED_DETAIL_LINE") + .hint("hint\u{202e}spoof")], + vec![ConfigDiagnostic { + code: "config.unknown-key", + kind: ConfigDiagnosticKind::Warning, + path: Some("example\nFORGED_CONFIG_FIELD".to_string()), + message: "Unknown\u{1b}[31m configuration key".to_string(), + original: Some("before\nFORGED_ORIGINAL_FIELD".to_string()), + effective: Some("after\u{202e}spoof".to_string()), + }], + DoctorLogResult::Unavailable { + source: DoctorLogSource::Manual, + reason: "unavailable\nFORGED_LOG_FIELD".to_string(), + hint: Some("log hint\u{1b}[31mred".to_string()), + }, + ); + + let rendered = render_human(&report); + + assert!(!rendered.contains('\u{1b}')); + assert!(!rendered.contains('\u{202e}')); + for forged_line in [ + "\nFORGED_CHECK_HEADING", + "\nFORGED_DETAIL_LINE", + "\nFORGED_CONFIG_FIELD", + "\nFORGED_ORIGINAL_FIELD", + "\nFORGED_LOG_FIELD", + ] { + assert!( + !rendered.contains(forged_line), + "free-form report values must not create terminal lines" + ); + } + assert!(rendered.contains("detail FORGED_DETAIL_LINE")); + assert!(rendered.contains("Key: example FORGED_CONFIG_FIELD")); +} + #[test] fn json_output_is_valid_and_versioned() { let report = DoctorReport::new( diff --git a/crates/noticenterctl/src/doctor/service/probe.rs b/crates/noticenterctl/src/doctor/service/probe.rs index 99bf2bfd9..d97a03c53 100644 --- a/crates/noticenterctl/src/doctor/service/probe.rs +++ b/crates/noticenterctl/src/doctor/service/probe.rs @@ -4,8 +4,8 @@ use std::env; use std::path::Path; use std::time::Duration; -use tokio::process::Command; use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; +use unixnotis_core::CommandSpec; use crate::debug_logs::journal::daemon_unit_from_env; use crate::system_tools; @@ -56,11 +56,11 @@ pub(super) async fn active_candidate( paths: &ServiceManagerPaths, ) -> (bool, Option) { // Candidate probes and final status checks share one command definition - let (program, args) = match status_command(kind, paths) { + let command = match status_command(kind, paths) { Ok(command) => command, Err(error) => return (false, Some(error)), }; - match run_bounded_status(program, &args).await { + match run_bounded_status(&command).await { Ok(output) => { let stdout = sanitize_output(&output.stdout); ( @@ -76,7 +76,7 @@ pub(super) async fn status_check( kind: ServiceManagerKind, paths: &ServiceManagerPaths, ) -> DoctorCheck { - let (program, args) = match status_command(kind, paths) { + let command = match status_command(kind, paths) { Ok(command) => command, Err(error) => { return DoctorCheck::new( @@ -90,7 +90,7 @@ pub(super) async fn status_check( } }; // Probe failures remain warnings so the rest of doctor can explain the install - let output = match run_bounded_status(program, &args).await { + let output = match run_bounded_status(&command).await { Ok(output) => output, Err(error) => { return DoctorCheck::new( @@ -149,7 +149,7 @@ pub(super) async fn status_check( pub(super) fn status_command( kind: ServiceManagerKind, paths: &ServiceManagerPaths, -) -> Result<(&'static str, Vec), String> { +) -> Result { status_command_with_env(kind, paths, |key| env::var(key)) } @@ -157,7 +157,7 @@ pub(super) fn status_command_with_env( kind: ServiceManagerKind, paths: &ServiceManagerPaths, get_var: impl FnOnce(&str) -> Result, -) -> Result<(&'static str, Vec), String> { +) -> Result { // Only systemd consumes the configurable unit name // // Keeping this validation inside the systemd branch prevents an invalid @@ -174,12 +174,12 @@ pub(super) fn status_command_for_unit( kind: ServiceManagerKind, paths: &ServiceManagerPaths, systemd_unit: &str, -) -> (&'static str, Vec) { +) -> CommandSpec { // Every backend uses its documented read-only status command match kind { - ServiceManagerKind::Systemd => ( + ServiceManagerKind::Systemd => CommandSpec::direct( "systemctl", - vec![ + [ "--user".to_string(), "show".to_string(), "--property=LoadState".to_string(), @@ -193,25 +193,25 @@ pub(super) fn status_command_for_unit( systemd_unit.to_string(), ], ), - ServiceManagerKind::Dinit => ( + ServiceManagerKind::Dinit => CommandSpec::direct( "dinitctl", - vec![ + [ "--user".to_string(), "--quiet".to_string(), "is-started".to_string(), SERVICE_NAME.to_string(), ], ), - ServiceManagerKind::Runit => ( + ServiceManagerKind::Runit => CommandSpec::direct( "sv", - vec![ + [ "status".to_string(), paths.artifact_root.join(SERVICE_NAME).display().to_string(), ], ), - ServiceManagerKind::S6 => ( + ServiceManagerKind::S6 => CommandSpec::direct( "s6-svstat", - vec![ + [ "-o".to_string(), "up".to_string(), paths @@ -227,15 +227,15 @@ pub(super) fn status_command_for_unit( } } -async fn run_bounded_status( - program: &str, - args: &[String], -) -> Result { - // Trusted fixed directories prevent PATH replacement from changing doctor behavior - let path = system_tools::trusted_program_path(program) - .ok_or_else(|| format!("{program} was not found in trusted system directories"))?; - let command = Command::new(path).args(args).output(); - tokio::time::timeout(SERVICE_STATUS_TIMEOUT, command) +async fn run_bounded_status(command: &CommandSpec) -> Result { + let program = command + .program() + .and_then(Path::to_str) + .unwrap_or("service manager"); + let process = system_tools::tokio_command_from_spec(command) + .map_err(|error| safe_doctor_text(&error.to_string()))? + .output(); + tokio::time::timeout(SERVICE_STATUS_TIMEOUT, process) .await .map_err(|_elapsed| format!("{program} status probe timed out"))? .map_err(|error| safe_doctor_text(&format!("{program} status probe failed: {error}"))) diff --git a/crates/noticenterctl/src/doctor/service/tests/probe.rs b/crates/noticenterctl/src/doctor/service/tests/probe.rs index a1b5e9e10..5f22e0ae4 100644 --- a/crates/noticenterctl/src/doctor/service/tests/probe.rs +++ b/crates/noticenterctl/src/doctor/service/tests/probe.rs @@ -93,14 +93,18 @@ fn other_status_parsers_require_their_documented_active_shape() { #[test] fn systemd_status_places_options_before_the_protected_unit_operand() { - let (_, args) = status_command_for_unit( + let command = status_command_for_unit( ServiceManagerKind::Systemd, &paths(ServiceManagerKind::Systemd), "custom.service", ); + let args = command.args().expect("direct systemd status command"); assert_eq!(args[args.len() - 2], "--"); - assert_eq!(args.last().map(String::as_str), Some("custom.service")); + assert_eq!( + args.last().and_then(|argument| argument.to_str()), + Some("custom.service") + ); assert!(args[..args.len() - 2] .iter() .all(|argument| argument != "custom.service")); diff --git a/crates/noticenterctl/src/main.rs b/crates/noticenterctl/src/main.rs index 005b70559..23ba7c9d8 100644 --- a/crates/noticenterctl/src/main.rs +++ b/crates/noticenterctl/src/main.rs @@ -24,7 +24,9 @@ mod debug_logs; mod doctor; mod output; mod preset; +mod session_environment; mod system_tools; +mod theme; use std::process::ExitCode; diff --git a/crates/noticenterctl/src/output/diagnostics.rs b/crates/noticenterctl/src/output/diagnostics.rs new file mode 100644 index 000000000..110097fc9 --- /dev/null +++ b/crates/noticenterctl/src/output/diagnostics.rs @@ -0,0 +1,241 @@ +//! Human-readable notification attribution and popup diagnostics + +use std::fmt::Write; + +use anyhow::Result; +use unixnotis_core::{ + util, ApplicationActionPolicy, CommandLineQualityView, IdentityAssurance, InlineReplyPolicy, + LaunchAuthorityView, LaunchVerificationView, NotificationDiagnosticsView, PopupAdmissionView, + PopupDeliveryStage, RecordTrust, +}; + +use super::write_stdout; + +pub fn print_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result<()> { + write_stdout(&format_notification_diagnostics(view)?) +} + +// Diagnostic wire values include sender-controlled notification metadata +// Every free-form string passes through the terminal sanitizer here +// Enum labels and numeric fields cannot carry free-form terminal text +fn format_notification_diagnostics(view: &NotificationDiagnosticsView) -> Result { + let diagnostics = &view.attribution; + let mut output = String::new(); + writeln!(output, "Notification: {}:{}", view.id, view.generation)?; + writeln!( + output, + "Application claim: {}", + diagnostic_value(&diagnostics.claimed_name) + )?; + writeln!( + output, + "Claimed desktop entry: {}", + diagnostic_value(&diagnostics.claimed_desktop_entry) + )?; + writeln!( + output, + "Sender executable: {}", + diagnostic_value(&diagnostics.sender_executable) + )?; + writeln!( + output, + "Matched desktop ID: {}", + diagnostic_value(&diagnostics.matched_desktop_id) + )?; + writeln!( + output, + "Record origin: {}", + record_trust(diagnostics.record_trust) + )?; + writeln!( + output, + "Launch authority: {}", + launch_authority(diagnostics.launch_authority) + )?; + writeln!( + output, + "Command line: {}", + command_line_quality(diagnostics.command_line_quality) + )?; + writeln!( + output, + "Launch verification: {}", + verification(diagnostics.verification) + )?; + writeln!( + output, + "Launch detail: {}", + diagnostic_value(&diagnostics.reason) + )?; + writeln!( + output, + "Identity assurance: {}", + identity_assurance(view.identity_assurance) + )?; + writeln!( + output, + "Default activation: {}", + action_policy(view.interaction_policies.default_activation) + )?; + writeln!( + output, + "Action buttons: {}", + action_policy(view.interaction_policies.action_buttons) + )?; + writeln!( + output, + "Inline reply: {}", + reply_policy(view.interaction_policies.inline_reply) + )?; + writeln!(output, "Stored: {}", yes_no(view.stored))?; + writeln!( + output, + "Popup: {}", + if view.popup_admission.should_show() { + "allowed" + } else { + "suppressed" + } + )?; + writeln!( + output, + "Popup reason: {}", + popup_admission(view.popup_admission) + )?; + writeln!( + output, + "Renderer process: {}", + if view.renderer_process_running { + "running" + } else { + "unavailable" + } + )?; + writeln!(output, "Renderer ready: {}", yes_no(view.renderer_ready))?; + writeln!( + output, + "Configured max visible: {}", + view.configured_max_visible + )?; + writeln!( + output, + "Decision time (Unix ms): {}", + view.decided_at_unix_ms + )?; + writeln!( + output, + "Delivery stage: {}", + popup_delivery_stage(view.delivery_stage) + )?; + Ok(output) +} + +const fn identity_assurance(value: IdentityAssurance) -> &'static str { + match value { + IdentityAssurance::Authenticated => "authenticated", + IdentityAssurance::SystemAssociated => "system associated", + IdentityAssurance::PortalAssociated => "portal associated", + IdentityAssurance::UserAssociated => "user associated", + IdentityAssurance::Unresolved => "unresolved", + IdentityAssurance::Conflict => "conflict", + IdentityAssurance::Relay => "relay", + } +} + +const fn action_policy(value: ApplicationActionPolicy) -> &'static str { + match value { + ApplicationActionPolicy::Allow => "allowed", + ApplicationActionPolicy::Confirm => "confirmation required", + ApplicationActionPolicy::Deny => "denied", + } +} + +const fn reply_policy(value: InlineReplyPolicy) -> &'static str { + match value { + InlineReplyPolicy::Allow => "allowed", + InlineReplyPolicy::Confirm => "confirmation required", + InlineReplyPolicy::Deny => "denied", + } +} + +const fn popup_delivery_stage(value: PopupDeliveryStage) -> &'static str { + match value { + PopupDeliveryStage::Suppressed => "suppressed", + PopupDeliveryStage::Admitted => "admitted", + PopupDeliveryStage::FanoutFailed => "fanout failed", + PopupDeliveryStage::RendererFetched => "renderer fetched", + PopupDeliveryStage::Materialized => "materialized", + PopupDeliveryStage::Visible => "visible", + } +} + +fn diagnostic_value(value: &str) -> String { + // Attribution diagnostics may contain sender-controlled metadata + // Keep each field bounded and single-line before it reaches the terminal + let value = util::sanitize_log_value(value, util::diagnostic_log_limit()); + + if value.is_empty() { + "none".to_string() + } else { + value + } +} + +const fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +const fn record_trust(value: RecordTrust) -> &'static str { + match value { + RecordTrust::None => "none", + RecordTrust::Portal => "portal", + RecordTrust::System => "system", + RecordTrust::User => "user", + } +} + +const fn launch_authority(value: LaunchAuthorityView) -> &'static str { + match value { + LaunchAuthorityView::None => "none", + LaunchAuthorityView::DedicatedExecutable => "dedicated executable", + LaunchAuthorityView::ProtectedPayload => "protected payload", + LaunchAuthorityView::DynamicOnly => "dynamic-only contract", + LaunchAuthorityView::Ambiguous => "ambiguous", + } +} + +const fn command_line_quality(value: CommandLineQualityView) -> &'static str { + match value { + CommandLineQualityView::Structured => "structured", + CommandLineQualityView::RewrittenProcessTitle => "rewritten process title", + CommandLineQualityView::Truncated => "truncated", + CommandLineQualityView::Unavailable => "unavailable", + } +} + +const fn verification(value: LaunchVerificationView) -> &'static str { + match value { + LaunchVerificationView::Verified => "verified", + LaunchVerificationView::InsufficientEvidence => "unverified", + LaunchVerificationView::DefinitiveMismatch => "suspicious", + } +} + +const fn popup_admission(value: PopupAdmissionView) -> &'static str { + match value { + PopupAdmissionView::Show => "show", + PopupAdmissionView::Rule => "rule", + PopupAdmissionView::Dnd => "DND", + PopupAdmissionView::Inhibitor => "inhibitor", + PopupAdmissionView::RendererUnavailable => "renderer unavailable", + PopupAdmissionView::RendererDisabled => "renderer disabled", + } +} + +#[cfg(test)] +#[path = "tests/diagnostics.rs"] +mod tests; diff --git a/crates/noticenterctl/src/output/mod.rs b/crates/noticenterctl/src/output/mod.rs index c4e76aae3..248d34ff0 100644 --- a/crates/noticenterctl/src/output/mod.rs +++ b/crates/noticenterctl/src/output/mod.rs @@ -1,10 +1,12 @@ //! Output formatting helpers for noticenterctl +mod diagnostics; mod error; mod gate; mod notifications; mod writer; +pub use diagnostics::print_notification_diagnostics; pub use error::format_cli_error; pub use gate::{allow_full_output, warn_full_requires_diagnostic}; pub use notifications::{print_inhibitors, print_notifications}; diff --git a/crates/noticenterctl/src/output/notifications.rs b/crates/noticenterctl/src/output/notifications.rs index 1af61e4c9..1dbc927d5 100644 --- a/crates/noticenterctl/src/output/notifications.rs +++ b/crates/noticenterctl/src/output/notifications.rs @@ -30,7 +30,7 @@ fn format_notifications(label: &str, notifications: &[NotificationView], full: b for notification in notifications { // Both fields come from notification clients and must remain single-line - let app = util::sanitize_log_value(¬ification.app_name, limit); + let app = util::sanitize_log_value(¬ification.attribution.display_name, limit); let summary = util::sanitize_log_value(¬ification.summary, limit); let action_count = notification.actions.len(); out.push_str(&format!( diff --git a/crates/noticenterctl/src/output/tests/diagnostics.rs b/crates/noticenterctl/src/output/tests/diagnostics.rs new file mode 100644 index 000000000..14a9ccaf8 --- /dev/null +++ b/crates/noticenterctl/src/output/tests/diagnostics.rs @@ -0,0 +1,73 @@ +use super::format_notification_diagnostics; + +#[test] +fn diagnostics_keep_launch_verification_distinct_from_attribution_status() { + let output = + format_notification_diagnostics(&unixnotis_core::NotificationDiagnosticsView::default()) + .expect("default diagnostics should render"); + + assert!( + output.contains("Launch verification: unverified"), + "diagnostics should name the launch evidence being reported" + ); + assert!( + output.contains("Launch detail: none"), + "diagnostics should label the launch evidence detail" + ); + assert!( + output.contains("Identity assurance: unresolved"), + "final identity authority must remain distinct from the launch match" + ); + assert!( + output.contains("Default activation: denied") + && output.contains("Action buttons: denied") + && output.contains("Inline reply: denied"), + "diagnostics must expose every independent interaction policy" + ); +} + +#[test] +fn diagnostics_sanitize_sender_controlled_terminal_text() { + let mut view = unixnotis_core::NotificationDiagnosticsView::default(); + + view.attribution.claimed_name = + "Example App\nFORGED_DIAGNOSTIC_LINE:\u{1b}[31mred\u{1b}[0m".to_string(); + view.attribution.claimed_desktop_entry = + "org.example.App.desktop\nFORGED_DESKTOP_LINE".to_string(); + view.attribution.sender_executable = + "/tmp/example\nFORGED_EXECUTABLE_LINE:\u{1b}[2J".to_string(); + view.attribution.matched_desktop_id = + "org.example.Match.desktop\nFORGED_MATCH_LINE".to_string(); + view.attribution.reason = "ambiguous\nFORGED_REASON_LINE:\u{202e}spoof".to_string(); + + let output = format_notification_diagnostics(&view).expect("diagnostics should render"); + + assert!( + !output.contains('\u{1b}'), + "terminal escape characters must not survive diagnostic rendering" + ); + assert!(!output.contains('\u{202e}')); + for forged_line in [ + "\nFORGED_DIAGNOSTIC_LINE:", + "\nFORGED_DESKTOP_LINE", + "\nFORGED_EXECUTABLE_LINE:", + "\nFORGED_MATCH_LINE", + "\nFORGED_REASON_LINE:", + ] { + assert!( + !output.contains(forged_line), + "diagnostic values must not inject terminal lines" + ); + } + assert!( + output.contains("Application claim: Example App FORGED_DIAGNOSTIC_LINE:"), + "sanitized diagnostic content should remain useful to the operator" + ); + assert!( + output.contains("Claimed desktop entry: org.example.App.desktop FORGED_DESKTOP_LINE"), + "sanitized desktop-entry content should remain inspectable" + ); + assert!(output.contains("Sender executable: /tmp/example FORGED_EXECUTABLE_LINE:")); + assert!(output.contains("Matched desktop ID: org.example.Match.desktop FORGED_MATCH_LINE")); + assert!(output.contains("Launch detail: ambiguous FORGED_REASON_LINE:spoof")); +} diff --git a/crates/noticenterctl/src/output/tests/notifications.rs b/crates/noticenterctl/src/output/tests/notifications.rs index 365cc67fe..79c47bd18 100644 --- a/crates/noticenterctl/src/output/tests/notifications.rs +++ b/crates/noticenterctl/src/output/tests/notifications.rs @@ -7,17 +7,29 @@ fn sample_notification() -> NotificationView { // Bad bytes on purpose NotificationView { id: 7, + generation: 1, app_name: "mailer\n\x1b[31m".to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "mailer\n\x1b[31m".to_string(), + badge_icon: "mailer".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: "subject\rline".to_string(), body: "body\ttext\nnext".to_string(), actions: vec![Action { key: "open".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, // CLI formatting only needs the lightweight transport fields image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/noticenterctl/src/preset/archive/preflight.rs b/crates/noticenterctl/src/preset/archive/preflight.rs index 754caae9f..c1be456b6 100644 --- a/crates/noticenterctl/src/preset/archive/preflight.rs +++ b/crates/noticenterctl/src/preset/archive/preflight.rs @@ -43,6 +43,12 @@ fn scan_headers(input: &mut impl Read) -> Result<()> { let entry_type = header.entry_type(); let recognized = header.as_gnu().is_some() || header.as_ustar().is_some(); + if entry_type.is_pax_global_extensions() { + // Global records change later entries and would give both parsers different state + return Err(anyhow!( + "preset bundle contains unsupported global PAX metadata" + )); + } let is_hidden_extension = recognized && (entry_type.is_gnu_longname() || entry_type.is_gnu_longlink() @@ -146,6 +152,13 @@ fn validate_visible_header(header: &Header, effective_size: u64) -> Result<()> { } return Ok(()); } + if !header.entry_type().is_file() { + // Preset archives model only regular files, directories, and handled local extensions + return Err(anyhow!( + "preset bundle contains an unsupported archive entry type: {}", + archive_path.display() + )); + } if archive_path == Path::new(MANIFEST_ARCHIVE_PATH) { return validate_entry_size( "manifest", diff --git a/crates/noticenterctl/src/preset/archive/tests/limits.rs b/crates/noticenterctl/src/preset/archive/tests/limits.rs index 5efcef454..36fb3fac9 100644 --- a/crates/noticenterctl/src/preset/archive/tests/limits.rs +++ b/crates/noticenterctl/src/preset/archive/tests/limits.rs @@ -72,6 +72,45 @@ fn read_bundle_uses_effective_pax_size_for_payload_limits() { assert!(error.to_string().contains("payload entry is too large")); } +#[test] +fn read_bundle_rejects_global_pax_metadata_before_second_pass() { + let root = TempDirGuard::new("global-pax-metadata"); + let bundle_path = root.path.join("demo.unixnotis"); + let pax = pax_record("path", "manifest.toml"); + + write_raw_gzip_tar(&bundle_path, |encoder| { + append_extension_entry(encoder, tar::EntryType::XGlobalHeader, &pax); + append_raw_tar_file(encoder, Path::new("ignored-name"), b"", 0o644); + }); + + let error = read_bundle(&bundle_path).expect_err("global PAX state must be rejected"); + + assert!(error.to_string().contains("global PAX metadata")); +} + +#[test] +fn read_bundle_rejects_unmodeled_archive_entry_types_during_preflight() { + let root = TempDirGuard::new("unsupported-archive-entry"); + let bundle_path = root.path.join("demo.unixnotis"); + + write_raw_gzip_tar(&bundle_path, |encoder| { + let mut header = tar::Header::new_gnu(); + header.set_path("foreign-link").expect("set link path"); + header.set_entry_type(tar::EntryType::Symlink); + header.set_link_name("target").expect("set link target"); + header.set_mode(0o777); + header.set_size(0); + header.set_cksum(); + encoder + .write_all(header.as_bytes()) + .expect("write unsupported entry"); + }); + + let error = read_bundle(&bundle_path).expect_err("unmodeled entry type must be rejected"); + + assert!(error.to_string().contains("unsupported archive entry type")); +} + #[test] fn read_bundle_accepts_a_bounded_pax_size_override() { let root = TempDirGuard::new("bounded-pax-size-override"); diff --git a/crates/noticenterctl/src/preset/command_rules/checks.rs b/crates/noticenterctl/src/preset/command_rules/checks.rs index 8fc8c01e2..603de9031 100644 --- a/crates/noticenterctl/src/preset/command_rules/checks.rs +++ b/crates/noticenterctl/src/preset/command_rules/checks.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::{parse_command, Config}; +use unixnotis_core::Config; use super::super::pathing::normalize_lexical_path; use super::collect::collect_command_references_from_config; @@ -86,19 +86,13 @@ pub fn validate_config_command_paths_stay_in_root( ) -> Result<()> { // Wrapper validation runs before path collection so ambiguous env forms fail closed for reference in collect_command_references_from_config(config) { - let parsed = parse_command(&reference.command).with_context(|| { - format!( - "{mode_label} because {} contains an invalid command", - reference.slot - ) - })?; - validate_env_command_layout(&parsed).map_err(|reason| { + validate_env_command_layout(&reference.command).map_err(|reason| { anyhow!( "{mode_label} because {} contains an unsafe env wrapper: {reason}", reference.slot ) })?; - validate_env_path_semantics(&parsed).map_err(|reason| { + validate_env_path_semantics(&reference.command).map_err(|reason| { anyhow!( "{mode_label} because {} contains unsafe environment path semantics: {reason}", reference.slot @@ -128,7 +122,7 @@ pub fn validate_command_paths_in_config_bytes( // Byte validation is used before imported configuration reaches the live directory let config_text = std::str::from_utf8(config_bytes).context("preset config.toml is not valid UTF-8")?; - let config: Config = - toml::from_str(config_text).context("parse bundled config.toml for command path checks")?; + let config = + Config::parse(config_text).context("parse bundled config.toml for command path checks")?; validate_config_command_paths_stay_in_root(config_dir, &config, mode_label) } diff --git a/crates/noticenterctl/src/preset/command_rules/collect.rs b/crates/noticenterctl/src/preset/command_rules/collect.rs index f8c7bb7e2..76c317b57 100644 --- a/crates/noticenterctl/src/preset/command_rules/collect.rs +++ b/crates/noticenterctl/src/preset/command_rules/collect.rs @@ -1,4 +1,4 @@ -use unixnotis_core::Config; +use unixnotis_core::{CommandSpec, Config}; use super::CommandReference; @@ -11,66 +11,66 @@ pub fn collect_command_references_from_config(config: &Config) -> Vec Vec, base_slot: &str, - get_cmd: &str, - set_cmd: &str, - toggle_cmd: Option<&str>, - watch_cmd: Option<&str>, + get_cmd: &CommandSpec, + set_cmd: &CommandSpec, + toggle_cmd: Option<&CommandSpec>, + watch_cmd: Option<&CommandSpec>, ) { // Sliders always expose read and write commands, so those are always listed commands.push(CommandReference { slot: format!("{base_slot}.get_cmd"), - command: get_cmd.to_string(), + command: get_cmd.clone(), }); commands.push(CommandReference { slot: format!("{base_slot}.set_cmd"), - command: set_cmd.to_string(), + command: set_cmd.clone(), }); push_optional_command(commands, &format!("{base_slot}.toggle_cmd"), toggle_cmd); push_optional_command(commands, &format!("{base_slot}.watch_cmd"), watch_cmd); } -fn push_optional_command(commands: &mut Vec, slot: &str, value: Option<&str>) { +fn push_optional_command( + commands: &mut Vec, + slot: &str, + value: Option<&CommandSpec>, +) { let Some(command) = value else { return; }; - let trimmed = command.trim(); - if trimmed.is_empty() { + if command.is_empty() { // Blank values are treated the same as missing values in reports return; } commands.push(CommandReference { slot: slot.to_string(), - command: trimmed.to_string(), + command: command.clone(), }); } diff --git a/crates/noticenterctl/src/preset/command_rules/model.rs b/crates/noticenterctl/src/preset/command_rules/model.rs index f95f81178..d9df80193 100644 --- a/crates/noticenterctl/src/preset/command_rules/model.rs +++ b/crates/noticenterctl/src/preset/command_rules/model.rs @@ -1,21 +1,22 @@ //! Command references and path findings shared by preset checks use std::path::PathBuf; +use unixnotis_core::CommandSpec; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandReference { // Config field name used in inspect and warning output pub(crate) slot: String, - // Raw command string carried by the parsed config - pub(crate) command: String, + // Typed command carried by the parsed config + pub(crate) command: CommandSpec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OutsideCommandPath { // Config slot that carried the outside path pub(crate) slot: String, - // Raw command string from the config - pub(crate) command: String, + // Typed command from the config + pub(crate) command: CommandSpec, // Resolved first-token path used by the validator pub(crate) resolved_path: PathBuf, } @@ -24,8 +25,8 @@ pub struct OutsideCommandPath { pub struct HostSpecificCommandPath { // Config slot that carried the host-specific path pub(crate) slot: String, - // Raw command string from the config - pub(crate) command: String, + // Typed command from the config + pub(crate) command: CommandSpec, // Resolved first-token path under the config root pub(crate) resolved_path: PathBuf, } diff --git a/crates/noticenterctl/src/preset/command_rules/rewrite.rs b/crates/noticenterctl/src/preset/command_rules/rewrite.rs index 6bc707429..198685d5d 100644 --- a/crates/noticenterctl/src/preset/command_rules/rewrite.rs +++ b/crates/noticenterctl/src/preset/command_rules/rewrite.rs @@ -1,6 +1,6 @@ use std::path::Path; -use unixnotis_core::{Config, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, Config, SliderWidgetConfig}; use super::checks::collect_host_specific_command_paths; use super::tokens::rewrite_command_to_config_relative; @@ -48,16 +48,13 @@ fn rewrite_slider_commands(config_dir: &Path, slider: &mut SliderWidgetConfig) { rewrite_optional_command(config_dir, &mut slider.watch_cmd); } -fn rewrite_optional_command(config_dir: &Path, value: &mut Option) { +fn rewrite_optional_command(config_dir: &Path, value: &mut Option) { let Some(command) = value.as_mut() else { return; }; rewrite_inline_command(config_dir, command); } -fn rewrite_inline_command(config_dir: &Path, command: &mut String) { - let Some(rewritten) = rewrite_command_to_config_relative(config_dir, command) else { - return; - }; - *command = rewritten; +fn rewrite_inline_command(config_dir: &Path, command: &mut CommandSpec) { + let _ = rewrite_command_to_config_relative(config_dir, command); } diff --git a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs index 6200438f4..48d57a726 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/cases.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/cases.rs @@ -1,15 +1,14 @@ -use unixnotis_core::Config; +use unixnotis_core::CommandSpec; use super::super::{ collect_command_references_from_config, collect_host_specific_command_paths, collect_outside_command_paths, rewrite_host_specific_command_paths, - validate_command_paths_in_config_bytes, }; -use super::support::temp_root; +use super::support::{parse_current_config, temp_root, validate_command_paths_in_config_bytes}; #[test] fn collects_widget_command_references() { - let config: Config = toml::from_str( + let config = parse_current_config( "\ [theme]\nbase_css = \"base.css\"\n\ [[widgets.toggles]]\nlabel = \"Action\"\nicon = \"applications-system-symbolic\"\ntoggle_cmd = \"scripts/action.sh\"\n\ @@ -36,7 +35,7 @@ fn outside_command_paths_include_absolute_plugin_command() { [[widgets.stats]]\nlabel = \"Probe\"\n\ [widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - let parsed = toml::from_str(config).expect("parse config"); + let parsed = parse_current_config(config).expect("parse config"); let outside = collect_outside_command_paths(&config_dir, &parsed); assert_eq!(outside.len(), 1); @@ -99,7 +98,7 @@ fn host_specific_command_paths_include_absolute_path_inside_root() { script_path.display().to_string() ); - let parsed = toml::from_str(&config).expect("parse config"); + let parsed = parse_current_config(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); @@ -118,7 +117,7 @@ fn rewrite_host_specific_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); @@ -128,7 +127,7 @@ fn rewrite_host_specific_command_paths_makes_commands_config_relative() { .as_ref() .expect("plugin") .command, - "scripts/unixnotis-thermal-stat --json" + CommandSpec::direct("scripts/unixnotis-thermal-stat", ["--json"]) ); } @@ -140,14 +139,17 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_assignments() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env MODE='two words' '{}' --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.stats[0].cmd.as_deref(), - Some("env 'MODE=two words' 'scripts/probe tool' --json") + parsed.widgets.stats[0].cmd, + Some(CommandSpec::direct( + "env", + ["MODE=two words", "scripts/probe tool", "--json"] + )) ); } @@ -159,14 +161,17 @@ fn rewrite_host_specific_command_inside_env_wrapper_preserves_options() { "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {:?}\n", format!("env -u HOME MODE=safe {} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.stats[0].cmd.as_deref(), - Some("env -u HOME 'MODE=safe' scripts/probe --json") + parsed.widgets.stats[0].cmd, + Some(CommandSpec::direct( + "env", + ["-u", "HOME", "MODE=safe", "scripts/probe", "--json"] + )) ); } @@ -181,13 +186,16 @@ fn rewrite_host_specific_toggle_command_paths_makes_commands_config_relative() { format!("{} --json", script_path.display()) ); - let mut parsed: Config = toml::from_str(&config).expect("parse config"); + let mut parsed = parse_current_config(&config).expect("parse config"); let rewritten = rewrite_host_specific_command_paths(&config_dir, &mut parsed); assert_eq!(rewritten.len(), 1); assert_eq!( - parsed.widgets.toggles[0].toggle_cmd.as_deref(), - Some("scripts/unixnotis-toggle-action --json") + parsed.widgets.toggles[0].toggle_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-toggle-action", + ["--json"] + )) ); } @@ -202,10 +210,13 @@ fn host_specific_command_paths_include_toggle_command() { script_path.display().to_string() ); - let parsed = toml::from_str(&config).expect("parse config"); + let parsed = parse_current_config(&config).expect("parse config"); let leaks = collect_host_specific_command_paths(&config_dir, &parsed); assert_eq!(leaks.len(), 1); assert_eq!(leaks[0].slot, "widgets.toggles[0].toggle_cmd"); - assert_eq!(leaks[0].command, script_path.display().to_string()); + assert_eq!( + leaks[0].command, + CommandSpec::direct(script_path, [] as [&str; 0]) + ); } diff --git a/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs b/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs deleted file mode 100644 index 95a235f8c..000000000 --- a/crates/noticenterctl/src/preset/command_rules/tests/env_paths.rs +++ /dev/null @@ -1,547 +0,0 @@ -use std::path::PathBuf; - -use super::super::tokens::{ - collect_outside_env_path_tokens, first_command_token, is_host_specific_path_token, - looks_like_path_token, split_env_assignment, validate_env_command_layout, - validate_env_path_semantics, -}; -use super::super::validate_command_paths_in_config_bytes; -use super::support::temp_root; -use unixnotis_core::parse_command; - -#[test] -fn validation_rejects_ld_preload_path_that_leaves_root() { - let config_dir = temp_root("ld-preload-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=/tmp/evil.so /bin/true\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject LD_PRELOAD outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_quoted_ld_preload_paths_that_leave_root() { - let config_dir = temp_root("quoted-ld-preload-outside"); - for command in [ - "LD_PRELOAD=\"/tmp/evil.so\" /bin/true", - "LD_PRELOAD='/tmp/evil.so' /bin/true", - "env LD_PRELOAD=/tmp/evil.so /bin/true", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject quoted or env-wrapped preload escape"); - } -} - -#[test] -fn validation_rejects_tilde_program_and_malformed_quoting() { - let config_dir = temp_root("tilde-and-quote"); - let tilde = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"~/outside-script\"\n"; - validate_command_paths_in_config_bytes(&config_dir, tilde, "preset import blocked") - .expect_err("reject tilde program outside config root"); - - let malformed = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = 'echo \"unterminated'\n"; - validate_command_paths_in_config_bytes(&config_dir, malformed, "preset import blocked") - .expect_err("reject malformed command quoting"); -} - -#[test] -fn validation_rejects_home_override_and_env_wrapped_absolute_program() { - let config_dir = temp_root("home-and-env-program"); - for command in ["HOME=/tmp ./script", "env SAFE=value /bin/true"] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject path policy escape"); - } -} - -#[test] -fn env_path_token_collector_finds_ld_preload_outside_root() { - let config_dir = temp_root("ld-preload-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so /bin/true"); - - assert_eq!(outside.len(), 1); - assert_eq!(outside[0].0, "LD_PRELOAD"); - assert_eq!(outside[0].1, PathBuf::from("/tmp/evil.so")); -} - -#[test] -fn validation_rejects_space_separated_ld_preload_path_that_leaves_root() { - let config_dir = temp_root("space-separated-ld-preload"); - let inside = config_dir.join("libsafe.so"); - let command = format!( - "LD_PRELOAD='{} /tmp/libevil.so' scripts/probe", - inside.display() - ); - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - - let error = validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject second preload object outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_semicolon_separated_library_directory_that_leaves_root() { - let config_dir = temp_root("semicolon-library-path"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH='lib;/tmp/evil' scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject semicolon-separated loader directory outside config root"); -} - -#[test] -fn validation_accepts_empty_list_components_with_the_pinned_config_cwd() { - let config_dir = temp_root("empty-loader-component"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH=':lib;' PATH=:bin scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("empty path components should resolve to the pinned config cwd"); -} - -#[test] -fn validation_keeps_single_path_environment_values_unsplit() { - let config_dir = temp_root("single-path-colon"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"HOME=profiles/home:secondary BASH_ENV=scripts/start:up scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("single path values containing colons should remain one relative path"); -} - -#[test] -fn validation_rejects_pythonhome_exec_prefix_outside_root() { - let config_dir = temp_root("pythonhome-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"PYTHONHOME='runtime:/tmp/outside' python3 -c pass\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external Python exec prefix"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_accepts_pythonhome_single_and_relative_prefix_pair() { - let config_dir = temp_root("pythonhome-relative"); - for command in [ - "PYTHONHOME=runtime python3 -c pass", - "PYTHONHOME='runtime:exec-runtime' python3 -c pass", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - - validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .unwrap_or_else(|error| panic!("valid PYTHONHOME was rejected for {command}: {error}")); - } -} - -#[test] -fn validation_rejects_pythonhome_empty_or_ambiguous_prefixes() { - for (command, reason) in [ - ( - "PYTHONHOME=':exec-runtime' python3 -c pass", - "PYTHONHOME contains an empty prefix", - ), - ( - "PYTHONHOME='runtime:' python3 -c pass", - "PYTHONHOME contains an empty prefix", - ), - ( - "PYTHONHOME='a:b:c' python3 -c pass", - "PYTHONHOME contains more than one prefix separator", - ), - ] { - let parsed = parse_command(command).expect("parse PYTHONHOME command"); - - assert_eq!( - validate_env_path_semantics(&parsed), - Err(reason), - "wrong PYTHONHOME result for {command}" - ); - } -} - -#[test] -fn validation_rejects_dynamic_loader_tokens_and_ambiguous_bare_objects() { - for command in [ - "LD_PRELOAD='$ORIGIN/libevil.so' /bin/true", - "LD_LIBRARY_PATH='${LIB}' /bin/true", - "LD_AUDIT='$PLATFORM/audit.so' /bin/true", - "LD_PRELOAD=libprobe.so /bin/true", - "LD_AUDIT=audit.so /bin/true", - ] { - let parsed = parse_command(command).expect("parse loader environment command"); - assert!( - validate_env_path_semantics(&parsed).is_err(), - "unsafe loader value was accepted: {command}" - ); - } -} - -#[test] -fn validation_rejects_shell_startup_path_expansions() { - for command in [ - "BASH_ENV='$HOME/evil' /bin/true", - "ENV='$(touch marker)' /bin/true", - "BASH_ENV='~/evil' /bin/true", - ] { - let parsed = parse_command(command).expect("parse shell environment command"); - assert!( - validate_env_path_semantics(&parsed).is_err(), - "expanded shell startup path was accepted: {command}" - ); - } -} - -#[test] -fn env_path_token_collector_ignores_invalid_env_assignment_names() { - let config_dir = temp_root("invalid-env-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "/tmp/with=equals /bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn env_path_token_collector_ignores_commands_with_carriage_returns() { - let config_dir = temp_root("carriage-return-env-token"); - - let outside = - collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so\r/bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn env_path_token_collector_ignores_unknown_env_names() { - let config_dir = temp_root("unknown-env-token"); - - let outside = collect_outside_env_path_tokens(&config_dir, "WIDGET_DATA=/tmp/evil /bin/true"); - - assert!(outside.is_empty()); -} - -#[test] -fn validation_ignores_loader_tokens_in_unknown_environment_variables() { - let parsed = parse_command("WIDGET_DATA='$ORIGIN/data' scripts/probe") - .expect("parse unknown environment variable"); - - validate_env_path_semantics(&parsed) - .expect("unknown variables do not use loader path semantics"); -} - -#[test] -fn env_path_token_collector_fails_closed_for_shell_assignment_scope() { - let config_dir = temp_root("complex-env-token"); - - let outside = - collect_outside_env_path_tokens(&config_dir, "LD_PRELOAD=/tmp/evil.so; /bin/true"); - - assert_eq!(outside.len(), 1); - assert_eq!(outside[0].0, "LD_PRELOAD"); -} - -#[test] -fn validation_rejects_bare_library_names_with_ambiguous_loader_search() { - let config_dir = temp_root("bare-env-token"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=libprobe.so scripts/probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject loader object without an explicit path"); - - assert!(error - .to_string() - .contains("unsafe environment path semantics")); -} - -#[test] -fn validation_rejects_colon_separated_env_path_that_leaves_root() { - let config_dir = temp_root("pythonpath-outside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.cards]]\nlabel = \"Probe\"\ncmd = \"PYTHONPATH=scripts:/tmp/evil python3 -c pass\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject PYTHONPATH outside config root"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_accepts_dangerous_env_paths_inside_root() { - let config_dir = temp_root("env-path-inside"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=scripts/libprobe.so scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("config-root-relative env paths should be allowed"); -} - -#[test] -fn validation_does_not_mistake_env_option_values_for_the_child_program() { - let config_dir = temp_root("env-option-program"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -u HOME /tmp/outside-probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external child after env option value"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_checks_env_assignments_that_follow_options() { - let config_dir = temp_root("env-option-assignment"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -i LD_PRELOAD=/tmp/evil.so scripts/probe\"\n"; - - let error = - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect_err("reject external environment path after env option"); - - assert!(error - .to_string() - .contains("resolves outside the UnixNotis config directory")); -} - -#[test] -fn validation_rejects_nonportable_env_reinterpretation_options() { - let config_dir = temp_root("env-nonportable-options"); - for command in [ - "env -C scripts ./probe", - "env --chdir=scripts ./probe", - "env -S 'MODE=safe /tmp/outside-probe'", - "env --split-string='MODE=safe /tmp/outside-probe'", - ] { - let config = format!( - "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); - let error = validate_command_paths_in_config_bytes( - &config_dir, - config.as_bytes(), - "preset import blocked", - ) - .expect_err("reject nonportable env option"); - - assert!(error.to_string().contains("unsafe env wrapper")); - } -} - -#[test] -fn validation_accepts_supported_env_options_before_a_portable_program() { - let config_dir = temp_root("env-supported-options"); - let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -iv -u HOME MODE=safe scripts/probe\"\n"; - - validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") - .expect("supported env options should preserve child discovery"); -} - -#[test] -fn every_supported_env_option_preserves_the_real_child_program() { - for command in [ - "env -- scripts/probe", - "env - scripts/probe", - "env -i scripts/probe", - "env -0 scripts/probe", - "env -v scripts/probe", - "env --ignore-environment scripts/probe", - "env --null scripts/probe", - "env --debug scripts/probe", - "env --list-signal-handling scripts/probe", - "env -u HOME scripts/probe", - "env --unset HOME scripts/probe", - "env -a probe scripts/probe", - "env --argv0 probe scripts/probe", - "env -uHOME scripts/probe", - "env --unset=HOME scripts/probe", - "env -aprobe scripts/probe", - "env --argv0=probe scripts/probe", - "env --block-signal scripts/probe", - "env --block-signal=PIPE scripts/probe", - "env --default-signal scripts/probe", - "env --default-signal=PIPE scripts/probe", - "env --ignore-signal scripts/probe", - "env --ignore-signal=PIPE scripts/probe", - "env -iv0 scripts/probe", - ] { - assert_eq!( - first_command_token(command).as_deref(), - Some("scripts/probe"), - "wrong env child for {command}" - ); - } -} - -#[test] -fn env_layout_counts_assignments_after_every_option() { - for command in [ - "env -- MODE=safe LEVEL=2 scripts/probe", - "env -iv0 MODE=safe LEVEL=2 scripts/probe", - "env --unset=HOME MODE=safe LEVEL=2 scripts/probe", - "env --block-signal=PIPE MODE=safe LEVEL=2 scripts/probe", - ] { - assert_eq!( - first_command_token(command).as_deref(), - Some("scripts/probe"), - "assignment range consumed the wrong child for {command}" - ); - } - - assert_eq!(first_command_token("env MODE=safe LEVEL=2"), None); -} - -#[test] -fn unsupported_and_incomplete_env_options_never_become_child_programs() { - for command in [ - "env -u", - "env --unset", - "env -a", - "env --argv0", - "env --unknown scripts/probe", - "env -ix scripts/probe", - ] { - assert_eq!( - first_command_token(command), - None, - "unsafe env layout was accepted for {command}" - ); - } -} - -#[test] -fn every_nonportable_env_option_form_is_rejected() { - for command in [ - "env -C scripts scripts/probe", - "env -Cscripts scripts/probe", - "env --chdir scripts scripts/probe", - "env --chdir=scripts scripts/probe", - "env -S scripts/probe", - "env -SMODE=safe scripts/probe", - "env --split-string scripts/probe", - "env --split-string=MODE=safe scripts/probe", - ] { - assert_eq!( - first_command_token(command), - None, - "nonportable env layout was accepted for {command}" - ); - } -} - -#[test] -fn nonportable_env_options_keep_specific_actionable_reasons() { - for command in [ - "env -C scripts scripts/probe", - "env -Cscripts scripts/probe", - "env --chdir scripts scripts/probe", - "env --chdir=scripts scripts/probe", - ] { - let parsed = parse_command(command).expect("parse env command"); - assert_eq!( - validate_env_command_layout(&parsed), - Err("env working-directory options are not portable in preset commands"), - "wrong working-directory reason for {command}" - ); - } - - for command in [ - "env -S scripts/probe", - "env -SMODE=safe scripts/probe", - "env --split-string scripts/probe", - "env --split-string=MODE=safe scripts/probe", - ] { - let parsed = parse_command(command).expect("parse env command"); - assert_eq!( - validate_env_command_layout(&parsed), - Err("env split-string options are ambiguous in preset commands"), - "wrong split-string reason for {command}" - ); - } -} - -#[test] -fn env_assignment_names_follow_portable_shell_identifier_rules() { - assert_eq!(split_env_assignment("NAME=value"), Some(("NAME", "value"))); - assert_eq!(split_env_assignment("_NAME=a=b"), Some(("_NAME", "a=b"))); - assert_eq!(split_env_assignment("A1="), Some(("A1", ""))); - - for token in [ - "1NAME=value", - "-NAME=value", - "NA-ME=value", - "=value", - "NAME", - ] { - assert_eq!( - split_env_assignment(token), - None, - "invalid assignment name accepted for {token}" - ); - } -} - -#[test] -fn path_token_detection_covers_every_supported_relative_form() { - for token in ["~", "~/tool", "./tool", "../tool", "dir/tool", "/tool"] { - assert!( - looks_like_path_token(token), - "path form not detected: {token}" - ); - } - for token in ["", "tool", "tool-name", ".", ".."] { - assert!( - !looks_like_path_token(token), - "plain command was treated as a path: {token}" - ); - } -} - -#[test] -fn host_specific_path_detection_excludes_portable_relative_paths() { - for token in ["/usr/bin/tool", "~", "~/bin/tool"] { - assert!( - is_host_specific_path_token(token), - "host path not detected: {token}" - ); - } - for token in ["tool", "./tool", "../tool", "dir/tool"] { - assert!( - !is_host_specific_path_token(token), - "portable path was treated as host-specific: {token}" - ); - } -} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/environment.rs b/crates/noticenterctl/src/preset/command_rules/tests/environment.rs new file mode 100644 index 000000000..f0f034fd7 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/environment.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use super::super::tokens::{collect_outside_env_path_tokens, validate_env_path_semantics}; +use super::support::{parsed_command, temp_root}; +use unixnotis_core::{parse_legacy_command as parse_command, CommandSpec}; + +#[test] +fn env_path_token_collector_finds_ld_preload_outside_root() { + let config_dir = temp_root("ld-preload-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &parsed_command("LD_PRELOAD=/tmp/evil.so /bin/true"), + ); + + assert_eq!(outside.len(), 1); + assert_eq!(outside[0].0, "LD_PRELOAD"); + assert_eq!(outside[0].1, PathBuf::from("/tmp/evil.so")); +} + +#[test] +fn validation_rejects_pythonhome_empty_or_ambiguous_prefixes() { + for (command, reason) in [ + ( + "PYTHONHOME=':exec-runtime' python3 -c pass", + "PYTHONHOME contains an empty prefix", + ), + ( + "PYTHONHOME='runtime:' python3 -c pass", + "PYTHONHOME contains an empty prefix", + ), + ( + "PYTHONHOME='a:b:c' python3 -c pass", + "PYTHONHOME contains more than one prefix separator", + ), + ] { + let parsed = parse_command(command).expect("parse PYTHONHOME command"); + + assert_eq!( + validate_env_path_semantics(&parsed), + Err(reason), + "wrong PYTHONHOME result for {command}" + ); + } +} + +#[test] +fn validation_rejects_dynamic_loader_tokens_and_ambiguous_bare_objects() { + for command in [ + "LD_PRELOAD='$ORIGIN/libevil.so' /bin/true", + "LD_LIBRARY_PATH='${LIB}' /bin/true", + "LD_AUDIT='$PLATFORM/audit.so' /bin/true", + "LD_PRELOAD=libprobe.so /bin/true", + "LD_AUDIT=audit.so /bin/true", + ] { + let parsed = parse_command(command).expect("parse loader environment command"); + assert!( + validate_env_path_semantics(&parsed).is_err(), + "unsafe loader value was accepted: {command}" + ); + } +} + +#[test] +fn validation_rejects_shell_startup_path_expansions() { + for command in [ + "BASH_ENV='$HOME/evil' /bin/true", + "ENV='$(touch marker)' /bin/true", + "BASH_ENV='~/evil' /bin/true", + ] { + let parsed = parse_command(command).expect("parse shell environment command"); + assert!( + validate_env_path_semantics(&parsed).is_err(), + "expanded shell startup path was accepted: {command}" + ); + } +} + +#[test] +fn env_path_token_collector_ignores_invalid_env_assignment_names() { + let config_dir = temp_root("invalid-env-token"); + + let outside = + collect_outside_env_path_tokens(&config_dir, &parsed_command("/tmp/with=equals /bin/true")); + + assert!(outside.is_empty()); +} + +#[test] +fn env_path_token_collector_ignores_commands_with_carriage_returns() { + let config_dir = temp_root("carriage-return-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &CommandSpec::shell("LD_PRELOAD=/tmp/evil.so\r/bin/true"), + ); + + assert!(outside.is_empty()); +} + +#[test] +fn env_path_token_collector_ignores_unknown_env_names() { + let config_dir = temp_root("unknown-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &parsed_command("WIDGET_DATA=/tmp/evil /bin/true"), + ); + + assert!(outside.is_empty()); +} + +#[test] +fn validation_ignores_loader_tokens_in_unknown_environment_variables() { + let parsed = parse_command("WIDGET_DATA='$ORIGIN/data' scripts/probe") + .expect("parse unknown environment variable"); + + validate_env_path_semantics(&parsed) + .expect("unknown variables do not use loader path semantics"); +} + +#[test] +fn env_path_token_collector_fails_closed_for_shell_assignment_scope() { + let config_dir = temp_root("complex-env-token"); + + let outside = collect_outside_env_path_tokens( + &config_dir, + &CommandSpec::direct("/bin/true", [] as [&str; 0]).with_env("LD_PRELOAD", "/tmp/evil.so"), + ); + + assert_eq!(outside.len(), 1); + assert_eq!(outside[0].0, "LD_PRELOAD"); +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/layout.rs b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs new file mode 100644 index 000000000..b53903f0b --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/layout.rs @@ -0,0 +1,212 @@ +use super::super::tokens::{ + first_command_token, split_env_assignment, validate_env_command_layout, +}; +use super::support::{parsed_command, temp_root, validate_command_paths_in_config_bytes}; +use unixnotis_core::parse_legacy_command as parse_command; + +#[test] +fn validation_does_not_mistake_env_option_values_for_the_child_program() { + let config_dir = temp_root("env-option-program"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -u HOME /tmp/outside-probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external child after env option value"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_checks_env_assignments_that_follow_options() { + let config_dir = temp_root("env-option-assignment"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -i LD_PRELOAD=/tmp/evil.so scripts/probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external environment path after env option"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_nonportable_env_reinterpretation_options() { + let config_dir = temp_root("env-nonportable-options"); + for command in [ + "env -C scripts ./probe", + "env --chdir=scripts ./probe", + "env -S 'MODE=safe /tmp/outside-probe'", + "env --split-string='MODE=safe /tmp/outside-probe'", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + let error = validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject nonportable env option"); + + assert!(error.to_string().contains("unsafe env wrapper")); + } +} + +#[test] +fn validation_accepts_supported_env_options_before_a_portable_program() { + let config_dir = temp_root("env-supported-options"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"env -iv -u HOME MODE=safe scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("supported env options should preserve child discovery"); +} + +#[test] +fn every_supported_env_option_preserves_the_real_child_program() { + for command in [ + "env -- scripts/probe", + "env - scripts/probe", + "env -i scripts/probe", + "env -0 scripts/probe", + "env -v scripts/probe", + "env --ignore-environment scripts/probe", + "env --null scripts/probe", + "env --debug scripts/probe", + "env --list-signal-handling scripts/probe", + "env -u HOME scripts/probe", + "env --unset HOME scripts/probe", + "env -a probe scripts/probe", + "env --argv0 probe scripts/probe", + "env -uHOME scripts/probe", + "env --unset=HOME scripts/probe", + "env -aprobe scripts/probe", + "env --argv0=probe scripts/probe", + "env --block-signal scripts/probe", + "env --block-signal=PIPE scripts/probe", + "env --default-signal scripts/probe", + "env --default-signal=PIPE scripts/probe", + "env --ignore-signal scripts/probe", + "env --ignore-signal=PIPE scripts/probe", + "env -iv0 scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)).as_deref(), + Some("scripts/probe"), + "wrong env child for {command}" + ); + } +} + +#[test] +fn env_layout_counts_assignments_after_every_option() { + for command in [ + "env -- MODE=safe LEVEL=2 scripts/probe", + "env -iv0 MODE=safe LEVEL=2 scripts/probe", + "env --unset=HOME MODE=safe LEVEL=2 scripts/probe", + "env --block-signal=PIPE MODE=safe LEVEL=2 scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)).as_deref(), + Some("scripts/probe"), + "assignment range consumed the wrong child for {command}" + ); + } + + assert_eq!( + first_command_token(&parsed_command("env MODE=safe LEVEL=2")), + None + ); +} + +#[test] +fn unsupported_and_incomplete_env_options_never_become_child_programs() { + for command in [ + "env -u", + "env --unset", + "env -a", + "env --argv0", + "env --unknown scripts/probe", + "env -ix scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)), + None, + "unsafe env layout was accepted for {command}" + ); + } +} + +#[test] +fn every_nonportable_env_option_form_is_rejected() { + for command in [ + "env -C scripts scripts/probe", + "env -Cscripts scripts/probe", + "env --chdir scripts scripts/probe", + "env --chdir=scripts scripts/probe", + "env -S scripts/probe", + "env -SMODE=safe scripts/probe", + "env --split-string scripts/probe", + "env --split-string=MODE=safe scripts/probe", + ] { + assert_eq!( + first_command_token(&parsed_command(command)), + None, + "nonportable env layout was accepted for {command}" + ); + } +} + +#[test] +fn nonportable_env_options_keep_specific_actionable_reasons() { + for command in [ + "env -C scripts scripts/probe", + "env -Cscripts scripts/probe", + "env --chdir scripts scripts/probe", + "env --chdir=scripts scripts/probe", + ] { + let parsed = parse_command(command).expect("parse env command"); + assert_eq!( + validate_env_command_layout(&parsed), + Err("env working-directory options are not portable in preset commands"), + "wrong working-directory reason for {command}" + ); + } + + for command in [ + "env -S scripts/probe", + "env -SMODE=safe scripts/probe", + "env --split-string scripts/probe", + "env --split-string=MODE=safe scripts/probe", + ] { + let parsed = parse_command(command).expect("parse env command"); + assert_eq!( + validate_env_command_layout(&parsed), + Err("env split-string options are ambiguous in preset commands"), + "wrong split-string reason for {command}" + ); + } +} + +#[test] +fn env_assignment_names_follow_portable_shell_identifier_rules() { + assert_eq!(split_env_assignment("NAME=value"), Some(("NAME", "value"))); + assert_eq!(split_env_assignment("_NAME=a=b"), Some(("_NAME", "a=b"))); + assert_eq!(split_env_assignment("A1="), Some(("A1", ""))); + + for token in [ + "1NAME=value", + "-NAME=value", + "NA-ME=value", + "=value", + "NAME", + ] { + assert_eq!( + split_env_assignment(token), + None, + "invalid assignment name accepted for {token}" + ); + } +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/mod.rs b/crates/noticenterctl/src/preset/command_rules/tests/mod.rs index 55bf8cafd..3bd0eeb6f 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/mod.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/mod.rs @@ -1,3 +1,6 @@ mod cases; -mod env_paths; +mod environment; +mod layout; +mod path_tokens; mod support; +mod validation; diff --git a/crates/noticenterctl/src/preset/command_rules/tests/model.rs b/crates/noticenterctl/src/preset/command_rules/tests/model.rs index b69bafbaa..caa03bc37 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/model.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/model.rs @@ -1,12 +1,13 @@ use std::path::PathBuf; use super::{CommandReference, HostSpecificCommandPath, OutsideCommandPath}; +use unixnotis_core::CommandSpec; #[test] fn command_path_findings_preserve_slot_command_and_resolved_target() { let reference = CommandReference { slot: "widgets.volume.get_cmd".to_string(), - command: "scripts/volume".to_string(), + command: CommandSpec::direct("scripts/volume", [] as [&str; 0]), }; let outside = OutsideCommandPath { slot: reference.slot.clone(), diff --git a/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs b/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs new file mode 100644 index 000000000..9362a1fb2 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/path_tokens.rs @@ -0,0 +1,28 @@ +use super::super::tokens::{is_host_specific_path_token, looks_like_path_token}; + +#[test] +fn path_token_detection_covers_every_supported_relative_form() { + for token in ["~/tool", "./tool", "../tool", "dir/tool", "/tool"] { + assert!( + looks_like_path_token(token), + "path form not detected: {token}" + ); + } + for token in ["", "tool", "tool-name", ".", "..", "~"] { + assert!( + !looks_like_path_token(token), + "plain command was treated as a path: {token}" + ); + } +} + +#[test] +fn host_specific_path_detection_excludes_portable_relative_paths() { + assert!(is_host_specific_path_token("/usr/bin/tool")); + for token in ["tool", "./tool", "../tool", "dir/tool", "~", "~/bin/tool"] { + assert!( + !is_host_specific_path_token(token), + "portable path was treated as host-specific: {token}" + ); + } +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/support.rs b/crates/noticenterctl/src/preset/command_rules/tests/support.rs index 821e2287e..235a79e40 100644 --- a/crates/noticenterctl/src/preset/command_rules/tests/support.rs +++ b/crates/noticenterctl/src/preset/command_rules/tests/support.rs @@ -1,7 +1,13 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use anyhow::Result; +use unixnotis_core::{parse_legacy_command, CommandSpec, Config, ConfigError}; + +use super::super::validate_command_paths_in_config_bytes as validate_command_paths; +use crate::test_support::{current_config_bytes, current_config_text}; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(super) fn temp_root(name: &str) -> PathBuf { @@ -15,3 +21,19 @@ pub(super) fn temp_root(name: &str) -> PathBuf { "unixnotis-preset-command-rules-{name}-{stamp}-{serial}" )) } + +pub(super) fn parsed_command(command: &str) -> CommandSpec { + parse_legacy_command(command).expect("valid legacy test command") +} + +pub(super) fn parse_current_config(contents: &str) -> Result { + Config::parse(¤t_config_text(contents)) +} + +pub(super) fn validate_command_paths_in_config_bytes( + config_dir: &Path, + config_bytes: &[u8], + mode_label: &str, +) -> Result<()> { + validate_command_paths(config_dir, ¤t_config_bytes(config_bytes), mode_label) +} diff --git a/crates/noticenterctl/src/preset/command_rules/tests/validation.rs b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs new file mode 100644 index 000000000..bcda6c412 --- /dev/null +++ b/crates/noticenterctl/src/preset/command_rules/tests/validation.rs @@ -0,0 +1,185 @@ +use super::support::{temp_root, validate_command_paths_in_config_bytes}; + +#[test] +fn validation_rejects_ld_preload_path_that_leaves_root() { + let config_dir = temp_root("ld-preload-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=/tmp/evil.so /bin/true\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject LD_PRELOAD outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_quoted_ld_preload_paths_that_leave_root() { + let config_dir = temp_root("quoted-ld-preload-outside"); + for command in [ + "LD_PRELOAD=\"/tmp/evil.so\" /bin/true", + "LD_PRELOAD='/tmp/evil.so' /bin/true", + "env LD_PRELOAD=/tmp/evil.so /bin/true", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject quoted or env-wrapped preload escape"); + } +} + +#[test] +fn validation_migrates_tilde_syntax_to_shell_and_rejects_malformed_quoting() { + let config_dir = temp_root("tilde-and-quote"); + let tilde = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"~/outside-script\"\n"; + validate_command_paths_in_config_bytes(&config_dir, tilde, "preset import blocked") + .expect("tilde syntax is an explicit shell command after migration"); + + let malformed = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = 'echo \"unterminated'\n"; + validate_command_paths_in_config_bytes(&config_dir, malformed, "preset import blocked") + .expect_err("reject malformed command quoting"); +} + +#[test] +fn validation_rejects_home_override_and_env_wrapped_absolute_program() { + let config_dir = temp_root("home-and-env-program"); + for command in ["HOME=/tmp ./script", "env SAFE=value /bin/true"] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject path policy escape"); + } +} + +#[test] +fn validation_rejects_space_separated_ld_preload_path_that_leaves_root() { + let config_dir = temp_root("space-separated-ld-preload"); + let inside = config_dir.join("libsafe.so"); + let command = format!( + "LD_PRELOAD='{} /tmp/libevil.so' scripts/probe", + inside.display() + ); + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + + let error = validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .expect_err("reject second preload object outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_rejects_semicolon_separated_library_directory_that_leaves_root() { + let config_dir = temp_root("semicolon-library-path"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH='lib;/tmp/evil' scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject semicolon-separated loader directory outside config root"); +} + +#[test] +fn validation_accepts_empty_list_components_with_the_pinned_config_cwd() { + let config_dir = temp_root("empty-loader-component"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_LIBRARY_PATH=':lib;' PATH=:bin scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("empty path components should resolve to the pinned config cwd"); +} + +#[test] +fn validation_keeps_single_path_environment_values_unsplit() { + let config_dir = temp_root("single-path-colon"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"HOME=profiles/home:secondary BASH_ENV=scripts/start:up scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("single path values containing colons should remain one relative path"); +} + +#[test] +fn validation_rejects_pythonhome_exec_prefix_outside_root() { + let config_dir = temp_root("pythonhome-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"PYTHONHOME='runtime:/tmp/outside' python3 -c pass\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject external Python exec prefix"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_accepts_pythonhome_single_and_relative_prefix_pair() { + let config_dir = temp_root("pythonhome-relative"); + for command in [ + "PYTHONHOME=runtime python3 -c pass", + "PYTHONHOME='runtime:exec-runtime' python3 -c pass", + ] { + let config = format!( + "[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" + ); + + validate_command_paths_in_config_bytes( + &config_dir, + config.as_bytes(), + "preset import blocked", + ) + .unwrap_or_else(|error| panic!("valid PYTHONHOME was rejected for {command}: {error}")); + } +} + +#[test] +fn validation_rejects_bare_library_names_with_ambiguous_loader_search() { + let config_dir = temp_root("bare-env-token"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=libprobe.so scripts/probe\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject loader object without an explicit path"); + + assert!(error + .to_string() + .contains("unsafe environment path semantics")); +} + +#[test] +fn validation_rejects_colon_separated_env_path_that_leaves_root() { + let config_dir = temp_root("pythonpath-outside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.cards]]\nlabel = \"Probe\"\ncmd = \"PYTHONPATH=scripts:/tmp/evil python3 -c pass\"\n"; + + let error = + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect_err("reject PYTHONPATH outside config root"); + + assert!(error + .to_string() + .contains("resolves outside the UnixNotis config directory")); +} + +#[test] +fn validation_accepts_dangerous_env_paths_inside_root() { + let config_dir = temp_root("env-path-inside"); + let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"LD_PRELOAD=scripts/libprobe.so scripts/probe\"\n"; + + validate_command_paths_in_config_bytes(&config_dir, config, "preset import blocked") + .expect("config-root-relative env paths should be allowed"); +} diff --git a/crates/noticenterctl/src/preset/command_rules/tokens.rs b/crates/noticenterctl/src/preset/command_rules/tokens.rs index ebfacfc11..a68d428a8 100644 --- a/crates/noticenterctl/src/preset/command_rules/tokens.rs +++ b/crates/noticenterctl/src/preset/command_rules/tokens.rs @@ -1,151 +1,142 @@ +use std::ffi::{OsStr, OsString}; use std::ops::Range; use std::path::{Path, PathBuf}; -use unixnotis_core::{parse_command, util, ExecutionMode, ParsedCommand}; +use unixnotis_core::CommandSpec; use super::super::pathing::{format_relative_path, normalize_lexical_path}; -pub fn resolve_command_path_token(config_dir: &Path, command: &str) -> Option { - let trimmed = command.trim(); - if trimmed.is_empty() { - return None; - } - let parsed = parse_command(trimmed).ok()?; - let first = effective_program(&parsed)?; +pub fn resolve_command_path_token(config_dir: &Path, command: &CommandSpec) -> Option { + let first = effective_program(command)?; + let first = first.to_str()?; if !looks_like_path_token(first) { return None; } - - let expanded = PathBuf::from(util::expand_tilde(first).into_owned()); - if expanded.is_absolute() { - return Some(expanded); + let path = PathBuf::from(first); + if path.is_absolute() { + return Some(path); } - Some(config_dir.join(expanded)) + Some(config_dir.join(path)) } -pub fn collect_outside_env_path_tokens(config_dir: &Path, command: &str) -> Vec<(String, PathBuf)> { - let trimmed = command.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - +pub fn collect_outside_env_path_tokens( + config_dir: &Path, + command: &CommandSpec, +) -> Vec<(String, PathBuf)> { let normalized_root = normalize_lexical_path(config_dir); - let Ok(parsed) = parse_command(trimmed) else { - return Vec::new(); - }; - command_env_assignments(&parsed) + command_env_assignments(command) + .unwrap_or_default() .into_iter() .filter_map(|(name, value)| { - let components = env_path_components(name, value).ok().flatten()?; + let components = env_path_components(&name, &value).ok().flatten()?; let outside_path = components .into_iter() .map(|part| resolve_env_path_value(config_dir, part)) .find(|path| !normalize_lexical_path(path).starts_with(&normalized_root))?; - Some((name.to_string(), outside_path)) + Some((name, outside_path)) }) .collect() } -pub fn rewrite_command_to_config_relative(config_dir: &Path, command: &str) -> Option { - let trimmed = command.trim(); - if trimmed.is_empty() { - return None; - } - - let parsed = parse_command(trimmed).ok()?; - if parsed.execution_mode != ExecutionMode::Direct { - // Rewriting shell syntax token-by-token could change operators or expansion behavior - return None; - } - let first = effective_program(&parsed)?; +pub fn rewrite_command_to_config_relative(config_dir: &Path, command: &mut CommandSpec) -> bool { + let Some(first) = effective_program(command).and_then(OsStr::to_str) else { + return false; + }; if !is_host_specific_path_token(first) { - return None; + return false; } - - let resolved_path = resolve_command_path_token(config_dir, trimmed)?; + let Some(resolved_path) = resolve_command_path_token(config_dir, command) else { + return false; + }; let normalized_root = normalize_lexical_path(config_dir); let normalized_path = normalize_lexical_path(&resolved_path); - // Only paths that really live under the config root can be rewritten safely - let relative_path = normalized_path.strip_prefix(&normalized_root).ok()?; - let rewritten_first = format_relative_path(relative_path); - if rewritten_first.is_empty() { - return None; + let Ok(relative_path) = normalized_path.strip_prefix(&normalized_root) else { + return false; + }; + let rewritten = format_relative_path(relative_path); + if rewritten.is_empty() { + return false; } - // Re-quote parsed tokens so spaces survive without preserving ambiguous source quoting - let mut words = parsed - .env - .iter() - .map(|(name, value)| format!("{name}={value}")) - .collect::>(); - if parsed.program == "env" { - let program_index = effective_program_index(&parsed)?; - words.push(parsed.program); - words.extend(parsed.args.into_iter().enumerate().map(|(index, token)| { - if index == program_index { - rewritten_first.clone() - } else { - token - } - })); + let CommandSpec::Direct { program, args, .. } = command else { + return false; + }; + if program == Path::new("env") { + let Ok(layout) = env_command_layout(args) else { + return false; + }; + let Some(index) = layout.program_index else { + return false; + }; + args[index] = OsString::from(rewritten); } else { - words.push(rewritten_first); - words.extend(parsed.args); + *program = PathBuf::from(rewritten); } - Some(shell_words::join(words)) + true } -pub fn first_command_token(command: &str) -> Option { - // Returning the parsed program prevents quote characters from becoming path data - let parsed = parse_command(command).ok()?; - effective_program(&parsed).map(str::to_string) +pub fn first_command_token(command: &CommandSpec) -> Option { + effective_program(command)?.to_str().map(str::to_string) } -fn command_env_assignments(parsed: &ParsedCommand) -> Vec<(&str, &str)> { - let mut assignments = parsed - .env +fn command_env_assignments(command: &CommandSpec) -> Result, &'static str> { + let CommandSpec::Direct { program, args, env } = command else { + return Ok(Vec::new()); + }; + let mut assignments = env .iter() - .map(|(name, value)| (name.as_str(), value.as_str())) - .collect::>(); - - // `env NAME=value program` applies assignments to the eventual child too - // Shell-mode parsing is conservative because unsafe assignments must fail closed - if parsed.program == "env" { - if let Ok(layout) = env_command_layout(parsed) { - assignments.extend( - parsed.args[layout.assignment_range] - .iter() - .filter_map(|token| split_env_assignment(token)), - ); - } + .map(|(name, value)| { + Ok(( + name.to_str() + .ok_or("environment name is not UTF-8")? + .to_string(), + value + .to_str() + .ok_or("environment value is not UTF-8")? + .to_string(), + )) + }) + .collect::, &'static str>>()?; + + if program == Path::new("env") { + let layout = env_command_layout(args)?; + assignments.extend( + args[layout.assignment_range] + .iter() + .map(|token| token.to_str().ok_or("env argument is not UTF-8")) + .collect::, _>>()? + .into_iter() + .filter_map(split_env_assignment) + .map(|(name, value)| (name.to_string(), value.to_string())), + ); } - assignments + Ok(assignments) } -fn effective_program(parsed: &ParsedCommand) -> Option<&str> { - if parsed.program != "env" { - return Some(parsed.program.as_str()); +fn effective_program(command: &CommandSpec) -> Option<&OsStr> { + let CommandSpec::Direct { program, args, .. } = command else { + return None; + }; + if program != Path::new("env") { + return Some(program.as_os_str()); } - - // The env utility consumes leading assignments before spawning its real program - effective_program_index(parsed).map(|index| parsed.args[index].as_str()) + let index = env_command_layout(args).ok()?.program_index?; + Some(args[index].as_os_str()) } -fn effective_program_index(parsed: &ParsedCommand) -> Option { - env_command_layout(parsed).ok()?.program_index -} - -pub(super) fn validate_env_command_layout(parsed: &ParsedCommand) -> Result<(), &'static str> { - if parsed.program != "env" || parsed.execution_mode != ExecutionMode::Direct { +pub(super) fn validate_env_command_layout(command: &CommandSpec) -> Result<(), &'static str> { + let CommandSpec::Direct { program, args, .. } = command else { + return Ok(()); + }; + if program != Path::new("env") { return Ok(()); } - - env_command_layout(parsed).map(|_| ()) + env_command_layout(args).map(|_| ()) } -pub(super) fn validate_env_path_semantics(parsed: &ParsedCommand) -> Result<(), &'static str> { - for (name, value) in command_env_assignments(parsed) { - let _ = env_path_components(name, value)?; +pub(super) fn validate_env_path_semantics(command: &CommandSpec) -> Result<(), &'static str> { + for (name, value) in command_env_assignments(command)? { + let _ = env_path_components(&name, &value)?; } Ok(()) } @@ -161,9 +152,9 @@ enum EnvOptionStep { Stop, } -fn env_command_layout(parsed: &ParsedCommand) -> Result { +fn env_command_layout(args: &[OsString]) -> Result { let mut option_count = 0usize; - let mut remaining = parsed.args.as_slice(); + let mut remaining = args; loop { match env_option_step(remaining)? { EnvOptionStep::Continue(width) => { @@ -183,25 +174,26 @@ fn env_command_layout(parsed: &ParsedCommand) -> Result Result { - let Some(token) = arguments.first().map(String::as_str) else { +fn env_option_step(arguments: &[OsString]) -> Result { + let Some(token) = arguments.first() else { return Ok(EnvOptionStep::Stop); }; + let token = token.to_str().ok_or("env argument is not UTF-8")?; if token == "--" { return Ok(EnvOptionStep::Finish(1)); } @@ -213,7 +205,6 @@ fn env_option_step(arguments: &[String]) -> Result return Ok(EnvOptionStep::Continue(1)); } if is_separate_value_option(token) { - // The following operand belongs to env rather than the eventual child process return (arguments.len() >= 2) .then_some(EnvOptionStep::Continue(2)) .ok_or("env option is missing its required value"); @@ -314,27 +305,19 @@ fn env_path_components<'a>( .chars() .any(|character| matches!(character, '$' | '`' | '~')) { - // Shells expand these startup-file values before opening them return Err("shell startup environment paths cannot contain expansions"); } let components = match name { - // glibc accepts ASCII whitespace or colons with no escaping for preload objects "LD_PRELOAD" => value .split(|character: char| character == ':' || character.is_ascii_whitespace()) .filter(|component| !component.is_empty()) .collect::>(), - // glibc accepts both directory separators and treats empty fields as the child cwd "LD_LIBRARY_PATH" => value.split([':', ';']).collect::>(), - // These are colon-separated lists on Unix and empty fields resolve from the child cwd "PATH" | "LD_AUDIT" | "PYTHONPATH" | "PERL5LIB" | "RUBYLIB" | "NODE_PATH" | "GCONV_PATH" => value.split(':').collect::>(), - // Python accepts separate installation and platform-specific roots "PYTHONHOME" => python_home_components(value)?, - // These consumers interpret the complete value as one path - "HOME" | "LD_CONFIG_FILE" | "BASH_ENV" | "ENV" | "ZDOTDIR" => { - vec![value] - } + "HOME" | "LD_CONFIG_FILE" | "BASH_ENV" | "ENV" | "ZDOTDIR" => vec![value], _ => return Ok(None), }; @@ -343,10 +326,8 @@ fn env_path_components<'a>( .iter() .any(|component| !component.is_empty() && !component.contains('/')) { - // Bare object names use the system loader search order rather than the config directory return Err("loader object names must use an explicit config-relative path"); } - Ok(Some(components)) } @@ -354,12 +335,9 @@ fn python_home_components(value: &str) -> Result, &'static str> { let mut parts = value.splitn(3, ':'); let prefix = parts.next().unwrap_or_default(); let exec_prefix = parts.next(); - - // More than two roots cannot match Python's documented environment format if parts.next().is_some() { return Err("PYTHONHOME contains more than one prefix separator"); } - match exec_prefix { Some(exec_prefix) if !prefix.is_empty() && !exec_prefix.is_empty() => { Ok(vec![prefix, exec_prefix]) @@ -388,7 +366,6 @@ fn contains_dynamic_loader_token(value: &str) -> bool { fn resolve_env_path_value(config_dir: &Path, value: &str) -> PathBuf { if value.is_empty() { - // Empty list components mean cwd for loaders and path-search consumers return config_dir.to_path_buf(); } let path = PathBuf::from(value); @@ -399,10 +376,9 @@ fn resolve_env_path_value(config_dir: &Path, value: &str) -> PathBuf { } pub fn looks_like_path_token(token: &str) -> bool { - // Every supported relative prefix already contains a path separator - token == "~" || token.contains('/') + token.contains('/') } pub fn is_host_specific_path_token(token: &str) -> bool { - token.starts_with('/') || token == "~" || token.starts_with("~/") + token.starts_with('/') } diff --git a/crates/noticenterctl/src/preset/css_asset_refs/paths.rs b/crates/noticenterctl/src/preset/css_asset_refs/paths.rs index 18889ff91..54f2ce441 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/paths.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/paths.rs @@ -6,12 +6,11 @@ use std::path::Path; use anyhow::{Context, Result}; use rustix::fs::{open, Mode, OFlags}; +use unixnotis_core::MAX_CSS_FILE_BYTES; use super::super::config_root::PresetFileSource; use super::super::pathing::normalize_lexical_path; -const MAX_CSS_FILE_BYTES: u64 = 16_777_216; - pub(in crate::preset) fn has_css_extension(path: &Path) -> bool { // CSS-only filtering keeps later URL parsing away from binary assets and config files path.extension() diff --git a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs index 62c680dd3..ffd6e204b 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/rewrite.rs @@ -50,6 +50,7 @@ fn rewrite_host_specific_refs_in_text( let mut last_index = 0usize; for span in collect_url_spans(css_text)? { + validate_rewrite_range(css_text, last_index, span.value_start, span.value_end)?; // Everything before the current url(...) payload is copied through unchanged rewritten.push_str(&css_text[last_index..span.value_start]); @@ -75,6 +76,23 @@ fn rewrite_host_specific_refs_in_text( Ok((rewritten, rewrites)) } +pub(super) fn validate_rewrite_range( + css_text: &str, + last_index: usize, + value_start: usize, + value_end: usize, +) -> Result<()> { + // Every slice must move forward and land on complete UTF-8 characters + if value_start > value_end + || value_start < last_index + || !css_text.is_char_boundary(value_start) + || !css_text.is_char_boundary(value_end) + { + anyhow::bail!("CSS scanner returned an invalid UTF-8 rewrite range"); + } + Ok(()) +} + fn rewrite_host_specific_asset_ref( config_dir: &Path, css_path: &Path, diff --git a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs index 795664fca..16d535667 100644 --- a/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs +++ b/crates/noticenterctl/src/preset/css_asset_refs/tests/rewrite.rs @@ -1,6 +1,25 @@ use std::path::Path; -use super::rewrite_host_specific_refs_in_text; +use super::{rewrite_host_specific_refs_in_text, validate_rewrite_range}; + +#[test] +fn rewrite_range_validation_accepts_ordered_character_boundaries() { + let css = "éx"; + + validate_rewrite_range(css, 0, 0, 0).expect("empty range at current offset"); + validate_rewrite_range(css, 0, 0, css.len()).expect("complete UTF-8 range"); + validate_rewrite_range(css, 2, 2, css.len()).expect("range at prior end"); +} + +#[test] +fn rewrite_range_validation_rejects_each_invalid_offset_shape() { + let css = "éx"; + + assert!(validate_rewrite_range(css, 0, 2, 0).is_err()); + assert!(validate_rewrite_range(css, 2, 0, 2).is_err()); + assert!(validate_rewrite_range(css, 0, 1, 2).is_err()); + assert!(validate_rewrite_range(css, 0, 0, 1).is_err()); +} #[test] fn rewrite_keeps_ambiguous_escaped_url_unchanged() { @@ -54,3 +73,30 @@ fn rewrite_percent_encodes_decoded_file_url_characters_in_quoted_and_unquoted_fo } } } + +#[test] +fn rewrite_preserves_unicode_whitespace_without_invalid_utf8_ranges() { + let whitespace = [ + '\u{0085}', '\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', + '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', + '\u{2028}', '\u{2029}', '\u{202F}', '\u{205F}', '\u{3000}', + ]; + + for character in whitespace { + for css in [ + format!(".a {{ background: url({character}asset.png); }}"), + format!(".a {{ background: url(asset.png{character}); }}"), + format!(".a {{ background: url(\"{character}asset.png{character}\"); }}"), + ] { + let (rewritten, findings) = rewrite_host_specific_refs_in_text( + Path::new("/config/unixnotis"), + Path::new("/config/unixnotis/base.css"), + &css, + ) + .expect("rewrite Unicode CSS URL safely"); + + assert_eq!(rewritten, css); + assert!(findings.is_empty()); + } + } +} diff --git a/crates/noticenterctl/src/preset/export/assets.rs b/crates/noticenterctl/src/preset/export/assets.rs index ec66a54b3..e15bcabe5 100644 --- a/crates/noticenterctl/src/preset/export/assets.rs +++ b/crates/noticenterctl/src/preset/export/assets.rs @@ -26,7 +26,7 @@ pub(super) fn collect_existing_icon_assets( paths.push(relative); } } - paths.sort(); + paths.sort_unstable(); paths.dedup(); Ok(paths) } diff --git a/crates/noticenterctl/src/preset/export/prompts/rewrite.rs b/crates/noticenterctl/src/preset/export/prompts/rewrite.rs index 99478a94c..d14de5033 100644 --- a/crates/noticenterctl/src/preset/export/prompts/rewrite.rs +++ b/crates/noticenterctl/src/preset/export/prompts/rewrite.rs @@ -153,9 +153,10 @@ fn format_host_specific_command_path_lines( .iter() .map(|leak| { // Show exact slot and command for quick review + let command = leak.command.display_lossy(); format!( " - {} = {} (absolute path under the config root; let noticenterctl rewrite it to a config-root-relative command)", - safe_prompt_value(&leak.slot), safe_prompt_value(&leak.command) + safe_prompt_value(&leak.slot), safe_prompt_value(&command) ) }) .collect() diff --git a/crates/noticenterctl/src/preset/export/script_dependencies.rs b/crates/noticenterctl/src/preset/export/script_dependencies.rs index 8d4698263..dea11a51c 100644 --- a/crates/noticenterctl/src/preset/export/script_dependencies.rs +++ b/crates/noticenterctl/src/preset/export/script_dependencies.rs @@ -5,7 +5,8 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::os::fd::OwnedFd; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; +use unixnotis_core::filesystem::ContainedPath; use anyhow::{anyhow, Context, Result}; @@ -232,18 +233,9 @@ fn is_shell_name(name: &str) -> bool { } pub(super) fn normalize_relative_path(path: &Path) -> Option { - let mut parts = Vec::new(); - for component in path.components() { - match component { - Component::Normal(value) => parts.push(value.to_os_string()), - Component::CurDir => {} - // Parent traversal is safe only while a prior config-relative segment remains to pop - Component::ParentDir => { - parts.pop()?; - } - Component::RootDir | Component::Prefix(_) => return None, - } - } - let normalized = parts.into_iter().collect::(); + let normalized = ContainedPath::resolve_relative("", path) + .ok()? + .relative() + .to_path_buf(); (!normalized.as_os_str().is_empty()).then_some(normalized) } diff --git a/crates/noticenterctl/src/preset/export/tests/support.rs b/crates/noticenterctl/src/preset/export/tests/support.rs index dfb0167c7..72f7f5fb8 100644 --- a/crates/noticenterctl/src/preset/export/tests/support.rs +++ b/crates/noticenterctl/src/preset/export/tests/support.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::fixture_file_contents; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub(in crate::preset::export) struct TempDirGuard { @@ -29,7 +31,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } } diff --git a/crates/noticenterctl/src/preset/import/review/checks.rs b/crates/noticenterctl/src/preset/import/review/checks.rs index 12af1b053..801b7b581 100644 --- a/crates/noticenterctl/src/preset/import/review/checks.rs +++ b/crates/noticenterctl/src/preset/import/review/checks.rs @@ -49,8 +49,8 @@ pub(in crate::preset) fn validate_imported_theme_paths_stay_in_root( // The bundle config is trusted during post-import setup, so its theme targets must stay local let config_text = std::str::from_utf8(config_bytes).context("preset config.toml is not valid UTF-8")?; - let config: Config = - toml::from_str(config_text).context("parse bundled config.toml for import validation")?; + let config = + Config::parse(config_text).context("parse bundled config.toml for import validation")?; validate_config_theme_paths_stay_in_root(config_dir, &config) } @@ -205,7 +205,7 @@ fn collect_explicit_exec_commands_from_config_bytes( .filter(|reference| explicit_slots.contains(&reference.slot)) .map(|reference| ImportedExecCommand { slot: reference.slot, - command: reference.command, + command: reference.command.display_lossy(), }) .collect()) } diff --git a/crates/noticenterctl/src/preset/import/review/tests/checks.rs b/crates/noticenterctl/src/preset/import/review/tests/checks.rs index b83b58fde..527037abb 100644 --- a/crates/noticenterctl/src/preset/import/review/tests/checks.rs +++ b/crates/noticenterctl/src/preset/import/review/tests/checks.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::test_support::current_config_bytes; + static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); fn temp_root(name: &str) -> PathBuf { @@ -28,8 +30,9 @@ fn imported_theme_checks_reject_parent_traversal_targets() { let config_dir = temp_root("relative-escape"); let config = b"[theme]\nbase_css = \"../escaped-base.css\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n"; - let error = validate_imported_theme_paths_stay_in_root(&config_dir, config) - .expect_err("reject relative theme escape"); + let error = + validate_imported_theme_paths_stay_in_root(&config_dir, ¤t_config_bytes(config)) + .expect_err("reject relative theme escape"); assert!(error .to_string() @@ -42,8 +45,9 @@ fn imported_command_checks_reject_absolute_plugin_command() { let config_dir = temp_root("outside-command"); let config = b"[theme]\nbase_css = \"base.css\"\n[[widgets.stats]]\nlabel = \"Probe\"\n[widgets.stats.plugin]\napi_version = 1\ncommand = \"/tmp/outside-plugin\"\n"; - let error = validate_imported_command_paths_stay_in_root(&config_dir, config) - .expect_err("reject outside command path"); + let error = + validate_imported_command_paths_stay_in_root(&config_dir, ¤t_config_bytes(config)) + .expect_err("reject outside command path"); assert!(error .to_string() @@ -166,7 +170,8 @@ label = "Probe" cmd = "scripts/check.sh" "#; - let content = collect_imported_exec_content(config, &[]).expect("collect exec content"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &[]) + .expect("collect exec content"); assert_eq!(content.commands.len(), 1); assert_eq!(content.commands[0].slot, "widgets.stats[0].cmd"); @@ -175,13 +180,13 @@ cmd = "scripts/check.sh" #[test] fn imported_exec_collection_ignores_unknown_command_keys_and_keeps_real_command() { - let mut config = String::from("config_version = 2\n"); + let mut config = String::new(); for index in 0..64 { config.push_str(&format!("[aaa{index:02}]\ncmd = \"true\"\n")); } config.push_str("[[widgets.stats]]\nlabel = \"Probe\"\ncmd = \"sh assets/payload.dat\"\n"); - let content = collect_imported_exec_content(config.as_bytes(), &[]) + let content = collect_imported_exec_content(¤t_config_bytes(config.as_bytes()), &[]) .expect("collect only typed command fields"); assert_eq!(content.commands.len(), 1); @@ -192,8 +197,6 @@ fn imported_exec_collection_ignores_unknown_command_keys_and_keeps_real_command( #[test] fn imported_exec_collection_covers_every_known_explicit_command_field() { let config = br#" -config_version = 2 - [widgets.volume] get_cmd = "volume-get" set_cmd = "volume-set" @@ -226,7 +229,8 @@ api_version = 1 command = "card-plugin" "#; - let content = collect_imported_exec_content(config, &[]).expect("collect known commands"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &[]) + .expect("collect known commands"); let slots = content .commands .iter() @@ -259,7 +263,7 @@ command = "card-plugin" #[test] fn imported_exec_collection_does_not_include_runtime_defaults() { - let content = collect_imported_exec_content(b"config_version = 2\n", &[]) + let content = collect_imported_exec_content(¤t_config_bytes(b""), &[]) .expect("parse data-only config"); assert!(content.commands.is_empty()); @@ -285,8 +289,8 @@ base_css = "base.css" }, ]; - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect script payload"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &bundle_files) + .expect("collect script payload"); assert!(content.commands.is_empty()); assert_eq!(content.files.len(), 2); @@ -327,8 +331,8 @@ cmd = "scripts/check.sh" }, ]; - let content = - collect_imported_exec_content(config, &bundle_files).expect("collect trusted exec"); + let content = collect_imported_exec_content(¤t_config_bytes(config), &bundle_files) + .expect("collect trusted exec"); assert_eq!(content.commands.len(), 1); assert_eq!(content.files.len(), 3); @@ -351,17 +355,16 @@ fn imported_exec_collection_inventories_plain_payloads_for_each_command_form() { ]; for (command, payload_path) in cases { - let config = format!( - "config_version = 2\n[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n" - ); + let config = format!("[[widgets.stats]]\nlabel = \"Probe\"\ncmd = {command:?}\n"); let files = [BundleFile { relative_path: PathBuf::from(payload_path), contents: b"plain file payload\n".to_vec(), mode: 0o644, }]; - let content = collect_imported_exec_content(config.as_bytes(), &files) - .expect("collect command-backed plain payload"); + let content = + collect_imported_exec_content(¤t_config_bytes(config.as_bytes()), &files) + .expect("collect command-backed plain payload"); assert_eq!(content.commands.len(), 1); assert_eq!(content.files.len(), 1); @@ -377,7 +380,7 @@ fn imported_exec_collection_ignores_plain_assets_without_commands() { mode: 0o644, }]; - let content = collect_imported_exec_content(b"config_version = 2\n", &files) + let content = collect_imported_exec_content(¤t_config_bytes(b""), &files) .expect("collect data-only bundle"); assert!(content.commands.is_empty()); diff --git a/crates/noticenterctl/src/preset/import/tests/helpers.rs b/crates/noticenterctl/src/preset/import/tests/helpers.rs index 356893459..b106a7fa0 100644 --- a/crates/noticenterctl/src/preset/import/tests/helpers.rs +++ b/crates/noticenterctl/src/preset/import/tests/helpers.rs @@ -7,6 +7,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Result; +use crate::test_support::fixture_file_contents; + pub(in crate::preset::import) use crate::preset::archive::write_bundle; pub(in crate::preset::import) use crate::preset::config_root::{ CollectedConfigFiles, PresetFileSource, @@ -46,7 +48,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write test file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write test file"); } } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs b/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs index 59eeaa69c..4c78a3950 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/apply.rs @@ -4,6 +4,7 @@ use crate::preset::import::transaction::apply::{ apply_import_plan, finalize_import_transaction, rollback_import_transaction, }; use crate::preset::import::transaction::plan::build_import_plan; +use crate::test_support::current_config_text; #[test] fn applied_import_can_restore_the_exact_previous_file() { @@ -29,7 +30,7 @@ fn applied_import_can_restore_the_exact_previous_file() { rollback_import_transaction(transaction).expect("rollback import"); assert_eq!( fs::read_to_string(root.path.join("config.toml")).expect("read restored file"), - "before" + current_config_text("before") ); } @@ -87,7 +88,7 @@ fn transaction_rejects_a_replaced_live_root_before_finalize() { assert_eq!( fs::read_to_string(moved.join("config.toml")).expect("read rolled-back old root"), - "before" + current_config_text("before") ); fs::remove_dir_all(&root.path).expect("remove replacement root"); fs::rename(&moved, &root.path).expect("restore imported config root"); @@ -119,7 +120,7 @@ fn root_drift_check_rolls_back_files_through_the_pinned_descriptor() { assert_eq!( fs::read_to_string(moved.join("config.toml")).expect("read descriptor-root config"), - "before" + current_config_text("before") ); fs::remove_dir_all(&root.path).expect("remove replacement root"); fs::rename(&moved, &root.path).expect("restore rolled-back config root"); diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs b/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs index 4d907a271..0fc692659 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/commit.rs @@ -8,11 +8,14 @@ use crate::preset::import::transaction::apply::{ }; use crate::preset::import::transaction::commit::commit_import_plan; use crate::preset::import::transaction::plan::build_import_plan; +use crate::test_support::{current_config_text, fixture_file_contents}; fn bundle_file(relative_path: &str, contents: &str) -> BundleFile { BundleFile { relative_path: PathBuf::from(relative_path), - contents: contents.as_bytes().to_vec(), + contents: fixture_file_contents(relative_path, contents) + .as_bytes() + .to_vec(), mode: 0o644, } } @@ -43,7 +46,7 @@ fn commit_import_plan_writes_files_runs_css_check_and_returns_backup() { assert!(css_result.is_ok()); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read imported config"), - "[panel]\nwidth = 444\n" + current_config_text("[panel]\nwidth = 444\n") ); assert_eq!( fs::read_to_string(import_root.path.join("theme/base.css")).expect("read imported css"), @@ -52,7 +55,7 @@ fn commit_import_plan_writes_files_runs_css_check_and_returns_backup() { let backup_dir = backup_dir.expect("overwritten config should create backup"); assert_eq!( fs::read_to_string(backup_dir.join("config.toml")).expect("read backup config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -79,7 +82,7 @@ fn commit_import_plan_rolls_back_when_imported_config_cannot_load() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -104,7 +107,7 @@ fn apply_failure_on_later_file_rolls_back_earlier_publication() { assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); } @@ -140,7 +143,7 @@ fn commit_import_plan_rolls_back_when_imported_config_points_outside_root() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert!(!outside_theme.exists()); } @@ -178,7 +181,7 @@ fn commit_import_plan_rolls_back_when_imported_command_points_outside_root() { assert_eq!(css_calls.load(Ordering::Relaxed), 0); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert!(!import_root.path.join("scripts/probe.sh").exists()); assert!(!outside_command.exists()); @@ -216,7 +219,7 @@ fn commit_import_plan_cleans_partial_backup_and_rolls_back_when_backup_write_fai assert_eq!(writes.load(Ordering::Relaxed), 2); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read restored config"), - "[panel]\nwidth = 320\n" + current_config_text("[panel]\nwidth = 320\n") ); assert_eq!( fs::read_to_string(import_root.path.join("theme/base.css")).expect("read restored css"), @@ -254,6 +257,6 @@ fn commit_import_plan_keeps_import_committed_when_css_check_fails() { .contains("css-check failed for test")); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read committed config"), - "[panel]\nwidth = 444\n" + current_config_text("[panel]\nwidth = 444\n") ); } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs b/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs index 5c9a714eb..2949cdf51 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/prepare.rs @@ -1,4 +1,5 @@ use super::*; +use crate::test_support::current_config_text; #[test] fn run_import_reports_missing_bundle_instead_of_succeeding() { @@ -40,7 +41,7 @@ fn import_dry_run_reports_create_and_overwrite_counts() { assert_eq!(summary.excluded, 1); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "old = true" + current_config_text("old = true") ); } @@ -84,7 +85,7 @@ fn import_writes_files_and_creates_backup_for_overwrites() { assert!(backup_dir.join("config.toml").exists()); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "[theme]\nbase_css = \"base.css\"\n" + current_config_text("[theme]\nbase_css = \"base.css\"\n") ); } @@ -108,7 +109,7 @@ fn import_accepts_bundle_that_contains_only_config_toml() { assert_eq!(summary.created, 1); assert_eq!( fs::read_to_string(import_root.path.join("config.toml")).expect("read config"), - "title = \"only config\"\n" + current_config_text("title = \"only config\"\n") ); } diff --git a/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs b/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs index a8ff114ea..2e1390124 100644 --- a/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs +++ b/crates/noticenterctl/src/preset/import/transaction/tests/prepare_exec.rs @@ -135,7 +135,7 @@ cmd = "scripts/probe.sh" #[test] fn import_review_ignores_unknown_command_decoys_and_includes_plain_payload() { let export_root = TempDirGuard::new("command-decoy-export"); - let mut config = String::from("config_version = 2\n[theme]\nbase_css = \"base.css\"\n"); + let mut config = String::from("[theme]\nbase_css = \"base.css\"\n"); for index in 0..64 { config.push_str(&format!("[aaa{index:02}]\ncmd = \"true\"\n")); } diff --git a/crates/noticenterctl/src/preset/inspect.rs b/crates/noticenterctl/src/preset/inspect.rs index a21d24e1e..edfa1ca56 100644 --- a/crates/noticenterctl/src/preset/inspect.rs +++ b/crates/noticenterctl/src/preset/inspect.rs @@ -72,10 +72,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for command in commands { + let command_text = command.command.display_lossy(); out.push_str(&format!( " - {} = {}\n", safe_report_value(&command.slot), - safe_report_value(&command.command) + safe_report_value(&command_text) )); } } @@ -90,10 +91,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for warning in outside_paths { + let command_text = warning.command.display_lossy(); out.push_str(&format!( " - {} points outside the config root: {}\n", safe_report_value(&warning.slot), - safe_report_value(&warning.command) + safe_report_value(&command_text) )); } } @@ -110,10 +112,11 @@ pub(super) fn inspect_preset_at(input_path: &Path) -> Result { out.push_str(" none\n"); } else { for leak in leaked_paths { + let command_text = leak.command.display_lossy(); out.push_str(&format!( " - {} uses a host-local config path: {}\n", safe_report_value(&leak.slot), - safe_report_value(&leak.command) + safe_report_value(&command_text) )); } } diff --git a/crates/noticenterctl/src/preset/mod.rs b/crates/noticenterctl/src/preset/mod.rs index 88caa71af..829327cfc 100644 --- a/crates/noticenterctl/src/preset/mod.rs +++ b/crates/noticenterctl/src/preset/mod.rs @@ -13,6 +13,7 @@ mod import; mod inspect; mod manifest; mod pathing; +mod reset; #[cfg(test)] mod tests; @@ -44,5 +45,6 @@ pub fn run_preset(command: PresetCommand) -> Result<()> { allow_external_css, ), PresetCommand::Inspect { input } => inspect::run_inspect(Path::new(&input)), + PresetCommand::ResetConfig { yes } => reset::run_reset_config(yes), } } diff --git a/crates/noticenterctl/src/preset/pathing.rs b/crates/noticenterctl/src/preset/pathing.rs index b25746d92..dd1611e66 100644 --- a/crates/noticenterctl/src/preset/pathing.rs +++ b/crates/noticenterctl/src/preset/pathing.rs @@ -91,28 +91,10 @@ pub(super) fn normalize_relative_path(path: &Path) -> Result { )); } - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - // `.` adds no meaning, so it is stripped out during normalization - Component::CurDir => {} - // `..` would let a bundle or flag escape the config root - Component::ParentDir => { - return Err(anyhow!( - "parent traversal is not allowed in preset paths: {}", - path.display() - )); - } - // Absolute and prefix components are already rejected above - Component::RootDir | Component::Prefix(_) => { - return Err(anyhow!( - "absolute paths are not allowed in preset paths: {}", - path.display() - )); - } - Component::Normal(part) => normalized.push(part), - } - } + let normalized = unixnotis_core::filesystem::ContainedPath::resolve_relative("", path) + .map_err(|error| anyhow!("unsafe preset path {}: {error}", path.display()))? + .relative() + .to_path_buf(); if normalized.as_os_str().is_empty() { return Err(anyhow!("path resolved to an empty relative path")); @@ -121,31 +103,11 @@ pub(super) fn normalize_relative_path(path: &Path) -> Result { } pub(super) fn normalize_lexical_path(path: &Path) -> PathBuf { - // This stays purely lexical so callers can validate paths before the target exists on disk - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - // Keep any platform prefix intact before later segments are folded in - Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), - // Root anchors the normalized path before normal segments are added - Component::RootDir => normalized.push(Path::new("/")), - // `.` adds no meaning to the final path - Component::CurDir => {} - // Normal path segments are preserved in order - Component::Normal(part) => normalized.push(part), - Component::ParentDir => match normalized.components().next_back() { - // One `..` can fold away one earlier normal segment - Some(Component::Normal(_)) => { - normalized.pop(); - } - // Parent segments at the filesystem root stay pinned there - Some(Component::RootDir | Component::Prefix(_)) => {} - // Relative paths may still carry leading `..` segments at this stage - _ => normalized.push(".."), - }, - } - } - normalized + // Invalid traversal stays visibly unnormalized so containment checks fail closed + unixnotis_core::filesystem::LexicallyNormalizedPath::new(path).map_or_else( + |_| path.to_path_buf(), + unixnotis_core::filesystem::LexicallyNormalizedPath::into_path_buf, + ) } pub(super) fn relative_path_matches_exclusion( diff --git a/crates/noticenterctl/src/preset/reset.rs b/crates/noticenterctl/src/preset/reset.rs new file mode 100644 index 000000000..4be651cac --- /dev/null +++ b/crates/noticenterctl/src/preset/reset.rs @@ -0,0 +1,69 @@ +//! Non-D-Bus configuration reset frontend + +use anyhow::{Context, Result}; +use std::io::{self, BufRead, IsTerminal, Write}; +use unixnotis_core::{ + ensure_installer_config, load_installer_config, reset_config_to_defaults, Config, + ResetConfigOptions, +}; + +pub(super) fn run_reset_config(skip_confirmation: bool) -> Result<()> { + let stdin = io::stdin(); + // Local reset is destructive, so unattended calls must opt in explicitly + if !skip_confirmation && !confirm_reset(&mut stdin.lock(), stdin.is_terminal())? { + println!("Reset cancelled."); + return Ok(()); + } + let config_dir = + Config::default_config_dir().map_err(|error| anyhow::anyhow!(error.to_string()))?; + // Both local frontends create and read the same settings file + let _ = ensure_installer_config(&config_dir).context("prepare installer settings")?; + let retention = load_installer_config(&config_dir) + .context("load installer settings")? + .backups + .keep; + // The core operation owns all filesystem changes and rollback behavior + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir, + backup_retention: retention, + }) + .context("reset configuration to defaults")?; + if let Some(backup_dir) = report.backup_dir { + println!( + "Backed up existing configuration to:\n{}", + backup_dir.display() + ); + } else { + println!("No backup was created because backup retention is disabled."); + } + println!("Reset config.toml to current defaults."); + println!("Reset bundled scripts."); + println!("Reset theme CSS files to current defaults."); + Ok(()) +} + +pub(super) fn confirm_reset(input: &mut impl BufRead, interactive: bool) -> Result { + if !interactive { + return Err(anyhow::anyhow!( + "reset-config requires --yes when standard input is not interactive" + )); + } + print!( + "This will reset UnixNotis configuration and bundled scripts.\n\ +Existing files will be backed up before replacement.\n\ +Continue? [y/N] " + ); + io::stdout().flush().context("flush reset confirmation")?; + let mut answer = String::new(); + input + .read_line(&mut answer) + .context("read reset confirmation")?; + Ok(matches!( + answer.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +#[cfg(test)] +#[path = "tests/reset.rs"] +mod tests; diff --git a/crates/noticenterctl/src/preset/tests/inspect.rs b/crates/noticenterctl/src/preset/tests/inspect.rs index 35a9ee8e0..5c5468068 100644 --- a/crates/noticenterctl/src/preset/tests/inspect.rs +++ b/crates/noticenterctl/src/preset/tests/inspect.rs @@ -8,6 +8,8 @@ use flate2::write::GzEncoder; use flate2::Compression; use tar::{Builder, Header}; +use crate::test_support::fixture_file_contents; + use super::super::export::flow::export_preset_from; use super::super::inspect::inspect_preset_at; use super::super::manifest::{PresetManifest, PresetManifestFile}; @@ -38,7 +40,11 @@ impl TempDirGuard { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dirs"); } - fs::write(path, contents).expect("write file"); + fs::write( + path, + fixture_file_contents(relative_path, contents).as_bytes(), + ) + .expect("write file"); } } @@ -107,7 +113,10 @@ fn inspect_sanitizes_preset_control_sequences_before_terminal_output() { assert!(!report.contains('\u{1b}')); assert!(!report.contains('\u{7}')); assert!(report.contains("preset: demo ]0;owned")); - assert!(report.contains("printf ' ]52;c;AAAA '")); + assert!( + report.contains("printf ]52;c;AAAA"), + "sanitized command missing from report: {report:?}" + ); } #[test] @@ -162,6 +171,10 @@ fn inspect_reports_theme_paths_that_leave_config_root() { fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(&str, &str)]) { // Raw bundle writing lets inspect tests model presets that export would reject + let normalized_files = files + .iter() + .map(|(path, contents)| (*path, fixture_file_contents(path, contents))) + .collect::>(); let output = fs::File::create(bundle_path).expect("create test bundle"); let encoder = GzEncoder::new(output, Compression::default()); let mut archive = Builder::new(encoder); @@ -169,7 +182,7 @@ fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(& bundle_name.to_string(), "2026-01-01T00:00:00Z".to_string(), "test".to_string(), - files + normalized_files .iter() .map(|(path, contents)| PresetManifestFile { path: (*path).to_string(), @@ -180,7 +193,7 @@ fn write_bundle_with_files(bundle_path: &PathBuf, bundle_name: &str, files: &[(& let manifest_text = manifest.encode().expect("encode manifest"); append_text_file(&mut archive, "manifest.toml", &manifest_text); - for (path, contents) in files { + for (path, contents) in &normalized_files { append_text_file(&mut archive, &format!("payload/{path}"), contents); } archive.finish().expect("finish archive"); diff --git a/crates/noticenterctl/src/preset/tests/pathing.rs b/crates/noticenterctl/src/preset/tests/pathing.rs index e2c0777ed..0a7c419be 100644 --- a/crates/noticenterctl/src/preset/tests/pathing.rs +++ b/crates/noticenterctl/src/preset/tests/pathing.rs @@ -19,10 +19,10 @@ fn normalize_relative_path_strips_dot_segments() { } #[test] -fn normalize_relative_path_rejects_parent_segments() { - let error = - normalize_relative_path(Path::new("./assets/../bg.png")).expect_err("reject parent"); - assert!(error.to_string().contains("parent traversal")); +fn normalize_relative_path_collapses_contained_parent_segments() { + let normalized = + normalize_relative_path(Path::new("./assets/../bg.png")).expect("normalize parent"); + assert_eq!(normalized, Path::new("bg.png")); } #[test] diff --git a/crates/noticenterctl/src/preset/tests/reset.rs b/crates/noticenterctl/src/preset/tests/reset.rs new file mode 100644 index 000000000..3a2bf0b6b --- /dev/null +++ b/crates/noticenterctl/src/preset/tests/reset.rs @@ -0,0 +1,132 @@ +use std::fs; +use std::io::Cursor; + +use super::super::reset::{confirm_reset, run_reset_config}; +use crate::test_support::{test_env_lock, EnvGuard}; + +#[test] +fn confirmation_accepts_yes_and_defaults_to_no() { + assert!(confirm_reset(&mut Cursor::new("yes\n"), true).expect("read yes")); + assert!(!confirm_reset(&mut Cursor::new("\n"), true).expect("read default")); +} + +#[test] +fn noninteractive_confirmation_fails_closed() { + let error = confirm_reset(&mut Cursor::new("yes\n"), false).expect_err("require --yes"); + assert!(error.to_string().contains("--yes")); +} + +#[test] +fn yes_mode_executes_reset_and_creates_shared_settings() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-reset-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom = true\n").expect("seed config"); + + run_reset_config(true).expect("--yes reset should execute"); + + assert!(config_dir.join("installer.toml").is_file()); + let config = fs::read_to_string(config_dir.join("config.toml")).expect("read reset config"); + toml::from_str::(&config).expect("reset config should parse"); + for (name, expected) in [ + ("base.css", unixnotis_core::DEFAULT_BASE_CSS), + ("panel.css", unixnotis_core::DEFAULT_PANEL_CSS), + ("popup.css", unixnotis_core::DEFAULT_POPUP_CSS), + ("widgets.css", unixnotis_core::DEFAULT_WIDGETS_CSS), + ("media.css", unixnotis_core::DEFAULT_MEDIA_CSS), + ] { + assert_eq!( + fs::read_to_string(config_dir.join(name)).expect("read reset stylesheet"), + expected, + "reset must restore {name}" + ); + } + assert!(fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with("Backup-"))); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn yes_mode_rejects_invalid_settings_without_changes() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-invalid-settings-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom config\n").expect("seed config"); + fs::write(config_dir.join("installer.toml"), "[backups\n").expect("corrupt settings"); + let before = fs::read(config_dir.join("config.toml")).expect("read config before reset"); + + let error = run_reset_config(true).expect_err("invalid settings must fail"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(config_dir.join("config.toml")).expect("read config after reset"), + before + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "invalid settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn yes_mode_rejects_non_file_settings_without_changes() { + let _lock = test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-cli-directory-settings-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config fixture"); + fs::write(config_dir.join("config.toml"), "custom config\n").expect("seed config"); + fs::create_dir(config_dir.join("installer.toml")).expect("create settings directory"); + let before = fs::read(config_dir.join("config.toml")).expect("read config before reset"); + + let error = run_reset_config(true).expect_err("directory settings must fail"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(config_dir.join("config.toml")).expect("read config after reset"), + before + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "non-file settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/noticenterctl/src/session_environment/backends/dinit.rs b/crates/noticenterctl/src/session_environment/backends/dinit.rs new file mode 100644 index 000000000..f68ae2d83 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/dinit.rs @@ -0,0 +1,34 @@ +//! Dinit user-manager environment import and start flow + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::super::variables::import_variables; + +pub(in crate::session_environment) fn sync_dinit() -> Result<()> { + let import_variables = import_variables(ServiceManagerKind::Dinit); + // Dinit imports named values directly from the current process environment + require_success(&CommandSpec::direct( + "dinitctl", + std::iter::once("--user") + .chain(std::iter::once("setenv")) + .chain(import_variables.iter().copied()), + ))?; + let restart = CommandSpec::direct( + "dinitctl", + [ + "--user", + "restart", + "--ignore-unstarted", + "unixnotis-daemon", + ], + ); + // An inactive service cannot restart, so a normal start follows every attempt + let _ = run(&restart)?; + require_success(&CommandSpec::direct( + "dinitctl", + ["--user", "start", "unixnotis-daemon"], + )) +} diff --git a/crates/noticenterctl/src/session_environment/backends/envdir.rs b/crates/noticenterctl/src/session_environment/backends/envdir.rs new file mode 100644 index 000000000..094d04e93 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/envdir.rs @@ -0,0 +1,37 @@ +//! Hardened envdir publication below an installed service directory + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::filesystem::write_file_atomic; +use unixnotis_core::service_manager::{envdir_file_contents, ServiceManagerKind}; + +use super::super::variables::import_variables; + +pub(in crate::session_environment) fn write_envdir( + service: &Path, + env_dir: &Path, + kind: ServiceManagerKind, +) -> Result<()> { + let metadata = fs::symlink_metadata(service) + .with_context(|| format!("inspect installed service directory {}", service.display()))?; + // The service anchor must be a real directory before any child path is created + if !metadata.file_type().is_dir() { + bail!( + "refusing to write environment outside a regular service directory: {}", + service.display() + ); + } + // Every published key comes from the backend-specific allowlist + for name in import_variables(kind) { + let value = env::var(name).ok(); + let contents = envdir_file_contents(value.as_deref()); + let target: PathBuf = env_dir.join(name); + // Atomic descriptor-relative writes reject symlink traversal and partial files + write_file_atomic(&target, contents.as_bytes(), 0o600) + .with_context(|| format!("write service environment file {}", target.display()))?; + } + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/backends/mod.rs b/crates/noticenterctl/src/session_environment/backends/mod.rs new file mode 100644 index 000000000..77eeb15df --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/mod.rs @@ -0,0 +1,10 @@ +mod dinit; +pub(super) mod envdir; +mod runit; +mod s6; +mod systemd; + +pub(super) use dinit::sync_dinit; +pub(super) use runit::sync_runit; +pub(super) use s6::sync_s6; +pub(super) use systemd::sync_systemd; diff --git a/crates/noticenterctl/src/session_environment/backends/runit.rs b/crates/noticenterctl/src/session_environment/backends/runit.rs new file mode 100644 index 000000000..5a99e1672 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/runit.rs @@ -0,0 +1,23 @@ +//! Runit envdir publication and supervisor restart flow + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerPaths; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::envdir::write_envdir; + +pub(in crate::session_environment) fn sync_runit(manager: &ServiceManagerPaths) -> Result<()> { + let service = manager.artifact_root.join("unixnotis-daemon"); + write_envdir(&service, &service.join("env"), manager.kind)?; + let restart = CommandSpec::direct("sv", ["restart".into(), service.as_os_str().to_os_string()]); + // A successful restart avoids a redundant start request + if run(&restart)?.success() { + return Ok(()); + } + // Fresh installations may exist before the service is supervised + require_success(&CommandSpec::direct( + "sv", + ["start".into(), service.into_os_string()], + )) +} diff --git a/crates/noticenterctl/src/session_environment/backends/s6.rs b/crates/noticenterctl/src/session_environment/backends/s6.rs new file mode 100644 index 000000000..b92e26c09 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/s6.rs @@ -0,0 +1,35 @@ +//! S6-rc envdir publication and live-tree restart flow + +use anyhow::{Context, Result}; +use unixnotis_core::service_manager::ServiceManagerPaths; +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::envdir::write_envdir; + +pub(in crate::session_environment) fn sync_s6(manager: &ServiceManagerPaths) -> Result<()> { + let service = manager.artifact_root.join("sv").join("unixnotis-daemon"); + write_envdir(&service, &service.join("env"), manager.kind)?; + let live = manager + .live_root + .as_deref() + .context("s6 live root was not resolved")?; + // Bringing the compiled service up also refreshes dependency state + require_success(&CommandSpec::direct( + "s6-rc", + [ + "-l".into(), + live.as_os_str().to_os_string(), + "-u".into(), + "change".into(), + "unixnotis-daemon".into(), + ], + ))?; + let live_service = live.join("servicedirs").join("unixnotis-daemon"); + // The direct service restart is best effort after s6-rc succeeds + let _ = run(&CommandSpec::direct( + "s6-svc", + ["-r".into(), live_service.into_os_string()], + ))?; + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/backends/systemd.rs b/crates/noticenterctl/src/session_environment/backends/systemd.rs new file mode 100644 index 000000000..02ea0a9b5 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/backends/systemd.rs @@ -0,0 +1,42 @@ +//! Systemd user-manager environment import and restart flow + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; +use unixnotis_core::CommandSpec; + +use crate::system_tools; + +use super::super::process::require_success; +use super::super::variables::import_variables; + +pub(in crate::session_environment) fn sync_systemd() -> Result<()> { + let import_variables = import_variables(ServiceManagerKind::Systemd); + // Older installer releases may have persisted a transient nested-session address + require_success(&CommandSpec::direct( + "systemctl", + ["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"], + ))?; + // D-Bus activation receives the compositor variables when the helper is installed + if system_tools::trusted_program_path("dbus-update-activation-environment").is_some() { + require_success(&CommandSpec::direct( + "dbus-update-activation-environment", + import_variables, + ))?; + } + // The user manager must import the same values before restarting the daemon + require_success(&CommandSpec::direct( + "systemctl", + std::iter::once("--user") + .chain(std::iter::once("import-environment")) + .chain(import_variables.iter().copied()), + ))?; + require_success(&CommandSpec::direct( + "systemctl", + [ + "--user", + "--no-block", + "restart", + "unixnotis-daemon.service", + ], + )) +} diff --git a/crates/noticenterctl/src/session_environment/manager.rs b/crates/noticenterctl/src/session_environment/manager.rs new file mode 100644 index 000000000..ea45cd194 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/manager.rs @@ -0,0 +1,64 @@ +//! Explicit and artifact-backed service-manager selection + +use std::fs; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::service_manager::{ + resolve_service_manager_paths, ServiceManagerKind, ServiceManagerPaths, +}; + +use crate::cli::DoctorServiceManagerArg; + +pub(super) fn select_manager(requested: DoctorServiceManagerArg) -> Result { + // Explicit CLI choices bypass artifact probing and ambiguity checks + let kind = match requested { + DoctorServiceManagerArg::Auto => detect_installed_manager()?, + DoctorServiceManagerArg::Systemd => ServiceManagerKind::Systemd, + DoctorServiceManagerArg::Dinit => ServiceManagerKind::Dinit, + DoctorServiceManagerArg::Runit => ServiceManagerKind::Runit, + DoctorServiceManagerArg::S6 => ServiceManagerKind::S6, + DoctorServiceManagerArg::Manual => { + bail!("manual launches do not have a service environment to synchronize") + } + }; + resolve_service_manager_paths(kind).context("resolve service-manager paths") +} + +fn detect_installed_manager() -> Result { + // Only installer-owned artifacts count as evidence for automatic selection + let installed = ServiceManagerKind::all() + .into_iter() + .filter(|kind| { + resolve_service_manager_paths(*kind).is_ok_and(|paths| manager_artifact_exists(&paths)) + }) + .collect::>(); + select_detected_manager(&installed) +} + +pub(super) fn select_detected_manager( + installed: &[ServiceManagerKind], +) -> Result { + // Automatic mode is safe only when one installed backend is unambiguous + match installed { + [kind] => Ok(*kind), + [] => bail!( + "no installed UnixNotis user service was found; pass --service-manager explicitly" + ), + _ => { + bail!("multiple UnixNotis user services were found; pass --service-manager explicitly") + } + } +} + +pub(super) fn manager_artifact_exists(paths: &ServiceManagerPaths) -> bool { + // Every manager stores its primary daemon artifact at a stable relative path + let artifact = match paths.kind { + ServiceManagerKind::Systemd => paths.artifact_root.join("unixnotis-daemon.service"), + ServiceManagerKind::Dinit => paths.artifact_root.join("unixnotis-daemon"), + ServiceManagerKind::Runit => paths.artifact_root.join("unixnotis-daemon"), + ServiceManagerKind::S6 => paths.artifact_root.join("sv").join("unixnotis-daemon"), + }; + // Symlink metadata avoids following an attacker-controlled artifact target + fs::symlink_metadata(artifact) + .is_ok_and(|metadata| metadata.file_type().is_file() || metadata.file_type().is_dir()) +} diff --git a/crates/noticenterctl/src/session_environment/mod.rs b/crates/noticenterctl/src/session_environment/mod.rs new file mode 100644 index 000000000..962fbaf1e --- /dev/null +++ b/crates/noticenterctl/src/session_environment/mod.rs @@ -0,0 +1,12 @@ +//! Session environment synchronization without generated shell transactions + +mod backends; +mod manager; +mod process; +mod sync; +mod variables; + +pub use sync::sync; + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/session_environment/process.rs b/crates/noticenterctl/src/session_environment/process.rs new file mode 100644 index 000000000..bcf6474e9 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/process.rs @@ -0,0 +1,29 @@ +//! Typed service-manager command execution through trusted tool lookup + +use std::process::ExitStatus; + +use anyhow::{bail, Context, Result}; +use unixnotis_core::CommandSpec; + +use crate::system_tools; + +pub(super) fn run(command: &CommandSpec) -> Result { + // Trusted lookup prevents inherited PATH entries from selecting service tools + let mut process = system_tools::command_from_spec(command) + .with_context(|| format!("resolve trusted {} executable", command.display_lossy()))?; + process.status().with_context(|| { + format!( + "run {} for session environment sync", + command.display_lossy() + ) + }) +} + +pub(super) fn require_success(command: &CommandSpec) -> Result<()> { + // Preserve the native exit status in the user-facing failure report + let status = run(command)?; + if !status.success() { + bail!("{} exited with status {status}", command.display_lossy()); + } + Ok(()) +} diff --git a/crates/noticenterctl/src/session_environment/sync.rs b/crates/noticenterctl/src/session_environment/sync.rs new file mode 100644 index 000000000..43f8f81f8 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/sync.rs @@ -0,0 +1,26 @@ +//! Session validation, manager selection, and backend dispatch + +use std::env; + +use anyhow::Result; +use unixnotis_core::service_manager::ServiceManagerKind; + +use crate::cli::DoctorServiceManagerArg; + +use super::backends::{sync_dinit, sync_runit, sync_s6, sync_systemd}; +use super::manager::select_manager; +use super::variables::{validate_persisted_bus_address, validate_session_environment}; + +pub fn sync(requested: DoctorServiceManagerArg) -> Result<()> { + // Reject detached launches before resolving or mutating service state + validate_session_environment(|name| env::var_os(name))?; + let manager = select_manager(requested)?; + validate_persisted_bus_address(manager.kind, env::var_os("DBUS_SESSION_BUS_ADDRESS"))?; + // Each backend owns its native restart and environment publication contract + match manager.kind { + ServiceManagerKind::Systemd => sync_systemd(), + ServiceManagerKind::Dinit => sync_dinit(), + ServiceManagerKind::Runit => sync_runit(&manager), + ServiceManagerKind::S6 => sync_s6(&manager), + } +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs b/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs new file mode 100644 index 000000000..154aef6c2 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/dinit.rs @@ -0,0 +1,26 @@ +use std::fs; + +use super::super::super::backends::sync_dinit; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn dinit_sync_tolerates_an_unstarted_restart_before_starting_service() { + let tools = TempToolDir::new("dinit-sync"); + let log = tools.path().join("commands.log"); + tools.write_executable( + "dinitctl", + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nif [ \"$2\" = \"restart\" ]; then exit 1; fi\nexit 0\n", + log.display() + ), + ); + let _tools = use_fake_tool_bin(tools.path()); + + sync_dinit().expect("synchronize dinit environment"); + + let calls = fs::read_to_string(log).expect("read dinit command log"); + assert!(calls.contains("--user setenv")); + assert!(calls.contains("--user restart --ignore-unstarted unixnotis-daemon")); + assert!(calls.contains("--user start unixnotis-daemon")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs new file mode 100644 index 000000000..2e416b79b --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/envdir.rs @@ -0,0 +1,18 @@ +use super::super::super::backends::envdir::write_envdir; +use super::super::support::TempToolDir; +use unixnotis_core::service_manager::ServiceManagerKind; + +#[test] +fn envdir_writer_rejects_a_non_directory_service_anchor() { + let root = TempToolDir::new("envdir-anchor"); + let service = root.write_file("unixnotis-daemon", "not a directory"); + + let error = write_envdir( + &service, + &root.path().join("env"), + ServiceManagerKind::Runit, + ) + .expect_err("non-directory service must be rejected"); + + assert!(error.to_string().contains("regular service directory")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/mod.rs b/crates/noticenterctl/src/session_environment/tests/backends/mod.rs new file mode 100644 index 000000000..f8f431684 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/mod.rs @@ -0,0 +1,5 @@ +mod dinit; +mod envdir; +mod runit; +mod s6; +mod systemd; diff --git a/crates/noticenterctl/src/session_environment/tests/backends/runit.rs b/crates/noticenterctl/src/session_environment/tests/backends/runit.rs new file mode 100644 index 000000000..e97a81f38 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/runit.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; + +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::super::backends::sync_runit; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn runit_sync_writes_private_envdir_files_and_restarts_service() { + let tools = TempToolDir::new("runit-sync"); + tools.write_executable("sv", "#!/bin/sh\nexit 0\n"); + let service = tools.create_dir("services/unixnotis-daemon"); + let manager = ServiceManagerPaths { + kind: ServiceManagerKind::Runit, + artifact_root: service.parent().expect("service parent").to_path_buf(), + live_root: None, + }; + let _tools = use_fake_tool_bin(tools.path()); + + sync_runit(&manager).expect("synchronize runit environment"); + + let environment = service.join("env/WAYLAND_DISPLAY"); + assert!(environment.is_file()); + assert_eq!( + fs::metadata(environment) + .expect("environment metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/s6.rs b/crates/noticenterctl/src/session_environment/tests/backends/s6.rs new file mode 100644 index 000000000..af38e1fc0 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/s6.rs @@ -0,0 +1,25 @@ +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::super::backends::sync_s6; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn s6_sync_writes_envdir_files_and_addresses_the_resolved_live_tree() { + let tools = TempToolDir::new("s6-sync"); + for name in ["s6-rc", "s6-svc"] { + tools.write_executable(name, "#!/bin/sh\nexit 0\n"); + } + let service = tools.create_dir("s6/sv/unixnotis-daemon"); + let live = tools.create_dir("live"); + let manager = ServiceManagerPaths { + kind: ServiceManagerKind::S6, + artifact_root: tools.path().join("s6"), + live_root: Some(live), + }; + let _tools = use_fake_tool_bin(tools.path()); + + sync_s6(&manager).expect("synchronize s6 environment"); + + assert!(service.join("env/XDG_RUNTIME_DIR").is_file()); +} diff --git a/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs new file mode 100644 index 000000000..4055590be --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/backends/systemd.rs @@ -0,0 +1,34 @@ +use std::fs; + +use super::super::super::backends::sync_systemd; +use super::super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn systemd_sync_runs_environment_import_and_restart_commands() { + let tools = TempToolDir::new("systemd-sync"); + let log = tools.path().join("commands.log"); + for name in ["dbus-update-activation-environment", "systemctl"] { + tools.write_executable( + name, + &format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ), + ); + } + let _tools = use_fake_tool_bin(tools.path()); + + sync_systemd().expect("synchronize systemd environment"); + + let calls = fs::read_to_string(log).expect("read systemd command log"); + assert!(calls.contains("--user unset-environment DBUS_SESSION_BUS_ADDRESS")); + assert!(calls.contains("--user import-environment")); + assert!(calls.contains("--user --no-block restart unixnotis-daemon.service")); + let import = calls + .lines() + .find(|line| line.contains("import-environment")) + .expect("systemd import command"); + assert!(!import.contains("DBUS_SESSION_BUS_ADDRESS")); + assert!(!import.contains(" PATH")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/manager.rs b/crates/noticenterctl/src/session_environment/tests/manager.rs new file mode 100644 index 000000000..2978c8056 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/manager.rs @@ -0,0 +1,50 @@ +use unixnotis_core::service_manager::{ServiceManagerKind, ServiceManagerPaths}; + +use super::super::manager::{manager_artifact_exists, select_detected_manager}; +use super::support::TempToolDir; + +#[test] +fn automatic_manager_selection_accepts_exactly_one_installed_service() { + assert_eq!( + select_detected_manager(&[ServiceManagerKind::Runit]).expect("one manager"), + ServiceManagerKind::Runit + ); +} + +#[test] +fn automatic_manager_selection_rejects_no_installed_service() { + let error = select_detected_manager(&[]).expect_err("missing service must be rejected"); + + assert!(error + .to_string() + .contains("no installed UnixNotis user service")); +} + +#[test] +fn automatic_manager_selection_rejects_ambiguous_installed_services() { + assert!( + select_detected_manager(&[ServiceManagerKind::Systemd, ServiceManagerKind::Dinit]).is_err() + ); +} + +#[test] +fn manager_artifact_detection_accepts_only_expected_files_or_directories() { + let root = TempToolDir::new("manager-artifacts"); + let runit = ServiceManagerPaths { + kind: ServiceManagerKind::Runit, + artifact_root: root.path().join("runit"), + live_root: None, + }; + + assert!(!manager_artifact_exists(&runit)); + root.create_dir("runit/unixnotis-daemon"); + assert!(manager_artifact_exists(&runit)); + + let systemd = ServiceManagerPaths { + kind: ServiceManagerKind::Systemd, + artifact_root: root.path().join("systemd"), + live_root: None, + }; + root.write_file("systemd/unixnotis-daemon.service", "[Unit]\n"); + assert!(manager_artifact_exists(&systemd)); +} diff --git a/crates/noticenterctl/src/session_environment/tests/mod.rs b/crates/noticenterctl/src/session_environment/tests/mod.rs new file mode 100644 index 000000000..8b9eafb87 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/mod.rs @@ -0,0 +1,6 @@ +mod backends; +mod manager; +mod process; +mod support; +mod sync; +mod variables; diff --git a/crates/noticenterctl/src/session_environment/tests/process.rs b/crates/noticenterctl/src/session_environment/tests/process.rs new file mode 100644 index 000000000..31a929701 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/process.rs @@ -0,0 +1,37 @@ +use unixnotis_core::CommandSpec; + +use super::super::process::{require_success, run}; +use super::support::TempToolDir; +use crate::system_tools::routing::use_fake_tool_bin; + +#[test] +fn run_executes_a_resolved_direct_service_command() { + let tools = TempToolDir::new("process-success"); + tools.write_executable("service-tool", "#!/bin/sh\nexit 0\n"); + let _tools = use_fake_tool_bin(tools.path()); + + let status = run(&CommandSpec::direct("service-tool", ["literal|argument"])) + .expect("run direct service command"); + + assert!(status.success()); +} + +#[test] +fn require_success_reports_a_failed_service_command() { + let tools = TempToolDir::new("process-failure"); + tools.write_executable("service-tool", "#!/bin/sh\nexit 23\n"); + let _tools = use_fake_tool_bin(tools.path()); + + let error = require_success(&CommandSpec::direct("service-tool", [] as [&str; 0])) + .expect_err("failed service command must be rejected"); + + assert!(error.to_string().contains("status")); +} + +#[test] +fn run_rejects_shell_service_commands() { + let error = run(&CommandSpec::shell("service-tool | parser")) + .expect_err("service commands must remain direct"); + + assert!(error.to_string().contains("resolve trusted")); +} diff --git a/crates/noticenterctl/src/session_environment/tests/support.rs b/crates/noticenterctl/src/session_environment/tests/support.rs new file mode 100644 index 000000000..b6ca84d8c --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/support.rs @@ -0,0 +1,58 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) struct TempToolDir { + path: PathBuf, +} + +impl TempToolDir { + pub(super) fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-session-environment-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create temporary tool directory"); + Self { path } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) fn create_dir(&self, relative: impl AsRef) -> PathBuf { + let path = self.path.join(relative); + fs::create_dir_all(&path).expect("create temporary directory"); + path + } + + pub(super) fn write_file(&self, relative: impl AsRef, contents: &str) -> PathBuf { + let path = self.path.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create temporary file parent"); + } + fs::write(&path, contents).expect("write temporary file"); + path + } + + pub(super) fn write_executable(&self, name: &str, contents: &str) { + let path = self.path.join(name); + fs::write(&path, contents).expect("write temporary tool"); + let mut permissions = fs::metadata(&path) + .expect("read temporary tool metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make temporary tool executable"); + } +} + +impl Drop for TempToolDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/crates/noticenterctl/src/session_environment/tests/sync.rs b/crates/noticenterctl/src/session_environment/tests/sync.rs new file mode 100644 index 000000000..514f25fc8 --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/sync.rs @@ -0,0 +1,10 @@ +use super::super::sync; +use crate::cli::DoctorServiceManagerArg; + +#[test] +fn top_level_sync_rejects_manual_service_management() { + let error = sync(DoctorServiceManagerArg::Manual) + .expect_err("manual service management cannot be synchronized"); + + assert!(!error.to_string().is_empty()); +} diff --git a/crates/noticenterctl/src/session_environment/tests/variables.rs b/crates/noticenterctl/src/session_environment/tests/variables.rs new file mode 100644 index 000000000..346d2698a --- /dev/null +++ b/crates/noticenterctl/src/session_environment/tests/variables.rs @@ -0,0 +1,79 @@ +use std::ffi::OsString; + +use super::super::variables::{ + import_variables, missing_session_variables, validate_persisted_bus_address, + validate_session_environment, +}; +use unixnotis_core::service_manager::ServiceManagerKind; + +#[test] +fn session_environment_reports_empty_required_values_as_missing() { + let missing = missing_session_variables(|name| match name { + "WAYLAND_DISPLAY" => Some(OsString::from("wayland-1")), + "XDG_RUNTIME_DIR" => Some(OsString::new()), + _ => None, + }); + + assert_eq!(missing, vec!["XDG_RUNTIME_DIR"]); +} + +#[test] +fn session_environment_reports_every_absent_required_value() { + let missing = missing_session_variables(|_| None); + + assert_eq!(missing, vec!["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]); +} + +#[test] +fn complete_session_environment_passes_validation() { + let missing = missing_session_variables(|_| Some(OsString::from("present"))); + + assert!(missing.is_empty()); + validate_session_environment(|_| Some(OsString::from("present"))) + .expect("complete session environment"); +} + +#[test] +fn missing_session_environment_returns_an_actionable_error() { + let error = validate_session_environment(|_| None) + .expect_err("missing session values must be rejected"); + + assert!(error.to_string().contains("WAYLAND_DISPLAY")); + assert!(error.to_string().contains("XDG_RUNTIME_DIR")); +} + +#[test] +fn systemd_repair_environment_omits_shell_bus_and_path_values() { + let variables = import_variables(ServiceManagerKind::Systemd); + + assert!(!variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); +} + +#[test] +fn systemd_does_not_validate_or_persist_the_calling_shell_bus() { + validate_persisted_bus_address( + ServiceManagerKind::Systemd, + Some(OsString::from("unix:path=/tmp/transient")), + ) + .expect("systemd should ignore the shell bus address"); +} + +#[test] +fn non_systemd_managers_reject_a_transient_shell_bus_address() { + for manager in [ + ServiceManagerKind::Dinit, + ServiceManagerKind::Runit, + ServiceManagerKind::S6, + ] { + let error = validate_persisted_bus_address( + manager, + Some(OsString::from("unix:path=/tmp/transient")), + ) + .expect_err("non-systemd managers must reject a transient bus"); + + assert!(error + .to_string() + .contains("nonstandard session bus address")); + } +} diff --git a/crates/noticenterctl/src/session_environment/variables.rs b/crates/noticenterctl/src/session_environment/variables.rs new file mode 100644 index 000000000..881b4092e --- /dev/null +++ b/crates/noticenterctl/src/session_environment/variables.rs @@ -0,0 +1,53 @@ +//! Session variables shared by service-manager backends + +use std::ffi::OsString; + +use anyhow::{bail, Result}; +use unixnotis_core::service_manager::{ + validate_session_bus_address, variables_for_backend, ServiceManagerKind, +}; + +pub(super) const fn import_variables(kind: ServiceManagerKind) -> &'static [&'static str] { + variables_for_backend(kind) +} + +pub(super) fn validate_persisted_bus_address( + kind: ServiceManagerKind, + address: Option, +) -> Result<()> { + if !import_variables(kind).contains(&"DBUS_SESSION_BUS_ADDRESS") { + return Ok(()); + } + let Some(address) = address else { + return Ok(()); + }; + let address = address + .to_str() + .ok_or_else(|| anyhow::anyhow!("session bus address is not valid UTF-8"))?; + // Repair commands use the same stable-bus rule as fresh installations + validate_session_bus_address(address, rustix::process::getuid().as_raw()).map_err(Into::into) +} + +pub(super) fn validate_session_environment( + get_var: impl FnMut(&str) -> Option, +) -> Result<()> { + let missing = missing_session_variables(get_var); + // Both values identify the compositor session and its private runtime root + if !missing.is_empty() { + bail!( + "missing session variables: {}; run from the compositor session", + missing.join(", ") + ); + } + Ok(()) +} + +pub(super) fn missing_session_variables( + mut get_var: impl FnMut(&str) -> Option, +) -> Vec<&'static str> { + // Empty variables are equivalent to absent variables for process launches + ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"] + .into_iter() + .filter(|name| get_var(name).is_none_or(|value| value.is_empty())) + .collect() +} diff --git a/crates/noticenterctl/src/system_tools/command.rs b/crates/noticenterctl/src/system_tools/command.rs index d5a66c79c..e6fae9a08 100644 --- a/crates/noticenterctl/src/system_tools/command.rs +++ b/crates/noticenterctl/src/system_tools/command.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::process::Command; +use unixnotis_core::CommandSpec; pub fn command(program: &str) -> std::io::Result { // Resolve before construction so inherited PATH never selects the executable @@ -15,6 +16,48 @@ pub fn command(program: &str) -> std::io::Result { Ok(Command::new(path)) } +pub fn command_from_spec(spec: &CommandSpec) -> std::io::Result { + let (program, args, env) = direct_parts(spec)?; + let mut command = command(program)?; + command.args(args).envs(env); + Ok(command) +} + +pub fn tokio_command_from_spec(spec: &CommandSpec) -> std::io::Result { + let (program, args, env) = direct_parts(spec)?; + let path = trusted_program_path(program).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{program} not found in trusted system tool directories"), + ) + })?; + let mut command = tokio::process::Command::new(path); + command.args(args).envs(env); + Ok(command) +} + +fn direct_parts( + spec: &CommandSpec, +) -> std::io::Result<( + &str, + &[std::ffi::OsString], + &std::collections::BTreeMap, +)> { + let CommandSpec::Direct { program, args, env } = spec else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "trusted system tool commands must use direct mode", + )); + }; + let program = program.to_str().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "trusted system tool program is not UTF-8", + ) + })?; + Ok((program, args, env)) +} + pub fn trusted_program_path(program: &str) -> Option { // Routing differs only in tests, while validation stays in the shared lookup layer super::routing::trusted_program_path(program) diff --git a/crates/noticenterctl/src/system_tools/lookup.rs b/crates/noticenterctl/src/system_tools/lookup.rs index 7018664ac..a0ee8f722 100644 --- a/crates/noticenterctl/src/system_tools/lookup.rs +++ b/crates/noticenterctl/src/system_tools/lookup.rs @@ -3,10 +3,6 @@ use std::path::PathBuf; pub(super) fn trusted_program_path(program: &str) -> Option { - // A plain program name prevents callers from smuggling an alternate directory - if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { - return None; - } - // Core owns the fixed directory policy shared by every UnixNotis executable + // Core validates plain names and owns the fixed directory policy used by every binary unixnotis_core::util::trusted_system_program_path(program) } diff --git a/crates/noticenterctl/src/system_tools/mod.rs b/crates/noticenterctl/src/system_tools/mod.rs index 59c59eb0f..8e7e6f4a7 100644 --- a/crates/noticenterctl/src/system_tools/mod.rs +++ b/crates/noticenterctl/src/system_tools/mod.rs @@ -14,7 +14,7 @@ mod routing; #[path = "tests/routing.rs"] pub mod routing; -pub use command::{command, trusted_program_path}; +pub use command::{command, command_from_spec, tokio_command_from_spec, trusted_program_path}; #[cfg(test)] mod tests; diff --git a/crates/noticenterctl/src/system_tools/tests/command.rs b/crates/noticenterctl/src/system_tools/tests/command.rs index 3617d7707..f191a9446 100644 --- a/crates/noticenterctl/src/system_tools/tests/command.rs +++ b/crates/noticenterctl/src/system_tools/tests/command.rs @@ -2,7 +2,8 @@ use std::fs; use std::os::unix::fs::PermissionsExt; use std::time::{SystemTime, UNIX_EPOCH}; -use super::super::{command, routing::use_fake_tool_bin}; +use super::super::{command, command_from_spec, routing::use_fake_tool_bin}; +use unixnotis_core::CommandSpec; struct TempDirGuard { path: std::path::PathBuf, @@ -63,3 +64,50 @@ fn trusted_command_rejects_program_names_with_path_separators() { assert_eq!(error.kind(), std::io::ErrorKind::NotFound); } + +#[test] +fn production_lookup_returns_the_core_resolved_trusted_program() { + let expected = unixnotis_core::util::trusted_system_program_path("sh") + .expect("find sh in a trusted system directory"); + + assert_eq!( + super::super::lookup::trusted_program_path("sh"), + Some(expected) + ); + let path_like_name = format!("bin{}sh", std::path::MAIN_SEPARATOR); + assert_eq!( + super::super::lookup::trusted_program_path(&path_like_name), + None + ); +} + +#[test] +fn typed_command_preserves_literal_arguments_and_environment() { + let root = TempDirGuard::new("typed"); + root.write_executable("printf", "#!/bin/sh\nexit 0\n"); + let _tools = use_fake_tool_bin(&root.path); + let spec = CommandSpec::direct("printf", ["battery|charging"]) + .with_env("WIDGET_MODE", "literal value"); + + let command = command_from_spec(&spec).expect("typed trusted command"); + + assert_eq!( + command.get_args().collect::>(), + vec![std::ffi::OsStr::new("battery|charging")] + ); + assert_eq!( + command + .get_envs() + .find(|(name, _)| *name == "WIDGET_MODE") + .and_then(|(_, value)| value), + Some(std::ffi::OsStr::new("literal value")) + ); +} + +#[test] +fn typed_trusted_command_rejects_shell_mode() { + let error = command_from_spec(&CommandSpec::shell("printf unsafe")) + .expect_err("trusted tools must not invoke a shell"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); +} diff --git a/crates/noticenterctl/src/system_tools/tests/routing.rs b/crates/noticenterctl/src/system_tools/tests/routing.rs index e7500231e..c6c7f0e34 100644 --- a/crates/noticenterctl/src/system_tools/tests/routing.rs +++ b/crates/noticenterctl/src/system_tools/tests/routing.rs @@ -31,17 +31,11 @@ fn executable_mode(_metadata: &std::fs::Metadata) -> bool { } fn fake_tool_bin_is_set() -> bool { - fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .is_some() + lock_fake_tool_bin().is_some() } fn fake_program_path(program: &str) -> Option { - let configured_bin = fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .clone()?; + let configured_bin = lock_fake_tool_bin().clone()?; let candidate = configured_bin.join(program); executable_file(&candidate).then_some(candidate) } @@ -53,15 +47,16 @@ pub struct FakeToolBinGuard { impl Drop for FakeToolBinGuard { fn drop(&mut self) { - *fake_tool_bin().lock().expect("fake tool bin lock") = self.previous.take(); + *lock_fake_tool_bin() = self.previous.take(); } } pub fn use_fake_tool_bin(path: &Path) -> FakeToolBinGuard { + // Recovering a poisoned fixture lock preserves isolation after another test unwinds let lock = fake_tool_bin_test_lock() .lock() - .expect("fake tool bin test lock"); - let mut fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut fake_bin = lock_fake_tool_bin(); let previous = fake_bin.replace(path.to_path_buf()); FakeToolBinGuard { _lock: lock, @@ -74,6 +69,12 @@ fn fake_tool_bin() -> &'static Mutex> { FAKE_TOOL_BIN.get_or_init(|| Mutex::new(None)) } +fn lock_fake_tool_bin() -> MutexGuard<'static, Option> { + fake_tool_bin() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn fake_tool_bin_test_lock() -> &'static Mutex<()> { static FAKE_TOOL_BIN_TEST_LOCK: OnceLock> = OnceLock::new(); FAKE_TOOL_BIN_TEST_LOCK.get_or_init(|| Mutex::new(())) diff --git a/crates/noticenterctl/src/tests/support.rs b/crates/noticenterctl/src/tests/support.rs index 10c23e4e2..008750cd7 100644 --- a/crates/noticenterctl/src/tests/support.rs +++ b/crates/noticenterctl/src/tests/support.rs @@ -1,8 +1,117 @@ //! Shared process-environment guards for CLI tests +use std::borrow::Cow; use std::ffi::{OsStr, OsString}; +use std::path::Path; use std::sync::{Mutex, MutexGuard, OnceLock}; +use unixnotis_core::{parse_legacy_command, CURRENT_CONFIG_VERSION}; + +pub fn current_config_text(contents: &str) -> String { + let Ok(mut document) = toml::from_str::(contents) else { + // Invalid fixtures stay invalid while still crossing the version gate first + return format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}"); + }; + let Some(root) = document.as_table_mut() else { + return format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}"); + }; + root.insert( + "config_version".to_string(), + toml::Value::Integer(i64::from(CURRENT_CONFIG_VERSION)), + ); + normalize_fixture_commands(root); + toml::to_string(&document).expect("serialize current-schema config fixture") +} + +pub fn current_config_bytes(contents: &[u8]) -> Vec { + if let Ok(contents) = std::str::from_utf8(contents) { + current_config_text(contents).into_bytes() + } else { + // Invalid UTF-8 remains visible to the parser after the schema prefix + let mut config = format!("config_version = {CURRENT_CONFIG_VERSION}\n").into_bytes(); + config.extend_from_slice(contents); + config + } +} + +pub fn fixture_file_contents<'a>(relative_path: &str, contents: &'a str) -> Cow<'a, str> { + let is_config = Path::new(relative_path).file_name() == Some(OsStr::new("config.toml")); + let has_version = contents + .lines() + .any(|line| line.trim_start().starts_with("config_version")); + if is_config && !has_version { + // Functional config fixtures always exercise the schema shipped by this test binary + Cow::Owned(current_config_text(contents)) + } else { + Cow::Borrowed(contents) + } +} + +fn normalize_fixture_commands(root: &mut toml::Table) { + let Some(widgets) = root.get_mut("widgets").and_then(toml::Value::as_table_mut) else { + return; + }; + + // Slider command fields live in fixed widget tables + for slider_name in ["volume", "brightness"] { + let Some(slider) = widgets + .get_mut(slider_name) + .and_then(toml::Value::as_table_mut) + else { + continue; + }; + normalize_table_commands(slider, &["get_cmd", "set_cmd", "toggle_cmd", "watch_cmd"]); + } + + normalize_widget_array_commands( + widgets, + "toggles", + &["state_cmd", "toggle_cmd", "on_cmd", "off_cmd", "watch_cmd"], + false, + ); + normalize_widget_array_commands(widgets, "stats", &["cmd"], true); + normalize_widget_array_commands(widgets, "cards", &["cmd"], true); +} + +fn normalize_widget_array_commands( + widgets: &mut toml::Table, + collection_name: &str, + fields: &[&str], + has_plugin: bool, +) { + let Some(items) = widgets + .get_mut(collection_name) + .and_then(toml::Value::as_array_mut) + else { + return; + }; + for item in items { + let Some(table) = item.as_table_mut() else { + continue; + }; + normalize_table_commands(table, fields); + if has_plugin { + let Some(plugin) = table.get_mut("plugin").and_then(toml::Value::as_table_mut) else { + continue; + }; + normalize_table_commands(plugin, &["command"]); + } + } +} + +fn normalize_table_commands(table: &mut toml::Table, fields: &[&str]) { + for field in fields { + let Some(command) = table.get(*field).and_then(toml::Value::as_str) else { + continue; + }; + let Ok(spec) = parse_legacy_command(command) else { + continue; + }; + let value = toml::Value::try_from(spec).expect("serialize command fixture"); + table.insert((*field).to_string(), value); + } +} + pub fn test_env_lock() -> MutexGuard<'static, ()> { // Every test that mutates process environment must share this one lock static LOCK: OnceLock> = OnceLock::new(); diff --git a/crates/noticenterctl/src/theme/export.rs b/crates/noticenterctl/src/theme/export.rs new file mode 100644 index 000000000..9ad76c169 --- /dev/null +++ b/crates/noticenterctl/src/theme/export.rs @@ -0,0 +1,183 @@ +//! Safe export of editable bundled theme files + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, remove_directory_tree, rename_directory_no_replace, + write_file_if_missing, CreateDirectoryOutcome, RenameDirectoryOutcome, +}; +use unixnotis_core::{ + Config, ThemeManifest, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, + DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, THEME_API_VERSION, +}; + +const DEFAULT_EXPORT_DIRECTORY: &str = "stock-theme-v2"; +const MAX_STAGING_ATTEMPTS: u8 = 16; +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(super) fn run(output: Option) -> Result<()> { + let destination = match output { + Some(output) => output, + None => default_export_directory()?, + }; + export_stock_theme(&destination)?; + crate::output::write_stdout(&format!( + "Exported editable bundled theme to {}\nRuntime CSS loading remains automatic.\n", + destination.display() + )) +} + +fn default_export_directory() -> Result { + let config_path = Config::active_config_path().context("resolve active config path")?; + default_export_directory_for_config(&config_path) +} + +pub(super) fn default_export_directory_for_config(config_path: &Path) -> Result { + let parent = config_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| anyhow!("active config path has no parent directory"))?; + Ok(parent.join(DEFAULT_EXPORT_DIRECTORY)) +} + +pub(super) fn export_stock_theme(destination: &Path) -> Result<()> { + let manifest = toml::to_string_pretty(&ThemeManifest { + api_version: THEME_API_VERSION, + name: "UnixNotis stock export".to_string(), + }) + .context("serialize stock theme manifest")?; + export_stock_theme_files( + destination, + &[ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", DEFAULT_POPUP_CSS), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ("theme.toml", manifest.as_str()), + ], + ) +} + +pub(super) fn export_stock_theme_files(destination: &Path, files: &[(&str, &str)]) -> Result<()> { + match std::fs::symlink_metadata(destination) { + Ok(_) => { + return Err(anyhow!( + "stock theme export directory already exists: {}", + destination.display() + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "inspect stock theme export destination {}", + destination.display() + ) + }); + } + } + + let staging = reserve_staging_directory(destination)?; + let write_result = write_staged_theme_files(&staging, files); + if let Err(error) = write_result { + return Err(clean_up_failed_staging(&staging, error)); + } + + match rename_directory_no_replace(&staging, destination).with_context(|| { + format!( + "publish complete stock theme export {}", + destination.display() + ) + }) { + Ok(RenameDirectoryOutcome::Renamed) => Ok(()), + Ok(RenameDirectoryOutcome::DestinationExists) => { + let error = anyhow!( + "stock theme export directory already exists: {}", + destination.display() + ); + Err(clean_up_failed_staging(&staging, error)) + } + Ok(RenameDirectoryOutcome::SourceMissing) => Err(anyhow!( + "stock theme staging directory disappeared before publication: {}", + staging.display() + )), + Err(error) => Err(clean_up_failed_staging(&staging, error)), + } +} + +fn write_staged_theme_files(staging: &Path, files: &[(&str, &str)]) -> Result<()> { + for (name, contents) in files { + let path = staging.join(name); + let created = write_file_if_missing(&path, contents.as_bytes(), 0o600) + .with_context(|| format!("write exported stock theme file {name}"))?; + if !created { + return Err(anyhow!( + "stock theme export was interrupted by an existing file: {}", + path.display() + )); + } + } + Ok(()) +} + +fn reserve_staging_directory(destination: &Path) -> Result { + let parent = export_parent(destination); + create_directory_all(parent, 0o700) + .with_context(|| format!("create stock theme export parent {}", parent.display()))?; + let file_name = destination + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("stock theme export path needs a directory name"))?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is earlier than the Unix epoch")? + .as_nanos(); + + for attempt in 0..MAX_STAGING_ATTEMPTS { + let serial = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + let mut staging_name = OsString::from("."); + staging_name.push(file_name); + staging_name.push(format!( + ".{}.{}.{serial}.{attempt}.staging", + std::process::id(), + nanos + )); + let staging = parent.join(staging_name); + match create_directory_all(&staging, 0o700).with_context(|| { + format!( + "create private stock theme staging area {}", + staging.display() + ) + })? { + CreateDirectoryOutcome::TargetCreated => return Ok(staging), + CreateDirectoryOutcome::TargetAlreadyExisted => {} + } + } + + Err(anyhow!( + "unable to reserve private staging beside {}", + destination.display() + )) +} + +pub(super) fn export_parent(destination: &Path) -> &Path { + destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn clean_up_failed_staging(staging: &Path, error: anyhow::Error) -> anyhow::Error { + match remove_directory_tree(staging) { + Ok(_) => error, + Err(cleanup_error) => error.context(format!( + "also failed to remove stock theme staging area {}: {cleanup_error}", + staging.display() + )), + } +} diff --git a/crates/noticenterctl/src/theme/mod.rs b/crates/noticenterctl/src/theme/mod.rs new file mode 100644 index 000000000..0b0046c99 --- /dev/null +++ b/crates/noticenterctl/src/theme/mod.rs @@ -0,0 +1,16 @@ +//! Local theme management commands + +mod export; + +use anyhow::Result; + +use crate::cli::ThemeCommand; + +pub fn run(command: ThemeCommand) -> Result<()> { + match command { + ThemeCommand::ExportStock { output } => export::run(output), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/noticenterctl/src/theme/tests/export.rs b/crates/noticenterctl/src/theme/tests/export.rs new file mode 100644 index 000000000..dc33f5c3a --- /dev/null +++ b/crates/noticenterctl/src/theme/tests/export.rs @@ -0,0 +1,158 @@ +//! Stock theme export behavior + +use std::fs; +use std::os::unix::fs::symlink; + +use unixnotis_core::{ThemeManifest, DEFAULT_BASE_CSS, THEME_API_VERSION}; + +use super::super::export::{ + default_export_directory_for_config, export_parent, export_stock_theme, + export_stock_theme_files, +}; + +fn test_root(name: &str) -> std::path::PathBuf { + let serial = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("unixnotis-{name}-{}-{serial}", std::process::id())) +} + +#[test] +fn stock_export_creates_complete_versioned_editable_copies() { + let root = test_root("theme-export"); + fs::create_dir_all(&root).expect("test root should be created"); + let destination = root.join("stock"); + + export_stock_theme(&destination).expect("stock theme should be exported"); + + assert_eq!( + fs::read_to_string(destination.join("base.css")).expect("base CSS should be readable"), + DEFAULT_BASE_CSS + ); + for name in [ + "panel.css", + "popup.css", + "widgets.css", + "media.css", + "theme.toml", + ] { + assert!( + destination.join(name).is_file(), + "stock export should include {name}" + ); + } + let manifest = fs::read_to_string(destination.join("theme.toml")) + .expect("theme manifest should be readable"); + let manifest = + toml::from_str::(&manifest).expect("theme manifest should be valid"); + assert_eq!(manifest.api_version, THEME_API_VERSION); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_refuses_an_existing_destination_without_changing_it() { + let root = test_root("theme-export-collision"); + let destination = root.join("stock"); + fs::create_dir_all(&destination).expect("existing destination should be created"); + let sentinel = destination.join("personal.css"); + fs::write(&sentinel, "/* keep */").expect("sentinel should be written"); + + export_stock_theme(&destination).expect_err("existing export directory must be rejected"); + + assert_eq!( + fs::read_to_string(&sentinel).expect("sentinel should remain readable"), + "/* keep */" + ); + assert!( + !destination.join("base.css").exists(), + "rejected export must not create theme files" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_rejects_a_symlinked_destination_parent() { + let root = test_root("theme-export-symlink"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("outside directory should be created"); + symlink(&outside, &linked).expect("linked parent should be created"); + + export_stock_theme(&linked.join("stock")) + .expect_err("stock export must not traverse a symbolic link"); + + assert!( + !outside.join("stock").exists(), + "symlink rejection must not create files outside the selected tree" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn failed_stock_export_removes_staging_without_publishing_a_partial_directory() { + let root = test_root("theme-export-partial-failure"); + fs::create_dir_all(&root).expect("test root should be created"); + let destination = root.join("stock"); + + export_stock_theme_files( + &destination, + &[("base.css", "first"), ("base.css", "collision")], + ) + .expect_err("a staged file collision should abort publication"); + + assert!( + !destination.exists(), + "a failed export must not publish an incomplete destination" + ); + assert_eq!( + fs::read_dir(&root) + .expect("test root should remain readable") + .count(), + 0, + "failed export staging should be removed" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn stock_export_stages_beside_the_selected_destination() { + let destination = std::path::Path::new("example-parent/stock"); + + assert_eq!( + export_parent(destination), + std::path::Path::new("example-parent") + ); + assert_eq!( + export_parent(std::path::Path::new("stock")), + std::path::Path::new(".") + ); +} + +#[test] +fn stock_export_reports_destination_inspection_failure_before_staging() { + let root = test_root("theme-export-invalid-parent"); + fs::create_dir_all(&root).expect("test root should be created"); + let parent_file = root.join("not-a-directory"); + fs::write(&parent_file, "content").expect("parent fixture should be written"); + let destination = parent_file.join("stock"); + + let error = export_stock_theme(&destination) + .expect_err("an unreadable destination path should fail before staging"); + + assert!( + format!("{error:#}").contains("inspect stock theme export destination"), + "destination inspection errors should retain their precise context" + ); + fs::remove_dir_all(root).expect("test root should be removable"); +} + +#[test] +fn default_stock_export_directory_is_sibling_of_active_config() { + let config = std::path::Path::new("profile/unixnotis/config.toml"); + + assert_eq!( + default_export_directory_for_config(config).expect("default export path should resolve"), + std::path::Path::new("profile/unixnotis/stock-theme-v2") + ); +} diff --git a/crates/noticenterctl/src/theme/tests/mod.rs b/crates/noticenterctl/src/theme/tests/mod.rs new file mode 100644 index 000000000..db02c8b1b --- /dev/null +++ b/crates/noticenterctl/src/theme/tests/mod.rs @@ -0,0 +1,3 @@ +//! Theme command tests + +mod export; diff --git a/crates/noticenterctl/tests/doctor.rs b/crates/noticenterctl/tests/doctor.rs deleted file mode 100644 index 38ef18153..000000000 --- a/crates/noticenterctl/tests/doctor.rs +++ /dev/null @@ -1,40 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_doctor_runs_all_checks_and_emits_versioned_json() -> TestResult { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-doctor-binary-{}-{stamp}", - std::process::id() - )); - std::fs::create_dir_all(&root)?; - let config = root.join("config.toml"); - std::fs::write(&config, "config_version = 2\n")?; - let missing_bus = root.join("missing-session-bus.sock"); - - // A missing private bus keeps the integration deterministic without touching the desktop - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["doctor", "--json", "--service-manager", "manual"]) - .env("UNIXNOTIS_CONFIG_PATH", &config) - .env( - "DBUS_SESSION_BUS_ADDRESS", - format!("unix:path={}", missing_bus.display()), - ) - .output()?; - - assert!(!output.status.success()); - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - assert_eq!(report["schema_version"], 1); - assert!(report["checks"].as_array().is_some_and(|checks| checks - .iter() - .any(|check| { check["id"] == "dbus.session" && check["severity"] == "error" }))); - std::fs::remove_dir_all(root)?; - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/errors.rs b/crates/noticenterctl/tests/errors.rs deleted file mode 100644 index b2a442773..000000000 --- a/crates/noticenterctl/tests/errors.rs +++ /dev/null @@ -1,33 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_rejects_unknown_command_before_dbus_setup() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("definitely-not-a-command") - .output()?; - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("unrecognized subcommand")); - assert!(stderr.contains("definitely-not-a-command")); - Ok(()) - } - - #[test] - fn binary_rejects_invalid_dnd_state_before_dbus_setup() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["dnd", "maybe"]) - .output()?; - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr)?; - assert!(stderr.contains("invalid value")); - assert!(stderr.contains("maybe")); - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/help.rs b/crates/noticenterctl/tests/help.rs deleted file mode 100644 index e88ed101b..000000000 --- a/crates/noticenterctl/tests/help.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn binary_help_prints_cli_usage() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .arg("--help") - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage:")); - assert!(stdout.contains("css-check")); - assert!(stdout.contains("doctor")); - assert!(stdout.contains("preset")); - Ok(()) - } - - #[test] - fn binary_doctor_help_lists_output_and_manager_controls() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["doctor", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("--json")); - assert!(stdout.contains("--verbose")); - assert!(stdout.contains("--service-manager")); - assert!(stdout.contains("manual")); - Ok(()) - } - - #[test] - fn binary_open_panel_help_lists_optional_debug_flag() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["open-panel", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("--debug")); - assert!(stdout.contains("critical")); - assert!(stdout.contains("verbose")); - Ok(()) - } - - #[test] - fn binary_preset_help_lists_local_bundle_commands() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - .args(["preset", "--help"]) - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("export")); - assert!(stdout.contains("import")); - assert!(stdout.contains("inspect")); - Ok(()) - } -} diff --git a/crates/noticenterctl/tests/preset_inspect.rs b/crates/noticenterctl/tests/preset_inspect.rs deleted file mode 100644 index 62cf0fb20..000000000 --- a/crates/noticenterctl/tests/preset_inspect.rs +++ /dev/null @@ -1,93 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::fs; - use std::path::PathBuf; - use std::process::Command; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - type TestResult = Result<(), Box>; - - static TEST_TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - struct TempDirGuard { - path: PathBuf, - } - - impl TempDirGuard { - fn new(name: &str) -> Result> { - // Unique roots keep binary tests from sharing config state through XDG_CONFIG_HOME - let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let serial = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-cli-preset-inspect-{name}-{stamp}-{serial}" - )); - fs::create_dir_all(&path)?; - Ok(Self { path }) - } - - fn write(&self, relative_path: &str, contents: &str) -> Result<(), Box> { - // The CLI reads the default config root, so tests place files under an isolated XDG root - let path = self.path.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents)?; - Ok(()) - } - } - - impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } - } - - #[test] - fn binary_preset_inspect_prints_bundle_report() -> TestResult { - let root = TempDirGuard::new("report")?; - let config_root = root.path.join("xdg"); - let unixnotis_root = config_root.join("unixnotis"); - root.write( - "xdg/unixnotis/config.toml", - "[theme]\nbase_css = \"base.css\"\n", - )?; - root.write("xdg/unixnotis/base.css", ".panel { color: red; }\n")?; - let bundle_path = root.path.join("demo.unixnotis"); - - let export_output = noticenterctl() - .env("XDG_CONFIG_HOME", &config_root) - .args(["preset", "export", "--force"]) - .arg(&bundle_path) - .output()?; - assert!( - export_output.status.success(), - "export failed: {}", - String::from_utf8_lossy(&export_output.stderr) - ); - - let inspect_output = noticenterctl() - .args(["preset", "inspect"]) - .arg(&bundle_path) - .output()?; - - assert!( - inspect_output.status.success(), - "inspect failed: {}", - String::from_utf8_lossy(&inspect_output.stderr) - ); - let stdout = String::from_utf8(inspect_output.stdout)?; - assert!(stdout.contains("preset: demo")); - assert!(stdout.contains("files: 2")); - assert!(stdout.contains("file list:")); - assert!(stdout.contains("config.toml")); - assert!(unixnotis_root.exists()); - Ok(()) - } - - fn noticenterctl() -> Command { - // Cargo provides the freshly-built binary path to integration tests - Command::new(env!("CARGO_BIN_EXE_noticenterctl")) - } -} diff --git a/crates/unixnotis-center/Cargo.toml b/crates/unixnotis-center/Cargo.toml index 454dac1b6..cf47662ca 100644 --- a/crates/unixnotis-center/Cargo.toml +++ b/crates/unixnotis-center/Cargo.toml @@ -4,10 +4,15 @@ version.workspace = true edition.workspace = true license.workspace = true +[[bin]] +name = "unixnotis-svg-renderer" +path = "src/bin/unixnotis-svg-renderer.rs" + [dependencies] anyhow.workspace = true async-channel.workspace = true blake3.workspace = true +chrono.workspace = true clap.workspace = true crossbeam-channel.workspace = true fast_image_resize.workspace = true @@ -34,3 +39,4 @@ unixnotis-ui = { path = "../unixnotis-ui" } [dev-dependencies] proptest.workspace = true toml.workspace = true +tempfile = "3" diff --git a/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs b/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs new file mode 100644 index 000000000..db3703c6e --- /dev/null +++ b/crates/unixnotis-center/src/bin/unixnotis-svg-renderer.rs @@ -0,0 +1,173 @@ +#![expect( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + reason = "checked: target_size ≤ MAX_DIMENSION and output dimensions ≤ MAX_PIXELS" +)] + +use std::io::{self, Read, Write}; + +use resvg::tiny_skia::Pixmap; +use resvg::usvg::Tree; + +const MAX_SVG_BYTES: usize = 1_024_000; +const MAX_DIMENSION: u32 = 2_048; +const MAX_PIXELS: u64 = 2_048 * 2_048; + +fn main() -> Result<(), Box> { + apply_resource_limits()?; + + let mut stdin = io::stdin(); + let mut stdout = io::stdout(); + + // First u32 is the target pixel dimension for scaling + let mut target_bytes = [0u8; 4]; + stdin.read_exact(&mut target_bytes)?; + let target_size = u32::from_le_bytes(target_bytes); + + // Read the rest of stdin as the SVG document bytes + let mut svg_data = Vec::new(); + stdin + .take( + u64::try_from(MAX_SVG_BYTES) + .unwrap_or(u64::MAX) + .saturating_add(1), + ) + .read_to_end(&mut svg_data)?; + + if svg_data.is_empty() + || svg_data.len() > MAX_SVG_BYTES + || target_size == 0 + || target_size > MAX_DIMENSION + { + eprintln!("invalid input"); + std::process::exit(1); + } + + let secondary_image = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let data_image = std::sync::Arc::clone(&secondary_image); + let path_image = std::sync::Arc::clone(&secondary_image); + let options = resvg::usvg::Options { + image_href_resolver: resvg::usvg::ImageHrefResolver { + resolve_data: Box::new(move |_mime, _data, _options| { + data_image.store(true, std::sync::atomic::Ordering::Relaxed); + None + }), + resolve_string: Box::new(move |_href, _options| { + path_image.store(true, std::sync::atomic::Ordering::Relaxed); + None + }), + }, + ..resvg::usvg::Options::default() + }; + let tree = Tree::from_data(&svg_data, &options)?; + + // Reject SVGs that attempt to load secondary images + if secondary_image.load(std::sync::atomic::Ordering::Relaxed) { + eprintln!("SVG icons must not contain secondary images"); + std::process::exit(1); + } + + let source_width = tree.size().width(); + let source_height = tree.size().height(); + + if !source_width.is_finite() + || !source_height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + { + eprintln!("invalid SVG dimensions"); + std::process::exit(1); + } + + let scale = (target_size as f32 / source_width).min(target_size as f32 / source_height); + if !scale.is_finite() || scale <= 0.0 { + eprintln!("invalid scale factor"); + std::process::exit(1); + } + + let Some(scaled_width) = bounded_dimension(source_width * scale) else { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + }; + let Some(scaled_height) = bounded_dimension(source_height * scale) else { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + }; + + let pixels = u64::from(scaled_width).checked_mul(u64::from(scaled_height)); + if pixels.is_none_or(|count| count > MAX_PIXELS) { + eprintln!("scaled dimensions exceed limits"); + std::process::exit(1); + } + + let mut pixmap = Pixmap::new(scaled_width, scaled_height).ok_or("failed to allocate pixmap")?; + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + + let rgba = pixmap.take(); + + stdout.write_all(&scaled_width.to_le_bytes())?; + stdout.write_all(&scaled_height.to_le_bytes())?; + stdout.write_all(&rgba)?; + + Ok(()) +} + +fn bounded_dimension(value: f32) -> Option { + if !value.is_finite() || value <= 0.0 || value > MAX_DIMENSION as f32 { + return None; + } + Some(value.round().max(1.0) as u32) +} + +#[cfg(target_os = "linux")] +fn apply_resource_limits() -> Result<(), Box> { + use rustix::process::{setrlimit, Resource, Rlimit}; + + // Clear all environment variables using safe API + for (key, _) in std::env::vars() { + std::env::remove_var(key); + } + std::env::set_var("PATH", "/usr/bin:/bin"); + + // 1 second CPU limit prevents CPU-bound SVG bombs + setrlimit( + Resource::Cpu, + Rlimit { + current: Some(1), + maximum: Some(1), + }, + )?; + + // 64 MiB address space limit prevents memory exhaustion + setrlimit( + Resource::As, + Rlimit { + current: Some(64 * 1024 * 1024), + maximum: Some(64 * 1024 * 1024), + }, + )?; + + // 32 MiB file write limit prevents disk-write bombs + setrlimit( + Resource::Fsize, + Rlimit { + current: Some(32 * 1024 * 1024), + maximum: Some(32 * 1024 * 1024), + }, + )?; + + // Keep relative paths deterministic; this is not a filesystem sandbox + std::env::set_current_dir("/")?; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn apply_resource_limits() -> Result<(), Box> { + Ok(()) +} diff --git a/crates/unixnotis-center/src/control/commands.rs b/crates/unixnotis-center/src/control/commands.rs index 4d6f6f3b0..727fb677d 100644 --- a/crates/unixnotis-center/src/control/commands.rs +++ b/crates/unixnotis-center/src/control/commands.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use tokio::sync::mpsc; use tracing::warn; -use unixnotis_core::{ControlProxy, PanelDebugLevel}; +use unixnotis_core::{timed_dbus_call, ControlProxy, PanelDebugLevel}; use zbus::Result as ZbusResult; use super::model::UiCommand; @@ -20,14 +20,46 @@ pub async fn handle_command( ) -> ZbusResult<()> { match command { // Per-row actions still map straight to the daemon methods - UiCommand::Dismiss(id) => proxy.dismiss(id).await, - UiCommand::InvokeAction { id, action_key } => proxy.invoke_action(id, &action_key).await, + UiCommand::Dismiss(notification) => { + timed_dbus_call(proxy.dismiss_generation(notification.id, notification.generation)) + .await + } + UiCommand::InvokeAction { + notification, + action_key, + confirmed, + } => { + timed_dbus_call(proxy.invoke_action_generation( + notification.id, + notification.generation, + &action_key, + confirmed, + )) + .await + } + UiCommand::Reply { + id, + generation, + text, + outcome, + } => { + let result = timed_dbus_call(proxy.reply_notification(id, generation, &text)).await; + let reply_result = match &result { + Ok(()) => Ok(()), + Err(err) => Err(err.to_string()), + }; + let _ = outcome.send(reply_result); + result + } // Daemon invalidation now drives refresh for every client, not just the caller // Keeping the caller path thin avoids reintroducing one-client-only fixes later - UiCommand::ClearAll => proxy.clear_all().await, + UiCommand::ClearAll => timed_dbus_call(proxy.clear_all()).await, // State and visibility commands remain safe to replay after reconnect - UiCommand::SetDnd(enabled) => proxy.set_dnd(enabled).await, - UiCommand::ClosePanel => proxy.close_panel().await, + UiCommand::SetDnd(enabled) => timed_dbus_call(proxy.set_dnd(enabled)).await, + UiCommand::SetDndUntil(expires_at) => { + timed_dbus_call(proxy.set_dnd_until(expires_at)).await + } + UiCommand::ClosePanel => timed_dbus_call(proxy.close_panel()).await, } } @@ -51,21 +83,42 @@ pub fn stash_offline_commands( } } -fn enqueue_offline_command(offline: &mut VecDeque, command: UiCommand) -> bool { +pub(super) fn enqueue_offline_command( + offline: &mut VecDeque, + command: UiCommand, +) -> bool { + let command = match command { + UiCommand::Reply { outcome, .. } => { + // Reply text is live-only and must never survive a D-Bus generation change + let _ = outcome.send(Err("notification service is unavailable".to_string())); + return false; + } + command => command, + }; match &command { // Close and clear are one-shot intents, so one buffered copy is enough UiCommand::ClearAll | UiCommand::ClosePanel => { - if offline.iter().any(|queued| queued == &command) { + let duplicate = offline.iter().any(|queued| { + matches!( + (queued, &command), + (UiCommand::ClearAll, UiCommand::ClearAll) + | (UiCommand::ClosePanel, UiCommand::ClosePanel) + ) + }); + if duplicate { // Duplicate one-shot replay adds no user value after reconnect return false; } } // DND should replay only the newest requested state after reconnect - UiCommand::SetDnd(_) => { + UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_) => { // Older states are stale once a newer DND request exists - offline.retain(|queued| !matches!(queued, UiCommand::SetDnd(_))); + offline.retain(|queued| { + !matches!(queued, UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_)) + }); } UiCommand::Dismiss(_) | UiCommand::InvokeAction { .. } => {} + UiCommand::Reply { .. } => unreachable!("reply commands return before queueing"), } if offline.len() >= MAX_OFFLINE_COMMANDS { @@ -98,13 +151,12 @@ pub async fn flush_offline_commands( } pub fn drop_stale_offline_commands(offline: &mut VecDeque) { - // Drop ID-based commands after reconnect to avoid acting on stale IDs - // Commands that do not depend on old notification ids are kept + // Destructive notification commands cannot cross a daemon generation let before = offline.len(); offline.retain(|command| { matches!( command, - UiCommand::ClearAll | UiCommand::SetDnd(_) | UiCommand::ClosePanel + UiCommand::SetDnd(_) | UiCommand::SetDndUntil(_) | UiCommand::ClosePanel ) }); let dropped = before.saturating_sub(offline.len()); diff --git a/crates/unixnotis-center/src/control/events.rs b/crates/unixnotis-center/src/control/events.rs index cb19bd553..0a3649535 100644 --- a/crates/unixnotis-center/src/control/events.rs +++ b/crates/unixnotis-center/src/control/events.rs @@ -1,7 +1,7 @@ //! Projection of notification signals into trusted UI payload events use tracing::warn; -use unixnotis_core::{ControlProxy, NotificationView}; +use unixnotis_core::{timed_dbus_call, ControlProxy, NotificationView}; use super::model::UiEvent; @@ -9,13 +9,13 @@ pub(super) async fn push_active_notification_event( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, id: u32, - show_popup: bool, + generation: u64, is_add: bool, ) { // Trusted UIs fetch current payloads through the authorized control method - match proxy.get_active_notification(id).await { + match timed_dbus_call(proxy.get_active_notification(id)).await { Ok(notifications) => { - if let Some(event) = active_notification_event(notifications, show_popup, is_add) { + if let Some(event) = active_notification_event(notifications, generation, is_add) { let _ = sender.send(event).await; } } @@ -27,15 +27,19 @@ pub(super) async fn push_active_notification_event( fn active_notification_event( mut notifications: Vec, - show_popup: bool, + generation: u64, is_add: bool, ) -> Option { // A close may win the race before this follow-up payload fetch completes let notification = notifications.pop()?; + if notification.generation != generation { + // The fetched payload belongs to a newer commit than the delayed signal + return None; + } if is_add { - Some(UiEvent::NotificationAdded(notification, show_popup)) + Some(UiEvent::NotificationAdded(notification)) } else { - Some(UiEvent::NotificationUpdated(notification, show_popup)) + Some(UiEvent::NotificationUpdated(notification)) } } diff --git a/crates/unixnotis-center/src/control/model.rs b/crates/unixnotis-center/src/control/model.rs index d2607dbb7..3189b7352 100644 --- a/crates/unixnotis-center/src/control/model.rs +++ b/crates/unixnotis-center/src/control/model.rs @@ -1,20 +1,26 @@ //! Shared UI event and command types for the center D-Bus runtime. -use unixnotis_core::{CloseReason, ControlState, Margins, NotificationView, PanelRequest}; +use std::fmt; + +use unixnotis_core::{ + CloseReason, ControlState, Margins, NotificationKey, NotificationView, PanelRequest, +}; use crate::media::MediaInfo; /// Events delivered to the GTK main loop. #[derive(Debug, Clone)] pub enum UiEvent { + // Owner loss clears snapshots that belong to the previous daemon generation + Disconnected, Seed { state: ControlState, active: Vec, history: Vec, }, - NotificationAdded(NotificationView, bool), - NotificationUpdated(NotificationView, bool), - NotificationClosed(u32, CloseReason), + NotificationAdded(NotificationView), + NotificationUpdated(NotificationView), + NotificationClosed(NotificationKey, CloseReason), StateChanged(ControlState), PanelRequested(PanelRequest), GroupToggled(String), @@ -35,15 +41,60 @@ pub enum UiEvent { } /// Commands sent from GTK handlers to the D-Bus runtime. -#[derive(Debug, Clone, PartialEq, Eq)] pub enum UiCommand { - Dismiss(u32), - InvokeAction { id: u32, action_key: String }, + Dismiss(NotificationKey), + InvokeAction { + notification: NotificationKey, + action_key: String, + confirmed: bool, + }, + Reply { + id: u32, + generation: u64, + text: String, + outcome: tokio::sync::oneshot::Sender>, + }, ClearAll, SetDnd(bool), + SetDndUntil(i64), ClosePanel, } +impl fmt::Debug for UiCommand { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Dismiss(notification) => formatter + .debug_tuple("Dismiss") + .field(notification) + .finish(), + Self::InvokeAction { + notification, + action_key, + confirmed, + } => formatter + .debug_struct("InvokeAction") + .field("notification", notification) + .field("action_key", action_key) + .field("confirmed", confirmed) + .finish(), + Self::Reply { id, generation, .. } => formatter + .debug_struct("Reply") + .field("id", id) + .field("generation", generation) + // Typed message content must never enter diagnostic logs + .field("text", &"[redacted]") + .finish_non_exhaustive(), + Self::ClearAll => formatter.write_str("ClearAll"), + Self::SetDnd(enabled) => formatter.debug_tuple("SetDnd").field(enabled).finish(), + Self::SetDndUntil(expires_at) => formatter + .debug_tuple("SetDndUntil") + .field(expires_at) + .finish(), + Self::ClosePanel => formatter.write_str("ClosePanel"), + } + } +} + #[cfg(test)] #[path = "tests/model.rs"] mod tests; diff --git a/crates/unixnotis-center/src/control/reconnect.rs b/crates/unixnotis-center/src/control/reconnect.rs index e4b5e330e..7d6d8fe6b 100644 --- a/crates/unixnotis-center/src/control/reconnect.rs +++ b/crates/unixnotis-center/src/control/reconnect.rs @@ -1,19 +1,26 @@ //! Session-bus reconnection and control-generation lifecycle use std::collections::VecDeque; +use std::future::Future; use std::time::Duration; +use futures_util::{Stream, StreamExt}; use tokio::sync::mpsc; -use tracing::info; -use unixnotis_core::ControlProxy; +use unixnotis_core::{ + ensure_control_api_version, log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, + INTERNAL_DBUS_CALL_TIMEOUT, +}; +use zbus::fdo::DBusProxy; +use zbus::names::{BusName, UniqueName}; +use zbus::proxy::OwnerChangedStream; use zbus::Connection; use super::backoff::{ Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, }; -use super::commands::stash_offline_commands; +use super::commands::{enqueue_offline_command, stash_offline_commands}; use super::model::{UiCommand, UiEvent}; -use super::subscriptions::run_control_generation; +use super::subscriptions::{run_control_generation, ControlGenerationContext}; #[cfg(test)] #[path = "tests/reconnect.rs"] @@ -46,6 +53,12 @@ pub(super) async fn run_control_loop( continue; } }; + if let Err(err) = log_session_bus_identity(&connection, "center").await { + connect_log.warn_or_debug(&err, "session bus identity probe failed; retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } // A live bus generation clears only connection-level failure history connect_backoff.reset(); connect_log.reset(); @@ -59,26 +72,201 @@ pub(super) async fn run_control_loop( continue; } }; - info!("connected to unixnotis control interface"); + if let Err(err) = ensure_control_api_version(&proxy).await { + connect_log.warn_or_debug(&err, "control API version mismatch, retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + let mut owner_changes = match proxy.inner().receive_owner_changed().await { + Ok(stream) => stream, + Err(err) => { + connect_log.warn_or_debug(&err, "control owner watch unavailable, retrying"); + stash_offline_commands(&mut command_rx, &mut offline_commands); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + }; + let dbus = match DBusProxy::new(&connection).await { + Ok(proxy) => proxy, + Err(err) => { + connect_log.warn_or_debug(&err, "session bus owner proxy unavailable, retrying"); + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + }; - // One generation owns every proxy stream tied to this exact connection - let generation = run_control_generation( - &proxy, + let owner = match wait_for_control_owner( + &dbus, + &mut owner_changes, &sender, &mut command_rx, &mut offline_commands, - &mut subscribe_backoff, - &mut subscribe_log, + ) + .await + { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => { + tokio::time::sleep(connect_backoff.next_sleep()).await; + continue; + } + OwnerWait::Shutdown => return, + }; + + // One generation owns every proxy stream tied to this exact connection + let generation = run_control_generation( + &proxy, + &owner, + ControlGenerationContext::new( + &mut owner_changes, + &sender, + &mut command_rx, + &mut offline_commands, + &mut subscribe_backoff, + &mut subscribe_log, + ), ) .await; if generation.should_stop() { return; } - if !generation.requires_reconnect_cleanup() { - continue; - } // Preserve safe commands before replacing the failed generation stash_offline_commands(&mut command_rx, &mut offline_commands); - tokio::time::sleep(subscribe_backoff.next_sleep()).await; + if generation.requires_connection_backoff() { + tokio::time::sleep(subscribe_backoff.next_sleep()).await; + } } } + +#[derive(Debug, Eq, PartialEq)] +enum OwnerWait { + Ready(String), + Disconnected, + Shutdown, +} + +async fn wait_for_control_owner( + dbus: &DBusProxy<'_>, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + offline_commands: &mut VecDeque, +) -> OwnerWait { + let control_name = BusName::try_from(CONTROL_BUS_NAME) + .expect("static UnixNotis control bus name must be valid"); + wait_for_control_owner_with_probe( + || probe_control_owner(dbus, control_name.clone()), + owner_changes, + sender, + command_rx, + offline_commands, + BACKOFF_BASE_MS, + ) + .await +} + +#[derive(Debug)] +enum GetOwnerError { + NoOwner, + Disconnected(String), + Transient(String), +} + +async fn wait_for_control_owner_with_probe( + mut probe: P, + owner_changes: &mut S, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + offline_commands: &mut VecDeque, + retry_base_ms: u64, +) -> OwnerWait +where + P: FnMut() -> F, + F: Future>, + S: Stream>> + Unpin, +{ + let mut probe_backoff = Backoff::new(retry_base_ms, BACKOFF_MAX_MS); + let mut probe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + match probe().await { + Ok(owner) => return OwnerWait::Ready(owner), + Err(GetOwnerError::Disconnected(error)) => { + probe_log.warn_or_debug(&error, "control owner lookup lost its bus connection"); + return OwnerWait::Disconnected; + } + Err(GetOwnerError::Transient(error)) => { + probe_log.warn_or_debug(&error, "control owner lookup failed; retrying"); + } + Err(GetOwnerError::NoOwner) => {} + } + // Missing ownership is a stable disconnected state, not a connection failure + let _ = sender.send(UiEvent::Disconnected).await; + loop { + let retry_delay = probe_backoff.next_sleep(); + tokio::select! { + command = command_rx.recv() => { + let Some(command) = command else { + return OwnerWait::Shutdown; + }; + enqueue_offline_command(offline_commands, command); + } + update = owner_changes.next() => { + match update { + Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), + Some(None) => {} + None => return OwnerWait::Disconnected, + } + } + () = tokio::time::sleep(retry_delay) => { + match probe().await { + Ok(owner) => return OwnerWait::Ready(owner), + Err(GetOwnerError::NoOwner) => {} + Err(GetOwnerError::Disconnected(error)) => { + probe_log.warn_or_debug( + &error, + "control owner lookup lost its bus connection", + ); + return OwnerWait::Disconnected; + } + Err(GetOwnerError::Transient(error)) => { + probe_log.warn_or_debug( + &error, + "control owner lookup failed; retrying", + ); + } + } + } + } + } +} + +async fn probe_control_owner( + dbus: &DBusProxy<'_>, + control_name: BusName<'_>, +) -> Result { + match tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + Ok(Ok(owner)) => Ok(owner.to_string()), + Ok(Err(zbus::fdo::Error::NameHasNoOwner(_))) => Err(GetOwnerError::NoOwner), + Ok(Err(error)) if owner_error_is_disconnected(&error) => { + Err(GetOwnerError::Disconnected(error.to_string())) + } + Ok(Err(error)) => Err(GetOwnerError::Transient(error.to_string())), + Err(_) => Err(GetOwnerError::Transient( + "control owner lookup timed out".to_string(), + )), + } +} + +const fn owner_error_is_disconnected(error: &zbus::fdo::Error) -> bool { + matches!( + error, + zbus::fdo::Error::IOError(_) + | zbus::fdo::Error::NoServer(_) + | zbus::fdo::Error::NoNetwork(_) + | zbus::fdo::Error::ZBus(zbus::Error::InputOutput(_)) + ) +} diff --git a/crates/unixnotis-center/src/control/seed.rs b/crates/unixnotis-center/src/control/seed.rs index 6674bf238..6444e7cee 100644 --- a/crates/unixnotis-center/src/control/seed.rs +++ b/crates/unixnotis-center/src/control/seed.rs @@ -1,80 +1,16 @@ -//! Seeding helpers for initial control state sync over D-Bus +//! Bounded seeding helpers for one verified control owner -use std::time::{Duration, Instant}; +use unixnotis_core::{timed_dbus_call, ControlProxy}; -use tokio::time::sleep; -use tracing::{debug, warn}; -use unixnotis_core::ControlProxy; - -use super::backoff::RetryLog; use super::model::UiEvent; -// Seed retries tolerate short startup hiccups without blocking indefinitely -pub const SEED_RETRY_BASE_MS: u64 = 250; -pub const SEED_RETRY_MAX_MS: u64 = 2000; -pub const SEED_RETRY_BUDGET_SECS: u64 = 30; -pub const SEED_RETRY_LOG_INTERVAL_SECS: u64 = 10; - -// Captures seed failures without forcing an immediate reconnect +// Each error identifies the exact stage that prevented one complete snapshot #[derive(Debug)] pub struct SeedError { pub(crate) state_error: Option, pub(crate) active_error: Option, pub(crate) history_error: Option, -} - -pub async fn seed_state_with_retry( - proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, -) { - // Seed retries are bounded to keep startup responsive while tolerating transient failures - let mut backoff = super::backoff::Backoff::new(SEED_RETRY_BASE_MS, SEED_RETRY_MAX_MS); - let deadline = seed_retry_deadline(Instant::now()); - let mut log = RetryLog::new(Duration::from_secs(SEED_RETRY_LOG_INTERVAL_SECS)); - - loop { - // Each attempt fetches a coherent three-part snapshot from one proxy - match seed_state(proxy, sender).await { - Ok(()) => return, - Err(err) => { - if Instant::now() >= deadline { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; giving up until reconnect" - ); - return; - } - // Throttled warnings keep prolonged outages useful without log flooding - log.log_with( - || { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; retrying" - ); - }, - || { - debug!( - state_error = ?err.state_error, - active_error = ?err.active_error, - history_error = ?err.history_error, - "failed to seed center state; retrying" - ); - }, - ); - // Exponential delay stays bounded by the shared seed maximum - sleep(backoff.next_sleep()).await; - } - } - } -} - -fn seed_retry_deadline(now: Instant) -> Instant { - // A fixed deadline prevents repeated seed failures from blocking forever - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) + pub(crate) send_error: Option, } #[cfg(test)] @@ -85,27 +21,30 @@ pub async fn seed_state( proxy: &ControlProxy<'_>, sender: &async_channel::Sender, ) -> Result<(), SeedError> { - // Fetch in parallel so startup waits on the slowest call instead of the sum of all calls - let (state, active, history) = - tokio::join!(proxy.get_state(), proxy.list_active(), proxy.list_history()); - - match (state, active, history) { - (Ok(state), Ok(active), Ok(history)) => { + // The daemon captures state and rows under one store lock + match timed_dbus_call(proxy.get_snapshot()).await { + Ok(snapshot) => { // Publish only complete snapshots so the UI never mixes generations - let _ = sender + sender .send(UiEvent::Seed { - state, - active, - history, + state: snapshot.state, + active: snapshot.active, + history: snapshot.history, }) - .await; + .await + .map_err(|error| SeedError { + state_error: None, + active_error: None, + history_error: None, + send_error: Some(error.to_string()), + })?; Ok(()) } - // Individual errors remain separate for useful diagnostics - (state, active, history) => Err(SeedError { - state_error: state.err().map(|err| err.to_string()), - active_error: active.err().map(|err| err.to_string()), - history_error: history.err().map(|err| err.to_string()), + Err(error) => Err(SeedError { + state_error: Some(error.to_string()), + active_error: None, + history_error: None, + send_error: None, }), } } diff --git a/crates/unixnotis-center/src/control/subscriptions.rs b/crates/unixnotis-center/src/control/subscriptions.rs index 7415b23dd..22a439ea7 100644 --- a/crates/unixnotis-center/src/control/subscriptions.rs +++ b/crates/unixnotis-center/src/control/subscriptions.rs @@ -4,14 +4,15 @@ use std::collections::VecDeque; use futures_util::StreamExt; use tokio::sync::mpsc; -use tracing::warn; -use unixnotis_core::ControlProxy; +use tracing::{info, warn}; +use unixnotis_core::{timed_dbus_call, ControlProxy}; +use zbus::proxy::OwnerChangedStream; use super::backoff::{Backoff, RetryLog}; use super::commands::{drop_stale_offline_commands, flush_offline_commands, handle_command}; use super::events::push_active_notification_event; use super::model::{UiCommand, UiEvent}; -use super::seed::seed_state_with_retry; +use super::seed::seed_state; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum ControlGenerationExit { @@ -19,28 +20,71 @@ pub(super) enum ControlGenerationExit { RetryDelayed, // A live stream ended and the reconnect owner must perform cleanup Disconnected, + // A well-known owner transition needs a new handshake without reconnect backoff + OwnerChanged, // The UI dropped every command sender and no longer needs a control task Shutdown, } impl ControlGenerationExit { - pub(super) const fn requires_reconnect_cleanup(self) -> bool { + pub(super) const fn requires_connection_backoff(self) -> bool { matches!(self, Self::Disconnected) } pub(super) const fn should_stop(self) -> bool { matches!(self, Self::Shutdown) } + + pub(super) const fn should_clear_panel_readiness(self) -> bool { + // Owner loss already clears owner-scoped state and must never trigger activation + matches!(self, Self::Shutdown) + } +} + +pub(super) struct ControlGenerationContext<'context, 'stream> { + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + offline_commands: &'context mut VecDeque, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, +} + +impl<'context, 'stream> ControlGenerationContext<'context, 'stream> { + pub(super) const fn new( + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + offline_commands: &'context mut VecDeque, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + ) -> Self { + Self { + owner_changes, + sender, + command_rx, + offline_commands, + subscribe_backoff, + subscribe_log, + } + } } pub(super) async fn run_control_generation( proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, - offline_commands: &mut VecDeque, - subscribe_backoff: &mut Backoff, - subscribe_log: &mut RetryLog, + owner: &str, + context: ControlGenerationContext<'_, '_>, ) -> ControlGenerationExit { + // One context keeps every mutable part tied to this exact owner generation + let ControlGenerationContext { + owner_changes, + sender, + command_rx, + offline_commands, + subscribe_backoff, + subscribe_log, + } = context; + // Every stream below belongs to the same verified proxy generation // Install every match rule before seeding so in-flight signals remain buffered let mut added_stream = match proxy.receive_notification_added().await { @@ -96,11 +140,22 @@ pub(super) async fn run_control_generation( subscribe_log.reset(); // Seed after subscription so events arriving during the fetch wait in their streams - seed_state_with_retry(proxy, sender).await; + if let Err(error) = seed_state(proxy, sender).await { + warn!( + state_error = ?error.state_error, + active_error = ?error.active_error, + history_error = ?error.history_error, + send_error = ?error.send_error, + "control readiness handshake or seed failed" + ); + retry_subscription(subscribe_backoff).await; + return ControlGenerationExit::RetryDelayed; + } + info!(owner, "UnixNotis control service ready"); drop_stale_offline_commands(offline_commands); flush_offline_commands(proxy, sender, offline_commands).await; // Readiness is published only after initial state and buffered commands settle - if let Err(err) = proxy.mark_panel_ready().await { + if let Err(err) = timed_dbus_call(proxy.mark_panel_ready()).await { subscribe_log.warn_or_debug(&err, "failed to mark panel ready"); retry_subscription(subscribe_backoff).await; return ControlGenerationExit::RetryDelayed; @@ -129,7 +184,7 @@ pub(super) async fn run_control_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), true, ).await; } @@ -144,7 +199,7 @@ pub(super) async fn run_control_generation( proxy, sender, *args.id(), - *args.show_popup(), + *args.generation(), false, ).await; } @@ -156,7 +211,13 @@ pub(super) async fn run_control_generation( }; if let Ok(args) = signal.args() { let _ = sender - .send(UiEvent::NotificationClosed(*args.id(), *args.reason())) + .send(UiEvent::NotificationClosed( + unixnotis_core::NotificationKey { + id: *args.id(), + generation: *args.generation(), + }, + *args.reason(), + )) .await; } } @@ -175,7 +236,16 @@ pub(super) async fn run_control_generation( break ControlGenerationExit::Disconnected; }; // A full seed is required because another client may have deleted any row - seed_state_with_retry(proxy, sender).await; + if let Err(error) = seed_state(proxy, sender).await { + warn!( + state_error = ?error.state_error, + active_error = ?error.active_error, + history_error = ?error.history_error, + send_error = ?error.send_error, + "control snapshot refresh failed" + ); + break ControlGenerationExit::Disconnected; + } } signal = panel_stream.next() => { let Some(signal) = signal else { @@ -186,11 +256,32 @@ pub(super) async fn run_control_generation( let _ = sender.send(UiEvent::PanelRequested(*args.request())).await; } } + owner_update = owner_changes.next() => { + match owner_update { + Some(Some(new_owner)) => { + warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::OwnerChanged; + } + Some(None) => { + info!("UnixNotis control service disconnected"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::OwnerChanged; + } + None => { + warn!("control owner stream ended"); + let _ = sender.send(UiEvent::Disconnected).await; + break ControlGenerationExit::Disconnected; + } + } + } } }; - // Readiness is best effort because a closed transport cannot accept cleanup calls - let _ = proxy.mark_panel_not_ready().await; + if exit.should_clear_panel_readiness() { + // Explicit UI shutdown clears readiness while the current owner is still available + let _ = timed_dbus_call(proxy.mark_panel_not_ready()).await; + } exit } diff --git a/crates/unixnotis-center/src/control/tests/client.rs b/crates/unixnotis-center/src/control/tests/client.rs index dc9ddeec4..910d83204 100644 --- a/crates/unixnotis-center/src/control/tests/client.rs +++ b/crates/unixnotis-center/src/control/tests/client.rs @@ -1,18 +1,23 @@ use super::UI_COMMAND_QUEUE_CAPACITY; +use unixnotis_core::NotificationKey; #[test] fn command_queue_rejects_work_beyond_its_fixed_capacity() { let (sender, _receiver) = tokio::sync::mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); for id in 0..UI_COMMAND_QUEUE_CAPACITY { sender - .try_send(crate::control::UiCommand::Dismiss( - u32::try_from(id).expect("test command id fits u32"), - )) + .try_send(crate::control::UiCommand::Dismiss(NotificationKey { + id: u32::try_from(id).expect("test command id fits u32"), + generation: u64::try_from(id).expect("test generation fits u64"), + })) .expect("bounded queue accepts work below its limit"); } assert!(matches!( - sender.try_send(crate::control::UiCommand::Dismiss(u32::MAX)), + sender.try_send(crate::control::UiCommand::Dismiss(NotificationKey { + id: u32::MAX, + generation: u64::MAX, + })), Err(tokio::sync::mpsc::error::TrySendError::Full(_)) )); } diff --git a/crates/unixnotis-center/src/control/tests/commands.rs b/crates/unixnotis-center/src/control/tests/commands.rs index 36493b869..1a80143d4 100644 --- a/crates/unixnotis-center/src/control/tests/commands.rs +++ b/crates/unixnotis-center/src/control/tests/commands.rs @@ -1,13 +1,21 @@ use super::*; +use unixnotis_core::NotificationKey; #[test] fn drop_stale_offline_commands_retains_safe_actions() { // Mix stale id-based actions with reconnect-safe commands let mut offline = VecDeque::new(); - offline.push_back(UiCommand::Dismiss(10)); + offline.push_back(UiCommand::Dismiss(NotificationKey { + id: 10, + generation: 12, + })); offline.push_back(UiCommand::InvokeAction { - id: 11, + notification: NotificationKey { + id: 11, + generation: 13, + }, action_key: "open".to_string(), + confirmed: false, }); offline.push_back(UiCommand::SetDnd(true)); offline.push_back(UiCommand::ClearAll); @@ -16,11 +24,11 @@ fn drop_stale_offline_commands_retains_safe_actions() { drop_stale_offline_commands(&mut offline); // Only commands that can survive reconnect without id drift should remain - assert_eq!(offline.len(), 3); + assert_eq!(offline.len(), 2); assert!(offline .iter() .any(|cmd| matches!(cmd, UiCommand::SetDnd(true)))); - assert!(offline.iter().any(|cmd| matches!(cmd, UiCommand::ClearAll))); + assert!(!offline.iter().any(|cmd| matches!(cmd, UiCommand::ClearAll))); assert!(offline .iter() .any(|cmd| matches!(cmd, UiCommand::ClosePanel))); @@ -39,8 +47,8 @@ fn enqueue_offline_command_drops_duplicate_one_shot_commands() { assert!(!enqueue_offline_command(&mut offline, UiCommand::ClearAll)); assert_eq!(offline.len(), 2); - assert_eq!(offline[0], UiCommand::ClosePanel); - assert_eq!(offline[1], UiCommand::ClearAll); + assert!(matches!(offline[0], UiCommand::ClosePanel)); + assert!(matches!(offline[1], UiCommand::ClearAll)); } #[test] @@ -53,9 +61,31 @@ fn enqueue_offline_command_keeps_latest_dnd_state_only() { )); assert!(enqueue_offline_command( &mut offline, - UiCommand::SetDnd(false) + UiCommand::SetDndUntil(500) )); assert_eq!(offline.len(), 1); - assert_eq!(offline[0], UiCommand::SetDnd(false)); + assert!(matches!(offline[0], UiCommand::SetDndUntil(500))); +} + +#[test] +fn enqueue_offline_command_rejects_live_reply_text_and_reports_failure() { + let mut offline = VecDeque::new(); + let (outcome, mut result) = tokio::sync::oneshot::channel(); + + assert!(!enqueue_offline_command( + &mut offline, + UiCommand::Reply { + id: 7, + generation: 11, + text: "Still there?".to_string(), + outcome, + } + )); + + assert!(offline.is_empty()); + assert!(matches!( + result.try_recv(), + Ok(Err(message)) if message.contains("unavailable") + )); } diff --git a/crates/unixnotis-center/src/control/tests/events.rs b/crates/unixnotis-center/src/control/tests/events.rs index aff0ccb58..cae84147c 100644 --- a/crates/unixnotis-center/src/control/tests/events.rs +++ b/crates/unixnotis-center/src/control/tests/events.rs @@ -5,32 +5,45 @@ use super::{active_notification_event, UiEvent}; fn notification(id: u32) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: "example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } #[test] fn closed_notification_race_emits_no_stale_event() { - assert!(active_notification_event(Vec::new(), true, true).is_none()); + assert!(active_notification_event(Vec::new(), 1, true).is_none()); } #[test] -fn fetched_payload_preserves_add_update_and_popup_semantics() { - let added = active_notification_event(vec![notification(7)], true, true); - let updated = active_notification_event(vec![notification(8)], false, false); +fn fetched_payload_preserves_matching_add_and_update_generations() { + let added = active_notification_event(vec![notification(7)], 7, true); + let updated = active_notification_event(vec![notification(8)], 8, false); assert!(matches!( added, - Some(UiEvent::NotificationAdded(notification, true)) if notification.id == 7 + Some(UiEvent::NotificationAdded(notification)) if notification.id == 7 )); assert!(matches!( updated, - Some(UiEvent::NotificationUpdated(notification, false)) if notification.id == 8 + Some(UiEvent::NotificationUpdated(notification)) if notification.id == 8 )); } + +#[test] +fn fetched_replacement_is_rejected_for_older_signal_generation() { + assert!(active_notification_event(vec![notification(8)], 7, false).is_none()); +} diff --git a/crates/unixnotis-center/src/control/tests/model.rs b/crates/unixnotis-center/src/control/tests/model.rs index d0bbadf2a..cb565911d 100644 --- a/crates/unixnotis-center/src/control/tests/model.rs +++ b/crates/unixnotis-center/src/control/tests/model.rs @@ -1,8 +1,17 @@ use super::{UiCommand, UiEvent}; +use unixnotis_core::NotificationKey; #[test] -fn dismiss_command_preserves_notification_id() { - assert!(matches!(UiCommand::Dismiss(29), UiCommand::Dismiss(29))); +fn dismiss_command_preserves_notification_generation() { + let notification = NotificationKey { + id: 29, + generation: 31, + }; + + assert!(matches!( + UiCommand::Dismiss(notification), + UiCommand::Dismiss(key) if key == notification + )); } #[test] @@ -10,3 +19,21 @@ fn reload_events_remain_distinct() { assert!(matches!(UiEvent::CssReload, UiEvent::CssReload)); assert!(matches!(UiEvent::ConfigReload, UiEvent::ConfigReload)); } + +#[test] +fn reply_command_debug_output_redacts_the_typed_message() { + let (outcome, _result) = tokio::sync::oneshot::channel(); + let command = UiCommand::Reply { + id: 9, + generation: 12, + text: "private reply text".to_string(), + outcome, + }; + + let rendered = format!("{command:?}"); + + assert!(rendered.contains("Reply")); + assert!(rendered.contains('9')); + assert!(rendered.contains("[redacted]")); + assert!(!rendered.contains("private reply text")); +} diff --git a/crates/unixnotis-center/src/control/tests/reconnect.rs b/crates/unixnotis-center/src/control/tests/reconnect.rs index 24e50bc0f..f6da32d28 100644 --- a/crates/unixnotis-center/src/control/tests/reconnect.rs +++ b/crates/unixnotis-center/src/control/tests/reconnect.rs @@ -1,12 +1,20 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures_util::StreamExt; +use unixnotis_core::reconnect::BACKOFF_JITTER_MS; +use unixnotis_core::CONTROL_BUS_NAME; use zbus::fdo::DBusProxy; +use zbus::names::BusName; use zbus::ConnectionBuilder; +use super::{ + owner_error_is_disconnected, probe_control_owner, wait_for_control_owner_with_probe, + GetOwnerError, OwnerWait, +}; use crate::test_support::broker::read_broker_address; static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); @@ -161,3 +169,110 @@ fn broker_socket_is_scoped_to_a_unique_temporary_directory() { let _ = std::fs::remove_dir_all(first.parent().expect("temporary socket has a parent")); let _ = std::fs::remove_dir_all(second.parent().expect("temporary socket has a parent")); } + +#[test] +fn transient_initial_owner_probe_retries_without_an_owner_change_signal() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime"); + runtime.block_on(async { + let attempts = std::sync::Arc::new(AtomicUsize::new(0)); + let mut owner_changes = + futures_util::stream::pending::>>(); + let (event_tx, event_rx) = async_channel::bounded(4); + let (_command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let mut offline_commands = std::collections::VecDeque::new(); + // The deadline covers the production jitter ceiling plus scheduler headroom + let outcome = tokio::time::timeout( + Duration::from_millis(BACKOFF_JITTER_MS + 100), + wait_for_control_owner_with_probe( + { + let attempts = attempts.clone(); + move || { + let attempt = attempts.fetch_add(1, Ordering::SeqCst); + async move { + if attempt == 0 { + Err(GetOwnerError::Transient("injected timeout".to_string())) + } else { + Ok(":1.42".to_string()) + } + } + } + }, + &mut owner_changes, + &event_tx, + &mut command_rx, + &mut offline_commands, + 1, + ), + ) + .await + .expect("owner retry should finish without a signal"); + + assert_eq!(outcome, OwnerWait::Ready(":1.42".to_string())); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + assert!(matches!( + event_rx.try_recv(), + Ok(super::UiEvent::Disconnected) + )); + assert!(event_rx.try_recv().is_err()); + }); +} + +#[test] +fn owner_probe_returns_the_live_control_service_unique_name() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime"); + runtime.block_on(async { + let broker = PrivateBroker::start(broker_socket()); + let service = connect(&broker.address).await.expect("connect service"); + service + .request_name(CONTROL_BUS_NAME) + .await + .expect("claim control service name"); + let observer = connect(&broker.address).await.expect("connect observer"); + let dbus = DBusProxy::new(&observer).await.expect("create D-Bus proxy"); + let control_name = BusName::try_from(CONTROL_BUS_NAME).expect("valid control bus name"); + + // The probe must return the unique owner instead of the requested well-known name + let owner = probe_control_owner(&dbus, control_name) + .await + .expect("probe live control owner"); + + assert_eq!( + owner, + service + .unique_name() + .expect("service connection has a unique name") + .to_string() + ); + }); +} + +#[test] +fn owner_lookup_errors_distinguish_connection_loss_from_transient_failures() { + assert!(owner_error_is_disconnected(&zbus::fdo::Error::IOError( + "broken socket".to_string() + ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::NoServer( + "missing broker".to_string() + ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::NoNetwork( + "network unavailable".to_string() + ))); + assert!(owner_error_is_disconnected(&zbus::fdo::Error::ZBus( + zbus::Error::InputOutput(Arc::new(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "broker closed", + ))) + ))); + assert!(!owner_error_is_disconnected(&zbus::fdo::Error::Timeout( + "slow broker".to_string() + ))); + assert!(!owner_error_is_disconnected( + &zbus::fdo::Error::NameHasNoOwner("service absent".to_string()) + )); +} diff --git a/crates/unixnotis-center/src/control/tests/seed.rs b/crates/unixnotis-center/src/control/tests/seed.rs index 1df457837..03ef4de35 100644 --- a/crates/unixnotis-center/src/control/tests/seed.rs +++ b/crates/unixnotis-center/src/control/tests/seed.rs @@ -1,13 +1,16 @@ -use std::time::{Duration, Instant}; - -use super::{seed_retry_deadline, SEED_RETRY_BUDGET_SECS}; +use super::SeedError; #[test] -fn seed_retry_deadline_adds_the_fixed_retry_budget() { - let now = Instant::now(); +fn seed_error_keeps_handshake_snapshot_and_delivery_failures_distinct() { + let error = SeedError { + state_error: Some("state unavailable".to_string()), + active_error: None, + history_error: None, + send_error: None, + }; - assert_eq!( - seed_retry_deadline(now), - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) - ); + assert!(error.state_error.is_some()); + assert!(error.active_error.is_none()); + assert!(error.history_error.is_none()); + assert!(error.send_error.is_none()); } diff --git a/crates/unixnotis-center/src/control/tests/subscriptions.rs b/crates/unixnotis-center/src/control/tests/subscriptions.rs index 8ab53d52d..caf57b44b 100644 --- a/crates/unixnotis-center/src/control/tests/subscriptions.rs +++ b/crates/unixnotis-center/src/control/tests/subscriptions.rs @@ -1,15 +1,25 @@ use super::ControlGenerationExit; #[test] -fn only_a_disconnected_live_generation_requests_reconnect_cleanup() { - assert!(ControlGenerationExit::Disconnected.requires_reconnect_cleanup()); - assert!(!ControlGenerationExit::RetryDelayed.requires_reconnect_cleanup()); - assert!(!ControlGenerationExit::Shutdown.requires_reconnect_cleanup()); +fn only_a_broken_bus_generation_uses_connection_backoff() { + assert!(ControlGenerationExit::Disconnected.requires_connection_backoff()); + assert!(!ControlGenerationExit::OwnerChanged.requires_connection_backoff()); + assert!(!ControlGenerationExit::RetryDelayed.requires_connection_backoff()); + assert!(!ControlGenerationExit::Shutdown.requires_connection_backoff()); } #[test] fn only_a_closed_ui_command_channel_stops_the_control_task() { assert!(ControlGenerationExit::Shutdown.should_stop()); assert!(!ControlGenerationExit::Disconnected.should_stop()); + assert!(!ControlGenerationExit::OwnerChanged.should_stop()); assert!(!ControlGenerationExit::RetryDelayed.should_stop()); } + +#[test] +fn owner_loss_never_calls_the_panel_readiness_cleanup_method() { + assert!(ControlGenerationExit::Shutdown.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::Disconnected.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::OwnerChanged.should_clear_panel_readiness()); + assert!(!ControlGenerationExit::RetryDelayed.should_clear_panel_readiness()); +} diff --git a/crates/unixnotis-center/src/main.rs b/crates/unixnotis-center/src/main.rs index 567a542f9..66f0872a8 100644 --- a/crates/unixnotis-center/src/main.rs +++ b/crates/unixnotis-center/src/main.rs @@ -70,9 +70,7 @@ fn main() -> Result<()> { let theme_paths = config .resolve_theme_paths_from(&theme_base) .context("resolve theme paths")?; - config - .ensure_theme_files(&theme_paths) - .context("ensure theme files")?; + // Theme paths are read-only at startup; missing files use the embedded layer fallback // Built-in defaults can run without the installer, so helper scripts are owned here too Config::ensure_default_scripts_in(&theme_base).context("ensure default scripts")?; diff --git a/crates/unixnotis-center/src/media/api/model.rs b/crates/unixnotis-center/src/media/api/model.rs index 97b200f8b..714425b89 100644 --- a/crates/unixnotis-center/src/media/api/model.rs +++ b/crates/unixnotis-center/src/media/api/model.rs @@ -8,8 +8,10 @@ pub struct MediaInfo { pub identity: String, /// Browser family tag used for grouping browser-backed players pub browser_family: Option, - /// Browser or source PID from MPRIS metadata or the owning bus process + /// Authenticated PID of the D-Bus connection that owns the player pub owner_pid: Option, + /// Untrusted browser-bridge PID hint used only for duplicate detection + pub source_pid_hint: Option, pub title: String, pub artist: String, pub playback_status: String, diff --git a/crates/unixnotis-center/src/media/art/source.rs b/crates/unixnotis-center/src/media/art/source.rs index 5c7d68e66..81f2c56be 100644 --- a/crates/unixnotis-center/src/media/art/source.rs +++ b/crates/unixnotis-center/src/media/art/source.rs @@ -33,14 +33,17 @@ impl MediaArtSource { pub(in crate::media) fn normalize_art_source( value: &str, allow_remote_https: bool, + allow_local_file: bool, ) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { return None; } // Local files stay available for native players like mpv and smplayer - if let Some(path) = normalize_local_file(trimmed) { - return Some(MediaArtSource::LocalFile(path)); + if allow_local_file { + if let Some(path) = normalize_local_file(trimmed) { + return Some(MediaArtSource::LocalFile(path)); + } } if !allow_remote_https { return None; diff --git a/crates/unixnotis-center/src/media/art/tests/source.rs b/crates/unixnotis-center/src/media/art/tests/source.rs index 909eed0ad..581f489f2 100644 --- a/crates/unixnotis-center/src/media/art/tests/source.rs +++ b/crates/unixnotis-center/src/media/art/tests/source.rs @@ -38,13 +38,13 @@ fn local_media_art_keys_keep_distinct_non_utf8_paths() { #[test] fn artwork_source_normalization_keeps_local_and_allowed_https_inputs() { - let local = normalize_art_source("file:///tmp/track%20art.png", false); + let local = normalize_art_source("file:///tmp/track%20art.png", false, true); assert!(matches!(local, Some(MediaArtSource::LocalFile(_)))); - let localhost = normalize_art_source("file://localhost/tmp/track%20art.png", false); + let localhost = normalize_art_source("file://localhost/tmp/track%20art.png", false, true); assert!(matches!(localhost, Some(MediaArtSource::LocalFile(_)))); - let remote = normalize_art_source("https://example.com/art.png", true); + let remote = normalize_art_source("https://example.com/art.png", true, true); assert!(matches!(remote, Some(MediaArtSource::RemoteHttps(_)))); } @@ -60,6 +60,15 @@ fn artwork_source_normalization_rejects_disallowed_remote_targets() { "https://example.com:8443/art.png", "https://example.com/art.png#section", ] { - assert!(normalize_art_source(value, true).is_none(), "{value}"); + assert!(normalize_art_source(value, true, true).is_none(), "{value}"); } } + +#[test] +fn artwork_source_normalization_rejects_local_files_when_not_allowed() { + let local = normalize_art_source("/tmp/art.png", false, false); + assert!(local.is_none()); + + let file_uri = normalize_art_source("file:///tmp/art.png", false, false); + assert!(file_uri.is_none()); +} diff --git a/crates/unixnotis-center/src/media/mpris/admission.rs b/crates/unixnotis-center/src/media/mpris/admission.rs index 67fe4f2fd..af23653c7 100644 --- a/crates/unixnotis-center/src/media/mpris/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/admission.rs @@ -1,6 +1,10 @@ //! Player allowlist, denylist, and browser-name admission -use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; +use std::fs::File; +use std::io::Read; +use std::os::unix::fs::MetadataExt; + +use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; pub(super) fn detect_browser_family( identity: &str, @@ -43,6 +47,110 @@ pub(super) fn remote_art_allowed( } } +pub(super) fn local_art_allowed( + browser_family: Option<&str>, + owner_executable: Option<&str>, + owner_executable_is_allowed: bool, + policy: MediaLocalArtPolicy, +) -> bool { + // A missing owner executable means the bus owner is not concrete enough to trust + let has_owner = owner_executable.is_some_and(|value| !value.trim().is_empty()); + if !has_owner { + return false; + } + match policy { + MediaLocalArtPolicy::Disabled => false, + MediaLocalArtPolicy::ExactExecutableOnly => { + // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. + // Only native players (non-browser) with an allowlist-matched executable may name host files. + browser_family.is_none() && owner_executable_is_allowed + } + MediaLocalArtPolicy::AllAdmitted => { + // Browser bridges can direct the renderer to arbitrary host files via mpris:artUrl. + // Only native players with a stable owner descriptor may name host files + browser_family.is_none() && owner_executable_is_allowed + } + } +} + +const MAX_EXECUTABLE_FINGERPRINT_BYTES: u64 = 512 * 1024 * 1024; + +pub(super) fn executable_file_matches_allowlist(owner_file: File, allowlist: &[String]) -> bool { + let owner_meta = match owner_file.metadata() { + Ok(meta) => meta, + Err(_) => return false, + }; + let needs_digest = owner_meta.uid() != 0 + || allowlist.iter().any(|path| { + File::open(path) + .and_then(|file| file.metadata()) + .is_ok_and(|metadata| metadata.uid() != 0) + }); + let owner_digest = if needs_digest { + let Some(clone) = owner_file.try_clone().ok() else { + return false; + }; + match executable_digest(clone) { + Some(digest) => Some(digest), + None => return false, + } + } else { + None + }; + let owner_identity = (owner_meta.dev(), owner_meta.ino()); + executable_file_matches_allowlist_with_owner( + owner_meta, + owner_identity, + owner_digest, + allowlist, + ) +} + +fn executable_file_matches_allowlist_with_owner( + owner_meta: std::fs::Metadata, + owner_identity: (u64, u64), + owner_digest: Option<[u8; 32]>, + allowlist: &[String], +) -> bool { + allowlist.iter().any(|allowed_path| { + let allowed_file = match File::open(allowed_path) { + Ok(file) => file, + Err(_) => return false, + }; + let allowed_meta = match allowed_file.metadata() { + Ok(meta) => meta, + Err(_) => return false, + }; + if (allowed_meta.dev(), allowed_meta.ino()) != owner_identity { + return false; + } + if owner_meta.uid() == 0 && allowed_meta.uid() == 0 { + return true; + } + let Some(owner_digest) = owner_digest else { + return false; + }; + executable_digest(allowed_file).is_some_and(|allowed_digest| allowed_digest == owner_digest) + }) +} + +fn executable_digest(mut file: File) -> Option<[u8; 32]> { + let mut hasher = blake3::Hasher::new(); + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + let mut total = 0_u64; + loop { + let read = file.read(&mut buffer).ok()?; + if read == 0 { + return Some(*hasher.finalize().as_bytes()); + } + total = total.checked_add(u64::try_from(read).ok()?)?; + if total > MAX_EXECUTABLE_FINGERPRINT_BYTES { + return None; + } + hasher.update(&buffer[..read]); + } +} + pub(in crate::media) fn is_allowed_player(name: &str, config: &MediaConfig) -> bool { let lower = name.to_lowercase(); if config.denylist.iter().any(|entry| lower.contains(entry)) { diff --git a/crates/unixnotis-center/src/media/mpris/command.rs b/crates/unixnotis-center/src/media/mpris/command.rs index 6341c47a2..ed704791f 100644 --- a/crates/unixnotis-center/src/media/mpris/command.rs +++ b/crates/unixnotis-center/src/media/mpris/command.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use unixnotis_core::PanelDebugLevel; +use super::constants::MPRIS_PROPERTY_TIMEOUT_MS; use super::PlayerState; use crate::diagnostics::panel_debug as debug; use crate::media::MediaCommand; @@ -20,7 +21,14 @@ pub(in crate::media) async fn handle_command( format!("media command: play/pause {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("PlayPause", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("PlayPause", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) @@ -31,7 +39,14 @@ pub(in crate::media) async fn handle_command( format!("media command: next {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("Next", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("Next", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) @@ -42,7 +57,14 @@ pub(in crate::media) async fn handle_command( format!("media command: previous {bus_name}") }); // The returned bus name triggers a fast refresh for the targeted player - let _value: () = state.player.call("Previous", &()).await?; + let _value: () = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + state.player.call("Previous", &()), + ) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("MPRIS command timed out".to_string()) + })??; return Ok(Some(bus_name)); } Ok(None) diff --git a/crates/unixnotis-center/src/media/mpris/constants.rs b/crates/unixnotis-center/src/media/mpris/constants.rs index caaed4b06..726b5ee0f 100644 --- a/crates/unixnotis-center/src/media/mpris/constants.rs +++ b/crates/unixnotis-center/src/media/mpris/constants.rs @@ -8,3 +8,28 @@ pub const MPRIS_PATH: &str = "/org/mpris/MediaPlayer2"; pub const MPRIS_PLAYER: &str = "org.mpris.MediaPlayer2.Player"; // Application identity and supported URI schemes use the root interface pub const MPRIS_APP: &str = "org.mpris.MediaPlayer2"; + +/// Every untrusted MPRIS call must complete within one bounded interval +pub const MPRIS_PROPERTY_TIMEOUT_MS: u64 = 500; +/// Reject unusually large property replies before decoding dynamic values +pub const MAX_MPRIS_PROPERTY_REPLY_BYTES: usize = 512 * 1024; +/// Reject oversized property-change signals before dynamic value deserialization +pub const MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES: usize = MAX_MPRIS_PROPERTY_REPLY_BYTES; +/// Bound dictionary and invalidation entries after the encoded byte gate +pub const MAX_MPRIS_CHANGED_PROPERTIES: usize = 32; +/// Identity is shown in the panel but is never allowed to grow without bound +pub const MAX_MPRIS_IDENTITY_BYTES: usize = 512; +pub const MPRIS_TIMEOUT_QUARANTINE_AFTER: u8 = 3; +pub const MPRIS_TIMEOUT_QUARANTINE_MS: u64 = 5_000; + +/// Discovery is capped so one bus connection cannot create unbounded state +pub const MAX_MPRIS_PLAYERS: usize = 32; +/// Quiet full-capacity inventories rotate one admission opportunity at this interval +pub const MPRIS_FAIRNESS_LEASE_MS: u64 = 5_000; +/// Failed candidate construction receives a bounded retry without resetting its lease +pub const MPRIS_FAIRNESS_RETRY_MS: u64 = 1_000; +/// Candidate owner probes are bounded before any full player construction +pub const MAX_MPRIS_CANDIDATES_PER_PASS: usize = 128; + +/// Metadata maps are retained only when they remain reasonably small +pub const MAX_METADATA_ENTRIES: usize = 256; diff --git a/crates/unixnotis-center/src/media/mpris/credentials.rs b/crates/unixnotis-center/src/media/mpris/credentials.rs new file mode 100644 index 000000000..431dc69c3 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/credentials.rs @@ -0,0 +1,37 @@ +//! Process credentials for MPRIS owner checks + +#[cfg(target_os = "linux")] +use zbus::names::BusName; +#[cfg(target_os = "linux")] +use zbus::zvariant::{DeserializeDict, Type}; +#[cfg(target_os = "linux")] +use zbus::{proxy, Connection}; + +#[cfg(target_os = "linux")] +#[derive(Debug, Default, DeserializeDict, Type)] +#[zvariant(signature = "a{sv}")] +pub(super) struct MprisCredentials { + #[zvariant(rename = "ProcessFD")] + pub(super) process_fd: Option, + #[zvariant(rename = "ProcessID")] + pub(super) process_id: Option, +} + +#[cfg(target_os = "linux")] +#[proxy( + interface = "org.freedesktop.DBus", + default_service = "org.freedesktop.DBus", + default_path = "/org/freedesktop/DBus" +)] +trait ConnectionCredentialsDbus { + fn get_connection_credentials(&self, bus_name: BusName<'_>) -> zbus::Result; +} + +#[cfg(target_os = "linux")] +pub(super) async fn get_connection_credentials( + connection: &Connection, + bus_name: BusName<'_>, +) -> Option { + let proxy = ConnectionCredentialsDbusProxy::new(connection).await.ok()?; + proxy.get_connection_credentials(bus_name).await.ok() +} diff --git a/crates/unixnotis-center/src/media/mpris/discovery.rs b/crates/unixnotis-center/src/media/mpris/discovery.rs index ae34ec15c..2a9176e5d 100644 --- a/crates/unixnotis-center/src/media/mpris/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/discovery.rs @@ -3,24 +3,69 @@ use std::collections::{HashMap, HashSet}; use std::num::NonZeroUsize; +use futures_util::stream::{self, StreamExt}; use tokio::sync::mpsc::Sender; +use tokio::time::Instant; use tracing::warn; use unixnotis_core::{MediaConfig, PanelDebugLevel}; use zbus::fdo::DBusProxy; use zbus::Connection; -use super::constants::MPRIS_PREFIX; -use super::{build_player_state, is_allowed_player, spawn_properties_listener, PlayerState}; +use super::constants::MAX_MPRIS_PLAYERS; +use super::fairness::MprisFairnessState; +use super::inventory::{ + admit_fairness_candidate, build_dbus_player_state, insert_player_state, FairnessAdmission, + PlayerStateBuilder, +}; +use super::player::{resolve_player_owner, OwnerProbe}; +use super::selection::{is_discoverable_player, select_player_names}; +use super::PlayerState; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::MediaSignal; +pub(super) struct DiscoveryState<'a> { + pub players: &'a mut HashMap, + pub discovery_cursor: &'a mut usize, + pub fairness: &'a mut MprisFairnessState, +} + pub(in crate::media) async fn refresh_players( connection: &Connection, dbus_proxy: &DBusProxy<'_>, config: &MediaConfig, signal_tx: &Sender, players: &mut HashMap, + discovery_cursor: &mut usize, + fairness: &mut MprisFairnessState, +) -> zbus::Result<()> { + refresh_players_with_builder( + connection, + dbus_proxy, + config, + signal_tx, + DiscoveryState { + players, + discovery_cursor, + fairness, + }, + build_dbus_player_state, + ) + .await +} + +pub(in crate::media) async fn refresh_players_with_builder( + connection: &Connection, + dbus_proxy: &DBusProxy<'_>, + config: &MediaConfig, + signal_tx: &Sender, + state: DiscoveryState<'_>, + build_player: PlayerStateBuilder, ) -> zbus::Result<()> { + let DiscoveryState { + players, + discovery_cursor, + fairness, + } = state; let names = dbus_proxy.list_names().await?; let mut allowed = HashSet::new(); for name in names { @@ -32,10 +77,15 @@ pub(in crate::media) async fn refresh_players( allowed.insert(name); } + // Keep active names, then rotate through the remaining sorted names + let tracked = players.keys().cloned().collect::>(); + let allowed = select_player_names(allowed, &tracked, discovery_cursor); + let allowed_set = allowed.iter().map(String::as_str).collect::>(); + // Remove players that no longer exist on the bus to avoid stale UI cards let mut removed_names = Vec::new(); for name in players.keys() { - if !allowed.contains(name) { + if !allowed_set.contains(name.as_str()) { removed_names.push(name.clone()); } } @@ -51,36 +101,123 @@ pub(in crate::media) async fn refresh_players( }); } - for name in allowed { - if players.contains_key(&name) { + let mut owners = players + .values() + .filter_map(|player| player.unique_owner.clone()) + .collect::>(); + let names_to_probe = allowed + .iter() + .filter(|name| !players.contains_key(*name)) + .cloned() + .collect::>(); + // Owner-only probes are bounded before any full player construction + let mut probed = stream::iter(names_to_probe) + .map(|name| async move { + let result = resolve_player_owner(connection, &name).await; + (name, result) + }) + .buffer_unordered(4) + .collect::>() + .await; + // Concurrency must not change which alias wins owner deduplication + probed.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + let mut failed_probes = 0usize; + let mut capacity_skipped = 0usize; + let mut eligible = Vec::<(String, OwnerProbe)>::new(); + let mut candidate_owners = HashSet::new(); + for (name, owner) in probed { + let Some(owner) = owner else { + failed_probes = failed_probes.saturating_add(1); + continue; + }; + // Several aliases can resolve to one connection; retain one stable alias + if owners.contains(&owner.unique_owner) + || !candidate_owners.insert(owner.unique_owner.clone()) + { continue; } - // New players are probed once before entering the live cache - let state = match build_player_state(connection, &name, config).await { + eligible.push((name, owner)); + } + + // Build ordinary admissions until successful states fill the owner capacity + while owners.len() < MAX_MPRIS_PLAYERS && !eligible.is_empty() { + let (name, owner) = eligible.remove(0); + let state = match build_player(connection, &name, config, owner).await { Ok(state) => state, Err(err) => { - warn!(?err, player = %name, "failed to build media player state"); + failed_probes = failed_probes.saturating_add(1); + debug::log(PanelDebugLevel::Verbose, || { + format!("failed to build media player state for {name}: {err}") + }); continue; } }; - if let Some(state) = state { - // Each player gets a properties listener so updates stay event-driven - spawn_properties_listener( - state.properties.clone(), - name.clone(), - signal_tx.clone(), - state.listener_cancel.subscribe(), - ); - players.insert(name.clone(), state); - debug::log(PanelDebugLevel::Info, || { - format!("media player added: {name}") - }); + owners.extend(state.unique_owner.iter().cloned()); + insert_player_state(players, signal_tx, name, state); + } + + // Starting the lease after normal admission also covers over-capacity startup inventories + let fairness_rotation_due = fairness.rotation_due( + owners.len() >= MAX_MPRIS_PLAYERS, + !eligible.is_empty(), + Instant::now(), + signal_tx, + ); + if fairness_rotation_due && !eligible.is_empty() { + match admit_fairness_candidate( + connection, + config, + signal_tx, + players, + fairness, + eligible.remove(0), + build_player, + ) + .await + { + FairnessAdmission::Admitted { + victim_name, + candidate_name, + } => { + // Successful admission starts the next bounded opportunity + fairness.complete_rotation(Instant::now(), true, signal_tx); + debug::log(PanelDebugLevel::Info, || { + format!("media player lease rotated: {victim_name} -> {candidate_name}") + }); + } + FairnessAdmission::BuildFailed { + candidate_name, + error, + } => { + failed_probes = failed_probes.saturating_add(1); + // A failed candidate leaves every healthy incumbent untouched + fairness.retry_failed_rotation(Instant::now(), signal_tx); + debug::log(PanelDebugLevel::Verbose, || { + format!( + "failed to build fairness media player state for {candidate_name}: {error}" + ) + }); + } + FairnessAdmission::NoVictim => { + capacity_skipped = capacity_skipped.saturating_add(1); + fairness.retry_failed_rotation(Instant::now(), signal_tx); + } } } + capacity_skipped = capacity_skipped.saturating_add(eligible.len()); + if failed_probes > 0 { + warn!( + failed = failed_probes, + "one or more MPRIS player probes failed" + ); + } + if capacity_skipped > 0 { + warn!( + skipped = capacity_skipped, + limit = MAX_MPRIS_PLAYERS, + "MPRIS player capacity reached; additional owners were ignored" + ); + } Ok(()) } - -pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { - name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) -} diff --git a/crates/unixnotis-center/src/media/mpris/fairness.rs b/crates/unixnotis-center/src/media/mpris/fairness.rs new file mode 100644 index 000000000..ac00afb89 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/fairness.rs @@ -0,0 +1,153 @@ +//! Monotonic fairness leases for full MPRIS inventories + +use std::collections::HashSet; +use std::time::Duration; + +use tokio::sync::mpsc::Sender; +use tokio::task::JoinHandle; +use tokio::time::Instant; + +use super::constants::{MPRIS_FAIRNESS_LEASE_MS, MPRIS_FAIRNESS_RETRY_MS}; +use crate::media::runtime::MediaSignal; + +pub(in crate::media) struct MprisFairnessState { + deadline: Option, + wakeup: Option<(u64, JoinHandle<()>)>, + generation: u64, + victim_cursor: usize, + lease_duration: Duration, + retry_duration: Duration, +} + +impl MprisFairnessState { + pub(in crate::media) const fn new() -> Self { + Self::with_durations( + Duration::from_millis(MPRIS_FAIRNESS_LEASE_MS), + Duration::from_millis(MPRIS_FAIRNESS_RETRY_MS), + ) + } + + pub(in crate::media) const fn with_durations( + lease_duration: Duration, + retry_duration: Duration, + ) -> Self { + Self { + deadline: None, + wakeup: None, + generation: 0, + victim_cursor: 0, + lease_duration, + retry_duration, + } + } + + pub(in crate::media) fn rotation_due( + &mut self, + capacity_was_full: bool, + has_untracked: bool, + now: Instant, + signal_tx: &Sender, + ) -> bool { + if !capacity_was_full || !has_untracked { + self.clear_lease(); + return false; + } + let Some(deadline) = self.deadline else { + self.start_lease(now, signal_tx); + return false; + }; + if now >= deadline { + return true; + } + self.ensure_wakeup(deadline, signal_tx); + false + } + + pub(in crate::media) fn complete_rotation( + &mut self, + now: Instant, + has_untracked: bool, + signal_tx: &Sender, + ) { + // Admission completion is the only event that renews an active fairness lease + self.clear_lease(); + if has_untracked { + self.start_lease(now, signal_tx); + } + } + + pub(in crate::media) fn retry_failed_rotation( + &mut self, + now: Instant, + signal_tx: &Sender, + ) { + if self.deadline.is_some() && self.wakeup.is_none() { + self.ensure_wakeup(now + self.retry_duration, signal_tx); + } + } + + pub(in crate::media) fn consume_wakeup(&mut self, generation: u64) -> bool { + let matches_current = self + .wakeup + .as_ref() + .is_some_and(|(scheduled_generation, _task)| *scheduled_generation == generation) + && self.generation == generation + && self.deadline.is_some(); + if matches_current { + self.wakeup.take(); + } + matches_current + } + + pub(in crate::media) fn select_victim(&mut self, tracked: &HashSet) -> Option { + let mut tracked = tracked.iter().collect::>(); + tracked.sort_unstable(); + if tracked.is_empty() { + return None; + } + let victim = (*tracked.get(self.victim_cursor % tracked.len())?).clone(); + self.victim_cursor = (self.victim_cursor + 1) % tracked.len(); + Some(victim) + } + + fn start_lease(&mut self, now: Instant, signal_tx: &Sender) { + self.generation = self.generation.wrapping_add(1); + let deadline = now + self.lease_duration; + self.deadline = Some(deadline); + self.ensure_wakeup(deadline, signal_tx); + } + + fn ensure_wakeup(&mut self, wake_at: Instant, signal_tx: &Sender) { + if self.wakeup.is_some() { + return; + } + let generation = self.generation; + let signal_tx = signal_tx.clone(); + // Exactly one task converts monotonic lease time into an event-loop refresh + let task = tokio::spawn(async move { + tokio::time::sleep_until(wake_at).await; + let _ = signal_tx + .send(MediaSignal::FairnessLeaseExpired { generation }) + .await; + }); + self.wakeup = Some((generation, task)); + } + + fn clear_lease(&mut self) { + if let Some((_generation, task)) = self.wakeup.take() { + task.abort(); + } + if self.deadline.take().is_some() { + // Queued messages from an old lease must not wake the renewed inventory + self.generation = self.generation.wrapping_add(1); + } + } +} + +impl Drop for MprisFairnessState { + fn drop(&mut self) { + if let Some((_generation, task)) = self.wakeup.take() { + task.abort(); + } + } +} diff --git a/crates/unixnotis-center/src/media/mpris/inventory.rs b/crates/unixnotis-center/src/media/mpris/inventory.rs new file mode 100644 index 000000000..226ffe31b --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/inventory.rs @@ -0,0 +1,95 @@ +//! Player-state construction and inventory commits + +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::pin::Pin; + +use tokio::sync::mpsc::Sender; +use unixnotis_core::{MediaConfig, PanelDebugLevel}; +use zbus::Connection; + +use super::player::{build_player_state_for_owner, OwnerProbe}; +use super::{spawn_properties_listener, MprisFairnessState, PlayerState}; +use crate::diagnostics::panel_debug as debug; +use crate::media::runtime::MediaSignal; + +pub(in crate::media) type PlayerStateBuildFuture<'a> = + Pin> + Send + 'a>>; +pub(in crate::media) type PlayerStateBuilder = + for<'a> fn(&'a Connection, &'a str, &'a MediaConfig, OwnerProbe) -> PlayerStateBuildFuture<'a>; + +pub(super) fn build_dbus_player_state<'a>( + connection: &'a Connection, + name: &'a str, + config: &'a MediaConfig, + owner: OwnerProbe, +) -> PlayerStateBuildFuture<'a> { + Box::pin(build_player_state_for_owner( + connection, name, config, owner, + )) +} + +pub(super) fn insert_player_state( + players: &mut HashMap, + signal_tx: &Sender, + name: String, + state: PlayerState, +) { + let properties = state.properties.clone(); + let listener_cancel = state.listener_cancel.subscribe(); + players.insert(name.clone(), state); + spawn_properties_listener(properties, name.clone(), signal_tx.clone(), listener_cancel); + debug::log(PanelDebugLevel::Info, || { + format!("media player added: {name}") + }); +} + +pub(super) enum FairnessAdmission { + Admitted { + victim_name: String, + candidate_name: String, + }, + BuildFailed { + candidate_name: String, + error: zbus::Error, + }, + NoVictim, +} + +pub(super) async fn admit_fairness_candidate( + connection: &Connection, + config: &MediaConfig, + signal_tx: &Sender, + players: &mut HashMap, + fairness: &mut MprisFairnessState, + candidate: (String, OwnerProbe), + build_player: PlayerStateBuilder, +) -> FairnessAdmission { + let (candidate_name, owner) = candidate; + let state = match build_player(connection, &candidate_name, config, owner).await { + Ok(state) => state, + Err(error) => { + return FairnessAdmission::BuildFailed { + candidate_name, + error, + }; + } + }; + + // Victim selection occurs only after the replacement is fully constructible + let tracked_names = players.keys().cloned().collect::>(); + let Some(victim_name) = fairness.select_victim(&tracked_names) else { + return FairnessAdmission::NoVictim; + }; + let Some(victim) = players.remove(&victim_name) else { + return FairnessAdmission::NoVictim; + }; + + // No await separates removal and insertion, so capacity never exposes a partial commit + let _ = victim.listener_cancel.send(true); + insert_player_state(players, signal_tx, candidate_name.clone(), state); + FairnessAdmission::Admitted { + victim_name, + candidate_name, + } +} diff --git a/crates/unixnotis-center/src/media/mpris/listener.rs b/crates/unixnotis-center/src/media/mpris/listener.rs index 92d5fc3d8..ec633b610 100644 --- a/crates/unixnotis-center/src/media/mpris/listener.rs +++ b/crates/unixnotis-center/src/media/mpris/listener.rs @@ -7,8 +7,14 @@ use tokio::sync::{mpsc::Sender, watch}; use tracing::warn; use unixnotis_core::PanelDebugLevel; use zbus::fdo::PropertiesProxy; +use zbus::message::Type; +use zbus::names::InterfaceName; +use zbus::zvariant::Value; +use zbus::{MatchRule, Message, MessageStream}; -use super::constants::MPRIS_PLAYER; +use super::constants::{ + MAX_MPRIS_CHANGED_PROPERTIES, MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES, MPRIS_PLAYER, +}; use crate::diagnostics::panel_debug as debug; use crate::media::runtime::{MediaRefreshOrigin, MediaSignal}; @@ -19,7 +25,24 @@ pub(in crate::media) fn spawn_properties_listener( mut cancel_rx: watch::Receiver, ) { tokio::spawn(async move { - let mut stream = match properties.receive_properties_changed().await { + let connection = properties.inner().connection().clone(); + let destination = properties.inner().destination().to_owned(); + let path = properties.inner().path().to_owned(); + let rule = match MatchRule::builder() + .msg_type(Type::Signal) + .sender(destination) + .and_then(|builder| builder.path(path)) + .and_then(|builder| builder.interface("org.freedesktop.DBus.Properties")) + .and_then(|builder| builder.member("PropertiesChanged")) + .map(zbus::MatchRuleBuilder::build) + { + Ok(rule) => rule, + Err(err) => { + warn!(?err, "failed to build media property signal rule"); + return; + } + }; + let mut stream = match MessageStream::for_match_rule(rule, &connection, Some(32)).await { Ok(stream) => stream, Err(err) => { warn!(?err, "failed to subscribe to media properties"); @@ -38,13 +61,13 @@ pub(in crate::media) fn spawn_properties_listener( let Some(update) = update else { break; }; - let Ok(args) = update.args() else { + let Ok(message) = update else { continue; }; - if args.interface_name != MPRIS_PLAYER { + let Some(relevant) = relevant_media_change_from_message(&message) else { continue; - } - if !is_relevant_media_change(&args.changed_properties, &args.invalidated_properties) { + }; + if !relevant { continue; } debug::log(PanelDebugLevel::Verbose, || { @@ -66,6 +89,40 @@ pub(in crate::media) fn spawn_properties_listener( }); } +pub(super) fn relevant_media_change_from_message(message: &Message) -> Option { + // SECURITY: enforce the encoded signal-body budget before deserializing + // `a{sv}`. Dynamic zvariant values may allocate attacker-controlled memory + if !properties_changed_body_allowed(message.body().len()) { + return None; + } + let body = message.body(); + let (interface_name, changed, invalidated): ( + InterfaceName<'_>, + HashMap<&str, Value<'_>>, + Vec<&str>, + ) = body.deserialize().ok()?; + if interface_name.as_str() != MPRIS_PLAYER + || !changed_property_count_allowed(changed.len(), invalidated.len()) + { + return None; + } + Some(is_relevant_media_change(&changed, &invalidated)) +} + +pub(super) const fn properties_changed_body_allowed(body_len: usize) -> bool { + body_len <= MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES +} + +pub(super) const fn changed_property_count_allowed( + changed_count: usize, + invalidated_count: usize, +) -> bool { + match changed_count.checked_add(invalidated_count) { + Some(count) => count <= MAX_MPRIS_CHANGED_PROPERTIES, + None => false, + } +} + pub(super) fn is_relevant_media_change( changed: &HashMap<&str, zbus::zvariant::Value<'_>>, invalidated: &[&str], diff --git a/crates/unixnotis-center/src/media/mpris/metadata.rs b/crates/unixnotis-center/src/media/mpris/metadata.rs index 3999acc63..ea996da59 100644 --- a/crates/unixnotis-center/src/media/mpris/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/metadata.rs @@ -2,48 +2,132 @@ use std::collections::HashMap; use zbus::zvariant::OwnedValue; -use super::PlayerState; +use super::constants::{ + MAX_METADATA_ENTRIES, MAX_MPRIS_PROPERTY_REPLY_BYTES, MPRIS_PROPERTY_TIMEOUT_MS, +}; +use super::player::PlayerState; use crate::media::art::normalize_art_source; use crate::media::MediaInfo; +use zbus::Proxy; + +// Bound MPRIS metadata fields before copying into runtime snapshots +const MAX_TITLE_BYTES: usize = 256; +const MAX_ARTIST_BYTES: usize = 256; +const MAX_ART_URL_BYTES: usize = 2048; +const PLASMA_BRIDGE: &str = "org.mpris.MediaPlayer2.plasma-browser-integration"; + +#[derive(Debug)] +pub(super) enum PropertyRead { + Value(T), + Timeout, + Oversize, + Invalid, + BusError, +} + +impl PropertyRead { + pub(super) const fn is_timeout(&self) -> bool { + matches!(self, Self::Timeout) + } + + pub(super) fn into_value(self) -> Option { + match self { + Self::Value(value) => Some(value), + Self::Timeout | Self::Oversize | Self::Invalid | Self::BusError => None, + } + } +} pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option { - // Missing metadata should not drop the card; fall back to identity-only. - let metadata: HashMap = state - .player - .get_property("Metadata") - .await + if state.timeout.is_quarantined() { + return None; + } + let timeout = std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS); + let (metadata, playback_status, can_play, can_pause, can_next, can_prev) = tokio::join!( + bounded_property::>( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "Metadata", + timeout, + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "PlaybackStatus", + timeout, + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanPlay", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanPause", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanGoNext", + timeout + ), + bounded_property::( + &state.property_calls, + super::constants::MPRIS_PLAYER, + "CanGoPrevious", + timeout + ), + ); + // Timeout quarantine is a refresh-batch invariant. A fast PlaybackStatus + // response must not erase timeouts from other calls in the same refresh + let any_timeout = metadata.is_timeout() + || playback_status.is_timeout() + || can_play.is_timeout() + || can_pause.is_timeout() + || can_next.is_timeout() + || can_prev.is_timeout(); + state.timeout.record_refresh_batch(any_timeout); + + let metadata = metadata + .into_value() + .filter(|map| metadata_entry_count_allowed(map.len())) + .unwrap_or_default(); + let title = metadata_string(&metadata, "xesam:title") + .map(|value| bound_string(&value, MAX_TITLE_BYTES)) + .unwrap_or_default(); + let artist = metadata_artist(&metadata) + .map(|value| bound_string(&value, MAX_ARTIST_BYTES)) .unwrap_or_default(); - let title = metadata_string(&metadata, "xesam:title").unwrap_or_default(); - let artist = metadata_artist(&metadata).unwrap_or_default(); - // Metadata PID wins because browser bridges publish the real browser process there - let owner_pid = metadata_pid(&metadata).or(state.owner_pid); let art_source = metadata_string(&metadata, "mpris:artUrl") - .and_then(|value| normalize_art_source(&value, state.remote_art_allowed)); + .filter(|value| value.len() <= MAX_ART_URL_BYTES) + .and_then(|value| { + normalize_art_source(&value, state.remote_art_allowed, state.local_art_allowed) + }); + // Only the Plasma bridge contract defines kde:pid as a source-browser hint + let source_pid_hint = is_plasma_browser_bridge(&state.bus_name) + .then(|| metadata_pid(&metadata, "kde:pid")) + .flatten(); // PlaybackStatus drives whether the player stays visible - // If that read fails, keep the previous snapshot instead of inventing a fake stop event - let playback_status: String = state.player.get_property("PlaybackStatus").await.ok()?; - let can_play: bool = state.player.get_property("CanPlay").await.unwrap_or(false); - let can_pause: bool = state.player.get_property("CanPause").await.unwrap_or(false); - let can_next: bool = state - .player - .get_property("CanGoNext") - .await - .unwrap_or(false); - let can_prev: bool = state - .player - .get_property("CanGoPrevious") - .await - .unwrap_or(false); + // A missing status keeps the prior cache entry instead of inventing a stop event + let playback_status = playback_status.into_value()?; + let can_play = can_play.into_value().unwrap_or(false); + let can_pause = can_pause.into_value().unwrap_or(false); + let can_next = can_next.into_value().unwrap_or(false); + let can_prev = can_prev.into_value().unwrap_or(false); Some(MediaInfo { bus_name: state.bus_name.clone(), identity: state.identity.clone(), // Browser family is decided once when the player is admitted. browser_family: state.browser_family.clone(), - // Plasma browser integration reports the real browser PID as kde:pid - // That PID is stronger than the bridge process owner for duplicate checks - owner_pid, + // The broker PID remains the authority for process-bound policy + owner_pid: state.owner_pid, + // KDE bridge metadata is retained separately and used only for deduplication + source_pid_hint, title, artist, playback_status, @@ -55,17 +139,93 @@ pub(in crate::media) async fn fetch_media_info(state: &PlayerState) -> Option, key: &str) -> Option { +/// Check the raw reply body before asking zvariant to allocate dynamic values +pub(super) async fn bounded_property( + proxy: &Proxy<'static>, + interface: &str, + property: &str, + timeout: std::time::Duration, +) -> PropertyRead +where + T: TryFrom, +{ + let reply = + match tokio::time::timeout(timeout, proxy.call_method("Get", &(interface, property))).await + { + Err(_elapsed) => return PropertyRead::Timeout, + Ok(Err(_error)) => return PropertyRead::BusError, + Ok(Ok(reply)) => reply, + }; + if !property_reply_body_allowed(reply.body().len()) { + return PropertyRead::Oversize; + } + let Ok(value) = reply.body().deserialize::() else { + return PropertyRead::Invalid; + }; + match T::try_from(value) { + Ok(value) => PropertyRead::Value(value), + Err(_error) => PropertyRead::Invalid, + } +} + +pub(super) const fn metadata_entry_count_allowed(count: usize) -> bool { + count <= MAX_METADATA_ENTRIES +} + +pub(super) const fn property_reply_body_allowed(body_len: usize) -> bool { + body_len <= MAX_MPRIS_PROPERTY_REPLY_BYTES +} + +pub(super) fn bound_string(value: &str, max_bytes: usize) -> String { + // Truncate at a UTF-8 boundary so the retained value stays valid + let trimmed = value.trim(); + if trimmed.len() <= max_bytes { + return trimmed.to_string(); + } + let mut end = max_bytes; + while !trimmed.is_char_boundary(end) { + end -= 1; + } + trimmed[..end].to_string() +} + +pub(super) fn metadata_string(map: &HashMap, key: &str) -> Option { let value = map.get(key)?; let owned = value.try_clone().ok()?; String::try_from(owned).ok() } -fn metadata_artist(map: &HashMap) -> Option { +pub(in crate::media) fn is_plasma_browser_bridge(bus_name: &str) -> bool { + // Only the known bridge name may contribute an untrusted source-PID hint + bus_name == PLASMA_BRIDGE + || bus_name + .strip_prefix(PLASMA_BRIDGE) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +pub(super) fn metadata_pid(map: &HashMap, key: &str) -> Option { + // Zero and negative values do not identify a live process + let value = map.get(key)?; + let owned = value.try_clone().ok()?; + if let Ok(pid) = i32::try_from(owned) { + return u32::try_from(pid).ok().filter(|pid| *pid != 0); + } + let owned = value.try_clone().ok()?; + u32::try_from(owned).ok().filter(|pid| *pid != 0) +} + +pub(super) fn metadata_artist(map: &HashMap) -> Option { let value = map.get("xesam:artist")?; let artists_value = value.try_clone().ok()?; if let Ok(artists) = Vec::::try_from(artists_value) { - return artists.into_iter().next(); + // Bound the number of artist entries before taking the first one + if artists.len() > 16 { + return None; + } + return artists + .into_iter() + .next() + .filter(|artist| !artist.trim().is_empty()); } let owned = value.try_clone().ok()?; if let Ok(artist) = String::try_from(owned) { @@ -75,18 +235,3 @@ fn metadata_artist(map: &HashMap) -> Option { } None } - -pub(super) fn metadata_pid(map: &HashMap) -> Option { - let value = map.get("kde:pid")?; - // KDE currently sends this as an integer PID, but bindings may expose signed values - let owned = value.try_clone().ok()?; - if let Ok(pid) = i32::try_from(owned) { - return u32::try_from(pid).ok(); - } - // Accept unsigned variants too so callers do not depend on one zvariant shape - let owned = value.try_clone().ok()?; - if let Ok(pid) = u32::try_from(owned) { - return Some(pid); - } - None -} diff --git a/crates/unixnotis-center/src/media/mpris/mod.rs b/crates/unixnotis-center/src/media/mpris/mod.rs index 1a8a8bf90..1d8a0b364 100644 --- a/crates/unixnotis-center/src/media/mpris/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/mod.rs @@ -3,18 +3,24 @@ mod admission; mod command; mod constants; +mod credentials; mod discovery; +mod fairness; +mod inventory; mod listener; mod metadata; mod player; +mod process; +mod selection; pub(in crate::media) use admission::is_allowed_player; pub(in crate::media) use command::handle_command; pub(in crate::media) use constants::MPRIS_PREFIX; pub(in crate::media) use discovery::refresh_players; +pub(in crate::media) use fairness::MprisFairnessState; pub(in crate::media) use listener::spawn_properties_listener; -pub(in crate::media) use metadata::fetch_media_info; -pub(in crate::media) use player::{build_player_state, PlayerState}; +pub(in crate::media) use metadata::{fetch_media_info, is_plasma_browser_bridge}; +pub(in crate::media) use player::PlayerState; #[cfg(test)] pub(in crate::media) mod tests; diff --git a/crates/unixnotis-center/src/media/mpris/player.rs b/crates/unixnotis-center/src/media/mpris/player.rs index 116630bd6..0319d95ad 100644 --- a/crates/unixnotis-center/src/media/mpris/player.rs +++ b/crates/unixnotis-center/src/media/mpris/player.rs @@ -1,12 +1,29 @@ //! Construction and process-bound identity for one MPRIS player +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + use tokio::sync::watch; use unixnotis_core::MediaConfig; use zbus::fdo::{DBusProxy, PropertiesProxy}; use zbus::{Connection, Proxy, ProxyBuilder}; -use super::admission::{detect_browser_family, remote_art_allowed}; -use super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER}; +use super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; +use super::constants::{ + MAX_MPRIS_IDENTITY_BYTES, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PROPERTY_TIMEOUT_MS, + MPRIS_TIMEOUT_QUARANTINE_AFTER, MPRIS_TIMEOUT_QUARANTINE_MS, +}; +use super::metadata::bounded_property; +#[cfg(target_os = "linux")] +use super::process::executable_allowed_from_pidfd; +#[cfg(target_os = "linux")] +use super::process::read_process_executable_path_from_pidfd; +#[cfg(target_os = "linux")] +use zbus::zvariant::OwnedFd; + +#[cfg(target_os = "linux")] +use super::credentials::get_connection_credentials; #[derive(Clone)] pub(in crate::media) struct PlayerState { @@ -17,59 +34,152 @@ pub(in crate::media) struct PlayerState { pub(in crate::media) browser_family: Option, pub(in crate::media) owner_pid: Option, pub(in crate::media) remote_art_allowed: bool, + pub(in crate::media) local_art_allowed: bool, pub(in crate::media) player: Proxy<'static>, + // Raw property calls allow reply-size checks before dynamic deserialization + pub(in crate::media) property_calls: Proxy<'static>, pub(in crate::media) properties: PropertiesProxy<'static>, + // Timeout state is shared by cloned refresh jobs for this player + pub(super) timeout: PlayerTimeoutState, // Cancellation sender for the properties listener task pub(in crate::media) listener_cancel: watch::Sender, } -pub(in crate::media) async fn build_player_state( +#[derive(Clone)] +pub(super) struct PlayerTimeoutState { + streak: Arc, + quarantined_until: Arc>>, +} + +impl PlayerTimeoutState { + pub(super) fn new() -> Self { + Self { + streak: Arc::new(AtomicU8::new(0)), + quarantined_until: Arc::new(Mutex::new(None)), + } + } + + pub(super) fn is_quarantined(&self) -> bool { + let Ok(mut until) = self.quarantined_until.lock() else { + return true; + }; + let Some(deadline) = *until else { + return false; + }; + if quarantine_active(Instant::now(), deadline) { + return true; + } + *until = None; + self.streak.store(0, Ordering::Release); + false + } + + pub(super) fn record_timeout(&self) { + let streak = self.streak.fetch_add(1, Ordering::AcqRel).saturating_add(1); + if streak >= MPRIS_TIMEOUT_QUARANTINE_AFTER { + if let Ok(mut until) = self.quarantined_until.lock() { + *until = Some( + Instant::now() + .checked_add(Duration::from_millis(MPRIS_TIMEOUT_QUARANTINE_MS)) + .unwrap_or_else(Instant::now), + ); + } + } + } + + pub(super) fn record_refresh_batch(&self, any_timeout: bool) { + if any_timeout { + self.record_timeout(); + } else { + self.clear_timeout(); + } + } + + pub(super) fn clear_timeout(&self) { + self.streak.store(0, Ordering::Release); + if let Ok(mut until) = self.quarantined_until.lock() { + *until = None; + } + } +} + +pub(super) fn quarantine_active(now: Instant, deadline: Instant) -> bool { + now < deadline +} + +// Keep credential handling separate so compatibility behavior can be tested without a bus shim +pub(super) async fn build_player_state_for_owner( connection: &Connection, name: &str, config: &MediaConfig, -) -> zbus::Result> { - // D-Bus owner data is captured once so snapshots do not need another bus round trip - // Browser bridges may later override this PID with a stronger metadata source PID - let Some((unique_owner, owner_pid, owner_executable)) = - resolve_player_owner(connection, name).await - else { - // Ownership changed during probing, so a later bus event should rebuild stable data - return Ok(None); - }; + owner: OwnerProbe, +) -> zbus::Result { // Every process-bound proxy targets the verified unique owner instead of the mutable alias - let identity = fetch_identity(connection, &unique_owner) + let identity = fetch_identity(connection, &owner.unique_owner) .await .unwrap_or_else(|| name.to_string()); let browser_family = detect_browser_family(&identity, name, &config.browser_tokens); let remote_art_allowed = remote_art_allowed( browser_family.as_deref(), - owner_executable.as_deref(), + owner.executable.as_deref(), config.remote_art_policy, ); + #[cfg(target_os = "linux")] + let owner_executable_is_allowed = match config.local_art_policy { + unixnotis_core::MediaLocalArtPolicy::ExactExecutableOnly => { + owner.process_fd.as_ref().is_some_and(|process_fd| { + executable_allowed_from_pidfd( + process_fd, + owner.pid, + &config.local_art_executable_allowlist, + ) + }) + } + // The all-admitted policy still requires a stable broker-provided process descriptor + unixnotis_core::MediaLocalArtPolicy::AllAdmitted => owner.process_fd.is_some(), + unixnotis_core::MediaLocalArtPolicy::Disabled => false, + }; + #[cfg(not(target_os = "linux"))] + let owner_executable_is_allowed = false; + let local_art_allowed = local_art_allowed( + browser_family.as_deref(), + owner.executable.as_deref(), + owner_executable_is_allowed, + config.local_art_policy, + ); let player = ProxyBuilder::new(connection) - .destination(unique_owner.clone())? + .destination(owner.unique_owner.clone())? .path(MPRIS_PATH)? .interface(MPRIS_PLAYER)? .build() .await?; + let property_calls = ProxyBuilder::new(connection) + .destination(owner.unique_owner.clone())? + .path(MPRIS_PATH)? + .interface("org.freedesktop.DBus.Properties")? + .build() + .await?; let properties = PropertiesProxy::builder(connection) - .destination(unique_owner.clone())? + .destination(owner.unique_owner.clone())? .path(MPRIS_PATH)? .build() .await?; let (listener_cancel, _listener_rx) = watch::channel(false); - Ok(Some(PlayerState { + Ok(PlayerState { bus_name: name.to_string(), - unique_owner: Some(unique_owner), + unique_owner: Some(owner.unique_owner), identity, browser_family, - owner_pid, + owner_pid: Some(owner.pid), remote_art_allowed, + local_art_allowed, player, + property_calls, properties, + timeout: PlayerTimeoutState::new(), listener_cancel, - })) + }) } pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Option { @@ -78,18 +188,28 @@ pub(super) async fn fetch_identity(connection: &Connection, name: &str) -> Optio .ok()? .path(MPRIS_PATH) .ok()? - .interface(MPRIS_APP) + .interface("org.freedesktop.DBus.Properties") .ok()? .build() .await .ok()?; - proxy.get_property("Identity").await.ok() + bounded_property::( + &proxy, + super::constants::MPRIS_APP, + "Identity", + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + ) + .await + .into_value() + .filter(|identity| identity.len() <= MAX_MPRIS_IDENTITY_BYTES) + .map(|identity| identity.trim().to_string()) + .filter(|identity| !identity.is_empty()) } pub(super) async fn resolve_player_owner( connection: &Connection, name: &str, -) -> Option<(String, Option, Option)> { +) -> Option { // Synthetic names cannot always be converted into a D-Bus bus name let Ok(bus_name) = zbus::names::BusName::try_from(name) else { return None; @@ -97,35 +217,75 @@ pub(super) async fn resolve_player_owner( let Ok(proxy) = DBusProxy::new(connection).await else { return None; }; - let unique_owner = proxy.get_name_owner(bus_name.clone()).await.ok()?; - // The bus owner PID is useful for normal players and art trust policy - // It is weaker than bridge metadata when a helper owns the MPRIS name - let pid = proxy - .get_connection_unix_process_id((&unique_owner).into()) - .await - .ok(); + let unique_owner = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_name_owner(bus_name.clone()), + ) + .await + .ok()? + .ok()?; #[cfg(target_os = "linux")] - let executable = match pid { - Some(pid) => read_process_executable_path(pid) - .await - .map(|path| path.display().to_string()), - None => None, - }; + let credentials = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + get_connection_credentials(connection, (&unique_owner).into()), + ) + .await + .ok()??; + #[cfg(target_os = "linux")] + let (pid, process_fd) = (credentials.process_id?, credentials.process_fd); #[cfg(not(target_os = "linux"))] - let executable = None; - let observed_owner = proxy.get_name_owner(bus_name).await.ok()?; + let pid = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_connection_unix_process_id((&unique_owner).into()), + ) + .await + .ok()? + .ok()?; + let observed_owner = tokio::time::timeout( + std::time::Duration::from_millis(MPRIS_PROPERTY_TIMEOUT_MS), + proxy.get_name_owner(bus_name), + ) + .await + .ok()? + .ok()?; if !owner_probe_is_stable(unique_owner.as_str(), observed_owner.as_str()) { return None; } - Some((unique_owner.to_string(), pid, executable)) + #[cfg(target_os = "linux")] + let executable = + read_owner_executable_path(pid, process_fd.as_ref()).map(|path| path.display().to_string()); + #[cfg(target_os = "linux")] + executable.as_ref()?; + Some(OwnerProbe { + unique_owner: unique_owner.to_string(), + pid, + executable, + #[cfg(target_os = "linux")] + process_fd, + }) } -pub(super) fn owner_probe_is_stable(initial_owner: &str, observed_owner: &str) -> bool { - initial_owner == observed_owner +pub(super) struct OwnerProbe { + pub(super) unique_owner: String, + pub(super) pid: u32, + pub(super) executable: Option, + #[cfg(target_os = "linux")] + pub(super) process_fd: Option, } #[cfg(target_os = "linux")] -async fn read_process_executable_path(pid: u32) -> Option { - // Reading procfs keeps the trust hint tied to the real bus owner process - tokio::fs::read_link(format!("/proc/{pid}/exe")).await.ok() +pub(super) fn read_owner_executable_path( + pid: u32, + process_fd: Option<&OwnedFd>, +) -> Option { + // A ProcessFD gives a stable object; older buses may provide only the PID + if let Some(process_fd) = process_fd { + return read_process_executable_path_from_pidfd(process_fd, pid); + } + + std::fs::read_link(format!("/proc/{pid}/exe")).ok() +} + +pub(super) fn owner_probe_is_stable(initial_owner: &str, observed_owner: &str) -> bool { + initial_owner == observed_owner } diff --git a/crates/unixnotis-center/src/media/mpris/process.rs b/crates/unixnotis-center/src/media/mpris/process.rs new file mode 100644 index 000000000..c27638f7c --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/process.rs @@ -0,0 +1,90 @@ +//! Stable process-object checks for MPRIS authorization + +#[cfg(target_os = "linux")] +use std::io::Read; +#[cfg(target_os = "linux")] +use std::os::fd::{AsFd, AsRawFd}; + +#[cfg(target_os = "linux")] +const MAX_PIDFD_INFO_BYTES: u64 = 4_096; + +#[cfg(target_os = "linux")] +pub(super) fn read_process_executable_path_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + let path = std::fs::read_link(format!("/proc/{expected_pid}/exe")).ok()?; + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(path) +} + +#[cfg(target_os = "linux")] +pub(super) fn open_process_executable_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + let file = std::fs::File::open(format!("/proc/{expected_pid}/exe")).ok()?; + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(file) +} + +#[cfg(target_os = "linux")] +fn pidfd_matches_live_process(pidfd: &Fd, expected_pid: u32) -> bool { + pidfd_is_live(pidfd) && read_pidfd_process_id(pidfd) == Some(expected_pid) +} + +#[cfg(target_os = "linux")] +fn pidfd_is_live(pidfd: &Fd) -> bool { + use rustix::event::{poll, PollFd, PollFlags, Timespec}; + + let mut poll_fds = [PollFd::new(pidfd, PollFlags::IN)]; + poll(&mut poll_fds, Some(&Timespec::default())).is_ok_and(|ready| ready == 0) +} + +#[cfg(target_os = "linux")] +fn read_pidfd_process_id(pidfd: &Fd) -> Option { + let raw_fd = pidfd.as_fd().as_raw_fd(); + let file = std::fs::File::open(format!("/proc/self/fdinfo/{raw_fd}")).ok()?; + let mut bytes = Vec::new(); + file.take(MAX_PIDFD_INFO_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + if u64::try_from(bytes.len()).ok()? > MAX_PIDFD_INFO_BYTES { + return None; + } + let mut values = std::str::from_utf8(&bytes) + .ok()? + .lines() + .filter_map(|line| { + line.strip_prefix("Pid:") + .and_then(|value| value.trim().parse::().ok()) + .filter(|pid| *pid > 0) + }); + let pid = values.next()?; + values.next().is_none().then_some(pid) +} + +#[cfg(target_os = "linux")] +pub(super) fn executable_allowed_from_pidfd( + pidfd: &impl AsFd, + expected_pid: u32, + allowlist: &[String], +) -> bool { + if allowlist.is_empty() { + return false; + } + let Some(owner_file) = open_process_executable_from_pidfd(pidfd, expected_pid) else { + return false; + }; + super::admission::executable_file_matches_allowlist(owner_file, allowlist) +} diff --git a/crates/unixnotis-center/src/media/mpris/selection.rs b/crates/unixnotis-center/src/media/mpris/selection.rs new file mode 100644 index 000000000..c24d0e06f --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/selection.rs @@ -0,0 +1,41 @@ +//! Bounded MPRIS discovery-name selection + +use std::collections::HashSet; + +use unixnotis_core::MediaConfig; + +use super::constants::{MAX_MPRIS_CANDIDATES_PER_PASS, MPRIS_PREFIX}; +use super::is_allowed_player; + +pub(super) fn is_discoverable_player(name: &str, config: &MediaConfig) -> bool { + name.starts_with(MPRIS_PREFIX) && is_allowed_player(name, config) +} + +pub(super) fn select_player_names( + names: HashSet, + tracked: &HashSet, + cursor: &mut usize, +) -> Vec { + let mut names = names.into_iter().collect::>(); + names.sort_unstable(); + + let mut selected = names + .iter() + .filter(|name| tracked.contains(*name)) + .cloned() + .collect::>(); + let remaining = names + .into_iter() + .filter(|name| !tracked.contains(name)) + .collect::>(); + let room = MAX_MPRIS_CANDIDATES_PER_PASS.saturating_sub(selected.len()); + if room == 0 || remaining.is_empty() { + return selected; + } + + let start = *cursor % remaining.len(); + let count = room.min(remaining.len()); + selected.extend((0..count).map(|offset| remaining[(start + offset) % remaining.len()].clone())); + *cursor = (start + count) % remaining.len(); + selected +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/admission.rs b/crates/unixnotis-center/src/media/mpris/tests/admission.rs index e78019e88..aea847025 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/admission.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/admission.rs @@ -1,6 +1,6 @@ -use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; +use unixnotis_core::{MediaConfig, MediaLocalArtPolicy, MediaRemoteArtPolicy}; -use super::super::admission::{detect_browser_family, remote_art_allowed}; +use super::super::admission::{detect_browser_family, local_art_allowed, remote_art_allowed}; use super::super::is_allowed_player; #[test] @@ -106,3 +106,71 @@ fn remote_art_admission_keeps_browsers_opt_in_and_requires_an_owner() { MediaRemoteArtPolicy::BrowsersToo )); } + +#[test] +fn local_art_admission_rejects_browsers_and_requires_an_owner() { + // Browser with owner executable should be rejected + assert!(!local_art_allowed( + Some("firefox"), + Some("/usr/bin/firefox"), + false, + MediaLocalArtPolicy::ExactExecutableOnly, + )); + + // Non-browser without allowlist match should be rejected + assert!(!local_art_allowed( + None, + Some("/usr/bin/spotify"), + false, + MediaLocalArtPolicy::ExactExecutableOnly, + )); + + // Non-browser without owner executable should be rejected + assert!(!local_art_allowed( + None, + None, + false, + MediaLocalArtPolicy::ExactExecutableOnly, + )); +} + +#[test] +fn local_art_admission_requires_verified_executable_evidence() { + // A verified descriptor comparison is the only exact-policy admission proof + assert!(local_art_allowed( + None, + Some("/usr/bin/player"), + true, + MediaLocalArtPolicy::ExactExecutableOnly, + )); + + // A path hint without descriptor proof remains denied + assert!(!local_art_allowed( + None, + Some("/usr/bin/player"), + false, + MediaLocalArtPolicy::ExactExecutableOnly, + )); +} + +#[test] +fn all_admitted_native_art_still_requires_a_stable_owner() { + assert!(local_art_allowed( + None, + Some("/usr/bin/player"), + true, + MediaLocalArtPolicy::AllAdmitted, + )); + assert!(!local_art_allowed( + None, + Some("/usr/bin/player"), + false, + MediaLocalArtPolicy::AllAdmitted, + )); + assert!(!local_art_allowed( + Some("chromium"), + Some("/usr/bin/chromium"), + true, + MediaLocalArtPolicy::AllAdmitted, + )); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/command.rs b/crates/unixnotis-center/src/media/mpris/tests/command.rs index 060047c2b..1222d79a3 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/command.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/command.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use super::super::command::handle_command; -use super::super::player::{build_player_state, PlayerState}; -use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use super::super::player::PlayerState; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::MediaCommand; use unixnotis_core::MediaConfig; diff --git a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs index 353eb1263..30d043ec0 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/discovery.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/discovery.rs @@ -5,27 +5,9 @@ use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::fdo::DBusProxy; -use super::super::discovery::{is_discoverable_player, refresh_players}; -use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; - -#[test] -fn discovery_requires_an_mpris_name_that_passes_admission() { - let config = MediaConfig { - denylist: vec!["blocked".to_string()], - ..MediaConfig::default() - }; - - assert!(is_discoverable_player( - "org.mpris.MediaPlayer2.allowed", - &config - )); - assert!(!is_discoverable_player("org.example.allowed", &config)); - assert!(!is_discoverable_player( - "org.mpris.MediaPlayer2.blocked", - &config - )); -} +use super::super::discovery::refresh_players; +use super::super::fairness::MprisFairnessState; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; #[tokio::test] async fn discovery_adds_live_players_and_removes_stale_entries() { @@ -43,6 +25,8 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { stale.bus_name = stale_name.to_string(); let mut stale_cancel = stale.listener_cancel.subscribe(); let mut players = HashMap::from([(stale_name.to_string(), stale)]); + let mut discovery_cursor = 0; + let mut fairness = MprisFairnessState::new(); tokio::time::timeout( Duration::from_secs(2), @@ -52,6 +36,8 @@ async fn discovery_adds_live_players_and_removes_stale_entries() { &config, &signal_tx, &mut players, + &mut discovery_cursor, + &mut fairness, ), ) .await diff --git a/crates/unixnotis-center/src/media/mpris/tests/fairness.rs b/crates/unixnotis-center/src/media/mpris/tests/fairness.rs new file mode 100644 index 000000000..5f7082ee5 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/tests/fairness.rs @@ -0,0 +1,235 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use tokio::sync::mpsc; +use unixnotis_core::MediaConfig; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use super::super::constants::MAX_MPRIS_PLAYERS; +use super::super::discovery::{refresh_players, refresh_players_with_builder, DiscoveryState}; +use super::super::fairness::MprisFairnessState; +use super::super::inventory::PlayerStateBuildFuture; +use super::super::player::OwnerProbe; +use super::super::PlayerState; +use super::support::{fleet_player_name, MprisFleetFixture}; + +#[tokio::test] +async fn full_capacity_fairness_becomes_due_at_its_monotonic_deadline() { + let lease = Duration::from_millis(20); + let mut fairness = MprisFairnessState::with_durations(lease, lease); + let (signal_tx, mut signal_rx) = mpsc::channel(1); + let admitted_at = tokio::time::Instant::now(); + + assert!(!fairness.rotation_due(true, true, admitted_at, &signal_tx)); + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + assert!(fairness.rotation_due(true, true, tokio::time::Instant::now(), &signal_tx)); +} + +#[tokio::test] +async fn fairness_never_schedules_below_capacity_or_without_untracked_candidates() { + let lease = Duration::from_millis(10); + let mut fairness = MprisFairnessState::with_durations(lease, lease); + let (signal_tx, mut signal_rx) = mpsc::channel(1); + + assert!(!fairness.rotation_due(false, true, tokio::time::Instant::now(), &signal_tx)); + assert!(!fairness.rotation_due(true, false, tokio::time::Instant::now(), &signal_tx)); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(signal_rx.try_recv().is_err()); +} + +#[test] +fn fairness_victim_selection_rotates_across_incumbents() { + let mut fairness = MprisFairnessState::new(); + let tracked = HashSet::from([ + "org.mpris.MediaPlayer2.a".to_string(), + "org.mpris.MediaPlayer2.b".to_string(), + ]); + + assert_eq!( + fairness.select_victim(&tracked).as_deref(), + Some("org.mpris.MediaPlayer2.a") + ); + assert_eq!( + fairness.select_victim(&tracked).as_deref(), + Some("org.mpris.MediaPlayer2.b") + ); +} + +async fn receive_fairness_wakeup( + fairness: &mut MprisFairnessState, + signal_rx: &mut mpsc::Receiver, +) { + let signal = tokio::time::timeout(Duration::from_secs(2), signal_rx.recv()) + .await + .expect("quiet capacity should receive its fairness wakeup") + .expect("fairness signal channel should remain open"); + let crate::media::runtime::MediaSignal::FairnessLeaseExpired { generation } = signal else { + panic!("quiet MPRIS players emitted an unrelated signal"); + }; + assert!(fairness.consume_wakeup(generation)); +} + +fn cancel_all_listeners(players: &HashMap) { + for player in players.values() { + let _ = player.listener_cancel.send(true); + } +} + +async fn discover_incumbent_fleet( + fixture: &MprisFleetFixture, + proxy: &DBusProxy<'_>, + config: &MediaConfig, + signal_tx: &mpsc::Sender, + players: &mut HashMap, + cursor: &mut usize, + fairness: &mut MprisFairnessState, +) { + refresh_players( + &fixture.client, + proxy, + config, + signal_tx, + players, + cursor, + fairness, + ) + .await + .expect("discover the incumbent fleet"); + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); +} + +#[tokio::test] +async fn quiet_full_capacity_inventory_wakes_and_admits_the_next_player() { + let mut fixture = MprisFleetFixture::start(MAX_MPRIS_PLAYERS).await; + let config = MediaConfig::default(); + let proxy = DBusProxy::new(&fixture.client) + .await + .expect("create private bus proxy"); + let (signal_tx, mut signal_rx) = mpsc::channel(64); + let mut players = HashMap::new(); + let mut cursor = 0; + let mut fairness = + MprisFairnessState::with_durations(Duration::from_millis(25), Duration::from_millis(25)); + discover_incumbent_fleet( + &fixture, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await; + + let candidate_name = fleet_player_name(MAX_MPRIS_PLAYERS); + fixture.add_player(MAX_MPRIS_PLAYERS).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("observe the over-capacity candidate"); + assert!(!players.contains_key(&candidate_name)); + + // The lease task is the only event that requests this second discovery pass + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("admit the fairness candidate after its deadline"); + + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); + assert!(players.contains_key(&candidate_name)); + cancel_all_listeners(&players); +} + +fn fail_player_state_build<'a>( + _connection: &'a Connection, + _name: &'a str, + _config: &'a MediaConfig, + _owner: OwnerProbe, +) -> PlayerStateBuildFuture<'a> { + Box::pin(async { + Err(zbus::Error::Failure( + "intentional candidate build failure".to_string(), + )) + }) +} + +#[tokio::test] +async fn failed_fairness_candidate_build_keeps_the_incumbent_and_listener_alive() { + let mut fixture = MprisFleetFixture::start(MAX_MPRIS_PLAYERS).await; + let config = MediaConfig::default(); + let proxy = DBusProxy::new(&fixture.client) + .await + .expect("create private bus proxy"); + let (signal_tx, mut signal_rx) = mpsc::channel(64); + let mut players = HashMap::new(); + let mut cursor = 0; + let mut fairness = + MprisFairnessState::with_durations(Duration::from_millis(25), Duration::from_millis(100)); + discover_incumbent_fleet( + &fixture, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await; + fixture.add_player(MAX_MPRIS_PLAYERS).await; + refresh_players( + &fixture.client, + &proxy, + &config, + &signal_tx, + &mut players, + &mut cursor, + &mut fairness, + ) + .await + .expect("start the fairness lease"); + receive_fairness_wakeup(&mut fairness, &mut signal_rx).await; + let victim_name = fleet_player_name(0); + let mut victim_cancel = players[&victim_name].listener_cancel.subscribe(); + + refresh_players_with_builder( + &fixture.client, + &proxy, + &config, + &signal_tx, + DiscoveryState { + players: &mut players, + discovery_cursor: &mut cursor, + fairness: &mut fairness, + }, + fail_player_state_build, + ) + .await + .expect("candidate build failure should not fail discovery"); + + assert_eq!(players.len(), MAX_MPRIS_PLAYERS); + assert!(players.contains_key(&victim_name)); + assert!(!players.contains_key(&fleet_player_name(MAX_MPRIS_PLAYERS))); + assert!( + tokio::time::timeout(Duration::from_millis(30), victim_cancel.changed()) + .await + .is_err(), + "failed admission must not cancel the selected incumbent" + ); + cancel_all_listeners(&players); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/listener.rs b/crates/unixnotis-center/src/media/mpris/tests/listener.rs index 59a3f9553..e4370b517 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/listener.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/listener.rs @@ -3,12 +3,43 @@ use std::time::Duration; use tokio::sync::mpsc; use unixnotis_core::MediaConfig; +use zbus::Message; -use super::super::listener::{is_relevant_media_change, spawn_properties_listener}; -use super::super::player::build_player_state; -use super::support::{MprisFixture, TEST_PLAYER_NAME}; +use super::super::constants::{ + MAX_MPRIS_CHANGED_PROPERTIES, MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES, MPRIS_PATH, MPRIS_PLAYER, +}; +use super::super::listener::{ + changed_property_count_allowed, is_relevant_media_change, properties_changed_body_allowed, + relevant_media_change_from_message, spawn_properties_listener, +}; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::runtime::{MediaRefreshOrigin, MediaSignal}; +fn properties_changed_message(body: &T) -> Message +where + T: serde::Serialize + zbus::zvariant::DynamicType, +{ + Message::signal( + MPRIS_PATH, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + ) + .expect("property signal builder") + .build(body) + .expect("property signal message") +} + +fn signal_with_exact_body_len(target_len: usize) -> Message { + let empty = properties_changed_message(&(Vec::::new(),)); + let overhead = empty.body().len(); + let payload_len = target_len + .checked_sub(overhead) + .expect("target body must exceed the encoded array overhead"); + let message = properties_changed_message(&(vec![0_u8; payload_len],)); + assert_eq!(message.body().len(), target_len); + message +} + #[test] fn relevant_media_change_detects_updates_and_invalidations() { let mut changed = HashMap::new(); @@ -29,6 +60,65 @@ fn relevant_media_change_ignores_unrelated_properties() { assert!(!is_relevant_media_change(&changed, &["Position"])); } +#[test] +fn properties_changed_encoded_body_limit_accepts_only_the_exact_budget() { + assert!(properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES - 1 + )); + assert!(properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + )); + assert!(!properties_changed_body_allowed( + MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1 + )); +} + +#[test] +fn raw_properties_changed_gate_handles_encoded_bodies_on_both_sides_of_limit() { + let below = signal_with_exact_body_len(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES - 1); + let above = signal_with_exact_body_len(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1); + + assert!(properties_changed_body_allowed(below.body().len())); + assert!(!properties_changed_body_allowed(above.body().len())); + assert_eq!(relevant_media_change_from_message(&above), None); +} + +#[test] +fn properties_changed_entry_limit_bounds_changes_and_invalidations_together() { + assert!(changed_property_count_allowed( + MAX_MPRIS_CHANGED_PROPERTIES, + 0 + )); + assert!(changed_property_count_allowed(16, 16)); + assert!(!changed_property_count_allowed(16, 17)); + assert!(!changed_property_count_allowed(usize::MAX, 1)); +} + +#[test] +fn raw_properties_changed_decoder_accepts_normal_media_signals() { + let changed = HashMap::from([("Metadata", zbus::zvariant::Value::from("track"))]); + let message = properties_changed_message(&(MPRIS_PLAYER, changed, Vec::<&str>::new())); + + assert_eq!(relevant_media_change_from_message(&message), Some(true)); +} + +#[test] +fn raw_properties_changed_decoder_rejects_oversized_irrelevant_data_before_decode() { + let oversized = "x".repeat(MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES + 1_024); + let changed = HashMap::from([("Unrelated", zbus::zvariant::Value::from(oversized.as_str()))]); + let message = properties_changed_message(&(MPRIS_PLAYER, changed, Vec::<&str>::new())); + + assert!(message.body().len() > MAX_MPRIS_PROPERTIES_CHANGED_BODY_BYTES); + assert_eq!(relevant_media_change_from_message(&message), None); +} + +#[test] +fn raw_properties_changed_decoder_rejects_malformed_body() { + let message = properties_changed_message(&("wrong shape",)); + + assert_eq!(relevant_media_change_from_message(&message), None); +} + #[tokio::test] async fn property_listener_forwards_relevant_live_player_changes() { let fixture = MprisFixture::start().await; diff --git a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs index 3182c8794..27f834cac 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/metadata.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/metadata.rs @@ -1,21 +1,209 @@ -use std::collections::HashMap; +use super::super::constants::MAX_MPRIS_PROPERTY_REPLY_BYTES; +use super::super::metadata::fetch_media_info; +use super::super::metadata::{ + bound_string, is_plasma_browser_bridge, metadata_artist, metadata_entry_count_allowed, + metadata_pid, metadata_string, property_reply_body_allowed, +}; +use super::support::{build_player_state, MprisFixture, TEST_BRIDGE_PLAYER_NAME, TEST_PLAYER_NAME}; +use unixnotis_core::MediaConfig; +use zbus::zvariant::{OwnedValue, Value}; -use zbus::zvariant::OwnedValue; +const SOURCE_BROWSER_PID: u32 = 42_424; -use super::super::metadata::metadata_pid; +#[test] +fn bounded_metadata_strings_trim_and_preserve_utf8_boundaries() { + assert_eq!(bound_string(" title ", 32), "title"); + assert_eq!(bound_string("éé", 3), "é"); + assert_eq!(bound_string("title", 0), ""); +} + +#[test] +fn metadata_fields_accept_expected_string_shapes() { + let title = OwnedValue::try_from(Value::from("A title")).expect("title value"); + let artists = + OwnedValue::try_from(Value::from(vec!["Artist".to_string()])).expect("artist value"); + let metadata = std::collections::HashMap::from([ + ("xesam:title".to_string(), title), + ("xesam:artist".to_string(), artists), + ]); + + assert_eq!( + metadata_string(&metadata, "xesam:title").as_deref(), + Some("A title") + ); + assert_eq!(metadata_artist(&metadata).as_deref(), Some("Artist")); +} + +#[test] +fn metadata_pid_accepts_unsigned_values_and_rejects_negative_values() { + let positive = std::collections::HashMap::from([( + "kde:pid".to_string(), + OwnedValue::from(SOURCE_BROWSER_PID), + )]); + let negative = + std::collections::HashMap::from([("kde:pid".to_string(), OwnedValue::from(-1_i32))]); + let zero = std::collections::HashMap::from([("kde:pid".to_string(), OwnedValue::from(0_u32))]); + + assert_eq!(metadata_pid(&positive, "kde:pid"), Some(SOURCE_BROWSER_PID)); + assert_eq!(metadata_pid(&negative, "kde:pid"), None); + assert_eq!(metadata_pid(&zero, "kde:pid"), None); +} + +#[test] +fn metadata_pid_accepts_positive_signed_values() { + let positive = std::collections::HashMap::from([( + "kde:pid".to_string(), + OwnedValue::from(i32::try_from(SOURCE_BROWSER_PID).expect("fixture PID fits")), + )]); + + assert_eq!(metadata_pid(&positive, "kde:pid"), Some(SOURCE_BROWSER_PID)); +} + +#[test] +fn source_pid_hints_are_limited_to_plasma_bridge_names() { + assert!(is_plasma_browser_bridge(TEST_BRIDGE_PLAYER_NAME)); + assert!(is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.plasma-browser-integration.instance" + )); + assert!(!is_plasma_browser_bridge(TEST_PLAYER_NAME)); + assert!(!is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.other-browser-bridge" + )); + assert!(!is_plasma_browser_bridge( + "org.mpris.MediaPlayer2.plasma-browser-integration-fake" + )); +} #[test] -fn metadata_pid_reads_unsigned_kde_pid() { - let mut metadata = HashMap::new(); - metadata.insert("kde:pid".to_string(), OwnedValue::from(103_380_u32)); +fn metadata_artist_rejects_empty_and_oversized_artist_lists() { + let empty = + OwnedValue::try_from(Value::from(vec![" ".to_string()])).expect("empty artist value"); + let oversized = OwnedValue::try_from(Value::from( + (0..17) + .map(|index| format!("Artist {index}")) + .collect::>(), + )) + .expect("oversized artist value"); + let maximum = OwnedValue::try_from(Value::from( + (0..16) + .map(|index| format!("Artist {index}")) + .collect::>(), + )) + .expect("maximum artist value"); + let scalar = OwnedValue::try_from(Value::from("Solo artist")).expect("scalar artist value"); + + let empty_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), empty)]); + let oversized_metadata = + std::collections::HashMap::from([("xesam:artist".to_string(), oversized)]); + let maximum_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), maximum)]); + let scalar_metadata = std::collections::HashMap::from([("xesam:artist".to_string(), scalar)]); - assert_eq!(metadata_pid(&metadata), Some(103_380)); + assert_eq!(metadata_artist(&empty_metadata), None); + assert_eq!(metadata_artist(&oversized_metadata), None); + assert_eq!( + metadata_artist(&maximum_metadata).as_deref(), + Some("Artist 0") + ); + assert_eq!( + metadata_artist(&scalar_metadata).as_deref(), + Some("Solo artist") + ); } #[test] -fn metadata_pid_rejects_negative_kde_pid() { - let mut metadata = HashMap::new(); - metadata.insert("kde:pid".to_string(), OwnedValue::from(-1_i32)); +fn metadata_limits_accept_exact_boundaries_only() { + assert_eq!(MAX_MPRIS_PROPERTY_REPLY_BYTES, 512 * 1024); + assert!(metadata_entry_count_allowed(256)); + assert!(!metadata_entry_count_allowed(257)); + assert!(property_reply_body_allowed(MAX_MPRIS_PROPERTY_REPLY_BYTES)); + assert!(!property_reply_body_allowed( + MAX_MPRIS_PROPERTY_REPLY_BYTES + 1 + )); +} + +#[tokio::test] +async fn oversized_metadata_reply_is_rejected_before_dynamic_decode() { + let fixture = MprisFixture::start_with_metadata_bytes(MAX_MPRIS_PROPERTY_REPLY_BYTES + 1).await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build oversized-metadata fixture player") + .expect("fixture owner should remain stable"); + + let info = fetch_media_info(&player) + .await + .expect("required playback status remains available"); + assert!(info.title.is_empty()); + assert!(info.artist.is_empty()); +} + +#[tokio::test] +async fn fast_playback_status_cannot_clear_five_sibling_property_timeouts() { + let fixture = MprisFixture::start_with_slow_non_status_properties().await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build slow-property fixture player") + .expect("fixture owner should remain stable"); + + for _ in 0..3 { + let info = fetch_media_info(&player) + .await + .expect("fast PlaybackStatus should still construct a partial snapshot"); + assert_eq!(info.playback_status, "Playing"); + assert!(!info.can_play); + assert!(!info.can_pause); + assert!(!info.can_next); + assert!(!info.can_prev); + } + + assert!(player.timeout.is_quarantined()); + assert_eq!(fetch_media_info(&player).await, None); +} + +#[tokio::test] +async fn oversized_art_url_is_not_retained() { + let fixture = MprisFixture::start_with_art_url_bytes(2_049).await; + let player = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build oversized-art fixture player") + .expect("fixture owner should remain stable"); + + let info = fetch_media_info(&player) + .await + .expect("playback status remains available"); + assert_eq!(info.art_source, None); +} + +#[tokio::test] +async fn browser_bridge_ingestion_keeps_owner_and_source_pids_separate() { + let fixture = MprisFixture::start_with_kde_pid(SOURCE_BROWSER_PID).await; + let player = build_player_state( + &fixture.client, + TEST_BRIDGE_PLAYER_NAME, + &MediaConfig::default(), + ) + .await + .expect("build bridge player") + .expect("bridge owner should remain stable"); + + let info = fetch_media_info(&player) + .await + .expect("bridge metadata should be readable"); + + assert_eq!(info.owner_pid, Some(std::process::id())); + assert_eq!(info.source_pid_hint, Some(SOURCE_BROWSER_PID)); + assert_eq!( + info.browser_family, None, + "the fixture keeps the bridge family unresolved so source-PID fallback is exercised" + ); - assert_eq!(metadata_pid(&metadata), None); + let ordinary = MprisFixture::start().await; + let ordinary_player = + build_player_state(&ordinary.client, TEST_PLAYER_NAME, &MediaConfig::default()) + .await + .expect("build ordinary player") + .expect("ordinary owner should remain stable"); + let ordinary_info = fetch_media_info(&ordinary_player) + .await + .expect("ordinary metadata should be readable"); + assert_eq!(ordinary_info.source_pid_hint, None); } diff --git a/crates/unixnotis-center/src/media/mpris/tests/mod.rs b/crates/unixnotis-center/src/media/mpris/tests/mod.rs index 75c3ee429..6a14eaf17 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/mod.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/mod.rs @@ -1,7 +1,9 @@ mod admission; mod command; mod discovery; +mod fairness; mod listener; mod metadata; mod player; +mod selection; pub(in crate::media) mod support; diff --git a/crates/unixnotis-center/src/media/mpris/tests/player.rs b/crates/unixnotis-center/src/media/mpris/tests/player.rs index da2ef4c83..758dafc8f 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/player.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/player.rs @@ -1,10 +1,13 @@ +use std::time::{Duration, Instant}; + use unixnotis_core::MediaConfig; use super::super::constants::{MPRIS_APP, MPRIS_PATH, MPRIS_PLAYER, MPRIS_PREFIX}; use super::super::player::{ - build_player_state, fetch_identity, owner_probe_is_stable, resolve_player_owner, + build_player_state_for_owner, fetch_identity, owner_probe_is_stable, quarantine_active, + read_owner_executable_path, resolve_player_owner, PlayerTimeoutState, }; -use super::support::{MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; +use super::support::{build_player_state, MprisFixture, TEST_PLAYER_IDENTITY, TEST_PLAYER_NAME}; #[test] fn owner_probe_accepts_only_one_stable_unique_owner() { @@ -12,6 +15,22 @@ fn owner_probe_accepts_only_one_stable_unique_owner() { assert!(!owner_probe_is_stable(":1.40", ":1.41")); } +#[test] +fn quarantine_deadline_is_exclusive() { + let now = Instant::now(); + assert!(quarantine_active(now, now + Duration::from_millis(1))); + assert!(!quarantine_active(now, now)); +} + +#[cfg(target_os = "linux")] +#[test] +fn owner_probe_keeps_metadata_when_process_fd_is_unavailable() { + let path = read_owner_executable_path(std::process::id(), None) + .expect("PID fallback should resolve the current executable"); + + assert!(path.is_absolute()); +} + #[test] fn player_proxy_constants_match_the_mpris_contract() { assert_eq!(MPRIS_PREFIX, "org.mpris.MediaPlayer2."); @@ -20,10 +39,47 @@ fn player_proxy_constants_match_the_mpris_contract() { assert_eq!(MPRIS_APP, "org.mpris.MediaPlayer2"); } +#[test] +fn player_timeout_state_quarantines_after_repeated_failures() { + let state = PlayerTimeoutState::new(); + + assert!(!state.is_quarantined()); + state.record_timeout(); + state.record_timeout(); + assert!(!state.is_quarantined()); + state.record_timeout(); + assert!(state.is_quarantined()); +} + +#[test] +fn player_timeout_state_clear_releases_a_quarantine() { + let state = PlayerTimeoutState::new(); + for _ in 0..3 { + state.record_timeout(); + } + + assert!(state.is_quarantined()); + state.clear_timeout(); + assert!(!state.is_quarantined()); +} + +#[test] +fn refresh_batch_with_fast_status_and_other_timeouts_reaches_quarantine() { + let state = PlayerTimeoutState::new(); + + // PlaybackStatus succeeded in each modeled batch, while five sibling calls timed out + for _ in 0..3 { + state.record_refresh_batch(true); + } + + assert!(state.is_quarantined()); +} + #[tokio::test] async fn player_state_uses_live_identity_owner_and_process_details() { let fixture = MprisFixture::start().await; + // Native players use bounded local artwork by default let state = build_player_state(&fixture.client, TEST_PLAYER_NAME, &MediaConfig::default()) .await .expect("probe test MPRIS player") @@ -33,6 +89,7 @@ async fn player_state_uses_live_identity_owner_and_process_details() { assert_eq!(state.identity, TEST_PLAYER_IDENTITY); assert_eq!(state.owner_pid, Some(std::process::id())); assert!(state.remote_art_allowed); + assert!(state.local_art_allowed); assert_eq!( state.unique_owner.as_deref(), fixture.server.unique_name().map(|name| name.as_str()) @@ -49,11 +106,14 @@ async fn player_state_uses_live_identity_owner_and_process_details() { let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) .await .expect("resolve stable test owner"); - assert_eq!(owner.0, state.unique_owner.expect("captured unique owner")); - assert_eq!(owner.1, Some(std::process::id())); + assert_eq!( + owner.unique_owner.as_str(), + state.unique_owner.expect("captured unique owner") + ); + assert_eq!(owner.pid, std::process::id()); #[cfg(target_os = "linux")] assert_eq!( - owner.2.as_deref(), + owner.executable.as_deref(), Some( std::env::current_exe() .expect("resolve current test executable") @@ -62,7 +122,63 @@ async fn player_state_uses_live_identity_owner_and_process_details() { ) ); assert_eq!( - fetch_identity(&fixture.client, owner.0.as_str()).await, + fetch_identity(&fixture.client, owner.unique_owner.as_str()).await, Some(TEST_PLAYER_IDENTITY.to_string()) ); } + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn player_state_without_process_fd_keeps_remote_metadata_and_disables_local_art() { + let fixture = MprisFixture::start().await; + let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) + .await + .expect("resolve stable test owner"); + let owner_pid = owner.pid; + let mut owner_without_process_fd = owner; + owner_without_process_fd.process_fd = None; + + let state = build_player_state_for_owner( + &fixture.client, + TEST_PLAYER_NAME, + &MediaConfig::default(), + owner_without_process_fd, + ) + .await + .expect("build player state without ProcessFD"); + + assert_eq!(state.owner_pid, Some(owner_pid)); + assert!(state.remote_art_allowed); + assert!(!state.local_art_allowed); +} + +#[tokio::test] +async fn oversized_identity_is_rejected_before_retention() { + let fixture = MprisFixture::start_with_identity_bytes(513).await; + let owner = resolve_player_owner(&fixture.client, TEST_PLAYER_NAME) + .await + .expect("resolve stable test owner"); + + assert_eq!( + fetch_identity(&fixture.client, owner.unique_owner.as_str()).await, + None + ); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn exact_local_art_policy_uses_the_connection_process_fd() { + let fixture = MprisFixture::start().await; + let current_executable = std::env::current_exe().expect("resolve current test executable"); + let config = MediaConfig { + local_art_executable_allowlist: vec![current_executable.display().to_string()], + ..MediaConfig::default() + }; + + let state = build_player_state(&fixture.client, TEST_PLAYER_NAME, &config) + .await + .expect("probe test MPRIS player") + .expect("stable test MPRIS owner"); + + assert!(state.local_art_allowed); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/selection.rs b/crates/unixnotis-center/src/media/mpris/tests/selection.rs new file mode 100644 index 000000000..69999c806 --- /dev/null +++ b/crates/unixnotis-center/src/media/mpris/tests/selection.rs @@ -0,0 +1,121 @@ +use std::collections::HashSet; + +use unixnotis_core::MediaConfig; + +use super::super::selection::{is_discoverable_player, select_player_names}; + +#[test] +fn discovery_requires_an_mpris_name_that_passes_admission() { + let config = MediaConfig { + denylist: vec!["blocked".to_string()], + ..MediaConfig::default() + }; + + assert!(is_discoverable_player( + "org.mpris.MediaPlayer2.allowed", + &config + )); + assert!(!is_discoverable_player("org.example.allowed", &config)); + assert!(!is_discoverable_player( + "org.mpris.MediaPlayer2.blocked", + &config + )); +} + +#[test] +fn discovery_orders_all_names_before_owner_capacity_is_applied() { + let names = (0..48) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 0; + let selected = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!(selected.len(), 48); + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-000") + ); + assert_eq!( + selected.last().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-047") + ); +} + +#[test] +fn discovery_keeps_all_admitted_names_for_owner_resolution() { + let names = (0..32) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + + let mut cursor = 0; + assert_eq!( + select_player_names(names, &HashSet::new(), &mut cursor).len(), + 32 + ); +} + +#[test] +fn discovery_caps_candidate_work_and_rotates_untracked_names() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 0; + let first = select_player_names(names.clone(), &HashSet::new(), &mut cursor); + let second = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!(first.len(), 128); + assert_eq!(second.len(), 128); + assert!(first.iter().all(|name| !second.contains(name))); +} + +#[test] +fn discovery_rotation_wraps_from_a_nonzero_cursor() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let mut cursor = 130; + + let selected = select_player_names(names, &HashSet::new(), &mut cursor); + + assert_eq!( + selected.first().map(String::as_str), + Some("org.mpris.MediaPlayer2.player-130") + ); + assert_eq!(cursor, 2); +} + +#[test] +fn discovery_always_preserves_tracked_names_before_rotation() { + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = HashSet::from([ + "org.mpris.MediaPlayer2.player-255".to_string(), + "org.mpris.MediaPlayer2.player-254".to_string(), + ]); + let mut cursor = 0; + let selected = select_player_names(names, &tracked, &mut cursor); + + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-254")); + assert!(selected + .iter() + .any(|name| name == "org.mpris.MediaPlayer2.player-255")); + assert_eq!(selected.len(), 128); +} + +#[test] +fn discovery_selection_handles_empty_and_full_tracked_pages() { + let mut cursor = 0; + assert!(select_player_names(HashSet::new(), &HashSet::new(), &mut cursor).is_empty()); + + let names = (0..256) + .map(|index| format!("org.mpris.MediaPlayer2.player-{index:03}")) + .collect::>(); + let tracked = names.iter().take(128).cloned().collect::>(); + let selected = select_player_names(names, &tracked, &mut cursor); + + assert_eq!(selected.len(), 128); + assert!(selected.iter().all(|name| tracked.contains(name))); +} diff --git a/crates/unixnotis-center/src/media/mpris/tests/support.rs b/crates/unixnotis-center/src/media/mpris/tests/support.rs index f02a9036c..c451fedb8 100644 --- a/crates/unixnotis-center/src/media/mpris/tests/support.rs +++ b/crates/unixnotis-center/src/media/mpris/tests/support.rs @@ -9,11 +9,33 @@ use zbus::zvariant::OwnedValue; use zbus::{Connection, ConnectionBuilder}; use super::super::constants::MPRIS_PATH; +use super::super::player::{build_player_state_for_owner, resolve_player_owner, PlayerState}; use crate::test_support::broker::read_broker_address; +use unixnotis_core::MediaConfig; pub(in crate::media) const TEST_PLAYER_NAME: &str = "org.mpris.MediaPlayer2.unixnotis_test"; +pub(in crate::media) const TEST_BRIDGE_PLAYER_NAME: &str = + "org.mpris.MediaPlayer2.plasma-browser-integration"; pub(in crate::media) const TEST_PLAYER_IDENTITY: &str = "UnixNotis Test Player"; +pub(in crate::media) async fn build_player_state( + connection: &Connection, + name: &str, + config: &MediaConfig, +) -> zbus::Result> { + // Resolve one stable owner before constructing proxies bound to that owner + let Some(owner) = resolve_player_owner(connection, name).await else { + return Ok(None); + }; + Ok(Some( + build_player_state_for_owner(connection, name, config, owner).await?, + )) +} + +pub(in crate::media) fn fleet_player_name(index: usize) -> String { + format!("org.mpris.MediaPlayer2.unixnotis_fleet_{index:03}") +} + // Parallel fixtures need distinct socket directories even inside one process static NEXT_BROKER: AtomicUsize = AtomicUsize::new(0); @@ -83,14 +105,16 @@ fn broker_socket() -> PathBuf { root.join("bus.sock") } -struct TestMprisRoot; +struct TestMprisRoot { + identity: String, +} #[zbus::interface(name = "org.mpris.MediaPlayer2")] impl TestMprisRoot { #[zbus(property)] - fn identity(&self) -> &'static str { + fn identity(&self) -> &str { // A fixed identity makes player construction assertions deterministic - TEST_PLAYER_IDENTITY + &self.identity } } @@ -103,6 +127,16 @@ struct CommandCounts { struct TestMprisPlayer { commands: Arc, + metadata_bytes: usize, + art_url_bytes: usize, + metadata_pid: Option, + slow_non_status: bool, +} + +async fn delay_non_status_property(slow: bool) { + if slow { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + } } #[zbus::interface(name = "org.mpris.MediaPlayer2.Player")] @@ -121,9 +155,30 @@ impl TestMprisPlayer { } #[zbus(property)] - fn metadata(&self) -> HashMap { - // Empty metadata keeps the fixture focused on transport behavior - HashMap::new() + async fn metadata(&self) -> HashMap { + delay_non_status_property(self.slow_non_status).await; + // The optional payload exercises the raw reply budget without a real player + let mut metadata = HashMap::new(); + if self.metadata_bytes > 0 { + let large_value = "x".repeat(self.metadata_bytes); + metadata.insert( + "test:large".to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(large_value.as_str())) + .expect("build large metadata value"), + ); + } + if self.art_url_bytes > 0 { + let art_url = format!("https://example.com/{}", "x".repeat(self.art_url_bytes)); + metadata.insert( + "mpris:artUrl".to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from(art_url.as_str())) + .expect("build art URL value"), + ); + } + if let Some(pid) = self.metadata_pid { + metadata.insert("kde:pid".to_string(), OwnedValue::from(pid)); + } + metadata } #[zbus(property)] @@ -132,22 +187,26 @@ impl TestMprisPlayer { } #[zbus(property)] - fn can_play(&self) -> bool { + async fn can_play(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_pause(&self) -> bool { + async fn can_pause(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_go_next(&self) -> bool { + async fn can_go_next(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } #[zbus(property)] - fn can_go_previous(&self) -> bool { + async fn can_go_previous(&self) -> bool { + delay_non_status_property(self.slow_non_status).await; true } } @@ -162,25 +221,69 @@ pub(in crate::media) struct MprisFixture { impl MprisFixture { pub(in crate::media) async fn start() -> Self { + Self::start_with_payload(0, 0, 0).await + } + + pub(in crate::media) async fn start_with_metadata_bytes(metadata_bytes: usize) -> Self { + Self::start_with_payload(metadata_bytes, 0, 0).await + } + + pub(in crate::media) async fn start_with_art_url_bytes(art_url_bytes: usize) -> Self { + Self::start_with_payload(0, art_url_bytes, 0).await + } + + pub(in crate::media) async fn start_with_kde_pid(pid: u32) -> Self { + Self::start_with_payload_for_name(TEST_BRIDGE_PLAYER_NAME, 0, 0, 0, Some(pid), false).await + } + + pub(in crate::media) async fn start_with_identity_bytes(identity_bytes: usize) -> Self { + Self::start_with_payload(0, 0, identity_bytes).await + } + + pub(in crate::media) async fn start_with_slow_non_status_properties() -> Self { + Self::start_with_payload_for_name(TEST_PLAYER_NAME, 0, 0, 0, None, true).await + } + + async fn start_with_payload( + metadata_bytes: usize, + art_url_bytes: usize, + identity_bytes: usize, + ) -> Self { + Self::start_with_payload_for_name( + TEST_PLAYER_NAME, + metadata_bytes, + art_url_bytes, + identity_bytes, + None, + false, + ) + .await + } + + async fn start_with_payload_for_name( + name: &str, + metadata_bytes: usize, + art_url_bytes: usize, + identity_bytes: usize, + metadata_pid: Option, + slow_non_status: bool, + ) -> Self { let broker = PrivateBroker::start(); - let commands = Arc::new(CommandCounts::default()); - // The service exports both MPRIS interfaces at the standard object path - let server = ConnectionBuilder::address(broker.address.as_str()) - .expect("parse private broker address") - .name(TEST_PLAYER_NAME) - .expect("request test MPRIS name") - .serve_at(MPRIS_PATH, TestMprisRoot) - .expect("register test MPRIS root") - .serve_at( - MPRIS_PATH, - TestMprisPlayer { - commands: commands.clone(), - }, - ) - .expect("register test MPRIS player") - .build() - .await - .expect("connect test MPRIS service"); + let identity = if identity_bytes == 0 { + TEST_PLAYER_IDENTITY.to_string() + } else { + "x".repeat(identity_bytes) + }; + let (server, commands) = build_test_player_service( + &broker.address, + name, + identity, + metadata_bytes, + art_url_bytes, + metadata_pid, + slow_non_status, + ) + .await; // A separate client connection exercises normal bus routing and owner lookup let client = ConnectionBuilder::address(broker.address.as_str()) .expect("parse private broker address") @@ -216,3 +319,73 @@ impl MprisFixture { .expect("emit playback status change"); } } + +pub(in crate::media) struct MprisFleetFixture { + pub(in crate::media) client: Connection, + servers: Vec, + broker: PrivateBroker, +} + +impl MprisFleetFixture { + pub(in crate::media) async fn start(player_count: usize) -> Self { + let broker = PrivateBroker::start(); + let client = ConnectionBuilder::address(broker.address.as_str()) + .expect("parse private broker address") + .build() + .await + .expect("connect fleet test client"); + let mut fixture = Self { + client, + servers: Vec::with_capacity(player_count.saturating_add(1)), + broker, + }; + for index in 0..player_count { + fixture.add_player(index).await; + } + fixture + } + + pub(in crate::media) async fn add_player(&mut self, index: usize) { + let name = fleet_player_name(index); + let identity = format!("UnixNotis Fleet Player {index:03}"); + let (server, _commands) = + build_test_player_service(&self.broker.address, &name, identity, 0, 0, None, false) + .await; + // Keeping the connection alive preserves the unique owner and all exported interfaces + self.servers.push(server); + } +} + +async fn build_test_player_service( + address: &str, + name: &str, + identity: String, + metadata_bytes: usize, + art_url_bytes: usize, + metadata_pid: Option, + slow_non_status: bool, +) -> (Connection, Arc) { + let commands = Arc::new(CommandCounts::default()); + // The service exports both MPRIS interfaces at the standard object path + let server = ConnectionBuilder::address(address) + .expect("parse private broker address") + .name(name) + .expect("request test MPRIS name") + .serve_at(MPRIS_PATH, TestMprisRoot { identity }) + .expect("register test MPRIS root") + .serve_at( + MPRIS_PATH, + TestMprisPlayer { + commands: commands.clone(), + metadata_bytes, + art_url_bytes, + metadata_pid, + slow_non_status, + }, + ) + .expect("register test MPRIS player") + .build() + .await + .expect("connect test MPRIS service"); + (server, commands) +} diff --git a/crates/unixnotis-center/src/media/runtime/cache.rs b/crates/unixnotis-center/src/media/runtime/cache.rs index 1e53613f8..291660a1b 100644 --- a/crates/unixnotis-center/src/media/runtime/cache.rs +++ b/crates/unixnotis-center/src/media/runtime/cache.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use futures_util::stream::{self, StreamExt}; + use super::super::mpris::{fetch_media_info, PlayerState}; use super::super::MediaInfo; @@ -18,16 +20,23 @@ pub(super) async fn refresh_cache( // Move the old cache out so the merge path can reuse prior snapshots // without cloning the whole map on every refresh let previous = std::mem::take(cache); + let results = stream::iter(players.values().cloned()) + .map(|state| async move { + let info = fetch_media_info(&state).await; + (state.bus_name.clone(), info) + }) + .buffer_unordered(4) + .collect::>() + .await; let mut next = HashMap::with_capacity(players.len()); - for state in players.values() { - // A transient DBus read error should not blank a live player card - // Keep the last good snapshot until a fresh read succeeds or the player disappears + for (bus_name, fetched) in results { + // A transient D-Bus read error should not blank a live player card if let Some(info) = merge_media_info( - previous.get(&state.bus_name), - fetch_media_info(state).await, + previous.get(&bus_name), + fetched, MediaCacheMergeMode::Stable, ) { - next.insert(state.bus_name.clone(), info); + next.insert(bus_name, info); } } *cache = next; @@ -83,6 +92,11 @@ fn preserve_transition_fields(existing: &MediaInfo, mut fetched: MediaInfo) -> M fetched.art_source = existing.art_source.clone(); } + if fetched.source_pid_hint.is_none() && existing.source_pid_hint.is_some() { + // Bridge hints can arrive one refresh after the rest of the track metadata + fetched.source_pid_hint = existing.source_pid_hint; + } + if metadata_is_blank(&fetched) && metadata_has_content(existing) { // A blank transition frame is worse than holding the prior text for one retry window fetched.title = existing.title.clone(); diff --git a/crates/unixnotis-center/src/media/runtime/dispatch.rs b/crates/unixnotis-center/src/media/runtime/dispatch.rs index 5245e42af..6dd3b23a1 100644 --- a/crates/unixnotis-center/src/media/runtime/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/dispatch.rs @@ -44,10 +44,10 @@ pub(super) async fn handle_runtime_signal( state: &mut MediaRuntimeState, signal_tx: &mpsc::Sender, sender: &async_channel::Sender, - signal: MediaSignal, + bus_name: String, + origin: MediaRefreshOrigin, ) { // Signal payloads name the one player that changed, avoiding a full cache rebuild - let MediaSignal::PropertiesChanged { bus_name, origin } = signal; refresh_player_cache( &state.players, &mut state.cache, diff --git a/crates/unixnotis-center/src/media/runtime/loop.rs b/crates/unixnotis-center/src/media/runtime/loop.rs index 0c8d1d2a8..b5c7d51bb 100644 --- a/crates/unixnotis-center/src/media/runtime/loop.rs +++ b/crates/unixnotis-center/src/media/runtime/loop.rs @@ -120,7 +120,24 @@ async fn run_connection_once( // Property listeners belong to this connection and must be rebuilt together return false; }; - handle_runtime_signal(&mut state, &signal_tx, sender, signal).await; + match signal { + MediaSignal::FairnessLeaseExpired { generation } => { + // Only the current lease may request a full discovery pass + if state.mpris_fairness.consume_wakeup(generation) { + refresh = true; + } + } + MediaSignal::PropertiesChanged { bus_name, origin } => { + handle_runtime_signal( + &mut state, + &signal_tx, + sender, + bus_name, + origin, + ) + .await; + } + } } retry = owner_retry_rx.recv() => { if retry.is_none() { diff --git a/crates/unixnotis-center/src/media/runtime/mod.rs b/crates/unixnotis-center/src/media/runtime/mod.rs index 58f22f12a..417f3b590 100644 --- a/crates/unixnotis-center/src/media/runtime/mod.rs +++ b/crates/unixnotis-center/src/media/runtime/mod.rs @@ -1,4 +1,4 @@ -//! Media task startup and runtime orchestration +//! Media runtime module wiring mod cache; mod dispatch; @@ -6,76 +6,15 @@ mod r#loop; mod owner; mod refresh; mod schedule; +mod signal; mod snapshot; +mod startup; mod state; -use tokio::sync::mpsc; -use unixnotis_core::MediaConfig; +pub(super) use signal::{MediaRefreshOrigin, MediaSignal}; +pub(super) use startup::start_media_task; -use crate::control::UiEvent; - -use super::api::MediaHandle; - -pub(super) const MEDIA_COMMAND_CAPACITY: usize = 32; pub(super) const MEDIA_SIGNAL_CAPACITY: usize = 256; -pub(super) fn start_media_task( - runtime: &tokio::runtime::Handle, - config: MediaConfig, - sender: async_channel::Sender, -) -> Option { - if !config.enabled { - // Disabled media means no background work and no command channel - return None; - } - - // Lowercase tokens once so the hot path can stay allocation-free - let config = normalize_media_config(config); - // The command channel stays small because button presses arrive in short bursts - let (command_tx, command_rx) = mpsc::channel(MEDIA_COMMAND_CAPACITY); - // The runtime task owns player state and feeds snapshots back to the UI - runtime.spawn(r#loop::run_event_loop(config, sender, command_rx)); - - Some(MediaHandle::connected(command_tx, runtime.clone())) -} - -fn normalize_media_config(mut config: MediaConfig) -> MediaConfig { - // Lowercase these token lists once so the hot path can use plain contains checks - config.allowlist = config - .allowlist - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - // Browser family matching uses the same lowercase path - config.browser_tokens = config - .browser_tokens - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - // Denylist entries follow the same normalized form - config.denylist = config - .denylist - .into_iter() - .map(|entry| entry.to_lowercase()) - .collect(); - config -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum MediaRefreshOrigin { - // Native bus traffic can justify one bounded fallback sweep - Bus, - // Synthetic retries never re-arm themselves because that would become polling - Fallback, -} - -#[derive(Debug)] -pub(super) enum MediaSignal { - PropertiesChanged { - bus_name: String, - origin: MediaRefreshOrigin, - }, -} - #[cfg(test)] mod tests; diff --git a/crates/unixnotis-center/src/media/runtime/owner.rs b/crates/unixnotis-center/src/media/runtime/owner.rs index b3bf68169..8a61a8d5a 100644 --- a/crates/unixnotis-center/src/media/runtime/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/owner.rs @@ -4,15 +4,12 @@ use tokio::sync::mpsc; use unixnotis_core::MediaConfig; use zbus::Connection; -use super::cache::{refresh_player_cache, MediaCacheMergeMode}; -use super::schedule::{cancel_delayed_refresh, schedule_metadata_fallback}; +use super::schedule::cancel_delayed_refresh; use super::snapshot::send_snapshot_if_changed; use super::state::MediaRuntimeState; use super::MediaSignal; use crate::control::UiEvent; -use crate::media::mpris::{ - build_player_state, is_allowed_player, spawn_properties_listener, MPRIS_PREFIX, -}; +use crate::media::mpris::{is_allowed_player, MPRIS_PREFIX}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum OwnerChangeOutcome { @@ -27,9 +24,9 @@ pub(super) enum OwnerChangeOutcome { pub(super) async fn apply_owner_change( name: &str, new_owner: Option<&str>, - connection: &Connection, + _connection: &Connection, config: &MediaConfig, - signal_tx: &mpsc::Sender, + _signal_tx: &mpsc::Sender, state: &mut MediaRuntimeState, sender: &async_channel::Sender, ) -> zbus::Result { @@ -70,48 +67,20 @@ pub(super) async fn apply_owner_change( false }; - let rebuilt = build_player_state(connection, name, config).await; - if let Ok(Some(player_state)) = rebuilt.as_ref() { - // Start the listener before publishing state so late property traffic is retained - spawn_properties_listener( - player_state.properties.clone(), - name.to_string(), - signal_tx.clone(), - player_state.listener_cancel.subscribe(), - ); - state.players.insert(name.to_string(), player_state.clone()); - refresh_player_cache( - &state.players, - &mut state.cache, - name, - MediaCacheMergeMode::Stable, - ) - .await; + if removed_previous { + // Rebuilding is deferred to one coalesced bounded discovery pass send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - schedule_metadata_fallback( - &mut state.delayed_refreshes, - &state.cache, - signal_tx.clone(), - name, - ); } - - // Removing a prior cache must reach GTK even when replacement probing fails - let outcome = match rebuilt { - Ok(state) => owner_rebuild_outcome(state.is_some()), - Err(err) => { - if removed_previous { - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - return Err(err); - } - }; - if replacement_removal_needs_snapshot(removed_previous, outcome) { - send_snapshot_if_changed(sender, &state.cache, &mut state.last_snapshot).await; - } - Ok(outcome) + Ok(OwnerChangeOutcome::RetryNeeded) } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "outcome helpers document and test refresh semantics" + ) +)] pub(super) const fn owner_rebuild_outcome(rebuilt: bool) -> OwnerChangeOutcome { if rebuilt { OwnerChangeOutcome::Applied @@ -120,6 +89,13 @@ pub(super) const fn owner_rebuild_outcome(rebuilt: bool) -> OwnerChangeOutcome { } } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "outcome helpers document and test refresh semantics" + ) +)] pub(super) const fn replacement_removal_needs_snapshot( removed_previous: bool, outcome: OwnerChangeOutcome, @@ -134,6 +110,22 @@ pub(super) fn owner_is_unchanged( current_owner.is_some() && current_owner == announced_owner } +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "alias deduplication rule remains covered by runtime tests" + ) +)] +pub(super) fn owner_is_duplicate( + existing_name: &str, + requested_name: &str, + existing_owner: Option<&str>, + requested_owner: Option<&str>, +) -> bool { + existing_name != requested_name && existing_owner == requested_owner +} + async fn remove_player( name: &str, state: &mut MediaRuntimeState, diff --git a/crates/unixnotis-center/src/media/runtime/refresh.rs b/crates/unixnotis-center/src/media/runtime/refresh.rs index 6eeba4a62..1b3135ca5 100644 --- a/crates/unixnotis-center/src/media/runtime/refresh.rs +++ b/crates/unixnotis-center/src/media/runtime/refresh.rs @@ -31,6 +31,8 @@ pub(super) async fn refresh_all_players( config, signal_tx, &mut state.players, + &mut state.discovery_cursor, + &mut state.mpris_fairness, ) .await { diff --git a/crates/unixnotis-center/src/media/runtime/signal.rs b/crates/unixnotis-center/src/media/runtime/signal.rs new file mode 100644 index 000000000..cc65773ac --- /dev/null +++ b/crates/unixnotis-center/src/media/runtime/signal.rs @@ -0,0 +1,20 @@ +//! Internal signals that drive bounded media refresh work + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::media) enum MediaRefreshOrigin { + // Native bus traffic can justify one bounded fallback sweep + Bus, + // Synthetic retries never re-arm themselves because that would become polling + Fallback, +} + +#[derive(Debug)] +pub(in crate::media) enum MediaSignal { + PropertiesChanged { + bus_name: String, + origin: MediaRefreshOrigin, + }, + FairnessLeaseExpired { + generation: u64, + }, +} diff --git a/crates/unixnotis-center/src/media/runtime/snapshot.rs b/crates/unixnotis-center/src/media/runtime/snapshot.rs index b3c78bb28..238edab89 100644 --- a/crates/unixnotis-center/src/media/runtime/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/snapshot.rs @@ -5,7 +5,7 @@ use tracing::debug; use crate::control::UiEvent; -use crate::media::MediaInfo; +use crate::media::{mpris::is_plasma_browser_bridge, MediaInfo}; pub(super) async fn send_snapshot_if_changed( sender: &Sender, @@ -18,7 +18,7 @@ pub(super) async fn send_snapshot_if_changed( // Identical snapshots do not need another UI event or list rebuild path return; } - *last_snapshot = snapshot.clone(); + last_snapshot.clone_from(&snapshot); if snapshot.is_empty() { if let Err(err) = sender.send(UiEvent::MediaCleared).await { // Closed UI channels are normal during teardown, but the drop should stay visible @@ -39,31 +39,19 @@ pub(super) fn build_snapshot(cache: &HashMap) -> Vec u8 { - match status { - "Playing" => 0, - "Paused" => 1, - _ => 2, - } + u8::from(status != "Playing") } fn is_active_player(info: &MediaInfo) -> bool { @@ -72,64 +60,122 @@ fn is_active_player(info: &MediaInfo) -> bool { } fn dedupe_players(infos: Vec) -> Vec { - let mut output: Vec = Vec::with_capacity(infos.len()); - let mut seen: HashMap = HashMap::new(); - for info in infos { - let Some(key) = dedupe_key(&info) else { - output.push(info); - continue; - }; - if let Some(existing_index) = seen.get(&key).copied() { - let existing = &output[existing_index]; - // Lower score wins, so a playing player with art beats a paused - // or artless duplicate from the same browser family or track key - if media_score(&info) < media_score(existing) { - output[existing_index] = info; + // A player can share one key with one group and another key with a second + // group, so pairwise replacement is not enough. Build connected components + // first, then choose one deterministic representative per component + let mut parents = (0..infos.len()).collect::>(); + let mut key_owner = HashMap::::new(); + for (index, info) in infos.iter().enumerate() { + for key in dedupe_keys(info) { + if let Some(previous) = key_owner.insert(key, index) { + union(&mut parents, previous, index); } - continue; } - seen.insert(key, output.len()); - output.push(info); } - output + + let mut representatives = HashMap::::new(); + for index in 0..infos.len() { + let root = find(&mut parents, index); + representatives + .entry(root) + .and_modify(|selection| { + selection.first_index = selection.first_index.min(index); + if representative_precedes(&infos[index], &infos[selection.representative_index]) { + selection.representative_index = index; + } + }) + .or_insert(ComponentSelection { + first_index: index, + representative_index: index, + }); + } + + let mut selected = representatives.into_values().collect::>(); + selected.sort_unstable_by_key(|selection| selection.first_index); + selected + .into_iter() + .map(|selection| infos[selection.representative_index].clone()) + .collect() } -fn dedupe_key(info: &MediaInfo) -> Option { - let title = info.title.trim(); - if let Some(family) = info.browser_family.as_deref() { - if let Some(pid) = info.owner_pid { - // Browser bridges can publish the same tab under different MPRIS names - // The source PID is the strongest signal that both cards mirror one source - return Some(format!("browser-pid:{pid}")); +struct ComponentSelection { + // Preserve the first component position even when a later player is the best card + first_index: usize, + // Artwork and playback state choose the representative shown to the user + representative_index: usize, +} + +fn find(parents: &mut [usize], index: usize) -> usize { + if parents[index] == index { + return index; + } + let root = find(parents, parents[index]); + parents[index] = root; + root +} + +fn union(parents: &mut [usize], left: usize, right: usize) { + let left = find(parents, left); + let right = find(parents, right); + if left != right { + parents[right] = left; + } +} + +fn representative_precedes(candidate: &MediaInfo, current: &MediaInfo) -> bool { + media_score(candidate) < media_score(current) + || (media_score(candidate) == media_score(current) && candidate.bus_name < current.bus_name) +} + +fn dedupe_keys(info: &MediaInfo) -> Vec { + let has_browser_process_identity = + info.browser_family.is_some() || info.source_pid_hint.is_some(); + if has_browser_process_identity { + // A bridge helper owns several sessions, so its PID is not the browser identity + if let Some(pid) = browser_process_pid(info) { + return vec![format!("browser-process:{pid}")]; } - if !title.is_empty() { - // Browser-backed players can expose one webpage through multiple MPRIS names - // Track metadata is the stable key across Brave, Chromium, and browser instances - let artist = info.artist.trim(); - return Some(format!( + + let title = info.title.trim(); + let artist = info.artist.trim(); + if !title.is_empty() && !artist.is_empty() { + // Metadata is only a fallback when no process identity exists + return vec![format!( "browser-track\n{}\n{}", normalize_token(title), - normalize_token(artist) - )); + normalize_token(artist), + )]; } - // Empty browser metadata is too weak for cross-browser matching - // Keep the old family fallback so duplicate instances still collapse - return Some(format!("browser:{family}")); + // A family name alone is not a track identity + return Vec::new(); } + let title = info.title.trim(); if title.is_empty() { // Empty titles are too weak to build a stable cross-player key - return None; + return Vec::new(); } let artist = info.artist.trim(); let identity = info.identity.trim(); let normalized_title = normalize_token(title); let normalized_artist = normalize_token(artist); - Some(format!( + vec![format!( "{}\n{}\n{}", normalize_token(identity), normalized_title, normalized_artist - )) + )] +} + +fn browser_process_pid(info: &MediaInfo) -> Option { + if let Some(source_pid) = info.source_pid_hint { + // kde:pid identifies the browser that supplied the bridge metadata + return Some(source_pid); + } + if is_plasma_browser_bridge(&info.bus_name) { + // The authenticated owner is only the shared bridge helper + return None; + } + info.owner_pid } fn media_score(info: &MediaInfo) -> (u8, u8) { diff --git a/crates/unixnotis-center/src/media/runtime/startup.rs b/crates/unixnotis-center/src/media/runtime/startup.rs new file mode 100644 index 000000000..83ca39421 --- /dev/null +++ b/crates/unixnotis-center/src/media/runtime/startup.rs @@ -0,0 +1,52 @@ +//! Media runtime startup and one-time configuration normalization + +use tokio::sync::mpsc; +use unixnotis_core::MediaConfig; + +use crate::control::UiEvent; + +use super::super::api::MediaHandle; + +const MEDIA_COMMAND_CAPACITY: usize = 32; + +pub(in crate::media) fn start_media_task( + runtime: &tokio::runtime::Handle, + config: MediaConfig, + sender: async_channel::Sender, +) -> Option { + if !config.enabled { + // Disabled media means no background work and no command channel + return None; + } + + // Lowercase tokens once so the hot path can stay allocation-free + let config = normalize_media_config(config); + // The command channel stays small because button presses arrive in short bursts + let (command_tx, command_rx) = mpsc::channel(MEDIA_COMMAND_CAPACITY); + // The runtime task owns player state and feeds snapshots back to the UI + runtime.spawn(super::r#loop::run_event_loop(config, sender, command_rx)); + + Some(MediaHandle::connected(command_tx, runtime.clone())) +} + +pub(super) fn normalize_media_config(mut config: MediaConfig) -> MediaConfig { + // Lowercase these token lists once so the hot path can use plain contains checks + config.allowlist = config + .allowlist + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + // Browser family matching uses the same lowercase path + config.browser_tokens = config + .browser_tokens + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + // Denylist entries follow the same normalized form + config.denylist = config + .denylist + .into_iter() + .map(|entry| entry.to_lowercase()) + .collect(); + config +} diff --git a/crates/unixnotis-center/src/media/runtime/state.rs b/crates/unixnotis-center/src/media/runtime/state.rs index 714658176..7823d49f2 100644 --- a/crates/unixnotis-center/src/media/runtime/state.rs +++ b/crates/unixnotis-center/src/media/runtime/state.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use super::schedule::DelayedRefreshTasks; -use crate::media::mpris::PlayerState; +use crate::media::mpris::{MprisFairnessState, PlayerState}; use crate::media::MediaInfo; pub(super) struct MediaRuntimeState { @@ -15,6 +15,10 @@ pub(super) struct MediaRuntimeState { pub(super) last_snapshot: Vec, // One delayed retry plan per player pub(super) delayed_refreshes: DelayedRefreshTasks, + // Rotates bounded candidate probes so names outside the first sorted page get a turn + pub(super) discovery_cursor: usize, + // A monotonic lease wakes quiet full-capacity inventories without polling + pub(super) mpris_fairness: MprisFairnessState, } impl MediaRuntimeState { @@ -25,6 +29,20 @@ impl MediaRuntimeState { cache: HashMap::new(), last_snapshot: Vec::new(), delayed_refreshes: HashMap::new(), + discovery_cursor: 0, + mpris_fairness: MprisFairnessState::new(), + } + } +} + +impl Drop for MediaRuntimeState { + fn drop(&mut self) { + // Connection teardown must cancel delayed work instead of detaching it + for task in self.delayed_refreshes.drain().map(|(_, task)| task) { + task.abort(); + } + for player in self.players.values() { + let _ = player.listener_cancel.send(true); } } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/cache.rs b/crates/unixnotis-center/src/media/runtime/tests/cache.rs index b63b38ac2..f188e805a 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/cache.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/cache.rs @@ -13,6 +13,7 @@ fn make_info( identity: identity.to_string(), browser_family: browser_family.map(std::string::ToString::to_string), owner_pid: None, + source_pid_hint: None, title: "title".to_string(), artist: "artist".to_string(), playback_status: playback_status.to_string(), diff --git a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs index 91270e6de..b55fbae62 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/dispatch.rs @@ -7,8 +7,7 @@ use super::super::state::MediaRuntimeState; use super::super::{MediaRefreshOrigin, MediaSignal}; use super::support::receive_ui_event; use crate::control::UiEvent; -use crate::media::mpris::build_player_state; -use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; +use crate::media::mpris::tests::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use crate::media::{MediaCommand, MediaInfo}; use unixnotis_core::MediaConfig; @@ -19,7 +18,9 @@ fn property_signal_preserves_player_and_refresh_origin() { origin: MediaRefreshOrigin::Fallback, }; - let MediaSignal::PropertiesChanged { bus_name, origin } = signal; + let MediaSignal::PropertiesChanged { bus_name, origin } = signal else { + panic!("property test signal changed variant"); + }; assert_eq!(bus_name, "org.mpris.MediaPlayer2.test"); assert_eq!(origin, MediaRefreshOrigin::Fallback); } @@ -93,7 +94,7 @@ async fn runtime_command_dispatches_and_schedules_a_targeted_refresh() { assert_eq!(fixture.next_calls(), 1); assert!(state.delayed_refreshes.contains_key(TEST_PLAYER_NAME)); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } @@ -116,10 +117,8 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { &mut state, &signal_tx, &event_tx, - MediaSignal::PropertiesChanged { - bus_name: TEST_PLAYER_NAME.to_string(), - origin: MediaRefreshOrigin::Bus, - }, + TEST_PLAYER_NAME.to_string(), + MediaRefreshOrigin::Bus, ) .await; @@ -135,6 +134,7 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { identity: "UnixNotis Test Player".to_string(), browser_family: None, owner_pid: Some(std::process::id()), + source_pid_hint: None, title: String::new(), artist: String::new(), playback_status: "Playing".to_string(), @@ -146,7 +146,7 @@ async fn runtime_signal_refreshes_cache_publishes_and_schedules_fallback() { }] )); let _ = cancel_tx.send(true); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/owner.rs b/crates/unixnotis-center/src/media/runtime/tests/owner.rs index f191e8a7e..9532e46c5 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/owner.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/owner.rs @@ -1,12 +1,12 @@ use super::super::owner::{ - apply_owner_change, owner_is_unchanged, owner_rebuild_outcome, + apply_owner_change, owner_is_duplicate, owner_is_unchanged, owner_rebuild_outcome, replacement_removal_needs_snapshot, OwnerChangeOutcome, }; use super::super::state::MediaRuntimeState; use super::support::receive_ui_event; use crate::control::UiEvent; -use crate::media::mpris::tests::support::{MprisFixture, TEST_PLAYER_NAME}; -use crate::media::mpris::{build_player_state, fetch_media_info}; +use crate::media::mpris::fetch_media_info; +use crate::media::mpris::tests::support::{build_player_state, MprisFixture, TEST_PLAYER_NAME}; use unixnotis_core::MediaConfig; async fn live_runtime_state(fixture: &MprisFixture) -> MediaRuntimeState { @@ -50,6 +50,28 @@ fn stable_owner_rebuild_does_not_publish_an_empty_replacement_snapshot() { assert!(!replacement_removal_needs_snapshot(true, outcome)); } +#[test] +fn owner_duplicate_check_requires_a_different_name_and_same_owner() { + assert!(owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.two", + Some(":1.42"), + Some(":1.42"), + )); + assert!(!owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.one", + Some(":1.42"), + Some(":1.42"), + )); + assert!(!owner_is_duplicate( + "org.mpris.MediaPlayer2.one", + "org.mpris.MediaPlayer2.two", + Some(":1.42"), + Some(":1.43"), + )); +} + #[tokio::test] async fn unrelated_owner_change_is_ignored() { let fixture = MprisFixture::start().await; @@ -162,3 +184,26 @@ async fn duplicate_owner_change_preserves_existing_player() { assert!(state.players.contains_key(TEST_PLAYER_NAME)); assert!(event_rx.is_empty()); } + +#[tokio::test] +async fn replacement_owner_change_defers_full_probe_to_coalesced_refresh() { + let fixture = MprisFixture::start().await; + let (signal_tx, _signal_rx) = tokio::sync::mpsc::channel(4); + let (event_tx, _event_rx) = async_channel::bounded(4); + let mut state = live_runtime_state(&fixture).await; + + let outcome = apply_owner_change( + TEST_PLAYER_NAME, + Some(":1.replacement"), + &fixture.client, + &MediaConfig::default(), + &signal_tx, + &mut state, + &event_tx, + ) + .await + .expect("defer replacement probe"); + + assert_eq!(outcome, OwnerChangeOutcome::RetryNeeded); + assert!(state.players.is_empty()); +} diff --git a/crates/unixnotis-center/src/media/runtime/tests/refresh.rs b/crates/unixnotis-center/src/media/runtime/tests/refresh.rs index e21ae315c..82e6af1da 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/refresh.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/refresh.rs @@ -56,7 +56,7 @@ async fn full_refresh_discovers_caches_and_publishes_live_players() { UiEvent::MediaUpdated(infos) if infos[0].bus_name == TEST_PLAYER_NAME )); let _ = state.players[TEST_PLAYER_NAME].listener_cancel.send(true); - for (_, task) in state.delayed_refreshes { + for task in state.delayed_refreshes.values_mut() { task.abort(); } } diff --git a/crates/unixnotis-center/src/media/runtime/tests/schedule.rs b/crates/unixnotis-center/src/media/runtime/tests/schedule.rs index 7bdf15b0a..9ad2d4f34 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/schedule.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/schedule.rs @@ -13,6 +13,7 @@ fn make_info(status: &str) -> MediaInfo { identity: "Spotify".to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "track".to_string(), artist: "artist".to_string(), playback_status: status.to_string(), diff --git a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs index 34c81012a..4bed17853 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/snapshot.rs @@ -8,6 +8,8 @@ use super::support::receive_ui_event; use crate::control::UiEvent; use crate::media::{MediaArtSource, MediaInfo}; +const SOURCE_BROWSER_PID: u32 = 42_424; + fn make_info( bus_name: &str, identity: &str, @@ -21,6 +23,7 @@ fn make_info( identity: identity.to_string(), browser_family: browser_family.map(std::string::ToString::to_string), owner_pid, + source_pid_hint: None, title: "title".to_string(), artist: "artist".to_string(), playback_status: playback_status.to_string(), @@ -74,6 +77,37 @@ fn build_snapshot_sorts_by_status_then_identity() { assert_eq!(identities, vec!["Alpha", "Beta", "Zeta"]); } +#[test] +fn build_snapshot_keeps_paused_players_before_inactive_sessions() { + let mut cache = HashMap::new(); + cache.insert( + "org.mpris.MediaPlayer2.stopped".to_string(), + make_info( + "org.mpris.MediaPlayer2.stopped", + "Stopped", + "Stopped", + false, + None, + None, + ), + ); + cache.insert( + "org.mpris.MediaPlayer2.paused".to_string(), + make_info( + "org.mpris.MediaPlayer2.paused", + "Paused", + "Paused", + false, + None, + None, + ), + ); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Paused"); +} + #[test] fn build_snapshot_dedupes_browser_family_by_score() { let mut cache = HashMap::new(); @@ -114,7 +148,7 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { "Playing", false, Some("brave"), - Some(103_380), + Some(SOURCE_BROWSER_PID), ); brave.title = "Rumble".to_string(); brave.artist.clear(); @@ -124,11 +158,11 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { "Playing", true, Some("chromium"), - Some(103_380), + Some(22), ); - plasma_bridge.title = - "LA Mayor Karen Bass suffers POLITICAL EXPLOSION as DEMS CRY RACISM".to_string(); - plasma_bridge.artist = "DeVory Darkins".to_string(); + plasma_bridge.source_pid_hint = Some(SOURCE_BROWSER_PID); + plasma_bridge.title = "A Long Tutorial With Several Chapters".to_string(); + plasma_bridge.artist = "Example Artist".to_string(); cache.insert(brave.bus_name.clone(), brave); cache.insert(plasma_bridge.bus_name.clone(), plasma_bridge); @@ -137,6 +171,39 @@ fn build_snapshot_dedupes_browser_bridge_with_same_source_pid() { assert_eq!(snapshot[0].identity, "Chromium"); } +#[test] +fn source_pid_hint_dedupes_when_bridge_family_is_unresolved() { + let browser_pid = 42_424; + + let direct = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave", + "Playing", + false, + Some("brave"), + Some(browser_pid), + ); + + let mut bridge = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration", + "Plasma Browser Integration", + "Playing", + true, + None, + Some(2_400), + ); + bridge.source_pid_hint = Some(browser_pid); + bridge.title = "Completely different bridge metadata".to_string(); + bridge.artist = "Different artist".to_string(); + + let cache = HashMap::from([ + (direct.bus_name.clone(), direct), + (bridge.bus_name.clone(), bridge), + ]); + + assert_eq!(build_snapshot(&cache).len(), 1); +} + #[test] fn build_snapshot_keeps_distinct_browser_tracks() { let mut cache = HashMap::new(); @@ -165,6 +232,443 @@ fn build_snapshot_keeps_distinct_browser_tracks() { assert_eq!(snapshot.len(), 2); } +#[test] +fn identical_browser_metadata_with_different_processes_remains_separate() { + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "Shared Video".to_string(); + first.artist = "Shared Creator".to_string(); + + let mut second = make_info( + "org.mpris.MediaPlayer2.second", + "Second", + "Playing", + false, + Some("second"), + Some(22), + ); + second.title = first.title.clone(); + second.artist = first.artist.clone(); + + let mut cache = HashMap::new(); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); +} + +#[test] +fn empty_browser_artist_does_not_create_a_cross_browser_track_key() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "Generic stream".to_string(); + first.artist.clear(); + let mut second = make_info( + "org.mpris.MediaPlayer2.second", + "Second", + "Playing", + false, + Some("second"), + Some(22), + ); + second.title = first.title.clone(); + second.artist.clear(); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + assert_eq!(build_snapshot(&cache).len(), 2); +} + +#[test] +fn build_snapshot_collapses_same_track_across_browser_families() { + let mut cache = HashMap::new(); + let mut chromium = make_info( + "org.mpris.MediaPlayer2.chromium.instance", + "Chromium", + "Playing", + false, + Some("chromium"), + None, + ); + chromium.title = "The Thing 1982 - What does it mean".to_string(); + chromium.artist = "That Scouse Dude".to_string(); + let mut brave = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave", + "Playing", + true, + Some("brave"), + None, + ); + brave.title = chromium.title.clone(); + brave.artist = chromium.artist.clone(); + cache.insert(chromium.bus_name.clone(), chromium); + cache.insert(brave.bus_name.clone(), brave); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Brave"); + assert!(snapshot[0].art_source.is_some()); +} + +#[test] +fn distinct_bridge_sources_owned_by_one_helper_remain_separate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.first", + "First bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + first.source_pid_hint = Some(11_000); + + let mut second = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.second", + "Second bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + second.source_pid_hint = Some(22_000); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); + assert_eq!( + snapshot + .iter() + .map(|info| info.identity.as_str()) + .collect::>(), + vec!["First bridge", "Second bridge"] + ); +} + +#[test] +fn browser_sources_match_direct_players_without_crossjoining_a_shared_helper() { + let mut cache = HashMap::new(); + let mut direct_first = make_info( + "org.mpris.MediaPlayer2.first", + "First browser", + "Playing", + false, + Some("first"), + Some(11_000), + ); + direct_first.title = "First track".to_string(); + direct_first.artist = "First artist".to_string(); + + let mut bridge_first = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.first", + "First bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + bridge_first.source_pid_hint = Some(11_000); + bridge_first.title = "Different bridge metadata".to_string(); + bridge_first.artist = "Different bridge artist".to_string(); + + let mut direct_second = make_info( + "org.mpris.MediaPlayer2.second", + "Second browser", + "Playing", + false, + Some("second"), + Some(22_000), + ); + direct_second.title = "Second track".to_string(); + direct_second.artist = "Second artist".to_string(); + + let mut bridge_second = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration.second", + "Second bridge", + "Playing", + true, + Some("chromium"), + Some(2_400), + ); + bridge_second.source_pid_hint = Some(22_000); + bridge_second.title = "Another bridge metadata".to_string(); + bridge_second.artist = "Another bridge artist".to_string(); + + for info in [direct_first, bridge_first, direct_second, bridge_second] { + cache.insert(info.bus_name.clone(), info); + } + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 2); + assert_eq!( + snapshot + .iter() + .map(|info| info.identity.as_str()) + .collect::>(), + vec!["First bridge", "Second bridge"] + ); +} + +#[test] +fn browser_track_key_requires_artist_to_avoid_generic_title_collisions() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + None, + ); + first.title = "YouTube".to_string(); + first.artist.clear(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + assert_eq!(build_snapshot(&cache).len(), 2); +} + +#[test] +fn browser_source_pid_bridges_different_metadata() { + let mut cache = HashMap::new(); + let mut brave = make_info( + "org.mpris.MediaPlayer2.brave.instance", + "Brave Origin", + "Playing", + false, + Some("brave"), + Some(SOURCE_BROWSER_PID), + ); + brave.title = "A Long Tutorial With Several Chapters - YouTube".to_string(); + brave.artist.clear(); + + let mut bridge = make_info( + "org.mpris.MediaPlayer2.plasma-browser-integration", + "Chromium", + "Paused", + true, + Some("chromium"), + Some(22), + ); + bridge.source_pid_hint = Some(SOURCE_BROWSER_PID); + bridge.title = "A Long Tutorial With Several Chapters".to_string(); + bridge.artist = "Example Artist".to_string(); + + cache.insert(brave.bus_name.clone(), brave); + cache.insert(bridge.bus_name.clone(), bridge); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Brave Origin"); +} + +#[test] +fn browser_players_with_different_process_pids_remain_separate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + false, + Some("first"), + Some(11), + ); + first.title = "One Two Three Four".to_string(); + first.artist.clear(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + second.owner_pid = Some(22); + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + // Four short words do not carry enough identity to bridge unrelated browser sessions + assert_eq!(build_snapshot(&cache).len(), 2); +} + +#[test] +fn duplicate_components_keep_first_component_order_when_art_selects_later_entry() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.component-a-first", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Component A".to_string(); + first.artist = "Artist A".to_string(); + + let mut middle = make_info( + "org.mpris.MediaPlayer2.component-b", + "Beta", + "Playing", + false, + Some("beta"), + None, + ); + middle.title = "Component B".to_string(); + middle.artist = "Artist B".to_string(); + + let mut later = first.clone(); + later.bus_name = "org.mpris.MediaPlayer2.component-a-later".to_string(); + later.identity = "Zeta".to_string(); + later.browser_family = Some("zeta".to_string()); + later.owner_pid = None; + later.art_source = Some(MediaArtSource::LocalFile(PathBuf::from("/tmp/art.png"))); + + cache.insert(first.bus_name.clone(), first); + cache.insert(middle.bus_name.clone(), middle); + cache.insert(later.bus_name.clone(), later); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 2); + assert_eq!(snapshot[0].identity, "Zeta"); + assert_eq!(snapshot[1].identity, "Beta"); +} + +#[test] +fn duplicate_selection_prefers_artwork_even_when_that_entry_sorts_later() { + let mut cache = HashMap::new(); + let mut no_art = make_info( + "org.mpris.MediaPlayer2.no-art", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + no_art.title = "Shared Long Tutorial Title With Context".to_string(); + no_art.artist = "Shared Artist".to_string(); + + let mut with_art = no_art.clone(); + with_art.bus_name = "org.mpris.MediaPlayer2.with-art".to_string(); + with_art.identity = "Zeta".to_string(); + with_art.browser_family = Some("zeta".to_string()); + with_art.owner_pid = None; + with_art.art_source = Some(MediaArtSource::LocalFile(PathBuf::from("/tmp/art.png"))); + + cache.insert(no_art.bus_name.clone(), no_art); + cache.insert(with_art.bus_name.clone(), with_art); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Zeta"); + assert!(snapshot[0].art_source.is_some()); +} + +#[test] +fn equal_score_duplicate_uses_bus_name_as_a_stable_tie_breaker() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.z-order", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Shared Track With Stable Metadata".to_string(); + first.artist = "Shared Artist".to_string(); + + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.a-order".to_string(); + second.identity = "Zeta".to_string(); + second.browser_family = Some("zeta".to_string()); + second.owner_pid = None; + + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Zeta"); +} + +#[test] +fn equal_bus_name_duplicate_keeps_the_first_equal_score_entry() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.same", + "Alpha", + "Playing", + false, + Some("alpha"), + None, + ); + first.title = "Shared Track With Stable Metadata".to_string(); + first.artist = "Shared Artist".to_string(); + + let mut second = first.clone(); + second.identity = "Zeta".to_string(); + second.browser_family = Some("zeta".to_string()); + second.owner_pid = None; + + cache.insert("entry-a".to_string(), first); + cache.insert("entry-b".to_string(), second); + + let snapshot = build_snapshot(&cache); + + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "Alpha"); +} + +#[test] +fn build_snapshot_keeps_the_first_equal_score_duplicate() { + let mut cache = HashMap::new(); + let mut first = make_info( + "org.mpris.MediaPlayer2.first", + "First", + "Playing", + true, + Some("first"), + Some(11), + ); + first.title = "shared track".to_string(); + let mut second = first.clone(); + second.bus_name = "org.mpris.MediaPlayer2.second".to_string(); + second.identity = "Second".to_string(); + second.browser_family = Some("second".to_string()); + cache.insert(first.bus_name.clone(), first); + cache.insert(second.bus_name.clone(), second); + + let snapshot = build_snapshot(&cache); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].identity, "First"); +} + #[test] fn normalize_token_compacts_and_lowercases() { let token = normalize_token(" Foo--Bar\tBaz "); diff --git a/crates/unixnotis-center/src/media/runtime/tests/startup.rs b/crates/unixnotis-center/src/media/runtime/tests/startup.rs index da647fe7a..c91611d68 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/startup.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/startup.rs @@ -2,8 +2,8 @@ use unixnotis_core::{MediaConfig, MediaRemoteArtPolicy}; use crate::media::MediaCommand; -use super::super::normalize_media_config; use super::super::r#loop::drain_stale_media_commands; +use super::super::startup::normalize_media_config; #[test] fn normalize_media_config_lowercases_all_matching_lists() { diff --git a/crates/unixnotis-center/src/media/runtime/tests/state.rs b/crates/unixnotis-center/src/media/runtime/tests/state.rs index d2f8a7bed..069ca9d69 100644 --- a/crates/unixnotis-center/src/media/runtime/tests/state.rs +++ b/crates/unixnotis-center/src/media/runtime/tests/state.rs @@ -1,4 +1,5 @@ use super::super::state::MediaRuntimeState; +use std::time::Duration; #[test] fn new_runtime_state_starts_without_players_cache_or_delayed_work() { @@ -9,3 +10,23 @@ fn new_runtime_state_starts_without_players_cache_or_delayed_work() { assert!(state.last_snapshot.is_empty()); assert!(state.delayed_refreshes.is_empty()); } + +#[tokio::test] +async fn dropping_runtime_state_aborts_delayed_refresh_tasks() { + let (completed_tx, completed_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_mins(1)).await; + let _ = completed_tx.send(()); + }); + let mut state = MediaRuntimeState::new(); + state + .delayed_refreshes + .insert("org.mpris.MediaPlayer2.test".to_string(), task); + + drop(state); + + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), completed_rx).await, + Ok(Err(_)) + )); +} diff --git a/crates/unixnotis-center/src/ui/events.rs b/crates/unixnotis-center/src/ui/events.rs index fb6d2e562..d43ca844b 100644 --- a/crates/unixnotis-center/src/ui/events.rs +++ b/crates/unixnotis-center/src/ui/events.rs @@ -3,6 +3,7 @@ //! Centralizes `UiEvent` handling so UI state transitions remain coherent and //! traceable in logs +use gtk::prelude::*; use tracing::debug; use unixnotis_core::PanelDebugLevel; @@ -10,9 +11,31 @@ use crate::control::UiEvent; use super::{panel, UiState}; +pub(in crate::ui) fn connect_user_scroll_tracking( + scroller: >k::ScrolledWindow, + generation: std::rc::Rc>, +) { + let controller = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + controller.connect_scroll(move |_, _, delta_y| { + if delta_y.abs() > f64::EPSILON { + generation.set(generation.get().wrapping_add(1)); + } + gtk::glib::Propagation::Proceed + }); + scroller.add_controller(controller); +} + impl UiState { pub fn handle_event(&mut self, event: UiEvent) { match event { + UiEvent::Disconnected => { + debug!("UnixNotis control service disconnected"); + // Old rows and state must not survive into a later daemon generation + self.list.clear_for_disconnect(); + self.mark_notifications_changed(); + self.update_state(unixnotis_core::ControlState::default()); + self.refresh_counts(); + } UiEvent::Seed { state, active, @@ -25,10 +48,11 @@ impl UiState { ); // Seed list data before applying state to keep counts aligned self.list.seed(active, history); + self.mark_notifications_changed(); self.update_state(state); self.refresh_counts(); } - UiEvent::NotificationAdded(notification, _show_popup) => { + UiEvent::NotificationAdded(notification) => { debug!( id = notification.id, app = %notification.app_name, @@ -41,10 +65,11 @@ impl UiState { ) }); self.list.add_or_update(notification, true); + self.mark_notifications_changed(); // Header count reflects the combined active + history totals self.refresh_counts(); } - UiEvent::NotificationUpdated(notification, _show_popup) => { + UiEvent::NotificationUpdated(notification) => { debug!( id = notification.id, app = %notification.app_name, @@ -57,15 +82,22 @@ impl UiState { ) }); self.list.add_or_update(notification, true); + self.mark_notifications_changed(); // Updates may shift groups; refresh count even when list is stable self.refresh_counts(); } - UiEvent::NotificationClosed(id, reason) => { - debug!(id, ?reason, "notification closed"); + UiEvent::NotificationClosed(key, reason) => { + debug!( + id = key.id, + generation = key.generation, + ?reason, + "notification closed" + ); self.log_debug(PanelDebugLevel::Verbose, || { - format!("notification closed: #{id} ({reason:?})") + format!("notification closed: #{} ({reason:?})", key.id) }); - self.list.mark_closed(id, reason); + self.list.mark_closed(key, reason); + self.mark_notifications_changed(); // Marking closed can move entries between active/history buckets self.refresh_counts(); } @@ -98,7 +130,8 @@ impl UiState { debug!(app = %key, "group toggled"); self.log_debug(PanelDebugLevel::Verbose, || format!("group toggled: {key}")); self.list.toggle_group(&key); - // Toggling can change stacked visibility; counts reflect total entries + self.mark_notifications_changed(); + // Toggling can change grouped visibility; counts reflect total entries self.refresh_counts(); } UiEvent::MediaUpdated(infos) => { @@ -148,7 +181,7 @@ impl UiState { self.work_area = reserved; // Re-apply panel sizing only when the work area actually changes // Avoids redundant calls that can cascade into GTK relayout passes - panel::apply_panel_config(&self.panel, &self.config, self.work_area); + panel::geometry::apply_panel_config(&self.panel, &self.config, self.work_area); let message = format!("work area update: {:?}", self.work_area); self.log_debug(PanelDebugLevel::Info, move || message); } @@ -164,9 +197,12 @@ impl UiState { } UiEvent::FilterChanged(query) => { if self.list.set_filter_query(&query) { + self.mark_notifications_changed(); self.log_debug(PanelDebugLevel::Verbose, || { format!("notification filter updated: '{query}'") }); + // Counts derive from list data and stay accurate before the GTK rebuild lands + self.refresh_counts(); } } UiEvent::WidgetsCollapsed(collapsed) => { @@ -196,10 +232,147 @@ impl UiState { } pub fn flush_list_rebuild(&mut self) { + self.flush_list_rebuild_with_policy(ScrollResetPolicy::NearTopOnly); + } + + pub(in crate::ui) fn flush_list_rebuild_with_policy(&mut self, policy: ScrollResetPolicy) { + let snap_to_top = self.panel_visible && should_snap_to_top(&self.panel.sections.scroller); + let generation = self.notification_rebuild_generation.get().wrapping_add(1); + self.notification_rebuild_generation.set(generation); self.list.flush_rebuild(); + if matches!(policy, ScrollResetPolicy::Force) || snap_to_top { + reset_notification_scroll( + &self.panel.sections.scroller, + self.notification_rebuild_generation.clone(), + generation, + self.scroll_user_generation.clone(), + self.scroll_user_generation.get(), + policy, + ); + } + } + + pub(in crate::ui) const fn mark_notifications_changed(&mut self) { + if !self.panel_visible { + self.notifications_changed_while_hidden = true; + } } pub const fn list_needs_rebuild(&self) -> bool { self.list.needs_rebuild() } } + +fn should_snap_to_top(scroller: >k::ScrolledWindow) -> bool { + let adjustment = scroller.vadjustment(); + should_snap_to_top_value(adjustment.value(), adjustment.lower()) +} + +const fn should_snap_to_top_value(value: f64, lower: f64) -> bool { + value <= lower + 18.0 +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::ui) enum ScrollResetPolicy { + // Live updates preserve a meaningful position once the user scrolls away + NearTopOnly, + // Hidden reseeds invalidate the old position and must show the first row + Force, +} + +pub(in crate::ui) fn reset_notification_scroll( + scroller: >k::ScrolledWindow, + rebuild_generation: std::rc::Rc>, + expected_generation: u64, + scroll_user_generation: std::rc::Rc>, + expected_user_generation: u64, + policy: ScrollResetPolicy, +) { + let scroller = scroller.clone(); + gtk::glib::idle_add_local_once(move || { + // A mapped panel gets a frame callback after recycled rows are allocated + if scroller.is_mapped() { + scroller.add_tick_callback(move |scroller, _clock| { + apply_scroll_reset_after_allocation( + scroller, + &rebuild_generation, + expected_generation, + &scroll_user_generation, + expected_user_generation, + policy, + ) + }); + return; + } + + // Unmapped unit-test widgets have no frame clock; apply only with valid geometry + let adjustment = scroller.vadjustment(); + if adjustment.page_size() > 0.0 + && should_apply_scroll_reset( + rebuild_generation.get(), + expected_generation, + scroll_user_generation.get(), + expected_user_generation, + &scroller, + policy, + ) + { + adjustment.set_value(adjustment.lower()); + } + }); +} + +fn apply_scroll_reset_after_allocation( + scroller: >k::ScrolledWindow, + rebuild_generation: &std::rc::Rc>, + expected_generation: u64, + scroll_user_generation: &std::rc::Rc>, + expected_user_generation: u64, + policy: ScrollResetPolicy, +) -> gtk::glib::ControlFlow { + // A tick runs after GTK has had a chance to measure recycled rows + let adjustment = scroller.vadjustment(); + if adjustment.page_size() <= 0.0 { + // Unmapped panels can need another frame before allocation is valid + return gtk::glib::ControlFlow::Continue; + } + + // Layout work can yield to a real user scroll before this callback runs + // Recheck both the rebuild and scroll state so stale work cannot win + if should_apply_scroll_reset( + rebuild_generation.get(), + expected_generation, + scroll_user_generation.get(), + expected_user_generation, + scroller, + policy, + ) { + adjustment.set_value(adjustment.lower()); + } + gtk::glib::ControlFlow::Break +} + +fn should_apply_scroll_reset( + current_generation: u64, + expected_generation: u64, + current_user_generation: u64, + expected_user_generation: u64, + scroller: >k::ScrolledWindow, + policy: ScrollResetPolicy, +) -> bool { + if !scroll_reset_generation_is_current(current_generation, expected_generation) + || !scroll_reset_generation_is_current(current_user_generation, expected_user_generation) + { + return false; + } + + matches!(policy, ScrollResetPolicy::Force) || should_snap_to_top(scroller) +} + +const fn scroll_reset_generation_is_current(current: u64, expected: u64) -> bool { + current == expected +} + +#[cfg(test)] +#[path = "events/tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/events/tests/mod.rs b/crates/unixnotis-center/src/ui/events/tests/mod.rs new file mode 100644 index 000000000..9fda4bf76 --- /dev/null +++ b/crates/unixnotis-center/src/ui/events/tests/mod.rs @@ -0,0 +1 @@ +mod scroll; diff --git a/crates/unixnotis-center/src/ui/events/tests/scroll.rs b/crates/unixnotis-center/src/ui/events/tests/scroll.rs new file mode 100644 index 000000000..c638cfe9c --- /dev/null +++ b/crates/unixnotis-center/src/ui/events/tests/scroll.rs @@ -0,0 +1,129 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; + +use super::super::{ + reset_notification_scroll, scroll_reset_generation_is_current, should_apply_scroll_reset, + should_snap_to_top_value, ScrollResetPolicy, +}; + +#[test] +fn near_top_insertions_snap_to_the_first_row() { + assert!(should_snap_to_top_value(0.0, 0.0)); + assert!(should_snap_to_top_value(17.5, 0.0)); + assert!(!should_snap_to_top_value(18.1, 0.0)); +} + +#[test] +fn scroll_threshold_follows_nonzero_adjustment_lower_bound() { + assert!(should_snap_to_top_value(118.0, 100.0)); + assert!(!should_snap_to_top_value(118.1, 100.0)); +} + +#[test] +fn stale_scroll_reset_generation_is_rejected() { + assert!(!scroll_reset_generation_is_current(11, 12)); + assert!(scroll_reset_generation_is_current(12, 12)); +} + +#[gtk::test] +fn scroll_reset_requires_current_generation_and_near_top_position() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(100.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + adjustment.set_value(100.0); + + assert!(should_apply_scroll_reset( + 4, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::NearTopOnly, + )); + assert!(!should_apply_scroll_reset( + 3, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::NearTopOnly, + )); + + adjustment.set_value(130.0); + assert!(!should_apply_scroll_reset( + 4, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::NearTopOnly, + )); + assert!(should_apply_scroll_reset( + 4, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::Force, + )); +} + +#[gtk::test] +fn force_scroll_reset_rejects_a_new_user_scroll_generation() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(100.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + + // Adjustment movement alone may come from layout, so only the explicit + // interaction generation identifies a real user scroll + assert!(!should_apply_scroll_reset( + 4, + 4, + 3, + 2, + &scroller, + ScrollResetPolicy::Force, + )); +} + +#[gtk::test] +fn force_scroll_reset_accepts_layout_adjustment_changes_without_user_input() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(130.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + + assert!(should_apply_scroll_reset( + 4, + 4, + 2, + 2, + &scroller, + ScrollResetPolicy::Force, + )); +} + +#[gtk::test] +fn deferred_scroll_reset_updates_the_adjustment_after_idle() { + let scroller = gtk::ScrolledWindow::new(); + let adjustment = gtk::Adjustment::new(108.0, 100.0, 300.0, 1.0, 10.0, 10.0); + scroller.set_vadjustment(Some(&adjustment)); + adjustment.set_value(108.0); + let generation = Rc::new(Cell::new(7)); + + let user_generation = Rc::new(Cell::new(3)); + reset_notification_scroll( + &scroller, + generation, + 7, + user_generation, + 3, + ScrollResetPolicy::NearTopOnly, + ); + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + assert!((adjustment.value() - adjustment.lower()).abs() < f64::EPSILON); +} diff --git a/crates/unixnotis-center/src/ui/icons/cache.rs b/crates/unixnotis-center/src/ui/icons/cache.rs index cfca9dadc..4a593713b 100644 --- a/crates/unixnotis-center/src/ui/icons/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/cache.rs @@ -2,28 +2,27 @@ //! //! Encapsulates cache storage and keying logic used by the icon resolver +use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use std::sync::OnceLock; use gtk::gdk::{Paintable, Texture}; use gtk::prelude::*; use gtk::IconPaintable; -use unixnotis_core::NotificationImage; const DEFAULT_MAX_CACHE_BYTES: usize = 64 * 1024 * 1024; +const MAX_TRACKED_IMAGE_KEYS: usize = 4096; + +// Weak image references let destroyed images disappear on the next cache access +// A key is retained only while its image can still be upgraded +thread_local! { + static IMAGE_KEYS: RefCell, IconKey)>> = + const { RefCell::new(Vec::new()) }; +} #[derive(Clone, Debug, Hash, PartialEq, Eq)] pub(super) enum IconKey { - ImageData { - hash: [u8; 32], - len: usize, - width: i32, - height: i32, - size: i32, - scale: i32, - }, Path { path: PathBuf, size: i32, @@ -39,36 +38,11 @@ pub(super) enum IconKey { impl IconKey { pub(super) const fn size_and_scale(&self) -> (i32, i32) { match self { - Self::ImageData { size, scale, .. } - | Self::Path { size, scale, .. } - | Self::Name { size, scale, .. } => (*size, *scale), + Self::Path { size, scale, .. } | Self::Name { size, scale, .. } => (*size, *scale), } } } -pub(super) fn icon_key_for_image( - image: &NotificationImage, - size: i32, - scale: i32, -) -> Option { - if !image.has_image_data { - return None; - } - let data = &image.image_data; - if data.data.is_empty() { - return None; - } - let hash = hash_image_data(&data.data); - Some(IconKey::ImageData { - hash, - len: data.data.len(), - width: data.width, - height: data.height, - size, - scale, - }) -} - pub(super) fn icon_key_for_path(path: &Path, size: i32, scale: i32) -> Option { // Empty path means “no icon path provided”; treat as absent rather than creating a useless cache key if path.as_os_str().is_empty() { @@ -98,31 +72,47 @@ pub(super) fn icon_key_for_name(name: &str, size: i32, scale: i32) -> Option [u8; 32] { - // Notification image payloads are already bounded, so hashing every byte keeps cache identity exact - *blake3::hash(data).as_bytes() -} - pub(super) fn set_image_key(image: >k::Image, key: IconKey) { - unsafe { - // SAFETY: gtk::Image is main-thread only; the quark/type pairing is stable - image.set_qdata(icon_key_quark(), key); - } + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + if let Some((_, existing)) = entries + .iter_mut() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *image)) + { + *existing = key; + } else { + entries.push((image.downgrade(), key)); + } + // A dead weak reference is normally removed on the next access. Keep a hard + // cap as a second line of defense when images stop being accessed entirely + if entries.len() > MAX_TRACKED_IMAGE_KEYS { + let excess = entries.len() - MAX_TRACKED_IMAGE_KEYS; + entries.drain(..excess); + } + }); } pub(super) fn image_key_matches(image: >k::Image, key: &IconKey) -> bool { - // SAFETY: The stable quark is written with IconKey values only on the GTK main thread - let stored = unsafe { image.qdata::(icon_key_quark()) }; - let Some(stored) = stored else { - return false; - }; - // SAFETY: Gtk owns the qdata value for at least as long as this image reference - unsafe { stored.as_ref() == key } + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + entries.iter().any(|(weak, stored)| { + weak.upgrade().is_some_and(|current| current == *image) && stored == key + }) + }) } -fn icon_key_quark() -> gtk::glib::Quark { - static QUARK: OnceLock = OnceLock::new(); - *QUARK.get_or_init(|| gtk::glib::Quark::from_str("unixnotis-icon-key")) +pub(super) fn clear_image_key(image: >k::Image) { + IMAGE_KEYS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| { + let Some(current) = weak.upgrade() else { + return false; + }; + current != *image + }); + }); } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/icons/decode/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/svg.rs index 839093562..20866187b 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/svg.rs @@ -1,9 +1,12 @@ //! Bounded SVG and SVGZ parsing with secondary image loading disabled +//! +//! Uses a subprocess renderer with a wall-clock deadline to prevent +//! CPU exhaustion from pathological SVGs (UNX-4-005). use std::borrow::Cow; -use std::io::Read; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::Duration; use flate2::read::GzDecoder; @@ -11,79 +14,236 @@ use super::file::MAX_ICON_BYTES; use super::model::RasterImage; use super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; +// Hard wall-clock deadline for the entire SVG subprocess (parse + render) +const SVG_SUBPROCESS_DEADLINE: Duration = Duration::from_millis(500); +const MAX_SVG_BYTES: usize = 1_024_000; +const MAX_RENDERER_STDERR: usize = 16 * 1024; + pub(super) const fn is_gzip_payload(bytes: &[u8]) -> bool { - // SVGZ uses the normal gzip signature regardless of its filename suffix matches!(bytes, [0x1f, 0x8b, ..]) } pub(super) fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { - // Compressed documents are expanded under the same source byte ceiling + if target == 0 || target > MAX_ICON_DIMENSION { + return Err("SVG target dimension exceeds decode limit".to_string()); + } + let svg_renderer = resolve_svg_renderer()?; + decode_svg_bytes_with_renderer(bytes, target, &svg_renderer) +} + +pub(super) fn decode_svg_bytes_with_renderer( + bytes: &[u8], + target: u32, + svg_renderer: &std::path::Path, +) -> Result { + if target == 0 || target > MAX_ICON_DIMENSION { + return Err("SVG target dimension exceeds decode limit".to_string()); + } let document = if is_gzip_payload(bytes) { Cow::Owned(decompress_svgz_with_limit(bytes, MAX_ICON_BYTES)?) } else { Cow::Borrowed(bytes) }; + if document.len() > MAX_SVG_BYTES { + return Err("SVG exceeds maximum byte limit".to_string()); + } - let secondary_image = Arc::new(AtomicBool::new(false)); - // Both resolver callbacks share one flag so attempted nested images fail the document - let data_image = Arc::clone(&secondary_image); - let path_image = Arc::clone(&secondary_image); - let options = resvg::usvg::Options { - // SVG image nodes stay disabled so parsing cannot open files or nested image decoders - image_href_resolver: resvg::usvg::ImageHrefResolver { - resolve_data: Box::new(move |_mime, _data, _options| { - data_image.store(true, Ordering::Relaxed); - None - }), - resolve_string: Box::new(move |_href, _options| { - path_image.store(true, Ordering::Relaxed); - None - }), - }, - ..resvg::usvg::Options::default() + let mut child = Command::new(svg_renderer) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .current_dir("/") + .spawn() + .map_err(|e| format!("failed to spawn SVG renderer: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to capture child stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to capture child stdout".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "failed to capture child stderr".to_string())?; + + // Binary protocol: u32 target dimension (LE) + SVG bytes (remainder of stdin) + stdin + .write_all(&(target).to_le_bytes()) + .map_err(|e| format!("failed to write target size: {e}"))?; + stdin + .write_all(&document) + .map_err(|e| format!("failed to write SVG data: {e}"))?; + drop(stdin); + + // Drain both stdout and stderr concurrently to avoid pipe deadlock + let wait_start = std::time::Instant::now(); + let read_handle = std::thread::spawn(move || read_stdout(stdout)); + let stderr_handle = std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stderr + .take( + u64::try_from(MAX_RENDERER_STDERR) + .unwrap_or(u64::MAX) + .saturating_add(1), + ) + .read_to_end(&mut bytes); + bytes.truncate(MAX_RENDERER_STDERR); + String::from_utf8_lossy(&bytes).into_owned() + }); + + // Wait for child with timeout + let exit_status = match wait_with_timeout(&mut child, SVG_SUBPROCESS_DEADLINE) { + Ok(status) => status, + Err(error) => { + let _ = read_handle.join(); + let _ = stderr_handle.join(); + return Err(error.to_string()); + } }; - let tree = - resvg::usvg::Tree::from_data(&document, &options).map_err(|error| error.to_string())?; - // Parsing may call a resolver even though that resolver returns no image - if secondary_image.load(Ordering::Relaxed) { - return Err("SVG icons must not contain secondary images".to_string()); + + // Check wall-clock timeout + if wait_start.elapsed() > SVG_SUBPROCESS_DEADLINE { + let _ = child.kill(); + let _ = read_handle.join(); + let _ = stderr_handle.join(); + return Err("SVG render exceeded time limit".to_string()); } - let source_width = tree.size().width().ceil() as u32; - let source_height = tree.size().height().ceil() as u32; - validate_svg_dimensions(source_width, source_height)?; - - // Fit the source inside the requested square while retaining its aspect ratio - let scale = (target as f32 / tree.size().width()).min(target as f32 / tree.size().height()); - let width = (tree.size().width() * scale).round().max(1.0) as u32; - let height = (tree.size().height() * scale).round().max(1.0) as u32; - // Output allocation follows the fitted dimensions rather than the source canvas - let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) - .ok_or_else(|| "could not allocate bounded SVG surface".to_string())?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale, scale), - &mut pixmap.as_mut(), - ); - - let width = i32::try_from(width).map_err(|error| error.to_string())?; - let height = i32::try_from(height).map_err(|error| error.to_string())?; - let stride = width + // Check exit status before parsing output; child may have failed with no output + if !exit_status.success() { + let _ = read_handle.join(); + let stderr_msg = stderr_handle.join().unwrap_or_default(); + let trimmed = stderr_msg.trim(); + if trimmed.is_empty() { + return Err("SVG renderer subprocess failed".to_string()); + } + return Err(format!("SVG renderer subprocess failed: {trimmed}")); + } + + // Child succeeded; parse the read thread result + let read_result = read_handle + .join() + .map_err(|err| format!("stdout reader panicked: {err:?}"))?; + // Join the bounded diagnostics reader on success as well, so no helper thread + // outlives the decoder operation + let _ = stderr_handle + .join() + .map_err(|err| format!("stderr reader panicked: {err:?}"))?; + let (width, height, rgba_data) = read_result?; + + let expected_len = checked_rgba_len(width, height)?; + if rgba_data.len() != expected_len { + return Err("SVG renderer returned unexpected byte count".to_string()); + } + + let width_i32 = i32::try_from(width).map_err(|e| e.to_string())?; + let height_i32 = i32::try_from(height).map_err(|e| e.to_string())?; + let stride = width_i32 .checked_mul(4) .ok_or_else(|| "SVG row stride exceeds supported size".to_string())?; Ok(RasterImage { - bytes: pixmap.take(), - width, - height, + bytes: rgba_data, + width: width_i32, + height: height_i32, stride, premultiplied_alpha: true, }) } +// Production resolves only the sibling binary next to the center executable +pub(super) fn resolve_svg_renderer() -> Result { + let current_exe = std::env::current_exe().map_err(|e| format!("current_exe failed: {e}"))?; + let parent = current_exe + .parent() + .ok_or("current executable has no parent directory")?; + let candidate = parent.join("unixnotis-svg-renderer"); + if candidate.exists() { + return Ok(candidate); + } + // Cargo test executables live in target/{debug,release}/deps while the + // sibling helper stays in the profile directory. Installed binaries do + // not use a `deps` parent, so this fallback is restricted to that layout + if parent.file_name() == Some(std::ffi::OsStr::new("deps")) { + if let Some(profile_dir) = parent.parent() { + let is_cargo_profile = matches!( + profile_dir.file_name().and_then(std::ffi::OsStr::to_str), + Some("debug" | "release") + ); + let candidate = profile_dir.join("unixnotis-svg-renderer"); + if is_cargo_profile && candidate.is_file() { + return Ok(candidate); + } + } + } + Err("unixnotis-svg-renderer binary not found next to center executable".to_string()) +} + +fn read_stdout(mut stdout: std::process::ChildStdout) -> Result<(u32, u32, Vec), String> { + let mut width_bytes = [0u8; 4]; + stdout + .read_exact(&mut width_bytes) + .map_err(|e| e.to_string())?; + let width = u32::from_le_bytes(width_bytes); + + let mut height_bytes = [0u8; 4]; + stdout + .read_exact(&mut height_bytes) + .map_err(|e| e.to_string())?; + let height = u32::from_le_bytes(height_bytes); + + let expected_len = checked_rgba_len(width, height)?; + + let mut rgba = vec![0u8; expected_len]; + stdout.read_exact(&mut rgba).map_err(|e| e.to_string())?; + Ok((width, height, rgba)) +} + +pub(super) fn checked_rgba_len(width: u32, height: u32) -> Result { + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or_else(|| "renderer returned overflowing dimensions".to_string())?; + if width == 0 + || height == 0 + || width > MAX_ICON_DIMENSION + || height > MAX_ICON_DIMENSION + || pixels > MAX_ICON_PIXELS + { + return Err("renderer returned oversized image".to_string()); + } + usize::try_from(pixels) + .ok() + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "renderer returned oversized image".to_string()) +} + +fn wait_with_timeout( + child: &mut std::process::Child, + timeout: Duration, +) -> Result { + let start = std::time::Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } else if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "SVG subprocess timed out", + )); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + pub(super) fn decompress_svgz_with_limit(bytes: &[u8], max_bytes: u64) -> Result, String> { let mut decoder = GzDecoder::new(bytes); let mut document = Vec::new(); - // One extra byte distinguishes an exact-limit document from an oversized stream decoder .by_ref() .take(max_bytes.saturating_add(1)) @@ -94,19 +254,3 @@ pub(super) fn decompress_svgz_with_limit(bytes: &[u8], max_bytes: u64) -> Result } Ok(document) } - -pub(super) fn validate_svg_dimensions(width: u32, height: u32) -> Result<(), String> { - // Source geometry is checked separately from the smaller fitted output surface - let pixels = u64::from(width).saturating_mul(u64::from(height)); - if width == 0 - || height == 0 - || width > MAX_ICON_DIMENSION - || height > MAX_ICON_DIMENSION - || pixels > MAX_ICON_PIXELS - { - return Err(format!( - "SVG dimensions exceed center decode limit ({width}x{height})" - )); - } - Ok(()) -} diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs index f6e56215a..1f1e6a382 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/pipeline.rs @@ -1,7 +1,7 @@ use std::path::Path; use super::super::pipeline::{decode_icon_bytes, decode_target, path_suggests_svg}; -use super::support::png_bytes; +use super::support::{png_bytes, svg_renderer_binary}; #[test] fn content_routing_decodes_raster_bytes_with_an_svg_suffix() { @@ -27,6 +27,7 @@ fn content_routing_rejects_incomplete_png_data_with_an_svg_suffix() { #[test] fn content_routing_decodes_extensionless_svg_with_resvg() { let svg = br#""#; + let _renderer = svg_renderer_binary(); let decoded = decode_icon_bytes(Path::new("icon"), svg, 16).expect("bounded SVG fallback"); diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs index abec10d6b..e6365a564 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/support.rs @@ -1,5 +1,7 @@ use std::fs; use std::path::PathBuf; +use std::process::Command; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use image::codecs::png::PngEncoder; @@ -26,3 +28,58 @@ pub(super) fn png_bytes(width: u32, height: u32) -> Vec { .expect("encode PNG"); bytes } + +pub(super) fn svg_renderer_binary() -> &'static PathBuf { + static BINARY: OnceLock = OnceLock::new(); + + BINARY.get_or_init(|| { + if let Some(path) = option_env!("CARGO_BIN_EXE_unixnotis-svg-renderer") { + return path.into(); + } + let current_exe = std::env::current_exe().expect("current center test binary"); + let profile_dir = current_exe + .parent() + .and_then(|path| path.parent()) + .expect("Cargo profile directory"); + let target_root = profile_dir.parent().expect("Cargo target root"); + let candidate = profile_dir.join(format!( + "unixnotis-svg-renderer{}", + std::env::consts::EXE_SUFFIX + )); + if fs::metadata(&candidate).is_err() { + build_svg_renderer(target_root); + } + assert!( + fs::metadata(&candidate).is_ok(), + "SVG renderer binary is missing at {candidate:?}" + ); + candidate + }) +} + +pub(super) fn renderer_fixture(name: &str) -> PathBuf { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/svg-renderers") + .join(name); + assert!( + fs::metadata(&fixture).is_ok_and(|metadata| metadata.is_file()), + "SVG renderer fixture is missing at {fixture:?}" + ); + fixture +} + +fn build_svg_renderer(target_root: &std::path::Path) { + // Unit-test targets do not guarantee that the non-test helper binary was built + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let output = Command::new(cargo) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .args(["build", "--bin", "unixnotis-svg-renderer", "--target-dir"]) + .arg(target_root) + .output() + .expect("build SVG renderer for center tests"); + assert!( + output.status.success(), + "failed to build SVG renderer\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs index fde9ce5c0..1320bd4f4 100644 --- a/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs +++ b/crates/unixnotis-center/src/ui/icons/decode/tests/svg.rs @@ -3,10 +3,16 @@ use std::io::Write; use flate2::write::GzEncoder; use flate2::Compression; -use super::super::pipeline::MAX_ICON_DIMENSION; +use super::super::model::RasterImage; +use super::super::pipeline::{MAX_ICON_DIMENSION, MAX_ICON_PIXELS}; use super::super::svg::{ - decode_svg_bytes, decompress_svgz_with_limit, is_gzip_payload, validate_svg_dimensions, + checked_rgba_len, decode_svg_bytes_with_renderer, decompress_svgz_with_limit, is_gzip_payload, }; +use super::support::{renderer_fixture, svg_renderer_binary}; + +fn decode_svg_bytes(bytes: &[u8], target: u32) -> Result { + decode_svg_bytes_with_renderer(bytes, target, svg_renderer_binary()) +} #[test] fn svg_decoder_renders_bounded_pixels_and_preserves_aspect_ratio() { @@ -19,6 +25,16 @@ fn svg_decoder_renders_bounded_pixels_and_preserves_aspect_ratio() { assert!(decoded.premultiplied_alpha); } +#[test] +fn svg_protocol_accepts_multiline_documents() { + let svg = br#" + +"#; + + let decoded = decode_svg_bytes(svg, 16).expect("multiline SVG should render"); + assert_eq!((decoded.width, decoded.height), (16, 8)); +} + #[test] fn svg_decoder_uses_height_as_the_constraint_for_tall_images() { let svg = br#""#; @@ -93,3 +109,154 @@ fn svg_source_limits_cover_zero_exact_and_oversized_boundaries() { assert!(validate_svg_dimensions(MAX_ICON_DIMENSION + 1, 1).is_err()); assert!(validate_svg_dimensions(1, MAX_ICON_DIMENSION + 1).is_err()); } + +#[test] +fn svg_scaling_rejects_non_finite_zero_and_oversized_inputs() { + let input_error = "SVG scaling inputs must be finite and bounded"; + assert_eq!( + fitted_svg_dimensions(f32::NAN, 10.0, 16).expect_err("NaN width must fail"), + input_error + ); + assert_eq!( + fitted_svg_dimensions(10.0, f32::INFINITY, 16).expect_err("infinite height must fail"), + input_error + ); + assert_eq!( + fitted_svg_dimensions(0.0, 10.0, 16).expect_err("zero width must fail"), + input_error + ); + assert_eq!( + fitted_svg_dimensions(10.0, 10.0, 0).expect_err("zero target must fail"), + input_error + ); + assert_eq!( + fitted_svg_dimensions(10.0, 10.0, MAX_ICON_DIMENSION + 1) + .expect_err("oversized target must fail"), + input_error + ); + assert_eq!( + fitted_svg_dimensions(f32::MIN_POSITIVE, f32::MIN_POSITIVE, 16) + .expect_err("infinite scale must fail"), + "SVG scaling result must be finite and positive" + ); +} + +#[test] +fn svg_scaling_returns_finite_bounded_geometry() { + let (width, height, scale) = + fitted_svg_dimensions(20.0, 10.0, 16).expect("fit finite geometry"); + + assert_eq!((width, height), (16, 8)); + assert!(scale.is_finite()); + assert!(scale > 0.0); + + let (width, height, _scale) = + fitted_svg_dimensions(1.0, 1.0, MAX_ICON_DIMENSION).expect("fit exact target limit"); + assert_eq!((width, height), (MAX_ICON_DIMENSION, MAX_ICON_DIMENSION)); +} + +#[test] +fn renderer_output_dimensions_are_checked_before_allocation() { + assert!(checked_rgba_len(0, 1).is_err()); + assert!(checked_rgba_len(MAX_ICON_DIMENSION + 1, 1).is_err()); + assert!(checked_rgba_len(1, MAX_ICON_DIMENSION + 1).is_err()); + assert!(checked_rgba_len(u32::MAX, u32::MAX).is_err()); + assert_eq!( + checked_rgba_len(MAX_ICON_DIMENSION, MAX_ICON_DIMENSION).expect("bounded output"), + usize::try_from(MAX_ICON_PIXELS).expect("usize pixels") * 4 + ); +} + +#[test] +fn malformed_renderer_dimensions_are_rejected_without_large_allocation() { + let renderer = renderer_fixture("bad-renderer"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("oversized child dimensions must fail"); + assert!( + error.contains("renderer returned oversized image"), + "unexpected error: {error}" + ); +} + +#[test] +fn renderer_deadline_terminates_a_slow_child() { + let renderer = renderer_fixture("slow-renderer"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("slow renderer must be stopped"); + assert!(error.contains("timed out")); +} + +#[test] +fn renderer_stderr_is_drained_while_stdout_is_decoded() { + let renderer = renderer_fixture("chatty-renderer"); + + let decoded = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect("chatty renderer should not deadlock"); + assert_eq!((decoded.width, decoded.height), (1, 1)); +} + +#[test] +fn renderer_stderr_is_bounded_before_error_reporting() { + let renderer = renderer_fixture("noisy-failing-renderer"); + + let error = decode_svg_bytes_with_renderer(b"", 16, &renderer) + .expect_err("failing renderer should return an error"); + assert!(error.len() <= 17_000, "stderr exceeded diagnostic cap"); +} + +#[test] +fn missing_sibling_renderer_is_reported() { + let error = decode_svg_bytes_with_renderer( + b"", + 16, + std::path::Path::new("/nonexistent/unixnotis-svg-renderer"), + ) + .expect_err("missing renderer must fail closed"); + assert!(error.contains("failed to spawn SVG renderer")); +} + +fn fitted_svg_dimensions( + source_width: f32, + source_height: f32, + target: u32, +) -> Result<(u32, u32, f32), String> { + if !source_width.is_finite() + || !source_height.is_finite() + || source_width <= 0.0 + || source_height <= 0.0 + || target == 0 + || target > MAX_ICON_DIMENSION + { + return Err("SVG scaling inputs must be finite and bounded".to_string()); + } + + let target = target as f32; + let scale = (target / source_width).min(target / source_height); + if !scale.is_finite() || scale <= 0.0 { + return Err("SVG scaling result must be finite and positive".to_string()); + } + let scaled_width = (source_width * scale).round().max(1.0); + let scaled_height = (source_height * scale).round().max(1.0); + + let width = scaled_width as u32; + let height = scaled_height as u32; + validate_svg_dimensions(width, height)?; + Ok((width, height, scale)) +} + +fn validate_svg_dimensions(width: u32, height: u32) -> Result<(), String> { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 + || height == 0 + || width > MAX_ICON_DIMENSION + || height > MAX_ICON_DIMENSION + || pixels > MAX_ICON_PIXELS + { + return Err(format!( + "SVG dimensions exceed center decode limit ({width}x{height})" + )); + } + Ok(()) +} diff --git a/crates/unixnotis-center/src/ui/icons/resolution.rs b/crates/unixnotis-center/src/ui/icons/resolution.rs index db820e9b0..023056838 100644 --- a/crates/unixnotis-center/src/ui/icons/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/resolution.rs @@ -1,45 +1,56 @@ //! Icon source selection and synchronous cache lookup -use std::rc::Rc; - use gtk::prelude::*; use unixnotis_core::NotificationView; -use super::cache::{ - icon_key_for_image, icon_key_for_name, icon_key_for_path, set_image_key, CachedPaintable, - IconKey, -}; +use super::cache::{icon_key_for_name, icon_key_for_path, set_image_key, CachedPaintable}; use super::resolver::IconResolverInner; use super::theme::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_source, + collect_icon_candidates, image_data_texture, image_data_texture_for_data, resolve_icon_source, IconSource, }; use super::types::{IconDecodeRequest, IconResolution}; impl IconResolverInner { - pub(super) fn apply_icon( + pub(super) fn apply_sender_visual(&self, image: >k::Image, notification: &NotificationView) { + // The daemon has already decoded and bounded this sender-provided raster + if !matches!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + | unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ) { + image.set_visible(false); + return; + } + if let Some(texture) = image_data_texture_for_data(¬ification.image.sender_visual) { + image.set_paintable(Some(&texture)); + image.set_visible(true); + return; + } + image.set_visible(false); + } + + pub(super) fn apply_content_visual(&self, image: >k::Image, notification: &NotificationView) { + // Content pixels were bounded by the daemon before reaching GTK + if let Some(texture) = image_data_texture(¬ification.image) { + image.set_paintable(Some(&texture)); + image.set_visible(true); + } else { + image.set_visible(false); + } + } + + pub(super) fn apply_badge( &self, image: >k::Image, notification: &NotificationView, size: i32, scale: i32, ) { - if let Some(resolved) = self.resolve_icon(notification, size, scale) { - match resolved { - IconResolution::Ready { key, paintable } => { - set_image_key(image, key); - image.set_paintable(Some(paintable.paintable())); - image.set_visible(true); - } - IconResolution::Async { request } => { - set_image_key(image, request.key.clone()); - self.enqueue(request, image); - image.set_visible(false); - } - } + if let Some(resolved) = self.resolve_badge(notification, size, scale) { + self.apply_resolution(image, resolved); return; } - image.set_visible(false); } @@ -47,42 +58,12 @@ impl IconResolverInner { self.missing_names.borrow_mut().clear(); } - fn resolve_icon( + fn resolve_badge( &self, notification: &NotificationView, size: i32, scale: i32, ) -> Option { - let image = ¬ification.image; - if let Some(key) = icon_key_for_image(image, size, scale) { - if let Some(paintable) = self.lookup_cached(key.clone(), || { - image_data_texture(image).map(CachedPaintable::from_texture) - }) { - return Some(IconResolution::Ready { key, paintable }); - } - } - - if let Some(path) = file_path_from_hint(&image.image_path) { - // File paths use asynchronous decoding so disk I/O stays off GTK - if let Some(key) = icon_key_for_path(&path, size, scale) { - if let Some(paintable) = self.cache.borrow_mut().get(&key) { - return Some(IconResolution::Ready { key, paintable }); - } - return Some(IconResolution::Async { - request: IconDecodeRequest { - key, - path, - size, - scale, - }, - }); - } - } - - if let Some(resolution) = self.resolve_icon_name(&image.icon_name, size, scale) { - return Some(resolution); - } - let candidates = collect_icon_candidates(notification); for candidate in &candidates { if let Some(icons) = self.desktop_index.icons_for(candidate) { @@ -101,6 +82,21 @@ impl IconResolverInner { None } + fn apply_resolution(&self, image: >k::Image, resolved: IconResolution) { + match resolved { + IconResolution::Ready { key, paintable } => { + set_image_key(image, key); + image.set_paintable(Some(paintable.paintable())); + image.set_visible(true); + } + IconResolution::Async { request } => { + set_image_key(image, request.key.clone()); + self.enqueue(request, image); + image.set_visible(false); + } + } + } + fn resolve_icon_name(&self, name: &str, size: i32, scale: i32) -> Option { if !icon_name_is_usable(name) { return None; @@ -147,17 +143,6 @@ impl IconResolverInner { } } } - - fn lookup_cached(&self, key: IconKey, build: F) -> Option> - where - F: FnOnce() -> Option, - { - if let Some(paintable) = self.cache.borrow_mut().get(&key) { - return Some(paintable); - } - let paintable = build()?; - Some(self.cache.borrow_mut().insert(key, paintable)) - } } const fn icon_name_is_usable(name: &str) -> bool { diff --git a/crates/unixnotis-center/src/ui/icons/resolver.rs b/crates/unixnotis-center/src/ui/icons/resolver.rs index 50163f756..b2077883a 100644 --- a/crates/unixnotis-center/src/ui/icons/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/resolver.rs @@ -5,10 +5,12 @@ use std::collections::HashMap; use std::rc::Rc; use gtk::glib; +use gtk::prelude::WidgetExt; use unixnotis_core::NotificationView; use unixnotis_ui::icons::DesktopIconIndex; +use unixnotis_ui::presentation::{apply_semantic_badge, BadgePresentation, TrustLevel}; -use super::cache::{IconCache, IconKey}; +use super::cache::{clear_image_key, IconCache, IconKey}; use super::decode::{IconUpdate, IconWorker}; use super::missing::MissingIconCache; @@ -33,25 +35,74 @@ impl IconResolver { missing_names: RefCell::new(MissingIconCache::new(512)), worker, }); - let update_target = Rc::clone(&inner); + let update_target = Rc::downgrade(&inner); glib::MainContext::default().spawn_local(async move { while let Ok(update) = update_rx.recv().await { // GTK objects are updated only from the owning main context - update_target.handle_update(update); + let Some(inner) = update_target.upgrade() else { + break; + }; + inner.handle_update(update); } }); Self { inner } } - pub fn apply_icon( + pub fn apply_badge( &self, image: >k::Image, notification: &NotificationView, size: i32, scale: i32, ) { - self.inner.apply_icon(image, notification, size, scale); + // Header badges deliberately exclude caller-controlled content image data and paths + self.inner.apply_badge(image, notification, size, scale); + } + + pub fn clear_identity_badge(&self, image: >k::Image) { + // Invalidate old async work before clearing the recycled GTK image + clear_image_key(image); + image.clear(); + image.set_visible(false); + } + + /// Applies application branding without letting contradictory trust evidence disappear + pub fn apply_identity_badge( + &self, + image: >k::Image, + notification: &NotificationView, + badge: BadgePresentation, + trust: TrustLevel, + size: i32, + scale: i32, + ) { + // Recycled rows may retain both a paintable and a hidden visibility state + self.clear_identity_badge(image); + + if trust.semantic_badge_is_authoritative() { + // Conflict and relay states keep their semantic warning icon in front + if apply_semantic_badge(image, badge, size) { + // A semantic warning is visible even when this widget was recycled + image.set_visible(true); + } + return; + } + + // Recognized and unresolved branding is presentation-only and may be resolved first + self.apply_badge(image, notification, size, scale); + if !image.get_visible() && apply_semantic_badge(image, badge, size) { + // Resolver misses and pending work leave the image hidden + image.set_visible(true); + } + } + + pub fn apply_sender_visual(&self, image: >k::Image, notification: &NotificationView) { + self.inner.apply_sender_visual(image, notification); + } + + pub fn apply_content_visual(&self, image: >k::Image, notification: &NotificationView) { + self.inner.apply_content_visual(image, notification); } pub fn clear_missing_cache(&self) { diff --git a/crates/unixnotis-center/src/ui/icons/tests/cache.rs b/crates/unixnotis-center/src/ui/icons/tests/cache.rs index a4a22b5ef..182433404 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/cache.rs @@ -1,4 +1,8 @@ -use super::{hash_image_data, icon_key_for_path, image_key_matches, set_image_key, IconKey}; +use super::{clear_image_key, icon_key_for_path, image_key_matches, set_image_key, IconKey}; + +fn hash_image_data(data: &[u8]) -> [u8; 32] { + *blake3::hash(data).as_bytes() +} fn key(name: &str) -> IconKey { IconKey::Name { @@ -9,7 +13,7 @@ fn key(name: &str) -> IconKey { } #[gtk::test] -fn image_qdata_key_matches_only_the_stored_icon_request() { +fn image_key_matches_only_the_stored_icon_request() { let image = gtk::Image::new(); let stored = key("network-wireless"); let different = key("audio-volume-high"); @@ -21,6 +25,42 @@ fn image_qdata_key_matches_only_the_stored_icon_request() { assert!(!image_key_matches(&image, &different)); } +#[gtk::test] +fn cleared_image_key_cannot_accept_a_stale_decode_completion() { + let image = gtk::Image::new(); + let key = key("org.example.Old"); + + set_image_key(&image, key.clone()); + assert!(image_key_matches(&image, &key)); + + clear_image_key(&image); + + assert!(!image_key_matches(&image, &key)); +} + +#[gtk::test] +fn image_keys_do_not_survive_the_image_object() { + let stored = key("network-wireless"); + let old_image = gtk::Image::new(); + set_image_key(&old_image, stored.clone()); + drop(old_image); + + let new_image = gtk::Image::new(); + assert!(!image_key_matches(&new_image, &stored)); +} + +#[gtk::test] +fn image_key_tracking_has_a_hard_bound_when_images_stop_being_accessed() { + for index in 0..=super::MAX_TRACKED_IMAGE_KEYS { + let image = gtk::Image::new(); + set_image_key(&image, key(&format!("icon-{index}"))); + } + + super::IMAGE_KEYS.with(|entries| { + assert!(entries.borrow().len() <= super::MAX_TRACKED_IMAGE_KEYS); + }); +} + #[test] fn image_data_hash_changes_when_only_the_middle_bytes_change() { let mut first = vec![0x11; 16_384]; diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs index f8c2ca473..52dabba4b 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolution.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolution.rs @@ -1,19 +1,13 @@ use std::cell::RefCell; use std::collections::HashMap; -use std::fs; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use gtk::prelude::*; -use image::codecs::png::PngEncoder; -use image::{ExtendedColorType, ImageEncoder}; use unixnotis_core::{NotificationImage, NotificationView}; use unixnotis_ui::icons::DesktopIconIndex; use super::{icon_name_is_usable, IconResolverInner}; -use crate::ui::icons::cache::{set_image_key, IconCache}; +use crate::ui::icons::cache::IconCache; use crate::ui::icons::decode::{IconUpdate, IconWorker}; use crate::ui::icons::missing::MissingIconCache; -use crate::ui::icons::types::IconResolution; #[test] fn empty_icon_name_is_not_resolved() { @@ -35,82 +29,42 @@ fn resolver_inner(update_tx: async_channel::Sender) -> IconResolverI } } -fn test_png() -> Vec { - let mut bytes = Vec::new(); - PngEncoder::new(&mut bytes) - .write_image(&[1, 2, 3, 255], 1, 1, ExtendedColorType::Rgba8) - .expect("encode icon PNG"); - bytes -} - -fn wait_for_update(receiver: &async_channel::Receiver) -> IconUpdate { - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match receiver.try_recv() { - Ok(update) => return update, - Err(async_channel::TryRecvError::Closed) => panic!("icon update channel closed"), - Err(async_channel::TryRecvError::Empty) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(async_channel::TryRecvError::Empty) => panic!("icon worker did not respond"), - } - } -} - #[gtk::test] -fn file_icon_resolution_enqueues_decodes_and_applies_the_worker_result() { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "unixnotis-center-resolution-{}-{stamp}.png", - std::process::id() - )); - fs::write(&path, test_png()).expect("write icon fixture"); - let (update_tx, update_rx) = async_channel::bounded(4); +fn sender_paths_are_not_resolved_by_client_icon_lookup() { + let (update_tx, _update_rx) = async_channel::bounded(1); let resolver = resolver_inner(update_tx); let notification = NotificationView { id: 1, + generation: 1, app_name: "Icon test".to_string(), + attribution: unixnotis_core::NotificationAttribution { + // Keep the daemon-owned fallback empty so this test isolates sender paths + badge_icon: String::new(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: String::new(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, - image: NotificationImage { - image_path: path.to_string_lossy().into_owned(), - ..NotificationImage::default() - }, - }; - - let resolution = resolver - .resolve_icon(¬ification, 16, 1) - .expect("file icon should resolve"); - let IconResolution::Async { request } = resolution else { - panic!("file icon should use the worker"); + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }; - let image = gtk::Image::new(); - image.set_visible(false); - set_image_key(&image, request.key.clone()); - resolver.enqueue(request, &image); - resolver.handle_update(wait_for_update(&update_rx)); - - assert!(image.get_visible()); - assert!(image.paintable().is_some()); - assert!(resolver.inflight.borrow().is_empty()); - fs::remove_file(path).expect("remove icon fixture"); + assert!(resolver.resolve_badge(¬ification, 16, 1).is_none()); } #[gtk::test] fn standard_theme_icon_name_resolves_through_the_resolver() { let (update_tx, _update_rx) = async_channel::bounded(1); let resolver = resolver_inner(update_tx); - let resolution = resolver .resolve_icon_name("folder", 24, 1) .or_else(|| resolver.resolve_icon_name("folder-symbolic", 24, 1)); - assert!(resolution.is_some()); } diff --git a/crates/unixnotis-center/src/ui/icons/tests/resolver.rs b/crates/unixnotis-center/src/ui/icons/tests/resolver.rs index b40419d91..d73f4f8c8 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/resolver.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/resolver.rs @@ -1,6 +1,73 @@ use super::ICON_UPDATE_QUEUE_CAPACITY; +use gtk::prelude::*; +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{BadgePresentation, TrustLevel}; + +use super::super::cache::{image_key_matches, set_image_key, IconKey}; #[test] fn icon_update_queue_capacity_remains_bounded() { assert_eq!(ICON_UPDATE_QUEUE_CAPACITY, 256); } + +#[gtk::test] +fn identity_badge_restores_semantic_fallback_visibility_on_a_recycled_image() { + let resolver = super::IconResolver::new(); + let mut notification = NotificationView { + id: 1, + generation: 1, + app_name: "Example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: String::new(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: unixnotis_core::NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + // Remove every branding candidate so the semantic fallback path is exercised + notification.attribution.badge_icon.clear(); + let image = gtk::Image::from_icon_name("folder"); + image.set_visible(false); + + resolver.apply_identity_badge( + &image, + ¬ification, + BadgePresentation::UnknownApplication, + TrustLevel::Unresolved, + 20, + 1, + ); + + assert!(image.get_visible()); + assert_eq!( + image.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); +} + +#[gtk::test] +fn clearing_identity_badge_invalidates_pending_icon_ownership() { + let resolver = super::IconResolver::new(); + let image = gtk::Image::new(); + let old_key = IconKey::Name { + name: "org.example.Old".to_string(), + size: 20, + scale: 1, + }; + + set_image_key(&image, old_key.clone()); + assert!(image_key_matches(&image, &old_key)); + + resolver.clear_identity_badge(&image); + + assert!(!image_key_matches(&image, &old_key)); + assert!(image.paintable().is_none()); + assert!(!image.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/icons/tests/theme.rs b/crates/unixnotis-center/src/ui/icons/tests/theme.rs index 093bd1537..a7c3cabf2 100644 --- a/crates/unixnotis-center/src/ui/icons/tests/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/tests/theme.rs @@ -3,9 +3,196 @@ use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ - expand_rgb_to_rgba, resolve_icon_source, theme_path_uses_worker, worker_decodes_theme_path, + collect_icon_candidates, expand_rgb_to_rgba, resolve_icon_source, theme_path_uses_worker, + worker_decodes_theme_path, }; -use unixnotis_core::ImageData; +use unixnotis_core::{ImageData, NotificationImage, NotificationView}; + +fn notification_view( + app_name: &str, + attribution: unixnotis_core::NotificationAttribution, + image: NotificationImage, +) -> NotificationView { + NotificationView { + id: 1, + generation: 1, + app_name: app_name.to_string(), + attribution, + summary: String::new(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image, + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +#[test] +fn badge_candidates_exclude_caller_content_icon() { + let notification = notification_view( + "sender-bin", + unixnotis_core::NotificationAttribution { + display_name: "Unknown application".to_string(), + claimed_name: "Claimed Brand".to_string(), + badge_icon: "sender-bin".to_string(), + status: unixnotis_core::AttributionStatus::Conflict, + reason: unixnotis_core::AttributionReason::ExecutableMismatch, + diagnostic_detail: "sender executable mismatch".to_string(), + group_key: "executable:1:2".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + NotificationImage { + badge_icon: "caller-content-icon".to_string(), + ..NotificationImage::default() + }, + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates.iter().any(|candidate| candidate == "sender-bin")); + assert!(!candidates + .iter() + .any(|candidate| candidate == "caller-content-icon")); +} + +#[test] +fn badge_candidates_exclude_unresolved_application_claim() { + let notification = notification_view( + "Trusted Brand", + unixnotis_core::NotificationAttribution { + display_name: "Trusted Brand".to_string(), + badge_icon: "dialog-warning-symbolic".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + NotificationImage::default(), + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "dialog-warning-symbolic")); + assert!(!candidates + .iter() + .any(|candidate| candidate == "Trusted Brand")); +} + +#[test] +fn unresolved_notifications_keep_only_bounded_decorative_theme_hints() { + let attribution = unixnotis_core::NotificationAttribution { + claimed_name: "Example Player".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }; + let image = NotificationImage { + claimed_theme_icon: "example-player".to_string(), + ..NotificationImage::default() + }; + + let notification = notification_view("Unknown", attribution, image); + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-player")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + +#[test] +fn claimed_desktop_id_is_a_bounded_decorative_theme_hint() { + let notification = notification_view( + "Unknown", + unixnotis_core::NotificationAttribution::default(), + NotificationImage { + claimed_desktop_id: "example-chat.desktop".to_string(), + ..NotificationImage::default() + }, + ); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat.desktop")); + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + +#[test] +fn unresolved_claimed_branding_precedes_the_generic_daemon_badge() { + let notification = notification_view( + "Example Application", + unixnotis_core::NotificationAttribution::default(), + NotificationImage { + claimed_desktop_id: "org.example.App.desktop".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.App.desktop") + ); +} + +#[test] +fn associated_branding_still_precedes_presentation_claims() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Associated", + "org.example.associated", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated fixture", + "associated:system-app:org.example.Associated".to_string(), + ); + let notification = notification_view( + "Example Application", + attribution, + NotificationImage { + claimed_desktop_id: "org.example.Claimed.desktop".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.associated") + ); +} + +#[test] +fn icon_candidates_remove_duplicate_presentation_hints() { + let notification = notification_view( + "Example", + unixnotis_core::NotificationAttribution { + badge_icon: "folder".to_string(), + desktop_id: "folder".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + NotificationImage { + claimed_theme_icon: "folder".to_string(), + claimed_desktop_id: "folder".to_string(), + ..NotificationImage::default() + }, + ); + let candidates = collect_icon_candidates(¬ification); + let mut unique = candidates.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(candidates.len(), unique.len()); +} #[test] fn expand_rgb_to_rgba_appends_alpha() { diff --git a/crates/unixnotis-center/src/ui/icons/theme.rs b/crates/unixnotis-center/src/ui/icons/theme.rs index fea379a3c..18b88efe4 100644 --- a/crates/unixnotis-center/src/ui/icons/theme.rs +++ b/crates/unixnotis-center/src/ui/icons/theme.rs @@ -9,7 +9,7 @@ use gio::prelude::FileExt; use gtk::gdk; use gtk::prelude::*; use gtk::{IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::{NotificationImage, NotificationView}; +use unixnotis_core::{AttributionStatus, ImageData, NotificationImage, NotificationView}; pub(super) enum IconSource { Paintable(IconPaintable), @@ -37,23 +37,6 @@ pub(super) fn resolve_icon_source(name: &str, size: i32, scale: i32) -> Option Option { - // Accept raw absolute paths and file:// URIs, decoding percent escapes when present - if path.starts_with('/') { - return Some(PathBuf::from(path)); - } - if path.starts_with("file://") { - // gio::File handles URI decoding and local filesystem resolution - let file = gio::File::for_uri(path); - // Only accept native filesystem paths to avoid non-local URIs - if !file.is_native() { - return None; - } - return file.path(); - } - None -} - fn worker_decodes_theme_path(path: &Path) -> bool { path.extension() .and_then(|extension| extension.to_str()) @@ -95,19 +78,16 @@ fn resolve_icon_paintable(name: &str, size: i32, scale: i32) -> Option Vec { - let mut candidates = Vec::new(); - if !notification.image.icon_name.is_empty() { - candidates.push(notification.image.icon_name.clone()); - if let Some(stripped) = notification.image.icon_name.strip_suffix(".desktop") { - candidates.push(stripped.to_string()); - } - candidates.push(notification.image.icon_name.to_lowercase()); - } - if !notification.app_name.is_empty() { - candidates.push(notification.app_name.clone()); - let lower = notification.app_name.to_lowercase(); - candidates.push(lower.clone()); - candidates.push(lower.replace(' ', "-")); + let mut candidates = Vec::with_capacity(12); + + // Presentation claims come first only when attribution is unresolved + // This keeps a generic daemon badge from hiding a useful bounded app hint + if notification.attribution.status == AttributionStatus::Unresolved { + push_claimed_icon_candidates(&mut candidates, notification); + push_attributed_icon_candidates(&mut candidates, notification); + } else { + push_attributed_icon_candidates(&mut candidates, notification); + push_claimed_icon_candidates(&mut candidates, notification); } let mut seen = HashSet::new(); @@ -117,6 +97,53 @@ pub(super) fn collect_icon_candidates(notification: &NotificationView) -> Vec, notification: &NotificationView) { + let badge_icon = notification.attribution.badge_icon.as_str(); + if !badge_icon.is_empty() { + candidates.push(badge_icon.to_string()); + if let Some(stripped) = badge_icon.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(badge_icon.to_lowercase()); + } + + let desktop_id = notification.attribution.desktop_id.as_str(); + if !desktop_id.is_empty() { + candidates.push(desktop_id.to_string()); + if let Some(stripped) = desktop_id.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(desktop_id.to_lowercase()); + } +} + +fn push_claimed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { + // A desktop-entry hint stays decorative and never changes attribution + let claimed_desktop_id = notification.image.claimed_desktop_id.as_str(); + if is_safe_theme_name(claimed_desktop_id) { + candidates.push(claimed_desktop_id.to_string()); + if let Some(stripped) = claimed_desktop_id.strip_suffix(".desktop") { + candidates.push(stripped.to_string()); + } + candidates.push(claimed_desktop_id.to_lowercase()); + } + + let claimed_theme_icon = notification.image.claimed_theme_icon.as_str(); + if is_safe_theme_name(claimed_theme_icon) { + candidates.push(claimed_theme_icon.to_string()); + candidates.push(claimed_theme_icon.to_lowercase()); + } +} + +fn is_safe_theme_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('.') + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) +} + fn is_missing_icon(path: &Path) -> bool { // Ignore theme placeholders to avoid rendering missing-icon glyphs // Many icon themes provide an "image-missing" asset; treating it as a real icon looks bad @@ -128,12 +155,14 @@ fn is_missing_icon(path: &Path) -> bool { pub(super) fn image_data_texture(image: &NotificationImage) -> Option { // Only proceed if the notification actually carried image-data (not just a name/path hint) - if !image.has_image_data { + if image.content_image.data.is_empty() { return None; } - let data = &image.image_data; + image_data_texture_for_data(&image.content_image) +} +pub(super) fn image_data_texture_for_data(data: &ImageData) -> Option { // The standard image-data payload for notifications is typically 8 bits per channel // If it's not 8, the byte layout is ambiguous for this path, so reject it if data.bits_per_sample != 8 { diff --git a/crates/unixnotis-center/src/ui/init/builders.rs b/crates/unixnotis-center/src/ui/init/builders.rs index e1c92b503..c0c368967 100644 --- a/crates/unixnotis-center/src/ui/init/builders.rs +++ b/crates/unixnotis-center/src/ui/init/builders.rs @@ -7,7 +7,7 @@ use gtk::prelude::*; use super::super::{icons, media, notifications, panel, widgets, UiStateInit}; pub(super) fn build_notification_list( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, icon_resolver: Rc, ) -> notifications::NotificationList { @@ -16,8 +16,13 @@ pub(super) fn build_notification_list( max_entries: init.config.history.max_entries, transient_to_history: init.config.history.transient_to_history, show_notification_metadata: init.config.panel.notification_metadata_visible, + notification_metadata: init.config.panel.notification_metadata.clone(), + notification_corners: init.config.theme.notification_corners, show_notification_thumbnails: init.config.panel.notification_thumbnails_visible, + show_notification_avatars: init.config.panel.notification_avatars_visible, + reduced_motion: init.config.panel.reduced_motion, empty_text: init.config.panel.empty_text.clone(), + no_matching_text: init.config.panel.no_matching_text.clone(), empty_offset_top: init.config.panel.empty_offset_top, empty_alignment: init.config.panel.empty_alignment, }; @@ -25,7 +30,7 @@ pub(super) fn build_notification_list( // Notification list owns row virtualization and icon resolution // Startup only passes the resolved policy and shared channels notifications::NotificationList::new( - panel.scroller.clone(), + panel.sections.scroller.clone(), init.command_tx.clone(), init.event_tx.clone(), icon_resolver, @@ -34,22 +39,24 @@ pub(super) fn build_notification_list( } pub(super) fn build_media_widget( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, ) -> Option { - let panel_width = panel::requested_panel_width(&panel.root); + let panel_width = panel::geometry::requested_panel_width(&panel.root); let media = init.media_handle.as_ref().map(|handle| { - media::MediaWidget::new( - &panel.media_container, + let media = media::MediaWidget::new( + &panel.sections.media_container, handle.clone(), panel_width, &init.config.media, - ) + ); + media.set_reduced_motion(init.config.panel.reduced_motion); + media }); if media.is_none() { // Hidden container keeps layout stable without reserving blank media space - panel.media_container.set_visible(false); + panel.sections.media_container.set_visible(false); } media } @@ -65,7 +72,7 @@ pub(super) struct ExtraWidgets { } pub(super) fn build_widget_sections( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, init: &UiStateInit, icon_resolver: &unixnotis_core::IconAssetResolver, ) -> ExtraWidgets { @@ -99,11 +106,11 @@ pub(super) fn icon_resolver_for_widgets( } } -pub(super) fn has_visible_widget_section(panel: &panel::PanelWidgets) -> bool { +pub(super) fn has_visible_widget_section(panel: &panel::widgets::PanelWidgets) -> bool { // Empty-state spacing depends on whether any upper panel section is visible - panel.quick_controls.get_visible() - || panel.media_container.get_visible() - || panel.toggle_container.get_visible() - || panel.stat_container.get_visible() - || panel.card_container.get_visible() + panel.sections.quick_controls.get_visible() + || panel.sections.media_container.get_visible() + || panel.sections.toggle_container.get_visible() + || panel.sections.stat_container.get_visible() + || panel.sections.card_container.get_visible() } diff --git a/crates/unixnotis-center/src/ui/init/constructor.rs b/crates/unixnotis-center/src/ui/init/constructor.rs index 12adac16e..0ff1b9d47 100644 --- a/crates/unixnotis-center/src/ui/init/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/constructor.rs @@ -22,7 +22,12 @@ impl UiState { } // Build the panel widget tree first so child widgets can be attached safely - let panel = panel::build_panel_widgets(&init.app, &init.config); + let panel = panel::build::build_panel_widgets(&init.app, &init.config); + let scroll_user_generation = Rc::new(Cell::new(0)); + super::super::events::connect_user_scroll_tracking( + &panel.sections.scroller, + scroll_user_generation.clone(), + ); let icon_resolver = Rc::new(icons::IconResolver::new()); debug::set_level(PanelDebugLevel::Off); let list = build_notification_list(&panel, &init, icon_resolver.clone()); @@ -35,15 +40,46 @@ impl UiState { let extra_widgets = build_widget_sections(&panel, &init, &widget_icon_resolver); list.set_empty_layout(has_visible_widget_section(&panel)); - panel::connect_dnd_toggle(&panel, dnd_guard.clone(), init.command_tx.clone()); - panel::connect_clear_button(&panel.clear_action_button, init.command_tx.clone()); - panel::connect_clear_button(&panel.clear_header_button, init.command_tx.clone()); - panel::connect_close_button(&panel, init.command_tx.clone()); - panel::connect_widget_collapse_toggle(&panel, init.event_tx.clone()); - panel::connect_filter_entry(&panel, init.event_tx.clone()); - panel::connect_search_toggle(&panel, search_toggle_guard.clone()); - panel::connect_auto_close(&panel, &init, panel_visible_flag.clone()); - panel::connect_keyboard_shortcuts(&panel, init.command_tx.clone()); + panel::header::actions::connect_dnd_toggle( + &panel, + dnd_guard.clone(), + init.command_tx.clone(), + ); + let dnd_duration_menu = panel::header::dnd::connect_dnd_menu( + &panel.header.actions.dnd_toggle, + &init.config.panel, + init.command_tx.clone(), + ); + panel::header::actions::connect_clear_button( + &panel.header.actions.clear_button, + init.command_tx.clone(), + ); + panel::header::actions::connect_clear_button( + &panel.sections.clear_header_button, + init.command_tx.clone(), + ); + panel::header::actions::connect_close_button(&panel, init.command_tx.clone()); + panel::header::search::connect_widget_collapse_toggle( + &panel.header.actions.focus_toggle, + &panel.sections.widget_revealer, + init.event_tx.clone(), + ); + panel::header::search::connect_filter_entry( + &panel.header.search.entry, + init.event_tx.clone(), + ); + panel::header::search::connect_search_toggle( + &panel.header.actions.search_toggle, + &panel.header.search.revealer, + &panel.header.search.entry, + search_toggle_guard.clone(), + ); + panel::behavior::autoclose::connect_auto_close(&panel, &init, panel_visible_flag.clone()); + panel::behavior::keyboard::connect_keyboard_shortcuts( + &panel, + init.command_tx.clone(), + scroll_user_generation.clone(), + ); if init.config.panel.respect_work_area { // Work area is refreshed early to ensure the panel anchors correctly @@ -58,13 +94,18 @@ impl UiState { config: init.config, config_path: init.config_path, css: init.css, + dnd_duration_menu, panel, list, icon_resolver, widget_icon_resolver, dnd_guard, + dnd_expiration_source: None, search_toggle_guard, panel_visible: false, + notifications_changed_while_hidden: false, + notification_rebuild_generation: Rc::new(Cell::new(0)), + scroll_user_generation, panel_visible_flag, work_area: None, last_count: None, diff --git a/crates/unixnotis-center/src/ui/init/tests/constructor.rs b/crates/unixnotis-center/src/ui/init/tests/constructor.rs index 5e4b69dc2..b1225c090 100644 --- a/crates/unixnotis-center/src/ui/init/tests/constructor.rs +++ b/crates/unixnotis-center/src/ui/init/tests/constructor.rs @@ -55,7 +55,7 @@ fn constructor_builds_disabled_optional_sections_without_reserving_space() { }); assert!(state.media.is_none()); - assert!(!state.panel.media_container.get_visible()); + assert!(!state.panel.sections.media_container.get_visible()); assert!(state.volume.is_none()); assert!(state.brightness.is_none()); assert!(state.toggles.is_none()); diff --git a/crates/unixnotis-center/src/ui/media/config.rs b/crates/unixnotis-center/src/ui/media/config.rs index 60e29cf08..62f8968d5 100644 --- a/crates/unixnotis-center/src/ui/media/config.rs +++ b/crates/unixnotis-center/src/ui/media/config.rs @@ -17,9 +17,9 @@ impl UiState { return; } - self.panel.media_container.set_visible(true); + self.panel.sections.media_container.set_visible(true); // The resolved request stays stable even when a child reports a wider natural allocation - let panel_width = super::super::panel::requested_panel_width(&self.panel.root); + let panel_width = super::super::panel::geometry::requested_panel_width(&self.panel.root); if self.media_layout_changed(config) { self.rebuild_media_widget(config, panel_width); return; @@ -29,7 +29,7 @@ impl UiState { } fn disable_media_widget(&mut self) { - self.panel.media_container.set_visible(false); + self.panel.sections.media_container.set_visible(false); self.clear_media_container(); self.media = None; debug!("media disabled"); @@ -61,11 +61,12 @@ impl UiState { debug!("media widget rebuilt for layout change"); let mut media = widget::MediaWidget::new( - &self.panel.media_container, + &self.panel.sections.media_container, handle.clone(), panel_width, &config.media, ); + media.set_reduced_motion(config.panel.reduced_motion); if !snapshot.is_empty() { // The visible player is restored so reload does not blank the current card media.restore_snapshot(&snapshot); @@ -79,15 +80,17 @@ impl UiState { // Reuse the existing shell when only width or metadata flags changed debug!("media layout updated"); media.apply_layout(panel_width, &config.media); + media.set_reduced_motion(config.panel.reduced_motion); } (None, Some(handle)) => { debug!("media widget created"); let media = widget::MediaWidget::new( - &self.panel.media_container, + &self.panel.sections.media_container, handle.clone(), panel_width, &config.media, ); + media.set_reduced_motion(config.panel.reduced_motion); self.media = Some(media); } (None, None) => { @@ -99,8 +102,12 @@ impl UiState { fn clear_media_container(&self) { // Rebuilds remove old children one by one so GTK releases the shell cleanly - while let Some(child) = self.panel.media_container.first_child() { - self.panel.media_container.remove(&child); + while let Some(child) = self.panel.sections.media_container.first_child() { + self.panel.sections.media_container.remove(&child); } } } + +#[cfg(test)] +#[path = "tests/config.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/media/marquee.rs b/crates/unixnotis-center/src/ui/media/marquee.rs index 26e7b47da..49e823f68 100644 --- a/crates/unixnotis-center/src/ui/media/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/marquee.rs @@ -22,7 +22,8 @@ struct MarqueeState { last_tick: Option, hold_until: Option, reset_pending: bool, - enabled: bool, + overflows: bool, + reduced_motion: bool, is_ticking: bool, is_mapped: bool, tick_source: Option, @@ -73,7 +74,7 @@ impl MarqueeLabel { let state = Rc::new(RefCell::new(MarqueeState { reset_pending: true, - enabled: false, + overflows: false, is_mapped: root.is_mapped(), tick_source: None, char_limit, @@ -95,7 +96,7 @@ impl MarqueeLabel { move |_| { let mut state = mapped_state.borrow_mut(); state.is_mapped = true; - let should_start = state.enabled && !state.is_ticking; + let should_start = marquee_can_start(&state); drop(state); if should_start { start_ticking_inner(mapped_state.clone(), mapped_label.clone()); @@ -112,13 +113,7 @@ impl MarqueeLabel { state.is_mapped = false; // Stop ticking immediately when the widget is unmapped to avoid background work // Unmapped widgets should not keep timers alive - if let Some(source_id) = state.tick_source.take() { - source_id.remove(); - state.is_ticking = false; - state.last_tick = None; - state.hold_until = None; - perf_probe::marquee_stop(); - } + stop_ticking_state(&mut state); } )); @@ -139,16 +134,15 @@ impl MarqueeLabel { } fn set_text_inner(&self, text: &str, force: bool) { + // Identical media snapshots avoid both allocation and Pango layout measurement + if !marquee_text_needs_update(&self.state.borrow().full_text, text, force) { + return; + } // Pango measures rendered pixels so short wide-glyph titles still activate scrolling let text_width = self.label.create_pango_layout(Some(text)).pixel_size().0; let mut state = self.state.borrow_mut(); - // Avoid resetting the marquee when the full text is identical - // This prevents unnecessary redraws and keeps CPU usage stable - if !force && state.full_text == text { - return; - } let char_limit = state.char_limit; - state.enabled = marquee_should_tick( + state.overflows = marquee_should_tick( char_limit, text.chars().count(), text_width, @@ -159,7 +153,8 @@ impl MarqueeLabel { state.hold_until = None; state.last_tick = None; state.full_text = text.to_string(); - state.buffer = if state.enabled { + let animate = state.overflows && !state.reduced_motion; + state.buffer = if animate { let padded = format!("{text} "); padded.chars().collect() } else { @@ -167,7 +162,7 @@ impl MarqueeLabel { }; state.last_rendered_offset = usize::MAX; - let enabled = state.enabled; + let enabled = animate; let mapped = state.is_mapped; let ticking = state.is_ticking; @@ -206,18 +201,59 @@ impl MarqueeLabel { self.update_limits(max_width, char_limit); } + pub fn set_reduced_motion(&self, reduced_motion: bool) { + let mut state = self.state.borrow_mut(); + if state.reduced_motion == reduced_motion { + return; + } + + state.reduced_motion = reduced_motion; + state.reset_pending = true; + state.offset = 0.0; + state.last_tick = None; + state.hold_until = None; + state.last_rendered_offset = usize::MAX; + + if reduced_motion { + // Stop before restoring text so no queued callback can move the stable label again + stop_ticking_state(&mut state); + state.buffer.clear(); + self.label.set_text(&state.full_text); + return; + } + + if state.overflows { + let padded = format!("{} ", state.full_text); + state.buffer = padded.chars().collect(); + render_visible(&mut state, 0); + self.label.set_text(&state.render_buf); + } else { + self.label.set_text(&state.full_text); + } + drop(state); + + self.start_ticking(); + } + fn start_ticking(&self) { start_ticking_inner(self.state.clone(), self.label.clone()); } fn stop_ticking(&self) { let mut state = self.state.borrow_mut(); - if let Some(source_id) = state.tick_source.take() { - source_id.remove(); - } - state.is_ticking = false; - state.last_tick = None; - state.hold_until = None; + stop_ticking_state(&mut state); + } +} + +fn stop_ticking_state(state: &mut MarqueeState) { + let was_ticking = state.is_ticking; + if let Some(source_id) = state.tick_source.take() { + source_id.remove(); + } + state.is_ticking = false; + state.last_tick = None; + state.hold_until = None; + if was_ticking { perf_probe::marquee_stop(); } } @@ -231,10 +267,26 @@ fn marquee_should_tick( char_limit > 0 && (char_count > char_limit || text_width > max_width.max(0)) } +fn marquee_text_needs_update(current: &str, next: &str, force: bool) -> bool { + force || current != next +} + +const fn marquee_can_start(state: &MarqueeState) -> bool { + !state.is_ticking + && state.tick_source.is_none() + && !state.reduced_motion + && state.overflows + && state.is_mapped +} + +const fn marquee_should_stop(state: &MarqueeState) -> bool { + !state.overflows || state.reduced_motion || !state.is_mapped +} + fn start_ticking_inner(state: Rc>, label: gtk::Label) { { let mut state = state.borrow_mut(); - if state.is_ticking { + if !marquee_can_start(&state) { return; } state.is_ticking = true; @@ -247,11 +299,12 @@ fn start_ticking_inner(state: Rc>, label: gtk::Label) { perf_probe::marquee_tick(); let mut state = state_tick.borrow_mut(); - if !state.enabled || !state.is_mapped { + if marquee_should_stop(&state) { state.is_ticking = false; state.tick_source = None; state.last_tick = None; state.hold_until = None; + perf_probe::marquee_stop(); return glib::ControlFlow::Break; } diff --git a/crates/unixnotis-center/src/ui/media/tests/config.rs b/crates/unixnotis-center/src/ui/media/tests/config.rs new file mode 100644 index 000000000..9227df978 --- /dev/null +++ b/crates/unixnotis-center/src/ui/media/tests/config.rs @@ -0,0 +1,157 @@ +//! Media configuration reload tests + +use std::fs; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Config, MediaLayout}; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::media::{MediaCommand, MediaHandle, MediaInfo}; +use crate::ui::{UiState, UiStateInit}; + +static APP_ID: AtomicUsize = AtomicUsize::new(0); + +fn media_state() -> UiState { + let serial = APP_ID.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.media.config.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + // Unrelated widgets stay disabled so this test owns only the media subtree + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let config_dir = std::env::temp_dir().join(format!( + "unixnotis-media-config-test-{}-{serial}", + std::process::id(), + )); + fs::create_dir_all(&config_dir).expect("test config directory should exist"); + let config_path = config_dir.join("config.toml"); + let theme_paths = config + .resolve_theme_paths_from(&config_dir) + .expect("test theme paths should resolve"); + let css = CssManager::new_panel(theme_paths, config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + let (media_tx, _media_rx) = tokio::sync::mpsc::channel::(8); + let media_handle = MediaHandle::connected(media_tx, runtime.handle().clone()); + + UiState::new(UiStateInit { + app, + config, + config_path, + command_tx, + css, + event_tx, + media_handle: Some(media_handle), + runtime, + }) +} + +fn sample_media(title: &str) -> MediaInfo { + MediaInfo { + bus_name: "org.mpris.MediaPlayer2.test".to_string(), + identity: "Test Player".to_string(), + browser_family: None, + owner_pid: None, + source_pid_hint: None, + title: title.to_string(), + artist: "Artist".to_string(), + playback_status: "Playing".to_string(), + art_source: None, + can_play: true, + can_pause: true, + can_next: true, + can_prev: true, + } +} + +fn find_label_with_class(root: >k::Widget, class_name: &str) -> Option { + if root.has_css_class(class_name) { + return root.clone().downcast::().ok(); + } + + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(label) = find_label_with_class(&widget, class_name) { + return Some(label); + } + child = widget.next_sibling(); + } + None +} + +#[gtk::test] +fn structural_media_reload_replaces_the_existing_shell() { + let mut state = media_state(); + let original = state + .panel + .sections + .media_container + .first_child() + .expect("initial media shell should exist"); + let mut config = state.config.clone(); + config.media.layout = MediaLayout::Inline; + + state.apply_media_config(&config); + + let replacement = state + .panel + .sections + .media_container + .first_child() + .expect("replacement media shell should exist"); + assert_ne!(original, replacement); + assert!(state + .media + .as_ref() + .expect("media widget should remain active") + .matches_layout(&config.media)); +} + +#[gtk::test] +fn light_media_reload_updates_limits_and_reduced_motion_without_rebuilding() { + const LONG_TITLE: &str = "A title that must overflow the configured four character lane"; + + let mut state = media_state(); + state + .media + .as_mut() + .expect("initial media widget should exist") + .update(&[sample_media(LONG_TITLE)]); + let original = state + .panel + .sections + .media_container + .first_child() + .expect("initial media shell should exist"); + let mut config = state.config.clone(); + config.media.title_char_limit = 4; + config.panel.reduced_motion = true; + + state.apply_media_config(&config); + + let retained = state + .panel + .sections + .media_container + .first_child() + .expect("media shell should remain attached"); + assert_eq!(original, retained); + let title = find_label_with_class(retained.as_ref(), hooks::media_shell::TITLE) + .expect("media title label should exist"); + assert_eq!(title.width_chars(), 4); + assert_eq!(title.max_width_chars(), 4); + assert_eq!(title.text(), LONG_TITLE); +} diff --git a/crates/unixnotis-center/src/ui/media/tests/marquee.rs b/crates/unixnotis-center/src/ui/media/tests/marquee.rs index e85914433..8cea2ec7f 100644 --- a/crates/unixnotis-center/src/ui/media/tests/marquee.rs +++ b/crates/unixnotis-center/src/ui/media/tests/marquee.rs @@ -1,4 +1,53 @@ -use super::marquee_should_tick; +use super::{ + marquee_can_start, marquee_should_stop, marquee_should_tick, marquee_text_needs_update, + MarqueeLabel, MarqueeState, +}; + +fn ready_marquee_state() -> MarqueeState { + MarqueeState { + overflows: true, + is_mapped: true, + ..MarqueeState::default() + } +} + +#[test] +fn marquee_start_policy_requires_one_idle_visible_overflow() { + let mut state = ready_marquee_state(); + assert!(marquee_can_start(&state)); + + state.is_ticking = true; + assert!(!marquee_can_start(&state)); + state.is_ticking = false; + + state.reduced_motion = true; + assert!(!marquee_can_start(&state)); + state.reduced_motion = false; + + state.overflows = false; + assert!(!marquee_can_start(&state)); + state.overflows = true; + + state.is_mapped = false; + assert!(!marquee_can_start(&state)); +} + +#[test] +fn marquee_stop_policy_covers_every_inactive_state() { + let mut state = ready_marquee_state(); + assert!(!marquee_should_stop(&state)); + + state.overflows = false; + assert!(marquee_should_stop(&state)); + state.overflows = true; + + state.reduced_motion = true; + assert!(marquee_should_stop(&state)); + state.reduced_motion = false; + + state.is_mapped = false; + assert!(marquee_should_stop(&state)); +} #[test] fn marquee_starts_when_short_title_exceeds_pixel_budget() { @@ -17,7 +66,101 @@ fn marquee_stays_idle_when_text_fits_both_limits() { assert!(!marquee_should_tick(32, 17, 81, 81)); } +#[test] +fn marquee_text_fast_path_skips_identical_updates_unless_forced() { + assert!(!marquee_text_needs_update("Track", "Track", false)); + assert!(marquee_text_needs_update("Track", "Track", true)); + assert!(marquee_text_needs_update("Track", "Next track", false)); +} + #[test] fn disabled_marquee_never_starts_for_overflowing_text() { assert!(!marquee_should_tick(0, 40, 300, 81)); } + +#[gtk::test] +fn reduced_motion_keeps_overflowing_text_stable_without_a_timer() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_reduced_motion(true); + + marquee.set_text("Long title"); + + let state = marquee.state.borrow(); + assert!(state.overflows); + assert!(state.reduced_motion); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert_eq!(marquee.label.text(), "Long title"); +} + +#[gtk::test] +fn runtime_reduced_motion_cancels_and_restores_one_marquee_source() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_text("Long title"); + assert!(marquee.state.borrow().tick_source.is_some()); + + marquee.set_reduced_motion(true); + { + let state = marquee.state.borrow(); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert!(state.offset.abs() <= f64::EPSILON); + } + assert_eq!(marquee.label.text(), "Long title"); + + marquee.set_reduced_motion(false); + let restarted_source = marquee + .state + .borrow() + .tick_source + .as_ref() + .expect("overflow should restart one source") + .as_raw(); + marquee.set_reduced_motion(false); + assert_eq!( + marquee + .state + .borrow() + .tick_source + .as_ref() + .expect("repeated preference should retain the source") + .as_raw(), + restarted_source + ); + + // Removing the source keeps it from escaping the test main context + marquee.set_reduced_motion(true); +} + +#[gtk::test] +fn disabling_reduced_motion_does_not_start_a_timer_when_text_fits() { + let marquee = MarqueeLabel::new("test-marquee", 400, 32); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_reduced_motion(true); + marquee.set_text("Short title"); + + marquee.set_reduced_motion(false); + + let state = marquee.state.borrow(); + assert!(!state.overflows); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); +} + +#[gtk::test] +fn replacing_overflow_with_short_text_cancels_the_active_source() { + let marquee = MarqueeLabel::new("test-marquee", 40, 4); + marquee.state.borrow_mut().is_mapped = true; + marquee.set_text("Long title"); + assert!(marquee.state.borrow().tick_source.is_some()); + + marquee.set_text("Fit"); + + let state = marquee.state.borrow(); + assert!(!state.overflows); + assert!(!state.is_ticking); + assert!(state.tick_source.is_none()); + assert_eq!(marquee.label.text(), "Fit"); +} diff --git a/crates/unixnotis-center/src/ui/media/widget/controller.rs b/crates/unixnotis-center/src/ui/media/widget/controller.rs index 1c1407672..811fd3e62 100644 --- a/crates/unixnotis-center/src/ui/media/widget/controller.rs +++ b/crates/unixnotis-center/src/ui/media/widget/controller.rs @@ -77,6 +77,10 @@ impl MediaWidget { self.root.set_visible(false); } + pub(in crate::ui) fn set_reduced_motion(&self, reduced_motion: bool) { + self.card.title_label.set_reduced_motion(reduced_motion); + } + pub(in crate::ui) fn matches_layout(&self, config: &MediaConfig) -> bool { self.shell == MediaShellConfig::from_config(config) } diff --git a/crates/unixnotis-center/src/ui/media/widget/parts.rs b/crates/unixnotis-center/src/ui/media/widget/parts.rs index 5fe54f00d..7472ee5c5 100644 --- a/crates/unixnotis-center/src/ui/media/widget/parts.rs +++ b/crates/unixnotis-center/src/ui/media/widget/parts.rs @@ -6,7 +6,7 @@ use gtk::prelude::*; use gtk::{Align, Overflow, PolicyType}; use crate::media::MediaHandle; -use crate::ui::panel::input::ClickCooldown; +use crate::ui::panel::behavior::input::ClickCooldown; use super::super::artwork::MediaArtState; use super::super::marquee::MarqueeLabel; @@ -135,7 +135,7 @@ fn build_art_picture(art_size_px: i32) -> gtk::Picture { art.add_css_class(hooks::media_shell::ART); art.set_can_shrink(true); art.set_size_request(art_size_px, art_size_px); - art.set_keep_aspect_ratio(true); + art.set_content_fit(gtk::ContentFit::Contain); art.set_hexpand(false); art.set_vexpand(false); art.set_halign(Align::Center); diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/card.rs b/crates/unixnotis-center/src/ui/media/widget/tests/card.rs index d2bc4d4d0..67d925d78 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/card.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/card.rs @@ -71,6 +71,7 @@ fn media_info() -> MediaInfo { identity: "Test Player".to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "A Track".to_string(), artist: "An Artist".to_string(), playback_status: "Playing".to_string(), diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/format.rs b/crates/unixnotis-center/src/ui/media/widget/tests/format.rs index c6b4ef176..733d29bed 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/format.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/format.rs @@ -14,6 +14,7 @@ fn media_info(identity: &str, title: &str, artist: &str) -> MediaInfo { identity: identity.to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: title.to_string(), artist: artist.to_string(), playback_status: "Paused".to_string(), @@ -120,6 +121,7 @@ fn blank_identity_falls_back_to_bus_name_tail() { identity: String::new(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: "Track".to_string(), artist: String::new(), playback_status: "Paused".to_string(), diff --git a/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs b/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs index 41b6d5863..f5fbe08d2 100644 --- a/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs +++ b/crates/unixnotis-center/src/ui/media/widget/tests/selection.rs @@ -8,6 +8,7 @@ fn media_info(bus_name: &str, title: &str) -> MediaInfo { identity: bus_name.to_string(), browser_family: None, owner_pid: None, + source_pid_hint: None, title: title.to_string(), artist: String::new(), playback_status: "Paused".to_string(), diff --git a/crates/unixnotis-center/src/ui/mod.rs b/crates/unixnotis-center/src/ui/mod.rs index b09b73358..27ff2d753 100644 --- a/crates/unixnotis-center/src/ui/mod.rs +++ b/crates/unixnotis-center/src/ui/mod.rs @@ -10,6 +10,7 @@ mod reload; mod init; mod media; +mod motion; mod notifications; mod panel; mod state; diff --git a/crates/unixnotis-center/src/ui/motion.rs b/crates/unixnotis-center/src/ui/motion.rs new file mode 100644 index 000000000..372e8f6a4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/motion.rs @@ -0,0 +1,39 @@ +//! Shared GTK motion-policy operations + +pub(super) fn apply_revealer_preference( + revealer: >k::Revealer, + standard_duration_ms: u32, + reduced_motion: bool, +) { + revealer.set_transition_duration(if reduced_motion { + 0 + } else { + standard_duration_ms + }); + + if let Some([edge, target]) = immediate_reveal_edges( + reduced_motion, + revealer.is_child_revealed(), + revealer.reveals_child(), + ) { + // Reapplying the target through an immediate edge finishes an animation already in flight + revealer.set_reveal_child(edge); + revealer.set_reveal_child(target); + } +} + +const fn immediate_reveal_edges( + reduced_motion: bool, + child_revealed: bool, + target: bool, +) -> Option<[bool; 2]> { + if reduced_motion && child_revealed != target { + Some([!target, target]) + } else { + None + } +} + +#[cfg(test)] +#[path = "tests/motion.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/mod.rs b/crates/unixnotis-center/src/ui/notifications/mod.rs index b06eedb3c..5c03ccbb5 100644 --- a/crates/unixnotis-center/src/ui/notifications/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/mod.rs @@ -10,6 +10,7 @@ mod store; pub(super) mod test_support; mod view; +pub(in crate::ui) use model::types::NotificationCounts; pub use model::types::{NotificationList, NotificationListConfig}; pub(in crate::ui::notifications) use model::item; diff --git a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs index ad7ebc0f0..c404cedf6 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/grouping.rs @@ -3,7 +3,7 @@ use std::borrow::Cow; use std::rc::Rc; -use super::types::{FilterQuery, NotificationList}; +use super::types::{FilterQuery, NotificationCounts, NotificationList}; impl NotificationList { pub(in crate::ui::notifications) fn intern_key(&mut self, key: &str) -> Rc { @@ -21,7 +21,7 @@ impl NotificationList { &self, key: &'a str, ) -> Cow<'a, str> { - // Trim outer whitespace to avoid duplicate stacks from padded app names + // Trim outer whitespace to avoid duplicate groups from padded app names let trimmed = key.trim(); if trimmed.is_empty() { return Cow::Borrowed(""); @@ -120,11 +120,40 @@ impl NotificationList { }) } - fn entry_matches_filter(&self, view: &unixnotis_core::NotificationView) -> bool { + pub(in crate::ui) fn notification_counts(&self) -> NotificationCounts { + let total = self.total_count(); + let Some(_) = self.filter_query.as_ref() else { + return NotificationCounts { + matching: total, + total, + filter_active: false, + }; + }; + // Count notifications rather than GTK rows because each group adds a header row + let matching = self + .active_order + .iter() + .chain(&self.history_order) + .filter_map(|id| self.entries.get(id)) + .filter(|entry| self.entry_matches_filter(&entry.view)) + .count(); + NotificationCounts { + matching, + total, + filter_active: true, + } + } + + pub(in crate::ui::notifications) fn entry_matches_filter( + &self, + view: &unixnotis_core::NotificationView, + ) -> bool { let Some(query) = self.filter_query.as_ref() else { return true; }; - contains_casefold(&view.app_name, query) + contains_casefold(&view.attribution.display_name, query) + || contains_casefold(&view.attribution.claimed_name, query) + || contains_casefold(&view.attribution.diagnostic_detail, query) || contains_casefold(&view.summary, query) || contains_casefold(&view.body, query) } diff --git a/crates/unixnotis-center/src/ui/notifications/model/item.rs b/crates/unixnotis-center/src/ui/notifications/model/item.rs index 8de32ec0c..2815fdec5 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/item.rs @@ -7,7 +7,7 @@ use std::sync::OnceLock; use glib::subclass::prelude::*; use gtk::glib; use gtk::glib::object::ObjectExt; -use unixnotis_core::NotificationView; +use unixnotis_core::{CutCorners, NotificationMetadataConfig, NotificationView}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RowKind { @@ -15,15 +15,50 @@ pub enum RowKind { Notification, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct RowPresentation { // Local receipt timestamp supports relative badges without changing D-Bus payloads pub received_at_ms: i64, // Optional lanes are disabled by default to preserve the compact stock card pub show_metadata: bool, pub show_thumbnail: bool, + pub show_avatar: bool, + // Runtime motion policy keeps recycled row revealers in sync with panel settings + pub reduced_motion: bool, + // Shared config avoids cloning every metadata string into every row snapshot + pub metadata: Rc, + // Card clipping follows theme reloads through the same row refresh path + pub card_corners: CutCorners, } +impl Default for RowPresentation { + fn default() -> Self { + Self { + received_at_ms: 0, + show_metadata: false, + show_thumbnail: false, + show_avatar: true, + reduced_motion: false, + metadata: Rc::new(NotificationMetadataConfig::default()), + card_corners: CutCorners::default(), + } + } +} + +impl PartialEq for RowPresentation { + fn eq(&self, other: &Self) -> bool { + self.received_at_ms == other.received_at_ms + && self.show_metadata == other.show_metadata + && self.show_thumbnail == other.show_thumbnail + && self.show_avatar == other.show_avatar + && self.reduced_motion == other.reduced_motion + && Rc::ptr_eq(&self.metadata, &other.metadata) + && self.card_corners == other.card_corners + } +} + +impl Eq for RowPresentation {} + #[derive(Debug, Clone)] pub struct RowData { pub kind: RowKind, @@ -31,9 +66,11 @@ pub struct RowData { pub group_key: Rc, pub count: u32, pub expanded: bool, - // True when this notification is the visible card for a collapsed group - pub stacked: bool, - // Number of internal ghost cards shown under the visible notification card + // Every notification block has a separate application identity header + pub app_header_present: bool, + // True when this notification previews a collapsed multi-item group + pub collapsed_group_preview: bool, + // Rear silhouettes cap at two layers while the count keeps the exact total pub stack_depth: u8, pub is_active: bool, pub presentation: RowPresentation, @@ -49,7 +86,8 @@ impl Default for RowData { group_key: Rc::from(""), count: 0, expanded: false, - stacked: false, + app_header_present: false, + collapsed_group_preview: false, stack_depth: 0, is_active: false, presentation: RowPresentation::default(), @@ -72,7 +110,8 @@ impl RowData { group_key, count: count as u32, expanded, - stacked: false, + app_header_present: false, + collapsed_group_preview: false, stack_depth: 0, is_active: false, presentation: RowPresentation::default(), @@ -83,7 +122,7 @@ impl RowData { pub fn notification( group_key: Rc, notification: Rc, - stacked: bool, + collapsed_group_preview: bool, stack_depth: u8, expanded: bool, is_active: bool, @@ -96,7 +135,8 @@ impl RowData { group_key, count: 0, expanded, - stacked, + app_header_present: true, + collapsed_group_preview, stack_depth, is_active, presentation, @@ -111,7 +151,8 @@ impl RowData { && Rc::ptr_eq(&self.group_key, &other.group_key) && self.count == other.count && self.expanded == other.expanded - && self.stacked == other.stacked + && self.app_header_present == other.app_header_present + && self.collapsed_group_preview == other.collapsed_group_preview && self.stack_depth == other.stack_depth && self.is_active == other.is_active && self.presentation == other.presentation diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs index f3aeef353..dc999d65d 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/grouping.rs @@ -105,6 +105,34 @@ fn group_visibility_and_entry_filter_cover_app_summary_and_body() { assert!(!list.group_has_visible_entries(&terminal_ids)); } +#[gtk::test] +fn notification_counts_report_matches_and_total_for_active_search() { + let mut list = support::make_list(); + let mut terminal = support::notification(1, "Terminal"); + terminal.body = "Build complete".to_string(); + list.seed( + vec![terminal, support::notification(2, "Browser")], + vec![support::notification(3, "Terminal history")], + ); + + let counts = list.notification_counts(); + assert_eq!(counts.matching, 3); + assert_eq!(counts.total, 3); + assert!(!counts.filter_active); + + assert!(list.set_filter_query("terminal")); + let counts = list.notification_counts(); + assert_eq!(counts.matching, 2); + assert_eq!(counts.total, 3); + assert!(counts.filter_active); + + assert!(list.set_filter_query("missing")); + let counts = list.notification_counts(); + assert_eq!(counts.matching, 0); + assert_eq!(counts.total, 3); + assert!(counts.filter_active); +} + #[test] fn ignorable_group_chars_cover_controls_and_zero_width_marks() { assert!(is_ignorable_group_char('\n')); diff --git a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs index 1306fcf9c..87859e027 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/tests/item.rs @@ -9,13 +9,21 @@ use super::{RowData, RowItem, RowKind, RowPresentation}; fn notification(id: u32) -> Rc { Rc::new(NotificationView { id, + generation: u64::from(id), app_name: "Terminal".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }) } @@ -40,6 +48,7 @@ fn row_data_notification_sets_expected_fields() { received_at_ms: 123, show_metadata: true, show_thumbnail: true, + ..RowPresentation::default() }; let data = RowData::notification( @@ -49,12 +58,12 @@ fn row_data_notification_sets_expected_fields() { 2, false, true, - presentation, + presentation.clone(), ); assert_eq!(data.kind, RowKind::Notification); assert_eq!(data.id, 42); - assert!(data.stacked); + assert!(data.collapsed_group_preview); assert_eq!(data.stack_depth, 2); assert!(data.is_active); assert_eq!(data.presentation, presentation); @@ -110,6 +119,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { received_at_ms: 12, show_metadata: true, show_thumbnail: false, + ..RowPresentation::default() }, ); @@ -136,7 +146,11 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { assert!(!base.is_equivalent(&changed)); let mut changed = base.clone(); - changed.stacked = true; + changed.app_header_present = false; + assert!(!base.is_equivalent(&changed)); + + let mut changed = base.clone(); + changed.collapsed_group_preview = true; assert!(!base.is_equivalent(&changed)); let mut changed = base.clone(); @@ -151,6 +165,10 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { changed.presentation.show_thumbnail = true; assert!(!base.is_equivalent(&changed)); + let mut changed = base.clone(); + changed.presentation.reduced_motion = true; + assert!(!base.is_equivalent(&changed)); + let mut changed = base; changed.notification = Some(notification(1)); assert!(!RowData::notification( @@ -164,6 +182,7 @@ fn row_data_equivalence_requires_every_rendered_field_to_match() { received_at_ms: 12, show_metadata: true, show_thumbnail: false, + ..RowPresentation::default() }, ) .is_equivalent(&changed)); diff --git a/crates/unixnotis-center/src/ui/notifications/model/types.rs b/crates/unixnotis-center/src/ui/notifications/model/types.rs index 8e997f83d..fc8cb142b 100644 --- a/crates/unixnotis-center/src/ui/notifications/model/types.rs +++ b/crates/unixnotis-center/src/ui/notifications/model/types.rs @@ -6,8 +6,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use gtk::glib; -use unixnotis_core::EmptyStateAlignment; -use unixnotis_core::NotificationView; +use unixnotis_core::{ + CutCorners, EmptyStateAlignment, NotificationMetadataConfig, NotificationView, +}; use super::item::RowItem; @@ -18,6 +19,7 @@ pub struct NotificationList { pub(in crate::ui::notifications) empty_offset_top: i32, pub(in crate::ui::notifications) empty_alignment: EmptyStateAlignment, pub(in crate::ui) empty_text: String, + pub(in crate::ui) no_matching_text: String, pub(in crate::ui::notifications) entries: HashMap, // Active notifications render first to match the in-flight stack pub(in crate::ui::notifications) active_order: VecDeque, @@ -47,7 +49,11 @@ pub struct NotificationList { pub(in crate::ui::notifications) transient_to_history: bool, // Optional metadata lanes stay config-owned so the stock row remains compact pub(in crate::ui::notifications) show_notification_metadata: bool, + pub(in crate::ui::notifications) notification_metadata: Rc, + pub(in crate::ui::notifications) notification_corners: CutCorners, pub(in crate::ui::notifications) show_notification_thumbnails: bool, + pub(in crate::ui::notifications) show_notification_avatars: bool, + pub(in crate::ui::notifications) reduced_motion: bool, pub(in crate::ui::notifications) max_active: usize, pub(in crate::ui::notifications) max_entries: usize, } @@ -58,12 +64,25 @@ pub struct NotificationListConfig { pub max_entries: usize, pub transient_to_history: bool, pub show_notification_metadata: bool, + pub notification_metadata: NotificationMetadataConfig, + pub notification_corners: CutCorners, pub show_notification_thumbnails: bool, + pub show_notification_avatars: bool, + pub reduced_motion: bool, pub empty_text: String, + pub no_matching_text: String, pub empty_offset_top: i32, pub empty_alignment: EmptyStateAlignment, } +/// Counts used by the panel header for normal and filtered list states +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::ui) struct NotificationCounts { + pub(in crate::ui) matching: usize, + pub(in crate::ui) total: usize, + pub(in crate::ui) filter_active: bool, +} + pub(in crate::ui::notifications) struct NotificationEntry { pub(in crate::ui::notifications) view: Rc, pub(in crate::ui::notifications) is_active: bool, diff --git a/crates/unixnotis-center/src/ui/notifications/row/group.rs b/crates/unixnotis-center/src/ui/notifications/row/group.rs index 1cc10e3b2..f425c6c74 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/group.rs @@ -10,15 +10,22 @@ use gtk::pango; use gtk::prelude::*; use tracing::debug; use unixnotis_core::{css::hooks, util}; +use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; use crate::control::UiEvent; use super::super::super::icons::IconResolver; use super::super::item::RowData; +const GROUP_AVATAR_SIZE: i32 = 26; +const GROUP_ICON_SIZE: i32 = 18; pub(in crate::ui::notifications) struct GroupRowWidgets { + pub(super) button: gtk::Button, + pub(super) avatar: gtk::Box, pub(super) icon: gtk::Image, pub(super) title: gtk::Label, + pub(super) secondary: gtk::Label, + pub(super) trust_chip: gtk::Label, pub(super) count: gtk::Label, pub(super) chevron: gtk::Image, pub(super) group_key: Rc>>, @@ -32,37 +39,68 @@ pub(in crate::ui::notifications) fn build_group_row( root.add_css_class(hooks::group_row::ROOT); root.add_css_class(hooks::group_row::CONTAINER); root.add_css_class(hooks::group_row::EXPANDED); + root.set_hexpand(true); + root.set_halign(gtk::Align::Fill); + root.set_vexpand(false); + root.set_margin_bottom(super::layout::NOTIFICATION_LIST_ROW_GAP); let button = gtk::Button::new(); button.add_css_class(hooks::group_row::HEADER); button.set_has_frame(false); button.set_focusable(true); button.set_tooltip_text(Some("Toggle group")); + button.set_hexpand(true); + button.set_halign(gtk::Align::Fill); let header = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.set_halign(gtk::Align::Fill); + let avatar = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar.set_halign(gtk::Align::Center); + avatar.set_valign(gtk::Align::Center); + avatar.set_size_request(GROUP_AVATAR_SIZE, GROUP_AVATAR_SIZE); + avatar.add_css_class("unixnotis-group-avatar"); let icon = gtk::Image::new(); - icon.set_pixel_size(18); + icon.set_pixel_size(GROUP_ICON_SIZE); icon.add_css_class(hooks::group_row::ICON); + avatar.append(&icon); + + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); + identity.set_hexpand(true); + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); let title = gtk::Label::new(None); title.set_xalign(0.0); - title.set_hexpand(true); title.set_ellipsize(pango::EllipsizeMode::End); + title.set_single_line_mode(true); title.add_css_class(hooks::group_row::TITLE); + let trust_chip = gtk::Label::new(None); + trust_chip.set_single_line_mode(true); + trust_chip.add_css_class("unixnotis-group-trust-chip"); + trust_chip.set_visible(false); + + let secondary = gtk::Label::new(None); + secondary.set_xalign(0.0); + secondary.set_ellipsize(pango::EllipsizeMode::End); + secondary.set_single_line_mode(true); + secondary.add_css_class("unixnotis-group-secondary"); + secondary.set_visible(false); + let count = gtk::Label::new(Some("0")); count.set_xalign(0.5); count.add_css_class(hooks::group_row::COUNT); - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - spacer.set_hexpand(true); - let chevron = gtk::Image::from_icon_name("pan-down-symbolic"); + chevron.set_pixel_size(14); chevron.add_css_class(hooks::group_row::CHEVRON); - header.append(&icon); - header.append(&title); - header.append(&spacer); + identity_top.append(&title); + identity_top.append(&trust_chip); + identity.append(&identity_top); + identity.append(&secondary); + header.append(&avatar); + header.append(&identity); header.append(&count); header.append(&chevron); button.set_child(Some(&header)); @@ -71,7 +109,11 @@ pub(in crate::ui::notifications) fn build_group_row( let group_key: Rc>> = Rc::new(RefCell::new(Rc::from(""))); let event_tx_clone = event_tx; let group_key_clone = group_key.clone(); - button.connect_clicked(move |_| { + button.connect_clicked(move |button| { + if !button.is_sensitive() { + // Programmatic signal emission must respect the same singleton guard + return; + } let group = group_key_clone.borrow().clone(); if group.is_empty() { return; @@ -99,8 +141,12 @@ pub(in crate::ui::notifications) fn build_group_row( ( root, GroupRowWidgets { + button, + avatar, icon, title, + secondary, + trust_chip, count, chevron, group_key, @@ -114,38 +160,160 @@ pub(in crate::ui::notifications) fn update_group_row( data: &RowData, icon_resolver: &IconResolver, ) { - let display_name = data + let presentation = data .notification .as_ref() - .map(|notification| notification.app_name.trim()) + .map(|notification| NotificationPresentation::from_view(notification)); + let display_name = presentation + .as_ref() + .map(|view| view.identity.primary_label.as_str()) .filter(|name| !name.is_empty()) - .unwrap_or_else(|| data.group_key.as_ref()); - // Display the original app label while the normalized key drives grouping behavior + .unwrap_or(data.group_key.as_ref()); + // Display application presentation while the daemon identity key drives grouping behavior // Fall back to the group key if no sample notification is available set_label_text_if_changed(&group.title, display_name); + let secondary = presentation + .as_ref() + .and_then(|view| view.identity.secondary_claim.as_deref()) + .unwrap_or_default(); + set_label_text_if_changed(&group.secondary, secondary); + set_widget_visible_if_changed(&group.secondary, !secondary.is_empty()); + let trust_label = presentation + .as_ref() + .and_then(|view| view.trust.short_label.as_deref()) + .unwrap_or_default(); + set_label_text_if_changed(&group.trust_chip, trust_label); + set_widget_visible_if_changed(&group.trust_chip, !trust_label.is_empty()); let next_count = data.count.to_string(); set_label_text_if_changed(&group.count, &next_count); + let has_multiple = data.count > 1; + set_widget_visible_if_changed(&group.count, has_multiple); + set_widget_visible_if_changed(&group.chevron, has_multiple); + group.button.set_focusable(has_multiple); + group.button.set_sensitive(has_multiple); + group + .button + .set_tooltip_text(has_multiple.then_some("Toggle notification group")); + let accessible_label = group_accessible_label( + display_name, + trust_label, + secondary, + data.count, + data.expanded, + ); + group + .button + .update_property(&[gtk::accessible::Property::Label(&accessible_label)]); let chevron_name = if data.expanded { "pan-up-symbolic" } else { "pan-down-symbolic" }; - set_icon_name_if_changed(&group.chevron, chevron_name); + if has_multiple { + set_icon_name_if_changed(&group.chevron, chevron_name); + } set_class_state(root, hooks::group_row::COLLAPSED, !data.expanded); set_class_state(root, hooks::group_row::EXPANDED, data.expanded); *group.group_key.borrow_mut() = data.group_key.clone(); if let Some(notification) = data.notification.as_ref() { + let Some(presentation) = presentation else { + root.queue_resize(); + return; + }; + set_widget_visible_if_changed(&group.avatar, true); + if presentation.trust.details_label.is_none() { + group.title.set_tooltip_text(None); + } else if let Some(details) = presentation.trust.details_label.as_deref() { + group.title.set_tooltip_text(Some(details)); + } + set_class_state( + root, + "unixnotis-attribution-warning", + presentation.trust.level == TrustLevel::Conflict, + ); + for (level, class_name) in [ + (TrustLevel::Verified, "verified"), + (TrustLevel::Unresolved, "unresolved"), + (TrustLevel::Conflict, "conflict"), + (TrustLevel::Relay, "relay"), + ] { + set_class_state(root, class_name, presentation.trust.level == level); + } + set_class_state( + root, + "recognized", + matches!( + presentation.trust.level, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated + ), + ); let scale = root.scale_factor(); - icon_resolver.apply_icon(&group.icon, notification.as_ref(), 18, scale); + icon_resolver.apply_identity_badge( + &group.icon, + notification.as_ref(), + presentation.identity.badge, + presentation.trust.level, + GROUP_ICON_SIZE, + scale, + ); set_class_state(root, hooks::group_row::HAS_ICON, true); set_class_state(root, hooks::group_row::NO_ICON, false); } else { - set_widget_visible_if_changed(&group.icon, false); + clear_group_identity(group, icon_resolver); + clear_group_trust_state(group, root); set_class_state(root, hooks::group_row::NO_ICON, true); set_class_state(root, hooks::group_row::HAS_ICON, false); } + // Group identity changes can alter the natural row height when rows are recycled + root.queue_resize(); +} + +pub(in crate::ui::notifications) fn clear_group_identity( + group: &GroupRowWidgets, + icon_resolver: &IconResolver, +) { + // Recycled group rows must revoke ownership of pending async icon work + // Hiding the widget alone is insufficient because a late decode shows it again + icon_resolver.clear_identity_badge(&group.icon); + set_widget_visible_if_changed(&group.avatar, false); +} + +fn clear_group_trust_state(group: &GroupRowWidgets, root: >k::Box) { + // An empty model sample carries no trust evidence from the previous recycled row + group.title.set_tooltip_text(None); + set_class_state(root, "unixnotis-attribution-warning", false); + for class_name in ["verified", "recognized", "unresolved", "conflict", "relay"] { + set_class_state(root, class_name, false); + } +} + +fn group_accessible_label( + display_name: &str, + trust_label: &str, + secondary: &str, + count: u32, + expanded: bool, +) -> String { + let mut parts = vec![display_name.trim().to_string()]; + if !trust_label.trim().is_empty() { + parts.push(trust_label.trim().to_string()); + } + if !secondary.trim().is_empty() { + parts.push(secondary.trim().to_string()); + } + parts.push(if count == 1 { + "1 notification".to_string() + } else { + format!("{count} notifications") + }); + if count > 1 { + parts.push(if expanded { "Expanded" } else { "Collapsed" }.to_string()); + } + parts.join(". ") } fn set_label_text_if_changed(label: >k::Label, text: &str) { diff --git a/crates/unixnotis-center/src/ui/notifications/row/layout.rs b/crates/unixnotis-center/src/ui/notifications/row/layout.rs new file mode 100644 index 000000000..1fd273071 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/layout.rs @@ -0,0 +1,4 @@ +//! Shared structural measurements for notification list rows + +// Row separation belongs to GTK layout so virtualized rows measure their gap +pub(super) const NOTIFICATION_LIST_ROW_GAP: i32 = 8; diff --git a/crates/unixnotis-center/src/ui/notifications/row/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/mod.rs index 310f09bc2..9f5670830 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/mod.rs @@ -5,4 +5,5 @@ pub(super) mod empty; pub(super) mod group; +pub(super) mod layout; pub(super) mod notification; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs index f84b47255..0b5c43c16 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/build.rs @@ -9,40 +9,41 @@ use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use tokio::sync::mpsc; use tracing::debug; -use unixnotis_core::css::hooks; +use unixnotis_core::{css::hooks, NotificationKey}; +use unixnotis_ui::presentation::default_activation::{ + connect_default_activation, mark_interactive, +}; +use unixnotis_ui::CutCorner; use crate::control::UiCommand; use crate::ui::try_send_command; +use super::reply::build_inline_reply; +use super::stack::append_stack_layers; use super::state::NotificationRowWidgets; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum StackLayer { - Back, - Middle, - Foreground, -} - -// Later GTK siblings paint above earlier siblings when card margins overlap -pub(super) const STACK_LAYER_ORDER: [StackLayer; 3] = - [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground]; - pub(in crate::ui::notifications) fn build_notification_row( command_tx: mpsc::Sender, ) -> (gtk::Box, NotificationRowWidgets) { - // Root owns the full collapsed-stack shape as one ListView row + // Root owns the full collapsed group preview as one ListView row let root = gtk::Box::new(gtk::Orientation::Vertical, 0); root.add_css_class(hooks::panel_card::ROW); root.set_hexpand(true); + root.set_halign(gtk::Align::Fill); + root.set_vexpand(false); + root.set_margin_bottom(super::super::layout::NOTIFICATION_LIST_ROW_GAP); - // Card uses vertical layout: header, summary, body, then actions + // Card keeps its header and message column in one measured composition let card = gtk::Box::new(gtk::Orientation::Vertical, 6); card.add_css_class("unixnotis-panel-card"); card.set_hexpand(true); + card.set_halign(gtk::Align::Fill); + card.set_vexpand(false); let meta_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); meta_top.add_css_class(hooks::panel_card::META_TOP); meta_top.set_hexpand(true); + meta_top.set_halign(gtk::Align::Fill); meta_top.set_visible(false); let meta_label = gtk::Label::new(None); @@ -50,24 +51,35 @@ pub(in crate::ui::notifications) fn build_notification_row( meta_label.set_xalign(0.0); meta_label.set_single_line_mode(true); - let meta_spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - meta_spacer.set_hexpand(true); - let time_badge = gtk::Label::new(None); time_badge.add_css_class(hooks::panel_card::TIME_BADGE); - time_badge.set_xalign(0.5); + time_badge.set_halign(gtk::Align::End); + time_badge.set_xalign(1.0); time_badge.set_single_line_mode(true); + time_badge.set_visible(false); meta_top.append(&meta_label); - meta_top.append(&meta_spacer); - meta_top.append(&time_badge); - // Header packs icon + app label + close button + // The dismiss control stays in the measured header like the stable master layout + let close_button = gtk::Button::from_icon_name("window-close-symbolic"); + close_button.set_halign(gtk::Align::End); + close_button.set_valign(gtk::Align::Center); + close_button.add_css_class("unixnotis-panel-close"); + mark_interactive(&close_button); + close_button.update_property(&[gtk::accessible::Property::Label("Dismiss notification")]); + + // Header owns identity, chronology, and dismiss without covering message content let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); header.add_css_class(hooks::panel_card::HEADER); + header.set_hexpand(true); + header.set_halign(gtk::Align::Fill); let icon = gtk::Image::new(); - icon.set_pixel_size(22); + icon.set_pixel_size(20); icon.add_css_class("unixnotis-panel-icon"); + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); + identity.set_hexpand(true); + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let app_label = gtk::Label::new(None); app_label.set_xalign(0.0); // Ellipsis avoids row width spikes from long app names @@ -76,21 +88,38 @@ pub(in crate::ui::notifications) fn build_notification_row( app_label.set_max_width_chars(40); app_label.add_css_class("unixnotis-panel-app"); - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // Spacer pushes close button to the far edge - spacer.set_hexpand(true); - - let close_button = gtk::Button::from_icon_name("window-close-symbolic"); - close_button.set_halign(gtk::Align::End); - close_button.add_css_class("unixnotis-panel-close"); - + let trust_chip = gtk::Label::new(None); + trust_chip.set_single_line_mode(true); + trust_chip.add_css_class("unixnotis-panel-trust-chip"); + trust_chip.set_visible(false); + + let secondary_claim = gtk::Label::new(None); + secondary_claim.set_xalign(0.0); + secondary_claim.set_single_line_mode(true); + secondary_claim.set_ellipsize(EllipsizeMode::End); + secondary_claim.add_css_class("unixnotis-panel-secondary-claim"); + secondary_claim.set_visible(false); + + let urgency_badge = gtk::Label::new(Some("Critical")); + // Reused rows toggle this widget instead of rebuilding the header tree + urgency_badge.add_css_class(hooks::urgency::BADGE); + urgency_badge.set_single_line_mode(true); + urgency_badge.set_visible(false); + + identity_top.append(&app_label); + identity_top.append(&trust_chip); + identity_top.append(&urgency_badge); + identity.append(&identity_top); + identity.append(&secondary_claim); header.append(&icon); - header.append(&app_label); - header.append(&spacer); + header.append(&identity); + header.append(&time_badge); header.append(&close_button); let body_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); body_row.set_hexpand(true); + body_row.set_halign(gtk::Align::Fill); + body_row.set_vexpand(false); let thumbnail = gtk::Image::new(); thumbnail.add_css_class(hooks::panel_card::THUMBNAIL); @@ -98,34 +127,46 @@ pub(in crate::ui::notifications) fn build_notification_row( thumbnail.set_size_request(56, 56); thumbnail.set_visible(false); - let text_stack = gtk::Box::new(gtk::Orientation::Vertical, 6); + let text_stack = gtk::Box::new(gtk::Orientation::Vertical, 2); text_stack.add_css_class(hooks::panel_card::TEXT); text_stack.set_hexpand(true); + text_stack.set_halign(gtk::Align::Fill); + text_stack.set_vexpand(false); // Summary is optional, so the update path decides later if the row should exist let summary_label = gtk::Label::new(None); summary_label.set_xalign(0.0); - // Summary can wrap but stays bounded to three lines + summary_label.set_hexpand(true); + // One title line keeps short grouped rows compact summary_label.set_wrap(true); summary_label.set_wrap_mode(WrapMode::WordChar); summary_label.set_ellipsize(EllipsizeMode::End); - summary_label.set_lines(3); + summary_label.set_lines(2); summary_label.set_max_width_chars(88); summary_label.add_css_class("unixnotis-panel-summary"); // Body follows the same optional-row rule as summary text let body_label = gtk::Label::new(None); body_label.set_xalign(0.0); - // Body gets more lines than summary but still has upper bounds + // Three body lines provide context without dominating the panel body_label.set_wrap(true); body_label.set_wrap_mode(WrapMode::WordChar); body_label.set_ellipsize(EllipsizeMode::End); - body_label.set_lines(8); + body_label.set_lines(5); body_label.set_max_width_chars(112); body_label.add_css_class("unixnotis-panel-body"); + let popup_status = gtk::Label::new(None); + popup_status.set_xalign(0.0); + popup_status.set_wrap(true); + popup_status.set_wrap_mode(WrapMode::WordChar); + popup_status.set_lines(2); + popup_status.add_css_class("unixnotis-popup-status"); + popup_status.set_visible(false); + text_stack.append(&summary_label); text_stack.append(&body_label); + text_stack.append(&popup_status); body_row.append(&thumbnail); body_row.append(&text_stack); @@ -153,6 +194,9 @@ pub(in crate::ui::notifications) fn build_notification_row( let actions_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); // Action buttons are added on demand during row updates actions_box.add_css_class("unixnotis-notification-actions"); + actions_box.set_visible(false); + mark_interactive(&actions_box); + let inline_reply = build_inline_reply(command_tx.clone()); // Keep the card tree fully built up front // Row refreshes then only replace content instead of rebuilding containers @@ -160,69 +204,110 @@ pub(in crate::ui::notifications) fn build_notification_row( card.append(&header); card.append(&body_row); card.append(&footer); - card.append(&actions_box); - - let stack_ghost_1 = build_stack_ghost(1); - let stack_ghost_2 = build_stack_ghost(2); - - // The explicit plan makes paint order reviewable without starting GTK in unit tests - for layer in STACK_LAYER_ORDER { - match layer { - StackLayer::Back => root.append(&stack_ghost_2), - StackLayer::Middle => root.append(&stack_ghost_1), - StackLayer::Foreground => root.append(&card), + card.append(&inline_reply.revealer); + // Actions share the message column with the avatar instead of adding a second full row + text_stack.append(&actions_box); + + // The wrapper owns the configured corner cut while the inner box keeps all CSS hooks + let card_plate = CutCorner::new(&card, unixnotis_core::CutCorners::default()); + card_plate.add_css_class("unixnotis-panel-card-foreground"); + card_plate.set_hexpand(true); + card_plate.set_halign(gtk::Align::Fill); + card_plate.set_vexpand(false); + + // One grid cell keeps every positive stack offset inside the measured row bounds + let stack = gtk::Grid::new(); + stack.add_css_class("unixnotis-panel-notification-stack"); + stack.set_hexpand(true); + root.append(&stack); + // Master-style silhouettes preserve the visible group depth without accepting input + let (stack_middle, stack_back) = append_stack_layers(&stack, &card_plate); + + let notify_key = Rc::new(Cell::new(NotificationKey { + id: 0, + generation: 0, + })); + // Recycled rows retain the exact generation rather than targeting a reused numeric id + connect_dismiss_button(&close_button, command_tx.clone(), notify_key.clone()); + let default_activation = connect_default_activation(&card, { + move |notification, action_key| { + try_send_command( + &command_tx, + UiCommand::InvokeAction { + notification, + action_key, + confirmed: false, + }, + ); } - } - - let notify_id = Rc::new(Cell::new(0)); - // Close click always targets the latest id assigned to this row - let close_tx = command_tx; - let notify_id_clone = notify_id.clone(); - close_button.connect_clicked(move |_| { - let id = notify_id_clone.get(); - if id == 0 { - // Ignore clicks before first binding - return; - } - debug!(id, "dismiss clicked"); - // Non-blocking enqueue avoids GTK stalls during D-Bus backpressure - try_send_command(&close_tx, UiCommand::Dismiss(id)); }); // The reusable widget bundle is returned with the root so the list factory // can keep the GTK tree and the cached row state together + let row_root = root.clone(); ( root, NotificationRowWidgets { + default_activation, + root: row_root, + stack, card, - stack_ghost_1, - stack_ghost_2, + card_plate, + stack_middle, + stack_back, icon, + header, app_label, + secondary_claim, + trust_chip, + urgency_badge, + close_button, meta_top, meta_label, time_badge, thumbnail, + text_stack, summary_label, body_label, + popup_status, footer, footer_left, footer_right, actions_box, - notify_id, + inline_reply, + notify_key, + action_cache_key: Cell::new(NotificationKey { + id: 0, + generation: 0, + }), action_cache: RefCell::new(Vec::new()), + reply_cache: RefCell::new(( + unixnotis_core::InlineReply::default(), + unixnotis_core::InlineReplyPolicy::Deny, + false, + )), icon_sig: RefCell::new(None), }, ) } -fn build_stack_ghost(depth: u8) -> gtk::Box { - let ghost = gtk::Box::new(gtk::Orientation::Vertical, 0); - // The real card and its shadows share theme hooks for consistent colors - ghost.add_css_class("unixnotis-panel-card"); - ghost.add_css_class("unixnotis-stack-ghost"); - ghost.add_css_class(&format!("unixnotis-stack-ghost-{depth}")); - ghost.set_hexpand(true); - ghost.set_visible(false); - ghost +fn connect_dismiss_button( + button: >k::Button, + command_tx: mpsc::Sender, + notify_key: Rc>, +) { + button.connect_clicked(move |_| { + let notification = notify_key.get(); + if notification.id == 0 { + // Ignore clicks before first binding + return; + } + debug!( + id = notification.id, + generation = notification.generation, + "dismiss clicked" + ); + // Non-blocking enqueue avoids GTK stalls during D-Bus backpressure + try_send_command(&command_tx, UiCommand::Dismiss(notification)); + }); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs index 2e5e04731..1920e3c0a 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/mod.rs @@ -1,35 +1,21 @@ //! Notification row widget module //! //! `mod.rs` only wires the notification row pieces together -//! Build, state, update, and tests stay in their own files +//! Build, reply, state, and update logic stay in focused modules -#[cfg(test)] -#[path = "tests/actions.rs"] -mod actions_tests; mod build; -#[cfg(test)] -#[path = "tests/labels.rs"] -mod labels_tests; -#[cfg(test)] -#[path = "tests/metadata.rs"] -mod metadata_tests; -#[cfg(test)] -#[path = "tests/stack.rs"] -mod stack_tests; +mod reply; +mod stack; mod state; #[cfg(test)] -#[path = "tests/state.rs"] -mod state_tests; -#[cfg(test)] #[path = "tests/support.rs"] mod test_support; -#[cfg(test)] -#[path = "tests/thumbnail.rs"] -mod thumbnail_tests; mod update; // The list factory only needs the stable notification-row entry points // Re-export them here so callers do not need to know the internal file split pub(in crate::ui::notifications) use self::build::build_notification_row; pub(in crate::ui::notifications) use self::state::NotificationRowWidgets; -pub(in crate::ui::notifications) use self::update::update_notification_row; +pub(in crate::ui::notifications) use self::update::{ + clear_notification_row, update_notification_row, +}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs new file mode 100644 index 000000000..5e1d749a8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/binding.rs @@ -0,0 +1,98 @@ +//! Notification binding and action-button behavior for inline replies + +use std::rc::Rc; +use std::rc::Weak; + +use gtk::prelude::*; +use unixnotis_core::{InlineReplyPolicy, NotificationView}; + +use super::lifecycle::invalidate_reply_attempt; +use super::presentation::{clear_reply_error, update_submit_content, DEFAULT_PLACEHOLDER}; +use super::state::InlineReplyWidgets; + +pub(in super::super) fn configure_inline_reply( + widgets: &InlineReplyWidgets, + notification: &Rc, + is_active: bool, +) { + let id = notification.id; + let reply = ¬ification.inline_reply; + // History rows keep metadata for display but never expose a live reply control + let available = is_active + && reply.available + && notification.inline_reply_policy == InlineReplyPolicy::Allow; + let snapshot_changed = widgets + .bound_snapshot + .borrow() + .upgrade() + .is_none_or(|bound| !Rc::ptr_eq(&bound, notification)); + if snapshot_changed || !available { + // Recycled rows, replacements, and unavailable actions begin with fresh form state + invalidate_reply_attempt(&widgets.state); + reset_reply_form(widgets); + } + if snapshot_changed { + *widgets.bound_snapshot.borrow_mut() = Rc::downgrade(notification); + } + // Unavailable policies also clear the command target used by click handlers + widgets.state.bound_id.set(if available { id } else { 0 }); + widgets.state.bound_generation.set(if available { + notification.generation + } else { + 0 + }); + if !available { + // History and ordinary actions never expose a stale reply field + return; + } + + // KDE hints customize only presentation and never change reply eligibility + let placeholder = if reply.placeholder.is_empty() { + DEFAULT_PLACEHOLDER + } else { + &reply.placeholder + }; + widgets.entry.set_placeholder_text(Some(placeholder)); + update_submit_content( + &widgets.send_button, + &reply.submit_label, + &reply.submit_icon, + ); +} + +fn reset_reply_form(widgets: &InlineReplyWidgets) { + // Every invalidation clears local-only state before the row can be reused + widgets.entry.set_sensitive(true); + widgets.entry.set_text(""); + widgets.send_button.set_sensitive(false); + clear_reply_error(&widgets.error_label); + widgets.revealer.set_reveal_child(false); +} + +impl InlineReplyWidgets { + pub(in super::super) fn reset_for_recycle(&self) { + // A row can be unbound without a replacement notification + invalidate_reply_attempt(&self.state); + self.state.bound_id.set(0); + self.state.bound_generation.set(0); + *self.bound_snapshot.borrow_mut() = Weak::new(); + reset_reply_form(self); + } +} + +pub(in super::super) fn connect_inline_reply_button( + button: >k::Button, + widgets: &InlineReplyWidgets, +) { + let revealer = widgets.revealer.clone(); + let entry = widgets.entry.clone(); + let state = widgets.state.clone(); + button.connect_clicked(move |_| { + // Zero is the unbound sentinel and in-flight work cannot reopen the form + if state.bound_id.get() == 0 || state.submitted.get() { + return; + } + revealer.set_reveal_child(true); + entry.grab_focus(); + }); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs new file mode 100644 index 000000000..ba88f5bd2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/build.rs @@ -0,0 +1,155 @@ +//! Inline reply widget construction and input signal wiring + +use gtk::prelude::*; +use tokio::sync::mpsc; + +use crate::control::UiCommand; +use unixnotis_ui::presentation::default_activation::mark_interactive; + +use super::lifecycle::{cancel_inline_reply, submit_reply, MAX_REPLY_BYTES}; +use super::presentation::{clear_reply_error, DEFAULT_PLACEHOLDER, DEFAULT_SUBMIT_LABEL}; +use super::state::{InlineReplyWidgets, ReplyState, INLINE_REPLY_TRANSITION_MS}; + +// GTK limits characters while the protocol boundary limits encoded bytes +const MAX_REPLY_CHARS: i32 = 4 * 1024; + +pub(in super::super) fn build_inline_reply( + command_tx: mpsc::Sender, +) -> InlineReplyWidgets { + // Build the hidden form once so row updates only change state and metadata + let revealer = gtk::Revealer::new(); + revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_transition_duration(INLINE_REPLY_TRANSITION_MS); + revealer.set_reveal_child(false); + mark_interactive(&revealer); + + let form = gtk::Box::new(gtk::Orientation::Vertical, 4); + form.add_css_class("unixnotis-inline-reply"); + let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_max_length(MAX_REPLY_CHARS); + entry.set_placeholder_text(Some(DEFAULT_PLACEHOLDER)); + entry.add_css_class("unixnotis-inline-reply-entry"); + mark_interactive(&entry); + + let send_button = gtk::Button::with_label(DEFAULT_SUBMIT_LABEL); + send_button.set_sensitive(false); + send_button.add_css_class("unixnotis-notification-action"); + send_button.add_css_class("unixnotis-inline-reply-send"); + mark_interactive(&send_button); + + let error_label = gtk::Label::new(None); + error_label.set_xalign(0.0); + error_label.set_wrap(true); + error_label.set_visible(false); + error_label.add_css_class("error"); + error_label.add_css_class("unixnotis-inline-reply-error"); + + input_row.append(&entry); + input_row.append(&send_button); + form.append(&input_row); + form.append(&error_label); + revealer.set_child(Some(&form)); + + let state = ReplyState::new(); + connect_draft_changes(&entry, &send_button, &error_label, &state); + connect_submission( + &entry, + &revealer, + &send_button, + &error_label, + &state, + command_tx, + ); + connect_cancel_key(&entry, &revealer, &error_label, &state); + + InlineReplyWidgets::new(revealer, entry, send_button, error_label, state) +} + +fn connect_draft_changes( + entry: >k::Entry, + send_button: >k::Button, + error_label: >k::Label, + state: &ReplyState, +) { + let changed_button = send_button.clone(); + let changed_submitted = state.submitted.clone(); + let changed_error = error_label.clone(); + entry.connect_changed(move |entry| { + // Editing clears the prior transport error because it described an older draft + clear_reply_error(&changed_error); + // Sensitivity mirrors the daemon byte limit before any command is queued + let text = entry.text(); + let text = text.trim(); + let too_long = text.len() > MAX_REPLY_BYTES; + entry.set_tooltip_text(too_long.then_some("Reply text must be no larger than 4 KiB")); + let valid = !text.is_empty() && !too_long; + changed_button.set_sensitive(valid && !changed_submitted.get()); + }); +} + +fn connect_submission( + entry: >k::Entry, + revealer: >k::Revealer, + send_button: >k::Button, + error_label: >k::Label, + state: &ReplyState, + command_tx: mpsc::Sender, +) { + let submit_entry = entry.clone(); + let submit_revealer = revealer.clone(); + let submit_button = send_button.clone(); + let submit_error = error_label.clone(); + let submit_state = state.clone(); + let submit_tx = command_tx.clone(); + // Mouse submission shares the exact same guarded path as keyboard activation + send_button.connect_clicked(move |_| { + submit_reply( + &submit_entry, + &submit_revealer, + &submit_button, + &submit_error, + &submit_state, + &submit_tx, + ); + }); + + let activate_revealer = revealer.clone(); + let activate_button = send_button.clone(); + let activate_error = error_label.clone(); + let activate_state = state.clone(); + // GtkEntry emits activate for Enter without needing a separate key handler + entry.connect_activate(move |entry| { + submit_reply( + entry, + &activate_revealer, + &activate_button, + &activate_error, + &activate_state, + &command_tx, + ); + }); +} + +fn connect_cancel_key( + entry: >k::Entry, + revealer: >k::Revealer, + error_label: >k::Label, + state: &ReplyState, +) { + let key_revealer = revealer.clone(); + let key_entry = entry.clone(); + let key_error = error_label.clone(); + let key_submitted = state.submitted.clone(); + let key_controller = gtk::EventControllerKey::new(); + // Escape owns draft cancellation while other keys continue through GTK + key_controller.connect_key_pressed(move |_, key, _, _| { + if key != gtk::gdk::Key::Escape { + return gtk::glib::Propagation::Proceed; + } + cancel_inline_reply(&key_entry, &key_revealer, &key_error, &key_submitted) + }); + entry.add_controller(key_controller); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs new file mode 100644 index 000000000..a7055c529 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/lifecycle.rs @@ -0,0 +1,114 @@ +//! Submission and cancellation lifecycle for inline replies + +use std::cell::Cell; + +use gtk::prelude::*; +use tokio::sync::mpsc; + +use crate::control::UiCommand; +use crate::ui::try_send_command; + +use super::presentation::{clear_reply_error, show_reply_error}; +use super::state::ReplyState; + +pub(super) const MAX_REPLY_BYTES: usize = 4 * 1024; + +pub(super) fn submit_reply( + entry: >k::Entry, + revealer: >k::Revealer, + button: >k::Button, + error_label: >k::Label, + state: &ReplyState, + command_tx: &mpsc::Sender, +) { + // Trim once so UI validation and the transmitted payload use the same content + let text = entry.text().trim().to_string(); + let id = state.bound_id.get(); + let generation = state.bound_generation.get(); + // replace(true) closes the race between Enter and a near-simultaneous click + if id == 0 + || generation == 0 + || text.is_empty() + || text.len() > MAX_REPLY_BYTES + || state.submitted.replace(true) + { + return; + } + let current_attempt = state.attempt.get().wrapping_add(1); + state.attempt.set(current_attempt); + + entry.set_sensitive(false); + button.set_sensitive(false); + clear_reply_error(error_label); + // A one-shot response lets the GTK task restore the draft after transport failure + let (outcome_tx, outcome_rx) = tokio::sync::oneshot::channel(); + try_send_command( + command_tx, + UiCommand::Reply { + id, + generation, + text, + outcome: outcome_tx, + }, + ); + + let result_entry = entry.clone(); + let result_revealer = revealer.clone(); + let result_button = button.clone(); + let result_error = error_label.clone(); + let result_state = state.clone(); + // The local main-context task is allowed to touch GTK widgets directly + gtk::glib::MainContext::default().spawn_local(async move { + let result = outcome_rx + .await + .unwrap_or_else(|_| Err("notification service did not return a result".to_string())); + if result_state.bound_id.get() != id + || result_state.bound_generation.get() != generation + || result_state.attempt.get() != current_attempt + || !result_state.submitted.get() + { + // A recycled row already owns different notification state + return; + } + result_state.submitted.set(false); + result_entry.set_sensitive(true); + match result { + Ok(()) => { + // Successful replies leave no draft behind in the reusable row + result_entry.set_text(""); + clear_reply_error(&result_error); + result_revealer.set_reveal_child(false); + result_button.set_sensitive(false); + } + Err(error) => { + // Keep the draft available for correction or retry + result_button.set_sensitive(!result_entry.text().trim().is_empty()); + show_reply_error(&result_error, &error); + result_entry.grab_focus(); + } + } + }); +} + +pub(super) fn invalidate_reply_attempt(state: &ReplyState) { + // Advancing first makes every delayed result stale before the form is reset + state.attempt.set(state.attempt.get().wrapping_add(1)); + state.submitted.set(false); +} + +pub(super) fn cancel_inline_reply( + entry: >k::Entry, + revealer: >k::Revealer, + error_label: >k::Label, + submitted: &Cell, +) -> gtk::glib::Propagation { + if submitted.get() { + // An in-flight reply cannot be canceled into a second submission + return gtk::glib::Propagation::Proceed; + } + // Canceling an idle draft restores the original action row + entry.set_text(""); + clear_reply_error(error_label); + revealer.set_reveal_child(false); + gtk::glib::Propagation::Stop +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs new file mode 100644 index 000000000..338fb00b7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/mod.rs @@ -0,0 +1,14 @@ +//! Inline reply form wiring + +mod binding; +mod build; +mod lifecycle; +mod presentation; +mod state; + +pub(super) use binding::{configure_inline_reply, connect_inline_reply_button}; +pub(super) use build::build_inline_reply; +pub(super) use state::InlineReplyWidgets; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs new file mode 100644 index 000000000..8d0e3c3d3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/presentation.rs @@ -0,0 +1,70 @@ +//! Bounded text and error presentation for inline replies + +use std::borrow::Cow; + +use gtk::prelude::*; +use unixnotis_core::util; + +pub(super) const DEFAULT_PLACEHOLDER: &str = "Type a reply…"; +pub(super) const DEFAULT_SUBMIT_LABEL: &str = "Send"; + +// Button text stays compact even when the sender provides a long custom hint +const MAX_SUBMIT_LABEL_CHARS: usize = 20; +const MAX_REPLY_ERROR_CHARS: usize = 180; +const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; + +pub(super) fn clear_reply_error(label: >k::Label) { + label.set_text(""); + label.set_visible(false); +} + +pub(super) fn show_reply_error(label: >k::Label, error: &str) { + // Known liveness failures use a short stable message instead of a D-Bus error prefix + let message = if error.contains(APPLICATION_UNAVAILABLE) { + APPLICATION_UNAVAILABLE.to_string() + } else { + util::sanitize_inline_display_text(error) + }; + let message = clamp_error_message(&message); + label.set_text(&format!("Could not send: {message}")); + label.set_visible(true); +} + +fn clamp_error_message(message: &str) -> Cow<'_, str> { + // Remote error text is display-only and must not create an unbounded row + let Some((cut, _)) = message.char_indices().nth(MAX_REPLY_ERROR_CHARS) else { + return Cow::Borrowed(message); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&message[..cut]); + bounded.push('…'); + Cow::Owned(bounded) +} + +pub(super) fn update_submit_content(button: >k::Button, label: &str, icon_name: &str) { + // Rebuild the tiny child box because KDE may change hints on replacement + let content = gtk::Box::new(gtk::Orientation::Horizontal, 4); + if !icon_name.is_empty() { + let icon = gtk::Image::from_icon_name(icon_name); + content.append(&icon); + } + let label = if label.is_empty() { + DEFAULT_SUBMIT_LABEL + } else { + label + }; + let label = gtk::Label::new(Some(clamp_submit_label(label).as_ref())); + content.append(&label); + button.set_child(Some(&content)); +} + +fn clamp_submit_label(label: &str) -> Cow<'_, str> { + // Character indexes preserve UTF-8 boundaries while enforcing visual length + let Some((cut, _)) = label.char_indices().nth(MAX_SUBMIT_LABEL_CHARS) else { + return Cow::Borrowed(label); + }; + let mut bounded = String::with_capacity(cut + 3); + bounded.push_str(&label[..cut]); + bounded.push('…'); + Cow::Owned(bounded) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs new file mode 100644 index 000000000..a27dc51b7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/state.rs @@ -0,0 +1,68 @@ +//! Shared state for a reusable inline reply form + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use unixnotis_core::NotificationView; + +use crate::ui::motion::apply_revealer_preference; + +pub(super) const INLINE_REPLY_TRANSITION_MS: u32 = 250; + +#[derive(Clone)] +pub(super) struct ReplyState { + // Numeric identity is retained for the command sent to the daemon + pub(super) bound_id: Rc>, + // Commit generation prevents a recycled identifier from receiving an older draft + pub(super) bound_generation: Rc>, + // One shared gate covers button and Enter submissions + pub(super) submitted: Rc>, + // Attempt identity keeps delayed outcomes tied to one exact submission + pub(super) attempt: Rc>, +} + +impl ReplyState { + pub(super) fn new() -> Self { + Self { + bound_id: Rc::new(Cell::new(0)), + bound_generation: Rc::new(Cell::new(0)), + submitted: Rc::new(Cell::new(false)), + attempt: Rc::new(Cell::new(0)), + } + } +} + +pub(in super::super) struct InlineReplyWidgets { + // The form is retained with the recycled row and revealed only on explicit action + pub(in super::super) revealer: gtk::Revealer, + pub(in super::super) entry: gtk::Entry, + pub(in super::super) send_button: gtk::Button, + pub(in super::super) error_label: gtk::Label, + // Snapshot identity distinguishes replacements that deliberately keep the same id + pub(super) bound_snapshot: RefCell>, + // Shared submission state keeps every GTK callback on the same generation + pub(super) state: ReplyState, +} + +impl InlineReplyWidgets { + pub(super) const fn new( + revealer: gtk::Revealer, + entry: gtk::Entry, + send_button: gtk::Button, + error_label: gtk::Label, + state: ReplyState, + ) -> Self { + Self { + revealer, + entry, + send_button, + error_label, + bound_snapshot: RefCell::new(Weak::new()), + state, + } + } + + pub(in super::super) fn set_reduced_motion(&self, reduced_motion: bool) { + apply_revealer_preference(&self.revealer, INLINE_REPLY_TRANSITION_MS, reduced_motion); + } +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs new file mode 100644 index 000000000..061cf99f4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/availability.rs @@ -0,0 +1,146 @@ +//! Reply action availability and row binding tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply, InlineReplyPolicy}; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::reply_notification; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, + connect_inline_reply_button, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_is_available_only_for_a_live_explicit_reply_action() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Duplicate reply".to_string(), + }, + ]; + notification.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + placeholder: "Write back".to_string(), + submit_label: "Send now".to_string(), + submit_icon: String::new(), + }; + + update_notification_row( + &row, + &row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + let button = row + .actions_box + .first_child() + .expect("reply action") + .downcast::() + .expect("reply child should be a button"); + assert!(button.next_sibling().is_none()); + button.emit_clicked(); + assert!(row.inline_reply.revealer.reveals_child()); + assert_eq!( + row.inline_reply.entry.placeholder_text().as_deref(), + Some("Write back") + ); + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.actions_box.first_child().is_none()); +} + +#[gtk::test] +fn inline_reply_action_does_not_open_an_unbound_or_submitted_form() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let action = gtk::Button::new(); + connect_inline_reply_button(&action, &widgets); + + action.emit_clicked(); + assert!(!widgets.revealer.reveals_child()); + + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + widgets.entry.set_text("Pending"); + widgets.entry.emit_activate(); + let _pending = command_rx.try_recv().expect("pending reply command"); + action.emit_clicked(); + + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn denied_inline_reply_policy_never_binds_the_form() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let widgets = build_inline_reply(command_tx); + let mut notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + Rc::make_mut(&mut notification).inline_reply_policy = InlineReplyPolicy::Deny; + + configure_inline_reply(&widgets, ¬ification, true); + + assert_eq!(widgets.state.bound_id.get(), 0); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn inactive_inline_reply_binding_clears_the_live_draft() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + widgets.entry.set_text("Live draft"); + widgets.revealer.set_reveal_child(true); + + configure_inline_reply(&widgets, ¬ification, false); + + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); + assert!(!widgets.send_button.is_sensitive()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs new file mode 100644 index 000000000..87116e377 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/generation.rs @@ -0,0 +1,161 @@ +//! Delayed reply result generation tests + +use gtk::prelude::*; +use unixnotis_core::InlineReply; + +use crate::control::UiCommand; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{build_inline_reply, configure_inline_reply}; + +#[gtk::test] +fn stale_reply_result_cannot_change_a_new_inflight_reply() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + let second_notification = reply_notification(42, reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + first_outcome + .send(Err("stale failure".to_string())) + .expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.error_label.is_visible()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); +} + +#[gtk::test] +fn stale_same_id_reply_result_cannot_change_a_new_attempt() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let available_reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, available_reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + // Same-ID replacements can temporarily remove and restore reply support + let unavailable_notification = reply_notification(41, InlineReply::default()); + configure_inline_reply(&widgets, &unavailable_notification, true); + let second_notification = reply_notification(41, available_reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + + first_outcome + .send(Err("stale same-ID failure".to_string())) + .expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + assert!(!widgets.error_label.is_visible()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn stale_reply_result_cannot_change_an_always_available_same_id_replacement() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("First"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: first_outcome, + .. + } = command_rx.try_recv().expect("first reply command") + else { + panic!("expected inline reply command"); + }; + + // Snapshot identity distinguishes a replacement that keeps the same id + let second_notification = reply_notification(41, reply); + configure_inline_reply(&widgets, &second_notification, true); + widgets.entry.set_text("Second"); + widgets.entry.emit_activate(); + let UiCommand::Reply { + outcome: second_outcome, + .. + } = command_rx.try_recv().expect("second reply command") + else { + panic!("expected inline reply command"); + }; + + first_outcome.send(Ok(())).expect("first outcome receiver"); + drain_main_context(); + + assert_eq!(widgets.entry.text(), "Second"); + assert!(!widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); + + second_outcome + .send(Ok(())) + .expect("second outcome receiver"); + drain_main_context(); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs new file mode 100644 index 000000000..c882aa106 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/keyboard.rs @@ -0,0 +1,78 @@ +//! Inline reply keyboard and editable-focus tests + +use std::cell::Cell; + +use gtk::prelude::*; + +use crate::ui::notifications::test_support::init_gtk; +use crate::ui::panel::behavior::keyboard::editable_has_focus; + +use super::{build_inline_reply, build_notification_row, cancel_inline_reply}; + +#[gtk::test] +fn inline_reply_escape_clears_an_idle_draft_and_collapses_the_form() { + init_gtk(); + let entry = gtk::Entry::new(); + let revealer = gtk::Revealer::new(); + let error_label = gtk::Label::new(Some("Could not send")); + let submitted = Cell::new(false); + entry.set_text("Unsent draft"); + revealer.set_reveal_child(true); + error_label.set_visible(true); + + assert_eq!( + cancel_inline_reply(&entry, &revealer, &error_label, &submitted), + gtk::glib::Propagation::Stop + ); + assert!(entry.text().is_empty()); + assert!(!revealer.reveals_child()); + assert!(error_label.text().is_empty()); + assert!(!error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_key_controller_cancels_only_escape() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + widgets.entry.set_text("Unsent draft"); + widgets.revealer.set_reveal_child(true); + let controllers = widgets.entry.observe_controllers(); + let controller = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index)) + .find_map(|object| object.downcast::().ok()) + .expect("inline reply key controller"); + + let proceed = controller.emit_by_name::( + "key-pressed", + &[>k::gdk::Key::a, &0_u32, >k::gdk::ModifierType::empty()], + ); + assert!(!proceed); + assert_eq!(widgets.entry.text(), "Unsent draft"); + + let stop = controller.emit_by_name::( + "key-pressed", + &[ + >k::gdk::Key::Escape, + &0_u32, + >k::gdk::ModifierType::empty(), + ], + ); + assert!(stop); + assert!(widgets.entry.text().is_empty()); + assert!(!widgets.revealer.reveals_child()); +} + +#[gtk::test] +fn inline_reply_entry_focus_is_recognized_as_editable_panel_input() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (root, row) = build_notification_row(command_tx); + let window = gtk::Window::new(); + window.set_child(Some(&root)); + window.set_visible(true); + + row.inline_reply.entry.grab_focus(); + + assert!(editable_has_focus(&window)); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs new file mode 100644 index 000000000..e77eb24a8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/mod.rs @@ -0,0 +1,16 @@ +//! Mirrored tests for inline reply behavior + +mod availability; +mod generation; +mod keyboard; +mod motion; +mod presentation; +mod recovery; +mod submission; +mod support; + +pub(super) use super::super::build::build_notification_row; +pub(super) use super::super::test_support::{row_data, sample_notification, RowFlags}; +pub(super) use super::super::update::update_notification_row; +pub(super) use super::lifecycle::cancel_inline_reply; +pub(super) use super::{build_inline_reply, configure_inline_reply, connect_inline_reply_button}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs new file mode 100644 index 000000000..324114818 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/motion.rs @@ -0,0 +1,59 @@ +//! Inline reply reduced-motion tests + +use std::rc::Rc; + +use unixnotis_core::Action; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::super::state::INLINE_REPLY_TRANSITION_MS; +use super::{ + build_notification_row, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_revealer_tracks_runtime_reduced_motion() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + let notification = Rc::new(notification); + + update_notification_row( + &row, + &row_data( + notification.clone(), + RowFlags { + is_active: true, + reduced_motion: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + assert_eq!(row.inline_reply.revealer.transition_duration(), 0); + + update_notification_row( + &row, + &row_data( + notification, + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + assert_eq!( + row.inline_reply.revealer.transition_duration(), + INLINE_REPLY_TRANSITION_MS + ); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs new file mode 100644 index 000000000..73eb86008 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/presentation.rs @@ -0,0 +1,95 @@ +//! Sender-provided reply presentation tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::Action; + +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::{ + build_notification_row, row_data, sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_submit_label_is_bounded_without_splitting_unicode() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply.submit_label = "界".repeat(22); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let content = row + .inline_reply + .send_button + .child() + .expect("submit content") + .downcast::() + .expect("submit content box"); + let label = content + .last_child() + .expect("submit label") + .downcast::() + .expect("submit label widget"); + assert_eq!(label.text(), format!("{}…", "界".repeat(20))); +} + +#[gtk::test] +fn inline_reply_submit_icon_is_rendered_before_the_label() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply.submit_icon = "mail-send-symbolic".to_string(); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let content = row + .inline_reply + .send_button + .child() + .expect("submit content") + .downcast::() + .expect("submit content box"); + assert!(content + .first_child() + .is_some_and(|child| child.is::())); + assert!(content + .last_child() + .is_some_and(|child| child.is::())); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs new file mode 100644 index 000000000..05bb1c59c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/recovery.rs @@ -0,0 +1,88 @@ +//! Reply failure display and row recovery tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, row_data, + sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_dead_sender_error_uses_the_stable_user_message() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("Hello?"); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err( + "org.freedesktop.DBus.Error.Failed: The application is no longer available".to_string(), + )) + .expect("reply result receiver"); + drain_main_context(); + + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: The application is no longer available" + ); + assert!(row.inline_reply.error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_rebind_clears_draft_and_prior_error() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let reply = InlineReply { + available: true, + ..InlineReply::default() + }; + let first_notification = reply_notification(41, reply.clone()); + configure_inline_reply(&widgets, &first_notification, true); + widgets.entry.set_text("Old draft"); + widgets.send_button.emit_clicked(); + let _pending_reply = command_rx.try_recv().expect("pending reply command"); + assert!(!widgets.entry.is_sensitive()); + widgets.error_label.set_text("Could not send: old error"); + widgets.error_label.set_visible(true); + widgets.revealer.set_reveal_child(true); + + let second_notification = reply_notification(42, reply); + configure_inline_reply(&widgets, &second_notification, true); + + assert!(widgets.entry.text().is_empty()); + assert!(widgets.error_label.text().is_empty()); + assert!(!widgets.error_label.is_visible()); + assert!(!widgets.revealer.reveals_child()); + assert!(widgets.entry.is_sensitive()); + assert!(!widgets.send_button.is_sensitive()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs new file mode 100644 index 000000000..ac2a3021f --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/submission.rs @@ -0,0 +1,177 @@ +//! Reply validation, submission, and retry tests + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{Action, InlineReply}; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; +use crate::ui::notifications::test_support::init_gtk; + +use super::support::{drain_main_context, reply_notification}; +use super::{ + build_inline_reply, build_notification_row, configure_inline_reply, row_data, + sample_notification, update_notification_row, RowFlags, +}; + +#[gtk::test] +fn inline_reply_submit_sends_text_once_and_hides_after_success() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + row.inline_reply.entry.set_text("On my way"); + row.inline_reply.entry.emit_activate(); + row.inline_reply.send_button.emit_clicked(); + + let UiCommand::Reply { + id, text, outcome, .. + } = command_rx.try_recv().expect("reply command") + else { + panic!("expected inline reply command"); + }; + assert_eq!(id, 1); + assert_eq!(text, "On my way"); + assert!(command_rx.try_recv().is_err()); + outcome.send(Ok(())).expect("reply result receiver"); + drain_main_context(); + + assert!(!row.inline_reply.revealer.reveals_child()); + assert!(row.inline_reply.entry.text().is_empty()); + assert!(!row.inline_reply.error_label.is_visible()); +} + +#[gtk::test] +fn inline_reply_rejects_empty_text_and_keeps_failed_draft_for_retry() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let (_root, row) = build_notification_row(command_tx.clone()); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + row.inline_reply.entry.set_text(" "); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); + row.inline_reply.entry.set_text(&"🙂".repeat(1_025)); + assert!(!row.inline_reply.send_button.is_sensitive()); + row.inline_reply.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); + row.inline_reply.entry.set_text("Try again"); + assert!(row.inline_reply.send_button.is_sensitive()); + row.inline_reply.send_button.emit_clicked(); + let UiCommand::Reply { outcome, .. } = command_rx.try_recv().expect("reply command") else { + panic!("expected inline reply command"); + }; + outcome + .send(Err("temporary failure".to_string())) + .expect("reply result receiver"); + drain_main_context(); + + assert_eq!(row.inline_reply.entry.text(), "Try again"); + assert!(row.inline_reply.entry.is_sensitive()); + assert!(row.inline_reply.send_button.is_sensitive()); + assert!(row.inline_reply.error_label.is_visible()); + assert_eq!( + row.inline_reply.error_label.text(), + "Could not send: temporary failure" + ); + + row.inline_reply.entry.set_text("Try once more"); + assert!(!row.inline_reply.error_label.is_visible()); + assert!(row.inline_reply.error_label.text().is_empty()); +} + +#[gtk::test] +fn inline_reply_accepts_exact_byte_limit_and_blocks_changes_during_submission() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + let notification = reply_notification( + 41, + InlineReply { + available: true, + ..InlineReply::default() + }, + ); + configure_inline_reply(&widgets, ¬ification, true); + let exact_limit = "🙂".repeat(1_024); + + widgets.entry.set_text(&exact_limit); + assert!(widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + let pending = command_rx.try_recv().expect("exact-limit reply command"); + let UiCommand::Reply { text, .. } = pending else { + panic!("expected inline reply command"); + }; + assert_eq!(text, exact_limit); + + widgets.entry.set_text("Changed while pending"); + assert!(!widgets.send_button.is_sensitive()); + widgets.entry.emit_activate(); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn inline_reply_entry_accepts_the_limit_and_truncates_excess_characters() { + init_gtk(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let widgets = build_inline_reply(command_tx); + let exact_limit = "a".repeat(4 * 1024); + + widgets.entry.set_text(&exact_limit); + assert_eq!(widgets.entry.text().len(), exact_limit.len()); + + // GTK applies the character cap before the byte-aware submission check + let over_limit = "b".repeat((4 * 1024) + 1); + widgets.entry.set_text(&over_limit); + assert_eq!(widgets.entry.text().len(), exact_limit.len()); +} + +#[gtk::test] +fn inline_reply_does_not_submit_before_binding_a_notification() { + init_gtk(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let widgets = build_inline_reply(command_tx); + + widgets.entry.set_text("Not bound"); + widgets.entry.emit_activate(); + + assert!(command_rx.try_recv().is_err()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs new file mode 100644 index 000000000..437986f39 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/reply/tests/support.rs @@ -0,0 +1,21 @@ +//! Shared fixtures for inline reply tests + +use std::rc::Rc; + +use unixnotis_core::{InlineReply, NotificationView}; + +use super::sample_notification; + +pub(super) fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} + +pub(super) fn reply_notification(id: u32, reply: InlineReply) -> Rc { + let mut notification = sample_notification(); + notification.id = id; + notification.inline_reply = reply; + Rc::new(notification) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs new file mode 100644 index 000000000..bba019bd2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/stack.rs @@ -0,0 +1,93 @@ +//! Collapsed group paint order and bounded rear silhouettes + +use gtk::prelude::*; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct StackLayerVisibility { + pub(super) middle: bool, + pub(super) back: bool, +} + +pub(super) fn append_stack_layers( + root: >k::Grid, + foreground: &unixnotis_ui::CutCorner, +) -> (gtk::Box, gtk::Box) { + let middle = build_stack_layer("unixnotis-stack-layer-middle"); + let back = build_stack_layer("unixnotis-stack-layer-back"); + + // A grid measures every visible layer as one ordinary list-row child + // This keeps positive offsets inside the row allocation + root.set_hexpand(true); + root.set_vexpand(false); + root.set_halign(gtk::Align::Fill); + root.set_valign(gtk::Align::Start); + root.attach(&back, 0, 0, 1, 1); + root.attach(&middle, 0, 0, 1, 1); + root.attach(foreground, 0, 0, 1, 1); + + // Structural offsets are GTK layout properties, not stylesheet geometry + back.set_halign(gtk::Align::Fill); + back.set_valign(gtk::Align::Start); + back.set_margin_start(20); + back.set_margin_end(20); + middle.set_halign(gtk::Align::Fill); + middle.set_valign(gtk::Align::Start); + middle.set_margin_top(6); + middle.set_margin_start(14); + middle.set_margin_end(14); + foreground.set_halign(gtk::Align::Fill); + foreground.set_hexpand(true); + foreground.set_valign(gtk::Align::Start); + foreground.set_vexpand(false); + foreground.set_margin_bottom(0); + (middle, back) +} + +pub(super) fn set_stack_layer_margins( + foreground: &unixnotis_ui::CutCorner, + middle: >k::Box, + back: >k::Box, + collapsed: bool, + grouped: bool, +) { + // Rear layers keep their measured positive peeks in every row state + back.set_margin_top(0); + back.set_margin_start(20); + back.set_margin_end(20); + back.set_margin_bottom(0); + middle.set_margin_top(6); + middle.set_margin_start(14); + middle.set_margin_end(14); + middle.set_margin_bottom(0); + + // Only a collapsed preview needs the foreground's positive inset + foreground.set_margin_top(if collapsed { 12 } else { 0 }); + foreground.set_margin_start(if grouped { 8 } else { 0 }); + foreground.set_margin_end(if grouped { 8 } else { 0 }); + foreground.set_margin_bottom(0); +} + +pub(super) const fn stack_layer_visibility(depth: u8) -> StackLayerVisibility { + StackLayerVisibility { + middle: depth >= 2, + back: depth >= 1, + } +} + +fn build_stack_layer(position_class: &str) -> gtk::Box { + let layer = gtk::Box::new(gtk::Orientation::Vertical, 0); + layer.add_css_class("unixnotis-stack-layer"); + layer.add_css_class(position_class); + layer.set_hexpand(true); + layer.set_halign(gtk::Align::Fill); + layer.set_vexpand(false); + layer.set_valign(gtk::Align::Start); + layer.set_can_target(false); + layer.set_accessible_role(gtk::AccessibleRole::Presentation); + layer.set_visible(false); + layer +} + +#[cfg(test)] +#[path = "tests/stack.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs index b65e752a6..2ab6320e8 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/state.rs @@ -6,40 +6,76 @@ use std::borrow::Cow; use std::cell::{Cell, RefCell}; use std::rc::Rc; -use unixnotis_core::NotificationView; +use unixnotis_core::{NotificationKey, NotificationView}; +use unixnotis_ui::presentation::default_activation::DefaultActionBinding; +use unixnotis_ui::presentation::{BadgePresentation, NotificationPresentation, TrustLevel}; + +use super::reply::InlineReplyWidgets; pub(in crate::ui::notifications) struct NotificationRowWidgets { + // Active rows use one shared generation-bound card activation binding + pub(in crate::ui::notifications) default_activation: DefaultActionBinding, + // The real ListView child owns vertical spacing and recycled-row geometry + pub(super) root: gtk::Box, + // Same-cell grid measures every visible stack layer as one row + pub(super) stack: gtk::Grid, // Styled notification card inside the ListView row wrapper pub(super) card: gtk::Box, - // Internal stack depth cards keep collapsed stacks in the same row update - pub(super) stack_ghost_1: gtk::Box, - pub(super) stack_ghost_2: gtk::Box, + // Polygon wrapper clips both visual output and pointer hit testing + pub(super) card_plate: unixnotis_ui::CutCorner, + // Collapsed groups use two non-interactive rear silhouettes + pub(super) stack_middle: gtk::Box, + pub(super) stack_back: gtk::Box, // Main icon shown at the top-left of the row pub(super) icon: gtk::Image, + // Identity header compacts only for collapsed rows owned by a group header + pub(super) header: gtk::Box, // App name text shown beside the icon pub(super) app_label: gtk::Label, + // Application identity is rendered by the shared block header + pub(super) secondary_claim: gtk::Label, + pub(super) trust_chip: gtk::Label, + // Critical badge remains allocated so urgency changes only toggle visibility + pub(super) urgency_badge: gtk::Label, + // Dismiss remains in the measured header and targets the exact generation + pub(super) close_button: gtk::Button, // Optional metadata rows are present for themes but hidden unless config enables them pub(super) meta_top: gtk::Box, // Optional top metadata label for category/urgency styling pub(super) meta_label: gtk::Label, - // Compact relative time badge shown on the top metadata lane + // Optional relative time badge shown when metadata is enabled pub(super) time_badge: gtk::Label, // Optional large image preview for notifications with image hints pub(super) thumbnail: gtk::Image, + // Message column keeps text and actions beside a lead visual + pub(super) text_stack: gtk::Box, // Summary line with stronger visual weight pub(super) summary_label: gtk::Label, // Body text section that can span multiple lines pub(super) body_label: gtk::Label, + // Arrival-time popup explanation remains visible after runtime state changes + pub(super) popup_status: gtk::Label, pub(super) footer: gtk::Box, // Optional footer metadata hooks for theme-specific chips pub(super) footer_left: gtk::Label, pub(super) footer_right: gtk::Label, // Container for optional action buttons pub(super) actions_box: gtk::Box, - // Current notification id bound to this reused row widget - pub(super) notify_id: Rc>, + // Live-only reply form is kept outside the action button cache + pub(super) inline_reply: InlineReplyWidgets, + // Exact notification identity bound to this reused row widget + pub(super) notify_key: Rc>, + // Recycled rows must rebuild action closures when the notification generation changes + pub(super) action_cache_key: Cell, // Last rendered action signature for cheap no-op detection - pub(super) action_cache: RefCell>, + pub(super) action_cache: + RefCell>, + // Reply metadata and live state are cached separately from ordinary actions + pub(super) reply_cache: RefCell<( + unixnotis_core::InlineReply, + unixnotis_core::InlineReplyPolicy, + bool, + )>, // Last rendered icon signature so decode work only happens on a real change pub(super) icon_sig: RefCell>, } @@ -59,29 +95,29 @@ pub(super) struct OptionalLabelState<'a> { #[derive(Clone, Debug, PartialEq, Eq)] pub(in crate::ui::notifications) struct IconSignature { - // These fields match the icon resolution inputs - // If none of them change, the existing paintable is still valid - image_path: String, - icon_name: String, - app_name: String, - has_image_data: bool, - image_len: usize, - image_width: i32, - image_height: i32, + // Every field that can change the chosen header icon belongs in this key + badge_icon: String, + desktop_id: String, + claimed_theme_icon: String, + claimed_desktop_id: String, + presentation: BadgePresentation, + trust: TrustLevel, } impl IconSignature { - pub(super) fn from(notification: &NotificationView) -> Self { + pub(super) fn from_presentation( + notification: &NotificationView, + presentation: &NotificationPresentation, + ) -> Self { // Signature includes all fields that can change icon resolution output - // This keeps row refreshes cheap when only text or actions changed + // Reuse the row presentation so icon checks do not rebuild all labels and actions Self { - image_path: notification.image.image_path.clone(), - icon_name: notification.image.icon_name.clone(), - app_name: notification.app_name.clone(), - has_image_data: notification.image.has_image_data, - image_len: notification.image.image_data.data.len(), - image_width: notification.image.image_data.width, - image_height: notification.image.image_data.height, + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + presentation: presentation.identity.badge, + trust: presentation.trust.level, } } } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs deleted file mode 100644 index 36cebf542..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/actions.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Action button update rules for notification rows - -use std::rc::Rc; - -use gtk::prelude::*; -use unixnotis_core::{hooks, Action}; - -use crate::control::UiCommand; -use crate::ui::icons::IconResolver; - -use super::test_support::{child_count, notification_row, row_data, sample_notification, RowFlags}; -use super::update::update_notification_row; - -#[gtk::test] -fn update_notification_row_rebuilds_actions_only_when_signature_changes() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "open".to_string(), - label: "Open".to_string(), - }]; - let data = row_data( - Rc::new(notification.clone()), - RowFlags { - is_active: true, - show_thumbnail: true, - ..Default::default() - }, - ); - let (command_tx, _rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert_eq!(child_count(&row.actions_box), 1); - assert!(row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); - assert!(!row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); - assert_eq!( - row.action_cache.borrow().as_slice(), - &[("open".to_string(), "Open".to_string())] - ); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - assert_eq!(child_count(&row.actions_box), 1); - - notification.actions[0].label = "Open notification details now".to_string(); - let data = row_data( - Rc::new(notification), - RowFlags { - is_active: true, - show_thumbnail: true, - ..Default::default() - }, - ); - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert_eq!(child_count(&row.actions_box), 1); - assert!(row.action_cache.borrow()[0] - .1 - .starts_with("Open notification")); - - notification = sample_notification(); - notification.actions = vec![Action { - key: "reply".to_string(), - label: "Open notification details now".to_string(), - }]; - let data = row_data( - Rc::new(notification), - RowFlags { - is_active: true, - show_thumbnail: true, - ..Default::default() - }, - ); - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert_eq!(child_count(&row.actions_box), 1); - assert_eq!(row.action_cache.borrow()[0].0, "reply"); -} - -#[gtk::test] -fn update_notification_row_action_button_sends_command_once_per_click_window() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.actions = vec![Action { - key: "open".to_string(), - label: "Open".to_string(), - }]; - let data = row_data( - Rc::new(notification), - RowFlags { - is_active: true, - ..Default::default() - }, - ); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - let button = row - .actions_box - .first_child() - .expect("action button") - .downcast::() - .expect("child should be action button"); - button.emit_clicked(); - - match command_rx.try_recv().expect("action command") { - UiCommand::InvokeAction { id, action_key } => { - assert_eq!(id, 1); - assert_eq!(action_key, "open"); - } - command => panic!("expected action command, got {command:?}"), - } - - button.emit_clicked(); - assert!(command_rx.try_recv().is_err()); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs deleted file mode 100644 index 55bd26042..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/metadata.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Metadata and relative-time rules for notification rows - -use unixnotis_core::Urgency; - -use super::test_support::{current_millis, sample_notification}; -use super::update::{notification_meta_label, relative_time_badge}; - -#[test] -fn notification_metadata_falls_back_to_urgency_label() { - let mut notification = sample_notification(); - notification.urgency = Urgency::Critical as u8; - - assert_eq!(notification_meta_label(¬ification), "ALERT"); -} - -#[test] -fn notification_metadata_labels_cover_low_and_normal_urgency() { - let mut notification = sample_notification(); - notification.urgency = Urgency::Low as u8; - assert_eq!(notification_meta_label(¬ification), "LOW"); - - notification.urgency = Urgency::Normal as u8; - assert_eq!(notification_meta_label(¬ification), "NOTICE"); -} - -#[test] -fn empty_timestamp_hides_relative_time_badge() { - assert!(relative_time_badge(0).is_empty()); -} - -#[test] -fn relative_time_badge_formats_minutes_hours_and_days() { - let now = current_millis(); - - assert_eq!(relative_time_badge(now - 30_000), "now"); - assert_eq!(relative_time_badge(now - 5 * 60_000), "5m"); - assert_eq!(relative_time_badge(now - 2 * 3_600_000), "2h"); - assert_eq!(relative_time_badge(now - 3 * 86_400_000), "3d"); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs index 2a903418b..b7fb42ce2 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/stack.rs @@ -1,49 +1,138 @@ -//! Collapsed notification-stack composition tests +use gtk::prelude::*; -use super::build::{StackLayer, STACK_LAYER_ORDER}; -use super::update::{stack_ghost_visibility, StackGhostVisibility}; +use super::{ + append_stack_layers, set_stack_layer_margins, stack_layer_visibility, StackLayerVisibility, +}; #[test] -fn notification_stack_places_readable_card_above_rear_layers() { - // The foreground must remain last because later GTK siblings paint on top +fn collapsed_stack_depth_maps_to_two_rear_layers() { + assert_eq!(stack_layer_visibility(0), StackLayerVisibility::default()); assert_eq!( - STACK_LAYER_ORDER, - [StackLayer::Back, StackLayer::Middle, StackLayer::Foreground] - ); -} - -#[test] -fn two_notification_stack_uses_non_overlapping_back_slot() { - assert_eq!( - stack_ghost_visibility(1), - StackGhostVisibility { + stack_layer_visibility(1), + StackLayerVisibility { middle: false, back: true, } ); -} - -#[test] -fn three_notification_stack_uses_both_rear_slots() { assert_eq!( - stack_ghost_visibility(2), - StackGhostVisibility { + stack_layer_visibility(2), + StackLayerVisibility { middle: true, back: true, } ); - - // Larger groups remain capped to the same two visual depth layers - assert_eq!(stack_ghost_visibility(u8::MAX), stack_ghost_visibility(2)); + assert_eq!(stack_layer_visibility(u8::MAX), stack_layer_visibility(2)); } -#[test] -fn single_notification_stack_hides_both_rear_slots() { +#[gtk::test] +fn stack_layers_paint_behind_foreground_and_never_accept_input() { + let root = gtk::Grid::new(); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + assert_eq!(back.margin_start(), 20); + assert_eq!(middle.margin_top(), 6); + assert_eq!(middle.margin_start(), 14); + + assert_eq!(root.child_at(0, 0).as_ref(), Some(back.upcast_ref())); assert_eq!( - stack_ghost_visibility(0), - StackGhostVisibility { - middle: false, - back: false, - } + root.child_at(0, 0) + .expect("back layer should be attached") + .next_sibling() + .as_ref(), + Some(middle.upcast_ref()) + ); + assert_eq!( + middle.next_sibling().as_ref(), + Some(foreground.upcast_ref()) ); + assert!(!root.vexpands()); + assert_eq!(root.valign(), gtk::Align::Start); + assert!(!middle.can_target()); + assert!(!back.can_target()); +} + +#[gtk::test] +fn measured_stack_includes_visible_positive_offset_layers() { + let root = gtk::Grid::new(); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + card.set_size_request(-1, 100); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + set_stack_layer_margins(&foreground, &middle, &back, true, true); + middle.set_size_request(-1, 68); + back.set_size_request(-1, 68); + middle.set_visible(true); + back.set_visible(true); + foreground.set_visible(true); + + let (_, natural_height, _, _) = root.measure(gtk::Orientation::Vertical, 320); + + assert!( + natural_height >= 112, + "measured grid height {natural_height} must contain the foreground offset" + ); + + let allocation = gtk::Allocation::new(0, 0, 320, natural_height); + root.size_allocate(&allocation, -1); + + let layers: [>k::Widget; 3] = [ + back.upcast_ref::(), + middle.upcast_ref::(), + foreground.upcast_ref::(), + ]; + for layer in layers { + if !layer.is_visible() { + continue; + } + let layer_bounds = layer + .compute_bounds(&root) + .expect("visible stack layers should have bounds"); + assert!(layer_bounds.y() >= 0.0); + assert!( + layer_bounds.y() + layer_bounds.height() <= natural_height as f32, + "visible stack layer must remain within its measured row" + ); + } + + let foreground_y = foreground + .compute_bounds(&root) + .expect("foreground should have bounds") + .y(); + let foreground_width = foreground.width(); + assert_eq!(foreground_width, 304); + let middle_y = middle + .compute_bounds(&root) + .expect("middle layer should have bounds") + .y(); + let back_y = back + .compute_bounds(&root) + .expect("back layer should have bounds") + .y(); + assert!(foreground_y >= middle_y); + assert!(middle_y >= back_y); +} + +#[gtk::test] +fn foreground_fills_grid_when_rear_layers_are_hidden() { + let root = gtk::Grid::new(); + root.set_hexpand(true); + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + card.set_size_request(-1, 80); + card.set_hexpand(true); + card.set_halign(gtk::Align::Fill); + let foreground = unixnotis_ui::CutCorner::new(&card, unixnotis_core::CutCorners::default()); + + let (middle, back) = append_stack_layers(&root, &foreground); + set_stack_layer_margins(&foreground, &middle, &back, false, false); + foreground.set_visible(true); + + let allocation = gtk::Allocation::new(0, 0, 320, 80); + root.size_allocate(&allocation, -1); + + assert_eq!(root.width(), 320); + assert_eq!(foreground.width(), 320); + assert_eq!(card.width(), 320); } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs deleted file mode 100644 index 88b3d7d60..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/state.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Visual state updates for notification rows - -use std::rc::Rc; - -use gtk::prelude::*; -use unixnotis_core::{hooks, Action, Urgency}; - -use crate::ui::icons::IconResolver; - -use super::test_support::{notification_row, row_data, sample_notification, RowFlags}; -use super::update::update_notification_row; - -#[gtk::test] -fn update_notification_row_applies_state_classes_and_text() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.urgency = Urgency::Critical as u8; - let notification = Rc::new(notification); - let data = row_data( - notification, - RowFlags { - is_active: true, - stacked: true, - stack_depth: 2, - ..Default::default() - }, - ); - let (command_tx, _rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert!(row.card.has_css_class(hooks::shared_state::CRITICAL)); - assert!(row.card.has_css_class(hooks::shared_state::ACTIVE)); - assert!(row.card.has_css_class(hooks::shared_state::STACKED)); - assert!(row.card.has_css_class(hooks::panel_card::GROUP_COLLAPSED)); - assert!(!row.card.has_css_class(hooks::panel_card::GROUP_EXPANDED)); - assert!(row.stack_ghost_1.get_visible()); - assert!(row.stack_ghost_2.get_visible()); - assert_eq!(row.app_label.text().as_str(), "demo"); - assert_eq!(row.summary_label.text().as_str(), "summary"); - assert_eq!(row.body_label.text().as_str(), "body"); - assert_eq!(row.notify_id.get(), 1); - assert!(row.icon_sig.borrow().is_some()); -} - -#[gtk::test] -fn update_notification_row_shows_metadata_lanes_and_footer_state() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.is_transient = true; - notification.actions = vec![Action { - key: "open".to_string(), - label: "Open".to_string(), - }]; - let data = row_data( - Rc::new(notification), - RowFlags { - show_metadata: true, - show_thumbnail: true, - ..Default::default() - }, - ); - let (command_tx, _rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert!(row.meta_top.get_visible()); - assert!(row.footer.get_visible()); - assert!(row.meta_label.get_visible()); - assert_eq!(row.meta_label.text().as_str(), "NOTICE"); - assert!(row.time_badge.get_visible()); - assert_eq!(row.footer_left.text().as_str(), "TRANSIENT"); - assert!(row.footer_right.get_visible()); - assert_eq!(row.footer_right.text().as_str(), "1 ACTIONS"); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs index 7be490bd8..863b8c41b 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/support.rs @@ -15,13 +15,29 @@ use super::state::NotificationRowWidgets; pub(super) fn sample_notification() -> NotificationView { NotificationView { id: 1, + generation: 1, app_name: "demo".to_string(), + attribution: unixnotis_core::NotificationAttribution::verified( + "demo", + "demo", + "org.example.Demo", + "demo", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "test:demo".to_string(), + ), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: Urgency::Normal as u8, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } @@ -31,27 +47,59 @@ pub(super) fn notification_row() -> (gtk::Box, NotificationRowWidgets) { build_notification_row(command_tx) } -#[derive(Default)] +pub(super) fn notification_row_with_receiver() -> ( + gtk::Box, + NotificationRowWidgets, + tokio::sync::mpsc::Receiver, +) { + support::init_gtk(); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(4); + let (root, row) = build_notification_row(command_tx); + (root, row, command_rx) +} + pub(super) struct RowFlags { pub(super) is_active: bool, - pub(super) stacked: bool, - pub(super) stack_depth: u8, + pub(super) collapsed_group_preview: bool, pub(super) show_metadata: bool, pub(super) show_thumbnail: bool, + pub(super) show_avatar: bool, + pub(super) reduced_motion: bool, + pub(super) metadata: Option, + pub(super) card_corners: unixnotis_core::CutCorners, +} + +impl Default for RowFlags { + fn default() -> Self { + Self { + is_active: false, + collapsed_group_preview: false, + show_metadata: false, + show_thumbnail: false, + show_avatar: true, + reduced_motion: false, + metadata: None, + card_corners: unixnotis_core::CutCorners::default(), + } + } } pub(super) fn row_data(notification: Rc, flags: RowFlags) -> RowData { RowData::notification( Rc::from(notification.app_name.to_ascii_lowercase()), notification, - flags.stacked, - flags.stack_depth, + flags.collapsed_group_preview, + u8::from(flags.collapsed_group_preview), false, flags.is_active, RowPresentation { received_at_ms: current_millis(), show_metadata: flags.show_metadata, show_thumbnail: flags.show_thumbnail, + show_avatar: flags.show_avatar, + reduced_motion: flags.reduced_motion, + metadata: Rc::new(flags.metadata.unwrap_or_default()), + card_corners: flags.card_corners, }, ) } diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs deleted file mode 100644 index 9553df0e6..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/thumbnail.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Thumbnail visibility rules for notification rows - -use std::rc::Rc; - -use gtk::prelude::*; -use unixnotis_core::hooks; - -use crate::ui::icons::IconResolver; - -use super::test_support::{notification_row, row_data, sample_notification, RowFlags}; -use super::update::{notification_has_thumbnail, update_notification_row}; - -#[test] -fn notification_thumbnail_only_uses_real_image_sources() { - let mut notification = sample_notification(); - assert!(!notification_has_thumbnail(¬ification)); - - notification.image.image_path = "/tmp/demo.png".to_string(); - assert!(notification_has_thumbnail(¬ification)); -} - -#[gtk::test] -fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.summary = " ".to_string(); - notification.body.clear(); - let data = row_data( - Rc::new(notification), - RowFlags { - show_thumbnail: true, - ..Default::default() - }, - ); - let (command_tx, _rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert!(!row.summary_label.get_visible()); - assert!(!row.body_label.get_visible()); - assert!(!row.thumbnail.get_visible()); - assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); - assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); -} - -#[gtk::test] -fn update_notification_row_shows_thumbnail_when_config_and_image_allow_it() { - let (_root, row) = notification_row(); - let mut notification = sample_notification(); - notification.image.image_path = "/tmp/demo.png".to_string(); - let data = row_data( - Rc::new(notification), - RowFlags { - show_thumbnail: true, - ..Default::default() - }, - ); - let (command_tx, _rx) = tokio::sync::mpsc::channel(4); - - update_notification_row(&row, &data, &IconResolver::new(), &command_tx); - - assert!(row.thumbnail.get_visible()); - assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); - assert!(!row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs deleted file mode 100644 index 61b9927a0..000000000 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/update.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! Notification row refresh logic -//! -//! This file owns the repeated update rules for reused notification rows - -use std::borrow::Cow; -use std::cell::RefCell; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; - -use gtk::prelude::*; -use tokio::sync::mpsc; -use tracing::debug; -use unixnotis_core::{hooks, NotificationView, Urgency}; - -use crate::control::UiCommand; -use crate::ui::icons::IconResolver; -use crate::ui::panel::input::ClickCooldown; -use crate::ui::try_send_command; - -use super::super::super::item::RowData; -use super::state::{ - IconSignature, NotificationRowWidgets, OptionalLabelState, MAX_ACTION_LABEL_CHARS, - MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, -}; - -const ACTION_BUTTON_GUARD_MS: u64 = 180; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) struct StackGhostVisibility { - pub(super) middle: bool, - pub(super) back: bool, -} - -pub(super) const fn stack_ghost_visibility(stack_depth: u8) -> StackGhostVisibility { - // A single rear layer uses the back slot because that slot starts without overlap - // The middle slot becomes safe only when the back layer is present beneath it - StackGhostVisibility { - middle: stack_depth >= 2, - back: stack_depth >= 1, - } -} - -pub(in crate::ui::notifications) fn update_notification_row( - row: &NotificationRowWidgets, - data: &RowData, - icon_resolver: &IconResolver, - command_tx: &mpsc::Sender, -) { - // Recycled rows can be updated with None while model changes - // Nothing should touch the GTK children until the row has real data again - let Some(notification) = data.notification.as_ref() else { - return; - }; - let notification = notification.as_ref(); - let card = &row.card; - - // State classes belong on the card, not the outer ListView row wrapper - // CSS state toggles stay explicit so stale visual state cannot linger - set_class_state( - card, - hooks::shared_state::CRITICAL, - notification.urgency == Urgency::Critical as u8, - ); - // Active rows can be styled differently from history rows - set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); - // Stacked class indicates collapsed entries in grouped mode - set_class_state(card, hooks::shared_state::STACKED, data.stacked); - // Grouped cards are separate ListView rows, so direct hooks replace dead descendant CSS - set_class_state(card, hooks::panel_card::GROUPED, true); - // Collapsed and expanded hooks let themes space grouped cards directly - set_class_state(card, hooks::panel_card::GROUP_COLLAPSED, data.stacked); - set_class_state(card, hooks::panel_card::GROUP_EXPANDED, data.expanded); - // Stack ghosts occupy fixed paint slots with different overlap rules - // Depth one must skip the middle slot or its negative margin escapes the row - let ghost_visibility = stack_ghost_visibility(data.stack_depth); - set_widget_visible_if_changed(&row.stack_ghost_1, ghost_visibility.middle); - set_widget_visible_if_changed(&row.stack_ghost_2, ghost_visibility.back); - - // Extra state classes give themes better hooks without changing old selectors - set_class_state( - card, - hooks::panel_card::HAS_SUMMARY, - has_visible_text(¬ification.summary), - ); - set_class_state( - card, - hooks::panel_card::HAS_BODY, - has_visible_text(¬ification.body), - ); - set_class_state( - card, - hooks::panel_card::HAS_ACTIONS, - !notification.actions.is_empty(), - ); - set_class_state( - card, - hooks::panel_card::NO_ACTIONS, - notification.actions.is_empty(), - ); - let has_thumbnail = - data.presentation.show_thumbnail && notification_has_thumbnail(notification); - set_class_state(card, hooks::panel_card::HAS_THUMBNAIL, has_thumbnail); - set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); - // App name always renders, even when summary or body are missing - set_label_text_if_changed(&row.app_label, ¬ification.app_name); - update_metadata_labels(row, data, notification); - // Clamp before GTK rendering to avoid giant layout passes - update_summary_label(&row.summary_label, ¬ification.summary); - update_body_label(&row.body_label, ¬ification.body); - row.notify_id.set(notification.id); - - update_actions( - &row.actions_box, - &row.action_cache, - command_tx, - notification, - ); - - // Icon decode and apply is skipped when the icon signature is unchanged - // Text and action changes should not trigger another icon pipeline round - let next_sig = IconSignature::from(notification); - let mut sig_guard = row.icon_sig.borrow_mut(); - let signature_changed = sig_guard.as_ref() != Some(&next_sig); - if signature_changed { - let scale = card.scale_factor(); - icon_resolver.apply_icon(&row.icon, notification, 22, scale); - *sig_guard = Some(next_sig); - } - if has_thumbnail { - // The icon cache handles repeat thumbnail lookups cheaply - // Reapply while visible so config reloads cannot leave a stale preview - let scale = card.scale_factor(); - icon_resolver.apply_icon(&row.thumbnail, notification, 56, scale); - } - set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); -} - -pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { - if !has_visible_text(text) { - // Empty text rows stay hidden so card spacing stays honest - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - if max_chars == 0 { - // Zero-char clamps are an explicit request to collapse the row - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - OptionalLabelState { - visible: true, - // Notification text stays plain so layout cannot be changed by markup - text: clamp_label_text(text, max_chars), - } -} - -pub(super) fn clamp_action_label_text(text: &str) -> Cow<'_, str> { - // Action text uses the same clamp rule every time so row width stays stable - // This keeps the panel from being stretched by one bad button label - clamp_label_text(text, MAX_ACTION_LABEL_CHARS) -} - -fn update_summary_label(label: >k::Label, summary: &str) { - // Summary rows collapse fully when the sender leaves the title empty - update_optional_label(label, summary, MAX_SUMMARY_LABEL_CHARS); -} - -fn update_body_label(label: >k::Label, body: &str) { - // Body rows follow the same empty-text rule as summary rows - update_optional_label(label, body, MAX_BODY_LABEL_CHARS); -} - -fn update_metadata_labels( - row: &NotificationRowWidgets, - data: &RowData, - notification: &NotificationView, -) { - set_widget_visible_if_changed(&row.meta_top, data.presentation.show_metadata); - set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); - if !data.presentation.show_metadata { - // Disabled lanes collapse fully so default cards keep the older compact shape - set_label_visible_if_changed(&row.meta_label, false); - set_label_visible_if_changed(&row.time_badge, false); - set_label_visible_if_changed(&row.footer_left, false); - set_label_visible_if_changed(&row.footer_right, false); - return; - } - - let meta = notification_meta_label(notification); - set_label_visible_if_changed(&row.meta_label, true); - set_label_text_if_changed(&row.meta_label, &meta); - - let time_badge = relative_time_badge(data.presentation.received_at_ms); - set_label_visible_if_changed(&row.time_badge, !time_badge.is_empty()); - set_label_text_if_changed(&row.time_badge, &time_badge); - - let footer_left = if notification.is_transient { - "TRANSIENT" - } else if data.is_active { - "LIVE" - } else { - "HISTORY" - }; - set_label_visible_if_changed(&row.footer_left, true); - set_label_text_if_changed(&row.footer_left, footer_left); - - let footer_right = if notification.actions.is_empty() { - Cow::Borrowed("") - } else { - Cow::Owned(format!("{} ACTIONS", notification.actions.len())) - }; - set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); - set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); -} - -pub(super) fn notification_meta_label(notification: &NotificationView) -> String { - match notification.urgency { - value if value == Urgency::Critical as u8 => "ALERT".to_string(), - value if value == Urgency::Low as u8 => "LOW".to_string(), - _ => "NOTICE".to_string(), - } -} - -pub(super) fn relative_time_badge(received_at_ms: i64) -> String { - if received_at_ms <= 0 { - return String::new(); - } - let Some(now_ms) = now_millis() else { - return String::new(); - }; - let age_ms = now_ms.saturating_sub(received_at_ms.max(0) as u128); - let age_secs = age_ms / 1_000; - match age_secs { - 0..=59 => "now".to_string(), - 60..=3_599 => format!("{}m", age_secs / 60), - 3_600..=86_399 => format!("{}h", age_secs / 3_600), - _ => format!("{}d", age_secs / 86_400), - } -} - -fn now_millis() -> Option { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_millis()) -} - -pub(super) fn notification_has_thumbnail(notification: &NotificationView) -> bool { - notification.image.has_image_data || !notification.image.image_path.trim().is_empty() -} - -fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { - // Build the shared row state first so summary and body stay in sync - // This keeps both rows on the same hide-or-clamp rules - let state = optional_label_state(text, max_chars); - set_label_visible_if_changed(label, state.visible); - set_label_text_if_changed(label, state.text.as_ref()); -} - -fn has_visible_text(text: &str) -> bool { - // Layout only needs to know if the row has real visible content - text.chars().any(|ch| !ch.is_whitespace()) -} - -fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { - // Reused rows are updated often - // Guard CSS churn so GTK does not reprocess classes that already match - if enabled { - if !root.has_css_class(class_name) { - root.add_css_class(class_name); - } - } else if root.has_css_class(class_name) { - root.remove_css_class(class_name); - } -} - -fn set_label_visible_if_changed(label: >k::Label, visible: bool) { - // Reused rows often receive the same visibility decision on every pass - // Skip the setter so hidden and shown states stay quiet when unchanged - if label.get_visible() != visible { - label.set_visible(visible); - } -} - -fn set_label_text_if_changed(label: >k::Label, text: &str) { - // Summary and body updates can be replayed many times while the row is stable - // Compare against the current label so GTK only sees real text changes - if label.text().as_str() != text { - label.set_text(text); - } -} - -fn set_widget_visible_if_changed>(widget: &W, visible: bool) { - // Stack ghost visibility can be replayed often while grouped counts change - if widget.get_visible() != visible { - widget.set_visible(visible); - } -} - -fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { - if max_chars == 0 { - // A zero cap means the caller wants the row blanked on purpose - return Cow::Borrowed(""); - } - // Iterate by character boundaries so UTF-8 stays valid after truncation - for (chars, (idx, _)) in text.char_indices().enumerate() { - if chars == max_chars { - // Allocate only when truncation actually happens - let mut clamped = String::with_capacity(idx + 3); - clamped.push_str(&text[..idx]); - clamped.push('…'); - return Cow::Owned(clamped); - } - } - Cow::Borrowed(text) -} - -fn update_actions( - actions_box: >k::Box, - cache: &RefCell>, - command_tx: &mpsc::Sender, - notification: &NotificationView, -) { - // Fast path: skip button rebuild when the action set is unchanged - // This avoids tearing down buttons during no-op refresh passes - { - let cached = cache.borrow(); - if cached.len() == notification.actions.len() - && cached - .iter() - .zip(notification.actions.iter()) - .all(|((key, label), action)| key == &action.key && label == &action.label) - { - return; - } - } - - { - // Cache the current action signature for the next update cycle - // Reserve once so the cache grows with the current action count - let mut cached = cache.borrow_mut(); - cached.clear(); - cached.reserve(notification.actions.len()); - for action in ¬ification.actions { - cached.push((action.key.clone(), action.label.clone())); - } - } - - // Refresh action buttons only when the action list changes - while let Some(child) = actions_box.first_child() { - // Remove old buttons before rebuilding the new set - actions_box.remove(&child); - } - if notification.actions.is_empty() { - // No buttons should remain when the sender drops all actions - return; - } - - for action in ¬ification.actions { - // Bound action text so one long label cannot stretch the whole row - // Clamp before button creation so GTK never measures the oversized string - let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); - button.add_css_class("unixnotis-panel-action"); - button.add_css_class("unixnotis-notification-action"); - let action_key = action.key.clone(); - let tx = command_tx.clone(); - let id = notification.id; - let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); - button.connect_clicked(move |_| { - if !action_gate.try_start() { - return; - } - debug!(id, action = %action_key, "action invoked"); - // Action execution is best-effort and non-blocking - // Best-effort enqueue keeps action handling responsive - // The closure keeps its own key copy so the button can outlive the loop frame - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - actions_box.append(&button); - } -} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs new file mode 100644 index 000000000..0e29063e5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/actions.rs @@ -0,0 +1,349 @@ +//! Notification action button rebuilding and dispatch + +use std::borrow::Cow; +use std::cell::Cell; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk::glib; + +use gtk::prelude::*; +use tokio::sync::mpsc; +use tracing::debug; +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{ + action_activation, ActionActivation, NotificationPresentation, ReplyPresentation, +}; + +use crate::control::UiCommand; +use crate::ui::panel::behavior::input::ClickCooldown; +use crate::ui::try_send_command; + +use super::super::reply::{configure_inline_reply, connect_inline_reply_button}; +use super::super::state::{NotificationRowWidgets, MAX_ACTION_LABEL_CHARS}; +use super::labels::clamp_label_text; + +const ACTION_BUTTON_GUARD_MS: u64 = 180; +// Clicks inside this window after arming are treated as accidental double-taps +const MIN_CONFIRM_INTERVAL_MS: u64 = 350; +// Armed state expires after this long and the button goes back to normal +const MAX_CONFIRM_TIMEOUT_MS: u64 = 5000; + +pub(super) fn clamp_action_label_text(text: &str) -> Cow<'_, str> { + // Action text uses the same clamp rule every time so row width stays stable + // This keeps the panel from being stretched by one bad button label + clamp_label_text(text, MAX_ACTION_LABEL_CHARS) +} + +pub(super) fn update_actions( + row: &NotificationRowWidgets, + command_tx: &mpsc::Sender, + notification: &Rc, + presentation: &NotificationPresentation, + is_active: bool, +) { + configure_inline_reply(&row.inline_reply, notification, is_active); + let has_actions = visible_action_count_from(presentation, is_active) > 0; + // Recycled rows may have hidden this container before the current bind + row.actions_box.set_visible(has_actions); + let action_signature = action_signature(presentation, is_active); + // Fast path skips button rebuilding when the action set is unchanged + { + let cached = row.action_cache.borrow(); + let reply_cached = row.reply_cache.borrow(); + if row.action_cache_key.get() == notification.key() + && cached.as_slice() == action_signature.as_slice() + && reply_cached.0 == notification.inline_reply + && reply_cached.1 == notification.inline_reply_policy + && reply_cached.2 == is_active + { + return; + } + } + + { + // Cache the current action signature for the next update cycle + let mut cached = row.action_cache.borrow_mut(); + cached.clear(); + cached.extend(action_signature); + row.action_cache_key.set(notification.key()); + *row.reply_cache.borrow_mut() = ( + notification.inline_reply.clone(), + notification.inline_reply_policy, + is_active, + ); + } + + // Old buttons leave before rebuilding the current action set + while let Some(child) = row.actions_box.first_child() { + row.actions_box.remove(&child); + } + // Archived notifications cannot be valid daemon action targets + if !is_active { + return; + } + if !has_actions { + return; + } + + if is_active && presentation.trust.reply == ReplyPresentation::Available { + let action_label = notification + .actions + .iter() + .find(|action| action.key == "inline-reply") + .map(|action| action.label.as_str()) + .unwrap_or_default(); + let label = if !notification.inline_reply.label.is_empty() { + notification.inline_reply.label.as_str() + } else if !action_label.is_empty() { + action_label + } else { + "Reply" + }; + let button = gtk::Button::with_label(clamp_action_label_text(label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + connect_inline_reply_button(&button, &row.inline_reply); + row.actions_box.append(&button); + } + + for action in &presentation.actions.primary { + let button = build_action_button(command_tx, notification.key(), action); + row.actions_box.append(&button); + } + if !presentation.actions.overflow.is_empty() { + row.actions_box.append(&build_overflow_menu( + command_tx, + notification.key(), + &presentation.actions.overflow, + )); + } + if let Some(default_key) = hidden_default_action_key(presentation) { + row.actions_box.append(&build_default_action_button( + command_tx, + notification.key(), + default_key, + )); + } +} + +fn action_signature( + presentation: &NotificationPresentation, + is_active: bool, +) -> Vec<(String, String, unixnotis_core::ApplicationActionPolicy)> { + if !is_active { + return Vec::new(); + } + let mut signature = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .map(|action| (action.key.clone(), action.label.clone(), action.policy)) + .collect::>(); + if let Some(default_key) = hidden_default_action_key(presentation) { + // The empty label distinguishes the compact icon-only default control + signature.push(( + default_key.to_string(), + String::new(), + unixnotis_core::ApplicationActionPolicy::Allow, + )); + } + signature +} + +fn hidden_default_action_key(presentation: &NotificationPresentation) -> Option<&str> { + // A labeled default is already rendered as a normal action button + let visible_default = presentation + .actions + .primary + .iter() + .chain(&presentation.actions.overflow) + .any(|action| action.key == "default"); + (!visible_default) + .then_some(presentation.actions.default_key.as_deref()) + .flatten() +} + +fn build_default_action_button( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + action_key: &str, +) -> gtk::Button { + let button = gtk::Button::from_icon_name("document-open-symbolic"); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + button.add_css_class("unixnotis-panel-default-action"); + button.set_tooltip_text(Some("Open notification")); + button.update_property(&[gtk::accessible::Property::Label("Open notification")]); + let action_key = action_key.to_string(); + let tx = command_tx.clone(); + let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); + button.connect_clicked(move |_| { + if !action_gate.try_start() { + return; + } + try_send_command( + &tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.clone(), + confirmed: false, + }, + ); + }); + button +} + +fn build_action_button( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + action: &unixnotis_ui::presentation::ActionView, +) -> gtk::Button { + // Bound action text before GTK measures the button + let button = gtk::Button::with_label(clamp_action_label_text(&action.label).as_ref()); + button.add_css_class("unixnotis-panel-action"); + button.add_css_class("unixnotis-notification-action"); + let action_key = action.key.clone(); + let original_label = clamp_action_label_text(&action.label).into_owned(); + let policy = action.policy; + let tx = command_tx.clone(); + // Single shared state: None = not armed, Some(instant) = armed at that time + // Using Rc> so both the click handler and the timeout callback read and + // write the same cell. The timeout captures `now` at arm time and only resets + // the button if that exact timestamp is still current — this prevents a stale + // timer from the first cycle from wiping the visual state of a newer cycle. + let armed_at = Rc::new(Cell::new(None::)); + let action_gate = ClickCooldown::new(Duration::from_millis(ACTION_BUTTON_GUARD_MS)); + button.connect_clicked(move |button| { + if !action_gate.try_start() { + return; + } + let confirmed = match action_activation(policy, armed_at.get().is_some()) { + ActionActivation::Denied => return, + ActionActivation::ArmConfirmation => { + let now = Instant::now(); + armed_at.set(Some(now)); + let confirmation_label = format!("Confirm {original_label}"); + button.set_label(&confirmation_label); + button.set_tooltip_text(Some("Activate again to confirm")); + button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + // Clean up the armed state after a timeout so the button does not stay in + // confirm mode forever + let expire_button = button.clone(); + let expire_label = original_label.clone(); + let expire_armed_at = Rc::clone(&armed_at); + glib::timeout_add_local_once( + Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS), + move || { + if expire_armed_at.get() == Some(now) { + expire_armed_at.set(None); + expire_button.set_label(&expire_label); + expire_button.set_tooltip_text(None); + expire_button.update_property(&[gtk::accessible::Property::Label( + &expire_label, + )]); + } + }, + ); + return; + } + ActionActivation::Invoke { confirmed } => { + // Only check timing when the action was actually confirmed + // Allow-policy actions skip this path entirely + if confirmed { + let elapsed = armed_at.get().map(|t| t.elapsed()); + match elapsed { + // No arm time recorded means something went wrong + // Clean up instead of dispatching + None => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); + return; + } + // Click came too fast after arming + // Probably an accidental double-tap, stay armed so the next click + // can still go through + Some(d) if d < Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => { + return; + } + // Confirmation took too long + // Reset the button and make the person re-arm + Some(d) if d > Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); + return; + } + // Right amount of time passed, dispatch the action + _ => {} + } + } + confirmed + } + }; + // Reset everything after a successful dispatch + // The next click will start a fresh confirmation cycle instead of invoking again + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label(&original_label)]); + debug!( + id = notification.id, + generation = notification.generation, + action = %action_key, + "action invoked" + ); + // The closure keeps its own key copy so the button can outlive the loop frame + try_send_command( + &tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.clone(), + confirmed, + }, + ); + }); + button +} + +fn build_overflow_menu( + command_tx: &mpsc::Sender, + notification: unixnotis_core::NotificationKey, + actions: &[unixnotis_ui::presentation::ActionView], +) -> gtk::MenuButton { + let menu = gtk::MenuButton::new(); + menu.set_icon_name("view-more-symbolic"); + menu.set_tooltip_text(Some("More actions")); + menu.add_css_class("unixnotis-panel-action-overflow"); + + let popover = gtk::Popover::new(); + let list = gtk::Box::new(gtk::Orientation::Vertical, 4); + list.add_css_class("unixnotis-panel-action-overflow-list"); + for action in actions { + list.append(&build_action_button(command_tx, notification, action)); + } + popover.set_child(Some(&list)); + menu.set_popover(Some(&popover)); + menu +} + +pub(super) fn visible_action_count_from( + presentation: &NotificationPresentation, + is_active: bool, +) -> usize { + if !is_active { + return 0; + } + let regular = presentation.actions.primary.len() + presentation.actions.overflow.len(); + let reply = presentation.trust.reply == ReplyPresentation::Available; + let blank_default = hidden_default_action_key(presentation).is_some(); + regular + usize::from(reply) + usize::from(blank_default) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs new file mode 100644 index 000000000..45d116ed6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/labels.rs @@ -0,0 +1,80 @@ +//! Bounded notification label state and GTK updates + +use std::borrow::Cow; + +use gtk::prelude::*; + +use super::super::state::{ + NotificationRowWidgets, OptionalLabelState, MAX_BODY_LABEL_CHARS, MAX_SUMMARY_LABEL_CHARS, +}; + +pub(super) fn update_notification_text( + row: &NotificationRowWidgets, + app_name: &str, + summary: &str, + body: &str, + popup_status: Option<&str>, +) { + // App name always renders while optional rows collapse on empty text + set_label_text_if_changed(&row.app_label, app_name); + update_optional_label(&row.summary_label, summary, MAX_SUMMARY_LABEL_CHARS); + update_optional_label(&row.body_label, body, MAX_BODY_LABEL_CHARS); + update_optional_label(&row.popup_status, popup_status.unwrap_or_default(), 160); +} + +pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { + if !has_visible_text(text) || max_chars == 0 { + // Empty and intentionally blanked labels must not reserve row space + return OptionalLabelState { + visible: false, + text: Cow::Borrowed(""), + }; + } + OptionalLabelState { + visible: true, + // Notification text stays plain so markup cannot change the layout + text: clamp_label_text(text, max_chars), + } +} + +fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { + // Summary and body use one hide-or-clamp rule + let state = optional_label_state(text, max_chars); + set_label_visible_if_changed(label, state.visible); + set_label_text_if_changed(label, state.text.as_ref()); +} + +pub(super) fn has_visible_text(text: &str) -> bool { + // Layout only needs to know whether real visible content exists + text.chars().any(|ch| !ch.is_whitespace()) +} + +pub(super) fn set_label_visible_if_changed(label: >k::Label, visible: bool) { + // Reused rows often receive the same visibility decision + if label.get_visible() != visible { + label.set_visible(visible); + } +} + +pub(super) fn set_label_text_if_changed(label: >k::Label, text: &str) { + // GTK only needs real text changes + if label.text().as_str() != text { + label.set_text(text); + } +} + +pub(super) fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { + if max_chars == 0 { + return Cow::Borrowed(""); + } + // Character boundaries keep UTF-8 valid after truncation + for (chars, (idx, _)) in text.char_indices().enumerate() { + if chars == max_chars { + let mut clamped = String::with_capacity(idx + 3); + clamped.push_str(&text[..idx]); + clamped.push('…'); + return Cow::Owned(clamped); + } + } + Cow::Borrowed(text) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs new file mode 100644 index 000000000..7230b7f67 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/metadata.rs @@ -0,0 +1,126 @@ +//! Notification metadata labels and relative timestamps + +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{NotificationMetadataConfig, NotificationView, Urgency}; +use unixnotis_ui::presentation::NotificationPresentation; + +use super::super::super::super::item::RowData; +use super::super::state::NotificationRowWidgets; +use super::actions::visible_action_count_from; +use super::labels::{set_label_text_if_changed, set_label_visible_if_changed}; +use super::visual::set_widget_visible_if_changed; + +pub(super) fn update_metadata_labels( + row: &NotificationRowWidgets, + data: &RowData, + notification: &NotificationView, + presentation: &NotificationPresentation, +) { + let metadata = data.presentation.metadata.as_ref(); + let time_badge = relative_time_badge(data.presentation.received_at_ms, metadata); + // Keep the stock row compact unless the optional metadata lane is enabled + set_label_visible_if_changed( + &row.time_badge, + data.presentation.show_metadata && !time_badge.is_empty(), + ); + set_label_text_if_changed(&row.time_badge, &time_badge); + set_widget_visible_if_changed(&row.footer, data.presentation.show_metadata); + if !data.presentation.show_metadata { + // Optional labels collapse together so ordinary rows match master spacing + set_widget_visible_if_changed(&row.meta_top, false); + set_label_visible_if_changed(&row.time_badge, false); + set_label_visible_if_changed(&row.meta_label, false); + set_label_visible_if_changed(&row.footer_left, false); + set_label_visible_if_changed(&row.footer_right, false); + return; + } + + // Urgency copy comes from one config block so themes can rename every lane together + let meta = notification_meta_label(notification, metadata); + set_widget_visible_if_changed(&row.meta_top, !meta.is_empty()); + set_label_visible_if_changed(&row.meta_label, !meta.is_empty()); + set_label_text_if_changed(&row.meta_label, meta); + + // The left footer distinguishes live cards from retained history at a glance + let footer_left = if notification.is_transient { + metadata.transient_label.as_str() + } else if data.is_active { + metadata.live_label.as_str() + } else { + metadata.history_label.as_str() + }; + set_label_visible_if_changed(&row.footer_left, !footer_left.is_empty()); + set_label_text_if_changed(&row.footer_left, footer_left); + + // Hidden reply actions are excluded from the displayed action count + let action_count = visible_action_count_from(presentation, data.is_active); + let footer_right = if action_count == 0 { + String::new() + } else if action_count == 1 { + render_template(&metadata.action_count_one, "{count}", action_count) + } else { + render_template(&metadata.action_count_many, "{count}", action_count) + }; + set_label_visible_if_changed(&row.footer_right, !footer_right.is_empty()); + set_label_text_if_changed(&row.footer_right, footer_right.as_ref()); +} + +pub(super) const fn notification_meta_label<'a>( + notification: &NotificationView, + metadata: &'a NotificationMetadataConfig, +) -> &'a str { + // Unknown urgency values retain the normal notice presentation + match notification.urgency { + value if value == Urgency::Critical as u8 => metadata.critical_label.as_str(), + value if value == Urgency::Low as u8 => metadata.low_label.as_str(), + _ => metadata.normal_label.as_str(), + } +} + +pub(super) fn relative_time_badge( + received_at_ms: i64, + metadata: &NotificationMetadataConfig, +) -> String { + if received_at_ms <= 0 { + return String::new(); + } + // A clock error should not prevent the row from rendering + let Some(now_ms) = now_millis() else { + return String::new(); + }; + relative_time_badge_at(received_at_ms, now_ms, metadata) +} + +pub(super) fn relative_time_badge_at( + received_at_ms: i64, + now_ms: u128, + metadata: &NotificationMetadataConfig, +) -> String { + if received_at_ms <= 0 { + return String::new(); + } + // Saturation handles timestamps that are slightly ahead of the local clock + let age_ms = now_ms.saturating_sub(received_at_ms.max(0) as u128); + let age_secs = age_ms / 1_000; + // Compact units keep the metadata lane from changing card width + match age_secs { + 0..=59 => metadata.relative_now.clone(), + 60..=3_599 => render_template(&metadata.relative_minutes, "{value}", age_secs / 60), + 3_600..=86_399 => render_template(&metadata.relative_hours, "{value}", age_secs / 3_600), + _ => render_template(&metadata.relative_days, "{value}", age_secs / 86_400), + } +} + +fn render_template(template: &str, token: &str, value: impl std::fmt::Display) -> String { + // Missing tokens are allowed so a theme can use fixed copy for a whole bucket + template.replace(token, &value.to_string()) +} + +fn now_millis() -> Option { + // Systems with an invalid pre-epoch clock omit relative time safely + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis()) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs new file mode 100644 index 000000000..c18dc9928 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/mod.rs @@ -0,0 +1,13 @@ +//! Notification row update wiring + +mod actions; +mod labels; +mod metadata; +mod row; +mod thumbnail; +mod visual; + +pub(in crate::ui::notifications) use row::{clear_notification_row, update_notification_row}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs new file mode 100644 index 000000000..6bd13c5ac --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/row.rs @@ -0,0 +1,268 @@ +//! Top-level refresh flow for a reusable notification row + +use gtk::prelude::*; +use tokio::sync::mpsc; +use unixnotis_core::hooks; +use unixnotis_ui::presentation::{ + default_activation::DefaultActionTarget, NotificationPresentation, +}; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; + +use super::super::super::super::item::RowData; +use super::super::state::{IconSignature, NotificationRowWidgets}; +use super::actions::{update_actions, visible_action_count_from}; +use super::labels::update_notification_text; +use super::metadata::update_metadata_labels; +use super::thumbnail::{has_content_thumbnail, panel_lead_visual, PanelLeadVisual}; +use super::visual::{apply_visual_state, set_widget_visible_if_changed}; + +pub(in crate::ui::notifications) fn clear_notification_row( + row: &NotificationRowWidgets, + icon_resolver: &IconResolver, +) { + // Clear every visible lane before a recycled row can be painted again + row.default_activation.set_target(None); + row.notify_key.set(unixnotis_core::NotificationKey { + id: 0, + generation: 0, + }); + row.action_cache_key.set(unixnotis_core::NotificationKey { + id: 0, + generation: 0, + }); + row.action_cache.borrow_mut().clear(); + *row.reply_cache.borrow_mut() = ( + unixnotis_core::InlineReply::default(), + unixnotis_core::InlineReplyPolicy::Deny, + false, + ); + row.icon_sig.borrow_mut().take(); + row.inline_reply.reset_for_recycle(); + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); + + for widget in [ + row.card.upcast_ref::(), + row.card_plate.upcast_ref::(), + row.header.upcast_ref::(), + row.meta_top.upcast_ref::(), + row.footer.upcast_ref::(), + row.actions_box.upcast_ref::(), + row.thumbnail.upcast_ref::(), + row.popup_status.upcast_ref::(), + row.stack_middle.upcast_ref::(), + row.stack_back.upcast_ref::(), + ] { + widget.set_visible(false); + } + icon_resolver.clear_identity_badge(&row.icon); + row.thumbnail.clear(); + for label in [ + &row.app_label, + &row.secondary_claim, + &row.trust_chip, + &row.summary_label, + &row.body_label, + &row.popup_status, + &row.meta_label, + &row.time_badge, + &row.footer_left, + &row.footer_right, + ] { + label.set_text(""); + label.set_visible(false); + } + row.app_label.set_tooltip_text(None); + while let Some(child) = row.actions_box.first_child() { + row.actions_box.remove(&child); + } + // A cleared recycled row must release its previous natural height + row.text_stack.queue_resize(); + row.card.queue_resize(); + row.card_plate.queue_resize(); + row.stack.queue_resize(); + row.root.queue_resize(); +} + +pub(in crate::ui::notifications) fn update_notification_row( + row: &NotificationRowWidgets, + data: &RowData, + icon_resolver: &IconResolver, + command_tx: &mpsc::Sender, +) { + row.inline_reply + .set_reduced_motion(data.presentation.reduced_motion); + // Model changes may briefly update a recycled row without notification data + let Some(notification_snapshot) = data.notification.as_ref() else { + clear_notification_row(row, icon_resolver); + return; + }; + let notification = notification_snapshot.as_ref(); + let presentation = NotificationPresentation::from_view(notification); + let default_target = data + .is_active + .then(|| { + presentation + .actions + .default_key + .as_ref() + .map(|action_key| DefaultActionTarget { + notification: notification.key(), + action_key: action_key.clone(), + }) + }) + .flatten(); + // Set this before action-cache early returns so recycled rows cannot retain + // a previous notification generation + row.default_activation.set_target(default_target); + // Identity visibility follows block assembly, not stack depth + let show_identity = !data.app_header_present; + let has_actions = visible_action_count_from(&presentation, data.is_active) > 0; + // The daemon has already assigned the visual role after attribution and safe decoding + let lead_visual = panel_lead_visual( + &presentation, + data.presentation.show_avatar, + data.presentation.show_thumbnail, + ); + update_notification_text( + row, + &presentation.identity.primary_label, + &presentation.title, + presentation.body.as_deref().unwrap_or_default(), + presentation.popup_status.as_deref(), + ); + if presentation.trust.details_label.is_none() { + row.app_label.set_tooltip_text(None); + } else if let Some(details) = presentation.trust.details_label.as_deref() { + row.app_label.set_tooltip_text(Some(details)); + } + super::labels::set_label_text_if_changed( + &row.secondary_claim, + presentation + .identity + .secondary_claim + .as_deref() + .unwrap_or_default(), + ); + super::labels::set_label_visible_if_changed( + &row.secondary_claim, + show_identity && presentation.identity.secondary_claim.is_some(), + ); + super::labels::set_label_text_if_changed( + &row.trust_chip, + presentation + .trust + .short_label + .as_deref() + .unwrap_or_default(), + ); + super::labels::set_label_visible_if_changed( + &row.trust_chip, + show_identity && presentation.trust.short_label.is_some(), + ); + update_metadata_labels(row, data, notification, &presentation); + row.notify_key.set(notification.key()); + update_actions( + row, + command_tx, + notification_snapshot, + &presentation, + data.is_active, + ); + + // Text and action changes must not restart an unchanged icon pipeline + let next_sig = IconSignature::from_presentation(notification, &presentation); + let mut sig_guard = row.icon_sig.borrow_mut(); + if show_identity && sig_guard.as_ref() != Some(&next_sig) { + let scale = row.card.scale_factor(); + icon_resolver.apply_identity_badge( + &row.icon, + notification, + presentation.identity.badge, + presentation.trust.level, + 20, + scale, + ); + *sig_guard = Some(next_sig); + } else if !show_identity { + // Grouped rows do not own the application icon anymore + icon_resolver.clear_identity_badge(&row.icon); + *sig_guard = None; + } + set_widget_visible_if_changed(&row.icon, show_identity); + set_widget_visible_if_changed(&row.app_label, show_identity); + // Group rows keep the measured top lane so dismiss never covers message text + set_widget_visible_if_changed(&row.header, true); + set_widget_visible_if_changed(&row.close_button, true); + // Clear paintable state before selecting a new role on a recycled row + row.thumbnail.clear(); + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); + let mut actual_visual = lead_visual; + match actual_visual { + PanelLeadVisual::ConversationAvatar => { + icon_resolver.apply_sender_visual(&row.thumbnail, notification); + } + PanelLeadVisual::ContentImage => { + row.thumbnail + .add_css_class(hooks::panel_card::CONTENT_IMAGE); + icon_resolver.apply_content_visual(&row.thumbnail, notification); + } + PanelLeadVisual::DecorativeSenderVisual => { + icon_resolver.apply_sender_visual(&row.thumbnail, notification); + row.thumbnail + .add_css_class(hooks::panel_card::SENDER_VISUAL); + } + PanelLeadVisual::None => {} + } + + // A malformed preferred avatar must not hide independently valid content media + if actual_visual == PanelLeadVisual::ConversationAvatar + && row.thumbnail.paintable().is_none() + && data.presentation.show_thumbnail + && has_content_thumbnail(&presentation) + { + row.thumbnail.clear(); + row.thumbnail + .remove_css_class(hooks::panel_card::SENDER_VISUAL); + row.thumbnail + .add_css_class(hooks::panel_card::CONTENT_IMAGE); + icon_resolver.apply_content_visual(&row.thumbnail, notification); + if row.thumbnail.paintable().is_some() { + actual_visual = PanelLeadVisual::ContentImage; + } else { + // Invalid content stays out of the lane instead of reserving a blank slot + row.thumbnail + .remove_css_class(hooks::panel_card::CONTENT_IMAGE); + actual_visual = PanelLeadVisual::None; + } + } + + // A semantic role is not enough to reserve a slot; the bounded texture must exist + let has_thumbnail = + actual_visual != PanelLeadVisual::None && row.thumbnail.paintable().is_some(); + apply_visual_state( + row, + data, + notification, + &presentation, + has_actions, + has_thumbnail, + ); + // Keep malformed or empty rasters out of the visible lead lane + set_widget_visible_if_changed(&row.thumbnail, has_thumbnail); + set_widget_visible_if_changed(&row.card_plate, true); + set_widget_visible_if_changed(&row.card, true); + // Recycled rows can change natural height when text, media, or stack depth changes + row.text_stack.queue_resize(); + row.card.queue_resize(); + row.card_plate.queue_resize(); + row.stack.queue_resize(); + row.root.queue_resize(); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs new file mode 100644 index 000000000..443f7cd3e --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/actions.rs @@ -0,0 +1,714 @@ +//! Action button update rules for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Action, ApplicationActionPolicy, InlineReply, NotificationView}; +use unixnotis_ui::presentation::NotificationPresentation; + +use crate::control::UiCommand; +use crate::ui::icons::IconResolver; + +use super::super::super::test_support::{ + child_count, notification_row, row_data, sample_notification, RowFlags, +}; +use super::update_notification_row; + +fn visible_action_count(notification: &NotificationView, is_active: bool) -> usize { + // Test the same presentation-derived count used by the production row update + super::super::actions::visible_action_count_from( + &NotificationPresentation::from_view(notification), + is_active, + ) +} + +#[gtk::test] +fn update_notification_row_rebuilds_actions_only_when_signature_changes() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(child_count(&row.actions_box), 1); + assert!(row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); + assert!(!row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); + assert_eq!( + row.action_cache.borrow().as_slice(), + &[( + "open".to_string(), + "Open".to_string(), + ApplicationActionPolicy::Allow, + )] + ); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(child_count(&row.actions_box), 1); + let original_button = row + .actions_box + .first_child() + .expect("original action button"); + + notification.actions[0].label = "Open notification details now".to_string(); + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + show_thumbnail: true, + ..Default::default() + }, + ); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 1); + assert!(row.action_cache.borrow()[0] + .1 + .starts_with("Open notification")); + + notification = sample_notification(); + notification.actions = vec![Action { + key: "reply".to_string(), + label: "Open notification details now".to_string(), + }]; + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + show_thumbnail: true, + ..Default::default() + }, + ); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 1); + assert_eq!(row.action_cache.borrow()[0].0, "reply"); + + // Repeating the unchanged update keeps the existing GTK action child + let stable_button = row.actions_box.first_child().expect("stable action button"); + assert_ne!(stable_button, original_button); + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!( + row.actions_box.first_child().expect("reused action button"), + stable_button + ); +} + +#[gtk::test] +fn unverified_panel_row_hides_application_actions_like_the_popup() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Claimed application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "unverified sender", + "unknown:claimed".to_string(), + ); + notification.actions = vec![Action { + key: "default".to_string(), + label: "Open".to_string(), + }]; + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(child_count(&row.actions_box), 0); + assert!(row.card.has_css_class("unresolved")); +} + +#[gtk::test] +fn reply_action_cache_tracks_allow_and_deny_policy_transitions() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }]; + notification.inline_reply.available = true; + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Allow; + + let render = |notification: &unixnotis_core::NotificationView| { + update_notification_row( + &row, + &row_data( + Rc::new(notification.clone()), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + }; + + render(¬ification); + assert_eq!(child_count(&row.actions_box), 1); + + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + render(¬ification); + assert_eq!(child_count(&row.actions_box), 0); + + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Allow; + render(¬ification); + assert_eq!(child_count(&row.actions_box), 1); +} + +#[gtk::test] +fn update_notification_row_action_button_sends_command_once_per_click_window() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + let button = row + .actions_box + .first_child() + .expect("action button") + .downcast::() + .expect("child should be action button"); + button.emit_clicked(); + + match command_rx.try_recv().expect("action command") { + UiCommand::InvokeAction { + notification, + action_key, + confirmed, + } => { + assert_eq!(notification.id, 1); + assert_eq!(notification.generation, 1); + assert_eq!(action_key, "open"); + assert!(!confirmed, "allowed action should not claim confirmation"); + } + command => panic!("expected action command, got {command:?}"), + } + + button.emit_clicked(); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn recycled_action_button_targets_the_new_notification_generation() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut first = sample_notification(); + first.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let mut second = first.clone(); + second.id = 2; + second.generation = 7; + + update_notification_row( + &row, + &row_data( + Rc::new(first), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + update_notification_row( + &row, + &row_data( + Rc::new(second), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .expect("recycled action button") + .downcast::() + .expect("child should be action button"); + button.emit_clicked(); + + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { notification, action_key, confirmed: false }) + if notification.id == 2 + && notification.generation == 7 + && action_key == "open" + )); +} + +#[gtk::test] +fn inactive_history_row_hides_every_application_action() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + ]; + notification.inline_reply.available = true; + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 0); + assert!(row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); +} + +#[gtk::test] +fn active_blank_default_action_builds_accessible_open_control() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("blank default action control"); + assert!(button.has_css_class("unixnotis-panel-default-action")); + assert_eq!(button.tooltip_text().as_deref(), Some("Open notification")); + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { notification, action_key, confirmed: false }) + if notification.id == 1 + && notification.generation == 1 + && action_key == "default" + )); +} + +#[gtk::test] +fn labeled_default_action_stays_a_visible_one_click_button() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: "Open conversation".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 1); + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("labeled default action button"); + assert!(!button.has_css_class("unixnotis-panel-default-action")); + assert_eq!(button.label().as_deref(), Some("Open conversation")); +} + +#[gtk::test] +fn confirmable_panel_action_requires_two_clicks_before_dispatch() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("confirmable action button"); + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "first click must not invoke a confirmable action" + ); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 1 + && notification.generation == 1 + && action_key == "archive" + )); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "third click must re-arm rather than dispatching" + ); +} + +#[gtk::test] +fn confirmable_panel_action_stale_timer_does_not_disarm_newer_cycle() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }]; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .and_downcast::() + .expect("confirmable action button"); + let context = gtk::glib::MainContext::default(); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + std::thread::sleep(std::time::Duration::from_millis(400)); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 1 && notification.generation == 1 && action_key == "archive" + )); + + // Wait for click cooldown before re-arming. + std::thread::sleep(std::time::Duration::from_millis(200)); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer A (from first arm at t=0) fires at t=5000. We are at t=600 now. + // Sleep 4400ms -> t=5000. Process timer A. It should NOT clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(4400)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer B (from second arm at t=600) fires at t=5600. We are at t=5000. + // Sleep 600ms -> t=5600. Process timer B. It SHOULD clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(600)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Archive")); + assert!(command_rx.try_recv().is_err()); + + // Next click re-arms rather than invokes. + std::thread::sleep(std::time::Duration::from_millis(200)); + while context.pending() { + context.iteration(false); + } + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn historical_blank_default_action_has_no_control_or_activation() { + let (_root, row) = notification_row(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + update_notification_row( + &row, + &row_data(Rc::new(notification), RowFlags::default()), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 0); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn panel_keeps_two_primary_actions_and_moves_the_rest_into_more_menu() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = sample_notification(); + notification.actions = ["Open", "Archive", "Mute"] + .into_iter() + .map(|label| Action { + key: label.to_ascii_lowercase(), + label: label.to_string(), + }) + .collect(); + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + assert_eq!(child_count(&row.actions_box), 3); + assert!(row + .actions_box + .first_child() + .is_some_and(|child| child.is::())); + assert!(row + .actions_box + .last_child() + .is_some_and(|child| child.is::())); + let menu = row + .actions_box + .last_child() + .and_downcast::() + .expect("overflow menu"); + assert_eq!(menu.icon_name().as_deref(), Some("view-more-symbolic")); + assert_eq!(menu.tooltip_text().as_deref(), Some("More actions")); + assert!(menu.has_css_class("unixnotis-panel-action-overflow")); + let popover = menu.popover().expect("overflow popover"); + let list = popover + .child() + .and_downcast::() + .expect("overflow action list"); + assert!(list.has_css_class("unixnotis-panel-action-overflow-list")); + let overflow = list + .first_child() + .and_downcast::() + .expect("overflow action button"); + assert_eq!(overflow.label().as_deref(), Some("Mute")); +} + +#[gtk::test] +fn reply_action_label_prefers_hint_then_action_then_default() { + let labels = [ + ("Hint reply", "Action reply", "Hint reply"), + ("", "Action reply", "Action reply"), + ("", "", "Reply"), + ]; + + for (hint, action, expected) in labels { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let mut notification = sample_notification(); + notification.actions = vec![Action { + key: "inline-reply".to_string(), + label: action.to_string(), + }]; + notification.inline_reply = InlineReply { + available: true, + label: hint.to_string(), + ..InlineReply::default() + }; + + update_notification_row( + &row, + &row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ), + &IconResolver::new(), + &command_tx, + ); + + let button = row + .actions_box + .first_child() + .expect("reply action") + .downcast::() + .expect("reply child should be a button"); + assert_eq!(button.label().as_deref(), Some(expected)); + } +} + +#[test] +fn visible_action_count_requires_a_live_available_explicit_reply() { + let mut notification = sample_notification(); + assert_eq!(visible_action_count(¬ification, true), 0); + + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "dismiss".to_string(), + label: "Dismiss".to_string(), + }, + ]; + assert_eq!(visible_action_count(¬ification, false), 0); + + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + assert_eq!(visible_action_count(¬ification, true), 2); + notification.inline_reply.available = true; + assert_eq!(visible_action_count(¬ification, false), 0); + assert_eq!(visible_action_count(¬ification, true), 3); + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + assert_eq!(visible_action_count(¬ification, true), 2); +} + +#[test] +fn visible_action_count_includes_primary_and_overflow_actions() { + let mut notification = sample_notification(); + notification.actions = ["Open", "Archive", "Mute"] + .into_iter() + .map(|label| Action { + key: label.to_ascii_lowercase(), + label: label.to_string(), + }) + .collect(); + + assert_eq!(visible_action_count(¬ification, true), 3); + assert_eq!(visible_action_count(¬ification, false), 0); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs similarity index 92% rename from crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs rename to crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs index 591c9419b..356605985 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/notification/tests/labels.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/labels.rs @@ -1,7 +1,7 @@ //! Text label rules for notification rows -use super::state::MAX_SUMMARY_LABEL_CHARS; -use super::update::{clamp_action_label_text, optional_label_state}; +use super::super::super::state::MAX_SUMMARY_LABEL_CHARS; +use super::{clamp_action_label_text, optional_label_state}; #[test] fn panel_summary_row_hides_when_text_is_empty() { diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs new file mode 100644 index 000000000..c58b715c9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/metadata.rs @@ -0,0 +1,75 @@ +//! Metadata and relative-time rules for notification rows + +use unixnotis_core::{NotificationMetadataConfig, Urgency}; + +use super::super::super::test_support::{current_millis, sample_notification}; +use super::{notification_meta_label, relative_time_badge, relative_time_badge_at}; + +#[test] +fn notification_metadata_falls_back_to_urgency_label() { + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + let metadata = NotificationMetadataConfig::default(); + + assert_eq!(notification_meta_label(¬ification, &metadata), "ALERT"); +} + +#[test] +fn notification_metadata_labels_cover_low_and_normal_urgency() { + let mut notification = sample_notification(); + let metadata = NotificationMetadataConfig::default(); + notification.urgency = Urgency::Low as u8; + assert_eq!(notification_meta_label(¬ification, &metadata), "LOW"); + + notification.urgency = Urgency::Normal as u8; + assert_eq!(notification_meta_label(¬ification, &metadata), "NOTICE"); +} + +#[test] +fn empty_timestamp_hides_relative_time_badge() { + assert!(relative_time_badge(0, &NotificationMetadataConfig::default()).is_empty()); +} + +#[test] +fn relative_time_badge_formats_minutes_hours_and_days() { + let now = u128::try_from(current_millis()).expect("current time should be positive"); + let metadata = NotificationMetadataConfig::default(); + + assert_eq!( + relative_time_badge_at((now - 30_000) as i64, now, &metadata), + "now" + ); + assert_eq!( + relative_time_badge_at((now - 5 * 60_000) as i64, now, &metadata), + "5m" + ); + assert_eq!( + relative_time_badge_at((now - 2 * 3_600_000) as i64, now, &metadata), + "2h" + ); + assert_eq!( + relative_time_badge_at((now - 3 * 86_400_000) as i64, now, &metadata), + "3d" + ); +} + +#[test] +fn custom_metadata_text_and_templates_replace_runtime_strings() { + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + let metadata = NotificationMetadataConfig { + critical_label: "PRIORITY".to_string(), + relative_hours: "{value} HOURS AGO".to_string(), + ..NotificationMetadataConfig::default() + }; + + assert_eq!( + notification_meta_label(¬ification, &metadata), + "PRIORITY" + ); + assert_eq!(relative_time_badge_at(0, 0, &metadata), ""); + assert_eq!( + relative_time_badge_at(1, 7_200_001, &metadata), + "2 HOURS AGO" + ); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs new file mode 100644 index 000000000..a8f4c33a3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/mod.rs @@ -0,0 +1,15 @@ +//! Mirrored tests for notification row updates + +mod actions; +mod labels; +mod metadata; +mod state; +mod thumbnail; +mod visual_matrix; + +pub(super) use super::actions::clamp_action_label_text; +pub(super) use super::labels::optional_label_state; +pub(super) use super::metadata::{ + notification_meta_label, relative_time_badge, relative_time_badge_at, +}; +pub(super) use super::row::{clear_notification_row, update_notification_row}; diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs new file mode 100644 index 000000000..a4e8429ea --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/state.rs @@ -0,0 +1,628 @@ +//! Visual state updates for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, Action, CutCorners, NotificationMetadataConfig, Urgency}; +use unixnotis_ui::presentation::NotificationPresentation; + +use crate::ui::icons::IconResolver; + +use super::super::super::state::IconSignature; +use super::super::super::test_support::{ + child_count, notification_row, notification_row_with_receiver, row_data, sample_notification, + RowFlags, +}; +use super::{clear_notification_row, update_notification_row}; + +fn icon_signature(notification: &unixnotis_core::NotificationView) -> IconSignature { + // Test-only construction stays beside the tests while production consumes a shared presentation + IconSignature::from_presentation( + notification, + &NotificationPresentation::from_view(notification), + ) +} + +#[test] +fn icon_signature_changes_when_trust_presentation_changes() { + let verified = sample_notification(); + let mut suspicious = verified.clone(); + // Keep resolver inputs unchanged to isolate the trust-state regression + suspicious.attribution.status = unixnotis_core::AttributionStatus::Conflict; + suspicious.attribution.assurance = unixnotis_core::IdentityAssurance::Conflict; + suspicious.attribution.interactions = unixnotis_core::InteractionPolicies::DENY; + + assert_ne!( + icon_signature(&verified), + icon_signature(&suspicious), + "trust changes must refresh a recycled row badge" + ); +} + +#[test] +fn claimed_application_branding_changes_the_icon_signature() { + let mut first = sample_notification(); + first.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + first.image.claimed_desktop_id = "org.example.First.desktop".to_string(); + + let mut second = first.clone(); + second.image.claimed_desktop_id = "org.example.Second.desktop".to_string(); + + assert_ne!(icon_signature(&first), icon_signature(&second)); +} + +#[gtk::test] +fn claimed_application_branding_refreshes_a_recycled_row_icon_signature() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + let mut first = sample_notification(); + first.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + first.image.claimed_desktop_id = "org.example.First.desktop".to_string(); + let mut first_data = row_data(Rc::new(first), RowFlags::default()); + first_data.app_header_present = false; + + update_notification_row(&row, &first_data, &resolver, &command_tx); + let first_signature = row.icon_sig.borrow().clone(); + assert!(first_signature.is_some()); + + let mut second = sample_notification(); + second.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic unresolved fixture", + "unknown:example".to_string(), + ); + second.image.claimed_desktop_id = "org.example.Second.desktop".to_string(); + let mut second_data = row_data(Rc::new(second), RowFlags::default()); + second_data.app_header_present = false; + + update_notification_row(&row, &second_data, &resolver, &command_tx); + + assert_ne!(*row.icon_sig.borrow(), first_signature); +} + +#[gtk::test] +fn close_control_ignores_unbound_rows_and_keeps_the_bound_generation() { + let (_root, row, mut command_rx) = notification_row_with_receiver(); + + row.close_button.emit_clicked(); + assert!( + command_rx.try_recv().is_err(), + "an unbound recycled control must not dismiss notification zero" + ); + + row.notify_key.set(unixnotis_core::NotificationKey { + id: 7, + generation: 11, + }); + row.close_button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(crate::control::UiCommand::Dismiss(notification)) + if notification.id == 7 && notification.generation == 11 + )); +} + +#[gtk::test] +fn clearing_a_recycled_row_removes_old_content_and_controls() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + assert_eq!(row.summary_label.text().as_str(), "summary"); + assert!(row.card.get_visible()); + + clear_notification_row(&row, &IconResolver::new()); + + assert!(row.summary_label.text().is_empty()); + assert!(row.body_label.text().is_empty()); + assert!(row.app_label.text().is_empty()); + assert!(!row.card.get_visible()); + assert!(!row.header.get_visible()); + assert!(row.action_cache.borrow().is_empty()); + assert_eq!(row.notify_key.get().id, 0); +} + +#[gtk::test] +fn rebinding_after_clear_restores_wrapper_and_actions() { + let (_root, row) = notification_row(); + let first = row_data( + Rc::new(sample_notification()), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let mut second_notification = sample_notification(); + second_notification.id = 2; + second_notification.generation = 2; + second_notification.summary = "second summary".to_string(); + second_notification.actions = vec![unixnotis_core::Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let second = row_data( + Rc::new(second_notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &first, &IconResolver::new(), &command_tx); + clear_notification_row(&row, &IconResolver::new()); + update_notification_row(&row, &second, &IconResolver::new(), &command_tx); + + assert!(row.card_plate.get_visible()); + assert!(row.card.get_visible()); + assert_eq!(row.summary_label.text().as_str(), "second summary"); + assert!(row.actions_box.get_visible()); + assert!(child_count(&row.actions_box) > 0); +} + +#[gtk::test] +fn update_notification_row_applies_state_classes_and_text() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.urgency = Urgency::Critical as u8; + let notification = Rc::new(notification); + let expected_key = notification.key(); + let data = row_data( + notification, + RowFlags { + is_active: true, + collapsed_group_preview: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.card.has_css_class(hooks::shared_state::CRITICAL)); + assert!(row.card.has_css_class(hooks::shared_state::ACTIVE)); + assert!(row + .card + .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW)); + assert!(row.card.has_css_class(hooks::panel_card::GROUPED)); + assert!(row.card.has_css_class("group-owned-identity")); + assert!(!row.app_label.get_visible()); + assert!(!row.icon.get_visible()); + assert!(row.header.get_visible()); + assert_eq!(row.card.spacing(), 2); + assert!(row.urgency_badge.get_visible()); + assert_eq!(row.urgency_badge.text().as_str(), "Critical"); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); + assert_eq!(row.app_label.text().as_str(), "demo"); + assert_eq!(row.summary_label.text().as_str(), "summary"); + assert_eq!(row.body_label.text().as_str(), "body"); + assert_eq!(row.notify_key.get(), expected_key); + assert!(row.icon_sig.borrow().is_none()); +} + +#[gtk::test] +fn notification_actions_live_inside_the_message_column() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions.push(Action { + key: "open".to_string(), + label: "View".to_string(), + }); + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + let parent = row + .actions_box + .parent() + .expect("actions should have a parent"); + assert!(parent == row.text_stack.clone().upcast::()); + assert!(row.actions_box.get_visible()); +} + +#[gtk::test] +fn recycled_panel_row_hides_critical_badge_after_urgency_returns_to_normal() { + let (_root, row) = notification_row(); + let mut critical = sample_notification(); + critical.urgency = Urgency::Critical as u8; + let critical = row_data(Rc::new(critical), RowFlags::default()); + let normal = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &critical, &IconResolver::new(), &command_tx); + assert!(row.urgency_badge.get_visible()); + + update_notification_row(&row, &normal, &IconResolver::new(), &command_tx); + assert!(!row.card.has_css_class(hooks::shared_state::CRITICAL)); + assert!(!row.urgency_badge.get_visible()); +} + +#[gtk::test] +fn singleton_notification_row_keeps_identity_in_the_shared_header() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.app_label.get_visible()); + assert!(row.header.get_visible()); + assert!(row.close_button.get_visible()); + assert_eq!(row.card.spacing(), 2); + assert_eq!(row.app_label.text().as_str(), "demo"); + assert!(row.icon_sig.borrow().is_none()); +} + +#[gtk::test] +fn relay_singleton_card_hides_identity_owned_by_its_shared_header() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.app_label.text().as_str(), "Command-line notification"); + assert_eq!( + row.secondary_claim.text().as_str(), + "App label: Example Chat" + ); + assert!(!row.secondary_claim.get_visible()); + assert!(!row.trust_chip.get_visible()); + assert!(row.card.has_css_class("relay")); + assert!(!row.card.has_css_class("conflict")); +} + +#[gtk::test] +fn panel_text_limits_keep_compact_rows_content_driven() { + let (root, row) = notification_row(); + let close = descendant_with_class(root.upcast_ref(), "unixnotis-panel-close") + .expect("panel close button"); + + assert_eq!(row.summary_label.lines(), 2); + assert_eq!(row.body_label.lines(), 5); + assert_eq!(close.parent().as_ref(), Some(row.header.upcast_ref())); +} + +#[gtk::test] +fn grouped_relay_row_hides_identity_details_owned_by_the_group_header() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + let data = row_data( + Rc::new(notification), + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.app_label.get_visible()); + assert!(!row.secondary_claim.get_visible()); + assert!(!row.trust_chip.get_visible()); + assert!(!row.icon.get_visible()); + assert!(row.header.get_visible()); + assert!(row.close_button.get_visible()); +} + +#[gtk::test] +fn expanded_group_rows_keep_identity_in_the_shared_header() { + let (_root, row) = notification_row(); + let data = row_data( + Rc::new(sample_notification()), + RowFlags { + collapsed_group_preview: false, + ..Default::default() + }, + ); + let mut data = data; + data.expanded = true; + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.app_label.get_visible()); + assert!(row.card.has_css_class("group-owned-identity")); + assert_eq!(row.card.spacing(), 2); +} + +#[gtk::test] +fn collapsed_group_preview_uses_readable_surface_above_stack_layers() { + let (root, row) = notification_row(); + let mut data = row_data( + Rc::new(sample_notification()), + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + // Two hidden notifications are required for both rear stack layers + data.stack_depth = 2; + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + let stack = root + .first_child() + .and_downcast::() + .expect("notification row should use one measured stack grid"); + let stack_child_count = + std::iter::successors(stack.first_child(), gtk::prelude::WidgetExt::next_sibling).count(); + assert_eq!(stack_child_count, 3); + assert!(row.stack_middle.get_visible()); + assert!(row.stack_back.get_visible()); + assert!(row.card_plate.get_visible()); + assert!( + row.card + .has_css_class(hooks::shared_state::COLLAPSED_GROUP_PREVIEW), + "the single readable surface should retain collapsed preview state" + ); +} + +#[gtk::test] +fn recycled_standalone_row_clears_identity_cache_when_it_becomes_grouped() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let standalone = row_data(notification.clone(), RowFlags::default()); + let grouped = row_data( + notification, + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &standalone, &IconResolver::new(), &command_tx); + assert!( + row.icon_sig.borrow().is_none(), + "application identity belongs to the shared header" + ); + + update_notification_row(&row, &grouped, &IconResolver::new(), &command_tx); + assert!( + row.icon_sig.borrow().is_none(), + "grouped rows must release identity state owned by their group header" + ); +} + +#[gtk::test] +fn compact_rows_hide_optional_time_until_metadata_is_enabled() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let current = row_data(notification.clone(), RowFlags::default()); + + update_notification_row(&row, ¤t, &IconResolver::new(), &command_tx); + + assert!(!row.meta_top.get_visible()); + assert!(!row.time_badge.get_visible()); + assert!(!row.meta_label.get_visible()); + assert!(!row.footer.get_visible()); + assert!(!row.footer_left.get_visible()); + assert!(!row.footer_right.get_visible()); + + let mut missing_time = row_data(notification, RowFlags::default()); + missing_time.presentation.received_at_ms = 0; + update_notification_row(&row, &missing_time, &IconResolver::new(), &command_tx); + + assert!(!row.meta_top.get_visible()); + assert!(!row.time_badge.get_visible()); +} + +fn descendant_with_class(widget: >k::Widget, class_name: &str) -> Option { + if widget.has_css_class(class_name) { + return Some(widget.clone()); + } + let mut child = widget.first_child(); + while let Some(current) = child { + if let Some(found) = descendant_with_class(¤t, class_name) { + return Some(found); + } + child = current.next_sibling(); + } + None +} + +#[gtk::test] +fn popup_suppression_reason_is_rendered_from_the_committed_decision() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: unixnotis_core::PopupAdmissionView::Dnd, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, + ..unixnotis_core::PopupDecisionRecord::default() + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!( + row.popup_status.text().as_str(), + "Not shown — Do Not Disturb was enabled" + ); + assert!(row.popup_status.get_visible()); +} + +#[gtk::test] +fn update_notification_row_shows_metadata_lanes_and_footer_state() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.is_transient = true; + notification.actions = vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }]; + let data = row_data( + Rc::new(notification), + RowFlags { + is_active: true, + show_metadata: true, + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.meta_top.get_visible()); + assert!(row.footer.get_visible()); + assert!(row.meta_label.get_visible()); + assert_eq!(row.meta_label.text().as_str(), "NOTICE"); + assert!(row.time_badge.get_visible()); + assert_eq!(row.footer_left.text().as_str(), "TRANSIENT"); + assert!(row.footer_right.get_visible()); + assert_eq!(row.footer_right.text().as_str(), "1 ACTION"); +} + +#[gtk::test] +fn update_notification_row_applies_custom_metadata_and_corner_geometry() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.actions = vec![ + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + Action { + key: "save".to_string(), + label: "Save".to_string(), + }, + ]; + let corners = CutCorners { + top_left: 18, + bottom_right: 12, + ..CutCorners::default() + }; + let metadata = NotificationMetadataConfig { + normal_label: "INFO".to_string(), + history_label: "ARCHIVE".to_string(), + action_count_many: "{count} OPTIONS".to_string(), + ..NotificationMetadataConfig::default() + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_metadata: true, + metadata: Some(metadata), + card_corners: corners, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.meta_label.text().as_str(), "INFO"); + assert_eq!(row.footer_left.text().as_str(), "ARCHIVE"); + assert!(!row.footer_right.get_visible()); + assert!(row.footer_right.text().is_empty()); + assert_eq!(row.card_plate.corners(), corners); +} + +#[gtk::test] +fn separated_group_rows_keep_complete_configured_cut_corners() { + let (_root, row) = notification_row(); + let corners = CutCorners { + top_left: 8, + top_right: 9, + bottom_right: 10, + bottom_left: 11, + }; + let mut middle = row_data( + Rc::new(sample_notification()), + RowFlags { + card_corners: corners, + ..Default::default() + }, + ); + middle.expanded = true; + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &middle, &IconResolver::new(), &command_tx); + assert_eq!(row.card_plate.corners(), corners); +} + +#[gtk::test] +fn update_notification_row_marks_an_empty_action_set_as_unavailable() { + let (_root, row) = notification_row(); + let data = row_data(Rc::new(sample_notification()), RowFlags::default()); + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.card.has_css_class(hooks::panel_card::HAS_ACTIONS)); + assert!(row.card.has_css_class(hooks::panel_card::NO_ACTIONS)); +} + +#[gtk::test] +fn update_notification_row_hides_metadata_labels_with_empty_custom_copy() { + let (_root, row) = notification_row(); + let metadata = NotificationMetadataConfig { + critical_label: String::new(), + low_label: String::new(), + normal_label: String::new(), + relative_now: String::new(), + relative_minutes: String::new(), + relative_hours: String::new(), + relative_days: String::new(), + transient_label: String::new(), + live_label: String::new(), + history_label: String::new(), + action_count_one: String::new(), + action_count_many: String::new(), + }; + let data = row_data( + Rc::new(sample_notification()), + RowFlags { + show_metadata: true, + metadata: Some(metadata), + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(1); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.meta_label.get_visible()); + assert!(!row.time_badge.get_visible()); + assert!(!row.footer_left.get_visible()); + assert!(!row.footer_right.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs new file mode 100644 index 000000000..6ec21ee5c --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/thumbnail.rs @@ -0,0 +1,337 @@ +//! Thumbnail visibility rules for notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, ImageData}; +use unixnotis_ui::presentation::NotificationPresentation; + +use crate::ui::icons::IconResolver; + +use super::super::super::test_support::{ + notification_row, row_data, sample_notification, RowFlags, +}; +use super::super::thumbnail::{ + has_content_thumbnail, has_conversation_avatar, has_sender_visual, panel_lead_visual, + PanelLeadVisual, +}; +use super::update_notification_row; + +fn notification_has_thumbnail(notification: &unixnotis_core::NotificationView) -> bool { + // Keep presentation construction in the mirrored test helper, not production code + has_content_thumbnail(&NotificationPresentation::from_view(notification)) +} + +fn notification_has_conversation_avatar(notification: &unixnotis_core::NotificationView) -> bool { + has_conversation_avatar(&NotificationPresentation::from_view(notification)) +} + +fn notification_has_sender_visual(notification: &unixnotis_core::NotificationView) -> bool { + has_sender_visual(&NotificationPresentation::from_view(notification)) +} + +#[test] +fn notification_thumbnail_only_uses_real_image_sources() { + let mut notification = sample_notification(); + assert!(!notification_has_thumbnail(¬ification)); + + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + }; + assert!(notification_has_thumbnail(¬ification)); +} + +#[test] +fn conversation_avatar_is_a_separate_thumbnail_source() { + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + + assert!(notification_has_conversation_avatar(¬ification)); + assert!(!notification_has_thumbnail(¬ification)); +} + +#[test] +fn application_visual_is_a_decorative_thumbnail_source() { + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![0, 255, 0, 255], + }; + + assert!(notification_has_sender_visual(¬ification)); + assert!(!notification_has_conversation_avatar(¬ification)); + assert!(!notification_has_thumbnail(¬ification)); +} + +#[test] +fn conversation_avatar_has_priority_over_content_thumbnail() { + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + notification.image.content_image = notification.image.sender_visual.clone(); + let presentation = NotificationPresentation::from_view(¬ification); + + assert_eq!( + panel_lead_visual(&presentation, true, true), + PanelLeadVisual::ConversationAvatar + ); + assert_eq!( + panel_lead_visual(&presentation, false, true), + PanelLeadVisual::ContentImage + ); +} + +#[gtk::test] +fn update_notification_row_hides_optional_text_and_thumbnail_when_absent() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.summary = " ".to_string(); + notification.body.clear(); + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.summary_label.get_visible()); + assert!(!row.body_label.get_visible()); + assert!(!row.thumbnail.get_visible()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn update_notification_row_shows_thumbnail_when_config_and_image_allow_it() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _rx) = tokio::sync::mpsc::channel(4); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(!row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn conversation_avatar_uses_the_master_panel_lead_slot_by_default() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert_eq!(row.thumbnail.pixel_size(), 56); + assert_eq!(row.thumbnail.width_request(), 56); + assert_eq!(row.thumbnail.height_request(), 56); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(!row.thumbnail.has_css_class("unixnotis-panel-sender-visual")); +} + +#[gtk::test] +fn disabled_notification_avatars_suppress_conversation_lead_visual() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_avatar: false, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn collapsed_and_expanded_group_rows_keep_conversation_avatar() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let notification = Rc::new(notification); + let collapsed = row_data( + Rc::clone(¬ification), + RowFlags { + collapsed_group_preview: true, + ..Default::default() + }, + ); + let expanded = row_data(notification, RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &collapsed, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + + update_notification_row(&row, &expanded, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); +} + +#[gtk::test] +fn historical_empty_avatar_role_does_not_create_a_blank_lead_slot() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn rebinding_avatar_row_to_history_clears_the_paintable_and_slot() { + let (_root, row) = notification_row(); + let mut active = sample_notification(); + active.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + active.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let active_data = row_data(Rc::new(active), RowFlags::default()); + + update_notification_row(&row, &active_data, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.paintable().is_some()); + assert!(row.thumbnail.get_visible()); + + let mut history = sample_notification(); + history.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + let history_data = row_data(Rc::new(history), RowFlags::default()); + + update_notification_row(&row, &history_data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn content_thumbnail_setting_does_not_hide_conversation_avatar() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 0, 0, 255], + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs new file mode 100644 index 000000000..d468d4f8b --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/tests/visual_matrix.rs @@ -0,0 +1,432 @@ +//! Generic popup/panel visual-role matrix for reusable notification rows + +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::{hooks, ImageData}; + +use crate::ui::icons::IconResolver; + +use super::super::super::test_support::{ + notification_row, row_data, sample_notification, RowFlags, +}; +use super::super::thumbnail::{panel_lead_visual, PanelLeadVisual}; +use super::super::update_notification_row; +use unixnotis_ui::presentation::NotificationPresentation; + +#[test] +fn unresolved_conversation_avatar_follows_avatar_setting() { + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + let presentation = NotificationPresentation::from_view(¬ification); + + assert_eq!( + panel_lead_visual(&presentation, true, false), + PanelLeadVisual::ConversationAvatar + ); + assert_eq!( + panel_lead_visual(&presentation, false, true), + PanelLeadVisual::None + ); +} + +#[gtk::test] +fn panel_conversation_avatar_obeys_avatar_setting_without_thumbnail_fallback() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![8, 9, 10, 255], + }; + let notification = Rc::new(notification); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + let enabled = row_data( + Rc::clone(¬ification), + RowFlags { + show_avatar: true, + show_thumbnail: false, + ..Default::default() + }, + ); + update_notification_row(&row, &enabled, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + + let disabled = row_data( + notification, + RowFlags { + show_avatar: false, + show_thumbnail: true, + ..Default::default() + }, + ); + update_notification_row(&row, &disabled, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn grouped_rows_keep_trust_chip_in_the_shared_application_header() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.trust_chip.get_visible()); +} + +#[gtk::test] +fn identity_signature_is_set_for_owned_headers_and_cleared_for_grouped_rows() { + let (_root, row) = notification_row(); + let notification = Rc::new(sample_notification()); + let mut standalone = row_data(notification.clone(), RowFlags::default()); + standalone.app_header_present = false; + let grouped = row_data(notification, RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &standalone, &IconResolver::new(), &command_tx); + assert!(row.icon_sig.borrow().is_some()); + + update_notification_row(&row, &grouped, &IconResolver::new(), &command_tx); + assert!(row.icon_sig.borrow().is_none()); +} + +#[gtk::test] +fn grouped_rebind_invalidates_previous_identity_icon_request() { + let (_root, row) = notification_row(); + let resolver = IconResolver::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + let mut notification = sample_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Application", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "generic fixture", + "unknown:example".to_string(), + ); + notification.image.claimed_desktop_id = "org.example.Async.desktop".to_string(); + let notification = Rc::new(notification); + + let mut standalone = row_data(Rc::clone(¬ification), RowFlags::default()); + standalone.app_header_present = false; + let mut grouped = row_data(notification, RowFlags::default()); + grouped.app_header_present = true; + + update_notification_row(&row, &standalone, &resolver, &command_tx); + update_notification_row(&row, &grouped, &resolver, &command_tx); + + assert!(row.icon_sig.borrow().is_none()); + assert!(row.icon.paintable().is_none()); + assert!(!row.icon.get_visible()); +} + +#[gtk::test] +fn malformed_conversation_avatar_does_not_reserve_a_panel_lead_slot() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + // The role is present, but the raster has no valid dimensions or pixels + notification.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(!row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + assert!(row.card.has_css_class(hooks::panel_card::NO_THUMBNAIL)); +} + +#[gtk::test] +fn conversation_avatar_wins_panel_lead_slot_when_content_is_also_present() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let data = row_data(Rc::new(notification), RowFlags::default()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn content_only_notification_stays_in_the_content_lead_lane() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); +} + +#[gtk::test] +fn malformed_conversation_avatar_falls_back_to_valid_content_media() { + let (_root, row) = notification_row(); + let mut notification = sample_notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + notification.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + }; + let data = row_data( + Rc::new(notification), + RowFlags { + show_avatar: true, + show_thumbnail: true, + ..Default::default() + }, + ); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + + update_notification_row(&row, &data, &IconResolver::new(), &command_tx); + + assert!(row.thumbnail.get_visible()); + assert!(row.thumbnail.paintable().is_some()); + assert!(row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::SENDER_VISUAL)); + assert!(row.card.has_css_class(hooks::panel_card::HAS_THUMBNAIL)); + + // Disabling content thumbnails must not turn the malformed avatar into a fallback lane + let mut hidden_content = sample_notification(); + hidden_content.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + hidden_content.image.sender_visual = ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + hidden_content.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + }; + let hidden_data = row_data( + Rc::new(hidden_content), + RowFlags { + show_avatar: true, + show_thumbnail: false, + ..Default::default() + }, + ); + update_notification_row(&row, &hidden_data, &IconResolver::new(), &command_tx); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); + assert!(!row + .thumbnail + .has_css_class(hooks::panel_card::CONTENT_IMAGE)); +} + +#[gtk::test] +fn rapid_avatar_replacement_clears_previous_paintable() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + let mut first = sample_notification(); + first.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + first.image.sender_visual = avatar_pixel([255, 0, 0, 255]); + update_notification_row( + &row, + &row_data(Rc::new(first), RowFlags::default()), + &resolver, + &command_tx, + ); + let first_pixels = paintable_rgba(&row.thumbnail).expect("first avatar pixels"); + + let mut second = sample_notification(); + second.image.sender_visual_role = unixnotis_core::NotificationVisualRole::ConversationAvatar; + second.image.sender_visual = avatar_pixel([0, 0, 255, 255]); + update_notification_row( + &row, + &row_data(Rc::new(second), RowFlags::default()), + &resolver, + &command_tx, + ); + let second_pixels = paintable_rgba(&row.thumbnail).expect("replacement avatar pixels"); + assert_ne!(first_pixels, second_pixels); + + let mut empty = sample_notification(); + empty.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + empty.image.sender_visual = ImageData::default(); + update_notification_row( + &row, + &row_data(Rc::new(empty), RowFlags::default()), + &resolver, + &command_tx, + ); + assert!(row.thumbnail.paintable().is_none()); + assert!(!row.thumbnail.get_visible()); +} + +#[gtk::test] +fn burst_rebinding_does_not_retain_another_notification_visual() { + let (_root, row) = notification_row(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let resolver = IconResolver::new(); + + for index in 0..40 { + let mut notification = sample_notification(); + let use_avatar = index % 2 == 0; + let use_content = index % 3 == 0; + if use_avatar { + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = avatar_pixel([index as u8, 2, 3, 255]); + } + if use_content { + notification.image.content_image = ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [4, index as u8, 6, 255].repeat(4), + }; + } + let expected_visual = use_avatar || use_content; + let show_thumbnail = use_content; + let data = row_data( + Rc::new(notification), + RowFlags { + show_thumbnail, + ..Default::default() + }, + ); + + update_notification_row(&row, &data, &resolver, &command_tx); + + assert_eq!(row.thumbnail.get_visible(), expected_visual); + assert_eq!(row.thumbnail.paintable().is_some(), expected_visual); + } +} + +fn avatar_pixel(pixel: [u8; 4]) -> ImageData { + ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: pixel.to_vec(), + } +} + +fn paintable_rgba(image: >k::Image) -> Option> { + let texture = image.paintable()?.downcast::().ok()?; + let width = usize::try_from(texture.width()).ok()?; + let height = usize::try_from(texture.height()).ok()?; + let stride = width.checked_mul(4)?; + let mut pixels = vec![0; stride.checked_mul(height)?]; + gtk::gdk::prelude::TextureExtManual::download(&texture, &mut pixels, stride); + Some(pixels) +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs new file mode 100644 index 000000000..ec12ae135 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/thumbnail.rs @@ -0,0 +1,54 @@ +//! Thumbnail source decisions for notification rows + +use unixnotis_ui::presentation::{ + NotificationPresentation, SenderVisualPresentation, ThumbnailKind, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PanelLeadVisual { + ConversationAvatar, + ContentImage, + DecorativeSenderVisual, + None, +} + +pub(super) fn has_content_thumbnail(presentation: &NotificationPresentation) -> bool { + // Content thumbnails are already classified by the shared presentation layer + presentation.media.thumbnail == ThumbnailKind::Content +} + +pub(super) const fn has_conversation_avatar(presentation: &NotificationPresentation) -> bool { + // Conversation photos may occupy the large sender-visual slot + matches!( + presentation.visuals.sender, + SenderVisualPresentation::ConversationAvatar + ) +} + +pub(super) const fn has_sender_visual(presentation: &NotificationPresentation) -> bool { + // Other sender visuals stay decorative and never replace the trusted badge + matches!( + presentation.visuals.sender, + SenderVisualPresentation::ApplicationProvidedIcon + ) +} + +pub(super) fn panel_lead_visual( + presentation: &NotificationPresentation, + show_avatars: bool, + show_thumbnails: bool, +) -> PanelLeadVisual { + // Conversation identity always wins the single master-style lead slot + if show_avatars && has_conversation_avatar(presentation) { + return PanelLeadVisual::ConversationAvatar; + } + // Content images are optional and come after a conversation avatar + if show_thumbnails && has_content_thumbnail(presentation) { + return PanelLeadVisual::ContentImage; + } + // Decorative sender art is lower priority than message content + if show_thumbnails && has_sender_visual(presentation) { + return PanelLeadVisual::DecorativeSenderVisual; + } + PanelLeadVisual::None +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs new file mode 100644 index 000000000..253c06f51 --- /dev/null +++ b/crates/unixnotis-center/src/ui/notifications/row/notification/update/visual.rs @@ -0,0 +1,113 @@ +//! Card state classes and widget visibility + +use gtk::prelude::*; +use unixnotis_core::{hooks, NotificationView, Urgency}; +use unixnotis_ui::presentation::{NotificationPresentation, TrustLevel}; + +use super::super::super::super::item::RowData; +use super::super::stack::{set_stack_layer_margins, stack_layer_visibility}; +use super::super::state::NotificationRowWidgets; +use super::labels::has_visible_text; + +pub(super) fn apply_visual_state( + row: &NotificationRowWidgets, + data: &RowData, + notification: &NotificationView, + presentation: &NotificationPresentation, + has_actions: bool, + has_thumbnail: bool, +) { + let card = &row.card; + let is_critical = notification.urgency == Urgency::Critical as u8; + // The application header owns identity whenever block assembly provided one + let group_owns_identity = data.app_header_present; + // Removing the hidden identity row also removes its old inter-row breathing room + card.set_spacing(if group_owns_identity { 2 } else { 6 }); + // Theme changes update recycled rows without rebuilding the GTK child tree + row.card_plate.set_corners(card_corners_for_row(data)); + // Explicit state updates prevent recycled rows from retaining stale classes + set_class_state(card, hooks::shared_state::CRITICAL, is_critical); + for (level, class_name) in [ + (TrustLevel::Verified, "verified"), + (TrustLevel::Unresolved, "unresolved"), + (TrustLevel::Conflict, "conflict"), + (TrustLevel::Relay, "relay"), + ] { + set_class_state(card, class_name, presentation.trust.level == level); + } + set_class_state( + card, + "recognized", + matches!( + presentation.trust.level, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated + ), + ); + set_widget_visible_if_changed(&row.urgency_badge, is_critical); + set_class_state(card, hooks::shared_state::ACTIVE, data.is_active); + set_class_state( + card, + hooks::shared_state::COLLAPSED_GROUP_PREVIEW, + data.collapsed_group_preview, + ); + set_class_state( + &row.card_plate, + hooks::shared_state::COLLAPSED_GROUP_PREVIEW, + data.collapsed_group_preview, + ); + let layers = stack_layer_visibility(data.stack_depth); + set_stack_layer_margins( + &row.card_plate, + &row.stack_middle, + &row.stack_back, + data.collapsed_group_preview, + data.collapsed_group_preview || data.expanded, + ); + set_widget_visible_if_changed(&row.stack_middle, layers.middle); + set_widget_visible_if_changed(&row.stack_back, layers.back); + let grouped = data.collapsed_group_preview || data.expanded; + set_class_state(card, hooks::panel_card::GROUPED, grouped); + set_class_state(&row.card_plate, hooks::panel_card::GROUPED, grouped); + // Group headers own identity details while child rows stay message-first + set_class_state(card, "group-owned-identity", group_owns_identity); + set_class_state(&row.card_plate, "group-owned-identity", group_owns_identity); + set_class_state( + card, + hooks::panel_card::HAS_SUMMARY, + has_visible_text(¬ification.summary), + ); + set_class_state( + card, + hooks::panel_card::HAS_BODY, + has_visible_text(¬ification.body), + ); + set_class_state(card, hooks::panel_card::HAS_ACTIONS, has_actions); + set_class_state(card, hooks::panel_card::NO_ACTIONS, !has_actions); + set_class_state(card, hooks::panel_card::HAS_THUMBNAIL, has_thumbnail); + set_class_state(card, hooks::panel_card::NO_THUMBNAIL, !has_thumbnail); +} + +const fn card_corners_for_row(data: &RowData) -> unixnotis_core::CutCorners { + // Every separated foreground card keeps the configured complete silhouette + data.presentation.card_corners +} + +fn set_class_state>(root: &W, class_name: &str, enabled: bool) { + // Guard CSS churn so GTK does not reprocess matching classes + if enabled { + if !root.has_css_class(class_name) { + root.add_css_class(class_name); + } + } else if root.has_css_class(class_name) { + root.remove_css_class(class_name); + } +} + +pub(super) fn set_widget_visible_if_changed>(widget: &W, visible: bool) { + // Stable visibility avoids unnecessary GTK property notifications + if widget.get_visible() != visible { + widget.set_visible(visible); + } +} diff --git a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs index 2b849727d..6c97fb1cc 100644 --- a/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs +++ b/crates/unixnotis-center/src/ui/notifications/row/tests/group.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gtk::prelude::*; use unixnotis_core::{NotificationImage, NotificationView}; -use super::{build_group_row, update_group_row}; +use super::{build_group_row, group_accessible_label, update_group_row}; use crate::control::UiEvent; use crate::ui::icons::IconResolver; use crate::ui::notifications::item::{RowData, RowKind}; @@ -13,13 +13,29 @@ use crate::ui::notifications::test_support as support; fn notification(app_name: &str) -> Rc { Rc::new(NotificationView { id: 1, + generation: 1, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution::verified( + app_name, + app_name, + "org.example.App", + "example-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), summary: "summary".to_string(), body: "body".to_string(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, }) } @@ -30,17 +46,35 @@ fn header_button(root: >k::Box) -> gtk::Button { .expect("group child should be button") } +fn direct_child_count(container: >k::Box) -> usize { + let mut count = 0; + let mut child = container.first_child(); + while let Some(widget) = child { + count += 1; + child = widget.next_sibling(); + } + count +} + #[gtk::test] fn update_group_row_sets_title_count_and_expanded_state() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); + assert_eq!(root.margin_bottom(), 8); let data = RowData::group_header(Rc::from("terminal"), 3, false, notification("Terminal")); update_group_row(&widgets, &root, &data, &IconResolver::new()); assert_eq!(widgets.title.text().as_str(), "Terminal"); + assert_eq!(widgets.avatar.width_request(), 26); + assert_eq!(widgets.avatar.height_request(), 26); + assert_eq!(widgets.icon.pixel_size(), 18); assert_eq!(widgets.count.text().as_str(), "3"); + assert!(gtk::test_accessible_has_property( + &widgets.button, + gtk::AccessibleProperty::Label + )); assert_eq!( widgets.chevron.icon_name().as_deref(), Some("pan-down-symbolic") @@ -60,11 +94,49 @@ fn update_group_row_sets_title_count_and_expanded_state() { assert!(root.has_css_class("unixnotis-group-row-expanded")); } +#[test] +fn group_accessible_name_keeps_identity_trust_count_and_state() { + assert_eq!( + group_accessible_label( + "Unknown application", + "Suspicious", + "Claimed app: Example Chat", + 4, + true, + ), + "Unknown application. Suspicious. Claimed app: Example Chat. 4 notifications. Expanded" + ); + assert_eq!( + group_accessible_label("Example Chat", "", "", 1, false), + "Example Chat. 1 notification" + ); +} + +#[gtk::test] +fn singleton_group_header_hides_group_controls_and_is_not_interactive() { + support::init_gtk(); + let (event_tx, event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let data = RowData::group_header(Rc::from("terminal"), 1, false, notification("Terminal")); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert!(!widgets.count.get_visible()); + assert!(!widgets.chevron.get_visible()); + assert!(!widgets.button.is_sensitive()); + assert!(!widgets.button.is_focusable()); + header_button(&root).emit_clicked(); + assert!(event_rx.try_recv().is_err()); +} + #[gtk::test] fn update_group_row_falls_back_to_group_key_without_sample() { support::init_gtk(); let (event_tx, _event_rx) = async_channel::bounded::(4); let (root, widgets) = build_group_row(event_tx); + let resolver = IconResolver::new(); + let sample = RowData::group_header(Rc::from("terminal"), 2, false, notification("Terminal")); + update_group_row(&widgets, &root, &sample, &resolver); let data = RowData { kind: RowKind::GroupHeader, group_key: Rc::from("terminal"), @@ -73,13 +145,188 @@ fn update_group_row_falls_back_to_group_key_without_sample() { ..RowData::default() }; - update_group_row(&widgets, &root, &data, &IconResolver::new()); + update_group_row(&widgets, &root, &data, &resolver); assert_eq!(widgets.title.text().as_str(), "terminal"); + assert!(widgets.icon.paintable().is_none()); assert!(!widgets.icon.get_visible()); assert!(root.has_css_class("unixnotis-group-row-no-icon")); } +#[gtk::test] +fn missing_group_sample_clears_recycled_conflict_presentation() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let resolver = IconResolver::new(); + let mut conflicting = notification("Unknown application").as_ref().clone(); + conflicting.attribution = unixnotis_core::NotificationAttribution::conflict( + "Example Claim", + "org.example.Application", + unixnotis_core::AttributionReason::ExecutableMismatch, + "conflicting process evidence", + "conflict:example".to_string(), + ); + let conflict = + RowData::group_header(Rc::from("conflict:example"), 2, false, Rc::new(conflicting)); + update_group_row(&widgets, &root, &conflict, &resolver); + assert!(root.has_css_class("unixnotis-attribution-warning")); + assert!(root.has_css_class("conflict")); + assert!(widgets.title.tooltip_text().is_some()); + + let empty = RowData { + kind: RowKind::GroupHeader, + group_key: Rc::from("empty:example"), + count: 1, + notification: None, + ..RowData::default() + }; + update_group_row(&widgets, &root, &empty, &resolver); + + assert!(!root.has_css_class("unixnotis-attribution-warning")); + assert!(!root.has_css_class("conflict")); + assert!(!root.has_css_class("relay")); + assert!(widgets.title.tooltip_text().is_none()); +} + +#[gtk::test] +fn update_group_row_keeps_conflict_warning_out_of_the_title() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut conflicting = notification("Unknown application").as_ref().clone(); + conflicting.attribution = unixnotis_core::NotificationAttribution::conflict( + "Trusted Brand", + "org.example.TrustedBrand", + unixnotis_core::AttributionReason::ExecutableMismatch, + "source /tmp/sender-bin", + "executable:1:2".to_string(), + ); + let data = RowData::group_header(Rc::from("executable:1:2"), 1, false, Rc::new(conflicting)); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_eq!(widgets.title.text().as_str(), "Unknown application"); + assert!(widgets + .title + .tooltip_text() + .is_some_and(|text| text.contains("/tmp/sender-bin"))); + assert_eq!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-shield-warning-symbolic") + ); + assert_eq!( + widgets.secondary.text().as_str(), + "Claimed app: Trusted Brand" + ); + assert_eq!(widgets.trust_chip.text().as_str(), "Suspicious"); + assert!(widgets.secondary.get_visible()); + assert!(widgets.trust_chip.get_visible()); + assert!(root.has_css_class("unixnotis-attribution-warning")); +} + +#[gtk::test] +fn recognized_group_keeps_application_icon_separate_from_trust_chip() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut recognized = notification("Example Chat").as_ref().clone(); + recognized.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "application-x-executable-symbolic", + unixnotis_core::IdentityAssurance::UserAssociated, + unixnotis_core::InteractionPolicies::CONFIRM_ACTIONS, + unixnotis_core::AttributionReason::ExactUserExecutable, + "associated user application", + "associated:user-app:org.example.Chat".to_string(), + ); + let data = RowData::group_header( + Rc::from("associated:user-app:org.example.Chat"), + 2, + false, + Rc::new(recognized), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert!(widgets.icon.paintable().is_some()); + assert_ne!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); + assert_eq!(widgets.trust_chip.text().as_str(), "Local app"); + assert!(widgets.trust_chip.get_visible()); +} + +#[gtk::test] +fn unresolved_group_keeps_unverified_claimed_branding_separate_from_trust() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut unresolved = notification("Example Chat").as_ref().clone(); + unresolved.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ); + // A claimed desktop id is presentation-only and does not authenticate the sender + // A symbolic fixture keeps this attribution test independent from raster-worker timing + unresolved.image.claimed_desktop_id = "application-x-executable-symbolic".to_string(); + let data = RowData::group_header( + Rc::from("claim:example-chat"), + 2, + false, + Rc::new(unresolved), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + assert_ne!( + widgets.icon.icon_name().as_deref(), + Some("unixnotis-app-unknown-symbolic") + ); + assert!(widgets.icon.paintable().is_some()); + assert_eq!(widgets.trust_chip.text().as_str(), "Unverified"); + assert!(widgets.trust_chip.get_visible()); +} + +#[gtk::test] +fn relay_group_header_keeps_claim_below_command_line_identity() { + support::init_gtk(); + let (event_tx, _event_rx) = async_channel::bounded::(4); + let (root, widgets) = build_group_row(event_tx); + let mut relayed = notification("Example Chat").as_ref().clone(); + relayed.attribution = unixnotis_core::NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + let data = RowData::group_header( + Rc::from("relay:notify-send:example-chat"), + 4, + false, + Rc::new(relayed), + ); + + update_group_row(&widgets, &root, &data, &IconResolver::new()); + + let header = header_button(&root) + .child() + .and_downcast::() + .expect("group header content"); + assert_eq!(direct_child_count(&header), 4); + assert_eq!(header.spacing(), 8); + assert_eq!(widgets.title.text().as_str(), "Command-line notification"); + assert_eq!(widgets.secondary.text().as_str(), "App label: Example Chat"); + assert!(widgets.secondary.get_visible()); + assert!(!widgets.trust_chip.get_visible()); + assert!(root.has_css_class("relay")); + assert!(!root.has_css_class("unixnotis-attribution-warning")); +} + #[gtk::test] fn group_header_click_sends_toggle_event() { support::init_gtk(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs index d92fdf630..87a410223 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/blocks.rs @@ -20,7 +20,9 @@ impl NotificationList { return (Vec::new(), Vec::new()); }; - // Cached header objects preserve GTK bindings across incremental rebuilds + let mut items = Vec::new(); + let mut keys = Vec::new(); + // Every application block owns one shared identity header let header = self.group_headers.entry(key.clone()).or_insert_with(|| { RowItem::new(RowData::group_header( key.clone(), @@ -35,15 +37,11 @@ impl NotificationList { expanded, first_entry.view.clone(), )); - - let mut items = Vec::new(); - let mut keys = Vec::new(); items.push(header.clone()); keys.push(RowKey::GroupHeader { group: key.clone() }); - // Collapsed groups render one notification row - // The row owns stack decoration so GTK does not virtualize it separately - let stacked = !expanded && ids.len() > 1; + // Collapsed groups render the newest content row under their shared header + let collapsed_group_preview = !expanded && ids.len() > 1; let stack_depth = collapsed_stack_depth(ids.len(), expanded); for (index, id) in ids.iter().enumerate() { if !expanded && index > 0 { @@ -56,16 +54,21 @@ impl NotificationList { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, + reduced_motion: self.reduced_motion, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; - entry.item.update(RowData::notification( + let row = RowData::notification( entry.app_key.clone(), entry.view.clone(), - stacked, + collapsed_group_preview, stack_depth, expanded, entry.is_active, presentation, - )); + ); + entry.item.update(row); items.push(entry.item.clone()); keys.push(RowKey::Notification { id: *id }); } @@ -79,7 +82,10 @@ impl NotificationList { ids: &[u32], ) -> usize { let expanded = self.group_expanded.get(key).copied().unwrap_or(false); - let mut len = 1; // header + if ids.is_empty() { + return 0; + } + let mut len = 1; // shared header if expanded { len += ids.len(); } else if !ids.is_empty() { @@ -144,11 +150,11 @@ impl NotificationList { } } -fn collapsed_stack_depth(count: usize, expanded: bool) -> u8 { +pub(in crate::ui::notifications) fn collapsed_stack_depth(count: usize, expanded: bool) -> u8 { if expanded { return 0; } - // One extra notification shows one shadow, larger stacks cap at two + // One hidden item adds one layer and larger groups cap at two quiet silhouettes count.saturating_sub(1).min(2) as u8 } diff --git a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs index 4236a164e..6884b4eeb 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/lifecycle.rs @@ -2,7 +2,7 @@ //! //! These helpers own the base storage lifecycle so mutation code can stay focused on updates -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -13,6 +13,13 @@ use super::item::{RowData, RowItem, RowPresentation}; use super::types::{NotificationEntry, NotificationList}; impl NotificationList { + pub fn clear_for_disconnect(&mut self) { + // Preserve group preferences while the old daemon generation is absent + let previous_expansion = std::mem::take(&mut self.group_expanded); + self.seed(Vec::new(), Vec::new()); + self.group_expanded = previous_expansion; + } + pub fn apply_limits(&mut self, max_active: usize, max_entries: usize) { let mut changed = false; if self.max_active != max_active { @@ -35,7 +42,7 @@ impl NotificationList { self.entries.clear(); self.active_order.clear(); self.history_order.clear(); - clear_seed_group_expansion(&mut self.group_expanded); + let previous_expansion = std::mem::take(&mut self.group_expanded); self.group_headers.clear(); self.group_order.clear(); self.group_order_scratch.clear(); @@ -54,6 +61,15 @@ impl NotificationList { self.insert_entry(notification, false); } self.trim_to_limits(); + // Preserve the user's open groups when the daemon sends a new seed + self.group_expanded = previous_expansion + .into_iter() + .filter(|(key, _)| { + self.entries + .values() + .any(|entry| entry.app_key.as_ref() == key.as_ref()) + }) + .collect(); debug!( active = self.active_order.len(), @@ -87,13 +103,21 @@ impl NotificationList { is_active: bool, ) -> Rc { let id = notification.id; - let app_key = self.intern_key(¬ification.app_name); + let app_key = self.intern_key(¬ification.attribution.group_key); + let received_at_ms = notification + .received_at_unix_seconds + .checked_mul(1_000) + .filter(|timestamp| *timestamp > 0) + .unwrap_or_else(now_millis); let view = Rc::new(notification); - let received_at_ms = now_millis(); let presentation = RowPresentation { received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, + reduced_motion: self.reduced_motion, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; let item = RowItem::new(RowData::notification( app_key.clone(), @@ -131,7 +155,7 @@ impl NotificationList { } fn now_millis() -> i64 { - // Local receipt time avoids adding timestamp fields to the D-Bus model + // Local receipt time is a fallback for legacy or malformed timestamp values SystemTime::now() .duration_since(UNIX_EPOCH) .ok() @@ -139,10 +163,6 @@ fn now_millis() -> i64 { .unwrap_or(0) } -fn clear_seed_group_expansion(group_expanded: &mut HashMap, bool>) { - group_expanded.clear(); -} - fn drain_order_over_limit(order: &mut VecDeque, max_entries: usize) -> Vec { if max_entries == 0 { // A zero limit means the section is disabled, so every id must leave storage diff --git a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs index 03271ebd6..05b330932 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/mutation.rs @@ -3,7 +3,9 @@ //! These paths own entry-level changes after the list has already been constructed use tracing::debug; -use unixnotis_core::{should_archive_closed_notification, CloseReason, NotificationView}; +use unixnotis_core::{ + should_archive_closed_notification, CloseReason, NotificationKey, NotificationView, +}; use super::types::NotificationList; @@ -11,16 +13,26 @@ impl NotificationList { pub fn add_or_update(&mut self, notification: NotificationView, is_active: bool) { let id = notification.id; let existing_entry = self.entries.get(&id); + if existing_entry.is_some_and(|entry| entry.view.generation > notification.generation) { + // Reordered signals must never roll a row back to an older payload + debug!( + id, + generation = notification.generation, + "stale row update skipped" + ); + return; + } let old_group = existing_entry.map(|entry| entry.app_key.clone()); let was_in_active = existing_entry.is_some_and(|entry| entry.is_active); let was_in_history = existing_entry.is_some() && !was_in_active; // Snapshot ordering state before any mutations; used to decide whether a full rebuild // is necessary because rebuilds are expensive for large histories let was_front = self.active_order.front().copied() == Some(id); - let needs_new_key = - existing_entry.is_some_and(|entry| entry.view.app_name != notification.app_name); + let needs_new_key = existing_entry.is_some_and(|entry| { + entry.view.attribution.group_key != notification.attribution.group_key + }); let new_key = if needs_new_key { - Some(self.intern_key(¬ification.app_name)) + Some(self.intern_key(¬ification.attribution.group_key)) } else { None }; @@ -64,41 +76,42 @@ impl NotificationList { if let Some(entry) = self.entries.get(&id) { if !self.group_span_matches_visible_shape(&entry.app_key) { // Span changes still need the rebuild path - // Header count and card depth must move as one visible update + // Header count and collapsed preview state must move as one visible update self.dirty_groups.insert(entry.app_key.clone()); self.request_rebuild(); - debug!(id, active = is_active, "notification stack shape changed"); + debug!(id, active = is_active, "notification group shape changed"); return; } - // Compute stack state from cached grouping instead of rebuilding the store + // Compute preview state from cached grouping instead of rebuilding the store let expanded = self .group_expanded .get(&entry.app_key) .copied() .unwrap_or(false); let group_len = self.grouped_cache.get(&entry.app_key).map_or(0, Vec::len); - let stacked = collapsed_group_is_stacked(expanded, group_len); - let stack_depth = if expanded { - 0 - } else { - group_len.saturating_sub(1).min(2) as u8 - }; + let collapsed_group_preview = is_collapsed_group_preview(expanded, group_len); + let stack_depth = super::blocks::collapsed_stack_depth(group_len, expanded); let presentation = super::item::RowPresentation { received_at_ms: entry.received_at_ms, show_metadata: self.show_notification_metadata, show_thumbnail: self.show_notification_thumbnails, + show_avatar: self.show_notification_avatars, + reduced_motion: self.reduced_motion, + metadata: self.notification_metadata.clone(), + card_corners: self.notification_corners, }; // Update the row object in-place when the visible span stays identical - entry.item.update(super::item::RowData::notification( + let row = super::item::RowData::notification( entry.app_key.clone(), entry.view.clone(), - stacked, + collapsed_group_preview, stack_depth, expanded, entry.is_active, presentation, - )); + ); + entry.item.update(row); if let Some(ids) = self.grouped_cache.get(&entry.app_key) { if ids.first().copied() == Some(id) { let expanded = self @@ -156,7 +169,16 @@ impl NotificationList { self.request_rebuild(); } - pub fn mark_closed(&mut self, id: u32, reason: CloseReason) { + pub fn mark_closed(&mut self, key: NotificationKey, reason: CloseReason) { + let id = key.id; + let Some(current) = self.entries.get(&id) else { + return; + }; + if current.view.generation != key.generation { + // A close for an older generation cannot mutate its replacement + debug!(id, generation = key.generation, "stale row close skipped"); + return; + } let group_key = self.entries.get(&id).map(|entry| entry.app_key.clone()); let should_archive = self.entries.get(&id).is_some_and(|entry| { should_archive_entry(entry.view.as_ref(), reason, self.transient_to_history) @@ -237,7 +259,7 @@ const fn should_move_active_to_front( was_in_history || !was_in_active || !was_front } -const fn collapsed_group_is_stacked(expanded: bool, group_len: usize) -> bool { +const fn is_collapsed_group_preview(expanded: bool, group_len: usize) -> bool { !expanded && group_len > 1 } diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs index 6efb09fc6..94c3034f9 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/blocks.rs @@ -7,18 +7,6 @@ use crate::ui::notifications::item::{RowData, RowItem}; use crate::ui::notifications::model::types::{GroupRange, RowKey}; use crate::ui::notifications::test_support as support; -#[test] -fn collapsed_stack_depth_caps_at_two() { - assert_eq!(collapsed_stack_depth(1, false), 0); - assert_eq!(collapsed_stack_depth(2, false), 1); - assert_eq!(collapsed_stack_depth(4, false), 2); -} - -#[test] -fn collapsed_stack_depth_is_zero_when_expanded() { - assert_eq!(collapsed_stack_depth(4, true), 0); -} - #[test] fn common_prefix_suffix_finds_stable_edges() { let group = Rc::::from("terminal"); @@ -87,23 +75,31 @@ fn build_group_block_collapses_group_to_header_and_top_notification() { assert_eq!(header.count, 3); assert!(!header.expanded); let visible = items[1].data(); - assert!(visible.stacked); + assert!(visible.collapsed_group_preview); assert_eq!(visible.stack_depth, 2); assert!(!visible.expanded); } #[gtk::test] -fn build_group_block_keeps_single_collapsed_notification_unstacked() { +fn build_group_block_keeps_single_notification_outside_collapsed_group_preview() { let mut list = support::make_list(); list.seed(vec![support::notification(1, "Terminal")], Vec::new()); let key = list.entries.get(&1).expect("entry").app_key.clone(); let ids = list.grouped_cache.get(&key).expect("group ids").clone(); - let (items, _keys) = list.build_group_block(&key, &ids); + let (items, keys) = list.build_group_block(&key, &ids); assert_eq!(items.len(), 2); + assert_eq!( + keys, + vec![ + RowKey::GroupHeader { group: key.clone() }, + RowKey::Notification { id: 1 }, + ] + ); + assert_eq!(items[0].data().count, 1); let visible = items[1].data(); - assert!(!visible.stacked); + assert!(!visible.collapsed_group_preview); assert_eq!(visible.stack_depth, 0); } @@ -137,12 +133,20 @@ fn build_group_block_expands_group_to_all_notifications() { ); for item in items.iter().skip(1) { let data = item.data(); - assert!(!data.stacked); - assert_eq!(data.stack_depth, 0); + assert!(!data.collapsed_group_preview); assert!(data.expanded); + assert_eq!(data.stack_depth, 0); } } +#[test] +fn collapsed_stack_depth_caps_at_two_and_clears_when_expanded() { + assert_eq!(collapsed_stack_depth(1, false), 0); + assert_eq!(collapsed_stack_depth(2, false), 1); + assert_eq!(collapsed_stack_depth(4, false), 2); + assert_eq!(collapsed_stack_depth(4, true), 0); +} + #[gtk::test] fn group_block_len_counts_header_and_visible_rows() { let mut list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs index 9a12728be..672227ec8 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/lifecycle.rs @@ -1,8 +1,8 @@ -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; -use super::{clear_seed_group_expansion, drain_order_over_limit}; +use super::drain_order_over_limit; use crate::ui::notifications::test_support as support; @@ -10,36 +10,6 @@ fn ordered_ids(ids: &[u32]) -> VecDeque { ids.iter().copied().collect() } -#[test] -fn seed_group_state_reset_clears_expanded_groups() { - let mut group_expanded = HashMap::from([(Rc::::from("Crash Reporting System"), true)]); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - -#[test] -fn seed_group_state_reset_clears_collapsed_groups_too() { - let mut group_expanded = HashMap::from([ - (Rc::::from("Crash Reporting System"), false), - (Rc::::from("notify-send"), true), - ]); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - -#[test] -fn seed_group_state_reset_accepts_empty_state() { - let mut group_expanded = HashMap::new(); - - clear_seed_group_expansion(&mut group_expanded); - - assert!(group_expanded.is_empty()); -} - #[test] fn drain_order_over_limit_removes_oldest_ids_from_back() { let mut order = ordered_ids(&[4, 3, 2, 1]); @@ -91,10 +61,10 @@ fn drain_order_over_limit_zero_capacity_accepts_empty_order() { } #[gtk::test] -fn seed_replaces_existing_state_and_requests_rebuild() { +fn seed_preserves_existing_group_expansion_for_surviving_groups() { let mut list = support::make_list(); - let stale_key = Rc::::from("crash reporting system"); - list.group_expanded.insert(stale_key, true); + list.group_expanded + .insert(Rc::::from("test:terminal"), true); list.seed( vec![ @@ -104,13 +74,42 @@ fn seed_replaces_existing_state_and_requests_rebuild() { vec![support::notification(3, "History")], ); - assert!(list.group_expanded.is_empty()); + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); assert_eq!(list.total_count(), 3); assert_eq!(list.active_order, ordered_ids(&[2, 1])); assert_eq!(list.history_order, ordered_ids(&[3])); assert!(list.needs_rebuild()); } +#[gtk::test] +fn disconnect_reset_keeps_group_expansion_until_reconnect_seed() { + let mut list = support::make_list(); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + Vec::new(), + ); + list.group_expanded + .insert(Rc::::from("test:terminal"), true); + + list.clear_for_disconnect(); + + assert!(list.entries.is_empty()); + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); + + list.seed( + vec![ + support::notification(3, "Terminal"), + support::notification(4, "Terminal"), + ], + Vec::new(), + ); + + assert_eq!(list.group_expanded.get("test:terminal"), Some(&true)); +} + #[gtk::test] fn seed_trims_to_current_limits() { let mut list = support::make_list(); @@ -246,11 +245,28 @@ fn insert_entry_records_recent_local_timestamp() { let after = super::now_millis(); let entry = list.entries.get(&9).expect("entry should be stored"); - assert_eq!(key.as_ref(), "terminal"); + assert_eq!(key.as_ref(), "test:terminal"); assert!(entry.received_at_ms >= before); assert!(entry.received_at_ms <= after); } +#[gtk::test] +fn insert_entry_preserves_original_notification_timestamp_for_history_chronology() { + let mut list = support::make_list(); + let mut notification = support::notification(9, "Terminal"); + notification.received_at_unix_seconds = 1_700_000_123; + + list.insert_entry(notification, false); + + assert_eq!( + list.entries + .get(&9) + .expect("history entry should be stored") + .received_at_ms, + 1_700_000_123_000 + ); +} + #[test] fn now_millis_tracks_current_unix_time() { let system_ms = SystemTime::now() diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs index 0fe57b006..ad9a8d33f 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/mutation.rs @@ -6,29 +6,60 @@ use crate::ui::notifications::test_support as support; fn make_view(is_transient: bool) -> NotificationView { NotificationView { id: 7, + generation: 7, app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "Test".to_string(), + group_key: "test:Test".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: "summary".to_string(), body: "body".to_string(), actions: vec![Action { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } fn view(id: u32, app_name: &str, is_transient: bool) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: app_name.to_string(), + group_key: format!("test:{app_name}"), + ..unixnotis_core::NotificationAttribution::default() + }, summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn notification_key(id: u32) -> NotificationKey { + NotificationKey { + id, + generation: u64::from(id), } } @@ -42,11 +73,11 @@ fn active_move_policy_covers_history_new_and_non_front_rows() { } #[test] -fn collapsed_group_stacked_policy_requires_collapsed_group_with_multiple_rows() { - assert!(!collapsed_group_is_stacked(false, 0)); - assert!(!collapsed_group_is_stacked(false, 1)); - assert!(collapsed_group_is_stacked(false, 2)); - assert!(!collapsed_group_is_stacked(true, 2)); +fn collapsed_group_preview_requires_a_collapsed_group_with_multiple_rows() { + assert!(!is_collapsed_group_preview(false, 0)); + assert!(!is_collapsed_group_preview(false, 1)); + assert!(is_collapsed_group_preview(false, 2)); + assert!(!is_collapsed_group_preview(true, 2)); } #[test] @@ -246,7 +277,7 @@ fn mark_closed_dismissed_row_removes_entry_and_marks_group_dirty() { list.flush_rebuild(); let key = list.entries.get(&1).expect("entry").app_key.clone(); - list.mark_closed(1, CloseReason::DismissedByUser); + list.mark_closed(notification_key(1), CloseReason::DismissedByUser); assert!(!list.entries.contains_key(&1)); assert!(list.active_order.is_empty()); @@ -262,7 +293,7 @@ fn mark_closed_expired_row_archives_to_history_when_policy_allows_it() { list.flush_rebuild(); let key = list.entries.get(&1).expect("entry").app_key.clone(); - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); assert!(list.active_order.is_empty()); assert_eq!( @@ -287,12 +318,49 @@ fn mark_closed_archived_row_does_not_duplicate_existing_history_id() { list.seed(vec![view(1, "Terminal", false)], Vec::new()); list.flush_rebuild(); - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); list.needs_rebuild = false; - list.mark_closed(1, CloseReason::Expired); + list.mark_closed(notification_key(1), CloseReason::Expired); assert_eq!( list.history_order.iter().copied().collect::>(), vec![1] ); } + +#[gtk::test] +fn reordered_update_cannot_replace_a_newer_row_generation() { + let mut list = support::make_list(); + let mut newest = view(1, "Terminal", false); + newest.generation = 3; + list.seed(vec![newest], Vec::new()); + let mut stale = view(1, "Terminal", false); + stale.generation = 2; + stale.summary = "stale payload".to_string(); + + list.add_or_update(stale, true); + + let current = &list.entries.get(&1).expect("current row").view; + assert_eq!(current.generation, 3); + assert_ne!(current.summary, "stale payload"); +} + +#[gtk::test] +fn reordered_close_cannot_remove_or_archive_a_newer_row_generation() { + let mut list = support::make_list(); + let mut replacement = view(1, "Terminal", false); + replacement.generation = 3; + list.seed(vec![replacement], Vec::new()); + + list.mark_closed( + NotificationKey { + id: 1, + generation: 2, + }, + CloseReason::Expired, + ); + + assert!(list.entries.get(&1).expect("replacement row").is_active); + assert_eq!(list.active_order.iter().copied().collect::>(), [1]); + assert!(list.history_order.is_empty()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs index b46577f27..d793da012 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/tests/update.rs @@ -1,4 +1,5 @@ use gio::prelude::ListModelExt; +use gtk::prelude::Cast; use gtk::prelude::WidgetExt; use std::rc::Rc; @@ -11,6 +12,16 @@ use super::{ should_keep_group, should_rebuild_from_scratch, }; +fn empty_overlay_text(list: &crate::ui::notifications::NotificationList) -> String { + list.empty_overlay + .first_child() + .expect("empty overlay should contain a label") + .downcast::() + .expect("empty overlay child should be a label") + .text() + .to_string() +} + #[gtk::test] fn request_rebuild_marks_list_dirty() { let mut list = support::make_list(); @@ -161,6 +172,36 @@ fn flush_rebuild_filters_existing_list_with_minimal_middle_splice() { assert_eq!(list.group_ranges[&browser].len, 2); } +#[gtk::test] +fn empty_overlay_distinguishes_no_matches_from_an_empty_notification_store() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + + assert!(list.set_filter_query("missing")); + list.flush_rebuild(); + + assert!(list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No matching notifications"); + + assert!(list.set_filter_query("")); + list.flush_rebuild(); + + assert!(!list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No notifications"); +} + +#[gtk::test] +fn empty_overlay_keeps_normal_copy_when_searching_an_empty_store() { + let mut list = support::make_list(); + + assert!(list.set_filter_query("missing")); + list.flush_rebuild(); + + assert!(list.empty_overlay.get_visible()); + assert_eq!(empty_overlay_text(&list), "No notifications"); +} + #[gtk::test] fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { let mut list = support::make_list(); @@ -200,6 +241,7 @@ fn flush_rebuild_rebuilds_from_nonempty_store_when_ranges_are_missing() { assert!(!list.group_ranges.contains_key(&browser)); assert_eq!(list.group_ranges[&editor].start, 0); assert_eq!(list.group_ranges[&terminal].start, 2); + assert_eq!(list.group_ranges[&terminal].len, 2); assert!(!list.interned.iter().any(|key| key.as_ref() == "stale")); } @@ -245,7 +287,13 @@ fn flush_rebuild_applies_dirty_group_span_changes_incrementally() { #[gtk::test] fn flush_rebuild_refreshes_dirty_group_even_when_span_is_stable() { let mut list = support::make_list(); - list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + ], + Vec::new(), + ); list.flush_rebuild(); let terminal = list.entries.get(&1).expect("terminal").app_key.clone(); let view = list.entries.get(&1).expect("terminal").view.clone(); @@ -257,7 +305,7 @@ fn flush_rebuild_refreshes_dirty_group_even_when_span_is_stable() { list.flush_rebuild(); assert_eq!(list.store.n_items(), 2); - assert_eq!(header.data().count, 1); + assert_eq!(header.data().count, 2); assert_eq!(list.group_ranges[&terminal].start, 0); assert_eq!(list.group_ranges[&terminal].len, 2); } diff --git a/crates/unixnotis-center/src/ui/notifications/store/update.rs b/crates/unixnotis-center/src/ui/notifications/store/update.rs index e29c9020f..27f095229 100644 --- a/crates/unixnotis-center/src/ui/notifications/store/update.rs +++ b/crates/unixnotis-center/src/ui/notifications/store/update.rs @@ -3,7 +3,6 @@ //! Keeps list-store mutation logic separate from data mutation methods use std::collections::{HashMap, HashSet}; -use std::ops::Not; use std::rc::Rc; use gio::prelude::ListModelExt; @@ -15,6 +14,7 @@ use tracing::debug; use super::blocks; use super::types::{GroupRange, NotificationList, RowKey}; use super::RowItem; +use crate::ui::notifications::row::empty::update_empty_row; impl NotificationList { pub fn flush_rebuild(&mut self) { @@ -253,7 +253,7 @@ impl NotificationList { }) .count(); if range_count_mismatch(self.group_ranges.len(), expected_ranges) { - // Missing ranges leave later stack edits dependent on a full expand/collapse rebuild + // Missing ranges leave later group edits dependent on a full expand/collapse rebuild debug!( expected_ranges, actual_ranges = self.group_ranges.len(), @@ -276,11 +276,19 @@ impl NotificationList { } fn group_ids_are_visible(&self, ids: &[u32]) -> bool { - self.visible_ids_for_group(ids).is_empty().not() + !self.visible_ids_for_group(ids).is_empty() } - fn update_empty_overlay(&self) { + pub(in crate::ui::notifications) fn update_empty_overlay(&self) { let is_empty = self.store.n_items() == 0; + let counts = self.notification_counts(); + let text = if counts.filter_active && counts.total > 0 && counts.matching == 0 { + &self.no_matching_text + } else { + &self.empty_text + }; + // Search with existing notifications needs different feedback from a truly empty list + update_empty_row(&self.empty_overlay, text); // Compare against the widget's own visible flag // Effective visibility can flip with parent state and leave the overlay logically stale if self.empty_overlay.get_visible() != is_empty { @@ -298,7 +306,7 @@ const fn has_pending_items(count: usize) -> bool { } const fn range_count_mismatch(actual: usize, expected: usize) -> bool { - actual.abs_diff(expected) > 0 + actual != expected } fn intern_key_is_live(key: &Rc) -> bool { diff --git a/crates/unixnotis-center/src/ui/notifications/tests/support.rs b/crates/unixnotis-center/src/ui/notifications/tests/support.rs index 6ed5e883d..d471d11c2 100644 --- a/crates/unixnotis-center/src/ui/notifications/tests/support.rs +++ b/crates/unixnotis-center/src/ui/notifications/tests/support.rs @@ -23,8 +23,13 @@ pub(super) fn list_config() -> NotificationListConfig { max_entries: 10, transient_to_history: true, show_notification_metadata: false, + notification_metadata: unixnotis_core::NotificationMetadataConfig::default(), + notification_corners: unixnotis_core::CutCorners::default(), show_notification_thumbnails: false, + show_notification_avatars: true, + reduced_motion: false, empty_text: "No notifications".to_string(), + no_matching_text: "No matching notifications".to_string(), empty_offset_top: 24, empty_alignment: unixnotis_core::EmptyStateAlignment::Auto, } @@ -53,12 +58,24 @@ pub(super) fn channels() -> (mpsc::Sender, Sender) { pub(super) fn notification(id: u32, app_name: &str) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: app_name.to_string(), + group_key: format!("test:{app_name}"), + ..unixnotis_core::NotificationAttribution::default() + }, summary: format!("summary {id}"), body: format!("body {id}"), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-center/src/ui/notifications/view/build.rs b/crates/unixnotis-center/src/ui/notifications/view/build.rs index 61a7581a6..6b4dad286 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/build.rs @@ -13,7 +13,7 @@ use tokio::sync::mpsc; use crate::control::{UiCommand, UiEvent}; use super::item::RowKind; -use super::row::empty::{build_empty_row, update_empty_row}; +use super::row::empty::build_empty_row; use super::types::{NotificationList, NotificationListConfig}; use super::widgets::{bind_row, ensure_row_widgets, get_row_widgets, set_row_widgets, RowWidgets}; use crate::ui::icons::IconResolver; @@ -53,7 +53,10 @@ impl NotificationList { let command_tx_clone = command_tx.clone(); let event_tx_clone = event_tx.clone(); - factory.connect_setup(move |_, gtk_item| { + factory.connect_setup(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; let widgets = RowWidgets::new( RowKind::Notification, command_tx_clone.clone(), @@ -64,8 +67,11 @@ impl NotificationList { let command_tx_clone = command_tx; let event_tx_clone = event_tx; - let icon_resolver_clone = icon_resolver; - factory.connect_bind(move |_, gtk_item| { + let icon_resolver_for_bind = icon_resolver.clone(); + factory.connect_bind(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; let Some(row_item) = gtk_item.item().and_downcast::() else { return; }; @@ -77,12 +83,16 @@ impl NotificationList { event_tx_clone.clone(), ); - bind_row(widgets, &row_item, &data, icon_resolver_clone.clone()); + bind_row(widgets, &row_item, &data, icon_resolver_for_bind.clone()); }); - factory.connect_unbind(move |_, gtk_item| { + let icon_resolver_for_unbind = icon_resolver; + factory.connect_unbind(move |_, item| { + let Some(gtk_item) = item.downcast_ref::() else { + return; + }; if let Some(widgets) = get_row_widgets(gtk_item) { - widgets.unbind(); + widgets.unbind(&icon_resolver_for_unbind); } // Keep RowWidgets attached so GTK can recycle rows without rebuilding // the widget tree on every scroll. Kind mismatches are handled in @@ -95,6 +105,7 @@ impl NotificationList { empty_offset_top: config.empty_offset_top, empty_alignment: config.empty_alignment, empty_text: config.empty_text, + no_matching_text: config.no_matching_text, entries: std::collections::HashMap::new(), active_order: std::collections::VecDeque::new(), history_order: std::collections::VecDeque::new(), @@ -116,7 +127,11 @@ impl NotificationList { filter_query: None, transient_to_history: config.transient_to_history, show_notification_metadata: config.show_notification_metadata, + notification_metadata: Rc::new(config.notification_metadata), + notification_corners: config.notification_corners, show_notification_thumbnails: config.show_notification_thumbnails, + show_notification_avatars: config.show_notification_avatars, + reduced_motion: config.reduced_motion, max_active: config.max_active, max_entries: config.max_entries, } @@ -127,13 +142,27 @@ impl NotificationList { self.transient_to_history = config.transient_to_history; let presentation_changed = self.show_notification_metadata != config.show_notification_metadata - || self.show_notification_thumbnails != config.show_notification_thumbnails; + || self.notification_metadata.as_ref() != &config.notification_metadata + || self.notification_corners != config.notification_corners + || self.show_notification_thumbnails != config.show_notification_thumbnails + || self.show_notification_avatars != config.show_notification_avatars + || self.reduced_motion != config.reduced_motion; self.show_notification_metadata = config.show_notification_metadata; + if self.notification_metadata.as_ref() != &config.notification_metadata { + self.notification_metadata = Rc::new(config.notification_metadata.clone()); + } + self.notification_corners = config.notification_corners; self.show_notification_thumbnails = config.show_notification_thumbnails; + self.show_notification_avatars = config.show_notification_avatars; + self.reduced_motion = config.reduced_motion; if self.empty_text != config.empty_text { - update_empty_row(&self.empty_overlay, &config.empty_text); self.empty_text = config.empty_text.clone(); } + if self.no_matching_text != config.no_matching_text { + self.no_matching_text = config.no_matching_text.clone(); + } + // The visible copy depends on both configuration and current search state + self.update_empty_overlay(); if self.empty_offset_top != config.empty_offset_top { self.empty_offset_top = config.empty_offset_top; } @@ -141,6 +170,7 @@ impl NotificationList { self.apply_limits(config.max_active, config.max_entries); if presentation_changed { // Existing rows need fresh RowData so optional lanes hide or show immediately + self.dirty_groups.extend(self.grouped_cache.keys().cloned()); self.request_rebuild(); } } diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs index 75dae9934..73f2e3e9f 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/build.rs @@ -1,4 +1,4 @@ -use gtk::prelude::WidgetExt; +use gtk::prelude::*; use gtk::Align; use unixnotis_core::EmptyStateAlignment; @@ -19,22 +19,250 @@ fn new_list_attaches_overlay_to_scroller() { ); assert!(scroller.child().is_some()); + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should wrap the notification list in a viewport"); + let overlay = viewport + .child() + .and_downcast::() + .expect("viewport should contain the notification-list overlay"); + let list_view = overlay + .child() + .and_downcast::() + .expect("overlay should keep the virtualized list as its main child"); + assert_eq!(list_view.margin_bottom(), 0); assert_eq!(list.empty_text, "No notifications"); + assert_eq!(list.no_matching_text, "No matching notifications"); assert_eq!(list.empty_offset_top, 24); assert!(list.empty_overlay.get_visible()); } +#[gtk::test] +fn mapped_rows_keep_adjacent_groups_separated_and_stack_inside_allocation() { + support::init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, event_tx) = support::channels(); + let mut list = crate::ui::notifications::NotificationList::new( + scroller.clone(), + command_tx, + event_tx, + std::rc::Rc::new(crate::ui::icons::IconResolver::new()), + support::list_config(), + ); + + // Two multi-item groups exercise headers, collapsed stacks, and a final row + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + support::notification(4, "Browser"), + support::notification(5, "Editor"), + ], + Vec::new(), + ); + list.flush_rebuild(); + + let window = gtk::Window::new(); + window.set_default_size(520, 760); + window.set_child(Some(&scroller)); + window.present(); + let context = gtk::glib::MainContext::default(); + for _ in 0..8 { + while context.pending() { + context.iteration(false); + } + } + + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should expose a viewport"); + let overlay = viewport + .child() + .and_downcast::() + .expect("viewport should contain the list overlay"); + let list_view = overlay + .child() + .and_downcast::() + .expect("overlay should contain the virtualized list"); + + let mut rows = Vec::new(); + let mut child = list_view.first_child(); + while let Some(item) = child { + rows.push(item); + child = rows.last().and_then(gtk::prelude::WidgetExt::next_sibling); + } + assert!(rows.len() >= 5, "all fixture rows should be mapped"); + + for pair in rows.windows(2) { + let current_item = pair[0] + .first_child() + .expect("mapped row should contain its root"); + let current = current_item + .compute_bounds(&list_view) + .expect("mapped row should have bounds"); + let next = pair[1] + .compute_bounds(&list_view) + .expect("mapped row should have bounds"); + assert!( + next.y() >= current.y() + current.height() + 7.5, + "adjacent ListView rows need the explicit 8px gap: current={current:?}, next={next:?}" + ); + } + + for item in rows { + let Some(root) = item.first_child() else { + continue; + }; + let Some(stack) = root.first_child().and_downcast::() else { + continue; + }; + let stack_bounds = stack + .compute_bounds(&root) + .expect("notification stack should have bounds"); + let root_bounds = root + .compute_bounds(&item) + .expect("notification row should have bounds"); + assert!(stack_bounds.y() >= 0.0); + assert!( + stack_bounds.y() + stack_bounds.height() <= root_bounds.height(), + "stack must remain inside its real ListView row allocation" + ); + } + + window.close(); +} + +#[gtk::test] +fn mapped_notification_foregrounds_fill_every_stack_mode() { + support::init_gtk(); + let scroller = gtk::ScrolledWindow::new(); + let (command_tx, event_tx) = support::channels(); + let mut list = crate::ui::notifications::NotificationList::new( + scroller.clone(), + command_tx, + event_tx, + std::rc::Rc::new(crate::ui::icons::IconResolver::new()), + support::list_config(), + ); + + // Two groups cover depth one, depth two, and a standalone row + list.seed( + vec![ + support::notification(1, "Terminal"), + support::notification(2, "Terminal"), + support::notification(3, "Browser"), + support::notification(4, "Browser"), + support::notification(5, "Browser"), + support::notification(6, "Editor"), + ], + Vec::new(), + ); + list.flush_rebuild(); + + let window = gtk::Window::new(); + window.set_default_size(620, 900); + window.set_child(Some(&scroller)); + window.present(); + pump_gtk_frames(); + + let list_view = mapped_list_view(&scroller); + assert_foregrounds_fill_rows(&list_view, 3); + + // Rebuild the same model with an expanded group so foreground width does not + // depend on a visible rear layer from the previous binding + list.toggle_group("test:Browser"); + list.flush_rebuild(); + pump_gtk_frames(); + assert_foregrounds_fill_rows(&list_view, 5); + + // Return to a collapsed state to exercise another recycled-row transition + list.toggle_group("test:Browser"); + list.flush_rebuild(); + pump_gtk_frames(); + assert_foregrounds_fill_rows(&list_view, 3); + + window.close(); +} + +fn pump_gtk_frames() { + let context = gtk::glib::MainContext::default(); + for _ in 0..8 { + while context.pending() { + context.iteration(false); + } + } +} + +fn mapped_list_view(scroller: >k::ScrolledWindow) -> gtk::ListView { + let viewport = scroller + .child() + .and_downcast::() + .expect("scroller should expose a viewport"); + viewport + .child() + .and_downcast::() + .expect("viewport should contain the list overlay") + .child() + .and_downcast::() + .expect("overlay should contain the virtualized list") +} + +fn assert_foregrounds_fill_rows(list_view: >k::ListView, minimum_rows: usize) { + let mut child = list_view.first_child(); + let mut notification_rows = 0; + while let Some(item) = child { + let Some(root) = item.first_child() else { + child = item.next_sibling(); + continue; + }; + let Some(stack) = root.first_child().and_downcast::() else { + child = item.next_sibling(); + continue; + }; + let Some(foreground) = stack.last_child().and_downcast::() else { + panic!("notification grid should end with its foreground card"); + }; + let grouped_inset = + if foreground.has_css_class(unixnotis_core::css::hooks::panel_card::GROUPED) { + 8 + } else { + 0 + }; + let expected = stack.width() - (grouped_inset * 2); + assert!( + foreground.width() >= expected - 1, + "foreground width {} did not fill stack width {} with inset {}", + foreground.width(), + stack.width(), + grouped_inset + ); + assert_eq!(foreground.halign(), gtk::Align::Fill); + assert!(foreground.hexpands()); + notification_rows += 1; + child = item.next_sibling(); + } + assert!( + notification_rows >= minimum_rows, + "mapped fixture should include collapsed, expanded, and standalone notifications" + ); +} + #[gtk::test] fn apply_config_updates_empty_copy_and_offset() { let mut list = support::make_list(); let mut config = support::list_config(); config.empty_text = "All clear".to_string(); + config.no_matching_text = "Nothing found".to_string(); config.empty_offset_top = 48; list.apply_config(&config); list.set_empty_layout(true); assert_eq!(list.empty_text, "All clear"); + assert_eq!(list.no_matching_text, "Nothing found"); assert_eq!(list.empty_offset_top, 48); assert_eq!(list.empty_overlay.margin_top(), 48); } @@ -61,6 +289,40 @@ fn apply_config_requests_rebuild_when_metadata_or_thumbnail_flags_change() { assert!(list.needs_rebuild()); } +#[gtk::test] +fn apply_config_requests_rebuild_when_metadata_text_or_corner_geometry_changes() { + let mut list = support::make_list(); + let mut config = support::list_config(); + config.notification_metadata.live_label = "CURRENT".to_string(); + + list.apply_config(&config); + + assert_eq!(list.notification_metadata.live_label, "CURRENT"); + assert!(list.needs_rebuild()); + + list.needs_rebuild = false; + config.notification_corners.top_right = 16; + list.apply_config(&config); + + assert_eq!(list.notification_corners.top_right, 16); + assert!(list.needs_rebuild()); +} + +#[gtk::test] +fn apply_config_refreshes_existing_rows_when_reduced_motion_changes() { + let mut list = support::make_list(); + list.seed(vec![support::notification(1, "Terminal")], Vec::new()); + list.flush_rebuild(); + let mut config = support::list_config(); + config.reduced_motion = true; + + list.apply_config(&config); + list.flush_rebuild(); + + let row = list.entries.get(&1).expect("notification should remain"); + assert!(row.item.data().presentation.reduced_motion); +} + #[gtk::test] fn set_empty_layout_switches_between_widget_offset_and_centered_empty_state() { let list = support::make_list(); diff --git a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs index 274ec4894..29135edc3 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/tests/widgets.rs @@ -30,6 +30,21 @@ fn contains_label_text(root: >k::Widget, text: &str) -> bool { false } +fn find_image_with_class(root: >k::Widget, class_name: &str) -> Option { + if root.has_css_class(class_name) { + return root.clone().downcast::().ok(); + } + + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(image) = find_image_with_class(&widget, class_name) { + return Some(image); + } + child = widget.next_sibling(); + } + None +} + #[gtk::test] fn set_and_get_row_widgets_round_trips_cached_bundle() { support::init_gtk(); @@ -44,6 +59,21 @@ fn set_and_get_row_widgets_round_trips_cached_bundle() { assert!(gtk_item.child().is_some()); } +#[gtk::test] +fn row_widget_cache_keeps_bundle_alive_after_setup_owner_is_dropped() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let gtk_item = new_gtk_item(); + let widgets = Rc::new(RowWidgets::new(RowKind::Notification, command_tx, event_tx)); + let weak = Rc::downgrade(&widgets); + + set_row_widgets(>k_item, widgets); + + // The factory callback may drop its local Rc immediately after setup + assert!(weak.upgrade().is_some()); + assert!(get_row_widgets(>k_item).is_some()); +} + #[gtk::test] fn ensure_row_widgets_reuses_same_kind() { support::init_gtk(); @@ -149,7 +179,7 @@ fn unbind_disconnects_row_item_update_handler() { "summary 1" )); - widgets.unbind(); + widgets.unbind(&IconResolver::new()); let changed = Rc::new(support::notification(2, "Terminal")); item.update(RowData::notification( Rc::from("terminal"), @@ -161,7 +191,7 @@ fn unbind_disconnects_row_item_update_handler() { RowPresentation::default(), )); - assert!(contains_label_text( + assert!(!contains_label_text( &widgets.root.clone().upcast::(), "summary 1" )); @@ -170,3 +200,32 @@ fn unbind_disconnects_row_item_update_handler() { "summary 2" )); } + +#[gtk::test] +fn unbind_clears_group_identity_before_async_work_can_repaint_it() { + support::init_gtk(); + let (command_tx, event_tx) = support::channels(); + let widgets = Rc::new(RowWidgets::new(RowKind::GroupHeader, command_tx, event_tx)); + let notification = Rc::new(support::notification(1, "Example Application")); + let item = RowItem::new(RowData::group_header( + Rc::from("example:application"), + 2, + false, + notification, + )); + let resolver = Rc::new(IconResolver::new()); + + bind_row(widgets.clone(), &item, &item.data(), resolver.clone()); + + let icon = find_image_with_class( + &widgets.root.clone().upcast::(), + unixnotis_core::hooks::group_row::ICON, + ) + .expect("group identity icon should exist"); + assert!(icon.get_visible()); + + widgets.unbind(&resolver); + + assert!(!icon.get_visible()); + assert!(icon.paintable().is_none()); +} diff --git a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs index 902c3166e..f454e36f3 100644 --- a/crates/unixnotis-center/src/ui/notifications/view/widgets.rs +++ b/crates/unixnotis-center/src/ui/notifications/view/widgets.rs @@ -4,7 +4,6 @@ use std::cell::RefCell; use std::rc::Rc; -use std::sync::OnceLock; use async_channel::Sender; use gtk::prelude::*; @@ -15,9 +14,9 @@ use tracing::debug; use crate::control::{UiCommand, UiEvent}; use super::item::{RowData, RowItem, RowKind}; -use super::row::group::{build_group_row, update_group_row, GroupRowWidgets}; +use super::row::group::{build_group_row, clear_group_identity, update_group_row, GroupRowWidgets}; use super::row::notification::{ - build_notification_row, update_notification_row, NotificationRowWidgets, + build_notification_row, clear_notification_row, update_notification_row, NotificationRowWidgets, }; use crate::ui::icons::IconResolver; @@ -31,9 +30,13 @@ pub(super) struct RowWidgets { command_tx: mpsc::Sender, } -fn row_widgets_quark() -> gtk::glib::Quark { - static QUARK: OnceLock = OnceLock::new(); - *QUARK.get_or_init(|| gtk::glib::Quark::from_str("unixnotis-row-widgets")) +// Weak item references prevent destroyed list items from keeping entries alive +// Strong widget bundles preserve the factory's reusable row tree between binds +const MAX_TRACKED_ROW_WIDGETS: usize = 4096; + +thread_local! { + static ROW_WIDGETS: RefCell, Rc)>> = + const { RefCell::new(Vec::new()) }; } impl RowWidgets { @@ -89,8 +92,15 @@ impl RowWidgets { } } - pub(super) fn unbind(&self) { + pub(super) fn unbind(&self, icon_resolver: &IconResolver) { self.disconnect(); + if let Some(group) = &self.group { + // Unbind is a real ownership boundary even when no empty model update arrives + clear_group_identity(group, icon_resolver); + } + if let Some(notification) = &self.notification { + clear_notification_row(notification, icon_resolver); + } } fn disconnect(&self) { @@ -141,20 +151,34 @@ pub(super) fn set_row_widgets(item: >k::ListItem, widgets: Rc) { // Attach the actual row root whenever the cached widget bundle changes // Setup also uses this so GTK never keeps an empty placeholder child item.set_child(Some(&widgets.root)); - unsafe { - // SAFETY: gtk::ListItem stays on the GTK main thread and never crosses threads - // RowWidgets uses Rc and is only accessed from list factory callbacks on the - // main thread. Data is replaced in ensure_row_widgets when the row kind changes - // and otherwise kept to let GTK reuse the row widgets across scroll events - item.set_qdata(row_widgets_quark(), widgets); - } + ROW_WIDGETS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + if let Some((_, existing)) = entries + .iter_mut() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *item)) + { + *existing = widgets; + } else { + entries.push((item.downgrade(), widgets)); + } + // Keep a bounded fallback for unusual list-model churn before another cache access + if entries.len() > MAX_TRACKED_ROW_WIDGETS { + let excess = entries.len() - MAX_TRACKED_ROW_WIDGETS; + entries.drain(..excess); + } + }); } pub(super) fn get_row_widgets(item: >k::ListItem) -> Option> { - // SAFETY: The stable quark is written with Rc on the GTK main thread only - let stored = unsafe { item.qdata::>(row_widgets_quark()) }?; - // SAFETY: Gtk owns the qdata value while the list item remains alive - Some(unsafe { stored.as_ref().clone() }) + ROW_WIDGETS.with(|entries| { + let mut entries = entries.borrow_mut(); + entries.retain(|(weak, _)| weak.upgrade().is_some()); + entries + .iter() + .find(|(weak, _)| weak.upgrade().is_some_and(|current| current == *item)) + .map(|(_, widgets)| widgets.clone()) + }) } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/panel/actions.rs b/crates/unixnotis-center/src/ui/panel/actions.rs deleted file mode 100644 index 026656617..000000000 --- a/crates/unixnotis-center/src/ui/panel/actions.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Panel action signal wiring - -use std::cell::Cell; -use std::rc::Rc; -use std::time::Duration; - -use gtk::prelude::*; -use tracing::debug; - -use super::super::try_send_command; -use super::input::ClickCooldown; -use super::timing::CONTROL_CLICK_GUARD_MS; -use super::PanelWidgets; -use crate::control::UiCommand; - -pub(in crate::ui) fn connect_clear_button( - button: >k::Button, - command_tx: tokio::sync::mpsc::Sender, -) { - let clear_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); - button.connect_clicked(move |_| { - if !clear_gate.try_start() { - return; - } - - debug!("clear all clicked"); - // Non-blocking send avoids UI stalls on D-Bus backpressure - try_send_command(&command_tx, UiCommand::ClearAll); - }); -} - -pub(in crate::ui) fn connect_dnd_toggle( - panel: &PanelWidgets, - dnd_guard: Rc>, - command_tx: tokio::sync::mpsc::Sender, -) { - panel.dnd_toggle.connect_toggled(move |button| { - if dnd_guard.get() { - // Daemon-driven state sync should not echo another DND command - return; - } - - debug!(enabled = button.is_active(), "dnd toggled"); - try_send_command(&command_tx, UiCommand::SetDnd(button.is_active())); - }); -} - -pub(in crate::ui) fn connect_close_button( - panel: &PanelWidgets, - command_tx: tokio::sync::mpsc::Sender, -) { - let close_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); - panel.close_button.connect_clicked(move |_| { - if !close_gate.try_start() { - return; - } - - debug!("close panel clicked"); - try_send_command(&command_tx, UiCommand::ClosePanel); - }); -} - -#[cfg(test)] -#[path = "tests/actions.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/apply.rs b/crates/unixnotis-center/src/ui/panel/apply.rs new file mode 100644 index 000000000..b93142e70 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/apply.rs @@ -0,0 +1,27 @@ +//! Panel reload helpers for structure and action chrome + +use unixnotis_core::{PanelConfig, PanelSection}; + +use super::widgets::PanelWidgets; + +pub fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { + super::header::actions::apply_panel_action_config( + &panel.header.top, + &panel.header.actions, + config, + ); + super::header::actions::apply_clear_button_config(&panel.sections.clear_header_button, config); +} + +pub fn apply_reloaded_body_order(panel: &PanelWidgets, order: &[PanelSection]) { + super::body::apply_panel_body_section_order( + &panel.sections.body_stack, + &panel.sections.widget_revealer, + &panel.sections.notification_container, + order, + ); +} + +#[cfg(test)] +#[path = "tests/apply.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/autoclose.rs b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs similarity index 93% rename from crates/unixnotis-center/src/ui/panel/autoclose.rs rename to crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs index 01160256e..9bfe034ac 100644 --- a/crates/unixnotis-center/src/ui/panel/autoclose.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/autoclose.rs @@ -5,11 +5,10 @@ use std::sync::Arc; use gtk::prelude::*; -use super::super::hyprland; -use super::super::try_send_command; -use super::super::UiStateInit; -use super::PanelWidgets; use crate::control::UiCommand; +use crate::ui::{hyprland, try_send_command, UiStateInit}; + +use super::super::widgets::PanelWidgets; fn connect_blur_close( command_tx: tokio::sync::mpsc::Sender, diff --git a/crates/unixnotis-center/src/ui/panel/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/input.rs similarity index 75% rename from crates/unixnotis-center/src/ui/panel/input.rs rename to crates/unixnotis-center/src/ui/panel/behavior/input.rs index a872b37e0..2b96e31eb 100644 --- a/crates/unixnotis-center/src/ui/panel/input.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/input.rs @@ -14,8 +14,10 @@ use crate::control::UiEvent; #[derive(Clone)] pub(in crate::ui) struct ClickCooldown { - // One bit is enough because callers only care whether a new click may start + // Block state answers whether a new click may start blocked: Rc>, + // Generation keeps retired timeout callbacks from changing a newer window + generation: Rc>, duration: Duration, } @@ -23,6 +25,7 @@ impl ClickCooldown { pub(in crate::ui) fn new(duration: Duration) -> Self { Self { blocked: Rc::new(Cell::new(false)), + generation: Rc::new(Cell::new(0)), duration, } } @@ -31,14 +34,31 @@ impl ClickCooldown { if self.blocked.replace(true) { return false; } + let ticket = self.generation.get().wrapping_add(1); + self.generation.set(ticket); // GTK-side timeout keeps the guard tied to the main-thread widget lifecycle let blocked = self.blocked.clone(); + let generation = self.generation.clone(); glib::timeout_add_local_once(self.duration, move || { - blocked.set(false); + release_cooldown_if_current(&blocked, &generation, ticket); }); true } + + pub(in crate::ui) fn release(&self) { + // Semantic actions such as Escape may end a transition immediately + // Advancing the generation also retires the earlier timeout callback + self.generation.set(self.generation.get().wrapping_add(1)); + self.blocked.set(false); + } +} + +fn release_cooldown_if_current(blocked: &Cell, generation: &Cell, ticket: u64) { + // An older timeout must not release a newer cooldown window + if generation.get() == ticket { + blocked.set(false); + } } #[derive(Clone)] diff --git a/crates/unixnotis-center/src/ui/panel/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs similarity index 78% rename from crates/unixnotis-center/src/ui/panel/keyboard.rs rename to crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs index 3eaf60e64..bb00638fd 100644 --- a/crates/unixnotis-center/src/ui/panel/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/keyboard.rs @@ -1,11 +1,15 @@ //! Keyboard shortcut wiring for the panel +use std::cell::Cell; +use std::rc::Rc; + use gtk::gdk; use gtk::prelude::*; -use super::super::try_send_command; -use super::PanelWidgets; use crate::control::UiCommand; +use crate::ui::try_send_command; + +use super::super::widgets::PanelWidgets; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum KeyboardPanelAction { @@ -31,8 +35,13 @@ pub(super) fn keyboard_action_for( key: gdk::Key, state: gdk::ModifierType, search_open: bool, - search_has_focus: bool, + editable_has_focus: bool, ) -> KeyboardPanelAction { + if editable_has_focus { + // Editable widgets own typing and Escape while a draft or query has focus + return KeyboardPanelAction::Continue; + } + if key == gdk::Key::Escape { return if search_open { KeyboardPanelAction::CloseSearch @@ -55,11 +64,11 @@ pub(super) fn keyboard_action_for( return KeyboardPanelAction::ToggleWidgets; } - if !search_has_focus && key == gdk::Key::j { + if key == gdk::Key::j { return KeyboardPanelAction::ScrollDown; } - if !search_has_focus && key == gdk::Key::k { + if key == gdk::Key::k { return KeyboardPanelAction::ScrollUp; } @@ -69,12 +78,14 @@ pub(super) fn keyboard_action_for( pub(in crate::ui) fn connect_keyboard_shortcuts( panel: &PanelWidgets, command_tx: tokio::sync::mpsc::Sender, + scroll_user_generation: Rc>, ) { - let focus_toggle = panel.focus_toggle.clone(); - let search_toggle = panel.search_toggle.clone(); - let search_revealer = panel.search_revealer.clone(); - let search_entry = panel.search_entry.clone(); - let scroller = panel.scroller.clone(); + let focus_toggle = panel.header.actions.focus_toggle.clone(); + let search_toggle = panel.header.actions.search_toggle.clone(); + let search_revealer = panel.header.search.revealer.clone(); + let search_entry = panel.header.search.entry.clone(); + let scroller = panel.sections.scroller.clone(); + let window = panel.window.clone(); let key_controller = gtk::EventControllerKey::new(); key_controller.connect_key_pressed(move |_, key, _, state| { @@ -82,7 +93,7 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( key, state, search_revealer.reveals_child(), - search_entry.has_focus(), + editable_has_focus(&window), ); match action { KeyboardPanelAction::CloseSearch => { @@ -107,10 +118,12 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( gtk::glib::Propagation::Stop } KeyboardPanelAction::ScrollDown => { + scroll_user_generation.set(scroll_user_generation.get().wrapping_add(1)); nudge_scroller(&scroller, 72.0); gtk::glib::Propagation::Stop } KeyboardPanelAction::ScrollUp => { + scroll_user_generation.set(scroll_user_generation.get().wrapping_add(1)); nudge_scroller(&scroller, -72.0); gtk::glib::Propagation::Stop } @@ -124,6 +137,10 @@ pub(in crate::ui) fn connect_keyboard_shortcuts( #[path = "tests/keyboard.rs"] mod tests; +pub(in crate::ui) fn editable_has_focus(window: &impl IsA) -> bool { + gtk::prelude::RootExt::focus(window.as_ref()).is_some_and(|widget| widget.is::()) +} + fn reveal_and_focus_search( search_toggle: >k::ToggleButton, search_revealer: >k::Revealer, diff --git a/crates/unixnotis-center/src/ui/panel/behavior/mod.rs b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs new file mode 100644 index 000000000..1c15cb9b5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/behavior/mod.rs @@ -0,0 +1,6 @@ +//! Panel interaction behavior grouped away from widget construction + +pub(in crate::ui) mod autoclose; +pub(in crate::ui) mod input; +pub(in crate::ui) mod keyboard; +mod visibility; diff --git a/crates/unixnotis-center/src/ui/panel/tests/autoclose.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/autoclose.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/autoclose.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/autoclose.rs diff --git a/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs new file mode 100644 index 000000000..04482eb56 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/behavior/tests/input.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use super::{release_cooldown_if_current, ClickCooldown, LatestBoolEventGate}; +use crate::control::UiEvent; + +#[gtk::test] +fn click_cooldown_rejects_bursts_and_reopens_after_its_timeout() { + let guard = ClickCooldown::new(Duration::ZERO); + + assert!(guard.try_start()); + assert!(!guard.try_start()); + + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!(guard.try_start()); +} + +#[gtk::test] +fn click_cooldown_release_accepts_an_immediate_semantic_action() { + let guard = ClickCooldown::new(Duration::from_secs(1)); + + assert!(guard.try_start()); + guard.release(); + + assert!(guard.try_start()); +} + +#[gtk::test] +fn released_timeout_cannot_end_the_next_cooldown_early() { + let guard = ClickCooldown::new(Duration::ZERO); + assert!(guard.try_start()); + let retired_ticket = guard.generation.get(); + + guard.release(); + assert!(guard.try_start()); + let current_ticket = guard.generation.get(); + + release_cooldown_if_current(&guard.blocked, &guard.generation, retired_ticket); + assert!(!guard.try_start()); + release_cooldown_if_current(&guard.blocked, &guard.generation, current_ticket); + assert!(guard.try_start()); + + // Drain zero-duration sources so this test leaves no main-context work behind + drain_main_context(); +} + +#[gtk::test] +fn latest_bool_event_gate_sends_the_requested_state() { + let gate = LatestBoolEventGate::new(Duration::ZERO); + let (event_tx, event_rx) = async_channel::bounded(1); + + gate.request_widgets_collapsed(&event_tx, true); + drain_main_context(); + + assert!(matches!( + event_rx.try_recv(), + Ok(UiEvent::WidgetsCollapsed(true)) + )); +} + +#[gtk::test] +fn latest_bool_event_gate_coalesces_to_the_newest_state() { + let gate = LatestBoolEventGate::new(Duration::ZERO); + let (event_tx, event_rx) = async_channel::bounded(1); + + gate.request_widgets_collapsed(&event_tx, true); + gate.request_widgets_collapsed(&event_tx, false); + drain_main_context(); + + assert!(matches!( + event_rx.try_recv(), + Ok(UiEvent::WidgetsCollapsed(false)) + )); + assert!(event_rx.try_recv().is_err()); +} + +fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/keyboard.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs similarity index 60% rename from crates/unixnotis-center/src/ui/panel/tests/keyboard.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs index 938e86441..cc3fe5725 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/keyboard.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/tests/keyboard.rs @@ -1,6 +1,7 @@ use gtk::gdk; +use gtk::prelude::*; -use super::{keyboard_action_for, KeyboardPanelAction}; +use super::{editable_has_focus, keyboard_action_for, KeyboardPanelAction}; #[test] fn escape_closes_search_before_panel() { @@ -42,10 +43,14 @@ fn ctrl_w_toggles_widget_section() { keyboard_action_for(gdk::Key::w, gdk::ModifierType::CONTROL_MASK, false, false), KeyboardPanelAction::ToggleWidgets ); + assert_eq!( + keyboard_action_for(gdk::Key::w, gdk::ModifierType::empty(), false, false), + KeyboardPanelAction::Continue + ); } #[test] -fn vim_scroll_keys_do_not_steal_text_entry_input() { +fn vim_scroll_keys_do_not_steal_editable_input() { let state = gdk::ModifierType::empty(); assert_eq!( @@ -66,6 +71,24 @@ fn vim_scroll_keys_do_not_steal_text_entry_input() { ); } +#[test] +fn all_panel_shortcuts_continue_while_an_editable_has_focus() { + for (key, state) in [ + (gdk::Key::Escape, gdk::ModifierType::empty()), + (gdk::Key::slash, gdk::ModifierType::empty()), + (gdk::Key::j, gdk::ModifierType::empty()), + (gdk::Key::k, gdk::ModifierType::empty()), + (gdk::Key::f, gdk::ModifierType::CONTROL_MASK), + (gdk::Key::l, gdk::ModifierType::CONTROL_MASK), + (gdk::Key::w, gdk::ModifierType::CONTROL_MASK), + ] { + assert_eq!( + keyboard_action_for(key, state, true, true), + KeyboardPanelAction::Continue + ); + } +} + #[test] fn unrelated_keys_continue_to_gtk() { assert_eq!( @@ -73,3 +96,14 @@ fn unrelated_keys_continue_to_gtk() { KeyboardPanelAction::Continue ); } + +#[gtk::test] +fn noneditable_focus_does_not_suppress_panel_shortcuts() { + let window = gtk::Window::new(); + let button = gtk::Button::with_label("Focus target"); + window.set_child(Some(&button)); + window.set_visible(true); + button.grab_focus(); + + assert!(!editable_has_focus(&window)); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/tests/visibility.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/visibility.rs rename to crates/unixnotis-center/src/ui/panel/behavior/tests/visibility.rs diff --git a/crates/unixnotis-center/src/ui/panel/visibility.rs b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs similarity index 75% rename from crates/unixnotis-center/src/ui/panel/visibility.rs rename to crates/unixnotis-center/src/ui/panel/behavior/visibility.rs index ce8a4af65..a2301c940 100644 --- a/crates/unixnotis-center/src/ui/panel/visibility.rs +++ b/crates/unixnotis-center/src/ui/panel/behavior/visibility.rs @@ -12,57 +12,9 @@ use unixnotis_core::{PanelAction, PanelDebugLevel, PanelRequest}; use crate::control::UiCommand; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; -use super::super::{try_send_command, UiState}; +use crate::ui::{try_send_command, UiState}; impl UiState { - pub const fn panel_is_visible(&self) -> bool { - self.panel_visible - } - - pub(in crate::ui) const fn has_any_widgets(&self) -> bool { - self.volume.is_some() - || self.brightness.is_some() - || self.toggles.is_some() - || self.stats.is_some() - || self.cards.is_some() - || (self.media.is_some() && self.config.media.enabled) - } - - pub(in crate::ui) fn set_widgets_collapsed(&mut self, collapsed: bool) { - self.widgets_collapsed = collapsed; - if self.panel.focus_toggle.is_active() != collapsed { - // Mirror external collapse requests into the header toggle state - self.panel.focus_toggle.set_active(collapsed); - } - if self.panel.widget_revealer.reveals_child() == collapsed { - self.panel.widget_revealer.set_reveal_child(!collapsed); - } - self.list - .set_empty_layout(!collapsed && self.has_any_widgets()); - } - - pub(in crate::ui) fn update_state(&mut self, state: unixnotis_core::ControlState) { - // Avoid re-entrant DND toggles while applying daemon state - self.dnd_guard.set(true); - self.panel.dnd_toggle.set_active(state.dnd_enabled); - self.dnd_guard.set(false); - } - - pub(in crate::ui) fn refresh_counts(&mut self) { - if !self.panel_visible { - // Skip label updates while hidden to avoid unnecessary UI work - // Counts are refreshed on the next open to keep the header accurate - return; - } - // Header count always reflects total active + history entries - let total = self.list.total_count(); - if self.last_count == Some(total) { - return; - } - self.last_count = Some(total); - self.panel.header_count.set_text(&format!("{total}")); - } - pub(in crate::ui) fn apply_panel_request(&mut self, request: PanelRequest) { let requested_visibility = panel_visibility_for_action(self.panel_visible, request.action); // Request-driven changes always flow through set_visible for consistent side effects @@ -129,31 +81,49 @@ impl UiState { widget.update(&infos); } } + // Capture hidden mutations before flushing so one rebuild owns one idle callback + let rebuild_was_deferred = self.list_needs_rebuild(); + let hidden_mutation = self.notifications_changed_while_hidden; + self.notifications_changed_while_hidden = false; // Flush deferred list rebuilds once to avoid repeated background work - if self.list_needs_rebuild() { + if rebuild_was_deferred { // Apply any deferred list rebuilds once the panel becomes visible - self.list.flush_rebuild(); + self.flush_list_rebuild_with_policy(crate::ui::events::ScrollResetPolicy::Force); } // Resolve work-area margins before showing the window to avoid a layout shift // This prevents a first-frame resize when Hyprland publishes margins after open // Only hit the compositor once per open when the cache is empty // Keeps open latency stable while avoiding repeated IPC work if self.config.panel.respect_work_area && self.work_area.is_none() { - self.work_area = super::super::hyprland::reserved_work_area_sync( + self.work_area = crate::ui::hyprland::reserved_work_area_sync( self.config.panel.output.as_deref(), ); - super::apply_panel_config(&self.panel, &self.config, self.work_area); + crate::ui::panel::geometry::apply_panel_config( + &self.panel, + &self.config, + self.work_area, + ); } // Only show the window after geometry is correct to avoid visible jitter self.panel.window.set_visible(true); + if hidden_mutation && !rebuild_was_deferred { + crate::ui::events::reset_notification_scroll( + &self.panel.sections.scroller, + self.notification_rebuild_generation.clone(), + self.notification_rebuild_generation.get(), + self.scroll_user_generation.clone(), + self.scroll_user_generation.get(), + crate::ui::events::ScrollResetPolicy::Force, + ); + } // Refresh counts after pending updates land so header stays accurate self.refresh_counts(); // Run the first widget pass after the window is visible // This avoids leaving plugin-backed stats on the n/a placeholder until a later tick self.refresh_widgets(true); self.start_refresh_timer(); - let width = self.panel.window.allocated_width(); - let height = self.panel.window.allocated_height(); + let width = self.panel.window.width(); + let height = self.panel.window.height(); let message = format!("panel allocated size: {width}x{height}"); self.log_debug(PanelDebugLevel::Verbose, move || message); } else { @@ -162,16 +132,13 @@ impl UiState { // Hide first so any teardown work does not trigger visible reflow self.panel.window.set_visible(false); // Reset transient search UI so each open starts from the full notification list - if self.panel.search_toggle.is_active() { - // Programmatic close should not be treated as a user click - self.search_toggle_guard.set(true); - self.panel.search_toggle.set_active(false); - self.search_toggle_guard.set(false); - } - if !self.panel.search_entry.text().is_empty() { - // Clearing text also removes any active list filter - self.panel.search_entry.set_text(""); - } + crate::ui::panel::header::search::set_search_open( + &self.panel.header.actions.search_toggle, + &self.panel.header.search.revealer, + &self.panel.header.search.entry, + self.search_toggle_guard.as_ref(), + false, + ); // Disable watch-based polling when hidden to reduce background load if let Some(volume) = self.volume.as_ref() { volume.set_watch_active(false); diff --git a/crates/unixnotis-center/src/ui/panel/sections.rs b/crates/unixnotis-center/src/ui/panel/body.rs similarity index 89% rename from crates/unixnotis-center/src/ui/panel/sections.rs rename to crates/unixnotis-center/src/ui/panel/body.rs index 61f62a348..c2245f7ac 100644 --- a/crates/unixnotis-center/src/ui/panel/sections.rs +++ b/crates/unixnotis-center/src/ui/panel/body.rs @@ -1,4 +1,4 @@ -//! Panel widget stack and scroller construction +//! Panel body, widget stack, and notification list construction use gtk::prelude::*; use gtk::Align; @@ -7,27 +7,27 @@ use unixnotis_core::{ WidgetDensity, }; -use super::action_widgets::build_clear_button; +use super::header::actions::build_clear_button; pub const WIDGET_REVEAL_TRANSITION_MS: u64 = 180; -pub(super) struct PanelSectionWidgets { - pub(super) body_stack: gtk::Box, - pub(super) widget_revealer: gtk::Revealer, - pub(super) widget_stack: gtk::Box, - pub(super) quick_controls: gtk::Box, - pub(super) toggle_container: gtk::Box, - pub(super) stat_container: gtk::Box, - pub(super) card_container: gtk::Box, - pub(super) scroller: gtk::ScrolledWindow, - pub(super) notification_container: gtk::Box, - pub(super) notification_header_row: gtk::Box, - pub(super) notification_header: gtk::Label, - pub(super) clear_header_button: gtk::Button, - pub(super) toggle_section_header: gtk::Label, - pub(super) stat_section_header: gtk::Label, - pub(super) footer: gtk::Label, - pub(super) media_container: gtk::Box, +pub(in crate::ui) struct PanelSectionWidgets { + pub(in crate::ui) body_stack: gtk::Box, + pub(in crate::ui) widget_revealer: gtk::Revealer, + pub(in crate::ui) widget_stack: gtk::Box, + pub(in crate::ui) quick_controls: gtk::Box, + pub(in crate::ui) toggle_container: gtk::Box, + pub(in crate::ui) stat_container: gtk::Box, + pub(in crate::ui) card_container: gtk::Box, + pub(in crate::ui) scroller: gtk::ScrolledWindow, + pub(in crate::ui) notification_container: gtk::Box, + pub(in crate::ui) notification_header_row: gtk::Box, + pub(in crate::ui) notification_header: gtk::Label, + pub(in crate::ui) clear_header_button: gtk::Button, + pub(in crate::ui) toggle_section_header: gtk::Label, + pub(in crate::ui) stat_section_header: gtk::Label, + pub(in crate::ui) footer: gtk::Label, + pub(in crate::ui) media_container: gtk::Box, } pub(super) fn build_panel_sections( @@ -239,5 +239,5 @@ pub const fn notification_header_row_visible(config: &PanelConfig) -> bool { } #[cfg(test)] -#[path = "tests/sections.rs"] +#[path = "tests/body.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/build.rs b/crates/unixnotis-center/src/ui/panel/build.rs index 7379d48a9..83936f0fa 100644 --- a/crates/unixnotis-center/src/ui/panel/build.rs +++ b/crates/unixnotis-center/src/ui/panel/build.rs @@ -6,10 +6,10 @@ use gtk::prelude::*; use gtk4_layer_shell::{Layer, LayerShell}; use unixnotis_core::{css::hooks, Config}; +use super::body::build_panel_sections; use super::header::build_panel_header; use super::notice::build_reload_notice; -use super::sections::build_panel_sections; -use super::types::PanelWidgets; +use super::widgets::PanelWidgets; pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidgets { let window = gtk::ApplicationWindow::new(app); @@ -17,32 +17,28 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window.set_resizable(false); window.set_title(Some("UnixNotis Center")); window.add_css_class(hooks::panel_shell::WINDOW); - if let Some(settings) = gtk::Settings::default() { - // GTK global setting that controls whether scrollbars overlay content - // Enabled here to keep scrollbar behavior consistent across widgets - settings.set_property("gtk-overlay-scrolling", true); - } window.init_layer_shell(); window.set_namespace(Some("unixnotis-panel")); window.set_layer(Layer::Overlay); - super::layout::apply_anchor(&window, config.panel.anchor, config.panel.margin); + super::geometry::apply_anchor(&window, config.panel.anchor, config.panel.margin); window.set_exclusive_zone(0); - window.set_keyboard_mode(super::layout::map_keyboard_mode( + window.set_keyboard_mode(super::geometry::map_keyboard_mode( config.panel.keyboard_interactivity, )); let monitor = if let Some(output) = config.panel.output.as_ref() { // Named outputs fall back to the compositor default when the monitor disappears - super::monitor::find_monitor(output).or_else(super::monitor::default_monitor) + super::geometry::monitor::find_monitor(output) + .or_else(super::geometry::monitor::default_monitor) } else { - super::monitor::default_monitor() + super::geometry::monitor::default_monitor() }; if let Some(monitor) = monitor.as_ref() { window.set_monitor(Some(monitor)); } - let (width, height) = super::layout::resolve_panel_size(config, monitor.as_ref(), None); + let (width, height) = super::geometry::resolve_panel_size(config, monitor.as_ref(), None); // Default size guides the compositor while size request constrains GTK children window.set_default_size(width, height); if height > 0 { @@ -85,43 +81,17 @@ pub fn build_panel_widgets(app: >k::Application, config: &Config) -> PanelWidg window.set_child(Some(&overlay)); window.set_visible(false); - PanelWidgets { + let panel = PanelWidgets { window, surface: overlay, root, - body_stack: sections.body_stack, - widget_revealer: sections.widget_revealer, - widget_stack: sections.widget_stack, - quick_controls: sections.quick_controls, - toggle_container: sections.toggle_container, - stat_container: sections.stat_container, - card_container: sections.card_container, - scroller: sections.scroller, - media_container: sections.media_container, - search_revealer: header.search.revealer, - search_entry: header.search.entry, - search_toggle: header.actions.search_toggle, - header_title: header.title, - header_subtitle: header.subtitle, - header_count: header.count, - header_top: header.top, - header_action_row: header.action_row, - header_action_group: header.actions.group, - notification_container: sections.notification_container, - notification_header_row: sections.notification_header_row, - notification_header: sections.notification_header, - toggle_section_header: sections.toggle_section_header, - stat_section_header: sections.stat_section_header, - footer_label: sections.footer, - focus_toggle: header.actions.focus_toggle, - dnd_toggle: header.actions.dnd_toggle, - clear_action_button: header.actions.clear_button, - clear_header_button: sections.clear_header_button, - close_button: header.actions.close_button, - reload_notice_revealer: reload_notice.revealer, - reload_notice_shell: reload_notice.shell, - reload_notice_label: reload_notice.label, - } + header, + sections, + reload_notice, + }; + // Apply motion after construction so every long-lived revealer receives the same policy + super::motion::apply_reduced_motion(&panel, config.panel.reduced_motion); + panel } fn build_panel_body_chrome(body_stack: >k::Box) -> gtk::Box { diff --git a/crates/unixnotis-center/src/ui/panel/layout.rs b/crates/unixnotis-center/src/ui/panel/geometry/layout.rs similarity index 93% rename from crates/unixnotis-center/src/ui/panel/layout.rs rename to crates/unixnotis-center/src/ui/panel/geometry/layout.rs index 11d36151d..e8bd0b178 100644 --- a/crates/unixnotis-center/src/ui/panel/layout.rs +++ b/crates/unixnotis-center/src/ui/panel/geometry/layout.rs @@ -9,7 +9,8 @@ use unixnotis_core::{ Anchor, Config, Margins, PanelKeyboardInteractivity, PANEL_RUNTIME_WIDTH_MIN, }; -use super::types::PanelWidgets; +use super::super::widgets::PanelWidgets; +use super::monitor; // Keep panel width reasonable on narrow displays to avoid dominating screen real estate const PANEL_WIDTH_MONITOR_RATIO_CAP: f32 = 0.32; @@ -26,7 +27,7 @@ fn normalize_panel_width_request(width_request: i32) -> i32 { width_request.max(1) } -pub(super) fn resolve_panel_size( +pub(in crate::ui::panel) fn resolve_panel_size( config: &Config, monitor: Option<&gdk::Monitor>, reserved: Option, @@ -37,7 +38,11 @@ pub(super) fn resolve_panel_size( (width, height) } -pub(super) fn apply_anchor(window: &impl IsA, anchor: Anchor, margin: Margins) { +pub(in crate::ui::panel) fn apply_anchor( + window: &impl IsA, + anchor: Anchor, + margin: Margins, +) { for edge in [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left] { window.set_anchor(edge, false); } @@ -88,9 +93,9 @@ pub(super) fn apply_anchor(window: &impl IsA, anchor: Anchor, margi pub fn apply_panel_config(panel: &PanelWidgets, config: &Config, reserved: Option) { let monitor = if let Some(output) = config.panel.output.as_ref() { - super::monitor::find_monitor(output).or_else(super::monitor::default_monitor) + monitor::find_monitor(output).or_else(monitor::default_monitor) } else { - super::monitor::default_monitor() + monitor::default_monitor() }; if let Some(monitor) = monitor.as_ref() { panel.window.set_monitor(Some(monitor)); @@ -117,7 +122,9 @@ pub fn apply_panel_config(panel: &PanelWidgets, config: &Config, reserved: Optio // margins, so only the outer shell receives an exact width request } -pub(super) const fn map_keyboard_mode(mode: PanelKeyboardInteractivity) -> KeyboardMode { +pub(in crate::ui::panel) const fn map_keyboard_mode( + mode: PanelKeyboardInteractivity, +) -> KeyboardMode { match mode { PanelKeyboardInteractivity::None => KeyboardMode::None, PanelKeyboardInteractivity::OnDemand => KeyboardMode::OnDemand, diff --git a/crates/unixnotis-center/src/ui/panel/geometry/mod.rs b/crates/unixnotis-center/src/ui/panel/geometry/mod.rs new file mode 100644 index 000000000..f7a0a0237 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/geometry/mod.rs @@ -0,0 +1,7 @@ +//! Panel geometry module wiring + +mod layout; +pub(super) mod monitor; + +pub(super) use layout::{apply_anchor, map_keyboard_mode, resolve_panel_size}; +pub use layout::{apply_panel_config, requested_panel_width}; diff --git a/crates/unixnotis-center/src/ui/panel/monitor.rs b/crates/unixnotis-center/src/ui/panel/geometry/monitor.rs similarity index 94% rename from crates/unixnotis-center/src/ui/panel/monitor.rs rename to crates/unixnotis-center/src/ui/panel/geometry/monitor.rs index 2d500ef23..6678ce030 100644 --- a/crates/unixnotis-center/src/ui/panel/monitor.rs +++ b/crates/unixnotis-center/src/ui/panel/geometry/monitor.rs @@ -5,7 +5,7 @@ use gtk::gdk; use gtk::gdk::prelude::*; -pub(super) fn default_monitor() -> Option { +pub(in crate::ui::panel) fn default_monitor() -> Option { let display = gdk::Display::default()?; let monitors = display.monitors(); let mut best: Option = None; @@ -36,7 +36,7 @@ pub(super) fn default_monitor() -> Option { item.downcast::().ok() } -pub(super) fn find_monitor(output: &str) -> Option { +pub(in crate::ui::panel) fn find_monitor(output: &str) -> Option { let display = gdk::Display::default()?; let monitors = display.monitors(); for index in 0..monitors.n_items() { diff --git a/crates/unixnotis-center/src/ui/panel/tests/layout.rs b/crates/unixnotis-center/src/ui/panel/geometry/tests/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/panel/tests/layout.rs rename to crates/unixnotis-center/src/ui/panel/geometry/tests/layout.rs diff --git a/crates/unixnotis-center/src/ui/panel/action_widgets.rs b/crates/unixnotis-center/src/ui/panel/header/actions.rs similarity index 63% rename from crates/unixnotis-center/src/ui/panel/action_widgets.rs rename to crates/unixnotis-center/src/ui/panel/header/actions.rs index f47fba24a..8dce8aab3 100644 --- a/crates/unixnotis-center/src/ui/panel/action_widgets.rs +++ b/crates/unixnotis-center/src/ui/panel/header/actions.rs @@ -1,17 +1,32 @@ -//! Panel action row construction +//! Panel action construction and signal wiring + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; use gtk::prelude::*; +use tracing::debug; use unixnotis_core::{ - css::hooks, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelConfig, + css::hooks, DndMenuTrigger, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, + PanelConfig, }; -pub(super) struct PanelActionWidgets { - pub(super) group: gtk::Box, - pub(super) focus_toggle: gtk::ToggleButton, - pub(super) dnd_toggle: gtk::ToggleButton, - pub(super) clear_button: gtk::Button, - pub(super) search_toggle: gtk::ToggleButton, - pub(super) close_button: gtk::Button, +use super::super::widgets::PanelWidgets; +use crate::control::UiCommand; +use crate::ui::panel::behavior::input::ClickCooldown; +use crate::ui::try_send_command; + +const CONTROL_CLICK_GUARD_MS: u64 = 180; + +pub(in crate::ui) struct PanelActionWidgets { + pub(in crate::ui) group: gtk::Box, + pub(in crate::ui) dnd_group: gtk::Box, + pub(in crate::ui) focus_toggle: gtk::ToggleButton, + pub(in crate::ui) dnd_toggle: gtk::ToggleButton, + pub(in crate::ui) dnd_status: gtk::Label, + pub(in crate::ui) clear_button: gtk::Button, + pub(in crate::ui) search_toggle: gtk::ToggleButton, + pub(in crate::ui) close_button: gtk::Button, } pub(super) struct PanelActionArea { @@ -30,6 +45,15 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { let focus_toggle = build_toggle_action(hooks::panel_action::FOCUS, &config.focus_action); let dnd_toggle = build_toggle_action(hooks::panel_action::PRIMARY, &config.dnd_action); + set_dnd_duration_tooltip(&dnd_toggle, config); + let dnd_status = gtk::Label::new(None); + dnd_status.add_css_class(hooks::panel_action::LABEL); + dnd_status.set_visible(false); + + let dnd_group = gtk::Box::new(gtk::Orientation::Horizontal, 2); + // The duration menu opens from the DND control without adding another button + dnd_group.append(&dnd_toggle); + dnd_group.append(&dnd_status); let clear_button = build_button_action(hooks::panel_action::MUTED, &resolved_clear_action(config)); @@ -46,7 +70,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { append_ordered_actions( &action_primary, &focus_toggle, - &dnd_toggle, + &dnd_group, &clear_button, &search_toggle, &close_button, @@ -58,8 +82,10 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { row: actions, widgets: PanelActionWidgets { group: action_primary, + dnd_group, focus_toggle, dnd_toggle, + dnd_status, clear_button, search_toggle, close_button, @@ -67,7 +93,7 @@ pub(super) fn build_panel_actions(config: &PanelConfig) -> PanelActionArea { } } -pub(super) fn build_clear_button(config: &PanelConfig) -> gtk::Button { +pub(in crate::ui::panel) fn build_clear_button(config: &PanelConfig) -> gtk::Button { build_button_action(hooks::panel_action::MUTED, &resolved_clear_action(config)) } @@ -86,6 +112,7 @@ pub(in crate::ui::panel) fn apply_panel_action_config( hooks::panel_action::PRIMARY, &config.dnd_action, ); + set_dnd_duration_tooltip(&widgets.dnd_toggle, config); update_action_button( &widgets.clear_button, hooks::panel_action::MUTED, @@ -108,7 +135,7 @@ pub(in crate::ui::panel) fn apply_panel_action_config( append_ordered_actions( &widgets.group, &widgets.focus_toggle, - &widgets.dnd_toggle, + &widgets.dnd_group, &widgets.clear_button, &widgets.search_toggle, &widgets.close_button, @@ -142,6 +169,42 @@ fn build_toggle_action(role_class: &str, config: &PanelActionConfig) -> gtk::Tog button } +fn set_dnd_duration_tooltip(button: >k::ToggleButton, config: &PanelConfig) { + // Keep custom copy while documenting only the input paths that are actually active + let mut hints = Vec::new(); + if !config.dnd_menu_choices.is_empty() { + if config + .dnd_menu_triggers + .contains(&DndMenuTrigger::RightClick) + { + hints.push("right-click"); + } + if config + .dnd_menu_triggers + .contains(&DndMenuTrigger::LongPress) + { + hints.push("long-press"); + } + if config.dnd_menu_triggers.contains(&DndMenuTrigger::Keyboard) { + hints.push("Shift+F10"); + } + } + let duration_hint = if hints.is_empty() { + String::new() + } else { + format!("{} for a duration", hints.join(", ")) + }; + let base = &config.dnd_action.tooltip; + let tooltip = if base.is_empty() { + duration_hint + } else if duration_hint.is_empty() { + base.clone() + } else { + format!("{base}\n{duration_hint}") + }; + button.set_tooltip_text(Some(&tooltip)); +} + fn build_button_action(role_class: &str, config: &PanelActionConfig) -> gtk::Button { let button = gtk::Button::new(); // Plain buttons reuse the same shell so role classes stay the only visual difference @@ -214,7 +277,7 @@ fn configure_action_button(button: &impl IsA, role_class: &str, ico fn append_ordered_actions( group: >k::Box, focus_toggle: >k::ToggleButton, - dnd_toggle: >k::ToggleButton, + dnd_group: >k::Box, clear_button: >k::Button, search_toggle: >k::ToggleButton, close_button: >k::Button, @@ -224,7 +287,7 @@ fn append_ordered_actions( for action in order { let child: gtk::Widget = match action { PanelActionId::Widgets => focus_toggle.clone().upcast(), - PanelActionId::Dnd => dnd_toggle.clone().upcast(), + PanelActionId::Dnd => dnd_group.clone().upcast(), PanelActionId::Clear => clear_button.clone().upcast(), PanelActionId::Search => search_toggle.clone().upcast(), PanelActionId::Close => close_button.clone().upcast(), @@ -259,6 +322,70 @@ fn resolved_clear_action(config: &PanelConfig) -> PanelActionConfig { action } +pub(in crate::ui) fn connect_clear_button( + button: >k::Button, + command_tx: tokio::sync::mpsc::Sender, +) { + let clear_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); + button.connect_clicked(move |_| { + if !clear_gate.try_start() { + return; + } + + debug!("clear all clicked"); + // Non-blocking send avoids UI stalls on D-Bus backpressure + try_send_command(&command_tx, UiCommand::ClearAll); + }); +} + +pub(in crate::ui) fn connect_dnd_toggle( + panel: &PanelWidgets, + dnd_guard: Rc>, + command_tx: tokio::sync::mpsc::Sender, +) { + connect_dnd_button(&panel.header.actions.dnd_toggle, dnd_guard, command_tx); +} + +fn connect_dnd_button( + button: >k::ToggleButton, + dnd_guard: Rc>, + command_tx: tokio::sync::mpsc::Sender, +) { + button.connect_toggled(move |button| { + if dnd_guard.get() { + // Daemon-driven state sync should not echo another DND command + return; + } + + let requested = button.is_active(); + // Keep the durable daemon state visible until the command commits successfully + dnd_guard.set(true); + button.set_active(!requested); + dnd_guard.set(false); + debug!(enabled = requested, "dnd toggled"); + try_send_command(&command_tx, UiCommand::SetDnd(requested)); + }); +} + +pub(in crate::ui) fn connect_close_button( + panel: &PanelWidgets, + command_tx: tokio::sync::mpsc::Sender, +) { + let close_gate = ClickCooldown::new(Duration::from_millis(CONTROL_CLICK_GUARD_MS)); + panel.header.actions.close_button.connect_clicked(move |_| { + if !close_gate.try_start() { + return; + } + + debug!("close panel clicked"); + try_send_command(&command_tx, UiCommand::ClosePanel); + }); +} + +#[cfg(test)] +#[path = "tests/actions.rs"] +mod construction_tests; + #[cfg(test)] -#[path = "tests/action_widgets.rs"] -mod tests; +#[path = "tests/action_signals.rs"] +mod signal_tests; diff --git a/crates/unixnotis-center/src/ui/panel/header.rs b/crates/unixnotis-center/src/ui/panel/header/build.rs similarity index 79% rename from crates/unixnotis-center/src/ui/panel/header.rs rename to crates/unixnotis-center/src/ui/panel/header/build.rs index 79c0e5338..5a7925a30 100644 --- a/crates/unixnotis-center/src/ui/panel/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header/build.rs @@ -1,28 +1,14 @@ -//! Panel header construction +//! Panel header widget construction use gtk::prelude::*; use gtk::Align; use unixnotis_core::{css::hooks, PanelConfig}; -use super::action_widgets::{action_order_contains_close, build_panel_actions, PanelActionWidgets}; -use super::search_widgets::{build_panel_search, PanelSearchWidgets}; +use super::actions::{action_order_contains_close, build_panel_actions}; +use super::search::build_panel_search; +use super::widgets::PanelHeaderWidgets; -pub(super) struct PanelHeaderWidgets { - pub(super) root: gtk::Box, - pub(super) top: gtk::Box, - pub(super) action_row: gtk::Box, - pub(super) title: gtk::Label, - pub(super) subtitle: gtk::Label, - pub(super) count: gtk::Label, - pub(super) search: PanelSearchWidgets, - pub(super) actions: PanelActionWidgets, -} - -#[cfg(test)] -#[path = "tests/header.rs"] -mod tests; - -pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { +pub(in crate::ui::panel) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { let header = gtk::Box::new(gtk::Orientation::Vertical, 8); header.add_css_class(hooks::panel_shell::HEADER); @@ -74,6 +60,11 @@ pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { header.append(&action_area.row); let search = build_panel_search(config); + // Initial configuration must keep the toggle aligned with the visible search row + action_area + .widgets + .search_toggle + .set_active(search.revealer.reveals_child()); header.append(&search.revealer); PanelHeaderWidgets { @@ -87,3 +78,7 @@ pub(super) fn build_panel_header(config: &PanelConfig) -> PanelHeaderWidgets { actions: action_area.widgets, } } + +#[cfg(test)] +#[path = "tests/build.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/header/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/dnd.rs new file mode 100644 index 000000000..a0a27ac8e --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/dnd.rs @@ -0,0 +1,333 @@ +//! Timed Do Not Disturb menu and compact countdown formatting + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use chrono::{Days, Local, NaiveDate, NaiveTime, TimeZone, Utc}; +use gtk::prelude::*; +use unixnotis_core::{css::hooks, DndMenuChoice, DndMenuTrigger, PanelConfig}; + +use crate::control::UiCommand; +use crate::ui::try_send_command; + +// Context-menu keys use one small decision type so GTK behavior stays explicit +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DndMenuKeyAction { + Open, + Ignore, +} + +pub(in crate::ui) struct DndCountdown { + source: Option, + active: Rc>, +} + +pub(in crate::ui) struct DndDurationMenu { + // A manually parented popover needs an explicit owner to detach it before panel teardown + popover: gtk::Popover, + secondary_click: gtk::GestureClick, + long_press: gtk::GestureLongPress, + key_controller: gtk::EventControllerKey, + command_tx: tokio::sync::mpsc::Sender, +} + +impl Drop for DndDurationMenu { + fn drop(&mut self) { + // GTK does not automatically detach popovers added with set_parent + if self.popover.parent().is_some() { + self.popover.unparent(); + } + } +} + +impl DndCountdown { + fn remove_active_source(&mut self) { + if self.active.replace(false) { + // GLib removal is valid only while the callback still owns a live source + if let Some(source) = self.source.take() { + source.remove(); + } + } + } +} + +impl Drop for DndCountdown { + fn drop(&mut self) { + // Dropping panel state must not leave a callback retaining the countdown label + self.remove_active_source(); + } +} + +pub(in crate::ui) fn connect_dnd_menu( + dnd_toggle: >k::ToggleButton, + config: &PanelConfig, + command_tx: tokio::sync::mpsc::Sender, +) -> DndDurationMenu { + // The DND toggle owns this popover without adding a separate arrow button + let popover = gtk::Popover::new(); + popover.add_css_class(hooks::dnd_menu::ROOT); + // A flat edge aligns with the action row without GTK's detached-looking arrow notch + popover.set_has_arrow(false); + popover.set_autohide(true); + popover.set_parent(dnd_toggle); + let (secondary_click, long_press, key_controller) = + connect_dnd_menu_inputs(dnd_toggle, &popover); + let menu = DndDurationMenu { + popover, + secondary_click, + long_press, + key_controller, + command_tx, + }; + menu.apply_config(config); + menu +} + +impl DndDurationMenu { + pub(in crate::ui) fn apply_config(&self, config: &PanelConfig) { + self.popover.popdown(); + self.popover.set_child(Some(&build_choice_box( + &config.dnd_menu_choices, + &self.command_tx, + &self.popover, + ))); + + // Installed controllers can be disabled safely without replacing GTK ownership + let has_choices = !config.dnd_menu_choices.is_empty(); + set_controller_enabled( + &self.secondary_click, + has_choices + && config + .dnd_menu_triggers + .contains(&DndMenuTrigger::RightClick), + ); + set_controller_enabled( + &self.long_press, + has_choices + && config + .dnd_menu_triggers + .contains(&DndMenuTrigger::LongPress), + ); + set_controller_enabled( + &self.key_controller, + has_choices && config.dnd_menu_triggers.contains(&DndMenuTrigger::Keyboard), + ); + } +} + +fn build_choice_box( + choices: &[DndMenuChoice], + command_tx: &tokio::sync::mpsc::Sender, + popover: >k::Popover, +) -> gtk::Box { + let container = gtk::Box::new(gtk::Orientation::Vertical, 0); + container.add_css_class(hooks::dnd_menu::CONTENT); + + // A small heading explains the time choices without repeating DND state + let title = gtk::Label::new(Some("Pause notifications")); + title.set_xalign(0.0); + title.add_css_class(hooks::dnd_menu::TITLE); + container.append(&title); + + for choice in choices { + if matches!(choice, DndMenuChoice::Indefinite { .. }) { + // A real separator stays crisp without borrowing a button border + let separator = gtk::Separator::new(gtk::Orientation::Horizontal); + separator.add_css_class(hooks::dnd_menu::SEPARATOR); + container.append(&separator); + } + // Left-aligned rows scan faster than a stack of centered default buttons + let button = gtk::Button::with_label(choice.label()); + if let Some(label) = button.child().and_downcast::() { + label.set_xalign(0.0); + label.set_hexpand(true); + } + button.add_css_class(hooks::dnd_menu::CHOICE); + if matches!(choice, DndMenuChoice::Indefinite { .. }) { + // Indefinite mode is separated because it has no automatic resume time + button.add_css_class(hooks::dnd_menu::INDEFINITE); + } + connect_choice_button(&button, choice, command_tx, popover); + container.append(&button); + } + container +} + +fn connect_choice_button( + button: >k::Button, + choice: &DndMenuChoice, + command_tx: &tokio::sync::mpsc::Sender, + popover: >k::Popover, +) { + let choice = choice.clone(); + let command_tx = command_tx.clone(); + let menu = popover.downgrade(); + button.connect_clicked(move |_| { + match choice { + DndMenuChoice::Duration { minutes, .. } => { + // Sanitized minute values still use saturation at the clock boundary + let seconds = i64::from(minutes).saturating_mul(60); + let expires_at = Utc::now().timestamp().saturating_add(seconds); + try_send_command(&command_tx, UiCommand::SetDndUntil(expires_at)); + } + DndMenuChoice::Tomorrow { hour, minute, .. } => { + if let Some(expires_at) = next_day_deadline(u32::from(hour), u32::from(minute)) { + try_send_command(&command_tx, UiCommand::SetDndUntil(expires_at)); + } else { + // Config values stay out of logs because the stable failure category is enough + tracing::warn!("could not resolve configured next-day DND deadline"); + } + } + DndMenuChoice::Indefinite { .. } => { + // Indefinite enablement deliberately replaces any timed deadline + try_send_command(&command_tx, UiCommand::SetDnd(true)); + } + } + if let Some(menu) = menu.upgrade() { + menu.popdown(); + } + }); +} + +fn set_controller_enabled(controller: &impl IsA, enabled: bool) { + let phase = if enabled { + gtk::PropagationPhase::Bubble + } else { + gtk::PropagationPhase::None + }; + controller.set_propagation_phase(phase); +} + +fn connect_dnd_menu_inputs( + dnd_toggle: >k::ToggleButton, + popover: >k::Popover, +) -> ( + gtk::GestureClick, + gtk::GestureLongPress, + gtk::EventControllerKey, +) { + let secondary_click = gtk::GestureClick::new(); + // Secondary click keeps the primary click dedicated to immediate toggling + secondary_click.set_button(3); + let click_menu = popover.downgrade(); + secondary_click.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(menu) = click_menu.upgrade() { + menu.popup(); + } + }); + dnd_toggle.add_controller(secondary_click.clone()); + + let long_press = gtk::GestureLongPress::new(); + let press_menu = popover.downgrade(); + long_press.connect_pressed(move |gesture, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(menu) = press_menu.upgrade() { + menu.popup(); + } + }); + dnd_toggle.add_controller(long_press.clone()); + + let key_controller = gtk::EventControllerKey::new(); + let key_menu = popover.downgrade(); + key_controller.connect_key_pressed(move |_, key, _, modifiers| { + let Some(menu) = key_menu.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; + match dnd_menu_key_action(key, modifiers) { + DndMenuKeyAction::Open => { + menu.popup(); + gtk::glib::Propagation::Stop + } + DndMenuKeyAction::Ignore => gtk::glib::Propagation::Proceed, + } + }); + dnd_toggle.add_controller(key_controller.clone()); + (secondary_click, long_press, key_controller) +} + +fn dnd_menu_key_action(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> DndMenuKeyAction { + if key == gtk::gdk::Key::Menu + || (key == gtk::gdk::Key::F10 && modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK)) + { + DndMenuKeyAction::Open + } else { + DndMenuKeyAction::Ignore + } +} + +pub(in crate::ui) fn update_dnd_status(label: >k::Label, expires_at: i64) { + // One helper keeps immediate and timer-driven label updates identical + let text = format_dnd_remaining(expires_at, Utc::now().timestamp()); + label.set_visible(!text.is_empty()); + label.set_text(&text); +} + +pub(in crate::ui) fn start_dnd_countdown(label: >k::Label, expires_at: i64) -> DndCountdown { + // GTK owns the callback on its main context while UiState owns the source id + let label = label.clone(); + let active = Rc::new(Cell::new(true)); + let callback_active = active.clone(); + let source = gtk::glib::timeout_add_local(Duration::from_secs(30), move || { + update_dnd_status(&label, expires_at); + let flow = countdown_control_flow(expires_at, Utc::now().timestamp()); + if flow == gtk::glib::ControlFlow::Break { + // Mark the ID inactive before GLib destroys it after this callback + callback_active.set(false); + } + flow + }); + DndCountdown { + source: Some(source), + active, + } +} + +const fn countdown_control_flow(expires_at: i64, now: i64) -> gtk::glib::ControlFlow { + if expires_at <= now { + gtk::glib::ControlFlow::Break + } else { + gtk::glib::ControlFlow::Continue + } +} + +fn format_dnd_remaining(expires_at: i64, now: i64) -> String { + let remaining = expires_at.saturating_sub(now); + if remaining <= 0 { + return String::new(); + } + // Round upward so a positive remainder never appears as zero minutes + let minutes = (remaining.saturating_add(59)) / 60; + if minutes < 60 { + return format!("· {minutes}m"); + } + let hours = minutes / 60; + let trailing_minutes = minutes % 60; + if trailing_minutes == 0 { + format!("· {hours}h") + } else { + format!("· {hours}h {trailing_minutes}m") + } +} + +fn next_day_deadline(hour: u32, minute: u32) -> Option { + let now = Local::now(); + // Construct the local clock value separately from the next calendar date + let local_time = NaiveTime::from_hms_opt(hour, minute, 0)?; + let date = tomorrow_date(now.date_naive())?; + match Local.from_local_datetime(&date.and_time(local_time)) { + chrono::LocalResult::Single(value) => Some(value.timestamp()), + // The earliest occurrence is sufficient because the whole date is in the future + chrono::LocalResult::Ambiguous(first, _) => Some(first.timestamp()), + chrono::LocalResult::None => None, + } +} + +const fn tomorrow_date(today: NaiveDate) -> Option { + today.checked_add_days(Days::new(1)) +} + +#[cfg(test)] +#[path = "tests/dnd.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/header/mod.rs b/crates/unixnotis-center/src/ui/panel/header/mod.rs new file mode 100644 index 000000000..1acf1b640 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/mod.rs @@ -0,0 +1,10 @@ +//! Panel header component wiring + +pub(in crate::ui) mod actions; +mod build; +pub(in crate::ui) mod dnd; +pub(in crate::ui) mod search; +mod widgets; + +pub(super) use build::build_panel_header; +pub(in crate::ui) use widgets::PanelHeaderWidgets; diff --git a/crates/unixnotis-center/src/ui/panel/header/search.rs b/crates/unixnotis-center/src/ui/panel/header/search.rs new file mode 100644 index 000000000..a0a64fcee --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/search.rs @@ -0,0 +1,274 @@ +//! Panel search construction, filtering, and reveal wiring + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use async_channel::TrySendError; +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelConfig}; + +use super::super::body::WIDGET_REVEAL_TRANSITION_MS; +use crate::control::UiEvent; +use crate::ui::panel::behavior::input::{ClickCooldown, LatestBoolEventGate}; + +pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; +const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; + +pub(in crate::ui) struct PanelSearchWidgets { + pub(in crate::ui) revealer: gtk::Revealer, + pub(in crate::ui) entry: gtk::SearchEntry, + pub(in crate::ui) magnifier: gtk::Image, + pub(in crate::ui) clear_button: gtk::Button, +} + +pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { + let search_shell = gtk::Box::new(gtk::Orientation::Horizontal, 6); + search_shell.add_css_class(hooks::panel_shell::SEARCH_SHELL); + search_shell.set_hexpand(true); + + let leading_accent = gtk::Box::new(gtk::Orientation::Vertical, 0); + leading_accent.add_css_class(hooks::panel_shell::SEARCH_ACCENT); + leading_accent.add_css_class(hooks::panel_shell::TICK_TOP_LEFT); + + let star_accent = gtk::Label::new(Some("*")); + star_accent.add_css_class(hooks::panel_shell::SEARCH_STAR); + + let magnifier = gtk::Image::from_icon_name(&config.search_magnifier_icon); + magnifier.add_css_class(hooks::panel_shell::SEARCH_MAGNIFIER); + magnifier.set_accessible_role(gtk::AccessibleRole::Presentation); + + let search_entry = gtk::SearchEntry::new(); + search_entry.add_css_class(hooks::panel_shell::SEARCH); + // Native icons have no public child or dedicated CSS node, so owned siblings replace them + search_entry.add_css_class(hooks::panel_shell::SEARCH_OWNED_ICONS); + // Placeholder text keeps the intent obvious before the first query + search_entry.set_placeholder_text(Some(&config.search_placeholder)); + search_entry.set_hexpand(true); + search_entry.set_tooltip_text(Some("Type to filter notifications")); + + let clear_button = gtk::Button::from_icon_name("edit-clear-symbolic"); + clear_button.add_css_class(hooks::panel_shell::SEARCH_CLEAR); + clear_button.set_tooltip_text(Some("Clear search")); + clear_button.set_visible(false); + let clear_entry = search_entry.clone(); + clear_button.connect_clicked(move |_| clear_entry.set_text("")); + let visible_clear = clear_button.clone(); + search_entry.connect_changed(move |entry| { + // The clear action exists only while a query can be removed + visible_clear.set_visible(!entry.text().is_empty()); + }); + + search_shell.append(&leading_accent); + search_shell.append(&magnifier); + search_shell.append(&search_entry); + search_shell.append(&clear_button); + search_shell.append(&star_accent); + + let search_revealer = gtk::Revealer::new(); + search_revealer.add_css_class(hooks::panel_shell::SEARCH_REVEALER); + // Slide-down matches the rest of the panel reveal motion + search_revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + search_revealer.set_transition_duration(SEARCH_REVEAL_TRANSITION_MS as u32); + // Keep search hidden until the user asks for it so notifications keep the space + search_revealer.set_reveal_child(config.search_visible); + search_revealer.set_child(Some(&search_shell)); + + PanelSearchWidgets { + revealer: search_revealer, + entry: search_entry, + magnifier, + clear_button, + } +} + +pub(in crate::ui) fn connect_widget_collapse_toggle( + focus_toggle: >k::ToggleButton, + widget_revealer: >k::Revealer, + event_tx: async_channel::Sender, +) { + let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); + let collapse_click_gate = + ClickCooldown::new(Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS)); + let accepted_collapsed = Rc::new(Cell::new(false)); + // Restore guard prevents a rejected click rollback from re-entering this handler + let collapse_restore = Rc::new(Cell::new(false)); + let collapse_revealer = widget_revealer.clone(); + + focus_toggle.connect_toggled(move |button| { + if collapse_restore.replace(false) { + return; + } + + let collapsed = button.is_active(); + // Ignore clicks while the previous reveal animation is still changing layout + if !try_start_reveal_transition(&collapse_click_gate, &collapse_revealer) { + let accepted = accepted_collapsed.get(); + if collapsed != accepted { + // Roll back only the rejected edge so the UI mirrors the running transition + collapse_restore.set(true); + button.set_active(accepted); + } + return; + } + + accepted_collapsed.set(collapsed); + hold_button_for_reveal_transition(button, &collapse_revealer); + collapse_gate.request_widgets_collapsed(&event_tx, collapsed); + }); +} + +pub(in crate::ui) fn connect_filter_entry( + search_entry: >k::SearchEntry, + event_tx: async_channel::Sender, +) { + // SearchChanged covers typing, clear actions, and programmatic text resets + search_entry.connect_search_changed(move |entry| { + send_filter_event(&event_tx, entry.text().to_string()); + }); +} + +pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filter: String) { + let event = UiEvent::FilterChanged(filter); + match event_tx.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(event)) => { + // Search changes are small and should retry instead of disappearing under bursts + let event_tx = event_tx.clone(); + gtk::glib::MainContext::default().spawn_local(async move { + let _ = event_tx.send(event).await; + }); + } + Err(TrySendError::Closed(_)) => {} // A closed UI channel means shutdown already owns the pending filter state + } +} + +pub(in crate::ui) fn set_search_open( + search_toggle: >k::ToggleButton, + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, + search_toggle_guard: &Cell, + open: bool, +) { + // Guarded changes still pass through the signal handler when the toggle changes + let previous_guard = search_toggle_guard.replace(true); + search_toggle.set_active(open); + // Nested callers retain the guard state owned by the outer operation + search_toggle_guard.set(previous_guard); + + // Apply directly as well because GTK emits no signal when the toggle already matches + apply_search_open_state(search_revealer, search_entry, open); +} + +pub(in crate::ui) fn connect_search_toggle( + search_toggle: >k::ToggleButton, + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, + search_toggle_guard: Rc>, +) { + let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); + // Programmatic rollback must not be mistaken for a fresh user click + let search_restore = Rc::new(Cell::new(false)); + let toggled_revealer = search_revealer.clone(); + let toggled_entry = search_entry.clone(); + + // A weak reference avoids a signal cycle between the entry and toggle + let stop_toggle = search_toggle.downgrade(); + let stop_revealer = search_revealer.clone(); + let stop_entry = search_entry.clone(); + let stop_click_gate = search_click_gate.clone(); + let stop_guard = search_toggle_guard.clone(); + search_entry.connect_stop_search(move |_| { + // Escape is a semantic close and should not wait for the reveal click cooldown + stop_click_gate.release(); + if let Some(toggle) = stop_toggle.upgrade() { + set_search_open( + &toggle, + &stop_revealer, + &stop_entry, + stop_guard.as_ref(), + false, + ); + } else { + // The entry may briefly outlive its toggle during GTK teardown + apply_search_open_state(&stop_revealer, &stop_entry, false); + } + }); + + search_toggle.connect_toggled(move |button| { + if search_restore.replace(false) { + return; + } + + let reveal = button.is_active(); + if search_toggle_guard.get() { + // Programmatic changes must keep every search widget on the same state + search_click_gate.release(); + apply_search_open_state(&toggled_revealer, &toggled_entry, reveal); + return; + } + + if !try_start_reveal_transition(&search_click_gate, &toggled_revealer) { + // The revealer records the last accepted transition target + let accepted = toggled_revealer.reveals_child(); + if reveal != accepted { + // Keep the visual toggle synced with the accepted revealer state + search_restore.set(true); + button.set_active(accepted); + } + return; + } + + hold_button_for_reveal_transition(button, &toggled_revealer); + apply_search_open_state(&toggled_revealer, &toggled_entry, reveal); + if reveal { + // Selecting existing text makes the next query replace it immediately + toggled_entry.grab_focus(); + toggled_entry.select_region(0, i32::MAX); + } + }); +} + +fn try_start_reveal_transition(gate: &ClickCooldown, revealer: >k::Revealer) -> bool { + if revealer.transition_duration() == 0 { + // Immediate transitions have no in-flight layout window to guard + gate.release(); + return true; + } + + gate.try_start() +} + +fn hold_button_for_reveal_transition(button: >k::ToggleButton, revealer: >k::Revealer) { + let duration_ms = revealer.transition_duration(); + if duration_ms == 0 { + return; + } + + // The control is held only for the transition duration currently applied to its revealer + button.set_sensitive(false); + let button_enable = button.clone(); + gtk::glib::timeout_add_local_once(Duration::from_millis(u64::from(duration_ms)), move || { + button_enable.set_sensitive(true); + }); +} + +fn apply_search_open_state( + search_revealer: >k::Revealer, + search_entry: >k::SearchEntry, + open: bool, +) { + search_revealer.set_reveal_child(open); + if !open && !search_entry.text().is_empty() { + // Closing search restores the full notification list + search_entry.set_text(""); + } +} + +#[cfg(test)] +#[path = "tests/search.rs"] +mod construction_tests; + +#[cfg(test)] +#[path = "tests/search_signals.rs"] +mod signal_tests; diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/action_signals.rs b/crates/unixnotis-center/src/ui/panel/header/tests/action_signals.rs new file mode 100644 index 000000000..18f3f1b41 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/tests/action_signals.rs @@ -0,0 +1,34 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; + +use super::{connect_clear_button, connect_dnd_button}; +use crate::control::UiCommand; + +#[gtk::test] +fn clear_button_sends_once_while_click_guard_is_active() { + let button = gtk::Button::new(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + connect_clear_button(&button, command_tx); + + button.emit_clicked(); + button.emit_clicked(); + + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::ClearAll))); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn dnd_toggle_waits_for_daemon_state_before_changing_visual_state() { + let button = gtk::ToggleButton::new(); + let guard = Rc::new(Cell::new(false)); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + connect_dnd_button(&button, guard, command_tx); + + button.set_active(true); + + assert!(!button.is_active()); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); + assert!(command_rx.try_recv().is_err()); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/action_widgets.rs b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs similarity index 78% rename from crates/unixnotis-center/src/ui/panel/tests/action_widgets.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/actions.rs index 38b08ee29..083268b8d 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/action_widgets.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/actions.rs @@ -91,3 +91,29 @@ fn apply_panel_action_config_moves_close_between_header_and_action_group() { assert!(child_with_class(&actions.widgets.group, hooks::panel_action::CLOSE).is_none()); assert!(child_with_class(&header_top, hooks::panel_action::CLOSE).is_some()); } + +#[gtk::test] +fn dnd_duration_menu_does_not_add_a_standalone_arrow_button() { + let actions = build_panel_actions(&PanelConfig::default()); + let toggle = actions + .widgets + .dnd_group + .first_child() + .expect("DND group should contain its toggle"); + let status = toggle + .next_sibling() + .expect("DND group should contain its countdown label"); + + assert_eq!(toggle, actions.widgets.dnd_toggle); + assert_eq!(status, actions.widgets.dnd_status); + assert!(status.next_sibling().is_none()); + assert!(actions + .widgets + .dnd_toggle + .tooltip_text() + .is_some_and(|text| { + text.contains("right-click") + && !text.contains("long-press") + && !text.contains("Shift+F10") + })); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/header.rs b/crates/unixnotis-center/src/ui/panel/header/tests/build.rs similarity index 60% rename from crates/unixnotis-center/src/ui/panel/tests/header.rs rename to crates/unixnotis-center/src/ui/panel/header/tests/build.rs index bd9e5abb3..89f457ea1 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/header.rs +++ b/crates/unixnotis-center/src/ui/panel/header/tests/build.rs @@ -40,3 +40,32 @@ fn build_panel_header_places_explicit_close_inside_action_group() { assert!(child_with_class(&header.top, hooks::panel_action::CLOSE).is_none()); assert!(child_with_class(&header.actions.group, hooks::panel_action::CLOSE).is_some()); } + +#[gtk::test] +fn visible_search_configuration_activates_toggle_and_revealer_together() { + let config = PanelConfig { + search_visible: true, + ..PanelConfig::default() + }; + + let header = build_panel_header(&config); + + assert!(header.actions.search_toggle.is_active()); + assert!(header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn subtitle_visibility_matches_whether_configured_copy_is_present() { + let visible_config = PanelConfig { + subtitle: "Live state".to_string(), + ..PanelConfig::default() + }; + let visible_header = build_panel_header(&visible_config); + assert!(visible_header.subtitle.is_visible()); + + let hidden_header = build_panel_header(&PanelConfig { + subtitle: String::new(), + ..PanelConfig::default() + }); + assert!(!hidden_header.subtitle.is_visible()); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs new file mode 100644 index 000000000..39bf3c4ee --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/tests/dnd.rs @@ -0,0 +1,304 @@ +use std::cell::Cell; +use std::rc::Rc; + +use chrono::{Local, NaiveDate, TimeZone, Timelike, Utc}; +use gtk::prelude::*; + +use super::{ + connect_dnd_menu, countdown_control_flow, dnd_menu_key_action, format_dnd_remaining, + next_day_deadline, tomorrow_date, update_dnd_status, DndCountdown, DndMenuKeyAction, +}; +use crate::control::UiCommand; +use unixnotis_core::{DndMenuChoice, DndMenuTrigger, PanelConfig}; + +#[test] +fn remaining_time_is_hidden_after_expiry_and_rounded_up_before_it() { + assert_eq!(format_dnd_remaining(100, 100), ""); + assert_eq!(format_dnd_remaining(99, 100), ""); + assert_eq!(format_dnd_remaining(101, 100), "· 1m"); + assert_eq!(format_dnd_remaining(100 + 47 * 60, 100), "· 47m"); +} + +#[test] +fn remaining_time_keeps_hours_compact_without_losing_partial_hour() { + assert_eq!(format_dnd_remaining(100 + 60 * 60, 100), "· 1h"); + assert_eq!( + format_dnd_remaining(100 + 2 * 60 * 60 + 5 * 60, 100), + "· 2h 5m" + ); +} + +#[test] +fn next_day_choice_uses_the_next_local_calendar_date() { + let today = NaiveDate::from_ymd_opt(2026, 7, 18).expect("valid date"); + + assert_eq!(tomorrow_date(today), NaiveDate::from_ymd_opt(2026, 7, 19)); + assert!(next_day_deadline(24, 0).is_none()); + assert!(next_day_deadline(8, 60).is_none()); +} + +#[test] +fn countdown_stops_at_the_deadline_and_continues_only_while_future() { + assert_eq!( + countdown_control_flow(100, 99), + gtk::glib::ControlFlow::Continue + ); + assert_eq!( + countdown_control_flow(100, 100), + gtk::glib::ControlFlow::Break + ); + assert_eq!( + countdown_control_flow(100, 101), + gtk::glib::ControlFlow::Break + ); +} + +#[test] +fn duration_menu_accepts_standard_keyboard_context_actions_only() { + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::Menu, gtk::gdk::ModifierType::empty()), + DndMenuKeyAction::Open + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::F10, gtk::gdk::ModifierType::SHIFT_MASK), + DndMenuKeyAction::Open + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::F10, gtk::gdk::ModifierType::empty()), + DndMenuKeyAction::Ignore + ); + assert_eq!( + dnd_menu_key_action(gtk::gdk::Key::space, gtk::gdk::ModifierType::SHIFT_MASK), + DndMenuKeyAction::Ignore + ); +} + +#[gtk::test] +fn default_duration_menu_enables_only_right_click_and_keeps_stock_choices() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8); + let config = PanelConfig::default(); + + let menu_owner = connect_dnd_menu(&toggle, &config, command_tx); + + let popover = attached_popover(&toggle); + assert!(popover.is_autohide()); + assert!(!popover.has_arrow()); + assert!(popover.has_css_class("unixnotis-dnd-menu")); + assert!(popover + .child() + .is_some_and(|child| child.has_css_class("unixnotis-dnd-menu-content"))); + assert_eq!( + menu_buttons(&popover) + .iter() + .filter_map(gtk::Button::label) + .collect::>(), + vec![ + "30 minutes", + "1 hour", + "2 hours", + "Until tomorrow morning", + "Indefinitely", + ] + ); + assert!(menu_buttons(&popover) + .iter() + .all(|button| button.has_css_class("unixnotis-dnd-menu-choice"))); + assert!(!menu_buttons(&popover)[3].has_css_class("unixnotis-dnd-menu-choice-indefinite")); + assert!(menu_buttons(&popover)[4].has_css_class("unixnotis-dnd-menu-choice-indefinite")); + assert!(menu_separator(&popover).has_css_class("unixnotis-dnd-menu-separator")); + + assert_eq!(menu_owner.secondary_click.button(), 3); + assert_eq!( + menu_owner.secondary_click.propagation_phase(), + gtk::PropagationPhase::Bubble + ); + assert_eq!( + menu_owner.long_press.propagation_phase(), + gtk::PropagationPhase::None + ); + assert_eq!( + menu_owner.key_controller.propagation_phase(), + gtk::PropagationPhase::None + ); +} + +#[gtk::test] +fn duration_menu_applies_custom_inputs_and_choices_without_reconnecting() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(8); + let menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); + let config = PanelConfig { + dnd_menu_triggers: vec![DndMenuTrigger::LongPress, DndMenuTrigger::Keyboard], + dnd_menu_choices: vec![DndMenuChoice::Duration { + label: "Focus block".to_string(), + minutes: 45, + }], + ..PanelConfig::default() + }; + + menu_owner.apply_config(&config); + + assert_eq!( + menu_buttons(&menu_owner.popover) + .iter() + .filter_map(gtk::Button::label) + .collect::>(), + vec!["Focus block"] + ); + assert_eq!( + menu_owner.secondary_click.propagation_phase(), + gtk::PropagationPhase::None + ); + assert_eq!( + menu_owner.long_press.propagation_phase(), + gtk::PropagationPhase::Bubble + ); + assert_eq!( + menu_owner.key_controller.propagation_phase(), + gtk::PropagationPhase::Bubble + ); +} + +#[gtk::test] +fn dropping_duration_menu_owner_detaches_the_manually_parented_popover() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); + let popover = menu_owner.popover.clone(); + + assert_eq!( + popover.parent().as_ref(), + Some(toggle.upcast_ref::()) + ); + drop(menu_owner); + assert!(popover.parent().is_none()); + drop(toggle); +} + +#[gtk::test] +fn duration_menu_buttons_send_their_exact_deadlines_and_indefinite_state() { + let toggle = gtk::ToggleButton::new(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(8); + let _menu_owner = connect_dnd_menu(&toggle, &PanelConfig::default(), command_tx); + let buttons = menu_buttons(&attached_popover(&toggle)); + + for (minutes, button) in [30_i64, 60, 120].iter().zip(&buttons[..3]) { + let seconds = minutes.saturating_mul(60); + let before = Utc::now().timestamp(); + button.emit_clicked(); + let after = Utc::now().timestamp(); + let UiCommand::SetDndUntil(expires_at) = command_rx + .try_recv() + .expect("duration command should queue") + else { + panic!("expected timed DND command"); + }; + assert!(expires_at >= before.saturating_add(seconds)); + assert!(expires_at <= after.saturating_add(seconds)); + } + + let before_morning = Local::now(); + buttons[3].emit_clicked(); + let UiCommand::SetDndUntil(morning_deadline) = + command_rx.try_recv().expect("morning command should queue") + else { + panic!("expected morning DND command"); + }; + let morning = Local + .timestamp_opt(morning_deadline, 0) + .single() + .expect("morning deadline should map to one local time"); + assert!(morning.date_naive() > before_morning.date_naive()); + assert_eq!(morning.hour(), 8); + assert_eq!(morning.minute(), 0); + assert_eq!(morning.second(), 0); + + buttons[4].emit_clicked(); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn dnd_status_updates_text_and_visibility_together() { + let label = gtk::Label::new(Some("stale")); + + update_dnd_status(&label, Utc::now().timestamp().saturating_add(60)); + assert!(label.is_visible()); + assert_eq!(label.text(), "· 1m"); + + update_dnd_status(&label, Utc::now().timestamp().saturating_sub(1)); + assert!(!label.is_visible()); + assert!(label.text().is_empty()); +} + +#[gtk::test] +fn dropping_countdown_removes_its_live_source() { + let callback_runs = Rc::new(Cell::new(0)); + let countdown = test_countdown(callback_runs.clone()); + + drop(countdown); + drain_main_context(); + + assert_eq!(callback_runs.get(), 0); +} + +fn test_countdown(callback_runs: Rc>) -> DndCountdown { + let source = gtk::glib::idle_add_local(move || { + callback_runs.set(callback_runs.get() + 1); + gtk::glib::ControlFlow::Break + }); + DndCountdown { + source: Some(source), + active: Rc::new(Cell::new(true)), + } +} + +fn drain_main_context() { + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } +} + +fn attached_popover(toggle: >k::ToggleButton) -> gtk::Popover { + let mut child = toggle.first_child(); + while let Some(widget) = child { + if let Ok(popover) = widget.clone().downcast::() { + return popover; + } + child = widget.next_sibling(); + } + panic!("DND toggle should own its duration popover"); +} + +fn menu_buttons(popover: >k::Popover) -> Vec { + let choices = popover + .child() + .and_then(|child| child.downcast::().ok()) + .expect("DND popover should contain its choice box"); + let mut buttons = Vec::new(); + let mut child = choices.first_child(); + while let Some(widget) = child { + if let Ok(button) = widget.clone().downcast::() { + buttons.push(button); + } + child = widget.next_sibling(); + } + buttons +} + +fn menu_separator(popover: >k::Popover) -> gtk::Separator { + let choices = popover + .child() + .and_then(|child| child.downcast::().ok()) + .expect("DND popover should contain its choice box"); + let mut child = choices.first_child(); + while let Some(widget) = child { + if let Ok(separator) = widget.clone().downcast::() { + return separator; + } + child = widget.next_sibling(); + } + panic!("DND popover should separate the indefinite choice"); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search.rs new file mode 100644 index 000000000..f48b475e0 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search.rs @@ -0,0 +1,49 @@ +use gtk::prelude::*; +use unixnotis_core::{css::hooks, PanelConfig}; + +use super::{build_panel_search, SEARCH_REVEAL_TRANSITION_MS}; + +#[gtk::test] +fn search_widget_applies_visibility_copy_and_transition_policy() { + let config = PanelConfig { + search_visible: true, + search_placeholder: "Find alerts".to_string(), + search_magnifier_icon: "edit-find-symbolic".to_string(), + ..PanelConfig::default() + }; + + let search = build_panel_search(&config); + + assert!(search.revealer.reveals_child()); + assert_eq!( + search.revealer.transition_duration(), + u32::try_from(SEARCH_REVEAL_TRANSITION_MS).expect("transition fits u32") + ); + assert_eq!( + search.entry.placeholder_text().as_deref(), + Some("Find alerts") + ); + assert!(search + .magnifier + .has_css_class(hooks::panel_shell::SEARCH_MAGNIFIER)); + assert_eq!( + search.magnifier.icon_name().as_deref(), + Some("edit-find-symbolic") + ); + assert!(search + .entry + .has_css_class(hooks::panel_shell::SEARCH_OWNED_ICONS)); + assert!(!search.clear_button.get_visible()); +} + +#[gtk::test] +fn search_clear_action_tracks_and_removes_the_current_query() { + let search = build_panel_search(&PanelConfig::default()); + + search.entry.set_text("urgent"); + assert!(search.clear_button.get_visible()); + + search.clear_button.emit_clicked(); + assert!(search.entry.text().is_empty()); + assert!(!search.clear_button.get_visible()); +} diff --git a/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs new file mode 100644 index 000000000..c3c6d14d6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/tests/search_signals.rs @@ -0,0 +1,222 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk::prelude::*; + +use super::{ + connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, send_filter_event, + set_search_open, +}; +use crate::control::UiEvent; + +#[test] +fn filter_event_sends_exact_query_without_waiting() { + let (event_tx, event_rx) = async_channel::bounded(1); + + send_filter_event(&event_tx, "terminal".to_string()); + + let event = event_rx.try_recv().expect("filter event should be queued"); + assert!(matches!(event, UiEvent::FilterChanged(query) if query == "terminal")); +} + +#[test] +fn filter_event_ignores_closed_channel() { + let (event_tx, event_rx) = async_channel::bounded(1); + drop(event_rx); + + send_filter_event(&event_tx, "ignored".to_string()); +} + +#[gtk::test] +fn stop_search_closes_revealer_and_clears_filter_immediately() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + revealer.set_child(Some(&entry)); + let (event_tx, event_rx) = async_channel::bounded(4); + connect_filter_entry(&entry, event_tx); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + entry.set_text("urgent"); + assert_eq!(next_filter(&event_rx), "urgent"); + + entry.emit_stop_search(); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); + assert_eq!(next_filter(&event_rx), ""); +} + +#[gtk::test] +fn guarded_search_toggle_synchronizes_revealer_state() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Rc::new(Cell::new(true)); + connect_search_toggle(&toggle, &revealer, &entry, guard); + + toggle.set_active(true); + + assert!(toggle.is_active()); + assert!(revealer.reveals_child()); + + entry.set_text("urgent"); + toggle.set_active(false); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); +} + +#[gtk::test] +fn rapid_search_toggle_restores_the_last_accepted_state() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + entry.set_text("urgent"); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + assert_eq!(entry.selection_bounds(), Some((0, 6))); + toggle.set_active(false); + + assert!(toggle.is_active()); + assert!(revealer.reveals_child()); + assert_eq!(entry.text(), "urgent"); +} + +#[gtk::test] +fn programmatic_panel_close_keeps_search_closed_for_the_next_open() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Rc::new(Cell::new(false)); + connect_search_toggle(&toggle, &revealer, &entry, guard.clone()); + + toggle.set_active(true); + entry.set_text("urgent"); + set_search_open(&toggle, &revealer, &entry, guard.as_ref(), false); + + // Reopening the panel does not mutate search state + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); +} + +#[gtk::test] +fn programmatic_search_sync_preserves_an_outer_guard_scope() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + let guard = Cell::new(true); + + set_search_open(&toggle, &revealer, &entry, &guard, true); + + assert!(guard.get()); + assert!(toggle.is_active()); + assert!(revealer.reveals_child()); +} + +#[gtk::test] +fn stop_search_closes_a_preexisting_toggle_revealer_mismatch() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + let entry = gtk::SearchEntry::new(); + revealer.set_reveal_child(true); + entry.set_text("urgent"); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + entry.emit_stop_search(); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(entry.text().is_empty()); +} + +#[gtk::test] +fn widget_collapse_toggle_sends_the_accepted_state_and_rejects_a_burst() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(180); + let (event_tx, event_rx) = async_channel::bounded(2); + connect_widget_collapse_toggle(&toggle, &revealer, event_tx); + + toggle.set_active(true); + assert!(!toggle.is_sensitive()); + toggle.set_active(false); + + // The rejected edge rolls back immediately to the accepted visual state + assert!(toggle.is_active()); + assert!(next_widgets_collapsed(&event_rx)); +} + +#[gtk::test] +fn reduced_motion_search_toggle_accepts_an_immediate_reversal() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(0); + let entry = gtk::SearchEntry::new(); + connect_search_toggle(&toggle, &revealer, &entry, Rc::new(Cell::new(false))); + + toggle.set_active(true); + toggle.set_active(false); + + assert!(!toggle.is_active()); + assert!(!revealer.reveals_child()); + assert!(toggle.is_sensitive()); +} + +#[gtk::test] +fn reduced_motion_widget_toggle_accepts_the_latest_state_without_a_cooldown() { + let toggle = gtk::ToggleButton::new(); + let revealer = gtk::Revealer::new(); + revealer.set_transition_duration(0); + let (event_tx, event_rx) = async_channel::bounded(2); + connect_widget_collapse_toggle(&toggle, &revealer, event_tx); + + toggle.set_active(true); + toggle.set_active(false); + + assert!(!toggle.is_active()); + assert!(toggle.is_sensitive()); + assert!(!next_widgets_collapsed(&event_rx)); +} + +fn next_filter(event_rx: &async_channel::Receiver) -> String { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let Ok(UiEvent::FilterChanged(filter)) = event_rx.try_recv() { + return filter; + } + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!( + Instant::now() < deadline, + "search filter event should arrive before timeout" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn next_widgets_collapsed(event_rx: &async_channel::Receiver) -> bool { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let Ok(UiEvent::WidgetsCollapsed(collapsed)) = event_rx.try_recv() { + return collapsed; + } + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + assert!( + Instant::now() < deadline, + "widget collapse event should arrive before timeout" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} diff --git a/crates/unixnotis-center/src/ui/panel/header/widgets.rs b/crates/unixnotis-center/src/ui/panel/header/widgets.rs new file mode 100644 index 000000000..ec2187f0a --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/header/widgets.rs @@ -0,0 +1,17 @@ +//! Widget handles owned by the panel header + +use super::actions::PanelActionWidgets; +use super::search::PanelSearchWidgets; + +pub(in crate::ui) struct PanelHeaderWidgets { + // Structural handles remain grouped so callers do not rebuild child relationships + pub(in crate::ui) root: gtk::Box, + pub(in crate::ui) top: gtk::Box, + pub(in crate::ui) action_row: gtk::Box, + pub(in crate::ui) title: gtk::Label, + pub(in crate::ui) subtitle: gtk::Label, + pub(in crate::ui) count: gtk::Label, + // Feature groups own their internal controls and signal state + pub(in crate::ui) search: PanelSearchWidgets, + pub(in crate::ui) actions: PanelActionWidgets, +} diff --git a/crates/unixnotis-center/src/ui/panel/mod.rs b/crates/unixnotis-center/src/ui/panel/mod.rs index f2cf5b9ec..9b60d73c6 100644 --- a/crates/unixnotis-center/src/ui/panel/mod.rs +++ b/crates/unixnotis-center/src/ui/panel/mod.rs @@ -1,35 +1,14 @@ //! Panel layout and widget construction for the center window //! -//! The folder root stays focused on module wiring and the public panel surface +//! The folder root contains module wiring only -mod action_widgets; -mod actions; -mod autoclose; -mod build; -mod header; -pub(in crate::ui) mod input; -mod keyboard; -mod layout; -mod monitor; -mod notice; -mod reload; -mod search; -mod search_widgets; -mod sections; -mod timing; -mod types; -mod visibility; - -pub use self::build::build_panel_widgets; -pub use self::layout::{apply_panel_config, requested_panel_width}; -pub use self::reload::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; -pub use self::search_widgets::SEARCH_REVEAL_TRANSITION_MS; -pub use self::sections::apply_widget_density; -pub use self::sections::{notification_header_row_visible, WIDGET_REVEAL_TRANSITION_MS}; -pub use self::types::PanelWidgets; -pub(in crate::ui) use actions::{connect_clear_button, connect_close_button, connect_dnd_toggle}; -pub(in crate::ui) use autoclose::connect_auto_close; -pub(in crate::ui) use keyboard::connect_keyboard_shortcuts; -pub(in crate::ui) use search::{ - connect_filter_entry, connect_search_toggle, connect_widget_collapse_toggle, -}; +pub(in crate::ui) mod apply; +pub(in crate::ui) mod behavior; +pub(in crate::ui) mod body; +pub(in crate::ui) mod build; +pub(in crate::ui) mod geometry; +pub(in crate::ui) mod header; +pub(in crate::ui) mod motion; +pub(in crate::ui) mod notice; +mod state; +pub(in crate::ui) mod widgets; diff --git a/crates/unixnotis-center/src/ui/panel/motion.rs b/crates/unixnotis-center/src/ui/panel/motion.rs new file mode 100644 index 000000000..0baf13d40 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/motion.rs @@ -0,0 +1,42 @@ +//! Panel-local motion preference policy + +use gtk::prelude::*; +use unixnotis_core::css::hooks; + +use super::body::WIDGET_REVEAL_TRANSITION_MS; +use super::header::search::SEARCH_REVEAL_TRANSITION_MS; +use super::notice::RELOAD_NOTICE_TRANSITION_MS; +use super::widgets::PanelWidgets; +use crate::ui::motion::apply_revealer_preference; + +pub(in crate::ui) fn apply_reduced_motion(panel: &PanelWidgets, reduced_motion: bool) { + apply_motion_class(&panel.root, reduced_motion); + apply_revealer_preference( + &panel.sections.widget_revealer, + WIDGET_REVEAL_TRANSITION_MS as u32, + reduced_motion, + ); + apply_revealer_preference( + &panel.header.search.revealer, + SEARCH_REVEAL_TRANSITION_MS as u32, + reduced_motion, + ); + apply_revealer_preference( + &panel.reload_notice.revealer, + RELOAD_NOTICE_TRANSITION_MS, + reduced_motion, + ); +} + +fn apply_motion_class(root: >k::Box, reduced_motion: bool) { + if reduced_motion { + // One stable class lets the internal policy layer cover custom and stock themes + root.add_css_class(hooks::panel_shell::REDUCED_MOTION); + } else { + root.remove_css_class(hooks::panel_shell::REDUCED_MOTION); + } +} + +#[cfg(test)] +#[path = "tests/motion.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/notice.rs b/crates/unixnotis-center/src/ui/panel/notice.rs index 7e8878dd9..8eb58dd34 100644 --- a/crates/unixnotis-center/src/ui/panel/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/notice.rs @@ -3,18 +3,24 @@ use gtk::prelude::*; use unixnotis_core::css::hooks; -pub(super) struct ReloadNoticeWidgets { - pub(super) revealer: gtk::Revealer, - pub(super) shell: gtk::Box, - pub(super) label: gtk::Label, +pub(super) const RELOAD_NOTICE_TRANSITION_MS: u32 = 160; + +pub(in crate::ui) struct ReloadNoticeWidgets { + pub(in crate::ui) revealer: gtk::Revealer, + pub(in crate::ui) shell: gtk::Box, + pub(in crate::ui) label: gtk::Label, + pub(in crate::ui) close: gtk::Button, } -pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { - // Horizontal layout keeps the message and dismissal action on one row - let shell = gtk::Box::new(gtk::Orientation::Horizontal, 10); +pub(in crate::ui) fn build_reload_notice() -> ReloadNoticeWidgets { + // The outer column keeps the status message independent from the panel contents + let shell = gtk::Box::new(gtk::Orientation::Vertical, 0); shell.add_css_class(hooks::panel_shell::RELOAD_NOTICE); shell.set_hexpand(true); + let content = gtk::Box::new(gtk::Orientation::Horizontal, 10); + content.add_css_class(hooks::panel_shell::RELOAD_NOTICE_CONTENT); + // Wrapping prevents long parser errors from changing panel width let label = gtk::Label::new(None); label.add_css_class(hooks::panel_shell::RELOAD_NOTICE_TEXT); @@ -29,13 +35,14 @@ pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { close.set_tooltip_text(Some("Dismiss reload notice")); close.set_valign(gtk::Align::Start); - shell.append(&label); - shell.append(&close); + content.append(&label); + content.append(&close); + shell.append(&content); // A short vertical transition keeps the header position stable let revealer = gtk::Revealer::new(); revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - revealer.set_transition_duration(160); + revealer.set_transition_duration(RELOAD_NOTICE_TRANSITION_MS); revealer.set_reveal_child(false); revealer.set_child(Some(&shell)); @@ -46,6 +53,7 @@ pub(super) fn build_reload_notice() -> ReloadNoticeWidgets { revealer, shell, label, + close, } } diff --git a/crates/unixnotis-center/src/ui/panel/reload.rs b/crates/unixnotis-center/src/ui/panel/reload.rs deleted file mode 100644 index 1ef9da667..000000000 --- a/crates/unixnotis-center/src/ui/panel/reload.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Panel reload helpers for structure and action chrome - -use unixnotis_core::{PanelConfig, PanelSection}; - -use super::types::PanelWidgets; - -pub fn apply_reloaded_panel_chrome(panel: &PanelWidgets, config: &PanelConfig) { - super::action_widgets::apply_panel_action_config( - &panel.header_top, - &super::action_widgets::PanelActionWidgets { - group: panel.header_action_group.clone(), - focus_toggle: panel.focus_toggle.clone(), - dnd_toggle: panel.dnd_toggle.clone(), - clear_button: panel.clear_action_button.clone(), - search_toggle: panel.search_toggle.clone(), - close_button: panel.close_button.clone(), - }, - config, - ); - super::action_widgets::apply_clear_button_config(&panel.clear_header_button, config); -} - -pub fn apply_reloaded_body_order(panel: &PanelWidgets, order: &[PanelSection]) { - super::sections::apply_panel_body_section_order( - &panel.body_stack, - &panel.widget_revealer, - &panel.notification_container, - order, - ); -} - -#[cfg(test)] -#[path = "tests/reload.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/search.rs b/crates/unixnotis-center/src/ui/panel/search.rs deleted file mode 100644 index e470a1399..000000000 --- a/crates/unixnotis-center/src/ui/panel/search.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Search, filter, and widget-collapse wiring - -use std::cell::Cell; -use std::rc::Rc; -use std::time::Duration; - -use async_channel::TrySendError; -use gtk::prelude::*; - -use super::input::{ClickCooldown, LatestBoolEventGate}; -use super::timing::WIDGETS_TOGGLE_COALESCE_MS; -use super::{PanelWidgets, SEARCH_REVEAL_TRANSITION_MS, WIDGET_REVEAL_TRANSITION_MS}; -use crate::control::UiEvent; - -pub(in crate::ui) fn connect_widget_collapse_toggle( - panel: &PanelWidgets, - event_tx: async_channel::Sender, -) { - let collapse_gate = LatestBoolEventGate::new(Duration::from_millis(WIDGETS_TOGGLE_COALESCE_MS)); - let collapse_click_gate = - ClickCooldown::new(Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS)); - let accepted_collapsed = Rc::new(Cell::new(false)); - // Restore guard prevents a rejected click rollback from re-entering this handler - let collapse_restore = Rc::new(Cell::new(false)); - - panel.focus_toggle.connect_toggled(move |button| { - if collapse_restore.replace(false) { - return; - } - - let collapsed = button.is_active(); - // Ignore clicks while the previous reveal animation is still changing layout - if !collapse_click_gate.try_start() { - let accepted = accepted_collapsed.get(); - if collapsed != accepted { - // Roll back only the rejected edge so the UI mirrors the running transition - collapse_restore.set(true); - button.set_active(accepted); - } - return; - } - - accepted_collapsed.set(collapsed); - // Disable the control until GTK finishes the matching reveal transition - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(WIDGET_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - collapse_gate.request_widgets_collapsed(&event_tx, collapsed); - }); -} - -pub(in crate::ui) fn connect_filter_entry( - panel: &PanelWidgets, - event_tx: async_channel::Sender, -) { - // SearchChanged covers typing, clear actions, and programmatic text resets - panel.search_entry.connect_search_changed(move |entry| { - send_filter_event(&event_tx, entry.text().to_string()); - }); -} - -#[cfg(test)] -#[path = "tests/search.rs"] -mod tests; - -pub(super) fn send_filter_event(event_tx: &async_channel::Sender, filter: String) { - let event = UiEvent::FilterChanged(filter); - match event_tx.try_send(event) { - Ok(()) => {} - Err(TrySendError::Full(event)) => { - // Search changes are small and should retry instead of disappearing under bursts - let event_tx = event_tx.clone(); - gtk::glib::MainContext::default().spawn_local(async move { - let _ = event_tx.send(event).await; - }); - } - Err(TrySendError::Closed(_)) => {} // A closed UI channel means shutdown already owns the pending filter state - } -} - -pub(in crate::ui) fn connect_search_toggle( - panel: &PanelWidgets, - search_toggle_guard: Rc>, -) { - let search_revealer = panel.search_revealer.clone(); - let search_entry = panel.search_entry.clone(); - let search_click_gate = ClickCooldown::new(Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS)); - let accepted_search_reveal = Rc::new(Cell::new(false)); - // Programmatic rollback must not be mistaken for a fresh user click - let search_restore = Rc::new(Cell::new(false)); - - panel.search_toggle.connect_toggled(move |button| { - if search_toggle_guard.get() || search_restore.replace(false) { - return; - } - - let reveal = button.is_active(); - if !search_click_gate.try_start() { - let accepted = accepted_search_reveal.get(); - if reveal != accepted { - // Keep the visual toggle synced with the accepted revealer state - search_restore.set(true); - button.set_active(accepted); - } - return; - } - - accepted_search_reveal.set(reveal); - // Freeze the toggle while its revealer animates to the accepted state - button.set_sensitive(false); - let button_enable = button.clone(); - gtk::glib::timeout_add_local_once( - Duration::from_millis(SEARCH_REVEAL_TRANSITION_MS), - move || { - button_enable.set_sensitive(true); - }, - ); - search_revealer.set_reveal_child(reveal); - if reveal { - // Selecting existing text makes the next query replace it immediately - search_entry.grab_focus(); - search_entry.select_region(0, -1); - } else if !search_entry.text().is_empty() { - // Closing search restores the full notification list - search_entry.set_text(""); - } - }); -} diff --git a/crates/unixnotis-center/src/ui/panel/search_widgets.rs b/crates/unixnotis-center/src/ui/panel/search_widgets.rs deleted file mode 100644 index ff5db7ff2..000000000 --- a/crates/unixnotis-center/src/ui/panel/search_widgets.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Panel search row construction - -use gtk::prelude::*; -use unixnotis_core::{css::hooks, PanelConfig}; - -pub const SEARCH_REVEAL_TRANSITION_MS: u64 = 180; - -pub(super) struct PanelSearchWidgets { - pub(super) revealer: gtk::Revealer, - pub(super) entry: gtk::SearchEntry, -} - -pub(super) fn build_panel_search(config: &PanelConfig) -> PanelSearchWidgets { - let search_shell = gtk::Box::new(gtk::Orientation::Horizontal, 6); - search_shell.add_css_class(hooks::panel_shell::SEARCH_SHELL); - search_shell.set_hexpand(true); - - let leading_accent = gtk::Box::new(gtk::Orientation::Vertical, 0); - leading_accent.add_css_class(hooks::panel_shell::SEARCH_ACCENT); - leading_accent.add_css_class(hooks::panel_shell::TICK_TOP_LEFT); - - let star_accent = gtk::Label::new(Some("*")); - star_accent.add_css_class(hooks::panel_shell::SEARCH_STAR); - - let search_entry = gtk::SearchEntry::new(); - search_entry.add_css_class(hooks::panel_shell::SEARCH); - // Placeholder text keeps the intent obvious before the first query - search_entry.set_placeholder_text(Some(&config.search_placeholder)); - search_entry.set_hexpand(true); - search_entry.set_tooltip_text(Some("Type to filter notifications")); - search_shell.append(&leading_accent); - search_shell.append(&search_entry); - search_shell.append(&star_accent); - - let search_revealer = gtk::Revealer::new(); - search_revealer.add_css_class(hooks::panel_shell::SEARCH_REVEALER); - // Slide-down matches the rest of the panel reveal motion - search_revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - search_revealer.set_transition_duration(SEARCH_REVEAL_TRANSITION_MS as u32); - // Keep search hidden until the user asks for it so notifications keep the space - search_revealer.set_reveal_child(config.search_visible); - search_revealer.set_child(Some(&search_shell)); - - PanelSearchWidgets { - revealer: search_revealer, - entry: search_entry, - } -} - -#[cfg(test)] -#[path = "tests/search_widgets.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/state.rs b/crates/unixnotis-center/src/ui/panel/state.rs new file mode 100644 index 000000000..afa720967 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/state.rs @@ -0,0 +1,87 @@ +//! Panel content and daemon-state synchronization + +use gtk::prelude::*; + +use crate::ui::UiState; + +impl UiState { + pub const fn panel_is_visible(&self) -> bool { + self.panel_visible + } + + pub(in crate::ui) const fn has_any_widgets(&self) -> bool { + self.volume.is_some() + || self.brightness.is_some() + || self.toggles.is_some() + || self.stats.is_some() + || self.cards.is_some() + || (self.media.is_some() && self.config.media.enabled) + } + + pub(in crate::ui) fn set_widgets_collapsed(&mut self, collapsed: bool) { + self.widgets_collapsed = collapsed; + if self.panel.header.actions.focus_toggle.is_active() != collapsed { + // Mirror external collapse requests into the header toggle state + self.panel.header.actions.focus_toggle.set_active(collapsed); + } + if self.panel.sections.widget_revealer.reveals_child() == collapsed { + self.panel + .sections + .widget_revealer + .set_reveal_child(!collapsed); + } + self.list + .set_empty_layout(!collapsed && self.has_any_widgets()); + } + + pub(in crate::ui) fn update_state(&mut self, state: unixnotis_core::ControlState) { + // Dropping the old countdown removes its source unless GLib already stopped it + drop(self.dnd_expiration_source.take()); + + // Avoid re-entrant DND toggles while applying daemon state + self.dnd_guard.set(true); + self.panel + .header + .actions + .dnd_toggle + .set_active(state.dnd_enabled); + self.dnd_guard.set(false); + let expires_at = state + .dnd_enabled + .then_some(state.dnd_expires_at) + .filter(|expires_at| *expires_at > 0) + .unwrap_or(0); + super::header::dnd::update_dnd_status(&self.panel.header.actions.dnd_status, expires_at); + if expires_at > 0 { + self.dnd_expiration_source = Some(super::header::dnd::start_dnd_countdown( + &self.panel.header.actions.dnd_status, + expires_at, + )); + } + } + + pub(in crate::ui) fn refresh_counts(&mut self) { + if !self.panel_visible { + // Skip label updates while hidden to avoid unnecessary UI work + // Counts are refreshed on the next open to keep the header accurate + return; + } + let counts = self.list.notification_counts(); + if self.last_count == Some(counts) { + return; + } + self.last_count = Some(counts); + self.panel.header.count.set_text(&format_counts(counts)); + } +} + +fn format_counts(counts: crate::ui::notifications::NotificationCounts) -> String { + if counts.filter_active { + return format!("{} / {}", counts.matching, counts.total); + } + counts.total.to_string() +} + +#[cfg(test)] +#[path = "tests/state.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/tests/actions.rs b/crates/unixnotis-center/src/ui/panel/tests/actions.rs deleted file mode 100644 index 29eef35ef..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/actions.rs +++ /dev/null @@ -1,17 +0,0 @@ -use gtk::prelude::*; - -use super::connect_clear_button; -use crate::control::UiCommand; - -#[gtk::test] -fn clear_button_sends_once_while_click_guard_is_active() { - let button = gtk::Button::new(); - let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); - connect_clear_button(&button, command_tx); - - button.emit_clicked(); - button.emit_clicked(); - - assert!(matches!(command_rx.try_recv(), Ok(UiCommand::ClearAll))); - assert!(command_rx.try_recv().is_err()); -} diff --git a/crates/unixnotis-center/src/ui/panel/tests/reload.rs b/crates/unixnotis-center/src/ui/panel/tests/apply.rs similarity index 55% rename from crates/unixnotis-center/src/ui/panel/tests/reload.rs rename to crates/unixnotis-center/src/ui/panel/tests/apply.rs index 0b179642f..90b16e7e7 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/reload.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/apply.rs @@ -5,10 +5,10 @@ use unixnotis_core::{ css::hooks, PanelActionId, PanelClearButtonPlacement, PanelConfig, PanelSection, }; +use super::super::body::build_panel_sections; use super::super::header::build_panel_header; use super::super::notice::build_reload_notice; -use super::super::sections::build_panel_sections; -use super::super::types::PanelWidgets; +use super::super::widgets::PanelWidgets; use super::{apply_reloaded_body_order, apply_reloaded_panel_chrome}; static APP_ID: AtomicUsize = AtomicUsize::new(0); @@ -40,38 +40,9 @@ fn panel_widgets(config: &PanelConfig) -> PanelWidgets { window: gtk::ApplicationWindow::new(&app), surface: gtk::Overlay::new(), root: gtk::Box::new(gtk::Orientation::Vertical, 0), - body_stack: sections.body_stack, - widget_revealer: sections.widget_revealer, - widget_stack: sections.widget_stack, - quick_controls: sections.quick_controls, - toggle_container: sections.toggle_container, - stat_container: sections.stat_container, - card_container: sections.card_container, - scroller: sections.scroller, - media_container: sections.media_container, - search_revealer: header.search.revealer, - search_entry: header.search.entry, - search_toggle: header.actions.search_toggle, - header_title: header.title, - header_subtitle: header.subtitle, - header_count: header.count, - header_top: header.top, - header_action_row: header.action_row, - header_action_group: header.actions.group, - notification_container: sections.notification_container, - notification_header_row: sections.notification_header_row, - notification_header: sections.notification_header, - toggle_section_header: sections.toggle_section_header, - stat_section_header: sections.stat_section_header, - footer_label: sections.footer, - focus_toggle: header.actions.focus_toggle, - dnd_toggle: header.actions.dnd_toggle, - clear_action_button: header.actions.clear_button, - clear_header_button: sections.clear_header_button, - close_button: header.actions.close_button, - reload_notice_revealer: notice.revealer, - reload_notice_shell: notice.shell, - reload_notice_label: notice.label, + header, + sections, + reload_notice: notice, } } @@ -92,10 +63,10 @@ fn apply_reloaded_panel_chrome_updates_clear_buttons_and_close_placement() { apply_reloaded_panel_chrome(&panel, &config); - assert!(!panel.clear_action_button.get_visible()); - assert!(panel.clear_header_button.get_visible()); - assert!(child_with_class(&panel.header_top, hooks::panel_action::CLOSE).is_none()); - assert!(child_with_class(&panel.header_action_group, hooks::panel_action::CLOSE).is_some()); + assert!(!panel.header.actions.clear_button.get_visible()); + assert!(panel.sections.clear_header_button.get_visible()); + assert!(child_with_class(&panel.header.top, hooks::panel_action::CLOSE).is_none()); + assert!(child_with_class(&panel.header.actions.group, hooks::panel_action::CLOSE).is_some()); } #[gtk::test] @@ -108,12 +79,13 @@ fn apply_reloaded_body_order_moves_notifications_before_widgets() { ); let first = panel + .sections .body_stack .first_child() .expect("body stack should retain both sections"); - assert_eq!(first, panel.notification_container); + assert_eq!(first, panel.sections.notification_container); let second = first .next_sibling() .expect("widget section should follow notifications"); - assert_eq!(second, panel.widget_revealer); + assert_eq!(second, panel.sections.widget_revealer); } diff --git a/crates/unixnotis-center/src/ui/panel/tests/sections.rs b/crates/unixnotis-center/src/ui/panel/tests/body.rs similarity index 89% rename from crates/unixnotis-center/src/ui/panel/tests/sections.rs rename to crates/unixnotis-center/src/ui/panel/tests/body.rs index 6603ddf7d..4cc8ec435 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/sections.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/body.rs @@ -34,6 +34,17 @@ fn compact_widget_density_updates_spacing_and_state_class() { .has_css_class(hooks::panel_shell::WIDGET_DENSITY_COMFORTABLE)); } +#[gtk::test] +fn notification_scroller_keeps_scrollbar_space_without_global_settings() { + let sections = build_panel_sections(&PanelConfig::default(), WidgetDensity::Comfortable); + + assert!(!sections.scroller.is_overlay_scrolling()); + assert_eq!( + sections.scroller.vscrollbar_policy(), + gtk::PolicyType::Always + ); +} + #[test] fn notification_header_row_uses_section_label_when_section_is_visible() { let config = PanelConfig { diff --git a/crates/unixnotis-center/src/ui/panel/tests/input.rs b/crates/unixnotis-center/src/ui/panel/tests/input.rs deleted file mode 100644 index 3e8be2bdb..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/input.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::time::Duration; - -use super::ClickCooldown; - -#[gtk::test] -fn click_cooldown_rejects_bursts_and_reopens_after_its_timeout() { - let guard = ClickCooldown::new(Duration::ZERO); - - assert!(guard.try_start()); - assert!(!guard.try_start()); - - let context = gtk::glib::MainContext::default(); - while context.pending() { - context.iteration(false); - } - assert!(guard.try_start()); -} diff --git a/crates/unixnotis-center/src/ui/panel/tests/motion.rs b/crates/unixnotis-center/src/ui/panel/tests/motion.rs new file mode 100644 index 000000000..1b69b59e6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/motion.rs @@ -0,0 +1,17 @@ +//! Panel motion preference tests + +use gtk::prelude::*; +use unixnotis_core::css::hooks; + +use super::apply_motion_class; + +#[gtk::test] +fn reduced_motion_class_tracks_the_runtime_preference() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + + apply_motion_class(&root, true); + assert!(root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); + + apply_motion_class(&root, false); + assert!(!root.has_css_class(hooks::panel_shell::REDUCED_MOTION)); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/notice.rs b/crates/unixnotis-center/src/ui/panel/tests/notice.rs index 775c9faa4..9c866b63a 100644 --- a/crates/unixnotis-center/src/ui/panel/tests/notice.rs +++ b/crates/unixnotis-center/src/ui/panel/tests/notice.rs @@ -14,14 +14,9 @@ fn reload_notice_starts_hidden_and_dismiss_button_hides_it() { assert!(notice .label .has_css_class(hooks::panel_shell::RELOAD_NOTICE_TEXT)); + assert!(notice.close.get_visible()); notice.revealer.set_reveal_child(true); - let close = notice - .shell - .last_child() - .expect("notice close button") - .downcast::() - .expect("close button widget"); - close.emit_clicked(); + notice.close.emit_clicked(); assert!(!notice.revealer.reveals_child()); } diff --git a/crates/unixnotis-center/src/ui/panel/tests/search.rs b/crates/unixnotis-center/src/ui/panel/tests/search.rs deleted file mode 100644 index 7af284c09..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/search.rs +++ /dev/null @@ -1,20 +0,0 @@ -use super::send_filter_event; -use crate::control::UiEvent; - -#[test] -fn filter_event_sends_exact_query_without_waiting() { - let (event_tx, event_rx) = async_channel::bounded(1); - - send_filter_event(&event_tx, "terminal".to_string()); - - let event = event_rx.try_recv().expect("filter event should be queued"); - assert!(matches!(event, UiEvent::FilterChanged(query) if query == "terminal")); -} - -#[test] -fn filter_event_ignores_closed_channel() { - let (event_tx, event_rx) = async_channel::bounded(1); - drop(event_rx); - - send_filter_event(&event_tx, "ignored".to_string()); -} diff --git a/crates/unixnotis-center/src/ui/panel/tests/search_widgets.rs b/crates/unixnotis-center/src/ui/panel/tests/search_widgets.rs deleted file mode 100644 index 632bbad85..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/search_widgets.rs +++ /dev/null @@ -1,24 +0,0 @@ -use unixnotis_core::PanelConfig; - -use super::{build_panel_search, SEARCH_REVEAL_TRANSITION_MS}; - -#[gtk::test] -fn search_widget_applies_visibility_copy_and_transition_policy() { - let config = PanelConfig { - search_visible: true, - search_placeholder: "Find alerts".to_string(), - ..PanelConfig::default() - }; - - let search = build_panel_search(&config); - - assert!(search.revealer.reveals_child()); - assert_eq!( - search.revealer.transition_duration(), - u32::try_from(SEARCH_REVEAL_TRANSITION_MS).expect("transition fits u32") - ); - assert_eq!( - search.entry.placeholder_text().as_deref(), - Some("Find alerts") - ); -} diff --git a/crates/unixnotis-center/src/ui/panel/tests/state.rs b/crates/unixnotis-center/src/ui/panel/tests/state.rs new file mode 100644 index 000000000..0353a052e --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/tests/state.rs @@ -0,0 +1,31 @@ +use crate::ui::notifications::NotificationCounts; + +use super::format_counts; + +#[test] +fn count_text_shows_total_when_search_is_inactive() { + let counts = NotificationCounts { + matching: 42, + total: 42, + filter_active: false, + }; + + assert_eq!(format_counts(counts), "42"); +} + +#[test] +fn count_text_shows_matches_over_total_during_search() { + let matches = NotificationCounts { + matching: 3, + total: 42, + filter_active: true, + }; + let no_matches = NotificationCounts { + matching: 0, + total: 42, + filter_active: true, + }; + + assert_eq!(format_counts(matches), "3 / 42"); + assert_eq!(format_counts(no_matches), "0 / 42"); +} diff --git a/crates/unixnotis-center/src/ui/panel/tests/timing.rs b/crates/unixnotis-center/src/ui/panel/tests/timing.rs deleted file mode 100644 index d3138404f..000000000 --- a/crates/unixnotis-center/src/ui/panel/tests/timing.rs +++ /dev/null @@ -1,10 +0,0 @@ -use super::{CONTROL_CLICK_GUARD_MS, WIDGETS_TOGGLE_COALESCE_MS}; - -#[test] -fn startup_timing_keeps_click_guard_above_event_coalescing() { - let click_guard_ms = std::hint::black_box(CONTROL_CLICK_GUARD_MS); - let coalesce_ms = std::hint::black_box(WIDGETS_TOGGLE_COALESCE_MS); - - assert!(click_guard_ms > coalesce_ms); - assert!(coalesce_ms > 0); -} diff --git a/crates/unixnotis-center/src/ui/panel/timing.rs b/crates/unixnotis-center/src/ui/panel/timing.rs deleted file mode 100644 index 6710e6410..000000000 --- a/crates/unixnotis-center/src/ui/panel/timing.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Shared startup interaction timing - -// Short guard for buttons that send daemon commands -// Prevents double-click bursts from queueing duplicate actions -pub(super) const CONTROL_CLICK_GUARD_MS: u64 = 180; - -// Tiny coalescing window for the widget collapse event -// Keeps rapid toggle edges from flooding the main event queue -pub(super) const WIDGETS_TOGGLE_COALESCE_MS: u64 = 16; - -#[cfg(test)] -#[path = "tests/timing.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/panel/types.rs b/crates/unixnotis-center/src/ui/panel/types.rs deleted file mode 100644 index 511fe299e..000000000 --- a/crates/unixnotis-center/src/ui/panel/types.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! GTK widget handles for the center panel -//! -//! Keeping the widget bundle here lets `mod.rs` stay as module wiring only - -/// GTK widgets backing the notification center panel window -pub struct PanelWidgets { - pub window: gtk::ApplicationWindow, - pub surface: gtk::Overlay, - pub root: gtk::Box, - pub body_stack: gtk::Box, - pub widget_revealer: gtk::Revealer, - pub widget_stack: gtk::Box, - pub quick_controls: gtk::Box, - pub toggle_container: gtk::Box, - pub stat_container: gtk::Box, - pub card_container: gtk::Box, - pub scroller: gtk::ScrolledWindow, - pub media_container: gtk::Box, - pub search_revealer: gtk::Revealer, - pub search_entry: gtk::SearchEntry, - pub search_toggle: gtk::ToggleButton, - pub header_title: gtk::Label, - pub header_subtitle: gtk::Label, - pub header_count: gtk::Label, - pub header_top: gtk::Box, - pub header_action_row: gtk::Box, - pub header_action_group: gtk::Box, - pub notification_container: gtk::Box, - pub notification_header_row: gtk::Box, - pub notification_header: gtk::Label, - pub toggle_section_header: gtk::Label, - pub stat_section_header: gtk::Label, - pub footer_label: gtk::Label, - pub focus_toggle: gtk::ToggleButton, - pub dnd_toggle: gtk::ToggleButton, - pub clear_action_button: gtk::Button, - pub clear_header_button: gtk::Button, - pub close_button: gtk::Button, - pub reload_notice_revealer: gtk::Revealer, - pub reload_notice_shell: gtk::Box, - pub reload_notice_label: gtk::Label, -} diff --git a/crates/unixnotis-center/src/ui/panel/widgets.rs b/crates/unixnotis-center/src/ui/panel/widgets.rs new file mode 100644 index 000000000..0b6a6852e --- /dev/null +++ b/crates/unixnotis-center/src/ui/panel/widgets.rs @@ -0,0 +1,17 @@ +//! Grouped GTK widget handles for the center panel +//! +//! Keeping the widget bundle here lets `mod.rs` stay as module wiring only + +use super::body::PanelSectionWidgets; +use super::header::PanelHeaderWidgets; +use super::notice::ReloadNoticeWidgets; + +/// GTK widgets backing the notification center panel window +pub struct PanelWidgets { + pub window: gtk::ApplicationWindow, + pub surface: gtk::Overlay, + pub root: gtk::Box, + pub(in crate::ui) header: PanelHeaderWidgets, + pub(in crate::ui) sections: PanelSectionWidgets, + pub(in crate::ui) reload_notice: ReloadNoticeWidgets, +} diff --git a/crates/unixnotis-center/src/ui/reload/config.rs b/crates/unixnotis-center/src/ui/reload/config.rs deleted file mode 100644 index 092ed5fbd..000000000 --- a/crates/unixnotis-center/src/ui/reload/config.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Config reload and widget rebuild logic for `UiState` -//! -//! Keeps dynamic configuration changes isolated from event handling and -//! visibility logic - -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; - -use gtk::prelude::*; -use tracing::debug; -use unixnotis_core::{ - css::hooks, Config, ConfigDiagnostic, ConfigError, PanelDebugLevel, PanelWidgetSection, - ThemePaths, -}; -use unixnotis_ui::css::CssReloadReport; - -use super::super::notifications; -use super::super::panel::notification_header_row_visible; -use super::super::widget_builders::{build_extra_widgets, build_quick_controls, clear_container}; -use super::super::{panel, UiState}; -use super::notices::{ReloadNotice, ReloadNoticeFingerprint, ReloadNoticeKind}; - -struct ReloadInputs { - config: Config, - diagnostics: Vec, - theme_paths: ThemePaths, -} - -#[derive(Debug)] -pub(in crate::ui) enum ReloadFailure { - Config(ConfigError), - ThemeBase(String), - ThemePaths(String), -} - -#[derive(Debug)] -pub(in crate::ui) enum ConfigReloadOutcome { - Applied { - diagnostics: Vec, - css: CssReloadReport, - }, - Rejected { - failure: ReloadFailure, - }, -} - -impl UiState { - pub(in crate::ui) fn reload_config(&mut self) -> ConfigReloadOutcome { - self.capture_notice_dismissal(); - let reload = match self.load_reload_inputs() { - Ok(reload) => reload, - Err(failure) => { - // Log only the stable category because parser errors can contain config text - tracing::warn!(kind = failure.kind(), "failed to reload config"); - self.show_config_reload_failure(&failure); - return ConfigReloadOutcome::Rejected { failure }; - } - }; - let widgets_changed = self.config.widgets != reload.config.widgets; - - // Store the new config early so shared helpers see one consistent state - self.config = reload.config.clone(); - debug!("config reloaded"); - - let css = self.apply_reloaded_theme(&reload); - self.apply_reloaded_panel(&reload.config); - // Media depends on panel geometry, so it needs the new width before widgets rebuild - self.apply_media_config(&reload.config); - self.apply_widget_sections_after_reload(&reload.config, widgets_changed); - self.apply_list_config_after_reload(&reload.config); - self.finish_reload_runtime(&reload.config); - // Any accepted config replaces a prior rejection before CSS reports its own result - self.clear_reload_notice(ReloadNoticeKind::Config); - self.apply_css_reload_notice(&css); - ConfigReloadOutcome::Applied { - diagnostics: reload.diagnostics, - css, - } - } - - fn load_reload_inputs(&self) -> Result { - // The accepted report keeps diagnostics tied to the same config object being applied - let report = - Config::load_from_path_with_report(&self.config_path).map_err(ReloadFailure::Config)?; - unixnotis_core::log_config_diagnostics(&report.diagnostics); - let config = report.config; - let theme_base = match Config::config_dir_for_path(&self.config_path) { - Ok(path) => path, - Err(err) => return Err(ReloadFailure::ThemeBase(err.to_string())), - }; - let theme_paths = match config.resolve_theme_paths_from(&theme_base) { - Ok(paths) => paths, - Err(err) => return Err(ReloadFailure::ThemePaths(err.to_string())), - }; - - Ok(ReloadInputs { - config, - diagnostics: report.diagnostics, - theme_paths, - }) - } - - fn apply_reloaded_theme(&mut self, reload: &ReloadInputs) -> CssReloadReport { - self.css - .update_theme(reload.theme_paths.clone(), reload.config.theme.clone()); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - // New theme assets may replace old cache misses, so clear the miss cache now - self.icon_resolver.clear_missing_cache(); - report - } - - pub(in crate::ui) fn reload_css(&mut self) -> CssReloadReport { - self.capture_notice_dismissal(); - let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); - self.apply_css_reload_notice(&report); - report - } - - fn show_config_reload_failure(&mut self, failure: &ReloadFailure) { - let detail = match failure { - ReloadFailure::Config(error) => error.shareable_summary(), - ReloadFailure::ThemeBase(detail) | ReloadFailure::ThemePaths(detail) => detail, - }; - let detail = unixnotis_core::util::sanitize_inline_display_text(detail); - let message = - format!("Config reload rejected\nThe previous configuration is still active\n{detail}"); - let identity = failure.safe_fingerprint(); - self.set_reload_notice(ReloadNoticeKind::Config, &message, true, &identity); - } - - fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { - // Intentional empty files are valid fallback requests and do not produce a notice - let failures = report.read_failures().collect::>(); - if failures.is_empty() { - self.clear_reload_notice(ReloadNoticeKind::Css); - return; - } - let first = failures[0]; - // File names are sufficient for the panel and avoid exposing full account paths - let file = first - .path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("CSS file"); - let suffix = if failures.len() == 1 { - String::new() - } else { - format!(" and {} other layer(s)", failures.len() - 1) - }; - let message = format!( - "Theme fallback active\n{file}{suffix} could not be read; embedded styling is active" - ); - let identity = css_failure_fingerprint(&failures); - self.set_reload_notice(ReloadNoticeKind::Css, &message, false, &identity); - } - - fn set_reload_notice( - &mut self, - kind: ReloadNoticeKind, - message: &str, - error: bool, - identity: &str, - ) { - self.reload_notices.set(ReloadNotice { - fingerprint: ReloadNoticeFingerprint { - kind, - identity: identity.to_string(), - }, - message: message.to_string(), - error, - }); - self.render_reload_notice(); - } - - fn render_reload_notice(&self) { - let Some(notice) = self.reload_notices.visible() else { - self.panel.reload_notice_revealer.set_reveal_child(false); - return; - }; - self.panel.reload_notice_label.set_label(¬ice.message); - self.panel - .reload_notice_shell - .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_ERROR); - self.panel - .reload_notice_shell - .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_WARNING); - self.panel - .reload_notice_shell - .add_css_class(if notice.error { - hooks::panel_shell::RELOAD_NOTICE_ERROR - } else { - hooks::panel_shell::RELOAD_NOTICE_WARNING - }); - self.panel.reload_notice_revealer.set_reveal_child(true); - } - - fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { - self.reload_notices.clear(kind); - self.render_reload_notice(); - } - - fn capture_notice_dismissal(&mut self) { - // The close button hides GTK immediately, then the next event records that dismissal - if !self.panel.reload_notice_revealer.reveals_child() - && self.reload_notices.visible().is_some() - { - self.reload_notices.dismiss_visible(); - } - } - - pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { - // Geometry goes first so later sections can size themselves from the final panel width - panel::apply_panel_config(&self.panel, config, self.work_area); - self.panel.header_title.set_label(&config.panel.title); - self.panel.header_subtitle.set_label(&config.panel.subtitle); - self.panel - .header_subtitle - .set_visible(!config.panel.subtitle.is_empty()); - self.panel - .search_entry - .set_placeholder_text(Some(&config.panel.search_placeholder)); - self.panel - .search_revealer - .set_reveal_child(config.panel.search_visible || self.panel.search_toggle.is_active()); - self.panel - .header_action_row - .set_visible(config.panel.action_row_visible); - panel::apply_reloaded_panel_chrome(&self.panel, &config.panel); - self.panel - .notification_header - .set_label(&config.panel.recent_notifications_label); - self.panel.notification_header.set_visible( - config.panel.notification_section_visible - && !config.panel.recent_notifications_label.is_empty(), - ); - self.panel - .notification_header_row - .set_visible(notification_header_row_visible(&config.panel)); - self.update_section_header( - &self.panel.toggle_section_header, - &config.panel.quick_actions_label, - ); - self.update_section_header( - &self.panel.stat_section_header, - &config.panel.system_status_label, - ); - if config.panel.notification_section_visible { - self.panel - .notification_container - .add_css_class(hooks::panel_shell::RECENT_SECTION); - } else { - self.panel - .notification_container - .remove_css_class(hooks::panel_shell::RECENT_SECTION); - } - self.panel - .scroller - .set_vexpand(config.panel.notification_list_expand); - self.panel - .notification_container - .set_vexpand(config.panel.notification_list_expand); - panel::apply_reloaded_body_order(&self.panel, &config.panel.section_order); - self.apply_widget_order(&config.panel.widget_order); - panel::apply_widget_density( - &self.panel.widget_stack, - &self.panel.quick_controls, - &self.panel.media_container, - config.widgets.density, - ); - self.panel - .footer_label - .set_label(&config.panel.footer_label); - self.panel - .footer_label - .set_visible(!config.panel.footer_label.is_empty()); - self.log_debug(PanelDebugLevel::Info, || { - "panel config applied after reload".to_string() - }); - } - - fn update_section_header(&self, header: >k::Label, label: &str) { - // Section headers are built once and updated in place on config reload - header.set_label(label); - header.set_visible(!label.is_empty()); - } - - fn apply_widget_order(&self, order: &[PanelWidgetSection]) { - let mut previous: Option = None; - for section in order { - // Config enum values map to the long-lived container built at startup - let child: gtk::Widget = match section { - PanelWidgetSection::Media => self.panel.media_container.clone().upcast(), - PanelWidgetSection::Toggles => self.panel.toggle_container.clone().upcast(), - PanelWidgetSection::Sliders => self.panel.quick_controls.clone().upcast(), - PanelWidgetSection::Stats => self.panel.stat_container.clone().upcast(), - PanelWidgetSection::Cards => self.panel.card_container.clone().upcast(), - }; - self.panel - .widget_stack - .reorder_child_after(&child, previous.as_ref()); - // The next child is inserted after the child placed in this iteration - previous = Some(child); - } - } - - fn apply_widget_sections_after_reload(&mut self, config: &Config, widgets_changed: bool) { - if widgets_changed { - // Widget rebuilds are the expensive part, so skip them when structure is unchanged - self.apply_widget_config(config); - } else { - debug!("widget config unchanged; skipping rebuild"); - } - } - - pub(in crate::ui) fn apply_list_config_after_reload(&mut self, config: &Config) { - // A compact value object prevents the list from reading half-applied UI state - let list_config = notifications::NotificationListConfig { - max_active: config.history.max_active, - max_entries: config.history.max_entries, - transient_to_history: config.history.transient_to_history, - show_notification_metadata: config.panel.notification_metadata_visible, - show_notification_thumbnails: config.panel.notification_thumbnails_visible, - empty_text: config.panel.empty_text.clone(), - empty_offset_top: config.panel.empty_offset_top, - empty_alignment: config.panel.empty_alignment, - }; - self.list.apply_config(&list_config); - // Empty-state placement depends on both list settings and current widget visibility - self.set_widgets_collapsed(self.widgets_collapsed); - } - - fn finish_reload_runtime(&mut self, config: &Config) { - // Refresh timers may need new intervals even when widget structure is unchanged - self.restart_refresh_timer(); - if config.panel.respect_work_area { - // Clearing the cache prevents stale compositor margins from surviving reload - self.work_area = None; - // Work area is refreshed after reload so compositor margins can update one more time - super::super::hyprland::refresh_reserved_work_area( - config.panel.output.clone(), - self.event_tx.clone(), - ); - } - } - - fn apply_widget_config(&mut self, config: &Config) { - // Old children are cleared first so the rebuild can treat each section as fresh state - clear_container(&self.panel.quick_controls); - let (volume, brightness) = build_quick_controls(&self.panel, config); - self.volume = volume; - self.brightness = brightness; - clear_container(&self.panel.toggle_container); - clear_container(&self.panel.stat_container); - clear_container(&self.panel.card_container); - let (toggles, stats, cards) = - build_extra_widgets(&self.panel, config, &self.widget_icon_resolver); - // Replace all handles together after the containers hold the new children - self.toggles = toggles; - self.stats = stats; - self.cards = cards; - } -} - -impl ReloadFailure { - pub(super) const fn kind(&self) -> &'static str { - match self { - Self::Config(_) => "config", - Self::ThemeBase(_) => "theme-base", - Self::ThemePaths(_) => "theme-paths", - } - } - - pub(super) fn safe_fingerprint(&self) -> String { - // Hash private parser details so distinct failures remain distinguishable without display - let mut hasher = DefaultHasher::new(); - format!("{self:?}").hash(&mut hasher); - format!("{:016x}", hasher.finish()) - } -} - -pub(in crate::ui) fn log_reload_rejection(failure: &ReloadFailure) { - // Raw parser errors can contain complete config lines, commands, labels, and paths - tracing::debug!( - kind = failure.kind(), - fingerprint = %failure.safe_fingerprint(), - "config reload rejected" - ); -} - -fn css_failure_fingerprint(failures: &[&unixnotis_ui::css::CssLayerReload]) -> String { - // The UI message stays compact while the hash distinguishes changed files and read errors - let mut hasher = DefaultHasher::new(); - for failure in failures { - format!("{:?}", failure.layer).hash(&mut hasher); - failure.path.hash(&mut hasher); - failure.error.hash(&mut hasher); - } - format!("{:016x}", hasher.finish()) -} - -#[cfg(test)] -#[path = "tests/config.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/reload/config/flow.rs b/crates/unixnotis-center/src/ui/reload/config/flow.rs new file mode 100644 index 000000000..6bf01f93e --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/flow.rs @@ -0,0 +1,88 @@ +//! Reload input loading and top-level application flow + +use tracing::debug; +use unixnotis_core::{Config, ConfigDiagnostic, ThemePaths}; +use unixnotis_ui::css::CssReloadReport; + +use super::outcome::{ConfigReloadOutcome, ReloadFailure}; +use crate::ui::reload::notices::ReloadNoticeKind; +use crate::ui::UiState; + +struct ReloadInputs { + config: Config, + diagnostics: Vec, + theme_paths: ThemePaths, +} + +impl UiState { + pub(in crate::ui) fn reload_config(&mut self) -> ConfigReloadOutcome { + self.capture_notice_dismissal(); + let reload = match self.load_reload_inputs() { + Ok(reload) => reload, + Err(failure) => { + // Log only the stable category because parser errors can contain config text + tracing::warn!(kind = failure.kind(), "failed to reload config"); + self.show_config_reload_failure(&failure); + return ConfigReloadOutcome::Rejected { failure }; + } + }; + let widgets_changed = self.config.widgets != reload.config.widgets; + + // Store the new config early so shared helpers see one consistent state + self.config = reload.config.clone(); + debug!("config reloaded"); + + let css = self.apply_reloaded_theme(&reload); + self.apply_reloaded_panel(&reload.config); + // Media depends on panel geometry, so it needs the new width before widgets rebuild + self.apply_media_config(&reload.config); + self.apply_widget_sections_after_reload(&reload.config, widgets_changed); + self.apply_list_config_after_reload(&reload.config); + self.finish_reload_runtime(&reload.config); + // Any accepted config replaces a prior rejection before CSS reports its own result + self.clear_reload_notice(ReloadNoticeKind::Config); + self.apply_css_reload_notice(&css); + ConfigReloadOutcome::Applied { + diagnostics: reload.diagnostics, + css, + } + } + + fn load_reload_inputs(&self) -> Result { + // The accepted report keeps diagnostics tied to the same config object being applied + let report = + Config::load_from_path_with_report(&self.config_path).map_err(ReloadFailure::Config)?; + unixnotis_core::log_config_diagnostics(&report.diagnostics); + let config = report.config; + let theme_base = match Config::config_dir_for_path(&self.config_path) { + Ok(path) => path, + Err(err) => return Err(ReloadFailure::ThemeBase(err.to_string())), + }; + let theme_paths = match config.resolve_theme_paths_from(&theme_base) { + Ok(paths) => paths, + Err(err) => return Err(ReloadFailure::ThemePaths(err.to_string())), + }; + + Ok(ReloadInputs { + config, + diagnostics: report.diagnostics, + theme_paths, + }) + } + + fn apply_reloaded_theme(&mut self, reload: &ReloadInputs) -> CssReloadReport { + self.css + .update_theme(reload.theme_paths.clone(), reload.config.theme.clone()); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + // New theme assets may replace old cache misses, so clear the miss cache now + self.icon_resolver.clear_missing_cache(); + report + } + + pub(in crate::ui) fn reload_css(&mut self) -> CssReloadReport { + self.capture_notice_dismissal(); + let report = self.css.reload(unixnotis_ui::css::DEFAULT_CSS); + self.apply_css_reload_notice(&report); + report + } +} diff --git a/crates/unixnotis-center/src/ui/reload/config/mod.rs b/crates/unixnotis-center/src/ui/reload/config/mod.rs new file mode 100644 index 000000000..86037b148 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/mod.rs @@ -0,0 +1,12 @@ +//! Configuration reload orchestration and application + +mod flow; +mod notice; +mod outcome; +mod panel; +mod widgets; + +pub(in crate::ui) use outcome::{log_reload_rejection, ConfigReloadOutcome}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/reload/config/notice.rs b/crates/unixnotis-center/src/ui/reload/config/notice.rs new file mode 100644 index 000000000..871fd2b1b --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/notice.rs @@ -0,0 +1,121 @@ +//! Reload notice rendering and failure priority + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use gtk::prelude::*; +use unixnotis_core::css::hooks; +use unixnotis_ui::css::CssReloadReport; + +use super::outcome::ReloadFailure; +use crate::ui::reload::notices::{ReloadNotice, ReloadNoticeFingerprint, ReloadNoticeKind}; +use crate::ui::UiState; + +impl UiState { + pub(super) fn show_config_reload_failure(&mut self, failure: &ReloadFailure) { + let detail = match failure { + ReloadFailure::Config(error) => error.shareable_summary(), + ReloadFailure::ThemeBase(detail) | ReloadFailure::ThemePaths(detail) => detail, + }; + let detail = unixnotis_core::util::sanitize_inline_display_text(detail); + let message = + format!("Config reload rejected\nThe previous configuration is still active\n{detail}"); + let identity = failure.safe_fingerprint(); + self.set_reload_notice(ReloadNoticeKind::Config, &message, true, &identity); + } + + pub(in crate::ui) fn apply_css_reload_notice(&mut self, report: &CssReloadReport) { + // Intentional empty files are valid fallback requests and do not produce a notice + let failures = report.read_failures().collect::>(); + if failures.is_empty() { + self.clear_reload_notice(ReloadNoticeKind::Css); + return; + } + let first = failures[0]; + // File names are sufficient for the panel and avoid exposing full account paths + let file = first + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("CSS file"); + let suffix = if failures.len() == 1 { + String::new() + } else { + format!(" and {} other layer(s)", failures.len() - 1) + }; + let message = format!( + "Theme fallback active\n{file}{suffix} could not be read; embedded styling is active" + ); + let identity = css_failure_fingerprint(&failures); + self.set_reload_notice(ReloadNoticeKind::Css, &message, false, &identity); + } + + pub(in crate::ui) fn set_reload_notice( + &mut self, + kind: ReloadNoticeKind, + message: &str, + error: bool, + identity: &str, + ) { + self.reload_notices.set(ReloadNotice { + fingerprint: ReloadNoticeFingerprint { + kind, + identity: identity.to_string(), + }, + message: message.to_string(), + error, + }); + self.render_reload_notice(); + } + + fn render_reload_notice(&self) { + let Some(notice) = self.reload_notices.visible() else { + self.panel.reload_notice.revealer.set_reveal_child(false); + return; + }; + self.panel.reload_notice.label.set_label(¬ice.message); + self.panel + .reload_notice + .shell + .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_ERROR); + self.panel + .reload_notice + .shell + .remove_css_class(hooks::panel_shell::RELOAD_NOTICE_WARNING); + self.panel + .reload_notice + .shell + .add_css_class(if notice.error { + hooks::panel_shell::RELOAD_NOTICE_ERROR + } else { + hooks::panel_shell::RELOAD_NOTICE_WARNING + }); + self.panel.reload_notice.close.set_visible(true); + self.panel.reload_notice.revealer.set_reveal_child(true); + } + + pub(in crate::ui) fn clear_reload_notice(&mut self, kind: ReloadNoticeKind) { + self.reload_notices.clear(kind); + self.render_reload_notice(); + } + + pub(super) fn capture_notice_dismissal(&mut self) { + // The close button hides GTK immediately, then the next event records that dismissal + if !self.panel.reload_notice.revealer.reveals_child() + && self.reload_notices.visible().is_some() + { + self.reload_notices.dismiss_visible(); + } + } +} + +fn css_failure_fingerprint(failures: &[&unixnotis_ui::css::CssLayerReload]) -> String { + // The UI message stays compact while the hash distinguishes changed files and read errors + let mut hasher = DefaultHasher::new(); + for failure in failures { + format!("{:?}", failure.layer).hash(&mut hasher); + failure.path.hash(&mut hasher); + failure.error.hash(&mut hasher); + } + format!("{:016x}", hasher.finish()) +} diff --git a/crates/unixnotis-center/src/ui/reload/config/outcome.rs b/crates/unixnotis-center/src/ui/reload/config/outcome.rs new file mode 100644 index 000000000..ae3b156d3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/outcome.rs @@ -0,0 +1,54 @@ +//! Reload outcomes and safe failure diagnostics + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use unixnotis_core::{ConfigDiagnostic, ConfigError}; +use unixnotis_ui::css::CssReloadReport; + +#[derive(Debug)] +pub(in crate::ui) enum ReloadFailure { + // Parser and path stages stay distinct for stable diagnostics + Config(ConfigError), + ThemeBase(String), + ThemePaths(String), +} + +#[derive(Debug)] +pub(in crate::ui) enum ConfigReloadOutcome { + // Successful reloads retain diagnostics and the matching CSS report + Applied { + diagnostics: Vec, + css: CssReloadReport, + }, + // Rejections keep the previous live state and expose only the failure category + Rejected { + failure: ReloadFailure, + }, +} + +impl ReloadFailure { + pub(super) const fn kind(&self) -> &'static str { + match self { + Self::Config(_) => "config", + Self::ThemeBase(_) => "theme-base", + Self::ThemePaths(_) => "theme-paths", + } + } + + pub(super) fn safe_fingerprint(&self) -> String { + // Hash private parser details so distinct failures remain distinguishable without display + let mut hasher = DefaultHasher::new(); + format!("{self:?}").hash(&mut hasher); + format!("{:016x}", hasher.finish()) + } +} + +pub(in crate::ui) fn log_reload_rejection(failure: &ReloadFailure) { + // Raw parser errors can contain complete config lines, commands, labels, and paths + tracing::debug!( + kind = failure.kind(), + fingerprint = %failure.safe_fingerprint(), + "config reload rejected" + ); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/panel.rs b/crates/unixnotis-center/src/ui/reload/config/panel.rs new file mode 100644 index 000000000..5b149b0fa --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/panel.rs @@ -0,0 +1,135 @@ +//! Panel presentation updates applied after a configuration reload + +use gtk::prelude::*; +use unixnotis_core::{css::hooks, Config, PanelDebugLevel, PanelWidgetSection}; + +use crate::ui::{panel, UiState}; + +impl UiState { + pub(in crate::ui) fn apply_reloaded_panel(&mut self, config: &Config) { + // Geometry goes first so later sections can size themselves from the final panel width + panel::geometry::apply_panel_config(&self.panel, config, self.work_area); + panel::motion::apply_reduced_motion(&self.panel, config.panel.reduced_motion); + self.panel.header.title.set_label(&config.panel.title); + self.panel.header.subtitle.set_label(&config.panel.subtitle); + self.panel + .header + .subtitle + .set_visible(!config.panel.subtitle.is_empty()); + self.panel + .header + .search + .entry + .set_placeholder_text(Some(&config.panel.search_placeholder)); + self.panel + .header + .search + .magnifier + .set_icon_name(Some(&config.panel.search_magnifier_icon)); + self.panel + .header + .search + .clear_button + .set_visible(!self.panel.header.search.entry.text().is_empty()); + let search_open = + config.panel.search_visible || self.panel.header.actions.search_toggle.is_active(); + panel::header::search::set_search_open( + &self.panel.header.actions.search_toggle, + &self.panel.header.search.revealer, + &self.panel.header.search.entry, + self.search_toggle_guard.as_ref(), + search_open, + ); + self.panel + .header + .action_row + .set_visible(config.panel.action_row_visible); + panel::apply::apply_reloaded_panel_chrome(&self.panel, &config.panel); + self.panel + .sections + .notification_header + .set_label(&config.panel.recent_notifications_label); + self.panel.sections.notification_header.set_visible( + config.panel.notification_section_visible + && !config.panel.recent_notifications_label.is_empty(), + ); + self.panel + .sections + .notification_header_row + .set_visible(panel::body::notification_header_row_visible(&config.panel)); + self.update_section_header( + &self.panel.sections.toggle_section_header, + &config.panel.quick_actions_label, + ); + self.update_section_header( + &self.panel.sections.stat_section_header, + &config.panel.system_status_label, + ); + if config.panel.notification_section_visible { + self.panel + .sections + .notification_container + .add_css_class(hooks::panel_shell::RECENT_SECTION); + } else { + self.panel + .sections + .notification_container + .remove_css_class(hooks::panel_shell::RECENT_SECTION); + } + self.panel + .sections + .scroller + .set_vexpand(config.panel.notification_list_expand); + self.panel + .sections + .notification_container + .set_vexpand(config.panel.notification_list_expand); + panel::apply::apply_reloaded_body_order(&self.panel, &config.panel.section_order); + self.apply_widget_order(&config.panel.widget_order); + panel::body::apply_widget_density( + &self.panel.sections.widget_stack, + &self.panel.sections.quick_controls, + &self.panel.sections.media_container, + config.widgets.density, + ); + self.panel + .sections + .footer + .set_label(&config.panel.footer_label); + self.panel + .sections + .footer + .set_visible(!config.panel.footer_label.is_empty()); + self.log_debug(PanelDebugLevel::Info, || { + "panel config applied after reload".to_string() + }); + } + + fn update_section_header(&self, header: >k::Label, label: &str) { + // Section headers are built once and updated in place on config reload + header.set_label(label); + header.set_visible(!label.is_empty()); + } + + fn apply_widget_order(&self, order: &[PanelWidgetSection]) { + let mut previous: Option = None; + for section in order { + // Config enum values map to the long-lived container built at startup + let child: gtk::Widget = match section { + PanelWidgetSection::Media => self.panel.sections.media_container.clone().upcast(), + PanelWidgetSection::Toggles => { + self.panel.sections.toggle_container.clone().upcast() + } + PanelWidgetSection::Sliders => self.panel.sections.quick_controls.clone().upcast(), + PanelWidgetSection::Stats => self.panel.sections.stat_container.clone().upcast(), + PanelWidgetSection::Cards => self.panel.sections.card_container.clone().upcast(), + }; + self.panel + .sections + .widget_stack + .reorder_child_after(&child, previous.as_ref()); + // The next child is inserted after the child placed in this iteration + previous = Some(child); + } + } +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs b/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs new file mode 100644 index 000000000..dbc4461e9 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/flow.rs @@ -0,0 +1,82 @@ +use std::fs; + +use gtk::prelude::*; +use unixnotis_core::{EmptyStateAlignment, Margins, ToggleWidgetConfig}; + +use super::super::outcome::ConfigReloadOutcome; +use super::support::{state, write_config}; + +#[gtk::test] +fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { + let mut state = state(); + let mut reloaded = state.config.clone(); + reloaded.panel.title = "Reloaded from disk".to_string(); + reloaded.panel.footer_label = "Ready".to_string(); + reloaded.panel.empty_alignment = EmptyStateAlignment::Auto; + reloaded.panel.empty_offset_top = 44; + reloaded.theme.base_css = "reloaded-base.css".to_string(); + reloaded.widgets.toggles = vec![ToggleWidgetConfig { + enabled: true, + kind: Some("test-toggle".to_string()), + label: "Test Toggle".to_string(), + ..ToggleWidgetConfig::default() + }]; + write_config(&state.config_path, &reloaded); + state.work_area = Some(Margins { + top: 1, + right: 2, + bottom: 3, + left: 4, + }); + + let outcome = state.reload_config(); + + assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); + assert_eq!(state.config.panel.title, "Reloaded from disk"); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert_eq!(state.panel.sections.footer.text(), "Ready"); + assert!(state.panel.sections.footer.get_visible()); + assert!(state.toggles.is_some()); + assert!(state + .panel + .sections + .toggle_container + .first_child() + .is_some()); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Start); + assert_eq!(state.list.empty_overlay.margin_top(), 44); + assert!(state.work_area.is_none()); + assert_eq!( + state.css.theme_paths().base_css, + state + .config_path + .parent() + .expect("config path should have a parent") + .join("reloaded-base.css") + ); + + state.widgets_collapsed = true; + state.apply_list_config_after_reload(&reloaded); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Center); + assert_eq!(state.list.empty_overlay.margin_top(), 0); + + fs::write(&state.config_path, "[panel\ntitle = broken") + .expect("malformed config should be written"); + let outcome = state.reload_config(); + assert!(matches!(outcome, ConfigReloadOutcome::Rejected { .. })); + assert_eq!(state.config.panel.title, "Reloaded from disk"); + assert_eq!(state.panel.header.title.text(), "Reloaded from disk"); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state + .panel + .reload_notice + .label + .text() + .contains("previous configuration is still active")); + assert!(!state + .panel + .reload_notice + .label + .text() + .contains("title = broken")); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs b/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs new file mode 100644 index 000000000..56e9484c4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/mod.rs @@ -0,0 +1,6 @@ +mod flow; +mod notice; +mod outcome; +mod panel; +mod support; +mod widgets; diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs new file mode 100644 index 000000000..ab3a5d64e --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/notice.rs @@ -0,0 +1,144 @@ +use std::fs; + +use gtk::prelude::*; + +use super::super::outcome::ConfigReloadOutcome; +use super::support::{enable_missing_panel_layer_fixture, state, write_config}; + +#[gtk::test] +fn accepted_reload_clears_rejected_config_notice() { + let mut state = state(); + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + let valid = state.config.clone(); + write_config(&state.config_path, &valid); + let theme_paths = valid + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + for path in [ + theme_paths.base_css, + theme_paths.panel_css, + theme_paths.widgets_css, + theme_paths.media_css, + ] { + fs::write(path, "/* intentionally valid */").expect("theme css"); + } + + let outcome = state.reload_config(); + + assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); + assert!(!state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { + let mut state = state(); + fs::write(&state.config_path, "[panel\ntitle = first").expect("first broken config"); + let _outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + state.panel.reload_notice.close.emit_clicked(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let _same_outcome = state.reload_config(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + fs::write(&state.config_path, "config_version = 999").expect("distinct broken config"); + let _distinct_outcome = state.reload_config(); + assert!(state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn changed_css_failure_reopens_after_the_previous_failure_was_dismissed() { + let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); + let first_report = state.reload_css(); + assert!(first_report.read_failures().count() > 1); + assert!(state.panel.reload_notice.revealer.reveals_child()); + + state.panel.reload_notice.close.emit_clicked(); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let same_report = state.reload_css(); + assert!(same_report.read_failures().count() > 1); + assert!(!state.panel.reload_notice.revealer.reveals_child()); + + let theme_paths = state + .config + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + fs::write(theme_paths.base_css, "/* one layer recovered */").expect("base theme css"); + + let changed_report = state.reload_css(); + assert!(changed_report.read_failures().count() > 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); +} + +#[gtk::test] +fn successful_css_only_reload_does_not_clear_config_rejection_notice() { + let mut state = state(); + let theme_paths = state + .config + .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) + .expect("theme paths"); + for path in [ + theme_paths.base_css, + theme_paths.panel_css, + theme_paths.widgets_css, + theme_paths.media_css, + ] { + fs::write(path, "/* valid reload css */").expect("theme css"); + } + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + let rejection = state.panel.reload_notice.label.text(); + + let report = state.reload_css(); + + assert_eq!(report.read_failures().count(), 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); +} + +#[gtk::test] +fn css_failure_cannot_replace_an_active_config_rejection() { + let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); + fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); + let _outcome = state.reload_config(); + let rejection = state.panel.reload_notice.label.text(); + + let report = state.reload_css(); + + assert!(report.read_failures().count() > 0); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert_eq!(state.panel.reload_notice.label.text(), rejection); + assert!(state + .panel + .reload_notice + .shell + .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_ERROR)); +} + +#[gtk::test] +fn css_reload_notice_summarizes_multiple_unreadable_layers() { + let mut state = state(); + enable_missing_panel_layer_fixture(&mut state); + let report = state.reload_css(); + + assert!(report.read_failures().count() > 1); + assert!(state.panel.reload_notice.revealer.reveals_child()); + assert!(state + .panel + .reload_notice + .label + .text() + .contains("other layer")); + assert!(state + .panel + .reload_notice + .shell + .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs b/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs new file mode 100644 index 000000000..27f479836 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/outcome.rs @@ -0,0 +1,60 @@ +use std::io::{self, Write}; +use std::sync::{Arc, Mutex}; + +use unixnotis_core::ConfigError; + +use super::super::outcome::{log_reload_rejection, ReloadFailure}; + +struct CapturedWriter(Arc>>); + +impl Write for CapturedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.0 + .lock() + .map_err(|_poisoned| io::Error::other("captured log lock poisoned"))? + .write(buffer) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[test] +fn reload_failure_kinds_remain_stable_for_structured_logs() { + assert_eq!( + ReloadFailure::Config(ConfigError::MissingHome).kind(), + "config" + ); + assert_eq!( + ReloadFailure::ThemeBase("missing".to_string()).kind(), + "theme-base" + ); + assert_eq!( + ReloadFailure::ThemePaths("invalid".to_string()).kind(), + "theme-paths" + ); +} + +#[test] +fn rejected_config_logs_never_include_private_parser_text() { + let output = Arc::new(Mutex::new(Vec::new())); + let writer_output = Arc::clone(&output); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer(move || CapturedWriter(Arc::clone(&writer_output))) + .finish(); + let failure = ReloadFailure::Config(ConfigError::ParseFailed( + "private-center-parser-sentinel".to_string(), + )); + + tracing::subscriber::with_default(subscriber, || log_reload_rejection(&failure)); + + let rendered = String::from_utf8(output.lock().expect("lock captured center output").clone()) + .expect("center output should be UTF-8"); + assert!(rendered.contains("kind=\"config\"")); + assert!(rendered.contains("fingerprint=")); + assert!(!rendered.contains("private-center-parser-sentinel")); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs new file mode 100644 index 000000000..5e35688e5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/panel.rs @@ -0,0 +1,155 @@ +use gtk::prelude::*; +use unixnotis_core::{PanelRequest, WidgetDensity}; + +use super::support::{same_widget, state}; + +#[gtk::test] +fn reloaded_panel_applies_copy_and_widget_density() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.title = "Operations".to_string(); + config.panel.subtitle = "Live state".to_string(); + config.panel.reduced_motion = true; + config.widgets.density = WidgetDensity::Compact; + + state.apply_reloaded_panel(&config); + + assert_eq!(state.panel.header.title.text(), "Operations"); + assert_eq!(state.panel.header.subtitle.text(), "Live state"); + assert!(state.panel.header.subtitle.get_visible()); + assert!(state + .panel + .root + .has_css_class(unixnotis_core::hooks::panel_shell::REDUCED_MOTION)); + assert_eq!( + state.panel.sections.widget_revealer.transition_duration(), + 0 + ); + assert_eq!(state.panel.header.search.revealer.transition_duration(), 0); + assert_eq!(state.panel.reload_notice.revealer.transition_duration(), 0); + assert_eq!(state.panel.sections.widget_stack.spacing(), 6); +} + +#[gtk::test] +fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { + let new_state = state; + let mut state = new_state(); + let mut config = state.config.clone(); + config.panel.subtitle.clear(); + config.panel.search_visible = false; + config.panel.action_row_visible = false; + config.panel.notification_section_visible = true; + config.panel.recent_notifications_label.clear(); + config.panel.quick_actions_label.clear(); + config.panel.system_status_label = "Resources".to_string(); + config.panel.notification_list_expand = false; + config.panel.footer_label.clear(); + config.panel.clear_button_placement = + unixnotis_core::PanelClearButtonPlacement::NotificationHeader; + config.panel.widget_order = vec![ + unixnotis_core::PanelWidgetSection::Cards, + unixnotis_core::PanelWidgetSection::Stats, + unixnotis_core::PanelWidgetSection::Toggles, + unixnotis_core::PanelWidgetSection::Media, + unixnotis_core::PanelWidgetSection::Sliders, + ]; + state.panel.header.actions.search_toggle.set_active(true); + + state.apply_reloaded_panel(&config); + + assert!(!state.panel.header.subtitle.get_visible()); + assert!(state.panel.header.search.revealer.reveals_child()); + assert!(!state.panel.header.action_row.get_visible()); + assert!(!state.panel.sections.notification_header.get_visible()); + assert!(!state.panel.sections.toggle_section_header.get_visible()); + assert_eq!(state.panel.sections.stat_section_header.text(), "Resources"); + assert!(state.panel.sections.stat_section_header.get_visible()); + assert!(state + .panel + .sections + .notification_container + .has_css_class(unixnotis_core::hooks::panel_shell::RECENT_SECTION)); + assert!(!state.panel.sections.scroller.vexpands()); + assert!(!state.panel.sections.notification_container.vexpands()); + assert!(!state.panel.header.actions.clear_button.get_visible()); + assert!(state.panel.sections.clear_header_button.get_visible()); + assert!(!state.panel.sections.footer.get_visible()); + + let first = state + .panel + .sections + .widget_stack + .first_child() + .expect("widget stack should keep configured sections"); + assert!(same_widget(&first, &state.panel.sections.card_container)); + + let mut hidden_state = new_state(); + hidden_state.apply_reloaded_panel(&config); + assert!(!hidden_state.panel.header.actions.search_toggle.is_active()); + assert!(!hidden_state.panel.header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn reload_enables_configured_search_in_toggle_and_revealer() { + let mut state = state(); + let mut config = state.config.clone(); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + + config.panel.search_visible = true; + state.apply_reloaded_panel(&config); + + assert!(state.panel.header.actions.search_toggle.is_active()); + assert!(state.panel.header.search.revealer.reveals_child()); +} + +#[gtk::test] +fn panel_reload_updates_search_icons_and_clear_visibility_from_live_text() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.search_visible = true; + config.panel.search_placeholder = "Filter alerts".to_string(); + config.panel.search_magnifier_icon = "system-search-symbolic".to_string(); + state.panel.header.search.entry.set_text("disk"); + + state.apply_reloaded_panel(&config); + + assert_eq!( + state + .panel + .header + .search + .entry + .placeholder_text() + .as_deref(), + Some("Filter alerts") + ); + assert_eq!( + state.panel.header.search.magnifier.icon_name().as_deref(), + Some("system-search-symbolic") + ); + assert!(state.panel.header.search.clear_button.get_visible()); + + state.panel.header.search.entry.set_text(""); + state.apply_reloaded_panel(&config); + assert!(!state.panel.header.search.clear_button.get_visible()); +} + +#[gtk::test] +fn panel_close_and_reopen_keep_transient_search_closed() { + let mut state = state(); + state.apply_panel_request(PanelRequest::open()); + state.panel.header.actions.search_toggle.set_active(true); + state.panel.header.search.entry.set_text("urgent"); + assert!(state.panel.header.search.revealer.reveals_child()); + + state.apply_panel_request(PanelRequest::close()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + assert!(state.panel.header.search.entry.text().is_empty()); + + state.apply_panel_request(PanelRequest::open()); + assert!(!state.panel.header.actions.search_toggle.is_active()); + assert!(!state.panel.header.search.revealer.reveals_child()); + state.apply_panel_request(PanelRequest::close()); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/support.rs b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs new file mode 100644 index 000000000..0f806d2bc --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/support.rs @@ -0,0 +1,75 @@ +use std::fs; +use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use gtk::prelude::*; +use unixnotis_core::Config; +use unixnotis_ui::css::CssManager; + +use crate::control::{UiCommand, UiEvent}; +use crate::ui::{UiState, UiStateInit}; + +static APP_ID: AtomicUsize = AtomicUsize::new(0); + +pub(super) fn state() -> UiState { + let serial = APP_ID.fetch_add(1, Ordering::Relaxed); + let app = gtk::Application::builder() + .application_id(format!("dev.unixnotis.config.reload.test{serial}")) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("test application should register"); + + let mut config = Config::default(); + // External widget processes are irrelevant to configuration application tests + config.media.enabled = false; + config.widgets.volume.enabled = false; + config.widgets.brightness.enabled = false; + config.widgets.toggles.clear(); + config.widgets.stats.clear(); + config.widgets.cards.clear(); + + let config_dir = std::env::temp_dir().join(format!( + "unixnotis-config-reload-test-{}-{serial}", + std::process::id(), + )); + let config_path = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("test config directory should exist"); + let theme_paths = config + .resolve_theme_paths_from(&config_dir) + .expect("test theme paths should resolve"); + let css = CssManager::new_panel(theme_paths, config.theme.clone()); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); + let (event_tx, _event_rx) = async_channel::bounded::(8); + let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); + + UiState::new(UiStateInit { + app, + config, + config_path, + command_tx, + css, + event_tx, + media_handle: None, + runtime, + }) +} + +pub(super) fn same_widget>(left: >k::Widget, right: &W) -> bool { + left == right.as_ref() +} + +pub(super) fn write_config(path: &Path, config: &Config) { + let text = toml::to_string(config).expect("test config should serialize"); + fs::write(path, text).expect("test config should be written"); +} + +pub(super) fn enable_missing_panel_layer_fixture(state: &mut UiState) { + state + .css + .update_theme(state.css.theme_paths().clone(), state.config.theme.clone()); + // A popup-only custom layer makes the panel fallback path observable + fs::write(&state.css.theme_paths().popup_css, "/* popup only */") + .expect("popup theme fixture should be written"); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs new file mode 100644 index 000000000..c24588992 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/tests/widgets.rs @@ -0,0 +1,21 @@ +use gtk::prelude::*; +use unixnotis_core::EmptyStateAlignment; + +use super::support::state; + +#[gtk::test] +fn reloaded_list_applies_explicit_empty_alignment() { + let mut state = state(); + let mut config = state.config.clone(); + config.panel.empty_text = "Nothing pending".to_string(); + config.panel.no_matching_text = "Nothing found".to_string(); + config.panel.empty_alignment = EmptyStateAlignment::End; + config.panel.empty_offset_top = 44; + + state.apply_list_config_after_reload(&config); + + assert_eq!(state.list.empty_text, "Nothing pending"); + assert_eq!(state.list.no_matching_text, "Nothing found"); + assert_eq!(state.list.empty_overlay.valign(), gtk::Align::End); + assert_eq!(state.list.empty_overlay.margin_top(), 0); +} diff --git a/crates/unixnotis-center/src/ui/reload/config/widgets.rs b/crates/unixnotis-center/src/ui/reload/config/widgets.rs new file mode 100644 index 000000000..33046ddc8 --- /dev/null +++ b/crates/unixnotis-center/src/ui/reload/config/widgets.rs @@ -0,0 +1,78 @@ +//! Widget, list, and refresh updates applied after configuration reload + +use tracing::debug; +use unixnotis_core::Config; + +use crate::ui::notifications; +use crate::ui::widget_builders::{build_extra_widgets, build_quick_controls, clear_container}; +use crate::ui::UiState; + +impl UiState { + pub(super) fn apply_widget_sections_after_reload( + &mut self, + config: &Config, + widgets_changed: bool, + ) { + if widgets_changed { + // Widget rebuilds are the expensive part, so skip them when structure is unchanged + self.apply_widget_config(config); + } else { + debug!("widget config unchanged; skipping rebuild"); + } + } + + pub(in crate::ui) fn apply_list_config_after_reload(&mut self, config: &Config) { + // Menu inputs and typed deadlines are live configuration like the surrounding actions + self.dnd_duration_menu.apply_config(&config.panel); + // A compact value object prevents the list from reading half-applied UI state + let list_config = notifications::NotificationListConfig { + max_active: config.history.max_active, + max_entries: config.history.max_entries, + transient_to_history: config.history.transient_to_history, + show_notification_metadata: config.panel.notification_metadata_visible, + notification_metadata: config.panel.notification_metadata.clone(), + notification_corners: config.theme.notification_corners, + show_notification_thumbnails: config.panel.notification_thumbnails_visible, + show_notification_avatars: config.panel.notification_avatars_visible, + reduced_motion: config.panel.reduced_motion, + empty_text: config.panel.empty_text.clone(), + no_matching_text: config.panel.no_matching_text.clone(), + empty_offset_top: config.panel.empty_offset_top, + empty_alignment: config.panel.empty_alignment, + }; + self.list.apply_config(&list_config); + // Empty-state placement depends on both list settings and current widget visibility + self.set_widgets_collapsed(self.widgets_collapsed); + } + + pub(super) fn finish_reload_runtime(&mut self, config: &Config) { + // Refresh timers may need new intervals even when widget structure is unchanged + self.restart_refresh_timer(); + if config.panel.respect_work_area { + // Clearing the cache prevents stale compositor margins from surviving reload + self.work_area = None; + // Work area is refreshed after reload so compositor margins can update one more time + crate::ui::hyprland::refresh_reserved_work_area( + config.panel.output.clone(), + self.event_tx.clone(), + ); + } + } + + fn apply_widget_config(&mut self, config: &Config) { + // Old children are cleared first so the rebuild can treat each section as fresh state + clear_container(&self.panel.sections.quick_controls); + let (volume, brightness) = build_quick_controls(&self.panel, config); + self.volume = volume; + self.brightness = brightness; + clear_container(&self.panel.sections.toggle_container); + clear_container(&self.panel.sections.stat_container); + clear_container(&self.panel.sections.card_container); + let (toggles, stats, cards) = + build_extra_widgets(&self.panel, config, &self.widget_icon_resolver); + // Replace all handles together after the containers hold the new children + self.toggles = toggles; + self.stats = stats; + self.cards = cards; + } +} diff --git a/crates/unixnotis-center/src/ui/reload/notices.rs b/crates/unixnotis-center/src/ui/reload/notices.rs index d2a8ae697..292171e1d 100644 --- a/crates/unixnotis-center/src/ui/reload/notices.rs +++ b/crates/unixnotis-center/src/ui/reload/notices.rs @@ -1,7 +1,7 @@ //! Priority and dismissal state for configuration and CSS reload notices #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(super) enum ReloadNoticeKind { +pub(in crate::ui) enum ReloadNoticeKind { Config, Css, } diff --git a/crates/unixnotis-center/src/ui/reload/tests/config.rs b/crates/unixnotis-center/src/ui/reload/tests/config.rs deleted file mode 100644 index b129f4497..000000000 --- a/crates/unixnotis-center/src/ui/reload/tests/config.rs +++ /dev/null @@ -1,394 +0,0 @@ -use std::io::{self, Write}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -use std::{fs, path::Path}; - -use gtk::prelude::*; -use unixnotis_core::{ - Config, ConfigError, EmptyStateAlignment, Margins, ToggleWidgetConfig, WidgetDensity, -}; -use unixnotis_ui::css::CssManager; - -use super::super::super::{UiState, UiStateInit}; -use super::{log_reload_rejection, ConfigReloadOutcome, ReloadFailure}; -use crate::control::{UiCommand, UiEvent}; - -static APP_ID: AtomicUsize = AtomicUsize::new(0); - -struct CapturedWriter(Arc>>); - -impl Write for CapturedWriter { - fn write(&mut self, buffer: &[u8]) -> io::Result { - self.0 - .lock() - .map_err(|_poisoned| io::Error::other("captured log lock poisoned"))? - .write(buffer) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -#[test] -fn reload_failure_kinds_remain_stable_for_structured_logs() { - assert_eq!( - ReloadFailure::Config(ConfigError::MissingHome).kind(), - "config" - ); - assert_eq!( - ReloadFailure::ThemeBase("missing".to_string()).kind(), - "theme-base" - ); - assert_eq!( - ReloadFailure::ThemePaths("invalid".to_string()).kind(), - "theme-paths" - ); -} - -#[test] -fn rejected_config_logs_never_include_private_parser_text() { - let output = Arc::new(Mutex::new(Vec::new())); - let writer_output = Arc::clone(&output); - let subscriber = tracing_subscriber::fmt() - .without_time() - .with_ansi(false) - .with_max_level(tracing::Level::DEBUG) - .with_writer(move || CapturedWriter(Arc::clone(&writer_output))) - .finish(); - let failure = ReloadFailure::Config(ConfigError::ParseFailed( - "private-center-parser-sentinel".to_string(), - )); - - tracing::subscriber::with_default(subscriber, || log_reload_rejection(&failure)); - - let rendered = String::from_utf8(output.lock().expect("lock captured center output").clone()) - .expect("center output should be UTF-8"); - assert!(rendered.contains("kind=\"config\"")); - assert!(rendered.contains("fingerprint=")); - assert!(!rendered.contains("private-center-parser-sentinel")); -} - -fn state() -> UiState { - let serial = APP_ID.fetch_add(1, Ordering::Relaxed); - let app = gtk::Application::builder() - .application_id(format!("dev.unixnotis.config.reload.test{serial}")) - .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) - .build(); - app.register(None::<>k::gio::Cancellable>) - .expect("test application should register"); - - let mut config = Config::default(); - // External widget processes are irrelevant to configuration application tests - config.media.enabled = false; - config.widgets.volume.enabled = false; - config.widgets.brightness.enabled = false; - config.widgets.toggles.clear(); - config.widgets.stats.clear(); - config.widgets.cards.clear(); - - let config_dir = std::env::temp_dir().join(format!( - "unixnotis-config-reload-test-{}-{serial}", - std::process::id(), - )); - let config_path = config_dir.join("config.toml"); - fs::create_dir_all(&config_dir).expect("test config directory should exist"); - let theme_paths = config - .resolve_theme_paths_from(&config_dir) - .expect("test theme paths should resolve"); - let css = CssManager::new_panel(theme_paths, config.theme.clone()); - let (command_tx, _command_rx) = tokio::sync::mpsc::channel::(8); - let (event_tx, _event_rx) = async_channel::bounded::(8); - let runtime = Arc::new(tokio::runtime::Runtime::new().expect("test runtime should build")); - - UiState::new(UiStateInit { - app, - config, - config_path, - command_tx, - css, - event_tx, - media_handle: None, - runtime, - }) -} - -fn same_widget>(left: >k::Widget, right: &W) -> bool { - left == right.as_ref() -} - -fn write_config(path: &Path, config: &Config) { - let text = toml::to_string(config).expect("test config should serialize"); - fs::write(path, text).expect("test config should be written"); -} - -#[gtk::test] -fn reloaded_panel_applies_copy_and_widget_density() { - let mut state = state(); - let mut config = state.config.clone(); - config.panel.title = "Operations".to_string(); - config.panel.subtitle = "Live state".to_string(); - config.widgets.density = WidgetDensity::Compact; - - state.apply_reloaded_panel(&config); - - assert_eq!(state.panel.header_title.text(), "Operations"); - assert_eq!(state.panel.header_subtitle.text(), "Live state"); - assert!(state.panel.header_subtitle.get_visible()); - assert_eq!(state.panel.widget_stack.spacing(), 6); -} - -#[gtk::test] -fn reloaded_list_applies_explicit_empty_alignment() { - let mut state = state(); - let mut config = state.config.clone(); - config.panel.empty_text = "Nothing pending".to_string(); - config.panel.empty_alignment = EmptyStateAlignment::End; - config.panel.empty_offset_top = 44; - - state.apply_list_config_after_reload(&config); - - assert_eq!(state.list.empty_text, "Nothing pending"); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::End); - assert_eq!(state.list.empty_overlay.margin_top(), 0); -} - -#[gtk::test] -fn reloaded_panel_applies_visibility_placement_and_widget_order_edges() { - let new_state = state; - let mut state = new_state(); - let mut config = state.config.clone(); - config.panel.subtitle.clear(); - config.panel.search_visible = false; - config.panel.action_row_visible = false; - config.panel.notification_section_visible = true; - config.panel.recent_notifications_label.clear(); - config.panel.quick_actions_label.clear(); - config.panel.system_status_label = "Resources".to_string(); - config.panel.notification_list_expand = false; - config.panel.footer_label.clear(); - config.panel.clear_button_placement = - unixnotis_core::PanelClearButtonPlacement::NotificationHeader; - config.panel.widget_order = vec![ - unixnotis_core::PanelWidgetSection::Cards, - unixnotis_core::PanelWidgetSection::Stats, - unixnotis_core::PanelWidgetSection::Toggles, - unixnotis_core::PanelWidgetSection::Media, - unixnotis_core::PanelWidgetSection::Sliders, - ]; - state.panel.search_toggle.set_active(true); - - state.apply_reloaded_panel(&config); - - assert!(!state.panel.header_subtitle.get_visible()); - assert!(state.panel.search_revealer.reveals_child()); - assert!(!state.panel.header_action_row.get_visible()); - assert!(!state.panel.notification_header.get_visible()); - assert!(!state.panel.toggle_section_header.get_visible()); - assert_eq!(state.panel.stat_section_header.text(), "Resources"); - assert!(state.panel.stat_section_header.get_visible()); - assert!(state - .panel - .notification_container - .has_css_class(unixnotis_core::hooks::panel_shell::RECENT_SECTION)); - assert!(!state.panel.scroller.vexpands()); - assert!(!state.panel.notification_container.vexpands()); - assert!(!state.panel.clear_action_button.get_visible()); - assert!(state.panel.clear_header_button.get_visible()); - assert!(!state.panel.footer_label.get_visible()); - - let first = state - .panel - .widget_stack - .first_child() - .expect("widget stack should keep configured sections"); - assert!(same_widget(&first, &state.panel.card_container)); - - let mut hidden_state = new_state(); - hidden_state.apply_reloaded_panel(&config); - assert!(!hidden_state.panel.search_toggle.is_active()); - assert!(!hidden_state.panel.search_revealer.reveals_child()); -} - -#[gtk::test] -fn reload_config_applies_valid_file_and_rejects_malformed_replacement() { - let mut state = state(); - let mut reloaded = state.config.clone(); - reloaded.panel.title = "Reloaded from disk".to_string(); - reloaded.panel.footer_label = "Ready".to_string(); - reloaded.panel.empty_alignment = EmptyStateAlignment::Auto; - reloaded.panel.empty_offset_top = 44; - reloaded.theme.base_css = "reloaded-base.css".to_string(); - reloaded.widgets.toggles = vec![ToggleWidgetConfig { - enabled: true, - kind: Some("test-toggle".to_string()), - label: "Test Toggle".to_string(), - ..ToggleWidgetConfig::default() - }]; - write_config(&state.config_path, &reloaded); - state.work_area = Some(Margins { - top: 1, - right: 2, - bottom: 3, - left: 4, - }); - - let outcome = state.reload_config(); - - assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); - - assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header_title.text(), "Reloaded from disk"); - assert_eq!(state.panel.footer_label.text(), "Ready"); - assert!(state.panel.footer_label.get_visible()); - assert!(state.toggles.is_some()); - assert!(state.panel.toggle_container.first_child().is_some()); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Start); - assert_eq!(state.list.empty_overlay.margin_top(), 44); - assert!(state.work_area.is_none()); - assert_eq!( - state.css.theme_paths().base_css, - state - .config_path - .parent() - .expect("config path should have a parent") - .join("reloaded-base.css") - ); - - state.widgets_collapsed = true; - state.apply_list_config_after_reload(&reloaded); - assert_eq!(state.list.empty_overlay.valign(), gtk::Align::Center); - assert_eq!(state.list.empty_overlay.margin_top(), 0); - - fs::write(&state.config_path, "[panel\ntitle = broken") - .expect("malformed config should be written"); - let outcome = state.reload_config(); - assert!(matches!(outcome, ConfigReloadOutcome::Rejected { .. })); - assert_eq!(state.config.panel.title, "Reloaded from disk"); - assert_eq!(state.panel.header_title.text(), "Reloaded from disk"); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert!(state - .panel - .reload_notice_label - .text() - .contains("previous configuration is still active")); - assert!(!state - .panel - .reload_notice_label - .text() - .contains("title = broken")); -} - -#[gtk::test] -fn accepted_reload_clears_rejected_config_notice() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); - - let valid = state.config.clone(); - write_config(&state.config_path, &valid); - let theme_paths = valid - .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) - .expect("theme paths"); - for path in [ - theme_paths.base_css, - theme_paths.panel_css, - theme_paths.widgets_css, - theme_paths.media_css, - ] { - fs::write(path, "/* intentionally valid */").expect("theme css"); - } - - let outcome = state.reload_config(); - - assert!(matches!(outcome, ConfigReloadOutcome::Applied { .. })); - assert!(!state.panel.reload_notice_revealer.reveals_child()); -} - -#[gtk::test] -fn dismissed_reload_notice_stays_hidden_until_failure_fingerprint_changes() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = first").expect("first broken config"); - let _outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); - - let close = state - .panel - .reload_notice_shell - .last_child() - .expect("reload notice close button") - .downcast::() - .expect("reload notice close widget"); - close.emit_clicked(); - assert!(!state.panel.reload_notice_revealer.reveals_child()); - - let _same_outcome = state.reload_config(); - assert!(!state.panel.reload_notice_revealer.reveals_child()); - - fs::write(&state.config_path, "config_version = 999").expect("distinct broken config"); - let _distinct_outcome = state.reload_config(); - assert!(state.panel.reload_notice_revealer.reveals_child()); -} - -#[gtk::test] -fn successful_css_only_reload_does_not_clear_config_rejection_notice() { - let mut state = state(); - let theme_paths = state - .config - .resolve_theme_paths_from(state.config_path.parent().expect("config parent")) - .expect("theme paths"); - for path in [ - theme_paths.base_css, - theme_paths.panel_css, - theme_paths.widgets_css, - theme_paths.media_css, - ] { - fs::write(path, "/* valid reload css */").expect("theme css"); - } - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice_label.text(); - - let report = state.reload_css(); - - assert_eq!(report.read_failures().count(), 0); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert_eq!(state.panel.reload_notice_label.text(), rejection); -} - -#[gtk::test] -fn css_failure_cannot_replace_an_active_config_rejection() { - let mut state = state(); - fs::write(&state.config_path, "[panel\ntitle = broken").expect("broken config"); - let _outcome = state.reload_config(); - let rejection = state.panel.reload_notice_label.text(); - - let report = state.reload_css(); - - assert!(report.read_failures().count() > 0); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert_eq!(state.panel.reload_notice_label.text(), rejection); - assert!(state - .panel - .reload_notice_shell - .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_ERROR)); -} - -#[gtk::test] -fn css_reload_notice_summarizes_multiple_unreadable_layers() { - let mut state = state(); - let report = state.reload_css(); - - assert!(report.read_failures().count() > 1); - assert!(state.panel.reload_notice_revealer.reveals_child()); - assert!(state - .panel - .reload_notice_label - .text() - .contains("other layer")); - assert!(state - .panel - .reload_notice_shell - .has_css_class(unixnotis_core::css::hooks::panel_shell::RELOAD_NOTICE_WARNING)); -} diff --git a/crates/unixnotis-center/src/ui/state.rs b/crates/unixnotis-center/src/ui/state.rs index 1eed37207..da1d5cdc4 100644 --- a/crates/unixnotis-center/src/ui/state.rs +++ b/crates/unixnotis-center/src/ui/state.rs @@ -19,19 +19,29 @@ pub struct UiState { pub(super) config: Config, pub(super) config_path: std::path::PathBuf, pub(super) css: CssManager, - pub(super) panel: panel::PanelWidgets, + // This owner must drop before the panel so its manually parented popover can detach + pub(super) dnd_duration_menu: panel::header::dnd::DndDurationMenu, + pub(super) panel: panel::widgets::PanelWidgets, pub(super) list: notifications::NotificationList, // Shared resolver keeps icon cache and inflight decode tracking centralized pub(super) icon_resolver: Rc, // Widget assets are resolved relative to the active config file root pub(super) widget_icon_resolver: IconAssetResolver, pub(super) dnd_guard: Rc>, + // One countdown owns its deadline so completed GLib sources are never removed twice + pub(super) dnd_expiration_source: Option, pub(super) search_toggle_guard: Rc>, pub(super) panel_visible: bool, + // A hidden panel defers list painting, so the next open must reveal the newest complete row + pub(super) notifications_changed_while_hidden: bool, + // Each list rebuild invalidates older idle scroll callbacks + pub(super) notification_rebuild_generation: Rc>, + // Pointer and touch scrolling invalidate deferred forced resets explicitly + pub(super) scroll_user_generation: Rc>, pub(super) panel_visible_flag: Arc, pub(super) work_area: Option, - // Tracks the last rendered count to avoid redundant label updates - pub(super) last_count: Option, + // Tracks the last rendered counts to avoid redundant label updates + pub(super) last_count: Option, pub(super) media: Option, pub(super) media_handle: Option, // Holds the most recent media snapshot while the panel is hidden diff --git a/crates/unixnotis-center/src/ui/tests/command.rs b/crates/unixnotis-center/src/ui/tests/command.rs index 4ba5020c6..d032fd9f9 100644 --- a/crates/unixnotis-center/src/ui/tests/command.rs +++ b/crates/unixnotis-center/src/ui/tests/command.rs @@ -7,7 +7,7 @@ fn available_command_queue_receives_the_original_command() { try_send_command(&command_tx, UiCommand::SetDnd(true)); - assert_eq!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true))); + assert!(matches!(command_rx.try_recv(), Ok(UiCommand::SetDnd(true)))); } #[test] diff --git a/crates/unixnotis-center/src/ui/tests/motion.rs b/crates/unixnotis-center/src/ui/tests/motion.rs new file mode 100644 index 000000000..31804dad3 --- /dev/null +++ b/crates/unixnotis-center/src/ui/tests/motion.rs @@ -0,0 +1,30 @@ +//! Shared motion-policy tests + +use super::{apply_revealer_preference, immediate_reveal_edges}; + +#[test] +fn immediate_reveal_edges_only_finish_inflight_reduced_motion_transitions() { + assert_eq!( + immediate_reveal_edges(true, false, true), + Some([false, true]) + ); + assert_eq!( + immediate_reveal_edges(true, true, false), + Some([true, false]) + ); + assert_eq!(immediate_reveal_edges(true, true, true), None); + assert_eq!(immediate_reveal_edges(true, false, false), None); + assert_eq!(immediate_reveal_edges(false, false, true), None); + assert_eq!(immediate_reveal_edges(false, true, false), None); +} + +#[gtk::test] +fn reduced_motion_makes_revealer_transitions_immediate_and_restorable() { + let revealer = gtk::Revealer::new(); + + apply_revealer_preference(&revealer, 180, true); + assert_eq!(revealer.transition_duration(), 0); + + apply_revealer_preference(&revealer, 180, false); + assert_eq!(revealer.transition_duration(), 180); +} diff --git a/crates/unixnotis-center/src/ui/widget_builders.rs b/crates/unixnotis-center/src/ui/widget_builders.rs index 8c58b852d..4f7e8cc00 100644 --- a/crates/unixnotis-center/src/ui/widget_builders.rs +++ b/crates/unixnotis-center/src/ui/widget_builders.rs @@ -10,7 +10,7 @@ use unixnotis_core::{css::hooks, Config, IconAssetResolver}; use super::{panel, widgets}; pub(super) fn build_quick_controls( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, config: &Config, ) -> ( Option, @@ -20,7 +20,7 @@ pub(super) fn build_quick_controls( let mut has_widgets = false; let volume = if config.widgets.volume.enabled { let widget = widgets::volume::VolumeWidget::new(config.widgets.volume.clone()); - panel.quick_controls.append(widget.root()); + panel.sections.quick_controls.append(widget.root()); has_widgets = true; Some(widget) } else { @@ -29,19 +29,19 @@ pub(super) fn build_quick_controls( let brightness = if config.widgets.brightness.enabled { let widget = widgets::brightness::BrightnessWidget::new(config.widgets.brightness.clone()); - panel.quick_controls.append(widget.root()); + panel.sections.quick_controls.append(widget.root()); has_widgets = true; Some(widget) } else { None }; - panel.quick_controls.set_visible(has_widgets); + panel.sections.quick_controls.set_visible(has_widgets); (volume, brightness) } pub(super) fn build_extra_widgets( - panel: &panel::PanelWidgets, + panel: &panel::widgets::PanelWidgets, config: &Config, icon_resolver: &IconAssetResolver, ) -> ( @@ -58,10 +58,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = toggles.as_ref() { - panel.toggle_container.set_visible(true); - panel.toggle_container.append(grid.root()); + panel.sections.toggle_container.set_visible(true); + panel.sections.toggle_container.append(grid.root()); } else { - panel.toggle_container.set_visible(false); + panel.sections.toggle_container.set_visible(false); } // Stats widgets expose periodic metrics like CPU and memory usage. @@ -71,10 +71,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = stats.as_ref() { - panel.stat_container.set_visible(true); - panel.stat_container.append(grid.root()); + panel.sections.stat_container.set_visible(true); + panel.sections.stat_container.append(grid.root()); } else { - panel.stat_container.set_visible(false); + panel.sections.stat_container.set_visible(false); } // Card widgets are larger, multi-line information tiles. @@ -84,10 +84,10 @@ pub(super) fn build_extra_widgets( icon_resolver, ); if let Some(grid) = cards.as_ref() { - panel.card_container.set_visible(true); - panel.card_container.append(grid.root()); + panel.sections.card_container.set_visible(true); + panel.sections.card_container.append(grid.root()); } else { - panel.card_container.set_visible(false); + panel.sections.card_container.set_visible(false); } (toggles, stats, cards) diff --git a/crates/unixnotis-center/src/ui/widgets/cards/build.rs b/crates/unixnotis-center/src/ui/widgets/cards/build.rs index 77d2aac9f..5283a25cf 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/build.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/build.rs @@ -11,7 +11,7 @@ use unixnotis_core::{css::hooks, CardLayout, CardWidgetConfig, IconAssetResolver use super::super::icon_image::image_from_icon_config; use super::weather::{apply_card_kind_classes, card_icon_size, configure_card_icon}; use super::{CardGrid, CardItem}; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; impl CardGrid { pub fn new( diff --git a/crates/unixnotis-center/src/ui/widgets/cards/model.rs b/crates/unixnotis-center/src/ui/widgets/cards/model.rs index 3bc54e787..abc518c30 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/model.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/model.rs @@ -6,7 +6,7 @@ use std::time::Instant; use unixnotis_core::CardWidgetConfig; -use super::super::utils::RefreshBackoff; +use super::super::command_runtime::backoff::RefreshBackoff; pub struct CardGrid { // FlowBox root is embedded directly by the panel widget layout diff --git a/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs b/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs index f791f4783..b793b9a9c 100644 --- a/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs +++ b/crates/unixnotis-center/src/ui/widgets/cards/refresh.rs @@ -10,10 +10,11 @@ use unixnotis_core::{PanelDebugLevel, WidgetPluginConfig}; use super::common::apply_cached_value; use super::CardItem; use crate::diagnostics::panel_debug as debug; -use crate::ui::widgets::plugin::{parse_card_plugin_payload, PluginOutputLimits}; -use crate::ui::widgets::utils::{ - run_command_capture_async, run_command_capture_with_timeout_async, INFLIGHT_REFRESH_RECHECK, +use crate::ui::widgets::command_runtime::backoff::INFLIGHT_REFRESH_RECHECK; +use crate::ui::widgets::command_runtime::command::{ + run_command_capture_async, run_command_capture_with_timeout_async, }; +use crate::ui::widgets::plugin::{parse_card_plugin_payload, PluginOutputLimits}; impl CardItem { pub(super) fn refresh(&self, base_interval: Duration, force: bool) { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs index 17691921c..c3d59ed0a 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/refresh_backoff.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/backoff.rs @@ -78,5 +78,5 @@ fn scale_duration(base: Duration, mult: u64) -> Duration { } #[cfg(test)] -#[path = "tests/refresh_backoff.rs"] +#[path = "tests/backoff.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/action.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command/action.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/action.rs index 27df44114..4f821f084 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/action.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/action.rs @@ -5,7 +5,7 @@ use std::process::Output; use gtk::glib; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; @@ -13,11 +13,10 @@ use super::queue::enqueue_command; use super::{resolve_command_plan, CommandKind}; pub(in crate::ui::widgets) fn run_command_capture_action_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { // Action capture keeps action priority while still reporting completion let (tx, rx) = async_channel::bounded(1); - let cmd = cmd.trim(); if cmd.is_empty() { // Keep the receiver behavior consistent with the non-empty path let _ = tx.send_blocking(Err(io::Error::new( @@ -28,15 +27,15 @@ pub(in crate::ui::widgets) fn run_command_capture_action_async( } let plan = resolve_command_plan(cmd, CommandKind::Action); debug::log(PanelDebugLevel::Verbose, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("enqueue action-capture command: {snippet}") }); - enqueue_command(cmd.to_string(), plan, Some(tx)); + enqueue_command(cmd.clone(), plan, Some(tx)); rx } pub(in crate::ui::widgets) fn run_action_command_with_completion( - cmd: String, + cmd: CommandSpec, context: &'static str, on_complete: F, ) where @@ -44,7 +43,7 @@ pub(in crate::ui::widgets) fn run_action_command_with_completion( { // One helper keeps action completion and failure handling the same across widgets let rx = run_command_capture_action_async(&cmd); - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); glib::MainContext::default().spawn_local(async move { let failed = match rx.recv().await { Ok(Ok(output)) => !output.status.success(), diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/capture.rs similarity index 86% rename from crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/capture.rs index 9034236dc..4c306d094 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/capture.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/capture.rs @@ -4,7 +4,7 @@ use std::io; use std::process::Output; use std::time::Duration; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use super::exec::set_command_config_dir; use super::plan::{resolve_command_plan, CommandKind}; @@ -16,32 +16,31 @@ pub fn configure_command_config_dir(config_dir: std::path::PathBuf) { } pub(in crate::ui::widgets) fn run_command_capture_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Slow, None, "slow") } pub(in crate::ui::widgets) fn run_command_capture_with_timeout_async( - cmd: &str, + cmd: &CommandSpec, timeout: Duration, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Slow, Some(timeout), "custom-timeout") } pub(in crate::ui::widgets) fn run_command_capture_status_async( - cmd: &str, + cmd: &CommandSpec, ) -> async_channel::Receiver> { enqueue_capture(cmd, CommandKind::Fast, None, "fast") } fn enqueue_capture( - cmd: &str, + cmd: &CommandSpec, kind: CommandKind, timeout: Option, label: &str, ) -> async_channel::Receiver> { let (tx, rx) = async_channel::bounded(1); - let cmd = cmd.trim(); if cmd.is_empty() { let _ = tx.send_blocking(Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -55,10 +54,10 @@ fn enqueue_capture( plan = plan.with_timeout(timeout); } debug::log(PanelDebugLevel::Verbose, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("enqueue {label} command: {snippet}") }); - enqueue_command(cmd.to_string(), plan, Some(tx)); + enqueue_command(cmd.clone(), plan, Some(tx)); rx } diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/command_parse.rs new file mode 100644 index 000000000..921043329 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/command_parse.rs @@ -0,0 +1,51 @@ +//! Typed command heuristics for widget execution planning +//! +//! Keeps shell parsing and "slow command" classification localized so the +//! enqueue/worker pipeline can stay focused on execution and backpressure + +use unixnotis_core::CommandSpec; + +pub(super) fn is_probably_slow(cmd: &CommandSpec) -> bool { + // Shared shell detection owns every direct interpreter spelling + // Shell startup and script execution belong on the wider timeout budget + if cmd.uses_shell_command_string() { + return true; + } + + let CommandSpec::Direct { program, .. } = cmd else { + return true; + }; + + // Compare only executable basename so absolute paths and wrappers still match + let program_name = program + .file_name() + .unwrap_or(program.as_os_str()) + .to_string_lossy() + .to_ascii_lowercase(); + + if program_name == "sleep" { + return true; + } + + // Known utilities that are likely to block or hit D-Bus + const SLOW_TOKENS: [&str; 9] = [ + "nmcli", + "bluetoothctl", + "rfkill", + "udevadm", + "upower", + "playerctl", + "pactl", + "wpctl", + "brightnessctl", + ]; + if SLOW_TOKENS.contains(&program_name.as_str()) { + return true; + } + + false +} + +#[cfg(test)] +#[path = "tests/command_parse.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs similarity index 50% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs index 7613e20c6..af2866692 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/builder.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/builder.rs @@ -2,15 +2,13 @@ #[cfg(unix)] use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use tokio::process::Command as TokioCommand; use tracing::warn; -use unixnotis_core::Config; - -use super::super::command_parse::{parse_simple_command, ParsedCommand}; +use unixnotis_core::{filesystem::ContainedPath, CommandSpec, Config}; // Missing target used when a command tries to leave the config dir const BLOCKED_OUTSIDE_ROOT_PROGRAM: &str = ".unixnotis-blocked-command-path"; @@ -19,7 +17,9 @@ const SHELL_FALLBACK_CACHE_LIMIT: usize = 64; static COMMAND_CONFIG_DIR: OnceLock = OnceLock::new(); -pub(in crate::ui::widgets::utils::command) fn set_command_config_dir(config_dir: PathBuf) -> bool { +pub(in crate::ui::widgets::command_runtime::command) fn set_command_config_dir( + config_dir: PathBuf, +) -> bool { // Widget commands are built after startup, so retain the active custom config root if COMMAND_CONFIG_DIR.get() == Some(&config_dir) { return true; @@ -31,51 +31,56 @@ pub(in crate::ui::widgets::utils::command) fn set_command_config_dir(config_dir: true } -pub(super) fn spawn_capture_command(cmd: &str) -> std::io::Result { +pub(super) fn spawn_capture_command(cmd: &CommandSpec) -> std::io::Result { let mut command = build_command(cmd); command.stdout(Stdio::piped()).stderr(Stdio::piped()); command.spawn() } -pub(in crate::ui::widgets::utils::command) fn build_command(cmd: &str) -> Command { - if let Some(parsed) = parse_simple_command(cmd) { - // Simple commands avoid shell invocation for safety and performance - let mut command = Command::new(resolve_simple_program(&parsed.program)); - apply_parsed_command_env(&mut command, &parsed); - command.args(&parsed.args); - configure_command(&mut command); - return command; - } - - let _ = log_shell_fallback_once(cmd); - let mut command = Command::new("sh"); - // Non-login shell avoids profile sourcing on every widget refresh - command.arg("-c").arg(cmd); +pub(in crate::ui::widgets::command_runtime::command) fn build_command( + cmd: &CommandSpec, +) -> Command { + let mut command = match cmd { + CommandSpec::Direct { program, args, env } => { + let mut command = Command::new(resolve_direct_program(program)); + command.args(args).envs(env); + command + } + CommandSpec::Shell { script } => { + let _ = log_shell_fallback_once(script); + let mut command = Command::new("sh"); + // Non-login shell avoids profile sourcing on every widget refresh + command.arg("-c").arg(script); + command + } + }; configure_command(&mut command); command } -pub(super) fn spawn_capture_command_async(cmd: &str) -> std::io::Result { +pub(super) fn spawn_capture_command_async( + cmd: &CommandSpec, +) -> std::io::Result { // Mirrors the blocking builder but returns a Tokio child with piped output let mut command = build_tokio_command(cmd); command.stdout(Stdio::piped()).stderr(Stdio::piped()); command.spawn() } -fn build_tokio_command(cmd: &str) -> TokioCommand { - if let Some(parsed) = parse_simple_command(cmd) { - // Tokio command mirrors the blocking path for consistent behavior - let mut command = TokioCommand::new(resolve_simple_program(&parsed.program)); - apply_parsed_command_env_tokio(&mut command, &parsed); - command.args(&parsed.args); - configure_command_tokio(&mut command); - return command; - } - - // Shell fallback keeps blocking and asynchronous behavior aligned - let _ = log_shell_fallback_once(cmd); - let mut command = TokioCommand::new("sh"); - command.arg("-c").arg(cmd); +fn build_tokio_command(cmd: &CommandSpec) -> TokioCommand { + let mut command = match cmd { + CommandSpec::Direct { program, args, env } => { + let mut command = TokioCommand::new(resolve_direct_program(program)); + command.args(args).envs(env); + command + } + CommandSpec::Shell { script } => { + let _ = log_shell_fallback_once(script); + let mut command = TokioCommand::new("sh"); + command.arg("-c").arg(script); + command + } + }; configure_command_tokio(&mut command); command } @@ -129,13 +134,6 @@ fn configure_command(command: &mut Command) { command.process_group(0); } -fn apply_parsed_command_env(command: &mut Command, parsed: &ParsedCommand) { - // Only this child receives command-specific environment overrides - for (name, value) in &parsed.env { - command.env(name, value); - } -} - fn configure_command_tokio(command: &mut TokioCommand) { command.stdin(Stdio::null()); if let Some(config_dir) = command_config_dir() { @@ -146,19 +144,12 @@ fn configure_command_tokio(command: &mut TokioCommand) { command.process_group(0); } -fn apply_parsed_command_env_tokio(command: &mut TokioCommand, parsed: &ParsedCommand) { - // Timeout strategy must not change the child's environment - for (name, value) in &parsed.env { - command.env(name, value); - } -} - -fn resolve_simple_program(program: &str) -> PathBuf { +fn resolve_direct_program(program: &Path) -> PathBuf { // Runtime lookup keeps exported config-relative scripts portable - resolve_simple_program_from_root(command_config_dir().as_deref(), program) + resolve_direct_program_from_root(command_config_dir().as_deref(), program) } -pub(in crate::ui::widgets::utils::command) fn command_config_dir() -> Option { +pub(in crate::ui::widgets::command_runtime::command) fn command_config_dir() -> Option { if let Some(config_dir) = COMMAND_CONFIG_DIR.get() { return Some(config_dir.clone()); } @@ -166,58 +157,30 @@ pub(in crate::ui::widgets::utils::command) fn command_config_dir() -> Option, program: &str) -> PathBuf { - let path = Path::new(program); - if !looks_like_relative_path_program(program, path) { - return path.to_path_buf(); +fn resolve_direct_program_from_root(config_dir: Option<&Path>, program: &Path) -> PathBuf { + if !looks_like_relative_path_program(program) { + return program.to_path_buf(); } // Preset imports rewrite bundled scripts to config-root-relative paths if let Some(config_dir) = config_dir { - let rooted = config_dir.join(path); - if command_path_escapes_root(config_dir, &rooted) { + let Ok(contained) = ContainedPath::resolve_relative(config_dir, program) else { warn!( - command = %program, + command = %program.display(), root = %config_dir.display(), "blocked path-like command that escapes the UnixNotis config directory" ); return config_dir.join(BLOCKED_OUTSIDE_ROOT_PROGRAM); - } - return rooted; + }; + return contained.absolute(); } - path.to_path_buf() + program.to_path_buf() } -fn looks_like_relative_path_program(program: &str, path: &Path) -> bool { +fn looks_like_relative_path_program(program: &Path) -> bool { // Bare names still use PATH lookup, while path-like names use the config dir - !path.is_absolute() && (program == "." || program.contains('/')) -} - -fn command_path_escapes_root(config_dir: &Path, rooted_path: &Path) -> bool { - // Catch parent traversal without requiring either path to exist - let normalized_root = normalize_lexical_path(config_dir); - let normalized_candidate = normalize_lexical_path(rooted_path); - !normalized_candidate.starts_with(&normalized_root) -} - -fn normalize_lexical_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - if !normalized.pop() { - normalized.push(component.as_os_str()); - } - } - Component::Normal(part) => normalized.push(part), - Component::RootDir | Component::Prefix(_) => { - normalized.push(component.as_os_str()); - } - } - } - normalized + !program.is_absolute() && (program == Path::new(".") || program.components().count() > 1) } #[cfg(test)] diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/output.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/output.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/output.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/output.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/process.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/process.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/process.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/process.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs index fd0f99034..f5ffbc401 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/runner.rs @@ -3,6 +3,7 @@ use std::io; use std::process::Output; use std::time::Duration; +use unixnotis_core::CommandSpec; use tokio::runtime::Runtime; use tracing::warn; @@ -14,7 +15,7 @@ use super::output::{ }; use super::process::{kill_child_process, kill_process_group}; -pub(in crate::ui::widgets::utils::command) fn build_command_runtime() -> Option { +pub(in crate::ui::widgets::command_runtime::command) fn build_command_runtime() -> Option { // A current-thread runtime keeps frequent widget probes lightweight tokio::runtime::Builder::new_current_thread() .enable_io() @@ -30,8 +31,8 @@ pub(in crate::ui::widgets::utils::command) fn build_command_runtime() -> Option< .ok() } -pub(in crate::ui::widgets::utils::command) fn run_command_with_timeout( - cmd: &str, +pub(in crate::ui::widgets::command_runtime::command) fn run_command_with_timeout( + cmd: &CommandSpec, timeout: Duration, runtime: Option<&Runtime>, ) -> Result { @@ -43,14 +44,17 @@ pub(in crate::ui::widgets::utils::command) fn run_command_with_timeout( } fn run_command_with_timeout_async( - cmd: &str, + cmd: &CommandSpec, timeout: Duration, runtime: &Runtime, ) -> Result { runtime.block_on(async { run_command_with_timeout_inner(cmd, timeout).await }) } -async fn run_command_with_timeout_inner(cmd: &str, timeout: Duration) -> io::Result { +async fn run_command_with_timeout_inner( + cmd: &CommandSpec, + timeout: Duration, +) -> io::Result { let mut child = spawn_capture_command_async(cmd)?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); @@ -98,7 +102,7 @@ async fn run_command_with_timeout_inner(cmd: &str, timeout: Duration) -> io::Res }) } -fn run_command_with_timeout_blocking(cmd: &str, timeout: Duration) -> io::Result { +fn run_command_with_timeout_blocking(cmd: &CommandSpec, timeout: Duration) -> io::Result { let mut child = spawn_capture_command(cmd)?; let stdout_handle = match child.stdout.take() { Some(stdout) => spawn_reader(stdout), @@ -132,3 +136,7 @@ fn run_command_with_timeout_blocking(cmd: &str, timeout: Duration) -> io::Result stderr, }) } + +#[cfg(test)] +#[path = "tests/runner.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/builder.rs similarity index 72% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/builder.rs index 36086e844..11a5554db 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/builder.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/builder.rs @@ -2,17 +2,18 @@ use std::path::Path; use super::super::super::test_support::configure_command_test_root; use super::{ - build_command, build_tokio_command, command_config_dir, command_path_escapes_root, - log_shell_fallback_once, resolve_simple_program_from_root, set_command_config_dir, - shell_fallback_cache, shell_fallback_hash, SHELL_FALLBACK_CACHE_LIMIT, + build_command, build_tokio_command, command_config_dir, log_shell_fallback_once, + resolve_direct_program_from_root, set_command_config_dir, shell_fallback_cache, + shell_fallback_hash, SHELL_FALLBACK_CACHE_LIMIT, }; +use unixnotis_core::CommandSpec; #[test] fn resolve_simple_program_roots_relative_script_paths_in_config_dir() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "scripts/demo-widget"), + resolve_direct_program_from_root(Some(config_dir), Path::new("scripts/demo-widget")), config_dir.join("scripts/demo-widget") ); } @@ -22,7 +23,10 @@ fn resolve_simple_program_uses_supplied_config_dir_for_relative_scripts() { let config_dir = Path::new("/tmp/unixnotis-custom-config-root"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "scripts/unixnotis-blue-light-state"), + resolve_direct_program_from_root( + Some(config_dir), + Path::new("scripts/unixnotis-blue-light-state") + ), config_dir.join("scripts/unixnotis-blue-light-state") ); } @@ -32,12 +36,12 @@ fn resolve_simple_program_roots_dot_and_explicit_relative_paths() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "."), + resolve_direct_program_from_root(Some(config_dir), Path::new(".")), config_dir ); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "./scripts/probe"), - config_dir.join("./scripts/probe") + resolve_direct_program_from_root(Some(config_dir), Path::new("./scripts/probe")), + config_dir.join("scripts/probe") ); } @@ -46,17 +50,21 @@ fn resolve_simple_program_blocks_parent_traversal_paths() { let config_dir = Path::new("/tmp/demo/unixnotis"); assert_eq!( - resolve_simple_program_from_root(Some(config_dir), "../outside-script"), + resolve_direct_program_from_root(Some(config_dir), Path::new("../outside-script")), config_dir.join(".unixnotis-blocked-command-path") ); } #[test] -fn nested_parent_traversal_is_detected_after_normal_components() { +fn nested_parent_traversal_is_blocked_after_normal_components() { let config_dir = Path::new("/tmp/demo/unixnotis"); - let candidate = config_dir.join("scripts/../../outside-script"); - - assert!(command_path_escapes_root(config_dir, &candidate)); + assert_eq!( + resolve_direct_program_from_root( + Some(config_dir), + Path::new("scripts/../../outside-script") + ), + config_dir.join(".unixnotis-blocked-command-path") + ); } #[test] @@ -104,7 +112,7 @@ fn shell_fallback_hash_distinguishes_command_text() { fn direct_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command("true"); + let command = build_command(&CommandSpec::direct("true", [] as [&str; 0])); assert_eq!(command.get_current_dir(), Some(config_dir.as_path())); } @@ -113,7 +121,7 @@ fn direct_commands_use_the_config_directory_as_their_working_directory() { fn shell_fallback_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command(". ./lib/common.sh"); + let command = build_command(&CommandSpec::shell(". ./lib/common.sh")); assert_eq!(command.get_current_dir(), Some(config_dir.as_path())); } @@ -122,7 +130,7 @@ fn shell_fallback_commands_use_the_config_directory_as_their_working_directory() fn tokio_commands_use_the_config_directory_as_their_working_directory() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_tokio_command("true"); + let command = build_tokio_command(&CommandSpec::direct("true", [] as [&str; 0])); assert_eq!( command.as_std().get_current_dir(), @@ -134,7 +142,10 @@ fn tokio_commands_use_the_config_directory_as_their_working_directory() { fn loader_environment_and_command_cwd_share_the_same_config_root() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_command("LD_PRELOAD=./assets/libprobe.so scripts/probe"); + let command = build_command( + &CommandSpec::direct("scripts/probe", [] as [&str; 0]) + .with_env("LD_PRELOAD", "./assets/libprobe.so"), + ); let preload = command .get_envs() .find(|(name, _)| *name == "LD_PRELOAD") @@ -149,7 +160,10 @@ fn loader_environment_and_command_cwd_share_the_same_config_root() { fn tokio_loader_environment_and_command_cwd_share_the_same_config_root() { configure_command_test_root(); let config_dir = command_config_dir().expect("resolve command config directory"); - let command = build_tokio_command("LD_PRELOAD=./assets/libprobe.so scripts/probe"); + let command = build_tokio_command( + &CommandSpec::direct("scripts/probe", [] as [&str; 0]) + .with_env("LD_PRELOAD", "./assets/libprobe.so"), + ); let command = command.as_std(); let preload = command .get_envs() diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/output.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/output.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/exec/tests/output.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/output.rs diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/runner.rs new file mode 100644 index 000000000..5d42e5a7e --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/exec/tests/runner.rs @@ -0,0 +1,51 @@ +use std::io; +use std::time::Duration; + +use unixnotis_core::CommandSpec; + +use super::{build_command_runtime, run_command_with_timeout}; + +#[test] +fn blocking_runner_preserves_literal_direct_arguments() { + let command = CommandSpec::direct("printf", ["battery|charging"]); + + let output = run_command_with_timeout(&command, Duration::ZERO, None) + .expect("run direct command without a deadline"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"battery|charging"); +} + +#[test] +fn asynchronous_runner_preserves_stdout_and_stderr() { + let runtime = build_command_runtime().expect("build command runtime"); + let command = CommandSpec::shell("printf output; printf error >&2"); + + let output = run_command_with_timeout(&command, Duration::from_secs(1), Some(&runtime)) + .expect("run command with Tokio pipe draining"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"output"); + assert_eq!(output.stderr, b"error"); +} + +#[test] +fn blocking_runner_terminates_a_command_after_its_deadline() { + let command = CommandSpec::shell("sleep 2"); + + let error = run_command_with_timeout(&command, Duration::from_millis(20), None) + .expect_err("blocking command should time out"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); +} + +#[test] +fn asynchronous_runner_terminates_a_command_after_its_deadline() { + let runtime = build_command_runtime().expect("build command runtime"); + let command = CommandSpec::shell("sleep 2"); + + let error = run_command_with_timeout(&command, Duration::from_millis(20), Some(&runtime)) + .expect_err("asynchronous command should time out"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/plan.rs similarity index 93% rename from crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/plan.rs index 52da8b413..b34de2687 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/plan.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/plan.rs @@ -3,6 +3,7 @@ use std::io; use std::process::Child; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use unixnotis_core::CommandSpec; use super::command_parse::is_probably_slow; use super::exec::build_command; @@ -54,7 +55,10 @@ impl CommandPlan { Duration::from_millis(jitter_ms) } - pub(in crate::ui::widgets) fn spawn_watch_command(&self, cmd: &str) -> io::Result { + pub(in crate::ui::widgets) fn spawn_watch_command( + &self, + cmd: &CommandSpec, + ) -> io::Result { // Watch commands keep stdout open while stderr stays detached from refresh wakeups let mut command = build_command(cmd); command @@ -72,7 +76,7 @@ impl CommandPlan { } pub(in crate::ui::widgets) fn resolve_command_plan( - cmd: &str, + cmd: &CommandSpec, default_kind: CommandKind, ) -> CommandPlan { let mut kind = default_kind; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs similarity index 83% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs index 964b53813..f0264b074 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/coalesced.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/coalesced.rs @@ -6,9 +6,10 @@ use std::time::Duration; use crossbeam_channel as channel; use tracing::warn; +use unixnotis_core::CommandSpec; use super::worker::CommandJob; -use crate::ui::widgets::utils::command::CommandKind; +use crate::ui::widgets::command_runtime::command::CommandKind; // Keep refresh overflow bounded const COALESCED_REFRESH_CAPACITY: usize = 256; @@ -16,7 +17,7 @@ const COALESCED_RETRY_DELAY_MS: u64 = 25; #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub(super) struct RefreshCommandKey { - cmd: String, + cmd: CommandSpec, kind: CommandKind, timeout_ms: Option, } @@ -119,14 +120,13 @@ impl CoalescedRefreshQueue { Err(channel::TrySendError::Full(job)) => { // Worker queue is still full, so put it back let mut state = self.state.lock().expect("coalesced refresh lock poisoned"); - if !state.pending.contains_key(&key) - && state.pending.len() >= COALESCED_REFRESH_CAPACITY - { + let already_pending = state.pending.contains_key(&key); + if !already_pending && state.pending.len() >= COALESCED_REFRESH_CAPACITY { if let Some(oldest) = state.order.pop_front() { state.pending.remove(&oldest); } } - if !state.pending.contains_key(&key) { + if !already_pending { state.order.push_front(key.clone()); } state.pending.insert(key, job); @@ -145,23 +145,28 @@ pub(super) fn insert_coalesced_job( job: CommandJob, ) -> CoalescedInsertOutcome { let key = RefreshCommandKey::from_job(&job); - let replaced_existing = state.pending.contains_key(&key); + if let Some(existing) = state.pending.get_mut(&key) { + // Replacing in place avoids a second hash lookup and keeps queue order stable + *existing = job; + return CoalescedInsertOutcome { + replaced_existing: true, + evicted_oldest: false, + }; + } + let mut evicted_oldest = false; - if !replaced_existing { - if state.pending.len() >= COALESCED_REFRESH_CAPACITY { - if let Some(oldest) = state.order.pop_front() { - // Drop the oldest job when full - state.pending.remove(&oldest); - evicted_oldest = true; - } + if state.pending.len() >= COALESCED_REFRESH_CAPACITY { + if let Some(oldest) = state.order.pop_front() { + // Drop the oldest job when full + state.pending.remove(&oldest); + evicted_oldest = true; } - // First seen key goes to the back - state.order.push_back(key.clone()); } - // Replacing the old job drops stale refresh work + // First-seen keys enter at the back of the drain order + state.order.push_back(key.clone()); state.pending.insert(key, job); CoalescedInsertOutcome { - replaced_existing, + replaced_existing: false, evicted_oldest, } } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/delayed.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/delayed.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/delayed.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/delayed.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/metrics.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/metrics.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/metrics.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/metrics.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs new file mode 100644 index 000000000..2b964532b --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/coalesced.rs @@ -0,0 +1,97 @@ +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +use super::super::worker::CommandJob; +use super::{insert_coalesced_job, CoalescedRefreshState}; +use crate::ui::widgets::command_runtime::command::{CommandKind, CommandPlan}; +use unixnotis_core::CommandSpec; + +fn job(cmd: CommandSpec, kind: CommandKind) -> CommandJob { + CommandJob { + cmd, + plan: CommandPlan { + kind, + timeout_override: None, + }, + respond: None, + queued_at: Instant::now(), + } +} + +#[test] +fn same_refresh_key_replaces_existing_job() { + let mut state = CoalescedRefreshState { + pending: HashMap::new(), + order: VecDeque::new(), + }; + + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), + ); + let replacement = job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast); + let replacement_queued_at = replacement.queued_at; + let outcome = insert_coalesced_job(&mut state, replacement); + + assert_eq!(state.pending.len(), 1); + assert_eq!(state.order.len(), 1); + assert_eq!( + state.pending.values().next().map(|item| item.queued_at), + Some(replacement_queued_at) + ); + assert!(outcome.replaced_existing); + assert!(!outcome.evicted_oldest); +} + +#[test] +fn distinct_refresh_kinds_keep_separate_jobs() { + let mut state = CoalescedRefreshState { + pending: HashMap::new(), + order: VecDeque::new(), + }; + + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Fast), + ); + insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["a"]), CommandKind::Slow), + ); + + assert_eq!(state.pending.len(), 2); + assert_eq!(state.order.len(), 2); +} + +#[test] +fn full_refresh_queue_evicts_oldest_key() { + let mut state = CoalescedRefreshState { + pending: HashMap::new(), + order: VecDeque::new(), + }; + for index in 0..256 { + insert_coalesced_job( + &mut state, + job( + CommandSpec::direct("echo", [index.to_string()]), + CommandKind::Fast, + ), + ); + } + + let outcome = insert_coalesced_job( + &mut state, + job(CommandSpec::direct("echo", ["newest"]), CommandKind::Fast), + ); + + assert_eq!(state.pending.len(), 256); + assert!(outcome.evicted_oldest); + assert!(!state + .pending + .values() + .any(|item| item.cmd == CommandSpec::direct("echo", ["0"]))); + assert!(state + .pending + .values() + .any(|item| item.cmd == CommandSpec::direct("echo", ["newest"]))); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs index ef54b0c40..53f5afe61 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/delayed.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/delayed.rs @@ -7,11 +7,12 @@ use super::{ next_delayed_wake, next_ready_delayed_job_index, try_enqueue_delayed_job, DelayedSlowQueue, DelayedState, }; -use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; +use crate::ui::widgets::command_runtime::command::{CommandKind, CommandPlan}; +use unixnotis_core::CommandSpec; fn job(cmd: &str) -> CommandJob { CommandJob { - cmd: cmd.to_string(), + cmd: CommandSpec::direct("echo", [cmd]), plan: CommandPlan { kind: CommandKind::Slow, timeout_override: None, @@ -71,6 +72,9 @@ fn due_job_selection_prefers_deadline_then_sequence() { let index = next_ready_delayed_job_index(&state.pending, now).expect("expected due job"); - assert_eq!(state.pending[index].job.cmd, "echo first"); + assert_eq!( + state.pending[index].job.cmd, + CommandSpec::direct("echo", ["echo first"]) + ); assert_eq!(next_delayed_wake(&state.pending, now), Some(Duration::ZERO)); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/metrics.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/metrics.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/metrics.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/metrics.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/worker.rs similarity index 92% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/worker.rs index ee1d5b302..b49595cfa 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/tests/worker.rs @@ -5,10 +5,15 @@ use super::{ dispatch_ready_job, should_warn_queue_full_from, CommandJob, CommandKind, CommandPlan, CommandWorker, }; +use unixnotis_core::CommandSpec; fn job(cmd: &str, kind: CommandKind) -> CommandJob { CommandJob { - cmd: cmd.to_string(), + cmd: if cmd == "sleep 1" { + CommandSpec::direct("sleep", ["1"]) + } else { + CommandSpec::direct(cmd, [] as [&str; 0]) + }, plan: CommandPlan { kind, timeout_override: None, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs index e4d354e97..465b7885e 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/worker.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/queue/worker.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use crossbeam_channel as channel; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; @@ -25,7 +25,7 @@ const COMMAND_QUEUE_WARN_INTERVAL_SECS: u64 = 5; pub(super) struct CommandJob { // Command text for this run - pub(super) cmd: String, + pub(super) cmd: CommandSpec, pub(super) plan: CommandPlan, pub(super) respond: Option>>, // Used to split wait time from run time @@ -87,8 +87,8 @@ impl CommandWorker { } } -pub(in crate::ui::widgets::utils::command) fn enqueue_command( - cmd: String, +pub(in crate::ui::widgets::command_runtime::command) fn enqueue_command( + cmd: CommandSpec, plan: CommandPlan, respond: Option>>, ) { @@ -274,7 +274,7 @@ fn run_worker(rx: channel::Receiver) { } fn handle_job(job: CommandJob, runtime: Option<&tokio::runtime::Runtime>) { - let cmd_snip = util::log_snippet(&job.cmd); + let cmd_snip = util::log_snippet(&job.cmd.display_lossy()); // Wait time includes queue time and slow-job jitter let queue_wait_ms = job.queued_at.elapsed().as_millis(); debug::log(PanelDebugLevel::Verbose, || { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/action.rs similarity index 75% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/action.rs index 19bc07089..281975011 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/action.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/action.rs @@ -2,10 +2,11 @@ use std::io::ErrorKind; use super::super::test_support::configure_command_test_root; use super::run_command_capture_action_async; +use unixnotis_core::CommandSpec; #[test] fn empty_action_command_returns_invalid_input_without_enqueueing() { - let response = run_command_capture_action_async(" ") + let response = run_command_capture_action_async(&CommandSpec::direct("", [] as [&str; 0])) .recv_blocking() .expect("action response should remain available") .expect_err("empty command should fail"); @@ -16,7 +17,7 @@ fn empty_action_command_returns_invalid_input_without_enqueueing() { #[test] fn action_command_runs_in_the_action_lane_and_reports_output() { configure_command_test_root(); - let output = run_command_capture_action_async("true") + let output = run_command_capture_action_async(&CommandSpec::direct("true", [] as [&str; 0])) .recv_blocking() .expect("action response should remain available") .expect("true should execute"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/capture.rs similarity index 60% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/capture.rs index 28e7b6ac7..094f33163 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/capture.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/capture.rs @@ -3,10 +3,11 @@ use std::time::Duration; use super::super::test_support::configure_command_test_root; use super::{run_command_capture_async, run_command_capture_with_timeout_async}; +use unixnotis_core::CommandSpec; #[test] fn empty_capture_command_returns_invalid_input_without_enqueueing() { - let response = run_command_capture_async("\t") + let response = run_command_capture_async(&CommandSpec::direct("", [] as [&str; 0])) .recv_blocking() .expect("capture response should remain available") .expect_err("empty command should fail"); @@ -17,10 +18,13 @@ fn empty_capture_command_returns_invalid_input_without_enqueueing() { #[test] fn custom_capture_timeout_terminates_long_running_command() { configure_command_test_root(); - let response = run_command_capture_with_timeout_async("sleep 1", Duration::from_millis(40)) - .recv_blocking() - .expect("capture response should remain available") - .expect_err("sleep should exceed the custom timeout"); + let response = run_command_capture_with_timeout_async( + &CommandSpec::direct("sleep", ["1"]), + Duration::from_millis(40), + ) + .recv_blocking() + .expect("capture response should remain available") + .expect_err("sleep should exceed the custom timeout"); assert_eq!(response.kind(), ErrorKind::TimedOut); } diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/command_parse.rs new file mode 100644 index 000000000..f617e8359 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/command_parse.rs @@ -0,0 +1,42 @@ +use super::is_probably_slow; +use unixnotis_core::CommandSpec; + +#[test] +fn slow_classification_uses_the_structured_program() { + assert!(is_probably_slow(&CommandSpec::direct("sleep", ["1"]))); + assert!(is_probably_slow(&CommandSpec::direct( + "nmcli", + ["radio", "wifi"] + ))); + assert!(!is_probably_slow( + &CommandSpec::direct("echo", ["ok"]).with_env("FOO", "bar") + )); + assert!(!is_probably_slow(&CommandSpec::direct( + "echo", + ["I am not sleeping"] + ))); +} + +#[test] +fn explicit_shell_commands_use_the_slow_lane() { + assert!(is_probably_slow(&CommandSpec::shell("printf ready"))); +} + +#[test] +fn direct_shell_wrappers_share_the_slow_lane_classification() { + for shell in ["sh", "ash", "bash", "dash", "fish", "ksh", "zsh"] { + assert!( + is_probably_slow(&CommandSpec::direct(shell, ["-c", "sleep 1"])), + "{shell} -c must receive the slow command budget" + ); + } + + assert!(is_probably_slow(&CommandSpec::direct( + "/bin/dash", + ["-c", "printf ready"] + ))); + assert!(!is_probably_slow(&CommandSpec::direct( + "dash", + ["-x", "script"] + ))); +} diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/plan.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/plan.rs new file mode 100644 index 000000000..3999f9e85 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/plan.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use super::{resolve_command_plan, CommandKind}; +use unixnotis_core::CommandSpec; + +#[test] +fn slow_command_promotes_refresh_plan_to_slow_lane() { + let plan = resolve_command_plan(&CommandSpec::direct("sleep", ["1"]), CommandKind::Fast); + + assert_eq!(plan.kind, CommandKind::Slow); + assert_eq!(plan.timeout(), Duration::from_millis(800)); +} + +#[test] +fn direct_dash_wrapper_receives_the_slow_timeout_budget() { + let plan = resolve_command_plan( + &CommandSpec::direct("dash", ["-c", "sleep 1"]), + CommandKind::Fast, + ); + + assert_eq!(plan.kind, CommandKind::Slow); + assert_eq!(plan.timeout(), Duration::from_millis(800)); +} + +#[test] +fn action_command_keeps_action_lane_even_when_command_is_slow() { + let plan = resolve_command_plan(&CommandSpec::direct("sleep", ["1"]), CommandKind::Action); + + assert_eq!(plan.kind, CommandKind::Action); + assert_eq!(plan.timeout(), Duration::from_millis(1_200)); +} + +#[test] +fn explicit_timeout_overrides_lane_default() { + let plan = resolve_command_plan( + &CommandSpec::direct("true", [] as [&str; 0]), + CommandKind::Fast, + ) + .with_timeout(Duration::from_millis(25)); + + assert_eq!(plan.timeout(), Duration::from_millis(25)); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/support.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command/tests/support.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/command/tests/support.rs diff --git a/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs new file mode 100644 index 000000000..0293650ed --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/mod.rs @@ -0,0 +1,6 @@ +//! Command execution, refresh backoff, and persistent watch lifecycles + +pub(in crate::ui::widgets) mod backoff; +pub(in crate::ui::widgets) mod command; +pub(in crate::ui::widgets) mod watch; +mod watch_reaper; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs index 11d8e5cd6..8fd080d09 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/tests/refresh_backoff.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/backoff.rs @@ -42,6 +42,6 @@ fn refresh_backoff_increases_on_errors() { #[test] fn in_flight_recheck_stays_slower_than_short_command_polling() { - // Async completion updates real deadlines, so rechecks should only be a safety net. + // Async completion updates real deadlines, so rechecks should only be a safety net assert!(INFLIGHT_REFRESH_RECHECK >= Duration::from_secs(1)); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/tests/watch_reaper.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch_reaper.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/tests/watch_reaper.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/tests/watch_reaper.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/watch.rs similarity index 95% rename from crates/unixnotis-center/src/ui/widgets/utils/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/watch.rs index 8f66ffe90..d52ff5214 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_runtime/watch.rs @@ -10,7 +10,7 @@ use std::time::Duration; use async_channel::{TryRecvError, TrySendError}; use gtk::glib; use tracing::warn; -use unixnotis_core::{util, PanelDebugLevel}; +use unixnotis_core::{util, CommandSpec, PanelDebugLevel}; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; @@ -50,27 +50,26 @@ impl Drop for CommandWatch { } pub(in crate::ui::widgets) fn start_command_watch( - cmd: &str, + cmd: &CommandSpec, on_event: F, ) -> Option { - let cmd = cmd.trim(); if cmd.is_empty() { warn!("watch command was empty"); return None; } debug::log(PanelDebugLevel::Info, || { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); format!("watch start: {snippet}") }); let plan = resolve_command_plan(cmd, CommandKind::Slow); - let cmd_string = cmd.to_string(); + let cmd_string = cmd.display_lossy(); let cmd_for_thread = cmd_string.clone(); // Spawn watch command with stdout piped so events can be consumed let mut child = match plan.spawn_watch_command(cmd) { Ok(child) => child, Err(err) => { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); warn!(command = %snippet, ?err, "watch command failed to start"); return None; } @@ -79,7 +78,7 @@ pub(in crate::ui::widgets) fn start_command_watch( let stdout = if let Some(stdout) = child.stdout.take() { stdout } else { - let snippet = util::log_snippet(cmd); + let snippet = util::log_snippet(&cmd.display_lossy()); warn!(command = %snippet, "watch command missing stdout"); let _ = child.kill(); let _ = child.wait(); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/watch_reaper.rs b/crates/unixnotis-center/src/ui/widgets/command_runtime/watch_reaper.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/watch_reaper.rs rename to crates/unixnotis-center/src/ui/widgets/command_runtime/watch_reaper.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs similarity index 87% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs index 1232defea..9090edbc6 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/schedule.rs @@ -3,15 +3,16 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use super::super::{run_action_command_with_completion, value::format_command_value}; -use unixnotis_core::PanelDebugLevel; +use super::super::value::format_command_value; +use crate::ui::widgets::command_runtime::command::run_action_command_with_completion; +use unixnotis_core::{CommandSpec, PanelDebugLevel}; use crate::diagnostics::panel_debug as debug; pub(super) fn schedule_command( pending: Rc>>, pending_value: Rc>>, - cmd_template: String, + cmd_template: CommandSpec, value: f64, step: f64, on_complete: Rc, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs similarity index 81% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs index f56f55333..d8c81c2f3 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/signals.rs @@ -10,11 +10,11 @@ use unixnotis_core::{PanelDebugLevel, SliderWidgetConfig}; use super::super::refresh::{ build_refresh_state_from_weak, request_refresh, SliderRefreshMeta, SliderRefreshRequest, }; -use super::super::run_action_command_with_completion; use super::super::value::format_display_value; use super::super::view::build_icon_shell; use super::schedule::schedule_command; use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::command_runtime::command::run_action_command_with_completion; pub(in super::super) fn attach_icon_action( root: >k::Box, @@ -98,6 +98,11 @@ pub(in super::super) fn attach_scale_action( return; } + // A user change supersedes any read that started before this interaction + // Its completion may otherwise snap the slider back before the write finishes + refresh_meta_for_set + .refresh_gen + .set(refresh_meta_for_set.refresh_gen.get().wrapping_add(1)); let value = scale.value(); // Local label echo keeps dragging responsive before the debounced command finishes label_clone.set_text(&format_display_value(value)); @@ -115,24 +120,26 @@ pub(in super::super) fn attach_scale_action( let refresh_meta = refresh_meta_for_set.clone(); move |failed| { if failed { - // Failed set actions should reconcile quickly instead of waiting for polling + // Failed writes are called out before the shared reconciliation below debug::log(PanelDebugLevel::Warn, || { format!( "slider set action failed; forcing refresh cmd=\"{}\"", request.command() ) }); - // Corrective refresh uses the same parser and backoff path as polling - let Some(refresh) = build_refresh_state_from_weak( - &scale_weak, - &label_weak, - &icon_weak, - &refresh_meta, - ) else { - return; - }; - request_refresh(request.clone(), refresh, Duration::from_secs(1), true); } + + // Read back both successful and failed writes because hardware may clamp values + // This also consumes any refresh queued behind the pre-action stale read + let Some(refresh) = build_refresh_state_from_weak( + &scale_weak, + &label_weak, + &icon_weak, + &refresh_meta, + ) else { + return; + }; + request_refresh(request.clone(), refresh, Duration::from_secs(1), true); } }), ); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/schedule.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/schedule.rs index c21eab11a..674d6c96c 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/schedule.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/schedule.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use std::time::Duration; use super::schedule_command; +use unixnotis_core::CommandSpec; #[gtk::test] fn scheduled_command_coalesces_values_and_clears_pending_state() { @@ -23,7 +24,7 @@ fn scheduled_command_coalesces_values_and_clears_pending_state() { schedule_command( pending.clone(), pending_value.clone(), - "test {value} = 17".to_string(), + CommandSpec::direct("test", ["{value}", "=", "17"]), 4.0, 1.0, on_complete.clone(), @@ -31,7 +32,7 @@ fn scheduled_command_coalesces_values_and_clears_pending_state() { schedule_command( pending.clone(), pending_value.clone(), - "test {value} = 17".to_string(), + CommandSpec::direct("test", ["{value}", "=", "17"]), 17.0, 1.0, on_complete, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs similarity index 79% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs index 23d4aebd8..1c23c0636 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/actions/tests/signals.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/actions/tests/signals.rs @@ -3,12 +3,12 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::SliderWidgetConfig; +use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::{attach_icon_action, attach_scale_action}; -use crate::ui::widgets::utils::command_slider::refresh::{SliderRefreshGate, SliderRefreshMeta}; -use crate::ui::widgets::utils::command_slider::view::build_slider_widgets; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::{SliderRefreshGate, SliderRefreshMeta}; +use crate::ui::widgets::command_slider::view::build_slider_widgets; #[gtk::test] fn icon_action_adds_a_static_shell_when_toggle_command_is_absent() { @@ -41,7 +41,7 @@ fn icon_action_adds_a_static_shell_when_toggle_command_is_absent() { #[gtk::test] fn scale_action_echoes_the_changed_value_immediately() { let config = SliderWidgetConfig { - set_cmd: ":".to_string(), + set_cmd: CommandSpec::direct("true", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; @@ -61,10 +61,10 @@ fn scale_action_echoes_the_changed_value_immediately() { } #[gtk::test] -fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { +fn successful_scale_action_invalidates_stale_reads_and_reconciles_backend_value() { let config = SliderWidgetConfig { - get_cmd: "printf 22".to_string(), - set_cmd: "true".to_string(), + get_cmd: CommandSpec::direct("printf", ["22"]), + set_cmd: CommandSpec::direct("true", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; @@ -81,15 +81,15 @@ fn successful_scale_action_keeps_the_local_value_without_corrective_refresh() { widgets.scale.set_value(37.0); iterate_main_context_for(Duration::from_millis(400)); - assert_eq!(refresh_meta.refresh_gen.get(), 0); - assert_eq!(widgets.value_label.text(), "37%"); + assert_eq!(refresh_meta.refresh_gen.get(), 2); + assert_eq!(widgets.value_label.text(), "22%"); } #[gtk::test] fn failed_scale_action_runs_corrective_refresh() { let config = SliderWidgetConfig { - get_cmd: "printf 22".to_string(), - set_cmd: "false".to_string(), + get_cmd: CommandSpec::direct("printf", ["22"]), + set_cmd: CommandSpec::direct("false", [] as [&str; 0]), toggle_cmd: None, ..SliderWidgetConfig::default() }; @@ -105,13 +105,13 @@ fn failed_scale_action_runs_corrective_refresh() { widgets.scale.set_value(37.0); let deadline = Instant::now() + Duration::from_secs(3); - while (refresh_meta.refresh_gen.get() == 0 || refresh_meta.gate.is_in_flight()) + while (refresh_meta.refresh_gen.get() < 2 || refresh_meta.gate.is_in_flight()) && Instant::now() < deadline { iterate_main_context_for(Duration::from_millis(1)); } - assert_eq!(refresh_meta.refresh_gen.get(), 1); + assert_eq!(refresh_meta.refresh_gen.get(), 2); assert!(!refresh_meta.gate.is_in_flight()); assert_eq!(widgets.value_label.text(), "22%"); } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs similarity index 63% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs index c5c8fcfcc..7165f4350 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/mod.rs @@ -8,11 +8,7 @@ mod refresh; mod value; // GTK construction, layout, and icon resolution mod view; -// Public widget shell that connects each focused subsystem +// Widget shell that connects each focused subsystem mod widget; -use super::{ - run_action_command_with_completion, run_command_capture_status_async, start_command_watch, - CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK, -}; pub use widget::CommandSlider; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/apply.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/apply.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/apply.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/apply.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/gate.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/gate.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/gate.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/gate.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs index aee2ad212..eea9567a1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/poll.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/poll.rs @@ -4,8 +4,9 @@ use std::cell::RefCell; use std::rc::Rc; use std::time::{Duration, Instant}; -use super::super::{CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; use super::gate::SliderRefreshGate; +use crate::ui::widgets::command_runtime::backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; +use crate::ui::widgets::command_runtime::watch::CommandWatch; pub(super) fn needs_polling(watch_handle: &RefCell>) -> bool { let mut handle = watch_handle.borrow_mut(); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/request.rs similarity index 84% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/request.rs index 1c39a224e..8167bd87f 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/request.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/request.rs @@ -1,11 +1,11 @@ //! Slider refresh request snapshots -use unixnotis_core::{NumericParseMode, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, NumericParseMode, SliderWidgetConfig}; #[derive(Clone)] pub(in super::super) struct SliderRefreshRequest { // Command used to read the current slider value - pub(super) cmd: String, + pub(super) cmd: CommandSpec, // Lower bound used for parser clamping pub(super) min: f64, // Upper bound used for parser clamping @@ -28,9 +28,9 @@ impl SliderRefreshRequest { } } - pub(in super::super) fn command(&self) -> &str { + pub(in super::super) fn command(&self) -> String { // Action diagnostics need the command identity without exposing mutable request fields - &self.cmd + self.cmd.display_lossy() } } diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs similarity index 92% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs index 7d6bce2a8..cf5791271 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/runner.rs @@ -9,10 +9,10 @@ use std::time::{Duration, Instant}; use tracing::warn; use unixnotis_core::{util, PanelDebugLevel}; -use super::super::run_command_capture_status_async; use super::apply::{apply_successful_output, note_slider_error}; use super::{SliderRefreshRequest, SliderRefreshState}; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; +use crate::ui::widgets::command_runtime::command::run_command_capture_status_async; pub(in super::super) fn request_refresh( request: SliderRefreshRequest, @@ -31,7 +31,7 @@ pub(in super::super) fn request_refresh( // Collapse bursty requests into one running refresh and one trailing refresh if !refresh.gate.begin_or_queue() { perf_probe::slider_refresh_queued(); - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh queued while in flight cmd=\"{cmd_snip}\"") }); @@ -39,7 +39,7 @@ pub(in super::super) fn request_refresh( } perf_probe::slider_refresh_start(); - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh start cmd=\"{cmd_snip}\"") }); @@ -103,7 +103,7 @@ fn finish_refresh( ) { // One queued refresh is allowed to run after the current one finishes if refresh.gate.finish() { - let cmd_snip = util::log_snippet(&request.cmd); + let cmd_snip = util::log_snippet(&request.cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("slider refresh consumed pending request cmd=\"{cmd_snip}\"") }); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs index c72736081..4b9a76a42 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/state.rs @@ -3,8 +3,8 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use super::super::RefreshBackoff; use super::gate::SliderRefreshGate; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; #[derive(Clone)] pub(in super::super) struct SliderRefreshState { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs similarity index 94% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs index 9f075a774..a8de5dc41 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/apply.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/apply.rs @@ -3,13 +3,13 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::NumericParseMode; +use unixnotis_core::{CommandSpec, NumericParseMode}; use super::{apply_slider_icon, apply_slider_value, apply_successful_output, note_slider_error}; -use crate::ui::widgets::utils::command_slider::refresh::{ +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::{ SliderRefreshGate, SliderRefreshRequest, SliderRefreshState, }; -use crate::ui::widgets::utils::RefreshBackoff; #[gtk::test] fn slider_value_application_updates_only_changed_widget_state() { @@ -113,7 +113,7 @@ fn slider_error_records_a_retry_deadline() { fn request() -> SliderRefreshRequest { SliderRefreshRequest { - cmd: "read-slider".to_string(), + cmd: CommandSpec::direct("read-slider", [] as [&str; 0]), min: 0.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/gate.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/gate.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/gate.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/gate.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs similarity index 82% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs index 487d9f0fa..13ad4eff8 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/poll.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/poll.rs @@ -3,10 +3,10 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use super::{needs_polling, next_poll_in}; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::{ - start_command_watch, CommandWatch, RefreshBackoff, INFLIGHT_REFRESH_RECHECK, -}; +use crate::ui::widgets::command_runtime::backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; +use crate::ui::widgets::command_runtime::watch::{start_command_watch, CommandWatch}; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; +use unixnotis_core::CommandSpec; #[test] fn polling_without_a_watch_starts_at_the_minimum_deadline() { @@ -63,7 +63,8 @@ fn polling_uses_the_recorded_backoff_deadline() { #[gtk::test] fn active_watch_suppresses_polling_until_it_exits() { - let watch = start_command_watch("sleep 2", || {}).expect("watch should start"); + let watch = start_command_watch(&CommandSpec::direct("sleep", ["2"]), || {}) + .expect("watch should start"); let watch = RefCell::new(Some(watch)); let gate = SliderRefreshGate::new(); let backoff = Rc::new(RefCell::new(RefreshBackoff::default())); @@ -83,7 +84,8 @@ fn active_watch_suppresses_polling_until_it_exits() { #[gtk::test] fn exited_watch_is_removed_before_polling_resumes() { - let watch = start_command_watch("true", || {}).expect("watch should start"); + let watch = start_command_watch(&CommandSpec::direct("true", [] as [&str; 0]), || {}) + .expect("watch should start"); let watch = RefCell::new(Some(watch)); let deadline = Instant::now() + Duration::from_secs(2); while watch.borrow().as_ref().is_some_and(CommandWatch::is_active) && Instant::now() < deadline diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/request.rs similarity index 72% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/request.rs index 35bd4eaff..9720c2d24 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/request.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/request.rs @@ -1,11 +1,11 @@ -use unixnotis_core::{NumericParseMode, SliderWidgetConfig}; +use unixnotis_core::{CommandSpec, NumericParseMode, SliderWidgetConfig}; use super::SliderRefreshRequest; #[test] fn refresh_request_copies_every_runtime_input_from_config() { let config = SliderWidgetConfig { - get_cmd: "read-custom-value".to_string(), + get_cmd: CommandSpec::direct("read-custom-value", [] as [&str; 0]), min: -12.5, max: 240.0, step: 0.25, @@ -16,7 +16,10 @@ fn refresh_request_copies_every_runtime_input_from_config() { let request = SliderRefreshRequest::from_config(&config); assert_eq!(request.command(), "read-custom-value"); - assert_eq!(request.cmd, "read-custom-value"); + assert_eq!( + request.cmd, + CommandSpec::direct("read-custom-value", [] as [&str; 0]) + ); assert_close(request.min, -12.5); assert_close(request.max, 240.0); assert_close(request.step, 0.25); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs similarity index 96% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs index fcedbaaf2..ab501b6c1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/runner.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/runner.rs @@ -12,8 +12,8 @@ use super::{ finish_refresh, handle_worker_result, next_refresh_generation, request_refresh, SliderRefreshRequest, SliderRefreshState, }; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; #[test] fn refresh_generation_increments_and_records_the_next_value() { @@ -168,7 +168,7 @@ fn refresh_gate_runs_one_queued_follow_up() { fn request(cmd: &str) -> SliderRefreshRequest { SliderRefreshRequest { - cmd: cmd.to_string(), + cmd: unixnotis_core::parse_legacy_command(cmd).expect("valid test command"), min: 0.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs similarity index 93% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs index e68a8e916..b094e01e0 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/state.rs @@ -4,8 +4,8 @@ use std::rc::Rc; use gtk::prelude::*; use super::{build_refresh_state_from_weak, SliderRefreshMeta}; -use crate::ui::widgets::utils::command_slider::refresh::SliderRefreshGate; -use crate::ui::widgets::utils::RefreshBackoff; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_slider::refresh::SliderRefreshGate; #[gtk::test] fn weak_widget_state_builds_while_every_widget_is_alive() { diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs similarity index 83% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs index 685f2b694..99175d8a7 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/tests/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/tests/watch.rs @@ -1,12 +1,12 @@ -use unixnotis_core::SliderWidgetConfig; +use unixnotis_core::{CommandSpec, SliderWidgetConfig}; use super::set_watch_active; -use crate::ui::widgets::utils::command_slider::CommandSlider; +use crate::ui::widgets::command_slider::CommandSlider; #[gtk::test] fn watch_lifecycle_starts_once_and_stops_cleanly() { let config = SliderWidgetConfig { - watch_cmd: Some("sleep 2".to_string()), + watch_cmd: Some(CommandSpec::direct("sleep", ["2"])), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "test-slider"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs similarity index 90% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs index f5114ddda..fad72b0e8 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/refresh/watch.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/refresh/watch.rs @@ -2,8 +2,9 @@ use std::time::Duration; -use super::super::{start_command_watch, CommandSlider, CommandWatch}; +use super::super::CommandSlider; use super::{request_refresh, SliderRefreshRequest}; +use crate::ui::widgets::command_runtime::watch::{start_command_watch, CommandWatch}; pub(in super::super) fn set_watch_active(slider: &CommandSlider, active: bool) { // Widgets without a watch command rely on polling only diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/tests/widget.rs similarity index 93% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/tests/widget.rs index a55737d47..6f70a4a86 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/tests/widget.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/tests/widget.rs @@ -1,7 +1,7 @@ use std::time::{Duration, Instant}; use gtk::prelude::*; -use unixnotis_core::{css::hooks, SliderWidgetConfig}; +use unixnotis_core::{css::hooks, CommandSpec, SliderWidgetConfig}; use super::CommandSlider; @@ -33,7 +33,7 @@ fn inactive_watch_slider_remains_eligible_for_polling() { #[gtk::test] fn public_refresh_starts_and_completes_slider_update() { let config = SliderWidgetConfig { - get_cmd: "printf 37".to_string(), + get_cmd: CommandSpec::direct("printf", ["37"]), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "volume-slider"); @@ -70,7 +70,7 @@ fn public_refresh_honors_recorded_backoff() { #[gtk::test] fn public_watch_lifecycle_controls_the_owned_handle() { let config = SliderWidgetConfig { - watch_cmd: Some("sleep 2".to_string()), + watch_cmd: Some(CommandSpec::direct("sleep", ["2"])), ..SliderWidgetConfig::default() }; let slider = CommandSlider::new(config, "volume-slider"); diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/change.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/change.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/change.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/change.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/format.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/format.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/format.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/format.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/parse.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/parse.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/parse.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/change.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs similarity index 98% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/change.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs index 06cfb7d98..6dce879ee 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/change.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/change.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "the tolerance helper returns exact configured constants for these finite inputs" )] diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/format.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/format.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/format.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/format.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/parse.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/parse.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs index 836a6874d..e744d7781 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/value/tests/parse.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/value/tests/parse.rs @@ -1,8 +1,3 @@ -#![allow( - clippy::float_cmp, - reason = "the parser produces exact values for these bounded decimal inputs" -)] - use unixnotis_core::NumericParseMode; use super::{parse_muted, parse_numeric}; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/build.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/build.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/build.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/build.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/icons.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/icons.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/icons.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/icons.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/layout.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/layout.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/layout.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/mod.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/mod.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/mod.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/mod.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/build.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/build.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/build.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/build.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/icons.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/icons.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/icons.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/icons.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/layout.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/layout.rs similarity index 100% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/view/tests/layout.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/view/tests/layout.rs diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs b/crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs similarity index 97% rename from crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs rename to crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs index e786081c8..1ae907db1 100644 --- a/crates/unixnotis-center/src/ui/widgets/utils/command_slider/widget.rs +++ b/crates/unixnotis-center/src/ui/widgets/command_slider/widget.rs @@ -6,11 +6,13 @@ use std::time::{Duration, Instant}; use unixnotis_core::SliderWidgetConfig; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::command_runtime::watch::CommandWatch; + use super::refresh::{ request_refresh, SliderRefreshGate, SliderRefreshMeta, SliderRefreshRequest, SliderRefreshState, }; use super::view::build_slider_widgets; -use super::{CommandWatch, RefreshBackoff}; pub struct CommandSlider { // Root widget embedded by higher-level widget wrappers diff --git a/crates/unixnotis-center/src/ui/widgets/mod.rs b/crates/unixnotis-center/src/ui/widgets/mod.rs index ee4c95ae5..715667fa3 100644 --- a/crates/unixnotis-center/src/ui/widgets/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/mod.rs @@ -2,19 +2,18 @@ pub mod brightness; pub mod cards; +mod command_runtime; +mod command_slider; mod icon_image; mod kind_css; // Plugin schema and JSON parsing helpers for widget-backed commands mod plugin; pub mod stats; pub mod toggles; -// Shared helpers are kept in a dedicated module to prevent single-file sprawl -mod utils; pub mod volume; -// Re-export keeps existing call sites stable while internals stay modular -pub use utils::CommandSlider; +pub use command_slider::CommandSlider; pub fn configure_command_config_dir(config_dir: std::path::PathBuf) { - utils::configure_command_config_dir(config_dir); + command_runtime::command::configure_command_config_dir(config_dir); } diff --git a/crates/unixnotis-center/src/ui/widgets/stats/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/build.rs deleted file mode 100644 index 54e8b6405..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/build.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Stat grid and card construction - -use gtk::prelude::*; -use gtk::Align; -use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; - -use super::super::icon_image::image_from_icon_config; -use super::css::stat_kind_css_class; -use super::{collect_builtin_groups, stats_builtin::BuiltinStat, StatGrid, StatItem}; - -impl StatGrid { - pub fn new( - configs: &[StatWidgetConfig], - columns: usize, - icon_resolver: &IconAssetResolver, - ) -> Option { - let mut items = Vec::new(); - for config in configs { - if !config.enabled { - continue; - } - // Preserve config order so layout remains predictable for users - items.push(StatItem::new(config.clone(), icon_resolver)); - } - if items.is_empty() { - // Skip widget creation when all stat entries are disabled - return None; - } - - let root = gtk::FlowBox::new(); - root.add_css_class(hooks::stat_card::GRID); - root.set_selection_mode(gtk::SelectionMode::None); - let columns = flowbox_columns(columns); - root.set_max_children_per_line(columns); - root.set_min_children_per_line(columns); - root.set_row_spacing(8); - root.set_column_spacing(8); - root.set_halign(Align::Fill); - root.set_hexpand(true); - - for item in &items { - // Insert in order so per-widget identity stays stable - root.insert(&item.root, -1); - } - - Some(Self { root, items }) - } - - pub const fn root(&self) -> >k::FlowBox { - &self.root - } - - pub fn refresh(&self, base_interval: std::time::Duration, force: bool) { - let now = std::time::Instant::now(); - let builtin_groups = collect_builtin_groups(&self.items, now, force); - - for item in &self.items { - if item.is_grouped_builtin(now, force) { - // Grouped builtin cards are refreshed once per source below - continue; - } - // Per-item refresh keeps slow widgets from blocking the grid - item.refresh(base_interval, force); - } - - for group in builtin_groups.into_values() { - // One sampled builtin value fans out to every matching stat card in the grid - group.refresh(base_interval); - } - } - - pub fn next_refresh_in(&self, now: std::time::Instant) -> Option { - self.items - .iter() - .filter_map(|item| item.next_refresh_in(now)) - .min() - } - - pub fn is_due(&self, now: std::time::Instant) -> bool { - self.next_refresh_in(now) - .is_some_and(|delay| delay.is_zero()) - } -} - -fn flowbox_columns(columns: usize) -> u32 { - u32::try_from(columns.max(1)).unwrap_or(u32::MAX) -} - -impl StatItem { - pub(super) fn new(config: StatWidgetConfig, icon_resolver: &IconAssetResolver) -> Self { - let card = gtk::Box::new(gtk::Orientation::Vertical, 6); - card.add_css_class(hooks::stat_card::ROOT); - if config.plugin.is_some() { - // Plugin cards get a dedicated class so themes can separate them from builtin stats - card.add_css_class(hooks::stat_card::PLUGIN); - } else { - card.add_css_class(hooks::stat_card::BUILTIN); - } - if config.min_height > 0 { - // Respect configured min height to keep cards visually aligned - card.set_size_request(-1, config.min_height); - } - if let Some(kind) = config.kind.as_deref().and_then(stat_kind_css_class) { - // Kind hooks let themes target user-defined stats without relying on order - card.add_css_class(&kind); - } - - let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); - header.add_css_class(hooks::stat_card::HEADER); - if let Some(icon) = image_from_icon_config( - icon_resolver, - &config.label, - config.icon.as_deref(), - config.icon_asset.as_deref(), - 16, - ) { - icon.add_css_class(hooks::stat_card::ICON); - header.append(&icon); - card.add_css_class(hooks::stat_card::HAS_ICON); - } else { - // No-icon cards still expose a hook so spacing can be rebalanced in CSS - card.add_css_class(hooks::stat_card::NO_ICON); - } - - let title = gtk::Label::new(Some(&config.label)); - title.add_css_class(hooks::stat_card::TITLE); - title.set_xalign(0.0); - header.append(&title); - - let value_label = gtk::Label::new(Some("n/a")); - value_label.add_css_class(hooks::stat_card::VALUE); - value_label.set_xalign(0.0); - value_label.set_width_chars(12); - - card.append(&header); - card.append(&value_label); - - let builtin = if config.plugin.is_some() { - // Plugin-backed stats bypass builtin readers to avoid dual data sources - None - } else { - config - .cmd - .as_ref() - .and_then(|cmd| BuiltinStat::from_command(cmd)) - }; - - Self { - config, - // Card widgets and refresh state stay together so one item owns its full lifecycle - root: card, - value_label, - builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), - inflight: std::rc::Rc::new(std::cell::Cell::new(false)), - last_value: std::rc::Rc::new(std::cell::RefCell::new(None)), - refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new( - super::RefreshBackoff::default(), - )), - } - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs new file mode 100644 index 000000000..c5aad9407 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/detect.rs @@ -0,0 +1,47 @@ +//! Built-in statistic source detection + +use super::model::{BuiltinStat, BuiltinStatKind}; +use super::readers::extract_iface; + +impl BuiltinStat { + pub(in crate::ui::widgets::stats) fn from_command(cmd: &str) -> Option { + let trimmed = cmd.trim(); + if let Some(rest) = trimmed.strip_prefix("builtin:") { + // Explicit builtin tags bypass filesystem path sniffing + return Self::from_builtin_tag(rest); + } + if trimmed.contains("/proc/stat") { + return Some(Self::new(BuiltinStatKind::Cpu)); + } + if trimmed.contains("/proc/meminfo") { + return Some(Self::new(BuiltinStatKind::Memory)); + } + if trimmed.contains("/proc/loadavg") { + return Some(Self::new(BuiltinStatKind::Load)); + } + if trimmed.contains("/sys/class/power_supply") { + return Some(Self::new(BuiltinStatKind::Battery)); + } + if trimmed.contains("/sys/class/net") && trimmed.contains("statistics") { + let iface = extract_iface(trimmed); + return Some(Self::new(BuiltinStatKind::Network { iface })); + } + None + } + + fn from_builtin_tag(tag: &str) -> Option { + let mut parts = tag.split(':'); + let kind = parts.next()?.trim(); + match kind { + "cpu" => Some(Self::new(BuiltinStatKind::Cpu)), + "mem" | "memory" => Some(Self::new(BuiltinStatKind::Memory)), + "load" => Some(Self::new(BuiltinStatKind::Load)), + "battery" => Some(Self::new(BuiltinStatKind::Battery)), + "net" => { + let iface = parts.next().map(std::string::ToString::to_string); + Some(Self::new(BuiltinStatKind::Network { iface })) + } + _ => None, + } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs new file mode 100644 index 000000000..27eb46306 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/group.rs @@ -0,0 +1,92 @@ +//! Refresh grouping for cards backed by the same built-in reader + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use gtk::glib; + +use super::worker::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; +use super::{BuiltinStat, BuiltinStatKey}; +use crate::ui::widgets::stats::card::StatItem; + +pub(in crate::ui::widgets::stats) struct RefreshGroup { + // One reader is enough for every card that points at the same source + stat: BuiltinStat, + // Each card receives the same sample and updated reader state + items: Vec, +} + +pub(in crate::ui::widgets::stats) fn collect_builtin_groups( + items: &[StatItem], + now: Instant, + force: bool, +) -> HashMap { + let mut groups: HashMap = HashMap::new(); + + for item in items { + let Some((key, stat)) = item.take_builtin_refresh(now, force) else { + continue; + }; + + // Keep one reader per source and collect every matching card + match groups.get_mut(&key) { + Some(group) => group.items.push(item.clone()), + None => { + groups.insert( + key, + RefreshGroup { + stat, + items: vec![item.clone()], + }, + ); + } + } + } + + groups +} + +impl RefreshGroup { + pub(in crate::ui::widgets::stats) fn refresh(self, base_interval: Duration) { + let (tx, rx) = async_channel::bounded(1); + let fallback = self.stat.clone(); + let worker = BuiltinWorker::global(); + + match worker.submit(BuiltinJob { + stat: self.stat, + respond: tx, + }) { + SubmitOutcome::Submitted => {} + SubmitOutcome::QueueFull => { + // Restore every card so the next refresh wave can retry + for item in self.items { + item.restore_builtin_error(fallback.clone(), base_interval); + } + return; + } + SubmitOutcome::WorkerUnavailable => { + // Inline fallback samples once before fan-out + let sample = BuiltinSample::read(fallback); + for item in self.items { + item.restore_builtin_sample(sample.clone(), base_interval); + } + return; + } + } + + glib::MainContext::default().spawn_local(async move { + let result = rx.recv().await; + let Ok(sample) = result else { + for item in self.items { + item.restore_builtin_error(fallback.clone(), base_interval); + } + return; + }; + + // Every grouped card receives the same value and reader state + for item in self.items { + item.restore_builtin_sample(sample.clone(), base_interval); + } + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs new file mode 100644 index 000000000..f1c4c172e --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/mod.rs @@ -0,0 +1,9 @@ +//! Built-in statistic sources and refresh infrastructure + +mod detect; +pub(in crate::ui::widgets::stats) mod group; +mod model; +pub(in crate::ui::widgets::stats) mod readers; +pub(in crate::ui::widgets::stats) mod worker; + +pub(super) use model::{BuiltinStat, BuiltinStatKey}; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs new file mode 100644 index 000000000..7db181450 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/model.rs @@ -0,0 +1,80 @@ +//! Built-in statistic identity and retained sample state + +use std::time::Instant; + +#[derive(Clone, Debug)] +pub(in crate::ui::widgets::stats) struct BuiltinStat { + pub(super) kind: BuiltinStatKind, + pub(super) state: BuiltinState, +} + +#[derive(Clone, Debug)] +pub(super) enum BuiltinStatKind { + Cpu, + Memory, + Load, + Battery, + Network { iface: Option }, +} + +#[derive(Clone, Debug)] +pub(super) enum BuiltinState { + None, + Cpu { + last_total: u64, + last_idle: u64, + }, + Network { + last_rx: u64, + last_tx: u64, + last_at: Instant, + }, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::ui::widgets::stats) enum BuiltinStatKey { + // Every CPU card reads the same procfs source + Cpu, + // Every memory card reads the same procfs source + Memory, + // Load average is shared across cards too + Load, + // Battery cards share one aggregated battery snapshot + Battery, + // Network cards only share reads when they target the same interface + Network { iface: Option }, +} + +impl BuiltinStat { + pub(super) fn new(kind: BuiltinStatKind) -> Self { + let state = match kind { + BuiltinStatKind::Cpu => BuiltinState::Cpu { + last_total: 0, + last_idle: 0, + }, + BuiltinStatKind::Network { .. } => BuiltinState::Network { + last_rx: 0, + last_tx: 0, + last_at: Instant::now(), + }, + _ => BuiltinState::None, + }; + Self { kind, state } + } + + pub(in crate::ui::widgets::stats) fn key(&self) -> BuiltinStatKey { + match &self.kind { + BuiltinStatKind::Cpu => BuiltinStatKey::Cpu, + BuiltinStatKind::Memory => BuiltinStatKey::Memory, + BuiltinStatKind::Load => BuiltinStatKey::Load, + BuiltinStatKind::Battery => BuiltinStatKey::Battery, + BuiltinStatKind::Network { iface } => BuiltinStatKey::Network { + iface: iface.clone(), + }, + } + } +} + +#[cfg(test)] +#[path = "tests/model.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs similarity index 95% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs index cebfaf7d1..7ec634ba5 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_battery.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/battery.rs @@ -6,11 +6,11 @@ use std::fs; use std::path::Path; -pub(super) fn read_battery() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_battery() -> Option { read_battery_from(Path::new("/sys/class/power_supply")) } -pub(super) fn read_battery_from(root: &Path) -> Option { +pub(in crate::ui::widgets::stats) fn read_battery_from(root: &Path) -> Option { let entries = fs::read_dir(root).ok()?; let mut energy_now_total = 0u64; let mut energy_full_total = 0u64; @@ -100,3 +100,7 @@ fn read_power_supply_value(path: &Path) -> Option { let contents = fs::read_to_string(path).ok()?; contents.trim().parse::().ok() } + +#[cfg(test)] +#[path = "tests/battery.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs similarity index 73% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs index c081e5e7b..e3643eb44 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_cpu.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/cpu.rs @@ -1,10 +1,10 @@ -//! CPU reader helpers for builtin stats. +//! CPU reader helpers for built-in stats //! -//! Reads /proc/stat and returns total/idle counters for usage calculation. +//! Reads /proc/stat and returns total and idle counters for usage calculation use std::fs; -pub(super) fn read_cpu_sample() -> Option<(u64, u64)> { +pub(in crate::ui::widgets::stats::builtin) fn read_cpu_sample() -> Option<(u64, u64)> { let contents = fs::read_to_string("/proc/stat").ok()?; let line = contents.lines().find(|line| line.starts_with("cpu "))?; let mut parts = line.split_whitespace(); diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs new file mode 100644 index 000000000..fd44d0ef0 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/dispatch.rs @@ -0,0 +1,44 @@ +//! Built-in reader dispatch and stateful sample formatting + +use super::{read_battery, read_cpu_sample, read_loadavg, read_memory, read_network}; +use crate::ui::widgets::stats::builtin::model::{BuiltinStat, BuiltinStatKind, BuiltinState}; + +impl BuiltinStat { + pub(in crate::ui::widgets::stats) fn read(&mut self) -> Option { + match &mut self.kind { + BuiltinStatKind::Cpu => self.read_cpu(), + BuiltinStatKind::Memory => read_memory(), + BuiltinStatKind::Load => read_loadavg(), + BuiltinStatKind::Battery => read_battery(), + BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), + } + } + + fn read_cpu(&mut self) -> Option { + let (total, idle) = read_cpu_sample()?; + let usage = match &mut self.state { + BuiltinState::Cpu { + last_total, + last_idle, + } => { + let usage = if *last_total > 0 && total > *last_total { + // Delta-based usage avoids spikes when the counter wraps + let delta_total = total - *last_total; + let delta_idle = idle.saturating_sub(*last_idle); + 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 + } else if total > 0 { + // The first read falls back to absolute usage + 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 + } else { + 0.0 + }; + // Updated counters become the baseline for the next delta + *last_total = total; + *last_idle = idle; + usage + } + _ => 0.0, + }; + Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs similarity index 82% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs index a6e01c7bb..228298a14 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_load.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/load.rs @@ -4,7 +4,7 @@ use std::fs; -pub(super) fn read_loadavg() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_loadavg() -> Option { let contents = fs::read_to_string("/proc/loadavg").ok()?; let mut parts = contents.split_whitespace(); let one = parts.next()?; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs similarity index 91% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs index 46e7ef00e..7bc05a2e8 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_memory.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/memory.rs @@ -4,7 +4,7 @@ use std::fs; -pub(super) fn read_memory() -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_memory() -> Option { let contents = fs::read_to_string("/proc/meminfo").ok()?; let mut total_kb = None; let mut avail_kb = None; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs new file mode 100644 index 000000000..65a65e497 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/mod.rs @@ -0,0 +1,17 @@ +//! Procfs and sysfs readers for built-in statistic cards + +pub(in crate::ui::widgets::stats) mod battery; +mod cpu; +mod dispatch; +mod load; +mod memory; +pub(in crate::ui::widgets::stats) mod network; + +pub(super) use battery::read_battery; +pub(super) use cpu::read_cpu_sample; +pub(super) use load::read_loadavg; +pub(super) use memory::read_memory; +pub(super) use network::{extract_iface, read_network}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs similarity index 89% rename from crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs rename to crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs index 4e93ca899..f78216098 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin_network.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/network.rs @@ -6,9 +6,12 @@ use std::fs; use std::path::Path; use std::time::Instant; -use super::BuiltinState; +use super::super::model::BuiltinState; -pub(super) fn read_network(state: &mut BuiltinState, iface: &mut Option) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn read_network( + state: &mut BuiltinState, + iface: &mut Option, +) -> Option { if iface.is_none() { // Choose a stable default interface once to avoid flicker between refreshes *iface = pick_default_iface(); @@ -83,14 +86,16 @@ fn pick_default_iface() -> Option { } #[derive(Debug, Clone)] -pub(super) struct IfaceCandidate { +pub(in crate::ui::widgets::stats) struct IfaceCandidate { // Interface name as reported by sysfs - pub(super) name: String, + pub(in crate::ui::widgets::stats) name: String, // Raw operstate contents ("up", "down", etc), kept for ranking - pub(super) operstate: String, + pub(in crate::ui::widgets::stats) operstate: String, } -pub(super) fn pick_default_iface_from(candidates: &[IfaceCandidate]) -> Option { +pub(in crate::ui::widgets::stats) fn pick_default_iface_from( + candidates: &[IfaceCandidate], +) -> Option { // Filter invalid entries early to keep ranking logic simple let mut ranked: Vec<&IfaceCandidate> = candidates .iter() @@ -166,7 +171,7 @@ fn format_rate(rate: f64) -> String { } } -pub(super) fn extract_iface(cmd: &str) -> Option { +pub(in crate::ui::widgets::stats::builtin) fn extract_iface(cmd: &str) -> Option { let marker = "/sys/class/net/"; let start = cmd.find(marker)? + marker.len(); let rest = &cmd[start..]; @@ -177,3 +182,7 @@ pub(super) fn extract_iface(cmd: &str) -> Option { Some(iface.to_string()) } } + +#[cfg(test)] +#[path = "tests/network.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs new file mode 100644 index 000000000..ef3dc9040 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/battery.rs @@ -0,0 +1,81 @@ +//! Battery reader tests + +use super::super::tests::support::{write_device, TempDir}; +use super::read_battery_from; + +#[test] +fn battery_energy_values_are_weighted_by_full_capacity() { + let temp = TempDir::new("unixnotis-battery-energy"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "30"), + ("energy_full", "60"), + ], + ); + write_device( + temp.path(), + "BAT1", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "10"), + ("energy_full", "40"), + ], + ); + + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + + assert_eq!(percent, "40"); +} + +#[test] +fn battery_mixed_units_fall_back_to_reported_capacity() { + let temp = TempDir::new("unixnotis-battery-mixed"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "1"), + ("energy_now", "30"), + ("energy_full", "60"), + ("capacity", "60"), + ], + ); + write_device( + temp.path(), + "BAT1", + &[ + ("type", "Battery"), + ("present", "1"), + ("charge_now", "10"), + ("charge_full", "40"), + ("capacity", "25"), + ], + ); + + let percent = read_battery_from(temp.path()).expect("battery percent missing"); + + assert_eq!(percent, "43"); +} + +#[test] +fn battery_reader_skips_devices_reported_as_absent() { + let temp = TempDir::new("unixnotis-battery-absent"); + write_device( + temp.path(), + "BAT0", + &[ + ("type", "Battery"), + ("present", "0"), + ("energy_now", "30"), + ("energy_full", "60"), + ], + ); + + assert!(read_battery_from(temp.path()).is_none()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs new file mode 100644 index 000000000..2c2400e32 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/mod.rs @@ -0,0 +1,3 @@ +//! Shared built-in reader test support + +pub(super) mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs new file mode 100644 index 000000000..a91171bf2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/network.rs @@ -0,0 +1,60 @@ +//! Network reader selection tests + +use super::{pick_default_iface_from, IfaceCandidate}; + +#[test] +fn default_interface_prefers_an_active_physical_device() { + let candidates = vec![ + IfaceCandidate { + name: "veth0".to_string(), + operstate: "up".to_string(), + }, + IfaceCandidate { + name: "wlan0".to_string(), + operstate: "up".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("wlan0".to_string()) + ); +} + +#[test] +fn default_interface_prefers_physical_devices_when_all_are_down() { + let candidates = vec![ + IfaceCandidate { + name: "eth0".to_string(), + operstate: "down".to_string(), + }, + IfaceCandidate { + name: "docker0".to_string(), + operstate: "up".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("eth0".to_string()) + ); +} + +#[test] +fn default_interface_uses_name_as_a_deterministic_tiebreaker() { + let candidates = vec![ + IfaceCandidate { + name: "eth1".to_string(), + operstate: "down".to_string(), + }, + IfaceCandidate { + name: "eth0".to_string(), + operstate: "down".to_string(), + }, + ]; + + assert_eq!( + pick_default_iface_from(&candidates), + Some("eth0".to_string()) + ); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs new file mode 100644 index 000000000..7daac08e7 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/readers/tests/support.rs @@ -0,0 +1,44 @@ +//! Filesystem fixtures for procfs and sysfs readers + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(in crate::ui::widgets::stats::builtin::readers) struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub(in crate::ui::widgets::stats::builtin::readers) fn new(prefix: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = std::env::temp_dir().join(format!("{prefix}-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path).expect("temp dir creation failed"); + Self { path } + } + + pub(in crate::ui::widgets::stats::builtin::readers) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Cleanup is best effort so a failed assertion remains visible + let _ = fs::remove_dir_all(&self.path); + } +} + +pub(in crate::ui::widgets::stats::builtin::readers) fn write_device( + root: &Path, + name: &str, + entries: &[(&str, &str)], +) { + let device_path = root.join(name); + fs::create_dir_all(&device_path).expect("device directory creation failed"); + for (file, contents) in entries { + fs::write(device_path.join(file), contents).expect("device file write failed"); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs new file mode 100644 index 000000000..ca6d01e22 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/model.rs @@ -0,0 +1,24 @@ +//! Built-in statistic identity tests + +use super::super::{BuiltinStat, BuiltinStatKey}; + +#[test] +fn matching_builtin_sources_produce_the_same_group_key() { + let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + + assert_eq!(first.key(), BuiltinStatKey::Cpu); + assert_eq!(first.key(), second.key()); +} + +#[test] +fn network_group_keys_include_the_interface_name() { + let stat = BuiltinStat::from_command("builtin:net:wlan0").expect("builtin stat"); + + assert_eq!( + stat.key(), + BuiltinStatKey::Network { + iface: Some("wlan0".to_string()), + } + ); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs new file mode 100644 index 000000000..cecd131a4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/tests/worker.rs @@ -0,0 +1,42 @@ +//! Built-in statistic worker tests + +use super::{BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome}; +use crate::ui::widgets::stats::builtin::BuiltinStat; + +#[test] +fn builtin_worker_reports_a_full_queue_without_blocking() { + let (tx, _worker_rx) = crossbeam_channel::bounded(1); + let worker = BuiltinWorker { + tx, + inline_fallback: false, + }; + let first = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let second = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + let (first_tx, _first_rx) = async_channel::bounded(1); + let (second_tx, _second_rx) = async_channel::bounded(1); + + assert_eq!( + worker.submit(BuiltinJob { + stat: first, + respond: first_tx, + }), + SubmitOutcome::Submitted + ); + assert_eq!( + worker.submit(BuiltinJob { + stat: second, + respond: second_tx, + }), + SubmitOutcome::QueueFull + ); +} + +#[test] +fn builtin_sample_preserves_reader_failure_as_missing_data() { + let stat = + BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); + + let sample = BuiltinSample::read(stat); + + assert!(sample.value.is_none()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs new file mode 100644 index 000000000..533093cac --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/builtin/worker.rs @@ -0,0 +1,90 @@ +//! Bounded worker for built-in statistic samples + +use std::thread; + +use crossbeam_channel::TrySendError; +use tracing::warn; + +use super::BuiltinStat; + +pub(in crate::ui::widgets::stats) struct BuiltinJob { + // Reader state moves to the worker for one sample + pub(in crate::ui::widgets::stats) stat: BuiltinStat, + // One-shot response keeps read failure separate from display policy + pub(in crate::ui::widgets::stats) respond: async_channel::Sender, +} + +#[derive(Clone, Debug)] +pub(in crate::ui::widgets::stats) struct BuiltinSample { + // Updated state must return to the card for the next delta sample + pub(in crate::ui::widgets::stats) stat: BuiltinStat, + // Missing values represent reader failure rather than display text + pub(in crate::ui::widgets::stats) value: Option, +} + +pub(in crate::ui::widgets::stats) struct BuiltinWorker { + // Bounded transport prevents refresh waves from growing memory without limit + pub(in crate::ui::widgets::stats) tx: crossbeam_channel::Sender, + // Failed startup selects the inline fallback path + pub(in crate::ui::widgets::stats) inline_fallback: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::ui::widgets::stats) enum SubmitOutcome { + Submitted, + QueueFull, + WorkerUnavailable, +} + +impl BuiltinWorker { + const QUEUE_CAPACITY: usize = 32; + + pub(in crate::ui::widgets::stats) fn global() -> &'static Self { + static WORKER: std::sync::OnceLock = std::sync::OnceLock::new(); + WORKER.get_or_init(Self::new) + } + + fn new() -> Self { + let (tx, rx) = crossbeam_channel::bounded::(Self::QUEUE_CAPACITY); + // One thread is enough because built-in reads are short and serialized + let spawn = thread::Builder::new() + .name("unixnotis-builtin-stats".to_string()) + .spawn(move || { + for job in &rx { + let _ = job.respond.send_blocking(BuiltinSample::read(job.stat)); + } + }); + let inline_fallback = spawn.is_err(); + if inline_fallback { + warn!("builtin stats worker unavailable; using inline reads"); + } + + Self { + tx, + inline_fallback, + } + } + + pub(in crate::ui::widgets::stats) fn submit(&self, job: BuiltinJob) -> SubmitOutcome { + if self.inline_fallback { + return SubmitOutcome::WorkerUnavailable; + } + // The GTK thread never waits for queue capacity + match self.tx.try_send(job) { + Ok(()) => SubmitOutcome::Submitted, + Err(TrySendError::Full(_job)) => SubmitOutcome::QueueFull, + Err(TrySendError::Disconnected(_job)) => SubmitOutcome::WorkerUnavailable, + } + } +} + +impl BuiltinSample { + pub(in crate::ui::widgets::stats) fn read(mut stat: BuiltinStat) -> Self { + let value = stat.read(); + Self { stat, value } + } +} + +#[cfg(test)] +#[path = "tests/worker.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card.rs b/crates/unixnotis-center/src/ui/widgets/stats/card.rs deleted file mode 100644 index b2b0b627d..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/card.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Stat refresh and label update logic - -use std::time::{Duration, Instant}; - -use gtk::glib; -use gtk::prelude::*; -use tracing::warn; -use unixnotis_core::{PanelDebugLevel, WidgetPluginConfig}; - -use super::super::plugin::{parse_stat_plugin_payload, PluginOutputLimits}; -use super::super::utils::{ - run_command_capture_async, run_command_capture_with_timeout_async, INFLIGHT_REFRESH_RECHECK, -}; -use super::{apply_cached_value, BuiltinStat, BuiltinStatKey, StatItem}; -use crate::diagnostics::panel_debug as debug; - -impl StatItem { - pub(super) fn has_builtin_source(&self) -> bool { - self.config.plugin.is_none() && self.builtin.borrow().is_some() - } - - pub(super) fn is_grouped_builtin(&self, now: Instant, force: bool) -> bool { - if !self.has_builtin_source() { - return false; - } - - if !self.root.is_visible() { - return false; - } - - if self.inflight.get() { - // Builtin groups keep their own in-flight guard, so grouped items can skip the fallback path - return true; - } - - self.refresh_backoff.borrow().should_refresh(now, force) - } - - pub(super) fn take_builtin_refresh( - &self, - now: Instant, - force: bool, - ) -> Option<(BuiltinStatKey, BuiltinStat)> { - if !self.root.is_visible() { - return None; - } - if self.config.plugin.is_some() { - return None; - } - if !self.refresh_backoff.borrow().should_refresh(now, force) { - return None; - } - if self.inflight.get() { - return None; - } - - let builtin = self.builtin.borrow_mut().take()?; - self.inflight.set(true); - Some((builtin.key(), builtin)) - } - - pub(super) fn refresh(&self, base_interval: Duration, force: bool) { - if !self.root.is_visible() { - return; - } - let now = Instant::now(); - // Skip refresh when the backoff window has not elapsed - if !self.refresh_backoff.borrow().should_refresh(now, force) { - return; - } - debug::log(PanelDebugLevel::Verbose, || { - format!("stat refresh: {}", self.config.label) - }); - if self.inflight.get() { - return; - } - if let Some(plugin) = self.config.plugin.as_ref() { - // Plugin source has higher priority than legacy cmd and builtin paths - self.refresh_plugin(plugin, base_interval); - return; - } - if let Some(builtin) = self.builtin.borrow_mut().take() { - self.refresh_builtin(builtin, base_interval); - return; - } - - let Some(cmd) = self.config.cmd.as_ref() else { - // Cards with no source fall back to the placeholder instead of spinning forever - let changed = self.apply_value("n/a"); - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - return; - }; - self.inflight.set(true); - let cmd = cmd.clone(); - let rx = run_command_capture_async(&cmd); - let label = self.value_label.clone(); - let inflight = self.inflight.clone(); - let last_value = self.last_value.clone(); - let refresh_backoff = self.refresh_backoff.clone(); - glib::MainContext::default().spawn_local(async move { - // Receive first so broken worker paths do not leave the card stuck in-flight - let output = if let Ok(output) = rx.recv().await { - output - } else { - inflight.set(false); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - }; - inflight.set(false); - let output = match output { - Ok(output) => output, - Err(err) => { - warn!(?cmd, ?err, "stat command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - if !output.status.success() { - warn!(?cmd, "stat command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - let stdout = String::from_utf8_lossy(&output.stdout); - let value = stdout.trim(); - if value.is_empty() { - // Empty command output keeps the last good value on screen - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, false); - } else { - let changed = last_value.borrow().as_deref() != Some(value); - if changed { - label.set_text(value); - *last_value.borrow_mut() = Some(value.to_string()); - } - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - } - }); - } - - pub(super) fn next_refresh_in(&self, now: Instant) -> Option { - if !self.root.is_visible() { - return None; - } - if self.inflight.get() { - // A slow command should not turn into a four-times-per-second scheduler loop - return Some(INFLIGHT_REFRESH_RECHECK); - } - self.refresh_backoff - .borrow() - .next_due_in(now) - .or(Some(Duration::ZERO)) - } - - pub(super) fn restore_builtin_error(&self, builtin: BuiltinStat, base_interval: Duration) { - self.inflight.set(false); - *self.builtin.borrow_mut() = Some(builtin); - self.refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - } - - pub(super) fn restore_builtin_value( - &self, - builtin: BuiltinStat, - value: &str, - base_interval: Duration, - ) { - self.inflight.set(false); - *self.builtin.borrow_mut() = Some(builtin); - if value.is_empty() { - apply_cached_value(&self.value_label, &self.last_value); - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, false); - return; - } - - let changed = self.last_value.borrow().as_deref() != Some(value); - if changed { - self.value_label.set_text(value); - *self.last_value.borrow_mut() = Some(value.to_string()); - } - self.refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - } - - fn refresh_plugin(&self, plugin: &WidgetPluginConfig, base_interval: Duration) { - self.inflight.set(true); - let command = plugin.command.clone(); - let timeout = Duration::from_millis(plugin.timeout_ms); - let output_limits = PluginOutputLimits { - max_output_bytes: plugin.max_output_bytes, - }; - let rx = run_command_capture_with_timeout_async(&command, timeout); - let label = self.value_label.clone(); - let inflight = self.inflight.clone(); - let last_value = self.last_value.clone(); - let refresh_backoff = self.refresh_backoff.clone(); - glib::MainContext::default().spawn_local(async move { - // Plugin output uses the same cache rules as plain commands - let output = if let Ok(output) = rx.recv().await { - output - } else { - inflight.set(false); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - }; - inflight.set(false); - let output = match output { - Ok(output) => output, - Err(err) => { - warn!(command = %command, ?err, "stat plugin command failed"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - if !output.status.success() { - warn!(command = %command, "stat plugin command returned non-zero status"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - - let parsed = match parse_stat_plugin_payload(&output.stdout, output_limits) { - Ok(parsed) => parsed, - Err(err) => { - warn!(command = %command, %err, "failed to parse stat plugin payload"); - apply_cached_value(&label, &last_value); - refresh_backoff - .borrow_mut() - .note_error(Instant::now(), base_interval); - return; - } - }; - let changed = if last_value.borrow().as_deref() == Some(parsed.text.as_str()) { - false - } else { - label.set_text(&parsed.text); - *last_value.borrow_mut() = Some(parsed.text); - true - }; - refresh_backoff - .borrow_mut() - .note_success(Instant::now(), base_interval, changed); - }); - } - - pub(super) fn apply_value(&self, value: &str) -> bool { - if self.last_value.borrow().as_deref() == Some(value) { - return false; - } - // Cache and label are updated together so later fallback reads stay honest - self.value_label.set_text(value); - *self.last_value.borrow_mut() = Some(value.to_string()); - true - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs new file mode 100644 index 000000000..a0a09f939 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/build.rs @@ -0,0 +1,90 @@ +//! Statistic card construction + +use gtk::prelude::*; +use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; + +use super::super::builtin::BuiltinStat; +use super::super::style::stat_kind_css_class; +use super::StatItem; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::icon_image::image_from_icon_config; + +impl StatItem { + pub(in crate::ui::widgets::stats) fn new( + config: StatWidgetConfig, + icon_resolver: &IconAssetResolver, + ) -> Self { + let card = gtk::Box::new(gtk::Orientation::Vertical, 6); + card.add_css_class(hooks::stat_card::ROOT); + if config.plugin.is_some() { + // Plugin cards expose a dedicated theme hook + card.add_css_class(hooks::stat_card::PLUGIN); + } else { + card.add_css_class(hooks::stat_card::BUILTIN); + } + if config.min_height > 0 { + // A minimum height keeps cards aligned within the grid + card.set_size_request(-1, config.min_height); + } + if let Some(kind) = config.kind.as_deref().and_then(stat_kind_css_class) { + // Kind hooks allow stable theme targeting without relying on order + card.add_css_class(&kind); + } + + let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); + header.add_css_class(hooks::stat_card::HEADER); + if let Some(icon) = image_from_icon_config( + icon_resolver, + &config.label, + config.icon.as_deref(), + config.icon_asset.as_deref(), + 16, + ) { + icon.add_css_class(hooks::stat_card::ICON); + header.append(&icon); + card.add_css_class(hooks::stat_card::HAS_ICON); + } else { + // No-icon cards expose a hook so CSS can rebalance spacing + card.add_css_class(hooks::stat_card::NO_ICON); + } + + let title = gtk::Label::new(Some(&config.label)); + title.add_css_class(hooks::stat_card::TITLE); + title.set_xalign(0.0); + header.append(&title); + + let value_label = gtk::Label::new(Some("n/a")); + value_label.add_css_class(hooks::stat_card::VALUE); + value_label.set_xalign(0.0); + value_label.set_width_chars(12); + + card.append(&header); + card.append(&value_label); + + let builtin = if config.plugin.is_some() { + // Plugin-backed cards bypass built-in readers + None + } else { + config + .cmd + .as_ref() + .and_then(|cmd| cmd.program()) + .and_then(|program| program.to_str()) + .and_then(BuiltinStat::from_command) + }; + + Self { + config, + root: card, + value_label, + builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), + inflight: std::rc::Rc::new(std::cell::Cell::new(false)), + last_value: std::rc::Rc::new(std::cell::RefCell::new(None)), + refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), + } + } + + pub(in crate::ui::widgets::stats) const fn root(&self) -> >k::Box { + &self.root + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs new file mode 100644 index 000000000..6d2d2bf26 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/mod.rs @@ -0,0 +1,9 @@ +//! Statistic card ownership and refresh behavior + +mod build; +mod model; +mod refresh; +mod render; + +pub(super) use model::StatItem; +use model::StatSourceRef; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs new file mode 100644 index 000000000..618b10ed0 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/model.rs @@ -0,0 +1,34 @@ +//! Retained state for one statistic card + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use unixnotis_core::{CommandSpec, StatWidgetConfig, WidgetPluginConfig}; + +use super::super::builtin::BuiltinStat; +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; + +#[derive(Clone)] +pub(in crate::ui::widgets::stats) struct StatItem { + // Raw config supplies source selection and display metadata + pub(in crate::ui::widgets::stats) config: StatWidgetConfig, + // Root card inserted into the grid + pub(in crate::ui::widgets::stats) root: gtk::Box, + // Label receives the latest rendered sample + pub(in crate::ui::widgets::stats) value_label: gtk::Label, + // Built-in reader state is retained across samples + pub(in crate::ui::widgets::stats) builtin: Rc>>, + // In-flight state prevents overlapping refreshes + pub(in crate::ui::widgets::stats) inflight: Rc>, + // Last good value avoids unnecessary relayout + pub(in crate::ui::widgets::stats) last_value: Rc>>, + // Backoff slows sources whose output remains stable + pub(in crate::ui::widgets::stats) refresh_backoff: Rc>, +} + +pub(super) enum StatSourceRef<'a> { + Plugin(&'a WidgetPluginConfig), + Builtin(BuiltinStat), + Command(&'a CommandSpec), + Missing, +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs new file mode 100644 index 000000000..e9bfe4afb --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/builtin.rs @@ -0,0 +1,93 @@ +//! Individual built-in refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::stats::builtin::worker::{ + BuiltinJob, BuiltinSample, BuiltinWorker, SubmitOutcome, +}; +use crate::ui::widgets::stats::builtin::BuiltinStat; + +impl StatItem { + pub(super) fn refresh_builtin(&self, builtin: BuiltinStat, base_interval: Duration) { + self.inflight.set(true); + let (tx, rx) = async_channel::bounded(1); + let fallback = builtin.clone(); + let worker = BuiltinWorker::global(); + + match worker.submit(BuiltinJob { + stat: builtin, + respond: tx, + }) { + SubmitOutcome::Submitted => {} + SubmitOutcome::QueueFull => { + // Queue pressure remains non-blocking on the GTK thread + self.restore_builtin_error(fallback, base_interval); + return; + } + SubmitOutcome::WorkerUnavailable => { + // Inline fallback keeps built-in cards available after startup failure + self.restore_builtin_sample(BuiltinSample::read(fallback), base_interval); + return; + } + } + + let item = self.clone(); + glib::MainContext::default().spawn_local(async move { + // Restore reader state on every exit path + let result = rx.recv().await; + let Ok(sample) = result else { + item.restore_builtin_error(fallback, base_interval); + return; + }; + item.restore_builtin_sample(sample, base_interval); + }); + } + + pub(in crate::ui::widgets::stats) fn restore_builtin_error( + &self, + builtin: BuiltinStat, + base_interval: Duration, + ) { + self.inflight.set(false); + *self.builtin.borrow_mut() = Some(builtin); + self.refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + } + + pub(in crate::ui::widgets::stats) fn restore_builtin_sample( + &self, + sample: BuiltinSample, + base_interval: Duration, + ) { + let BuiltinSample { stat, value } = sample; + let Some(value) = value else { + // Reader failure preserves the last good value and uses error backoff + apply_cached_value(&self.value_label, &self.last_value); + self.restore_builtin_error(stat, base_interval); + return; + }; + + self.inflight.set(false); + *self.builtin.borrow_mut() = Some(stat); + if value.is_empty() { + apply_cached_value(&self.value_label, &self.last_value); + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, false); + return; + } + + let changed = self.last_value.borrow().as_deref() != Some(value.as_str()); + if changed { + self.value_label.set_text(&value); + *self.last_value.borrow_mut() = Some(value); + } + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs new file mode 100644 index 000000000..022f0380b --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/command.rs @@ -0,0 +1,74 @@ +//! Arbitrary command refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; +use tracing::warn; +use unixnotis_core::CommandSpec; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::command_runtime::command::run_command_capture_async; + +impl StatItem { + pub(super) fn refresh_command(&self, command: &CommandSpec, base_interval: Duration) { + self.inflight.set(true); + let command = command.clone(); + let rx = run_command_capture_async(&command); + let label = self.value_label.clone(); + let inflight = self.inflight.clone(); + let last_value = self.last_value.clone(); + let refresh_backoff = self.refresh_backoff.clone(); + + glib::MainContext::default().spawn_local(async move { + // Receive first so a broken worker cannot leave the card in flight + let output = if let Ok(output) = rx.recv().await { + output + } else { + inflight.set(false); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + }; + inflight.set(false); + let output = match output { + Ok(output) => output, + Err(error) => { + warn!(?command, ?error, "stat command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + if !output.status.success() { + warn!(?command, "stat command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let value = stdout.trim(); + if value.is_empty() { + // Empty output preserves the last good value + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, false); + } else { + let changed = last_value.borrow().as_deref() != Some(value); + if changed { + label.set_text(value); + *last_value.borrow_mut() = Some(value.to_string()); + } + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs new file mode 100644 index 000000000..d27774570 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/dispatch.rs @@ -0,0 +1,110 @@ +//! Statistic card source dispatch and scheduling gates + +use std::time::{Duration, Instant}; + +use gtk::prelude::*; +use unixnotis_core::PanelDebugLevel; + +use super::super::{StatItem, StatSourceRef}; +use crate::diagnostics::panel_debug as debug; +use crate::ui::widgets::command_runtime::backoff::INFLIGHT_REFRESH_RECHECK; +use crate::ui::widgets::stats::builtin::{BuiltinStat, BuiltinStatKey}; + +impl StatItem { + pub(in crate::ui::widgets::stats) fn has_builtin_source(&self) -> bool { + self.config.plugin.is_none() && self.builtin.borrow().is_some() + } + + pub(in crate::ui::widgets::stats) fn is_grouped_builtin( + &self, + now: Instant, + force: bool, + ) -> bool { + if !self.has_builtin_source() || !self.root.is_visible() { + return false; + } + + if self.inflight.get() { + // Groups keep their own in-flight guard + return true; + } + + self.refresh_backoff.borrow().should_refresh(now, force) + } + + pub(in crate::ui::widgets::stats) fn take_builtin_refresh( + &self, + now: Instant, + force: bool, + ) -> Option<(BuiltinStatKey, BuiltinStat)> { + if !self.root.is_visible() + || self.config.plugin.is_some() + || !self.refresh_backoff.borrow().should_refresh(now, force) + || self.inflight.get() + { + return None; + } + + let builtin = self.builtin.borrow_mut().take()?; + self.inflight.set(true); + Some((builtin.key(), builtin)) + } + + pub(in crate::ui::widgets::stats) fn refresh(&self, base_interval: Duration, force: bool) { + if !self.root.is_visible() { + return; + } + let now = Instant::now(); + if !self.refresh_backoff.borrow().should_refresh(now, force) { + return; + } + debug::log(PanelDebugLevel::Verbose, || { + format!("stat refresh: {}", self.config.label) + }); + if self.inflight.get() { + return; + } + match self.source() { + StatSourceRef::Plugin(plugin) => self.refresh_plugin(plugin, base_interval), + StatSourceRef::Builtin(builtin) => self.refresh_builtin(builtin, base_interval), + StatSourceRef::Command(command) => self.refresh_command(command, base_interval), + StatSourceRef::Missing => self.refresh_missing(base_interval), + } + } + + fn source(&self) -> StatSourceRef<'_> { + if let Some(plugin) = self.config.plugin.as_ref() { + // Plugin configuration always has source precedence + return StatSourceRef::Plugin(plugin); + } + if let Some(builtin) = self.builtin.borrow_mut().take() { + return StatSourceRef::Builtin(builtin); + } + self.config + .cmd + .as_ref() + .map_or(StatSourceRef::Missing, StatSourceRef::Command) + } + + pub(in crate::ui::widgets::stats) fn refresh_missing(&self, base_interval: Duration) { + // Missing sources settle on the placeholder without spinning + let changed = self.apply_value("n/a"); + self.refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + } + + pub(in crate::ui::widgets::stats) fn next_refresh_in(&self, now: Instant) -> Option { + if !self.root.is_visible() { + return None; + } + if self.inflight.get() { + // Slow sources should not create a tight scheduler loop + return Some(INFLIGHT_REFRESH_RECHECK); + } + self.refresh_backoff + .borrow() + .next_due_in(now) + .or(Some(Duration::ZERO)) + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs new file mode 100644 index 000000000..f413749ca --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/mod.rs @@ -0,0 +1,9 @@ +//! Statistic card refresh dispatch and scheduling gates + +mod builtin; +mod command; +mod dispatch; +mod plugin; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs new file mode 100644 index 000000000..ee5515f04 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/plugin.rs @@ -0,0 +1,82 @@ +//! Plugin refresh handling + +use std::time::{Duration, Instant}; + +use gtk::glib; +use tracing::warn; +use unixnotis_core::WidgetPluginConfig; + +use super::super::{render::apply_cached_value, StatItem}; +use crate::ui::widgets::command_runtime::command::run_command_capture_with_timeout_async; +use crate::ui::widgets::plugin::{parse_stat_plugin_payload, PluginOutputLimits}; + +impl StatItem { + pub(super) fn refresh_plugin(&self, plugin: &WidgetPluginConfig, base_interval: Duration) { + self.inflight.set(true); + let command = plugin.command.clone(); + let timeout = Duration::from_millis(plugin.timeout_ms); + let output_limits = PluginOutputLimits { + max_output_bytes: plugin.max_output_bytes, + }; + let rx = run_command_capture_with_timeout_async(&command, timeout); + let label = self.value_label.clone(); + let inflight = self.inflight.clone(); + let last_value = self.last_value.clone(); + let refresh_backoff = self.refresh_backoff.clone(); + + glib::MainContext::default().spawn_local(async move { + // Plugins use the same cache and backoff policy as commands + let output = if let Ok(output) = rx.recv().await { + output + } else { + inflight.set(false); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + }; + inflight.set(false); + let output = match output { + Ok(output) => output, + Err(error) => { + warn!(command = %command, ?error, "stat plugin command failed"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + if !output.status.success() { + warn!(command = %command, "stat plugin command returned non-zero status"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + + let parsed = match parse_stat_plugin_payload(&output.stdout, output_limits) { + Ok(parsed) => parsed, + Err(error) => { + warn!(command = %command, %error, "failed to parse stat plugin payload"); + apply_cached_value(&label, &last_value); + refresh_backoff + .borrow_mut() + .note_error(Instant::now(), base_interval); + return; + } + }; + let changed = if last_value.borrow().as_deref() == Some(parsed.text.as_str()) { + false + } else { + label.set_text(&parsed.text); + *last_value.borrow_mut() = Some(parsed.text); + true + }; + refresh_backoff + .borrow_mut() + .note_success(Instant::now(), base_interval, changed); + }); + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs new file mode 100644 index 000000000..54c767bb5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/builtin.rs @@ -0,0 +1,39 @@ +//! Built-in statistic card refresh tests + +use std::time::Duration; + +use super::support::stat_item; +use crate::ui::widgets::stats::builtin::worker::BuiltinSample; +use crate::ui::widgets::stats::builtin::BuiltinStat; + +#[gtk::test] +fn failed_builtin_sample_preserves_the_last_good_value() { + let item = stat_item(None, Some("42%")); + let stat = + BuiltinStat::from_command("builtin:net:unixnotis-missing-interface").expect("builtin stat"); + + item.restore_builtin_sample(BuiltinSample { stat, value: None }, Duration::from_secs(1)); + + assert_eq!(item.value_label.text(), "42%"); + assert_eq!(item.last_value.borrow().as_deref(), Some("42%")); + assert!(!item.inflight.get()); + assert!(item.builtin.borrow().is_some()); +} + +#[gtk::test] +fn successful_builtin_sample_replaces_a_changed_value() { + let item = stat_item(None, Some("41%")); + let stat = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); + + item.restore_builtin_sample( + BuiltinSample { + stat, + value: Some("42%".to_string()), + }, + Duration::from_secs(1), + ); + + assert_eq!(item.value_label.text(), "42%"); + assert_eq!(item.last_value.borrow().as_deref(), Some("42%")); + assert!(!item.inflight.get()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs new file mode 100644 index 000000000..cd4c739ac --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/dispatch.rs @@ -0,0 +1,15 @@ +//! Statistic card dispatch tests + +use std::time::Duration; + +use super::support::stat_item; + +#[gtk::test] +fn missing_card_source_renders_the_placeholder() { + let item = stat_item(None, None); + + item.refresh_missing(Duration::from_secs(1)); + + assert_eq!(item.value_label.text(), "n/a"); + assert_eq!(item.last_value.borrow().as_deref(), Some("n/a")); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs new file mode 100644 index 000000000..bf7c8a183 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/mod.rs @@ -0,0 +1,5 @@ +//! Statistic card refresh tests mirrored by source type + +mod builtin; +mod dispatch; +mod support; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs new file mode 100644 index 000000000..f64a3039d --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/refresh/tests/support.rs @@ -0,0 +1,30 @@ +//! Shared GTK card fixtures + +use crate::ui::widgets::command_runtime::backoff::RefreshBackoff; +use crate::ui::widgets::stats::builtin::BuiltinStat; +use crate::ui::widgets::stats::card::StatItem; +use unixnotis_core::StatWidgetConfig; + +static GTK_INIT: std::sync::Once = std::sync::Once::new(); + +pub(super) fn init_gtk() { + GTK_INIT.call_once(|| { + gtk::init().expect("gtk should initialize under the test display"); + }); +} + +pub(super) fn stat_item(builtin: Option, value: Option<&str>) -> StatItem { + init_gtk(); + let rendered = value.unwrap_or("n/a"); + StatItem { + config: StatWidgetConfig::default(), + root: gtk::Box::new(gtk::Orientation::Vertical, 0), + value_label: gtk::Label::new(Some(rendered)), + builtin: std::rc::Rc::new(std::cell::RefCell::new(builtin)), + inflight: std::rc::Rc::new(std::cell::Cell::new(true)), + last_value: std::rc::Rc::new(std::cell::RefCell::new( + value.map(std::string::ToString::to_string), + )), + refresh_backoff: std::rc::Rc::new(std::cell::RefCell::new(RefreshBackoff::default())), + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs b/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs new file mode 100644 index 000000000..1b11f0a23 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/card/render.rs @@ -0,0 +1,30 @@ +//! Statistic card value rendering + +use std::cell::RefCell; +use std::rc::Rc; + +use super::StatItem; + +pub(super) fn apply_cached_value(label: >k::Label, cache: &Rc>>) { + if let Some(value) = cache.borrow().as_ref() { + // Stable values avoid an unnecessary GTK property update + if label.text().as_str() != value { + label.set_text(value); + } + } else if label.text().as_str() != "n/a" { + // Missing samples share one predictable fallback label + label.set_text("n/a"); + } +} + +impl StatItem { + pub(super) fn apply_value(&self, value: &str) -> bool { + if self.last_value.borrow().as_deref() == Some(value) { + return false; + } + // Cache and label change together so fallback reads remain accurate + self.value_label.set_text(value); + *self.last_value.borrow_mut() = Some(value.to_string()); + true + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs new file mode 100644 index 000000000..d04821e93 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/build.rs @@ -0,0 +1,59 @@ +//! Statistic grid construction + +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::{css::hooks, IconAssetResolver, StatWidgetConfig}; + +use super::super::card::StatItem; +use super::StatGrid; + +impl StatGrid { + pub fn new( + configs: &[StatWidgetConfig], + columns: usize, + icon_resolver: &IconAssetResolver, + ) -> Option { + let mut items = Vec::new(); + for config in configs { + if !config.enabled { + continue; + } + // Preserve config order so layout remains predictable + items.push(StatItem::new(config.clone(), icon_resolver)); + } + if items.is_empty() { + // Skip widget creation when all stat entries are disabled + return None; + } + + let root = gtk::FlowBox::new(); + root.add_css_class(hooks::stat_card::GRID); + root.set_selection_mode(gtk::SelectionMode::None); + let columns = flowbox_columns(columns); + root.set_max_children_per_line(columns); + root.set_min_children_per_line(columns); + root.set_row_spacing(8); + root.set_column_spacing(8); + root.set_halign(Align::Fill); + root.set_hexpand(true); + + for item in &items { + // Insert in order so card identity stays stable + root.insert(item.root(), -1); + } + + Some(Self { root, items }) + } + + pub const fn root(&self) -> >k::FlowBox { + &self.root + } +} + +pub(in crate::ui::widgets::stats) fn flowbox_columns(columns: usize) -> u32 { + u32::try_from(columns.max(1)).unwrap_or(u32::MAX) +} + +#[cfg(test)] +#[path = "tests/build.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs new file mode 100644 index 000000000..f0d873fed --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/mod.rs @@ -0,0 +1,8 @@ +//! Statistic grid ownership + +pub(in crate::ui::widgets::stats) mod build; +mod model; +mod refresh; +pub(in crate::ui::widgets::stats) mod schedule; + +pub use model::StatGrid; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs new file mode 100644 index 000000000..9bbb6aa17 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/model.rs @@ -0,0 +1,10 @@ +//! Retained statistic grid widget state + +use super::super::card::StatItem; + +pub struct StatGrid { + // FlowBox root is embedded by the panel widget tree + pub(super) root: gtk::FlowBox, + // Per-card state is retained for refresh scheduling + pub(super) items: Vec, +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs new file mode 100644 index 000000000..b926fe2a6 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/refresh.rs @@ -0,0 +1,27 @@ +//! Statistic grid refresh waves + +use std::time::{Duration, Instant}; + +use super::super::builtin::group::collect_builtin_groups; +use super::StatGrid; + +impl StatGrid { + pub fn refresh(&self, base_interval: Duration, force: bool) { + let now = Instant::now(); + let builtin_groups = collect_builtin_groups(&self.items, now, force); + + for item in &self.items { + if item.is_grouped_builtin(now, force) { + // Grouped built-ins are refreshed once per source below + continue; + } + // Per-card refresh keeps slow sources from blocking the grid + item.refresh(base_interval, force); + } + + for group in builtin_groups.into_values() { + // One sample fans out to every matching card in the grid + group.refresh(base_interval); + } + } +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs new file mode 100644 index 000000000..bf70e9cd4 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/schedule.rs @@ -0,0 +1,26 @@ +//! Statistic grid scheduling + +use std::time::{Duration, Instant}; + +use super::StatGrid; + +impl StatGrid { + pub fn next_refresh_in(&self, now: Instant) -> Option { + self.items + .iter() + .filter_map(|item| item.next_refresh_in(now)) + .min() + } + + pub fn is_due(&self, now: Instant) -> bool { + is_due_delay(self.next_refresh_in(now)) + } +} + +pub(in crate::ui::widgets::stats) fn is_due_delay(delay: Option) -> bool { + delay.is_some_and(|value| value.is_zero()) +} + +#[cfg(test)] +#[path = "tests/schedule.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs new file mode 100644 index 000000000..16ea07893 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/build.rs @@ -0,0 +1,15 @@ +//! Statistic grid construction tests + +use super::flowbox_columns; + +#[test] +fn grid_columns_normalize_zero_and_preserve_positive_values() { + assert_eq!(flowbox_columns(0), 1); + assert_eq!(flowbox_columns(1), 1); + assert_eq!(flowbox_columns(4), 4); +} + +#[test] +fn grid_columns_saturate_when_usize_exceeds_u32() { + assert_eq!(flowbox_columns(usize::MAX), u32::MAX); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs new file mode 100644 index 000000000..554c9600f --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/stats/grid/tests/schedule.rs @@ -0,0 +1,16 @@ +//! Statistic refresh scheduling tests + +use std::time::Duration; + +use super::is_due_delay; + +#[test] +fn zero_delay_is_due_immediately() { + assert!(is_due_delay(Some(Duration::ZERO))); +} + +#[test] +fn missing_or_positive_delay_is_not_due() { + assert!(!is_due_delay(None)); + assert!(!is_due_delay(Some(Duration::from_millis(1)))); +} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs index db604ba3f..b9151e655 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/mod.rs @@ -1,116 +1,8 @@ -//! Statistic widgets and refresh orchestration +//! Statistic cards and grid orchestration -mod build; +mod builtin; mod card; -mod css; -mod stats_builtin; -#[cfg(test)] -#[path = "tests/grid.rs"] -mod tests; -mod worker; +mod grid; +mod style; -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::rc::Rc; -use std::time::Instant; - -use unixnotis_core::StatWidgetConfig; - -use self::stats_builtin::{BuiltinStat, BuiltinStatKey}; -use super::utils::RefreshBackoff; - -pub struct StatGrid { - // FlowBox root is embedded by the panel widget tree - root: gtk::FlowBox, - // Per-stat item state is retained for refresh scheduling - items: Vec, -} - -#[derive(Clone)] -struct StatItem { - // Raw config is retained for command and plugin selection plus labels - config: StatWidgetConfig, - // Root card inserted into the grid - root: gtk::Box, - // Render target for the latest stat value - value_label: gtk::Label, - // Optional builtin reader reused across refresh calls - builtin: Rc>>, - // Guard prevents overlapping command or builtin reads - inflight: Rc>, - // Cached value avoids unnecessary relayout for unchanged results - last_value: Rc>>, - // Backoff reduces repeated reads when the value is stable - refresh_backoff: Rc>, -} - -struct BuiltinStatJob { - // Builtin reader variant to execute on the worker thread - stat: BuiltinStat, - // One-shot response channel used to return the sampled value - respond: async_channel::Sender<(BuiltinStat, String)>, -} - -struct BuiltinStatWorker { - // Bounded queue feeding the dedicated builtin worker thread - tx: crossbeam_channel::Sender, - // True when worker startup failed and callers should read inline - inline_fallback: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum BuiltinSubmitOutcome { - // Job was accepted by the worker queue - Submitted, - // Queue is healthy but currently saturated - QueueFull, - // Worker is unavailable and caller must use inline fallback - WorkerUnavailable, -} - -fn apply_cached_value(label: >k::Label, cache: &Rc>>) { - if let Some(value) = cache.borrow().as_ref() { - if label.text().as_str() != value { - label.set_text(value); - } - } else if label.text().as_str() != "n/a" { - label.set_text("n/a"); - } -} - -struct BuiltinRefreshGroup { - // One live builtin reader is enough for all cards that point at the same source - stat: BuiltinStat, - // Every item in the group receives the same sampled value and updated reader state - items: Vec, -} - -fn collect_builtin_groups( - items: &[StatItem], - now: Instant, - force: bool, -) -> HashMap { - let mut groups: HashMap = HashMap::new(); - - for item in items { - let Some((key, stat)) = item.take_builtin_refresh(now, force) else { - continue; - }; - - // Keep one reader per unique builtin source, then fan the result out to every card - match groups.get_mut(&key) { - Some(group) => group.items.push(item.clone()), - None => { - groups.insert( - key, - BuiltinRefreshGroup { - stat, - items: vec![item.clone()], - }, - ); - } - } - } - - groups -} +pub use grid::StatGrid; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs deleted file mode 100644 index f0a1e3d03..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/stats_builtin.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! In-process stats readers for common widgets -//! -//! Reads system data from procfs/sysfs to avoid spawning shell commands - -#[path = "stats_builtin_battery.rs"] -mod stats_builtin_battery; -#[path = "stats_builtin_cpu.rs"] -mod stats_builtin_cpu; -#[path = "stats_builtin_load.rs"] -mod stats_builtin_load; -#[path = "stats_builtin_memory.rs"] -mod stats_builtin_memory; -#[path = "stats_builtin_network.rs"] -mod stats_builtin_network; - -use std::time::Instant; - -use stats_builtin_battery::read_battery; -use stats_builtin_cpu::read_cpu_sample; -use stats_builtin_load::read_loadavg; -use stats_builtin_memory::read_memory; -use stats_builtin_network::{extract_iface, read_network}; - -#[derive(Clone, Debug)] -pub(super) struct BuiltinStat { - kind: BuiltinStatKind, - state: BuiltinState, -} - -#[derive(Clone, Debug)] -enum BuiltinStatKind { - Cpu, - Memory, - Load, - Battery, - Network { iface: Option }, -} - -#[derive(Clone, Debug)] -enum BuiltinState { - None, - Cpu { - last_total: u64, - last_idle: u64, - }, - Network { - last_rx: u64, - last_tx: u64, - last_at: Instant, - }, -} - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub(super) enum BuiltinStatKey { - // Every CPU card reads the same procfs source - Cpu, - // Every memory card reads the same procfs source - Memory, - // Load average is shared across cards too - Load, - // Battery cards share one aggregated battery snapshot - Battery, - // Network cards only share reads when they target the same interface - Network { iface: Option }, -} - -impl BuiltinStat { - pub(super) fn from_command(cmd: &str) -> Option { - let trimmed = cmd.trim(); - if let Some(rest) = trimmed.strip_prefix("builtin:") { - // Explicit builtin tags bypass filesystem path sniffing - return Self::from_builtin_tag(rest); - } - if trimmed.contains("/proc/stat") { - return Some(Self::new(BuiltinStatKind::Cpu)); - } - if trimmed.contains("/proc/meminfo") { - return Some(Self::new(BuiltinStatKind::Memory)); - } - if trimmed.contains("/proc/loadavg") { - return Some(Self::new(BuiltinStatKind::Load)); - } - if trimmed.contains("/sys/class/power_supply") { - return Some(Self::new(BuiltinStatKind::Battery)); - } - if trimmed.contains("/sys/class/net") && trimmed.contains("statistics") { - let iface = extract_iface(trimmed); - return Some(Self::new(BuiltinStatKind::Network { iface })); - } - None - } - - pub(super) fn read(&mut self) -> Option { - match &mut self.kind { - BuiltinStatKind::Cpu => self.read_cpu(), - BuiltinStatKind::Memory => read_memory(), - BuiltinStatKind::Load => read_loadavg(), - BuiltinStatKind::Battery => read_battery(), - BuiltinStatKind::Network { iface } => read_network(&mut self.state, iface), - } - } - - pub(super) fn key(&self) -> BuiltinStatKey { - match &self.kind { - BuiltinStatKind::Cpu => BuiltinStatKey::Cpu, - BuiltinStatKind::Memory => BuiltinStatKey::Memory, - BuiltinStatKind::Load => BuiltinStatKey::Load, - BuiltinStatKind::Battery => BuiltinStatKey::Battery, - BuiltinStatKind::Network { iface } => BuiltinStatKey::Network { - iface: iface.clone(), - }, - } - } - - fn new(kind: BuiltinStatKind) -> Self { - let state = match kind { - BuiltinStatKind::Cpu => BuiltinState::Cpu { - last_total: 0, - last_idle: 0, - }, - BuiltinStatKind::Network { .. } => BuiltinState::Network { - last_rx: 0, - last_tx: 0, - last_at: Instant::now(), - }, - _ => BuiltinState::None, - }; - Self { kind, state } - } - - fn from_builtin_tag(tag: &str) -> Option { - let mut parts = tag.split(':'); - let kind = parts.next()?.trim(); - match kind { - "cpu" => Some(Self::new(BuiltinStatKind::Cpu)), - "mem" | "memory" => Some(Self::new(BuiltinStatKind::Memory)), - "load" => Some(Self::new(BuiltinStatKind::Load)), - "battery" => Some(Self::new(BuiltinStatKind::Battery)), - "net" => { - let iface = parts.next().map(std::string::ToString::to_string); - Some(Self::new(BuiltinStatKind::Network { iface })) - } - _ => None, - } - } - - fn read_cpu(&mut self) -> Option { - let (total, idle) = read_cpu_sample()?; - let usage = match &mut self.state { - BuiltinState::Cpu { - last_total, - last_idle, - } => { - let usage = if *last_total > 0 && total > *last_total { - // Delta-based usage avoids spikes when the counter wraps - let delta_total = total - *last_total; - let delta_idle = idle.saturating_sub(*last_idle); - 100.0 * (delta_total.saturating_sub(delta_idle)) as f64 / delta_total as f64 - } else if total > 0 { - // First read falls back to absolute usage - 100.0 * (total.saturating_sub(idle)) as f64 / total as f64 - } else { - 0.0 - }; - *last_total = total; - *last_idle = idle; - usage - } - _ => 0.0, - }; - Some(format!("{:.0}%", usage.clamp(0.0, 100.0))) - } -} - -#[cfg(test)] -#[path = "tests/builtin.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/css.rs b/crates/unixnotis-center/src/ui/widgets/stats/style.rs similarity index 88% rename from crates/unixnotis-center/src/ui/widgets/stats/css.rs rename to crates/unixnotis-center/src/ui/widgets/stats/style.rs index 84ee9828c..f9ff56f63 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/css.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/style.rs @@ -5,5 +5,5 @@ pub(super) fn stat_kind_css_class(kind: &str) -> Option { } #[cfg(test)] -#[path = "tests/css.rs"] +#[path = "tests/style.rs"] mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs deleted file mode 100644 index fa784a0f0..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/builtin.rs +++ /dev/null @@ -1,167 +0,0 @@ -use super::stats_builtin_battery::read_battery_from; -use super::stats_builtin_network::{pick_default_iface_from, IfaceCandidate}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -struct TempDir { - path: PathBuf, -} - -impl TempDir { - fn new(prefix: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = - std::env::temp_dir().join(format!("{}-{}-{}", prefix, std::process::id(), stamp)); - fs::create_dir_all(&path).expect("temp dir creation failed"); - Self { path } - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - // Best-effort cleanup to avoid leaving test artifacts on disk. - let _ = fs::remove_dir_all(&self.path); - } -} - -fn write_device(root: &Path, name: &str, entries: &[(&str, &str)]) { - let device_path = root.join(name); - fs::create_dir_all(&device_path).expect("device directory creation failed"); - for (file, contents) in entries { - fs::write(device_path.join(file), contents).expect("device file write failed"); - } -} - -#[test] -fn battery_energy_aggregates_weighted() { - let temp = TempDir::new("unixnotis-battery-energy"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "30"), - ("energy_full", "60"), - ], - ); - write_device( - temp.path(), - "BAT1", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "10"), - ("energy_full", "40"), - ], - ); - let percent = read_battery_from(temp.path()).expect("battery percent missing"); - assert_eq!(percent, "40"); -} - -#[test] -fn battery_mixed_units_falls_back_to_capacity() { - let temp = TempDir::new("unixnotis-battery-mixed"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "1"), - ("energy_now", "30"), - ("energy_full", "60"), - ("capacity", "60"), - ], - ); - write_device( - temp.path(), - "BAT1", - &[ - ("type", "Battery"), - ("present", "1"), - ("charge_now", "10"), - ("charge_full", "40"), - ("capacity", "25"), - ], - ); - let percent = read_battery_from(temp.path()).expect("battery percent missing"); - assert_eq!(percent, "43"); -} - -#[test] -fn battery_skips_not_present_devices() { - let temp = TempDir::new("unixnotis-battery-absent"); - write_device( - temp.path(), - "BAT0", - &[ - ("type", "Battery"), - ("present", "0"), - ("energy_now", "30"), - ("energy_full", "60"), - ], - ); - assert!(read_battery_from(temp.path()).is_none()); -} - -#[test] -fn default_iface_prefers_up_physical_over_virtual() { - let candidates = vec![ - IfaceCandidate { - name: "veth0".to_string(), - operstate: "up".to_string(), - }, - IfaceCandidate { - name: "wlan0".to_string(), - operstate: "up".to_string(), - }, - ]; - assert_eq!( - pick_default_iface_from(&candidates), - Some("wlan0".to_string()) - ); -} - -#[test] -fn default_iface_falls_back_to_physical_when_none_up() { - let candidates = vec![ - IfaceCandidate { - name: "eth0".to_string(), - operstate: "down".to_string(), - }, - IfaceCandidate { - name: "docker0".to_string(), - operstate: "up".to_string(), - }, - ]; - assert_eq!( - pick_default_iface_from(&candidates), - Some("eth0".to_string()) - ); -} - -#[test] -fn default_iface_uses_deterministic_name_tiebreaker() { - let candidates = vec![ - IfaceCandidate { - name: "eth1".to_string(), - operstate: "down".to_string(), - }, - IfaceCandidate { - name: "eth0".to_string(), - operstate: "down".to_string(), - }, - ]; - assert_eq!( - pick_default_iface_from(&candidates), - Some("eth0".to_string()) - ); -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs deleted file mode 100644 index c4c8be6cb..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/grid.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Stat worker tests - -use super::{ - stats_builtin::BuiltinStatKey, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, - BuiltinSubmitOutcome, -}; - -#[test] -fn builtin_worker_queue_full_falls_back() { - let (tx, _worker_rx) = crossbeam_channel::bounded(1); - let worker = BuiltinStatWorker { - tx, - inline_fallback: false, - }; - let stat_a = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let stat_b = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let (tx_a, _rx_a) = async_channel::bounded(1); - let (tx_b, _rx_b) = async_channel::bounded(1); - - // First job fits in the bounded queue - assert_eq!( - worker.submit(BuiltinStatJob { - stat: stat_a, - respond: tx_a, - }), - BuiltinSubmitOutcome::Submitted - ); - // Second job proves the submit path reports saturation instead of blocking - assert_eq!( - worker.submit(BuiltinStatJob { - stat: stat_b, - respond: tx_b, - }), - BuiltinSubmitOutcome::QueueFull - ); -} - -#[test] -fn builtin_stat_keys_dedupe_matching_sources() { - let cpu_a = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let cpu_b = BuiltinStat::from_command("builtin:cpu").expect("builtin stat"); - let net = BuiltinStat::from_command("builtin:net:wlan0").expect("builtin stat"); - - assert_eq!(cpu_a.key(), BuiltinStatKey::Cpu); - assert_eq!(cpu_a.key(), cpu_b.key()); - assert_eq!( - net.key(), - BuiltinStatKey::Network { - iface: Some("wlan0".to_string()), - } - ); -} diff --git a/crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs b/crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs similarity index 81% rename from crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs rename to crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs index 5f1e634a6..88a48071b 100644 --- a/crates/unixnotis-center/src/ui/widgets/stats/tests/css.rs +++ b/crates/unixnotis-center/src/ui/widgets/stats/tests/style.rs @@ -1,7 +1,9 @@ +//! Statistic style tests + use super::stat_kind_css_class; #[test] -fn stat_kind_css_class_sanitizes_to_stable_token() { +fn card_kind_class_normalizes_theme_tokens() { assert_eq!( stat_kind_css_class("RAM"), Some("unixnotis-stat-kind-ram".to_string()) diff --git a/crates/unixnotis-center/src/ui/widgets/stats/worker.rs b/crates/unixnotis-center/src/ui/widgets/stats/worker.rs deleted file mode 100644 index a83c43ff7..000000000 --- a/crates/unixnotis-center/src/ui/widgets/stats/worker.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Builtin stat worker and builtin refresh helpers - -use std::thread; - -use crossbeam_channel::TrySendError; -use gtk::glib; -use tracing::warn; - -use super::{ - BuiltinRefreshGroup, BuiltinStat, BuiltinStatJob, BuiltinStatWorker, BuiltinSubmitOutcome, - StatItem, -}; - -impl BuiltinStatWorker { - // Limit queued jobs to avoid unbounded growth if refresh is faster than the worker - const QUEUE_CAPACITY: usize = 32; - - // Single worker avoids per-refresh thread churn while keeping UI updates async - pub(super) fn global() -> &'static Self { - static WORKER: std::sync::OnceLock = std::sync::OnceLock::new(); - WORKER.get_or_init(Self::new) - } - - fn new() -> Self { - let (tx, rx) = crossbeam_channel::bounded::(Self::QUEUE_CAPACITY); - // One worker thread is enough because builtin reads are short and serialized - let spawn = thread::Builder::new() - .name("unixnotis-builtin-stats".to_string()) - .spawn(move || { - for mut job in &rx { - let value = job.stat.read().unwrap_or_else(|| "n/a".to_string()); - let _ = job.respond.send_blocking((job.stat, value)); - } - }); - let inline_fallback = spawn.is_err(); - if inline_fallback { - warn!("builtin stats worker unavailable; using inline reads"); - } - - Self { - tx, - inline_fallback, - } - } - - pub(super) fn submit(&self, job: BuiltinStatJob) -> BuiltinSubmitOutcome { - if self.inline_fallback { - return BuiltinSubmitOutcome::WorkerUnavailable; - } - // Avoid blocking the UI thread when the worker queue is saturated - match self.tx.try_send(job) { - Ok(()) => BuiltinSubmitOutcome::Submitted, - Err(TrySendError::Full(_job)) => BuiltinSubmitOutcome::QueueFull, - // Disconnected queue means the worker path is no longer usable - Err(TrySendError::Disconnected(_job)) => BuiltinSubmitOutcome::WorkerUnavailable, - } - } -} - -impl StatItem { - pub(super) fn refresh_builtin(&self, builtin: BuiltinStat, base_interval: std::time::Duration) { - // Temporarily take builtin state to prevent overlapping reads - self.inflight.set(true); - let (tx, rx) = async_channel::bounded(1); - let mut fallback = builtin.clone(); - let worker = BuiltinStatWorker::global(); - match worker.submit(BuiltinStatJob { - stat: builtin, - respond: tx, - }) { - BuiltinSubmitOutcome::Submitted => {} - BuiltinSubmitOutcome::QueueFull => { - // Queue saturation should stay non-blocking on the GTK thread - self.restore_builtin_error(fallback, base_interval); - return; - } - BuiltinSubmitOutcome::WorkerUnavailable => { - // Inline fallback keeps builtin stats readable when the worker is missing - let value = fallback.read().unwrap_or_else(|| "n/a".to_string()); - self.restore_builtin_value(fallback, &value, base_interval); - return; - } - } - - let item = self.clone(); - glib::MainContext::default().spawn_local(async move { - // Restore builtin state on every exit path so later refreshes can keep working - let result = rx.recv().await; - let Ok((builtin, value)) = result else { - item.restore_builtin_error(fallback, base_interval); - return; - }; - item.restore_builtin_value(builtin, &value, base_interval); - }); - } -} - -impl BuiltinRefreshGroup { - pub(super) fn refresh(self, base_interval: std::time::Duration) { - let (tx, rx) = async_channel::bounded(1); - let mut fallback = self.stat.clone(); - let worker = BuiltinStatWorker::global(); - - match worker.submit(BuiltinStatJob { - stat: self.stat, - respond: tx, - }) { - BuiltinSubmitOutcome::Submitted => {} - BuiltinSubmitOutcome::QueueFull => { - // Restore every grouped item so the next refresh wave can retry cleanly - for item in self.items { - item.restore_builtin_error(fallback.clone(), base_interval); - } - return; - } - BuiltinSubmitOutcome::WorkerUnavailable => { - // Inline fallback still samples the source once, then fans the value out to every card - let value = fallback.read().unwrap_or_else(|| "n/a".to_string()); - for item in self.items { - item.restore_builtin_value(fallback.clone(), &value, base_interval); - } - return; - } - } - - glib::MainContext::default().spawn_local(async move { - let result = rx.recv().await; - let Ok((builtin, value)) = result else { - for item in self.items { - item.restore_builtin_error(fallback.clone(), base_interval); - } - return; - }; - - // Every grouped card receives the same value and updated reader state clone - for item in self.items { - item.restore_builtin_value(builtin.clone(), &value, base_interval); - } - }); - } -} diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs index 57fde4db9..65c2d17d6 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/grid.rs @@ -7,11 +7,12 @@ use gtk::prelude::*; use gtk::Align; use tracing::warn; use unixnotis_core::{ - css::hooks, IconAssetResolver, PanelDebugLevel, ToggleLayout, ToggleWidgetConfig, + css::hooks, CommandSpec, IconAssetResolver, PanelDebugLevel, ToggleLayout, ToggleWidgetConfig, }; +use super::super::command_runtime::command::run_action_command_with_completion; +use super::super::command_runtime::watch::{start_command_watch, CommandWatch}; use super::super::icon_image::image_from_icon_config; -use super::super::utils::{run_action_command_with_completion, start_command_watch, CommandWatch}; use crate::diagnostics::panel_debug as debug; use super::css::toggle_kind_css_class; @@ -119,17 +120,17 @@ fn flowbox_columns(columns: usize) -> u32 { } pub(super) fn toggle_action_command<'a>( - toggle_cmd: Option<&'a String>, - on_cmd: Option<&'a String>, - off_cmd: Option<&'a String>, + toggle_cmd: Option<&'a CommandSpec>, + on_cmd: Option<&'a CommandSpec>, + off_cmd: Option<&'a CommandSpec>, active: bool, -) -> Option<&'a String> { +) -> Option<&'a CommandSpec> { toggle_cmd.or(if active { on_cmd } else { off_cmd }) } pub(super) const fn should_reset_after_action( - toggle_cmd: Option<&String>, - state_cmd: Option<&String>, + toggle_cmd: Option<&CommandSpec>, + state_cmd: Option<&CommandSpec>, ) -> bool { // Without a state command, the card cannot know whether the action changed system state toggle_cmd.is_some() && state_cmd.is_none() @@ -233,6 +234,7 @@ impl ToggleItem { // Clone command fields once so toggle callback stays allocation-light let guard_clone = guard.clone(); let state_cmd = config.state_cmd.clone(); + let backend = config.backend; let toggle_cmd = config.toggle_cmd.clone(); let on_cmd = config.on_cmd.clone(); let off_cmd = config.off_cmd.clone(); @@ -281,6 +283,7 @@ impl ToggleItem { if let Some(state_cmd) = state_cmd_for_retry.clone() { schedule_toggle_refresh_with_retry( state_cmd, + backend, expected, button.clone(), guard.clone(), @@ -293,7 +296,14 @@ impl ToggleItem { }); } else if let Some(state_cmd) = state_cmd.clone() { // Command-free toggles still use the same reconcile path - schedule_toggle_refresh_with_retry(state_cmd, expected, button, guard, refresh_gen); + schedule_toggle_refresh_with_retry( + state_cmd, + backend, + expected, + button, + guard, + refresh_gen, + ); } else { // No command and no state is inert, so undo the visual edge immediately reset_toggle_visual_state(&button, &guard); @@ -318,6 +328,7 @@ impl ToggleItem { if let Some(state_cmd) = self.config.state_cmd.as_ref() { refresh_toggle_state( state_cmd, + self.config.backend, &self.button, &self.guard, &self.refresh_gen, @@ -372,10 +383,18 @@ impl ToggleItem { let guard = self.guard.clone(); let refresh_gen = self.refresh_gen.clone(); let refresh_gate = self.refresh_gate.clone(); + let backend = self.config.backend; // Watch callbacks trigger the same refresh path as polling so semantics stay identical start_command_watch(watch_cmd, move || { - refresh_toggle_state(&state_cmd, &button, &guard, &refresh_gen, &refresh_gate); + refresh_toggle_state( + &state_cmd, + backend, + &button, + &guard, + &refresh_gen, + &refresh_gate, + ); }) } } diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs b/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs index dcd2e8038..740953f6b 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/mod.rs @@ -3,6 +3,7 @@ mod css; mod grid; mod icons; +mod rfkill; mod state; pub use grid::ToggleGrid; diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs b/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs new file mode 100644 index 000000000..afc65d5e2 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/toggles/rfkill.rs @@ -0,0 +1,43 @@ +//! Machine-readable rfkill state parsing for the stock airplane toggle + +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct RfkillState { + pub(super) device_count: usize, + pub(super) all_soft_blocked: bool, +} + +impl RfkillState { + pub(super) const fn is_airplane_mode_active(self) -> bool { + // Empty rfkill output never claims that airplane mode is active + self.device_count > 0 && self.all_soft_blocked + } +} + +#[derive(Deserialize)] +struct RfkillDocument { + rfkilldevices: Vec, +} + +#[derive(Deserialize)] +struct RfkillDevice { + soft: String, +} + +pub(super) fn parse_rfkill_state(output: &[u8]) -> Result { + // Structured output avoids localized and deprecated display formatting + let document: RfkillDocument = serde_json::from_slice(output)?; + Ok(RfkillState { + device_count: document.rfkilldevices.len(), + // Airplane mode requires every discovered radio to be soft blocked + all_soft_blocked: document + .rfkilldevices + .iter() + .all(|device| device.soft == "blocked"), + }) +} + +#[cfg(test)] +#[path = "tests/rfkill.rs"] +mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs index a418ea3dd..199bddbab 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/state.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/state.rs @@ -9,9 +9,10 @@ use std::time::Duration; use gtk::glib; use gtk::prelude::*; use tracing::warn; -use unixnotis_core::{css::hooks, util, PanelDebugLevel}; +use unixnotis_core::{css::hooks, util, CommandSpec, PanelDebugLevel, ToggleBackend}; -use super::super::utils::run_command_capture_status_async; +use super::super::command_runtime::command::run_command_capture_status_async; +use super::rfkill::parse_rfkill_state; use crate::diagnostics::{panel_debug as debug, performance as perf_probe}; // Staggered retry delays keep UI responsive without long-lived polling loops @@ -49,7 +50,8 @@ impl ToggleRefreshGate { } pub(super) fn refresh_toggle_state( - cmd: &str, + cmd: &CommandSpec, + backend: Option, button: >k::ToggleButton, guard: &Rc>, refresh_gen: &Rc>, @@ -58,7 +60,7 @@ pub(super) fn refresh_toggle_state( // Bursty watch events only need one running probe and one trailing probe if !refresh_gate.begin_or_queue() { perf_probe::toggle_refresh_queued(); - let cmd_snip = util::log_snippet(cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh queued while in flight cmd=\"{cmd_snip}\"") }); @@ -67,7 +69,7 @@ pub(super) fn refresh_toggle_state( perf_probe::toggle_refresh_start(); // Periodic refresh path keeps UI aligned with external command state - let cmd = cmd.to_string(); + let cmd = cmd.clone(); // Each refresh claims a generation so stale tasks cannot overwrite newer state let gen = next_refresh_generation(refresh_gen); @@ -76,21 +78,35 @@ pub(super) fn refresh_toggle_state( let refresh_gen = refresh_gen.clone(); let refresh_gate = refresh_gate.clone(); let refresh_cmd = cmd.clone(); - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh start cmd=\"{cmd_snip}\"") }); glib::MainContext::default().spawn_local(async move { // Single probe path is used for periodic refresh and watch-trigger refresh - let Some(active) = fetch_toggle_state(&cmd, true).await else { - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + let Some(active) = fetch_toggle_state(&cmd, backend, true).await else { + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); return; }; // Drop stale result when a newer refresh has already started if refresh_gen.get() != gen { - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); return; } @@ -103,12 +119,20 @@ pub(super) fn refresh_toggle_state( } apply_active_class(&button, active); - finish_toggle_refresh(refresh_cmd, button, guard, refresh_gen, refresh_gate); + finish_toggle_refresh( + refresh_cmd, + backend, + button, + guard, + refresh_gen, + refresh_gate, + ); }); } pub(super) fn schedule_toggle_refresh_with_retry( - state_cmd: String, + state_cmd: CommandSpec, + backend: Option, expected: bool, button: gtk::ToggleButton, guard: Rc>, @@ -141,7 +165,7 @@ pub(super) fn schedule_toggle_refresh_with_retry( // Keep warnings bounded to the first failed probe per action let log_failures = attempt == 0; - let Some(active) = fetch_toggle_state(&state_cmd, log_failures).await else { + let Some(active) = fetch_toggle_state(&state_cmd, backend, log_failures).await else { // Probe failed, continue to next retry window continue; }; @@ -184,7 +208,11 @@ fn apply_active_class(button: >k::ToggleButton, active: bool) { } } -async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { +async fn fetch_toggle_state( + cmd: &CommandSpec, + backend: Option, + log_failures: bool, +) -> Option { // Shared fetch routine is used by both periodic refresh and retry path // Command helper returns receiver so execution stays off the GTK thread let rx = run_command_capture_status_async(cmd); @@ -206,6 +234,24 @@ async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { } }; + if backend == Some(ToggleBackend::Rfkill) { + if !output.status.success() { + if log_failures { + warn!(status = ?output.status, "rfkill state command failed"); + } + return None; + } + return match parse_rfkill_state(&output.stdout) { + Ok(state) => Some(state.is_airplane_mode_active()), + Err(err) => { + if log_failures { + warn!(?err, "failed to parse rfkill JSON state"); + } + None + } + }; + } + let success = output.status.success(); let stdout = String::from_utf8_lossy(&output.stdout); @@ -220,7 +266,8 @@ async fn fetch_toggle_state(cmd: &str, log_failures: bool) -> Option { } fn finish_toggle_refresh( - cmd: String, + cmd: CommandSpec, + backend: Option, button: gtk::ToggleButton, guard: Rc>, refresh_gen: Rc>, @@ -228,11 +275,11 @@ fn finish_toggle_refresh( ) { // One queued refresh is enough to bring the toggle back to the newest state if refresh_gate.finish() { - let cmd_snip = util::log_snippet(&cmd); + let cmd_snip = util::log_snippet(&cmd.display_lossy()); debug::log(PanelDebugLevel::Verbose, || { format!("toggle refresh consumed pending request cmd=\"{cmd_snip}\"") }); - refresh_toggle_state(&cmd, &button, &guard, &refresh_gen, &refresh_gate); + refresh_toggle_state(&cmd, backend, &button, &guard, &refresh_gen, &refresh_gate); } } diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs index 55d2f7432..583e1cdd7 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/grid.rs @@ -1,10 +1,11 @@ use super::grid::{should_reset_after_action, toggle_action_command}; +use unixnotis_core::CommandSpec; #[test] fn toggle_action_command_prefers_custom_toggle_command() { - let toggle_cmd = "scripts/do-anything".to_string(); - let on_cmd = "turn-on".to_string(); - let off_cmd = "turn-off".to_string(); + let toggle_cmd = CommandSpec::direct("scripts/do-anything", [] as [&str; 0]); + let on_cmd = CommandSpec::direct("turn-on", [] as [&str; 0]); + let off_cmd = CommandSpec::direct("turn-off", [] as [&str; 0]); assert_eq!( toggle_action_command(Some(&toggle_cmd), Some(&on_cmd), Some(&off_cmd), true), @@ -18,8 +19,8 @@ fn toggle_action_command_prefers_custom_toggle_command() { #[test] fn toggle_action_command_uses_on_off_when_custom_command_is_absent() { - let on_cmd = "turn-on".to_string(); - let off_cmd = "turn-off".to_string(); + let on_cmd = CommandSpec::direct("turn-on", [] as [&str; 0]); + let off_cmd = CommandSpec::direct("turn-off", [] as [&str; 0]); assert_eq!( toggle_action_command(None, Some(&on_cmd), Some(&off_cmd), true), @@ -39,8 +40,8 @@ fn toggle_action_command_allows_state_only_custom_buttons() { #[test] fn stateless_toggle_command_resets_after_action() { - let toggle_cmd = "scripts/do-anything".to_string(); - let state_cmd = "scripts/state".to_string(); + let toggle_cmd = CommandSpec::direct("scripts/do-anything", [] as [&str; 0]); + let state_cmd = CommandSpec::direct("scripts/state", [] as [&str; 0]); assert!(should_reset_after_action(Some(&toggle_cmd), None)); assert!(!should_reset_after_action( diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs index 3a6613656..3ef466a11 100644 --- a/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/icons.rs @@ -8,6 +8,7 @@ fn test_toggle(kind: Option<&str>, label: &str, icon: &str) -> ToggleWidgetConfi label: label.to_string(), icon: icon.to_string(), icon_asset: None, + backend: None, state_cmd: None, toggle_cmd: None, on_cmd: None, diff --git a/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs b/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs new file mode 100644 index 000000000..fa29ac2f5 --- /dev/null +++ b/crates/unixnotis-center/src/ui/widgets/toggles/tests/rfkill.rs @@ -0,0 +1,45 @@ +use super::parse_rfkill_state; + +#[test] +fn rfkill_state_is_active_only_when_every_discovered_device_is_soft_blocked() { + let output = br#"{ + "rfkilldevices": [ + {"id": 0, "type": "wlan", "soft": "blocked", "hard": "unblocked"}, + {"id": 1, "type": "bluetooth", "soft": "blocked", "hard": "blocked"} + ] + }"#; + + let state = parse_rfkill_state(output).expect("parse blocked rfkill state"); + + assert_eq!(state.device_count, 2); + assert!(state.all_soft_blocked); + assert!(state.is_airplane_mode_active()); +} + +#[test] +fn rfkill_state_is_inactive_when_one_device_is_unblocked() { + let output = br#"{ + "rfkilldevices": [ + {"soft": "blocked"}, + {"soft": "unblocked"} + ] + }"#; + + let state = parse_rfkill_state(output).expect("parse mixed rfkill state"); + + assert!(!state.all_soft_blocked); + assert!(!state.is_airplane_mode_active()); +} + +#[test] +fn rfkill_state_is_inactive_when_no_devices_exist() { + let state = parse_rfkill_state(br#"{"rfkilldevices": []}"#).expect("parse empty state"); + + assert_eq!(state.device_count, 0); + assert!(!state.is_airplane_mode_active()); +} + +#[test] +fn malformed_rfkill_json_is_rejected() { + assert!(parse_rfkill_state(br#"{"rfkilldevices": [}"#).is_err()); +} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs deleted file mode 100644 index 3d126e9ec..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/command_parse.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Command parsing and heuristics for widget command planning. -//! -//! Keeps shell parsing and "slow command" classification localized so the -//! enqueue/worker pipeline can stay focused on execution and backpressure. - -pub(super) use unixnotis_core::ParsedCommand; -use unixnotis_core::{parse_command, ExecutionMode}; - -pub(super) fn parse_simple_command(cmd: &str) -> Option { - // Runtime consumes the same parsed representation used by preset security checks - let parsed = parse_command(cmd).ok()?; - (parsed.execution_mode == ExecutionMode::Direct).then_some(parsed) -} - -pub(super) fn is_probably_slow(cmd: &str) -> bool { - // Complex commands (shell meta, unsupported env forms, etc.) are treated as slow to - // avoid under-budgeting timeouts for shells and pipelines - let Some(parsed) = parse_simple_command(cmd) else { - return true; - }; - - // Compare only executable basename so absolute paths and wrappers still match - let program_name = parsed - .program - .rsplit('/') - .next() - .unwrap_or(parsed.program.as_str()) - .to_ascii_lowercase(); - - if program_name == "sleep" { - return true; - } - - // Known utilities that are likely to block or hit D-Bus - const SLOW_TOKENS: [&str; 9] = [ - "nmcli", - "bluetoothctl", - "rfkill", - "udevadm", - "upower", - "playerctl", - "pactl", - "wpctl", - "brightnessctl", - ]; - if SLOW_TOKENS.contains(&program_name.as_str()) { - return true; - } - - if matches!(program_name.as_str(), "sh" | "bash" | "zsh" | "fish") { - // Shell scripts are treated as slow if the first token is "sleep" - if let Some(script) = shell_script_arg(&parsed.args) { - if script.split_whitespace().next() == Some("sleep") { - return true; - } - } - } - - false -} - -fn shell_script_arg(args: &[String]) -> Option<&str> { - let mut iter = args.iter().peekable(); - while let Some(arg) = iter.next() { - if arg == "-c" { - return iter.peek().map(|value| value.as_str()); - } - } - None -} - -#[cfg(test)] -#[path = "tests/command_parse.rs"] -mod tests; diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs deleted file mode 100644 index bc73b73a5..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/queue/tests/coalesced.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::time::Instant; - -use super::super::worker::CommandJob; -use super::{insert_coalesced_job, CoalescedRefreshState}; -use crate::ui::widgets::utils::command::{CommandKind, CommandPlan}; - -fn job(cmd: &str, kind: CommandKind) -> CommandJob { - CommandJob { - cmd: cmd.to_string(), - plan: CommandPlan { - kind, - timeout_override: None, - }, - respond: None, - queued_at: Instant::now(), - } -} - -#[test] -fn same_refresh_key_replaces_existing_job() { - let mut state = CoalescedRefreshState { - pending: HashMap::new(), - order: VecDeque::new(), - }; - - insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); - let outcome = insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); - - assert_eq!(state.pending.len(), 1); - assert_eq!(state.order.len(), 1); - assert!(outcome.replaced_existing); - assert!(!outcome.evicted_oldest); -} - -#[test] -fn distinct_refresh_kinds_keep_separate_jobs() { - let mut state = CoalescedRefreshState { - pending: HashMap::new(), - order: VecDeque::new(), - }; - - insert_coalesced_job(&mut state, job("echo a", CommandKind::Fast)); - insert_coalesced_job(&mut state, job("echo a", CommandKind::Slow)); - - assert_eq!(state.pending.len(), 2); - assert_eq!(state.order.len(), 2); -} - -#[test] -fn full_refresh_queue_evicts_oldest_key() { - let mut state = CoalescedRefreshState { - pending: HashMap::new(), - order: VecDeque::new(), - }; - for index in 0..256 { - insert_coalesced_job(&mut state, job(&format!("echo {index}"), CommandKind::Fast)); - } - - let outcome = insert_coalesced_job(&mut state, job("echo newest", CommandKind::Fast)); - - assert_eq!(state.pending.len(), 256); - assert!(outcome.evicted_oldest); - assert!(!state.pending.values().any(|item| item.cmd == "echo 0")); - assert!(state.pending.values().any(|item| item.cmd == "echo newest")); -} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs deleted file mode 100644 index 83a7b9d6c..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/command_parse.rs +++ /dev/null @@ -1,43 +0,0 @@ -use super::{is_probably_slow, parse_simple_command}; - -#[test] -fn parse_simple_command_honors_quotes() { - let parsed = parse_simple_command("notify-send \"Hello World\"").expect("parsed command"); - assert_eq!(parsed.program, "notify-send"); - assert!(parsed.env.is_empty()); - assert_eq!(parsed.args, vec!["Hello World"]); -} - -#[test] -fn parse_simple_command_rejects_shell_meta() { - assert!(parse_simple_command("echo hi | wc -l").is_none()); -} - -#[test] -fn parse_simple_command_accepts_leading_env_assignments() { - let parsed = parse_simple_command("FOO=bar BAR='two words' notify-send done").expect("parsed"); - - assert_eq!(parsed.program, "notify-send"); - assert_eq!( - parsed.env, - vec![ - ("FOO".to_string(), "bar".to_string()), - ("BAR".to_string(), "two words".to_string()) - ] - ); - assert_eq!(parsed.args, vec!["done"]); -} - -#[test] -fn is_probably_slow_respects_program_tokens() { - assert!(is_probably_slow("sleep 1")); - assert!(is_probably_slow("nmcli radio wifi")); - assert!(!is_probably_slow("FOO=bar echo ok")); - assert!(!is_probably_slow("echo \"I am not sleeping\"")); -} - -#[test] -fn is_probably_slow_handles_shell_sleep_script() { - assert!(is_probably_slow("bash -c \"sleep 1\"")); - assert!(!is_probably_slow("bash -c \"echo sleep\"")); -} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs b/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs deleted file mode 100644 index 11d5e72af..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/command/tests/plan.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::time::Duration; - -use super::{resolve_command_plan, CommandKind}; - -#[test] -fn slow_command_promotes_refresh_plan_to_slow_lane() { - let plan = resolve_command_plan("sleep 1", CommandKind::Fast); - - assert_eq!(plan.kind, CommandKind::Slow); - assert_eq!(plan.timeout(), Duration::from_millis(800)); -} - -#[test] -fn action_command_keeps_action_lane_even_when_command_is_slow() { - let plan = resolve_command_plan("sleep 1", CommandKind::Action); - - assert_eq!(plan.kind, CommandKind::Action); - assert_eq!(plan.timeout(), Duration::from_millis(1_200)); -} - -#[test] -fn explicit_timeout_overrides_lane_default() { - let plan = - resolve_command_plan("true", CommandKind::Fast).with_timeout(Duration::from_millis(25)); - - assert_eq!(plan.timeout(), Duration::from_millis(25)); -} diff --git a/crates/unixnotis-center/src/ui/widgets/utils/mod.rs b/crates/unixnotis-center/src/ui/widgets/utils/mod.rs deleted file mode 100644 index 78d350b08..000000000 --- a/crates/unixnotis-center/src/ui/widgets/utils/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Shared widget helpers and command plumbing - -// Command execution and queueing internals -mod command; -// Command-driven slider widget implementation -mod command_slider; -// Shared refresh backoff policy used by cards and stats -mod refresh_backoff; -// Shared watch cleanup worker keeps teardown off the GTK thread -mod watch_reaper; -// Long-running command watch lifecycle helpers -mod watch; - -// Shared command helpers are scoped to widget internals -pub use command::configure_command_config_dir; -pub(super) use command::{ - run_action_command_with_completion, run_command_capture_async, - run_command_capture_status_async, run_command_capture_with_timeout_async, -}; -// Public re-export keeps widget wrappers concise -pub use command_slider::CommandSlider; -// Backoff policy is reused by polling widgets -pub(super) use refresh_backoff::{RefreshBackoff, INFLIGHT_REFRESH_RECHECK}; -// Watcher helpers are reused by sliders and toggles -pub(super) use watch::{start_command_watch, CommandWatch}; diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer new file mode 100755 index 000000000..327cdf70d --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/bad-renderer @@ -0,0 +1,6 @@ +#!/bin/sh + +# Consume the complete request before returning malformed dimensions +dd bs=1 count=8 iflag=fullblock of=/dev/null 2>/dev/null || exit 1 +cat >/dev/null +printf '\377\377\377\377\377\377\377\377' diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer new file mode 100755 index 000000000..20bf78ac6 --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/chatty-renderer @@ -0,0 +1,4 @@ +#!/bin/sh + +head -c 1048576 /dev/zero >&2 +printf '\001\000\000\000\001\000\000\000\000\000\000\377' diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer new file mode 100755 index 000000000..1fa687e4e --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/noisy-failing-renderer @@ -0,0 +1,4 @@ +#!/bin/sh + +yes X | head -c 1048576 >&2 +exit 1 diff --git a/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer b/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer new file mode 100755 index 000000000..00e486f6d --- /dev/null +++ b/crates/unixnotis-center/tests/fixtures/svg-renderers/slow-renderer @@ -0,0 +1,5 @@ +#!/bin/sh + +# Consume the request so the parent can finish writing before the timeout +cat >/dev/null +sleep 2 diff --git a/crates/unixnotis-core/Cargo.toml b/crates/unixnotis-core/Cargo.toml index 59cb239d6..7491cea08 100644 --- a/crates/unixnotis-core/Cargo.toml +++ b/crates/unixnotis-core/Cargo.toml @@ -5,6 +5,8 @@ edition.workspace = true license.workspace = true [dependencies] +anyhow.workspace = true +blake3.workspace = true chrono.workspace = true image.workspace = true resvg.workspace = true @@ -14,8 +16,11 @@ serde_repr.workspace = true serde_ignored.workspace = true shell-words.workspace = true toml.workspace = true +toml_edit.workspace = true thiserror.workspace = true +tokio.workspace = true tracing.workspace = true +unicode-width.workspace = true zbus.workspace = true [dev-dependencies] diff --git a/crates/unixnotis-core/assets/base.css b/crates/unixnotis-core/assets/base.css index 113abb257..895d2a86a 100644 --- a/crates/unixnotis-core/assets/base.css +++ b/crates/unixnotis-core/assets/base.css @@ -38,6 +38,11 @@ @define-color unixnotis-accent #66f2e6; @define-color unixnotis-accent-2 #ff4fb7; @define-color unixnotis-urgent #ff5a78; +@define-color unixnotis-critical-surface #29151d; +@define-color unixnotis-critical-surface-strong #351923; +@define-color unixnotis-critical-border #fb7185; +@define-color unixnotis-critical-text #fecdd3; +@define-color unixnotis-critical-icon #fda4af; /* Per-toggle accents * @@ -100,7 +105,19 @@ .unixnotis-panel-window, .unixnotis-popup-window { background: transparent; - font-family: "Manrope", "SF Pro Text", "CaskaydiaCove Nerd Font Propo", "Noto Sans", sans-serif; font-family: var(--unixnotis-ui-font-family); } + +/* Shared urgency badge stays compact enough for long application names */ +.unixnotis-urgency-badge { + background: alpha(@unixnotis-critical-border, 0.12); + border: 1px solid alpha(@unixnotis-critical-border, 0.42); + border-radius: 999px; + color: @unixnotis-critical-text; + font-size: 9px; + font-weight: 750; + letter-spacing: 0.08em; + padding: 2px 7px; + text-transform: uppercase; +} /* End of base theme. */ diff --git a/crates/unixnotis-core/assets/internal-structure.css b/crates/unixnotis-core/assets/internal-structure.css index 71539b756..fa8113194 100644 --- a/crates/unixnotis-core/assets/internal-structure.css +++ b/crates/unixnotis-core/assets/internal-structure.css @@ -7,6 +7,7 @@ background: alpha(@theme_bg_color, 0.92); } +.unixnotis-reload-notice-content, .unixnotis-reload-notice-text { min-width: 0; } @@ -16,3 +17,8 @@ min-height: 28px; padding: 0; } + +/* GtkSearchEntry has no public icon child, so the native glyphs yield to owned controls */ +.unixnotis-panel-search-owned-icons { + -gtk-icon-source: none; +} diff --git a/crates/unixnotis-core/assets/media.css b/crates/unixnotis-core/assets/media.css index 19c1c2033..f60263ee5 100644 --- a/crates/unixnotis-core/assets/media.css +++ b/crates/unixnotis-core/assets/media.css @@ -5,21 +5,21 @@ * without touching the rest of widgets.css */ :root { - --unixnotis-media-card-radius: 20px; - --unixnotis-media-card-min-height: 72px; - --unixnotis-media-card-padding-x: 10px; + --unixnotis-media-card-radius: 18px; + --unixnotis-media-card-min-height: 82px; + --unixnotis-media-card-padding-x: 8px; --unixnotis-media-card-padding-y: 8px; - --unixnotis-media-card-padding-inline-x: 10px; + --unixnotis-media-card-padding-inline-x: 8px; --unixnotis-media-card-padding-inline-y: 8px; --unixnotis-media-card-padding-stacked: 8px; - --unixnotis-media-card-padding-showcase-x: 10px; + --unixnotis-media-card-padding-showcase-x: 8px; --unixnotis-media-card-padding-showcase-y: 8px; - --unixnotis-media-button-padding-x: 6px; + --unixnotis-media-button-padding-x: 5px; --unixnotis-media-button-padding-y: 4px; - --unixnotis-media-art-size: 48px; - --unixnotis-media-art-radius: 10px; + --unixnotis-media-art-size: 56px; + --unixnotis-media-art-radius: 12px; --unixnotis-media-art-frame-radius: 12px; - --unixnotis-media-title-font-size: 13px; + --unixnotis-media-title-font-size: 14px; --unixnotis-media-title-font-weight: 700; --unixnotis-media-source-font-size: 11px; --unixnotis-media-artist-font-size: 12px; @@ -59,51 +59,91 @@ background: transparent; } +/* Player switcher: symmetric chevron docks flanking the card. Quiet glass so + * they never compete with the notification list. */ .unixnotis-media-nav { - background-image: linear-gradient(150deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.9)); - border-radius: 12px; - padding: calc((var(--unixnotis-media-nav-size) - 14px) / 2); - border: 1px solid alpha(@unixnotis-accent, 0.2); + background: alpha(#ffffff, 0.05); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 999px; + padding: 0; + margin: 0; + min-width: 24px; + min-height: 52px; + -gtk-icon-size: 16px; + font-size: 16px; font-weight: 700; - font-size: 12px; - min-width: var(--unixnotis-media-nav-size); - color: @unixnotis-text; + color: alpha(#ffffff, 0.80); box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - 0 0 16px -14px @unixnotis-glow-cyan; + inset 0 1px 0 alpha(#ffffff, 0.06), + 0 2px 6px -4px alpha(#000000, 0.4); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-media-nav:hover { - background-image: linear-gradient(150deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.16)); - border-color: alpha(@unixnotis-accent, 0.5); + background: alpha(#ffffff, 0.10); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.03); + color: #ffffff; + box-shadow: + 0 8px 16px -10px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.10); +} + +.unixnotis-media-nav:active { + background: alpha(#ffffff, 0.14); + color: #ffffff; +} + +.unixnotis-media-nav:backdrop { + background: alpha(#ffffff, 0.035); + border-color: alpha(#ffffff, 0.05); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04); } -.unixnotis-media-nav-prev, -.unixnotis-media-nav-next { - min-width: var(--unixnotis-media-nav-size); +/* Keep the arrows clear of the rounded chip edges so the glyph never clips. */ +.unixnotis-media-nav image, +.unixnotis-media-nav .icon { + color: inherit; } .unixnotis-media-position { - color: @unixnotis-muted; - font-size: 11px; - letter-spacing: 0.08em; + color: alpha(#ffffff, 0.60); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + background: alpha(#ffffff, 0.05); + border: 1px solid alpha(#ffffff, 0.08); + border-radius: 999px; + padding: 1px 8px; + box-shadow: none; } +/* Glass card: translucent with a soft top sheen, so it reads as a pane of + * frosted glass rather than a solid panel. Deliberately low-contrast so the + * notification feed stays the visual center. */ .unixnotis-media-card { - background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, alpha(#0f1828, 0.94)); - border-radius: 18px; - border: 1px solid @unixnotis-card-border; + background-image: linear-gradient(165deg, alpha(#ffffff, 0.07) 0%, alpha(#ffffff, 0.02) 100%); + border-top: 1px solid alpha(#ffffff, 0.10); + border-left: 1px solid alpha(#ffffff, 0.07); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: var(--unixnotis-media-card-radius); padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); - min-height: 68px; + min-height: var(--unixnotis-media-card-min-height); box-shadow: - 0 14px 26px -20px @unixnotis-shadow-strong, - 0 0 22px -20px alpha(@unixnotis-accent-2, 0.16), - inset 0 0 0 1px alpha(#ffffff, 0.04); + 0 4px 12px -8px alpha(#000000, 0.4), + inset 0 1px 0 alpha(#ffffff, 0.05); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-media-card-carousel { padding: var(--unixnotis-media-card-padding-y) var(--unixnotis-media-card-padding-x); - min-height: 68px; + min-height: var(--unixnotis-media-card-min-height); } .unixnotis-media-card-inline { @@ -125,8 +165,40 @@ padding: var(--unixnotis-media-card-padding-showcase-y) var(--unixnotis-media-card-padding-showcase-x); } +/* Playing is a gentle glass brightening, not a colored outline. */ .unixnotis-media-card.playing { - border-left: 1px solid @unixnotis-card-border; + background-image: linear-gradient(165deg, alpha(#ffffff, 0.09) 0%, alpha(#ffffff, 0.03) 100%); + border-top-color: alpha(#ffffff, 0.14); + border-left-color: alpha(#ffffff, 0.10); + border-right-color: alpha(#ffffff, 0.05); + border-bottom-color: alpha(#ffffff, 0.02); + box-shadow: + 0 8px 16px -12px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.07); +} + +.unixnotis-media-card:hover { + background-image: linear-gradient(165deg, alpha(#ffffff, 0.09) 0%, alpha(#ffffff, 0.03) 100%); + border-top: 1px solid alpha(#ffffff, 0.13); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.05); + border-bottom: 1px solid alpha(#ffffff, 0.02); + box-shadow: + 0 8px 16px -12px alpha(#000000, 0.5), + inset 0 1px 0 alpha(#ffffff, 0.07); +} + +/* Keep depth visible when the panel is unfocused, matching the widget family. */ +.unixnotis-media-card:backdrop { + border-top-color: alpha(#ffffff, 0.07); + border-left-color: alpha(#ffffff, 0.05); + border-right-color: alpha(#ffffff, 0.03); + border-bottom-color: alpha(#ffffff, 0.02); + box-shadow: + 0 12px 24px -22px alpha(#000000, 0.35), + 0 0 0 1px alpha(#ffffff, 0.04), + inset 0 1px 0 alpha(#ffffff, 0.04), + inset 0 -2px 6px -5px alpha(#000000, 0.35); } /* @@ -156,7 +228,6 @@ .unixnotis-media-art { min-width: var(--unixnotis-media-art-size); min-height: var(--unixnotis-media-art-size); - border-radius: 10px; border-radius: var(--unixnotis-media-art-radius); background: alpha(#000000, 0.12); } @@ -164,9 +235,8 @@ .unixnotis-media-art-frame { min-width: var(--unixnotis-media-art-frame-size); min-height: var(--unixnotis-media-art-frame-size); - border-radius: 12px; border-radius: var(--unixnotis-media-art-frame-radius); - background: alpha(#000000, 0.12); + background: alpha(@unixnotis-surface-strong-base, 0.44); border-top: 1px solid alpha(#ffffff, 0.10); border-left: 1px solid alpha(#ffffff, 0.08); border-right: 1px solid alpha(#ffffff, 0.04); @@ -175,11 +245,12 @@ } .unixnotis-media-art.empty { - background: alpha(#000000, 0.18); + background: alpha(@unixnotis-surface-strong-base, 0.58); + border-color: alpha(@unixnotis-accent, 0.10); } .unixnotis-media-source { - color: alpha(@unixnotis-accent, 0.75); + color: alpha(@unixnotis-accent, 0.65); font-weight: 700; font-size: 10px; letter-spacing: 0.12em; @@ -188,9 +259,10 @@ .unixnotis-media-title { color: #ffffff; - font-weight: 800; - font-size: 13px; + font-weight: 750; + font-size: var(--unixnotis-media-title-font-size); letter-spacing: -0.01em; + line-height: 1.2; } .unixnotis-marquee { @@ -200,9 +272,10 @@ } .unixnotis-media-artist { - color: #cbd5e1; + color: alpha(#cbd5e1, 0.85); font-weight: 500; font-size: 12px; + line-height: 1.25; } .unixnotis-media-artist.empty { @@ -213,75 +286,21 @@ background: transparent; } +/* Transport buttons share the toggle/action glass. */ .unixnotis-media-button { - background-image: linear-gradient(160deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.95)); - border-radius: 10px; - border: 1px solid alpha(@unixnotis-accent, 0.2); - padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); - box-shadow: - 0 6px 14px -12px @unixnotis-shadow-soft, - 0 0 14px -14px @unixnotis-glow-cyan; -} - -.unixnotis-media-button:hover { - background-image: linear-gradient(160deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.2)); - border-color: alpha(@unixnotis-accent, 0.5); -} - -.unixnotis-media-button.primary { - background-image: linear-gradient(160deg, @unixnotis-action-bg-active, alpha(@unixnotis-accent-2, 0.28)); - border-color: alpha(@unixnotis-accent, 0.75); -} - -/* Restrained media transport */ -.unixnotis-media-card { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - border-radius: var(--unixnotis-media-card-radius); - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out; -} - -.unixnotis-media-card:hover { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); - border-top-color: alpha(#ffffff, 0.14); - border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.06); - border-bottom-color: alpha(#ffffff, 0.03); - box-shadow: 0 8px 20px -10px alpha(#000000, 0.7), inset 0 1px 0 alpha(#ffffff, 0.05); -} - -.unixnotis-media-card.playing { - background-image: linear-gradient(135deg, alpha(#ffffff, 0.075), alpha(#ffffff, 0.025)); - border-top: 1px solid alpha(#ffffff, 0.16); - border-left: 1px solid alpha(#ffffff, 0.12); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: - 0 16px 36px -20px alpha(#000000, 0.8), - inset 0 1px 0 alpha(#ffffff, 0.12); -} - -.unixnotis-media-button, -.unixnotis-media-nav { background: alpha(#ffffff, 0.04); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); - box-shadow: none; - color: alpha(#ffffff, 0.85); - border-radius: 999px; border-radius: var(--unixnotis-media-button-radius); - transition: background-color 0.12s ease-out, border-color 0.12s ease-out, transform 0.12s ease-out, color 0.12s ease-out, box-shadow 0.12s ease-out; + padding: var(--unixnotis-media-button-padding-y) var(--unixnotis-media-button-padding-x); + box-shadow: none; + color: alpha(#ffffff, 0.78); + transition: background-color 0.12s ease-out, border-color 0.12s ease-out, color 0.12s ease-out; } -.unixnotis-media-button:hover, -.unixnotis-media-nav:hover { +.unixnotis-media-button:hover { background: alpha(#ffffff, 0.09); border-top-color: alpha(#ffffff, 0.16); border-left-color: alpha(#ffffff, 0.12); @@ -289,13 +308,23 @@ border-bottom-color: alpha(#ffffff, 0.03); box-shadow: 0 4px 10px -5px alpha(#000000, 0.4), inset 0 1px 0 alpha(#ffffff, 0.05); color: #ffffff; - transform: translateY(-1px); } +.unixnotis-media-button:disabled, +.unixnotis-media-button:disabled:hover { + background: alpha(#ffffff, 0.04); + border: 1px solid alpha(#ffffff, 0.08); + color: alpha(#ffffff, 0.50); + -gtk-icon-filter: none; + box-shadow: none; +} + +/* Primary play: a clean white pill so it reads as the main control without + * borrowing the accent hue. */ .unixnotis-media-button.primary { - background: #ffffff; - border: 1px solid #ffffff; - color: #0f172a; + background: @unixnotis-text; + border: 1px solid @unixnotis-text; + color: @unixnotis-surface-base; box-shadow: 0 4px 10px -3px alpha(#000000, 0.3); } @@ -304,24 +333,34 @@ border-color: alpha(#ffffff, 0.90); color: #020617; box-shadow: 0 6px 14px -2px alpha(#000000, 0.45); - transform: translateY(-1.5px) scale(1.04); } -.unixnotis-media-button:focus, -.unixnotis-media-nav:focus { - /* Remove default blue focus rings from GTK button selections */ - outline: none; +/* Transport icons stay legible on every player (browser MPRIS icons can arrive + * dark); the primary play button keeps its dark glyph. */ +.unixnotis-media-button image, +.unixnotis-media-button .icon, +.unixnotis-media-button:disabled image, +.unixnotis-media-button:disabled .icon { + color: alpha(#ffffff, 0.90); + -gtk-icon-palette: success alpha(#ffffff, 0.90), warning alpha(#ffffff, 0.90), error alpha(#ffffff, 0.90); + -gtk-icon-filter: none; + -gtk-icon-shadow: 0 0 0 transparent; } -/* Compact carousel navigation to protect panel width budget */ -.unixnotis-media-nav { - min-width: 18px; - min-height: 18px; - padding: 3px; - margin: 0; +.unixnotis-media-button.primary image, +.unixnotis-media-button.primary .icon { + color: @unixnotis-surface-base; + -gtk-icon-palette: success @unixnotis-surface-base, warning @unixnotis-surface-base, error @unixnotis-surface-base; + -gtk-icon-shadow: 0 0 0 transparent; +} + +.unixnotis-media-button.primary:hover image, +.unixnotis-media-button.primary:hover .icon { + color: #020617; } -.unixnotis-media-nav-prev, -.unixnotis-media-nav-next { - min-width: 18px; +.unixnotis-media-button:focus, +.unixnotis-media-nav:focus { + /* Remove default blue focus rings from GTK button selections */ + outline: none; } diff --git a/crates/unixnotis-core/assets/motion-policy.css b/crates/unixnotis-core/assets/motion-policy.css new file mode 100644 index 000000000..fee47bd7c --- /dev/null +++ b/crates/unixnotis-core/assets/motion-policy.css @@ -0,0 +1,7 @@ +/* Runtime motion policy stays above editable theme layers for accessibility */ +.unixnotis-panel.unixnotis-reduced-motion, +.unixnotis-panel.unixnotis-reduced-motion * { + transition: none; + animation: none; + transform: none; +} diff --git a/crates/unixnotis-core/assets/panel.css b/crates/unixnotis-core/assets/panel.css index 0e364dcda..26d8fe1a3 100644 --- a/crates/unixnotis-core/assets/panel.css +++ b/crates/unixnotis-core/assets/panel.css @@ -4,29 +4,21 @@ */ .unixnotis-panel { min-width: 420px; - /* Primary surface gradient. - * The last stop (panel-grad-3) is intentionally hotpink-leaning to create a - * bottom-right glow without changing any layout logic. */ - background-image: linear-gradient(155deg, @unixnotis-panel-grad-1 0%, @unixnotis-panel-grad-2 55%, @unixnotis-panel-grad-3 100%); + background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); color: @unixnotis-text; - border-radius: 30px; border-radius: var(--unixnotis-panel-radius); - padding: 16px; padding: var(--unixnotis-panel-padding); - border: 1px solid @unixnotis-outline; + border: 1px solid alpha(#9bb8e8, 0.16); + font-family: "Inter", "Manrope", "Noto Sans", sans-serif; box-shadow: - 0 28px 70px -38px @unixnotis-glow-cyan, - 0 22px 60px -42px @unixnotis-glow-pink, - 0 12px 28px -20px @unixnotis-shadow-soft, - inset 0 0 0 1px alpha(#ffffff, 0.04); + 0 26px 64px -34px alpha(#000000, 0.88), + inset 0 1px 0 alpha(#ffffff, 0.035); } .unixnotis-panel-header { margin-bottom: 12px; - padding: 12px; padding: var(--unixnotis-panel-header-padding); - border-radius: 18px; border-radius: var(--unixnotis-panel-header-radius); background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.7), alpha(@unixnotis-surface, 0.9)); border: 1px solid alpha(@unixnotis-accent, 0.16); @@ -79,21 +71,20 @@ .unixnotis-panel-title { font-weight: 700; font-size: 16px; - letter-spacing: 0.3px; + letter-spacing: -0.01em; } .unixnotis-panel-count { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.22), alpha(@unixnotis-accent-2, 0.18)); - color: @unixnotis-text; + background: alpha(@unixnotis-accent, 0.12); + color: #bffaf5; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; border-radius: 999px; padding: 2px 8px; - border: 1px solid alpha(@unixnotis-accent, 0.35); + border: 1px solid alpha(@unixnotis-accent, 0.28); min-width: 26px; - /* Slight bloom improves readability over complex wallpapers. */ - box-shadow: 0 0 12px -10px @unixnotis-glow-cyan; + box-shadow: none; } .unixnotis-panel-actions { @@ -107,18 +98,18 @@ } .unixnotis-panel-action { - background-image: linear-gradient(160deg, @unixnotis-action-bg, alpha(@unixnotis-surface, 0.9)); - color: @unixnotis-text; - border-radius: 999px; - padding: 6px 10px; + background: alpha(#ffffff, 0.045); + color: alpha(#ffffff, 0.75); + border-radius: 10px; padding: var(--unixnotis-panel-action-gap) calc(var(--unixnotis-panel-action-gap) + 4px); - border: 1px solid alpha(@unixnotis-accent, 0.18); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: 28px; - box-shadow: - 0 8px 16px -12px @unixnotis-shadow-soft, - 0 0 18px -16px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.04); + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); font-size: 11px; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action-focus, @@ -160,8 +151,12 @@ } .unixnotis-panel-action:hover { - background-image: linear-gradient(160deg, @unixnotis-action-bg-hover, alpha(@unixnotis-accent-2, 0.16)); - border-color: alpha(@unixnotis-accent, 0.5); + background: alpha(#ffffff, 0.08); + border-top: 1px solid alpha(#ffffff, 0.14); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + color: #ffffff; box-shadow: 0 10px 18px -14px @unixnotis-shadow-soft, 0 0 22px -18px @unixnotis-glow-cyan, @@ -170,13 +165,14 @@ } .unixnotis-panel-action:checked { - background-image: linear-gradient(140deg, @unixnotis-action-bg-active, alpha(@unixnotis-accent-2, 0.3)); - border-color: alpha(@unixnotis-accent, 0.75); + background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); + border: 1px solid alpha(#00a2ff, 0.50); + box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } .unixnotis-panel-action:checked .unixnotis-panel-action-glyph, .unixnotis-panel-action:checked .unixnotis-panel-action-label { - color: @unixnotis-text; + color: #ffffff; } .unixnotis-panel-action-icon { @@ -188,17 +184,89 @@ .unixnotis-panel-action-close { /* Close action is visually isolated from destructive list actions. */ margin-left: 6px; - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-strong, 0.88), alpha(@unixnotis-surface-soft, 0.84)); - border-color: alpha(@unixnotis-accent, 0.26); - box-shadow: - 0 8px 14px -12px @unixnotis-shadow-soft, - 0 0 14px -14px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); + background: alpha(#ffffff, 0.045); + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 10px; + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } .unixnotis-panel-action-close:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg-hover, 0.88), alpha(@unixnotis-accent-2, 0.18)); - border-color: alpha(@unixnotis-accent, 0.58); + background: alpha(#fb7185, 0.16); + border-color: alpha(#fb7185, 0.45); + color: #fb7185; +} + +/* The DND menu is a compact action list rather than a stack of stock buttons */ +.unixnotis-dnd-menu > contents { + padding: 8px; + border-radius: 12px; + border: 1px solid alpha(#ffffff, 0.08); + background-color: @unixnotis-surface-base; + background-image: none; + box-shadow: + 0 18px 38px -20px @unixnotis-shadow-strong, + inset 0 1px alpha(#ffffff, 0.025); +} + +.unixnotis-dnd-menu-content { + min-width: 216px; + border-spacing: 2px; +} + +.unixnotis-dnd-menu-title { + margin: 5px 9px 7px; + color: @unixnotis-muted; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice { + min-height: 32px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 9px; + color: @unixnotis-text; + background-color: transparent; + background-image: none; + box-shadow: none; + text-shadow: none; + transition: background-color 0.1s ease-out, border-color 0.1s ease-out; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice label { + font-size: 12px; + font-weight: 500; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:hover, +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:focus-visible { + color: @unixnotis-text; + border-color: alpha(#ffffff, 0.07); + background-color: @unixnotis-card-base; + background-image: none; + box-shadow: none; +} + +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:active { + border-color: alpha(#ffffff, 0.10); + background-color: @unixnotis-surface-strong-base; + background-image: none; + box-shadow: none; +} + +.unixnotis-dnd-menu-choice-indefinite { + color: @unixnotis-text; +} + +.unixnotis-dnd-menu-separator { + margin: 4px 9px 3px; + min-height: 1px; + background-color: alpha(#ffffff, 0.08); } .unixnotis-panel-search-revealer { @@ -206,7 +274,6 @@ } .unixnotis-panel-search { - min-height: 34px; min-height: var(--unixnotis-panel-search-min-height); border-radius: 12px; background: alpha(#000000, 0.4); @@ -214,12 +281,23 @@ border-left: 1px solid alpha(#ffffff, 0.04); border-right: 1px solid alpha(#ffffff, 0.02); border-bottom: 1px solid alpha(#ffffff, 0.01); - padding: 0 10px; padding: 0 var(--unixnotis-panel-search-padding-x); box-shadow: inset 0 1px 3px alpha(#000000, 0.5); transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } +.unixnotis-panel-search-magnifier { + min-width: 16px; + min-height: 16px; + color: @unixnotis-muted; +} + +.unixnotis-panel-search-clear { + min-width: 24px; + min-height: 24px; + padding: 0; +} + .unixnotis-panel-search:focus-within { border-color: alpha(@unixnotis-accent, 0.60); box-shadow: @@ -234,15 +312,13 @@ entry selection { .unixnotis-panel-close { background: alpha(#ffffff, 0.045); - border-radius: 999px; + border-radius: 10px; border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); padding: 2px; - min-width: 28px; min-width: var(--unixnotis-panel-close-size); - min-height: 28px; min-height: var(--unixnotis-panel-close-size); box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; @@ -255,8 +331,14 @@ entry selection { box-shadow: 0 0 8px alpha(#fb7185, 0.35); } +.unixnotis-panel-close:focus-visible { + border-color: alpha(@unixnotis-accent, 0.45); + outline: none; +} + .unixnotis-panel-list { background: transparent; + padding-bottom: 20px; } .unixnotis-panel-list row { @@ -270,21 +352,21 @@ entry selection { */ .unixnotis-group { background: transparent; - margin-bottom: 8px; } .unixnotis-group-header { - background-image: linear-gradient(160deg, @unixnotis-pill-bg, alpha(@unixnotis-surface-soft, 0.86)); + background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); color: @unixnotis-text; border-radius: 999px; padding: 6px 12px; - border: 1px solid @unixnotis-pill-border; - box-shadow: 0 10px 18px -16px @unixnotis-shadow-soft; + border: 1px solid @unixnotis-card-border; + box-shadow: none; outline: none; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-group-header:hover { - background-image: linear-gradient(160deg, @unixnotis-pill-hover, alpha(@unixnotis-accent-2, 0.18)); + border-color: alpha(@unixnotis-accent, 0.22); } .unixnotis-group-header:focus, @@ -306,19 +388,68 @@ entry selection { .unixnotis-group-title { font-weight: 600; font-size: 12px; - letter-spacing: 0.2px; + letter-spacing: 0.1px; +} + +.unixnotis-group-avatar { + min-width: 26px; + min-height: 26px; + border-radius: 9px; + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.9); +} + +.unixnotis-group.relay .unixnotis-group-avatar { + background: alpha(#fbbf24, 0.08); + color: alpha(#fde68a, 0.90); +} + +.unixnotis-group.unresolved .unixnotis-group-avatar { + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.82); +} + +.unixnotis-group.recognized .unixnotis-group-avatar { + border: 1px solid alpha(#ffffff, 0.10); +} + +.unixnotis-group.conflict .unixnotis-group-avatar { + background: alpha(#fb7185, 0.11); + color: #fecdd3; +} + +.unixnotis-group-secondary { + color: alpha(#ffffff, 0.54); + font-size: 11px; +} + +.unixnotis-group-trust-chip { + border-radius: 999px; + padding: 1px 6px; + font-size: 9px; + font-weight: 600; + background: alpha(#fbbf24, 0.09); + color: alpha(#fde68a, 0.86); + border: 1px solid alpha(#fbbf24, 0.18); +} + +.unixnotis-group.conflict .unixnotis-group-trust-chip { + background: alpha(#fb7185, 0.12); + color: #fecdd3; + border-color: alpha(#fb7185, 0.30); } .unixnotis-group-count { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.22), alpha(@unixnotis-accent-2, 0.2)); - color: @unixnotis-text; + background: alpha(#ffffff, 0.06); + color: alpha(#ffffff, 0.72); border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; - border: 1px solid alpha(@unixnotis-accent, 0.35); + border: 1px solid alpha(#ffffff, 0.10); min-width: 22px; + box-shadow: none; } .unixnotis-group-chevron { @@ -326,17 +457,13 @@ entry selection { } .unixnotis-group-row-collapsed .unixnotis-group-count { - border-color: alpha(@unixnotis-accent-2, 0.3); + border-color: alpha(#ffffff, 0.10); } .unixnotis-group-row-no-icon .unixnotis-group-title { margin-left: 2px; } -.unixnotis-panel-card-grouped { - margin-left: 8px; -} - /* * Notification cards (panel) */ @@ -346,107 +473,164 @@ entry selection { } .unixnotis-panel-card { - background-image: linear-gradient(165deg, @unixnotis-notification-bg-1, @unixnotis-notification-bg-2); - border: 1px solid @unixnotis-card-border; - border-radius: 20px; + background-image: linear-gradient( + 180deg, + alpha(#2c3762, 0.82) 0%, + alpha(#1a2242, 0.88) 55%, + alpha(#121834, 0.93) 100% + ); + border: 1px solid alpha(#ffffff, 0.10); border-radius: var(--unixnotis-notification-card-radius); - padding: 10px 12px; padding: var(--unixnotis-panel-card-padding-y) var(--unixnotis-panel-card-padding-x); - margin-bottom: 8px; + margin: 0; box-shadow: - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 22px -18px alpha(@unixnotis-accent, 0.16), - inset 0 0 0 1px alpha(#ffffff, 0.05); + inset 0 1px 0 alpha(#ffffff, 0.09), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 4px 12px -8px alpha(#000000, 0.65); + transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; } -.unixnotis-panel-card.stacked { - /* One focused shadow keeps the foreground content visually above the rear layers */ - box-shadow: - 0 12px 24px -20px @unixnotis-shadow-strong, - inset 0 0 0 1px alpha(#ffffff, 0.04); +/* Group headers already carry identity, so child cards use a compact metadata lane */ +.unixnotis-panel-card.group-owned-identity { + padding-top: 6px; + padding-bottom: 8px; } -.unixnotis-panel-card-group-collapsed { - /* Pull the foreground over the middle layer while leaving its rounded top visible */ - margin-top: -58px; - margin-bottom: 8px; +.unixnotis-panel-card.group-owned-identity .unixnotis-panel-card-header { + margin-bottom: -6px; } -.unixnotis-panel-card-group-expanded { - margin-bottom: var(--unixnotis-panel-card-gap); +.unixnotis-panel-card.group-owned-identity .unixnotis-panel-close { + min-width: 24px; + min-height: 24px; } -.unixnotis-stack-ghost { - /* Full card silhouettes preserve the stack shape if card colors are customized */ - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.10); - border-radius: 18px; - padding: 0; - min-height: 68px; +.unixnotis-panel-card-thumbnail { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; opacity: 1; - margin-left: 10px; - margin-right: 10px; - margin-top: -58px; - margin-bottom: 0; - border: 1px solid alpha(@unixnotis-card-border, 0.62); +} + +.unixnotis-panel-card-thumbnail.unixnotis-panel-content-image, +.unixnotis-panel-card-thumbnail.unixnotis-panel-sender-visual { + border-radius: 10px; + background: alpha(#0a0f1f, 0.40); + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: + inset 0 1px 2px alpha(#000000, 0.25), + inset 0 0 0 1px alpha(#ffffff, 0.03); + opacity: 0.92; +} + +.unixnotis-panel-card.collapsed-group-preview { box-shadow: - 0 -2px 10px -8px alpha(@unixnotis-accent, 0.22), - 0 8px 14px -14px @unixnotis-shadow-soft; + inset 0 1px 0 alpha(#ffffff, 0.11), + inset 0 2px 0 alpha(#ffffff, 0.04), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 6px 14px -10px alpha(#000000, 0.72); +} + +.unixnotis-panel-card.unixnotis-panel-card-grouped { + border-radius: var(--unixnotis-notification-card-radius); } -.unixnotis-stack-ghost-2 { - background: mix(@unixnotis-notification-bg-2, @unixnotis-card-border, 0.06); +.unixnotis-panel-card-foreground.unixnotis-panel-card-grouped { + border-radius: var(--unixnotis-notification-card-radius); +} + +.unixnotis-stack-layer { min-height: 68px; - opacity: 1; - margin-left: 20px; - margin-right: 20px; - margin-top: 0; - margin-bottom: 0; - border-color: alpha(@unixnotis-card-border, 0.42); + padding: 0; + border: 1px solid alpha(#ffffff, 0.10); + border-radius: var(--unixnotis-notification-card-radius); + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.10) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#2c3762, 0.85) 0%, + alpha(#1a2242, 0.90) 55%, + alpha(#121834, 0.94) 100% + ); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.10), + inset 0 2px 0 alpha(#ffffff, 0.04), + 0 6px 10px -10px alpha(#000000, 0.30); +} + +.unixnotis-stack-layer-back { + opacity: 0.80; + border-color: alpha(#ffffff, 0.07); + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.06) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#232c50, 0.82) 0%, + alpha(#151d3a, 0.88) 55%, + alpha(#0e122b, 0.92) 100% + ); box-shadow: - 0 -2px 10px -9px alpha(@unixnotis-accent, 0.14), - 0 10px 16px -15px @unixnotis-shadow-soft; + inset 0 1px 0 alpha(#ffffff, 0.06), + 0 6px 10px -10px alpha(#000000, 0.24); +} + +.unixnotis-stack-layer-middle { + opacity: 0.92; + border-color: alpha(#ffffff, 0.09); + background-image: + linear-gradient(135deg, alpha(#ffffff, 0.08) 0%, alpha(#ffffff, 0) 28%), + linear-gradient( + 180deg, + alpha(#29325c, 0.84) 0%, + alpha(#19213f, 0.90) 55%, + alpha(#101531, 0.94) 100% + ); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + 0 6px 10px -10px alpha(#000000, 0.26); } .unixnotis-panel-card.active { + border-color: alpha(#ffffff, 0.14); +} + +/* Critical state composes after the ordinary active and stack rules */ +.unixnotis-panel-card.critical, +.unixnotis-panel-card.active.critical { + background-image: linear-gradient( + 180deg, + alpha(#3a2430, 0.88) 0%, + alpha(#23161f, 0.92) 100% + ); + border-color: alpha(@unixnotis-critical-border, 0.42); box-shadow: - 0 0 0 1px alpha(@unixnotis-accent, 0.28), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.22), + 0 4px 12px -8px alpha(#000000, 0.68); } -.unixnotis-panel-card.critical { - box-shadow: - 0 0 0 1px alpha(@unixnotis-urgent, 0.35), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px alpha(@unixnotis-urgent, 0.35), - inset 0 0 0 1px alpha(#ffffff, 0.05); +.unixnotis-panel-card.critical .unixnotis-panel-app { + color: @unixnotis-critical-text; } -.unixnotis-panel-card.stacked.active { - /* Active state should not erase the collapsed-stack shadow */ - box-shadow: - 0 8px 0 -4px alpha(@unixnotis-accent, 0.18), - 0 16px 0 -8px alpha(@unixnotis-accent, 0.14), - 0 0 0 1px alpha(@unixnotis-accent, 0.28), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05); +.unixnotis-panel-card.critical .unixnotis-panel-icon { + background: alpha(@unixnotis-critical-border, 0.12); + border: 1px solid alpha(@unixnotis-critical-border, 0.28); + border-radius: 8px; + color: @unixnotis-critical-icon; + padding: 4px; } -.unixnotis-panel-card.stacked.critical, -.unixnotis-panel-card.stacked.active.critical { - /* Urgent stacks keep the same depth while using the urgent border color */ - box-shadow: - 0 8px 0 -4px alpha(@unixnotis-urgent, 0.20), - 0 16px 0 -8px alpha(@unixnotis-urgent, 0.14), - 0 0 0 1px alpha(@unixnotis-urgent, 0.35), - 0 12px 26px -20px @unixnotis-shadow-strong, - 0 0 26px -20px alpha(@unixnotis-urgent, 0.35), - inset 0 0 0 1px alpha(#ffffff, 0.05); +.unixnotis-panel-card.critical .unixnotis-panel-summary { + color: #ffffff; } .unixnotis-panel-card-has-actions .unixnotis-notification-actions { + margin-top: 2px; margin-top: calc(var(--unixnotis-panel-action-gap) - 4px); } @@ -485,229 +669,178 @@ entry selection { } .unixnotis-panel-app { - font-weight: 700; - font-size: 14px; + color: alpha(@unixnotis-text, 0.78); + font-size: 12px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; +} + +.unixnotis-panel-secondary-claim { + color: alpha(#ffffff, 0.54); + font-size: 11px; +} + +.unixnotis-panel-trust-chip { + border-radius: 999px; + padding: 1px 6px; + font-size: 9px; + font-weight: 600; + background: alpha(#fbbf24, 0.09); + color: alpha(#fde68a, 0.86); + border: 1px solid alpha(#fbbf24, 0.18); +} + +.unixnotis-panel-card.conflict .unixnotis-panel-trust-chip { + background: alpha(#fb7185, 0.12); + color: #fecdd3; + border-color: alpha(#fb7185, 0.30); } .unixnotis-panel-summary { - font-size: 13px; + font-size: 14px; + line-height: 1.2; + color: @unixnotis-text; + font-weight: 650; } .unixnotis-panel-body { color: @unixnotis-muted; font-size: 12px; + line-height: 1.34; } -.unixnotis-panel-icon { - margin-right: 8px; -} - -.unixnotis-notification-actions { +.unixnotis-popup-status { + color: alpha(#ffffff, 0.52); + font-size: 11px; margin-top: 2px; } -.unixnotis-notification-action { - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.9), alpha(@unixnotis-surface, 0.95)); - color: @unixnotis-text; +.unixnotis-panel-icon { + min-width: 30px; + min-height: 30px; + margin-right: 2px; border-radius: 10px; - padding: 4px 10px; - padding: var(--unixnotis-notification-action-padding-y) var(--unixnotis-notification-action-padding-x); - border: 1px solid alpha(@unixnotis-accent, 0.18); - min-height: 28px; - font-size: 12px; -} - -.unixnotis-notification-action:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-accent, 0.2), alpha(@unixnotis-accent-2, 0.2)); - border-color: alpha(@unixnotis-accent, 0.5); + padding: 5px; + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.90); } -/* Restrained default composition - * - * Navy remains the visual identity while flat surfaces and spacing carry hierarchy - */ -.unixnotis-panel { - background-image: linear-gradient(155deg, alpha(#080d18, 0.98) 0%, alpha(#10152a, 0.98) 58%, alpha(#241734, 0.97) 100%); - border: 1px solid alpha(#9bb8e8, 0.16); - border-radius: 20px; - font-family: "Inter", "Manrope", "Noto Sans", sans-serif; - box-shadow: - 0 26px 64px -34px alpha(#000000, 0.88), - inset 0 1px 0 alpha(#ffffff, 0.035); +.unixnotis-panel-card.relay .unixnotis-panel-icon { + background: alpha(#fbbf24, 0.08); + color: alpha(#fde68a, 0.90); } -.unixnotis-panel-header { - background: transparent; - border: 0; - border-bottom: 1px solid alpha(#9bb8e8, 0.12); - border-radius: 0; - box-shadow: none; - padding-left: 2px; - padding-right: 2px; - padding-bottom: 12px; +.unixnotis-panel-card.unresolved .unixnotis-panel-icon { + background: alpha(#ffffff, 0.065); + color: alpha(#ffffff, 0.82); } -.unixnotis-panel-title { - font-size: 16px; - letter-spacing: -0.01em; +.unixnotis-panel-card.recognized .unixnotis-panel-icon { + border: 1px solid alpha(#ffffff, 0.10); } -.unixnotis-panel-count, -.unixnotis-group-count { - background: alpha(@unixnotis-accent, 0.12); - color: #bffaf5; - border-color: alpha(@unixnotis-accent, 0.28); - box-shadow: none; +.unixnotis-panel-card.conflict .unixnotis-panel-icon { + background: alpha(#fb7185, 0.11); + color: #fecdd3; } -.unixnotis-panel-action { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 10px; - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.75); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; +.unixnotis-notification-actions { + margin-top: 2px; } -.unixnotis-panel-action:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - color: #ffffff; - transform: translateY(-0.5px); +.unixnotis-panel-action-overflow { + min-width: 32px; + padding-left: 7px; + padding-right: 7px; } -.unixnotis-panel-action:checked { - background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); - border: 1px solid alpha(#00a2ff, 0.50); - box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); +.unixnotis-panel-default-action { + min-width: 32px; + min-height: 28px; + padding: 3px 8px; } -.unixnotis-panel-action:checked .unixnotis-panel-action-glyph, -.unixnotis-panel-action:checked .unixnotis-panel-action-label { - color: #ffffff; +.unixnotis-panel-action-overflow-list { + padding: 6px; } -.unixnotis-panel-action-close, -.unixnotis-panel-close { +.unixnotis-notification-action { background: alpha(#ffffff, 0.045); + color: alpha(#ffffff, 0.75); + border-radius: 10px; + padding: var(--unixnotis-notification-action-padding-y) var(--unixnotis-notification-action-padding-x); border-top: 1px solid alpha(#ffffff, 0.08); border-left: 1px solid alpha(#ffffff, 0.06); border-right: 1px solid alpha(#ffffff, 0.04); border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 10px; + min-height: 28px; + font-size: 12px; box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } -.unixnotis-panel-action-close:hover, -.unixnotis-panel-close:hover { - background: alpha(#fb7185, 0.16); - border-color: alpha(#fb7185, 0.45); - color: #fb7185; -} - -.unixnotis-group-header { - background: alpha(#ffffff, 0.025); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.03); - border-bottom: 1px solid alpha(#ffffff, 0.01); - box-shadow: none; - transition: background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-group-header:hover { - background: alpha(#ffffff, 0.05); +.unixnotis-notification-action:hover { + background: alpha(#ffffff, 0.08); border-top-color: alpha(#ffffff, 0.14); border-left-color: alpha(#ffffff, 0.10); - border-right-color: alpha(#ffffff, 0.05); - border-bottom-color: alpha(#ffffff, 0.02); -} - -.unixnotis-group-header:hover .unixnotis-group-count { - background: alpha(@unixnotis-accent, 0.22); + border-right-color: alpha(#ffffff, 0.06); + border-bottom-color: alpha(#ffffff, 0.04); color: #ffffff; - border-color: alpha(@unixnotis-accent, 0.45); -} - -.unixnotis-panel-card { - background-image: linear-gradient(135deg, alpha(#17253f, 0.90), alpha(#12182c, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.02), 0 4px 12px -8px alpha(#000000, 0.7); - transition: background-image 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; -} - -.unixnotis-panel-card:hover { - background-image: linear-gradient(135deg, alpha(#21355a, 0.92), alpha(#161e38, 0.97)); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04), 0 10px 24px -16px alpha(#000000, 0.85); - transform: translateY(-1.5px); + box-shadow: + 0 10px 18px -14px @unixnotis-shadow-soft, + 0 0 22px -18px @unixnotis-glow-cyan, + 0 0 20px -18px @unixnotis-glow-pink, + inset 0 0 0 1px alpha(#ffffff, 0.05); } -.unixnotis-panel-card.active { - background-image: linear-gradient(135deg, alpha(#1a2e50, 0.93), alpha(#111627, 0.96)); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.09); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.03); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.03), 0 6px 16px -8px alpha(#000000, 0.8); +.unixnotis-inline-reply { + margin-top: 4px; } -.unixnotis-panel-card.active:hover { - background-image: linear-gradient(135deg, alpha(#264375, 0.95), alpha(#151b32, 0.97)); - border-top: 1px solid alpha(#ffffff, 0.18); - border-left: 1px solid alpha(#ffffff, 0.14); - border-right: 1px solid alpha(#ffffff, 0.08); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05), 0 12px 28px -14px alpha(#000000, 0.9); - transform: translateY(-1.5px); +.unixnotis-inline-reply-entry { + color: @unixnotis-text; + background-color: alpha(@unixnotis-surface-strong, 0.9); + border: 1px solid alpha(@unixnotis-accent, 0.25); + border-radius: 10px; + padding: 5px 9px; + min-height: 28px; } -.unixnotis-panel-card.stacked, -.unixnotis-panel-card.stacked.active { - box-shadow: 0 12px 24px -20px alpha(#000000, 0.78); +.unixnotis-inline-reply-entry:focus { + border-color: alpha(@unixnotis-accent, 0.7); + box-shadow: 0 0 16px -12px @unixnotis-glow-cyan; } -.unixnotis-stack-ghost { - background: #172238; - border-color: alpha(#9bb8e8, 0.18); - border-radius: 16px; - box-shadow: none; +/* Interactive refinements follow the canonical base rules */ +.unixnotis-group-header:hover .unixnotis-group-count { + background: alpha(#ffffff, 0.09); + color: alpha(#ffffff, 0.82); + border-color: alpha(#ffffff, 0.14); } -.unixnotis-stack-ghost-2 { - background: #121c2f; - border-color: alpha(#9bb8e8, 0.14); - border-radius: 16px; +.unixnotis-panel-card.unixnotis-default-action:hover { + border-color: alpha(@unixnotis-accent, 0.22); + box-shadow: + 0 14px 28px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.05); } -.unixnotis-panel-app { - color: #a3b3cc; - font-size: 10px; - letter-spacing: 0.07em; - text-transform: uppercase; +.unixnotis-panel-card.active.unixnotis-default-action:hover { + border-color: alpha(@unixnotis-accent, 0.28); + box-shadow: + 0 14px 28px -20px @unixnotis-shadow-strong, + inset 0 0 0 1px alpha(#ffffff, 0.05); } -.unixnotis-panel-summary { - color: #ffffff; - font-weight: 700; +.unixnotis-panel-card.unixnotis-panel-card-grouped:hover, +.unixnotis-panel-card.active.unixnotis-panel-card-grouped:hover { + box-shadow: inset 0 0 0 1px alpha(#ffffff, 0.05); } -.unixnotis-panel-body { - color: #cbd5e1; +.unixnotis-panel-card.unixnotis-default-action:focus-visible { + outline: none; + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.25); } /* @@ -736,7 +869,7 @@ scrollbar slider { border: none; min-width: 4px; min-height: 4px; - transition: background-color 0.15s ease-out, min-width 0.15s ease-out, box-shadow 0.15s ease-out; + transition: background-color 0.15s ease-out; } scrollbar slider:hover { diff --git a/crates/unixnotis-core/assets/popup.css b/crates/unixnotis-core/assets/popup.css index 16f268bc0..6c6156553 100644 --- a/crates/unixnotis-core/assets/popup.css +++ b/crates/unixnotis-core/assets/popup.css @@ -1,36 +1,53 @@ -/* UnixNotis popup theme */ +/* UnixNotis popup theme + * + * Owns the toast popup surfaces and their cards. Scope is strictly the popup + * window/stack/card; the panel and its card stacking are untouched. + * + * Visual direction: a calm iOS-style glass banner. Quiet frosted dark glass, + * a hairline border, soft neutral depth, natural-case typography and minimal + * interaction feedback. No accent rails, no glow, nothing shouting. + */ -/* Shared close button styling for popup surfaces. */ +/* Shared close button styling for popup surfaces */ .unixnotis-popup-close { - background: alpha(#ffffff, 0.045); + background: alpha(#ffffff, 0.06); border-radius: 999px; - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); + border: 1px solid alpha(#ffffff, 0.10); padding: 3px; - min-width: 24px; min-width: var(--unixnotis-popup-close-size); - min-height: 24px; min-height: var(--unixnotis-popup-close-size); - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.70); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out, box-shadow 0.15s ease-out; + color: alpha(#ffffff, 0.75); + /* Touch and keyboard users need a visible resting target */ + opacity: 0.58; + transition: opacity 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; +} + +.unixnotis-popup-card:hover .unixnotis-popup-close { + opacity: 1; +} + +.unixnotis-popup-close:focus-visible { + opacity: 1; + border-color: alpha(@unixnotis-accent, 0.55); + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.15); } .unixnotis-popup-close:hover { - background: alpha(#fb7185, 0.16); + opacity: 1; + background: alpha(#fb7185, 0.18); border-color: alpha(#fb7185, 0.45); color: #fb7185; - box-shadow: 0 0 8px alpha(#fb7185, 0.35); - transform: translateY(-0.5px); +} + +.unixnotis-popup-close:active { + background: alpha(#fb7185, 0.28); + border-color: alpha(#fb7185, 0.60); + color: #fb7185; } /* Popup stack */ .unixnotis-popup-stack { - padding: 8px; padding: var(--unixnotis-popup-stack-padding); - border-radius: 16px; border-radius: calc(var(--unixnotis-popup-card-radius) - 4px); background: transparent; } @@ -39,70 +56,204 @@ background: transparent; } +/* Card: quiet frosted dark glass with a hairline edge and soft depth. + * The subtle top highlight reads as the material's light catch. */ .unixnotis-popup-card { - background-image: linear-gradient(135deg, alpha(#141d30, 0.94), alpha(#0a0e1a, 0.98)); - color: #ffffff; - border-radius: 20px; + background-image: linear-gradient( + 180deg, + alpha(#2c3762, 0.82) 0%, + alpha(#1a2242, 0.88) 55%, + alpha(#121834, 0.93) 100% + ); + color: @unixnotis-text; border-radius: var(--unixnotis-popup-card-radius); - padding: 14px 16px; - padding: var(--unixnotis-popup-card-padding-y) var(--unixnotis-popup-card-padding-x); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.09); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); + padding: calc(var(--unixnotis-popup-card-padding-y) + 4px) + calc(var(--unixnotis-popup-card-padding-x) + 6px); + border: 1px solid alpha(#ffffff, 0.10); + font-family: "Inter", "Noto Sans", sans-serif; box-shadow: - 0 20px 40px -20px alpha(#000000, 0.9), - 0 0 26px -22px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.08); + inset 0 1px 0 alpha(#ffffff, 0.09), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); + transition: border-color 0.18s ease-out, box-shadow 0.18s ease-out; } -.unixnotis-popup-card.critical { - border-left: 3px solid @unixnotis-urgent; - border-top: 1px solid alpha(#ffffff, 0.16); - border-right: 1px solid alpha(#ffffff, 0.08); - border-bottom: 1px solid alpha(#ffffff, 0.04); +.unixnotis-popup-card:hover { + border-color: alpha(#ffffff, 0.16); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.11), + inset 0 2px 0 alpha(#ffffff, 0.04), + inset 0 -1px 0 alpha(#000000, 0.20), + 0 1px 2px -1px alpha(#000000, 0.42), + 0 18px 36px -18px alpha(#000000, 0.70); } -.unixnotis-popup-header-row { - margin-bottom: 8px; - padding-bottom: 6px; - border-bottom: 1px solid alpha(#ffffff, 0.06); +.unixnotis-popup-card.utility { + padding-top: 12px; + padding-bottom: 12px; } -.unixnotis-popup-header { - color: @unixnotis-accent; - font-weight: 800; +.unixnotis-popup-communication-content, +.unixnotis-popup-utility-content { + background: transparent; +} + +.unixnotis-popup-content-grid { + min-width: 0; +} + +.unixnotis-popup-identity-row, +.unixnotis-popup-message, +.unixnotis-popup-header-row, +.unixnotis-popup-message-row { + min-width: 0; +} + +/* Identity header: natural-case app label, quiet timestamp, subtle chip */ +.unixnotis-popup-app-name { + color: alpha(@unixnotis-text, 0.90); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.01em; +} + +.unixnotis-popup-time { + color: alpha(@unixnotis-text, 0.52); + font-weight: 400; font-size: 11px; - letter-spacing: 0.06em; - text-transform: uppercase; + letter-spacing: 0.02em; + margin-right: 18px; +} + +.unixnotis-popup-trust-chip { + border-radius: 999px; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; } +.unixnotis-popup-trust-chip.recognized, +.unixnotis-popup-trust-chip.unresolved, +.unixnotis-popup-trust-chip.relay { + background: alpha(#fbbf24, 0.10); + color: alpha(#fde68a, 0.85); + border: 1px solid alpha(#fbbf24, 0.22); +} + +.unixnotis-popup-trust-chip.conflict { + background: alpha(#fb7185, 0.12); + color: #fecdd3; + border: 1px solid alpha(#fb7185, 0.30); +} + +/* Message hierarchy: semibold title, quiet support copy */ .unixnotis-popup-summary { font-weight: 700; - font-size: 13px; - margin-top: 4px; + font-size: 15px; + letter-spacing: -0.015em; + margin-top: 3px; + color: #ffffff; } .unixnotis-popup-icon { - margin-right: 8px; - min-width: 18px; - min-height: 18px; + color: inherit; +} + +/* Application branding stays compact so the message remains the visual focus */ +.unixnotis-popup-application-icon-slot { + min-width: 24px; + min-height: 24px; + border-radius: 6px; + background: transparent; + border: none; + color: alpha(#ffffff, 0.95); +} + +.unixnotis-popup-application-icon-slot.relay { + color: alpha(#fde68a, 0.90); +} + +.unixnotis-popup-application-icon-slot.unresolved { + color: alpha(#ffffff, 0.84); +} + +.unixnotis-popup-application-icon-slot.conflict { + color: #fecdd3; } .unixnotis-popup-body { - color: #cbd5e1; + color: alpha(@unixnotis-text, 0.82); + font-weight: 400; + font-size: 13px; + letter-spacing: 0.01em; + margin-top: 2px; +} + +.unixnotis-popup-footer-note { + color: alpha(#fde68a, 0.72); + font-size: 11px; font-weight: 500; + margin-top: 3px; +} + +.unixnotis-popup-secondary-claim { + color: alpha(@unixnotis-text, 0.58); font-size: 12px; + font-weight: 400; margin-top: 2px; } -.unixnotis-popup-actions { +.unixnotis-popup-content-image { + min-width: 64px; + min-height: 64px; margin-top: 8px; + border-radius: 10px; + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.04); +} + +/* Conversation avatars share the message lane and carry no app-identity chrome */ +.unixnotis-popup-conversation-avatar-slot { + min-width: 46px; + min-height: 46px; + border-radius: 50%; + background: transparent; + border: none; + box-shadow: none; +} + +.unixnotis-popup-conversation-avatar-slot .unixnotis-popup-conversation-avatar { + min-width: 46px; + min-height: 46px; + border-radius: 50%; + background: transparent; + border: none; + box-shadow: none; +} + +.unixnotis-popup-card.recognized, +.unixnotis-popup-card.unresolved { + border-color: alpha(#ffffff, 0.09); +} + +.unixnotis-popup-card.relay { + border-color: alpha(#fbbf24, 0.26); +} + +.unixnotis-popup-card.conflict { + border-color: alpha(@unixnotis-critical-border, 0.34); +} + +/* Actions: hairline separator and quiet minimal buttons */ +.unixnotis-popup-actions { margin-top: var(--unixnotis-popup-actions-gap); } .unixnotis-popup-card-has-summary .unixnotis-popup-summary { - color: @unixnotis-text; + color: #ffffff; } .unixnotis-popup-card-has-actions .unixnotis-popup-actions { @@ -110,36 +261,109 @@ padding-top: calc(var(--unixnotis-popup-actions-gap) - 2px); } -.unixnotis-popup-card-no-icon .unixnotis-popup-header-row { - padding-left: 0; -} - .unixnotis-popup-action { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - color: alpha(#ffffff, 0.75); - border-radius: 10px; - padding: 4px 10px; + background: alpha(#ffffff, 0.04); + border: 1px solid alpha(#ffffff, 0.08); + color: alpha(#ffffff, 0.80); + border-radius: 9px; + padding: 5px 11px; font-size: 12px; - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; + font-weight: 500; + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.15s ease-out; } .unixnotis-popup-action:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.14); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); + background: alpha(#ffffff, 0.09); + border-color: alpha(#ffffff, 0.14); color: #ffffff; - transform: translateY(-0.5px); +} + +.unixnotis-popup-action:active { + background: alpha(#ffffff, 0.13); + border-color: alpha(#ffffff, 0.18); +} + +.unixnotis-popup-action:focus-visible { + border-color: alpha(@unixnotis-accent, 0.55); + box-shadow: 0 0 0 2px alpha(@unixnotis-accent, 0.15); } .unixnotis-popup-action:checked { - background-image: linear-gradient(135deg, alpha(#00b4db, 0.22), alpha(#0083b0, 0.22)); - border: 1px solid alpha(#00a2ff, 0.50); - box-shadow: 0 4px 12px -8px alpha(#00a2ff, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); + background: alpha(@unixnotis-accent, 0.16); + border-color: alpha(@unixnotis-accent, 0.38); + color: alpha(#ffffff, 0.95); +} + +.unixnotis-popup-action-overflow { + min-width: 32px; + padding-left: 7px; + padding-right: 7px; +} + +.unixnotis-popup-action-overflow-list { + padding: 6px; +} + +/* Inline reply: recessed entry with a quiet focus ring */ +.unixnotis-popup-inline-reply { + margin-top: 10px; +} + +.unixnotis-popup-reply-entry { + min-height: 32px; + border-radius: 9px; + padding-left: 10px; + padding-right: 10px; + background: alpha(#0a0f1f, 0.45); + border: 1px solid alpha(#ffffff, 0.10); + box-shadow: inset 0 1px 2px alpha(#000000, 0.35); + transition: border-color 0.15s ease-out, box-shadow 0.15s ease-out; +} + +.unixnotis-popup-reply-entry:hover { + border-color: alpha(#ffffff, 0.16); +} + +.unixnotis-popup-reply-entry:focus { + border-color: alpha(@unixnotis-accent, 0.50); + box-shadow: + inset 0 1px 2px alpha(#000000, 0.35), + 0 0 0 2px alpha(@unixnotis-accent, 0.14); +} + +.unixnotis-popup-reply-error { + color: alpha(#fb7185, 0.90); + font-size: 11px; + margin-top: 3px; +} + +/* Critical state: a quiet rose-tinted glass, no glow. Composes after the + * ordinary card and interaction rules. */ +.unixnotis-popup-card.critical { + background-image: linear-gradient( + 180deg, + alpha(#3a2430, 0.88) 0%, + alpha(#23161f, 0.92) 100% + ); + border: 1px solid alpha(@unixnotis-critical-border, 0.42); + box-shadow: + inset 0 1px 0 alpha(#ffffff, 0.08), + inset 0 2px 0 alpha(#ffffff, 0.03), + inset 0 -1px 0 alpha(#000000, 0.22), + 0 1px 2px -1px alpha(#000000, 0.40), + 0 16px 32px -18px alpha(#000000, 0.65); +} + +.unixnotis-popup-card.critical .unixnotis-popup-app-name { + color: @unixnotis-critical-text; +} + +.unixnotis-popup-card.critical .unixnotis-popup-application-icon-slot, +.unixnotis-popup-card.critical .unixnotis-popup-icon { + color: @unixnotis-critical-icon; +} + +.unixnotis-popup-card.critical .unixnotis-popup-summary { + color: #ffffff; } -/* End of popup theme. */ +/* End of popup theme */ diff --git a/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 new file mode 100644 index 000000000..3468fd3fe --- /dev/null +++ b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-lib-v1 @@ -0,0 +1,104 @@ +#!/bin/sh +set -eu + +: "${UNIXNOTIS_BLUE_LIGHT_TEMP:=4500}" +: "${UNIXNOTIS_BLUE_LIGHT_GAMMA:=90}" + +has_backend() { + command -v "$1" >/dev/null 2>&1 +} + +backend_running() { + pgrep -x "$1" >/dev/null 2>&1 +} + +active_backend() { + # Prefer the backend already in charge so the toggle does not switch tools + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if backend_running "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +installed_backend() { + # Fall back to the first supported tool installed on the system + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if has_backend "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +selected_backend() { + active_backend || installed_backend +} + +stop_backend() { + case "$1" in + hyprsunset) + pkill -x hyprsunset >/dev/null 2>&1 || true + ;; + gammastep) + if has_backend gammastep; then + gammastep -x >/dev/null 2>&1 || true + fi + pkill -x gammastep >/dev/null 2>&1 || true + ;; + wlsunset) + pkill -x wlsunset >/dev/null 2>&1 || true + ;; + sunsetr) + if has_backend sunsetr; then + sunsetr stop >/dev/null 2>&1 || true + fi + pkill -x sunsetr >/dev/null 2>&1 || true + ;; + esac +} + +stop_conflicting_backends() { + active="$1" + + # Only one color-temperature process should own the display pipeline + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if [ "$candidate" != "$active" ] && backend_running "$candidate"; then + stop_backend "$candidate" + fi + done +} + +stop_active_backends() { + # Off stops every supported backend that is currently active + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if backend_running "$candidate"; then + stop_backend "$candidate" + fi + done +} + +start_backend() { + case "$1" in + hyprsunset) + nohup hyprsunset --temperature "$UNIXNOTIS_BLUE_LIGHT_TEMP" >/dev/null 2>&1 & + ;; + gammastep) + nohup gammastep -m wayland -l 0:0 -t "$UNIXNOTIS_BLUE_LIGHT_TEMP:$UNIXNOTIS_BLUE_LIGHT_TEMP" -P >/dev/null 2>&1 & + ;; + wlsunset) + nohup wlsunset -t "$UNIXNOTIS_BLUE_LIGHT_TEMP" -T "$UNIXNOTIS_BLUE_LIGHT_TEMP" -l 0 -L 0 >/dev/null 2>&1 & + ;; + sunsetr) + nohup sunsetr test "$UNIXNOTIS_BLUE_LIGHT_TEMP" "$UNIXNOTIS_BLUE_LIGHT_GAMMA" >/dev/null 2>&1 & + ;; + *) + return 127 + ;; + esac +} diff --git a/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 new file mode 100644 index 000000000..207ce0ccc --- /dev/null +++ b/crates/unixnotis-core/assets/scripts/legacy/unixnotis-blue-light-on-v1 @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +# shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +. "$script_dir/unixnotis-blue-light-lib" + +# Keep the user's active backend when possible, otherwise use the first installed one +backend=$(selected_backend) +stop_conflicting_backends "$backend" +stop_backend "$backend" +start_backend "$backend" diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib index 49a0f8ee1..02313ebae 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib @@ -3,9 +3,7 @@ set -eu : "${UNIXNOTIS_BLUE_LIGHT_TEMP:=4500}" : "${UNIXNOTIS_BLUE_LIGHT_GAMMA:=90}" - -STATE_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/unixnotis" -STATE_FILE="$STATE_DIR/blue-light-backend" +: "${UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY:=0.2}" has_backend() { command -v "$1" >/dev/null 2>&1 @@ -43,47 +41,40 @@ selected_backend() { active_backend || installed_backend } -remember_backend() { - # Runtime state keeps off clicks paired with the backend started by on - mkdir -p "$STATE_DIR" - printf '%s\n' "$1" > "$STATE_FILE" -} +terminate_backend() { + backend="$1" + pkill -x "$backend" >/dev/null 2>&1 || return 0 -remembered_backend() { - if [ -r "$STATE_FILE" ]; then - read -r backend < "$STATE_FILE" - if [ "$backend" != "" ]; then - printf '%s\n' "$backend" - return 0 - fi - fi - - return 1 -} + # A short grace period lets the backend restore display state before refresh runs + attempts=0 + while backend_running "$backend" && [ "$attempts" -lt 10 ]; do + sleep 0.05 + attempts=$((attempts + 1)) + done -forget_backend() { - rm -f "$STATE_FILE" + # A stuck backend must not keep fighting the replacement selected by the toggle + if backend_running "$backend"; then + pkill -KILL -x "$backend" >/dev/null 2>&1 || true + fi } stop_backend() { case "$1" in hyprsunset) - pkill -x hyprsunset >/dev/null 2>&1 || true + terminate_backend hyprsunset ;; gammastep) - if has_backend gammastep; then - gammastep -x >/dev/null 2>&1 || true - fi - pkill -x gammastep >/dev/null 2>&1 || true + # SIGTERM lets the running backend restore its own gamma ramps + terminate_backend gammastep ;; wlsunset) - pkill -x wlsunset >/dev/null 2>&1 || true + terminate_backend wlsunset ;; sunsetr) if has_backend sunsetr; then sunsetr stop >/dev/null 2>&1 || true fi - pkill -x sunsetr >/dev/null 2>&1 || true + terminate_backend sunsetr ;; esac } @@ -100,7 +91,7 @@ stop_conflicting_backends() { } stop_active_backends() { - # Off should clean stale state and any active supported backend + # Off stops every supported backend that is currently active for candidate in hyprsunset gammastep wlsunset sunsetr; do if backend_running "$candidate"; then stop_backend "$candidate" @@ -127,3 +118,37 @@ start_backend() { ;; esac } + +start_backend_if_healthy() { + candidate="$1" + + # Clear a stale instance before asking one candidate to own the display + stop_backend "$candidate" + start_backend "$candidate" + sleep "$UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY" + + # A tool that exits during startup is unavailable even when it exists on PATH + if backend_running "$candidate"; then + stop_conflicting_backends "$candidate" + return 0 + fi + + stop_backend "$candidate" + return 1 +} + +start_available_backend() { + # Preserve a working backend instead of changing tools on every click + if candidate=$(active_backend); then + start_backend_if_healthy "$candidate" && return 0 + fi + + # Broken packages and unsupported compositors fall through to the next tool + for candidate in hyprsunset gammastep wlsunset sunsetr; do + if has_backend "$candidate" && start_backend_if_healthy "$candidate"; then + return 0 + fi + done + + return 1 +} diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off index 2fad37b95..e14822531 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-off @@ -5,9 +5,4 @@ script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) # shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib . "$script_dir/unixnotis-blue-light-lib" -if backend=$(remembered_backend); then - stop_backend "$backend" - forget_backend -fi - stop_active_backends diff --git a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on index 62c50a6b9..08a3f5341 100755 --- a/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on +++ b/crates/unixnotis-core/assets/scripts/unixnotis-blue-light-on @@ -5,9 +5,5 @@ script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) # shellcheck source=crates/unixnotis-core/assets/scripts/unixnotis-blue-light-lib . "$script_dir/unixnotis-blue-light-lib" -# Keep the user's active backend when possible, otherwise use the first installed one -backend=$(selected_backend) -stop_conflicting_backends "$backend" -stop_backend "$backend" -start_backend "$backend" -remember_backend "$backend" +# Keep a healthy active backend, then fall through installed tools that fail to start +start_available_backend diff --git a/crates/unixnotis-core/assets/widgets.css b/crates/unixnotis-core/assets/widgets.css index 8c2bf6990..be699f153 100644 --- a/crates/unixnotis-core/assets/widgets.css +++ b/crates/unixnotis-core/assets/widgets.css @@ -3,63 +3,38 @@ * Quick controls, toggles, stats, info cards, and media carousel */ .unixnotis-quick-controls { - background-image: linear-gradient(155deg, alpha(#11182a, 0.98), alpha(#17162b, 0.96)); - border: 1px solid alpha(#a9b8d4, 0.16); - border-radius: 14px; - padding: 4px 10px; + background: alpha(#ffffff, 0.02); + border-top: 1px solid alpha(#ffffff, 0.06); + border-left: 1px solid alpha(#ffffff, 0.05); + border-right: 1px solid alpha(#ffffff, 0.03); + border-bottom: 1px solid alpha(#ffffff, 0.02); + border-radius: 16px; + padding: 8px 14px; margin-bottom: 10px; - box-shadow: - 0 12px 28px -24px alpha(#000000, 0.9), - inset 0 1px 0 alpha(#ffffff, 0.045); -} - -.unixnotis-quick-slider { - background-image: linear-gradient(160deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.97)); - border-radius: 18px; - border-radius: var(--unixnotis-quick-slider-radius); - padding: 8px 12px; - padding: var(--unixnotis-quick-slider-padding-y) var(--unixnotis-quick-slider-padding-x); - border: 1px solid alpha(@unixnotis-accent, 0.26); - box-shadow: - 0 12px 24px -20px @unixnotis-shadow-soft, - 0 0 18px -14px @unixnotis-glow-cyan, - 0 0 18px -16px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.06), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 0 0 1px alpha(#ffffff, 0.06); -} - -.unixnotis-quick-slider:hover { - border-color: alpha(@unixnotis-accent, 0.45); - box-shadow: - 0 16px 26px -20px @unixnotis-shadow-soft, - 0 0 22px -14px @unixnotis-glow-cyan, - 0 0 22px -16px @unixnotis-glow-pink, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.26), - inset 0 0 0 1px alpha(#ffffff, 0.08); -} - -.unixnotis-quick-slider-volume { - border-color: alpha(@unixnotis-accent, 0.35); + box-shadow: 0 4px 16px -8px alpha(#000000, 0.5); } +.unixnotis-quick-slider, +.unixnotis-quick-slider-volume, .unixnotis-quick-slider-brightness { - border-color: alpha(@unixnotis-accent-2, 0.35); + background: transparent; + border-radius: var(--unixnotis-quick-slider-radius); + padding: 4px 6px; + border: none; + box-shadow: none; + margin: 0; } .unixnotis-quick-slider-icon { - background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.65)); + background: transparent; border-radius: 999px; - border: 1px solid alpha(@unixnotis-accent, 0.3); + border: 0; padding: 4px; - min-width: 32px; min-width: var(--unixnotis-quick-slider-icon-size); - min-height: 32px; min-height: var(--unixnotis-quick-slider-icon-size); - box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - inset 0 0 0 1px alpha(#ffffff, 0.05); + box-shadow: none; + color: alpha(#ffffff, 0.65); + transition: color 0.15s ease-out; } .unixnotis-quick-slider-icon:hover { @@ -68,20 +43,23 @@ } .unixnotis-quick-slider-value { - color: @unixnotis-muted; - font-size: 12px; - letter-spacing: 0.06em; + color: alpha(#ffffff, 0.7); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; min-width: 42px; /* Alignment is controlled by the widget code (set_xalign) for GTK compatibility. */ font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; + transition: color 0.15s ease-out; } .unixnotis-quick-slider-scale trough { - background-image: linear-gradient(90deg, alpha(#000000, 0.3), alpha(#000000, 0.15)); + background: alpha(#000000, 0.4); border-radius: 999px; - min-height: 6px; - border: 1px solid alpha(#ffffff, 0.05); + min-height: 4px; + border: none; + box-shadow: none; } .unixnotis-quick-slider-scale highlight { @@ -91,19 +69,15 @@ } .unixnotis-quick-slider-scale slider { - /* Knob keeps the "neon" look by mixing both accents. */ - background-image: linear-gradient(140deg, alpha(@unixnotis-accent-2, 0.92), alpha(@unixnotis-accent, 0.66)); + background-image: linear-gradient(135deg, #ffffff 30%, #e2e8f0 100%); border-radius: 999px; - min-width: 16px; - min-width: var(--unixnotis-quick-slider-knob-size); - min-height: 16px; - min-height: var(--unixnotis-quick-slider-knob-size); - border: 1px solid alpha(@unixnotis-accent, 0.6); - margin: 0; + min-width: 12px; + min-height: 12px; + border: 1px solid alpha(#000000, 0.15); + margin: -4px 0; padding: 0; - box-shadow: - 0 6px 12px -10px @unixnotis-shadow-soft, - 0 0 10px -6px @unixnotis-glow-pink; + box-shadow: 0 1.5px 3.5px alpha(#000000, 0.40); + transition: border-color 0.15s ease-out; } /* @@ -117,129 +91,35 @@ padding: 0; } -.unixnotis-toggle { - /* Base toggle: "glass pill" with a consistent outline and subtle 3D depth. */ - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-radius: 18px; - border-radius: calc(var(--unixnotis-quick-slider-radius)); - padding: 10px 12px; +.unixnotis-toggle, +.unixnotis-toggle.unixnotis-toggle-kind-wifi, +.unixnotis-toggle.unixnotis-toggle-kind-bluetooth, +.unixnotis-toggle.unixnotis-toggle-kind-airplane, +.unixnotis-toggle.unixnotis-toggle-kind-night { + background: alpha(#ffffff, 0.045); padding: var(--unixnotis-toggle-padding-y) var(--unixnotis-toggle-padding-x); - border: 1px solid alpha(@unixnotis-outline, 0.9); - min-height: 56px; + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: var(--unixnotis-toggle-min-height); - min-width: 104px; min-width: var(--unixnotis-toggle-min-width); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent, 0.10), - 0 0 20px -18px alpha(@unixnotis-accent, 0.12), - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.45); -} - -/* Kind-specific toggle accents - * - * The center UI assigns a stable class: `.unixnotis-toggle-kind-` - * These overrides provide a distinct accent per control while preserving the - * same layout and interaction behavior. */ -.unixnotis-toggle.unixnotis-toggle-kind-wifi { - border-color: alpha(@unixnotis-accent-wifi, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-wifi, 0.14), - 0 0 20px -18px @unixnotis-glow-wifi, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth { - border-color: alpha(@unixnotis-accent-bluetooth, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-bluetooth, 0.14), - 0 0 20px -18px @unixnotis-glow-bluetooth, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-airplane { - border-color: alpha(@unixnotis-accent-airplane, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-airplane, 0.14), - 0 0 20px -18px @unixnotis-glow-airplane, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 -1px 0 alpha(#000000, 0.22); -} - -.unixnotis-toggle.unixnotis-toggle-kind-night { - border-color: alpha(@unixnotis-accent-night, 0.55); - box-shadow: - 0 16px 28px -22px alpha(#000000, 0.40), - 0 0 0 1px alpha(@unixnotis-accent-night, 0.14), - 0 0 20px -18px @unixnotis-glow-night, - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.45); -} - -.unixnotis-toggle:hover { - border-color: alpha(@unixnotis-accent, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 22px -18px alpha(@unixnotis-accent, 0.14), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); + box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); + color: alpha(#ffffff, 0.7); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out; } -.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover { - border-color: alpha(@unixnotis-accent-wifi, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-wifi, - 0 0 0 1px alpha(@unixnotis-accent-wifi, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover { - border-color: alpha(@unixnotis-accent-bluetooth, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-bluetooth, - 0 0 0 1px alpha(@unixnotis-accent-bluetooth, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} -.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover { - border-color: alpha(@unixnotis-accent-airplane, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-airplane, - 0 0 0 1px alpha(@unixnotis-accent-airplane, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); -} +.unixnotis-toggle:hover, +.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover, +.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover, +.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover, .unixnotis-toggle.unixnotis-toggle-kind-night:hover { - border-color: alpha(@unixnotis-accent-night, 0.62); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.44), - 0 0 24px -18px @unixnotis-glow-night, - 0 0 0 1px alpha(@unixnotis-accent-night, 0.22), - inset 0 1px 0 alpha(#ffffff, 0.08), - inset 0 2px 4px -3px alpha(#ffffff, 0.16), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.48); + background: alpha(#ffffff, 0.08); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + color: #ffffff; } .unixnotis-toggle:checked { @@ -257,35 +137,7 @@ inset 0 1px 0 alpha(#ffffff, 0.12), inset 0 2px 4px -3px alpha(#ffffff, 0.18), inset 0 -2px 6px -5px alpha(#000000, 0.38); -} - -.unixnotis-toggle.unixnotis-toggle-kind-wifi:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-wifi, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-wifi, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-bluetooth, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-bluetooth, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-airplane:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-airplane, 0.18), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-airplane, 0.75); -} -.unixnotis-toggle.unixnotis-toggle-kind-night:checked { - background-image: - radial-gradient(circle at 22% 20%, alpha(@unixnotis-accent-night, 0.2), transparent 64%), - radial-gradient(circle at 88% 92%, alpha(@unixnotis-accent-2, 0.12), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.94), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-night, 0.75); + color: #ffffff; } /* Keep outlines and depth visible when the panel is unfocused (GTK backdrop). */ @@ -320,8 +172,8 @@ -gtk-icon-palette: success @unixnotis-accent, warning @unixnotis-accent, error @unixnotis-accent; min-width: 24px; min-height: 24px; - /* Icons inherit widget state; base color uses the cyan accent for clarity. */ - color: @unixnotis-accent; + color: alpha(#ffffff, 0.65); + transition: color 0.15s ease-out; } .unixnotis-toggle-label { @@ -346,24 +198,25 @@ .unixnotis-stat-icon { background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.7)); - border-radius: 10px; - padding: 4px; + border-radius: 8px; + padding: 5px; border: 1px solid alpha(@unixnotis-outline, 0.5); - box-shadow: - 0 8px 16px -14px alpha(#000000, 0.45), - inset 0 0 0 1px alpha(#ffffff, 0.06); + min-width: 24px; + min-height: 24px; + box-shadow: none; color: @unixnotis-accent; + transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; } -.unixnotis-info-icon { - background-image: linear-gradient(150deg, alpha(@unixnotis-surface, 0.9), alpha(@unixnotis-surface-soft, 0.7)); - border-radius: 12px; +.unixnotis-info-icon, +.unixnotis-info-icon-weather { + background: alpha(#ffffff, 0.04); + border-radius: 7px; padding: 6px; - border: 1px solid alpha(@unixnotis-outline, 0.5); - box-shadow: - 0 8px 16px -14px alpha(#000000, 0.45), - inset 0 0 0 1px alpha(#ffffff, 0.06); - color: @unixnotis-accent-2; + border: 1px solid alpha(#ffffff, 0.04); + box-shadow: none; + color: alpha(#ffffff, 0.6); + transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; } /* @@ -378,37 +231,24 @@ } .unixnotis-stat-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.95), alpha(@unixnotis-surface, 0.98)); - border-radius: 18px; - border-radius: var(--unixnotis-stat-card-radius); - padding: 10px 12px; + background: alpha(#ffffff, 0.035); padding: var(--unixnotis-stat-card-padding-y) var(--unixnotis-stat-card-padding-x); - /* Keep the outline always visible so the stat grid reads as "cards" - * even when not hovered. */ - border: 1px solid alpha(@unixnotis-outline, 0.88); - min-height: 56px; + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: var(--unixnotis-stat-card-min-height); - box-shadow: - 0 18px 30px -22px alpha(#000000, 0.35), - 0 0 0 1px alpha(@unixnotis-accent, 0.1), - 0 0 22px -18px alpha(@unixnotis-accent, 0.16), - inset 0 1px 0 alpha(#ffffff, 0.06), - inset 0 2px 4px -3px alpha(#ffffff, 0.12), - inset 0 -1px 0 alpha(#000000, 0.22), - inset 0 -3px 6px -5px alpha(#000000, 0.38), - inset 0 0 0 1px alpha(#ffffff, 0.05); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-stat-card:hover { - border-color: alpha(@unixnotis-accent, 0.45); - box-shadow: - 0 20px 32px -24px alpha(#000000, 0.4), - 0 0 24px -20px alpha(@unixnotis-accent, 0.18), - inset 0 1px 0 alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -1px 0 alpha(#000000, 0.24), - inset 0 -3px 6px -5px alpha(#000000, 0.42), - inset 0 0 0 1px alpha(#ffffff, 0.06); + background: alpha(#ffffff, 0.06); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + box-shadow: 0 8px 20px -12px alpha(#000000, 0.6); } .unixnotis-stat-card:backdrop { @@ -422,10 +262,11 @@ } .unixnotis-stat-title { - font-size: 12px; + font-size: 9px; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; - color: @unixnotis-muted; + letter-spacing: 0.10em; + color: alpha(#ffffff, 0.45); } .unixnotis-stat-card-plugin .unixnotis-stat-title { @@ -437,8 +278,9 @@ } .unixnotis-stat-value { - font-size: 15px; - font-weight: 600; + color: #ffffff; + font-size: 13px; + font-weight: 700; } /* @@ -452,30 +294,26 @@ padding: 0; } -.unixnotis-info-card { - background-image: linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.98)); - border-radius: 22px; - border-radius: var(--unixnotis-info-card-radius); - padding: 12px; +.unixnotis-info-card, +.unixnotis-info-card-weather { + background: alpha(#ffffff, 0.035); padding: var(--unixnotis-info-card-padding); - border: 1px solid alpha(@unixnotis-outline, 0.7); - min-height: 56px; + border-top: 1px solid alpha(#ffffff, 0.08); + border-left: 1px solid alpha(#ffffff, 0.06); + border-right: 1px solid alpha(#ffffff, 0.04); + border-bottom: 1px solid alpha(#ffffff, 0.02); min-height: var(--unixnotis-info-card-min-height); - box-shadow: - 0 20px 32px -24px alpha(#000000, 0.4), - 0 0 0 1px alpha(@unixnotis-accent-2, 0.1), - inset 0 0 0 1px alpha(#ffffff, 0.06), - inset 0 2px 4px -3px alpha(#ffffff, 0.12), - inset 0 -3px 6px -5px alpha(#000000, 0.35); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); + transition: background-color 0.15s ease-out, border-color 0.15s ease-out; } .unixnotis-info-card:hover { - border-color: alpha(@unixnotis-accent-2, 0.45); - box-shadow: - 0 22px 36px -24px alpha(#000000, 0.4), - inset 0 0 0 1px alpha(#ffffff, 0.07), - inset 0 2px 4px -3px alpha(#ffffff, 0.14), - inset 0 -3px 6px -5px alpha(#000000, 0.38); + background: alpha(#ffffff, 0.06); + border-top: 1px solid alpha(#ffffff, 0.12); + border-left: 1px solid alpha(#ffffff, 0.10); + border-right: 1px solid alpha(#ffffff, 0.06); + border-bottom: 1px solid alpha(#ffffff, 0.04); + box-shadow: 0 6px 14px -6px alpha(#000000, 0.6); } .unixnotis-info-card:backdrop { @@ -488,9 +326,11 @@ } .unixnotis-info-title { - font-size: 13px; + color: alpha(#ffffff, 0.45); + font-size: 9px; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.10em; + text-transform: uppercase; } .unixnotis-info-body { @@ -499,12 +339,11 @@ } .unixnotis-info-card-mono .unixnotis-info-body { - font-family: "CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace; font-family: var(--unixnotis-monospace-font-family); } .unixnotis-info-card-calendar .unixnotis-info-title { - color: alpha(@unixnotis-accent, 0.88); + color: alpha(#ffffff, 0.6); } /* @@ -513,27 +352,19 @@ * Purpose: make the calendar read as a premium card while keeping retro cues */ .unixnotis-calendar { - background-image: - radial-gradient(circle at 14% 18%, alpha(@unixnotis-accent, 0.16), transparent 55%), - linear-gradient(160deg, alpha(@unixnotis-card, 0.96), alpha(@unixnotis-surface, 0.98)); - border: 1px solid alpha(@unixnotis-outline, 0.6); - border-radius: 18px; - border-radius: var(--unixnotis-calendar-radius); - padding: 10px 12px; + background: alpha(#ffffff, 0.02); + border: 1px solid alpha(#ffffff, 0.06); + border-radius: 12px; padding: var(--unixnotis-info-card-padding); color: @unixnotis-text; - box-shadow: - 0 18px 28px -22px alpha(#000000, 0.6), - 0 0 18px -12px @unixnotis-glow-cyan, - inset 0 0 0 1px alpha(#ffffff, 0.05), - inset 0 -8px 16px -14px alpha(#000000, 0.5); + box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); } .unixnotis-calendar button { /* Calendar nav controls are custom-styled so the arrow icons remain visible. */ - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg, 0.8), alpha(@unixnotis-surface-strong, 0.9)); + background: transparent; border-radius: 999px; - border: 1px solid alpha(@unixnotis-outline, 0.7); + border: 0; padding: 3px; min-width: 22px; min-height: 22px; @@ -542,15 +373,14 @@ -gtk-icon-style: symbolic; -gtk-icon-shadow: 0 0 6px alpha(@unixnotis-accent, 0.45); -gtk-icon-palette: success @unixnotis-accent, warning @unixnotis-accent, error @unixnotis-accent; - box-shadow: - 0 8px 14px -12px alpha(#000000, 0.6), - inset 0 0 0 1px alpha(#ffffff, 0.06); + box-shadow: none; } .unixnotis-calendar button:hover { - background-image: linear-gradient(160deg, alpha(@unixnotis-action-bg-hover, 0.78), alpha(@unixnotis-accent, 0.28)); - border-color: alpha(@unixnotis-accent, 0.7); + background: alpha(#ffffff, 0.08); + border: 0; color: @unixnotis-accent; + box-shadow: none; } .unixnotis-calendar button:active { @@ -655,37 +485,26 @@ .unixnotis-calendar:selected, .unixnotis-calendar .day-number:selected { - background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.85), alpha(@unixnotis-accent-2, 0.75)); - color: #0b1020; + background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.25), alpha(@unixnotis-accent, 0.15)); + border: 1px solid alpha(@unixnotis-accent, 0.60); + color: #ffffff; border-radius: 8px; - box-shadow: - 0 0 0 1px alpha(@unixnotis-accent, 0.5), - 0 10px 16px -12px alpha(@unixnotis-accent, 0.7); + box-shadow: 0 0 12px -2px alpha(@unixnotis-accent, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); } .unixnotis-calendar .day-number.today { - box-shadow: - inset 0 0 0 1px alpha(@unixnotis-accent-2, 0.45), - 0 0 12px -10px @unixnotis-glow-pink; + background: alpha(#ffffff, 0.08); + color: @unixnotis-text; + border: 1px solid alpha(#ffffff, 0.20); + box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05); border-radius: 8px; } -.unixnotis-info-card-weather { - background-image: - radial-gradient(circle at 20% 20%, alpha(@unixnotis-accent-2, 0.18), transparent 60%), - linear-gradient(165deg, alpha(@unixnotis-surface-soft, 0.92), alpha(@unixnotis-surface, 0.98)); - border-color: alpha(@unixnotis-accent-2, 0.45); - box-shadow: - 0 22px 36px -24px alpha(#000000, 0.5), - 0 0 0 1px alpha(@unixnotis-accent-2, 0.12), - inset 0 0 0 1px alpha(#ffffff, 0.07); -} - .unixnotis-info-card-weather .unixnotis-info-title { font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; - color: @unixnotis-muted; + color: alpha(#ffffff, 0.6); } .unixnotis-info-card-weather .unixnotis-info-body { @@ -697,14 +516,6 @@ min-height: 18px; } -.unixnotis-info-icon-weather { - background-image: linear-gradient(150deg, alpha(@unixnotis-accent-2, 0.35), alpha(@unixnotis-accent, 0.2)); - border-color: alpha(@unixnotis-accent-2, 0.6); - color: @unixnotis-accent-2; - box-shadow: - 0 12px 18px -14px alpha(@unixnotis-accent-2, 0.6), - inset 0 0 0 1px alpha(#ffffff, 0.1); -} /* Compact density trims shell padding without shrinking interactive targets below 36px */ .unixnotis-widget-density-compact .unixnotis-quick-slider { padding: 8px 10px; @@ -723,51 +534,11 @@ * * Repeated widget types share one surface treatment and reserve color for state */ -.unixnotis-quick-controls { - background: alpha(#ffffff, 0.02); - border-top: 1px solid alpha(#ffffff, 0.06); - border-left: 1px solid alpha(#ffffff, 0.05); - border-right: 1px solid alpha(#ffffff, 0.03); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - padding: 8px 14px; - margin-bottom: 10px; - box-shadow: 0 4px 16px -8px alpha(#000000, 0.5); -} - -.unixnotis-quick-slider, -.unixnotis-quick-slider-volume, -.unixnotis-quick-slider-brightness { - background: transparent; - border: none; - border-color: transparent; - box-shadow: none; - padding: 4px 6px; - margin: 0; -} - -.unixnotis-quick-slider:hover, -.unixnotis-quick-slider-volume:hover, -.unixnotis-quick-slider-brightness:hover { - background: transparent; - border: none; - border-color: transparent; - box-shadow: none; -} - -.unixnotis-quick-slider-icon { - background: transparent; - border: 0; - box-shadow: none; - color: alpha(#ffffff, 0.65); - transition: color 0.15s ease-out; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-icon { color: alpha(#ffffff, 0.65); } -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-icon { +.unixnotis-quick-slider-volume .unixnotis-quick-slider-icon:hover { color: #00f2fe; } @@ -775,35 +546,10 @@ color: alpha(#ffffff, 0.65); } -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-icon { +.unixnotis-quick-slider-brightness .unixnotis-quick-slider-icon:hover { color: #ffb86b; } -.unixnotis-quick-slider-value { - color: alpha(#ffffff, 0.7); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.02em; - transition: color 0.15s ease-out; -} - -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-value { - color: #ffffff; -} - -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-value { - color: #ffffff; -} - -.unixnotis-quick-slider-scale trough { - background: alpha(#000000, 0.4); - border-top: 1px solid alpha(#ffffff, 0.05); - border-radius: 999px; - min-height: 4px; - border: none; - box-shadow: none; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale highlight { background-image: linear-gradient(90deg, #3b82f6, #00a2ff); } @@ -812,18 +558,6 @@ background-image: linear-gradient(90deg, #d97706, #ff9f0a); } -.unixnotis-quick-slider-scale slider { - background-image: linear-gradient(135deg, #ffffff 30%, #e2e8f0 100%); - border: 1px solid alpha(#000000, 0.15); - border-radius: 999px; - min-width: 12px; - min-height: 12px; - margin-top: -4px; /* Center 12px knob over 4px trough */ - margin-bottom: -4px; - box-shadow: 0 1.5px 3.5px alpha(#000000, 0.40); - transition: min-width 0.15s ease-out, min-height 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; -} - .unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider { border-color: alpha(#00a2ff, 0.6); } @@ -832,16 +566,12 @@ border-color: alpha(#ff9f0a, 0.6); } -.unixnotis-quick-slider-scale slider:hover { - transform: scale(1.10); -} - -.unixnotis-quick-slider-volume:hover slider { +.unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider:hover { border-color: #00a2ff; box-shadow: 0 0 6px alpha(#00a2ff, 0.5), 0 1.5px 3.5px alpha(#000000, 0.40); } -.unixnotis-quick-slider-brightness:hover slider { +.unixnotis-quick-slider-brightness .unixnotis-quick-slider-scale slider:hover { border-color: #ff9f0a; box-shadow: 0 0 6px alpha(#ff9f0a, 0.5), 0 1.5px 3.5px alpha(#000000, 0.40); } @@ -859,7 +589,7 @@ min-width: 4px; border-radius: 999px 999px 0 0; margin: 0 1.5px; - transition: background-color 0.15s ease-out, box-shadow 0.15s ease-out, min-height 0.15s ease-out; + transition: background-color 0.15s ease-out; } .unixnotis-quick-slider-segment:nth-child(1) { min-height: 2px; } @@ -900,105 +630,52 @@ transition: color 0.15s ease-out; } -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel-min, -.unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel-max { - color: alpha(#00a2ff, 0.7); -} - -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel-min, -.unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel-max { - color: alpha(#ff9f0a, 0.7); -} - -.unixnotis-toggle, -.unixnotis-toggle.unixnotis-toggle-kind-wifi, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth, -.unixnotis-toggle.unixnotis-toggle-kind-airplane, -.unixnotis-toggle.unixnotis-toggle-kind-night { - background: alpha(#ffffff, 0.045); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 14px; - box-shadow: 0 2px 6px -4px alpha(#000000, 0.4); - color: alpha(#ffffff, 0.7); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, color 0.15s ease-out, transform 0.15s ease-out; -} - -.unixnotis-toggle:hover, -.unixnotis-toggle.unixnotis-toggle-kind-wifi:hover, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:hover, -.unixnotis-toggle.unixnotis-toggle-kind-airplane:hover, -.unixnotis-toggle.unixnotis-toggle-kind-night:hover { - background: alpha(#ffffff, 0.08); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - color: #ffffff; - transform: translateY(-0.5px); -} - -.unixnotis-toggle:checked, -.unixnotis-toggle.unixnotis-toggle-kind-wifi:checked, -.unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked, -.unixnotis-toggle.unixnotis-toggle-kind-airplane:checked, -.unixnotis-toggle.unixnotis-toggle-kind-night:checked { - color: #ffffff; -} - .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked { background-image: linear-gradient(135deg, #00b4db, #0083b0); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#0083b0, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-wifi:checked:hover { background-image: linear-gradient(135deg, #00c6ff, #0072ff); box-shadow: 0 8px 20px -6px alpha(#0072ff, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked { background-image: linear-gradient(135deg, #3b82f6, #1d4ed8); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#1d4ed8, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-bluetooth:checked:hover { background-image: linear-gradient(135deg, #60a5fa, #2563eb); box-shadow: 0 8px 20px -6px alpha(#2563eb, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-airplane:checked { background-image: linear-gradient(135deg, #f59e0b, #d97706); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#d97706, 0.5), inset 0 1px 0 alpha(#ffffff, 0.15); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-airplane:checked:hover { background-image: linear-gradient(135deg, #fbbf24, #b45309); box-shadow: 0 8px 20px -6px alpha(#b45309, 0.6), inset 0 1px 0 alpha(#ffffff, 0.20); - transform: translateY(-1px); } .unixnotis-toggle.unixnotis-toggle-kind-night:checked { background-image: linear-gradient(135deg, #8b5cf6, #6d28d9); border: 1px solid alpha(#ffffff, 0.15); box-shadow: 0 6px 16px -8px alpha(#6d28d9, 0.5), inset 0 1px 0 alpha(#ffffff, 0.12); + color: #ffffff; } .unixnotis-toggle.unixnotis-toggle-kind-night:checked:hover { background-image: linear-gradient(135deg, #a78bfa, #5b21b6); box-shadow: 0 8px 20px -6px alpha(#5b21b6, 0.6), inset 0 1px 0 alpha(#ffffff, 0.18); - transform: translateY(-1px); -} - -.unixnotis-toggle-icon { - color: alpha(#ffffff, 0.65); - transition: color 0.15s ease-out; } .unixnotis-toggle:hover .unixnotis-toggle-icon { @@ -1009,36 +686,6 @@ color: #ffffff; } -.unixnotis-stat-card { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 14px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; -} - -.unixnotis-stat-card:hover { - background: alpha(#ffffff, 0.06); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: 0 8px 20px -12px alpha(#000000, 0.6); - transform: translateY(-1.5px); -} - -.unixnotis-stat-icon { - min-width: 24px; - min-height: 24px; - padding: 5px; - border-radius: 8px; - box-shadow: none; - transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; -} - /* CPU Stat Style (Emerald Green) */ .unixnotis-stat-kind-cpu .unixnotis-stat-icon { background: alpha(#10b981, 0.08); @@ -1078,95 +725,8 @@ color: #fbbf24; } -.unixnotis-info-icon, -.unixnotis-info-icon-weather { - background: alpha(#ffffff, 0.04); - border: 1px solid alpha(#ffffff, 0.04); - border-radius: 7px; - box-shadow: none; - color: alpha(#ffffff, 0.6); - transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; -} - -.unixnotis-stat-title, -.unixnotis-info-title { - color: alpha(#ffffff, 0.45); - font-size: 9px; - font-weight: 700; - letter-spacing: 0.10em; - text-transform: uppercase; -} - -.unixnotis-stat-value { - color: #ffffff; - font-size: 13px; - font-weight: 700; -} - -.unixnotis-info-card, -.unixnotis-info-card-weather { - background: alpha(#ffffff, 0.035); - border-top: 1px solid alpha(#ffffff, 0.08); - border-left: 1px solid alpha(#ffffff, 0.06); - border-right: 1px solid alpha(#ffffff, 0.04); - border-bottom: 1px solid alpha(#ffffff, 0.02); - border-radius: 16px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); - transition: background-color 0.15s ease-out, border-color 0.15s ease-out, box-shadow 0.15s ease-out, transform 0.15s ease-out; -} - -.unixnotis-info-card:hover { - background: alpha(#ffffff, 0.06); - border-top: 1px solid alpha(#ffffff, 0.12); - border-left: 1px solid alpha(#ffffff, 0.10); - border-right: 1px solid alpha(#ffffff, 0.06); - border-bottom: 1px solid alpha(#ffffff, 0.04); - box-shadow: 0 6px 14px -6px alpha(#000000, 0.6); - transform: translateY(-0.5px); -} - .unixnotis-info-card:hover .unixnotis-info-icon { color: #ffffff; background: alpha(#ffffff, 0.12); border-color: alpha(#ffffff, 0.12); } - -.unixnotis-info-card-calendar .unixnotis-info-title, -.unixnotis-info-card-weather .unixnotis-info-title { - color: alpha(#ffffff, 0.6); -} - -.unixnotis-calendar { - background: alpha(#ffffff, 0.02); - border: 1px solid alpha(#ffffff, 0.06); - border-radius: 12px; - box-shadow: 0 4px 10px -8px alpha(#000000, 0.5); -} - -.unixnotis-calendar button { - background: transparent; - border: 0; - box-shadow: none; -} - -.unixnotis-calendar button:hover { - background: alpha(#ffffff, 0.08); - border: 0; - box-shadow: none; -} - -.unixnotis-calendar .day-number:selected { - background-image: linear-gradient(135deg, alpha(@unixnotis-accent, 0.25), alpha(@unixnotis-accent, 0.15)); - border: 1px solid alpha(@unixnotis-accent, 0.60); - color: #ffffff; - box-shadow: 0 0 12px -2px alpha(@unixnotis-accent, 0.4), inset 0 1px 0 alpha(#ffffff, 0.12); - border-radius: 8px; -} - -.unixnotis-calendar .day-number.today { - background: alpha(#ffffff, 0.08); - color: @unixnotis-text; - border: 1px solid alpha(#ffffff, 0.20); - box-shadow: inset 0 1px 0 alpha(#ffffff, 0.05); - border-radius: 8px; -} diff --git a/crates/unixnotis-core/src/bus_call.rs b/crates/unixnotis-core/src/bus_call.rs new file mode 100644 index 000000000..36ed7889c --- /dev/null +++ b/crates/unixnotis-core/src/bus_call.rs @@ -0,0 +1,33 @@ +//! Shared hard timeout for internal D-Bus method calls + +use std::future::Future; +use std::time::Duration; + +/// Maximum wait for one internal `UnixNotis` D-Bus method +pub const INTERNAL_DBUS_CALL_TIMEOUT: Duration = Duration::from_secs(2); + +/// Run one D-Bus method with the internal hard timeout +/// +/// # Errors +/// +/// Returns the method error or a timeout error when the call exceeds the limit +pub async fn timed_dbus_call(call: impl Future>) -> zbus::Result { + timed_dbus_call_with_timeout(INTERNAL_DBUS_CALL_TIMEOUT, call).await +} + +async fn timed_dbus_call_with_timeout( + timeout: Duration, + call: impl Future>, +) -> zbus::Result { + match tokio::time::timeout(timeout, call).await { + Ok(result) => result, + Err(_) => Err(zbus::Error::Failure(format!( + "UnixNotis D-Bus call timed out after {} seconds", + timeout.as_secs_f64() + ))), + } +} + +#[cfg(test)] +#[path = "tests/bus_call.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/bus_identity.rs b/crates/unixnotis-core/src/bus_identity.rs new file mode 100644 index 000000000..9371e8f71 --- /dev/null +++ b/crates/unixnotis-core/src/bus_identity.rs @@ -0,0 +1,50 @@ +//! Sanitized session-bus identity diagnostics shared by every process + +use tracing::info; +use zbus::fdo::DBusProxy; +use zbus::Connection; + +use crate::INTERNAL_DBUS_CALL_TIMEOUT; + +/// Stable identity assigned by one message-bus instance and connection +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionBusIdentity { + pub bus_id: String, + pub unique_name: String, + pub runtime_dir: String, +} + +/// Read and log a sanitized session-bus identity +/// +/// # Errors +/// +/// Returns an error when the bus identity probe fails, times out, or lacks a unique name +pub async fn log_session_bus_identity( + connection: &Connection, + component: &'static str, +) -> zbus::Result { + let dbus = DBusProxy::new(connection).await?; + let bus_id = tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, dbus.get_id()) + .await + .map_err(|_elapsed| { + zbus::Error::Failure("session bus identity probe timed out".to_string()) + })? + .map_err(zbus::Error::from)?; + let unique_name = connection + .unique_name() + .ok_or_else(|| zbus::Error::Failure("session bus has no unique name".to_string()))?; + let identity = SessionBusIdentity { + bus_id: bus_id.to_string(), + unique_name: unique_name.to_string(), + runtime_dir: std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(), + }; + + info!( + bus_id = %identity.bus_id, + unique_name = %identity.unique_name, + runtime_dir = %identity.runtime_dir, + component, + "connected to session bus" + ); + Ok(identity) +} diff --git a/crates/unixnotis-core/src/config/appearance/corners.rs b/crates/unixnotis-core/src/config/appearance/corners.rs new file mode 100644 index 000000000..754850b64 --- /dev/null +++ b/crates/unixnotis-core/src/config/appearance/corners.rs @@ -0,0 +1,25 @@ +//! Angled corner geometry shared by notification surfaces + +use serde::{Deserialize, Serialize}; + +/// Pixel cuts applied to the four corners of a rendered plate +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct CutCorners { + /// Diagonal cut measured from the top-left corner + pub top_left: u16, + /// Diagonal cut measured from the top-right corner + pub top_right: u16, + /// Diagonal cut measured from the bottom-right corner + pub bottom_right: u16, + /// Diagonal cut measured from the bottom-left corner + pub bottom_left: u16, +} + +impl CutCorners { + /// Return true when at least one corner needs path clipping + #[must_use] + pub const fn is_active(self) -> bool { + self.top_left != 0 || self.top_right != 0 || self.bottom_right != 0 || self.bottom_left != 0 + } +} diff --git a/crates/unixnotis-core/src/config/appearance/mod.rs b/crates/unixnotis-core/src/config/appearance/mod.rs index 2fd44deb7..cc55b7674 100644 --- a/crates/unixnotis-core/src/config/appearance/mod.rs +++ b/crates/unixnotis-core/src/config/appearance/mod.rs @@ -1,4 +1,5 @@ //! Theme values and safely resolved icon assets +pub(in crate::config) mod corners; pub(in crate::config) mod icon_assets; pub(in crate::config) mod theme; diff --git a/crates/unixnotis-core/src/config/appearance/tests/theme.rs b/crates/unixnotis-core/src/config/appearance/tests/theme.rs index da0991ae6..066e5f6b5 100644 --- a/crates/unixnotis-core/src/config/appearance/tests/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/tests/theme.rs @@ -1,4 +1,4 @@ -use super::ThemeConfig; +use super::{CutCorners, ThemeConfig}; #[test] fn default_theme_opacity_values_stay_within_css_alpha_bounds() { @@ -14,3 +14,34 @@ fn default_theme_opacity_values_stay_within_css_alpha_bounds() { assert!((0.0..=1.0).contains(&alpha)); } } + +#[test] +fn default_theme_keeps_notification_corner_clipping_disabled() { + let theme = ThemeConfig::default(); + + assert!(!theme.notification_corners.is_active()); +} + +#[test] +fn every_individual_cut_corner_enables_clipping() { + for corners in [ + CutCorners { + top_left: 1, + ..CutCorners::default() + }, + CutCorners { + top_right: 1, + ..CutCorners::default() + }, + CutCorners { + bottom_right: 1, + ..CutCorners::default() + }, + CutCorners { + bottom_left: 1, + ..CutCorners::default() + }, + ] { + assert!(corners.is_active()); + } +} diff --git a/crates/unixnotis-core/src/config/appearance/theme.rs b/crates/unixnotis-core/src/config/appearance/theme.rs index 93283dfd7..08bd8c47a 100644 --- a/crates/unixnotis-core/src/config/appearance/theme.rs +++ b/crates/unixnotis-core/src/config/appearance/theme.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use super::corners::CutCorners; + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct ThemeConfig { @@ -16,6 +18,8 @@ pub struct ThemeConfig { pub border_width: u8, /// Corner radius for notification cards (pixels). pub card_radius: u8, + /// True diagonal cuts applied to panel and popup notification cards + pub notification_corners: CutCorners, /// Base alpha for panel surfaces (0.0 - 1.0). pub surface_alpha: f32, /// Stronger alpha for panel surfaces (0.0 - 1.0). @@ -43,6 +47,8 @@ impl Default for ThemeConfig { border_width: 1, // Matches the default card radius used by the bundled theme. card_radius: 22, + // Square clipping preserves the existing rounded CSS presentation + notification_corners: CutCorners::default(), surface_alpha: 0.88, surface_strong_alpha: 0.96, card_alpha: 0.94, diff --git a/crates/unixnotis-core/src/config/command/defaults.rs b/crates/unixnotis-core/src/config/command/defaults.rs index a571058fb..2f0e17f3f 100644 --- a/crates/unixnotis-core/src/config/command/defaults.rs +++ b/crates/unixnotis-core/src/config/command/defaults.rs @@ -1,21 +1,54 @@ -//! Shared command templates for widget defaults and runtime migrations - -pub const WIFI_STATE_NMCLI: &str = "nmcli radio wifi"; -pub const WIFI_ON_NMCLI: &str = "nmcli radio wifi on"; -pub const WIFI_OFF_NMCLI: &str = "nmcli radio wifi off"; -pub const WIFI_WATCH_NMCLI: &str = "nmcli -t monitor"; - -pub const BLUETOOTH_STATE_BLUETOOTHCTL: &str = "bluetoothctl show"; -pub const BLUETOOTH_ON_BLUETOOTHCTL: &str = "bluetoothctl power on"; -pub const BLUETOOTH_OFF_BLUETOOTHCTL: &str = "bluetoothctl power off"; -// D-Bus monitoring keeps updates flowing without a controlling terminal -pub const BLUETOOTH_WATCH_DBUS: &str = "dbus-monitor --system type=signal,sender=org.bluez"; - -pub const AIRPLANE_STATE_CMD: &str = - "rfkill list all | awk '/Soft blocked:/ { seen=1; if ($3 != \"yes\") bad=1 } END { exit (seen && !bad) ? 0 : 1 }'"; -pub const AIRPLANE_ON_CMD: &str = "rfkill block all"; -pub const AIRPLANE_OFF_CMD: &str = "rfkill unblock all"; -pub const AIRPLANE_WATCH_CMD: &str = "udevadm monitor --udev --subsystem-match=rfkill"; +//! Shared typed command templates for widget defaults and runtime migrations + +use crate::CommandSpec; + +pub fn wifi_state() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi"]) +} + +pub fn wifi_on() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi", "on"]) +} + +pub fn wifi_off() -> CommandSpec { + CommandSpec::direct("nmcli", ["radio", "wifi", "off"]) +} + +pub fn wifi_watch() -> CommandSpec { + CommandSpec::direct("nmcli", ["-t", "monitor"]) +} + +pub fn bluetooth_state() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["show"]) +} + +pub fn bluetooth_on() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["power", "on"]) +} + +pub fn bluetooth_off() -> CommandSpec { + CommandSpec::direct("bluetoothctl", ["power", "off"]) +} + +pub fn bluetooth_watch() -> CommandSpec { + CommandSpec::direct("dbus-monitor", ["--system", "type=signal,sender=org.bluez"]) +} + +pub fn airplane_state() -> CommandSpec { + CommandSpec::direct("rfkill", ["--json"]) +} + +pub fn airplane_on() -> CommandSpec { + CommandSpec::direct("rfkill", ["block", "all"]) +} + +pub fn airplane_off() -> CommandSpec { + CommandSpec::direct("rfkill", ["unblock", "all"]) +} + +pub fn airplane_watch() -> CommandSpec { + CommandSpec::direct("udevadm", ["monitor", "--udev", "--subsystem-match=rfkill"]) +} pub const TOGGLE_KIND_WIFI: &str = "wifi"; pub const TOGGLE_KIND_BLUETOOTH: &str = "bluetooth"; diff --git a/crates/unixnotis-core/src/config/command/mod.rs b/crates/unixnotis-core/src/config/command/mod.rs index 10d5d2fee..a870d58be 100644 --- a/crates/unixnotis-core/src/config/command/mod.rs +++ b/crates/unixnotis-core/src/config/command/mod.rs @@ -1,9 +1,6 @@ //! Command parsing and built-in widget command templates pub(super) mod defaults; -mod parse; - -pub use parse::{parse_command, CommandParseError, ExecutionMode, ParsedCommand}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/config/command/parse.rs b/crates/unixnotis-core/src/config/command/parse.rs deleted file mode 100644 index ec82304e3..000000000 --- a/crates/unixnotis-core/src/config/command/parse.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Shared widget command parsing policy - -use thiserror::Error; - -use crate::util::SHELL_META_CHARS; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ExecutionMode { - // Direct commands are spawned without a shell - Direct, - // Shell commands retain syntax that must be interpreted by `sh -c` - Shell, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ParsedCommand { - // Leading assignments apply only to the spawned command - pub env: Vec<(String, String)>, - // Program and arguments are unquoted exactly once by the shared parser - pub program: String, - pub args: Vec, - pub execution_mode: ExecutionMode, -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum CommandParseError { - #[error("command is empty")] - Empty, - #[error("command contains malformed shell quoting: {0}")] - Malformed(String), - #[error("command contains environment assignments but no program")] - MissingProgram, -} - -/// Parse one simple command into environment assignments, program, and arguments -/// -/// # Errors -/// -/// Returns an error when the command is empty, malformed, or contains no program -pub fn parse_command(command: &str) -> Result { - let trimmed = command.trim(); - if trimmed.is_empty() { - return Err(CommandParseError::Empty); - } - - // One parser owns quote removal for runtime execution and preset review - let parts = shell_words::split(trimmed) - .map_err(|error| CommandParseError::Malformed(error.to_string()))?; - let (env, remaining) = split_leading_env_assignments(parts); - let mut remaining = remaining.into_iter(); - let program = remaining.next().ok_or(CommandParseError::MissingProgram)?; - let args = remaining.collect(); - - // Shell syntax remains explicit even when tokenization succeeds - let execution_mode = if requires_shell(trimmed) { - ExecutionMode::Shell - } else { - ExecutionMode::Direct - }; - - Ok(ParsedCommand { - env, - program, - args, - execution_mode, - }) -} - -fn split_leading_env_assignments(mut parts: Vec) -> (Vec<(String, String)>, Vec) { - let mut env = Vec::new(); - let mut index = 0; - - // Assignment scanning ends at the first token that is not a valid shell name - while let Some(token) = parts.get(index) { - let Some((name, value)) = split_env_assignment(token) else { - break; - }; - env.push((name.to_string(), value.to_string())); - index += 1; - } - - // Split ownership at the first program token so arguments are not cloned - let remaining = parts.split_off(index); - (env, remaining) -} - -fn split_env_assignment(token: &str) -> Option<(&str, &str)> { - let (name, value) = token.split_once('=')?; - let mut chars = name.chars(); - let first = chars.next()?; - if !(first == '_' || first.is_ascii_alphabetic()) { - return None; - } - if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) { - return None; - } - Some((name, value)) -} - -fn requires_shell(command: &str) -> bool { - command.chars().any(|character| { - SHELL_META_CHARS.contains(&character) - || character == '~' - || character == '\n' - || character == '\r' - }) -} diff --git a/crates/unixnotis-core/src/config/command/tests/defaults.rs b/crates/unixnotis-core/src/config/command/tests/defaults.rs index 141aafe19..a72558eb0 100644 --- a/crates/unixnotis-core/src/config/command/tests/defaults.rs +++ b/crates/unixnotis-core/src/config/command/tests/defaults.rs @@ -1,6 +1,6 @@ use super::super::defaults::{ - BLUETOOTH_WATCH_DBUS, TOGGLE_KIND_AIRPLANE, TOGGLE_KIND_BLUETOOTH, TOGGLE_KIND_NIGHT, - TOGGLE_KIND_WIFI, WIFI_STATE_NMCLI, + bluetooth_watch, wifi_state, TOGGLE_KIND_AIRPLANE, TOGGLE_KIND_BLUETOOTH, TOGGLE_KIND_NIGHT, + TOGGLE_KIND_WIFI, }; #[test] @@ -13,6 +13,12 @@ fn built_in_toggle_kinds_and_watch_commands_remain_nonempty() { ] { assert!(!kind.is_empty()); } - assert!(WIFI_STATE_NMCLI.starts_with("nmcli ")); - assert!(BLUETOOTH_WATCH_DBUS.starts_with("dbus-monitor ")); + assert_eq!( + wifi_state().program().and_then(|path| path.to_str()), + Some("nmcli") + ); + assert_eq!( + bluetooth_watch().program().and_then(|path| path.to_str()), + Some("dbus-monitor") + ); } diff --git a/crates/unixnotis-core/src/config/command/tests/mod.rs b/crates/unixnotis-core/src/config/command/tests/mod.rs index 49eb869aa..59595b2c1 100644 --- a/crates/unixnotis-core/src/config/command/tests/mod.rs +++ b/crates/unixnotis-core/src/config/command/tests/mod.rs @@ -1,2 +1 @@ mod defaults; -mod parse; diff --git a/crates/unixnotis-core/src/config/command/tests/parse.rs b/crates/unixnotis-core/src/config/command/tests/parse.rs deleted file mode 100644 index f61ff25ee..000000000 --- a/crates/unixnotis-core/src/config/command/tests/parse.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::{parse_command, CommandParseError, ExecutionMode}; - -#[test] -fn quoted_assignments_and_arguments_are_unquoted_once() { - let parsed = parse_command("LD_PRELOAD=\"/tmp/library.so\" VAR='two words' /bin/true done") - .expect("parse quoted command"); - - assert_eq!( - parsed.env, - vec![ - ("LD_PRELOAD".to_string(), "/tmp/library.so".to_string()), - ("VAR".to_string(), "two words".to_string()), - ] - ); - assert_eq!(parsed.program, "/bin/true"); - assert_eq!(parsed.args, vec!["done"]); - assert_eq!(parsed.execution_mode, ExecutionMode::Direct); -} - -#[test] -fn shell_syntax_is_classified_for_shell_execution() { - for command in ["~/bin/probe", "echo ok | wc -l", "echo one\recho two"] { - assert_eq!( - parse_command(command) - .expect("parse shell command") - .execution_mode, - ExecutionMode::Shell - ); - } -} - -#[test] -fn malformed_quoting_and_assignment_only_commands_are_rejected() { - assert!(matches!( - parse_command("echo \"unterminated"), - Err(CommandParseError::Malformed(_)) - )); - assert_eq!( - parse_command("HOME=/tmp"), - Err(CommandParseError::MissingProgram) - ); -} - -#[test] -fn invalid_environment_names_remain_program_tokens() { - let parsed = parse_command("1INVALID=value /bin/true").expect("parse invalid assignment name"); - - assert!(parsed.env.is_empty()); - assert_eq!(parsed.program, "1INVALID=value"); -} - -#[test] -fn escaped_spaces_remain_inside_the_program_token() { - let parsed = parse_command("scripts/escaped\\ path/tool --check").expect("parse escaped path"); - - assert_eq!(parsed.program, "scripts/escaped path/tool"); - assert_eq!(parsed.args, vec!["--check"]); -} diff --git a/crates/unixnotis-core/src/config/installer_settings.rs b/crates/unixnotis-core/src/config/installer_settings.rs new file mode 100644 index 000000000..0b032e5fa --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings.rs @@ -0,0 +1,83 @@ +//! Shared installer settings used by local reset frontends + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::filesystem::{create_directory_all, write_file_if_missing}; + +pub const INSTALLER_CONFIG_FILE: &str = "installer.toml"; +pub const DEFAULT_BACKUP_RETENTION: usize = 3; + +const INSTALLER_CONFIG_TEMPLATE: &str = r"# UnixNotis installer settings +# Backup retention for config/theme resets +[backups] +keep = 3 +"; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct InstallerConfig { + pub backups: BackupConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct BackupConfig { + pub keep: usize, +} + +impl Default for BackupConfig { + fn default() -> Self { + Self { + keep: DEFAULT_BACKUP_RETENTION, + } + } +} + +#[must_use] +pub fn installer_config_path(config_dir: &Path) -> PathBuf { + config_dir.join(INSTALLER_CONFIG_FILE) +} + +/// Ensure that the shared retention settings file exists +/// +/// # Errors +/// +/// Returns an error when the configuration directory or settings file cannot +/// be created +pub fn ensure_installer_config(config_dir: &Path) -> Result<(PathBuf, bool)> { + create_directory_all(config_dir, 0o700).context("create UnixNotis configuration directory")?; + let config_path = installer_config_path(config_dir); + let created = write_file_if_missing(&config_path, INSTALLER_CONFIG_TEMPLATE.as_bytes(), 0o644) + .context("write installer settings")?; + Ok((config_path, created)) +} + +/// Read retention settings while distinguishing absence from I/O failure +/// +/// # Errors +/// +/// Returns an error when an existing settings file cannot be read or parsed +pub fn load_installer_config(config_dir: &Path) -> Result { + let config_path = installer_config_path(config_dir); + let contents = match fs::read_to_string(&config_path) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => { + // A first-run install has no settings yet, so use the shared default + return Ok(InstallerConfig::default()); + } + Err(error) => { + // Permission, encoding, directory, and other failures must reach both callers + return Err(error).with_context(|| format!("read {}", config_path.display())); + } + }; + toml::from_str(&contents).with_context(|| format!("parse {}", config_path.display())) +} + +#[cfg(test)] +#[path = "installer_settings/tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs b/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs new file mode 100644 index 000000000..9ca36aa69 --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings/tests/mod.rs @@ -0,0 +1 @@ +mod settings; diff --git a/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs b/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs new file mode 100644 index 000000000..8902655c8 --- /dev/null +++ b/crates/unixnotis-core/src/config/installer_settings/tests/settings.rs @@ -0,0 +1,89 @@ +use super::super::{ensure_installer_config, load_installer_config}; + +fn test_directory(label: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "unixnotis-installer-settings-{label}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("create settings directory"); + path +} + +#[test] +fn ensure_creates_shared_defaults_once() { + let directory = test_directory("create"); + let (path, created) = ensure_installer_config(&directory).expect("create settings"); + assert!(created); + assert!(path.is_file()); + + let settings = load_installer_config(&directory).expect("load settings"); + assert_eq!(settings.backups.keep, 3); + + let (_, created_again) = ensure_installer_config(&directory).expect("keep existing settings"); + assert!(!created_again); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_preserves_existing_retention() { + let directory = test_directory("load"); + std::fs::write(directory.join("installer.toml"), "[backups]\nkeep = 9\n") + .expect("write settings"); + + let settings = load_installer_config(&directory).expect("load settings"); + assert_eq!(settings.backups.keep, 9); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_missing_settings_uses_defaults() { + let directory = test_directory("missing"); + + let settings = load_installer_config(&directory).expect("missing settings use defaults"); + + assert_eq!(settings.backups.keep, 3); + let _ = std::fs::remove_dir_all(directory); +} + +#[test] +fn load_invalid_settings_fails_instead_of_defaulting() { + let directory = test_directory("invalid"); + std::fs::write(directory.join("installer.toml"), "[backups\n").expect("write invalid settings"); + + let error = load_installer_config(&directory).expect_err("invalid settings must fail"); + + assert!(error.to_string().contains("parse")); + let _ = std::fs::remove_dir_all(directory); +} + +#[cfg(unix)] +#[test] +fn load_directory_settings_fails_instead_of_defaulting() { + let directory = test_directory("directory"); + std::fs::create_dir(directory.join("installer.toml")).expect("create invalid settings path"); + + let error = load_installer_config(&directory).expect_err("directory settings must fail"); + + assert!(error.to_string().contains("read")); + let _ = std::fs::remove_dir_all(directory); +} + +#[cfg(unix)] +#[test] +fn ensure_rejects_a_settings_symlink_without_replacing_it() { + let directory = test_directory("symlink"); + let target = directory.join("settings-target"); + std::fs::write(&target, b"[backups]\nkeep = 7\n").expect("write target settings"); + std::os::unix::fs::symlink(&target, directory.join("installer.toml")) + .expect("create settings symlink"); + + let error = ensure_installer_config(&directory).expect_err("symlink must be rejected"); + + assert!(error.to_string().contains("write installer settings")); + assert_eq!( + std::fs::read_to_string(target).expect("read target settings"), + "[backups]\nkeep = 7\n" + ); + let _ = std::fs::remove_dir_all(directory); +} diff --git a/crates/unixnotis-core/src/config/layout/common.rs b/crates/unixnotis-core/src/config/layout/common.rs index 9718f667e..58f06e84a 100644 --- a/crates/unixnotis-core/src/config/layout/common.rs +++ b/crates/unixnotis-core/src/config/layout/common.rs @@ -50,10 +50,9 @@ pub struct Margins { impl Default for Margins { fn default() -> Self { - // Default padding around the panel. Keeping it symmetric produces a balanced look by default. - // Users can override individual edges in config for tighter or asymmetric layouts. + // Neutral default shared by panel and popup. Callers that need edge + // clearance for shadows should set margins explicitly. Self { - // Matches the default popup stack spacing for a cohesive baseline layout. top: 14, right: 14, bottom: 14, diff --git a/crates/unixnotis-core/src/config/layout/mod.rs b/crates/unixnotis-core/src/config/layout/mod.rs index 732cb3f74..dd02a302a 100644 --- a/crates/unixnotis-core/src/config/layout/mod.rs +++ b/crates/unixnotis-core/src/config/layout/mod.rs @@ -3,8 +3,11 @@ mod common; mod popup; +#[cfg(test)] +mod tests; + pub use self::common::{ Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT, PANEL_RUNTIME_WIDTH_MIN, }; -pub use self::popup::PopupConfig; +pub use self::popup::{PopupConfig, MAX_POPUP_TIMEOUT_MS}; diff --git a/crates/unixnotis-core/src/config/layout/popup.rs b/crates/unixnotis-core/src/config/layout/popup.rs index 84411dbaf..115a7c7e4 100644 --- a/crates/unixnotis-core/src/config/layout/popup.rs +++ b/crates/unixnotis-core/src/config/layout/popup.rs @@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize}; use super::{Anchor, Margins}; +/// Longest automatic popup timer accepted by the freedesktop millisecond domain +pub const MAX_POPUP_TIMEOUT_MS: u64 = 2_147_483_647; + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct PopupConfig { @@ -22,10 +25,15 @@ impl Default for PopupConfig { fn default() -> Self { Self { anchor: Anchor::TopRight, - margin: Margins::default(), + margin: Margins { + top: 14, + right: 18, + bottom: 14, + left: 18, + }, width: 360, spacing: 12, - max_visible: 4, + max_visible: 3, default_timeout_ms: 5000, critical_timeout_ms: None, allow_click_through: false, diff --git a/crates/unixnotis-core/src/config/layout/tests/mod.rs b/crates/unixnotis-core/src/config/layout/tests/mod.rs new file mode 100644 index 000000000..8b2895849 --- /dev/null +++ b/crates/unixnotis-core/src/config/layout/tests/mod.rs @@ -0,0 +1 @@ +mod popup; diff --git a/crates/unixnotis-core/src/config/layout/tests/popup.rs b/crates/unixnotis-core/src/config/layout/tests/popup.rs new file mode 100644 index 000000000..fc24e3622 --- /dev/null +++ b/crates/unixnotis-core/src/config/layout/tests/popup.rs @@ -0,0 +1,20 @@ +use super::super::PopupConfig; + +#[test] +fn popup_defaults_limit_the_visible_stack_to_three_notifications() { + let popup = PopupConfig::default(); + + assert_eq!(popup.max_visible, 3); +} + +#[test] +fn popup_defaults_include_edge_clearance_for_card_shadow() { + let popup = PopupConfig::default(); + + // Both left and right margins accommodate the card box-shadow (~17px blur) + // so the shadow is not clipped at the work-area boundary regardless of anchor. + assert_eq!(popup.margin.left, 18); + assert_eq!(popup.margin.right, 18); + assert_eq!(popup.margin.top, 14); + assert_eq!(popup.margin.bottom, 14); +} diff --git a/crates/unixnotis-core/src/config/loading/diagnostics.rs b/crates/unixnotis-core/src/config/loading/diagnostics.rs index ece00c293..949e750ac 100644 --- a/crates/unixnotis-core/src/config/loading/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/diagnostics.rs @@ -6,7 +6,7 @@ use serde::Serialize; use toml::Value; use tracing::{info, warn}; -use super::super::{Config, CURRENT_CONFIG_VERSION}; +use super::super::Config; /// Classification used by configuration diagnostics and doctor output #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -46,35 +46,28 @@ pub struct ConfigLoadReport { pub diagnostics: Vec, } -pub(super) fn migration_diagnostic(contents: &str) -> Option { - // Diagnostics inspect a separate value tree so deserialization behavior stays unchanged +pub(super) fn empty_exact_media_policy_diagnostic(contents: &str) -> Option { let document = contents.parse::().ok()?; - // Unversioned files are schema zero and follow the explicit legacy migration path - let version = document - .as_table() - .and_then(|root| root.get("config_version")) - .and_then(Value::as_integer) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(0); - (version < CURRENT_CONFIG_VERSION).then(|| ConfigDiagnostic { - code: "config.schema.migrated", - kind: ConfigDiagnosticKind::Note, - path: Some("config_version".to_string()), - message: "Configuration was migrated to the current schema".to_string(), - original: Some(version.to_string()), - effective: Some(CURRENT_CONFIG_VERSION.to_string()), - }) -} - -pub(super) fn migrated_field_diagnostic(path: String) -> ConfigDiagnostic { - ConfigDiagnostic { - code: "config.schema.field-migrated", - kind: ConfigDiagnosticKind::Note, - path: Some(path), - message: "Missing legacy field received its schema-compatible value".to_string(), - original: None, + let root = document.as_table()?; + let media = root.get("media").and_then(Value::as_table)?; + let exact = media + .get("local_art_policy") + .and_then(Value::as_str) + .is_some_and(|value| value == "exact_executable_only"); + let empty = media + .get("local_art_executable_allowlist") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty); + (exact && empty).then(|| ConfigDiagnostic { + code: "config.media.empty-exact-allowlist", + kind: ConfigDiagnosticKind::Warning, + path: Some("media.local_art_policy".to_string()), + message: + "Exact local artwork policy has an empty executable allowlist; artwork is disabled" + .to_string(), + original: Some("exact_executable_only".to_string()), effective: None, - } + }) } pub(super) fn unknown_key_diagnostic(path: String) -> ConfigDiagnostic { @@ -161,20 +154,23 @@ fn adjustment(path: &str, original: Option, effective: Option) - fn adjustment_code(path: &str) -> &'static str { // Specific codes remain stable even when the user-facing wording improves - if path.starts_with("widgets.volume.") - && [ - "enabled", - "get_cmd", - "set_cmd", - "toggle_cmd", - "watch_cmd", - "parse_mode", - ] - .iter() - .any(|field| path.ends_with(field)) - { + if [ + "widgets.volume.enabled", + "widgets.volume.get_cmd", + "widgets.volume.set_cmd", + "widgets.volume.toggle_cmd", + "widgets.volume.watch_cmd", + "widgets.volume.parse_mode", + ] + .iter() + .any(|field| { + path.strip_prefix(field) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('.')) + }) { "config.widgets.volume-backend-selected" - } else if path == "widgets.brightness.watch_cmd" { + } else if path == "widgets.brightness.watch_cmd" + || path.starts_with("widgets.brightness.watch_cmd.") + { "config.widgets.brightness-backend-corrected" } else if path == "widgets.refresh_interval_ms" || path == "widgets.refresh_interval_slow_ms" { "config.widgets.refresh-clamped" diff --git a/crates/unixnotis-core/src/config/loading/io.rs b/crates/unixnotis-core/src/config/loading/io.rs deleted file mode 100644 index a54cb0a38..000000000 --- a/crates/unixnotis-core/src/config/loading/io.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Configuration loading, path resolution, and on-disk defaults -//! -//! Focuses on I/O and filesystem-related helpers for config management - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; - -use thiserror::Error; -use tracing::warn; - -use crate::filesystem::{make_file_executable, write_file_atomic, write_file_if_missing}; -use crate::util::expand_tilde; -use crate::{ - DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_SCRIPTS, - DEFAULT_WIDGETS_CSS, -}; - -use super::super::runtime::{apply_brightness_backend, apply_volume_backend, sanitize_config}; -use super::super::schema::deserialize_config_with_migrations; -use super::super::{log_config_diagnostics, Config, ConfigLoadReport}; -use super::diagnostics::{ - adjustment_diagnostics, migrated_field_diagnostic, migration_diagnostic, unknown_key_diagnostic, -}; - -static LEGACY_RENAME_WARNED: AtomicBool = AtomicBool::new(false); -static INVALID_XDG_WARNED: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone)] -pub struct ThemePaths { - // Base directory used to resolve relative theme paths - pub base_dir: PathBuf, - pub base_css: PathBuf, - pub popup_css: PathBuf, - pub panel_css: PathBuf, - pub widgets_css: PathBuf, - pub media_css: PathBuf, -} - -#[derive(Debug, Error)] -pub enum ConfigError { - #[error("failed to read config file: {0}")] - ReadFailed(String), - #[error("failed to parse config: {0}")] - ParseFailed(String), - #[error("missing $HOME, unable to resolve config directory")] - MissingHome, -} - -impl ConfigError { - /// Return a stable summary that never includes configuration contents - #[must_use] - pub const fn shareable_summary(&self) -> &'static str { - match self { - Self::ReadFailed(_) => "Configuration file could not be read", - Self::ParseFailed(_) => "Configuration TOML or schema is invalid", - Self::MissingHome => "HOME is missing, so the configuration path cannot resolve", - } - } -} - -impl Config { - /// Load configuration from a specific path - /// - /// # Errors - /// - /// Returns an error when the file cannot be read or its TOML cannot be parsed - pub fn load_from_path(path: &Path) -> Result { - let report = Self::load_from_path_with_report(path)?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Load configuration from a specific path with structured diagnostics - /// - /// # Errors - /// - /// Returns an error when the file cannot be read or its TOML cannot be parsed - pub fn load_from_path_with_report(path: &Path) -> Result { - let contents = - fs::read_to_string(path).map_err(|err| ConfigError::ReadFailed(err.to_string()))?; - Self::parse_with_report(&contents) - } - - /// Parse and migrate configuration text without reading the filesystem - /// - /// # Errors - /// - /// Returns an error for invalid TOML or unsupported schema versions - pub fn parse(contents: &str) -> Result { - let report = Self::parse_with_report(contents)?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Parse and migrate configuration text with structured diagnostics - /// - /// # Errors - /// - /// Returns an error for invalid TOML or unsupported schema versions - pub fn parse_with_report(contents: &str) -> Result { - let (mut config, ignored_keys, migrated_paths) = - deserialize_config_with_migrations(contents).map_err(ConfigError::ParseFailed)?; - let mut diagnostics = migration_diagnostic(contents) - .into_iter() - .collect::>(); - diagnostics.extend(migrated_paths.into_iter().map(migrated_field_diagnostic)); - diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); - let before_runtime = config.clone(); - config.apply_runtime_defaults(); - diagnostics.extend(adjustment_diagnostics(&before_runtime, &config)); - Ok(ConfigLoadReport { - config, - diagnostics, - }) - } - - /// Load configuration from the default XDG config location, if present - /// - /// # Errors - /// - /// Returns an error when the default location cannot be resolved or an existing config file - /// cannot be read and parsed - pub fn load_default() -> Result { - let report = Self::load_default_with_report()?; - log_config_diagnostics(&report.diagnostics); - Ok(report.config) - } - - /// Load default configuration with structured diagnostics - /// - /// # Errors - /// - /// Returns an error when the default location cannot be resolved or read - pub fn load_default_with_report() -> Result { - let path = Self::default_config_path()?; - if !path.exists() { - let mut config = Self::default(); - let before_runtime = config.clone(); - config.apply_runtime_defaults(); - return Ok(ConfigLoadReport { - diagnostics: adjustment_diagnostics(&before_runtime, &config), - config, - }); - } - Self::load_from_path_with_report(&path) - } - - /// Resolve configured CSS paths relative to the config directory - /// - /// # Errors - /// - /// Returns an error when the default config directory cannot be resolved - pub fn resolve_theme_paths(&self) -> Result { - let base = Self::default_config_dir()?; - self.resolve_theme_paths_from(&base) - } - - /// Resolve the config directory that should anchor relative theme paths - /// - /// # Errors - /// - /// Returns an error when a parentless relative path requires the current directory and that - /// directory cannot be read - pub fn config_dir_for_path(path: &Path) -> Result { - if let Some(parent) = path.parent() { - // Plain file names report an empty parent, so skip that case - if !parent.as_os_str().is_empty() { - return Ok(parent.to_path_buf()); - } - } - env::current_dir().map_err(|err| ConfigError::ReadFailed(err.to_string())) - } - - /// Resolve configured CSS paths relative to an explicit config directory - /// - /// # Errors - /// - /// This operation currently has no failure path; the result type is retained for API - /// compatibility with other theme-resolution helpers - pub fn resolve_theme_paths_from(&self, base: &Path) -> Result { - // Resolve relative paths against the supplied config directory - Ok(ThemePaths { - base_dir: base.to_path_buf(), - base_css: Self::resolve_path(base, &self.theme.base_css), - popup_css: Self::resolve_path(base, &self.theme.popup_css), - panel_css: Self::resolve_path(base, &self.theme.panel_css), - widgets_css: Self::resolve_path(base, &self.theme.widgets_css), - media_css: Self::resolve_path(base, &self.theme.media_css), - }) - } - - /// Ensure all theme files exist in the config directory - /// - /// # Errors - /// - /// Returns an error when a missing theme file cannot be created safely - pub fn ensure_theme_files(&self, theme_paths: &ThemePaths) -> Result<(), ConfigError> { - // Use the same base directory used for resolving theme paths - let config_dir = &theme_paths.base_dir; - - let legacy = config_dir.join("style.css"); - let base_exists = theme_paths.base_css.exists(); - let legacy_contents = if base_exists { - None - } else { - fs::read_to_string(&legacy) - .ok() - .filter(|contents| !contents.trim().is_empty()) - }; - - write_if_missing( - &theme_paths.base_css, - legacy_contents.as_deref().unwrap_or(DEFAULT_BASE_CSS), - )?; - write_if_missing(&theme_paths.panel_css, DEFAULT_PANEL_CSS)?; - write_if_missing(&theme_paths.popup_css, DEFAULT_POPUP_CSS)?; - write_if_missing(&theme_paths.widgets_css, DEFAULT_WIDGETS_CSS)?; - write_if_missing(&theme_paths.media_css, DEFAULT_MEDIA_CSS)?; - - if legacy_contents.is_some() && legacy.exists() { - let backup = legacy.with_extension("css.bak"); - if !backup.exists() { - if let Err(err) = fs::rename(&legacy, &backup) { - // Non-fatal: leave legacy style.css in place if backup fails (permissions, - // existing paths, or filesystem limitations) - if LEGACY_RENAME_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!( - ?err, - legacy = %legacy.display(), - backup = %backup.display(), - "failed to rename legacy style.css" - ); - } - } - } - } - - Ok(()) - } - - /// Ensure helper scripts used by the shipped default config exist - /// - /// # Errors - /// - /// Returns an error when a missing script cannot be written or made executable - pub fn ensure_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { - for script in DEFAULT_SCRIPTS { - let path = config_dir.join(script.relative_path); - // Existing files are preserved so user-edited helpers are not overwritten - if !path.exists() { - write_default_script(&path, script.contents)?; - } - // Relative commands run the helper directly, so execute bits must be present - set_executable(&path)?; - } - Ok(()) - } - - /// Overwrite helper scripts with the built-in defaults - /// - /// # Errors - /// - /// Returns an error when any script cannot be replaced safely - pub fn write_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { - for script in DEFAULT_SCRIPTS { - write_default_script(&config_dir.join(script.relative_path), script.contents)?; - } - Ok(()) - } - - fn apply_runtime_defaults(&mut self) { - apply_volume_backend(&mut self.widgets.volume); - apply_brightness_backend(&mut self.widgets.brightness); - sanitize_config(self); - } - - /// Return the default config directory based on XDG or $HOME - /// - /// # Errors - /// - /// Returns an error when neither a valid absolute `XDG_CONFIG_HOME` nor `HOME` is available - pub fn default_config_dir() -> Result { - if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { - let trimmed = xdg.trim(); - if !trimmed.is_empty() { - let path = PathBuf::from(trimmed); - if path.is_absolute() { - // Prefer the XDG base directory when it is explicitly configured - return Ok(path.join("unixnotis")); - } - } - if INVALID_XDG_WARNED - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - warn!("invalid XDG_CONFIG_HOME; falling back to $HOME/.config"); - } - } - let home = env::var("HOME").map_err(|_error| ConfigError::MissingHome)?; - // Fall back to the standard $HOME/.config path for predictable location - Ok(PathBuf::from(home).join(".config").join("unixnotis")) - } - - /// Return the default config file path - /// - /// # Errors - /// - /// Returns an error when the default config directory cannot be resolved - pub fn default_config_path() -> Result { - Ok(Self::default_config_dir()?.join("config.toml")) - } - - /// Resolve the environment-selected config file or the normal default file - /// - /// # Errors - /// - /// Returns an error when no explicit path is set and the default directory cannot resolve - pub fn active_config_path() -> Result { - let configured = - env::var_os(crate::util::CONFIG_PATH_ENV).filter(|value| !value.is_empty()); - configured.map_or_else(Self::default_config_path, |path| Ok(PathBuf::from(path))) - } - - fn resolve_path(base: &Path, value: &str) -> PathBuf { - let path = expand_tilde(value); - let path = PathBuf::from(path.as_ref()); - if path.is_absolute() { - path - } else { - base.join(path) - } - } -} - -fn write_if_missing(path: &Path, contents: &str) -> Result<(), ConfigError> { - write_file_if_missing(path, contents.as_bytes(), 0o644) - .map(|_created| ()) - .map_err(|err| ConfigError::ReadFailed(err.to_string())) -} - -fn write_default_script(path: &Path, contents: &str) -> Result<(), ConfigError> { - // Script reset uses the same atomic path as startup provisioning - // This keeps installer resets from leaving half-written helpers behind - write_file_atomic(path, contents.as_bytes(), 0o755) - .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; - set_executable(path) -} - -#[cfg(unix)] -fn set_executable(path: &Path) -> Result<(), ConfigError> { - make_file_executable(path).map_err(|err| ConfigError::ReadFailed(err.to_string())) -} - -#[cfg(not(unix))] -fn set_executable(_path: &Path) -> Result<(), ConfigError> { - Ok(()) -} - -#[cfg(test)] -#[path = "tests/io/mod.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/error.rs b/crates/unixnotis-core/src/config/loading/io/error.rs new file mode 100644 index 000000000..ada1e24e2 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/error.rs @@ -0,0 +1,28 @@ +//! Errors returned while loading and preparing configuration files + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file: {0}")] + ReadFailed(String), + #[error("failed to parse config: {0}")] + ParseFailed(String), + #[error("configuration file is too large ({size} bytes; maximum {max} bytes)")] + TooLarge { size: u64, max: u64 }, + #[error("missing $HOME, unable to resolve config directory")] + MissingHome, +} + +impl ConfigError { + /// Return a stable summary that never includes configuration contents + #[must_use] + pub const fn shareable_summary(&self) -> &'static str { + match self { + Self::ReadFailed(_) => "Configuration file could not be read", + Self::ParseFailed(_) => "Configuration TOML or schema is invalid", + Self::TooLarge { .. } => "Configuration file exceeds the maximum supported size", + Self::MissingHome => "HOME is missing, so the configuration path cannot resolve", + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/load.rs b/crates/unixnotis-core/src/config/loading/io/load.rs new file mode 100644 index 000000000..2eacca843 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/load.rs @@ -0,0 +1,147 @@ +//! Bounded configuration loading and parsing +//! +//! Focuses on I/O and filesystem-related helpers for config management + +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use crate::config::runtime::{apply_brightness_backend, apply_volume_backend, sanitize_config}; +use crate::config::schema::deserialize_current_config; +use crate::{log_config_diagnostics, Config, ConfigLoadReport}; + +use super::super::diagnostics::{ + adjustment_diagnostics, empty_exact_media_policy_diagnostic, unknown_key_diagnostic, +}; +use super::ConfigError; + +/// Maximum accepted `config.toml` size before parsing +pub const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +impl Config { + /// Load configuration from a specific path + /// + /// # Errors + /// + /// Returns an error when the file cannot be read or its TOML cannot be parsed + pub fn load_from_path(path: &Path) -> Result { + let report = Self::load_from_path_with_report(path)?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Load configuration from a specific path with structured diagnostics + /// + /// # Errors + /// + /// Returns an error when the file cannot be read or its TOML cannot be parsed + pub fn load_from_path_with_report(path: &Path) -> Result { + let contents = read_config_bounded(path)?; + Self::parse_with_report(&contents) + } + + /// Parse current-schema configuration text without reading the filesystem + /// + /// # Errors + /// + /// Returns an error for invalid TOML or unsupported schema versions + pub fn parse(contents: &str) -> Result { + let report = Self::parse_with_report(contents)?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Parse current-schema configuration text with structured diagnostics + /// + /// # Errors + /// + /// Returns an error for invalid TOML or unsupported schema versions + pub fn parse_with_report(contents: &str) -> Result { + let (mut config, ignored_keys) = + deserialize_current_config(contents).map_err(ConfigError::ParseFailed)?; + let mut diagnostics = Vec::new(); + diagnostics.extend(empty_exact_media_policy_diagnostic(contents)); + diagnostics.extend(ignored_keys.into_iter().map(unknown_key_diagnostic)); + let before_runtime = config.clone(); + config.apply_runtime_defaults(); + diagnostics.extend(adjustment_diagnostics(&before_runtime, &config)); + Ok(ConfigLoadReport { + config, + diagnostics, + }) + } + + /// Load configuration from the default XDG config location, if present + /// + /// # Errors + /// + /// Returns an error when the default location cannot be resolved or an existing config file + /// cannot be read and parsed + pub fn load_default() -> Result { + let report = Self::load_default_with_report()?; + log_config_diagnostics(&report.diagnostics); + Ok(report.config) + } + + /// Load default configuration with structured diagnostics + /// + /// # Errors + /// + /// Returns an error when the default location cannot be resolved or read + pub fn load_default_with_report() -> Result { + let path = Self::default_config_path()?; + if !path.exists() { + let mut config = Self::default(); + let before_runtime = config.clone(); + config.apply_runtime_defaults(); + return Ok(ConfigLoadReport { + diagnostics: adjustment_diagnostics(&before_runtime, &config), + config, + }); + } + Self::load_from_path_with_report(&path) + } + + fn apply_runtime_defaults(&mut self) { + apply_volume_backend(&mut self.widgets.volume); + apply_brightness_backend(&mut self.widgets.brightness); + sanitize_config(self); + } +} + +fn read_config_bounded(path: &Path) -> Result { + // Opening first keeps metadata and reads tied to the same filesystem object + let file = File::open(path).map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + let initial_size = file + .metadata() + .map_err(|err| ConfigError::ReadFailed(err.to_string()))? + .len(); + read_config_contents(file, initial_size) +} + +pub(super) fn read_config_contents( + reader: R, + initial_size: u64, +) -> Result { + if initial_size > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + size: initial_size, + max: MAX_CONFIG_BYTES, + }); + } + + // The extra byte detects files that grow after metadata is checked + let mut contents = String::with_capacity(initial_size as usize); + reader + .take(MAX_CONFIG_BYTES + 1) + .read_to_string(&mut contents) + .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + let observed_size = contents.len() as u64; + if observed_size > MAX_CONFIG_BYTES { + return Err(ConfigError::TooLarge { + size: observed_size, + max: MAX_CONFIG_BYTES, + }); + } + Ok(contents) +} diff --git a/crates/unixnotis-core/src/config/loading/io/mod.rs b/crates/unixnotis-core/src/config/loading/io/mod.rs new file mode 100644 index 000000000..150406e91 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/mod.rs @@ -0,0 +1,18 @@ +//! Configuration filesystem operations + +mod error; +mod load; +mod paths; +mod script_migrations; +mod scripts; +mod theme_contract; + +pub use error::ConfigError; +pub use load::MAX_CONFIG_BYTES; +pub use paths::ThemePaths; +pub use theme_contract::{ + ThemeContractState, ThemeIncompatibility, ThemeManifest, THEME_API_VERSION, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/config/loading/io/paths.rs b/crates/unixnotis-core/src/config/loading/io/paths.rs new file mode 100644 index 000000000..b1ff903a8 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/paths.rs @@ -0,0 +1,128 @@ +//! Configuration and theme path discovery + +use std::env; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::warn; + +use crate::util::expand_tilde; +use crate::Config; + +use super::ConfigError; + +static INVALID_XDG_WARNED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone)] +pub struct ThemePaths { + // Base directory used to resolve relative theme paths + pub base_dir: PathBuf, + pub base_css: PathBuf, + pub popup_css: PathBuf, + pub panel_css: PathBuf, + pub widgets_css: PathBuf, + pub media_css: PathBuf, +} + +impl Config { + /// Resolve configured CSS paths relative to the config directory + /// + /// # Errors + /// + /// Returns an error when the default config directory cannot be resolved + pub fn resolve_theme_paths(&self) -> Result { + let base = Self::default_config_dir()?; + self.resolve_theme_paths_from(&base) + } + + /// Resolve the config directory that should anchor relative theme paths + /// + /// # Errors + /// + /// Returns an error when a parentless relative path requires the current directory and that + /// directory cannot be read + pub fn config_dir_for_path(path: &Path) -> Result { + if let Some(parent) = path.parent() { + // Plain file names report an empty parent, so skip that case + if !parent.as_os_str().is_empty() { + return Ok(parent.to_path_buf()); + } + } + env::current_dir().map_err(|err| ConfigError::ReadFailed(err.to_string())) + } + + /// Resolve configured CSS paths relative to an explicit config directory + /// + /// # Errors + /// + /// This operation currently has no failure path; the result type is retained for API + /// compatibility with other theme-resolution helpers + pub fn resolve_theme_paths_from(&self, base: &Path) -> Result { + // Resolve relative paths against the supplied config directory + Ok(ThemePaths { + base_dir: base.to_path_buf(), + base_css: Self::resolve_path(base, &self.theme.base_css), + popup_css: Self::resolve_path(base, &self.theme.popup_css), + panel_css: Self::resolve_path(base, &self.theme.panel_css), + widgets_css: Self::resolve_path(base, &self.theme.widgets_css), + media_css: Self::resolve_path(base, &self.theme.media_css), + }) + } + + /// Return the default config directory based on XDG or $HOME + /// + /// # Errors + /// + /// Returns an error when neither a valid absolute `XDG_CONFIG_HOME` nor `HOME` is available + pub fn default_config_dir() -> Result { + if let Ok(xdg) = env::var("XDG_CONFIG_HOME") { + let trimmed = xdg.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + // Prefer the XDG base directory when it is explicitly configured + return Ok(path.join("unixnotis")); + } + } + if INVALID_XDG_WARNED + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + warn!("invalid XDG_CONFIG_HOME; falling back to $HOME/.config"); + } + } + let home = env::var("HOME").map_err(|_error| ConfigError::MissingHome)?; + // Fall back to the standard $HOME/.config path for predictable location + Ok(PathBuf::from(home).join(".config").join("unixnotis")) + } + + /// Return the default config file path + /// + /// # Errors + /// + /// Returns an error when the default config directory cannot be resolved + pub fn default_config_path() -> Result { + Ok(Self::default_config_dir()?.join("config.toml")) + } + + /// Resolve the environment-selected config file or the normal default file + /// + /// # Errors + /// + /// Returns an error when no explicit path is set and the default directory cannot resolve + pub fn active_config_path() -> Result { + let configured = + env::var_os(crate::util::CONFIG_PATH_ENV).filter(|value| !value.is_empty()); + configured.map_or_else(Self::default_config_path, |path| Ok(PathBuf::from(path))) + } + + fn resolve_path(base: &Path, value: &str) -> PathBuf { + let path = expand_tilde(value); + let path = PathBuf::from(path.as_ref()); + if path.is_absolute() { + path + } else { + base.join(path) + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/script_migrations.rs b/crates/unixnotis-core/src/config/loading/io/script_migrations.rs new file mode 100644 index 000000000..7f4812444 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/script_migrations.rs @@ -0,0 +1,39 @@ +//! Exact legacy stock helpers that can be upgraded without replacing user edits + +use std::path::Path; + +struct LegacyScript { + relative_path: &'static str, + contents: &'static [u8], +} + +const LEGACY_SCRIPTS: &[LegacyScript] = &[ + LegacyScript { + relative_path: "scripts/unixnotis-blue-light-lib", + contents: include_bytes!("../../../../assets/scripts/legacy/unixnotis-blue-light-lib-v1"), + }, + LegacyScript { + relative_path: "scripts/unixnotis-blue-light-on", + contents: include_bytes!("../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"), + }, +]; + +pub(super) fn is_legacy_stock_script(path: &Path, relative_path: &str) -> bool { + let Some(legacy) = LEGACY_SCRIPTS + .iter() + .find(|legacy| legacy.relative_path == relative_path) + else { + return false; + }; + + // A metadata length check avoids reading an unrelated large user file + let Ok(metadata) = path.symlink_metadata() else { + return false; + }; + if !metadata.file_type().is_file() || metadata.len() != legacy.contents.len() as u64 { + return false; + } + + // Exact bytes make the migration safe for every customized variant + std::fs::read(path).is_ok_and(|contents| contents == legacy.contents) +} diff --git a/crates/unixnotis-core/src/config/loading/io/scripts.rs b/crates/unixnotis-core/src/config/loading/io/scripts.rs new file mode 100644 index 000000000..9595661e9 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/scripts.rs @@ -0,0 +1,59 @@ +//! Provisioning for built-in helper scripts + +use std::path::Path; + +use crate::filesystem::{make_file_executable, write_file_atomic}; +use crate::{Config, DEFAULT_SCRIPTS}; + +use super::script_migrations::is_legacy_stock_script; +use super::ConfigError; + +impl Config { + /// Ensure helper scripts used by the shipped default config exist + /// + /// # Errors + /// + /// Returns an error when a missing script cannot be written or made executable + pub fn ensure_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { + for script in DEFAULT_SCRIPTS { + let path = config_dir.join(script.relative_path); + // Known stock versions can move forward while all edited helpers stay untouched + if !path.exists() || is_legacy_stock_script(&path, script.relative_path) { + write_default_script(&path, script.contents)?; + } + // Relative commands run the helper directly, so execute bits must be present + set_executable(&path)?; + } + Ok(()) + } + + /// Overwrite helper scripts with the built-in defaults + /// + /// # Errors + /// + /// Returns an error when any script cannot be replaced safely + pub fn write_default_scripts_in(config_dir: &Path) -> Result<(), ConfigError> { + for script in DEFAULT_SCRIPTS { + write_default_script(&config_dir.join(script.relative_path), script.contents)?; + } + Ok(()) + } +} + +fn write_default_script(path: &Path, contents: &str) -> Result<(), ConfigError> { + // Script reset uses the same atomic path as startup provisioning + // This keeps installer resets from leaving half-written helpers behind + write_file_atomic(path, contents.as_bytes(), 0o755) + .map_err(|err| ConfigError::ReadFailed(err.to_string()))?; + set_executable(path) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<(), ConfigError> { + make_file_executable(path).map_err(|err| ConfigError::ReadFailed(err.to_string())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<(), ConfigError> { + Ok(()) +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs new file mode 100644 index 000000000..3d443bdf6 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/blue_light.rs @@ -0,0 +1,152 @@ +//! Behavioral coverage for each shipped blue-light backend + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use super::support::test_root; + +const LIBRARY: &str = include_str!("../../../../../assets/scripts/unixnotis-blue-light-lib"); + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).expect("write fake backend"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod fake backend"); +} + +fn backend_fixture(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = test_root(label); + let bin = root.join("bin"); + let log = root.join("calls.log"); + fs::create_dir_all(&bin).expect("create fake backend directory"); + fs::write(root.join("blue-light-lib"), LIBRARY).expect("write blue-light library"); + write_executable(&bin.join("nohup"), "#!/bin/sh\nexec \"$@\"\n"); + let logger = "#!/bin/sh\nprintf '%s %s\\n' \"${0##*/}\" \"$*\" >> \"$TEST_LOG\"\n"; + for backend in ["hyprsunset", "gammastep", "wlsunset", "sunsetr"] { + write_executable(&bin.join(backend), logger); + } + (root, log) +} + +#[test] +fn every_supported_backend_receives_its_expected_start_arguments() { + let cases = [ + ("hyprsunset", "hyprsunset --temperature 4500"), + ("gammastep", "gammastep -m wayland -l 0:0 -t 4500:4500 -P"), + ("wlsunset", "wlsunset -t 4500 -T 4500 -l 0 -L 0"), + ("sunsetr", "sunsetr test 4500 90"), + ]; + + for (backend, expected) in cases { + let (root, log) = backend_fixture(&format!("blue-light-start-{backend}")); + let status = Command::new("/bin/sh") + .args([ + "-c", + ". \"$1\"; start_backend \"$2\"; wait", + "blue-light-test", + ]) + .arg(root.join("blue-light-lib")) + .arg(backend) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .status() + .expect("run backend start"); + + assert!(status.success(), "backend failed: {backend}"); + assert_eq!( + fs::read_to_string(&log).expect("read backend log").trim(), + expected + ); + let _ = fs::remove_dir_all(root); + } +} + +#[test] +fn stopping_night_mode_visits_every_active_supported_backend() { + let (root, log) = backend_fixture("blue-light-stop-all"); + write_executable(&root.join("bin/pgrep"), "#!/bin/sh\nexit 0\n"); + write_executable( + &root.join("bin/pkill"), + "#!/bin/sh\nprintf 'pkill %s\\n' \"$*\" >> \"$TEST_LOG\"\n", + ); + write_executable(&root.join("bin/sleep"), "#!/bin/sh\nexit 0\n"); + + let status = Command::new("/bin/sh") + .args(["-c", ". \"$1\"; stop_active_backends", "blue-light-test"]) + .arg(root.join("blue-light-lib")) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .status() + .expect("stop every active backend"); + + assert!(status.success()); + let calls = fs::read_to_string(&log).expect("read stop calls"); + for expected in [ + "pkill -x hyprsunset", + "pkill -x gammastep", + "pkill -x wlsunset", + "sunsetr stop", + "pkill -x sunsetr", + ] { + assert!( + calls.lines().any(|call| call == expected), + "missing {expected}" + ); + } + assert!( + !calls.lines().any(|call| call == "gammastep -x"), + "stopping Night mode must not invoke the blocking reset process" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn night_mode_falls_through_when_the_first_installed_backend_cannot_stay_running() { + let (root, log) = backend_fixture("blue-light-fallback"); + let marker = root.join("gammastep.running"); + write_executable( + &root.join("bin/gammastep"), + "#!/bin/sh\nprintf '%s %s\\n' \"${0##*/}\" \"$*\" >> \"$TEST_LOG\"\n: > \"$TEST_MARKER\"\n", + ); + write_executable( + &root.join("bin/pgrep"), + "#!/bin/sh\n[ \"$2\" = gammastep ] && [ -f \"$TEST_MARKER\" ]\n", + ); + write_executable(&root.join("bin/pkill"), "#!/bin/sh\nexit 0\n"); + write_executable( + &root.join("bin/sleep"), + "#!/bin/sh\nexec /bin/sleep \"$1\"\n", + ); + + let status = Command::new("/bin/sh") + .args(["-c", ". \"$1\"; start_available_backend", "blue-light-test"]) + .arg(root.join("blue-light-lib")) + .env("PATH", root.join("bin")) + .env("TEST_LOG", &log) + .env("TEST_MARKER", &marker) + .env("UNIXNOTIS_BLUE_LIGHT_STARTUP_DELAY", "0.05") + .status() + .expect("start first healthy blue-light backend"); + + assert!(status.success()); + let calls = fs::read_to_string(&log).expect("read backend calls"); + assert!(calls.lines().any(|call| call.starts_with("hyprsunset "))); + assert!(calls + .lines() + .any(|call| call.starts_with("gammastep -m wayland"))); + assert!( + !calls.lines().any(|call| call == "gammastep -x"), + "fallback must not block on an inactive gammastep reset" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn blue_light_scripts_do_not_use_cross_user_temporary_state() { + for script in crate::DEFAULT_SCRIPTS { + if script.relative_path.contains("blue-light") { + assert!(!script.contents.contains("STATE_FILE")); + assert!(!script.contents.contains("/tmp/unixnotis")); + } + } +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/load.rs b/crates/unixnotis-core/src/config/loading/io/tests/load.rs new file mode 100644 index 000000000..1ec6f0fe7 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/load.rs @@ -0,0 +1,241 @@ +//! Tests for bounded configuration loading and parsing + +use std::fs; +use std::io::Cursor; + +use crate::{Config, ConfigError, CURRENT_CONFIG_VERSION, MAX_CONFIG_BYTES}; + +use super::super::load::read_config_contents; +use super::support::{env_lock, test_root, EnvGuard}; + +const EXPECTED_MAX_CONFIG_BYTES: usize = 1_048_576; + +#[test] +fn load_from_path_reads_toml_and_applies_runtime_defaults() { + let root = test_root("load-from-path"); + // Start from a clean root so the test only sees the TOML written below + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("config dir"); + let path = root.join("config.toml"); + // Deliberately use too-small refresh intervals to prove load sanitization still runs + fs::write( + &path, + format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} + [panel] + title = "Loaded Title" + + [widgets] + refresh_interval_ms = 1 + refresh_interval_slow_ms = 50 + "# + ), + ) + .expect("config file"); + + let config = Config::load_from_path(&path).expect("config should load"); + + // User text should survive loading while runtime defaults repair unsafe timing values + assert_eq!(config.panel.title, "Loaded Title"); + assert_eq!(config.widgets.refresh_interval_ms, 100); + assert_eq!(config.widgets.refresh_interval_slow_ms, 100); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn load_from_path_returns_parse_error_for_invalid_toml() { + let root = test_root("load-invalid"); + // Parse failures should come from the target file, not from leftover temp data + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("config dir"); + let path = root.join("config.toml"); + fs::write(&path, "[panel\n").expect("invalid config"); + + let err = Config::load_from_path(&path).expect_err("invalid toml should fail"); + + assert!(matches!(err, ConfigError::ParseFailed(_))); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn parse_returns_the_config_produced_by_the_report_pipeline() { + let config = Config::parse(&format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} + [panel] + title = "Parsed Title" + "# + )) + .expect("valid config text should parse"); + + assert_eq!(config.panel.title, "Parsed Title"); +} + +#[test] +fn obsolete_theme_mode_is_ignored_and_default_rendering_omits_it() { + let report = Config::parse_with_report(&format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} + [theme] + mode = "stock" + popup_css = "popup.css" + "# + )) + .expect("obsolete theme mode should be ignored"); + + assert_eq!(report.config.theme.popup_css, "popup.css"); + assert!(!report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "config.unknown-key" && diagnostic.path.as_deref() == Some("theme.mode") + })); + let rendered = toml::to_string_pretty(&Config::default()).expect("default config renders"); + assert!(!rendered.contains("mode = \"stock\"")); + assert!(!rendered.contains("mode = \"custom\"")); +} + +#[test] +fn sound_file_hints_require_explicit_configuration() { + let defaults = Config::parse(&format!("config_version = {CURRENT_CONFIG_VERSION}\n")) + .expect("default current config should parse"); + let enabled = Config::parse(&format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} + [sound] + allow_file_hints = true + allowed_file_hint_dirs = ["sounds", "/srv/notification-sounds"] + "# + )) + .expect("sound hint policy should parse"); + + assert!(!defaults.sound.allow_file_hints); + assert!(defaults.sound.allowed_file_hint_dirs.is_empty()); + assert!(enabled.sound.allow_file_hints); + assert_eq!( + enabled.sound.allowed_file_hint_dirs, + ["sounds", "/srv/notification-sounds"] + ); +} + +#[test] +fn load_from_path_rejects_oversized_config_before_parsing() { + assert_eq!(MAX_CONFIG_BYTES, EXPECTED_MAX_CONFIG_BYTES as u64); + let root = test_root("load-oversized"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("config dir"); + let path = root.join("config.toml"); + let file = fs::File::create(&path).expect("oversized config file"); + // A sparse file exercises the metadata guard without allocating the payload in the test + file.set_len(EXPECTED_MAX_CONFIG_BYTES as u64 + 1) + .expect("oversized config length"); + + let error = Config::load_from_path(&path).expect_err("oversized config should fail"); + + assert!(matches!( + error, + ConfigError::TooLarge { + size, + max, + } if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); + assert_eq!( + error.shareable_summary(), + "Configuration file exceeds the maximum supported size" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_reader_accepts_exact_limit_and_rejects_a_growing_stream() { + assert_eq!(MAX_CONFIG_BYTES, EXPECTED_MAX_CONFIG_BYTES as u64); + let declared_oversized = read_config_contents( + Cursor::new(Vec::::new()), + EXPECTED_MAX_CONFIG_BYTES as u64 + 1, + ) + .expect_err("declared oversized input should fail before reading"); + + assert!(matches!( + declared_oversized, + ConfigError::TooLarge { size, max } + if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); + + let exact = vec![b' '; EXPECTED_MAX_CONFIG_BYTES]; + + let contents = read_config_contents(Cursor::new(exact), EXPECTED_MAX_CONFIG_BYTES as u64) + .expect("a config at the exact size limit should be accepted"); + + assert_eq!(contents.len(), EXPECTED_MAX_CONFIG_BYTES); + + let grew_after_metadata = vec![b' '; EXPECTED_MAX_CONFIG_BYTES + 1]; + let error = read_config_contents(Cursor::new(grew_after_metadata), 0) + .expect_err("a stream that grows beyond the limit should be rejected"); + + assert!(matches!( + error, + ConfigError::TooLarge { size, max } + if size == EXPECTED_MAX_CONFIG_BYTES as u64 + 1 + && max == EXPECTED_MAX_CONFIG_BYTES as u64 + )); +} + +#[test] +fn shareable_error_summaries_never_echo_private_error_details() { + let error = ConfigError::ParseFailed("secret_command = 'private-parser-sentinel'".to_string()); + + let summary = error.shareable_summary(); + + assert_eq!(summary, "Configuration TOML or schema is invalid"); + assert!(!summary.contains("secret_command")); + assert!(!summary.contains("private-parser-sentinel")); +} + +#[test] +fn load_default_reads_config_when_default_file_exists() { + let _guard = env_lock(); + let root = test_root("load-default-existing"); + // Default-path discovery reads process-global env, so this test owns the env lock + let _ = fs::remove_dir_all(&root); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_dir.join("config.toml"), + format!( + r#" + config_version = {CURRENT_CONFIG_VERSION} + [panel] + title = "Default Path Title" + "# + ), + ) + .expect("default config file"); + + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", root.as_os_str()); + let _home = EnvGuard::set("HOME", root.as_os_str()); + // This exercises the public default loader instead of the explicit-path helper + let config = Config::load_default().expect("default config should load"); + + assert_eq!(config.panel.title, "Default Path Title"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn load_default_returns_sanitized_stock_config_when_file_is_missing() { + let _guard = env_lock(); + let root = test_root("load-default-missing"); + // Missing config should not require creating the config directory first + let _ = fs::remove_dir_all(&root); + + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", root.as_os_str()); + let _home = EnvGuard::set("HOME", root.as_os_str()); + let config = Config::load_default().expect("missing config should fall back"); + + // Fallback config still passes through the runtime sanitizer + assert_eq!(config.panel.title, crate::PanelConfig::default().title); + assert_eq!(config.widgets.refresh_interval_ms, 1000); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/loading/io/tests/mod.rs b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs new file mode 100644 index 000000000..0a38534f1 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/mod.rs @@ -0,0 +1,9 @@ +//! Configuration I/O test declarations + +mod blue_light; +mod load; +mod paths; +mod script_migrations; +mod scripts; +mod support; +mod theme_contract; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/paths.rs b/crates/unixnotis-core/src/config/loading/io/tests/paths.rs similarity index 98% rename from crates/unixnotis-core/src/config/loading/tests/io/paths.rs rename to crates/unixnotis-core/src/config/loading/io/tests/paths.rs index e30de80e7..be827c058 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/paths.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/paths.rs @@ -1,3 +1,5 @@ +//! Tests for configuration path discovery and resolution + use std::env; use std::path::PathBuf; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs b/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs new file mode 100644 index 000000000..9945669f6 --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/script_migrations.rs @@ -0,0 +1,58 @@ +use std::fs; + +use super::super::script_migrations::is_legacy_stock_script; +use super::support::test_root; + +const LEGACY_BLUE_LIGHT_ON: &[u8] = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"); + +#[test] +fn exact_legacy_stock_script_is_recognized() { + let root = test_root("legacy-stock-script"); + let path = root.join("unixnotis-blue-light-on"); + fs::create_dir_all(&root).expect("legacy helper directory"); + fs::write(&path, LEGACY_BLUE_LIGHT_ON).expect("write legacy helper"); + + assert!(is_legacy_stock_script( + &path, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn edited_legacy_script_is_not_recognized_as_stock() { + let root = test_root("edited-legacy-script"); + let path = root.join("unixnotis-blue-light-on"); + fs::create_dir_all(&root).expect("edited helper directory"); + let mut edited = LEGACY_BLUE_LIGHT_ON.to_vec(); + edited.extend_from_slice(b"\n# local setting\n"); + fs::write(&path, edited).expect("write edited helper"); + + assert!(!is_legacy_stock_script( + &path, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn legacy_bytes_reached_through_a_same_length_symlink_are_not_stock() { + use std::os::unix::fs::symlink; + + let root = test_root("linked-legacy-script"); + fs::create_dir_all(&root).expect("linked helper directory"); + let fixture = root.join("fixture"); + let link = root.join("unixnotis-blue-light-on"); + fs::write(&fixture, LEGACY_BLUE_LIGHT_ON).expect("write linked helper target"); + let link_target = format!("{}fixture", "./".repeat(197)); + assert_eq!(link_target.len(), LEGACY_BLUE_LIGHT_ON.len()); + symlink(link_target, &link).expect("link legacy helper"); + + assert!(!is_legacy_stock_script( + &link, + "scripts/unixnotis-blue-light-on" + )); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/loading/tests/io/scripts.rs b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs similarity index 75% rename from crates/unixnotis-core/src/config/loading/tests/io/scripts.rs rename to crates/unixnotis-core/src/config/loading/io/tests/scripts.rs index a2b28af20..8811e927e 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/scripts.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/scripts.rs @@ -1,3 +1,5 @@ +//! Tests for provisioning built-in helper scripts + use std::fs; use crate::Config; @@ -75,6 +77,34 @@ fn ensure_default_scripts_in_preserves_user_edited_script_contents() { let _ = fs::remove_dir_all(&root); } +#[test] +fn ensure_default_scripts_in_upgrades_exact_legacy_blue_light_helpers() { + let root = test_root("default-script-upgrade"); + let _ = fs::remove_dir_all(&root); + let legacy_lib = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-lib-v1"); + let legacy_on = + include_bytes!("../../../../../assets/scripts/legacy/unixnotis-blue-light-on-v1"); + let scripts = root.join("scripts"); + fs::create_dir_all(&scripts).expect("script directory"); + fs::write(scripts.join("unixnotis-blue-light-lib"), legacy_lib).expect("legacy library"); + fs::write(scripts.join("unixnotis-blue-light-on"), legacy_on).expect("legacy on helper"); + + Config::ensure_default_scripts_in(&root).expect("upgrade stock scripts"); + + for name in ["unixnotis-blue-light-lib", "unixnotis-blue-light-on"] { + let expected = crate::DEFAULT_SCRIPTS + .iter() + .find(|script| script.relative_path.ends_with(name)) + .expect("current stock helper"); + assert_eq!( + fs::read(scripts.join(name)).expect("upgraded helper"), + expected.contents.as_bytes() + ); + } + let _ = fs::remove_dir_all(root); +} + #[cfg(unix)] #[test] fn ensure_default_scripts_in_rejects_symlink_without_changing_external_permissions() { @@ -123,19 +153,22 @@ fn enabled_default_script_commands_have_shipped_files() { .filter(|toggle| toggle.enabled) { for command in [ - toggle.state_cmd.as_deref(), - toggle.toggle_cmd.as_deref(), - toggle.on_cmd.as_deref(), - toggle.off_cmd.as_deref(), - toggle.watch_cmd.as_deref(), + toggle.state_cmd.as_ref(), + toggle.toggle_cmd.as_ref(), + toggle.on_cmd.as_ref(), + toggle.off_cmd.as_ref(), + toggle.watch_cmd.as_ref(), ] .into_iter() .flatten() { - if command.starts_with("scripts/") { + let Some(program) = command.program().and_then(std::path::Path::to_str) else { + continue; + }; + if program.starts_with("scripts/") { assert!( - shipped.contains(&command), - "default command must be shipped: {command}" + shipped.contains(&program), + "default command must be shipped: {program}" ); } } diff --git a/crates/unixnotis-core/src/config/loading/tests/io/support.rs b/crates/unixnotis-core/src/config/loading/io/tests/support.rs similarity index 95% rename from crates/unixnotis-core/src/config/loading/tests/io/support.rs rename to crates/unixnotis-core/src/config/loading/io/tests/support.rs index 2ab50664e..5099913e4 100644 --- a/crates/unixnotis-core/src/config/loading/tests/io/support.rs +++ b/crates/unixnotis-core/src/config/loading/io/tests/support.rs @@ -1,3 +1,5 @@ +//! Shared filesystem and environment support for configuration I/O tests + use std::env; use std::ffi::{OsStr, OsString}; use std::path::PathBuf; diff --git a/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs new file mode 100644 index 000000000..a3da94b4d --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/tests/theme_contract.rs @@ -0,0 +1,169 @@ +use std::fs; + +use crate::{ThemeContractState, ThemeIncompatibility, ThemeManifest, THEME_API_VERSION}; + +use super::support::test_root; + +fn theme_root(name: &str) -> std::path::PathBuf { + let root = test_root(name); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("temporary theme root should be creatable"); + root +} + +fn theme_paths(root: &std::path::Path) -> crate::ThemePaths { + crate::Config::default() + .resolve_theme_paths_from(root) + .expect("theme paths should resolve") +} + +#[test] +fn missing_manifest_is_incompatible_for_export_review_without_creating_files() { + let root = theme_root("theme-contract-stock"); + let paths = theme_paths(&root); + + let state = paths.inspect_theme_contract(); + + assert_eq!( + state, + ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest) + ); + assert!(!paths.manifest_path().exists()); + assert!(!paths.base_css.exists()); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn matching_manifest_enables_existing_custom_theme() { + let root = theme_root("theme-contract-compatible"); + let paths = theme_paths(&root); + fs::write(&paths.base_css, "/* custom */").expect("custom CSS should be writable"); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"Night Glass\"\n"), + ) + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Compatible(ThemeManifest { + api_version: THEME_API_VERSION, + name: "Night Glass".to_string(), + }) + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn existing_theme_without_manifest_is_incompatible_and_unchanged() { + let root = theme_root("theme-contract-missing"); + let paths = theme_paths(&root); + let original = "/* preserve this exact theme */"; + fs::write(&paths.panel_css, original).expect("custom CSS should be writable"); + + let state = paths.inspect_theme_contract(); + + assert_eq!( + state, + ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest) + ); + assert_eq!( + fs::read_to_string(&paths.panel_css).expect("custom CSS should remain readable"), + original + ); + assert!(!paths.manifest_path().exists()); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn unsupported_manifest_version_falls_back_without_rewriting_theme() { + let root = theme_root("theme-contract-version"); + let paths = theme_paths(&root); + let original = "/* older theme */"; + fs::write(&paths.base_css, original).expect("custom CSS should be writable"); + fs::write(paths.manifest_path(), "api_version = 1\nname = \"Old\"\n") + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::UnsupportedVersion { found: 1 }) + ); + assert_eq!( + fs::read_to_string(&paths.base_css).expect("custom CSS should remain readable"), + original + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn blank_or_control_character_theme_names_are_incompatible() { + let root = theme_root("theme-contract-invalid-names"); + let paths = theme_paths(&root); + for name in [" ", "Bad\\tName"] { + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{name}\"\n"), + ) + .expect("theme manifest should be writable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName) + ); + } + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[test] +fn theme_name_length_accepts_the_limit_and_rejects_the_next_character() { + let root = theme_root("theme-contract-name-limit"); + let paths = theme_paths(&root); + let maximum_name = "a".repeat(128); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{maximum_name}\"\n"), + ) + .expect("theme manifest should be writable"); + assert!(matches!( + paths.inspect_theme_contract(), + ThemeContractState::Compatible(_) + )); + + let oversized_name = "a".repeat(129); + fs::write( + paths.manifest_path(), + format!("api_version = {THEME_API_VERSION}\nname = \"{oversized_name}\"\n"), + ) + .expect("theme manifest should be writable"); + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName) + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); +} + +#[cfg(unix)] +#[test] +fn linked_manifest_is_rejected_without_following_its_target() { + use std::os::unix::fs::symlink; + + let root = theme_root("theme-contract-linked"); + let outside = theme_root("theme-contract-linked-outside"); + let paths = theme_paths(&root); + fs::write(&paths.base_css, "/* custom */").expect("custom CSS should be writable"); + let target = outside.join("theme.toml"); + fs::write(&target, "api_version = 2\nname = \"Linked\"\n") + .expect("outside manifest should be writable"); + symlink(&target, paths.manifest_path()).expect("manifest symlink should be creatable"); + + assert_eq!( + paths.inspect_theme_contract(), + ThemeContractState::Incompatible(ThemeIncompatibility::UnreadableManifest) + ); + assert_eq!( + fs::read_to_string(target).expect("outside manifest should remain readable"), + "api_version = 2\nname = \"Linked\"\n" + ); + fs::remove_dir_all(root).expect("temporary theme root should be removable"); + fs::remove_dir_all(outside).expect("temporary outside root should be removable"); +} diff --git a/crates/unixnotis-core/src/config/loading/io/theme_contract.rs b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs new file mode 100644 index 000000000..3d989a54e --- /dev/null +++ b/crates/unixnotis-core/src/config/loading/io/theme_contract.rs @@ -0,0 +1,95 @@ +//! Read-only metadata contract for exported theme directories + +use std::io::ErrorKind; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::filesystem::read_regular_file_bounded; + +use super::ThemePaths; + +/// Theme contract understood by this release +pub const THEME_API_VERSION: u32 = 2; + +const THEME_MANIFEST_FILE: &str = "theme.toml"; +const MAX_THEME_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_THEME_NAME_CHARS: usize = 128; + +/// Manifest written beside an exported theme directory +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeManifest { + pub api_version: u32, + pub name: String, +} + +/// Reason an exported theme directory is not self-describing +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ThemeIncompatibility { + MissingManifest, + UnreadableManifest, + InvalidManifest, + UnsupportedVersion { found: u32 }, + InvalidName, +} + +/// Result of inspecting exported theme directory metadata +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ThemeContractState { + Compatible(ThemeManifest), + Incompatible(ThemeIncompatibility), +} + +impl ThemeContractState { + /// Return whether the exported directory metadata is incompatible + #[must_use] + pub const fn is_incompatible(&self) -> bool { + matches!(self, Self::Incompatible(_)) + } +} + +impl ThemePaths { + /// Return the manifest anchored beside exported theme files + #[must_use] + pub fn manifest_path(&self) -> PathBuf { + self.base_dir.join(THEME_MANIFEST_FILE) + } + + /// Inspect exported theme metadata without creating or changing files + #[must_use] + pub fn inspect_theme_contract(&self) -> ThemeContractState { + let manifest_path = self.manifest_path(); + let contents = match read_regular_file_bounded(&manifest_path, MAX_THEME_MANIFEST_BYTES) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => { + return ThemeContractState::Incompatible(ThemeIncompatibility::MissingManifest); + } + Err(_error) => { + return ThemeContractState::Incompatible(ThemeIncompatibility::UnreadableManifest); + } + }; + let Ok(contents) = std::str::from_utf8(&contents) else { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidManifest); + }; + let Ok(mut manifest) = toml::from_str::(contents) else { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidManifest); + }; + if manifest.api_version != THEME_API_VERSION { + return ThemeContractState::Incompatible(ThemeIncompatibility::UnsupportedVersion { + found: manifest.api_version, + }); + } + + // A bounded printable name keeps diagnostics useful without becoming another payload + manifest.name = manifest.name.trim().to_string(); + if manifest.name.is_empty() + || manifest.name.chars().count() > MAX_THEME_NAME_CHARS + || manifest.name.chars().any(char::is_control) + { + return ThemeContractState::Incompatible(ThemeIncompatibility::InvalidName); + } + + ThemeContractState::Compatible(manifest) + } +} diff --git a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs index 5eec9c3c0..d25cd5458 100644 --- a/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs +++ b/crates/unixnotis-core/src/config/loading/tests/diagnostics.rs @@ -2,6 +2,7 @@ use std::io::{self, Write}; use std::sync::{Arc, Mutex}; use super::*; +use crate::CommandSpec; use crate::{Config, ConfigDiagnosticKind, CURRENT_CONFIG_VERSION}; struct CapturedWriter(Arc>>); @@ -20,34 +21,26 @@ impl Write for CapturedWriter { } #[test] -fn migration_diagnostic_reports_unversioned_input_without_exposing_text() { - let diagnostic = migration_diagnostic("[panel]\ntitle = 'private title'\n") - .expect("unversioned config should report migration"); - - assert_eq!(diagnostic.code, "config.schema.migrated"); - assert_eq!(diagnostic.original.as_deref(), Some("0")); - assert_eq!( - diagnostic.effective.as_deref(), - Some(CURRENT_CONFIG_VERSION.to_string().as_str()) +fn current_empty_exact_media_policy_emits_a_warning() { + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[media]\nlocal_art_policy = \"exact_executable_only\"\n" ); - assert!(!diagnostic.message.contains("private title")); -} - -#[test] -fn current_schema_produces_no_migration_diagnostic() { - let input = format!("config_version = {CURRENT_CONFIG_VERSION}\n"); - - assert!(migration_diagnostic(&input).is_none()); + let report = Config::parse_with_report(&input).expect("current config should parse"); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "config.media.empty-exact-allowlist" + && diagnostic.kind == ConfigDiagnosticKind::Warning + })); } #[test] fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { let mut before = Config::default(); before.widgets.refresh_interval_ms = 1; - before.widgets.volume.get_cmd = "private-volume-command-sentinel".to_string(); + before.widgets.volume.get_cmd = + CommandSpec::direct("private-volume-command-sentinel", [] as [&str; 0]); let mut after = before.clone(); after.widgets.refresh_interval_ms = 100; - after.widgets.volume.get_cmd = "pactl get-sink-volume".to_string(); + after.widgets.volume.get_cmd = CommandSpec::direct("pactl", ["get-sink-volume"]); let diagnostics = adjustment_diagnostics(&before, &after); @@ -57,7 +50,9 @@ fn adjustment_diagnostics_report_safe_scalar_changes_and_hide_commands() { && item.effective.as_deref() == Some("100") })); assert!(diagnostics.iter().any(|item| { - item.path.as_deref() == Some("widgets.volume.get_cmd") + item.path + .as_deref() + .is_some_and(|path| path.starts_with("widgets.volume.get_cmd")) && item.code == "config.widgets.volume-backend-selected" })); let rendered = format!("{diagnostics:?}"); @@ -74,20 +69,6 @@ fn unknown_key_diagnostic_uses_stable_code_and_warning_kind() { assert_eq!(diagnostic.path.as_deref(), Some("panel.search_visble")); } -#[test] -fn legacy_migration_reports_each_inserted_compatibility_path() { - let report = Config::parse_with_report("").expect("empty legacy config should migrate"); - - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "config.schema.field-migrated" - && diagnostic.path.as_deref() == Some("panel.empty_offset_top") - })); - assert!(report.diagnostics.iter().any(|diagnostic| { - diagnostic.code == "config.schema.field-migrated" - && diagnostic.path.as_deref() == Some("media.art_size_px") - })); -} - #[test] fn array_adjustments_report_length_and_changed_items_exactly_once() { let before = Value::Array(vec![Value::Integer(1)]); @@ -215,7 +196,7 @@ fn safe_values_distinguish_finite_and_non_finite_numbers() { } #[test] -fn compatibility_logger_emits_each_diagnostic() { +fn diagnostic_logger_emits_each_current_diagnostic() { let output = Arc::new(Mutex::new(Vec::new())); let writer_output = output.clone(); let subscriber = tracing_subscriber::fmt() @@ -225,7 +206,7 @@ fn compatibility_logger_emits_each_diagnostic() { .finish(); let diagnostics = vec![ unknown_key_diagnostic("panel.unknown".to_string()), - migrated_field_diagnostic("panel.width".to_string()), + unknown_key_diagnostic("media.unknown".to_string()), ]; tracing::subscriber::with_default(subscriber, || { @@ -240,5 +221,6 @@ fn compatibility_logger_emits_each_diagnostic() { ) .expect("diagnostic output should be UTF-8"); assert!(rendered.contains("config.unknown-key")); - assert!(rendered.contains("config.schema.field-migrated")); + assert!(rendered.contains("panel.unknown")); + assert!(rendered.contains("media.unknown")); } diff --git a/crates/unixnotis-core/src/config/loading/tests/io/load.rs b/crates/unixnotis-core/src/config/loading/tests/io/load.rs deleted file mode 100644 index af393f6b4..000000000 --- a/crates/unixnotis-core/src/config/loading/tests/io/load.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::fs; - -use crate::{Config, ConfigError}; - -use super::support::{env_lock, test_root, EnvGuard}; - -#[test] -fn load_from_path_reads_toml_and_applies_runtime_defaults() { - let root = test_root("load-from-path"); - // Start from a clean root so the test only sees the TOML written below - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("config dir"); - let path = root.join("config.toml"); - // Deliberately use too-small refresh intervals to prove load sanitization still runs - fs::write( - &path, - r#" - [panel] - title = "Loaded Title" - - [widgets] - refresh_interval_ms = 1 - refresh_interval_slow_ms = 50 - "#, - ) - .expect("config file"); - - let config = Config::load_from_path(&path).expect("config should load"); - - // User text should survive loading while runtime defaults repair unsafe timing values - assert_eq!(config.panel.title, "Loaded Title"); - assert_eq!(config.widgets.refresh_interval_ms, 100); - assert_eq!(config.widgets.refresh_interval_slow_ms, 100); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn load_from_path_returns_parse_error_for_invalid_toml() { - let root = test_root("load-invalid"); - // Parse failures should come from the target file, not from leftover temp data - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("config dir"); - let path = root.join("config.toml"); - fs::write(&path, "[panel\n").expect("invalid config"); - - let err = Config::load_from_path(&path).expect_err("invalid toml should fail"); - - assert!(matches!(err, ConfigError::ParseFailed(_))); - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn shareable_error_summaries_never_echo_private_error_details() { - let error = ConfigError::ParseFailed("secret_command = 'private-parser-sentinel'".to_string()); - - let summary = error.shareable_summary(); - - assert_eq!(summary, "Configuration TOML or schema is invalid"); - assert!(!summary.contains("secret_command")); - assert!(!summary.contains("private-parser-sentinel")); -} - -#[test] -fn load_default_reads_config_when_default_file_exists() { - let _guard = env_lock(); - let root = test_root("load-default-existing"); - // Default-path discovery reads process-global env, so this test owns the env lock - let _ = fs::remove_dir_all(&root); - let config_dir = root.join("unixnotis"); - fs::create_dir_all(&config_dir).expect("config dir"); - fs::write( - config_dir.join("config.toml"), - r#" - [panel] - title = "Default Path Title" - "#, - ) - .expect("default config file"); - - let _xdg = EnvGuard::set("XDG_CONFIG_HOME", root.as_os_str()); - let _home = EnvGuard::set("HOME", root.as_os_str()); - // This exercises the public default loader instead of the explicit-path helper - let config = Config::load_default().expect("default config should load"); - - assert_eq!(config.panel.title, "Default Path Title"); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn load_default_returns_sanitized_stock_config_when_file_is_missing() { - let _guard = env_lock(); - let root = test_root("load-default-missing"); - // Missing config should not require creating the config directory first - let _ = fs::remove_dir_all(&root); - - let _xdg = EnvGuard::set("XDG_CONFIG_HOME", root.as_os_str()); - let _home = EnvGuard::set("HOME", root.as_os_str()); - let config = Config::load_default().expect("missing config should fall back"); - - // Fallback config still passes through the runtime sanitizer - assert_eq!(config.panel.title, crate::PanelConfig::default().title); - assert_eq!(config.widgets.refresh_interval_ms, 1000); - - let _ = fs::remove_dir_all(root); -} diff --git a/crates/unixnotis-core/src/config/loading/tests/io/mod.rs b/crates/unixnotis-core/src/config/loading/tests/io/mod.rs deleted file mode 100644 index 94c531119..000000000 --- a/crates/unixnotis-core/src/config/loading/tests/io/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Config I/O test declarations - -mod load; -mod paths; -mod scripts; -mod support; -mod theme_files; -mod write; diff --git a/crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs b/crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs deleted file mode 100644 index 4721a556b..000000000 --- a/crates/unixnotis-core/src/config/loading/tests/io/theme_files.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::fs; - -use crate::Config; - -use super::support::test_root; - -#[test] -fn ensure_theme_files_writes_missing_files_and_renames_legacy_style() { - let root = test_root("theme-files"); - // Legacy style.css should be migrated only when base.css does not exist yet - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - fs::write(root.join("style.css"), "/* custom legacy */").expect("legacy css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - // The legacy stylesheet becomes the new base stylesheet and leaves a backup marker - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - "/* custom legacy */" - ); - assert!(paths.panel_css.exists()); - assert!(paths.popup_css.exists()); - assert!(paths.widgets_css.exists()); - assert!(paths.media_css.exists()); - assert!(root.join("style.css.bak").exists()); - assert!(!root.join("style.css").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_preserves_existing_base_css() { - let root = test_root("theme-preserve"); - // Existing base.css is user-owned and must win over legacy migration - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - fs::write(&paths.base_css, "/* keep */").expect("existing base css"); - fs::write(root.join("style.css"), "/* legacy ignored */").expect("legacy css"); - - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - assert_eq!( - fs::read_to_string(&paths.base_css).expect("base css"), - "/* keep */" - ); - assert!(root.join("style.css").exists()); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn ensure_theme_files_keeps_legacy_style_when_backup_already_exists() { - let root = test_root("theme-backup-exists"); - // A pre-existing backup means migration already happened or was handled by the user - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("theme root"); - fs::write(root.join("style.css"), "/* keep legacy */").expect("legacy css"); - fs::write(root.join("style.css.bak"), "/* keep backup */").expect("backup css"); - - let config = Config::default(); - let paths = config - .resolve_theme_paths_from(&root) - .expect("theme paths should resolve"); - config - .ensure_theme_files(&paths) - .expect("theme files should be provisioned"); - - // Both legacy and backup files should remain untouched in this conservative path - assert_eq!( - fs::read_to_string(root.join("style.css")).expect("legacy css"), - "/* keep legacy */" - ); - assert_eq!( - fs::read_to_string(root.join("style.css.bak")).expect("backup css"), - "/* keep backup */" - ); - - let _ = fs::remove_dir_all(root); -} diff --git a/crates/unixnotis-core/src/config/loading/tests/io/write.rs b/crates/unixnotis-core/src/config/loading/tests/io/write.rs deleted file mode 100644 index a01887575..000000000 --- a/crates/unixnotis-core/src/config/loading/tests/io/write.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::fs; - -use super::support::test_root; - -#[test] -fn write_if_missing_preserves_existing_contents() { - let root = test_root("write-if-missing"); - // Existing files should be treated as user-owned content - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("root"); - let path = root.join("file.txt"); - fs::write(&path, "keep").expect("existing file"); - - super::super::write_if_missing(&path, "replace").expect("write should succeed"); - - assert_eq!(fs::read_to_string(&path).expect("file contents"), "keep"); - - let _ = fs::remove_dir_all(root); -} - -#[test] -fn write_if_missing_creates_new_file() { - let root = test_root("write-if-missing-create"); - // Missing files are safe for bootstrap helpers to create - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("root"); - let path = root.join("file.txt"); - - super::super::write_if_missing(&path, "created").expect("write should succeed"); - - assert_eq!(fs::read_to_string(&path).expect("file contents"), "created"); - - let _ = fs::remove_dir_all(root); -} diff --git a/crates/unixnotis-core/src/config/media/defaults.rs b/crates/unixnotis-core/src/config/media/defaults.rs index 088f44881..f103a020e 100644 --- a/crates/unixnotis-core/src/config/media/defaults.rs +++ b/crates/unixnotis-core/src/config/media/defaults.rs @@ -1,6 +1,6 @@ use super::types::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, - MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, }; // Compact artwork leaves more horizontal space for title metadata and controls @@ -45,6 +45,9 @@ impl Default for MediaConfig { denylist: vec!["playerctld".to_string()], // Browsers stay opt-in because webpage metadata can choose artwork URLs remote_art_policy: MediaRemoteArtPolicy::NativeOnly, + // Native players regain the normal cover-art behavior; browser local paths remain denied + local_art_policy: MediaLocalArtPolicy::AllAdmitted, + local_art_executable_allowlist: Vec::new(), } } } diff --git a/crates/unixnotis-core/src/config/media/mod.rs b/crates/unixnotis-core/src/config/media/mod.rs index eb871600b..5dc34120e 100644 --- a/crates/unixnotis-core/src/config/media/mod.rs +++ b/crates/unixnotis-core/src/config/media/mod.rs @@ -13,6 +13,6 @@ pub use self::defaults::{ DEFAULT_MEDIA_ART_SIZE_PX, DEFAULT_MEDIA_TEXT_WIDTH_FLOOR_PX, }; pub use self::types::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, - MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, MediaPositionFormat, MediaRemoteArtPolicy, MediaTitleFallback, }; diff --git a/crates/unixnotis-core/src/config/media/tests/effective.rs b/crates/unixnotis-core/src/config/media/tests/effective.rs index f0d74fc59..4e75cbe25 100644 --- a/crates/unixnotis-core/src/config/media/tests/effective.rs +++ b/crates/unixnotis-core/src/config/media/tests/effective.rs @@ -1,7 +1,23 @@ use crate::{ - MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaNavigationPosition, + MediaArtPosition, MediaConfig, MediaControlsPosition, MediaLayout, MediaLocalArtPolicy, + MediaNavigationPosition, }; +#[test] +fn native_local_art_is_enabled_by_default() { + assert_eq!( + MediaConfig::default().local_art_policy, + MediaLocalArtPolicy::AllAdmitted + ); +} + +#[test] +fn stock_media_serialization_names_native_art_policy_and_omits_empty_allowlist() { + let serialized = toml::to_string(&MediaConfig::default()).expect("serialize media config"); + assert!(serialized.contains("local_art_policy = \"all_admitted\"")); + assert!(!serialized.contains("local_art_executable_allowlist")); +} + #[test] fn preset_defaults_stay_stable() { let mut config = MediaConfig { diff --git a/crates/unixnotis-core/src/config/media/types.rs b/crates/unixnotis-core/src/config/media/types.rs index 357a9289d..dd42c93d9 100644 --- a/crates/unixnotis-core/src/config/media/types.rs +++ b/crates/unixnotis-core/src/config/media/types.rs @@ -72,6 +72,11 @@ pub struct MediaConfig { pub denylist: Vec, /// Controls which players may trigger remote media artwork fetches pub remote_art_policy: MediaRemoteArtPolicy, + /// Controls which players may use local file paths for artwork + pub local_art_policy: MediaLocalArtPolicy, + /// Exact executable paths allowed for local artwork (device/inode verified) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub local_art_executable_allowlist: Vec, } #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] @@ -86,6 +91,18 @@ pub enum MediaRemoteArtPolicy { BrowsersToo, } +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum MediaLocalArtPolicy { + /// Disable local artwork fetches for every player + Disabled, + /// Allow local artwork only for players whose executable matches the allowlist + ExactExecutableOnly, + /// Allow local artwork for all admitted players + #[default] + AllAdmitted, +} + #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum MediaLayout { diff --git a/crates/unixnotis-core/src/config/mod.rs b/crates/unixnotis-core/src/config/mod.rs index 38609277a..1267a6149 100644 --- a/crates/unixnotis-core/src/config/mod.rs +++ b/crates/unixnotis-core/src/config/mod.rs @@ -4,17 +4,19 @@ mod appearance; mod command; +mod installer_settings; mod layout; mod loading; mod media; mod panel; +mod reset; mod runtime; mod types; mod validation; mod widgets; -pub(in crate::config) use appearance::{icon_assets, theme}; -pub use command::{parse_command, CommandParseError, ExecutionMode, ParsedCommand}; +pub(in crate::config) use appearance::{corners, icon_assets, theme}; +pub use corners::CutCorners; pub use diagnostics::{ log_config_diagnostics, ConfigDiagnostic, ConfigDiagnosticKind, ConfigLoadReport, }; @@ -25,11 +27,21 @@ pub use icon_assets::{ ResolvedIconAsset, DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_BYTES, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, DEFAULT_ICON_ASSET_MAX_WIDTH, }; -pub use io::{ConfigError, ThemePaths}; +pub use installer_settings::{ + ensure_installer_config, installer_config_path, load_installer_config, BackupConfig, + InstallerConfig, DEFAULT_BACKUP_RETENTION, INSTALLER_CONFIG_FILE, +}; +pub use io::{ + ConfigError, ThemeContractState, ThemeIncompatibility, ThemeManifest, ThemePaths, + MAX_CONFIG_BYTES, THEME_API_VERSION, +}; pub use layout::*; pub(in crate::config) use loading::{diagnostics, io}; pub use media::*; pub use panel::*; +pub use reset::{ + render_default_config_toml, reset_config_to_defaults, ResetConfigOptions, ResetConfigReport, +}; pub use rules::*; pub use runtime::{MAX_CARD_WIDGETS, MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, MAX_TOTAL_WIDGETS}; pub use theme::*; diff --git a/crates/unixnotis-core/src/config/panel/config.rs b/crates/unixnotis-core/src/config/panel/config.rs index befe0874c..c8b17b767 100644 --- a/crates/unixnotis-core/src/config/panel/config.rs +++ b/crates/unixnotis-core/src/config/panel/config.rs @@ -4,11 +4,17 @@ use serde::{Deserialize, Serialize}; use super::super::{Anchor, Margins, PanelKeyboardInteractivity, PANEL_HEIGHT_PERCENT_DEFAULT}; use super::{ - default_panel_action_order, default_panel_section_order, default_panel_widget_order, - EmptyStateAlignment, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, PanelSection, - PanelWidgetSection, + default_dnd_menu_choices, default_dnd_menu_triggers, default_panel_action_order, + default_panel_section_order, default_panel_widget_order, DndMenuChoice, DndMenuTrigger, + EmptyStateAlignment, NotificationMetadataConfig, PanelActionConfig, PanelActionId, + PanelClearButtonPlacement, PanelSection, PanelWidgetSection, }; +// Conversation photos are useful context and stay enabled unless explicitly hidden +const fn default_notification_avatars_visible() -> bool { + true +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct PanelConfig { @@ -26,24 +32,35 @@ pub struct PanelConfig { pub output: Option, /// Text shown when the notification list is empty pub empty_text: String, + /// Text shown when an active search has no matching notifications + pub no_matching_text: String, /// Main heading shown in the panel header pub title: String, /// Secondary text shown below the main heading pub subtitle: String, /// Placeholder text shown in the panel search entry pub search_placeholder: String, + /// GTK icon-theme name used by the UnixNotis-owned search magnifier + pub search_magnifier_icon: String, /// Show the search entry without requiring the search toggle first pub search_visible: bool, /// Show the compact utility action row below the header pub action_row_visible: bool, + /// Disable panel motion effects without requiring GTK 4.20 media queries + pub reduced_motion: bool, /// Wrap the notification list in a titled section pub notification_section_visible: bool, /// Let the notification list consume remaining vertical panel space pub notification_list_expand: bool, /// Show optional notification metadata lanes pub notification_metadata_visible: bool, + /// Text and compact templates rendered inside notification metadata lanes + pub notification_metadata: NotificationMetadataConfig, /// Show optional notification image thumbnails in panel rows pub notification_thumbnails_visible: bool, + /// Show bounded conversation avatars in the master-style row image slot + #[serde(default = "default_notification_avatars_visible")] + pub notification_avatars_visible: bool, /// Where the "clear all" action is rendered pub clear_button_placement: PanelClearButtonPlacement, /// Heading shown above toggle-style quick actions @@ -66,6 +83,10 @@ pub struct PanelConfig { pub focus_action: PanelActionConfig, /// Do-not-disturb action customization pub dnd_action: PanelActionConfig, + /// Input gestures that open the timed DND menu + pub dnd_menu_triggers: Vec, + /// Typed deadlines shown in the timed DND menu + pub dnd_menu_choices: Vec, /// Clear-notifications action customization pub clear_action: PanelActionConfig, /// Search action customization @@ -102,15 +123,20 @@ impl Default for PanelConfig { keyboard_interactivity: PanelKeyboardInteractivity::OnDemand, output: None, empty_text: "NO NOTIFICATIONS".to_string(), + no_matching_text: "NO MATCHING NOTIFICATIONS".to_string(), title: "Notifications".to_string(), subtitle: String::new(), search_placeholder: "Search app, title, or message".to_string(), + search_magnifier_icon: "system-search-symbolic".to_string(), search_visible: false, action_row_visible: true, + reduced_motion: false, notification_section_visible: false, notification_list_expand: true, notification_metadata_visible: false, + notification_metadata: NotificationMetadataConfig::default(), notification_thumbnails_visible: false, + notification_avatars_visible: true, clear_button_placement: PanelClearButtonPlacement::ActionRow, quick_actions_label: "Quick settings".to_string(), system_status_label: "System health".to_string(), @@ -122,6 +148,8 @@ impl Default for PanelConfig { action_order: default_panel_action_order(), focus_action: PanelActionConfig::widgets(), dnd_action: PanelActionConfig::dnd(), + dnd_menu_triggers: default_dnd_menu_triggers(), + dnd_menu_choices: default_dnd_menu_choices(), clear_action: PanelActionConfig::clear(), search_action: PanelActionConfig::search(), close_action: PanelActionConfig::close(), diff --git a/crates/unixnotis-core/src/config/panel/dnd.rs b/crates/unixnotis-core/src/config/panel/dnd.rs new file mode 100644 index 000000000..598e7855b --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/dnd.rs @@ -0,0 +1,79 @@ +//! Timed Do Not Disturb menu configuration + +use serde::{Deserialize, Serialize}; + +/// Input gestures that can open the timed DND menu +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum DndMenuTrigger { + RightClick, + LongPress, + Keyboard, +} + +/// One typed deadline shown in the timed DND menu +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum DndMenuChoice { + /// Enable DND for a relative number of minutes + Duration { label: String, minutes: u32 }, + /// Enable DND until a clock time on the next local calendar day + Tomorrow { label: String, hour: u8, minute: u8 }, + /// Enable DND without an expiration deadline + Indefinite { label: String }, +} + +impl DndMenuChoice { + /// Return the user-facing menu label + #[must_use] + pub fn label(&self) -> &str { + match self { + Self::Duration { label, .. } + | Self::Tomorrow { label, .. } + | Self::Indefinite { label } => label, + } + } + + /// Return mutable access to the user-facing menu label + pub(in crate::config) const fn label_mut(&mut self) -> &mut String { + match self { + Self::Duration { label, .. } + | Self::Tomorrow { label, .. } + | Self::Indefinite { label } => label, + } + } +} + +/// Return the stock DND menu input policy +#[must_use] +pub fn default_dnd_menu_triggers() -> Vec { + // Secondary click is the only default path so ordinary pointer use stays quiet + vec![DndMenuTrigger::RightClick] +} + +/// Return the stock DND deadline menu +#[must_use] +pub fn default_dnd_menu_choices() -> Vec { + vec![ + DndMenuChoice::Duration { + label: "30 minutes".to_string(), + minutes: 30, + }, + DndMenuChoice::Duration { + label: "1 hour".to_string(), + minutes: 60, + }, + DndMenuChoice::Duration { + label: "2 hours".to_string(), + minutes: 120, + }, + DndMenuChoice::Tomorrow { + label: "Until tomorrow morning".to_string(), + hour: 8, + minute: 0, + }, + DndMenuChoice::Indefinite { + label: "Indefinitely".to_string(), + }, + ] +} diff --git a/crates/unixnotis-core/src/config/panel/metadata.rs b/crates/unixnotis-core/src/config/panel/metadata.rs new file mode 100644 index 000000000..79a5e9eb9 --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/metadata.rs @@ -0,0 +1,45 @@ +//! Configurable notification metadata text + +use serde::{Deserialize, Serialize}; + +/// Text and compact templates used by optional notification metadata lanes +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct NotificationMetadataConfig { + pub critical_label: String, + pub low_label: String, + pub normal_label: String, + pub relative_now: String, + /// Minute template where `{value}` is replaced with the elapsed count + pub relative_minutes: String, + /// Hour template where `{value}` is replaced with the elapsed count + pub relative_hours: String, + /// Day template where `{value}` is replaced with the elapsed count + pub relative_days: String, + pub transient_label: String, + pub live_label: String, + pub history_label: String, + /// Singular template where `{count}` is replaced with one + pub action_count_one: String, + /// Plural template where `{count}` is replaced with the visible action count + pub action_count_many: String, +} + +impl Default for NotificationMetadataConfig { + fn default() -> Self { + Self { + critical_label: "ALERT".to_string(), + low_label: "LOW".to_string(), + normal_label: "NOTICE".to_string(), + relative_now: "now".to_string(), + relative_minutes: "{value}m".to_string(), + relative_hours: "{value}h".to_string(), + relative_days: "{value}d".to_string(), + transient_label: "TRANSIENT".to_string(), + live_label: "LIVE".to_string(), + history_label: "HISTORY".to_string(), + action_count_one: "{count} ACTION".to_string(), + action_count_many: "{count} ACTIONS".to_string(), + } + } +} diff --git a/crates/unixnotis-core/src/config/panel/mod.rs b/crates/unixnotis-core/src/config/panel/mod.rs index a0ceafb5f..06e979976 100644 --- a/crates/unixnotis-core/src/config/panel/mod.rs +++ b/crates/unixnotis-core/src/config/panel/mod.rs @@ -2,14 +2,20 @@ mod actions; mod config; +mod dnd; mod empty; +mod metadata; mod sections; pub use self::actions::{ default_panel_action_order, PanelActionConfig, PanelActionId, PanelClearButtonPlacement, }; pub use self::config::PanelConfig; +pub use self::dnd::{ + default_dnd_menu_choices, default_dnd_menu_triggers, DndMenuChoice, DndMenuTrigger, +}; pub use self::empty::EmptyStateAlignment; +pub use self::metadata::NotificationMetadataConfig; pub use self::sections::{ default_panel_section_order, default_panel_widget_order, PanelSection, PanelWidgetSection, }; diff --git a/crates/unixnotis-core/src/config/panel/tests/config.rs b/crates/unixnotis-core/src/config/panel/tests/config.rs index 020384d8e..3f93d6d25 100644 --- a/crates/unixnotis-core/src/config/panel/tests/config.rs +++ b/crates/unixnotis-core/src/config/panel/tests/config.rs @@ -26,12 +26,16 @@ fn default_panel_config_keeps_expected_layout_and_text_contract() { )); assert_eq!(panel.title, "Notifications"); assert_eq!(panel.empty_text, "NO NOTIFICATIONS"); + assert_eq!(panel.no_matching_text, "NO MATCHING NOTIFICATIONS"); assert_eq!(panel.empty_offset_top, 24); assert_eq!(panel.empty_alignment, EmptyStateAlignment::Auto); assert_eq!(panel.quick_actions_label, "Quick settings"); assert_eq!(panel.system_status_label, "System health"); assert_eq!(panel.search_placeholder, "Search app, title, or message"); + assert_eq!(panel.search_magnifier_icon, "system-search-symbolic"); assert!(panel.action_row_visible); + assert!(!panel.reduced_motion); + assert!(panel.notification_avatars_visible); assert!(panel.notification_list_expand); assert!(panel.close_on_click_outside); assert!(panel.respect_work_area); @@ -45,6 +49,16 @@ fn partial_panel_values_use_current_presentation_defaults() { assert_eq!(panel.quick_actions_label, "Quick settings"); assert_eq!(panel.system_status_label, "System health"); assert_eq!(panel.empty_offset_top, 24); + assert!(!panel.reduced_motion); + assert!(panel.notification_avatars_visible); +} + +#[test] +fn panel_config_parses_reduced_motion_preference() { + let panel: PanelConfig = + toml::from_str("reduced_motion = true").expect("reduced motion should parse"); + + assert!(panel.reduced_motion); } #[test] diff --git a/crates/unixnotis-core/src/config/panel/tests/dnd.rs b/crates/unixnotis-core/src/config/panel/tests/dnd.rs new file mode 100644 index 000000000..61f02430d --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/dnd.rs @@ -0,0 +1,65 @@ +use super::super::*; + +#[test] +fn default_dnd_menu_uses_only_right_click_and_keeps_stock_deadlines() { + assert_eq!( + default_dnd_menu_triggers(), + vec![DndMenuTrigger::RightClick] + ); + assert_eq!(default_dnd_menu_choices().len(), 5); + assert!(matches!( + &default_dnd_menu_choices()[0], + DndMenuChoice::Duration { minutes: 30, .. } + )); + assert!(matches!( + &default_dnd_menu_choices()[3], + DndMenuChoice::Tomorrow { + hour: 8, + minute: 0, + .. + } + )); + assert!(matches!( + &default_dnd_menu_choices()[4], + DndMenuChoice::Indefinite { .. } + )); +} + +#[test] +fn dnd_menu_parses_custom_triggers_and_typed_choices() { + let panel: PanelConfig = toml::from_str( + r#" + dnd_menu_triggers = ["right-click", "keyboard"] + + [[dnd_menu_choices]] + mode = "duration" + label = "Focus block" + minutes = 45 + + [[dnd_menu_choices]] + mode = "tomorrow" + label = "Tomorrow at lunch" + hour = 12 + minute = 30 + + [[dnd_menu_choices]] + mode = "indefinite" + label = "Until disabled" + "#, + ) + .expect("custom DND menu should parse"); + + assert_eq!( + panel.dnd_menu_triggers, + vec![DndMenuTrigger::RightClick, DndMenuTrigger::Keyboard] + ); + assert_eq!(panel.dnd_menu_choices[0].label(), "Focus block"); + assert!(matches!( + panel.dnd_menu_choices[1], + DndMenuChoice::Tomorrow { + hour: 12, + minute: 30, + .. + } + )); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/metadata.rs b/crates/unixnotis-core/src/config/panel/tests/metadata.rs new file mode 100644 index 000000000..8f1f58273 --- /dev/null +++ b/crates/unixnotis-core/src/config/panel/tests/metadata.rs @@ -0,0 +1,37 @@ +use super::super::NotificationMetadataConfig; + +#[test] +fn metadata_defaults_keep_existing_runtime_copy() { + let metadata = NotificationMetadataConfig::default(); + + assert_eq!(metadata.critical_label, "ALERT"); + assert_eq!(metadata.relative_minutes, "{value}m"); + assert_eq!(metadata.live_label, "LIVE"); + assert_eq!(metadata.action_count_one, "{count} ACTION"); + assert_eq!(metadata.action_count_many, "{count} ACTIONS"); +} + +#[test] +fn metadata_text_parses_as_one_nested_panel_block() { + #[derive(serde::Deserialize)] + struct Fixture { + metadata: NotificationMetadataConfig, + } + + let fixture: Fixture = toml::from_str( + r#" + [metadata] + critical_label = "PRIORITY" + relative_hours = "{value} hours ago" + history_label = "ARCHIVE" + action_count_many = "{count} OPTIONS" + "#, + ) + .expect("metadata block should parse"); + + assert_eq!(fixture.metadata.critical_label, "PRIORITY"); + assert_eq!(fixture.metadata.relative_hours, "{value} hours ago"); + assert_eq!(fixture.metadata.history_label, "ARCHIVE"); + assert_eq!(fixture.metadata.action_count_many, "{count} OPTIONS"); + assert_eq!(fixture.metadata.low_label, "LOW"); +} diff --git a/crates/unixnotis-core/src/config/panel/tests/mod.rs b/crates/unixnotis-core/src/config/panel/tests/mod.rs index c46bf825a..75a5229f8 100644 --- a/crates/unixnotis-core/src/config/panel/tests/mod.rs +++ b/crates/unixnotis-core/src/config/panel/tests/mod.rs @@ -2,4 +2,6 @@ use super::*; mod actions; mod config; +mod dnd; +mod metadata; mod sections; diff --git a/crates/unixnotis-core/src/config/reset.rs b/crates/unixnotis-core/src/config/reset.rs new file mode 100644 index 000000000..1ee5129d7 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset.rs @@ -0,0 +1,323 @@ +//! Shared, transactional reset of the user configuration and bundled scripts + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; + +use crate::filesystem::{ + copy_file_atomic, create_directory_all, remove_directory_tree, remove_regular_file, + write_file_atomic, CreateDirectoryOutcome, +}; +use crate::{ + Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, + DEFAULT_SCRIPTS, DEFAULT_WIDGETS_CSS, +}; + +const BACKUP_PREFIX: &str = "Backup-"; +type ResetWriter = dyn Fn(&Path, &[u8], u32) -> std::io::Result<()>; + +/// Inputs for a configuration reset +#[derive(Debug, Clone)] +pub struct ResetConfigOptions { + pub config_dir: PathBuf, + pub backup_retention: usize, +} + +/// Files changed by a reset and the backup made before it +#[derive(Debug, Clone, Default)] +pub struct ResetConfigReport { + pub backup_dir: Option, + pub backed_up_files: Vec, + pub written_files: Vec, +} + +#[derive(Debug, Clone)] +struct OriginalFile { + path: PathBuf, + contents: Vec, + mode: u32, +} + +#[derive(Debug, Clone)] +struct ResetTarget { + path: PathBuf, + contents: Vec, + mode: u32, +} + +/// Render the annotated stock configuration used by both installer frontends +/// +/// # Errors +/// +/// Returns an error when serialization fails or the expected annotated fields +/// are missing from the serialized configuration +pub fn render_default_config_toml(config: &Config) -> Result { + let mut config_toml = toml::to_string_pretty(config).context("serialize default config")?; + let panel_height_line = format!("height = {}\n", config.panel.height); + let panel_height_block = format!( + "# Vertical size as a percent of usable monitor height after margins\n\ +# and reserved work area\n\ +height = {}\n\ +\n\ +# Exact pixel height override for advanced users\n\ +# height_override = 1487\n", + config.panel.height + ); + let reduced_motion_line = format!("reduced_motion = {}\n", config.panel.reduced_motion); + let reduced_motion_block = format!( + "# Disable panel animation and moving text without requiring GTK 4.20\n\ +reduced_motion = {}\n", + config.panel.reduced_motion + ); + if !config_toml.contains(&panel_height_line) { + return Err(anyhow!("default config template missing panel height line")); + } + if !config_toml.contains(&reduced_motion_line) { + return Err(anyhow!( + "default config template missing reduced motion line" + )); + } + config_toml = config_toml.replacen(&panel_height_line, &panel_height_block, 1); + config_toml = config_toml.replacen(&reduced_motion_line, &reduced_motion_block, 1); + Ok(config_toml) +} + +/// Reset config and bundled scripts while retaining a recoverable snapshot +/// +/// # Errors +/// +/// Returns an error when a destination is unsafe, backup or publication fails, +/// or a partial reset cannot be restored +pub fn reset_config_to_defaults(options: &ResetConfigOptions) -> Result { + reset_config_to_defaults_with_writer(options, &write_file_atomic) +} + +fn reset_config_to_defaults_with_writer( + options: &ResetConfigOptions, + write: &ResetWriter, +) -> Result { + reset_config_to_defaults_inner(options, write) +} + +fn reset_config_to_defaults_inner( + options: &ResetConfigOptions, + write: &ResetWriter, +) -> Result { + // Build the default once so every generated file uses one consistent schema + let config = Config::default(); + // Create the parent before validating child destinations + create_directory_all(&options.config_dir, 0o700) + .context("create UnixNotis configuration directory")?; + let theme_paths = config + .resolve_theme_paths_from(&options.config_dir) + .map_err(|error| anyhow!(error.to_string()))?; + let config_path = options.config_dir.join("config.toml"); + let mut paths = vec![config_path.clone()]; + paths.extend([ + theme_paths.base_css.clone(), + theme_paths.panel_css.clone(), + theme_paths.popup_css.clone(), + theme_paths.widgets_css.clone(), + theme_paths.media_css.clone(), + ]); + for script in DEFAULT_SCRIPTS { + paths.push(options.config_dir.join(script.relative_path)); + } + + // Validate every destination before touching the first file + let originals = paths + .iter() + .filter_map(|path| snapshot_existing_file(path).transpose()) + .collect::>>()?; + // The backup is created before any destination is replaced + let backup_dir = create_backup_dir(&options.config_dir, options.backup_retention)?; + let mut report = ResetConfigReport { + backup_dir: backup_dir.clone(), + ..ResetConfigReport::default() + }; + if let Some(backup_dir) = &backup_dir { + for original in &originals { + let destination = backup_dir.join( + original + .path + .file_name() + .ok_or_else(|| anyhow!("configuration path has no file name"))?, + ); + copy_file_atomic(&original.path, &destination) + .with_context(|| format!("backup {}", original.path.display()))?; + report.backed_up_files.push(destination); + } + } + // Prune only after the new backup exists, but before any live file changes + prune_backups( + &options.config_dir, + options.backup_retention, + backup_dir.as_deref(), + ) + .context("prune configuration backups")?; + + // Render all replacement content before starting publication + let config_toml = render_default_config_toml(&config)?; + let mut targets = vec![ResetTarget { + path: config_path, + contents: config_toml.into_bytes(), + mode: 0o644, + }]; + // Reset every active stylesheet so file-backed loading is immediately usable + for (path, contents) in [ + (theme_paths.base_css, DEFAULT_BASE_CSS), + (theme_paths.panel_css, DEFAULT_PANEL_CSS), + (theme_paths.popup_css, DEFAULT_POPUP_CSS), + (theme_paths.widgets_css, DEFAULT_WIDGETS_CSS), + (theme_paths.media_css, DEFAULT_MEDIA_CSS), + ] { + targets.push(ResetTarget { + path, + contents: contents.as_bytes().to_vec(), + mode: 0o644, + }); + } + for script in DEFAULT_SCRIPTS { + let path = options.config_dir.join(script.relative_path); + if let Some(parent) = path.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create script directory {}", parent.display()))?; + } + targets.push(ResetTarget { + path, + contents: script.contents.as_bytes().to_vec(), + mode: 0o755, + }); + } + + // Keep the successful targets so a later write can be rolled back + let mut written = Vec::new(); + for target in &targets { + if let Err(error) = write(&target.path, &target.contents, target.mode) { + let rollback_error = rollback_reset(&written, &originals, write); + let message = format!("write {}: {error}", target.path.display()); + return match rollback_error { + Ok(()) => Err(anyhow!(message)), + Err(rollback) => Err(anyhow!("{message}; rollback failed: {rollback}")), + }; + } + written.push(target.path.clone()); + report.written_files.push(target.path.clone()); + } + Ok(report) +} + +fn snapshot_existing_file(path: &Path) -> Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())), + }; + if !metadata.file_type().is_file() { + return Err(anyhow!( + "reset target is not a regular file: {}", + path.display() + )); + } + Ok(Some(OriginalFile { + path: path.to_path_buf(), + contents: fs::read(path).with_context(|| format!("read {}", path.display()))?, + mode: metadata.permissions().mode() & 0o777, + })) +} + +fn rollback_reset( + written: &[PathBuf], + originals: &[OriginalFile], + write: &ResetWriter, +) -> Result<()> { + let mut failures = Vec::new(); + // Attempt every restoration so one damaged destination does not hide others + for path in written.iter().rev() { + let result = + if let Some(original) = originals.iter().find(|original| original.path == *path) { + write(&original.path, &original.contents, original.mode) + .with_context(|| format!("restore {}", original.path.display())) + } else { + remove_regular_file(path) + .map(|_| ()) + .with_context(|| format!("remove {}", path.display())) + }; + if let Err(error) = result { + failures.push(format!("{error:#}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!(failures.join("; "))) + } +} + +fn create_backup_dir(config_dir: &Path, retention: usize) -> Result> { + if retention == 0 { + return Ok(None); + } + // A suffix handles repeated resets within one clock second + let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M%S"); + let mut candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}")); + let mut suffix = 1_u32; + loop { + // Directory creation reserves the name, so concurrent resets cannot choose one path + match create_directory_all(&candidate, 0o700) + .context("create configuration backup directory")? + { + CreateDirectoryOutcome::TargetCreated => return Ok(Some(candidate)), + CreateDirectoryOutcome::TargetAlreadyExisted => { + candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}-{suffix:03}")); + suffix = suffix + .checked_add(1) + .ok_or_else(|| anyhow!("configuration backup name space exhausted"))?; + } + } + } +} + +fn prune_backups(config_dir: &Path, retention: usize, protected: Option<&Path>) -> Result<()> { + if retention == 0 { + return Ok(()); + } + let mut backups = Vec::new(); + for entry in fs::read_dir(config_dir).context("read configuration backup directory")? { + let entry = entry.context("read configuration backup entry")?; + let file_type = entry + .file_type() + .with_context(|| format!("inspect backup entry {}", entry.path().display()))?; + if file_type.is_dir() + && entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with(BACKUP_PREFIX)) + { + backups.push(entry.path()); + } + } + // Lexical order matches the timestamped backup names + backups.sort(); + let excess = backups.len().saturating_sub(retention); + let mut failures = Vec::new(); + for backup in backups.into_iter().take(excess) { + if protected.is_some_and(|protected| protected == backup) { + continue; + } + if let Err(error) = remove_directory_tree(&backup) { + failures.push(format!("{}: {error}", backup.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!(failures.join("; "))) + } +} + +#[cfg(test)] +#[path = "reset/tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/reset/tests/files.rs b/crates/unixnotis-core/src/config/reset/tests/files.rs new file mode 100644 index 000000000..c517de7c6 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/files.rs @@ -0,0 +1,177 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use super::super::{reset_config_to_defaults, snapshot_existing_file, ResetConfigOptions}; +use super::support::temp_config_dir; +use crate::DEFAULT_SCRIPTS; + +#[test] +fn reset_backs_up_existing_files_and_writes_bundled_defaults() { + let root = temp_config_dir("present"); + fs::write(root.join("config.toml"), "custom = true\n").expect("seed config"); + let script = root.join(DEFAULT_SCRIPTS[0].relative_path); + fs::create_dir_all(script.parent().expect("script parent")).expect("script directory"); + fs::write(&script, "custom script\n").expect("seed script"); + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 3, + }) + .expect("reset should succeed"); + + let config_text = fs::read_to_string(root.join("config.toml")).expect("read reset config"); + toml::from_str::(&config_text).expect("reset config should parse"); + assert_eq!( + fs::read_to_string(&script).expect("read reset script"), + DEFAULT_SCRIPTS[0].contents + ); + let backup = report.backup_dir.expect("backup directory"); + assert_eq!( + fs::read_to_string(backup.join("config.toml")).expect("read config backup"), + "custom = true\n" + ); + assert_eq!( + fs::read_to_string(backup.join("unixnotis-blue-light-lib")).expect("read script backup"), + "custom script\n" + ); + assert_eq!(report.written_files.len(), 6 + DEFAULT_SCRIPTS.len()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_creates_missing_config_and_scripts_with_safe_modes() { + let root = temp_config_dir("missing"); + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("reset should create missing files"); + assert!(report.backup_dir.is_none()); + assert!(root.join("config.toml").is_file()); + for stylesheet in [ + "base.css", + "panel.css", + "popup.css", + "widgets.css", + "media.css", + ] { + assert!(root.join(stylesheet).is_file(), "missing {stylesheet}"); + } + for script in DEFAULT_SCRIPTS { + let path = root.join(script.relative_path); + assert!(path.is_file()); + #[cfg(unix)] + assert_eq!( + fs::metadata(path) + .expect("script metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_restores_custom_theme_files_but_backs_them_up() { + let root = temp_config_dir("theme"); + fs::write(root.join("panel.css"), "custom panel\n").expect("seed custom CSS"); + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("reset should succeed"); + assert_eq!( + fs::read_to_string(root.join("panel.css")).expect("read reset CSS"), + crate::DEFAULT_PANEL_CSS + ); + let backup = report.backup_dir.expect("backup directory"); + assert_eq!( + fs::read_to_string(backup.join("panel.css")).expect("read CSS backup"), + "custom panel\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_accepts_large_existing_files_and_backs_them_up() { + let root = temp_config_dir("large-file"); + let original = vec![b'x'; 8 * 1024 * 1024 + 1]; + fs::write(root.join("config.toml"), &original).expect("seed large config"); + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("the configured boundary remains valid"); + + let backup = report.backup_dir.expect("boundary backup directory"); + assert_eq!( + fs::read(backup.join("config.toml")).expect("read large backup"), + original + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_ignores_a_theme_manifest_directory() { + let root = temp_config_dir("manifest-directory"); + let manifest = crate::Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths") + .manifest_path(); + fs::create_dir(&manifest).expect("create manifest directory"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("manifest metadata must not block reset"); + + assert!(manifest.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn reset_ignores_a_theme_manifest_symlink() { + use std::os::unix::fs::symlink; + + let root = temp_config_dir("manifest-symlink"); + let outside = temp_config_dir("manifest-symlink-outside"); + let manifest = crate::Config::default() + .resolve_theme_paths_from(&root) + .expect("theme paths") + .manifest_path(); + let target = outside.join("theme.toml"); + fs::write(&target, "external metadata\n").expect("write manifest target"); + symlink(&target, &manifest).expect("create manifest symlink"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 0, + }) + .expect("manifest metadata must not block reset"); + + assert!(manifest.is_symlink()); + assert_eq!( + fs::read_to_string(target).expect("read manifest target"), + "external metadata\n" + ); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(outside); +} + +#[test] +fn snapshot_reports_errors_other_than_missing_files() { + let root = temp_config_dir("snapshot-error"); + let parent_file = root.join("not-a-directory"); + fs::write(&parent_file, b"file").expect("seed parent file"); + + let error = snapshot_existing_file(&parent_file.join("child")) + .expect_err("a non-directory parent must not look like a missing file"); + assert!(error.to_string().contains("inspect"), "{error}"); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/mod.rs b/crates/unixnotis-core/src/config/reset/tests/mod.rs new file mode 100644 index 000000000..abcfc286e --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/mod.rs @@ -0,0 +1,5 @@ +mod files; +mod renderer; +mod retention; +mod rollback; +mod support; diff --git a/crates/unixnotis-core/src/config/reset/tests/renderer.rs b/crates/unixnotis-core/src/config/reset/tests/renderer.rs new file mode 100644 index 000000000..2684b8168 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/renderer.rs @@ -0,0 +1,9 @@ +use super::super::render_default_config_toml; +use crate::Config; + +#[test] +fn reset_uses_the_same_annotated_default_renderer() { + let rendered = render_default_config_toml(&Config::default()).expect("render defaults"); + assert!(rendered.contains("# Exact pixel height override")); + assert!(rendered.contains("# Disable panel animation")); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/retention.rs b/crates/unixnotis-core/src/config/reset/tests/retention.rs new file mode 100644 index 000000000..595ccecf0 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/retention.rs @@ -0,0 +1,47 @@ +use std::fs; + +use super::super::{reset_config_to_defaults, ResetConfigOptions}; +use super::support::temp_config_dir; + +#[test] +fn reset_retains_only_the_newest_backup_directory() { + let root = temp_config_dir("retention"); + for name in ["Backup-2026-07-30-120000", "Backup-2026-07-31-120000"] { + fs::create_dir(root.join(name)).expect("seed old backup"); + } + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }) + .expect("reset should succeed"); + + let backups = fs::read_dir(&root) + .expect("read config directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(); + assert_eq!(backups, 1); + assert!(report.backup_dir.is_some()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn retention_ignores_backup_named_regular_files() { + let root = temp_config_dir("retention-file"); + for name in ["Backup-2026-07-30-120000", "Backup-2026-07-31-120000"] { + fs::create_dir(root.join(name)).expect("seed old backup"); + } + let regular_backup = root.join("Backup-z-not-a-directory"); + fs::write(®ular_backup, b"keep this file").expect("seed regular backup-like file"); + + reset_config_to_defaults(&ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 2, + }) + .expect("reset should succeed"); + + assert!(regular_backup.is_file()); + assert!(root.join("Backup-2026-07-31-120000").is_dir()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/rollback.rs b/crates/unixnotis-core/src/config/reset/tests/rollback.rs new file mode 100644 index 000000000..ee6d17310 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/rollback.rs @@ -0,0 +1,96 @@ +use std::fs; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use super::super::{reset_config_to_defaults_with_writer, ResetConfigOptions}; +use super::support::temp_config_dir; + +#[test] +fn reset_restores_replaced_files_after_a_later_write_fails() { + let root = temp_config_dir("rollback"); + let config_path = root.join("config.toml"); + let script_path = root.join("scripts/unixnotis-blue-light-lib"); + fs::write(&config_path, "original config\n").expect("seed config"); + #[cfg(unix)] + fs::set_permissions(&config_path, fs::Permissions::from_mode(0o640)) + .expect("set original config mode"); + fs::create_dir_all(script_path.parent().expect("script parent")).expect("script directory"); + fs::write(&script_path, "original script\n").expect("seed script"); + let failure_path = script_path.clone(); + + let error = reset_config_to_defaults_with_writer( + &ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }, + &move |path, contents, mode| { + if path == failure_path.as_path() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "injected publication failure", + )); + } + crate::filesystem::write_file_atomic(path, contents, mode) + }, + ) + .expect_err("the injected failure must be returned"); + + assert!(error.to_string().contains("injected publication failure")); + assert_eq!( + fs::read_to_string(&config_path).expect("restored config"), + "original config\n" + ); + assert_eq!( + fs::read_to_string(script_path).expect("original script"), + "original script\n" + ); + #[cfg(unix)] + assert_eq!( + fs::metadata(config_path) + .expect("restored config metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_attempts_every_written_path_and_reports_all_failures() { + let root = temp_config_dir("rollback-all"); + let config_path = root.join("config.toml"); + let first_script = root.join("scripts/unixnotis-blue-light-state"); + let second_script = root.join("scripts/unixnotis-blue-light-on"); + fs::write(&config_path, "original config\n").expect("seed config"); + fs::create_dir_all(first_script.parent().expect("script parent")).expect("script directory"); + fs::write(&first_script, "original state\n").expect("seed first script"); + fs::write(&second_script, "original on\n").expect("seed second script"); + let error = reset_config_to_defaults_with_writer( + &ResetConfigOptions { + config_dir: root.clone(), + backup_retention: 1, + }, + &move |path, contents, mode| { + if path == second_script && contents != b"original on\n" { + return Err(io::Error::other("injected publication failure")); + } + if path == first_script && contents == b"original state\n" { + return Err(io::Error::other("injected rollback failure")); + } + crate::filesystem::write_file_atomic(path, contents, mode) + }, + ) + .expect_err("the injected failure must be returned"); + + let message = error.to_string(); + assert!(message.contains("injected publication failure")); + assert!(message.contains("rollback failed"), "{message}"); + assert!(message.contains("injected rollback failure"), "{message}"); + assert_eq!( + fs::read_to_string(config_path).expect("config rollback should be attempted"), + "original config\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/config/reset/tests/support.rs b/crates/unixnotis-core/src/config/reset/tests/support.rs new file mode 100644 index 000000000..783c41e81 --- /dev/null +++ b/crates/unixnotis-core/src/config/reset/tests/support.rs @@ -0,0 +1,8 @@ +use std::fs; + +pub(super) fn temp_config_dir(label: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("unixnotis-reset-{label}-{}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create reset fixture"); + path +} diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs b/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs index 66f000e28..43061602b 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/mod.rs @@ -10,7 +10,7 @@ mod theme; pub(in super::super) use pipeline::sanitize_config; pub(super) use pipeline::{ - MAX_BORDER_WIDTH, MAX_CARD_HEIGHT, MAX_CARD_RADIUS, MAX_MEDIA_ART_SIZE, + MAX_BORDER_WIDTH, MAX_CARD_HEIGHT, MAX_CARD_RADIUS, MAX_CORNER_CUT, MAX_MEDIA_ART_SIZE, MAX_MEDIA_TEXT_WIDTH_FLOOR, MAX_MEDIA_TITLE_CHAR_LIMIT, MAX_SPACING, MAX_WIDGET_COLUMNS, MIN_MEDIA_TEXT_WIDTH_FLOOR, MIN_MEDIA_TITLE_CHAR_LIMIT, MIN_WIDGET_COLUMNS, }; diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs b/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs index bbf3c608f..516f3c389 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/panel.rs @@ -3,9 +3,16 @@ use std::collections::HashSet; use super::{MAX_WIDGET_COLUMNS, MIN_WIDGET_COLUMNS}; use crate::{ default_panel_action_order, default_panel_section_order, default_panel_widget_order, Config, - PanelActionConfig, PanelActionId, PanelConfig, PanelSection, PanelWidgetSection, + DndMenuChoice, NotificationMetadataConfig, PanelActionConfig, PanelActionId, PanelConfig, + PanelSection, PanelWidgetSection, }; +const MAX_DND_MENU_CHOICES: usize = 16; +const MAX_DND_DURATION_MINUTES: u32 = 525_600; +const MAX_DND_LABEL_CHARS: usize = 96; +const MAX_METADATA_TEXT_CHARS: usize = 128; +const MAX_ICON_NAME_CHARS: usize = 128; + pub(super) fn sanitize_panel_text(panel: &mut PanelConfig) { // Empty core labels make the panel harder to operate, so restore only required text if panel.title.trim().is_empty() { @@ -14,6 +21,10 @@ pub(super) fn sanitize_panel_text(panel: &mut PanelConfig) { if panel.clear_label.trim().is_empty() { panel.clear_label = PanelConfig::default().clear_label; } + if panel.search_magnifier_icon.trim().is_empty() { + panel.search_magnifier_icon = PanelConfig::default().search_magnifier_icon; + } + truncate_chars(&mut panel.search_magnifier_icon, MAX_ICON_NAME_CHARS); sanitize_action_config(&mut panel.focus_action, PanelActionConfig::widgets()); sanitize_action_config(&mut panel.dnd_action, PanelActionConfig::dnd()); sanitize_action_config(&mut panel.clear_action, PanelActionConfig::clear()); @@ -33,6 +44,55 @@ pub(super) fn sanitize_panel_action_order(order: &mut Vec) { sanitize_order(order, default_panel_action_order); } +pub(super) fn sanitize_dnd_menu(panel: &mut PanelConfig) { + let mut seen = HashSet::new(); + // An empty trigger list deliberately disables the context menu + panel + .dnd_menu_triggers + .retain(|trigger| seen.insert(*trigger)); + panel.dnd_menu_choices.truncate(MAX_DND_MENU_CHOICES); + panel.dnd_menu_choices.retain_mut(|choice| { + let label = choice.label_mut(); + truncate_chars(label, MAX_DND_LABEL_CHARS); + if label.trim().is_empty() { + // Empty buttons are unusable and should not occupy menu space + return false; + } + + match choice { + DndMenuChoice::Duration { minutes, .. } => { + *minutes = (*minutes).clamp(1, MAX_DND_DURATION_MINUTES); + } + DndMenuChoice::Tomorrow { hour, minute, .. } => { + *hour = (*hour).min(23); + *minute = (*minute).min(59); + } + DndMenuChoice::Indefinite { .. } => {} + } + true + }); +} + +pub(super) fn sanitize_notification_metadata(config: &mut NotificationMetadataConfig) { + for text in [ + &mut config.critical_label, + &mut config.low_label, + &mut config.normal_label, + &mut config.relative_now, + &mut config.relative_minutes, + &mut config.relative_hours, + &mut config.relative_days, + &mut config.transient_label, + &mut config.live_label, + &mut config.history_label, + &mut config.action_count_one, + &mut config.action_count_many, + ] { + // Empty metadata text is valid because it hides that optional badge + truncate_chars(text, MAX_METADATA_TEXT_CHARS); + } +} + fn sanitize_order(order: &mut Vec, defaults: fn() -> Vec) where T: Copy + Eq + std::hash::Hash, @@ -93,3 +153,11 @@ fn sanitize_column_count(value: usize, default_value: usize) -> usize { } value.clamp(MIN_WIDGET_COLUMNS, MAX_WIDGET_COLUMNS) } + +fn truncate_chars(value: &mut String, max_chars: usize) { + // UTF-8 boundaries are found through char indices before truncation + let Some((index, _)) = value.char_indices().nth(max_chars) else { + return; + }; + value.truncate(index); +} diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs index c82851ede..1d4dc56a8 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/pipeline.rs @@ -1,4 +1,6 @@ -use super::super::super::{Config, PanelConfig, PopupConfig, PANEL_HEIGHT_PERCENT_DEFAULT}; +use super::super::super::{ + Config, PanelConfig, PopupConfig, MAX_POPUP_TIMEOUT_MS, PANEL_HEIGHT_PERCENT_DEFAULT, +}; use super::{media, panel, plugins, refresh, shell, theme}; pub(in super::super) const MIN_REFRESH_MS: u64 = 100; @@ -20,6 +22,7 @@ pub(in super::super) const MAX_HISTORY_ENTRIES: usize = 5_000; pub(in super::super) const MAX_HISTORY_ACTIVE: usize = 12; pub(in super::super) const MAX_BORDER_WIDTH: u8 = 16; pub(in super::super) const MAX_CARD_RADIUS: u8 = 64; +pub(in super::super) const MAX_CORNER_CUT: u16 = 512; pub(in super::super) const MIN_WIDGET_COLUMNS: usize = 1; pub(in super::super) const MAX_WIDGET_COLUMNS: usize = 8; @@ -27,6 +30,7 @@ pub(in super::super::super) fn sanitize_config(config: &mut Config) { sanitize_refresh_intervals(config); sanitize_panel_geometry(config); sanitize_popup_geometry(config); + sanitize_popup_timeouts(config); // Media, plugin, and theme rules live in their own files because each has // enough edge cases to test directly @@ -84,6 +88,8 @@ fn sanitize_panel_geometry(config: &mut Config) { panel::sanitize_panel_section_order(&mut config.panel.section_order); panel::sanitize_panel_widget_order(&mut config.panel.widget_order); panel::sanitize_panel_action_order(&mut config.panel.action_order); + panel::sanitize_dnd_menu(&mut config.panel); + panel::sanitize_notification_metadata(&mut config.panel.notification_metadata); panel::sanitize_widget_columns(config); config.panel.margin.top = config.panel.margin.top.clamp(0, MAX_MARGIN); @@ -106,6 +112,15 @@ fn sanitize_popup_geometry(config: &mut Config) { config.popups.margin.left = config.popups.margin.left.clamp(0, MAX_MARGIN); } +fn sanitize_popup_timeouts(config: &mut Config) { + // Zero remains the no-timeout sentinel while positive values share one finite domain + config.popups.default_timeout_ms = config.popups.default_timeout_ms.min(MAX_POPUP_TIMEOUT_MS); + config.popups.critical_timeout_ms = config + .popups + .critical_timeout_ms + .map(|timeout| timeout.min(MAX_POPUP_TIMEOUT_MS)); +} + fn sanitize_history(config: &mut Config) { // Active notifications are bounded tighter than history to protect panel layout and memory config.history.max_active = config.history.max_active.min(MAX_HISTORY_ACTIVE); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs index d88189199..5a8e69517 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/plugins.rs @@ -4,7 +4,6 @@ use super::{ super::super::{Config, SliderWidgetConfig, WidgetPluginConfig}, MAX_CARD_HEIGHT, }; -use crate::util; pub(super) const MIN_PLUGIN_TIMEOUT_MS: u64 = 100; pub(super) const MAX_PLUGIN_TIMEOUT_MS: u64 = 30_000; @@ -114,8 +113,7 @@ fn sanitize_widget_plugin( return; } - let command = plugin_cfg.command.trim(); - if command.is_empty() { + if plugin_cfg.command.is_empty() { // Empty commands only look configured but can never run warn!( widget_type, @@ -124,7 +122,7 @@ fn sanitize_widget_plugin( *plugin = None; return; } - if !util::is_simple_command(command) { + if plugin_cfg.command.uses_shell_command_string() { // Shell syntax is not allowed in the plugin command field warn!( widget_type, @@ -133,8 +131,6 @@ fn sanitize_widget_plugin( *plugin = None; return; } - plugin_cfg.command = command.to_string(); - if plugin_cfg.timeout_ms == 0 { // Zero timeout falls back to the canonical plugin default plugin_cfg.timeout_ms = WidgetPluginConfig::default().timeout_ms; diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs index 5d2874f33..aec222016 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/shell.rs @@ -1,7 +1,7 @@ use tracing::warn; use super::super::super::Config; -use crate::{program_in_path, util}; +use crate::{program_in_path, CommandSpec}; pub(super) fn warn_missing_shell(config: &Config) -> bool { // Only warn when the config actually depends on shell syntax @@ -66,19 +66,12 @@ fn config_requires_shell(config: &Config) -> bool { }) } -fn command_requires_shell_opt(value: &Option) -> bool { - value.as_deref().is_some_and(command_requires_shell) +fn command_requires_shell_opt(value: &Option) -> bool { + value.as_ref().is_some_and(command_requires_shell) } -fn command_requires_shell(cmd: &str) -> bool { - let cmd = cmd.trim(); - if cmd.is_empty() { - return false; - } - - // Strip known runtime placeholders so braces do not trigger false positives - let cmd = cmd.replace("{value}", "0"); - !util::is_simple_command(&cmd) +fn command_requires_shell(command: &CommandSpec) -> bool { + command.uses_shell_command_string() } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs index 950148c1d..133ea4209 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/pipeline.rs @@ -1,9 +1,11 @@ use super::super::super::super::widgets::{CardWidgetConfig, StatWidgetConfig}; use super::*; +use crate::CommandSpec; use crate::{ - Config, PanelActionConfig, PanelActionId, PanelConfig, PanelSection, PanelWidgetSection, - PopupConfig, ToggleLayout, WidgetPluginConfig, CURRENT_CONFIG_VERSION, MAX_CARD_WIDGETS, - MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, MAX_TOTAL_WIDGETS, + Config, DndMenuChoice, DndMenuTrigger, PanelActionConfig, PanelActionId, PanelConfig, + PanelSection, PanelWidgetSection, PopupConfig, ToggleLayout, WidgetPluginConfig, + CURRENT_CONFIG_VERSION, MAX_CARD_WIDGETS, MAX_STAT_WIDGETS, MAX_TOGGLE_WIDGETS, + MAX_TOTAL_WIDGETS, }; use proptest::prelude::*; use proptest::test_runner::RngSeed; @@ -137,7 +139,7 @@ proptest! { let mut config = Config::default(); config.widgets.stats[0].plugin = Some(WidgetPluginConfig { api_version, - command, + command: CommandSpec::direct(command, [] as [&str; 0]), ..WidgetPluginConfig::default() }); @@ -202,11 +204,49 @@ fn sanitize_clamps_panel_and_popup_sizes() { assert_eq!(config.popups.spacing, MAX_SPACING); } +#[test] +fn sanitize_bounds_every_custom_notification_metadata_string() { + let mut config = Config::default(); + let oversized = "界".repeat(160); + config.panel.notification_metadata.critical_label = oversized.clone(); + config.panel.notification_metadata.low_label = oversized.clone(); + config.panel.notification_metadata.normal_label = oversized.clone(); + config.panel.notification_metadata.relative_now = oversized.clone(); + config.panel.notification_metadata.relative_minutes = oversized.clone(); + config.panel.notification_metadata.relative_hours = oversized.clone(); + config.panel.notification_metadata.relative_days = oversized.clone(); + config.panel.notification_metadata.transient_label = oversized.clone(); + config.panel.notification_metadata.live_label = oversized.clone(); + config.panel.notification_metadata.history_label = oversized.clone(); + config.panel.notification_metadata.action_count_one = oversized.clone(); + config.panel.notification_metadata.action_count_many = oversized; + + sanitize_config(&mut config); + + for text in [ + &config.panel.notification_metadata.critical_label, + &config.panel.notification_metadata.low_label, + &config.panel.notification_metadata.normal_label, + &config.panel.notification_metadata.relative_now, + &config.panel.notification_metadata.relative_minutes, + &config.panel.notification_metadata.relative_hours, + &config.panel.notification_metadata.relative_days, + &config.panel.notification_metadata.transient_label, + &config.panel.notification_metadata.live_label, + &config.panel.notification_metadata.history_label, + &config.panel.notification_metadata.action_count_one, + &config.panel.notification_metadata.action_count_many, + ] { + assert_eq!(text.chars().count(), 128); + } +} + #[test] fn sanitize_preserves_optional_panel_labels_and_repairs_widget_order() { let mut config = Config::default(); config.panel.title = " ".to_string(); config.panel.search_placeholder.clear(); + config.panel.search_magnifier_icon = "x".repeat(256); config.panel.quick_actions_label.clear(); config.panel.system_status_label.clear(); config.panel.recent_notifications_label.clear(); @@ -223,6 +263,7 @@ fn sanitize_preserves_optional_panel_labels_and_repairs_widget_order() { assert_eq!(config.panel.title, PanelConfig::default().title); assert!(config.panel.search_placeholder.is_empty()); + assert_eq!(config.panel.search_magnifier_icon.chars().count(), 128); assert!(config.panel.quick_actions_label.is_empty()); assert!(config.panel.system_status_label.is_empty()); assert!(config.panel.recent_notifications_label.is_empty()); @@ -291,6 +332,66 @@ fn sanitize_preserves_explicit_close_action_order() { ); } +#[test] +fn sanitize_dnd_menu_deduplicates_triggers_and_bounds_choices() { + let mut config = Config::default(); + config.panel.dnd_menu_triggers = vec![ + DndMenuTrigger::RightClick, + DndMenuTrigger::Keyboard, + DndMenuTrigger::RightClick, + ]; + config.panel.dnd_menu_choices = vec![ + DndMenuChoice::Duration { + label: String::new(), + minutes: 0, + }, + DndMenuChoice::Duration { + label: "Year".to_string(), + minutes: u32::MAX, + }, + DndMenuChoice::Tomorrow { + label: "Next day".to_string(), + hour: u8::MAX, + minute: u8::MAX, + }, + ]; + + sanitize_config(&mut config); + + assert_eq!( + config.panel.dnd_menu_triggers, + vec![DndMenuTrigger::RightClick, DndMenuTrigger::Keyboard] + ); + assert_eq!(config.panel.dnd_menu_choices.len(), 2); + assert!(matches!( + config.panel.dnd_menu_choices[0], + DndMenuChoice::Duration { + minutes: 525_600, + .. + } + )); + assert!(matches!( + config.panel.dnd_menu_choices[1], + DndMenuChoice::Tomorrow { + hour: 23, + minute: 59, + .. + } + )); +} + +#[test] +fn sanitize_preserves_an_explicitly_disabled_dnd_menu() { + let mut config = Config::default(); + config.panel.dnd_menu_triggers.clear(); + config.panel.dnd_menu_choices.clear(); + + sanitize_config(&mut config); + + assert!(config.panel.dnd_menu_triggers.is_empty()); + assert!(config.panel.dnd_menu_choices.is_empty()); +} + #[test] fn default_panel_section_labels_name_the_visible_widget_groups() { let config = PanelConfig::default(); @@ -367,6 +468,36 @@ fn sanitize_keeps_active_limit_independent_from_history_retention() { assert_eq!(config.history.max_active, 12); } +#[test] +fn sanitize_clamps_popup_timeouts_to_the_supported_timer_domain() { + let mut config = Config::default(); + config.popups.default_timeout_ms = u64::MAX; + config.popups.critical_timeout_ms = Some(u64::MAX); + + sanitize_config(&mut config); + + assert_eq!( + config.popups.default_timeout_ms, + crate::MAX_POPUP_TIMEOUT_MS + ); + assert_eq!( + config.popups.critical_timeout_ms, + Some(crate::MAX_POPUP_TIMEOUT_MS) + ); +} + +#[test] +fn sanitize_preserves_zero_popup_timeout_as_indefinite() { + let mut config = Config::default(); + config.popups.default_timeout_ms = 0; + config.popups.critical_timeout_ms = Some(0); + + sanitize_config(&mut config); + + assert_eq!(config.popups.default_timeout_ms, 0); + assert_eq!(config.popups.critical_timeout_ms, Some(0)); +} + #[test] fn sanitize_clamps_margins_and_card_heights() { // Margin and min-height clamping should cover both stats and cards @@ -423,7 +554,7 @@ fn widget_toggle_tooltips_parse_cleanly() { enabled = true label = "Custom Action" icon = "applications-system-symbolic" - toggle_cmd = "scripts/custom-action" + toggle_cmd = { mode = "direct", program = "scripts/custom-action" } "#, ) .expect("config should parse"); @@ -435,7 +566,10 @@ fn widget_toggle_tooltips_parse_cleanly() { assert_eq!(config.widgets.stat_columns, 4); assert_eq!(config.widgets.card_columns, 1); assert_eq!( - config.widgets.toggles[0].toggle_cmd.as_deref(), - Some("scripts/custom-action") + config.widgets.toggles[0].toggle_cmd, + Some(CommandSpec::direct( + "scripts/custom-action", + [] as [&str; 0] + )) ); } diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs index a94b8007f..2adf4979e 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/plugins.rs @@ -1,18 +1,17 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "sanitization assigns exact finite constants and test inputs" )] use super::super::super::super::widgets::WidgetPluginConfig; use super::super::*; -use crate::Config; +use crate::{CommandSpec, Config}; #[test] -fn sanitize_widget_plugin_clamps_bounds_and_trim_command() { - // Plugin commands should be trimmed and bounded before any worker runs them +fn sanitize_widget_plugin_clamps_bounds_and_preserves_literal_arguments() { let mut config = Config::default(); config.widgets.stats[0].plugin = Some(WidgetPluginConfig { - command: " script arg ".to_string(), + command: CommandSpec::direct("script", [" literal arg "]), timeout_ms: super::super::plugins::MAX_PLUGIN_TIMEOUT_MS + 1, max_output_bytes: super::super::plugins::MAX_PLUGIN_OUTPUT_BYTES + 10, ..WidgetPluginConfig::default() @@ -23,7 +22,10 @@ fn sanitize_widget_plugin_clamps_bounds_and_trim_command() { .plugin .as_ref() .expect("plugin should remain enabled"); - assert_eq!(plugin.command, "script arg"); + assert_eq!( + plugin.command, + CommandSpec::direct("script", [" literal arg "]) + ); assert_eq!( plugin.timeout_ms, super::super::plugins::MAX_PLUGIN_TIMEOUT_MS @@ -39,13 +41,47 @@ fn sanitize_widget_plugin_rejects_shell_meta_commands() { // Shell syntax is not allowed in the simple plugin command field let mut config = Config::default(); config.widgets.cards[0].plugin = Some(WidgetPluginConfig { - command: "sh -c 'echo pwned | cat'".to_string(), + command: CommandSpec::shell("echo pwned | cat"), + ..WidgetPluginConfig::default() + }); + sanitize_config(&mut config); + assert!(config.widgets.cards[0].plugin.is_none()); +} + +#[test] +fn sanitize_widget_plugin_rejects_direct_shell_interpreters() { + let mut config = Config::default(); + config.widgets.cards[0].plugin = Some(WidgetPluginConfig { + command: CommandSpec::direct("sh", ["-c", "printf unsafe"]), ..WidgetPluginConfig::default() }); + sanitize_config(&mut config); + assert!(config.widgets.cards[0].plugin.is_none()); } +#[test] +fn sanitize_widget_plugin_keeps_direct_shell_scripts_with_long_options() { + for (shell, option, script) in [ + ("bash", "--norc", "script.sh"), + ("fish", "--no-config", "script.fish"), + ] { + let mut config = Config::default(); + config.widgets.cards[0].plugin = Some(WidgetPluginConfig { + command: CommandSpec::direct(shell, [option, script]), + ..WidgetPluginConfig::default() + }); + + sanitize_config(&mut config); + + assert!( + config.widgets.cards[0].plugin.is_some(), + "{shell} script plugins must remain enabled" + ); + } +} + #[test] fn sanitize_widget_options_caps_decorative_layout_counts() { let mut config = Config::default(); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs index e52d6bcc8..708f5a0e3 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/shell.rs @@ -52,12 +52,12 @@ fn write_fake_program(dir: &std::path::Path, name: &str) { fn config_without_shell_commands() -> Config { let mut config = Config::default(); - config.widgets.volume.get_cmd = "volume-get".to_string(); - config.widgets.volume.set_cmd = "volume-set {value}".to_string(); + config.widgets.volume.get_cmd = CommandSpec::direct("volume-get", [] as [&str; 0]); + config.widgets.volume.set_cmd = CommandSpec::direct("volume-set", ["{value}"]); config.widgets.volume.toggle_cmd = None; config.widgets.volume.watch_cmd = None; - config.widgets.brightness.get_cmd = "brightness-get".to_string(); - config.widgets.brightness.set_cmd = "brightness-set {value}".to_string(); + config.widgets.brightness.get_cmd = CommandSpec::direct("brightness-get", [] as [&str; 0]); + config.widgets.brightness.set_cmd = CommandSpec::direct("brightness-set", ["{value}"]); config.widgets.brightness.toggle_cmd = None; config.widgets.brightness.watch_cmd = None; config.widgets.toggles.clear(); @@ -68,15 +68,23 @@ fn config_without_shell_commands() -> Config { #[test] fn command_requires_shell_accepts_plain_commands_and_placeholders() { - // The slider placeholder is replaced before shell-character checks run - assert!(!command_requires_shell("notify-send hello")); - assert!(!command_requires_shell("wpctl set-volume sink {value}%")); - assert!(!command_requires_shell(" ")); + assert!(!command_requires_shell(&CommandSpec::direct( + "notify-send", + ["hello"] + ))); + assert!(!command_requires_shell(&CommandSpec::direct( + "wpctl", + ["set-volume", "sink", "{value}%"] + ))); + assert!(!command_requires_shell(&CommandSpec::direct( + "", + [] as [&str; 0] + ))); } #[test] fn command_requires_shell_rejects_shell_syntax() { - for command in [ + for script in [ "echo hi | wc -l", "echo hi && echo bye", "echo > file", @@ -84,8 +92,8 @@ fn command_requires_shell_rejects_shell_syntax() { "echo ~/file", ] { assert!( - command_requires_shell(command), - "command should need shell: {command}" + command_requires_shell(&CommandSpec::shell(script)), + "command should need shell: {script}" ); } } @@ -93,12 +101,13 @@ fn command_requires_shell_rejects_shell_syntax() { #[test] fn optional_command_requires_shell_only_when_present_and_complex() { assert!(!command_requires_shell_opt(&None)); - assert!(!command_requires_shell_opt(&Some( - "notify-send hi".to_string() - ))); - assert!(command_requires_shell_opt(&Some( - "notify-send hi | cat".to_string() - ))); + assert!(!command_requires_shell_opt(&Some(CommandSpec::direct( + "notify-send", + ["hi"] + )))); + assert!(command_requires_shell_opt(&Some(CommandSpec::shell( + "notify-send hi | cat" + )))); } #[test] @@ -107,7 +116,7 @@ fn config_requires_shell_checks_volume_and_brightness_commands() { assert!(config_requires_shell(&Config { widgets: crate::WidgetsConfig { volume: SliderWidgetConfig { - get_cmd: "echo volume | cat".to_string(), + get_cmd: CommandSpec::shell("echo volume | cat"), ..config.widgets.volume.clone() }, ..config.widgets.clone() @@ -115,28 +124,33 @@ fn config_requires_shell_checks_volume_and_brightness_commands() { ..config.clone() })); - config.widgets.brightness.set_cmd = "brightnessctl s {value}% && notify-send done".to_string(); + config.widgets.brightness.set_cmd = + CommandSpec::shell("brightnessctl s {value}% && notify-send done"); assert!(config_requires_shell(&config)); } #[test] fn config_requires_shell_checks_each_slider_command_branch() { let slider_cases: [fn(&mut Config); 8] = [ - |config: &mut Config| config.widgets.volume.get_cmd = "echo get | cat".to_string(), - |config: &mut Config| config.widgets.volume.set_cmd = "echo set | cat".to_string(), + |config: &mut Config| config.widgets.volume.get_cmd = CommandSpec::shell("echo get | cat"), + |config: &mut Config| config.widgets.volume.set_cmd = CommandSpec::shell("echo set | cat"), + |config: &mut Config| { + config.widgets.volume.toggle_cmd = Some(CommandSpec::shell("echo toggle | cat")); + }, |config: &mut Config| { - config.widgets.volume.toggle_cmd = Some("echo toggle | cat".to_string()); + config.widgets.volume.watch_cmd = Some(CommandSpec::shell("echo watch | cat")); }, |config: &mut Config| { - config.widgets.volume.watch_cmd = Some("echo watch | cat".to_string()); + config.widgets.brightness.get_cmd = CommandSpec::shell("echo bget | cat"); }, - |config: &mut Config| config.widgets.brightness.get_cmd = "echo bget | cat".to_string(), - |config: &mut Config| config.widgets.brightness.set_cmd = "echo bset | cat".to_string(), |config: &mut Config| { - config.widgets.brightness.toggle_cmd = Some("echo btoggle | cat".to_string()); + config.widgets.brightness.set_cmd = CommandSpec::shell("echo bset | cat"); }, |config: &mut Config| { - config.widgets.brightness.watch_cmd = Some("echo bwatch | cat".to_string()); + config.widgets.brightness.toggle_cmd = Some(CommandSpec::shell("echo btoggle | cat")); + }, + |config: &mut Config| { + config.widgets.brightness.watch_cmd = Some(CommandSpec::shell("echo bwatch | cat")); }, ]; @@ -155,7 +169,7 @@ fn config_requires_shell_checks_each_slider_command_branch() { fn config_requires_shell_checks_toggle_commands() { let mut config = Config::default(); config.widgets.toggles = vec![ToggleWidgetConfig { - toggle_cmd: Some("echo toggle | cat".to_string()), + toggle_cmd: Some(CommandSpec::shell("echo toggle | cat")), ..ToggleWidgetConfig::default() }]; @@ -165,11 +179,19 @@ fn config_requires_shell_checks_toggle_commands() { #[test] fn config_requires_shell_checks_each_toggle_command_branch() { let toggle_cases: [fn(&mut ToggleWidgetConfig); 5] = [ - |toggle: &mut ToggleWidgetConfig| toggle.state_cmd = Some("echo state | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.toggle_cmd = Some("echo toggle | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.on_cmd = Some("echo on | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.off_cmd = Some("echo off | cat".to_string()), - |toggle: &mut ToggleWidgetConfig| toggle.watch_cmd = Some("echo watch | cat".to_string()), + |toggle: &mut ToggleWidgetConfig| { + toggle.state_cmd = Some(CommandSpec::shell("echo state | cat")); + }, + |toggle: &mut ToggleWidgetConfig| { + toggle.toggle_cmd = Some(CommandSpec::shell("echo toggle | cat")); + }, + |toggle: &mut ToggleWidgetConfig| toggle.on_cmd = Some(CommandSpec::shell("echo on | cat")), + |toggle: &mut ToggleWidgetConfig| { + toggle.off_cmd = Some(CommandSpec::shell("echo off | cat")); + }, + |toggle: &mut ToggleWidgetConfig| { + toggle.watch_cmd = Some(CommandSpec::shell("echo watch | cat")); + }, ]; for make_shell_command in toggle_cases { @@ -188,7 +210,7 @@ fn config_requires_shell_checks_each_toggle_command_branch() { fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut stat_config = config_without_shell_commands(); stat_config.widgets.stats = vec![StatWidgetConfig { - cmd: Some("echo stat | cat".to_string()), + cmd: Some(CommandSpec::shell("echo stat | cat")), ..StatWidgetConfig::default() }]; assert!(config_requires_shell(&stat_config)); @@ -196,7 +218,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut stat_plugin = config_without_shell_commands(); stat_plugin.widgets.stats = vec![StatWidgetConfig { plugin: Some(WidgetPluginConfig { - command: "echo stat-plugin | cat".to_string(), + command: CommandSpec::shell("echo stat-plugin | cat"), ..WidgetPluginConfig::default() }), ..StatWidgetConfig::default() @@ -205,7 +227,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut card_config = config_without_shell_commands(); card_config.widgets.cards = vec![CardWidgetConfig { - cmd: Some("echo card | cat".to_string()), + cmd: Some(CommandSpec::shell("echo card | cat")), ..CardWidgetConfig::default() }]; assert!(config_requires_shell(&card_config)); @@ -213,7 +235,7 @@ fn config_requires_shell_checks_stat_and_card_commands_and_plugins() { let mut card_plugin = config_without_shell_commands(); card_plugin.widgets.cards = vec![CardWidgetConfig { plugin: Some(WidgetPluginConfig { - command: "echo card-plugin | cat".to_string(), + command: CommandSpec::shell("echo card-plugin | cat"), ..WidgetPluginConfig::default() }), ..CardWidgetConfig::default() @@ -231,7 +253,7 @@ fn warn_missing_shell_reports_only_when_shell_is_missing_and_needed() { let previous = set_path(&root); let mut config = Config::default(); config.widgets.toggles = vec![ToggleWidgetConfig { - toggle_cmd: Some("echo toggle | cat".to_string()), + toggle_cmd: Some(CommandSpec::shell("echo toggle | cat")), ..ToggleWidgetConfig::default() }]; config.widgets.stats.clear(); diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs index f62dccdf5..122cef860 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/tests/theme.rs @@ -1,4 +1,4 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "theme sanitization assigns exact clamp boundaries and explicit fallback constants" )] @@ -21,6 +21,7 @@ fn sanitize_clamps_alpha_and_theme_limits() { config.theme.shadow_strong_alpha = -0.5; config.theme.border_width = MAX_BORDER_WIDTH + 2; config.theme.card_radius = MAX_CARD_RADIUS + 3; + config.theme.notification_corners.top_left = u16::MAX; sanitize_config(&mut config); assert_eq!(config.theme.surface_alpha, 0.0); @@ -36,6 +37,7 @@ fn sanitize_clamps_alpha_and_theme_limits() { assert_eq!(config.theme.shadow_strong_alpha, 0.0); assert_eq!(config.theme.border_width, MAX_BORDER_WIDTH); assert_eq!(config.theme.card_radius, MAX_CARD_RADIUS); + assert_eq!(config.theme.notification_corners.top_left, MAX_CORNER_CUT); } #[test] diff --git a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs index 6c8c1d0a1..e7b017545 100644 --- a/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs +++ b/crates/unixnotis-core/src/config/runtime/sanitize/theme.rs @@ -1,4 +1,4 @@ -use super::{MAX_BORDER_WIDTH, MAX_CARD_RADIUS}; +use super::{MAX_BORDER_WIDTH, MAX_CARD_RADIUS, MAX_CORNER_CUT}; use crate::{Config, ThemeConfig}; pub(super) fn sanitize_theme_config(config: &mut Config) { @@ -38,6 +38,26 @@ pub(super) fn sanitize_theme_config(config: &mut Config) { // CSS generation reads these directly, so keep values inside simple visual bounds config.theme.border_width = config.theme.border_width.min(MAX_BORDER_WIDTH); config.theme.card_radius = config.theme.card_radius.min(MAX_CARD_RADIUS); + config.theme.notification_corners.top_left = config + .theme + .notification_corners + .top_left + .min(MAX_CORNER_CUT); + config.theme.notification_corners.top_right = config + .theme + .notification_corners + .top_right + .min(MAX_CORNER_CUT); + config.theme.notification_corners.bottom_right = config + .theme + .notification_corners + .bottom_right + .min(MAX_CORNER_CUT); + config.theme.notification_corners.bottom_left = config + .theme + .notification_corners + .bottom_left + .min(MAX_CORNER_CUT); } const fn clamp_alpha(value: &mut f32, fallback: f32) { diff --git a/crates/unixnotis-core/src/config/runtime/tests/widgets.rs b/crates/unixnotis-core/src/config/runtime/tests/widgets.rs index d234284d1..7b31a0945 100644 --- a/crates/unixnotis-core/src/config/runtime/tests/widgets.rs +++ b/crates/unixnotis-core/src/config/runtime/tests/widgets.rs @@ -54,8 +54,8 @@ fn custom_volume_without_watch_stays_config_owned() { label: "Volume".to_string(), icon: "audio-volume-high-symbolic".to_string(), icon_muted: None, - get_cmd: "custom-volume-get".to_string(), - set_cmd: "custom-volume-set {value}".to_string(), + get_cmd: CommandSpec::direct("custom-volume-get", [] as [&str; 0]), + set_cmd: CommandSpec::direct("custom-volume-set", ["{value}"]), toggle_cmd: None, watch_cmd: None, min: 0.0, @@ -84,15 +84,15 @@ fn partial_stock_volume_commands_do_not_migrate_to_pactl() { let cases = [ SliderWidgetConfig { - get_cmd: "custom get".to_string(), + get_cmd: CommandSpec::direct("custom", ["get"]), ..SliderWidgetConfig::default() }, SliderWidgetConfig { - set_cmd: "custom set {value}".to_string(), + set_cmd: CommandSpec::direct("custom", ["set", "{value}"]), ..SliderWidgetConfig::default() }, SliderWidgetConfig { - toggle_cmd: Some("custom toggle".to_string()), + toggle_cmd: Some(CommandSpec::direct("custom", ["toggle"])), ..SliderWidgetConfig::default() }, ]; @@ -126,16 +126,10 @@ fn stock_volume_uses_pactl_when_wpctl_is_missing() { apply_volume_backend(&mut volume); assert!(volume.enabled); - assert_eq!(volume.get_cmd, SliderWidgetConfig::PACTL_GET); - assert_eq!(volume.set_cmd, SliderWidgetConfig::PACTL_SET); - assert_eq!( - volume.toggle_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_TOGGLE) - ); - assert_eq!( - volume.watch_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_WATCH) - ); + assert_eq!(volume.get_cmd, SliderWidgetConfig::pactl_get()); + assert_eq!(volume.set_cmd, SliderWidgetConfig::pactl_set()); + assert_eq!(volume.toggle_cmd, Some(SliderWidgetConfig::pactl_toggle())); + assert_eq!(volume.watch_cmd, Some(SliderWidgetConfig::pactl_watch())); restore_path(previous); let _ = fs::remove_dir_all(root); @@ -154,12 +148,9 @@ fn stock_volume_keeps_wpctl_when_available() { apply_volume_backend(&mut volume); assert!(volume.enabled); - assert_eq!(volume.get_cmd, SliderWidgetConfig::WPCTL_GET); - assert_eq!(volume.set_cmd, SliderWidgetConfig::WPCTL_SET); - assert_eq!( - volume.watch_cmd.as_deref(), - Some(SliderWidgetConfig::PACTL_WATCH) - ); + assert_eq!(volume.get_cmd, SliderWidgetConfig::wpctl_get()); + assert_eq!(volume.set_cmd, SliderWidgetConfig::wpctl_set()); + assert_eq!(volume.watch_cmd, Some(SliderWidgetConfig::pactl_watch())); restore_path(previous); let _ = fs::remove_dir_all(root); @@ -190,7 +181,7 @@ fn legacy_wpctl_watch_is_removed_when_pactl_is_missing() { let previous = set_path(&root); let mut volume = SliderWidgetConfig { - watch_cmd: Some("wpctl subscribe".to_string()), + watch_cmd: Some(CommandSpec::direct("wpctl", ["subscribe"])), ..SliderWidgetConfig::default() }; apply_volume_backend(&mut volume); @@ -208,10 +199,10 @@ fn legacy_brightness_watch_is_removed() { label: "Brightness".to_string(), icon: "display-brightness-symbolic".to_string(), icon_muted: None, - get_cmd: "brightnessctl -m".to_string(), - set_cmd: "brightnessctl s {value}%".to_string(), + get_cmd: CommandSpec::direct("brightnessctl", ["-m"]), + set_cmd: CommandSpec::direct("brightnessctl", ["s", "{value}%"]), toggle_cmd: None, - watch_cmd: Some("brightnessctl -w".to_string()), + watch_cmd: Some(CommandSpec::direct("brightnessctl", ["-w"])), min: 1.0, max: 100.0, step: 1.0, diff --git a/crates/unixnotis-core/src/config/runtime/widgets.rs b/crates/unixnotis-core/src/config/runtime/widgets.rs index 3e6231cd6..81386885c 100644 --- a/crates/unixnotis-core/src/config/runtime/widgets.rs +++ b/crates/unixnotis-core/src/config/runtime/widgets.rs @@ -1,22 +1,23 @@ //! Runtime adjustments for slider widget backends use super::super::{NumericParseMode, SliderWidgetConfig}; -use crate::program_in_path; +use crate::{program_in_path, CommandSpec}; use tracing::warn; -const LEGACY_WPCTL_WATCH: &str = "wpctl subscribe"; - pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { if !volume.enabled { return; } - let is_wpctl_default = volume.get_cmd == SliderWidgetConfig::WPCTL_GET - && volume.set_cmd == SliderWidgetConfig::WPCTL_SET + let is_wpctl_default = volume.get_cmd == SliderWidgetConfig::wpctl_get() + && volume.set_cmd == SliderWidgetConfig::wpctl_set() && volume .toggle_cmd - .as_deref() - .is_some_and(|cmd| cmd == SliderWidgetConfig::WPCTL_TOGGLE); - let watch_is_legacy = volume.watch_cmd.as_deref() == Some(LEGACY_WPCTL_WATCH); + .as_ref() + .is_some_and(|cmd| *cmd == SliderWidgetConfig::wpctl_toggle()); + let watch_is_legacy = volume + .watch_cmd + .as_ref() + .is_some_and(|command| *command == CommandSpec::direct("wpctl", ["subscribe"])); let pactl_available = program_in_path("pactl"); let wpctl_available = program_in_path("wpctl"); @@ -25,7 +26,7 @@ pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { if watch_needs_stock_backfill || watch_is_legacy { if pactl_available { // Prefer the documented long-running `pactl subscribe` watcher when available - volume.watch_cmd = Some(SliderWidgetConfig::PACTL_WATCH.to_string()); + volume.watch_cmd = Some(SliderWidgetConfig::pactl_watch()); } else if watch_is_legacy { // Avoid spawning the legacy wpctl watcher that is not part of `wpctl` CLI volume.watch_cmd = None; @@ -40,13 +41,13 @@ pub(in super::super) fn apply_volume_backend(volume: &mut SliderWidgetConfig) { } if pactl_available { // pactl is the compatible fallback when wpctl is not installed - volume.get_cmd = SliderWidgetConfig::PACTL_GET.to_string(); - volume.set_cmd = SliderWidgetConfig::PACTL_SET.to_string(); - volume.toggle_cmd = Some(SliderWidgetConfig::PACTL_TOGGLE.to_string()); + volume.get_cmd = SliderWidgetConfig::pactl_get(); + volume.set_cmd = SliderWidgetConfig::pactl_set(); + volume.toggle_cmd = Some(SliderWidgetConfig::pactl_toggle()); // Fall back to auto parsing because pactl output differs from wpctl ratios volume.parse_mode = NumericParseMode::Auto; if volume.watch_cmd.is_none() { - volume.watch_cmd = Some(SliderWidgetConfig::PACTL_WATCH.to_string()); + volume.watch_cmd = Some(SliderWidgetConfig::pactl_watch()); } } else { // Disable the widget explicitly when no supported backend is present @@ -59,7 +60,11 @@ pub(in super::super) fn apply_brightness_backend(brightness: &mut SliderWidgetCo if !brightness.enabled { return; } - if brightness.watch_cmd.as_deref() == Some("brightnessctl -w") { + if brightness + .watch_cmd + .as_ref() + .is_some_and(|command| *command == CommandSpec::direct("brightnessctl", ["-w"])) + { // Remove the legacy watch flag because brightnessctl has no watch mode brightness.watch_cmd = None; } diff --git a/crates/unixnotis-core/src/config/types.rs b/crates/unixnotis-core/src/config/types.rs index dca9ec600..57d46887e 100644 --- a/crates/unixnotis-core/src/config/types.rs +++ b/crates/unixnotis-core/src/config/types.rs @@ -12,7 +12,7 @@ use super::rules::RuleConfig; use super::theme::ThemeConfig; use super::widgets::WidgetsConfig; -pub const CURRENT_CONFIG_VERSION: u32 = 2; +pub const CURRENT_CONFIG_VERSION: u32 = 5; /// Top-level configuration loaded from config.toml #[derive(Debug, Clone, Deserialize, Serialize)] @@ -87,7 +87,7 @@ pub enum InhibitMode { pub struct HistoryConfig { // Saved items pub max_entries: usize, - // Live items + // Live items retained per stable sender process pub max_active: usize, // Save transient items too pub transient_to_history: bool, @@ -97,7 +97,7 @@ impl Default for HistoryConfig { fn default() -> Self { Self { max_entries: 200, - // Match the daemon cap + // Match the daemon's per-principal cap max_active: 12, transient_to_history: false, } @@ -110,6 +110,10 @@ impl Default for HistoryConfig { pub struct SoundConfig { /// Enables sound playback when the daemon receives notifications pub enabled: bool, + /// Allows notification senders to request local audio files + pub allow_file_hints: bool, + /// Directories that may contain notification-requested audio files + pub allowed_file_hint_dirs: Vec, /// Default named sound from the freedesktop sound theme pub default_name: Option, /// Default sound file path, resolves relative to the `UnixNotis` config dir @@ -122,6 +126,8 @@ impl Default for SoundConfig { fn default() -> Self { Self { enabled: true, + allow_file_hints: false, + allowed_file_hint_dirs: Vec::new(), default_name: Some("message-new-instant".to_string()), default_file: None, default_dir: None, diff --git a/crates/unixnotis-core/src/config/validation/mod.rs b/crates/unixnotis-core/src/config/validation/mod.rs index a7191545b..c5657ad05 100644 --- a/crates/unixnotis-core/src/config/validation/mod.rs +++ b/crates/unixnotis-core/src/config/validation/mod.rs @@ -1,4 +1,4 @@ -//! Notification rule validation and explicit schema migration +//! Notification rule and current schema validation pub(in crate::config) mod rules; pub(in crate::config) mod schema; diff --git a/crates/unixnotis-core/src/config/validation/rules.rs b/crates/unixnotis-core/src/config/validation/rules.rs index 09f21fc3b..cdc53d472 100644 --- a/crates/unixnotis-core/src/config/validation/rules.rs +++ b/crates/unixnotis-core/src/config/validation/rules.rs @@ -121,8 +121,10 @@ impl<'de> Deserialize<'de> for RuleUrgency { pub struct RuleConfig { /// Optional rule name for logging or debugging pub name: Option, - /// Match against the notification app name (case-insensitive substring) + /// Match daemon-resolved application identity (case-insensitive substring) pub app: Option, + /// Match sender-provided freedesktop `app_name` presentation metadata + pub claimed_app: Option, /// Match against the notification summary (case-insensitive substring) pub summary: Option, /// Match against the notification body (case-insensitive substring) diff --git a/crates/unixnotis-core/src/config/validation/schema.rs b/crates/unixnotis-core/src/config/validation/schema.rs index 33f9dc415..305eb6187 100644 --- a/crates/unixnotis-core/src/config/validation/schema.rs +++ b/crates/unixnotis-core/src/config/validation/schema.rs @@ -1,210 +1,46 @@ -//! Explicit configuration schema migrations +//! Current configuration schema deserialization use serde::de::IntoDeserializer; use super::super::{Config, CURRENT_CONFIG_VERSION}; -pub(in crate::config) fn deserialize_config_with_migrations( +pub(in crate::config) fn deserialize_current_config( contents: &str, -) -> Result<(Config, Vec, Vec), String> { - // Keep the original tree so migration reporting can describe every inserted field - let mut document = contents +) -> Result<(Config, Vec), String> { + let document = contents .parse::() - .map_err(|err| err.to_string())?; - let original_document = document.clone(); - let migration = migrate_document(&mut document)?; - let mut migrated_paths = Vec::new(); - collect_changed_paths( - "", - Some(&original_document), - Some(&document), - &mut migrated_paths, - ); - if migration.restore_legacy_cards { - // Card restoration changes the typed config after the document migration finishes - // Card restoration happens after deserialization, so record it outside the TOML diff - migrated_paths.push("widgets.cards".to_string()); - } - migrated_paths.sort(); - migrated_paths.dedup(); + .map_err(|error| error.to_string())?; + validate_current_version(&document)?; + let mut ignored_keys = Vec::new(); let deserializer = document.into_deserializer(); - // Unknown fields are collected without weakening normal serde type validation - let mut config: Config = serde_ignored::deserialize(deserializer, |path| { - ignored_keys.push(path.to_string()); - }) - .map_err(|err| err.to_string())?; - - // Older configs enabled the original calendar and weather cards when the key was absent - if migration.restore_legacy_cards { - config.widgets.cards = Config::default().widgets.cards; - for card in &mut config.widgets.cards { - card.enabled = true; - } - } - config.config_version = CURRENT_CONFIG_VERSION; - Ok((config, ignored_keys, migrated_paths)) -} - -fn collect_changed_paths( - path: &str, - before: Option<&toml::Value>, - after: Option<&toml::Value>, - paths: &mut Vec, -) { - if before == after { - return; - } - match (before, after) { - (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => { - // Union traversal catches inserted, removed, and changed child keys - let mut keys = before.keys().chain(after.keys()).collect::>(); - keys.sort(); - keys.dedup(); - for key in keys { - let child = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - collect_changed_paths(&child, before.get(key), after.get(key), paths); - } + // Unknown fields remain visible to diagnostics without weakening serde validation + let config = serde_ignored::deserialize(deserializer, |path| { + // This runtime-only field is intentionally ignored for stock theme compatibility + let path = path.to_string(); + if path != "theme.mode" { + ignored_keys.push(path); } - (None, Some(toml::Value::Table(after))) => { - // Newly created compatibility tables report their leaf fields instead of one table - for (key, value) in after { - let child = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - collect_changed_paths(&child, None, Some(value), paths); - } - } - _ if !path.is_empty() => paths.push(path.to_string()), - // The root itself is not a useful config-key path - _ => {} - } -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct MigrationResult { - restore_legacy_cards: bool, + }) + .map_err(|error| error.to_string())?; + Ok((config, ignored_keys)) } -fn migrate_document(document: &mut toml::Value) -> Result { +fn validate_current_version(document: &toml::Value) -> Result<(), String> { let root = document - .as_table_mut() + .as_table() .ok_or_else(|| "configuration root must be a TOML table".to_string())?; let version = match root.get("config_version") { None => 0, - Some(toml::Value::Integer(version)) if *version >= 0 => *version as u32, + Some(toml::Value::Integer(version)) if *version >= 0 => u32::try_from(*version) + .map_err(|_error| format!("unsupported config version {version}"))?, Some(_) => return Err("config_version must be a non-negative integer".to_string()), }; - if version > CURRENT_CONFIG_VERSION { - // Future schemas fail closed because silently dropping fields would corrupt intent - return Err(format!( - "config version {version} is newer than supported version {CURRENT_CONFIG_VERSION}" - )); - } - - let result = match version { - // Schema one used the same legacy layout compatibility values as unversioned files - 0 | 1 => migrate_legacy_layout(root), - CURRENT_CONFIG_VERSION => MigrationResult::default(), - _ => return Err(format!("unsupported config version {version}")), - }; - root.insert( - "config_version".to_string(), - toml::Value::Integer(i64::from(CURRENT_CONFIG_VERSION)), - ); - Ok(result) -} - -fn migrate_legacy_layout(root: &mut toml::Table) -> MigrationResult { - // Missing legacy tables still represent omitted old fields, not a request for new defaults - if let Some(panel) = child_table_or_insert(root, "panel") { - insert_string(panel, "quick_actions_label", ""); - insert_string(panel, "system_status_label", ""); - insert_integer(panel, "empty_offset_top", 120); - insert_strings(panel, "section_order", &["widgets", "notifications"]); - insert_strings( - panel, - "widget_order", - &["sliders", "media", "toggles", "stats", "cards"], - ); + if version != CURRENT_CONFIG_VERSION { + // A clean schema break prevents old fields from receiving silently changed semantics + return Err(format!("unsupported config version {version}")); } - - let mut restore_legacy_cards = false; - if let Some(widgets) = child_table_or_insert(root, "widgets") { - insert_string(widgets, "density", "comfortable"); - insert_integer(widgets, "toggle_columns", 4); - insert_integer(widgets, "stat_columns", 2); - insert_integer(widgets, "card_columns", 2); - restore_legacy_cards = !widgets.contains_key("cards"); - for slider_name in ["volume", "brightness"] { - // Both sliders existed in the old effective config even when their tables were omitted - if let Some(slider) = child_table_or_insert(widgets, slider_name) { - insert_integer(slider, "segments", 0); - insert_bool(slider, "show_sublabels", false); - insert_string(slider, "sublabel_min", ""); - insert_string(slider, "sublabel_max", ""); - } - } - } - - if let Some(media) = child_table_or_insert(root, "media") { - insert_integer(media, "art_size_px", 50); - insert_integer(media, "text_width_floor_px", 140); - insert_integer(media, "content_spacing_px", 10); - insert_integer(media, "control_spacing_px", 6); - insert_integer(media, "navigation_spacing_px", 6); - } - - MigrationResult { - restore_legacy_cards, - } -} - -fn child_table_or_insert<'a>(table: &'a mut toml::Table, key: &str) -> Option<&'a mut toml::Table> { - // Existing invalid scalar values stay intact so deserialization can report the real type error - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::Table::new())) - .as_table_mut() -} - -fn insert_string(table: &mut toml::Table, key: &str, value: &str) { - // Explicit user values always win over compatibility defaults - table - .entry(key.to_string()) - .or_insert_with(|| toml::Value::String(value.to_string())); -} - -fn insert_integer(table: &mut toml::Table, key: &str, value: i64) { - // Entry insertion preserves existing values including values later rejected by serde - table - .entry(key.to_string()) - .or_insert(toml::Value::Integer(value)); -} - -fn insert_bool(table: &mut toml::Table, key: &str, value: bool) { - // Missing booleans receive legacy behavior without rewriting explicit false values - table - .entry(key.to_string()) - .or_insert(toml::Value::Boolean(value)); -} - -fn insert_strings(table: &mut toml::Table, key: &str, values: &[&str]) { - // Ordered arrays preserve the historic panel and widget placement - table.entry(key.to_string()).or_insert_with(|| { - toml::Value::Array( - values - .iter() - .map(|value| toml::Value::String((*value).to_string())) - .collect(), - ) - }); + Ok(()) } #[cfg(test)] diff --git a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml b/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml deleted file mode 100644 index 96307d81e..000000000 --- a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v0.toml +++ /dev/null @@ -1,11 +0,0 @@ -[panel] -width = 470 - -[widgets] -refresh_interval_ms = 1000 - -[widgets.volume] -enabled = true - -[media] -enabled = true diff --git a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml b/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml deleted file mode 100644 index 795dcdc17..000000000 --- a/crates/unixnotis-core/src/config/validation/tests/fixtures/config-v2-partial.toml +++ /dev/null @@ -1,13 +0,0 @@ -config_version = 2 - -[panel] -width = 470 - -[widgets] -refresh_interval_ms = 1000 - -[widgets.volume] -enabled = true - -[media] -enabled = true diff --git a/crates/unixnotis-core/src/config/validation/tests/schema.rs b/crates/unixnotis-core/src/config/validation/tests/schema.rs index 59557e9f5..844dca3f7 100644 --- a/crates/unixnotis-core/src/config/validation/tests/schema.rs +++ b/crates/unixnotis-core/src/config/validation/tests/schema.rs @@ -1,162 +1,95 @@ use super::*; -use crate::{PanelSection, PanelWidgetSection, WidgetDensity}; - -const LEGACY_FIXTURE: &str = include_str!("fixtures/config-v0.toml"); -const CURRENT_PARTIAL_FIXTURE: &str = include_str!("fixtures/config-v2-partial.toml"); fn deserialize_config(contents: &str) -> Result<(Config, Vec), String> { - let (config, ignored_keys, _migrated_paths) = deserialize_config_with_migrations(contents)?; - Ok((config, ignored_keys)) + deserialize_current_config(contents) } #[test] -fn unversioned_fixture_migrates_to_the_legacy_layout() { - let (config, ignored) = deserialize_config(LEGACY_FIXTURE).expect("migrate legacy config"); +fn current_schema_parses_with_current_defaults() { + let input = format!("config_version = {CURRENT_CONFIG_VERSION}\n[media]\n"); + let (config, ignored) = deserialize_config(&input).expect("parse current config"); assert!(ignored.is_empty()); assert_eq!(config.config_version, CURRENT_CONFIG_VERSION); - assert!(config.panel.quick_actions_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!( - config.panel.section_order, - vec![PanelSection::Widgets, PanelSection::Notifications] - ); assert_eq!( - config.panel.widget_order, - vec![ - PanelWidgetSection::Sliders, - PanelWidgetSection::Media, - PanelWidgetSection::Toggles, - PanelWidgetSection::Stats, - PanelWidgetSection::Cards, - ] + config.media.local_art_policy, + crate::MediaLocalArtPolicy::AllAdmitted ); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.volume.segments, 0); - assert!(!config.widgets.volume.show_sublabels); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); - assert_eq!(config.media.art_size_px, 50); } #[test] -fn current_partial_fixture_uses_current_defaults() { - let (config, ignored) = - deserialize_config(CURRENT_PARTIAL_FIXTURE).expect("parse current config"); +fn current_schema_preserves_explicit_values() { + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[panel]\nwidth = 517\n[media]\nlocal_art_policy = \"exact_executable_only\"\nlocal_art_executable_allowlist = [\"/usr/bin/player\"]\n" + ); + let (config, ignored) = deserialize_config(&input).expect("parse explicit current config"); assert!(ignored.is_empty()); - assert_eq!(config.panel.quick_actions_label, "Quick settings"); - assert_eq!(config.panel.empty_offset_top, 24); - assert_eq!(config.widgets.toggle_columns, 2); - assert_eq!(config.widgets.volume.segments, 10); - assert_eq!(config.media.art_size_px, 48); -} - -#[test] -fn future_schema_is_rejected_instead_of_guessed() { - let error = deserialize_config("config_version = 999\n").expect_err("reject future config"); - - assert!(error.contains("newer than supported")); + assert_eq!(config.panel.width, 517); + assert_eq!( + config.media.local_art_policy, + crate::MediaLocalArtPolicy::ExactExecutableOnly + ); + assert_eq!( + config.media.local_art_executable_allowlist, + ["/usr/bin/player"] + ); } #[test] -fn negative_schema_version_is_rejected_instead_of_wrapping() { - let error = deserialize_config("config_version = -1\n").expect_err("reject negative version"); - - assert!(error.contains("non-negative integer")); +fn every_pre_v5_schema_is_rejected_without_migration() { + for version in 0..CURRENT_CONFIG_VERSION { + let input = if version == 0 { + String::new() + } else { + format!("config_version = {version}\n") + }; + let error = deserialize_config(&input).expect_err("reject pre-v5 config"); + assert_eq!(error, format!("unsupported config version {version}")); + } } #[test] -fn explicit_legacy_values_remain_authoritative_during_migration() { - let text = "[panel]\nquick_actions_label = 'Custom'\nempty_offset_top = 77\n"; - let (config, _) = deserialize_config(text).expect("migrate explicit values"); +fn future_schema_is_rejected_without_guessing() { + let error = deserialize_config("config_version = 999\n").expect_err("reject future config"); - assert_eq!(config.panel.quick_actions_label, "Custom"); - assert_eq!(config.panel.empty_offset_top, 77); + assert_eq!(error, "unsupported config version 999"); } #[test] -fn empty_unversioned_config_receives_complete_legacy_defaults() { - let (config, ignored) = deserialize_config("").expect("migrate empty legacy config"); +fn oversized_schema_version_is_rejected_without_integer_wrapping() { + let error = deserialize_config("config_version = 4294967296\n") + .expect_err("reject schema version larger than u32"); - assert!(ignored.is_empty()); - assert!(config.panel.quick_actions_label.is_empty()); - assert!(config.panel.system_status_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!(config.widgets.density, WidgetDensity::Comfortable); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.stat_columns, 2); - assert_eq!(config.widgets.card_columns, 2); - assert_eq!(config.widgets.volume.segments, 0); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); - assert_eq!(config.media.art_size_px, 50); - assert_eq!(config.media.text_width_floor_px, 140); - assert_eq!(config.media.content_spacing_px, 10); - assert_eq!(config.media.control_spacing_px, 6); - assert_eq!(config.media.navigation_spacing_px, 6); + assert_eq!(error, "unsupported config version 4294967296"); } #[test] -fn legacy_widgets_without_slider_tables_receive_slider_compatibility() { - let (config, _) = deserialize_config("[widgets]\ntoggle_columns = 3\n") - .expect("migrate legacy widgets without sliders"); - - // Explicit layout remains authoritative while omitted slider visuals stay historic - assert_eq!(config.widgets.toggle_columns, 3); - assert_eq!(config.widgets.volume.segments, 0); - assert!(!config.widgets.volume.show_sublabels); - assert!(config.widgets.volume.sublabel_min.is_empty()); - assert!(config.widgets.volume.sublabel_max.is_empty()); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(!config.widgets.brightness.show_sublabels); - assert!(config.widgets.brightness.sublabel_min.is_empty()); - assert!(config.widgets.brightness.sublabel_max.is_empty()); +fn negative_or_non_integer_schema_versions_are_rejected() { + for input in [ + "config_version = -1\n", + "config_version = \"5\"\n", + "config_version = true\n", + ] { + let error = deserialize_config(input).expect_err("reject malformed schema version"); + assert_eq!(error, "config_version must be a non-negative integer"); + } } #[test] -fn legacy_config_without_panel_table_receives_panel_compatibility() { - let (config, _) = deserialize_config("[general]\ndnd_default = true\n") - .expect("migrate legacy config without panel"); - - assert!(config.panel.quick_actions_label.is_empty()); - assert!(config.panel.system_status_label.is_empty()); - assert_eq!(config.panel.empty_offset_top, 120); - assert_eq!( - config.panel.section_order, - vec![PanelSection::Widgets, PanelSection::Notifications] +fn current_schema_reports_unknown_keys_without_rejecting_valid_fields() { + let input = format!( + "config_version = {CURRENT_CONFIG_VERSION}\n[panel]\nwidth = 500\nunknown_panel_key = true\n" ); -} - -#[test] -fn legacy_config_without_media_table_receives_media_compatibility() { - let (config, _) = - deserialize_config("[panel]\nwidth = 480\n").expect("migrate legacy config without media"); - - assert_eq!(config.media.art_size_px, 50); - assert_eq!(config.media.text_width_floor_px, 140); - assert_eq!(config.media.content_spacing_px, 10); - assert_eq!(config.media.control_spacing_px, 6); - assert_eq!(config.media.navigation_spacing_px, 6); -} - -#[test] -fn legacy_config_without_widgets_table_receives_widget_compatibility() { - let (config, _) = deserialize_config("[panel]\nwidth = 480\n") - .expect("migrate legacy config without widgets"); + let (config, ignored) = deserialize_config(&input).expect("parse current config"); - assert_eq!(config.widgets.density, WidgetDensity::Comfortable); - assert_eq!(config.widgets.toggle_columns, 4); - assert_eq!(config.widgets.stat_columns, 2); - assert_eq!(config.widgets.card_columns, 2); - assert_eq!(config.widgets.volume.segments, 0); - assert_eq!(config.widgets.brightness.segments, 0); - assert!(config.widgets.cards.iter().all(|card| card.enabled)); + assert_eq!(config.panel.width, 500); + assert_eq!(ignored, ["panel.unknown_panel_key"]); } #[test] -fn malformed_legacy_table_is_reported_instead_of_replaced() { - let error = deserialize_config("panel = 'not a table'\n") - .expect_err("invalid legacy table should remain a type error"); +fn non_table_configuration_root_is_rejected() { + let error = deserialize_config("[1, 2, 3]").expect_err("reject non-table TOML root"); - assert!(error.contains("invalid type")); + assert!(!error.is_empty()); } diff --git a/crates/unixnotis-core/src/config/widgets/cards.rs b/crates/unixnotis-core/src/config/widgets/cards.rs index 5c848c43d..f8130ac98 100644 --- a/crates/unixnotis-core/src/config/widgets/cards.rs +++ b/crates/unixnotis-core/src/config/widgets/cards.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::WidgetPluginConfig; +use crate::CommandSpec; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] @@ -12,7 +13,7 @@ pub struct CardWidgetConfig { pub subtitle: Option, pub icon: Option, pub icon_asset: Option, - pub cmd: Option, + pub cmd: Option, /// External plugin source for this card (preferred over cmd when set) pub plugin: Option, pub min_height: i32, @@ -93,3 +94,7 @@ pub enum CardLayout { Banner, ImageRow, } + +#[cfg(test)] +#[path = "tests/cards.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/mod.rs b/crates/unixnotis-core/src/config/widgets/mod.rs index 13f358f0a..7b74a1cf3 100644 --- a/crates/unixnotis-core/src/config/widgets/mod.rs +++ b/crates/unixnotis-core/src/config/widgets/mod.rs @@ -12,4 +12,4 @@ pub use self::plugin::WidgetPluginConfig; pub use self::settings::{WidgetDensity, WidgetsConfig}; pub use self::sliders::{NumericParseMode, SliderWidgetConfig}; pub use self::stats::StatWidgetConfig; -pub use self::toggles::{ToggleLayout, ToggleWidgetConfig}; +pub use self::toggles::{ToggleBackend, ToggleLayout, ToggleWidgetConfig}; diff --git a/crates/unixnotis-core/src/config/widgets/plugin.rs b/crates/unixnotis-core/src/config/widgets/plugin.rs index ecd1e1e29..fe277c48e 100644 --- a/crates/unixnotis-core/src/config/widgets/plugin.rs +++ b/crates/unixnotis-core/src/config/widgets/plugin.rs @@ -1,12 +1,14 @@ use serde::{Deserialize, Serialize}; +use crate::CommandSpec; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] pub struct WidgetPluginConfig { /// Versioned widget plugin contract pub api_version: u32, /// Plugin command executed by the widget worker - pub command: String, + pub command: CommandSpec, /// Maximum allowed command runtime before timeout (milliseconds) pub timeout_ms: u64, /// Maximum accepted stdout payload size before parse rejection @@ -23,9 +25,13 @@ impl Default for WidgetPluginConfig { fn default() -> Self { Self { api_version: Self::API_VERSION_V1, - command: String::new(), + command: CommandSpec::direct("", std::iter::empty::<&str>()), timeout_ms: Self::DEFAULT_TIMEOUT_MS, max_output_bytes: Self::DEFAULT_MAX_OUTPUT_BYTES, } } } + +#[cfg(test)] +#[path = "tests/plugin.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/sliders.rs b/crates/unixnotis-core/src/config/widgets/sliders.rs index 66b4d830a..548c0af8b 100644 --- a/crates/unixnotis-core/src/config/widgets/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/sliders.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::CommandSpec; + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[serde(default)] pub struct SliderWidgetConfig { @@ -9,10 +11,10 @@ pub struct SliderWidgetConfig { pub label: String, pub icon: String, pub icon_muted: Option, - pub get_cmd: String, - pub set_cmd: String, - pub toggle_cmd: Option, - pub watch_cmd: Option, + pub get_cmd: CommandSpec, + pub set_cmd: CommandSpec, + pub toggle_cmd: Option, + pub watch_cmd: Option, pub min: f64, pub max: f64, pub step: f64, @@ -32,22 +34,37 @@ pub struct SliderWidgetConfig { impl SliderWidgetConfig { // wpctl is the stock PipeWire path and stays shell-free for the common case - pub(in crate::config) const WPCTL_GET: &'static str = "wpctl get-volume @DEFAULT_AUDIO_SINK@"; - pub(in crate::config) const WPCTL_SET: &'static str = - "wpctl set-volume @DEFAULT_AUDIO_SINK@ {value}%"; - pub(in crate::config) const WPCTL_TOGGLE: &'static str = - "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; + pub(in crate::config) fn wpctl_get() -> CommandSpec { + CommandSpec::direct("wpctl", ["get-volume", "@DEFAULT_AUDIO_SINK@"]) + } + + pub(in crate::config) fn wpctl_set() -> CommandSpec { + CommandSpec::direct("wpctl", ["set-volume", "@DEFAULT_AUDIO_SINK@", "{value}%"]) + } + + pub(in crate::config) fn wpctl_toggle() -> CommandSpec { + CommandSpec::direct("wpctl", ["set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]) + } // pactl supports both PulseAudio and pipewire-pulse setups - pub(in crate::config) const PACTL_GET: &'static str = - "pactl get-sink-volume @DEFAULT_SINK@; pactl get-sink-mute @DEFAULT_SINK@"; - pub(in crate::config) const PACTL_SET: &'static str = - "pactl set-sink-volume @DEFAULT_SINK@ {value}%"; - pub(in crate::config) const PACTL_TOGGLE: &'static str = - "pactl set-sink-mute @DEFAULT_SINK@ toggle"; + pub(in crate::config) fn pactl_get() -> CommandSpec { + CommandSpec::shell( + "pactl get-sink-volume @DEFAULT_SINK@; pactl get-sink-mute @DEFAULT_SINK@", + ) + } + + pub(in crate::config) fn pactl_set() -> CommandSpec { + CommandSpec::direct("pactl", ["set-sink-volume", "@DEFAULT_SINK@", "{value}%"]) + } + + pub(in crate::config) fn pactl_toggle() -> CommandSpec { + CommandSpec::direct("pactl", ["set-sink-mute", "@DEFAULT_SINK@", "toggle"]) + } // Long-running watcher used only when runtime detection confirms pactl exists - pub(in crate::config) const PACTL_WATCH: &'static str = "pactl subscribe"; + pub(in crate::config) fn pactl_watch() -> CommandSpec { + CommandSpec::direct("pactl", ["subscribe"]) + } pub(super) fn default_volume() -> Self { Self { @@ -56,9 +73,9 @@ impl SliderWidgetConfig { icon: "audio-volume-high-symbolic".to_string(), icon_muted: Some("audio-volume-muted-symbolic".to_string()), // Runtime migration may switch these to pactl only for untouched stock config - get_cmd: Self::WPCTL_GET.to_string(), - set_cmd: Self::WPCTL_SET.to_string(), - toggle_cmd: Some(Self::WPCTL_TOGGLE.to_string()), + get_cmd: Self::wpctl_get(), + set_cmd: Self::wpctl_set(), + toggle_cmd: Some(Self::wpctl_toggle()), // None avoids writing a watcher that may not exist on the target host watch_cmd: None, min: 0.0, @@ -81,8 +98,8 @@ impl SliderWidgetConfig { icon: "display-brightness-symbolic".to_string(), icon_muted: None, // -m keeps brightnessctl output stable enough for the shared parser - get_cmd: "brightnessctl -m".to_string(), - set_cmd: "brightnessctl s {value}%".to_string(), + get_cmd: CommandSpec::direct("brightnessctl", ["-m"]), + set_cmd: CommandSpec::direct("brightnessctl", ["s", "{value}%"]), toggle_cmd: None, // brightnessctl has no reliable stock watch mode, so polling remains explicit watch_cmd: None, @@ -117,3 +134,7 @@ pub enum NumericParseMode { /// Interprets values as 0.0-1.0 ratios and scales to percent Ratio, } + +#[cfg(test)] +#[path = "tests/sliders.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/stats.rs b/crates/unixnotis-core/src/config/widgets/stats.rs index 7e76ecfed..297946375 100644 --- a/crates/unixnotis-core/src/config/widgets/stats.rs +++ b/crates/unixnotis-core/src/config/widgets/stats.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use super::WidgetPluginConfig; +use crate::CommandSpec; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] @@ -10,7 +11,7 @@ pub struct StatWidgetConfig { pub icon: Option, pub icon_asset: Option, pub kind: Option, - pub cmd: Option, + pub cmd: Option, /// External plugin source for this stat (preferred over cmd when set) pub plugin: Option, pub min_height: i32, @@ -25,7 +26,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("cpu".to_string()), // Builtins avoid shelling out for common fast-refresh stats - cmd: Some("builtin:cpu".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:cpu", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } @@ -39,7 +43,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("ram".to_string()), // Memory comes from the same builtin path so defaults stay cheap to poll - cmd: Some("builtin:memory".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:memory", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } @@ -53,7 +60,10 @@ impl StatWidgetConfig { icon_asset: None, kind: Some("battery".to_string()), // Battery remains optional at runtime; systems without a battery render fallback text - cmd: Some("builtin:battery".to_string()), + cmd: Some(CommandSpec::direct( + "builtin:battery", + std::iter::empty::<&str>(), + )), plugin: None, min_height: 72, } @@ -74,3 +84,7 @@ impl Default for StatWidgetConfig { } } } + +#[cfg(test)] +#[path = "tests/stats.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/config/widgets/tests/cards.rs b/crates/unixnotis-core/src/config/widgets/tests/cards.rs index 51e35cf95..c477db622 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/cards.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/cards.rs @@ -1,4 +1,4 @@ -use crate::{CardLayout, CardWidgetConfig, WidgetPluginConfig, WidgetsConfig}; +use crate::{CardLayout, CardWidgetConfig, CommandSpec, WidgetPluginConfig, WidgetsConfig}; #[test] fn default_card_widgets_keep_builtin_identity_and_layout() { @@ -54,7 +54,7 @@ fn custom_card_layout_and_carousel_options_parse() { subtitle = "Live" icon = "image-x-generic-symbolic" icon_asset = "assets/card.webp" - cmd = "scripts/card" + cmd = { mode = "direct", program = "scripts/card" } min_height = 220 monospace = true carousel_dots = 5 @@ -62,7 +62,7 @@ fn custom_card_layout_and_carousel_options_parse() { [plugin] api_version = 1 - command = "scripts/card-plugin" + command = { mode = "direct", program = "scripts/card-plugin" } timeout_ms = 3000 max_output_bytes = 4096 "#, @@ -76,7 +76,10 @@ fn custom_card_layout_and_carousel_options_parse() { assert_eq!(card.subtitle.as_deref(), Some("Live")); assert_eq!(card.icon.as_deref(), Some("image-x-generic-symbolic")); assert_eq!(card.icon_asset.as_deref(), Some("assets/card.webp")); - assert_eq!(card.cmd.as_deref(), Some("scripts/card")); + assert_eq!( + card.cmd, + Some(CommandSpec::direct("scripts/card", [] as [&str; 0])) + ); assert_eq!(card.min_height, 220); assert!(card.monospace); assert_eq!(card.carousel_dots, 5); @@ -85,7 +88,7 @@ fn custom_card_layout_and_carousel_options_parse() { card.plugin, Some(WidgetPluginConfig { api_version: 1, - command: "scripts/card-plugin".to_string(), + command: CommandSpec::direct("scripts/card-plugin", [] as [&str; 0]), timeout_ms: 3000, max_output_bytes: 4096, }) diff --git a/crates/unixnotis-core/src/config/widgets/tests/plugin.rs b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs index fc70753ef..ee4de15d1 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/plugin.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/plugin.rs @@ -1,11 +1,11 @@ -use crate::WidgetPluginConfig; +use crate::{CommandSpec, WidgetPluginConfig}; #[test] fn widget_plugin_defaults_keep_contract_limits() { let plugin = WidgetPluginConfig::default(); assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); - assert_eq!(plugin.command, ""); + assert!(plugin.command.is_empty()); assert_eq!(plugin.timeout_ms, 2_000); assert_eq!(plugin.max_output_bytes, 16 * 1024); } @@ -14,13 +14,16 @@ fn widget_plugin_defaults_keep_contract_limits() { fn widget_plugin_partial_toml_uses_default_limits() { let plugin: WidgetPluginConfig = toml::from_str( r#" - command = "scripts/widget" + command = { mode = "direct", program = "scripts/widget" } "#, ) .expect("plugin should parse"); assert_eq!(plugin.api_version, WidgetPluginConfig::API_VERSION_V1); - assert_eq!(plugin.command, "scripts/widget"); + assert_eq!( + plugin.command, + CommandSpec::direct("scripts/widget", std::iter::empty::<&str>()) + ); assert_eq!(plugin.timeout_ms, WidgetPluginConfig::default().timeout_ms); assert_eq!( plugin.max_output_bytes, diff --git a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs index f0882e9e3..349bf9a43 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/sliders.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/sliders.rs @@ -1,9 +1,9 @@ -#![allow( +#![expect( clippy::float_cmp, reason = "TOML parsing preserves these exactly representable slider values" )] -use crate::{NumericParseMode, SliderWidgetConfig, WidgetsConfig}; +use crate::{CommandSpec, NumericParseMode, SliderWidgetConfig, WidgetsConfig}; #[test] fn default_slider_widgets_keep_stock_commands() { @@ -11,11 +11,11 @@ fn default_slider_widgets_keep_stock_commands() { assert!(widgets.volume.enabled); assert_eq!(widgets.volume.label, "Volume"); - assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::WPCTL_GET); - assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::WPCTL_SET); + assert_eq!(widgets.volume.get_cmd, SliderWidgetConfig::wpctl_get()); + assert_eq!(widgets.volume.set_cmd, SliderWidgetConfig::wpctl_set()); assert_eq!( - widgets.volume.toggle_cmd.as_deref(), - Some(SliderWidgetConfig::WPCTL_TOGGLE) + widgets.volume.toggle_cmd, + Some(SliderWidgetConfig::wpctl_toggle()) ); assert_eq!(widgets.volume.watch_cmd, None); assert_eq!(widgets.volume.segments, 10); @@ -25,8 +25,14 @@ fn default_slider_widgets_keep_stock_commands() { assert!(widgets.brightness.enabled); assert_eq!(widgets.brightness.label, "Brightness"); - assert_eq!(widgets.brightness.get_cmd, "brightnessctl -m"); - assert_eq!(widgets.brightness.set_cmd, "brightnessctl s {value}%"); + assert_eq!( + widgets.brightness.get_cmd, + CommandSpec::direct("brightnessctl", ["-m"]) + ); + assert_eq!( + widgets.brightness.set_cmd, + CommandSpec::direct("brightnessctl", ["s", "{value}%"]) + ); assert_eq!(widgets.brightness.watch_cmd, None); assert_eq!(widgets.brightness.segments, 10); assert!(widgets.brightness.show_sublabels); @@ -58,10 +64,10 @@ fn custom_slider_config_parses_numeric_bounds_and_labels() { label = "Mic" icon = "audio-input-microphone-symbolic" icon_muted = "microphone-disabled-symbolic" - get_cmd = "scripts/mic get" - set_cmd = "scripts/mic set {value}" - toggle_cmd = "scripts/mic toggle" - watch_cmd = "scripts/mic watch" + get_cmd = { mode = "direct", program = "scripts/mic", args = ["get"] } + set_cmd = { mode = "direct", program = "scripts/mic", args = ["set", "{value}"] } + toggle_cmd = { mode = "direct", program = "scripts/mic", args = ["toggle"] } + watch_cmd = { mode = "direct", program = "scripts/mic", args = ["watch"] } min = -12.5 max = 12.5 step = 0.5 @@ -81,8 +87,14 @@ fn custom_slider_config_parses_numeric_bounds_and_labels() { slider.icon_muted.as_deref(), Some("microphone-disabled-symbolic") ); - assert_eq!(slider.toggle_cmd.as_deref(), Some("scripts/mic toggle")); - assert_eq!(slider.watch_cmd.as_deref(), Some("scripts/mic watch")); + assert_eq!( + slider.toggle_cmd, + Some(CommandSpec::direct("scripts/mic", ["toggle"])) + ); + assert_eq!( + slider.watch_cmd, + Some(CommandSpec::direct("scripts/mic", ["watch"])) + ); assert_eq!(slider.min, -12.5); assert_eq!(slider.max, 12.5); assert_eq!(slider.step, 0.5); diff --git a/crates/unixnotis-core/src/config/widgets/tests/stats.rs b/crates/unixnotis-core/src/config/widgets/tests/stats.rs index f313ef783..a8bc5175d 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/stats.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/stats.rs @@ -1,4 +1,4 @@ -use crate::{StatWidgetConfig, WidgetPluginConfig, WidgetsConfig}; +use crate::{CommandSpec, StatWidgetConfig, WidgetPluginConfig, WidgetsConfig}; #[test] fn default_stat_widgets_keep_builtin_commands() { @@ -25,7 +25,10 @@ fn default_stat_widgets_keep_builtin_commands() { assert_eq!(stat.icon.as_deref(), Some(icon)); assert_eq!(stat.icon_asset, None); assert_eq!(stat.kind.as_deref(), Some(kind)); - assert_eq!(stat.cmd.as_deref(), Some(command)); + assert_eq!( + stat.cmd, + Some(CommandSpec::direct(command, [] as [&str; 0])) + ); assert_eq!(stat.min_height, 72); } } @@ -53,12 +56,12 @@ fn custom_stat_plugin_config_parses_with_command_fallback() { icon = "video-display-symbolic" icon_asset = "assets/gpu.svg" kind = "gpu" - cmd = "scripts/gpu-fallback" + cmd = { mode = "direct", program = "scripts/gpu-fallback" } min_height = 96 [plugin] api_version = 1 - command = "scripts/gpu-plugin" + command = { mode = "direct", program = "scripts/gpu-plugin" } timeout_ms = 1500 max_output_bytes = 2048 "#, @@ -70,13 +73,16 @@ fn custom_stat_plugin_config_parses_with_command_fallback() { assert_eq!(stat.icon.as_deref(), Some("video-display-symbolic")); assert_eq!(stat.icon_asset.as_deref(), Some("assets/gpu.svg")); assert_eq!(stat.kind.as_deref(), Some("gpu")); - assert_eq!(stat.cmd.as_deref(), Some("scripts/gpu-fallback")); + assert_eq!( + stat.cmd, + Some(CommandSpec::direct("scripts/gpu-fallback", [] as [&str; 0])) + ); assert_eq!(stat.min_height, 96); assert_eq!( stat.plugin, Some(WidgetPluginConfig { api_version: 1, - command: "scripts/gpu-plugin".to_string(), + command: CommandSpec::direct("scripts/gpu-plugin", [] as [&str; 0]), timeout_ms: 1500, max_output_bytes: 2048, }) diff --git a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs index b89822e90..a28b0c415 100644 --- a/crates/unixnotis-core/src/config/widgets/tests/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/tests/toggles.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::{ToggleLayout, ToggleWidgetConfig, WidgetsConfig}; +use crate::{CommandSpec, ToggleLayout, ToggleWidgetConfig, WidgetsConfig}; #[test] fn default_toggles_have_unique_stable_kinds() { @@ -26,16 +26,25 @@ fn default_night_toggle_uses_shipped_relative_scripts() { // The commands stay config-owned while core startup guarantees the files exist assert_eq!( - night.state_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-state") + night.state_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-state", + [] as [&str; 0] + )) ); assert_eq!( - night.on_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-on") + night.on_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-on", + [] as [&str; 0] + )) ); assert_eq!( - night.off_cmd.as_deref(), - Some("scripts/unixnotis-blue-light-off") + night.off_cmd, + Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-off", + [] as [&str; 0] + )) ); assert_eq!(night.toggle_cmd, None); assert_eq!(night.watch_cmd, None); @@ -47,18 +56,20 @@ fn default_toggles_keep_commands_config_owned() { for toggle in widgets.toggles { for command in [ - toggle.state_cmd.as_deref(), - toggle.toggle_cmd.as_deref(), - toggle.on_cmd.as_deref(), - toggle.off_cmd.as_deref(), - toggle.watch_cmd.as_deref(), + toggle.state_cmd.as_ref(), + toggle.toggle_cmd.as_ref(), + toggle.on_cmd.as_ref(), + toggle.off_cmd.as_ref(), + toggle.watch_cmd.as_ref(), ] .into_iter() .flatten() { // Stock commands should stay relative or PATH based so config files remain portable assert!( - !command.starts_with('/'), + command + .program() + .is_none_or(|program| !program.is_absolute()), "absolute command leaked: {command}" ); } @@ -75,11 +86,11 @@ fn custom_toggles_round_trip_arbitrary_user_commands() { label = "Build" icon = "applications-development-symbolic" icon_asset = "assets/build.svg" - state_cmd = "scripts/build-state" - toggle_cmd = "sh -c 'make test && notify-send done'" - on_cmd = "scripts/build-on" - off_cmd = "scripts/build-off" - watch_cmd = "scripts/build-watch" + state_cmd = { mode = "direct", program = "scripts/build-state" } + toggle_cmd = { mode = "shell", script = "make test && notify-send done" } + on_cmd = { mode = "direct", program = "scripts/build-on" } + off_cmd = { mode = "direct", program = "scripts/build-off" } + watch_cmd = { mode = "direct", program = "scripts/build-watch" } "#, ) .expect("widgets config should parse"); @@ -88,14 +99,26 @@ fn custom_toggles_round_trip_arbitrary_user_commands() { assert_eq!(toggle.kind.as_deref(), Some("build")); assert_eq!(toggle.label, "Build"); assert_eq!(toggle.icon_asset.as_deref(), Some("assets/build.svg")); - assert_eq!(toggle.state_cmd.as_deref(), Some("scripts/build-state")); assert_eq!( - toggle.toggle_cmd.as_deref(), - Some("sh -c 'make test && notify-send done'") + toggle.state_cmd, + Some(CommandSpec::direct("scripts/build-state", [] as [&str; 0])) + ); + assert_eq!( + toggle.toggle_cmd, + Some(CommandSpec::shell("make test && notify-send done")) + ); + assert_eq!( + toggle.on_cmd, + Some(CommandSpec::direct("scripts/build-on", [] as [&str; 0])) + ); + assert_eq!( + toggle.off_cmd, + Some(CommandSpec::direct("scripts/build-off", [] as [&str; 0])) + ); + assert_eq!( + toggle.watch_cmd, + Some(CommandSpec::direct("scripts/build-watch", [] as [&str; 0])) ); - assert_eq!(toggle.on_cmd.as_deref(), Some("scripts/build-on")); - assert_eq!(toggle.off_cmd.as_deref(), Some("scripts/build-off")); - assert_eq!(toggle.watch_cmd.as_deref(), Some("scripts/build-watch")); } #[test] diff --git a/crates/unixnotis-core/src/config/widgets/toggles.rs b/crates/unixnotis-core/src/config/widgets/toggles.rs index d5af0b093..6021c1cbd 100644 --- a/crates/unixnotis-core/src/config/widgets/toggles.rs +++ b/crates/unixnotis-core/src/config/widgets/toggles.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::config::command::defaults as commands; +use crate::CommandSpec; /// Icon and label orientation for toggle cards #[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq, Default)] @@ -11,6 +12,13 @@ pub enum ToggleLayout { Vertical, } +/// Built-in state parser used after a direct toggle command completes +#[derive(Debug, Copy, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ToggleBackend { + Rfkill, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(default)] pub struct ToggleWidgetConfig { @@ -22,14 +30,15 @@ pub struct ToggleWidgetConfig { pub label: String, pub icon: String, pub icon_asset: Option, - pub state_cmd: Option, + pub backend: Option, + pub state_cmd: Option, /// Optional command run for every user click before state is refreshed /// /// Useful for custom buttons that do not map cleanly to separate on/off commands - pub toggle_cmd: Option, - pub on_cmd: Option, - pub off_cmd: Option, - pub watch_cmd: Option, + pub toggle_cmd: Option, + pub on_cmd: Option, + pub off_cmd: Option, + pub watch_cmd: Option, } impl ToggleWidgetConfig { @@ -40,11 +49,12 @@ impl ToggleWidgetConfig { label: "Wi-Fi".to_string(), icon: "network-wireless-signal-excellent-symbolic".to_string(), icon_asset: None, - state_cmd: Some(commands::WIFI_STATE_NMCLI.to_string()), + backend: None, + state_cmd: Some(commands::wifi_state()), toggle_cmd: None, - on_cmd: Some(commands::WIFI_ON_NMCLI.to_string()), - off_cmd: Some(commands::WIFI_OFF_NMCLI.to_string()), - watch_cmd: Some(commands::WIFI_WATCH_NMCLI.to_string()), + on_cmd: Some(commands::wifi_on()), + off_cmd: Some(commands::wifi_off()), + watch_cmd: Some(commands::wifi_watch()), } } @@ -55,12 +65,13 @@ impl ToggleWidgetConfig { label: "Bluetooth".to_string(), icon: "bluetooth-active-symbolic".to_string(), icon_asset: None, - state_cmd: Some(commands::BLUETOOTH_STATE_BLUETOOTHCTL.to_string()), + backend: None, + state_cmd: Some(commands::bluetooth_state()), toggle_cmd: None, - on_cmd: Some(commands::BLUETOOTH_ON_BLUETOOTHCTL.to_string()), - off_cmd: Some(commands::BLUETOOTH_OFF_BLUETOOTHCTL.to_string()), + on_cmd: Some(commands::bluetooth_on()), + off_cmd: Some(commands::bluetooth_off()), // D-Bus monitoring avoids TTY requirements and follows BlueZ state changes - watch_cmd: Some(commands::BLUETOOTH_WATCH_DBUS.to_string()), + watch_cmd: Some(commands::bluetooth_watch()), } } @@ -71,12 +82,13 @@ impl ToggleWidgetConfig { label: "Airplane".to_string(), icon: "airplane-mode-symbolic".to_string(), icon_asset: None, - // Airplane reads active only when every rfkill device is soft-blocked - state_cmd: Some(commands::AIRPLANE_STATE_CMD.to_string()), + backend: Some(ToggleBackend::Rfkill), + // Airplane state is parsed from rfkill's machine-readable JSON output + state_cmd: Some(commands::airplane_state()), toggle_cmd: None, - on_cmd: Some(commands::AIRPLANE_ON_CMD.to_string()), - off_cmd: Some(commands::AIRPLANE_OFF_CMD.to_string()), - watch_cmd: Some(commands::AIRPLANE_WATCH_CMD.to_string()), + on_cmd: Some(commands::airplane_on()), + off_cmd: Some(commands::airplane_off()), + watch_cmd: Some(commands::airplane_watch()), } } @@ -87,11 +99,21 @@ impl ToggleWidgetConfig { label: "Night".to_string(), icon: "weather-clear-night-symbolic".to_string(), icon_asset: None, + backend: None, // Shipped scripts keep backend fallback logic in editable files - state_cmd: Some("scripts/unixnotis-blue-light-state".to_string()), + state_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-state", + std::iter::empty::<&str>(), + )), toggle_cmd: None, - on_cmd: Some("scripts/unixnotis-blue-light-on".to_string()), - off_cmd: Some("scripts/unixnotis-blue-light-off".to_string()), + on_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-on", + std::iter::empty::<&str>(), + )), + off_cmd: Some(CommandSpec::direct( + "scripts/unixnotis-blue-light-off", + std::iter::empty::<&str>(), + )), watch_cmd: None, } } @@ -105,6 +127,7 @@ impl Default for ToggleWidgetConfig { label: "Toggle".to_string(), icon: "applications-system-symbolic".to_string(), icon_asset: None, + backend: None, state_cmd: None, toggle_cmd: None, on_cmd: None, @@ -113,3 +136,7 @@ impl Default for ToggleWidgetConfig { } } } + +#[cfg(test)] +#[path = "tests/toggles.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/control/constants.rs b/crates/unixnotis-core/src/control/constants.rs index 2ddf75076..b6de8e201 100644 --- a/crates/unixnotis-core/src/control/constants.rs +++ b/crates/unixnotis-core/src/control/constants.rs @@ -6,6 +6,8 @@ pub const CONTROL_BUS_NAME: &str = "com.unixnotis.Control"; pub const CONTROL_OBJECT_PATH: &str = "/com/unixnotis/Control"; /// D-Bus interface name for control calls pub const CONTROL_INTERFACE: &str = "com.unixnotis.Control"; +/// Coordinated private interface version shared by daemon and UI binaries +pub const CONTROL_API_VERSION: u32 = 3; /// Freedesktop notification service name owned by the active notification daemon pub const NOTIFICATIONS_BUS_NAME: &str = "org.freedesktop.Notifications"; /// Inhibit scope meaning all notification output diff --git a/crates/unixnotis-core/src/control/diagnostics.rs b/crates/unixnotis-core/src/control/diagnostics.rs new file mode 100644 index 000000000..c6386952a --- /dev/null +++ b/crates/unixnotis-core/src/control/diagnostics.rs @@ -0,0 +1,27 @@ +//! Read-only notification explanation returned by the control service + +use serde::{Deserialize, Serialize}; +use zbus::zvariant::Type; + +use crate::{AttributionDiagnostics, IdentityAssurance, InteractionPolicies}; + +use super::{PopupAdmissionView, PopupDeliveryStage}; + +/// One active notification and the state that controls its popup rendering +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct NotificationDiagnosticsView { + pub id: u32, + pub generation: u64, + pub stored: bool, + pub attribution: AttributionDiagnostics, + // Final authority stays separate from the lower-level launch evidence above + pub identity_assurance: IdentityAssurance, + pub interaction_policies: InteractionPolicies, + pub popup_admission: PopupAdmissionView, + pub renderer_process_running: bool, + pub renderer_ready: bool, + pub renderer_health_revision: u64, + pub configured_max_visible: u32, + pub decided_at_unix_ms: i64, + pub delivery_stage: PopupDeliveryStage, +} diff --git a/crates/unixnotis-core/src/control/mod.rs b/crates/unixnotis-core/src/control/mod.rs index 8f4f4fb50..9768abc6b 100644 --- a/crates/unixnotis-core/src/control/mod.rs +++ b/crates/unixnotis-core/src/control/mod.rs @@ -1,15 +1,19 @@ //! D-Bus control interface types and proxy definitions mod constants; +mod diagnostics; mod notification; mod panel; mod policy; mod proxy; mod state; +mod version; pub use constants::*; +pub use diagnostics::*; pub use notification::*; pub use panel::*; pub use policy::*; pub use proxy::*; pub use state::*; +pub use version::*; diff --git a/crates/unixnotis-core/src/control/notification.rs b/crates/unixnotis-core/src/control/notification.rs index 999e4c4c4..4ae3db946 100644 --- a/crates/unixnotis-core/src/control/notification.rs +++ b/crates/unixnotis-core/src/control/notification.rs @@ -1,10 +1,13 @@ //! Notification close reason wire types +use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; +use crate::NotificationView; + /// Reason codes aligned with the notification specification -#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] #[repr(u32)] pub enum CloseReason { Expired = 1, @@ -12,3 +15,71 @@ pub enum CloseReason { ClosedByCall = 3, Undefined = 4, } + +/// Current reason a stored notification may or may not become a popup +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum PopupAdmissionView { + Show = 0, + Rule = 1, + Dnd = 2, + Inhibitor = 3, + #[default] + RendererUnavailable = 4, + RendererDisabled = 5, +} + +impl PopupAdmissionView { + /// Whether the current admission permits popup rendering + #[must_use] + pub const fn should_show(self) -> bool { + matches!(self, Self::Show) + } +} + +/// Furthest delivery stage reached by one committed popup decision +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum PopupDeliveryStage { + #[default] + Suppressed = 0, + Admitted = 1, + FanoutFailed = 2, + RendererFetched = 3, + Materialized = 4, + Visible = 5, +} + +impl PopupDeliveryStage { + /// Monotonic ordering for retained delivery history + #[must_use] + pub const fn rank(self) -> u8 { + self as u8 + } +} + +/// Immutable arrival decision plus later delivery progress for one generation +#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize, Deserialize, Type)] +pub struct PopupDecisionRecord { + pub admission_at_commit: PopupAdmissionView, + pub renderer_process_running_at_commit: bool, + pub renderer_ready_at_commit: bool, + /// Readiness revision observed while the notification was committed + pub renderer_health_revision_at_commit: u64, + pub max_visible_at_commit: u32, + pub decided_at_unix_ms: i64, + pub delivery_stage: PopupDeliveryStage, + /// Sanitized banner visibility duration fixed for this notification generation + pub popup_hide_after_ms: u64, +} + +/// One atomic popup payload and its current admission decision +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, Type)] +pub struct PopupCandidate { + pub notification: NotificationView, + pub admission: PopupAdmissionView, +} + +#[cfg(test)] +#[path = "tests/notification.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/control/proxy.rs b/crates/unixnotis-core/src/control/proxy.rs index 25fd679ad..5b5f9e7ba 100644 --- a/crates/unixnotis-core/src/control/proxy.rs +++ b/crates/unixnotis-core/src/control/proxy.rs @@ -1,11 +1,18 @@ //! Generated D-Bus control proxy contract +// The proxy macro creates signal collections consumed through generated streams +#![expect( + clippy::collection_is_never_read, + reason = "the zbus proxy macro generates signal collections consumed through generated streams" +)] + use zbus::proxy; -use crate::NotificationView; +use crate::{NotificationDiagnosticsView, NotificationView, PopupCandidate}; use super::{ - CloseReason, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, PopupGateState, + CloseReason, ControlSnapshot, ControlState, InhibitorInfo, PanelDebugLevel, PanelRequest, + PopupGateState, UiHealth, }; #[proxy( @@ -14,16 +21,33 @@ use super::{ default_path = "/com/unixnotis/Control" )] trait Control { + /// Coordinated private interface version + fn get_api_version(&self) -> zbus::Result; /// Current daemon state fn get_state(&self) -> zbus::Result; + /// Complete active/history seed captured under one store lock + fn get_snapshot(&self) -> zbus::Result; + /// Readiness of the daemon-managed center and popup clients + fn get_ui_health(&self) -> zbus::Result; /// Active notifications intended for popups fn list_active(&self) -> zbus::Result>; + /// Active notifications whose persistent rule policy permits popup rendering + fn list_popup_candidates(&self) -> zbus::Result>; /// History notifications for the panel fn list_history(&self) -> zbus::Result>; /// Fetch one currently active notification by identifier fn get_active_notification(&self, id: u32) -> zbus::Result>; + /// Fetch one current popup payload and admission decision atomically + fn get_popup_candidate(&self, id: u32) -> zbus::Result>; + /// Explain attribution and popup admission for one active notification + fn get_notification_diagnostics( + &self, + id: u32, + ) -> zbus::Result>; /// Open the control center panel fn open_panel(&self) -> zbus::Result<()>; + /// Rebuild the desktop application index immediately + fn refresh_applications(&self) -> zbus::Result<()>; /// Open the control center panel with debug logging fn open_panel_debug(&self, level: PanelDebugLevel) -> zbus::Result<()>; /// Close the control center panel @@ -32,6 +56,8 @@ trait Control { fn toggle_panel(&self) -> zbus::Result<()>; /// Update the Do Not Disturb state fn set_dnd(&self, enabled: bool) -> zbus::Result<()>; + /// Enable Do Not Disturb until one future Unix timestamp + fn set_dnd_until(&self, expires_at: i64) -> zbus::Result<()>; /// Toggle the Do Not Disturb state atomically in the daemon fn toggle_dnd(&self) -> zbus::Result<()>; /// Register an inhibitor and return its token @@ -40,10 +66,18 @@ trait Control { fn uninhibit(&self, id: u64) -> zbus::Result<()>; /// List active inhibitors fn list_inhibitors(&self) -> zbus::Result>; - /// Remove a notification by identifier - fn dismiss(&self, id: u32) -> zbus::Result<()>; - /// Invoke an action key for a notification - fn invoke_action(&self, id: u32, action_key: &str) -> zbus::Result<()>; + /// Remove only the exact notification generation represented by a UI row + fn dismiss_generation(&self, id: u32, generation: u64) -> zbus::Result<()>; + /// Invoke an action only for the exact notification generation represented by a UI row + fn invoke_action_generation( + &self, + id: u32, + generation: u64, + action_key: &str, + confirmed: bool, + ) -> zbus::Result<()>; + /// Submit text for an explicitly advertised inline-reply action + fn reply_notification(&self, id: u32, generation: u64, reply_text: &str) -> zbus::Result<()>; /// Clear active notifications and saved history fn clear_all(&self) -> zbus::Result<()>; /// Clear active notifications without deleting saved history @@ -53,14 +87,29 @@ trait Control { /// Mark the panel UI ready after signal subscriptions are active fn mark_panel_ready(&self) -> zbus::Result<()>; /// Clear panel readiness while the UI reconnects or shuts down + #[zbus(no_autostart)] fn mark_panel_not_ready(&self) -> zbus::Result<()>; + /// Mark popup rendering ready after subscriptions, seed, and GTK initialization + fn mark_popups_ready(&self) -> zbus::Result<()>; + /// Clear popup readiness during orderly shutdown without activating the daemon + #[zbus(no_autostart)] + fn mark_popups_not_ready(&self) -> zbus::Result<()>; + /// Confirm that GTK attached one exact generation to the popup stack + fn mark_popup_materialized(&self, id: u32, generation: u64) -> zbus::Result<()>; + /// Confirm that one exact generation became visible on a mapped popup surface + fn mark_popup_visible(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] - fn notification_added(&self, id: u32, show_popup: bool) -> zbus::Result<()>; + fn notification_added(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] - fn notification_updated(&self, id: u32, show_popup: bool) -> zbus::Result<()>; + fn notification_updated(&self, id: u32, generation: u64) -> zbus::Result<()>; #[zbus(signal)] - fn notification_closed(&self, id: u32, reason: CloseReason) -> zbus::Result<()>; + fn notification_closed( + &self, + id: u32, + generation: u64, + reason: CloseReason, + ) -> zbus::Result<()>; #[zbus(signal)] fn state_changed(&self, state: ControlState) -> zbus::Result<()>; /// Emitted only when popup gating changes diff --git a/crates/unixnotis-core/src/control/state.rs b/crates/unixnotis-core/src/control/state.rs index b48553b97..e12eb3fa5 100644 --- a/crates/unixnotis-core/src/control/state.rs +++ b/crates/unixnotis-core/src/control/state.rs @@ -3,10 +3,14 @@ use serde::{Deserialize, Serialize}; use zbus::zvariant::Type; +use crate::NotificationView; + /// Control-plane state broadcast to the UI #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct ControlState { pub dnd_enabled: bool, + /// Unix timestamp in seconds, or zero for an indefinite/disabled state + pub dnd_expires_at: i64, pub history_count: u32, /// True when at least one active inhibitor suppresses popups pub inhibited: bool, @@ -14,6 +18,14 @@ pub struct ControlState { pub inhibitor_count: u32, } +/// Active and historical rows captured under one daemon store lock +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +pub struct ControlSnapshot { + pub state: ControlState, + pub active: Vec, + pub history: Vec, +} + /// Popup gating fields that affect toast visibility #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct PopupGateState { @@ -21,5 +33,16 @@ pub struct PopupGateState { pub inhibited: bool, } +/// Process and handshake state for both daemon-managed user interfaces +#[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] +pub struct UiHealth { + pub center_process_running: bool, + pub center_ready: bool, + pub popups_process_running: bool, + pub popups_ready: bool, + /// Monotonic readiness revision sampled with popup admission + pub revision: u64, +} + /// Tuple layout for inhibitor listings: identifier, reason, scope, and owner pub type InhibitorInfo = (u64, String, u32, String); diff --git a/crates/unixnotis-core/src/control/tests/notification.rs b/crates/unixnotis-core/src/control/tests/notification.rs new file mode 100644 index 000000000..1272f1606 --- /dev/null +++ b/crates/unixnotis-core/src/control/tests/notification.rs @@ -0,0 +1,59 @@ +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +use super::{PopupAdmissionView, PopupDeliveryStage}; + +#[test] +fn popup_admission_wire_values_remain_stable_and_complete() { + for (admission, expected) in [ + (PopupAdmissionView::Show, 0_u8), + (PopupAdmissionView::Rule, 1), + (PopupAdmissionView::Dnd, 2), + (PopupAdmissionView::Inhibitor, 3), + (PopupAdmissionView::RendererUnavailable, 4), + (PopupAdmissionView::RendererDisabled, 5), + ] { + let encoded = to_bytes(Context::new_dbus(LE, 0), &admission) + .expect("popup admission should serialize"); + + assert_eq!(encoded.bytes(), &[expected]); + } + + assert_eq!(PopupAdmissionView::signature(), u8::signature()); +} + +#[test] +fn popup_delivery_stage_wire_values_remain_stable_and_complete() { + for (stage, expected) in [ + (PopupDeliveryStage::Suppressed, 0_u8), + (PopupDeliveryStage::Admitted, 1), + (PopupDeliveryStage::FanoutFailed, 2), + (PopupDeliveryStage::RendererFetched, 3), + (PopupDeliveryStage::Materialized, 4), + (PopupDeliveryStage::Visible, 5), + ] { + let encoded = to_bytes(Context::new_dbus(LE, 0), &stage) + .expect("popup delivery stage should serialize"); + + assert_eq!(encoded.bytes(), &[expected]); + } + + assert_eq!(PopupDeliveryStage::signature(), u8::signature()); +} + +#[test] +fn only_show_admission_permits_popup_rendering() { + assert!(PopupAdmissionView::Show.should_show()); + + for admission in [ + PopupAdmissionView::Rule, + PopupAdmissionView::Dnd, + PopupAdmissionView::Inhibitor, + PopupAdmissionView::RendererUnavailable, + PopupAdmissionView::RendererDisabled, + ] { + assert!( + !admission.should_show(), + "{admission:?} should keep the popup hidden", + ); + } +} diff --git a/crates/unixnotis-core/src/control/version.rs b/crates/unixnotis-core/src/control/version.rs new file mode 100644 index 000000000..d810dbb42 --- /dev/null +++ b/crates/unixnotis-core/src/control/version.rs @@ -0,0 +1,35 @@ +//! Private control-interface version negotiation + +use thiserror::Error; + +use super::{ControlProxy, CONTROL_API_VERSION}; +use crate::timed_dbus_call; + +/// Failure to prove that `UnixNotis` components share one control contract +#[derive(Debug, Error)] +pub enum ControlApiVersionError { + #[error("read UnixNotis control API version: {0}")] + Transport(#[from] zbus::Error), + #[error("UnixNotis component version mismatch: expected {expected}, got {actual}")] + Mismatch { expected: u32, actual: u32 }, +} + +/// Require the daemon and client to use the same private interface version +/// +/// # Errors +/// +/// Returns a transport error when the version cannot be read or a mismatch error when the daemon +/// and client use different private control contracts +pub async fn ensure_control_api_version( + proxy: &ControlProxy<'_>, +) -> Result<(), ControlApiVersionError> { + let actual = timed_dbus_call(proxy.get_api_version()).await?; + if actual == CONTROL_API_VERSION { + Ok(()) + } else { + Err(ControlApiVersionError::Mismatch { + expected: CONTROL_API_VERSION, + actual, + }) + } +} diff --git a/crates/unixnotis-core/src/css/features.rs b/crates/unixnotis-core/src/css/features.rs index 76c869b90..5513d731f 100644 --- a/crates/unixnotis-core/src/css/features.rs +++ b/crates/unixnotis-core/src/css/features.rs @@ -1,25 +1,18 @@ //! Shared GTK CSS capability checks -pub const GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL: &str = "GTK 4.16+"; +pub const GTK_MIN_VERSION_LABEL: &str = "GTK 4.18+"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct GtkCssFeatures { - // Newer GTK builds can expand var() and custom properties + // The installer uses this capability to enforce the supported baseline pub custom_properties: bool, } -impl GtkCssFeatures { - #[must_use] - pub const fn supports_modern_theme_tokens(self) -> bool { - self.custom_properties - } -} - #[must_use] pub const fn gtk_css_features_for_version(major: u32, minor: u32) -> GtkCssFeatures { - // GTK 4.16 added custom properties and var() + // GTK 4.18 is the common baseline for CSS variables and popup Wayland APIs GtkCssFeatures { - custom_properties: major > 4 || (major == 4 && minor >= 16), + custom_properties: major > 4 || (major == 4 && minor >= 18), } } @@ -38,7 +31,7 @@ fn parse_major_minor(version: &str) -> Option<(u32, u32)> { } fn parse_version_part(part: &str) -> Option { - // Stop at the first non-digit so values like 4.16.0-2 still parse cleanly + // Stop at the first non-digit so values like 4.18.0-2 still parse cleanly let digits = part .trim() .chars() diff --git a/crates/unixnotis-core/src/css/hooks/classes.rs b/crates/unixnotis-core/src/css/hooks/classes.rs index 91461f4d0..a31e141ef 100644 --- a/crates/unixnotis-core/src/css/hooks/classes.rs +++ b/crates/unixnotis-core/src/css/hooks/classes.rs @@ -6,7 +6,27 @@ pub mod shared_state { pub const CRITICAL: &str = "critical"; pub const EMPTY: &str = "empty"; pub const PLAYING: &str = "playing"; - pub const STACKED: &str = "stacked"; + pub const COLLAPSED_GROUP_PREVIEW: &str = "collapsed-group-preview"; +} + +pub mod urgency { + // One badge class keeps popup and panel urgency labels visually aligned + pub const BADGE: &str = "unixnotis-urgency-badge"; +} + +pub mod cut_corner { + // The wrapper hook lets themes adjust the primitive without using its custom CSS node name + pub const ROOT: &str = "unixnotis-cut-corner"; +} + +pub mod dnd_menu { + // Timed DND hooks expose the popover layers without relying on GTK node names + pub const ROOT: &str = "unixnotis-dnd-menu"; + pub const CONTENT: &str = "unixnotis-dnd-menu-content"; + pub const TITLE: &str = "unixnotis-dnd-menu-title"; + pub const CHOICE: &str = "unixnotis-dnd-menu-choice"; + pub const INDEFINITE: &str = "unixnotis-dnd-menu-choice-indefinite"; + pub const SEPARATOR: &str = "unixnotis-dnd-menu-separator"; } pub mod panel_action { @@ -31,6 +51,7 @@ pub mod panel_shell { // Panel shell hooks keep split panel files on one stable class contract pub const WINDOW: &str = "unixnotis-panel-window"; pub const ROOT: &str = "unixnotis-panel"; + pub const REDUCED_MOTION: &str = "unixnotis-reduced-motion"; pub const HEADER: &str = "unixnotis-panel-header"; pub const HEADER_TOP: &str = "unixnotis-panel-header-top"; pub const TITLE_STACK: &str = "unixnotis-panel-title-stack"; @@ -39,6 +60,9 @@ pub mod panel_shell { pub const SUBTITLE: &str = "unixnotis-panel-subtitle"; pub const COUNT: &str = "unixnotis-panel-count"; pub const SEARCH: &str = "unixnotis-panel-search"; + pub const SEARCH_MAGNIFIER: &str = "unixnotis-panel-search-magnifier"; + pub const SEARCH_CLEAR: &str = "unixnotis-panel-search-clear"; + pub const SEARCH_OWNED_ICONS: &str = "unixnotis-panel-search-owned-icons"; pub const SEARCH_SHELL: &str = "unixnotis-panel-search-shell"; pub const SEARCH_ACCENT: &str = "unixnotis-panel-search-accent"; pub const SEARCH_STAR: &str = "unixnotis-panel-search-star"; @@ -46,6 +70,7 @@ pub mod panel_shell { pub const RELOAD_NOTICE: &str = "unixnotis-reload-notice"; pub const RELOAD_NOTICE_ERROR: &str = "unixnotis-reload-notice-error"; pub const RELOAD_NOTICE_WARNING: &str = "unixnotis-reload-notice-warning"; + pub const RELOAD_NOTICE_CONTENT: &str = "unixnotis-reload-notice-content"; pub const RELOAD_NOTICE_TEXT: &str = "unixnotis-reload-notice-text"; pub const RELOAD_NOTICE_CLOSE: &str = "unixnotis-reload-notice-close"; pub const BODY_STACK: &str = "unixnotis-panel-body-stack"; @@ -84,8 +109,8 @@ pub mod panel_card { pub const FOOTER_LEFT: &str = "unixnotis-panel-card-footer-left"; pub const FOOTER_RIGHT: &str = "unixnotis-panel-card-footer-right"; pub const THUMBNAIL: &str = "unixnotis-panel-card-thumbnail"; - pub const GROUP_COLLAPSED: &str = "unixnotis-panel-card-group-collapsed"; - pub const GROUP_EXPANDED: &str = "unixnotis-panel-card-group-expanded"; + pub const CONTENT_IMAGE: &str = "unixnotis-panel-content-image"; + pub const SENDER_VISUAL: &str = "unixnotis-panel-sender-visual"; pub const GROUPED: &str = "unixnotis-panel-card-grouped"; pub const HAS_ACTIONS: &str = "unixnotis-panel-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-panel-card-has-body"; @@ -158,6 +183,7 @@ pub mod popup_card { pub const HAS_ACTIONS: &str = "unixnotis-popup-card-has-actions"; pub const HAS_BODY: &str = "unixnotis-popup-card-has-body"; pub const HAS_ICON: &str = "unixnotis-popup-card-has-icon"; + pub const HAS_IMAGE: &str = "unixnotis-popup-card-has-image"; pub const HAS_SUMMARY: &str = "unixnotis-popup-card-has-summary"; pub const NO_ICON: &str = "unixnotis-popup-card-no-icon"; } @@ -181,11 +207,6 @@ pub mod empty_row { pub const LABEL: &str = "unixnotis-empty-label"; } -pub mod ghost_row { - pub const ROOT: &str = "unixnotis-stack-ghost"; - pub const DEPTH_PREFIX: &str = "unixnotis-stack-ghost-"; -} - pub mod media_card { pub const EMPTY_ARTIST: &str = "unixnotis-media-card-empty-artist"; pub const HAS_ART: &str = "unixnotis-media-card-has-art"; diff --git a/crates/unixnotis-core/src/css/hooks/mod.rs b/crates/unixnotis-core/src/css/hooks/mod.rs index a1c86ebd8..ca2b39cd7 100644 --- a/crates/unixnotis-core/src/css/hooks/mod.rs +++ b/crates/unixnotis-core/src/css/hooks/mod.rs @@ -3,10 +3,10 @@ mod classes; pub use self::classes::{ - empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, - panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, + cut_corner, dnd_menu, empty_row, group_row, info_card, media_card, media_shell, panel_action, + panel_card, panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, urgency, }; #[cfg(test)] -#[path = "../tests/hooks.rs"] +#[path = "tests/hooks.rs"] mod tests; diff --git a/crates/unixnotis-core/src/css/tests/hooks.rs b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs similarity index 69% rename from crates/unixnotis-core/src/css/tests/hooks.rs rename to crates/unixnotis-core/src/css/hooks/tests/hooks.rs index 3f833e42a..3e64f818b 100644 --- a/crates/unixnotis-core/src/css/tests/hooks.rs +++ b/crates/unixnotis-core/src/css/hooks/tests/hooks.rs @@ -1,8 +1,10 @@ +//! Public CSS hook consistency tests + use std::collections::HashSet; use super::{ - empty_row, ghost_row, group_row, info_card, media_card, media_shell, panel_action, panel_card, - panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, + cut_corner, dnd_menu, empty_row, group_row, info_card, media_card, media_shell, panel_action, + panel_card, panel_shell, popup_card, shared_state, slider, stat_card, toggle_card, urgency, }; #[test] @@ -13,11 +15,19 @@ use super::{ fn hook_names_stay_unique() { // One flat set makes accidental selector reuse obvious during refactors let names = [ + cut_corner::ROOT, + dnd_menu::ROOT, + dnd_menu::CONTENT, + dnd_menu::TITLE, + dnd_menu::CHOICE, + dnd_menu::INDEFINITE, + dnd_menu::SEPARATOR, shared_state::ACTIVE, shared_state::CRITICAL, shared_state::EMPTY, shared_state::PLAYING, - shared_state::STACKED, + shared_state::COLLAPSED_GROUP_PREVIEW, + urgency::BADGE, panel_action::FOCUS, panel_action::PRIMARY, panel_action::MUTED, @@ -34,6 +44,7 @@ fn hook_names_stay_unique() { panel_action::LABEL_HIDDEN, panel_shell::WINDOW, panel_shell::ROOT, + panel_shell::REDUCED_MOTION, panel_shell::HEADER, panel_shell::HEADER_TOP, panel_shell::TITLE_STACK, @@ -42,6 +53,9 @@ fn hook_names_stay_unique() { panel_shell::SUBTITLE, panel_shell::COUNT, panel_shell::SEARCH, + panel_shell::SEARCH_MAGNIFIER, + panel_shell::SEARCH_CLEAR, + panel_shell::SEARCH_OWNED_ICONS, panel_shell::SEARCH_SHELL, panel_shell::SEARCH_ACCENT, panel_shell::SEARCH_STAR, @@ -49,6 +63,7 @@ fn hook_names_stay_unique() { panel_shell::RELOAD_NOTICE, panel_shell::RELOAD_NOTICE_ERROR, panel_shell::RELOAD_NOTICE_WARNING, + panel_shell::RELOAD_NOTICE_CONTENT, panel_shell::RELOAD_NOTICE_TEXT, panel_shell::RELOAD_NOTICE_CLOSE, panel_shell::BODY_STACK, @@ -84,8 +99,6 @@ fn hook_names_stay_unique() { panel_card::FOOTER_LEFT, panel_card::FOOTER_RIGHT, panel_card::THUMBNAIL, - panel_card::GROUP_COLLAPSED, - panel_card::GROUP_EXPANDED, panel_card::GROUPED, panel_card::HAS_ACTIONS, panel_card::HAS_BODY, @@ -143,6 +156,7 @@ fn hook_names_stay_unique() { popup_card::HAS_ACTIONS, popup_card::HAS_BODY, popup_card::HAS_ICON, + popup_card::HAS_IMAGE, popup_card::HAS_SUMMARY, popup_card::NO_ICON, group_row::ROOT, @@ -158,8 +172,6 @@ fn hook_names_stay_unique() { group_row::NO_ICON, empty_row::ROOT, empty_row::LABEL, - ghost_row::ROOT, - ghost_row::DEPTH_PREFIX, media_card::EMPTY_ARTIST, media_card::HAS_ART, media_card::HAS_ARTIST, @@ -226,9 +238,18 @@ fn stock_panel_css_targets_real_group_card_hooks() { let css = crate::theme::DEFAULT_PANEL_CSS; // Group headers and notification cards are sibling ListView rows, not nested widgets - // Stock CSS must target direct card hooks so grouped spacing actually applies - assert!(css.contains(&format!(".{}", panel_card::GROUPED))); - assert!(css.contains(&format!(".{}", panel_card::GROUP_COLLAPSED))); + // Grouped cards stay separate while collapsed previews own their internal depth layers + assert!(css.contains(&format!(".unixnotis-panel-card.{}", panel_card::GROUPED))); + assert!(css.contains(&format!( + ".unixnotis-panel-card-foreground.{}", + panel_card::GROUPED + ))); + assert!(css.contains(&format!( + ".unixnotis-panel-card.{}", + shared_state::COLLAPSED_GROUP_PREVIEW + ))); + assert!(css.contains(".unixnotis-stack-layer-back")); + assert!(css.contains(".unixnotis-stack-layer-middle")); // These selectors belonged to an older nested-card idea and do not match the real tree assert!(!css.contains("unixnotis-group-cards")); @@ -237,17 +258,44 @@ fn stock_panel_css_targets_real_group_card_hooks() { } #[test] -fn stock_panel_css_uses_two_overlapping_full_card_stack_layers() { +fn stock_group_count_stays_neutral_during_header_hover() { + let css = crate::theme::DEFAULT_PANEL_CSS; + + assert!(css.contains( + ".unixnotis-group-header:hover .unixnotis-group-count {\n background: alpha(#ffffff, 0.09);" + )); + assert!(!css.contains( + ".unixnotis-group-header:hover .unixnotis-group-count {\n background: alpha(@unixnotis-accent" + )); +} + +#[test] +fn stock_panel_css_uses_separated_rows_and_bounded_depth_layers() { let css = crate::theme::DEFAULT_PANEL_CSS; - // Full-height rear layers overlap so themes retain a coherent card silhouette - assert!(css.contains(".unixnotis-stack-ghost")); - assert!(css.contains("min-height: 68px;")); - assert!(css.contains("margin-left: 10px;")); - assert!(css.contains("margin-top: -58px;")); + // The discarded legacy names stay absent while the new layers remain explicit + assert!(!css.contains("unixnotis-stack-ghost")); + assert!(css.contains(".unixnotis-stack-layer-back")); + assert!(css.contains(".unixnotis-stack-layer-middle")); + assert!(!css.contains("margin: 6px 14px 0")); + assert!(!css.contains("margin: 12px 8px 0")); + assert!(css.contains(".unixnotis-panel-card-row {\n margin: 0;")); + assert!(css + .contains(".unixnotis-panel-list {\n background: transparent;\n padding-bottom: 20px;")); + assert!(css.contains("border-radius: var(--unixnotis-notification-card-radius);")); + assert!(css.contains("padding: var(--unixnotis-panel-card-padding-y)")); + assert!(!css.contains("0 16px 32px -18px")); + assert!(!css.contains("margin: -58px 14px 0")); + assert!(!css.contains("margin: 0 20px")); + assert!(css.contains(".unixnotis-panel-card.unixnotis-panel-card-grouped {\n border-radius:")); +} + +#[test] +fn stock_panel_close_control_remains_available_without_hover() { + let css = crate::theme::DEFAULT_PANEL_CSS; - // The back layer narrows again and starts the stack without a negative offset - assert!(css.contains(".unixnotis-stack-ghost-2")); - assert!(css.contains("margin-left: 20px;")); - assert!(css.contains("margin-top: 0;")); + assert!(css.contains(".unixnotis-panel-close {\n")); + assert!(!css.contains(".unixnotis-panel-card-overlay:hover .unixnotis-panel-close")); + assert!(css.contains(".unixnotis-panel-close:hover")); + assert!(css.contains(".unixnotis-panel-close:focus-visible")); } diff --git a/crates/unixnotis-core/src/css/limits.rs b/crates/unixnotis-core/src/css/limits.rs new file mode 100644 index 000000000..e64464040 --- /dev/null +++ b/crates/unixnotis-core/src/css/limits.rs @@ -0,0 +1,4 @@ +//! Shared limits for CSS files read by runtime and preset tooling + +/// Maximum size of one configured CSS stylesheet +pub const MAX_CSS_FILE_BYTES: u64 = 16_777_216; diff --git a/crates/unixnotis-core/src/css/mod.rs b/crates/unixnotis-core/src/css/mod.rs index 72fb02484..e14655818 100644 --- a/crates/unixnotis-core/src/css/mod.rs +++ b/crates/unixnotis-core/src/css/mod.rs @@ -10,11 +10,14 @@ pub mod references; pub mod tokens; // URI byte checks are shared by import policy and runtime rebasing mod uri; +// Keep the shared CSS-size policy separate from this module façade +mod limits; pub use self::features::{ gtk_css_features_for_version, gtk_css_features_from_version_string, GtkCssFeatures, - GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, + GTK_MIN_VERSION_LABEL, }; +pub use self::limits::MAX_CSS_FILE_BYTES; pub use self::references::{ collect_css_import_dependency_values, collect_css_import_url_spans, collect_css_import_values, collect_css_url_spans, collect_css_url_values, CssImportReference, CssReference, diff --git a/crates/unixnotis-core/src/css/references/import.rs b/crates/unixnotis-core/src/css/references/import.rs index 40f099ea3..24b630f37 100644 --- a/crates/unixnotis-core/src/css/references/import.rs +++ b/crates/unixnotis-core/src/css/references/import.rs @@ -1,7 +1,7 @@ //! Decoded CSS `@import` discovery use super::lexer::{ - consume_escape, consume_identifier, skip_comment, skip_css_whitespace_and_comments, + consume_escape, identifier_matches, skip_comment, skip_css_whitespace_and_comments, skip_quoted_value, starts_comment, utf8_char_len, would_start_identifier, }; use super::url::{parse_url_value, MAX_CSS_REFERENCES_PER_FILE}; @@ -86,8 +86,8 @@ fn collect_import_records(css_text: &str) -> Result, CssRefere } // At-keyword names follow the same escape rules as function identifiers - let (name, name_end) = consume_identifier(css_text, index.saturating_add(1)); - if !name.eq_ignore_ascii_case("import") { + let (is_import, name_end) = identifier_matches(css_text, index.saturating_add(1), "import"); + if !is_import { if name_end <= index { return Err(CssReferenceError::ScannerDidNotAdvance); } @@ -125,8 +125,8 @@ fn parse_import_value( let mut index = skip_css_whitespace_and_comments(bytes, start); if would_start_identifier(bytes, index) { - let (name, name_end) = consume_identifier(input, index); - if name.eq_ignore_ascii_case("url") && bytes.get(name_end) == Some(&b'(') { + let (is_url, name_end) = identifier_matches(input, index, "url"); + if is_url && bytes.get(name_end) == Some(&b'(') { let Some((span, next_index)) = parse_url_value(input, name_end.saturating_add(1)) else { return (Some(CssImportReference::Ambiguous), None, bytes.len()); diff --git a/crates/unixnotis-core/src/css/references/lexer.rs b/crates/unixnotis-core/src/css/references/lexer.rs index cc029c54b..2dc3bed1b 100644 --- a/crates/unixnotis-core/src/css/references/lexer.rs +++ b/crates/unixnotis-core/src/css/references/lexer.rs @@ -1,40 +1,41 @@ //! CSS identifier, escape, comment, and whitespace primitives -pub(super) fn consume_identifier(input: &str, start: usize) -> (String, usize) { +pub(super) fn identifier_matches(input: &str, start: usize, expected: &str) -> (bool, usize) { let bytes = input.as_bytes(); - let mut decoded = String::new(); + let expected = expected.as_bytes(); + let mut matched = true; + let mut decoded_len = 0usize; let mut index = start; - // A source byte can be visited at most once during a valid identifier scan + // Security scanners recognize a small fixed vocabulary without allocating every identifier for _ in 0..bytes.len().saturating_add(1) { let Some(&byte) = bytes.get(index) else { break; }; - if is_name_byte(byte) { - if byte.is_ascii() { - decoded.push(char::from(byte)); - index = index.saturating_add(1); + let (decoded, next_index) = if is_name_byte(byte) { + let decoded = if byte.is_ascii() { + char::from(byte) } else { - // Non-ASCII name characters are copied by scalar value - let ch = input[index..].chars().next().unwrap_or('\u{FFFD}'); - decoded.push(ch); - index = index.saturating_add(ch.len_utf8()); - } - continue; - } - if byte == b'\\' && valid_escape(bytes, index) { - let (ch, next_index) = consume_escape(input, index); - if next_index <= index { - break; - } - decoded.push(ch); - index = next_index; - continue; + input[index..].chars().next().unwrap_or('\u{FFFD}') + }; + (decoded, index.saturating_add(decoded.len_utf8())) + } else if valid_escape(bytes, index) { + consume_escape(input, index) + } else { + break; + }; + + if next_index <= index { + break; } - break; + matched &= expected + .get(decoded_len) + .is_some_and(|expected| decoded.eq_ignore_ascii_case(&char::from(*expected))); + decoded_len = decoded_len.saturating_add(1); + index = next_index; } - (decoded, index) + (matched && decoded_len == expected.len(), index) } pub(super) fn consume_escape(input: &str, slash_index: usize) -> (char, usize) { @@ -90,7 +91,10 @@ pub(super) fn skip_css_whitespace_and_comments(bytes: &[u8], mut index: usize) - pub(super) fn skip_css_whitespace(bytes: &[u8], mut index: usize) -> usize { for _ in 0..bytes.len().saturating_add(1) { - if !bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + if !bytes + .get(index) + .is_some_and(|byte| is_css_whitespace(*byte)) + { break; } index = index.saturating_add(1); @@ -98,6 +102,29 @@ pub(super) fn skip_css_whitespace(bytes: &[u8], mut index: usize) -> usize { index } +pub(super) fn trim_css_whitespace_range( + bytes: &[u8], + mut start: usize, + mut end: usize, +) -> (usize, usize) { + while start < end + && bytes + .get(start) + .is_some_and(|byte| is_css_whitespace(*byte)) + { + start = start.saturating_add(1); + } + while end > start + && end + .checked_sub(1) + .and_then(|index| bytes.get(index)) + .is_some_and(|byte| is_css_whitespace(*byte)) + { + end = end.saturating_sub(1); + } + (start, end) +} + pub(super) fn skip_quoted_value(input: &str, start: usize) -> Option { let bytes = input.as_bytes(); let quote = *bytes.get(start)?; @@ -157,6 +184,10 @@ const fn is_name_byte(byte: u8) -> bool { is_name_start_byte(byte) || byte.is_ascii_digit() || byte == b'-' } +const fn is_css_whitespace(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ') +} + pub(super) const fn utf8_char_len(first_byte: u8) -> usize { match first_byte { 0x00..=0x7f => 1, @@ -171,7 +202,7 @@ fn consume_escape_terminator(bytes: &[u8], index: usize) -> usize { Some(b'\r') if bytes.get(index.saturating_add(1)) == Some(&b'\n') => { index.saturating_add(2) } - Some(byte) if byte.is_ascii_whitespace() => index.saturating_add(1), + Some(byte) if is_css_whitespace(*byte) => index.saturating_add(1), _ => index, } } diff --git a/crates/unixnotis-core/src/css/references/tests/lexer.rs b/crates/unixnotis-core/src/css/references/tests/lexer.rs index f730f220c..1879f4d01 100644 --- a/crates/unixnotis-core/src/css/references/tests/lexer.rs +++ b/crates/unixnotis-core/src/css/references/tests/lexer.rs @@ -1,6 +1,6 @@ use super::super::lexer::{ - consume_escape, consume_identifier, skip_css_whitespace_and_comments, skip_quoted_value, - valid_escape, would_start_identifier, + consume_escape, identifier_matches, skip_css_whitespace_and_comments, skip_quoted_value, + trim_css_whitespace_range, valid_escape, would_start_identifier, }; use super::super::{collect_css_import_values, collect_css_url_values, CssImportReference}; @@ -82,19 +82,20 @@ fn escaped_non_ascii_string_content_does_not_end_string_skipping_early() { } #[test] -fn identifier_consumption_preserves_unicode_digits_hyphens_and_exact_end() { - assert_eq!( - consume_identifier("é-theme2(", 0), - ("é-theme2".to_string(), 9) - ); - assert_eq!(consume_identifier("_theme(", 0), ("_theme".to_string(), 6)); - assert_eq!(consume_identifier("url-2(", 0), ("url-2".to_string(), 5)); +fn fixed_identifier_matching_decodes_escapes_without_allocating_names() { + assert_eq!(identifier_matches("u\\72l(", 0, "url"), (true, 5)); + assert_eq!(identifier_matches("im\\70ort ", 0, "import"), (true, 8)); + assert_eq!(identifier_matches("url-extra(", 0, "url"), (false, 9)); + assert_eq!(identifier_matches("éurl(", 0, "url"), (false, 5)); + assert_eq!(identifier_matches("xrl(", 0, "url"), (false, 3)); + assert_eq!(identifier_matches("urx(", 0, "url"), (false, 3)); } #[test] -fn identifier_consumption_stops_before_invalid_escapes() { - assert_eq!(consume_identifier("url\\\nnext", 0), ("url".to_string(), 3)); - assert_eq!(consume_identifier("url\\", 0), ("url".to_string(), 3)); +fn css_whitespace_range_trimming_handles_empty_and_nonempty_boundaries() { + assert_eq!(trim_css_whitespace_range(b" value ", 0, 7), (1, 6)); + assert_eq!(trim_css_whitespace_range(b" ", 0, 1), (1, 1)); + assert_eq!(trim_css_whitespace_range(b" x", 1, 1), (1, 1)); } #[test] diff --git a/crates/unixnotis-core/src/css/references/tests/url.rs b/crates/unixnotis-core/src/css/references/tests/url.rs index 73e230348..de40bca4d 100644 --- a/crates/unixnotis-core/src/css/references/tests/url.rs +++ b/crates/unixnotis-core/src/css/references/tests/url.rs @@ -1,4 +1,4 @@ -use super::super::url::parse_url_value; +use super::super::url::{parse_url_value, valid_url_value_range}; use super::super::{collect_css_url_spans, collect_css_url_values}; #[test] @@ -88,3 +88,50 @@ fn invalid_unquoted_delimiters_and_controls_are_marked_ambiguous() { assert!(values[0].ambiguous, "{css:?} should be ambiguous"); } } + +#[test] +fn unicode_whitespace_in_unquoted_urls_preserves_utf8_aligned_ranges() { + let unicode_whitespace = [ + '\u{0085}', '\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', + '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', + '\u{2028}', '\u{2029}', '\u{202F}', '\u{205F}', '\u{3000}', + ]; + + for whitespace in unicode_whitespace { + for value in [ + format!("{whitespace}asset.png"), + format!("asset.png{whitespace}"), + format!("{whitespace}asset.png{whitespace}"), + ] { + let css = format!("url({value})"); + let spans = collect_css_url_spans(&css).expect("scan Unicode URL whitespace"); + let span = spans.first().expect("one URL span"); + + assert_eq!(span.value, value); + assert!(css.is_char_boundary(span.value_start)); + assert!(css.is_char_boundary(span.value_end)); + assert_eq!(&css[span.value_start..span.value_end], value); + } + } +} + +#[test] +fn unquoted_url_trims_only_css_whitespace_bytes() { + let css = "url(\t\n\u{000c}\r asset.png \t\n\u{000c}\r) url(\u{000b}asset.png\u{000b})"; + let spans = collect_css_url_spans(css).expect("scan exact CSS whitespace"); + + assert_eq!(spans[0].value, "asset.png"); + assert_eq!(spans[1].value, "\u{000b}asset.png\u{000b}"); + assert!(spans[1].ambiguous); +} + +#[test] +fn url_value_ranges_require_ordered_utf8_boundaries() { + let value = "aéz"; + + assert!(valid_url_value_range(value, 0, value.len())); + assert!(valid_url_value_range(value, 1, 3)); + assert!(!valid_url_value_range(value, 3, 1)); + assert!(!valid_url_value_range(value, 2, 3)); + assert!(!valid_url_value_range(value, 1, 2)); +} diff --git a/crates/unixnotis-core/src/css/references/url.rs b/crates/unixnotis-core/src/css/references/url.rs index 3178d13fa..fbc950425 100644 --- a/crates/unixnotis-core/src/css/references/url.rs +++ b/crates/unixnotis-core/src/css/references/url.rs @@ -1,8 +1,8 @@ //! Decoded CSS `url(...)` discovery and byte-range extraction use super::lexer::{ - consume_escape, consume_identifier, skip_comment, skip_css_whitespace, skip_quoted_value, - starts_comment, utf8_char_len, would_start_identifier, + consume_escape, identifier_matches, skip_comment, skip_css_whitespace, skip_quoted_value, + starts_comment, trim_css_whitespace_range, utf8_char_len, would_start_identifier, }; use super::{CssReference, CssReferenceError, CssUrlSpan}; @@ -47,8 +47,8 @@ pub fn collect_css_url_spans(css_text: &str) -> Result, CssRefer } // CSS escapes are decoded while the source indexes remain byte-exact - let (name, name_end) = consume_identifier(css_text, index); - if name.eq_ignore_ascii_case("url") && bytes.get(name_end) == Some(&b'(') { + let (is_url, name_end) = identifier_matches(css_text, index, "url"); + if is_url && bytes.get(name_end) == Some(&b'(') { let (span, next_index) = parse_url_value(css_text, name_end.saturating_add(1)) .ok_or(CssReferenceError::UnterminatedUrl)?; if next_index <= index { @@ -148,14 +148,14 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS continue; } if byte == b')' { - let raw = &input[raw_start..index]; - let value = raw.trim(); - // Leading whitespace was consumed before raw_start was recorded - let value_start = raw_start; - let value_end = value_start + value.len(); + // CSS defines five ASCII whitespace bytes; Unicode whitespace remains URL data + let (value_start, value_end) = trim_css_whitespace_range(bytes, raw_start, index); + if !valid_url_value_range(input, value_start, value_end) { + return None; + } return Some(( CssUrlSpan { - value: value.to_string(), + value: input[value_start..value_end].to_string(), value_start, value_end, ambiguous, @@ -177,3 +177,14 @@ pub(super) fn parse_url_value(input: &str, open_index: usize) -> Option<(CssUrlS } None } + +pub(super) const fn valid_url_value_range( + input: &str, + value_start: usize, + value_end: usize, +) -> bool { + // Scanner offsets are accepted only when direct string slicing is safe + value_start <= value_end + && input.is_char_boundary(value_start) + && input.is_char_boundary(value_end) +} diff --git a/crates/unixnotis-core/src/css/tests/features.rs b/crates/unixnotis-core/src/css/tests/features.rs index 0209e93a0..f626d39ba 100644 --- a/crates/unixnotis-core/src/css/tests/features.rs +++ b/crates/unixnotis-core/src/css/tests/features.rs @@ -1,29 +1,28 @@ use super::{ - gtk_css_features_for_version, gtk_css_features_from_version_string, - GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, + gtk_css_features_for_version, gtk_css_features_from_version_string, GTK_MIN_VERSION_LABEL, }; #[test] -fn gtk_css_features_gate_custom_properties_at_gtk_416() { - assert!(!gtk_css_features_for_version(4, 15).custom_properties); - assert!(gtk_css_features_for_version(4, 16).custom_properties); +fn gtk_css_features_gate_common_apis_at_gtk_418() { + assert!(!gtk_css_features_for_version(4, 17).custom_properties); + assert!(gtk_css_features_for_version(4, 18).custom_properties); assert!(gtk_css_features_for_version(5, 0).custom_properties); } #[test] fn gtk_css_features_can_parse_pkg_config_versions() { assert!( - !gtk_css_features_from_version_string("4.15.9") + !gtk_css_features_from_version_string("4.17.9") .expect("version") .custom_properties ); assert!( - gtk_css_features_from_version_string("4.16.3") + gtk_css_features_from_version_string("4.18.3") .expect("version") .custom_properties ); assert!( - gtk_css_features_from_version_string("4.16.0-2") + gtk_css_features_from_version_string("4.18.0-2") .expect("version") .custom_properties ); @@ -31,5 +30,5 @@ fn gtk_css_features_can_parse_pkg_config_versions() { #[test] fn custom_properties_requirement_label_stays_stable() { - assert_eq!(GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, "GTK 4.16+"); + assert_eq!(GTK_MIN_VERSION_LABEL, "GTK 4.18+"); } diff --git a/crates/unixnotis-core/src/css/tests/tokens.rs b/crates/unixnotis-core/src/css/tests/tokens.rs deleted file mode 100644 index c0f37129a..000000000 --- a/crates/unixnotis-core/src/css/tests/tokens.rs +++ /dev/null @@ -1,91 +0,0 @@ -#![allow( - clippy::float_cmp, - reason = "theme-token resolution returns exact configured and clamped constants" -)] - -use super::{ - build_legacy_theme_color_overrides, build_modern_theme_custom_properties, - theme_card_style_values, -}; -use crate::{gtk_css_features_for_version, ThemeConfig}; - -#[test] -fn theme_card_style_values_clamp_alpha_and_keep_lengths() { - let values = theme_card_style_values(&ThemeConfig { - border_width: 3, - card_radius: 18, - card_alpha: 1.5, - ..ThemeConfig::default() - }); - - assert_eq!(values.border_width_px, 3.0); - assert_eq!(values.card_radius_px, 18.0); - assert_eq!(values.card_alpha, 1.0); -} - -#[test] -fn legacy_theme_color_overrides_include_card_alpha() { - let overrides = build_legacy_theme_color_overrides(&ThemeConfig { - card_alpha: 0.42, - ..ThemeConfig::default() - }); - - assert!(overrides.contains("@define-color unixnotis-card alpha(@unixnotis-card-base, 0.42);")); -} - -#[test] -fn modern_theme_custom_properties_stay_additive() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 2, - card_radius: 12, - surface_alpha: 0.88, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); - - assert!(overrides.contains(":root {")); - assert!(overrides.contains("--unixnotis-border-width: 2px;")); - assert!(overrides.contains("--unixnotis-card-radius: 12px;")); - assert!(overrides.contains("--unixnotis-panel-card-padding-y: 10px;")); - assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); - assert!(overrides.contains("--unixnotis-media-card-radius: 18px;")); - assert!(overrides.contains("--unixnotis-media-title-font-size: 13px;")); - assert!(overrides.contains("--unixnotis-ui-font-family: \"Manrope\", \"SF Pro Text\",")); - assert!( - overrides.contains("--unixnotis-monospace-font-family: \"CaskaydiaCove Nerd Font Mono\",") - ); - assert!(overrides.contains("--unixnotis-accent-color: @unixnotis-accent;")); - assert!(overrides.contains("--unixnotis-surface-alpha: 0.88;")); - assert!(overrides.contains("--unixnotis-card-alpha: 0.94;")); -} - -#[test] -fn modern_theme_custom_properties_stay_off_on_older_gtk() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig::default(), - gtk_css_features_for_version(4, 15), - ); - assert!(overrides.is_empty()); -} - -#[test] -fn modern_theme_tokens_trim_float_values_without_losing_fraction() { - let overrides = build_modern_theme_custom_properties( - &ThemeConfig { - border_width: 3, - card_radius: 10, - surface_alpha: 0.5, - surface_strong_alpha: 1.0, - card_alpha: 0.125, - ..ThemeConfig::default() - }, - gtk_css_features_for_version(4, 16), - ); - - assert!(overrides.contains("--unixnotis-border-width: 3px;")); - assert!(overrides.contains("--unixnotis-surface-alpha: 0.5;")); - assert!(overrides.contains("--unixnotis-surface-strong-alpha: 1;")); - assert!(overrides.contains("--unixnotis-card-alpha: 0.125;")); -} diff --git a/crates/unixnotis-core/src/css/tokens.rs b/crates/unixnotis-core/src/css/tokens.rs deleted file mode 100644 index aa3125658..000000000 --- a/crates/unixnotis-core/src/css/tokens.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Shared theme token contract for legacy and modern GTK CSS paths - -use crate::config::ThemeConfig; - -use super::features::GtkCssFeatures; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct ThemeCardStyleValues { - // These are reused by several override builders, so they stay grouped here - pub border_width_px: f32, - pub card_radius_px: f32, - pub card_alpha: f32, -} - -#[must_use] -pub fn theme_card_style_values(theme: &ThemeConfig) -> ThemeCardStyleValues { - ThemeCardStyleValues { - border_width_px: f32::from(theme.border_width), - card_radius_px: f32::from(theme.card_radius), - card_alpha: clamp_alpha(theme.card_alpha), - } -} - -#[must_use] -pub fn build_legacy_theme_color_overrides(theme: &ThemeConfig) -> String { - // Legacy alpha colors stay first so old themes keep working as-is - let surface_alpha = clamp_alpha(theme.surface_alpha); - let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); - let card_alpha = clamp_alpha(theme.card_alpha); - let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); - let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); - - format!( - r" -@define-color unixnotis-surface alpha(@unixnotis-surface-base, {surface_alpha}); -@define-color unixnotis-surface-strong alpha(@unixnotis-surface-strong-base, {surface_strong_alpha}); -@define-color unixnotis-card alpha(@unixnotis-card-base, {card_alpha}); -@define-color unixnotis-shadow-soft alpha(#000000, {shadow_soft}); -@define-color unixnotis-shadow-strong alpha(#000000, {shadow_strong}); -" - ) -} - -#[must_use] -pub fn build_modern_theme_custom_properties( - theme: &ThemeConfig, - features: GtkCssFeatures, -) -> String { - // Older GTK builds should see no modern token output at all - if !features.supports_modern_theme_tokens() { - return String::new(); - } - - let surface_alpha = clamp_alpha(theme.surface_alpha); - let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); - let card_alpha = clamp_alpha(theme.card_alpha); - let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); - let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); - let card_style = theme_card_style_values(theme); - - // Keep the selector text plain in the final output while avoiding lint confusion here - let mut block = String::from(":\u{72}oot {\n"); - - // Config-driven tokens stay aligned with live theme knobs - push_px_token( - &mut block, - "--unixnotis-border-width", - card_style.border_width_px, - ); - push_px_token( - &mut block, - "--unixnotis-card-radius", - card_style.card_radius_px, - ); - push_alpha_token(&mut block, "--unixnotis-surface-alpha", surface_alpha); - push_alpha_token( - &mut block, - "--unixnotis-surface-strong-alpha", - surface_strong_alpha, - ); - push_alpha_token(&mut block, "--unixnotis-card-alpha", card_alpha); - push_alpha_token(&mut block, "--unixnotis-shadow-soft-alpha", shadow_soft); - push_alpha_token(&mut block, "--unixnotis-shadow-strong-alpha", shadow_strong); - - // Shared color aliases let modern themes keep using the same palette names - for (name, value) in color_alias_tokens() { - push_raw_token(&mut block, name, value); - } - - // Layout tokens give custom themes stable numbers without scraping the stock css - for (name, value) in layout_tokens() { - push_raw_token(&mut block, name, value); - } - - block.push_str("}\n"); - block -} - -const fn clamp_alpha(value: f32) -> f32 { - value.clamp(0.0, 1.0) -} - -fn push_px_token(block: &mut String, name: &str, value: f32) { - // Trimmed floats keep the generated CSS readable in bug reports - block.push_str(&format!(" {name}: {}px;\n", trim_float(value))); -} - -fn push_alpha_token(block: &mut String, name: &str, value: f32) { - block.push_str(&format!(" {name}: {};\n", trim_float(value))); -} - -fn push_raw_token(block: &mut String, name: &str, value: &str) { - block.push_str(&format!(" {name}: {value};\n")); -} - -fn trim_float(value: f32) -> String { - let mut text = format!("{value:.4}"); - while text.contains('.') && text.ends_with('0') { - text.pop(); - } - if text.ends_with('.') { - text.pop(); - } - text -} - -const fn color_alias_tokens() -> &'static [(&'static str, &'static str)] { - // Color aliases mirror the stock palette so modern themes can stay readable - &[ - ("--unixnotis-surface-base-color", "@unixnotis-surface-base"), - ("--unixnotis-surface-color", "@unixnotis-surface"), - ( - "--unixnotis-surface-strong-color", - "@unixnotis-surface-strong", - ), - ("--unixnotis-surface-soft-color", "@unixnotis-surface-soft"), - ("--unixnotis-card-color", "@unixnotis-card"), - ("--unixnotis-text-color", "@unixnotis-text"), - ("--unixnotis-muted-color", "@unixnotis-muted"), - ("--unixnotis-accent-color", "@unixnotis-accent"), - ("--unixnotis-accent-2-color", "@unixnotis-accent-2"), - ("--unixnotis-urgent-color", "@unixnotis-urgent"), - ("--unixnotis-accent-wifi-color", "@unixnotis-accent-wifi"), - ( - "--unixnotis-accent-bluetooth-color", - "@unixnotis-accent-bluetooth", - ), - ( - "--unixnotis-accent-airplane-color", - "@unixnotis-accent-airplane", - ), - ("--unixnotis-accent-night-color", "@unixnotis-accent-night"), - ("--unixnotis-card-border-color", "@unixnotis-card-border"), - ("--unixnotis-outline-color", "@unixnotis-outline"), - ("--unixnotis-shadow-soft-color", "@unixnotis-shadow-soft"), - ( - "--unixnotis-shadow-strong-color", - "@unixnotis-shadow-strong", - ), - ("--unixnotis-glow-cyan-color", "@unixnotis-glow-cyan"), - ("--unixnotis-glow-pink-color", "@unixnotis-glow-pink"), - ("--unixnotis-glow-wifi-color", "@unixnotis-glow-wifi"), - ( - "--unixnotis-glow-bluetooth-color", - "@unixnotis-glow-bluetooth", - ), - ( - "--unixnotis-glow-airplane-color", - "@unixnotis-glow-airplane", - ), - ("--unixnotis-glow-night-color", "@unixnotis-glow-night"), - ("--unixnotis-panel-grad-1-color", "@unixnotis-panel-grad-1"), - ("--unixnotis-panel-grad-2-color", "@unixnotis-panel-grad-2"), - ("--unixnotis-panel-grad-3-color", "@unixnotis-panel-grad-3"), - ( - "--unixnotis-notification-bg-1-color", - "@unixnotis-notification-bg-1", - ), - ( - "--unixnotis-notification-bg-2-color", - "@unixnotis-notification-bg-2", - ), - ("--unixnotis-popup-bg-1-color", "@unixnotis-popup-bg-1"), - ("--unixnotis-popup-bg-2-color", "@unixnotis-popup-bg-2"), - ("--unixnotis-pill-bg-color", "@unixnotis-pill-bg"), - ("--unixnotis-pill-border-color", "@unixnotis-pill-border"), - ("--unixnotis-pill-hover-color", "@unixnotis-pill-hover"), - ("--unixnotis-action-bg-color", "@unixnotis-action-bg"), - ( - "--unixnotis-action-bg-hover-color", - "@unixnotis-action-bg-hover", - ), - ( - "--unixnotis-action-bg-active-color", - "@unixnotis-action-bg-active", - ), - ( - "--unixnotis-popup-action-bg-color", - "@unixnotis-popup-action-bg", - ), - ( - "--unixnotis-popup-action-hover-color", - "@unixnotis-popup-action-hover", - ), - ( - "--unixnotis-popup-action-active-color", - "@unixnotis-popup-action-active", - ), - ] -} - -const fn layout_tokens() -> &'static [(&'static str, &'static str)] { - // These numbers match the shipped layout so custom themes can override safely - &[ - ( - "--unixnotis-ui-font-family", - r#""Manrope", "SF Pro Text", "CaskaydiaCove Nerd Font Propo", "Noto Sans", sans-serif"#, - ), - ( - "--unixnotis-monospace-font-family", - r#""CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace"#, - ), - ("--unixnotis-panel-radius", "30px"), - ("--unixnotis-panel-padding", "16px"), - ("--unixnotis-panel-header-radius", "18px"), - ("--unixnotis-panel-header-padding", "12px"), - ("--unixnotis-panel-card-padding-y", "10px"), - ("--unixnotis-panel-card-padding-x", "12px"), - ("--unixnotis-panel-card-gap", "8px"), - ("--unixnotis-panel-action-gap", "6px"), - ("--unixnotis-panel-close-size", "28px"), - ("--unixnotis-panel-search-min-height", "34px"), - ("--unixnotis-panel-search-padding-x", "10px"), - ("--unixnotis-notification-card-radius", "20px"), - ("--unixnotis-notification-action-padding-y", "4px"), - ("--unixnotis-notification-action-padding-x", "10px"), - ("--unixnotis-popup-stack-padding", "8px"), - ("--unixnotis-popup-card-radius", "20px"), - ("--unixnotis-popup-card-padding-y", "14px"), - ("--unixnotis-popup-card-padding-x", "16px"), - ("--unixnotis-popup-actions-gap", "6px"), - ("--unixnotis-popup-close-size", "24px"), - ("--unixnotis-popup-reveal-duration", "200ms"), - ("--unixnotis-quick-slider-radius", "18px"), - ("--unixnotis-quick-slider-padding-y", "8px"), - ("--unixnotis-quick-slider-padding-x", "12px"), - ("--unixnotis-quick-slider-icon-size", "32px"), - ("--unixnotis-quick-slider-knob-size", "16px"), - ("--unixnotis-toggle-min-width", "104px"), - ("--unixnotis-toggle-min-height", "56px"), - ("--unixnotis-toggle-padding-y", "10px"), - ("--unixnotis-toggle-padding-x", "12px"), - ("--unixnotis-stat-card-radius", "18px"), - ("--unixnotis-stat-card-min-height", "56px"), - ("--unixnotis-stat-card-padding-y", "10px"), - ("--unixnotis-stat-card-padding-x", "12px"), - ("--unixnotis-info-card-min-height", "56px"), - ("--unixnotis-info-card-padding", "12px"), - ("--unixnotis-info-card-radius", "22px"), - ("--unixnotis-calendar-radius", "18px"), - ("--unixnotis-media-container-gap", "10px"), - ("--unixnotis-media-row-gap", "6px"), - ("--unixnotis-media-control-gap", "6px"), - ("--unixnotis-media-action-rail-gap", "8px"), - ("--unixnotis-media-card-padding-y", "8px"), - ("--unixnotis-media-card-padding-x", "10px"), - ("--unixnotis-media-card-padding-inline-y", "10px"), - ("--unixnotis-media-card-padding-inline-x", "12px"), - ("--unixnotis-media-card-padding-stacked", "12px"), - ("--unixnotis-media-card-padding-showcase-y", "10px"), - ("--unixnotis-media-card-padding-showcase-x", "12px"), - ("--unixnotis-media-art-size", "50px"), - ("--unixnotis-media-art-frame-size", "54px"), - ("--unixnotis-media-button-padding-y", "4px"), - ("--unixnotis-media-button-padding-x", "6px"), - ("--unixnotis-media-nav-size", "22px"), - ("--unixnotis-media-nav-radius", "12px"), - ("--unixnotis-media-nav-font-size", "12px"), - ("--unixnotis-media-card-radius", "18px"), - ("--unixnotis-media-card-min-height", "68px"), - ("--unixnotis-media-card-inline-min-height", "88px"), - ("--unixnotis-media-card-stacked-min-height", "108px"), - ("--unixnotis-media-card-showcase-min-height", "92px"), - ("--unixnotis-media-art-radius", "12px"), - ("--unixnotis-media-art-frame-radius", "14px"), - ("--unixnotis-media-source-font-size", "11px"), - ("--unixnotis-media-source-letter-spacing", "0.1em"), - ("--unixnotis-media-position-font-size", "11px"), - ("--unixnotis-media-position-letter-spacing", "0.08em"), - ("--unixnotis-media-title-font-size", "13px"), - ("--unixnotis-media-title-font-weight", "700"), - ("--unixnotis-media-artist-font-size", "12px"), - ("--unixnotis-media-button-radius", "10px"), - ] -} - -#[cfg(test)] -#[path = "tests/tokens.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/css/tokens/layout.rs b/crates/unixnotis-core/src/css/tokens/layout.rs new file mode 100644 index 000000000..12b98bf8b --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/layout.rs @@ -0,0 +1,84 @@ +//! Stable layout values exposed to compatible custom themes + +pub(super) const fn layout_tokens() -> &'static [(&'static str, &'static str)] { + // These numbers match shipped layout defaults without requiring themes to scrape stock CSS + &[ + ( + "--unixnotis-ui-font-family", + r#""Inter", "SF Pro Text", "Noto Sans", sans-serif"#, + ), + ( + "--unixnotis-monospace-font-family", + r#""CaskaydiaCove Nerd Font Mono", "JetBrains Mono", monospace"#, + ), + ("--unixnotis-panel-radius", "30px"), + ("--unixnotis-panel-padding", "16px"), + ("--unixnotis-panel-header-radius", "18px"), + ("--unixnotis-panel-header-padding", "12px"), + ("--unixnotis-panel-card-padding-y", "9px"), + ("--unixnotis-panel-card-padding-x", "11px"), + ("--unixnotis-panel-action-gap", "6px"), + ("--unixnotis-panel-close-size", "28px"), + ("--unixnotis-panel-search-min-height", "34px"), + ("--unixnotis-panel-search-padding-x", "10px"), + ("--unixnotis-notification-action-padding-y", "4px"), + ("--unixnotis-notification-action-padding-x", "10px"), + ("--unixnotis-popup-stack-padding", "8px"), + ("--unixnotis-popup-card-radius", "18px"), + ("--unixnotis-popup-card-padding-y", "10px"), + ("--unixnotis-popup-card-padding-x", "14px"), + ("--unixnotis-popup-actions-gap", "6px"), + ("--unixnotis-popup-close-size", "24px"), + ("--unixnotis-popup-reveal-duration", "200ms"), + ("--unixnotis-quick-slider-radius", "18px"), + ("--unixnotis-quick-slider-padding-y", "8px"), + ("--unixnotis-quick-slider-padding-x", "12px"), + ("--unixnotis-quick-slider-icon-size", "32px"), + ("--unixnotis-quick-slider-knob-size", "16px"), + ("--unixnotis-toggle-min-width", "104px"), + ("--unixnotis-toggle-min-height", "56px"), + ("--unixnotis-toggle-padding-y", "10px"), + ("--unixnotis-toggle-padding-x", "12px"), + ("--unixnotis-stat-card-radius", "18px"), + ("--unixnotis-stat-card-min-height", "56px"), + ("--unixnotis-stat-card-padding-y", "10px"), + ("--unixnotis-stat-card-padding-x", "12px"), + ("--unixnotis-info-card-min-height", "56px"), + ("--unixnotis-info-card-padding", "12px"), + ("--unixnotis-info-card-radius", "22px"), + ("--unixnotis-calendar-radius", "18px"), + ("--unixnotis-media-container-gap", "10px"), + ("--unixnotis-media-row-gap", "6px"), + ("--unixnotis-media-control-gap", "6px"), + ("--unixnotis-media-action-rail-gap", "8px"), + ("--unixnotis-media-card-padding-y", "8px"), + ("--unixnotis-media-card-padding-x", "10px"), + ("--unixnotis-media-card-padding-inline-y", "10px"), + ("--unixnotis-media-card-padding-inline-x", "12px"), + ("--unixnotis-media-card-padding-stacked", "12px"), + ("--unixnotis-media-card-padding-showcase-y", "10px"), + ("--unixnotis-media-card-padding-showcase-x", "12px"), + ("--unixnotis-media-art-size", "50px"), + ("--unixnotis-media-art-frame-size", "54px"), + ("--unixnotis-media-button-padding-y", "4px"), + ("--unixnotis-media-button-padding-x", "6px"), + ("--unixnotis-media-nav-size", "22px"), + ("--unixnotis-media-nav-radius", "12px"), + ("--unixnotis-media-nav-font-size", "12px"), + ("--unixnotis-media-card-radius", "18px"), + ("--unixnotis-media-card-min-height", "68px"), + ("--unixnotis-media-card-inline-min-height", "88px"), + ("--unixnotis-media-card-stacked-min-height", "108px"), + ("--unixnotis-media-card-showcase-min-height", "92px"), + ("--unixnotis-media-art-radius", "12px"), + ("--unixnotis-media-art-frame-radius", "14px"), + ("--unixnotis-media-source-font-size", "11px"), + ("--unixnotis-media-source-letter-spacing", "0.1em"), + ("--unixnotis-media-position-font-size", "11px"), + ("--unixnotis-media-position-letter-spacing", "0.08em"), + ("--unixnotis-media-title-font-size", "13px"), + ("--unixnotis-media-title-font-weight", "700"), + ("--unixnotis-media-artist-font-size", "12px"), + ("--unixnotis-media-button-radius", "10px"), + ] +} diff --git a/crates/unixnotis-core/src/css/tokens/legacy.rs b/crates/unixnotis-core/src/css/tokens/legacy.rs new file mode 100644 index 000000000..ae31ff68e --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/legacy.rs @@ -0,0 +1,25 @@ +//! GTK color definitions supported by every compatible GTK version + +use crate::config::ThemeConfig; + +use super::model::clamp_alpha; + +#[must_use] +pub fn build_legacy_theme_color_overrides(theme: &ThemeConfig) -> String { + // Legacy alpha colors stay first so existing theme palettes remain stable + let surface_alpha = clamp_alpha(theme.surface_alpha); + let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); + let card_alpha = clamp_alpha(theme.card_alpha); + let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); + let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); + + format!( + r" +@define-color unixnotis-surface alpha(@unixnotis-surface-base, {surface_alpha}); +@define-color unixnotis-surface-strong alpha(@unixnotis-surface-strong-base, {surface_strong_alpha}); +@define-color unixnotis-card alpha(@unixnotis-card-base, {card_alpha}); +@define-color unixnotis-shadow-soft alpha(#000000, {shadow_soft}); +@define-color unixnotis-shadow-strong alpha(#000000, {shadow_strong}); +" + ) +} diff --git a/crates/unixnotis-core/src/css/tokens/mod.rs b/crates/unixnotis-core/src/css/tokens/mod.rs new file mode 100644 index 000000000..65986a529 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/mod.rs @@ -0,0 +1,14 @@ +//! Shared theme token contract for legacy and modern GTK CSS paths + +mod layout; +mod legacy; +mod model; +mod modern; +mod palette; + +pub use legacy::build_legacy_theme_color_overrides; +pub use model::{theme_card_style_values, ThemeCardStyleValues}; +pub use modern::build_modern_theme_custom_properties; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/css/tokens/model.rs b/crates/unixnotis-core/src/css/tokens/model.rs new file mode 100644 index 000000000..73c0e90b4 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/model.rs @@ -0,0 +1,24 @@ +//! Config-backed values shared by CSS token renderers + +use crate::config::ThemeConfig; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeCardStyleValues { + // These values are reused by several override builders + pub border_width_px: f32, + pub card_radius_px: f32, + pub card_alpha: f32, +} + +#[must_use] +pub fn theme_card_style_values(theme: &ThemeConfig) -> ThemeCardStyleValues { + ThemeCardStyleValues { + border_width_px: f32::from(theme.border_width), + card_radius_px: f32::from(theme.card_radius), + card_alpha: clamp_alpha(theme.card_alpha), + } +} + +pub(super) const fn clamp_alpha(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} diff --git a/crates/unixnotis-core/src/css/tokens/modern.rs b/crates/unixnotis-core/src/css/tokens/modern.rs new file mode 100644 index 000000000..ddef20fa6 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/modern.rs @@ -0,0 +1,79 @@ +//! Modern GTK custom-property rendering + +use crate::config::ThemeConfig; + +use super::layout::layout_tokens; +use super::model::{clamp_alpha, theme_card_style_values}; +use super::palette::color_alias_tokens; + +#[must_use] +pub fn build_modern_theme_custom_properties(theme: &ThemeConfig) -> String { + let surface_alpha = clamp_alpha(theme.surface_alpha); + let surface_strong_alpha = clamp_alpha(theme.surface_strong_alpha); + let card_alpha = clamp_alpha(theme.card_alpha); + let shadow_soft = clamp_alpha(theme.shadow_soft_alpha); + let shadow_strong = clamp_alpha(theme.shadow_strong_alpha); + let card_style = theme_card_style_values(theme); + + // Keep the selector plain in generated CSS while avoiding source-lint confusion + let mut block = String::from(":\u{72}oot {\n"); + push_px_token( + &mut block, + "--unixnotis-border-width", + card_style.border_width_px, + ); + push_px_token( + &mut block, + "--unixnotis-card-radius", + card_style.card_radius_px, + ); + // Stack shells use the same dynamic radius as foreground notification cards + push_px_token( + &mut block, + "--unixnotis-notification-card-radius", + card_style.card_radius_px, + ); + push_alpha_token(&mut block, "--unixnotis-surface-alpha", surface_alpha); + push_alpha_token( + &mut block, + "--unixnotis-surface-strong-alpha", + surface_strong_alpha, + ); + push_alpha_token(&mut block, "--unixnotis-card-alpha", card_alpha); + push_alpha_token(&mut block, "--unixnotis-shadow-soft-alpha", shadow_soft); + push_alpha_token(&mut block, "--unixnotis-shadow-strong-alpha", shadow_strong); + + for (name, value) in color_alias_tokens() { + push_raw_token(&mut block, name, value); + } + for (name, value) in layout_tokens() { + push_raw_token(&mut block, name, value); + } + + block.push_str("}\n"); + block +} + +fn push_px_token(block: &mut String, name: &str, value: f32) { + block.push_str(&format!(" {name}: {}px;\n", trim_float(value))); +} + +fn push_alpha_token(block: &mut String, name: &str, value: f32) { + block.push_str(&format!(" {name}: {};\n", trim_float(value))); +} + +fn push_raw_token(block: &mut String, name: &str, value: &str) { + block.push_str(&format!(" {name}: {value};\n")); +} + +fn trim_float(value: f32) -> String { + // Removing trailing zeroes keeps generated diagnostics readable + let mut text = format!("{value:.4}"); + while text.contains('.') && text.ends_with('0') { + text.pop(); + } + if text.ends_with('.') { + text.pop(); + } + text +} diff --git a/crates/unixnotis-core/src/css/tokens/palette.rs b/crates/unixnotis-core/src/css/tokens/palette.rs new file mode 100644 index 000000000..3a8468d35 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/palette.rs @@ -0,0 +1,85 @@ +//! Stable color aliases exposed to compatible custom themes + +pub(super) const fn color_alias_tokens() -> &'static [(&'static str, &'static str)] { + &[ + ("--unixnotis-surface-base-color", "@unixnotis-surface-base"), + ("--unixnotis-surface-color", "@unixnotis-surface"), + ( + "--unixnotis-surface-strong-color", + "@unixnotis-surface-strong", + ), + ("--unixnotis-surface-soft-color", "@unixnotis-surface-soft"), + ("--unixnotis-card-color", "@unixnotis-card"), + ("--unixnotis-text-color", "@unixnotis-text"), + ("--unixnotis-muted-color", "@unixnotis-muted"), + ("--unixnotis-accent-color", "@unixnotis-accent"), + ("--unixnotis-accent-2-color", "@unixnotis-accent-2"), + ("--unixnotis-urgent-color", "@unixnotis-urgent"), + ("--unixnotis-accent-wifi-color", "@unixnotis-accent-wifi"), + ( + "--unixnotis-accent-bluetooth-color", + "@unixnotis-accent-bluetooth", + ), + ( + "--unixnotis-accent-airplane-color", + "@unixnotis-accent-airplane", + ), + ("--unixnotis-accent-night-color", "@unixnotis-accent-night"), + ("--unixnotis-card-border-color", "@unixnotis-card-border"), + ("--unixnotis-outline-color", "@unixnotis-outline"), + ("--unixnotis-shadow-soft-color", "@unixnotis-shadow-soft"), + ( + "--unixnotis-shadow-strong-color", + "@unixnotis-shadow-strong", + ), + ("--unixnotis-glow-cyan-color", "@unixnotis-glow-cyan"), + ("--unixnotis-glow-pink-color", "@unixnotis-glow-pink"), + ("--unixnotis-glow-wifi-color", "@unixnotis-glow-wifi"), + ( + "--unixnotis-glow-bluetooth-color", + "@unixnotis-glow-bluetooth", + ), + ( + "--unixnotis-glow-airplane-color", + "@unixnotis-glow-airplane", + ), + ("--unixnotis-glow-night-color", "@unixnotis-glow-night"), + ("--unixnotis-panel-grad-1-color", "@unixnotis-panel-grad-1"), + ("--unixnotis-panel-grad-2-color", "@unixnotis-panel-grad-2"), + ("--unixnotis-panel-grad-3-color", "@unixnotis-panel-grad-3"), + ( + "--unixnotis-notification-bg-1-color", + "@unixnotis-notification-bg-1", + ), + ( + "--unixnotis-notification-bg-2-color", + "@unixnotis-notification-bg-2", + ), + ("--unixnotis-popup-bg-1-color", "@unixnotis-popup-bg-1"), + ("--unixnotis-popup-bg-2-color", "@unixnotis-popup-bg-2"), + ("--unixnotis-pill-bg-color", "@unixnotis-pill-bg"), + ("--unixnotis-pill-border-color", "@unixnotis-pill-border"), + ("--unixnotis-pill-hover-color", "@unixnotis-pill-hover"), + ("--unixnotis-action-bg-color", "@unixnotis-action-bg"), + ( + "--unixnotis-action-bg-hover-color", + "@unixnotis-action-bg-hover", + ), + ( + "--unixnotis-action-bg-active-color", + "@unixnotis-action-bg-active", + ), + ( + "--unixnotis-popup-action-bg-color", + "@unixnotis-popup-action-bg", + ), + ( + "--unixnotis-popup-action-hover-color", + "@unixnotis-popup-action-hover", + ), + ( + "--unixnotis-popup-action-active-color", + "@unixnotis-popup-action-active", + ), + ] +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/legacy.rs b/crates/unixnotis-core/src/css/tokens/tests/legacy.rs new file mode 100644 index 000000000..3ec6abeae --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/legacy.rs @@ -0,0 +1,15 @@ +use super::super::build_legacy_theme_color_overrides; +use crate::ThemeConfig; + +#[test] +fn legacy_theme_color_overrides_include_card_alpha() { + let overrides = build_legacy_theme_color_overrides(&ThemeConfig { + card_alpha: 0.42, + ..ThemeConfig::default() + }); + + assert!( + overrides.contains("@define-color unixnotis-card alpha(@unixnotis-card-base, 0.42);"), + "legacy output should preserve the configured card alpha" + ); +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/mod.rs b/crates/unixnotis-core/src/css/tokens/tests/mod.rs new file mode 100644 index 000000000..bcdf028b2 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/mod.rs @@ -0,0 +1,5 @@ +//! Theme token contract tests by renderer + +mod legacy; +mod model; +mod modern; diff --git a/crates/unixnotis-core/src/css/tokens/tests/model.rs b/crates/unixnotis-core/src/css/tokens/tests/model.rs new file mode 100644 index 000000000..98d41db93 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/model.rs @@ -0,0 +1,21 @@ +#![expect( + clippy::float_cmp, + reason = "theme-token resolution returns exact configured and clamped constants" +)] + +use super::super::theme_card_style_values; +use crate::ThemeConfig; + +#[test] +fn theme_card_style_values_clamp_alpha_and_keep_lengths() { + let values = theme_card_style_values(&ThemeConfig { + border_width: 3, + card_radius: 18, + card_alpha: 1.5, + ..ThemeConfig::default() + }); + + assert_eq!(values.border_width_px, 3.0); + assert_eq!(values.card_radius_px, 18.0); + assert_eq!(values.card_alpha, 1.0); +} diff --git a/crates/unixnotis-core/src/css/tokens/tests/modern.rs b/crates/unixnotis-core/src/css/tokens/tests/modern.rs new file mode 100644 index 000000000..f71d3fba5 --- /dev/null +++ b/crates/unixnotis-core/src/css/tokens/tests/modern.rs @@ -0,0 +1,58 @@ +use super::super::build_modern_theme_custom_properties; +use crate::ThemeConfig; + +#[test] +fn modern_theme_custom_properties_stay_additive() { + let overrides = build_modern_theme_custom_properties(&ThemeConfig { + border_width: 2, + card_radius: 12, + surface_alpha: 0.88, + ..ThemeConfig::default() + }); + + for expected in [ + ":root {", + "--unixnotis-border-width: 2px;", + "--unixnotis-card-radius: 12px;", + "--unixnotis-notification-card-radius: 12px;", + "--unixnotis-panel-card-padding-y: 9px;", + "--unixnotis-popup-card-padding-y: 10px;", + "--unixnotis-popup-reveal-duration: 200ms;", + "--unixnotis-media-card-radius: 18px;", + "--unixnotis-media-title-font-size: 13px;", + "--unixnotis-ui-font-family: \"Inter\", \"SF Pro Text\",", + "--unixnotis-monospace-font-family: \"CaskaydiaCove Nerd Font Mono\",", + "--unixnotis-accent-color: @unixnotis-accent;", + "--unixnotis-surface-alpha: 0.88;", + "--unixnotis-card-alpha: 0.94;", + ] { + assert!( + overrides.contains(expected), + "modern token output should contain {expected}" + ); + } +} + +#[test] +fn modern_theme_tokens_trim_float_values_without_losing_fraction() { + let overrides = build_modern_theme_custom_properties(&ThemeConfig { + border_width: 3, + card_radius: 10, + surface_alpha: 0.5, + surface_strong_alpha: 1.0, + card_alpha: 0.125, + ..ThemeConfig::default() + }); + + for expected in [ + "--unixnotis-border-width: 3px;", + "--unixnotis-surface-alpha: 0.5;", + "--unixnotis-surface-strong-alpha: 1;", + "--unixnotis-card-alpha: 0.125;", + ] { + assert!( + overrides.contains(expected), + "trimmed modern output should contain {expected}" + ); + } +} diff --git a/crates/unixnotis-core/src/embedded/css.rs b/crates/unixnotis-core/src/embedded/css.rs index 58e6ccaa0..d860dbe06 100644 --- a/crates/unixnotis-core/src/embedded/css.rs +++ b/crates/unixnotis-core/src/embedded/css.rs @@ -20,6 +20,11 @@ pub const INTERNAL_STRUCTURE_CSS: &str = include_str!(concat!( "/assets/internal-structure.css" )); +pub const MOTION_POLICY_CSS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/motion-policy.css" +)); + #[cfg(test)] #[path = "tests/css.rs"] mod tests; diff --git a/crates/unixnotis-core/src/embedded/tests/css.rs b/crates/unixnotis-core/src/embedded/tests/css.rs index b98afb7ff..edae6ec41 100644 --- a/crates/unixnotis-core/src/embedded/tests/css.rs +++ b/crates/unixnotis-core/src/embedded/tests/css.rs @@ -1,6 +1,6 @@ use super::{ DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, - INTERNAL_STRUCTURE_CSS, + INTERNAL_STRUCTURE_CSS, MOTION_POLICY_CSS, }; #[test] @@ -12,13 +12,282 @@ fn every_embedded_css_layer_contains_real_stylesheet_content() { ("widgets", DEFAULT_WIDGETS_CSS), ("media", DEFAULT_MEDIA_CSS), ("internal structure", INTERNAL_STRUCTURE_CSS), + ("motion policy", MOTION_POLICY_CSS), ] { assert!(!css.trim().is_empty(), "{name} CSS should not be empty"); } } #[test] -fn internal_structure_css_only_targets_reload_notice_structure() { +fn motion_policy_disables_theme_motion_under_the_runtime_class() { + assert!(MOTION_POLICY_CSS.contains(".unixnotis-panel.unixnotis-reduced-motion")); + assert!(MOTION_POLICY_CSS.contains("transition: none")); + assert!(MOTION_POLICY_CSS.contains("animation: none")); + assert!(MOTION_POLICY_CSS.contains("transform: none")); +} + +#[test] +fn internal_structure_css_contains_only_required_fallback_structure() { assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-reload-notice")); + assert!(INTERNAL_STRUCTURE_CSS.contains(".unixnotis-panel-search-owned-icons")); assert!(!INTERNAL_STRUCTURE_CSS.contains("@define-color")); } + +#[test] +fn panel_css_keeps_the_dnd_menu_visual_hooks() { + for selector in [ + ".unixnotis-dnd-menu > contents", + ".unixnotis-dnd-menu-title", + ".unixnotis-dnd-menu-choice", + ".unixnotis-dnd-menu-choice-indefinite", + ".unixnotis-dnd-menu-separator", + ] { + assert!( + DEFAULT_PANEL_CSS.contains(selector), + "panel CSS should retain {selector}" + ); + } +} + +#[test] +fn panel_css_leaves_stack_offsets_and_row_gaps_to_gtk_layout() { + assert!(DEFAULT_PANEL_CSS.contains(".unixnotis-panel-card-row {\n margin: 0;")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 12px 8px 0")); + assert!(DEFAULT_PANEL_CSS.contains("border-radius: var(--unixnotis-notification-card-radius);")); +} + +#[test] +fn dnd_menu_hover_and_keyboard_focus_share_one_visual_rule() { + let shared_selector = ".unixnotis-dnd-menu .unixnotis-dnd-menu-choice:hover,\n\ +.unixnotis-dnd-menu .unixnotis-dnd-menu-choice:focus-visible"; + + // PrintScreen can switch GTK into keyboard modality while the pointer remains over a row + // One selector keeps that modality change from altering the captured menu appearance + assert!(DEFAULT_PANEL_CSS.contains(shared_selector)); + assert!(!DEFAULT_PANEL_CSS.contains("box-shadow: inset 2px 0")); +} + +#[test] +fn stock_panel_hover_styles_avoid_transform_and_geometry_animation() { + for (name, css) in [ + ("panel", DEFAULT_PANEL_CSS), + ("widgets", DEFAULT_WIDGETS_CSS), + ("media", DEFAULT_MEDIA_CSS), + ] { + assert!( + !css.contains("\n transform:"), + "{name} CSS should not move widgets during hover" + ); + } + + assert!(!DEFAULT_PANEL_CSS.contains("transition: background-image")); + assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-width")); + assert!(!DEFAULT_WIDGETS_CSS.contains("transition: min-height")); +} + +#[test] +fn stock_quick_slider_hover_targets_only_changed_widgets() { + // Composite slider hover rules restyle descendants even when no pixels change on the parent + for selector in [ + ".unixnotis-quick-slider:hover", + ".unixnotis-quick-slider-volume:hover", + ".unixnotis-quick-slider-brightness:hover", + ".unixnotis-quick-slider-volume:hover slider", + ".unixnotis-quick-slider-brightness:hover slider", + ".unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-value", + ".unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-value", + ".unixnotis-quick-slider-volume:hover .unixnotis-quick-slider-sublabel", + ".unixnotis-quick-slider-brightness:hover .unixnotis-quick-slider-sublabel", + ] { + assert!( + !DEFAULT_WIDGETS_CSS.contains(selector), + "quick-slider CSS must not broadcast ancestor hover through {selector}" + ); + } + + // Thumb and icon feedback remain attached to the widgets that actually change appearance + for selector in [ + ".unixnotis-quick-slider-volume .unixnotis-quick-slider-scale slider:hover", + ".unixnotis-quick-slider-brightness .unixnotis-quick-slider-scale slider:hover", + ".unixnotis-quick-slider-volume .unixnotis-quick-slider-icon:hover", + ".unixnotis-quick-slider-brightness .unixnotis-quick-slider-icon:hover", + ] { + assert!( + DEFAULT_WIDGETS_CSS.contains(selector), + "quick-slider CSS should keep direct hover feedback on {selector}" + ); + } +} + +#[test] +fn stock_scrollbar_keeps_master_sizing_without_geometry_animation() { + assert!(DEFAULT_PANEL_CSS.contains( + "scrollbar slider {\n background: alpha(#ffffff, 0.16);\n border-radius: 999px;\n border: none;\n min-width: 4px;" + )); + for selector in ["scrollbar slider:hover", "scrollbar slider:active"] { + let rule = DEFAULT_PANEL_CSS + .split(selector) + .nth(1) + .and_then(|suffix| suffix.split('}').next()) + .expect("stock scrollbar state rule"); + assert!( + rule.contains("min-width: 6px"), + "{selector} should retain the master width" + ); + } + assert!(!DEFAULT_PANEL_CSS.contains("transition: background-color 0.15s ease-out, min-width")); +} + +#[test] +fn critical_alert_assets_define_composed_popup_and_panel_states() { + for token in [ + "unixnotis-critical-surface", + "unixnotis-critical-surface-strong", + "unixnotis-critical-border", + "unixnotis-critical-text", + "unixnotis-critical-icon", + ] { + assert!( + DEFAULT_BASE_CSS.contains(token), + "base CSS should define {token}" + ); + } + + for selector in [ + ".unixnotis-popup-card.critical", + ".unixnotis-popup-card.critical .unixnotis-popup-icon", + ".unixnotis-panel-card.critical,\n.unixnotis-panel-card.active.critical", + ".unixnotis-panel-card.critical .unixnotis-panel-icon", + ] { + let css = if selector.contains("popup") { + DEFAULT_POPUP_CSS + } else { + DEFAULT_PANEL_CSS + }; + assert!(css.contains(selector), "stock CSS should retain {selector}"); + } + + assert!(DEFAULT_BASE_CSS.contains(".unixnotis-urgency-badge")); + assert!(!DEFAULT_PANEL_CSS.contains("animation:")); + assert!(!DEFAULT_POPUP_CSS.contains("animation:")); +} + +#[test] +fn popup_theme_keeps_kind_trust_and_compact_media_hooks() { + for selector in [ + ".unixnotis-popup-card.utility", + ".unixnotis-popup-communication-content", + ".unixnotis-popup-utility-content", + ".unixnotis-popup-trust-chip.recognized", + ".unixnotis-popup-trust-chip.unresolved", + ".unixnotis-popup-trust-chip.relay", + ".unixnotis-popup-trust-chip.conflict", + ".unixnotis-popup-time", + ] { + assert!( + DEFAULT_POPUP_CSS.contains(selector), + "popup CSS should retain {selector}" + ); + } + + // Default popups must not restore the old raw provenance body row + assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-popup-source")); + assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-popup-application-icon-slot")); + assert!(DEFAULT_POPUP_CSS.contains(".unixnotis-popup-conversation-avatar-slot")); + assert!(!DEFAULT_POPUP_CSS.contains(".unixnotis-identity-avatar")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 46px")); + assert!(DEFAULT_POPUP_CSS.contains("min-width: 64px")); + assert!(!DEFAULT_POPUP_CSS.contains("popup-warning-content")); +} + +#[test] +fn notification_surfaces_keep_compact_master_geometry() { + assert!(DEFAULT_PANEL_CSS.contains("min-width: 420px")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-radius)")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-notification-card-radius)")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-y)")); + assert!(DEFAULT_PANEL_CSS.contains("var(--unixnotis-panel-card-padding-x)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-radius)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-y)")); + assert!(DEFAULT_POPUP_CSS.contains("var(--unixnotis-popup-card-padding-x)")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 6px 14px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 12px 8px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: -58px 14px 0")); + assert!(!DEFAULT_PANEL_CSS.contains("margin: 0 20px")); +} + +#[test] +fn panel_group_headers_keep_master_pill_geometry() { + let header = DEFAULT_PANEL_CSS + .split(".unixnotis-group-header {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("group header rules should be present"); + let count = DEFAULT_PANEL_CSS + .split(".unixnotis-group-count {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("group count rules should be present"); + + assert!(header.contains("border-radius: 999px")); + assert!(header.contains("padding: 6px 12px")); + assert!(count.contains("padding: 2px 8px")); + assert!(count.contains("min-width: 22px")); +} + +#[test] +fn panel_avatar_slot_is_plain_but_content_visuals_keep_their_tile() { + let thumbnail = DEFAULT_PANEL_CSS + .split(".unixnotis-panel-card-thumbnail {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("thumbnail rules should be present"); + + assert!(thumbnail.contains("background: transparent")); + assert!(thumbnail.contains("border: 0")); + assert!(thumbnail.contains("border-radius: 0")); + assert!(thumbnail.contains("box-shadow: none")); + assert!(DEFAULT_PANEL_CSS + .contains(".unixnotis-panel-card-thumbnail.unixnotis-panel-content-image,")); + assert!( + DEFAULT_PANEL_CSS.contains(".unixnotis-panel-card-thumbnail.unixnotis-panel-sender-visual") + ); +} + +#[test] +fn notification_action_hover_matches_panel_glass_controls() { + let action = DEFAULT_PANEL_CSS + .split(".unixnotis-notification-action {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("notification action rules should be present"); + let hover = DEFAULT_PANEL_CSS + .split(".unixnotis-notification-action:hover {") + .nth(1) + .and_then(|rules| rules.split('}').next()) + .expect("notification action hover rules should be present"); + + assert!(action.contains("background: alpha(#ffffff, 0.045)")); + assert!(action.contains("border-top: 1px solid alpha(#ffffff, 0.08)")); + assert!(hover.contains("background: alpha(#ffffff, 0.08)")); + assert!(hover.contains("color: #ffffff")); + assert!(!hover.contains("linear-gradient")); +} + +#[test] +fn bundled_media_defaults_match_the_active_player_surface() { + assert!(DEFAULT_MEDIA_CSS.contains("min-height: 52px")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-nav:hover")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-button:disabled")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-card.playing")); + assert!(DEFAULT_MEDIA_CSS.contains("--unixnotis-media-art-size: 56px")); +} + +#[test] +fn media_cards_keep_art_and_transport_as_separate_visual_lanes() { + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-art-frame")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-control-strip")); + assert!(DEFAULT_MEDIA_CSS.contains(".unixnotis-media-card.playing")); + assert!(DEFAULT_MEDIA_CSS.contains("--unixnotis-media-art-size")); +} diff --git a/crates/unixnotis-core/src/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/atomic.rs index 420c69af8..07d29b0b3 100644 --- a/crates/unixnotis-core/src/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/atomic.rs @@ -1,15 +1,18 @@ //! Durable file replacement that does not follow target symlinks -use rustix::fs::{mkdirat, openat2, renameat, unlinkat, AtFlags, Mode, OFlags, ResolveFlags, CWD}; +use rustix::fs::{openat2, renameat, unlinkat, AtFlags, Mode, OFlags}; use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::os::fd::OwnedFd; use std::os::unix::fs::PermissionsExt; -use std::path::{Component, Path}; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use super::descriptor::{contained_resolve_flags, open_parent, sync_directory}; +use super::regular::{existing_target_mode, validate_existing_target}; + const TEMP_ATTEMPTS: u8 = 16; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -20,25 +23,65 @@ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); /// Returns an error when containment checks fail or the temporary write, synchronization, target /// validation, rename, or parent-directory synchronization cannot complete pub fn write_file_atomic(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { + publish_file_atomic(path, mode, |file| file.write_all(contents)) +} + +/// Replace a regular file while retaining its current permission bits +/// +/// A missing destination receives `default_mode`. Existing special files and links are rejected +/// through the same descriptor-relative checks as [`write_file_atomic`] +/// +/// # Errors +/// +/// Returns an error when containment checks fail or the temporary write, synchronization, target +/// validation, rename, or parent-directory synchronization cannot complete +pub fn write_file_atomic_preserving_mode( + path: &Path, + contents: &[u8], + default_mode: u32, +) -> io::Result<()> { + let (parent_fd, file_name) = open_parent(path)?; + let mode = existing_target_mode(&parent_fd, &file_name)?.unwrap_or(default_mode); + write_file_atomic_at(&parent_fd, &file_name, mode, |file| { + file.write_all(contents) + }) +} + +pub(super) fn publish_file_atomic( + path: &Path, + mode: u32, + write_payload: impl FnOnce(&mut fs::File) -> io::Result<()>, +) -> io::Result<()> { let (parent_fd, file_name) = open_parent(path)?; validate_target(&parent_fd, &file_name)?; - let candidates = temp_candidates(&file_name); - let (temp_name, mut temp_file) = reserve_temp(&parent_fd, candidates, mode)?; + write_file_atomic_at(&parent_fd, &file_name, mode, write_payload) +} - if let Err(error) = write_and_sync(&mut temp_file, contents, mode) { +fn write_file_atomic_at( + parent_fd: &OwnedFd, + file_name: &OsString, + mode: u32, + write_payload: impl FnOnce(&mut fs::File) -> io::Result<()>, +) -> io::Result<()> { + let candidates = temp_candidates(file_name); + let (temp_name, mut temp_file) = reserve_temp(parent_fd, candidates, mode)?; + + if let Err(error) = + write_payload(&mut temp_file).and_then(|()| set_mode_and_sync(&temp_file, mode)) + { drop(temp_file); - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error); } drop(temp_file); // A second check catches target swaps made while the payload was written - if let Err(error) = validate_target(&parent_fd, &file_name) { - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + if let Err(error) = validate_target(parent_fd, file_name) { + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error); } - if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, &file_name) { - let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + if let Err(error) = renameat(parent_fd, &temp_name, parent_fd, file_name) { + let _ = unlinkat(parent_fd, &temp_name, AtFlags::empty()); return Err(error.into()); } sync_directory(parent_fd) @@ -63,7 +106,7 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res contained_resolve_flags(), ) { Ok(fd) => fd, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(rustix::io::Errno::EXIST) => { // A collision is safe only when the existing destination is a regular file validate_existing_target(&parent_fd, &file_name)?; return Ok(false); @@ -71,155 +114,24 @@ pub fn write_file_if_missing(path: &Path, contents: &[u8], mode: u32) -> io::Res Err(error) => return Err(error.into()), }; let mut file = fs::File::from(fd); - if let Err(error) = write_and_sync(&mut file, contents, mode) { + if let Err(error) = file + .write_all(contents) + .and_then(|()| set_mode_and_sync(&file, mode)) + { drop(file); let _ = unlinkat(&parent_fd, &file_name, AtFlags::empty()); return Err(error); } drop(file); - sync_directory(parent_fd)?; + sync_directory(&parent_fd)?; Ok(true) } -/// Add executable bits to an existing regular file without following links -/// -/// # Errors -/// -/// Returns an error when the path escapes through a link, is not a regular file, or cannot be -/// opened and updated through its stable descriptor -pub fn make_file_executable(path: &Path) -> io::Result<()> { - let (parent_fd, file_name) = open_parent(path)?; - let fd = openat2( - &parent_fd, - &file_name, - OFlags::RDONLY - .union(OFlags::NONBLOCK) - .union(OFlags::CLOEXEC) - .union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - )?; - let file = fs::File::from(fd); - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(unsafe_target_error()); - } - - // Descriptor-based chmod prevents a path swap from redirecting the permission update - let mode = metadata.permissions().mode() | 0o111; - file.set_permissions(fs::Permissions::from_mode(mode)) -} - -fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { - let file_name = path - .file_name() - .filter(|name| !name.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? - .to_os_string(); - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let mut parent_fd = if path.is_absolute() { - openat2( - CWD, - "/", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - anchor_resolve_flags(), - )? - } else { - openat2( - CWD, - ".", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - anchor_resolve_flags(), - )? - }; - - for component in parent.components() { - match component { - Component::Prefix(_) | Component::RootDir | Component::CurDir => {} - Component::ParentDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "atomic write path cannot contain parent traversal", - )); - } - Component::Normal(name) => parent_fd = open_or_create_dir(&parent_fd, name)?, - } - } - Ok((parent_fd, file_name)) -} - -fn open_or_create_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result { - match openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - contained_resolve_flags(), - ) { - Ok(fd) => Ok(fd), - Err(error) if error.kind() == io::ErrorKind::NotFound => { - mkdirat(parent_fd, name, Mode::from_raw_mode(0o755))?; - openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - contained_resolve_flags(), - ) - .map_err(Into::into) - } - Err(error) => Err(error.into()), - } -} - fn validate_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { - match openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - ) { - Ok(fd) => { - if fs::File::from(fd).metadata()?.is_file() { - Ok(()) - } else { - Err(unsafe_target_error()) - } - } - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -fn validate_existing_target(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result<()> { - let fd = openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - contained_resolve_flags(), - )?; - if fs::File::from(fd).metadata()?.is_file() { - Ok(()) - } else { - Err(unsafe_target_error()) - } -} - -fn unsafe_target_error() -> io::Error { - io::Error::new( - io::ErrorKind::InvalidInput, - "refusing to operate on a non-regular file target", - ) + existing_target_mode(parent_fd, file_name).map(|_mode| ()) } -fn temp_candidates(file_name: &OsString) -> impl Iterator + '_ { +pub(super) fn temp_candidates(file_name: &OsString) -> impl Iterator + '_ { (0..TEMP_ATTEMPTS).map(move |attempt| { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -262,31 +174,16 @@ fn reserve_temp( )) } -fn write_and_sync(file: &mut fs::File, contents: &[u8], mode: u32) -> io::Result<()> { - file.write_all(contents)?; +fn set_mode_and_sync(file: &fs::File, mode: u32) -> io::Result<()> { // Mode is fixed before publication so readers never observe broad temporary permissions file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; file.sync_all() } -fn sync_directory(parent_fd: OwnedFd) -> io::Result<()> { - fs::File::from(parent_fd).sync_all() -} - const fn file_mode(mode: u32) -> Mode { Mode::from_raw_mode(mode & 0o777) } -const fn contained_resolve_flags() -> ResolveFlags { - ResolveFlags::BENEATH - .union(ResolveFlags::NO_SYMLINKS) - .union(ResolveFlags::NO_MAGICLINKS) -} - -const fn anchor_resolve_flags() -> ResolveFlags { - ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) -} - #[cfg(test)] -#[path = "../tests/filesystem/atomic.rs"] +#[path = "tests/atomic.rs"] mod tests; diff --git a/crates/unixnotis-core/src/filesystem/descriptor.rs b/crates/unixnotis-core/src/filesystem/descriptor.rs new file mode 100644 index 000000000..9cec8f337 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/descriptor.rs @@ -0,0 +1,199 @@ +//! Stable directory anchors and descriptor-relative path traversal + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::path::{Component, Path}; + +use rustix::fs::{fchmod, fsync, mkdirat, openat2, Mode, OFlags, ResolveFlags, CWD}; + +/// Outcome for the final component of recursive directory creation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateDirectoryOutcome { + /// The requested directory itself was created by this operation + TargetCreated, + /// The requested directory already existed when its retained descriptor was opened + TargetAlreadyExisted, +} + +pub(super) fn open_parent(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Create(0o755)) +} + +pub(super) fn open_parent_existing(path: &Path) -> io::Result<(OwnedFd, OsString)> { + open_parent_with(path, MissingDirectory::Reject) +} + +pub(super) fn open_directory_for_creation( + path: &Path, + mode: u32, +) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { + open_directory_path(path, MissingDirectory::Create(mode)) +} + +pub(super) fn open_target_directory( + path: &Path, +) -> io::Result> { + // Removal never creates missing parents as a side effect + let (parent_fd, file_name) = match open_parent_existing(path) { + Ok(parent) => parent, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + match open_directory_at(&parent_fd, &file_name) { + Ok(directory_fd) => Ok(Some((parent_fd, file_name, directory_fd))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub(super) fn sync_directory(directory_fd: &OwnedFd) -> io::Result<()> { + Ok(fsync(directory_fd)?) +} + +pub(super) const fn contained_resolve_flags() -> ResolveFlags { + ResolveFlags::BENEATH + .union(ResolveFlags::NO_SYMLINKS) + .union(ResolveFlags::NO_MAGICLINKS) +} + +pub(super) const fn anchor_resolve_flags() -> ResolveFlags { + ResolveFlags::NO_SYMLINKS.union(ResolveFlags::NO_MAGICLINKS) +} + +#[derive(Clone, Copy)] +enum MissingDirectory { + Create(u32), + Reject, +} + +fn open_parent_with( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, OsString)> { + // Keeping the final name separate makes every later operation descriptor-relative + let file_name = path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no file name"))? + .to_os_string(); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let (parent_fd, _created) = open_directory_path(parent, missing_directory)?; + Ok((parent_fd, file_name)) +} + +fn open_directory_path( + path: &Path, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, CreateDirectoryOutcome)> { + // Absolute and relative paths begin from different trusted anchors + let mut directory_fd = open_anchor(path)?; + let mut target_outcome = CreateDirectoryOutcome::TargetAlreadyExisted; + + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem path cannot contain parent traversal", + )); + } + Component::Normal(name) => { + let (next_fd, component_created) = + open_directory_component(&directory_fd, name, missing_directory)?; + directory_fd = next_fd; + target_outcome = if component_created { + CreateDirectoryOutcome::TargetCreated + } else { + CreateDirectoryOutcome::TargetAlreadyExisted + }; + } + } + } + + Ok((directory_fd, target_outcome)) +} + +fn open_anchor(path: &Path) -> io::Result { + openat2( + CWD, + if path.is_absolute() { "/" } else { "." }, + OFlags::DIRECTORY.union(OFlags::CLOEXEC), + Mode::empty(), + anchor_resolve_flags(), + ) + .map_err(Into::into) +} + +fn open_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + missing_directory: MissingDirectory, +) -> io::Result<(OwnedFd, bool)> { + match open_directory_at(parent_fd, name) { + Ok(fd) => Ok((fd, false)), + Err(error) + if error.kind() == io::ErrorKind::NotFound + && matches!(missing_directory, MissingDirectory::Create(_)) => + { + let MissingDirectory::Create(mode) = missing_directory else { + unreachable!("guard requires directory creation mode"); + }; + create_directory_component(parent_fd, name, mode) + } + Err(error) => Err(error), + } +} + +fn create_directory_component( + parent_fd: &OwnedFd, + name: &OsStr, + mode: u32, +) -> io::Result<(OwnedFd, bool)> { + let create_result = mkdirat(parent_fd, name, file_mode(mode)).map_err(Into::into); + let created = classify_directory_creation(create_result)?; + let directory_fd = open_directory_at(parent_fd, name)?; + if created { + // Exact permissions are restored because mkdir remains subject to the process umask + fchmod(&directory_fd, file_mode(mode))?; + fsync(&directory_fd)?; + fsync(parent_fd)?; + } + Ok((directory_fd, created)) +} + +pub(super) fn classify_directory_creation(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(true), + Err(error) => match error.kind() { + // A concurrent creator still passes the same no-follow open before use + io::ErrorKind::AlreadyExists => Ok(false), + _ => Err(error), + }, + } +} + +pub(super) fn open_directory_at(parent_fd: &OwnedFd, name: &OsStr) -> io::Result { + openat2( + parent_fd, + name, + OFlags::DIRECTORY + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + ) + .map_err(Into::into) +} + +const fn file_mode(mode: u32) -> Mode { + Mode::from_raw_mode(mode & 0o777) +} + +#[cfg(test)] +#[path = "tests/descriptor.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/directory.rs b/crates/unixnotis-core/src/filesystem/directory.rs new file mode 100644 index 000000000..a66e08639 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/directory.rs @@ -0,0 +1,104 @@ +//! Directory creation, ownership markers, and empty removal + +use std::ffi::OsStr; +use std::io; +use std::path::{Component, Path}; + +use rustix::fs::{unlinkat, AtFlags}; + +use super::descriptor::{ + open_directory_for_creation, open_target_directory, sync_directory, CreateDirectoryOutcome, +}; +use super::exact::{ensure_exact_file_at, EnsureExactFileOutcome}; +use super::regular::{file_contents_equal, open_regular_file_at}; + +/// Create a directory and every missing parent without following links +/// +/// Reports whether the final directory was created without conflating parent creation +/// +/// # Errors +/// +/// Returns an error when the path traverses upward or through a link, an existing component is not +/// a directory, or creation, permission repair, or synchronization fails +pub fn create_directory_all(path: &Path, mode: u32) -> io::Result { + let (_directory_fd, outcome) = open_directory_for_creation(path, mode)?; + Ok(outcome) +} + +/// Create a directory with an ownership marker or validate the retained existing directory +/// +/// Existing directories are never mutated until their marker bytes are proven through the same +/// directory descriptor used for the decision +/// +/// # Errors +/// +/// Returns an error for unsafe paths, invalid marker names, missing or mismatched ownership +/// markers, and directory or marker creation failures +pub fn ensure_marked_directory( + path: &Path, + directory_mode: u32, + marker_name: &OsStr, + marker_contents: &[u8], + marker_mode: u32, +) -> io::Result { + validate_child_name(marker_name)?; + let (directory_fd, outcome) = open_directory_for_creation(path, directory_mode)?; + let marker_name = marker_name.to_os_string(); + + match outcome { + CreateDirectoryOutcome::TargetCreated => { + let marker_outcome = + ensure_exact_file_at(&directory_fd, &marker_name, marker_contents, marker_mode)?; + if matches!(marker_outcome, EnsureExactFileOutcome::ContentsMismatch) { + return Err(invalid_marker_error()); + } + } + CreateDirectoryOutcome::TargetAlreadyExisted => { + let mut marker = open_regular_file_at(&directory_fd, &marker_name) + .map_err(|_error| invalid_marker_error())?; + if !file_contents_equal(&mut marker, marker_contents)? { + return Err(invalid_marker_error()); + } + } + } + + Ok(outcome) +} + +/// Remove an empty directory without following links +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not an empty directory, or the +/// removal or parent-directory synchronization fails +pub fn remove_empty_directory(path: &Path) -> io::Result { + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +pub(super) fn validate_child_name(name: &OsStr) -> io::Result<()> { + let mut components = Path::new(name).components(); + if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ownership marker must be one relative file name", + )) +} + +pub(super) fn invalid_marker_error() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "directory ownership marker is missing or does not match", + ) +} + +#[cfg(test)] +#[path = "tests/directory.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/exact.rs b/crates/unixnotis-core/src/filesystem/exact.rs new file mode 100644 index 000000000..84908ba4d --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/exact.rs @@ -0,0 +1,324 @@ +//! Create-or-validate transactions for exact regular-file state + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Write}; +use std::os::fd::OwnedFd; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use rustix::fs::{fstat, openat2, statat, unlinkat, AtFlags, Mode, OFlags}; + +use super::descriptor::{contained_resolve_flags, open_parent, sync_directory}; +use super::regular::{file_contents_equal, open_regular_file_at}; + +/// Result of creating or validating a file whose bytes must match exactly +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureExactFileOutcome { + /// The destination was absent and this operation created it + Created, + /// The existing regular file already contained the required bytes + AlreadyExact, + /// The existing regular file belongs to another owner or configuration + ContentsMismatch, +} + +/// Result of creating or validating an exact file-and-marker pair +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsureExactFilePairOutcome { + /// At least one missing member was created and the complete pair is exact + Created, + /// Both existing regular files already contained the required bytes + AlreadyExact, + /// The primary file was already exact but no ownership marker existed + AlreadyExactUnowned, + /// At least one existing member contained different bytes + ContentsMismatch, +} + +struct ExactMember { + file: fs::File, + created: bool, +} + +#[derive(Clone, Copy)] +struct ExactMode(u32); + +impl ExactMode { + const fn new(mode: u32) -> Self { + Self(mode & 0o777) + } + + const fn rustix(self) -> Mode { + Mode::from_raw_mode(self.0) + } + + const fn permissions(self) -> u32 { + self.0 + } +} + +enum ExactMemberResult { + Exact(ExactMember), + ContentsMismatch, +} + +/// Create a regular file when absent or validate an exact existing payload +/// +/// A collision is opened once through the retained parent descriptor and is never replaced +/// +/// # Errors +/// +/// Returns an error when the parent path is unsafe, the destination is not a regular file, or +/// creating, reading, applying the mode, or synchronizing the file fails +pub fn ensure_exact_file( + path: &Path, + contents: &[u8], + mode: u32, +) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + ensure_exact_file_at(&parent_fd, &file_name, contents, mode) +} + +/// Create or validate a same-directory regular file and ownership marker as one transaction +/// +/// Existing members are never replaced. When either member conflicts, files created by this +/// operation are removed through retained descriptors before returning +/// +/// # Errors +/// +/// Returns an error when the paths do not share one parent, a path is unsafe, either target is not +/// a regular file, or creation, rollback, permission repair, or synchronization fails +pub fn ensure_exact_file_pair( + path: &Path, + contents: &[u8], + mode: u32, + marker_path: &Path, + marker_contents: &[u8], + marker_mode: u32, +) -> io::Result { + if path.parent() != marker_path.parent() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "exact file pair must share one parent directory", + )); + } + + let (parent_fd, file_name) = open_parent(path)?; + let marker_name = marker_path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "marker has no file name"))? + .to_os_string(); + if file_name == marker_name { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "exact file pair must use two distinct names", + )); + } + + let mode = ExactMode::new(mode); + let marker_mode = ExactMode::new(marker_mode); + let file = match create_or_validate_member(&parent_fd, &file_name, contents, mode)? { + ExactMemberResult::Exact(member) => member, + ExactMemberResult::ContentsMismatch => { + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + }; + let marker = if file.created { + match create_or_validate_member(&parent_fd, &marker_name, marker_contents, marker_mode) { + Ok(ExactMemberResult::Exact(member)) => member, + Ok(ExactMemberResult::ContentsMismatch) => { + rollback_created_member(&parent_fd, &file_name, &file)?; + sync_directory(&parent_fd)?; + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + Err(error) => { + return Err(rollback_after_error(&parent_fd, &file_name, &file, error)); + } + } + } else { + // An existing unmarked file may be compatible user state, so never claim it retroactively + match open_regular_file_at(&parent_fd, &marker_name) { + Ok(mut marker_file) => { + if !file_contents_equal(&mut marker_file, marker_contents)? { + return Ok(EnsureExactFilePairOutcome::ContentsMismatch); + } + ExactMember { + file: marker_file, + created: false, + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(EnsureExactFilePairOutcome::AlreadyExactUnowned); + } + Err(error) => return Err(error), + } + }; + + // Modes are repaired only after both retained payloads prove the complete pair is owned + if let Err(error) = set_mode_and_sync(&file.file, mode) + .and_then(|()| set_mode_and_sync(&marker.file, marker_mode)) + .and_then(|()| sync_directory(&parent_fd)) + { + return Err(rollback_pair_after_error( + &parent_fd, + (&file_name, &file), + (&marker_name, &marker), + error, + )); + } + + if file.created || marker.created { + Ok(EnsureExactFilePairOutcome::Created) + } else { + Ok(EnsureExactFilePairOutcome::AlreadyExact) + } +} + +pub(super) fn ensure_exact_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: u32, +) -> io::Result { + let mode = ExactMode::new(mode); + let member = match create_or_validate_member(parent_fd, file_name, contents, mode)? { + ExactMemberResult::Exact(member) => member, + ExactMemberResult::ContentsMismatch => { + return Ok(EnsureExactFileOutcome::ContentsMismatch); + } + }; + + if let Err(error) = set_mode_and_sync(&member.file, mode) { + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + if let Err(error) = sync_directory(parent_fd) { + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + + if member.created { + Ok(EnsureExactFileOutcome::Created) + } else { + Ok(EnsureExactFileOutcome::AlreadyExact) + } +} + +fn create_or_validate_member( + parent_fd: &OwnedFd, + file_name: &OsString, + contents: &[u8], + mode: ExactMode, +) -> io::Result { + let fd = match openat2( + parent_fd, + file_name, + OFlags::RDWR + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::CREATE) + .union(OFlags::EXCL), + mode.rustix(), + contained_resolve_flags(), + ) { + Ok(fd) => fd, + Err(rustix::io::Errno::EXIST) => { + let mut file = open_regular_file_at(parent_fd, file_name)?; + if !file_contents_equal(&mut file, contents)? { + return Ok(ExactMemberResult::ContentsMismatch); + } + return Ok(ExactMemberResult::Exact(ExactMember { + file, + created: false, + })); + } + Err(error) => return Err(error.into()), + }; + + let mut file = fs::File::from(fd); + if let Err(error) = file + .write_all(contents) + .and_then(|()| set_mode_and_sync(&file, mode)) + { + let member = ExactMember { + file, + created: true, + }; + return Err(rollback_after_error(parent_fd, file_name, &member, error)); + } + Ok(ExactMemberResult::Exact(ExactMember { + file, + created: true, + })) +} + +fn rollback_pair_after_error( + parent_fd: &OwnedFd, + file: (&OsString, &ExactMember), + marker: (&OsString, &ExactMember), + error: io::Error, +) -> io::Error { + let marker_rollback = rollback_created_member(parent_fd, marker.0, marker.1); + let file_rollback = rollback_created_member(parent_fd, file.0, file.1); + let directory_sync = sync_directory(parent_fd); + combine_rollback_error( + error, + marker_rollback.and(file_rollback).and(directory_sync), + ) +} + +fn rollback_after_error( + parent_fd: &OwnedFd, + file_name: &OsString, + member: &ExactMember, + error: io::Error, +) -> io::Error { + let rollback = rollback_created_member(parent_fd, file_name, member) + .and_then(|()| sync_directory(parent_fd)); + combine_rollback_error(error, rollback) +} + +fn combine_rollback_error(error: io::Error, rollback: io::Result<()>) -> io::Error { + match rollback { + Ok(()) => error, + Err(rollback_error) => io::Error::new( + rollback_error.kind(), + format!("{error}; exact-file rollback also failed: {rollback_error}"), + ), + } +} + +fn rollback_created_member( + parent_fd: &OwnedFd, + file_name: &OsString, + member: &ExactMember, +) -> io::Result<()> { + if !member.created { + return Ok(()); + } + + // Identity revalidation prevents rollback from removing a replacement object + let retained = fstat(&member.file)?; + let visible = match statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW) { + Ok(visible) => visible, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if retained.st_dev != visible.st_dev || retained.st_ino != visible.st_ino { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "created exact file changed before rollback", + )); + } + unlinkat(parent_fd, file_name, AtFlags::empty())?; + Ok(()) +} + +fn set_mode_and_sync(file: &fs::File, mode: ExactMode) -> io::Result<()> { + file.set_permissions(fs::Permissions::from_mode(mode.permissions()))?; + file.sync_all() +} + +#[cfg(test)] +#[path = "tests/exact.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/install.rs b/crates/unixnotis-core/src/filesystem/install.rs new file mode 100644 index 000000000..e56b3659a --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/install.rs @@ -0,0 +1,32 @@ +//! Atomic regular-file copies for executable and backup installation + +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::atomic::publish_file_atomic; +use super::regular::open_regular_file; + +/// Copy one regular file into an atomically published destination +/// +/// Source and destination ancestors must be real directories. The source mode is applied to the +/// staged file before publication, and existing destination links or special files are rejected +/// +/// # Errors +/// +/// Returns an error when either path crosses a link, the source is not a regular file, or copying, +/// synchronizing, validating, renaming, or parent-directory synchronization fails +pub fn copy_file_atomic(source: &Path, destination: &Path) -> io::Result<()> { + // Open once so source bytes and permissions come from the same stable object + let mut input = open_regular_file(source)?; + let mode = input.metadata()?.permissions().mode() & 0o777; + + publish_file_atomic(destination, mode, |output| { + io::copy(&mut input, output)?; + Ok(()) + }) +} + +#[cfg(test)] +#[path = "tests/install.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/mod.rs b/crates/unixnotis-core/src/filesystem/mod.rs index 4239f562c..b707ed37d 100644 --- a/crates/unixnotis-core/src/filesystem/mod.rs +++ b/crates/unixnotis-core/src/filesystem/mod.rs @@ -1,5 +1,39 @@ //! Shared filesystem operations with stable directory anchors mod atomic; +mod descriptor; +mod directory; +mod exact; +mod install; +mod path; +mod quarantine; +mod regular; +mod remove; +mod rename; +mod symlink; +mod tree; -pub use atomic::{make_file_executable, write_file_atomic, write_file_if_missing}; +pub use atomic::{write_file_atomic, write_file_atomic_preserving_mode, write_file_if_missing}; +pub use descriptor::CreateDirectoryOutcome; +pub use directory::{create_directory_all, ensure_marked_directory, remove_empty_directory}; +pub use exact::{ + ensure_exact_file, ensure_exact_file_pair, EnsureExactFileOutcome, EnsureExactFilePairOutcome, +}; +pub use install::copy_file_atomic; +pub use path::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; +pub use regular::{ + make_file_executable, open_regular_file, read_regular_file_bounded, + regular_file_contents_equal, set_file_mode, +}; +pub use remove::{ + remove_regular_file, remove_regular_file_pair_if_contents, remove_symlink, + remove_symlink_if_target, RemoveExactFileOutcome, RemoveSymlinkOutcome, +}; +pub use rename::{ + rename_directory_no_replace, rename_regular_file_no_replace, RenameDirectoryOutcome, + RenameRegularFileOutcome, +}; +pub use symlink::{ + create_symlink_if_missing, read_symlink, replace_symlink_atomic, CreateSymlinkOutcome, +}; +pub use tree::{remove_directory_tree, remove_marked_directory_tree}; diff --git a/crates/unixnotis-core/src/filesystem/path.rs b/crates/unixnotis-core/src/filesystem/path.rs new file mode 100644 index 000000000..56fca64ac --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/path.rs @@ -0,0 +1,129 @@ +//! Lexical path normalization and root containment without filesystem access + +use std::path::{Component, Path, PathBuf}; + +use thiserror::Error; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct LexicallyNormalizedPath(PathBuf); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContainedPath { + root: LexicallyNormalizedPath, + relative: PathBuf, +} + +#[derive(Clone, Debug, Error, Eq, PartialEq)] +pub enum LexicalPathError { + #[error("path parent traversal escapes its lexical root")] + ParentEscape, + #[error("contained path must be relative")] + ExpectedRelative, + #[error("path is outside the supplied root")] + OutsideRoot, +} + +impl LexicallyNormalizedPath { + /// Normalize `.` and `..` components without resolving symlinks + /// + /// # Errors + /// + /// Returns an error when parent traversal would escape the lexical path root + pub fn new(path: impl AsRef) -> Result { + let mut normalized = PathBuf::new(); + for component in path.as_ref().components() { + match component { + Component::CurDir => {} + Component::ParentDir => match normalized.components().next_back() { + Some(Component::Normal(_)) => { + let removed = normalized.pop(); + debug_assert!(removed, "normal path component must be removable"); + } + _ => return Err(LexicalPathError::ParentEscape), + }, + Component::Normal(part) => normalized.push(part), + Component::RootDir | Component::Prefix(_) => { + normalized.push(component.as_os_str()); + } + } + } + Ok(Self(normalized)) + } + + #[must_use] + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + #[must_use] + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +impl AsRef for LexicallyNormalizedPath { + fn as_ref(&self) -> &Path { + self.as_path() + } +} + +impl ContainedPath { + /// Resolve an absolute or relative candidate beneath one lexical root + /// + /// # Errors + /// + /// Returns an error when normalization fails or the result leaves `root` + pub fn resolve( + root: impl AsRef, + candidate: impl AsRef, + ) -> Result { + let root = LexicallyNormalizedPath::new(root)?; + let candidate = candidate.as_ref(); + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.as_path().join(candidate) + }; + let normalized = LexicallyNormalizedPath::new(joined)?; + let relative = normalized + .as_path() + .strip_prefix(root.as_path()) + .map_err(|_outside_root| LexicalPathError::OutsideRoot)? + .to_path_buf(); + Ok(Self { root, relative }) + } + + /// Resolve a candidate that must be relative beneath one lexical root + /// + /// # Errors + /// + /// Returns an error for absolute candidates, traversal, or containment failure + pub fn resolve_relative( + root: impl AsRef, + relative: impl AsRef, + ) -> Result { + if relative.as_ref().is_absolute() { + return Err(LexicalPathError::ExpectedRelative); + } + Self::resolve(root, relative) + } + + #[must_use] + pub fn root(&self) -> &Path { + self.root.as_path() + } + + #[must_use] + pub fn relative(&self) -> &Path { + self.relative.as_path() + } + + #[must_use] + pub fn absolute(&self) -> PathBuf { + self.root.as_path().join(&self.relative) + } +} + +#[cfg(test)] +#[path = "tests/path.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/quarantine.rs b/crates/unixnotis-core/src/filesystem/quarantine.rs new file mode 100644 index 000000000..6b768cd01 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/quarantine.rs @@ -0,0 +1,203 @@ +//! Private same-filesystem quarantine directories for exact entry retirement +//! +//! The first rename is the security boundary for the visible source name. It moves the entry +//! out of the watched basename in one kernel operation, so a later replacement cannot be +//! mistaken for the original source entry +//! +//! The retained quarantine descriptor pins the directory used by later checks and operations. +//! It does not turn the final entry name into a file-descriptor operation: Linux unlinkat still +//! resolves the final entry by pathname. Mode 0700 therefore excludes other UIDs, while a +//! hostile process with the same UID still requires a trusted quarantine directory boundary + +use std::ffi::OsString; +use std::fmt::Write as _; +use std::io; +use std::os::fd::OwnedFd; + +use rustix::fs::{fchmod, mkdirat, renameat_with, unlinkat, AtFlags, Mode, RenameFlags}; +use rustix::rand::{getrandom, GetRandomFlags}; + +use super::descriptor::{open_directory_at, sync_directory}; + +const QUARANTINE_ATTEMPTS: usize = 16; +const RANDOM_BYTES: usize = 16; +const QUARANTINE_PREFIX: &str = ".unixnotis-quarantine."; +const ENTRY_PREFIX: &str = ".unixnotis-entry."; + +/// One private quarantine directory retained through a stable descriptor +pub(super) struct Quarantine { + name: OsString, + fd: OwnedFd, +} + +/// One entry moved into a retained quarantine directory +#[derive(Debug)] +pub(super) struct QuarantinedEntry { + name: OsString, +} + +impl Quarantine { + /// Create a mode-0700 directory beside the source entry + pub(super) fn create(parent_fd: &OwnedFd) -> io::Result { + // The quarantine must be beside the source so renameat can keep the original filesystem + // semantics and preserve the exact inode instead of falling back to a data copy + let candidates = random_names(QUARANTINE_PREFIX)?; + for name in candidates { + match mkdirat(parent_fd, &name, Mode::from_raw_mode(0o700)).map_err(io::Error::from) { + Ok(()) => { + // Restore exact permissions after umask processing before any entry is moved + let fd = match open_directory_at(parent_fd, &name) { + Ok(fd) => fd, + Err(error) => { + remove_created_directory(parent_fd, &name); + return Err(error); + } + }; + if let Err(error) = fchmod(&fd, Mode::from_raw_mode(0o700)) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error.into()); + } + if let Err(error) = sync_directory(&fd) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error); + } + if let Err(error) = sync_directory(parent_fd) { + drop(fd); + remove_created_directory(parent_fd, &name); + return Err(error); + } + // Keep this descriptor for the entire claim, validation, and cleanup flow + return Ok(Self { name, fd }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to create a private quarantine directory", + )) + } + + /// Atomically move one source basename into the private directory + pub(super) fn move_entry( + &self, + source_parent: &OwnedFd, + source_name: &OsString, + ) -> io::Result { + for name in random_names(ENTRY_PREFIX)? { + // RENAME_NOREPLACE prevents a pre-existing quarantine name from being overwritten + match renameat_with( + source_parent, + source_name, + &self.fd, + &name, + RenameFlags::NOREPLACE, + ) + .map_err(io::Error::from) + { + Ok(()) => { + // The source basename is now empty, so a watcher can only create a new entry + // there and cannot change the object that this quarantine entry represents + return Ok(QuarantinedEntry { name }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to reserve a quarantine entry name", + )) + } + + /// Restore a quarantined entry without replacing a new source entry + pub(super) fn restore( + &self, + entry: &QuarantinedEntry, + source_parent: &OwnedFd, + source_name: &OsString, + ) -> io::Result<()> { + // Never restore over a replacement that appeared at the original basename + renameat_with( + &self.fd, + &entry.name, + source_parent, + source_name, + RenameFlags::NOREPLACE, + ) + .map_err(io::Error::from) + } + + /// Remove one already-validated quarantined entry + pub(super) fn unlink(&self, entry: &QuarantinedEntry) -> io::Result<()> { + // The caller revalidates the entry against its retained descriptor immediately before + // this operation, which catches replacement objects during the normal claim flow + // unlinkat still resolves entry.name at this syscall; it has no unlink-by-FD mode + unlinkat(&self.fd, &entry.name, AtFlags::empty()).map_err(io::Error::from)?; + // Persist the removal while the quarantine descriptor still identifies its directory + sync_directory(&self.fd) + } + + /// Remove the quarantine directory when no mismatched entry was retained + pub(super) fn cleanup(self, parent_fd: &OwnedFd) -> io::Result<()> { + let Self { name, fd } = self; + // Directory removal is housekeeping after the claimed entry is gone, not the object claim + drop(fd); + match unlinkat(parent_fd, &name, AtFlags::REMOVEDIR).map_err(io::Error::from) { + Ok(()) => sync_directory(parent_fd), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => { + Err(io::Error::other("quarantine retained an unexpected entry")) + } + Err(error) => Err(error), + } + } + + /// Expose the retained descriptor to identity checks without exposing a pathname + pub(super) const fn fd(&self) -> &OwnedFd { + &self.fd + } +} + +fn remove_created_directory(parent_fd: &OwnedFd, name: &OsString) { + // Failed setup must not leave an unused staging directory behind + let _ = unlinkat(parent_fd, name, AtFlags::REMOVEDIR); + let _ = sync_directory(parent_fd); +} + +impl QuarantinedEntry { + /// Return the entry name relative to the retained quarantine descriptor + pub(super) const fn name(&self) -> &OsString { + &self.name + } +} + +fn random_names(prefix: &str) -> io::Result> { + let mut random = [0_u8; RANDOM_BYTES]; + let bytes_read = getrandom(&mut random, GetRandomFlags::empty()).map_err(io::Error::from)?; + if bytes_read != random.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "secure random source returned too few bytes", + )); + } + let mut token = String::with_capacity(RANDOM_BYTES.saturating_mul(2)); + for byte in random { + write!(&mut token, "{byte:02x}") + .map_err(|_| io::Error::other("failed to format quarantine name"))?; + } + let process_id = std::process::id(); + + Ok((0..QUARANTINE_ATTEMPTS) + .map(|attempt| OsString::from(format!("{prefix}{process_id}.{token}.{attempt}"))) + .collect()) +} + +#[cfg(test)] +#[path = "tests/quarantine.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/regular.rs b/crates/unixnotis-core/src/filesystem/regular.rs new file mode 100644 index 000000000..446504fd1 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/regular.rs @@ -0,0 +1,216 @@ +//! Stable-descriptor operations for regular files + +use std::ffi::OsString; +use std::fs; +use std::io::{self, Read}; +use std::os::fd::OwnedFd; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use rustix::fs::{fstat, openat2, statat, AtFlags, Mode, OFlags}; + +use super::descriptor::{contained_resolve_flags, open_parent_existing}; + +/// Open one regular file through a no-follow descriptor path +/// +/// # Errors +/// +/// Returns an error when any path component is a link, the target is not a regular file, or the +/// descriptor-relative open fails +pub fn open_regular_file(path: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent_existing(path)?; + open_regular_file_at(&parent_fd, &file_name) +} + +/// Compare one regular file with expected bytes through a single retained descriptor +/// +/// Files larger than `maximum_size` are reported as non-matching without being read +/// +/// # Errors +/// +/// Returns an error when the expected bytes exceed the declared limit, the path is unsafe, the +/// target is not a regular file, or the bounded comparison cannot complete +pub fn regular_file_contents_equal( + path: &Path, + expected: &[u8], + maximum_size: u64, +) -> io::Result { + let expected_size = u64::try_from(expected.len()).map_err(|_error| { + io::Error::new( + io::ErrorKind::InvalidInput, + "expected regular-file contents do not fit the size limit", + ) + })?; + if expected_size > maximum_size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected regular-file contents exceed the size limit", + )); + } + + // One open pins the object used by both the size check and byte comparison + let mut file = open_regular_file(path)?; + if file.metadata()?.len() > maximum_size { + return Ok(false); + } + file_contents_equal(&mut file, expected) +} + +/// Read a regular file without following links and enforce a byte limit +/// +/// # Errors +/// +/// Returns an error when the path crosses a link, the target is not a regular file, the file is +/// larger than `max_bytes`, or the bounded read cannot complete +pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> io::Result> { + // Opening once keeps the size check and payload read tied to one filesystem object + let mut file = open_regular_file(path)?; + let initial_size = file.metadata()?.len(); + if initial_size > max_bytes { + return Err(limit_error(max_bytes)); + } + + let capacity = usize::try_from(initial_size).map_err(|_size_error| { + io::Error::new(io::ErrorKind::InvalidData, "file size does not fit memory") + })?; + let mut contents = Vec::with_capacity(capacity); + file.by_ref() + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents)?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(limit_error(max_bytes)); + } + Ok(contents) +} + +/// Add executable bits to an existing regular file without following links +/// +/// # Errors +/// +/// Returns an error when the path escapes through a link, is not a regular file, or cannot be +/// opened and updated through its stable descriptor +pub fn make_file_executable(path: &Path) -> io::Result<()> { + let file = open_regular_file(path)?; + let mode = file.metadata()?.permissions().mode() | 0o111; + file.set_permissions(fs::Permissions::from_mode(mode)) +} + +/// Set permission bits on an existing regular file without following links +/// +/// # Errors +/// +/// Returns an error when the path escapes through a link, is not a regular file, or cannot be +/// opened and updated through its stable descriptor +pub fn set_file_mode(path: &Path, mode: u32) -> io::Result<()> { + let file = open_regular_file(path)?; + file.set_permissions(fs::Permissions::from_mode(mode & 0o777)) +} + +pub(super) fn open_regular_file_at( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result { + let fd = openat2( + parent_fd, + file_name, + OFlags::RDONLY + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + let file = fs::File::from(fd); + if !file.metadata()?.is_file() { + return Err(unsafe_target_error()); + } + Ok(file) +} + +pub(super) fn revalidate_file_identity( + parent_fd: &OwnedFd, + file_name: &OsString, + file: &fs::File, +) -> io::Result<()> { + // The retained descriptor identifies the object that passed the earlier validation + let retained = fstat(file)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev && retained.st_ino == visible.st_ino { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "regular file changed before the filesystem operation", + )) +} + +pub(super) fn file_contents_equal(file: &mut fs::File, expected: &[u8]) -> io::Result { + let read_limit = u64::try_from(expected.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut actual = Vec::with_capacity(expected.len().saturating_add(1)); + file.take(read_limit).read_to_end(&mut actual)?; + Ok(actual == expected) +} + +pub(super) fn existing_target_mode( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result> { + match openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + ) { + Ok(fd) => { + let metadata = fs::File::from(fd).metadata()?; + if metadata.is_file() { + Ok(Some(metadata.permissions().mode() & 0o777)) + } else { + Err(unsafe_target_error()) + } + } + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(error.into()), + }, + } +} + +pub(super) fn validate_existing_target( + parent_fd: &OwnedFd, + file_name: &OsString, +) -> io::Result<()> { + let fd = openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + if fs::File::from(fd).metadata()?.is_file() { + Ok(()) + } else { + Err(unsafe_target_error()) + } +} + +pub(super) fn unsafe_target_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to operate on a non-regular file target", + ) +} + +fn limit_error(max_bytes: u64) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("regular file exceeds the {max_bytes}-byte limit"), + ) +} + +#[cfg(test)] +#[path = "tests/regular.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/remove.rs b/crates/unixnotis-core/src/filesystem/remove.rs new file mode 100644 index 000000000..282856ebd --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/remove.rs @@ -0,0 +1,377 @@ +//! Descriptor-relative removal through a private same-filesystem quarantine +//! +//! Each removal first claims the requested basename with an atomic rename. Validation then uses +//! the retained object descriptor inside the quarantine instead of reopening the visible path +//! +//! The final unlink remains pathname-based because Linux has no unlink-by-file-descriptor API. +//! The quarantine directory must therefore be inaccessible to hostile same-UID writers when the +//! caller needs protection beyond the normal other-UID filesystem boundary + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; + +use super::descriptor::open_parent_existing; +use super::quarantine::{Quarantine, QuarantinedEntry}; +use super::regular::{file_contents_equal, open_regular_file_at, revalidate_file_identity}; +use super::symlink::{open_symlink_at, read_symlink_at, revalidate_symlink_identity}; + +/// Result of removing a symbolic link with an expected target +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveSymlinkOutcome { + /// No filesystem entry existed at the requested path + Missing, + /// A matching symbolic link was removed + Removed, + /// The link remained because its stored target no longer matched + TargetMismatch(PathBuf), +} + +/// Result of conditionally removing one exact regular file +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoveExactFileOutcome { + /// The requested file or its required marker was absent + Missing, + /// One retained file did not contain the authorized bytes + ContentsMismatch, + /// Every retained file matched and the requested entries were removed + Removed, +} + +/// Remove a regular file without following links in its path +/// +/// The source basename is first moved into a private mode-0700 directory on the same filesystem. +/// Identity checks and physical unlinking then use the retained quarantine directory descriptor +/// rather than a visible claim pathname +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target changes during quarantine, the +/// target is not a regular file, or quarantine cleanup cannot complete +pub fn remove_regular_file(path: &Path) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(false); + }; + let file = match open_regular_file_at(&parent_fd, &file_name) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(false); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + + let result = + unlink_regular_entry(&quarantine, &entry, &parent_fd, &file_name, &file).map(|()| true); + finish_quarantine(quarantine, &parent_fd, result) +} + +/// Remove two same-directory regular files only when both retained payloads match +/// +/// Both names are quarantined before either entry is physically unlinked. A mismatch leaves the +/// quarantined entry in place or restores it without deleting a replacement basename +/// +/// # Errors +/// +/// Returns an error when paths have different parents, path traversal is unsafe, either target is +/// not a regular file, retained identities change, or durable quarantine cleanup fails +pub fn remove_regular_file_pair_if_contents( + path: &Path, + expected_contents: &[u8], + marker_path: &Path, + expected_marker_contents: &[u8], +) -> io::Result { + if path.parent() != marker_path.parent() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "guarded files must share one parent directory", + )); + } + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(RemoveExactFileOutcome::Missing); + }; + let marker_name = marker_path + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "marker has no file name"))? + .to_os_string(); + + let mut file = match open_regular_file_at(&parent_fd, &file_name) { + Ok(file) => file, + Err(error) if file_lookup_is_missing(&error) => return Ok(RemoveExactFileOutcome::Missing), + Err(error) => return Err(error), + }; + let mut marker = match open_regular_file_at(&parent_fd, &marker_name) { + Ok(marker) => marker, + Err(error) if file_lookup_is_missing(&error) => return Ok(RemoveExactFileOutcome::Missing), + Err(error) => return Err(error), + }; + if !file_contents_equal(&mut file, expected_contents)? + || !file_contents_equal(&mut marker, expected_marker_contents)? + { + return Ok(RemoveExactFileOutcome::ContentsMismatch); + } + + let quarantine = Quarantine::create(&parent_fd)?; + let marker_entry = match quarantine.move_entry(&parent_fd, &marker_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(RemoveExactFileOutcome::Missing); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), marker_entry.name(), &marker) { + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + + let file_entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let result = match quarantine.restore(&marker_entry, &parent_fd, &marker_name) { + Ok(()) => Ok(RemoveExactFileOutcome::Missing), + Err(restore_error) => { + Err(combine_operation_and_restore_error(&error, &restore_error)) + } + }; + return finish_quarantine(quarantine, &parent_fd, result); + } + Err(error) => { + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), file_entry.name(), &file) { + let error = restore_entry_or_error(&quarantine, &file_entry, &parent_fd, &file_name, error); + let error = + restore_entry_or_error(&quarantine, &marker_entry, &parent_fd, &marker_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + + // The marker is removed first to preserve the existing ownership protocol + if let Err(error) = unlink_regular_entry( + &quarantine, + &marker_entry, + &parent_fd, + &marker_name, + &marker, + ) { + let error = restore_entry_or_error(&quarantine, &file_entry, &parent_fd, &file_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + let result = unlink_regular_entry(&quarantine, &file_entry, &parent_fd, &file_name, &file) + .map(|()| RemoveExactFileOutcome::Removed); + finish_quarantine(quarantine, &parent_fd, result) +} + +/// Remove a symbolic link without requiring a specific target +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a symbolic link, the +/// quarantined identity changes, or quarantine cleanup fails +pub fn remove_symlink(path: &Path) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(false); + }; + let link = match open_symlink_at(&parent_fd, &file_name) { + Ok(link) => link, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(false); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + let result = + unlink_symlink_entry(&quarantine, &entry, &parent_fd, &file_name, &link).map(|()| true); + finish_quarantine(quarantine, &parent_fd, result) +} + +/// Remove a symbolic link only when its stored target matches exactly +/// +/// # Errors +/// +/// Returns an error when a path component is unsafe, the target is not a symbolic link, the +/// quarantined identity changes, or quarantine cleanup fails +pub fn remove_symlink_if_target( + path: &Path, + expected_target: &Path, +) -> io::Result { + let Some((parent_fd, file_name)) = existing_parent(path)? else { + return Ok(RemoveSymlinkOutcome::Missing); + }; + let link = match open_symlink_at(&parent_fd, &file_name) { + Ok(link) => link, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RemoveSymlinkOutcome::Missing), + _ => return Err(error), + }, + }; + let actual_target = match read_symlink_at(&parent_fd, &file_name) { + Ok(target) => target, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RemoveSymlinkOutcome::Missing), + _ => return Err(error), + }, + }; + if actual_target != expected_target { + return Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)); + } + + let quarantine = Quarantine::create(&parent_fd)?; + let entry = match quarantine.move_entry(&parent_fd, &file_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&parent_fd); + return Ok(RemoveSymlinkOutcome::Missing); + } + Err(error) => { + let _ = quarantine.cleanup(&parent_fd); + return Err(error); + } + }; + let quarantined_target = match read_symlink_at(quarantine.fd(), entry.name()) { + Ok(target) => target, + Err(error) => { + let error = restore_entry_or_error(&quarantine, &entry, &parent_fd, &file_name, error); + return finish_quarantine(quarantine, &parent_fd, Err(error)); + } + }; + if quarantined_target != expected_target { + let mismatch_error = io::Error::new( + io::ErrorKind::InvalidInput, + "symbolic-link target changed during quarantine", + ); + let result = match quarantine.restore(&entry, &parent_fd, &file_name) { + Ok(()) => Ok(RemoveSymlinkOutcome::TargetMismatch(quarantined_target)), + Err(restore_error) => Err(combine_operation_and_restore_error( + &mismatch_error, + &restore_error, + )), + }; + return finish_quarantine(quarantine, &parent_fd, result); + } + + let result = unlink_symlink_entry(&quarantine, &entry, &parent_fd, &file_name, &link) + .map(|()| RemoveSymlinkOutcome::Removed); + finish_quarantine(quarantine, &parent_fd, result) +} + +fn unlink_regular_entry( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + file: &std::fs::File, +) -> io::Result<()> { + // Revalidate the object after the atomic claim so a failed claim never authorizes a new file + revalidate_file_identity(quarantine.fd(), entry.name(), file).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + })?; + // The retained quarantine descriptor pins the parent; the final basename is still resolved + // by unlinkat, so a hostile writer must not control this private directory + quarantine.unlink(entry).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + }) +} + +fn unlink_symlink_entry( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + link: &std::os::fd::OwnedFd, +) -> io::Result<()> { + // Symlink identity is checked without following the stored target + revalidate_symlink_identity(quarantine.fd(), entry.name(), link).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + })?; + // As with regular files, unlinkat protects the parent directory but not a hostile same-UID + // replacement of the final quarantine basename between validation and the syscall + quarantine.unlink(entry).map_err(|error| { + restore_entry_or_error(quarantine, entry, source_parent, source_name, error) + }) +} + +fn restore_entry_or_error( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + source_parent: &std::os::fd::OwnedFd, + source_name: &OsString, + operation_error: io::Error, +) -> io::Error { + match quarantine.restore(entry, source_parent, source_name) { + Ok(()) => operation_error, + Err(restore_error) => combine_operation_and_restore_error(&operation_error, &restore_error), + } +} + +fn finish_quarantine( + quarantine: Quarantine, + parent_fd: &std::os::fd::OwnedFd, + result: io::Result, +) -> io::Result { + let cleanup = quarantine.cleanup(parent_fd); + match result { + Ok(value) => { + cleanup?; + Ok(value) + } + Err(error) => match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(combine_operation_and_restore_error(&error, &cleanup_error)), + }, + } +} + +fn combine_operation_and_restore_error( + operation_error: &io::Error, + restore_error: &io::Error, +) -> io::Error { + io::Error::new( + operation_error.kind(), + format!("{operation_error}; failed to restore quarantine entry: {restore_error}"), + ) +} + +fn existing_parent(path: &Path) -> io::Result> { + // The optional form keeps idempotent removal separate from unsafe-shape failures + match open_parent_existing(path) { + Ok(parent) => Ok(Some(parent)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn file_lookup_is_missing(error: &io::Error) -> bool { + // Missing exact-pair members are idempotent while every other error fails closed + error.kind() == io::ErrorKind::NotFound +} + +#[cfg(test)] +#[path = "tests/remove.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/rename.rs b/crates/unixnotis-core/src/filesystem/rename.rs new file mode 100644 index 000000000..def7ac89c --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/rename.rs @@ -0,0 +1,282 @@ +//! No-replace regular-file moves through stable parent descriptors + +use std::io; +use std::path::Path; + +use rustix::fs::{renameat_with, RenameFlags}; + +use super::descriptor::{open_parent_existing, open_target_directory, sync_directory}; +use super::quarantine::{Quarantine, QuarantinedEntry}; +use super::regular::{open_regular_file_at, revalidate_file_identity}; +use super::tree::revalidate_directory_identity; + +/// Result of moving a regular file without replacing another filesystem entry +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenameRegularFileOutcome { + /// The source did not exist when the move reached the filesystem boundary + SourceMissing, + /// The source was moved to the previously unused destination + Renamed, + /// A destination entry already existed and was preserved + DestinationExists, +} + +/// Result of moving a directory without replacing another filesystem entry +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenameDirectoryOutcome { + /// The source did not exist when the move reached the filesystem boundary + SourceMissing, + /// The source was moved to the previously unused destination + Renamed, + /// A destination entry already existed and was preserved + DestinationExists, +} + +/// Move a regular file without following links or replacing the destination +/// +/// # Errors +/// +/// Returns an error when either parent crosses a link, the source is not a regular file, or the +/// rename and directory synchronization cannot complete +pub fn rename_regular_file_no_replace( + source: &Path, + destination: &Path, +) -> io::Result { + let (source_parent, source_name) = match open_parent_existing(source) { + Ok(parent) => parent, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), + _ => return Err(error), + }, + }; + // Retain the validated source so a replacement basename cannot be claimed + let source_file = match open_regular_file_at(&source_parent, &source_name) { + Ok(file) => file, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(RenameRegularFileOutcome::SourceMissing), + _ => return Err(error), + }, + }; + + let (destination_parent, destination_name) = open_parent_existing(destination)?; + // Claim the source basename before publication so no later operation uses a watched source + // name to identify the object being moved + let quarantine = Quarantine::create(&source_parent)?; + let entry = match quarantine.move_entry(&source_parent, &source_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&source_parent); + return Ok(RenameRegularFileOutcome::SourceMissing); + } + Err(error) => { + let _ = quarantine.cleanup(&source_parent); + return Err(error); + } + }; + if let Err(error) = revalidate_file_identity(quarantine.fd(), entry.name(), &source_file) { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + + // Rename the quarantined entry itself so sparse data, metadata, ACLs, timestamps, and hard + // links survive without copy-delete behavior + // The quarantine descriptor pins the parent, while the entry name still relies on the private + // directory boundary described by the quarantine module + // The directory descriptor pins the quarantine parent; the final entry name remains a path + // and therefore uses the same private-directory trust boundary + let rename_result = renameat_with( + quarantine.fd(), + entry.name(), + &destination_parent, + &destination_name, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into); + let outcome = match classify_rename_attempt(rename_result) { + Ok(outcome) => outcome, + Err(error) => { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + }; + if outcome != RenameRegularFileOutcome::Renamed { + let result = quarantine + .restore(&entry, &source_parent, &source_name) + .map(|()| outcome) + .map_err(|error| { + restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ) + }); + return finish_quarantine(quarantine, &source_parent, result); + } + + // Both final directory entries must reach durable storage + let result = sync_directory(&destination_parent) + .and(sync_directory(&source_parent)) + .map(|()| RenameRegularFileOutcome::Renamed); + finish_quarantine(quarantine, &source_parent, result) +} + +/// Move a directory without following links or replacing the destination +/// +/// # Errors +/// +/// Returns an error when either parent crosses a link, the source is not a directory, or the +/// rename and directory synchronization cannot complete +pub fn rename_directory_no_replace( + source: &Path, + destination: &Path, +) -> io::Result { + let Some((source_parent, source_name, source_directory)) = open_target_directory(source)? + else { + return Ok(RenameDirectoryOutcome::SourceMissing); + }; + let (destination_parent, destination_name) = open_parent_existing(destination)?; + // Claim the staged directory basename before publication for the same reason as regular files + let quarantine = Quarantine::create(&source_parent)?; + let entry = match quarantine.move_entry(&source_parent, &source_name) { + Ok(entry) => entry, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let _ = quarantine.cleanup(&source_parent); + return Ok(RenameDirectoryOutcome::SourceMissing); + } + Err(error) => { + let _ = quarantine.cleanup(&source_parent); + return Err(error); + } + }; + if let Err(error) = + revalidate_directory_identity(quarantine.fd(), entry.name(), &source_directory) + { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + + let rename_result = renameat_with( + quarantine.fd(), + entry.name(), + &destination_parent, + &destination_name, + RenameFlags::NOREPLACE, + ) + .map_err(Into::into); + let outcome = match classify_directory_rename_attempt(rename_result) { + Ok(outcome) => outcome, + Err(error) => { + let error = restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ); + return finish_quarantine(quarantine, &source_parent, Err(error)); + } + }; + if outcome != RenameDirectoryOutcome::Renamed { + let result = quarantine + .restore(&entry, &source_parent, &source_name) + .map(|()| outcome) + .map_err(|error| { + restore_quarantined_entry_or_error( + &quarantine, + &entry, + &source_parent, + &source_name, + error, + ) + }); + return finish_quarantine(quarantine, &source_parent, result); + } + + let result = sync_directory(&destination_parent) + .and(sync_directory(&source_parent)) + .map(|()| RenameDirectoryOutcome::Renamed); + finish_quarantine(quarantine, &source_parent, result) +} + +fn restore_quarantined_entry_or_error( + quarantine: &Quarantine, + entry: &QuarantinedEntry, + parent_fd: &std::os::fd::OwnedFd, + claimed_name: &std::ffi::OsString, + operation_error: io::Error, +) -> io::Error { + match quarantine.restore(entry, parent_fd, claimed_name) { + Ok(()) => operation_error, + Err(restore_error) => io::Error::new( + operation_error.kind(), + format!("{operation_error}; failed to restore quarantine entry: {restore_error}"), + ), + } +} + +fn finish_quarantine( + quarantine: Quarantine, + parent_fd: &std::os::fd::OwnedFd, + result: io::Result, +) -> io::Result { + let cleanup = quarantine.cleanup(parent_fd); + match result { + Ok(value) => { + cleanup?; + Ok(value) + } + Err(error) => match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(io::Error::new( + error.kind(), + format!("{error}; failed to clean up quarantine: {cleanup_error}"), + )), + }, + } +} + +fn classify_rename_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(RenameRegularFileOutcome::Renamed), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(RenameRegularFileOutcome::DestinationExists), + io::ErrorKind::NotFound => Ok(RenameRegularFileOutcome::SourceMissing), + _ => Err(error), + }, + } +} + +fn classify_directory_rename_attempt(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(RenameDirectoryOutcome::Renamed), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(RenameDirectoryOutcome::DestinationExists), + io::ErrorKind::NotFound => Ok(RenameDirectoryOutcome::SourceMissing), + _ => Err(error), + }, + } +} + +#[cfg(test)] +#[path = "tests/rename.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/filesystem/symlink.rs b/crates/unixnotis-core/src/filesystem/symlink.rs new file mode 100644 index 000000000..1fcb241ce --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/symlink.rs @@ -0,0 +1,230 @@ +//! Symbolic-link inspection and publication through stable parent descriptors + +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use rustix::fs::{ + fstat, openat2, readlinkat, renameat, statat, symlinkat, unlinkat, AtFlags, FileType, Mode, + OFlags, +}; + +use super::atomic::temp_candidates; +use super::descriptor::{ + contained_resolve_flags, open_parent, open_parent_existing, sync_directory, +}; + +/// Result of creating a symbolic link without replacing an existing path +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateSymlinkOutcome { + /// A new link was created + Created, + /// The existing link already stored the requested target + Unchanged, + /// A different link target was preserved + TargetMismatch(PathBuf), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SymlinkCreateAttempt { + Created, + Collision, +} + +/// Create a symbolic link while preserving every existing path +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, the destination is an existing non-link, or link +/// creation and parent-directory synchronization fail +pub fn create_symlink_if_missing(path: &Path, target: &Path) -> io::Result { + // Parent creation and lookup stay beneath one no-follow directory walk + let (parent_fd, file_name) = open_parent(path)?; + match read_symlink_at(&parent_fd, &file_name) { + // Exact links are idempotent and avoid a new directory entry + Ok(existing) => return Ok(existing_link_outcome(existing, target)), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => {} + _ => return Err(error), + }, + } + + let create_result = symlinkat(target, &parent_fd, &file_name).map_err(Into::into); + match classify_symlink_creation(create_result)? { + SymlinkCreateAttempt::Created => { + sync_directory(&parent_fd)?; + Ok(CreateSymlinkOutcome::Created) + } + SymlinkCreateAttempt::Collision => { + // A concurrent creator is accepted only when it published the requested link + let existing = read_symlink_at(&parent_fd, &file_name)?; + Ok(existing_link_outcome(existing, target)) + } + } +} + +fn classify_symlink_creation(result: io::Result<()>) -> io::Result { + match result { + Ok(()) => Ok(SymlinkCreateAttempt::Created), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => Ok(SymlinkCreateAttempt::Collision), + _ => Err(error), + }, + } +} + +/// Atomically create or replace a symbolic link +/// +/// Existing non-link destinations are rejected. A matching existing link is left untouched +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, an existing destination is not a symbolic link, +/// or temporary-link creation, revalidation, rename, cleanup, or synchronization fails +pub fn replace_symlink_atomic(path: &Path, target: &Path) -> io::Result { + let (parent_fd, file_name) = open_parent(path)?; + match read_symlink_at(&parent_fd, &file_name) { + Ok(existing) => match existing_link_outcome(existing, target) { + CreateSymlinkOutcome::Unchanged => return Ok(false), + CreateSymlinkOutcome::TargetMismatch(_) => {} + CreateSymlinkOutcome::Created => unreachable!("existing links cannot be newly created"), + }, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => {} + _ => return Err(error), + }, + } + + // The replacement is prepared under an exclusive sibling name + let temp_name = reserve_temp_symlink(&parent_fd, temp_candidates(&file_name), target)?; + // Revalidation prevents known non-link targets from being overwritten + if let Err(error) = validate_symlink_or_missing(&parent_fd, &file_name) { + let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + return Err(error); + } + // One rename publishes the complete link without an absent-target window + if let Err(error) = renameat(&parent_fd, &temp_name, &parent_fd, &file_name) { + let _ = unlinkat(&parent_fd, &temp_name, AtFlags::empty()); + return Err(error.into()); + } + sync_directory(&parent_fd)?; + Ok(true) +} + +/// Read a symbolic link target without following links in its parent path +/// +/// # Errors +/// +/// Returns an error when a parent crosses a link, the target is not a symbolic link, or the link +/// cannot be read +pub fn read_symlink(path: &Path) -> io::Result> { + // Inspection never creates a missing parent directory + let (parent_fd, file_name) = match open_parent_existing(path) { + Ok(parent) => parent, + Err(error) => match error.kind() { + io::ErrorKind::NotFound => return Ok(None), + _ => return Err(error), + }, + }; + match read_symlink_at(&parent_fd, &file_name) { + Ok(target) => Ok(Some(target)), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(None), + _ => Err(error), + }, + } +} + +pub(super) fn read_symlink_at(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Result { + let target = readlinkat(parent_fd, file_name, Vec::new())?; + Ok(PathBuf::from(OsString::from_vec(target.into_bytes()))) +} + +pub(super) fn open_symlink_at(parent_fd: &OwnedFd, file_name: &OsString) -> io::Result { + // O_PATH plus NOFOLLOW retains the link itself instead of opening its target + let fd = openat2( + parent_fd, + file_name, + OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), + Mode::empty(), + contained_resolve_flags(), + )?; + let stat = fstat(&fd)?; + if FileType::from_raw_mode(stat.st_mode).is_symlink() { + return Ok(fd); + } + Err(not_symlink_error()) +} + +pub(super) fn revalidate_symlink_identity( + parent_fd: &OwnedFd, + file_name: &OsString, + link: &OwnedFd, +) -> io::Result<()> { + // Compare the retained link object with the visible basename immediately before unlinking + let retained = fstat(link)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev + && retained.st_ino == visible.st_ino + && FileType::from_raw_mode(visible.st_mode).is_symlink() + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "symbolic link changed before removal", + )) +} + +fn not_symlink_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "refusing to operate on a non-symbolic-link target", + ) +} + +fn reserve_temp_symlink( + parent_fd: &OwnedFd, + candidates: impl IntoIterator, + target: &Path, +) -> io::Result { + // Exclusive candidates make planted temporary names harmless collisions + for temp_name in candidates { + match symlinkat(target, parent_fd, &temp_name) { + Ok(()) => return Ok(temp_name), + Err(error) => match error.kind() { + io::ErrorKind::AlreadyExists => continue, + _ => return Err(error.into()), + }, + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "unable to reserve an exclusive temporary symbolic link", + )) +} + +fn validate_symlink_or_missing(parent_fd: &OwnedFd, file_name: &OsStr) -> io::Result<()> { + // Regular files, directories, and special objects fail through readlinkat + match read_symlink_at(parent_fd, file_name) { + Ok(_target) => Ok(()), + Err(error) => match error.kind() { + io::ErrorKind::NotFound => Ok(()), + _ => Err(error), + }, + } +} + +fn existing_link_outcome(existing: PathBuf, target: &Path) -> CreateSymlinkOutcome { + if existing == target { + CreateSymlinkOutcome::Unchanged + } else { + CreateSymlinkOutcome::TargetMismatch(existing) + } +} + +#[cfg(test)] +#[path = "tests/symlink.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/tests/filesystem/atomic.rs b/crates/unixnotis-core/src/filesystem/tests/atomic.rs similarity index 76% rename from crates/unixnotis-core/src/tests/filesystem/atomic.rs rename to crates/unixnotis-core/src/filesystem/tests/atomic.rs index b4c727270..09fa3da4d 100644 --- a/crates/unixnotis-core/src/tests/filesystem/atomic.rs +++ b/crates/unixnotis-core/src/filesystem/tests/atomic.rs @@ -1,6 +1,8 @@ +//! Atomic file operation tests + use super::{ - anchor_resolve_flags, contained_resolve_flags, file_mode, make_file_executable, open_parent, - reserve_temp, sync_directory, write_file_atomic, write_file_if_missing, + file_mode, reserve_temp, write_file_atomic, write_file_atomic_preserving_mode, + write_file_if_missing, }; use std::ffi::OsString; use std::fs; @@ -9,8 +11,9 @@ use std::os::fd::OwnedFd; use std::os::unix::fs::{symlink, PermissionsExt}; use std::os::unix::net::UnixStream; -use rustix::fs::{mkfifoat, Mode, ResolveFlags, CWD}; +use rustix::fs::{mkfifoat, Mode, CWD}; +use crate::filesystem::descriptor::{open_parent, sync_directory}; use crate::test_support::unique_temp_path; #[test] @@ -127,39 +130,29 @@ fn create_if_missing_rejects_every_unsafe_existing_target() { } #[test] -fn executable_update_rejects_symlink_without_touching_its_target() { - let root = unique_temp_path("atomic-executable-symlink"); +fn create_if_missing_propagates_non_collision_open_error() { + let root = unique_temp_path("atomic-if-missing-error"); fs::create_dir_all(&root).expect("create test root"); - let outside = root.join("outside.sh"); - let link = root.join("script.sh"); - fs::write(&outside, "safe").expect("write outside script"); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); - symlink(&outside, &link).expect("create script link"); + let long_name = "x".repeat(300); - make_file_executable(&link).expect_err("script link should fail"); + let error = write_file_if_missing(&root.join(long_name), b"data", 0o600) + .expect_err("overlong target name should fail"); - assert_eq!(fs::read_to_string(&outside).expect("read outside"), "safe"); - assert_eq!( - fs::metadata(&outside) - .expect("outside metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); + assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); let _ = fs::remove_dir_all(root); } #[test] -fn create_if_missing_propagates_non_collision_open_error() { - let root = unique_temp_path("atomic-if-missing-error"); +fn create_if_missing_preserves_non_directory_parent_errors() { + let root = unique_temp_path("atomic-if-missing-parent-file"); fs::create_dir_all(&root).expect("create test root"); - let long_name = "x".repeat(300); + let parent_file = root.join("parent-file"); + fs::write(&parent_file, "not a directory").expect("write parent file"); - let error = write_file_if_missing(&root.join(long_name), b"data", 0o600) - .expect_err("overlong target name should fail"); + let error = write_file_if_missing(&parent_file.join("state"), b"data", 0o600) + .expect_err("regular-file parent must reject creation"); - assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); let _ = fs::remove_dir_all(root); } @@ -183,7 +176,7 @@ fn directory_sync_propagates_invalid_descriptor_type() { let (stream, _peer) = UnixStream::pair().expect("create socket pair"); let fd: OwnedFd = stream.into(); - let error = sync_directory(fd).expect_err("socket cannot be synchronized as a directory"); + let error = sync_directory(&fd).expect_err("socket cannot be synchronized as a directory"); assert_ne!(error.kind(), std::io::ErrorKind::NotFound); } @@ -194,31 +187,33 @@ fn file_mode_masks_special_and_non_permission_bits() { } #[test] -fn contained_resolution_policy_keeps_every_escape_barrier() { - let flags = contained_resolve_flags(); - - assert!(flags.contains(ResolveFlags::BENEATH)); - assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); - assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); -} +fn atomic_write_replaces_regular_file_and_applies_requested_mode() { + let root = unique_temp_path("atomic-replace"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("state.json"); + fs::write(&target, "old").expect("write old file"); -#[test] -fn anchor_resolution_policy_rejects_link_detours() { - let flags = anchor_resolve_flags(); + write_file_atomic(&target, b"new", 0o600).expect("replace file"); - assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); - assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); - assert!(!flags.contains(ResolveFlags::BENEATH)); + assert_eq!(fs::read_to_string(&target).expect("read file"), "new"); + let mode = fs::metadata(&target) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_dir_all(root); } #[test] -fn atomic_write_replaces_regular_file_and_applies_requested_mode() { - let root = unique_temp_path("atomic-replace"); +fn preserving_atomic_write_keeps_existing_mode_and_replaces_contents() { + let root = unique_temp_path("atomic-preserve-mode"); fs::create_dir_all(&root).expect("create test root"); - let target = root.join("state.json"); + let target = root.join("config.toml"); fs::write(&target, "old").expect("write old file"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set old mode"); - write_file_atomic(&target, b"new", 0o600).expect("replace file"); + write_file_atomic_preserving_mode(&target, b"new", 0o644).expect("replace file"); assert_eq!(fs::read_to_string(&target).expect("read file"), "new"); let mode = fs::metadata(&target) @@ -229,3 +224,20 @@ fn atomic_write_replaces_regular_file_and_applies_requested_mode() { assert_eq!(mode, 0o600); let _ = fs::remove_dir_all(root); } + +#[test] +fn preserving_atomic_write_uses_default_mode_for_a_missing_file() { + let root = unique_temp_path("atomic-preserve-default"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("config.toml"); + + write_file_atomic_preserving_mode(&target, b"new", 0o640).expect("create file"); + + let mode = fs::metadata(&target) + .expect("file metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o640); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/descriptor.rs b/crates/unixnotis-core/src/filesystem/tests/descriptor.rs new file mode 100644 index 000000000..ba40d36f8 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/descriptor.rs @@ -0,0 +1,23 @@ +//! Descriptor traversal policy tests + +use rustix::fs::ResolveFlags; + +use super::{anchor_resolve_flags, contained_resolve_flags}; + +#[test] +fn contained_resolution_policy_keeps_every_escape_barrier() { + let flags = contained_resolve_flags(); + + assert!(flags.contains(ResolveFlags::BENEATH)); + assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); + assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); +} + +#[test] +fn anchor_resolution_policy_rejects_link_detours() { + let flags = anchor_resolve_flags(); + + assert!(flags.contains(ResolveFlags::NO_SYMLINKS)); + assert!(flags.contains(ResolveFlags::NO_MAGICLINKS)); + assert!(!flags.contains(ResolveFlags::BENEATH)); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/directory.rs b/crates/unixnotis-core/src/filesystem/tests/directory.rs new file mode 100644 index 000000000..cdd42253a --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/directory.rs @@ -0,0 +1,171 @@ +//! Directory creation, marker, and empty-removal tests + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use super::{ + create_directory_all, ensure_marked_directory, remove_empty_directory, validate_child_name, +}; +use crate::filesystem::descriptor::{classify_directory_creation, CreateDirectoryOutcome}; +use crate::test_support::unique_temp_path; + +#[test] +fn directory_creation_builds_missing_components_with_requested_mode() { + let root = unique_temp_path("create-directory-tree"); + let target = root.join("parent").join("child"); + + assert_eq!( + create_directory_all(&target, 0o750).expect("create directory tree"), + CreateDirectoryOutcome::TargetCreated + ); + assert_eq!( + create_directory_all(&target, 0o700).expect("existing directory stays unchanged"), + CreateDirectoryOutcome::TargetAlreadyExisted + ); + + for directory in [&root, &root.join("parent"), &target] { + assert_eq!( + fs::metadata(directory) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + 0o750 + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn ownership_marker_name_accepts_one_normal_component_only() { + validate_child_name(".owner".as_ref()).expect("plain marker name"); + + for invalid in ["", ".", "..", "nested/.owner", "/.owner"] { + validate_child_name(invalid.as_ref()).expect_err("invalid marker name must fail"); + } +} + +#[test] +fn directory_creation_rejects_a_linked_parent() { + let root = unique_temp_path("create-directory-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + symlink(&outside, &linked).expect("create parent link"); + + create_directory_all(&linked.join("child"), 0o755).expect_err("linked parent should fail"); + + assert!(!outside.join("child").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_creation_result_distinguishes_creation_collision_and_failure() { + assert!(classify_directory_creation(Ok(())).expect("successful mkdir should be new")); + assert!( + !classify_directory_creation(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("mkdir collision should be retried as existing") + ); + + let error = classify_directory_creation(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated mkdir failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[test] +fn marked_directory_refuses_to_adopt_an_unmarked_existing_target() { + let root = unique_temp_path("marked-directory-adoption"); + let target = root.join("service"); + fs::create_dir_all(&target).expect("create foreign directory"); + fs::write(target.join("foreign"), "keep").expect("write foreign child"); + + ensure_marked_directory(&target, 0o755, ".owner".as_ref(), b"owned\n", 0o644) + .expect_err("unmarked directory should not be adopted"); + + assert!(!target.join(".owner").exists()); + assert_eq!( + fs::read_to_string(target.join("foreign")).expect("read foreign child"), + "keep" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_directory_creation_and_reopen_share_one_ownership_contract() { + let root = unique_temp_path("marked-directory-create"); + let target = root.join("service"); + + assert_eq!( + ensure_marked_directory(&target, 0o750, ".owner".as_ref(), b"owned\n", 0o640) + .expect("create marked directory"), + CreateDirectoryOutcome::TargetCreated + ); + assert_eq!( + ensure_marked_directory(&target, 0o700, ".owner".as_ref(), b"owned\n", 0o600) + .expect("validate marked directory"), + CreateDirectoryOutcome::TargetAlreadyExisted + ); + assert_eq!( + fs::read_to_string(target.join(".owner")).expect("read marker"), + "owned\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_is_idempotent() { + let root = unique_temp_path("remove-empty-directory"); + let target = root.join("empty"); + fs::create_dir_all(&target).expect("create empty directory"); + + assert!(remove_empty_directory(&target).expect("remove empty directory")); + assert!(!remove_empty_directory(&target).expect("missing directory stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_rejects_nonempty_and_link_targets() { + let root = unique_temp_path("remove-empty-directory-shapes"); + let target = root.join("directory"); + let linked = root.join("linked"); + fs::create_dir_all(&target).expect("create target directory"); + fs::write(target.join("file"), "data").expect("write child"); + symlink(&target, &linked).expect("create directory link"); + + remove_empty_directory(&target).expect_err("nonempty directory should fail"); + remove_empty_directory(&linked).expect_err("directory link should fail"); + + assert!(target.join("file").exists()); + assert!(fs::symlink_metadata(linked) + .expect("link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_rejects_linked_ancestors_without_touching_target() { + let root = unique_temp_path("remove-empty-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(outside.join("empty")).expect("create outside directory"); + symlink(&outside, &linked).expect("create parent link"); + + remove_empty_directory(&linked.join("empty")).expect_err("linked parent should fail"); + + assert!(outside.join("empty").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn empty_directory_removal_does_not_create_missing_parents() { + let root = unique_temp_path("remove-empty-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("directory"); + + assert!(!remove_empty_directory(&target).expect("empty directory is missing")); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/exact.rs b/crates/unixnotis-core/src/filesystem/tests/exact.rs new file mode 100644 index 000000000..84827052c --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/exact.rs @@ -0,0 +1,309 @@ +//! Exact regular-file transaction tests + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use super::{ + ensure_exact_file, ensure_exact_file_pair, rollback_created_member, EnsureExactFileOutcome, + EnsureExactFilePairOutcome, ExactMember, +}; +use crate::filesystem::descriptor::open_parent_existing; +use crate::test_support::unique_temp_path; + +#[test] +fn exact_file_creation_accepts_only_identical_existing_bytes() { + let root = unique_temp_path("exact-file"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o644).expect("create exact file"), + EnsureExactFileOutcome::Created + ); + assert_eq!( + ensure_exact_file(&target, b"bundle\n", 0o600).expect("accept exact file"), + EnsureExactFileOutcome::AlreadyExact + ); + assert_eq!( + ensure_exact_file(&target, b"longrun\n", 0o644).expect("reject mismatched bytes"), + EnsureExactFileOutcome::ContentsMismatch + ); + + assert_eq!( + fs::read_to_string(&target).expect("read exact file"), + "bundle\n" + ); + assert_eq!( + fs::metadata(&target) + .expect("exact file metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_masks_non_permission_mode_bits() { + let root = unique_temp_path("exact-file-mode-mask"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + + ensure_exact_file(&target, b"bundle\n", 0o100_600).expect("create exact file"); + + assert_eq!( + fs::metadata(&target) + .expect("target metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_never_follows_a_collision_symlink() { + let root = unique_temp_path("exact-file-link"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let target = root.join("type"); + fs::write(&outside, "foreign").expect("write outside file"); + symlink(&outside, &target).expect("create exact-file link"); + + ensure_exact_file(&target, b"bundle\n", 0o644).expect_err("link collision should fail"); + + assert_eq!( + fs::read_to_string(outside).expect("read outside file"), + "foreign" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_file_creation_preserves_non_directory_parent_errors() { + let root = unique_temp_path("exact-file-blocked-parent"); + fs::create_dir_all(&root).expect("create test root"); + let parent_file = root.join("parent-file"); + fs::write(&parent_file, "not a directory").expect("write blocking parent"); + + let error = ensure_exact_file(&parent_file.join("state"), b"data", 0o600) + .expect_err("non-directory parent should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::NotADirectory); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_creates_and_validates_both_members() { + let root = unique_temp_path("exact-pair-create"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + + assert_eq!( + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o600,) + .expect("create exact pair"), + EnsureExactFilePairOutcome::Created + ); + assert_eq!( + ensure_exact_file_pair(&target, b"bundle\n", 0o640, &marker, b"unixnotis\n", 0o644,) + .expect("validate exact pair"), + EnsureExactFilePairOutcome::AlreadyExact + ); + assert_eq!( + fs::metadata(&target) + .expect("target metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert_eq!( + fs::metadata(&marker) + .expect("marker metadata") + .permissions() + .mode() + & 0o777, + 0o644 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_preserves_an_unmarked_exact_existing_file() { + let root = unique_temp_path("exact-pair-unmarked-file"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("preserve exact unmarked file"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::AlreadyExactUnowned); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rejects_an_invalid_marker_shape_for_an_existing_file() { + let root = unique_temp_path("exact-pair-invalid-marker"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + fs::create_dir(&marker).expect("create invalid marker directory"); + + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect_err("an invalid marker shape must not be treated as missing"); + + assert!(target.is_file()); + assert!(marker.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rolls_back_a_new_file_when_the_marker_conflicts() { + let root = unique_temp_path("exact-pair-marker-conflict"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&marker, b"foreign\n").expect("write foreign marker"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("report marker conflict"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::ContentsMismatch); + assert!(!target.exists()); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_preserves_an_existing_file_when_the_marker_conflicts() { + let root = unique_temp_path("exact-pair-existing-file-conflict"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("type"); + let marker = root.join(".created-type"); + fs::write(&target, b"bundle\n").expect("write exact existing file"); + fs::write(&marker, b"foreign\n").expect("write foreign marker"); + + let outcome = + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &marker, b"unixnotis\n", 0o644) + .expect("report marker conflict"); + + assert_eq!(outcome, EnsureExactFilePairOutcome::ContentsMismatch); + assert_eq!( + fs::read_to_string(target).expect("read existing file"), + "bundle\n" + ); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_rejects_different_parents_and_reused_names() { + let root = unique_temp_path("exact-pair-path-validation"); + fs::create_dir_all(root.join("other")).expect("create test roots"); + let target = root.join("type"); + + ensure_exact_file_pair( + &target, + b"bundle\n", + 0o644, + &root.join("other").join("marker"), + b"unixnotis\n", + 0o644, + ) + .expect_err("different parents should fail"); + ensure_exact_file_pair(&target, b"bundle\n", 0o644, &target, b"unixnotis\n", 0o644) + .expect_err("reused names should fail"); + + assert!(!target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_accepts_a_created_member_that_is_already_missing() { + let root = unique_temp_path("exact-rollback-missing"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("state"); + fs::write(&target, b"owned").expect("write target"); + let retained = fs::File::open(&target).expect("open retained target"); + let (parent_fd, file_name) = open_parent_existing(&target).expect("open retained parent"); + fs::remove_file(&target).expect("remove visible target"); + let member = ExactMember { + file: retained, + created: true, + }; + + rollback_created_member(&parent_fd, &file_name, &member) + .expect("an already absent created member needs no rollback"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_propagates_non_missing_lookup_errors() { + let root = unique_temp_path("exact-rollback-lookup-error"); + fs::create_dir_all(&root).expect("create test root"); + let retained_path = root.join("retained"); + fs::write(&retained_path, b"owned").expect("write retained file"); + let member = ExactMember { + file: fs::File::open(&retained_path).expect("open retained file"), + created: true, + }; + let (parent_fd, _file_name) = + open_parent_existing(&retained_path).expect("open retained parent"); + let oversized_name = OsString::from("x".repeat(1_024)); + + rollback_created_member(&parent_fd, &oversized_name, &member) + .expect_err("invalid lookup errors must not be treated as a missing target"); + + assert_eq!( + fs::read_to_string(retained_path).expect("read retained file"), + "owned" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rollback_preserves_a_same_device_replacement() { + let root = unique_temp_path("exact-rollback-replacement"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("state"); + let moved = root.join("original"); + fs::write(&target, b"owned").expect("write original target"); + let retained = fs::File::open(&target).expect("open retained target"); + let (parent_fd, file_name) = open_parent_existing(&target).expect("open retained parent"); + fs::rename(&target, &moved).expect("move original target"); + fs::write(&target, b"replacement").expect("write replacement target"); + let member = ExactMember { + file: retained, + created: true, + }; + + rollback_created_member(&parent_fd, &file_name, &member) + .expect_err("identity mismatch must stop rollback"); + + assert_eq!( + fs::read_to_string(target).expect("read replacement target"), + "replacement" + ); + assert_eq!( + fs::read_to_string(moved).expect("read original target"), + "owned" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/install.rs b/crates/unixnotis-core/src/filesystem/tests/install.rs new file mode 100644 index 000000000..a3155b1a7 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/install.rs @@ -0,0 +1,124 @@ +//! Atomic file installation tests + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use super::copy_file_atomic; +use crate::test_support::unique_temp_path; + +#[test] +fn atomic_copy_replaces_regular_file_and_preserves_source_mode() { + let root = unique_temp_path("copy-file-replace"); + let source = root.join("release").join("unixnotis-daemon"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(source.parent().expect("source parent")).expect("create source parent"); + fs::create_dir_all(destination.parent().expect("destination parent")) + .expect("create destination parent"); + fs::write(&source, "new binary").expect("write source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o751)).expect("set source mode"); + fs::write(&destination, "old binary").expect("write destination"); + + copy_file_atomic(&source, &destination).expect("copy regular file"); + + assert_eq!( + fs::read_to_string(&destination).expect("read destination"), + "new binary" + ); + assert_eq!( + fs::metadata(&destination) + .expect("destination metadata") + .permissions() + .mode() + & 0o777, + 0o751 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_source_symlink_without_publishing_destination() { + let root = unique_temp_path("copy-file-source-symlink"); + let source_target = root.join("source-target"); + let source_link = root.join("source-link"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source_target, "source").expect("write source target"); + symlink(&source_target, &source_link).expect("create source link"); + + copy_file_atomic(&source_link, &destination).expect_err("source link should fail"); + + assert!(!destination.exists()); + assert_eq!( + fs::read_to_string(source_target).expect("read source target"), + "source" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_destination_symlink_without_changing_its_target() { + let root = unique_temp_path("copy-file-destination-symlink"); + let source = root.join("source"); + let protected = root.join("protected"); + let destination = root.join("destination"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "source").expect("write source"); + fs::write(&protected, "protected").expect("write protected"); + symlink(&protected, &destination).expect("create destination link"); + + copy_file_atomic(&source, &destination).expect_err("destination link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected"), + "protected" + ); + assert!(fs::symlink_metadata(destination) + .expect("destination link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_symlinked_destination_parent() { + let root = unique_temp_path("copy-file-parent-symlink"); + let source = root.join("source"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-bin"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(&source, "source").expect("write source"); + symlink(&outside, &linked_parent).expect("create parent link"); + let destination = linked_parent.join("unixnotis-daemon"); + + copy_file_atomic(&source, &destination).expect_err("linked parent should fail"); + + assert!(!outside.join("unixnotis-daemon").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_rejects_directory_source_without_creating_destination() { + let root = unique_temp_path("copy-file-directory-source"); + let source = root.join("source-directory"); + let destination = root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(&source).expect("create source directory"); + + copy_file_atomic(&source, &destination).expect_err("directory source should fail"); + + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_copy_does_not_create_a_missing_source_parent() { + let root = unique_temp_path("copy-file-missing-source-parent"); + let missing_parent = root.join("missing-source"); + let source = missing_parent.join("unixnotis-daemon"); + let destination = root.join("bin").join("unixnotis-daemon"); + + copy_file_atomic(&source, &destination).expect_err("missing source should fail"); + + assert!(!missing_parent.exists()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/path.rs b/crates/unixnotis-core/src/filesystem/tests/path.rs new file mode 100644 index 000000000..178b94136 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/path.rs @@ -0,0 +1,73 @@ +//! Lexical and contained path tests + +use std::path::{Path, PathBuf}; + +use proptest::prelude::*; + +use crate::filesystem::{ContainedPath, LexicalPathError, LexicallyNormalizedPath}; + +#[test] +fn lexical_normalization_removes_current_and_internal_parent_components() { + let path = LexicallyNormalizedPath::new("/srv/unixnotis/./scripts/old/../probe") + .expect("normalize contained path"); + + assert_eq!(path.as_path(), Path::new("/srv/unixnotis/scripts/probe")); +} + +#[test] +fn lexical_normalization_can_transfer_owned_path_storage() { + let path = LexicallyNormalizedPath::new("scripts/old/../probe") + .expect("normalize owned path") + .into_path_buf(); + + assert_eq!(path, PathBuf::from("scripts/probe")); +} + +#[test] +fn lexical_normalization_rejects_parent_escape() { + assert_eq!( + LexicallyNormalizedPath::new("../../outside"), + Err(LexicalPathError::ParentEscape) + ); + assert_eq!( + LexicallyNormalizedPath::new("/../../outside"), + Err(LexicalPathError::ParentEscape) + ); +} + +#[test] +fn contained_paths_reject_absolute_and_relative_escape() { + let root = Path::new("/srv/unixnotis"); + + assert_eq!( + ContainedPath::resolve_relative(root, "/tmp/outside"), + Err(LexicalPathError::ExpectedRelative) + ); + assert_eq!( + ContainedPath::resolve_relative(root, "../outside"), + Err(LexicalPathError::OutsideRoot) + ); +} + +proptest! { + #[test] + fn normalization_is_idempotent(parts in prop::collection::vec("[a-z]{1,8}", 0..12)) { + let path = parts.iter().collect::(); + let once = LexicallyNormalizedPath::new(&path).expect("normalize generated path"); + let twice = LexicallyNormalizedPath::new(once.as_path()).expect("normalize normalized path"); + + prop_assert_eq!(once, twice); + } + + #[test] + fn resolved_relative_paths_remain_beneath_root( + parts in prop::collection::vec("[a-z]{1,8}", 0..12) + ) { + let relative = parts.iter().collect::(); + let resolved = ContainedPath::resolve_relative("/srv/unixnotis", relative) + .expect("resolve generated path"); + + prop_assert!(resolved.absolute().starts_with(resolved.root())); + prop_assert!(!resolved.relative().is_absolute()); + } +} diff --git a/crates/unixnotis-core/src/filesystem/tests/quarantine.rs b/crates/unixnotis-core/src/filesystem/tests/quarantine.rs new file mode 100644 index 000000000..8ccffa8e2 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/quarantine.rs @@ -0,0 +1,62 @@ +//! Private quarantine lifecycle tests + +use std::fs; + +use super::Quarantine; +use crate::filesystem::descriptor::open_parent_existing; +use crate::test_support::unique_temp_path; + +#[test] +fn quarantine_moves_and_restores_an_entry_without_following_a_new_source_name() { + let root = unique_temp_path("quarantine-restore"); + let source = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original"); + + let (parent_fd, source_name) = open_parent_existing(&source).expect("open source parent"); + let quarantine = Quarantine::create(&parent_fd).expect("create quarantine"); + let entry = quarantine + .move_entry(&parent_fd, &source_name) + .expect("move source into quarantine"); + fs::write(&source, "replacement").expect("write replacement source"); + + quarantine + .restore(&entry, &parent_fd, &source_name) + .expect_err("restore must not replace an unrelated destination"); + fs::remove_file(&source).expect("remove test replacement"); + quarantine + .restore(&entry, &parent_fd, &source_name) + .expect("restore original source name"); + + assert_eq!( + fs::read_to_string(&source).expect("read restored source"), + "original" + ); + quarantine + .cleanup(&parent_fd) + .expect("remove empty quarantine"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn quarantine_keeps_an_entry_when_cleanup_is_not_requested() { + let root = unique_temp_path("quarantine-retain"); + let source = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original"); + + let (parent_fd, source_name) = open_parent_existing(&source).expect("open source parent"); + let quarantine = Quarantine::create(&parent_fd).expect("create quarantine"); + let entry = quarantine + .move_entry(&parent_fd, &source_name) + .expect("move source into quarantine"); + quarantine + .unlink(&entry) + .expect("unlink quarantined source"); + quarantine + .cleanup(&parent_fd) + .expect("remove empty quarantine"); + + assert!(!source.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/regular.rs b/crates/unixnotis-core/src/filesystem/tests/regular.rs new file mode 100644 index 000000000..c4329e72d --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/regular.rs @@ -0,0 +1,229 @@ +//! Stable regular-file operation tests + +use std::fs; +use std::io::Read; +use std::os::unix::fs::{symlink, PermissionsExt}; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{ + make_file_executable, open_regular_file, read_regular_file_bounded, + regular_file_contents_equal, set_file_mode, +}; +use crate::test_support::unique_temp_path; + +#[test] +fn bounded_comparison_accepts_exact_bytes_and_rejects_larger_files() { + let root = unique_temp_path("regular-bounded-comparison"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("service"); + fs::write(&target, b"bundle\n").expect("write exact file"); + + assert!( + regular_file_contents_equal(&target, b"bundle\n", 7).expect("compare exact regular file") + ); + assert!(regular_file_contents_equal(&target, b"bundle\n", 8) + .expect("compare regular file below the maximum")); + assert!( + !regular_file_contents_equal(&target, b"bundle", 6).expect("reject oversized regular file") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_comparison_rejects_a_limit_smaller_than_expected_bytes() { + let root = unique_temp_path("regular-invalid-comparison-limit"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("service"); + fs::write(&target, b"bundle\n").expect("write exact file"); + + let error = regular_file_contents_equal(&target, b"bundle\n", 6) + .expect_err("invalid comparison limit should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_comparison_rejects_links_and_special_files_without_blocking() { + let root = unique_temp_path("regular-unsafe-comparison"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let link = root.join("link"); + let fifo = root.join("fifo"); + fs::write(&outside, b"bundle\n").expect("write outside file"); + symlink(&outside, &link).expect("create comparison link"); + mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create comparison fifo"); + + regular_file_contents_equal(&link, b"bundle\n", 7).expect_err("comparison link should fail"); + regular_file_contents_equal(&fifo, b"bundle\n", 7).expect_err("comparison fifo should fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn executable_update_rejects_symlink_without_touching_its_target() { + let root = unique_temp_path("regular-executable-symlink"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside.sh"); + let link = root.join("script.sh"); + fs::write(&outside, "safe").expect("write outside script"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); + symlink(&outside, &link).expect("create script link"); + + make_file_executable(&link).expect_err("script link should fail"); + + assert_eq!(fs::read_to_string(&outside).expect("read outside"), "safe"); + assert_eq!( + fs::metadata(&outside) + .expect("outside metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mode_update_applies_exact_permissions_to_a_regular_file() { + let root = unique_temp_path("regular-mode-update"); + fs::create_dir_all(&root).expect("create test root"); + let target = root.join("run"); + fs::write(&target, "service").expect("write service file"); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set original mode"); + + set_file_mode(&target, 0o755).expect("set service mode"); + + assert_eq!( + fs::metadata(&target) + .expect("service metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn mode_update_rejects_a_symlink_without_touching_its_target() { + let root = unique_temp_path("regular-mode-symlink"); + fs::create_dir_all(&root).expect("create test root"); + let outside = root.join("outside"); + let link = root.join("run"); + fs::write(&outside, "service").expect("write outside file"); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o600)).expect("set outside mode"); + symlink(&outside, &link).expect("create mode link"); + + set_file_mode(&link, 0o755).expect_err("service link should fail"); + + assert_eq!( + fs::metadata(&outside) + .expect("outside metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_accepts_the_exact_limit() { + let root = unique_temp_path("read-regular-exact-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"12345678").expect("write file"); + + let contents = read_regular_file_bounded(&path, 8).expect("read bounded file"); + + assert_eq!(contents, b"12345678"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_file_over_the_limit() { + let root = unique_temp_path("read-regular-over-limit"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"123456789").expect("write file"); + + let error = read_regular_file_bounded(&path, 8).expect_err("oversized file should fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_source_symlink() { + let root = unique_temp_path("read-regular-symlink"); + let protected = root.join("protected.css"); + let path = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &path).expect("create file link"); + + read_regular_file_bounded(&path, 1024).expect_err("source link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_linked_parent() { + let root = unique_temp_path("read-regular-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("style.css"), "outside theme").expect("write outside file"); + symlink(&outside, &linked).expect("create parent link"); + + read_regular_file_bounded(&linked.join("style.css"), 1024) + .expect_err("linked parent should fail"); + + assert_eq!( + fs::read_to_string(outside.join("style.css")).expect("read outside file"), + "outside theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn bounded_regular_file_read_rejects_a_directory() { + let root = unique_temp_path("read-regular-directory"); + let path = root.join("style.css"); + fs::create_dir_all(&path).expect("create directory target"); + + read_regular_file_bounded(&path, 1024).expect_err("directory should fail"); + + assert!(path.is_dir()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn open_regular_file_retains_the_validated_object_after_path_replacement() { + let root = unique_temp_path("open-regular-pinned"); + let path = root.join("sound.ogg"); + let moved = root.join("original.ogg"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&path, b"original").expect("write original file"); + let mut file = open_regular_file(&path).expect("open validated file"); + + fs::rename(&path, &moved).expect("move original file"); + fs::write(&path, b"replacement").expect("write replacement file"); + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("read retained descriptor"); + + assert_eq!(contents, "original"); + assert_eq!( + fs::read_to_string(path).expect("read replacement"), + "replacement" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/remove.rs b/crates/unixnotis-core/src/filesystem/tests/remove.rs new file mode 100644 index 000000000..709c23a0e --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/remove.rs @@ -0,0 +1,310 @@ +//! Descriptor-relative removal tests + +use std::fs; +use std::os::unix::fs::symlink; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{ + existing_parent, file_lookup_is_missing, remove_regular_file, + remove_regular_file_pair_if_contents, remove_symlink, remove_symlink_if_target, + RemoveExactFileOutcome, RemoveSymlinkOutcome, +}; +use crate::filesystem::regular::{open_regular_file_at, revalidate_file_identity}; +use crate::filesystem::symlink::{open_symlink_at, read_symlink, revalidate_symlink_identity}; +use crate::test_support::unique_temp_path; + +#[test] +fn optional_file_lookup_classifies_only_missing_errors() { + assert!(file_lookup_is_missing(&std::io::ErrorKind::NotFound.into())); + assert!(!file_lookup_is_missing( + &std::io::ErrorKind::PermissionDenied.into() + )); + assert!(!file_lookup_is_missing( + &std::io::ErrorKind::InvalidInput.into() + )); +} + +#[test] +fn retained_file_identity_rejects_a_same_directory_replacement() { + let root = unique_temp_path("remove-file-identity"); + fs::create_dir_all(&root).expect("create root"); + let target = root.join("shared"); + let moved = root.join("original"); + fs::write(&target, "original").expect("write original"); + let (parent_fd, file_name) = existing_parent(&target) + .expect("open parent") + .expect("parent exists"); + let retained = open_regular_file_at(&parent_fd, &file_name).expect("open retained file"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect("unchanged file should pass"); + fs::rename(&target, &moved).expect("move original"); + fs::write(&target, "replacement").expect("write replacement"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn retained_symlink_identity_rejects_a_same_name_regular_replacement() { + let root = unique_temp_path("remove-symlink-identity"); + fs::create_dir_all(&root).expect("create root"); + let target = root.join("enabled"); + let moved = root.join("original-link"); + symlink("service", &target).expect("write original link"); + let (parent_fd, file_name) = existing_parent(&target) + .expect("open parent") + .expect("parent exists"); + let retained = open_symlink_at(&parent_fd, &file_name).expect("open retained link"); + + revalidate_symlink_identity(&parent_fd, &file_name, &retained) + .expect("unchanged link should pass"); + fs::rename(&target, &moved).expect("move original link"); + fs::write(&target, "replacement").expect("write replacement file"); + + revalidate_symlink_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_removal_is_idempotent() { + let root = unique_temp_path("remove-regular-file"); + let target = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "state").expect("write target"); + + assert!(remove_regular_file(&target).expect("remove regular file")); + assert!(!remove_regular_file(&target).expect("missing file stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_removal_rejects_a_symlink_and_keeps_its_target() { + let root = unique_temp_path("remove-regular-symlink"); + let protected = root.join("protected"); + let link = root.join("state.json"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected"); + symlink(&protected, &link).expect("create link"); + + remove_regular_file(&link).expect_err("regular removal should reject a link"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected"), + "protected" + ); + assert!(fs::symlink_metadata(link) + .expect("link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_removal_rejects_a_symlinked_parent() { + let root = unique_temp_path("remove-regular-parent-symlink"); + let outside = root.join("outside"); + let linked_parent = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + fs::write(outside.join("state.json"), "state").expect("write outside state"); + symlink(&outside, &linked_parent).expect("create parent link"); + + remove_regular_file(&linked_parent.join("state.json")).expect_err("linked parent should fail"); + + assert_eq!( + fs::read_to_string(outside.join("state.json")).expect("read outside state"), + "state" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_removal_requires_both_payloads_before_unlinking_either_file() { + let root = unique_temp_path("remove-exact-pair"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + fs::write(&target, "bundle\n").expect("write shared file"); + fs::write(&marker, "foreign\n").expect("write foreign marker"); + + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("inspect exact pair"), + RemoveExactFileOutcome::ContentsMismatch + ); + assert!(target.exists()); + assert!(marker.exists()); + + fs::write(&marker, "owned\n").expect("repair marker"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("remove exact pair"), + RemoveExactFileOutcome::Removed + ); + assert!(!target.exists()); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_removal_reports_each_missing_member() { + let root = unique_temp_path("remove-exact-missing-member"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("both missing is idempotent"), + RemoveExactFileOutcome::Missing + ); + fs::write(&target, "bundle\n").expect("write shared file"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("missing marker is idempotent"), + RemoveExactFileOutcome::Missing + ); + fs::remove_file(&target).expect("remove shared file"); + fs::write(&marker, "owned\n").expect("write marker"); + assert_eq!( + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect("missing shared file is idempotent"), + RemoveExactFileOutcome::Missing + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn exact_pair_removal_rejects_special_objects_for_each_member() { + let root = unique_temp_path("remove-exact-special-member"); + let target = root.join("type"); + let marker = root.join(".owner"); + fs::create_dir_all(&root).expect("create test root"); + + fs::create_dir(&target).expect("create target directory"); + fs::write(&marker, "owned\n").expect("write marker"); + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect_err("target directory must be rejected"); + fs::remove_dir(&target).expect("remove target directory"); + + fs::write(&target, "bundle\n").expect("write target"); + fs::remove_file(&marker).expect("remove marker"); + mkfifoat(CWD, &marker, Mode::from_raw_mode(0o600)).expect("create marker FIFO"); + remove_regular_file_pair_if_contents(&target, b"bundle\n", &marker, b"owned\n") + .expect_err("marker FIFO must be rejected"); + + assert_eq!( + fs::read_to_string(&target).expect("shared file remains"), + "bundle\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_removal_keeps_the_link_target() { + let root = unique_temp_path("remove-symlink"); + let target = root.join("service"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "service").expect("write target"); + symlink(&target, &link).expect("create link"); + + assert!(remove_symlink(&link).expect("remove link")); + assert!(!remove_symlink(&link).expect("missing link stays removed")); + + assert_eq!(fs::read_to_string(target).expect("read target"), "service"); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn target_checked_symlink_removal_reports_mismatch_without_removing_link() { + let root = unique_temp_path("remove-symlink-mismatch"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("actual", &link).expect("create link"); + + let outcome = remove_symlink_if_target(&link, std::path::Path::new("expected")) + .expect("inspect link target"); + + assert_eq!( + outcome, + RemoveSymlinkOutcome::TargetMismatch("actual".into()) + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("actual".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn target_checked_symlink_removal_removes_only_an_exact_match() { + let root = unique_temp_path("remove-symlink-match"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("../service", &link).expect("create link"); + + let outcome = remove_symlink_if_target(&link, std::path::Path::new("../service")) + .expect("remove matching link"); + + assert_eq!(outcome, RemoveSymlinkOutcome::Removed); + assert_eq!(read_symlink(&link).expect("link is missing"), None); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn target_checked_symlink_removal_reports_a_missing_final_entry() { + let root = unique_temp_path("remove-symlink-missing-final"); + fs::create_dir_all(&root).expect("create root"); + + let outcome = remove_symlink_if_target(&root.join("missing"), std::path::Path::new("service")) + .expect("missing final link should be idempotent"); + + assert_eq!(outcome, RemoveSymlinkOutcome::Missing); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_operations_reject_regular_files() { + let root = unique_temp_path("remove-symlink-regular"); + let target = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&target, "regular").expect("write regular file"); + + read_symlink(&target).expect_err("read should reject a regular file"); + remove_symlink(&target).expect_err("removal should reject a regular file"); + remove_symlink_if_target(&target, std::path::Path::new("service")) + .expect_err("target-checked removal should reject a regular file"); + + assert_eq!( + fs::read_to_string(target).expect("read regular file"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn removal_does_not_create_a_missing_parent() { + let root = unique_temp_path("remove-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("state.json"); + + assert!(!remove_regular_file(&target).expect("regular file is missing")); + assert!(!remove_symlink(&target).expect("link is missing")); + assert_eq!(read_symlink(&target).expect("link is missing"), None); + assert_eq!( + remove_symlink_if_target(&target, std::path::Path::new("service")) + .expect("link is missing"), + RemoveSymlinkOutcome::Missing + ); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/rename.rs b/crates/unixnotis-core/src/filesystem/tests/rename.rs new file mode 100644 index 000000000..3b38e92d3 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/rename.rs @@ -0,0 +1,299 @@ +//! No-replace regular-file rename tests + +use std::fs; +use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; + +use super::{ + classify_directory_rename_attempt, classify_rename_attempt, rename_directory_no_replace, + rename_regular_file_no_replace, RenameDirectoryOutcome, RenameRegularFileOutcome, +}; +use crate::filesystem::descriptor::open_parent_existing; +use crate::filesystem::regular::{open_regular_file_at, revalidate_file_identity}; +use crate::test_support::unique_temp_path; + +#[test] +fn regular_file_rename_moves_source_to_an_unused_destination() { + let root = unique_temp_path("rename-regular-file"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o640)).expect("set source mode"); + let source_metadata = fs::metadata(&source).expect("read source metadata"); + + let outcome = rename_regular_file_no_replace(&source, &destination).expect("rename file"); + + assert_eq!(outcome, RenameRegularFileOutcome::Renamed); + assert!(!source.exists()); + let destination_metadata = fs::metadata(&destination).expect("read destination metadata"); + assert_eq!(destination_metadata.dev(), source_metadata.dev()); + assert_eq!(destination_metadata.ino(), source_metadata.ino()); + assert_eq!(destination_metadata.permissions().mode() & 0o777, 0o640); + assert_eq!( + fs::read_to_string(destination).expect("read destination"), + "legacy theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn retained_rename_source_identity_rejects_a_same_name_replacement() { + let root = unique_temp_path("rename-source-identity"); + let source = root.join("style.css"); + let moved = root.join("original.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "original").expect("write original source"); + let (parent_fd, file_name) = open_parent_existing(&source).expect("open source parent"); + let retained = open_regular_file_at(&parent_fd, &file_name).expect("open retained source"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect("unchanged source should pass"); + fs::rename(&source, &moved).expect("move original source"); + fs::write(&source, "replacement").expect("write replacement source"); + + revalidate_file_identity(&parent_fd, &file_name, &retained) + .expect_err("replacement identity must fail before rename"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn rename_attempt_result_distinguishes_every_kernel_outcome() { + assert_eq!( + classify_rename_attempt(Ok(())).expect("successful rename"), + RenameRegularFileOutcome::Renamed + ); + assert_eq!( + classify_rename_attempt(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("destination collision"), + RenameRegularFileOutcome::DestinationExists + ); + assert_eq!( + classify_rename_attempt(Err(std::io::ErrorKind::NotFound.into())) + .expect("source disappeared"), + RenameRegularFileOutcome::SourceMissing + ); + + let error = classify_rename_attempt(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated rename failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[test] +fn regular_file_rename_reports_a_missing_source_without_creating_parents() { + let root = unique_temp_path("rename-missing-source"); + let source = root.join("missing").join("style.css"); + let destination = root.join("backup").join("style.css.bak"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("missing source outcome"); + + assert_eq!(outcome, RenameRegularFileOutcome::SourceMissing); + assert!(!root.exists()); +} + +#[test] +fn regular_file_rename_reports_a_missing_final_source() { + let root = unique_temp_path("rename-missing-final-source"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("missing source outcome"); + + assert_eq!(outcome, RenameRegularFileOutcome::SourceMissing); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_propagates_a_non_collision_destination_error() { + let root = unique_temp_path("rename-invalid-destination"); + let source = root.join("style.css"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + let destination = root.join("x".repeat(300)); + + let error = rename_regular_file_no_replace(&source, &destination) + .expect_err("overlong destination should fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(&source).expect("read source"), + "legacy theme" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_preserves_an_existing_destination() { + let root = unique_temp_path("rename-existing-destination"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&source, "legacy theme").expect("write source"); + fs::write(&destination, "existing backup").expect("write destination"); + + let outcome = + rename_regular_file_no_replace(&source, &destination).expect("preserve destination"); + + assert_eq!(outcome, RenameRegularFileOutcome::DestinationExists); + assert_eq!( + fs::read_to_string(source).expect("read source"), + "legacy theme" + ); + assert_eq!( + fs::read_to_string(destination).expect("read destination"), + "existing backup" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_source_symlink() { + let root = unique_temp_path("rename-source-symlink"); + let protected = root.join("protected.css"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &source).expect("create source link"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("source link should be rejected"); + + assert!(fs::symlink_metadata(source) + .expect("source link remains") + .file_type() + .is_symlink()); + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_linked_parent() { + let root = unique_temp_path("rename-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + let source = linked.join("style.css"); + let destination = linked.join("style.css.bak"); + fs::create_dir_all(&outside).expect("create outside directory"); + fs::write(outside.join("style.css"), "outside theme").expect("write outside source"); + symlink(&outside, &linked).expect("create parent link"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_to_string(outside.join("style.css")).expect("read outside source"), + "outside theme" + ); + assert!(!outside.join("style.css.bak").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn regular_file_rename_rejects_a_directory_source() { + let root = unique_temp_path("rename-directory-source"); + let source = root.join("style.css"); + let destination = root.join("style.css.bak"); + fs::create_dir_all(&source).expect("create source directory"); + + rename_regular_file_no_replace(&source, &destination) + .expect_err("directory source should be rejected"); + + assert!(source.is_dir()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_publishes_a_complete_tree_without_replacing_a_destination() { + let root = unique_temp_path("rename-directory"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&source).expect("create staged directory"); + fs::write(source.join("theme.toml"), "api_version = 2").expect("write staged manifest"); + + let outcome = + rename_directory_no_replace(&source, &destination).expect("publish staged directory"); + + assert_eq!(outcome, RenameDirectoryOutcome::Renamed); + assert!(!source.exists()); + assert_eq!( + fs::read_to_string(destination.join("theme.toml")).expect("read published manifest"), + "api_version = 2" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_preserves_an_existing_destination_and_staged_source() { + let root = unique_temp_path("rename-directory-collision"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&source).expect("create staged directory"); + fs::create_dir_all(&destination).expect("create destination directory"); + fs::write(source.join("staged.css"), "staged").expect("write staged file"); + fs::write(destination.join("personal.css"), "personal").expect("write personal file"); + + let outcome = + rename_directory_no_replace(&source, &destination).expect("classify destination collision"); + + assert_eq!(outcome, RenameDirectoryOutcome::DestinationExists); + assert_eq!( + fs::read_to_string(source.join("staged.css")).expect("read retained staged file"), + "staged" + ); + assert_eq!( + fs::read_to_string(destination.join("personal.css")).expect("read personal file"), + "personal" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_rejects_a_symlink_source() { + let root = unique_temp_path("rename-directory-symlink"); + let actual = root.join("actual"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&actual).expect("create actual directory"); + symlink(&actual, &source).expect("create staged directory link"); + + rename_directory_no_replace(&source, &destination) + .expect_err("a staged directory link must be rejected"); + + assert!(actual.is_dir()); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_reports_a_missing_staged_source_without_creating_a_destination() { + let root = unique_temp_path("rename-directory-missing-source"); + let source = root.join(".stock.staging"); + let destination = root.join("stock"); + fs::create_dir_all(&root).expect("create rename test root"); + + let outcome = rename_directory_no_replace(&source, &destination) + .expect("a missing staged directory should be a normal classified outcome"); + + assert_eq!(outcome, RenameDirectoryOutcome::SourceMissing); + assert!(!destination.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_rename_classifies_source_disappearance_at_the_rename_boundary() { + let outcome = + classify_directory_rename_attempt(Err(std::io::Error::from(std::io::ErrorKind::NotFound))) + .expect("rename-time source disappearance should be classified"); + + assert_eq!(outcome, RenameDirectoryOutcome::SourceMissing); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/symlink.rs b/crates/unixnotis-core/src/filesystem/tests/symlink.rs new file mode 100644 index 000000000..981b33cbb --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/symlink.rs @@ -0,0 +1,213 @@ +//! Symbolic-link operation tests + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use super::{ + classify_symlink_creation, create_symlink_if_missing, existing_link_outcome, open_parent, + read_symlink, replace_symlink_atomic, reserve_temp_symlink, validate_symlink_or_missing, + CreateSymlinkOutcome, SymlinkCreateAttempt, +}; +use crate::test_support::unique_temp_path; +use std::ffi::OsString; + +#[test] +fn create_symlink_is_idempotent_for_an_exact_target() { + let root = unique_temp_path("create-symlink"); + let link = root.join("service").join("enabled"); + + assert_eq!( + create_symlink_if_missing(&link, Path::new("../run")).expect("create symbolic link"), + CreateSymlinkOutcome::Created + ); + assert_eq!( + create_symlink_if_missing(&link, Path::new("../run")).expect("keep matching symbolic link"), + CreateSymlinkOutcome::Unchanged + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("../run".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn create_symlink_preserves_a_different_target() { + let root = unique_temp_path("create-symlink-mismatch"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + symlink("actual", &link).expect("create existing link"); + + let outcome = create_symlink_if_missing(&link, Path::new("expected")) + .expect("inspect existing symbolic link"); + + assert_eq!( + outcome, + CreateSymlinkOutcome::TargetMismatch("actual".into()) + ); + assert_eq!( + read_symlink(&link).expect("read link"), + Some("actual".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_creation_result_distinguishes_creation_collision_and_failure() { + assert_eq!( + classify_symlink_creation(Ok(())).expect("successful symlink creation"), + SymlinkCreateAttempt::Created + ); + assert_eq!( + classify_symlink_creation(Err(std::io::ErrorKind::AlreadyExists.into())) + .expect("symlink collision"), + SymlinkCreateAttempt::Collision + ); + + let error = classify_symlink_creation(Err(std::io::ErrorKind::PermissionDenied.into())) + .expect_err("unrelated symlink failure should propagate"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); +} + +#[test] +fn existing_link_classification_distinguishes_exact_and_different_targets() { + assert_eq!( + existing_link_outcome("service".into(), Path::new("service")), + CreateSymlinkOutcome::Unchanged + ); + assert_eq!( + existing_link_outcome("other".into(), Path::new("service")), + CreateSymlinkOutcome::TargetMismatch("other".into()) + ); +} + +#[test] +fn create_symlink_rejects_an_existing_regular_file() { + let root = unique_temp_path("create-symlink-regular"); + let link = root.join("enabled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&link, "regular").expect("write regular file"); + + create_symlink_if_missing(&link, Path::new("service")) + .expect_err("regular destination should fail"); + + assert_eq!( + fs::read_to_string(link).expect("read regular file"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn create_symlink_rejects_a_linked_parent() { + let root = unique_temp_path("create-symlink-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("create outside"); + symlink(&outside, &linked).expect("create parent link"); + + create_symlink_if_missing(&linked.join("enabled"), Path::new("service")) + .expect_err("linked parent should fail"); + + assert!(!outside.join("enabled").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_symlink_replacement_handles_missing_and_existing_links() { + let root = unique_temp_path("replace-symlink"); + let link = root.join("compiled"); + + assert!(replace_symlink_atomic(&link, Path::new("compiled-one")).expect("create compiled link")); + assert!( + replace_symlink_atomic(&link, Path::new("compiled-two")).expect("replace compiled link") + ); + assert!(!replace_symlink_atomic(&link, Path::new("compiled-two")) + .expect("matching compiled link stays unchanged")); + + assert_eq!( + read_symlink(&link).expect("read compiled link"), + Some("compiled-two".into()) + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn atomic_symlink_replacement_rejects_a_regular_destination() { + let root = unique_temp_path("replace-symlink-regular"); + let link = root.join("compiled"); + fs::create_dir_all(&root).expect("create root"); + fs::write(&link, "regular").expect("write regular destination"); + + replace_symlink_atomic(&link, Path::new("compiled-next")) + .expect_err("regular destination should fail"); + + assert_eq!( + fs::read_to_string(link).expect("read regular destination"), + "regular" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn temporary_symlink_reservation_skips_a_collision_and_uses_the_next_name() { + let root = unique_temp_path("symlink-temp-collision"); + fs::create_dir_all(&root).expect("create root"); + symlink("protected", root.join("first")).expect("plant first candidate"); + let (parent_fd, _) = open_parent(&root.join("link")).expect("open parent"); + + let reserved = reserve_temp_symlink( + &parent_fd, + [OsString::from("first"), OsString::from("second")], + Path::new("service"), + ) + .expect("reserve second candidate"); + + assert_eq!(reserved, OsString::from("second")); + assert_eq!( + fs::read_link(root.join("first")).expect("first link"), + Path::new("protected") + ); + assert_eq!( + fs::read_link(root.join("second")).expect("second link"), + Path::new("service") + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn temporary_symlink_reservation_propagates_non_collision_errors() { + let root = unique_temp_path("symlink-temp-error"); + fs::create_dir_all(&root).expect("create root"); + let (parent_fd, _) = open_parent(&root.join("link")).expect("open parent"); + + let error = reserve_temp_symlink( + &parent_fd, + [OsString::from("x".repeat(300)), OsString::from("unused")], + Path::new("service"), + ) + .expect_err("overlong candidate should fail"); + + assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert!(!root.join("unused").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_revalidation_accepts_links_and_missing_entries_but_rejects_files() { + let root = unique_temp_path("symlink-revalidation"); + fs::create_dir_all(&root).expect("create root"); + let (parent_fd, _) = open_parent(&root.join("target")).expect("open parent"); + + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("missing")) + .expect("missing entry is safe"); + symlink("service", root.join("link")).expect("create link"); + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("link")) + .expect("link entry is safe"); + fs::write(root.join("regular"), "data").expect("write regular file"); + validate_symlink_or_missing(&parent_fd, std::ffi::OsStr::new("regular")) + .expect_err("regular entry should fail"); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tests/tree.rs b/crates/unixnotis-core/src/filesystem/tests/tree.rs new file mode 100644 index 000000000..2d9d8b643 --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tests/tree.rs @@ -0,0 +1,169 @@ +//! Preflighted directory-tree removal tests + +use std::fs; +use std::os::unix::fs::symlink; + +use rustix::fs::{mkfifoat, Mode, CWD}; + +use super::{ + preflight_directory_contents, remove_directory_tree, remove_marked_directory_tree, + revalidate_directory_identity, +}; +use crate::filesystem::descriptor::open_target_directory; +use crate::test_support::unique_temp_path; + +#[test] +fn recursive_directory_removal_deletes_regular_nested_tree() { + let root = unique_temp_path("remove-directory-tree"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("root-file"), "root").expect("write root file"); + fs::write(target.join("nested").join("child-file"), "child").expect("write child file"); + + assert!(remove_directory_tree(&target).expect("remove managed tree")); + assert!(!remove_directory_tree(&target).expect("missing tree stays removed")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_child_symlink() { + let root = unique_temp_path("remove-directory-child-link"); + let target = root.join("managed"); + let protected = root.join("protected"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, target.join("linked-child")).expect("create child link"); + + remove_directory_tree(&target).expect_err("child link should fail"); + + assert_eq!( + fs::read_to_string(protected).expect("read protected file"), + "protected" + ); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn recursive_directory_removal_rejects_a_special_child() { + let root = unique_temp_path("remove-directory-special-child"); + let target = root.join("managed"); + let fifo = target.join("fifo"); + fs::create_dir_all(&target).expect("create managed directory"); + mkfifoat(CWD, &fifo, Mode::from_raw_mode(0o600)).expect("create fifo child"); + + remove_directory_tree(&target).expect_err("special child should fail"); + + assert!(fs::symlink_metadata(fifo).is_ok()); + assert!(target.exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_preflight_preserves_regular_siblings_when_a_child_is_unsafe() { + let root = unique_temp_path("marked-tree-preflight"); + let target = root.join("managed"); + fs::create_dir_all(&target).expect("create managed directory"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("regular"), "keep until full preflight").expect("write regular child"); + symlink("regular", target.join("unsafe-link")).expect("create unsafe link"); + + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect_err("unsafe child should reject the whole tree"); + + assert_eq!( + fs::read_to_string(target.join("regular")).expect("regular sibling remains"), + "keep until full preflight" + ); + assert!(target.join(".owner").exists()); + assert!(fs::symlink_metadata(target.join("unsafe-link")) + .expect("unsafe link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_preflight_directly_rejects_unsafe_descendants() { + let root = unique_temp_path("marked-tree-direct-preflight"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested directory"); + fs::write(target.join("nested").join("regular"), "keep").expect("write regular child"); + symlink("regular", target.join("nested").join("unsafe-link")).expect("create unsafe link"); + let (_parent_fd, _name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + preflight_directory_contents(&directory_fd).expect_err("unsafe descendant must fail preflight"); + + assert_eq!( + fs::read_to_string(target.join("nested").join("regular")).expect("regular child remains"), + "keep" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn directory_identity_revalidation_rejects_a_same_device_replacement() { + let root = unique_temp_path("directory-identity-replacement"); + let target = root.join("managed"); + let moved = root.join("original"); + fs::create_dir_all(&target).expect("create original directory"); + let (parent_fd, file_name, directory_fd) = open_target_directory(&target) + .expect("open target") + .expect("target exists"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect("unchanged identity should pass"); + fs::rename(&target, &moved).expect("move retained directory"); + fs::create_dir(&target).expect("create same-device replacement"); + + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd) + .expect_err("replacement identity must fail"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn marked_tree_removal_validates_marker_and_deletes_a_preflighted_tree() { + let root = unique_temp_path("marked-tree-remove"); + let target = root.join("managed"); + fs::create_dir_all(target.join("nested")).expect("create nested tree"); + fs::write(target.join(".owner"), "owned\n").expect("write ownership marker"); + fs::write(target.join("nested").join("file"), "owned").expect("write nested file"); + + assert!( + remove_marked_directory_tree(&target, ".owner".as_ref(), b"owned\n") + .expect("remove marked tree") + ); + assert!(!target.exists()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn tree_removal_rejects_linked_ancestors_without_touching_target() { + let root = unique_temp_path("remove-tree-linked-parent"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(outside.join("empty")).expect("create outside directory"); + symlink(&outside, &linked).expect("create parent link"); + + remove_directory_tree(&linked).expect_err("linked root should fail"); + + assert!(outside.join("empty").exists()); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn tree_removal_does_not_create_missing_parents() { + let root = unique_temp_path("remove-tree-missing-parent"); + let missing_parent = root.join("missing"); + let target = missing_parent.join("directory"); + + assert!(!remove_directory_tree(&target).expect("directory tree is missing")); + + assert!(!missing_parent.exists()); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-core/src/filesystem/tree.rs b/crates/unixnotis-core/src/filesystem/tree.rs new file mode 100644 index 000000000..be0ae789c --- /dev/null +++ b/crates/unixnotis-core/src/filesystem/tree.rs @@ -0,0 +1,149 @@ +//! Preflighted recursive removal for regular-only directory trees + +use std::ffi::{CStr, OsStr}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use rustix::fs::{fstat, statat, unlinkat, AtFlags, Dir, FileType}; + +use super::descriptor::{open_directory_at, open_target_directory, sync_directory}; +use super::directory::{invalid_marker_error, validate_child_name}; +use super::regular::{file_contents_equal, open_regular_file_at}; + +/// Recursively remove a directory containing only regular files and directories +/// +/// Symbolic links and special files are rejected and left in place +/// +/// # Errors +/// +/// Returns an error when a path component or child has an unsafe shape, an entry changes during +/// traversal, or removal and synchronization cannot complete +pub fn remove_directory_tree(path: &Path) -> io::Result { + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + remove_directory_contents(&directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +/// Remove a marked regular-only directory tree through one retained root descriptor +/// +/// The entire tree is checked before any entry is deleted. The ownership marker is read relative +/// to that same descriptor, and the visible root name must still identify it before final removal +/// +/// # Errors +/// +/// Returns an error when the path or marker is unsafe, marker bytes differ, the tree contains a +/// link or special file, an entry changes shape, or durable removal fails +pub fn remove_marked_directory_tree( + path: &Path, + marker_name: &OsStr, + marker_contents: &[u8], +) -> io::Result { + validate_child_name(marker_name)?; + let Some((parent_fd, file_name, directory_fd)) = open_target_directory(path)? else { + return Ok(false); + }; + let marker_name = marker_name.to_os_string(); + let mut marker = open_regular_file_at(&directory_fd, &marker_name) + .map_err(|_error| invalid_marker_error())?; + if !file_contents_equal(&mut marker, marker_contents)? { + return Err(invalid_marker_error()); + } + + // Preflight is intentionally read-only so one rejected child cannot cause partial deletion + preflight_directory_contents(&directory_fd)?; + remove_directory_contents(&directory_fd)?; + revalidate_directory_identity(&parent_fd, &file_name, &directory_fd)?; + drop(directory_fd); + unlinkat(&parent_fd, &file_name, AtFlags::REMOVEDIR)?; + sync_directory(&parent_fd)?; + Ok(true) +} + +fn remove_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + unlinkat(directory_fd, name, AtFlags::empty())?; + sync_directory(directory_fd)?; + } else if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + remove_directory_contents(&child_fd)?; + drop(child_fd); + unlinkat(directory_fd, name, AtFlags::REMOVEDIR)?; + sync_directory(directory_fd)?; + } else { + return Err(unsafe_tree_entry_error(name)); + } + } + Ok(()) +} + +pub(super) fn preflight_directory_contents(directory_fd: &OwnedFd) -> io::Result<()> { + let mut entries = Dir::read_from(directory_fd)?; + while let Some(entry) = entries.read() { + let entry = entry?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + let stat = statat(directory_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + let file_type = FileType::from_raw_mode(stat.st_mode); + if file_type.is_file() { + continue; + } + if file_type.is_dir() { + let child_fd = open_directory_at(directory_fd, OsStr::from_bytes(name.to_bytes()))?; + preflight_directory_contents(&child_fd)?; + continue; + } + return Err(unsafe_tree_entry_error(name)); + } + Ok(()) +} + +pub(super) fn revalidate_directory_identity( + parent_fd: &OwnedFd, + file_name: &OsStr, + directory_fd: &OwnedFd, +) -> io::Result<()> { + let retained = fstat(directory_fd)?; + let visible = statat(parent_fd, file_name, AtFlags::SYMLINK_NOFOLLOW)?; + if retained.st_dev == visible.st_dev + && retained.st_ino == visible.st_ino + && FileType::from_raw_mode(visible.st_mode).is_dir() + { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "directory changed while guarded removal was in progress", + )) +} + +fn unsafe_tree_entry_error(name: &CStr) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "refusing unsafe entry inside directory tree: {}", + name.to_string_lossy() + ), + ) +} + +#[cfg(test)] +#[path = "tests/tree.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/lib.rs b/crates/unixnotis-core/src/lib.rs index aed752224..99c27f9f7 100644 --- a/crates/unixnotis-core/src/lib.rs +++ b/crates/unixnotis-core/src/lib.rs @@ -16,12 +16,17 @@ reason = "reviewed compatibility, wire-format, and bounded numeric conversions that cannot change without breaking public configuration behavior" )] +pub mod bus_call; +pub mod bus_identity; pub mod config; pub mod control; pub mod css; pub mod embedded; pub mod filesystem; pub mod model; +pub mod notification_daemons; +pub mod notifications; +pub mod process; pub mod reconnect; pub mod service_manager; #[cfg(test)] @@ -29,11 +34,16 @@ pub mod service_manager; mod test_support; pub mod util; +pub use bus_call::*; +pub use bus_identity::*; pub use config::*; pub use control::*; pub use css::*; pub use embedded::*; pub use model::*; +pub use notification_daemons::*; +pub use notifications::*; +pub use process::*; pub use util::program_in_path; /// Compatibility path for script resources published before the embedded module was introduced diff --git a/crates/unixnotis-core/src/model/attribution.rs b/crates/unixnotis-core/src/model/attribution.rs new file mode 100644 index 000000000..8b9e88de1 --- /dev/null +++ b/crates/unixnotis-core/src/model/attribution.rs @@ -0,0 +1,379 @@ +//! Structured notification attribution and interaction policy + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use zbus::zvariant::Type; + +use super::interaction::{ApplicationActionPolicy, InteractionPolicies}; +use crate::util; + +const MAX_ATTRIBUTION_TEXT_BYTES: usize = 256; +const MAX_GROUP_KEY_BYTES: usize = 512; + +/// Daemon-owned result of application identity evaluation +// Representation-aware Serde keeps each wire value at one byte +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum AttributionStatus { + Verified = 0, + Recognized = 1, + #[default] + Unresolved = 2, + Conflict = 3, + Relay = 4, +} + +/// Security boundary supporting the visible application association +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum IdentityAssurance { + /// Kernel, confinement, or broker evidence binds both identity and execution origin + Authenticated = 0, + /// Protected installation evidence binds an app but cannot prove same-UID code integrity + SystemAssociated = 1, + /// A trusted portal process supplied an app ID without unforgeable caller provenance + PortalAssociated = 2, + /// A user-local desktop record associates branding without a protected boundary + UserAssociated = 3, + #[default] + /// No positive application association was established + Unresolved = 4, + /// Concrete protected evidence contradicts the application claim + Conflict = 5, + /// A known forwarding executable supplied an unauthenticated application label + Relay = 6, +} + +/// Stable reason for one attribution result +// Numeric ranges keep positive, uncertain, and contradictory evidence easy to inspect +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum AttributionReason { + ExactSystemExecutable = 0, + PortalAppIdAssociation = 1, + ExactUserExecutable = 2, + ProtectedPayloadMatch = 3, + TrustedRelayExecutable = 4, + + #[default] + MissingSenderEvidence = 10, + MissingCommandLine = 11, + AmbiguousDesktopRecords = 12, + DynamicLaunchContract = 13, + UnsupportedWrapper = 14, + NoDesktopCandidate = 15, + + ExecutableMismatch = 20, + ProtectedPayloadMismatch = 21, + ApplicationClaimMismatch = 22, +} + +/// Application identity selected from sender and desktop evidence +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct NotificationAttribution { + // The primary label is always selected by the daemon + pub display_name: String, + // The protocol app_name stays visible without becoming identity evidence + pub claimed_name: String, + // Empty strings represent optional wire fields that were not resolved + pub desktop_id: String, + pub badge_icon: String, + // Status and reason carry state without parsing diagnostic text + pub status: AttributionStatus, + // Assurance names the boundary independently from evidence completeness + pub assurance: IdentityAssurance, + // Interaction authority is explicit so UI code never infers it from branding + pub interactions: InteractionPolicies, + pub reason: AttributionReason, + // Human-readable detail is display-only and never interpreted by clients + pub diagnostic_detail: String, + // The daemon owns grouping so copied labels cannot join trusted groups + pub group_key: String, +} + +impl Default for NotificationAttribution { + fn default() -> Self { + Self { + display_name: "Unknown application".to_string(), + claimed_name: String::new(), + desktop_id: String::new(), + badge_icon: "application-x-executable-symbolic".to_string(), + status: AttributionStatus::Unresolved, + assurance: IdentityAssurance::Unresolved, + interactions: InteractionPolicies::DENY, + reason: AttributionReason::MissingSenderEvidence, + diagnostic_detail: String::new(), + group_key: "unknown".to_string(), + } + } +} + +impl NotificationAttribution { + /// Build a strongly bound application identity + #[must_use] + pub fn verified( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self::resolved( + display_name, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Verified, + IdentityAssurance::Authenticated, + InteractionPolicies::AUTHENTICATED, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build a known but non-authoritative application identity + #[must_use] + pub fn recognized( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self::resolved( + display_name, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Recognized, + IdentityAssurance::UserAssociated, + InteractionPolicies::DENY, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build a canonical identity with an explicit non-authenticating boundary + #[must_use] + #[expect( + clippy::too_many_arguments, + reason = "association and interaction fields stay explicit at the trust boundary" + )] + pub fn associated( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + assurance: IdentityAssurance, + interactions: InteractionPolicies, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + debug_assert!( + matches!( + assurance, + IdentityAssurance::SystemAssociated + | IdentityAssurance::PortalAssociated + | IdentityAssurance::UserAssociated + ), + "associated attribution requires an application association boundary" + ); + Self::resolved( + display_name, + claimed_name, + desktop_id, + badge_icon, + AttributionStatus::Recognized, + assurance, + interactions, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build an attribution without a reliable desktop association + #[must_use] + pub fn unresolved( + claimed_name: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self::resolved( + "Unknown application", + claimed_name, + "", + "application-x-executable-symbolic", + AttributionStatus::Unresolved, + IdentityAssurance::Unresolved, + InteractionPolicies::DENY, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build an attribution backed by a concrete contradictory candidate + #[must_use] + pub fn conflict( + claimed_name: &str, + desktop_id: &str, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + debug_assert!( + matches!( + reason, + AttributionReason::ExecutableMismatch + | AttributionReason::ProtectedPayloadMismatch + | AttributionReason::ApplicationClaimMismatch + ), + "conflict attribution requires a concrete contradiction reason" + ); + Self::resolved( + "Unknown application", + claimed_name, + desktop_id, + "dialog-warning-symbolic", + AttributionStatus::Conflict, + IdentityAssurance::Conflict, + InteractionPolicies::DENY, + reason, + diagnostic_detail, + group_key, + ) + } + + /// Build a known relay identity without authenticating its app label + #[must_use] + pub fn relay(claimed_name: &str, diagnostic_detail: &str, group_key: String) -> Self { + Self::resolved( + "Command-line notification", + claimed_name, + "", + "utilities-terminal-symbolic", + AttributionStatus::Relay, + IdentityAssurance::Relay, + InteractionPolicies::DENY, + AttributionReason::TrustedRelayExecutable, + diagnostic_detail, + group_key, + ) + } + + #[expect( + clippy::needless_pass_by_value, + clippy::too_many_arguments, + reason = "the wire fields stay explicit at construction" + )] + fn resolved( + display_name: &str, + claimed_name: &str, + desktop_id: &str, + badge_icon: &str, + status: AttributionStatus, + assurance: IdentityAssurance, + interactions: InteractionPolicies, + reason: AttributionReason, + diagnostic_detail: &str, + group_key: String, + ) -> Self { + Self { + display_name: display_name_or_unknown(display_name), + claimed_name: bounded_text(claimed_name), + desktop_id: bounded_text(desktop_id), + badge_icon: bounded_text(badge_icon), + status, + assurance, + interactions, + reason, + diagnostic_detail: bounded_text(diagnostic_detail), + group_key: bounded_group_key(&group_key), + } + } + + /// Policy for whole-card or advertised default activation + #[must_use] + pub const fn default_activation_policy(&self) -> ApplicationActionPolicy { + self.interactions.default_activation + } + + /// Policy for non-default application action buttons + #[must_use] + pub const fn action_button_policy(&self) -> ApplicationActionPolicy { + self.interactions.action_buttons + } + + /// Application-provided decorative visuals require a positive local association + #[must_use] + pub const fn may_materialize_application_icon(&self) -> bool { + matches!( + self.assurance, + IdentityAssurance::Authenticated + | IdentityAssurance::SystemAssociated + | IdentityAssurance::UserAssociated + ) + } + + /// Message content can be decoded without granting any application action + #[must_use] + pub const fn may_materialize_content_image(&self) -> bool { + self.may_materialize_application_icon() + } + + /// Whether this attribution has kernel or broker-backed identity evidence + #[must_use] + pub const fn is_verified(&self) -> bool { + matches!(self.status, AttributionStatus::Verified) + } + + // Decide what happens when a specific action key is activated + // "default" follows the card-level activation rules so physical gestures still work + // "inline-reply" is always blocked here — the dedicated reply method handles that + // everything else uses the normal button policy from the identity resolver + #[must_use] + pub fn action_policy(&self, action_key: &str) -> ApplicationActionPolicy { + match action_key { + "default" => self.default_activation_policy(), + "inline-reply" => ApplicationActionPolicy::Deny, + _ => self.action_button_policy(), + } + } +} + +fn bounded_text(value: &str) -> String { + let clean = util::sanitize_inline_display_text(value); + util::truncate_utf8_bytes(clean.trim(), MAX_ATTRIBUTION_TEXT_BYTES) +} + +fn bounded_group_key(value: &str) -> String { + let clean = util::sanitize_inline_display_text(value); + let bounded = util::truncate_utf8_bytes(clean.trim(), MAX_GROUP_KEY_BYTES); + if bounded.is_empty() { + "unknown".to_string() + } else { + bounded + } +} + +fn display_name_or_unknown(value: &str) -> String { + let value = bounded_text(value); + if value.is_empty() { + "Unknown application".to_string() + } else { + value + } +} + +#[cfg(test)] +#[path = "tests/attribution.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/diagnostics.rs b/crates/unixnotis-core/src/model/diagnostics.rs new file mode 100644 index 000000000..c4e769a8d --- /dev/null +++ b/crates/unixnotis-core/src/model/diagnostics.rs @@ -0,0 +1,67 @@ +//! Structured application-attribution evidence for diagnostic clients + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use zbus::zvariant::Type; + +/// Trust level of the desktop record selected by launch verification +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum RecordTrust { + #[default] + None = 0, + Portal = 1, + System = 2, + User = 3, +} + +/// Evidence that establishes or weakens one desktop launch association +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum LaunchAuthorityView { + #[default] + None = 0, + DedicatedExecutable = 1, + ProtectedPayload = 2, + DynamicOnly = 3, + Ambiguous = 4, +} + +/// Reliability of the argument boundaries read for the sender process +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum CommandLineQualityView { + Structured = 0, + RewrittenProcessTitle = 1, + Truncated = 2, + #[default] + Unavailable = 3, +} + +/// Summary of the strongest launch-verification result +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize_repr, Deserialize_repr, Type)] +#[repr(u8)] +pub enum LaunchVerificationView { + Verified = 0, + #[default] + InsufficientEvidence = 1, + DefinitiveMismatch = 2, +} + +/// Bounded evidence retained for notification explanation and debug logs +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct AttributionDiagnostics { + pub claimed_name: String, + pub claimed_desktop_entry: String, + pub sender_executable: String, + pub matched_desktop_id: String, + pub record_trust: RecordTrust, + pub launch_authority: LaunchAuthorityView, + pub command_line_quality: CommandLineQualityView, + pub verification: LaunchVerificationView, + pub reason: String, +} + +#[cfg(test)] +#[path = "tests/diagnostics.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/image/hints.rs b/crates/unixnotis-core/src/model/image/hints.rs index 1048fe6be..a45e2dab9 100644 --- a/crates/unixnotis-core/src/model/image/hints.rs +++ b/crates/unixnotis-core/src/model/image/hints.rs @@ -4,15 +4,15 @@ use std::collections::HashMap; use zbus::zvariant::{Array, OwnedValue, Structure, Value}; -use crate::util; - -use super::{ - ImageData, NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_PATH_BYTES, -}; +use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; impl NotificationImage { - pub fn from_hints(app_name: &str, app_icon: &str, hints: &HashMap) -> Self { - // The notification spec prefers image-data over image-path and app_icon + pub fn from_hints( + _app_name: &str, + app_icon: &str, + hints: &HashMap, + ) -> Self { + // Embedded pixels are already detached from the sender's filesystem let image_data = hints .get("image-data") .and_then(Self::parse_image_data) @@ -20,37 +20,46 @@ impl NotificationImage { .or_else(|| hints.get("icon_data").and_then(Self::parse_image_data)); let image_data = image_data.filter(Self::is_image_data_usable); - let mut image_path = hints - .get("image-path") - .and_then(owned_to_string) - .or_else(|| hints.get("image_path").and_then(owned_to_string)) - .map(|path| normalize_image_path(&path)) - .unwrap_or_default(); + Self { + badge_icon: String::new(), + claimed_theme_icon: Self::sanitize_theme_icon_hint(app_icon), + claimed_desktop_id: hints + .get("desktop-entry") + .and_then(|value| value.try_clone().ok()) + .and_then(|value| String::try_from(value).ok()) + .map_or_else(String::new, |value| Self::sanitize_desktop_id_hint(&value)), + sender_visual_role: super::NotificationVisualRole::None, + sender_visual: ImageData::default(), + content_image: image_data.unwrap_or_default(), + } + } - // Desktop-entry values map to icon theme names after the suffix is removed - let desktop_entry = hints - .get("desktop-entry") - .and_then(owned_to_string) - .map(|entry| strip_desktop_suffix(&entry)); - let app_icon_path = normalize_app_icon_path(app_icon); - if image_path.is_empty() { - if let Some(path) = app_icon_path.as_ref() { - image_path = path.clone(); - } + fn sanitize_theme_icon_hint(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.len() > 128 + || value.starts_with('.') + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return String::new(); } - let icon_name = bound_icon_name(&resolve_icon_name( - app_name, - app_icon, - app_icon_path.as_ref(), - desktop_entry, - )); + value.to_string() + } - Self { - has_image_data: image_data.is_some(), - image_data: image_data.unwrap_or_default(), - image_path, - icon_name, + fn sanitize_desktop_id_hint(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.len() > 128 + || value.starts_with('.') + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) + { + return String::new(); } + value.to_string() } pub(super) fn parse_image_data(value: &OwnedValue) -> Option { @@ -94,118 +103,3 @@ impl NotificationImage { Some(bytes) } } - -fn resolve_icon_name( - app_name: &str, - app_icon: &str, - app_icon_path: Option<&String>, - desktop_entry: Option, -) -> String { - if app_icon_path.is_some() { - return String::new(); - } - if !app_icon.is_empty() && !app_icon.starts_with("file://") { - return strip_desktop_suffix(app_icon); - } - if let Some(desktop_entry) = desktop_entry { - return desktop_entry; - } - if !app_name.is_empty() { - return app_name.to_string(); - } - String::new() -} - -fn normalize_app_icon_path(app_icon: &str) -> Option { - // Normalize the incoming icon path first so later checks operate on a cleaned, - // bounded value rather than raw metadata input - let path = normalize_image_path(app_icon); - - // Only accept paths that are already absolute filesystem paths or valid file URIs - // Relative paths are rejected because app icons need to resolve unambiguously - if path.starts_with('/') || path.starts_with("file://") { - Some(path) - } else { - None - } -} - -fn normalize_image_path(value: &str) -> String { - // Sanitize display-facing metadata and enforce the maximum byte length before - // doing any URI-specific normalization - let bounded = sanitize_metadata_string(value, MAX_IMAGE_PATH_BYTES); - - // File URIs get normalized into the accepted form when possible. Invalid or - // unsupported file URI shapes fall back to an empty string - if bounded.starts_with("file://") { - return normalize_file_uri(&bounded).unwrap_or_default(); - } - - // Non-file URI values are returned after sanitization/truncation only - bounded -} - -fn normalize_file_uri(value: &str) -> Option { - // This function only handles file:// URIs; anything else is rejected immediately - let stripped = value.strip_prefix("file://")?; - - // A file URI with an absolute path is already in the expected form - if stripped.starts_with('/') { - return Some(value.to_string()); - } - - // Convert localhost-based file URIs into the canonical absolute-path form - stripped - .strip_prefix("localhost/") - .map(|path| format!("file:///{path}")) -} - -fn bound_icon_name(value: &str) -> String { - // Icon names use the same metadata sanitization path, but with the icon-name - // byte limit instead of the image-path byte limit - sanitize_metadata_string(value, MAX_ICON_NAME_BYTES) -} - -fn sanitize_metadata_string(value: &str, max_bytes: usize) -> String { - // Remove inline display control/problematic characters before trimming and - // applying the final UTF-8-safe byte limit - let cleaned = util::sanitize_inline_display_text(value); - truncate_utf8_bytes(cleaned.trim(), max_bytes) -} - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - // Fast path: avoid allocation/truncation work when the value already fits - if value.len() <= max_bytes { - return value.to_string(); - } - - // Find the last valid UTF-8 character boundary that does not exceed max_bytes, - // so slicing never cuts through the middle of a multi-byte character - let end = value - .char_indices() - .map(|(index, _)| index) - .take_while(|index| *index <= max_bytes) - .last() - .unwrap_or(0); - - // Return only the byte-safe prefix - value.get(..end).unwrap_or_default().to_string() -} - -pub(in crate::model) fn owned_to_string(value: &OwnedValue) -> Option { - // Clone the owned D-Bus value first, then attempt to extract it as a String - // Any clone or conversion failure is represented as None - value - .try_clone() - .ok() - .and_then(|owned| String::try_from(owned).ok()) -} - -pub(in crate::model) fn strip_desktop_suffix(value: &str) -> String { - // Desktop entries may include ".desktop"; icon themes usually omit it - if let Some(stripped) = value.strip_suffix(".desktop") { - stripped.to_string() - } else { - value.to_string() - } -} diff --git a/crates/unixnotis-core/src/model/image/mod.rs b/crates/unixnotis-core/src/model/image/mod.rs index 4076853a7..e8bceccca 100644 --- a/crates/unixnotis-core/src/model/image/mod.rs +++ b/crates/unixnotis-core/src/model/image/mod.rs @@ -6,10 +6,8 @@ mod normalize; mod projection; mod rgb; -pub use model::{ImageData, NotificationImage}; -pub(super) use model::{ - MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, MAX_IMAGE_PATH_BYTES, -}; +pub use model::{ImageData, NotificationImage, NotificationVisualRole}; +pub(super) use model::{MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-core/src/model/image/model.rs b/crates/unixnotis-core/src/model/image/model.rs index 1d0b181e1..818515c5b 100644 --- a/crates/unixnotis-core/src/model/image/model.rs +++ b/crates/unixnotis-core/src/model/image/model.rs @@ -5,6 +5,7 @@ //! RGB expansion live in focused files under `model/image` use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::Type; /// Raw image data payload from notification hints @@ -19,21 +20,36 @@ pub struct ImageData { pub data: Vec, } -/// Image information derived from standard hints and `app_icon` -#[expect( - clippy::unsafe_derive_deserialize, - reason = "deserialization only fills owned fields; nested image methods validate buffers before unsafe SIMD access" -)] +/// Presentation role selected by the daemon after attribution and payload checks +#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum NotificationVisualRole { + #[default] + None = 0, + ConversationAvatar = 1, + ApplicationProvidedIcon = 2, + ContentImage = 3, +} + +/// Pixel visuals retained after daemon-side validation #[derive(Debug, Clone, Serialize, Deserialize, Type, Default, PartialEq, Eq)] pub struct NotificationImage { - pub has_image_data: bool, - pub image_data: ImageData, - pub image_path: String, - pub icon_name: String, + /// Desktop-index-selected identity icon + pub badge_icon: String, + /// Sender-supplied theme name retained only as a decorative lookup hint + #[serde(default)] + pub claimed_theme_icon: String, + /// Sender-supplied desktop id retained only for bounded decorative lookup + /// This value is never attribution evidence or an authorization input + #[serde(default)] + pub claimed_desktop_id: String, + /// Safely decoded sender-provided visual + pub sender_visual_role: NotificationVisualRole, + pub sender_visual: ImageData, + /// Safely decoded message content image + pub content_image: ImageData, } // Bound untrusted image payloads to keep daemon/UI memory predictable under floods pub(in crate::model) const MAX_IMAGE_BYTES: usize = 256 * 1024; pub(in crate::model) const MAX_IMAGE_DIMENSION: i32 = 256; -pub(in crate::model) const MAX_IMAGE_PATH_BYTES: usize = 1024; -pub(in crate::model) const MAX_ICON_NAME_BYTES: usize = 256; diff --git a/crates/unixnotis-core/src/model/image/normalize.rs b/crates/unixnotis-core/src/model/image/normalize.rs index 4a186f7d2..0b2527809 100644 --- a/crates/unixnotis-core/src/model/image/normalize.rs +++ b/crates/unixnotis-core/src/model/image/normalize.rs @@ -3,6 +3,18 @@ use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION}; impl NotificationImage { + /// Returns the maximum decoded image payload retained by the notification model + #[must_use] + pub const fn retained_byte_limit() -> usize { + MAX_IMAGE_BYTES + } + + /// Returns the maximum dimension retained by the notification model + #[must_use] + pub const fn retained_dimension_limit() -> i32 { + MAX_IMAGE_DIMENSION + } + pub(super) fn is_image_data_usable(data: &ImageData) -> bool { // Hard dimension caps keep texture creation and D-Bus payloads predictable if data.width > MAX_IMAGE_DIMENSION || data.height > MAX_IMAGE_DIMENSION { @@ -23,7 +35,9 @@ impl NotificationImage { .is_some() } - pub(super) fn normalize_image_data(image: ImageData) -> Option { + /// Validates raw pixels and normalizes supported RGB images to RGBA + #[must_use] + pub fn normalize_image_data(image: ImageData) -> Option { if image.bits_per_sample != 8 { return None; } diff --git a/crates/unixnotis-core/src/model/image/projection.rs b/crates/unixnotis-core/src/model/image/projection.rs index 80bc09f71..a7bf0e864 100644 --- a/crates/unixnotis-core/src/model/image/projection.rs +++ b/crates/unixnotis-core/src/model/image/projection.rs @@ -1,30 +1,17 @@ //! Lightweight notification image projections -use super::{ImageData, NotificationImage}; +use super::NotificationImage; impl NotificationImage { #[must_use] pub fn for_listing(&self) -> Self { - if self.image_data.data.is_empty() { - return self.clone(); - } - Self { - has_image_data: false, - image_data: ImageData::default(), - image_path: self.image_path.clone(), - icon_name: self.icon_name.clone(), - } + // All retained images are already bounded daemon-owned pixels + self.clone() } #[must_use] pub fn for_history(&self) -> Self { - if self.has_image_data && (!self.image_path.is_empty() || !self.icon_name.is_empty()) { - let mut trimmed = self.clone(); - // History rows can use a path or theme name, so raw bytes are dropped - trimmed.has_image_data = false; - trimmed.image_data = ImageData::default(); - return trimmed; - } + // History receives pixels only; sender paths never cross this boundary self.clone() } } diff --git a/crates/unixnotis-core/src/model/image/rgb.rs b/crates/unixnotis-core/src/model/image/rgb.rs index d340aab6b..72d0e7c13 100644 --- a/crates/unixnotis-core/src/model/image/rgb.rs +++ b/crates/unixnotis-core/src/model/image/rgb.rs @@ -1,7 +1,8 @@ //! RGB-to-RGBA image expansion - -#[cfg(target_arch = "x86_64")] -use std::sync::OnceLock; +//! +//! Uses the scalar path exclusively to keep the module free of unsafe SIMD intrinsics. +//! Modern `x86_64` compilers auto-vectorize the hot pixel loop, and notification images +//! are small enough that any performance difference is negligible. use super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; @@ -22,15 +23,6 @@ impl NotificationImage { } let mut rgba = vec![0u8; output_len]; - #[cfg(target_arch = "x86_64")] - let use_simd = { - // Cache CPUID once so repeated image hints do not re-run feature detection - static HAS_SSSE3: OnceLock = OnceLock::new(); - *HAS_SSSE3.get_or_init(|| std::is_x86_feature_detected!("ssse3")) - }; - #[cfg(not(target_arch = "x86_64"))] - let use_simd = false; - for y in 0..height { let row_start = y.saturating_mul(rowstride); let row_bytes = width.checked_mul(3)?; @@ -42,17 +34,7 @@ impl NotificationImage { let dst_start = (y * width) * 4; let dst_end = dst_start + width * 4; let dst_row = &mut rgba[dst_start..dst_end]; - if use_simd { - #[cfg(target_arch = "x86_64")] - // SAFETY: Guarded by SSSE3 detection; row slices are bounded to full pixels - unsafe { - expand_rgb_row_ssse3(row, dst_row); - } - #[cfg(not(target_arch = "x86_64"))] - expand_rgb_row_scalar(row, dst_row); - } else { - expand_rgb_row_scalar(row, dst_row); - } + expand_rgb_row_scalar(row, dst_row); } Some(ImageData { @@ -75,49 +57,3 @@ pub(in crate::model) fn expand_rgb_row_scalar(src: &[u8], dst: &mut [u8]) { dst[dst_index..dst_index + 4].copy_from_slice(&packed.to_le_bytes()); } } - -#[cfg(target_arch = "x86_64")] -#[target_feature(enable = "ssse3")] -#[expect( - clippy::cast_ptr_alignment, - reason = "the SSSE3 loadu and storeu intrinsics explicitly support unaligned byte buffers" -)] -pub(in crate::model) unsafe fn expand_rgb_row_ssse3(src: &[u8], dst: &mut [u8]) { - // SSSE3 shuffles 12-byte RGB quads into 16-byte RGBA blocks with a fixed alpha mask - use std::arch::x86_64::{ - __m128i, _mm_loadu_si128, _mm_or_si128, _mm_setr_epi8, _mm_shuffle_epi8, _mm_storeu_si128, - }; - - let mut s = 0usize; - let mut d = 0usize; - - let mask: __m128i = _mm_setr_epi8(0, 1, 2, -128, 3, 4, 5, -128, 6, 7, 8, -128, 9, 10, 11, -128); - let alpha: __m128i = _mm_setr_epi8(0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1, 0, 0, 0, -1); - - // Process 4 pixels at a time (12 bytes -> 16 bytes). Read requires 16 bytes - while s + 16 <= src.len() { - // SAFETY: The loop guard proves the 16-byte unaligned read remains inside src - let src_ptr = unsafe { src.as_ptr().add(s) }; - // SAFETY: SSSE3 permits this pointer to be unaligned - let chunk = unsafe { _mm_loadu_si128(src_ptr.cast::<__m128i>()) }; - let shuffled = _mm_shuffle_epi8(chunk, mask); - let with_alpha = _mm_or_si128(shuffled, alpha); - // SAFETY: Four source pixels always map to the next 16-byte destination block - let dst_ptr = unsafe { dst.as_mut_ptr().add(d) }; - // SAFETY: The caller allocates four RGBA bytes for every source RGB pixel - unsafe { _mm_storeu_si128(dst_ptr.cast::<__m128i>(), with_alpha) }; - s += 12; - d += 16; - } - - // Tail handles the remaining one to three pixels - let remaining_pixels = (src.len().saturating_sub(s)) / 3; - for index in 0..remaining_pixels { - let s = s + index * 3; - let d = d + index * 4; - dst[d] = src[s]; - dst[d + 1] = src[s + 1]; - dst[d + 2] = src[s + 2]; - dst[d + 3] = 255; - } -} diff --git a/crates/unixnotis-core/src/model/image/tests/hints.rs b/crates/unixnotis-core/src/model/image/tests/hints.rs index 6dea6d90c..16fa7e37c 100644 --- a/crates/unixnotis-core/src/model/image/tests/hints.rs +++ b/crates/unixnotis-core/src/model/image/tests/hints.rs @@ -1,11 +1,10 @@ -use super::super::hints::{owned_to_string, strip_desktop_suffix}; -use super::super::{NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_PATH_BYTES}; +use super::super::{ImageData, NotificationImage, NotificationVisualRole, MAX_IMAGE_BYTES}; use super::{image_data_value, string_value}; use std::collections::HashMap; use zbus::zvariant::{OwnedValue, Structure, Value}; #[test] -fn from_hints_prefers_valid_image_data_over_image_path_and_icon() { +fn embedded_image_data_is_retained_as_content() { let mut hints = HashMap::new(); hints.insert( "image-data".to_string(), @@ -13,155 +12,130 @@ fn from_hints_prefers_valid_image_data_over_image_path_and_icon() { ); hints.insert("image-path".to_string(), string_value("/tmp/icon.png")); - let image = NotificationImage::from_hints("App", "fallback-icon", &hints); - - assert!(image.has_image_data); - assert_eq!(image.image_data.data, vec![1, 2, 3, 4]); - assert_eq!(image.image_path, "/tmp/icon.png"); - assert_eq!(image.icon_name, "fallback-icon"); -} - -#[test] -fn from_hints_falls_back_from_invalid_image_data_to_app_icon_path() { - let mut hints = HashMap::new(); - hints.insert( - "image-data".to_string(), - image_data_value(0, 1, 4, true, 8, 4, vec![1, 2, 3, 4]), - ); - - let image = NotificationImage::from_hints("App", "/tmp/app-icon.png", &hints); - - assert!(!image.has_image_data); - assert_eq!(image.image_path, "/tmp/app-icon.png"); - assert!(image.icon_name.is_empty()); + let image = NotificationImage::from_hints("App", "/tmp/app.png", &hints); + assert_eq!(image.sender_visual_role, NotificationVisualRole::None); + assert_eq!(image.content_image.data, vec![1, 2, 3, 4]); + assert!(image.badge_icon.is_empty()); } #[test] -fn from_hints_uses_desktop_entry_before_app_name_for_icon_name() { +fn app_icon_and_image_path_never_become_retained_host_paths() { let mut hints = HashMap::new(); - hints.insert( - "desktop-entry".to_string(), - string_value("org.example.App.desktop"), - ); - - let image = NotificationImage::from_hints("Fallback App", "", &hints); - - assert_eq!(image.icon_name, "org.example.App"); -} - -#[test] -fn from_hints_uses_app_name_when_no_icon_hints_exist() { - let hints = HashMap::new(); - - let image = NotificationImage::from_hints("Fallback App", "", &hints); - - assert_eq!(image.icon_name, "Fallback App"); - assert!(image.image_path.is_empty()); - assert!(!image.has_image_data); + hints.insert("image-path".to_string(), string_value("/tmp/icon.png")); + let image = NotificationImage::from_hints("App", "/tmp/app.png", &hints); + assert!(image.sender_visual.data.is_empty()); + assert!(image.content_image.data.is_empty()); } #[test] -fn from_hints_bounds_image_path_and_icon_name_without_splitting_utf8() { - let mut hints = HashMap::new(); - let long_path = format!("/tmp/{}{}", "a".repeat(MAX_IMAGE_PATH_BYTES), "é"); - let long_icon = format!("{}{}", "b".repeat(MAX_ICON_NAME_BYTES), "é"); - hints.insert("image-path".to_string(), string_value(&long_path)); - - let image = NotificationImage::from_hints("App", &long_icon, &hints); - - assert!(image.image_path.len() <= MAX_IMAGE_PATH_BYTES); - assert!(image.image_path.is_char_boundary(image.image_path.len())); - assert!(image.icon_name.len() <= MAX_ICON_NAME_BYTES); - assert!(image.icon_name.is_char_boundary(image.icon_name.len())); +fn app_icon_theme_names_are_retained_only_as_bounded_lookup_hints() { + let image = NotificationImage::from_hints("App", "example-player", &HashMap::new()); + assert_eq!(image.claimed_theme_icon, "example-player"); + + for value in [ + "/tmp/icon.png", + "file:///tmp/icon.png", + "../icon", + "icon name", + "icon:remote", + ] { + let image = NotificationImage::from_hints("App", value, &HashMap::new()); + assert!(image.claimed_theme_icon.is_empty(), "unsafe hint: {value}"); + } } #[test] -fn from_hints_truncates_image_path_at_previous_utf8_boundary() { +fn desktop_entry_is_retained_only_as_a_bounded_branding_hint() { let mut hints = HashMap::new(); - let prefix = format!("/{}", "a".repeat(MAX_IMAGE_PATH_BYTES - 2)); hints.insert( - "image-path".to_string(), - string_value(&format!("{prefix}é-tail")), + "desktop-entry".to_string(), + string_value("Example.Chat.desktop"), ); - let image = NotificationImage::from_hints("App", "", &hints); + assert_eq!(image.claimed_desktop_id, "Example.Chat.desktop"); + + for value in [ + "/tmp/example.desktop", + "file:///tmp/example", + "bad id", + ".hidden", + ] { + let mut hints = HashMap::new(); + hints.insert("desktop-entry".to_string(), string_value(value)); + let image = NotificationImage::from_hints("App", "", &hints); + assert!( + image.claimed_desktop_id.is_empty(), + "unsafe desktop hint: {value}" + ); + } + + for value in [ + "example/chat", + "example\\chat", + "example:chat", + "example chat", + "example@chat", + ] { + let mut hints = HashMap::new(); + hints.insert("desktop-entry".to_string(), string_value(value)); + let image = NotificationImage::from_hints("App", "", &hints); + assert!( + image.claimed_desktop_id.is_empty(), + "unsafe desktop hint: {value}" + ); + } - assert_eq!(image.image_path, prefix); - assert_eq!(image.image_path.len(), MAX_IMAGE_PATH_BYTES - 1); - assert!(image.image_path.is_char_boundary(image.image_path.len())); -} - -#[test] -fn from_hints_normalizes_localhost_file_uri_and_ignores_remote_file_uri_path() { let mut hints = HashMap::new(); - hints.insert( - "image-path".to_string(), - string_value("file://localhost/tmp/icon%20name.png"), + hints.insert("desktop-entry".to_string(), string_value(&"a".repeat(128))); + assert_eq!( + NotificationImage::from_hints("App", "", &hints) + .claimed_desktop_id + .len(), + 128 ); - - let image = NotificationImage::from_hints("App", "file://example.com/tmp/app.png", &hints); - - assert_eq!(image.image_path, "file:///tmp/icon%20name.png"); - assert_eq!(image.icon_name, "App"); + hints.insert("desktop-entry".to_string(), string_value(&"a".repeat(129))); + assert!(NotificationImage::from_hints("App", "", &hints) + .claimed_desktop_id + .is_empty()); } #[test] -fn parse_image_data_accepts_legacy_hint_aliases_and_rejects_wrong_field_count() { - let parsed = NotificationImage::parse_image_data(&image_data_value( - 1, - 1, - 4, - true, - 8, - 4, - vec![1, 2, 3, 4], - )) - .expect("valid image-data should parse"); - assert_eq!(parsed.width, 1); - assert_eq!(parsed.channels, 4); - +fn parse_image_data_rejects_wrong_structure() { let wrong = Structure::from((1_i32, 1_i32)); - let wrong: OwnedValue = Value::from(wrong) - .try_into() - .expect("wrong structure should convert"); + let wrong: OwnedValue = Value::from(wrong).try_into().expect("structure conversion"); assert!(NotificationImage::parse_image_data(&wrong).is_none()); } #[test] -fn array_to_bytes_rejects_empty_large_and_non_byte_arrays() { - let empty = Value::from(Vec::::new()); - assert!(NotificationImage::array_to_bytes(&empty).is_none()); - - let too_large = Value::from(vec![0_u8; super::super::MAX_IMAGE_BYTES + 1]); - assert!(NotificationImage::array_to_bytes(&too_large).is_none()); - - let exact_limit = Value::from(vec![0_u8; super::super::MAX_IMAGE_BYTES]); +fn parse_image_data_accepts_legacy_aliases() { + let value = image_data_value(1, 1, 4, true, 8, 4, vec![1, 2, 3, 4]); + let parsed = NotificationImage::parse_image_data(&value).expect("valid image data"); assert_eq!( - NotificationImage::array_to_bytes(&exact_limit) - .expect("exact limit should be accepted") - .len(), - super::super::MAX_IMAGE_BYTES + parsed, + ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 4], + } ); +} - let wrong_type = Value::from(vec![1_u32]); - assert!(NotificationImage::array_to_bytes(&wrong_type).is_none()); +#[test] +fn parse_image_data_enforces_the_exact_raw_byte_boundary() { + let accepted = image_data_value(1, 1, 4, true, 8, 4, vec![0; MAX_IMAGE_BYTES]); + assert!(NotificationImage::parse_image_data(&accepted).is_some()); - let bytes = Value::from(vec![1_u8, 2, 3]); - assert_eq!( - NotificationImage::array_to_bytes(&bytes), - Some(vec![1, 2, 3]) - ); + let rejected = image_data_value(1, 1, 4, true, 8, 4, vec![0; MAX_IMAGE_BYTES + 1]); + assert!(NotificationImage::parse_image_data(&rejected).is_none()); } #[test] -fn owned_string_and_desktop_suffix_helpers_match_hint_expectations() { - assert_eq!( - owned_to_string(&string_value("org.example.App.desktop")).as_deref(), - Some("org.example.App.desktop") - ); - assert_eq!( - strip_desktop_suffix("org.example.App.desktop"), - "org.example.App" - ); - assert_eq!(strip_desktop_suffix("org.example.App"), "org.example.App"); +fn array_to_bytes_rejects_empty_payloads() { + let value = Value::from(Vec::::new()); + + assert!(NotificationImage::array_to_bytes(&value).is_none()); } diff --git a/crates/unixnotis-core/src/model/image/tests/model.rs b/crates/unixnotis-core/src/model/image/tests/model.rs index ef4a6a55b..8f733c990 100644 --- a/crates/unixnotis-core/src/model/image/tests/model.rs +++ b/crates/unixnotis-core/src/model/image/tests/model.rs @@ -1,20 +1,20 @@ use super::super::{ - ImageData, NotificationImage, MAX_ICON_NAME_BYTES, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, - MAX_IMAGE_PATH_BYTES, + ImageData, NotificationImage, NotificationVisualRole, MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, }; #[test] fn image_models_default_to_empty_bounded_payloads() { - let data = ImageData::default(); let image = NotificationImage::default(); - - assert_eq!(data.width, 0); - assert!(data.data.is_empty()); - assert!(!image.has_image_data); - assert!(image.image_path.is_empty()); - assert!(image.icon_name.is_empty()); + assert!(image.badge_icon.is_empty()); + assert_eq!(image.sender_visual_role, NotificationVisualRole::None); + assert!(image.sender_visual.data.is_empty()); + assert!(image.content_image.data.is_empty()); assert_eq!(MAX_IMAGE_BYTES, 256 * 1024); assert_eq!(MAX_IMAGE_DIMENSION, 256); - assert_eq!(MAX_IMAGE_PATH_BYTES, 1024); - assert_eq!(MAX_ICON_NAME_BYTES, 256); + assert_eq!(NotificationImage::retained_byte_limit(), MAX_IMAGE_BYTES); + assert_eq!( + NotificationImage::retained_dimension_limit(), + MAX_IMAGE_DIMENSION + ); + assert_eq!(ImageData::default().width, 0); } diff --git a/crates/unixnotis-core/src/model/image/tests/projection.rs b/crates/unixnotis-core/src/model/image/tests/projection.rs index aeee0bc7b..5b71ab86e 100644 --- a/crates/unixnotis-core/src/model/image/tests/projection.rs +++ b/crates/unixnotis-core/src/model/image/tests/projection.rs @@ -1,51 +1,41 @@ -use super::super::{ImageData, NotificationImage}; +use super::super::{ImageData, NotificationImage, NotificationVisualRole}; -#[test] -fn listing_projection_removes_raw_image_bytes_but_keeps_identifiers() { - let image = NotificationImage { - has_image_data: true, - image_data: ImageData { +fn image() -> NotificationImage { + NotificationImage { + badge_icon: "mail".to_string(), + claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), + sender_visual_role: NotificationVisualRole::ConversationAvatar, + sender_visual: ImageData { width: 1, height: 1, rowstride: 4, has_alpha: true, bits_per_sample: 8, channels: 4, - data: vec![9, 8, 7, 6], + data: vec![1, 2, 3, 4], }, - image_path: "/tmp/icon.png".to_string(), - icon_name: "icon-name".to_string(), - }; - - let listing = image.for_listing(); - - assert!(!listing.has_image_data); - assert!(listing.image_data.data.is_empty()); - assert_eq!(listing.image_path, "/tmp/icon.png"); - assert_eq!(listing.icon_name, "icon-name"); -} - -#[test] -fn history_projection_drops_raw_data_only_when_alternate_identifier_exists() { - let with_icon = NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 1, height: 1, rowstride: 4, has_alpha: true, bits_per_sample: 8, channels: 4, - data: vec![1, 2, 3, 4], + data: vec![4, 3, 2, 1], }, - image_path: String::new(), - icon_name: "app-icon".to_string(), - }; - let without_icon = NotificationImage { - icon_name: String::new(), - ..with_icon.clone() - }; + } +} + +#[test] +fn listing_projection_keeps_bounded_daemon_owned_pixels() { + let listing = image().for_listing(); + assert_eq!(listing, image()); +} - assert!(!with_icon.for_history().has_image_data); - assert!(without_icon.for_history().has_image_data); +#[test] +fn history_projection_keeps_safe_pixels_without_paths() { + let history = image().for_history(); + assert_eq!(history.sender_visual.data, vec![1, 2, 3, 4]); + assert_eq!(history.content_image.data, vec![4, 3, 2, 1]); } diff --git a/crates/unixnotis-core/src/model/image/tests/rgb.rs b/crates/unixnotis-core/src/model/image/tests/rgb.rs index 621480641..3f9285b9d 100644 --- a/crates/unixnotis-core/src/model/image/tests/rgb.rs +++ b/crates/unixnotis-core/src/model/image/tests/rgb.rs @@ -1,6 +1,4 @@ use super::super::rgb::expand_rgb_row_scalar; -#[cfg(target_arch = "x86_64")] -use super::super::rgb::expand_rgb_row_ssse3; use super::super::{ImageData, NotificationImage, MAX_IMAGE_BYTES}; #[test] @@ -82,21 +80,3 @@ fn scalar_rgb_expansion_writes_expected_alpha_bytes() { assert_eq!(out, vec![1, 2, 3, 255, 4, 5, 6, 255]); } - -#[cfg(target_arch = "x86_64")] -#[test] -fn ssse3_rgb_expansion_matches_scalar_when_supported() { - if !std::is_x86_feature_detected!("ssse3") { - return; - } - - let src = [1_u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; - let mut scalar = vec![0; src.len() / 3 * 4]; - let mut simd = vec![0; scalar.len()]; - - expand_rgb_row_scalar(&src, &mut scalar); - // SAFETY: The test is guarded by the same runtime feature probe as production - unsafe { expand_rgb_row_ssse3(&src, &mut simd) }; - - assert_eq!(simd, scalar); -} diff --git a/crates/unixnotis-core/src/model/interaction.rs b/crates/unixnotis-core/src/model/interaction.rs new file mode 100644 index 000000000..652f8c137 --- /dev/null +++ b/crates/unixnotis-core/src/model/interaction.rs @@ -0,0 +1,75 @@ +//! Independent authority for application-owned notification controls + +use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use zbus::zvariant::Type; + +/// Policy for credential-like inline text controls +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum InlineReplyPolicy { + Allow = 0, + Confirm = 1, + #[default] + Deny = 2, +} + +/// Policy for one application-owned action signal +#[derive(Debug, Copy, Clone, Default, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] +#[repr(u8)] +pub enum ApplicationActionPolicy { + Allow = 0, + Confirm = 1, + #[default] + Deny = 2, +} + +/// Independent authority for each interaction surface +#[derive(Debug, Copy, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct InteractionPolicies { + pub default_activation: ApplicationActionPolicy, + pub action_buttons: ApplicationActionPolicy, + pub inline_reply: InlineReplyPolicy, +} + +impl InteractionPolicies { + /// Future strong boundaries may grant every advertised interaction + pub const AUTHENTICATED: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Allow, + inline_reply: InlineReplyPolicy::Allow, + }; + + /// Native association keeps compatible card activation but gates richer controls + pub const NATIVE_COMPATIBILITY: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Confirm, + inline_reply: InlineReplyPolicy::Deny, + }; + + /// A strongly owner-bound sender may expose only the advertised default action + /// This does not authenticate application branding or grant richer controls + pub const OWNER_BOUND_DEFAULT: Self = Self { + default_activation: ApplicationActionPolicy::Allow, + action_buttons: ApplicationActionPolicy::Deny, + inline_reply: InlineReplyPolicy::Deny, + }; + + /// Brokered and user-local associations require confirmation for every action + pub const CONFIRM_ACTIONS: Self = Self { + default_activation: ApplicationActionPolicy::Confirm, + action_buttons: ApplicationActionPolicy::Confirm, + inline_reply: InlineReplyPolicy::Deny, + }; + + /// Uncertain or contradictory senders cannot emit application-owned signals + pub const DENY: Self = Self { + default_activation: ApplicationActionPolicy::Deny, + action_buttons: ApplicationActionPolicy::Deny, + inline_reply: InlineReplyPolicy::Deny, + }; +} + +#[cfg(test)] +#[path = "tests/interaction.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/mod.rs b/crates/unixnotis-core/src/model/mod.rs index 9dfd52e7d..54d1c202f 100644 --- a/crates/unixnotis-core/src/model/mod.rs +++ b/crates/unixnotis-core/src/model/mod.rs @@ -1,11 +1,24 @@ //! Notification data model and image hint parsing // Keep the public model surface small by splitting large helpers into files. +mod attribution; +mod diagnostics; mod image; +mod interaction; mod notification; +mod reply; mod types; // Re-export the public surface so callers continue to import from unixnotis_core::model. -pub use image::{ImageData, NotificationImage}; -pub use notification::{Notification, NotificationView}; +pub use attribution::{ + AttributionReason, AttributionStatus, IdentityAssurance, NotificationAttribution, +}; +pub use diagnostics::{ + AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + RecordTrust, +}; +pub use image::{ImageData, NotificationImage, NotificationVisualRole}; +pub use interaction::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; +pub use notification::{Notification, NotificationKey, NotificationView}; +pub use reply::InlineReply; pub use types::{Action, Urgency}; diff --git a/crates/unixnotis-core/src/model/notification.rs b/crates/unixnotis-core/src/model/notification.rs index 98496281c..21315fb99 100644 --- a/crates/unixnotis-core/src/model/notification.rs +++ b/crates/unixnotis-core/src/model/notification.rs @@ -6,22 +6,44 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Type}; +use super::attribution::NotificationAttribution; +use super::diagnostics::AttributionDiagnostics; use super::image::NotificationImage; +use super::interaction::InlineReplyPolicy; +use super::reply::InlineReply; use super::types::{Action, Urgency}; +use crate::util::{fold_text_for_layout, MAX_DISPLAY_TOKEN_WIDTH}; +use crate::PopupDecisionRecord; + +/// Exact identity of one committed notification payload +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq, Hash)] +pub struct NotificationKey { + pub id: u32, + pub generation: u64, +} /// Full notification record stored by the daemon #[derive(Debug)] pub struct Notification { // Stable identifier assigned by the daemon pub id: u32, + // Process-wide commit generation distinguishes same-ID replacements + pub generation: u64, // Origin metadata for display and filtering pub app_name: String, pub app_icon: String, + // Daemon-resolved application association stays stable for the notification lifetime + pub attribution: NotificationAttribution, + // Structured evidence is retained for authenticated explanation requests + pub attribution_diagnostics: AttributionDiagnostics, // User-facing content as provided by the sender pub summary: String, pub body: String, // Optional actions supplied by the app pub actions: Vec, + // Reply metadata exists only for an explicit KDE-compatible action + pub inline_reply: InlineReply, + pub inline_reply_policy: InlineReplyPolicy, // Raw hints preserved for storage and downstream consumers pub hints: HashMap, // Derived urgency used for styling and escalation @@ -46,20 +68,48 @@ pub struct Notification { } impl Notification { + /// Return the exact key for this committed payload + #[must_use] + pub const fn key(&self) -> NotificationKey { + NotificationKey { + id: self.id, + generation: self.generation, + } + } + + /// Update canonical urgency and any retained protocol projection together + pub fn set_urgency(&mut self, urgency: Urgency) { + self.urgency = urgency; + // Retained hints are projections of the model and never independent policy inputs + if self.hints.contains_key("urgency") { + self.hints + .insert("urgency".to_string(), OwnedValue::from(urgency.as_u32())); + } + } + /// Convert to a lightweight view for UI consumption #[must_use] pub fn to_view(&self) -> NotificationView { NotificationView { id: self.id, - app_name: self.app_name.clone(), - summary: notification_plain_text(&self.summary), - body: notification_plain_text(&self.body), + generation: self.generation, + app_name: self.attribution.display_name.clone(), + attribution: self.attribution.clone(), + summary: notification_display_text(&self.summary), + body: notification_display_text(&self.body), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), + category: self.category.clone().unwrap_or_default(), // Center and popup policy both need the transient bit to stay in sync is_transient: self.is_transient, + // Relative popup time needs the original commit time after reconnect and seed + received_at_unix_seconds: self.received_at.timestamp(), // UIs only need the text, actions, and image payload used for rendering image: self.image.clone(), + popup_decision: PopupDecisionRecord::default(), + popup_hide_after_ms: 0, // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -69,15 +119,24 @@ impl Notification { pub fn to_list_view(&self) -> NotificationView { NotificationView { id: self.id, - app_name: self.app_name.clone(), - summary: notification_plain_text(&self.summary), - body: notification_plain_text(&self.body), + generation: self.generation, + app_name: self.attribution.display_name.clone(), + attribution: self.attribution.clone(), + summary: notification_display_text(&self.summary), + body: notification_display_text(&self.body), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, urgency: self.urgency.as_u8(), + category: self.category.clone().unwrap_or_default(), // History policy still depends on the transient bit in panel rows is_transient: self.is_transient, + // List and popup views use the same stable wall-clock timestamp + received_at_unix_seconds: self.received_at.timestamp(), // List rows should avoid carrying raw image buffers across D-Bus image: self.image.for_listing(), + popup_decision: PopupDecisionRecord::default(), + popup_hide_after_ms: 0, // Protocol flags and sender metadata stay daemon-side to keep D-Bus payloads small } } @@ -85,17 +144,24 @@ impl Notification { /// Create a history entry with heavyweight hint data stripped out #[must_use] pub fn to_history(&self) -> Self { - // History entries should never retain raw image-data blobs + // History entries keep only bounded daemon-owned image roles let mut image = self.image.clone(); - image.has_image_data = false; - image.image_data = Default::default(); + image.content_image = Default::default(); + image.sender_visual = Default::default(); + // A cleared raster must never retain a role that can select an image slot + image.sender_visual_role = crate::NotificationVisualRole::None; Self { id: self.id, + generation: self.generation, app_name: self.app_name.clone(), app_icon: self.app_icon.clone(), + attribution: self.attribution.clone(), + attribution_diagnostics: self.attribution_diagnostics.clone(), summary: self.summary.clone(), body: self.body.clone(), actions: self.actions.clone(), + inline_reply: self.inline_reply.clone(), + inline_reply_policy: self.inline_reply_policy, // Keep history entries lightweight by dropping raw hint payloads hints: HashMap::new(), urgency: self.urgency, @@ -152,15 +218,24 @@ fn notification_plain_text(input: &str) -> String { collapse_notification_whitespace(&output) } +fn notification_display_text(input: &str) -> String { + // Markup removal can join text that was separated by tags in the stored payload + fold_text_for_layout(¬ification_plain_text(input), MAX_DISPLAY_TOKEN_WIDTH) +} + fn push_tag_spacing(output: &mut String, tag: &str) { + const BLOCK_TAGS: [&str; 5] = ["br", "p", "div", "li", "tr"]; + // Trim "/" first so opening and closing tags use the same spacing rule let tag_name = tag .trim_start_matches('/') .split(|ch: char| ch.is_whitespace() || ch == '/') .next() - .unwrap_or_default() - .to_ascii_lowercase(); - if matches!(tag_name.as_str(), "br" | "p" | "div" | "li" | "tr") { + .unwrap_or_default(); + if BLOCK_TAGS + .iter() + .any(|expected| tag_name.eq_ignore_ascii_case(expected)) + { // These tags normally separate chunks of text output.push('\n'); } @@ -244,7 +319,17 @@ fn collapse_notification_whitespace(input: &str) -> String { } } - output.trim().to_string() + if saw_newline { + // A newline can follow an already-normalized space at the tail + output.pop(); + if output.ends_with(' ') { + output.pop(); + } + } else if saw_space { + output.pop(); + } + + output } /// Serializable view of a notification for D-Bus signals @@ -252,17 +337,42 @@ fn collapse_notification_whitespace(input: &str) -> String { pub struct NotificationView { // Identifier matches Notification::id pub id: u32, + // Generation identifies the exact same-ID payload represented by this view + pub generation: u64, // Lightweight fields used for UI display and filtering - // Intentionally omits daemon-only protocol flags and timestamps + // Intentionally omits daemon-only protocol flags and full timestamp objects pub app_name: String, + // Authenticated badge identity and any mismatched caller-supplied brand claim + pub attribution: NotificationAttribution, pub summary: String, pub body: String, pub actions: Vec, + pub inline_reply: InlineReply, + pub inline_reply_policy: InlineReplyPolicy, pub urgency: u8, + // Category lets compact clients distinguish real media from decorative icon payloads + pub category: String, // Close handling needs this flag so history policy stays shared pub is_transient: bool, + // Unix seconds preserve the original receipt time across UI reconnects + pub received_at_unix_seconds: i64, // Image metadata intended for UI usage pub image: NotificationImage, + // Arrival-time popup reasoning stays stable while DND and renderer state change later + pub popup_decision: PopupDecisionRecord, + // Sanitized banner duration resolved by the daemon for this generation + pub popup_hide_after_ms: u64, +} + +impl NotificationView { + /// Return the exact committed identity represented by this UI snapshot + #[must_use] + pub const fn key(&self) -> NotificationKey { + NotificationKey { + id: self.id, + generation: self.generation, + } + } } #[cfg(test)] diff --git a/crates/unixnotis-core/src/model/reply.rs b/crates/unixnotis-core/src/model/reply.rs new file mode 100644 index 000000000..f1c64d82a --- /dev/null +++ b/crates/unixnotis-core/src/model/reply.rs @@ -0,0 +1,21 @@ +//! Inline reply metadata shared by the daemon and notification UIs + +use serde::{Deserialize, Serialize}; +use zbus::zvariant::Type; + +/// KDE-compatible reply controls attached to one notification action +#[derive(Debug, Clone, Default, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct InlineReply { + // False keeps the D-Bus structure stable when no reply action exists + pub available: bool, + // Label comes from the matching action pair + pub label: String, + // Optional KDE hints use empty strings when the sender omits them + pub placeholder: String, + pub submit_label: String, + pub submit_icon: String, +} + +#[cfg(test)] +#[path = "tests/reply.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/model/tests/attribution.rs b/crates/unixnotis-core/src/model/tests/attribution.rs new file mode 100644 index 000000000..dca01890f --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/attribution.rs @@ -0,0 +1,367 @@ +use super::{AttributionReason, AttributionStatus, IdentityAssurance, NotificationAttribution}; +use crate::model::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +#[test] +fn attribution_wire_enums_use_declared_one_byte_values() { + let context = Context::new_dbus(LE, 0); + + for (status, discriminant) in [ + (AttributionStatus::Verified, 0_u8), + (AttributionStatus::Recognized, 1), + (AttributionStatus::Unresolved, 2), + (AttributionStatus::Conflict, 3), + (AttributionStatus::Relay, 4), + ] { + let encoded = to_bytes(context, &status).expect("serialize attribution status"); + assert_eq!(AttributionStatus::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: AttributionStatus = encoded + .deserialize() + .expect("deserialize attribution status") + .0; + assert_eq!(decoded, status); + } + + for (reason, discriminant) in [ + (AttributionReason::ExactSystemExecutable, 0_u8), + (AttributionReason::PortalAppIdAssociation, 1), + (AttributionReason::ExactUserExecutable, 2), + (AttributionReason::ProtectedPayloadMatch, 3), + (AttributionReason::TrustedRelayExecutable, 4), + (AttributionReason::MissingSenderEvidence, 10), + (AttributionReason::MissingCommandLine, 11), + (AttributionReason::AmbiguousDesktopRecords, 12), + (AttributionReason::DynamicLaunchContract, 13), + (AttributionReason::UnsupportedWrapper, 14), + (AttributionReason::NoDesktopCandidate, 15), + (AttributionReason::ExecutableMismatch, 20), + (AttributionReason::ProtectedPayloadMismatch, 21), + (AttributionReason::ApplicationClaimMismatch, 22), + ] { + let encoded = to_bytes(context, &reason).expect("serialize attribution reason"); + assert_eq!(AttributionReason::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: AttributionReason = encoded + .deserialize() + .expect("deserialize attribution reason") + .0; + assert_eq!(decoded, reason); + } + + for (policy, discriminant) in [ + (InlineReplyPolicy::Allow, 0_u8), + (InlineReplyPolicy::Confirm, 1), + (InlineReplyPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize inline reply policy"); + assert_eq!(InlineReplyPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } + + for (assurance, discriminant) in [ + (IdentityAssurance::Authenticated, 0_u8), + (IdentityAssurance::SystemAssociated, 1), + (IdentityAssurance::PortalAssociated, 2), + (IdentityAssurance::UserAssociated, 3), + (IdentityAssurance::Unresolved, 4), + (IdentityAssurance::Conflict, 5), + (IdentityAssurance::Relay, 6), + ] { + let encoded = to_bytes(context, &assurance).expect("serialize identity assurance"); + assert_eq!(IdentityAssurance::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } + + for (policy, discriminant) in [ + (ApplicationActionPolicy::Allow, 0_u8), + (ApplicationActionPolicy::Confirm, 1), + (ApplicationActionPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize application action policy"); + assert_eq!(ApplicationActionPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } +} + +#[test] +fn attribution_wire_enums_reject_unknown_discriminants() { + let context = Context::new_dbus(LE, 0); + let unknown = to_bytes(context, &u8::MAX).expect("serialize unknown byte"); + + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); + + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); + assert!(unknown.deserialize::().is_err()); +} + +#[test] +fn verified_identity_keeps_claim_and_diagnostics_structured() { + let attribution = NotificationAttribution::verified( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + AttributionReason::ExactSystemExecutable, + "/usr/bin/example-chat", + "system-app:org.example.Chat".to_string(), + ); + + assert_eq!(attribution.display_name, "Example Chat"); + assert_eq!(attribution.claimed_name, "Example Chat"); + assert_eq!(attribution.status, AttributionStatus::Verified); + assert_eq!(attribution.reason, AttributionReason::ExactSystemExecutable); + assert_eq!( + attribution.group_key, "system-app:org.example.Chat", + "a valid daemon group key must survive wire construction" + ); +} + +#[test] +fn recognized_identity_preserves_canonical_application_fields() { + let attribution = NotificationAttribution::recognized( + "Example Chat", + "Caller label", + "org.example.Chat", + "org.example.Chat", + AttributionReason::MissingCommandLine, + "the sender command line was unavailable", + "recognized:system-app:org.example.Chat:7:11".to_string(), + ); + + assert_eq!(attribution.display_name, "Example Chat"); + assert_eq!(attribution.claimed_name, "Caller label"); + assert_eq!(attribution.desktop_id, "org.example.Chat"); + assert_eq!(attribution.badge_icon, "org.example.Chat"); + assert_eq!(attribution.status, AttributionStatus::Recognized); + assert_eq!(attribution.reason, AttributionReason::MissingCommandLine); + assert_eq!( + attribution.group_key, + "recognized:system-app:org.example.Chat:7:11" + ); +} + +#[test] +fn unresolved_identity_preserves_claim_reason_and_isolated_group() { + let attribution = NotificationAttribution::unresolved( + "Caller label", + AttributionReason::NoDesktopCandidate, + "no desktop candidate matched", + "unresolved:7:11:callerlabel".to_string(), + ); + + assert_eq!(attribution.display_name, "Unknown application"); + assert_eq!(attribution.claimed_name, "Caller label"); + assert!(attribution.desktop_id.is_empty()); + assert_eq!(attribution.badge_icon, "application-x-executable-symbolic"); + assert_eq!(attribution.status, AttributionStatus::Unresolved); + assert_eq!(attribution.reason, AttributionReason::NoDesktopCandidate); + assert_eq!(attribution.group_key, "unresolved:7:11:callerlabel"); +} + +#[test] +fn empty_group_key_fails_closed_to_unknown() { + let attribution = NotificationAttribution::unresolved( + "Caller label", + AttributionReason::MissingSenderEvidence, + "", + " \n\t ".to_string(), + ); + + assert_eq!( + attribution.group_key, "unknown", + "empty or display-control-only group keys must not escape construction" + ); +} + +#[test] +fn conflict_keeps_claim_out_of_human_diagnostic_state() { + let attribution = NotificationAttribution::conflict( + "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, + "sender executable differs from the protected record", + "unknown:7:9:passwordmanager".to_string(), + ); + + assert_eq!(attribution.display_name, "Unknown application"); + assert_eq!(attribution.claimed_name, "Password Manager"); + assert_eq!(attribution.status, AttributionStatus::Conflict); + assert!(!attribution.diagnostic_detail.contains("Claims to be")); +} + +#[test] +fn relay_never_promotes_the_caller_label_to_primary_identity() { + let attribution = NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:1:2:examplechat".to_string(), + ); + + assert_eq!(attribution.display_name, "Command-line notification"); + assert_eq!(attribution.claimed_name, "Example Chat"); + assert_eq!(attribution.status, AttributionStatus::Relay); +} + +#[test] +fn authenticated_and_native_policies_keep_action_surfaces_separate() { + let verified = NotificationAttribution::verified( + "Verified", + "Verified", + "org.example.Verified", + "verified", + AttributionReason::ExactSystemExecutable, + "", + "system-app:verified".to_string(), + ); + assert_eq!( + verified.action_policy("default"), + ApplicationActionPolicy::Allow + ); + assert_eq!( + verified.action_policy("open"), + ApplicationActionPolicy::Allow + ); + assert_eq!( + verified.action_policy("inline-reply"), + ApplicationActionPolicy::Deny, + "even fully verified attributions must reject inline-reply through action dispatch" + ); + + let native = NotificationAttribution::associated( + "System app", + "System app", + "org.example.System", + "system", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "", + "associated:system-app:system".to_string(), + ); + assert_eq!( + native.default_activation_policy(), + ApplicationActionPolicy::Allow, + "native association should preserve compatible card activation" + ); + assert_eq!( + native.action_button_policy(), + ApplicationActionPolicy::Confirm, + "native association should require confirmation for richer actions" + ); + assert_eq!( + native.action_policy("default"), + ApplicationActionPolicy::Allow, + "the protocol default key should use default activation policy" + ); + assert_eq!( + native.action_policy("inline-reply"), + ApplicationActionPolicy::Deny, + "the inline-reply key must be rejected regardless of button policy" + ); + assert_eq!( + native.action_policy("archive"), + ApplicationActionPolicy::Confirm, + "non-default keys should use button policy" + ); + assert_eq!( + native.interactions.inline_reply, + InlineReplyPolicy::Deny, + "same-user native association cannot protect credential-like reply text" + ); + assert!(native.may_materialize_application_icon()); +} + +#[test] +fn portal_and_unassociated_policies_never_allow_silent_actions() { + let portal = NotificationAttribution::associated( + "Portal app", + "Portal app", + "org.example.Portal", + "portal", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "", + "associated:portal-app:portal".to_string(), + ); + assert_eq!( + portal.default_activation_policy(), + ApplicationActionPolicy::Confirm, + "an app id without unforgeable provenance must not activate silently" + ); + assert!(!portal.may_materialize_application_icon()); + + for attribution in [ + NotificationAttribution::recognized( + "Local", + "Local", + "org.example.Local", + "local", + AttributionReason::ExactUserExecutable, + "", + "user-app:local".to_string(), + ), + NotificationAttribution::unresolved( + "Unknown", + AttributionReason::MissingSenderEvidence, + "", + "unknown:unknown".to_string(), + ), + NotificationAttribution::conflict( + "Conflict", + "org.example.Conflict", + AttributionReason::ExecutableMismatch, + "", + "unknown:conflict".to_string(), + ), + NotificationAttribution::relay("Relay", "", "relay:relay".to_string()), + ] { + assert_eq!( + attribution.action_policy("default"), + ApplicationActionPolicy::Deny, + "status {:?} must not emit application-owned signals", + attribution.status + ); + } +} + +#[test] +fn host_visuals_do_not_require_action_authority() { + let mut authenticated = NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example", + AttributionReason::ExactSystemExecutable, + "", + "verified:example".to_string(), + ); + authenticated.interactions = InteractionPolicies::DENY; + + assert!(authenticated.may_materialize_application_icon()); +} + +#[test] +fn verification_status_is_not_inferred_from_display_fields() { + let verified = NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example", + AttributionReason::ExactSystemExecutable, + "", + "verified:example".to_string(), + ); + let unresolved = NotificationAttribution::unresolved( + "Example", + AttributionReason::MissingSenderEvidence, + "", + "unknown:example".to_string(), + ); + + assert!(verified.is_verified()); + assert!(!unresolved.is_verified()); +} diff --git a/crates/unixnotis-core/src/model/tests/diagnostics.rs b/crates/unixnotis-core/src/model/tests/diagnostics.rs new file mode 100644 index 000000000..bb663d7ef --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/diagnostics.rs @@ -0,0 +1,41 @@ +use zbus::zvariant::{serialized::Context, to_bytes, LE}; + +use super::{ + AttributionDiagnostics, CommandLineQualityView, LaunchAuthorityView, LaunchVerificationView, + RecordTrust, +}; + +#[test] +fn attribution_diagnostics_round_trip_every_evidence_dimension() { + let diagnostics = AttributionDiagnostics { + claimed_name: "Example".to_string(), + claimed_desktop_entry: "org.example.App".to_string(), + sender_executable: "/opt/example/app".to_string(), + matched_desktop_id: "org.example.App".to_string(), + record_trust: RecordTrust::System, + launch_authority: LaunchAuthorityView::ProtectedPayload, + command_line_quality: CommandLineQualityView::RewrittenProcessTitle, + verification: LaunchVerificationView::InsufficientEvidence, + reason: "unstructured command-line evidence".to_string(), + }; + + let encoded = + to_bytes(Context::new_dbus(LE, 0), &diagnostics).expect("serialize attribution evidence"); + let decoded = encoded + .deserialize::() + .expect("deserialize attribution evidence") + .0; + + assert_eq!(decoded, diagnostics); +} + +#[test] +fn diagnostic_wire_enums_reject_unknown_values() { + let encoded = + to_bytes(Context::new_dbus(LE, 0), &u8::MAX).expect("serialize unknown evidence value"); + + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); + assert!(encoded.deserialize::().is_err()); +} diff --git a/crates/unixnotis-core/src/model/tests/interaction.rs b/crates/unixnotis-core/src/model/tests/interaction.rs new file mode 100644 index 000000000..f1600d774 --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/interaction.rs @@ -0,0 +1,74 @@ +//! Interaction policy wire and matrix regressions + +use zbus::zvariant::{serialized::Context, to_bytes, Type, LE}; + +use super::{ApplicationActionPolicy, InlineReplyPolicy, InteractionPolicies}; + +#[test] +fn interaction_policy_enums_keep_stable_one_byte_wire_values() { + let context = Context::new_dbus(LE, 0); + for (policy, discriminant) in [ + (ApplicationActionPolicy::Allow, 0_u8), + (ApplicationActionPolicy::Confirm, 1), + (ApplicationActionPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize action policy"); + assert_eq!(ApplicationActionPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } + for (policy, discriminant) in [ + (InlineReplyPolicy::Allow, 0_u8), + (InlineReplyPolicy::Confirm, 1), + (InlineReplyPolicy::Deny, 2), + ] { + let encoded = to_bytes(context, &policy).expect("serialize reply policy"); + assert_eq!(InlineReplyPolicy::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + } +} + +#[test] +fn native_compatibility_keeps_default_activation_without_richer_authority() { + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.default_activation, + ApplicationActionPolicy::Allow + ); + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.action_buttons, + ApplicationActionPolicy::Confirm + ); + assert_eq!( + InteractionPolicies::NATIVE_COMPATIBILITY.inline_reply, + InlineReplyPolicy::Deny + ); +} + +#[test] +fn owner_bound_default_grants_only_default_activation() { + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.default_activation, + ApplicationActionPolicy::Allow + ); + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.action_buttons, + ApplicationActionPolicy::Deny + ); + assert_eq!( + InteractionPolicies::OWNER_BOUND_DEFAULT.inline_reply, + InlineReplyPolicy::Deny + ); +} + +#[test] +fn confirmation_and_denial_matrices_never_allow_inline_text() { + for policies in [ + InteractionPolicies::CONFIRM_ACTIONS, + InteractionPolicies::DENY, + ] { + assert_ne!( + policies.inline_reply, + InlineReplyPolicy::Allow, + "weaker associations must not expose credential-like reply text" + ); + } +} diff --git a/crates/unixnotis-core/src/model/tests/notification.rs b/crates/unixnotis-core/src/model/tests/notification.rs index c9ab0f5a5..18bc1911d 100644 --- a/crates/unixnotis-core/src/model/tests/notification.rs +++ b/crates/unixnotis-core/src/model/tests/notification.rs @@ -1,10 +1,12 @@ use std::collections::HashMap; -use chrono::Utc; -use zbus::zvariant::Value; +use zbus::zvariant::{serialized::Context, to_bytes, Value, LE}; use super::{Notification, NotificationImage}; -use crate::{Action, ImageData, Urgency}; +use crate::{ + Action, AttributionReason, AttributionStatus, ImageData, InlineReply, InlineReplyPolicy, + NotificationAttribution, Urgency, +}; fn notification_with_image(image: NotificationImage) -> Notification { let mut hints = HashMap::new(); @@ -15,14 +17,27 @@ fn notification_with_image(image: NotificationImage) -> Notification { Notification { id: 42, + generation: 11, app_name: "Mail".to_string(), app_icon: "mail".to_string(), + attribution: NotificationAttribution::verified( + "Mail", + "Mail", + "org.example.Mail", + "mail", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.Mail".to_string(), + ), + attribution_diagnostics: crate::AttributionDiagnostics::default(), summary: "Subject".to_string(), body: "Body".to_string(), actions: vec![Action { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, hints, urgency: Urgency::Critical, category: Some("email".to_string()), @@ -32,7 +47,8 @@ fn notification_with_image(image: NotificationImage) -> Notification { suppress_sound: true, image, expire_timeout: 5000, - received_at: Utc::now(), + received_at: chrono::DateTime::from_timestamp(1_700_000_000, 0) + .expect("fixed notification timestamp"), sender_name: Some(":1.42".to_string()), sender_pid: Some(1234), sender_start_time: Some(9000), @@ -42,8 +58,7 @@ fn notification_with_image(image: NotificationImage) -> Notification { fn image_with_raw_bytes() -> NotificationImage { NotificationImage { - has_image_data: true, - image_data: ImageData { + content_image: ImageData { width: 1, height: 1, rowstride: 4, @@ -52,8 +67,11 @@ fn image_with_raw_bytes() -> NotificationImage { channels: 4, data: vec![1, 2, 3, 4], }, - image_path: "/tmp/icon.png".to_string(), - icon_name: "mail".to_string(), + sender_visual_role: crate::NotificationVisualRole::None, + sender_visual: ImageData::default(), + badge_icon: "mail".to_string(), + claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), } } @@ -66,12 +84,66 @@ fn notification_view_keeps_ui_fields_and_transient_policy_flag() { // Live popup views keep enough information for UI actions and close policy assert_eq!(view.id, 42); assert_eq!(view.app_name, "Mail"); + assert_eq!(view.attribution.status, AttributionStatus::Verified); + assert_eq!(view.attribution.badge_icon, "mail"); assert_eq!(view.summary, "Subject"); assert_eq!(view.body, "Body"); assert_eq!(view.actions.len(), 1); assert_eq!(view.urgency, Urgency::Critical.as_u8()); assert!(view.is_transient); - assert!(view.image.has_image_data); + assert_eq!(view.received_at_unix_seconds, 1_700_000_000); + assert!(!view.image.content_image.data.is_empty()); +} + +#[test] +fn notification_view_round_trips_every_attribution_and_reply_policy_pair() { + let context = Context::new_dbus(LE, 0); + let cases = [ + (AttributionStatus::Verified, InlineReplyPolicy::Allow), + (AttributionStatus::Recognized, InlineReplyPolicy::Deny), + (AttributionStatus::Relay, InlineReplyPolicy::Deny), + (AttributionStatus::Unresolved, InlineReplyPolicy::Deny), + (AttributionStatus::Conflict, InlineReplyPolicy::Deny), + ]; + + for (status, policy) in cases { + let mut view = notification_with_image(image_with_raw_bytes()).to_view(); + view.attribution.status = status; + view.inline_reply_policy = policy; + + // This nested payload matches GetActiveNotification and exercises both wire enums + let encoded = to_bytes(context, &view).expect("serialize notification view"); + let decoded = encoded + .deserialize::() + .expect("deserialize notification view") + .0; + assert_eq!(decoded, view); + } +} + +#[test] +fn notification_view_keeps_conflict_warning_separate_from_primary_name() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.app_name = "Password Manager".to_string(); + notification.sender_executable = Some("/usr/bin/unknown-client".to_string()); + notification.attribution = NotificationAttribution::conflict( + "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, + "source /usr/bin/unknown-client", + "executable:1:2".to_string(), + ); + + let view = notification.to_view(); + + assert_eq!(view.app_name, "Unknown application"); + assert_eq!(view.attribution.status, AttributionStatus::Conflict); + assert_eq!(view.attribution.claimed_name, "Password Manager"); + assert_eq!( + view.attribution.reason, + AttributionReason::ExecutableMismatch + ); + assert!(!view.app_name.contains("unverified claim")); } #[test] @@ -121,6 +193,16 @@ fn notification_view_treats_self_closing_break_as_newline() { assert_eq!(view.body, "Line one\nLine two"); } +#[test] +fn notification_view_matches_block_tags_without_allocating_lowercase_names() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = "Line one
Line two

Line three".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Line one\nLine two\nLine three"); +} + #[test] fn notification_view_preserves_inline_markup_adjacency() { let mut notification = notification_with_image(image_with_raw_bytes()); @@ -131,6 +213,26 @@ fn notification_view_preserves_inline_markup_adjacency() { assert_eq!(view.body, "foobar and baz"); } +#[test] +fn notification_view_refolds_tokens_joined_by_markup_removal() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.summary = format!( + "markup-{}", + "link".repeat(180) + ); + + let view = notification.to_view(); + let longest = view + .summary + .split_whitespace() + .map(|token| token.chars().count()) + .max() + .unwrap_or_default(); + + assert!(view.summary.contains('…')); + assert!(longest <= crate::util::MAX_DISPLAY_TOKEN_WIDTH); +} + #[test] fn notification_view_collapses_inline_spaces_without_leaking_after_blocks() { let mut notification = notification_with_image(image_with_raw_bytes()); @@ -151,6 +253,16 @@ fn notification_view_collapses_repeated_block_tag_newlines() { assert_eq!(view.body, "Line one\nLine two"); } +#[test] +fn notification_view_removes_trailing_whitespace_in_place() { + let mut notification = notification_with_image(image_with_raw_bytes()); + notification.body = " Alpha \n ".to_string(); + + let view = notification.to_view(); + + assert_eq!(view.body, "Alpha"); +} + #[test] fn notification_view_preserves_unterminated_entity_text() { let mut notification = notification_with_image(image_with_raw_bytes()); @@ -168,10 +280,8 @@ fn list_view_strips_raw_image_bytes_but_keeps_icon_identifiers() { let view = notification.to_list_view(); // List rows should avoid carrying raw image buffers across D-Bus - assert!(!view.image.has_image_data); - assert!(view.image.image_data.data.is_empty()); - assert_eq!(view.image.image_path, "/tmp/icon.png"); - assert_eq!(view.image.icon_name, "mail"); + assert!(!view.image.content_image.data.is_empty()); + assert_eq!(view.image.badge_icon, "mail"); assert!(view.is_transient); } @@ -183,10 +293,34 @@ fn history_projection_drops_raw_hints_and_image_bytes() { // History entries should stay lightweight and avoid retaining raw D-Bus hints assert!(history.hints.is_empty()); - assert!(!history.image.has_image_data); - assert!(history.image.image_data.data.is_empty()); + assert!(history.image.content_image.data.is_empty()); assert_eq!(history.sender_name.as_deref(), Some(":1.42")); assert_eq!(history.sender_pid, Some(1234)); assert_eq!(history.sender_start_time, Some(9000)); assert_eq!(history.sender_executable.as_deref(), Some("/usr/bin/mail")); } + +#[test] +fn history_projection_clears_sender_visual_role_with_sender_pixels() { + let notification = notification_with_image(NotificationImage { + sender_visual_role: crate::NotificationVisualRole::ConversationAvatar, + sender_visual: ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }, + ..image_with_raw_bytes() + }); + + let history = notification.to_history(); + + assert_eq!( + history.image.sender_visual_role, + crate::NotificationVisualRole::None + ); + assert!(history.image.sender_visual.data.is_empty()); +} diff --git a/crates/unixnotis-core/src/model/tests/reply.rs b/crates/unixnotis-core/src/model/tests/reply.rs new file mode 100644 index 000000000..abad21bc4 --- /dev/null +++ b/crates/unixnotis-core/src/model/tests/reply.rs @@ -0,0 +1,12 @@ +use super::InlineReply; + +#[test] +fn inline_reply_default_is_unavailable_and_carries_no_display_text() { + let reply = InlineReply::default(); + + assert!(!reply.available); + assert!(reply.label.is_empty()); + assert!(reply.placeholder.is_empty()); + assert!(reply.submit_label.is_empty()); + assert!(reply.submit_icon.is_empty()); +} diff --git a/crates/unixnotis-core/src/model/tests/types.rs b/crates/unixnotis-core/src/model/tests/types.rs index eeb9ecab3..7127640a8 100644 --- a/crates/unixnotis-core/src/model/tests/types.rs +++ b/crates/unixnotis-core/src/model/tests/types.rs @@ -1,5 +1,30 @@ use super::Urgency; -use zbus::zvariant::OwnedValue; +use zbus::zvariant::{serialized::Context, to_bytes, OwnedValue, Type, LE}; + +#[test] +fn urgency_wire_values_use_their_declared_one_byte_signature() { + let context = Context::new_dbus(LE, 0); + + for (urgency, discriminant) in [ + (Urgency::Low, 0_u8), + (Urgency::Normal, 1), + (Urgency::Critical, 2), + ] { + let encoded = to_bytes(context, &urgency).expect("serialize urgency"); + assert_eq!(Urgency::signature(), u8::signature()); + assert_eq!(encoded.bytes(), &[discriminant]); + let decoded: Urgency = encoded.deserialize().expect("deserialize urgency").0; + assert_eq!(decoded, urgency); + } +} + +#[test] +fn urgency_wire_values_reject_unknown_discriminants() { + let context = Context::new_dbus(LE, 0); + let encoded = to_bytes(context, &u8::MAX).expect("serialize unknown urgency byte"); + + assert!(encoded.deserialize::().is_err()); +} #[test] fn urgency_hint_maps_known_values_to_protocol_urgency() { @@ -37,3 +62,10 @@ fn urgency_as_u8_matches_freedesktop_values() { assert_eq!(Urgency::Normal.as_u8(), 1); assert_eq!(Urgency::Critical.as_u8(), 2); } + +#[test] +fn urgency_as_u32_matches_freedesktop_values() { + assert_eq!(Urgency::Low.as_u32(), 0); + assert_eq!(Urgency::Normal.as_u32(), 1); + assert_eq!(Urgency::Critical.as_u32(), 2); +} diff --git a/crates/unixnotis-core/src/model/types.rs b/crates/unixnotis-core/src/model/types.rs index 7472e6d3f..4317089ce 100644 --- a/crates/unixnotis-core/src/model/types.rs +++ b/crates/unixnotis-core/src/model/types.rs @@ -1,10 +1,12 @@ //! Core notification enum and action types shared across models use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use zbus::zvariant::{OwnedValue, Type}; /// Notification urgency levels defined by the specification -#[derive(Debug, Copy, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +// The protocol exposes urgency as one byte, including inside notification views +#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)] #[repr(u8)] pub enum Urgency { Low = 0, @@ -39,6 +41,11 @@ impl Urgency { pub const fn as_u8(self) -> u8 { self as u8 } + + #[must_use] + pub const fn as_u32(self) -> u32 { + self as u32 + } } /// Action pair in the notification protocol diff --git a/crates/unixnotis-core/src/notification_daemons.rs b/crates/unixnotis-core/src/notification_daemons.rs new file mode 100644 index 000000000..460313650 --- /dev/null +++ b/crates/unixnotis-core/src/notification_daemons.rs @@ -0,0 +1,77 @@ +//! Shared catalog of standalone notification daemons + +/// A process that may own the freedesktop notifications bus name +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct KnownNotificationDaemon { + pub name: &'static str, + // Some daemons use D-Bus activation or desktop startup instead of a user unit + pub systemd_unit: Option<&'static str>, +} + +/// Standalone daemons safe to identify and stop by their exact owner process +pub const KNOWN_NOTIFICATION_DAEMONS: &[KnownNotificationDaemon] = &[ + KnownNotificationDaemon { + name: "unixnotis-daemon", + systemd_unit: Some("unixnotis-daemon.service"), + }, + KnownNotificationDaemon { + name: "fnott", + systemd_unit: Some("fnott.service"), + }, + KnownNotificationDaemon { + name: "mako", + systemd_unit: Some("mako.service"), + }, + KnownNotificationDaemon { + name: "dunst", + systemd_unit: Some("dunst.service"), + }, + KnownNotificationDaemon { + name: "swaync", + systemd_unit: Some("swaync.service"), + }, + KnownNotificationDaemon { + name: "xfce4-notifyd", + systemd_unit: Some("xfce4-notifyd.service"), + }, + KnownNotificationDaemon { + name: "wired", + systemd_unit: Some("wired.service"), + }, + KnownNotificationDaemon { + name: "notify-osd", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "quickshell", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "hyprnotify", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "lxqt-notificationd", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "mate-notification-daemon", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "notification-daemon", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "deadd-notification-center", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "tiramisu", + systemd_unit: None, + }, + KnownNotificationDaemon { + name: "runst", + systemd_unit: None, + }, +]; diff --git a/crates/unixnotis-core/src/notifications.rs b/crates/unixnotis-core/src/notifications.rs new file mode 100644 index 000000000..08eca8b6b --- /dev/null +++ b/crates/unixnotis-core/src/notifications.rs @@ -0,0 +1,36 @@ +//! Freedesktop notification client proxy contract + +use std::collections::HashMap; + +use zbus::proxy; +use zbus::zvariant::OwnedValue; + +#[proxy( + interface = "org.freedesktop.Notifications", + default_service = "org.freedesktop.Notifications", + default_path = "/org/freedesktop/Notifications" +)] +pub trait Notifications { + /// Capabilities advertised by the active notification server + fn get_capabilities(&self) -> zbus::Result>; + + /// Stable server identity and protocol version + fn get_server_information(&self) -> zbus::Result<(String, String, String, String)>; + + /// Submit one notification and return its assigned identifier + #[expect( + clippy::too_many_arguments, + reason = "the D-Bus method must match the freedesktop notification protocol" + )] + fn notify( + &self, + app_name: &str, + replaces_id: u32, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, + expire_timeout: i32, + ) -> zbus::Result; +} diff --git a/crates/unixnotis-core/src/process/legacy.rs b/crates/unixnotis-core/src/process/legacy.rs new file mode 100644 index 000000000..b0de6670a --- /dev/null +++ b/crates/unixnotis-core/src/process/legacy.rs @@ -0,0 +1,164 @@ +//! One-way migration from legacy shell-shaped command strings + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +use thiserror::Error; + +use super::CommandSpec; + +const VALUE_PLACEHOLDER: &str = "{value}"; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum LegacyCommandError { + #[error("command is empty")] + Empty, + #[error("command contains malformed shell quoting: {0}")] + Malformed(String), + #[error("command contains environment assignments but no program")] + MissingProgram, +} + +/// Convert one legacy command string into an explicit direct or shell specification +/// +/// # Errors +/// +/// Returns an error when the legacy command is empty, malformed, or has no program +pub fn parse_legacy_command(command: &str) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err(LegacyCommandError::Empty); + } + + let parts = shell_words::split(trimmed) + .map_err(|error| LegacyCommandError::Malformed(error.to_string()))?; + // Shell operators are detected before quote removal so literal punctuation stays direct + if contains_shell_syntax(trimmed) { + return Ok(CommandSpec::shell(trimmed)); + } + let (env, remaining) = split_leading_env_assignments(parts); + let mut remaining = remaining.into_iter(); + let program = remaining.next().ok_or(LegacyCommandError::MissingProgram)?; + + let spec = CommandSpec::Direct { + program: PathBuf::from(program), + args: remaining.map(OsString::from).collect(), + env, + }; + if let Some(script) = exact_shell_c_script(&spec) { + return Ok(CommandSpec::shell(script)); + } + Ok(spec) +} + +fn exact_shell_c_script(spec: &CommandSpec) -> Option<&str> { + let CommandSpec::Direct { args, env, .. } = spec else { + return None; + }; + // Environment prefixes and extra operands change shell wrapper semantics + if !env.is_empty() || !spec.uses_shell_command_string() { + return None; + } + let [flag, script] = args.as_slice() else { + return None; + }; + if flag != "-c" { + return None; + } + script.to_str() +} + +fn split_leading_env_assignments( + mut parts: Vec, +) -> (BTreeMap, Vec) { + let assignment_count = parts + .iter() + .take_while(|token| split_env_assignment(token).is_some()) + .count(); + let remaining = parts.split_off(assignment_count); + let env = parts + .iter() + .filter_map(|token| split_env_assignment(token)) + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect(); + (env, remaining) +} + +fn split_env_assignment(token: &str) -> Option<(&str, &str)> { + let (name, value) = token.split_once('=')?; + let mut chars = name.chars(); + let first = chars.next()?; + if !(first == '_' || first.is_ascii_alphabetic()) { + return None; + } + if chars.any(|character| !(character == '_' || character.is_ascii_alphanumeric())) { + return None; + } + Some((name, value)) +} + +fn contains_shell_syntax(command: &str) -> bool { + let mut quote = None; + let mut escaped = false; + let mut token_start = true; + let mut chars = command.char_indices(); + + while let Some((index, character)) = chars.next() { + if escaped { + escaped = false; + token_start = false; + continue; + } + + match quote { + Some('\'') => { + if character == '\'' { + quote = None; + } + continue; + } + Some('"') => { + match character { + '"' => quote = None, + '\\' => escaped = true, + '$' | '`' => return true, + _ => {} + } + continue; + } + Some(_) => unreachable!("legacy scanner stores only shell quote characters"), + None => {} + } + + match character { + '\'' | '"' => { + quote = Some(character); + token_start = false; + } + '\\' => { + escaped = true; + token_start = false; + } + ' ' | '\t' => token_start = true, + '#' | '!' if token_start => return true, + '{' if command[index..].starts_with(VALUE_PLACEHOLDER) => { + // Skip the rest of the known runtime placeholder as literal direct data + for _ in 1..VALUE_PLACEHOLDER.len() { + let _ = chars.next(); + } + token_start = false; + } + '\n' | '\r' | '|' | '&' | ';' | '<' | '>' | '$' | '`' | '(' | ')' | '[' | ']' | '*' + | '?' | '~' | '{' | '}' => return true, + _ => token_start = false, + } + } + + // shell_words validates these states before this classifier runs + debug_assert!( + quote.is_none() && !escaped, + "validated legacy command must finish outside quoted or escaped input" + ); + false +} diff --git a/crates/unixnotis-core/src/process/mod.rs b/crates/unixnotis-core/src/process/mod.rs new file mode 100644 index 000000000..fc7ad9047 --- /dev/null +++ b/crates/unixnotis-core/src/process/mod.rs @@ -0,0 +1,10 @@ +//! Typed child-process descriptions shared across `UnixNotis` binaries + +mod legacy; +mod spec; + +pub use legacy::{parse_legacy_command, LegacyCommandError}; +pub use spec::CommandSpec; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-core/src/process/spec.rs b/crates/unixnotis-core/src/process/spec.rs new file mode 100644 index 000000000..35ce5f6af --- /dev/null +++ b/crates/unixnotis-core/src/process/spec.rs @@ -0,0 +1,328 @@ +//! Explicit direct and shell command representations + +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// One command whose shell boundary is selected by configuration, not inferred at runtime +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum CommandSpec { + /// Executes one program with literal arguments and child-local environment overrides + Direct { + program: PathBuf, + #[serde(default, with = "os_string_vec")] + args: Vec, + #[serde(default, with = "os_string_map")] + env: BTreeMap, + }, + /// Executes one script through the system's POSIX shell + Shell { script: String }, +} + +impl fmt::Display for CommandSpec { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.display_lossy()) + } +} + +impl CommandSpec { + /// Build a direct command without any shell parsing or expansion + pub fn direct(program: impl Into, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self::Direct { + program: program.into(), + args: args.into_iter().map(Into::into).collect(), + env: BTreeMap::new(), + } + } + + /// Build an explicit POSIX shell command + pub fn shell(script: impl Into) -> Self { + Self::Shell { + script: script.into(), + } + } + + /// Add one child-local environment value to a direct command + #[must_use] + pub fn with_env(mut self, name: impl Into, value: impl Into) -> Self { + if let Self::Direct { env, .. } = &mut self { + env.insert(name.into(), value.into()); + } + self + } + + #[must_use] + pub const fn is_shell(&self) -> bool { + matches!(self, Self::Shell { .. }) + } + + #[must_use] + /// Reports whether the command evaluates an inline shell command string + pub fn uses_shell_command_string(&self) -> bool { + match self { + Self::Shell { .. } => true, + Self::Direct { program, args, .. } => { + // Basenames keep absolute interpreter paths and PATH lookups equivalent + let Some(shell) = program + .file_name() + .and_then(OsStr::to_str) + .filter(|name| is_shell_program(name)) + else { + return false; + }; + + shell_args_use_command_string(shell, args) + } + } + } + + /// Compatibility alias for the former inline shell command detector + #[deprecated(note = "use uses_shell_command_string")] + #[must_use] + pub fn invokes_shell(&self) -> bool { + self.uses_shell_command_string() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + match self { + Self::Direct { program, .. } => program.as_os_str().is_empty(), + Self::Shell { script } => script.trim().is_empty(), + } + } + + #[must_use] + pub fn program(&self) -> Option<&Path> { + match self { + Self::Direct { program, .. } => Some(program), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub fn args(&self) -> Option<&[OsString]> { + match self { + Self::Direct { args, .. } => Some(args), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub const fn env(&self) -> Option<&BTreeMap> { + match self { + Self::Direct { env, .. } => Some(env), + Self::Shell { .. } => None, + } + } + + #[must_use] + pub fn script(&self) -> Option<&str> { + match self { + Self::Direct { .. } => None, + Self::Shell { script } => Some(script), + } + } + + /// Replace a runtime placeholder without reparsing direct arguments + #[must_use] + pub fn replace(&self, placeholder: &str, value: &str) -> Self { + match self { + Self::Direct { program, args, env } => Self::Direct { + program: replace_os(program.as_os_str(), placeholder, value).into(), + args: args + .iter() + .map(|arg| replace_os(arg, placeholder, value)) + .collect(), + env: env + .iter() + .map(|(name, current)| { + ( + name.clone(), + replace_os(current.as_os_str(), placeholder, value), + ) + }) + .collect(), + }, + Self::Shell { script } => Self::shell(script.replace(placeholder, value)), + } + } + + /// Produce bounded-log input without changing execution semantics + #[must_use] + pub fn display_lossy(&self) -> String { + match self { + Self::Direct { program, args, .. } => { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(program.as_os_str().to_string_lossy().into_owned()); + parts.extend(args.iter().map(|arg| arg.to_string_lossy().into_owned())); + parts.join(" ") + } + Self::Shell { script } => script.clone(), + } + } +} + +fn is_shell_program(name: &str) -> bool { + matches!( + name, + "sh" | "ash" | "bash" | "dash" | "fish" | "ksh" | "zsh" + ) +} + +fn shell_args_use_command_string(shell: &str, args: &[OsString]) -> bool { + let mut option_value_pending = false; + for argument in args { + let Some(argument) = argument.to_str() else { + // TOML arguments are UTF-8, while an opaque programmatic operand ends option parsing + return false; + }; + if option_value_pending { + option_value_pending = false; + continue; + } + if matches!(argument, "-" | "--") { + // Both portable terminators make every following `-c` literal script data + return false; + } + if is_command_string_flag(argument) + || (shell == "fish" && is_fish_command_string_option(argument)) + { + return true; + } + if !argument.starts_with(['-', '+']) { + // The first positional operand is the script path for direct shell execution + return false; + } + option_value_pending = shell_option_takes_next_value(shell, argument); + } + false +} + +fn is_command_string_flag(argument: &str) -> bool { + argument.strip_prefix('-').is_some_and(|flags| { + // Long options such as `--norc` contain a letter c but do not evaluate command text + !flags.starts_with('-') && flags.contains('c') + }) +} + +fn is_fish_command_string_option(argument: &str) -> bool { + // Fish documents a long spelling in addition to the shared short `-c` form + argument == "--command" || argument.starts_with("--command=") +} + +fn shell_option_takes_next_value(shell: &str, argument: &str) -> bool { + match shell { + // Bash accepts option names and startup files as separate operands before `-c` + "bash" => matches!( + argument, + "-o" | "+o" | "-O" | "+O" | "--init-file" | "--rcfile" + ), + // These POSIX-style shells accept a separate value for `-o` + "sh" | "ash" | "dash" => argument == "-o", + "ksh" => matches!(argument, "-o" | "+o" | "-R"), + "zsh" => matches!(argument, "-o" | "+o"), + // Fish accepts both short and long value-taking startup options + "fish" => matches!( + argument, + "-C" | "-d" + | "-o" + | "-p" + | "-f" + | "--init-command" + | "--debug" + | "--debug-output" + | "--profile" + | "--profile-startup" + | "--features" + ), + _ => false, + } +} + +fn replace_os(value: &OsStr, placeholder: &str, replacement: &str) -> OsString { + // TOML-originated values are UTF-8; non-UTF-8 programmatic values remain byte-for-byte stable + value.to_str().map_or_else( + || value.to_os_string(), + |value| OsString::from(value.replace(placeholder, replacement)), + ) +} + +mod os_string_vec { + use std::ffi::OsString; + + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) fn serialize(values: &[OsString], serializer: S) -> Result + where + S: Serializer, + { + values + .iter() + .map(|value| { + value + .to_str() + .ok_or_else(|| serde::ser::Error::custom("command argument is not UTF-8")) + }) + .collect::, _>>()? + .serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(deserializer) + .map(|values| values.into_iter().map(OsString::from).collect()) + } +} + +mod os_string_map { + use std::collections::BTreeMap; + use std::ffi::OsString; + + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) fn serialize( + values: &BTreeMap, + serializer: S, + ) -> Result + where + S: Serializer, + { + let values = values + .iter() + .map(|(name, value)| { + let name = name + .to_str() + .ok_or_else(|| serde::ser::Error::custom("environment name is not UTF-8"))?; + let value = value + .to_str() + .ok_or_else(|| serde::ser::Error::custom("environment value is not UTF-8"))?; + Ok((name, value)) + }) + .collect::, S::Error>>()?; + values.serialize(serializer) + } + + pub(super) fn deserialize<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + BTreeMap::::deserialize(deserializer).map(|values| { + values + .into_iter() + .map(|(name, value)| (OsString::from(name), OsString::from(value))) + .collect() + }) + } +} diff --git a/crates/unixnotis-core/src/process/tests/legacy.rs b/crates/unixnotis-core/src/process/tests/legacy.rs new file mode 100644 index 000000000..c7047eeb8 --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/legacy.rs @@ -0,0 +1,186 @@ +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use super::super::{parse_legacy_command, CommandSpec, LegacyCommandError}; + +#[test] +fn quoted_shell_punctuation_migrates_to_literal_direct_arguments() { + let parsed = parse_legacy_command("printf '%s\\n' 'battery|charging'") + .expect("parse quoted literal command"); + + assert_eq!(parsed.program(), Some(Path::new("printf"))); + assert_eq!( + parsed.args(), + Some([OsString::from("%s\\n"), OsString::from("battery|charging")].as_slice()) + ); + assert!(!parsed.is_shell()); +} + +#[test] +fn leading_environment_assignments_migrate_to_direct_environment() { + let parsed = parse_legacy_command("LANG=C MODE='two words' /bin/printf ok") + .expect("parse environment command"); + + assert_eq!(parsed.program(), Some(Path::new("/bin/printf"))); + let env = parsed.env().expect("direct environment"); + assert_eq!(env.get(OsStr::new("LANG")), Some(&"C".into())); + assert_eq!(env.get(OsStr::new("MODE")), Some(&"two words".into())); +} + +#[test] +fn real_shell_operators_remain_explicit_shell_scripts() { + for command in [ + "producer | parser", + "first && second", + "echo $HOME", + "printf '%s' \"$HOME\"", + "echo *.png", + ] { + assert_eq!( + parse_legacy_command(command).expect("parse shell command"), + CommandSpec::shell(command), + "{command}" + ); + } +} + +#[test] +fn legacy_shell_c_wrapper_migrates_to_the_inner_explicit_script() { + assert_eq!( + parse_legacy_command("sh -c 'producer | parser'").expect("parse shell wrapper"), + CommandSpec::shell("producer | parser") + ); +} + +#[test] +fn shell_wrappers_with_environment_or_extra_arguments_remain_direct() { + for command in ["MODE=safe sh -c 'exit 0'", "sh -c 'exit 0' extra"] { + let parsed = parse_legacy_command(command).expect("parse shell wrapper"); + + assert!(!parsed.is_shell(), "{command}"); + assert!(parsed.uses_shell_command_string(), "{command}"); + } +} + +#[test] +fn ordinary_two_argument_commands_never_become_shell_scripts() { + let parsed = parse_legacy_command("printf -c literal").expect("parse direct command"); + + assert!(!parsed.is_shell()); + assert_eq!(parsed.program(), Some(Path::new("printf"))); +} + +#[test] +fn escaped_metacharacters_and_runtime_placeholders_stay_direct() { + for command in [ + r"printf battery\|charging", + "wpctl set-volume sink {value}%", + r"printf \$HOME", + ] { + assert!( + !parse_legacy_command(command) + .expect("parse direct command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn escaped_quotes_do_not_expose_literal_shell_punctuation() { + for command in [ + r#"printf "literal\"|value""#, + r"printf 'literal|value'", + r"printf literal\|value", + ] { + assert!( + !parse_legacy_command(command) + .expect("parse quoted direct command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn shell_expansion_inside_double_quotes_remains_shell_mode() { + for command in [r#"printf "$HOME""#, r#"printf "`pwd`""#] { + assert!( + parse_legacy_command(command) + .expect("parse expanded command") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn comments_and_history_expansion_only_trigger_at_token_boundaries() { + for command in ["printf value#suffix", "printf value!suffix"] { + assert!( + !parse_legacy_command(command) + .expect("parse literal token") + .is_shell(), + "{command}" + ); + } + for command in ["printf value # comment", "printf value ! history"] { + assert!( + parse_legacy_command(command) + .expect("parse shell token") + .is_shell(), + "{command}" + ); + } +} + +#[test] +fn unknown_or_unbalanced_braces_require_explicit_shell_mode() { + for command in ["printf {other}", "printf value}", "printf {value}{other}"] { + assert!( + parse_legacy_command(command) + .expect("parse brace command") + .is_shell(), + "{command}" + ); + } + + assert!(!parse_legacy_command("printf {value}") + .expect("parse runtime placeholder") + .is_shell()); +} + +#[test] +fn environment_assignment_names_follow_portable_identifier_rules() { + let parsed = parse_legacy_command("_A=1 A2=two /bin/true").expect("parse valid assignments"); + let env = parsed.env().expect("direct environment"); + assert_eq!(env.get(OsStr::new("_A")), Some(&OsString::from("1"))); + assert_eq!(env.get(OsStr::new("A2")), Some(&OsString::from("two"))); + + for command in [ + "1A=value /bin/true", + "A-B=value /bin/true", + "=value /bin/true", + ] { + let parsed = parse_legacy_command(command).expect("parse non-assignment token"); + let first_token = command + .split_whitespace() + .next() + .expect("test command must contain a program token"); + assert_eq!(parsed.program(), Some(Path::new(first_token))); + assert!(parsed.env().expect("direct environment").is_empty()); + } +} + +#[test] +fn invalid_legacy_commands_fail_closed() { + assert_eq!(parse_legacy_command(" "), Err(LegacyCommandError::Empty)); + assert!(matches!( + parse_legacy_command("echo 'unterminated"), + Err(LegacyCommandError::Malformed(_)) + )); + assert_eq!( + parse_legacy_command("NAME=value"), + Err(LegacyCommandError::MissingProgram) + ); +} diff --git a/crates/unixnotis-core/src/process/tests/mod.rs b/crates/unixnotis-core/src/process/tests/mod.rs new file mode 100644 index 000000000..efc97803f --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/mod.rs @@ -0,0 +1,2 @@ +mod legacy; +mod spec; diff --git a/crates/unixnotis-core/src/process/tests/spec.rs b/crates/unixnotis-core/src/process/tests/spec.rs new file mode 100644 index 000000000..16a372d51 --- /dev/null +++ b/crates/unixnotis-core/src/process/tests/spec.rs @@ -0,0 +1,146 @@ +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use super::super::CommandSpec; + +#[test] +fn direct_spec_round_trips_through_toml_as_strings() { + let spec = + CommandSpec::direct("wpctl", ["get-volume", "@DEFAULT_AUDIO_SINK@"]).with_env("LANG", "C"); + let encoded = toml::to_string(&spec).expect("serialize direct command"); + let decoded: CommandSpec = toml::from_str(&encoded).expect("deserialize direct command"); + + assert_eq!(decoded, spec); + assert!(encoded.contains("mode = \"direct\"")); + assert!(encoded.contains("args = [\"get-volume\", \"@DEFAULT_AUDIO_SINK@\"]")); +} + +#[test] +fn placeholder_replacement_preserves_direct_command_boundaries() { + let spec = CommandSpec::direct("tool", ["--value={value}", "literal space"]) + .with_env("CURRENT", "{value}"); + let replaced = spec.replace("{value}", "42"); + + assert_eq!(replaced.program(), Some(Path::new("tool"))); + assert_eq!( + replaced.args(), + Some( + [ + OsString::from("--value=42"), + OsString::from("literal space") + ] + .as_slice() + ) + ); + assert_eq!( + replaced + .env() + .expect("direct environment") + .get(OsStr::new("CURRENT")), + Some(&"42".into()) + ); +} + +#[test] +fn placeholder_replacement_updates_explicit_shell_script_without_reclassification() { + let replaced = CommandSpec::shell("producer {value} | parser").replace("{value}", "7"); + + assert_eq!(replaced, CommandSpec::shell("producer 7 | parser")); +} + +#[test] +fn shell_detection_includes_direct_interpreter_invocations() { + assert!(CommandSpec::shell("printf ready").uses_shell_command_string()); + for shell in ["sh", "ash", "bash", "dash", "fish", "ksh", "zsh"] { + assert!( + CommandSpec::direct(shell, ["-c", "printf ready"]).uses_shell_command_string(), + "{shell} -c must retain the explicit shell boundary" + ); + } + assert!(CommandSpec::direct("/bin/bash", ["-lc", "printf ready"]).uses_shell_command_string()); + assert!(!CommandSpec::direct("sh", ["-x", "script"]).uses_shell_command_string()); + assert!(!CommandSpec::direct("printf", ["sh -c"]).uses_shell_command_string()); +} + +#[test] +fn shell_detection_does_not_treat_long_options_as_short_flag_clusters() { + assert!(!CommandSpec::direct("bash", ["--norc", "script.sh"]).uses_shell_command_string()); + assert!( + !CommandSpec::direct("fish", ["--no-config", "script.fish"]).uses_shell_command_string() + ); +} + +#[test] +fn shell_detection_stops_at_option_and_script_boundaries() { + assert!(!CommandSpec::direct("bash", ["--", "-c", "printf data"]).uses_shell_command_string()); + assert!( + !CommandSpec::direct("bash", ["script.sh", "-c", "literal argument"]) + .uses_shell_command_string() + ); +} + +#[test] +fn shell_detection_skips_option_values_before_command_flags() { + for (shell, option, value) in [ + ("bash", "-O", "extglob"), + ("sh", "-o", "nounset"), + ("ash", "-o", "nounset"), + ("dash", "-o", "nounset"), + ("ksh", "-R", "restricted-root"), + ("zsh", "-o", "SH_WORD_SPLIT"), + ("fish", "--debug", "reader"), + ] { + assert!( + CommandSpec::direct(shell, [option, value, "-c", "printf ready"]) + .uses_shell_command_string(), + "{shell} must resume option scanning after the {option} value" + ); + + assert!( + !CommandSpec::direct(shell, [option, "-c", "script.sh"]).uses_shell_command_string(), + "{shell} must not interpret the {option} value as a command flag" + ); + } + + assert!(CommandSpec::direct("bash", ["-x", "-c", "printf ready"]).uses_shell_command_string()); +} + +#[test] +fn fish_long_command_option_retains_the_command_string_boundary() { + assert!(CommandSpec::direct("fish", ["--command=printf ready"]).uses_shell_command_string()); +} + +#[test] +fn command_accessors_distinguish_direct_and_shell_data() { + let direct = CommandSpec::direct("printf", ["literal value"]); + let shell = CommandSpec::shell("producer | parser"); + + assert_eq!(direct.program(), Some(Path::new("printf"))); + assert_eq!(direct.script(), None); + assert_eq!(shell.program(), None); + assert_eq!(shell.args(), None); + assert_eq!(shell.env(), None); + assert_eq!(shell.script(), Some("producer | parser")); +} + +#[test] +fn empty_commands_are_detected_in_both_explicit_modes() { + assert!(CommandSpec::direct("", [] as [&str; 0]).is_empty()); + assert!(!CommandSpec::direct("printf", [] as [&str; 0]).is_empty()); + assert!(CommandSpec::shell(" \t\n").is_empty()); + assert!(!CommandSpec::shell("true").is_empty()); +} + +#[test] +fn command_display_keeps_program_arguments_and_shell_script_readable() { + let direct = CommandSpec::direct("printf", ["literal value", "battery|charging"]); + let shell = CommandSpec::shell("producer | parser"); + + assert_eq!( + direct.display_lossy(), + "printf literal value battery|charging" + ); + assert_eq!(direct.to_string(), "printf literal value battery|charging"); + assert_eq!(shell.display_lossy(), "producer | parser"); + assert_eq!(shell.to_string(), "producer | parser"); +} diff --git a/crates/unixnotis-core/src/reconnect.rs b/crates/unixnotis-core/src/reconnect.rs index f41f0039e..314b27cfe 100644 --- a/crates/unixnotis-core/src/reconnect.rs +++ b/crates/unixnotis-core/src/reconnect.rs @@ -94,7 +94,7 @@ pub fn jitter_duration(max_ms: u64) -> Duration { if max_ms == 0 { return Duration::ZERO; } - let jitter_ms = next_jitter_seed().wrapping_rem(max_ms); + let jitter_ms = next_jitter_seed() % max_ms; Duration::from_millis(jitter_ms) } diff --git a/crates/unixnotis-core/src/service_manager/envdir.rs b/crates/unixnotis-core/src/service_manager/envdir.rs new file mode 100644 index 000000000..6b5690107 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/envdir.rs @@ -0,0 +1,18 @@ +//! Shared envdir value encoding for runit and s6 service environments + +/// Convert one optional environment value to chpst/s6-envdir file contents +#[must_use] +pub fn envdir_file_contents(value: Option<&str>) -> String { + value.map_or_else(String::new, |value| { + let first_line = value + .split(['\0', '\n']) + .next() + .unwrap_or_default() + .trim_end_matches([' ', '\t']); + format!("{first_line}\n") + }) +} + +#[cfg(test)] +#[path = "tests/envdir.rs"] +mod tests; diff --git a/crates/unixnotis-core/src/service_manager/environment.rs b/crates/unixnotis-core/src/service_manager/environment.rs new file mode 100644 index 000000000..7e1e973f0 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/environment.rs @@ -0,0 +1,70 @@ +//! Backend-specific session environment policy + +use std::fmt; + +use super::ServiceManagerKind; + +const GRAPHICAL_SESSION_VARIABLES: [&str; 6] = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", + "XDG_SESSION_DESKTOP", +]; + +const DIRECT_MANAGER_VARIABLES: [&str; 7] = [ + "WAYLAND_DISPLAY", + "DISPLAY", + "XDG_RUNTIME_DIR", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_TYPE", + "XDG_SESSION_DESKTOP", + "DBUS_SESSION_BUS_ADDRESS", +]; + +/// Return the narrow environment allowlist for one service manager +#[must_use] +pub const fn variables_for_backend(kind: ServiceManagerKind) -> &'static [&'static str] { + match kind { + // systemd resolves the stable user bus through its own user-manager context + ServiceManagerKind::Systemd => &GRAPHICAL_SESSION_VARIABLES, + // Direct supervisors may need the stable user-bus address persisted explicitly + ServiceManagerKind::Dinit | ServiceManagerKind::Runit | ServiceManagerKind::S6 => { + &DIRECT_MANAGER_VARIABLES + } + } +} + +/// Error returned when an installer shell points at a transient or nonstandard bus +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SessionBusAddressError { + address: String, +} + +impl fmt::Display for SessionBusAddressError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "refusing to persist nonstandard session bus address: {}", + self.address + ) + } +} + +impl std::error::Error for SessionBusAddressError {} + +/// Require the standard per-user bus before persisting an explicit address +/// +/// # Errors +/// +/// Returns an error when the address does not name `/run/user//bus` +pub fn validate_session_bus_address(address: &str, uid: u32) -> Result<(), SessionBusAddressError> { + let expected = format!("unix:path=/run/user/{uid}/bus"); + if address == expected { + return Ok(()); + } + Err(SessionBusAddressError { + address: address.to_string(), + }) +} diff --git a/crates/unixnotis-core/src/service_manager/mod.rs b/crates/unixnotis-core/src/service_manager/mod.rs index 22e29d345..b3f1f9303 100644 --- a/crates/unixnotis-core/src/service_manager/mod.rs +++ b/crates/unixnotis-core/src/service_manager/mod.rs @@ -1,8 +1,13 @@ //! Shared service-manager identity and user-path resolution +mod envdir; +mod environment; mod kind; mod paths; +pub use environment::{ + validate_session_bus_address, variables_for_backend, SessionBusAddressError, +}; pub use kind::ServiceManagerKind; pub use paths::{ dinit_user_dir, resolve_service_manager_paths, runit_user_dir, s6_live_dir, s6_user_dir, @@ -11,3 +16,4 @@ pub use paths::{ #[cfg(test)] mod tests; +pub use envdir::envdir_file_contents; diff --git a/crates/unixnotis-core/src/service_manager/tests/envdir.rs b/crates/unixnotis-core/src/service_manager/tests/envdir.rs new file mode 100644 index 000000000..61fdc4b73 --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/tests/envdir.rs @@ -0,0 +1,15 @@ +use super::super::envdir_file_contents; + +#[test] +fn envdir_contents_keep_only_the_trimmed_first_line() { + assert_eq!( + envdir_file_contents(Some("wayland-1 \nignored")), + "wayland-1\n" + ); + assert_eq!(envdir_file_contents(Some("value\0ignored")), "value\n"); +} + +#[test] +fn missing_envdir_value_creates_an_empty_unset_marker() { + assert_eq!(envdir_file_contents(None), ""); +} diff --git a/crates/unixnotis-core/src/service_manager/tests/environment.rs b/crates/unixnotis-core/src/service_manager/tests/environment.rs new file mode 100644 index 000000000..88ab03f7e --- /dev/null +++ b/crates/unixnotis-core/src/service_manager/tests/environment.rs @@ -0,0 +1,36 @@ +use super::super::{validate_session_bus_address, variables_for_backend, ServiceManagerKind}; + +#[test] +fn systemd_environment_excludes_shell_bus_and_path_values() { + let variables = variables_for_backend(ServiceManagerKind::Systemd); + + assert!(variables.contains(&"WAYLAND_DISPLAY")); + assert!(variables.contains(&"XDG_RUNTIME_DIR")); + assert!(!variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); +} + +#[test] +fn direct_managers_accept_only_an_explicit_stable_bus_variable() { + for kind in [ + ServiceManagerKind::Dinit, + ServiceManagerKind::Runit, + ServiceManagerKind::S6, + ] { + let variables = variables_for_backend(kind); + assert!(variables.contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!variables.contains(&"PATH")); + } +} + +#[test] +fn persisted_session_bus_address_must_match_the_standard_user_bus() { + assert!(validate_session_bus_address("unix:path=/run/user/1000/bus", 1000).is_ok()); + + let error = validate_session_bus_address("unix:path=/tmp/transient-bus", 1000) + .expect_err("transient bus must be rejected"); + assert_eq!( + error.to_string(), + "refusing to persist nonstandard session bus address: unix:path=/tmp/transient-bus" + ); +} diff --git a/crates/unixnotis-core/src/service_manager/tests/mod.rs b/crates/unixnotis-core/src/service_manager/tests/mod.rs index 707d93256..6cbb4521b 100644 --- a/crates/unixnotis-core/src/service_manager/tests/mod.rs +++ b/crates/unixnotis-core/src/service_manager/tests/mod.rs @@ -1,2 +1,3 @@ +mod environment; mod kind; mod paths; diff --git a/crates/unixnotis-core/src/tests/bus_call.rs b/crates/unixnotis-core/src/tests/bus_call.rs new file mode 100644 index 000000000..cd08d930c --- /dev/null +++ b/crates/unixnotis-core/src/tests/bus_call.rs @@ -0,0 +1,22 @@ +use std::time::Duration; + +use super::{timed_dbus_call, timed_dbus_call_with_timeout}; + +#[tokio::test] +async fn internal_dbus_call_timeout_is_hard_and_bounded() { + let call = std::future::pending::>(); + let error = timed_dbus_call_with_timeout(Duration::from_millis(1), call) + .await + .expect_err("pending method must time out"); + + assert!(error.to_string().contains("timed out")); +} + +#[tokio::test] +async fn internal_dbus_call_returns_success_without_delay() { + let value = timed_dbus_call(std::future::ready(Ok::<_, zbus::Error>(42))) + .await + .expect("ready call should pass"); + + assert_eq!(value, 42); +} diff --git a/crates/unixnotis-core/src/util/commands.rs b/crates/unixnotis-core/src/util/commands.rs deleted file mode 100644 index 463593f8d..000000000 --- a/crates/unixnotis-core/src/util/commands.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Command-shape checks shared by configuration consumers - -pub const SHELL_META_CHARS: [char; 15] = [ - '|', '&', ';', '<', '>', '$', '`', '(', ')', '{', '}', '[', ']', '*', '?', -]; - -/// Returns true when the command can run without a shell wrapper -/// -/// # Example -/// ``` -/// use unixnotis_core::util::is_simple_command; -/// -/// assert!(is_simple_command("echo hello")); -/// assert!(!is_simple_command("echo hello | wc -l")); -/// ``` -#[must_use] -pub fn is_simple_command(cmd: &str) -> bool { - if cmd - .chars() - .any(|ch| SHELL_META_CHARS.contains(&ch) || ch == '~' || ch == '\n' || ch == '\r') - { - return false; - } - - // Leading assignments need shell parsing unless the first token is an explicit path - let first = cmd.split_whitespace().next().unwrap_or_default(); - if first.contains('=') && !first.starts_with('/') && !first.starts_with("./") { - return false; - } - - true -} - -#[cfg(test)] -#[path = "tests/commands.rs"] -mod tests; diff --git a/crates/unixnotis-core/src/util/diagnostics.rs b/crates/unixnotis-core/src/util/diagnostics.rs index 8ad473e6c..177f2524c 100644 --- a/crates/unixnotis-core/src/util/diagnostics.rs +++ b/crates/unixnotis-core/src/util/diagnostics.rs @@ -14,14 +14,11 @@ pub fn diagnostic_mode() -> bool { } fn diagnostic_mode_from(value: Option<&str>) -> bool { - matches!( - value - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str(), - "1" | "true" | "yes" | "on" - ) + let value = value.unwrap_or_default().trim(); + value == "1" + || ["true", "yes", "on"] + .iter() + .any(|expected| value.eq_ignore_ascii_case(expected)) } /// Returns the default redaction length for logs diff --git a/crates/unixnotis-core/src/util/display.rs b/crates/unixnotis-core/src/util/display.rs index 942cfba81..a69818df2 100644 --- a/crates/unixnotis-core/src/util/display.rs +++ b/crates/unixnotis-core/src/util/display.rs @@ -52,6 +52,53 @@ pub fn sanitize_inline_display_text(value: &str) -> String { sanitize_display_text_with(value, false) } +/// Fold an unbroken display token to a bounded column width +#[must_use] +pub fn fold_text_for_layout(value: &str, max_contiguous: usize) -> String { + if value.is_empty() || max_contiguous == 0 { + return value.to_string(); + } + + let mut output = String::with_capacity(value.len()); + let mut run_width = 0usize; + let mut folded_run = false; + + for character in value.chars() { + if character.is_whitespace() { + // Whitespace begins a fresh independently bounded token + run_width = 0; + folded_run = false; + output.push(character); + continue; + } + + let width = display_width(character); + if run_width.saturating_add(width) <= max_contiguous { + output.push(character); + run_width = run_width.saturating_add(width); + continue; + } + + if !folded_run { + let ellipsis_width = display_width('…'); + // Reclaim only the columns needed for one visible truncation marker + while run_width.saturating_add(ellipsis_width) > max_contiguous { + let Some(last) = output.pop() else { + break; + }; + run_width = run_width.saturating_sub(display_width(last)); + } + if run_width.saturating_add(ellipsis_width) <= max_contiguous { + output.push('…'); + run_width = run_width.saturating_add(ellipsis_width); + } + folded_run = true; + } + } + + output +} + fn sanitize_display_text_with(value: &str, keep_newlines: bool) -> String { sanitize_display_text_with_limit(value, keep_newlines, usize::MAX) } @@ -107,6 +154,20 @@ const fn is_bidi_control(ch: char) -> bool { ) } +fn display_width(character: char) -> usize { + // Joiners and selectors count as one slot because UI estimators can expose them separately + if matches!( + character, + '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FE0E}' | '\u{FE0F}' + ) { + return 1; + } + UnicodeWidthChar::width_cjk(character).unwrap_or(0) +} + #[cfg(test)] #[path = "tests/display.rs"] mod tests; +use unicode_width::UnicodeWidthChar; + +pub const MAX_DISPLAY_TOKEN_WIDTH: usize = 96; diff --git a/crates/unixnotis-core/src/util/mod.rs b/crates/unixnotis-core/src/util/mod.rs index 27524bab4..4ae176f6e 100644 --- a/crates/unixnotis-core/src/util/mod.rs +++ b/crates/unixnotis-core/src/util/mod.rs @@ -1,18 +1,18 @@ //! Shared helper utilities used across `UnixNotis` components -mod commands; mod diagnostics; mod display; mod paths; mod programs; +mod text; -pub use commands::{is_simple_command, SHELL_META_CHARS}; pub use diagnostics::{ default_log_limit, diagnostic_log_limit, diagnostic_mode, log_limit, log_snippet, }; pub use display::{ - sanitize_display_text, sanitize_display_text_bounded, sanitize_inline_display_text, - sanitize_log_value, + fold_text_for_layout, sanitize_display_text, sanitize_display_text_bounded, + sanitize_inline_display_text, sanitize_log_value, MAX_DISPLAY_TOKEN_WIDTH, }; pub use paths::{expand_tilde, resolve_state_dir, resolve_state_dir_from_env, CONFIG_PATH_ENV}; pub use programs::{program_in_path, trusted_system_program_path, TRUSTED_SYSTEM_TOOL_DIRS}; +pub use text::truncate_utf8_bytes; diff --git a/crates/unixnotis-core/src/util/tests/commands.rs b/crates/unixnotis-core/src/util/tests/commands.rs deleted file mode 100644 index e67947acd..000000000 --- a/crates/unixnotis-core/src/util/tests/commands.rs +++ /dev/null @@ -1,34 +0,0 @@ -use super::*; - -#[test] -fn simple_command_accepts_plain_program_and_arguments() { - assert!(is_simple_command("notify-send hello world")); - assert!(is_simple_command("/usr/bin/notify-send hello")); - assert!(is_simple_command("./local-helper --flag value")); -} - -#[test] -fn simple_command_rejects_shell_meta_characters_and_newlines() { - for command in [ - "echo hi | wc -l", - "echo hi && echo bye", - "echo hi; rm -rf x", - "echo $(date)", - "echo `date`", - "echo ~/file", - "echo one\necho two", - "echo one\recho two", - ] { - assert!( - !is_simple_command(command), - "command should need a shell: {command}" - ); - } -} - -#[test] -fn simple_command_rejects_leading_env_assignment_without_explicit_path() { - assert!(!is_simple_command("FOO=bar notify-send hi")); - assert!(is_simple_command("/tmp/FOO=bar notify-send hi")); - assert!(is_simple_command("./FOO=bar notify-send hi")); -} diff --git a/crates/unixnotis-core/src/util/tests/display.rs b/crates/unixnotis-core/src/util/tests/display.rs index 30451fa45..855896a76 100644 --- a/crates/unixnotis-core/src/util/tests/display.rs +++ b/crates/unixnotis-core/src/util/tests/display.rs @@ -63,3 +63,61 @@ fn bounded_display_text_handles_zero_and_exact_limits() { assert_eq!(sanitize_display_text_bounded("value", 5), "value..."); assert_eq!(sanitize_display_text_bounded("ok", 5), "ok"); } + +#[test] +fn layout_folding_bounds_long_unbroken_tokens() { + let input = "x".repeat(200); + let folded = fold_text_for_layout(&input, MAX_DISPLAY_TOKEN_WIDTH); + let longest = folded + .split_whitespace() + .map(|part| part.chars().filter(char::is_ascii_alphanumeric).count()) + .max() + .unwrap_or(0); + + assert!(folded.contains('…')); + assert!(longest <= MAX_DISPLAY_TOKEN_WIDTH); +} + +#[test] +fn layout_folding_handles_zero_exact_and_separate_token_limits() { + assert_eq!(fold_text_for_layout("unchanged", 0), "unchanged"); + assert_eq!( + fold_text_for_layout( + &"x".repeat(MAX_DISPLAY_TOKEN_WIDTH), + MAX_DISPLAY_TOKEN_WIDTH + ), + "x".repeat(MAX_DISPLAY_TOKEN_WIDTH) + ); + let separate = format!( + "{} {}", + "x".repeat(MAX_DISPLAY_TOKEN_WIDTH), + "y".repeat(MAX_DISPLAY_TOKEN_WIDTH) + ); + assert_eq!( + fold_text_for_layout(&separate, MAX_DISPLAY_TOKEN_WIDTH), + separate + ); +} + +#[test] +fn layout_folding_reserves_only_the_required_ellipsis_width() { + assert_eq!(fold_text_for_layout("xxxx", 3), "x…"); + let folded = fold_text_for_layout(&"x".repeat(200), MAX_DISPLAY_TOKEN_WIDTH); + + // The CJK-width ellipsis uses two columns beside 94 ASCII characters + assert_eq!(folded.chars().count(), 95); +} + +#[test] +fn layout_folding_counts_wide_glyphs_joiners_and_selectors() { + let wide = fold_text_for_layout(&"界".repeat(120), MAX_DISPLAY_TOKEN_WIDTH); + let emoji = fold_text_for_layout( + &"👨\u{200D}👩\u{200D}👧\u{200D}👦".repeat(80), + MAX_DISPLAY_TOKEN_WIDTH, + ); + + assert!(wide.chars().map(display_width).sum::() <= MAX_DISPLAY_TOKEN_WIDTH); + assert!(emoji.chars().map(display_width).sum::() <= MAX_DISPLAY_TOKEN_WIDTH); + assert!(display_width('界') > 1); + assert_eq!(display_width('\u{200D}'), 1); +} diff --git a/crates/unixnotis-core/src/util/tests/text.rs b/crates/unixnotis-core/src/util/tests/text.rs new file mode 100644 index 000000000..1b0f412cf --- /dev/null +++ b/crates/unixnotis-core/src/util/tests/text.rs @@ -0,0 +1,25 @@ +use super::truncate_utf8_bytes; + +#[test] +fn truncation_keeps_values_that_fit_the_byte_budget() { + assert_eq!(truncate_utf8_bytes("plain", 5), "plain"); + assert_eq!(truncate_utf8_bytes("🙂", 4), "🙂"); +} + +#[test] +fn truncation_returns_empty_text_for_a_zero_byte_budget() { + assert_eq!(truncate_utf8_bytes("text", 0), ""); +} + +#[test] +fn truncation_stops_before_a_partial_multibyte_character() { + assert_eq!(truncate_utf8_bytes("abc🙂def", 5), "abc"); + assert_eq!(truncate_utf8_bytes("éé", 3), "é"); +} + +#[test] +fn truncation_accepts_every_boundary_inside_a_four_byte_character() { + for limit in 1..4 { + assert_eq!(truncate_utf8_bytes("🙂tail", limit), ""); + } +} diff --git a/crates/unixnotis-core/src/util/text.rs b/crates/unixnotis-core/src/util/text.rs new file mode 100644 index 000000000..6dc1f8b85 --- /dev/null +++ b/crates/unixnotis-core/src/util/text.rs @@ -0,0 +1,22 @@ +//! Bounded text operations shared across process and D-Bus boundaries + +/// Return an owned prefix no longer than `max_bytes` without splitting a UTF-8 character +#[must_use] +pub fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + // Preserve the complete value when it already fits the caller's byte budget + return value.to_string(); + } + + // A UTF-8 scalar uses at most four bytes, so this range examines no more than four offsets + let end = (max_bytes.saturating_sub(3)..=max_bytes) + .rev() + .find(|offset| value.is_char_boundary(*offset)) + .unwrap_or_default(); + + value[..end].to_string() +} + +#[cfg(test)] +#[path = "tests/text.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/Cargo.toml b/crates/unixnotis-daemon/Cargo.toml index 07245cfe8..f47b15ce3 100644 --- a/crates/unixnotis-daemon/Cargo.toml +++ b/crates/unixnotis-daemon/Cargo.toml @@ -6,16 +6,29 @@ license.workspace = true [dependencies] anyhow.workspace = true +arc-swap.workspace = true +blake3.workspace = true clap.workspace = true chrono.workspace = true futures-util.workspace = true +gio.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +unicode-security.workspace = true zbus.workspace = true unixnotis-core = { path = "../unixnotis-core" } indexmap.workspace = true +libc.workspace = true +notify.workspace = true rustix.workspace = true -unicode-width.workspace = true +shell-words.workspace = true +tree-sitter.workspace = true +tree-sitter-bash.workspace = true +url.workspace = true +wait-timeout.workspace = true + +[dev-dependencies] +image.workspace = true diff --git a/crates/unixnotis-daemon/src/child_process/paths.rs b/crates/unixnotis-daemon/src/child_process/paths.rs index 79a4f0070..bbf0f023b 100644 --- a/crates/unixnotis-daemon/src/child_process/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/paths.rs @@ -8,7 +8,7 @@ use tokio::process::Command; #[cfg(target_os = "linux")] use std::os::unix::process::CommandExt; -#[cfg(unix)] +#[cfg(target_os = "linux")] use rustix::process::{set_parent_process_death_signal, Signal}; fn resolve_sibling_binary(name: &str) -> Option { @@ -39,18 +39,26 @@ pub(super) fn resolve_center_path() -> Option { } #[cfg(target_os = "linux")] -pub(super) fn apply_parent_death_signal(command: &mut Command) { - // If the daemon dies, the UI child should not linger alone - // SAFETY: The pre-exec closure performs only the Linux prctl call before process launch +pub(super) fn apply_parent_death_signal(command: &mut Command, expected_parent_pid: u32) { + // The kernel clears the child relationship before the new program starts + // SAFETY: This closure only calls prctl through rustix and returns its OS error unsafe { - command.as_std_mut().pre_exec(|| { - set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from) + command.as_std_mut().pre_exec(move || { + set_parent_process_death_signal(Some(Signal::TERM)).map_err(std::io::Error::from)?; + let current_parent = rustix::process::getppid() + .map(|pid| pid.as_raw_nonzero().get()) + .unwrap_or_default(); + if current_parent != i32::try_from(expected_parent_pid).unwrap_or_default() { + // ESRCH is returned without formatting or allocating after fork + return Err(std::io::Error::from_raw_os_error(libc::ESRCH)); + } + Ok(()) }); } } #[cfg(not(target_os = "linux"))] -pub(super) fn apply_parent_death_signal(_command: &mut Command) {} +pub(super) fn apply_parent_death_signal(_command: &mut Command, _expected_parent_pid: u32) {} #[cfg(test)] #[path = "tests/paths.rs"] diff --git a/crates/unixnotis-daemon/src/child_process/process.rs b/crates/unixnotis-daemon/src/child_process/process.rs index 307057d4d..c9ea77cde 100644 --- a/crates/unixnotis-daemon/src/child_process/process.rs +++ b/crates/unixnotis-daemon/src/child_process/process.rs @@ -40,35 +40,27 @@ impl UiProcessKind { pub(super) fn mark_running(self, state: &DaemonState, running: bool) { match self { - Self::Popups => state.set_popups_running(running), - Self::Center => { - let _ = running; - // Center readiness is tied to live subscriptions - // A spawned process alone is not enough to mark it ready - // Spawned is not the same as subscribed and ready - // The center flips this to true once its control streams are active - state.set_panel_ready(false); - } + Self::Popups => state.set_popups_process_running(running), + Self::Center => state.set_center_process_running(running), } } - pub(super) fn build_command(self, args: &Args) -> Command { - let mut command = match self { - Self::Popups => { - if let Some(path) = resolve_popups_path() { - Command::new(path) - } else { - Command::new("unixnotis-popups") - } - } - Self::Center => { - if let Some(path) = resolve_center_path() { - Command::new(path) - } else { - Command::new("unixnotis-center") - } - } + pub(super) fn build_command(self, args: &Args) -> Result { + let path = match self { + Self::Popups => resolve_popups_path(), + Self::Center => resolve_center_path(), }; + let path = path.ok_or_else(|| { + anyhow!( + "{} is missing beside the daemon executable; refusing a PATH-based child launch", + self.label() + ) + })?; + Ok(Self::build_command_for_path(args, path)) + } + + fn build_command_for_path(args: &Args, path: PathBuf) -> Command { + let mut command = Command::new(path); // Journal should keep child logs tied to the daemon service // Inherited output makes crash lines easier to trace later @@ -76,7 +68,7 @@ impl UiProcessKind { command.stdout(Stdio::inherit()); command.stderr(Stdio::inherit()); - apply_parent_death_signal(&mut command); + apply_parent_death_signal(&mut command, std::process::id()); if let Some(config) = args.config.as_ref() { // GTK re-parses argv in child apps, so custom config paths travel by env instead @@ -90,10 +82,10 @@ impl UiProcessKind { } pub(super) fn start(self, args: &Args) -> Result { - let mut command = self.build_command(args); + let mut command = self.build_command(args)?; let label = self.label(); command.spawn().map_err(|err| { - anyhow!("failed to start {label} ({err}); build it or install it on PATH") + anyhow!("failed to start {label} ({err}); install it beside the daemon executable") }) } } diff --git a/crates/unixnotis-daemon/src/child_process/tests/command.rs b/crates/unixnotis-daemon/src/child_process/tests/command.rs index 4f2a91ab3..1c27515f7 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/command.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/command.rs @@ -19,10 +19,10 @@ async fn mark_running_updates_popup_health_and_resets_center_readiness() { let state = daemon_state_for_test(false).await; UiProcessKind::Popups.mark_running(&state, true); - assert!(state.popups_running()); + assert!(state.ui_health().popups_process_running); // Center process spawn is not readiness; readiness only flips after live subscriptions - state.set_panel_ready(true); + state.set_panel_ready(":1.20", true); UiProcessKind::Center.mark_running(&state, true); assert!(!state.panel_ready()); } @@ -45,7 +45,10 @@ fn child_config_env_path_resolves_relative_paths_against_current_directory() { #[test] fn build_command_sets_config_env_instead_of_forwarding_flag() { let args = Args::parse_from(["unixnotis-daemon", "--config", "fixtures/config.toml"]); - let command = UiProcessKind::Center.build_command(&args); + let command = UiProcessKind::build_command_for_path( + &args, + PathBuf::from("/tmp/unixnotis-test/bin/unixnotis-center"), + ); let std_command = command.as_std(); let args: Vec<_> = std_command.get_args().map(OsString::from).collect(); let envs: Vec<_> = std_command @@ -57,6 +60,7 @@ fn build_command_sets_config_env_instead_of_forwarding_flag() { !args.iter().any(|arg| arg == "--config"), "child argv should stay free of UnixNotis-only flags" ); + assert!(Path::new(std_command.get_program()).is_absolute()); assert!( envs.iter().any(|(key, value)| { key == CONFIG_PATH_ENV @@ -69,7 +73,10 @@ fn build_command_sets_config_env_instead_of_forwarding_flag() { #[test] fn build_command_clears_inherited_config_override_without_custom_path() { let args = Args::parse_from(["unixnotis-daemon"]); - let command = UiProcessKind::Popups.build_command(&args); + let command = UiProcessKind::build_command_for_path( + &args, + PathBuf::from("/tmp/unixnotis-test/bin/unixnotis-popups"), + ); let std_command = command.as_std(); assert!( diff --git a/crates/unixnotis-daemon/src/child_process/tests/paths.rs b/crates/unixnotis-daemon/src/child_process/tests/paths.rs index abce75605..f9d564eaf 100644 --- a/crates/unixnotis-daemon/src/child_process/tests/paths.rs +++ b/crates/unixnotis-daemon/src/child_process/tests/paths.rs @@ -15,6 +15,25 @@ fn write_sibling(name: &str) -> PathBuf { path } +#[cfg(target_os = "linux")] +fn process_is_running(pid: u32) -> bool { + let stat_path = format!("/proc/{pid}/stat"); + let Ok(stat) = std::fs::read_to_string(stat_path) else { + return false; + }; + + // The process name may contain spaces and parentheses, so parse after its final ')' + let Some(state) = stat + .rsplit_once(") ") + .and_then(|(_, rest)| rest.chars().next()) + else { + return false; + }; + + // A zombie has exited but can remain visible until the reaper collects it + !matches!(state, 'Z' | 'X') +} + #[test] fn resolve_sibling_binary_prefers_exact_sibling_name() { let _guard = env_lock(); @@ -55,3 +74,99 @@ fn resolve_sibling_binary_returns_none_when_no_sibling_exists() { assert!(resolve_sibling_binary("unixnotis-missing").is_none()); } + +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_terminates_a_child_when_its_launcher_exits() { + let _guard = env_lock(); + let marker_path = std::env::temp_dir().join(format!( + "unixnotis-pdeath-{}-{}.pid", + std::process::id(), + std::time::Instant::now().elapsed().as_nanos() + )); + let _ = std::fs::remove_file(&marker_path); + let helper = std::env::current_exe().expect("current test executable"); + let status = std::process::Command::new(helper) + .args([ + "--exact", + "child_process::paths::tests::parent_death_signal_child_helper", + "--nocapture", + ]) + .env("UNIXNOTIS_PDEATH_MARKER", &marker_path) + .status() + .expect("launch parent-death helper"); + assert!(status.success(), "helper test failed: {status}"); + + let pid = std::fs::read_to_string(&marker_path) + .expect("helper should publish the child pid") + .trim() + .parse::() + .expect("child pid should be numeric"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process_is_running(pid) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + if process_is_running(pid) { + // Clean up a failed mutation so the test cannot leak a long-running shell + let _ = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + } + let cleanup_deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while process_is_running(pid) && std::time::Instant::now() < cleanup_deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(!process_is_running(pid), "child survived launcher exit"); + let _ = std::fs::remove_file(marker_path); +} + +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_rejects_a_changed_parent_before_exec() { + let _guard = env_lock(); + let helper = std::env::current_exe().expect("current test executable"); + let status = std::process::Command::new(helper) + .args([ + "--exact", + "child_process::paths::tests::parent_death_signal_child_helper", + "--nocapture", + ]) + .env("UNIXNOTIS_PDEATH_EXPECT_MISMATCH", "1") + .status() + .expect("launch parent-death race helper"); + assert!(status.success(), "mismatch helper failed: {status}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn parent_death_signal_child_helper() { + let Some(marker) = std::env::var_os("UNIXNOTIS_PDEATH_MARKER") else { + return; + }; + let mut command = tokio::process::Command::new("/bin/sh"); + command + .args(["-c", "trap 'exit 0' TERM; while :; do sleep 1; done"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + let expected_parent_pid = if std::env::var_os("UNIXNOTIS_PDEATH_EXPECT_MISMATCH").is_some() { + std::process::id().saturating_add(1) + } else { + std::process::id() + }; + apply_parent_death_signal(&mut command, expected_parent_pid); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .expect("build helper runtime"); + runtime.block_on(async move { + let child = command.spawn(); + if std::env::var_os("UNIXNOTIS_PDEATH_EXPECT_MISMATCH").is_some() { + assert!(child.is_err(), "mismatched parent must fail before exec"); + return; + } + let child = child.expect("spawn supervised child"); + std::fs::write(marker, child.id().expect("child pid").to_string()) + .expect("write child pid marker"); + }); +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs index aecec1580..b283ef004 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/authorization.rs @@ -1,5 +1,6 @@ //! Caller authorization flow for control operations +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -10,12 +11,20 @@ use zbus::message::Header; use crate::daemon::DaemonState; use super::credentials::{connection_credentials, CallerCredentials}; -use super::paths::is_trusted_control_executable_path; -use super::policy::{TRUSTED_CONTROL_EXECUTABLES, TRUSTED_PANEL_READINESS_EXECUTABLES}; +#[cfg(target_os = "linux")] +use super::executable_trust::is_trusted_control_executable_from_fd; +#[cfg(not(target_os = "linux"))] +use super::executable_trust::is_trusted_control_executable_path; +use super::policy::{ + TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES, TRUSTED_INTERACTION_EXECUTABLES, + TRUSTED_PANEL_READINESS_EXECUTABLES, TRUSTED_POPUP_READINESS_EXECUTABLES, +}; #[cfg(not(target_os = "linux"))] -use super::process::read_process_executable_path; +use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] -use super::process::read_process_executable_path_from_pidfd; +use super::process_identity::{ + open_process_executable_from_pidfd, read_process_executable_path_from_pidfd, +}; pub(in crate::daemon) async fn authorize_control_call( state: &Arc, @@ -40,6 +49,29 @@ pub(in crate::daemon) async fn authorize_panel_readiness_call( .await } +pub(in crate::daemon) async fn authorize_interaction_call( + state: &Arc, + header: &Header<'_>, + method: &'static str, +) -> zbus::fdo::Result<()> { + authorize_control_call_for_executables(state, header, method, &TRUSTED_INTERACTION_EXECUTABLES) + .await +} + +pub(in crate::daemon) async fn authorize_popup_readiness_call( + state: &Arc, + header: &Header<'_>, + method: &'static str, +) -> zbus::fdo::Result<()> { + authorize_control_call_for_executables( + state, + header, + method, + &TRUSTED_POPUP_READINESS_EXECUTABLES, + ) + .await +} + async fn authorize_control_call_for_executables( state: &Arc, header: &Header<'_>, @@ -52,6 +84,11 @@ async fn authorize_control_call_for_executables( .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; let sender_name = sender.as_str().to_string(); + if state.control_owner_is_preauthorized(&sender_name) { + // The policy is internal and production startup always selects strict verification + return Ok(()); + } + let bus_name = zbus::names::BusName::try_from(sender_name.as_str()) .map_err(|_error| zbus::fdo::Error::AccessDenied("invalid sender".to_string()))?; // One bus reply keeps all identity fields tied to the same sender snapshot @@ -78,16 +115,24 @@ async fn authorize_control_call_for_executables( zbus::fdo::Error::AccessDenied("caller process id is unavailable".to_string()) })?; #[cfg(target_os = "linux")] - let exe_path = { + let (exe_path, exe_fd) = { // Linux must use the stable process handle from the same credential snapshot let pidfd = required_linux_process_fd(&credentials)?; - read_process_executable_path_from_pidfd(pidfd, pid) + let exe_path = read_process_executable_path_from_pidfd(pidfd, pid); + // Open /proc//exe as a descriptor to fingerprint the actual file object + // rather than a pathname that could be shadowed by a mount namespace + let exe_fd = open_process_executable_from_pidfd(pidfd, pid); + (exe_path, exe_fd) }; #[cfg(not(target_os = "linux"))] - let exe_path = read_process_executable_path(pid).await; - if let Some(err) = - control_executable_error(exe_path.as_deref(), allowed_executables, state.trial_mode()) - { + let (exe_path, exe_fd) = (read_process_executable_path(pid).await, None); + if let Some(err) = control_executable_error( + exe_path.as_deref(), + exe_fd.as_ref(), + allowed_executables, + state.trial_mode(), + state.trusted_executables(), + ) { warn!( method, sender = %sender_name, @@ -132,25 +177,58 @@ pub(in crate::daemon) fn control_owner_uid_error( )) } -pub(in crate::daemon) fn control_executable_is_allowed( - path: &Path, +pub(in crate::daemon) fn control_executable_is_allowed( + path: Option<&Path>, + exe_fd: Option<&Fd>, allowed_executables: &[&str], relaxed: bool, + trusted_snapshots: &HashMap, ) -> bool { - // Name allowlist and path trust are separate checks; both must pass + // Name allowlist is required; path trust is a separate check that must also pass + let Some(path) = path else { + return false; + }; let name_allowed = path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| allowed_executables.contains(&name)); - name_allowed && is_trusted_control_executable_path(path, relaxed) + if !name_allowed { + return false; + } + + // On Linux, verify the executable via its file descriptor to prevent + // mount-namespace bypass (UNX-4-001). The path is only used for the + // name allowlist above; the actual trust check uses the kernel file object. + // If we cannot obtain the descriptor, fail closed. + #[cfg(target_os = "linux")] + { + let Some(fd) = exe_fd else { + return false; + }; + is_trusted_control_executable_from_fd(fd, path, relaxed, trusted_snapshots) + } + + // Fallback for non-Linux: use path-based trust + #[cfg(not(target_os = "linux"))] + { + is_trusted_control_executable_path(path, relaxed, trusted_snapshots) + } } -pub(in crate::daemon) fn control_executable_error( +pub(in crate::daemon) fn control_executable_error( path: Option<&Path>, + exe_fd: Option<&Fd>, allowed_executables: &[&str], relaxed: bool, + trusted_snapshots: &HashMap, ) -> Option { - if path.is_some_and(|path| control_executable_is_allowed(path, allowed_executables, relaxed)) { + if control_executable_is_allowed( + path, + exe_fd, + allowed_executables, + relaxed, + trusted_snapshots, + ) { return None; } Some(zbus::fdo::Error::AccessDenied( diff --git a/crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs similarity index 65% rename from crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs index a99644b14..218d5273c 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/fingerprint.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/fingerprint.rs @@ -1,12 +1,15 @@ //! Fingerprint cache for trusted executable files +use std::os::unix::io::AsFd; use std::path::Path; use std::sync::{Mutex, OnceLock}; -use super::metadata::trusted_control_file_metadata_is_safe; -use super::policy::{ +use super::super::policy::{ FileFingerprint, FileFingerprintSignature, FingerprintCacheEntry, FINGERPRINT_CACHE_CAPACITY, }; +use super::metadata::trusted_control_file_metadata_is_safe; +#[cfg(target_os = "linux")] +use super::metadata::trusted_control_file_metadata_is_safe_from_stat; pub(in crate::daemon) fn file_fingerprint(path: &Path) -> Option { let metadata = std::fs::metadata(path).ok()?; @@ -27,6 +30,30 @@ pub(in crate::daemon) fn file_fingerprint(path: &Path) -> Option( + fd: &Fd, + path: &Path, +) -> Option { + // Open /proc//exe as a descriptor and fingerprint the actual kernel + // file object, not a pathname that could be shadowed by a mount namespace. + // This prevents the UNX-4-001 mount-namespace bypass. + let stat = rustix::fs::fstat(fd.as_fd()).ok()?; + if !rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() { + return None; + } + if !trusted_control_file_metadata_is_safe_from_stat(&stat) { + return None; + } + let signature = file_fingerprint_signature_from_stat(&stat)?; + if let Some(cached) = load_cached_fingerprint(path, signature) { + return Some(cached); + } + + let fingerprint = FileFingerprint { signature }; + store_cached_fingerprint(path, signature, fingerprint.clone()); + Some(fingerprint) +} + pub(in crate::daemon) fn file_fingerprint_signature( metadata: &std::fs::Metadata, ) -> Option { @@ -56,6 +83,24 @@ pub(in crate::daemon) fn file_fingerprint_signature( } } +#[cfg(target_os = "linux")] +pub(super) const fn file_fingerprint_signature_from_stat( + stat: &rustix::fs::Stat, +) -> Option { + Some(FileFingerprintSignature { + len: stat.st_size as u64, + dev: stat.st_dev, + ino: stat.st_ino, + mode: stat.st_mode, + uid: stat.st_uid, + gid: stat.st_gid, + mtime: stat.st_mtime, + mtime_nsec: stat.st_mtime_nsec.cast_signed(), + ctime: stat.st_ctime, + ctime_nsec: stat.st_ctime_nsec.cast_signed(), + }) +} + pub(in crate::daemon) fn fingerprint_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(Vec::new())) diff --git a/crates/unixnotis-daemon/src/daemon/auth/metadata.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs similarity index 61% rename from crates/unixnotis-daemon/src/daemon/auth/metadata.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs index f5f3a89ce..1bf52489b 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/metadata.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/metadata.rs @@ -19,6 +19,20 @@ pub(in crate::daemon) fn trusted_control_file_metadata_is_safe( trusted_control_owner_uid_is_allowed(uid, expected_uid) } +#[cfg(target_os = "linux")] +pub(in crate::daemon) fn trusted_control_file_metadata_is_safe_from_stat( + stat: &rustix::fs::Stat, +) -> bool { + // Group/world writable binaries can be replaced by accounts outside the trust boundary + if stat.st_mode & 0o022 != 0 { + return false; + } + + // User installs should be owned by the desktop user, while distro packages may be root + let expected_uid = geteuid().as_raw(); + trusted_control_owner_uid_is_allowed(stat.st_uid, expected_uid) +} + pub(in crate::daemon) const fn trusted_control_owner_uid_is_allowed( uid: u32, expected_uid: u32, diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs new file mode 100644 index 000000000..32dbcf361 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/mod.rs @@ -0,0 +1,24 @@ +//! Trusted executable path, metadata, fingerprint, and startup snapshot policy + +use std::collections::HashMap; + +mod fingerprint; +mod metadata; +pub(in crate::daemon::auth) mod paths; +mod snapshots; + +#[cfg(target_os = "linux")] +pub(super) use paths::is_trusted_control_executable_from_fd; +#[cfg(not(target_os = "linux"))] +pub(super) use paths::is_trusted_control_executable_path; + +pub(in crate::daemon) fn build_trusted_control_snapshots_for_current_executable( +) -> HashMap { + // Resolve the sibling directory before the daemon publishes any D-Bus service + paths::trusted_control_directory().map_or_else(HashMap::new, |trusted_dir| { + snapshots::build_trusted_control_snapshots(&trusted_dir) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/auth/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs similarity index 53% rename from crates/unixnotis-daemon/src/daemon/auth/paths.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs index 72cdee9f7..7a3e77a90 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/paths.rs @@ -1,14 +1,24 @@ //! Trusted executable path matching +use std::collections::HashMap; +use std::os::unix::io::AsFd; use std::path::{Path, PathBuf}; -use super::filesystem::canonicalize_best_effort; -use super::fingerprint::file_fingerprint; +use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; +use super::fingerprint::{file_fingerprint, file_fingerprint_from_fd}; use super::metadata::trusted_control_file_metadata_is_safe; -use super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; -use super::snapshots::trusted_control_snapshot; -pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed: bool) -> bool { +pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { + // Missing paths remain raw so later trust comparisons fail as ordinary mismatches + std::fs::canonicalize(path).unwrap_or_else(|_error| path.to_path_buf()) +} + +#[cfg(not(target_os = "linux"))] +pub(in crate::daemon) fn is_trusted_control_executable_path( + path: &Path, + relaxed: bool, + trusted_snapshots: &HashMap, +) -> bool { // Trust only known sibling binaries from the daemon install/build directory let Some(trusted_dir) = trusted_control_directory() else { return false; @@ -26,7 +36,7 @@ pub(in crate::daemon) fn is_trusted_control_executable_path(path: &Path, relaxed return is_trusted_control_executable_path_relaxed_in_dir(&observed, &trusted_dir); } - let Some(snapshot) = trusted_control_snapshot(&trusted_dir, observed_name) else { + let Some(snapshot) = trusted_snapshots.get(observed_name) else { return false; }; trusted_snapshot_matches_observed(&snapshot, &observed) @@ -53,10 +63,10 @@ pub(in crate::daemon) fn is_trusted_control_executable_path_relaxed_in_dir( return false; } - // Keep trust scoped to known local build/install locations in trial mode + // Writable launcher and shim paths are convenience locations, never trust roots + // Trial mode still binds authorization to the actual executable in the known tree trusted_path_matches_executable(trusted_dir, executable, path) || trusted_profile_sibling_matches_executable(trusted_dir, executable, path) - || trusted_local_bin_matches_executable(executable, path) } pub(in crate::daemon) fn trusted_path_matches_executable( @@ -87,28 +97,14 @@ pub(in crate::daemon) fn trusted_profile_sibling_matches_executable( .any(|candidate| canonicalize_best_effort(&candidate) == observed) } -pub(in crate::daemon) fn trusted_local_bin_matches_executable( - executable: &str, - observed: &Path, -) -> bool { - // Installed keybinds usually point to ~/.local/bin during trial sessions - let Some(home) = std::env::var_os("HOME") else { - return false; - }; - let candidate = PathBuf::from(home) - .join(".local") - .join("bin") - .join(executable); - canonicalize_best_effort(&candidate) == observed -} - -fn trusted_control_directory() -> Option { +pub(in crate::daemon::auth) fn trusted_control_directory() -> Option { // The daemon trusts binaries installed next to the running daemon executable let current_exe = std::env::current_exe().ok()?; let current_exe = canonicalize_best_effort(¤t_exe); current_exe.parent().map(Path::to_path_buf) } +#[cfg(not(target_os = "linux"))] pub(in crate::daemon::auth) fn trusted_snapshot_matches_observed( snapshot: &TrustedExecutableSnapshot, observed: &Path, @@ -120,3 +116,48 @@ pub(in crate::daemon::auth) fn trusted_snapshot_matches_observed( // Live fingerprint must still match the pinned startup snapshot file_fingerprint(observed).is_some_and(|fingerprint| fingerprint == snapshot.fingerprint) } + +#[cfg(target_os = "linux")] +pub(in crate::daemon::auth) fn is_trusted_control_executable_from_fd( + fd: &Fd, + path: &Path, + relaxed: bool, + trusted_snapshots: &HashMap, +) -> bool { + // Trust only known sibling binaries from the daemon install/build directory + let Some(trusted_dir) = trusted_control_directory() else { + return false; + }; + + let observed = canonicalize_best_effort(path); + let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { + return false; + } + + // Fingerprint the kernel file object via the descriptor, not the pathname. + // This prevents the UNX-4-001 mount-namespace bypass where an attacker + // shadows a trusted path with a different executable in their own namespace. + let fingerprint = match file_fingerprint_from_fd(fd, path) { + Some(fingerprint) => fingerprint, + None => return false, + }; + + if relaxed { + // Relaxed mode checks the path is in a trusted location, then verifies + // the descriptor fingerprint matches the live file at that path + if !is_trusted_control_executable_path_relaxed_in_dir(&observed, &trusted_dir) { + return false; + } + // Verify the descriptor fingerprint matches what we'd get from the path + file_fingerprint(path).is_some_and(|path_fingerprint| path_fingerprint == fingerprint) + } else { + // Strict mode: the descriptor fingerprint must match the startup snapshot + let Some(snapshot) = trusted_snapshots.get(observed_name) else { + return false; + }; + fingerprint == snapshot.fingerprint + } +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs new file mode 100644 index 000000000..94150f705 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/snapshots.rs @@ -0,0 +1,39 @@ +//! Startup-time trusted executable snapshots + +use std::collections::HashMap; +use std::path::Path; + +use super::super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; +use super::fingerprint::file_fingerprint; +use super::paths::canonicalize_best_effort; + +pub(in crate::daemon) fn build_trusted_control_snapshots( + trusted_dir: &Path, +) -> HashMap { + let mut snapshots = HashMap::new(); + for executable in TRUSTED_CONTROL_EXECUTABLES { + let Some(snapshot) = build_trusted_control_snapshot(trusted_dir, executable) else { + continue; + }; + snapshots.insert(executable.to_string(), snapshot); + } + snapshots +} + +fn build_trusted_control_snapshot( + trusted_dir: &Path, + executable: &str, +) -> Option { + // Missing sibling means this executable is not trusted in strict mode + let candidate = trusted_dir.join(executable); + if !candidate.is_file() { + return None; + } + + let canonical = canonicalize_best_effort(&candidate); + let fingerprint = file_fingerprint(&canonical)?; + Some(TrustedExecutableSnapshot { + canonical_path: canonical, + fingerprint, + }) +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs similarity index 90% rename from crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs index 5008b072e..385a44400 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/metadata.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/metadata.rs @@ -1,8 +1,8 @@ -use super::authorization::control_owner_uid_is_allowed; -use super::metadata::{ +use super::super::metadata::{ trusted_control_file_metadata_is_safe, trusted_control_owner_uid_is_allowed, }; -use super::support::write_executable; +use crate::daemon::auth::authorization::control_owner_uid_is_allowed; +use crate::daemon::auth::support::write_executable; use crate::test_support::TempRoot; #[cfg(unix)] diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs new file mode 100644 index 000000000..b4c0c749a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/mod.rs @@ -0,0 +1,4 @@ +mod metadata; +mod paths; +mod snapshots; +mod strict; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs similarity index 66% rename from crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs rename to crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs index 6182b4ba8..2bcd64b6f 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/paths.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/paths.rs @@ -1,10 +1,9 @@ -use super::filesystem::canonicalize_best_effort; -use super::paths::{ - is_trusted_control_executable_path_relaxed_in_dir, trusted_local_bin_matches_executable, +use super::super::paths::{ + canonicalize_best_effort, is_trusted_control_executable_path_relaxed_in_dir, trusted_path_matches_executable, trusted_profile_sibling_matches_executable, }; -use super::support::write_executable; -use crate::test_support::{env_lock, EnvVarGuard, TempRoot}; +use crate::daemon::auth::support::write_executable; +use crate::test_support::TempRoot; #[test] fn trusted_path_match_requires_exact_canonical_sibling() { @@ -54,46 +53,6 @@ fn trusted_profile_sibling_requires_debug_or_release_target_root() { )); } -#[test] -fn trusted_local_bin_uses_home_local_bin_exactly() { - let _guard = env_lock(); - let home = TempRoot::new("auth-home"); - let local_ctl = home.join(".local/bin/noticenterctl"); - let wrong_name = home.join(".local/bin/untrusted"); - let outside = home.join("bin/noticenterctl"); - write_executable(&local_ctl); - write_executable(&wrong_name); - write_executable(&outside); - let _home = EnvVarGuard::set("HOME", home.path()); - - assert!(trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&local_ctl) - )); - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&outside) - )); - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&wrong_name) - )); -} - -#[test] -fn trusted_local_bin_requires_home() { - let _guard = env_lock(); - let root = TempRoot::new("auth-no-home"); - let local_ctl = root.join(".local/bin/noticenterctl"); - write_executable(&local_ctl); - let _home = EnvVarGuard::remove("HOME"); - - assert!(!trusted_local_bin_matches_executable( - "noticenterctl", - &canonicalize_best_effort(&local_ctl) - )); -} - #[test] fn relaxed_path_check_accepts_safe_trusted_sibling() { let root = TempRoot::new("auth-relaxed-sibling"); @@ -106,6 +65,60 @@ fn relaxed_path_check_accepts_safe_trusted_sibling() { )); } +#[test] +fn relaxed_path_check_rejects_arbitrary_local_bin_components() { + // Writable launcher paths are not executable trust roots in trial mode + let root = TempRoot::new("auth-local-bin-components"); + let trusted_dir = root.join("target/debug"); + let local_bin = root.join(".local/bin"); + std::fs::create_dir_all(&trusted_dir).expect("trusted directory"); + std::fs::create_dir_all(&local_bin).expect("local bin"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let forged = local_bin.join(executable); + write_executable(&forged); + + assert!(!is_trusted_control_executable_path_relaxed_in_dir( + &forged, + &trusted_dir, + )); + } +} + +#[cfg(unix)] +#[test] +fn relaxed_path_check_accepts_local_bin_symlink_to_trial_binary() { + // PATH convenience remains supported when the symlink resolves into the + // known trial build tree rather than to an arbitrary local-bin executable + let root = TempRoot::new("auth-local-bin-symlink"); + let trusted_dir = root.join("target/debug"); + let local_bin = root.join(".local/bin"); + std::fs::create_dir_all(&trusted_dir).expect("trusted directory"); + std::fs::create_dir_all(&local_bin).expect("local bin"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let target = trusted_dir.join(executable); + let shim = local_bin.join(executable); + write_executable(&target); + std::os::unix::fs::symlink(&target, &shim).expect("trial symlink"); + + assert!(is_trusted_control_executable_path_relaxed_in_dir( + &canonicalize_best_effort(&shim), + &trusted_dir, + )); + } +} + #[test] fn relaxed_path_check_accepts_safe_profile_sibling() { let target = TempRoot::new("auth-relaxed-profile"); diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs new file mode 100644 index 000000000..d428fb249 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/snapshots.rs @@ -0,0 +1,200 @@ +#[cfg(target_os = "linux")] +#[test] +fn startup_snapshot_does_not_adopt_a_sibling_replaced_before_first_authorization() { + use super::super::fingerprint::file_fingerprint; + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + + let trusted_dir = TempRoot::new("auth-startup-snapshot-replacement"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + let startup_snapshot = snapshots + .get("noticenterctl") + .expect("trusted sibling should be captured at startup") + .clone(); + + std::fs::remove_file(&trusted).expect("remove original sibling"); + write_executable(&trusted); + let replacement = file_fingerprint(&trusted).expect("replacement should be fingerprinted"); + + // A later authorization reads the immutable startup map instead of adopting this replacement + assert_ne!(startup_snapshot.fingerprint, replacement); + assert_eq!( + snapshots + .get("noticenterctl") + .expect("startup snapshot remains present"), + &startup_snapshot + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn concurrent_authorization_reads_share_one_startup_snapshot() { + use std::sync::Arc; + + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + + let trusted_dir = TempRoot::new("auth-concurrent-startup-snapshot"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = Arc::new(build_trusted_control_snapshots(trusted_dir.path())); + let expected = snapshots + .get("noticenterctl") + .expect("trusted sibling should be captured once") + .clone(); + + std::thread::scope(|scope| { + for _ in 0..8 { + let snapshots = Arc::clone(&snapshots); + let expected = expected.clone(); + scope.spawn(move || { + assert_eq!(snapshots.get("noticenterctl"), Some(&expected)); + }); + } + }); +} + +#[cfg(not(target_os = "linux"))] +mod strict_snapshot_tests { + use super::super::paths::{canonicalize_best_effort, trusted_snapshot_matches_observed}; + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; + use crate::daemon::auth::support::write_executable; + use crate::test_support::TempRoot; + use std::collections::HashMap; + use std::path::Path; + + fn is_trusted_control_executable_path_in_dir( + path: &Path, + _trusted_dir: &Path, + snapshots: &HashMap, + ) -> bool { + let observed = canonicalize_best_effort(path); + let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { + return false; + } + + snapshots + .get(observed_name) + .is_some_and(|snapshot| trusted_snapshot_matches_observed(snapshot, &observed)) + } + + #[test] + fn strict_snapshot_rejects_unknown_or_untrusted_paths() { + let trusted_dir = TempRoot::new("auth-rejects-unknown"); + let outsider = trusted_dir.join("python3"); + write_executable(&outsider); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + // Random paths and unapproved binary names must not satisfy strict trust + assert!(!is_trusted_control_executable_path_in_dir( + std::path::Path::new("/tmp/noticenterctl"), + trusted_dir.path(), + &snapshots, + )); + assert!(!is_trusted_control_executable_path_in_dir( + &outsider, + trusted_dir.path(), + &snapshots, + )); + } + + #[test] + fn strict_snapshot_rejects_trusted_name_alias_suffixes() { + let trusted_dir = TempRoot::new("auth-rejects-alias"); + let alias = trusted_dir.join("noticenterctl.exe"); + write_executable(&alias); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + // Suffix lookalikes should not pass the exact trusted executable list + assert!(!is_trusted_control_executable_path_in_dir( + &alias, + trusted_dir.path(), + &snapshots, + )); + } + + #[test] + fn strict_snapshot_accepts_trusted_sibling_binary_only() { + let trusted_dir = TempRoot::new("auth-accepts-sibling"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + assert!(is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + + let other_dir = TempRoot::new("auth-other-sibling"); + let forged = other_dir.join("noticenterctl"); + write_executable(&forged); + assert!(!is_trusted_control_executable_path_in_dir( + &forged, + trusted_dir.path(), + &snapshots, + )); + + // Same path after replacement must no longer match the pinned startup fingerprint + write_executable(&trusted); + std::fs::write(&trusted, "#!/bin/sh\necho forged\n").expect("overwrite trusted sibling"); + assert!(!is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + } + + #[test] + fn strict_snapshot_pins_all_trusted_siblings_at_once() { + let trusted_dir = TempRoot::new("auth-pins-all-siblings"); + let ctl = trusted_dir.join("noticenterctl"); + let center = trusted_dir.join("unixnotis-center"); + write_executable(&ctl); + write_executable(¢er); + + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + assert!(is_trusted_control_executable_path_in_dir( + &ctl, + trusted_dir.path(), + &snapshots, + )); + + // A sibling that has not called yet is still pinned by the initial snapshot + std::fs::write(¢er, "#!/bin/sh\necho replaced\n").expect("replace center"); + assert!(!is_trusted_control_executable_path_in_dir( + ¢er, + trusted_dir.path(), + &snapshots, + )); + } + + #[cfg(unix)] + #[test] + fn strict_snapshot_rejects_group_writable_trusted_binary() { + use std::os::unix::fs::PermissionsExt; + + let trusted_dir = TempRoot::new("auth-rejects-group-writable"); + let trusted = trusted_dir.join("noticenterctl"); + write_executable(&trusted); + let mut permissions = std::fs::metadata(&trusted).expect("metadata").permissions(); + permissions.set_mode(0o775); + std::fs::set_permissions(&trusted, permissions).expect("set permissions"); + + let snapshots = build_trusted_control_snapshots(trusted_dir.path()); + + assert!(!is_trusted_control_executable_path_in_dir( + &trusted, + trusted_dir.path(), + &snapshots, + )); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs new file mode 100644 index 000000000..8b36f266a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/auth/executable_trust/tests/strict.rs @@ -0,0 +1,68 @@ +#[cfg(not(target_os = "linux"))] +mod strict_path_tests { + use super::super::fingerprint::fingerprint_cache; + use super::super::paths::is_trusted_control_executable_path; + use super::super::snapshots::build_trusted_control_snapshots; + use crate::daemon::auth::authorization::control_executable_is_allowed; + use crate::daemon::auth::support::write_executable; + use crate::test_support::{env_lock, TempRoot}; + use std::fs::File; + use std::os::fd::OwnedFd; + + fn open_test_executable(path: &std::path::Path) -> OwnedFd { + File::open(path).expect("open test executable").into() + } + + #[test] + fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { + let _guard = env_lock(); + let current_exe = std::env::current_exe().expect("current test executable"); + let trusted_dir = current_exe + .parent() + .expect("current executable should have a parent") + .to_path_buf(); + let trusted = trusted_dir.join("noticenterctl"); + let root = TempRoot::new("auth-strict-foreign"); + let foreign = root.join("noticenterctl"); + write_executable(&trusted); + write_executable(&foreign); + fingerprint_cache() + .lock() + .expect("fingerprint cache lock") + .clear(); + let snapshots = build_trusted_control_snapshots(&trusted_dir); + + assert!(is_trusted_control_executable_path( + &trusted, false, &snapshots + )); + assert!(!is_trusted_control_executable_path( + &foreign, false, &snapshots + )); + let trusted_fd = open_test_executable(&trusted); + let foreign_fd = open_test_executable(&foreign); + assert!(control_executable_is_allowed::( + Some(&trusted), + Some(&trusted_fd), + &["noticenterctl"], + false, + &snapshots, + )); + assert!(!control_executable_is_allowed::( + Some(&trusted), + Some(&trusted_fd), + &["unixnotis-center"], + false, + &snapshots, + )); + // Foreign path must be checked with its own fd to verify it's a different executable + assert!(!control_executable_is_allowed::( + Some(&foreign), + Some(&foreign_fd), + &["noticenterctl"], + false, + &snapshots, + )); + + let _ = std::fs::remove_file(trusted); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs b/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs deleted file mode 100644 index b27f11e92..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/filesystem.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Small filesystem helpers shared by authorization modules - -use std::path::{Path, PathBuf}; - -pub(in crate::daemon) fn canonicalize_best_effort(path: &Path) -> PathBuf { - // Fall back to the raw path so missing paths fail later as normal trust mismatches - std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/mod.rs b/crates/unixnotis-daemon/src/daemon/auth/mod.rs index faeefa07c..c7dca9b0a 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/mod.rs @@ -14,40 +14,26 @@ mod authorization; mod credentials; -mod filesystem; -mod fingerprint; -mod metadata; -mod paths; +mod executable_trust; mod policy; -mod process; -mod snapshots; +mod process_identity; -pub(super) use authorization::{authorize_control_call, authorize_panel_readiness_call}; +pub(super) use authorization::{ + authorize_control_call, authorize_interaction_call, authorize_panel_readiness_call, + authorize_popup_readiness_call, +}; +pub(in crate::daemon) use executable_trust::build_trusted_control_snapshots_for_current_executable; +pub(in crate::daemon) use policy::TrustedExecutableSnapshot; #[cfg(test)] #[path = "tests/authorization.rs"] mod authorization_tests; #[cfg(test)] -#[path = "tests/cache.rs"] -mod cache_tests; -#[cfg(test)] #[path = "tests/credentials.rs"] mod credentials_tests; #[cfg(test)] -#[path = "tests/metadata.rs"] -mod metadata_tests; -#[cfg(test)] -#[path = "tests/paths.rs"] -mod paths_tests; -#[cfg(test)] -#[path = "tests/procfs.rs"] -mod procfs_tests; -#[cfg(test)] -#[path = "tests/snapshot.rs"] -mod snapshot_tests; -#[cfg(test)] -#[path = "tests/strict.rs"] -mod strict_tests; +#[path = "tests/process_identity.rs"] +mod process_identity_tests; #[cfg(test)] #[path = "tests/support.rs"] mod support; diff --git a/crates/unixnotis-daemon/src/daemon/auth/policy.rs b/crates/unixnotis-daemon/src/daemon/auth/policy.rs index 5c9ae6ec9..155d71979 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/policy.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/policy.rs @@ -1,6 +1,5 @@ //! Shared authorization policy constants and small data records -use std::collections::HashMap; use std::path::PathBuf; // Only these sibling binaries may call privileged control methods @@ -11,12 +10,18 @@ pub(in crate::daemon) const TRUSTED_CONTROL_EXECUTABLES: [&str; 4] = [ "unixnotis-daemon", ]; +// Only interactive renderers may assert that a user confirmed an application action +pub(in crate::daemon) const TRUSTED_INTERACTION_EXECUTABLES: [&str; 2] = + ["unixnotis-center", "unixnotis-popups"]; + // Only the center process may publish panel readiness state pub(in crate::daemon) const TRUSTED_PANEL_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-center"]; -// Small bounded caches avoid unbounded growth from repeated forged callers +// Only the popup renderer may publish its composite D-Bus and GTK readiness +pub(in crate::daemon) const TRUSTED_POPUP_READINESS_EXECUTABLES: [&str; 1] = ["unixnotis-popups"]; + +// Small bounded cache avoids unbounded growth from repeated fingerprint lookups pub(in crate::daemon) const FINGERPRINT_CACHE_CAPACITY: usize = 32; -pub(in crate::daemon) const TRUSTED_SNAPSHOT_CACHE_CAPACITY: usize = 32; #[derive(Clone, Debug, Eq, PartialEq)] pub(in crate::daemon) struct TrustedExecutableSnapshot { @@ -62,9 +67,3 @@ pub(in crate::daemon) struct FingerprintCacheEntry { pub(in crate::daemon) signature: FileFingerprintSignature, pub(in crate::daemon) fingerprint: FileFingerprint, } - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(in crate::daemon) struct TrustedSnapshotCacheEntry { - pub(in crate::daemon) trusted_dir: PathBuf, - pub(in crate::daemon) snapshots: HashMap, -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/process.rs b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs similarity index 77% rename from crates/unixnotis-daemon/src/daemon/auth/process.rs rename to crates/unixnotis-daemon/src/daemon/auth/process_identity.rs index 18e7d1615..47470e2f9 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/process.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/process_identity.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; #[cfg(target_os = "linux")] use std::io::Read; #[cfg(target_os = "linux")] -use std::os::fd::{AsFd, AsRawFd}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd}; #[cfg(target_os = "linux")] const MAX_PIDFD_INFO_BYTES: u64 = 4_096; @@ -37,6 +37,32 @@ pub(in crate::daemon) fn read_process_executable_path_from_pidfd( Some(executable) } +#[cfg(target_os = "linux")] +pub(in crate::daemon) fn open_process_executable_from_pidfd( + pidfd: &Fd, + expected_pid: u32, +) -> Option { + // A ready pidfd means its process has exited and its pid must not be followed + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + + // Open /proc//exe as a file descriptor. This follows the procfs + // magic symlink to the actual executable object. The resulting descriptor + // refers to the kernel file object, not a pathname that could be shadowed + // by a mount namespace. + let fd = std::fs::OpenOptions::new() + .read(true) + .open(format!("/proc/{expected_pid}/exe")) + .ok()?; + + // A second check closes the small window where the process exits during open + if !pidfd_matches_live_process(pidfd, expected_pid) { + return None; + } + Some(fd.into()) +} + #[cfg(target_os = "linux")] pub(in crate::daemon) fn read_pidfd_process_id(pidfd: &Fd) -> Option { let raw_fd = pidfd.as_fd().as_raw_fd(); diff --git a/crates/unixnotis-daemon/src/daemon/auth/snapshots.rs b/crates/unixnotis-daemon/src/daemon/auth/snapshots.rs deleted file mode 100644 index 575fc891c..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/snapshots.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Startup-time trusted executable snapshots - -use std::collections::HashMap; -use std::path::Path; -use std::sync::{Mutex, OnceLock}; - -use super::filesystem::canonicalize_best_effort; -use super::fingerprint::file_fingerprint; -use super::policy::{ - TrustedExecutableSnapshot, TrustedSnapshotCacheEntry, TRUSTED_CONTROL_EXECUTABLES, - TRUSTED_SNAPSHOT_CACHE_CAPACITY, -}; - -pub(in crate::daemon) fn trusted_control_snapshot( - trusted_dir: &Path, - executable: &str, -) -> Option { - if let Some(snapshot) = load_cached_trusted_snapshot(trusted_dir, executable) { - return Some(snapshot); - } - - // Pin the whole sibling trust set together so late file swaps do not sneak in - let snapshots = build_trusted_control_snapshots(trusted_dir); - let snapshot = snapshots.get(executable).cloned()?; - store_cached_trusted_snapshots(trusted_dir, snapshots); - Some(snapshot) -} - -pub(in crate::daemon) fn build_trusted_control_snapshots( - trusted_dir: &Path, -) -> HashMap { - let mut snapshots = HashMap::new(); - for executable in TRUSTED_CONTROL_EXECUTABLES { - let Some(snapshot) = build_trusted_control_snapshot(trusted_dir, executable) else { - continue; - }; - snapshots.insert(executable.to_string(), snapshot); - } - snapshots -} - -fn build_trusted_control_snapshot( - trusted_dir: &Path, - executable: &str, -) -> Option { - // Missing sibling means this executable is not trusted in strict mode - let candidate = trusted_dir.join(executable); - if !candidate.is_file() { - return None; - } - - let canonical = canonicalize_best_effort(&candidate); - let fingerprint = file_fingerprint(&canonical)?; - Some(TrustedExecutableSnapshot { - canonical_path: canonical, - fingerprint, - }) -} - -pub(in crate::daemon) fn trusted_snapshot_cache() -> &'static Mutex> -{ - static CACHE: OnceLock>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(Vec::new())) -} - -pub(in crate::daemon) fn load_cached_trusted_snapshot( - trusted_dir: &Path, - executable: &str, -) -> Option { - let cache = trusted_snapshot_cache(); - let cache = match cache.lock() { - Ok(cache) => cache, - Err(poisoned) => poisoned.into_inner(), - }; - cache - .iter() - .find(|entry| entry.trusted_dir == trusted_dir) - .and_then(|entry| entry.snapshots.get(executable).cloned()) -} - -pub(in crate::daemon) fn store_cached_trusted_snapshots( - trusted_dir: &Path, - snapshots: HashMap, -) { - let cache = trusted_snapshot_cache(); - let mut cache = match cache.lock() { - Ok(cache) => cache, - Err(poisoned) => poisoned.into_inner(), - }; - - // Replace existing directory cache before enforcing capacity - if let Some(index) = cache - .iter() - .position(|entry| entry.trusted_dir == trusted_dir) - { - cache.remove(index); - } - if cache.len() >= TRUSTED_SNAPSHOT_CACHE_CAPACITY { - cache.remove(0); - } - cache.push(TrustedSnapshotCacheEntry { - trusted_dir: trusted_dir.to_path_buf(), - snapshots, - }); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs index 0ff434458..496cdcaf3 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/authorization.rs @@ -1,16 +1,24 @@ +use std::collections::HashMap; +use std::fs::File; +use std::os::fd::OwnedFd; use zbus::Message; #[cfg(target_os = "linux")] use super::authorization::required_linux_process_fd; use super::authorization::{ - authorize_control_call, authorize_panel_readiness_call, control_executable_error, - control_owner_uid_error, + authorize_control_call, authorize_interaction_call, authorize_panel_readiness_call, + authorize_popup_readiness_call, control_executable_error, control_owner_uid_error, }; #[cfg(target_os = "linux")] use super::credentials::CallerCredentials; -use super::filesystem::canonicalize_best_effort; +use super::executable_trust::paths::canonicalize_best_effort; +use super::policy::TRUSTED_INTERACTION_EXECUTABLES; use super::support::write_executable; -use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; +use crate::test_support::{daemon_state_for_test, TempRoot}; + +fn open_test_executable(path: &std::path::Path) -> OwnedFd { + File::open(path).expect("open test executable").into() +} fn message_without_bus_sender() -> Message { // Locally built messages have no unique bus sender, which must fail auth early @@ -46,6 +54,32 @@ async fn panel_readiness_authorization_rejects_header_without_bus_sender() { assert!(err.to_string().contains("missing sender")); } +#[tokio::test] +async fn interaction_authorization_rejects_header_without_bus_sender() { + let state = daemon_state_for_test(false).await; + let message = message_without_bus_sender(); + let header = message.header(); + + let err = authorize_interaction_call(&state, &header, "InvokeAction") + .await + .expect_err("missing interaction sender must be rejected"); + + assert!(err.to_string().contains("missing sender")); +} + +#[tokio::test] +async fn popup_readiness_authorization_rejects_header_without_bus_sender() { + let state = daemon_state_for_test(false).await; + let message = message_without_bus_sender(); + let header = message.header(); + + let err = authorize_popup_readiness_call(&state, &header, "PopupsReady") + .await + .expect_err("missing sender must be rejected"); + + assert!(err.to_string().contains("missing sender")); +} + #[test] fn control_uid_error_is_none_only_for_matching_uid() { assert!(control_owner_uid_error(1000, 1000).is_none()); @@ -56,21 +90,103 @@ fn control_uid_error_is_none_only_for_matching_uid() { } #[test] -fn control_executable_error_requires_present_allowed_trusted_binary() { - let _guard = env_lock(); - let home = TempRoot::new("auth-executable-error"); - let trusted = home.join(".local/bin/noticenterctl"); - let untrusted_name = home.join(".local/bin/unknown"); - write_executable(&trusted); +fn control_executable_error_rejects_missing_or_untrusted_binary() { + let root = TempRoot::new("auth-executable-error"); + let untrusted_name = root.join(".local/bin/noticenterctl"); write_executable(&untrusted_name); - let _home = EnvVarGuard::set("HOME", home.path()); - let trusted = canonicalize_best_effort(&trusted); let untrusted_name = canonicalize_best_effort(&untrusted_name); + let untrusted_fd = open_test_executable(&untrusted_name); + + // An allowed executable name still fails when its file object is outside + // the trusted build or install tree + assert!(control_executable_error( + Some(&untrusted_name), + Some(&untrusted_fd), + &["noticenterctl"], + true, + &HashMap::new(), + ) + .is_some()); + assert!(control_executable_error::( + None, + None::<&OwnedFd>, + &["noticenterctl"], + true, + &HashMap::new(), + ) + .is_some()); + assert!(control_executable_error( + Some(&untrusted_name), + Some(&untrusted_fd), + &["unknown"], + true, + &HashMap::new(), + ) + .is_some()); +} - assert!(control_executable_error(Some(&trusted), &["noticenterctl"], true).is_none()); - assert!(control_executable_error(None, &["noticenterctl"], true).is_some()); - assert!(control_executable_error(Some(&trusted), &["unixnotis-center"], true).is_some()); - assert!(control_executable_error(Some(&untrusted_name), &["unknown"], true).is_some()); +#[test] +fn interaction_executable_policy_rejects_untrusted_components() { + let root = TempRoot::new("auth-interaction-executable"); + for executable in ["unixnotis-center", "unixnotis-popups"] { + let path = root.join(".local/bin").join(executable); + write_executable(&path); + let path = canonicalize_best_effort(&path); + let fd = open_test_executable(&path); + + // Renderer names do not create trust for arbitrary local-bin files + assert!(control_executable_error::( + Some(&path), + Some(&fd), + &TRUSTED_INTERACTION_EXECUTABLES, + true, + &HashMap::new(), + ) + .is_some()); + } + + let cli = root.join(".local/bin/noticenterctl"); + write_executable(&cli); + let cli = canonicalize_best_effort(&cli); + let cli_fd = open_test_executable(&cli); + + // The CLI is not an interactive renderer, even when its name is allowed + // by another control policy + assert!(control_executable_error::( + Some(&cli), + Some(&cli_fd), + &TRUSTED_INTERACTION_EXECUTABLES, + true, + &HashMap::new(), + ) + .is_some()); +} + +#[test] +fn trial_control_authorization_rejects_all_arbitrary_local_bin_components() { + // Every privileged component name still requires a trusted-tree executable + let root = TempRoot::new("auth-local-bin-components"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let forged = root.join(".local/bin").join(executable); + write_executable(&forged); + let forged_path = canonicalize_best_effort(&forged); + let forged_fd = open_test_executable(&forged_path); + + assert!(control_executable_error( + Some(&forged_path), + Some(&forged_fd), + &[executable], + true, + &HashMap::new(), + ) + .is_some()); + } } #[cfg(target_os = "linux")] diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs deleted file mode 100644 index b56d355c1..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/cache.rs +++ /dev/null @@ -1,109 +0,0 @@ -use std::collections::HashMap; - -use super::fingerprint::{fingerprint_cache, load_cached_fingerprint, store_cached_fingerprint}; -use super::policy::{ - TrustedExecutableSnapshot, FINGERPRINT_CACHE_CAPACITY, TRUSTED_SNAPSHOT_CACHE_CAPACITY, -}; -use super::snapshots::{ - load_cached_trusted_snapshot, store_cached_trusted_snapshots, trusted_snapshot_cache, -}; -use super::support::{test_fingerprint, test_signature}; -use crate::test_support::{env_lock, TempRoot}; - -#[test] -fn fingerprint_cache_loads_only_same_path_and_signature() { - let _guard = env_lock(); - fingerprint_cache().lock().expect("cache lock").clear(); - let root = TempRoot::new("auth-fingerprint-cache"); - let path = root.join("noticenterctl"); - let other = root.join("unixnotis-center"); - let signature = test_signature(10); - let fingerprint = test_fingerprint(10); - - store_cached_fingerprint(&path, signature, fingerprint.clone()); - - assert_eq!(load_cached_fingerprint(&path, signature), Some(fingerprint)); - assert!(load_cached_fingerprint(&other, signature).is_none()); - assert!(load_cached_fingerprint(&path, test_signature(11)).is_none()); -} - -#[test] -fn fingerprint_cache_replaces_same_path_and_evicts_oldest_entry() { - let _guard = env_lock(); - fingerprint_cache().lock().expect("cache lock").clear(); - let root = TempRoot::new("auth-fingerprint-evict"); - let path = root.join("noticenterctl"); - - store_cached_fingerprint(&path, test_signature(1), test_fingerprint(1)); - store_cached_fingerprint(&path, test_signature(2), test_fingerprint(2)); - assert!(load_cached_fingerprint(&path, test_signature(1)).is_none()); - assert_eq!( - load_cached_fingerprint(&path, test_signature(2)), - Some(test_fingerprint(2)) - ); - - for index in 0..FINGERPRINT_CACHE_CAPACITY { - let entry_path = root.join(format!("tool-{index}")); - store_cached_fingerprint( - &entry_path, - test_signature(100 + index as u64), - test_fingerprint(100 + index as u64), - ); - } - - assert!(load_cached_fingerprint(&path, test_signature(2)).is_none()); -} - -#[test] -fn trusted_snapshot_cache_loads_replaces_and_evicts_by_directory() { - let _guard = env_lock(); - trusted_snapshot_cache() - .lock() - .expect("snapshot cache lock") - .clear(); - let root = TempRoot::new("auth-snapshot-cache"); - let first_dir = root.join("first"); - let second_dir = root.join("second"); - let ctl_path = first_dir.join("noticenterctl"); - let center_path = first_dir.join("unixnotis-center"); - let first_snapshot = TrustedExecutableSnapshot { - canonical_path: ctl_path, - fingerprint: test_fingerprint(1), - }; - let replacement_snapshot = TrustedExecutableSnapshot { - canonical_path: center_path, - fingerprint: test_fingerprint(2), - }; - let mut snapshots = HashMap::new(); - snapshots.insert("noticenterctl".to_string(), first_snapshot.clone()); - - store_cached_trusted_snapshots(&first_dir, snapshots); - assert_eq!( - load_cached_trusted_snapshot(&first_dir, "noticenterctl"), - Some(first_snapshot) - ); - assert!(load_cached_trusted_snapshot(&second_dir, "noticenterctl").is_none()); - - let mut replacement = HashMap::new(); - replacement.insert("noticenterctl".to_string(), replacement_snapshot.clone()); - store_cached_trusted_snapshots(&first_dir, replacement); - assert_eq!( - load_cached_trusted_snapshot(&first_dir, "noticenterctl"), - Some(replacement_snapshot) - ); - - for index in 0..TRUSTED_SNAPSHOT_CACHE_CAPACITY { - let dir = root.join(format!("dir-{index}")); - let mut snapshots = HashMap::new(); - snapshots.insert( - "noticenterctl".to_string(), - TrustedExecutableSnapshot { - canonical_path: dir.join("noticenterctl"), - fingerprint: test_fingerprint(100 + index as u64), - }, - ); - store_cached_trusted_snapshots(&dir, snapshots); - } - - assert!(load_cached_trusted_snapshot(&first_dir, "noticenterctl").is_none()); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs index 4b34da0f6..81bfc66ac 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/credentials.rs @@ -4,7 +4,7 @@ use zbus::Connection; use super::credentials::{connection_credentials, CallerCredentials}; #[cfg(target_os = "linux")] -use super::process::read_pidfd_process_id; +use super::process_identity::read_pidfd_process_id; #[tokio::test] async fn connection_credentials_match_the_current_bus_process() { diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs similarity index 97% rename from crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs rename to crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs index cb2161e27..00c906700 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/procfs.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/process_identity.rs @@ -1,6 +1,6 @@ -use super::process::read_process_executable_path; +use super::process_identity::read_process_executable_path; #[cfg(target_os = "linux")] -use super::process::{ +use super::process_identity::{ parse_pidfd_process_id, pidfd_is_live, pidfd_matches_live_process, read_pidfd_info_bytes, read_pidfd_process_id, read_process_executable_path_from_pidfd, }; diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs deleted file mode 100644 index 2410ce12b..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/snapshot.rs +++ /dev/null @@ -1,138 +0,0 @@ -use super::filesystem::canonicalize_best_effort; -use super::paths::trusted_snapshot_matches_observed; -use super::policy::{TrustedExecutableSnapshot, TRUSTED_CONTROL_EXECUTABLES}; -use super::snapshots::build_trusted_control_snapshots; -use super::support::write_executable; -use crate::test_support::TempRoot; -use std::collections::HashMap; -use std::path::Path; - -fn is_trusted_control_executable_path_in_dir( - path: &Path, - _trusted_dir: &Path, - snapshots: &HashMap, -) -> bool { - let observed = canonicalize_best_effort(path); - let Some(observed_name) = observed.file_name().and_then(|name| name.to_str()) else { - return false; - }; - if !TRUSTED_CONTROL_EXECUTABLES.contains(&observed_name) { - return false; - } - - snapshots - .get(observed_name) - .is_some_and(|snapshot| trusted_snapshot_matches_observed(snapshot, &observed)) -} - -#[test] -fn strict_snapshot_rejects_unknown_or_untrusted_paths() { - let trusted_dir = TempRoot::new("auth-rejects-unknown"); - let outsider = trusted_dir.join("python3"); - write_executable(&outsider); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - // Random paths and unapproved binary names must not satisfy strict trust - assert!(!is_trusted_control_executable_path_in_dir( - std::path::Path::new("/tmp/noticenterctl"), - trusted_dir.path(), - &snapshots, - )); - assert!(!is_trusted_control_executable_path_in_dir( - &outsider, - trusted_dir.path(), - &snapshots, - )); -} - -#[test] -fn strict_snapshot_rejects_trusted_name_alias_suffixes() { - let trusted_dir = TempRoot::new("auth-rejects-alias"); - let alias = trusted_dir.join("noticenterctl.exe"); - write_executable(&alias); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - // Suffix lookalikes should not pass the exact trusted executable list - assert!(!is_trusted_control_executable_path_in_dir( - &alias, - trusted_dir.path(), - &snapshots, - )); -} - -#[test] -fn strict_snapshot_accepts_trusted_sibling_binary_only() { - let trusted_dir = TempRoot::new("auth-accepts-sibling"); - let trusted = trusted_dir.join("noticenterctl"); - write_executable(&trusted); - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - assert!(is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); - - let other_dir = TempRoot::new("auth-other-sibling"); - let forged = other_dir.join("noticenterctl"); - write_executable(&forged); - assert!(!is_trusted_control_executable_path_in_dir( - &forged, - trusted_dir.path(), - &snapshots, - )); - - // Same path after replacement must no longer match the pinned startup fingerprint - write_executable(&trusted); - std::fs::write(&trusted, "#!/bin/sh\necho forged\n").expect("overwrite trusted sibling"); - assert!(!is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); -} - -#[test] -fn strict_snapshot_pins_all_trusted_siblings_at_once() { - let trusted_dir = TempRoot::new("auth-pins-all-siblings"); - let ctl = trusted_dir.join("noticenterctl"); - let center = trusted_dir.join("unixnotis-center"); - write_executable(&ctl); - write_executable(¢er); - - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - assert!(is_trusted_control_executable_path_in_dir( - &ctl, - trusted_dir.path(), - &snapshots, - )); - - // A sibling that has not called yet is still pinned by the initial snapshot - std::fs::write(¢er, "#!/bin/sh\necho replaced\n").expect("replace center"); - assert!(!is_trusted_control_executable_path_in_dir( - ¢er, - trusted_dir.path(), - &snapshots, - )); -} - -#[cfg(unix)] -#[test] -fn strict_snapshot_rejects_group_writable_trusted_binary() { - use std::os::unix::fs::PermissionsExt; - - let trusted_dir = TempRoot::new("auth-rejects-group-writable"); - let trusted = trusted_dir.join("noticenterctl"); - write_executable(&trusted); - let mut permissions = std::fs::metadata(&trusted).expect("metadata").permissions(); - permissions.set_mode(0o775); - std::fs::set_permissions(&trusted, permissions).expect("set permissions"); - - let snapshots = build_trusted_control_snapshots(trusted_dir.path()); - - assert!(!is_trusted_control_executable_path_in_dir( - &trusted, - trusted_dir.path(), - &snapshots, - )); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs deleted file mode 100644 index 4131a32cb..000000000 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/strict.rs +++ /dev/null @@ -1,49 +0,0 @@ -use super::authorization::control_executable_is_allowed; -use super::fingerprint::fingerprint_cache; -use super::paths::is_trusted_control_executable_path; -use super::snapshots::trusted_snapshot_cache; -use super::support::write_executable; -use crate::test_support::{env_lock, TempRoot}; - -#[test] -fn strict_trust_uses_current_executable_directory_and_rejects_foreign_path() { - let _guard = env_lock(); - let current_exe = std::env::current_exe().expect("current test executable"); - let trusted_dir = current_exe - .parent() - .expect("current executable should have a parent") - .to_path_buf(); - let trusted = trusted_dir.join("noticenterctl"); - let root = TempRoot::new("auth-strict-foreign"); - let foreign = root.join("noticenterctl"); - write_executable(&trusted); - write_executable(&foreign); - trusted_snapshot_cache() - .lock() - .expect("snapshot cache lock") - .clear(); - fingerprint_cache() - .lock() - .expect("fingerprint cache lock") - .clear(); - - assert!(is_trusted_control_executable_path(&trusted, false)); - assert!(!is_trusted_control_executable_path(&foreign, false)); - assert!(control_executable_is_allowed( - &trusted, - &["noticenterctl"], - false - )); - assert!(!control_executable_is_allowed( - &trusted, - &["unixnotis-center"], - false - )); - assert!(!control_executable_is_allowed( - &foreign, - &["noticenterctl"], - false - )); - - let _ = std::fs::remove_file(trusted); -} diff --git a/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs b/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs index 8a3f1a9fb..518bd8672 100644 --- a/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs +++ b/crates/unixnotis-daemon/src/daemon/auth/tests/support.rs @@ -1,7 +1,5 @@ use std::path::Path; -use super::policy::{FileFingerprint, FileFingerprintSignature}; - pub(super) fn write_executable(path: &Path) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("create executable parent"); @@ -16,34 +14,3 @@ pub(super) fn write_executable(path: &Path) { std::fs::set_permissions(path, permissions).expect("set executable mode"); } } - -pub(super) fn test_signature(len: u64) -> FileFingerprintSignature { - let signed_len = i64::try_from(len).expect("test length should fit i64"); - FileFingerprintSignature { - len, - #[cfg(unix)] - dev: len + 1, - #[cfg(unix)] - ino: len + 2, - #[cfg(unix)] - mode: 0o755, - #[cfg(unix)] - uid: rustix::process::geteuid().as_raw(), - #[cfg(unix)] - gid: 1000, - #[cfg(unix)] - mtime: signed_len + 3, - #[cfg(unix)] - mtime_nsec: signed_len + 4, - #[cfg(unix)] - ctime: signed_len + 5, - #[cfg(unix)] - ctime_nsec: signed_len + 6, - } -} - -pub(super) fn test_fingerprint(len: u64) -> FileFingerprint { - FileFingerprint { - signature: test_signature(len), - } -} diff --git a/crates/unixnotis-daemon/src/daemon/bus/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/clients.rs new file mode 100644 index 000000000..85544e997 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/clients.rs @@ -0,0 +1,22 @@ +//! Domain cleanup when a unique D-Bus client disconnects + +use crate::daemon::DaemonState; + +impl DaemonState { + pub(in crate::daemon) async fn remove_disconnected_client(&self, owner: &str) { + // Sender metadata is keyed by unique names and cannot survive owner loss + self.sender_metadata_cache.remove(owner); + // Panel readiness follows the same unique-owner lease as popup readiness + self.set_panel_ready(owner, false); + // Only the owner that published the active popup generation can clear it + self.set_popups_ready(owner, false); + + let inhibitors_removed = { + let mut store = self.store.lock().await; + store.remove_inhibitors_by_owner(owner) + }; + if inhibitors_removed { + self.publish_inhibitors_changed("owner-disconnected").await; + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/health.rs b/crates/unixnotis-daemon/src/daemon/bus/health.rs new file mode 100644 index 000000000..2d86eb61d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/health.rs @@ -0,0 +1,152 @@ +//! Session-bus identity, ownership verification, and runtime health checks + +use std::time::Duration; + +use anyhow::{anyhow, ensure, Context, Result}; +use tracing::warn; +use unixnotis_core::{CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::Connection; + +const BUS_HEALTH_INTERVAL: Duration = Duration::from_secs(1); +const BUS_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_CONSECUTIVE_TRANSIENT_FAILURES: u8 = 3; + +#[derive(Debug)] +enum BusProbeOutcome { + Healthy, + DefinitiveNameLoss { + name: &'static str, + owner: Option, + }, + DefinitiveTransportFailure(anyhow::Error), + TransientFailure(anyhow::Error), +} + +#[derive(Debug, Default)] +struct TransientFailureCounter { + consecutive: u8, +} + +impl TransientFailureCounter { + const fn observe_healthy(&mut self) { + self.consecutive = 0; + } + + const fn observe_failure(&mut self) -> bool { + self.consecutive = self.consecutive.saturating_add(1); + self.consecutive >= MAX_CONSECUTIVE_TRANSIENT_FAILURES + } +} + +pub async fn verify_name_owner( + dbus: &DBusProxy<'_>, + connection: &Connection, + name: &'static str, +) -> Result<()> { + let expected = connection + .unique_name() + .context("session bus did not assign a unique name")?; + let bus_name = BusName::try_from(name).context("invalid required D-Bus name")?; + let actual = tokio::time::timeout(BUS_PROBE_TIMEOUT, dbus.get_name_owner(bus_name)) + .await + .with_context(|| format!("D-Bus owner probe timed out for {name}"))? + .with_context(|| format!("D-Bus owner probe failed for {name}"))?; + + ensure!( + actual.as_str() == expected.as_str(), + "{name} owner mismatch: expected {expected}, found {actual}" + ); + Ok(()) +} + +pub async fn monitor_required_bus_names(connection: Connection) -> Result<()> { + let dbus = DBusProxy::new(&connection) + .await + .context("create D-Bus health proxy")?; + + let expected = connection + .unique_name() + .context("session bus did not assign a unique name")? + .to_string(); + let mut transient_failures = TransientFailureCounter::default(); + loop { + tokio::time::sleep(BUS_HEALTH_INTERVAL).await; + match probe_required_names(&dbus, &expected).await { + BusProbeOutcome::Healthy => transient_failures.observe_healthy(), + BusProbeOutcome::DefinitiveNameLoss { name, owner } => { + anyhow::bail!("lost required D-Bus name {name}; owner={owner:?}"); + } + BusProbeOutcome::DefinitiveTransportFailure(error) => { + return Err(error).context("session bus connection is closed"); + } + BusProbeOutcome::TransientFailure(error) => { + let fatal = transient_failures.observe_failure(); + warn!( + ?error, + transient_failures = transient_failures.consecutive, + "transient D-Bus health probe failure" + ); + if fatal { + return Err(error).context("repeated D-Bus health failures"); + } + } + } + } +} + +async fn probe_required_names(dbus: &DBusProxy<'_>, expected: &str) -> BusProbeOutcome { + for required in [NOTIFICATIONS_BUS_NAME, CONTROL_BUS_NAME] { + let bus_name = + BusName::try_from(required).expect("static required D-Bus name must be valid"); + let reply = tokio::time::timeout(BUS_PROBE_TIMEOUT, dbus.get_name_owner(bus_name)).await; + match reply { + Ok(Ok(owner)) if owner.as_str() == expected => {} + Ok(Ok(owner)) => { + return BusProbeOutcome::DefinitiveNameLoss { + name: required, + owner: Some(owner.to_string()), + }; + } + Ok(Err(error)) => return probe_error_outcome(required, error), + Err(error) => { + return BusProbeOutcome::TransientFailure(anyhow!( + "D-Bus owner probe timed out for {required}: {error}" + )); + } + } + } + BusProbeOutcome::Healthy +} + +fn probe_error_outcome(name: &'static str, error: zbus::fdo::Error) -> BusProbeOutcome { + if matches!(error, zbus::fdo::Error::NameHasNoOwner(_)) { + return BusProbeOutcome::DefinitiveNameLoss { name, owner: None }; + } + let message = anyhow!("D-Bus owner probe failed for {name}: {error}"); + if definitive_transport_failure(&error) { + BusProbeOutcome::DefinitiveTransportFailure(message) + } else { + BusProbeOutcome::TransientFailure(message) + } +} + +fn definitive_transport_failure(error: &zbus::fdo::Error) -> bool { + match error { + zbus::fdo::Error::Disconnected(_) => true, + zbus::fdo::Error::ZBus(zbus::Error::InputOutput(error)) => matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::NotConnected + | std::io::ErrorKind::UnexpectedEof + ), + _ => false, + } +} + +#[cfg(test)] +#[path = "tests/health.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/bus/mod.rs b/crates/unixnotis-daemon/src/daemon/bus/mod.rs new file mode 100644 index 000000000..cec984b04 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/mod.rs @@ -0,0 +1,13 @@ +//! Bus-name acquisition and client ownership lifecycle + +mod clients; +mod health; +mod names; +mod ownership; + +pub use health::{monitor_required_bus_names, verify_name_owner}; +pub use names::{log_name_reply, request_control_name, request_well_known_name}; +pub use ownership::{spawn_client_owner_watch, wait_for_owner_state}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/bus_names.rs b/crates/unixnotis-daemon/src/daemon/bus/names.rs similarity index 100% rename from crates/unixnotis-daemon/src/daemon/bus_names.rs rename to crates/unixnotis-daemon/src/daemon/bus/names.rs diff --git a/crates/unixnotis-daemon/src/dbus_owner.rs b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs similarity index 61% rename from crates/unixnotis-daemon/src/dbus_owner.rs rename to crates/unixnotis-daemon/src/daemon/bus/ownership.rs index ab8b47667..81466d521 100644 --- a/crates/unixnotis-daemon/src/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/ownership.rs @@ -2,13 +2,15 @@ //! //! Provides reusable helpers for name ownership checks during startup and trial mode +use std::sync::Arc; use std::time::Duration; use anyhow::Result; use futures_util::StreamExt; -use tracing::{info, warn}; +use tracing::warn; use zbus::fdo::DBusProxy; -use zbus::Connection; + +use crate::daemon::DaemonState; pub async fn wait_for_owner_state( dbus_proxy: &DBusProxy<'_>, @@ -54,41 +56,33 @@ pub async fn wait_for_owner_state( } } -pub async fn log_current_owner( - dbus_proxy: &DBusProxy<'_>, - connection: &Connection, - name: zbus::names::BusName<'_>, -) -> Result { - let unique_name = connection - .unique_name() - .map(std::string::ToString::to_string); - let owner = match dbus_proxy.get_name_owner(name).await { - Ok(owner) => owner.to_string(), - Err(err) => { - info!(?err, "org.freedesktop.Notifications has no owner"); - return Ok(false); - } - }; - let is_self = owner_name_is_self(unique_name.as_deref(), owner.as_str()); - if is_self { - info!(owner, "org.freedesktop.Notifications owner (self)"); - } else { - info!(owner, "org.freedesktop.Notifications owner"); - } - Ok(is_self) -} - -fn owner_state_matches(new_owner: Option<&str>, expect_owner: bool) -> bool { +pub(super) fn owner_state_matches(new_owner: Option<&str>, expect_owner: bool) -> bool { // D-Bus signals encode release as an empty owner name, not as a missing signal let has_owner = new_owner.is_some_and(|name| !name.is_empty()); has_owner == expect_owner } -fn owner_name_is_self(unique_name: Option<&str>, owner: &str) -> bool { - // Unique names come from the live connection and must match the queried owner exactly - unique_name == Some(owner) -} +pub async fn spawn_client_owner_watch(state: Arc) -> zbus::Result<()> { + // One owner-loss stream serves sender metadata and every client-owned domain resource + let proxy = DBusProxy::new(state.connection()).await?; + let mut stream = proxy.receive_name_owner_changed().await?; + + tokio::spawn(async move { + while let Some(signal) = stream.next().await { + let args = match signal.args() { + Ok(args) => args, + Err(error) => { + warn!(?error, "failed to decode NameOwnerChanged arguments"); + continue; + } + }; + if args.new_owner().is_some() { + continue; + } + + state.remove_disconnected_client(args.name().as_str()).await; + } + }); -#[cfg(test)] -#[path = "tests/dbus_owner.rs"] -mod tests; + Ok(()) +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs new file mode 100644 index 000000000..bdcbe1b13 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/clients.rs @@ -0,0 +1,37 @@ +use std::time::Duration; + +use super::super::ownership::spawn_client_owner_watch; +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn owner_watch_removes_inhibitors_when_the_client_disconnects() { + let state = daemon_state_for_test(false).await; + let client = zbus::Connection::session() + .await + .expect("connect inhibitor owner to session bus"); + let owner = client + .unique_name() + .expect("client should have a unique bus name") + .to_string(); + { + let mut store = state.store.lock().await; + store.add_inhibitor(owner, "test owner lifetime".to_string(), 0); + assert_eq!(store.inhibitor_count(), 1); + } + + spawn_client_owner_watch(state.clone()) + .await + .expect("start inhibitor owner watch"); + client.close().await.expect("disconnect inhibitor owner"); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if state.store.lock().await.inhibitor_count() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("owner watch should remove disconnected inhibitors"); +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs new file mode 100644 index 000000000..0e2772834 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/health.rs @@ -0,0 +1,110 @@ +use std::io; +use std::sync::Arc; + +use super::{ + definitive_transport_failure, probe_error_outcome, BusProbeOutcome, TransientFailureCounter, + MAX_CONSECUTIVE_TRANSIENT_FAILURES, +}; + +#[test] +fn one_transient_probe_timeout_keeps_monitor_policy_alive() { + let mut failures = TransientFailureCounter::default(); + + assert!(!failures.observe_failure()); + assert_eq!(failures.consecutive, 1); +} + +#[test] +fn two_transient_failures_then_success_reset_the_failure_counter() { + let mut failures = TransientFailureCounter::default(); + + assert!(!failures.observe_failure()); + assert!(!failures.observe_failure()); + failures.observe_healthy(); + + assert_eq!(failures.consecutive, 0); + assert!(!failures.observe_failure()); +} + +#[test] +fn repeated_transient_failures_become_fatal_at_the_configured_limit() { + let mut failures = TransientFailureCounter::default(); + + for _ in 1..MAX_CONSECUTIVE_TRANSIENT_FAILURES { + assert!(!failures.observe_failure()); + } + + assert!(failures.observe_failure()); +} + +#[test] +fn name_without_an_owner_is_a_definitive_loss() { + let outcome = BusProbeOutcome::DefinitiveNameLoss { + name: unixnotis_core::CONTROL_BUS_NAME, + owner: None, + }; + + assert!(matches!( + outcome, + BusProbeOutcome::DefinitiveNameLoss { owner: None, .. } + )); +} + +#[test] +fn different_owner_is_a_definitive_loss() { + let outcome = BusProbeOutcome::DefinitiveNameLoss { + name: unixnotis_core::NOTIFICATIONS_BUS_NAME, + owner: Some(":1.99".to_string()), + }; + + assert!(matches!( + outcome, + BusProbeOutcome::DefinitiveNameLoss { + owner: Some(owner), + .. + } if owner == ":1.99" + )); +} + +#[test] +fn concrete_closed_socket_errors_are_definitive_transport_failures() { + let disconnected = zbus::fdo::Error::Disconnected("closed test connection".to_string()); + let closed = zbus::fdo::Error::ZBus(zbus::Error::InputOutput(Arc::new(io::Error::new( + io::ErrorKind::BrokenPipe, + "closed test socket", + )))); + let interrupted = zbus::fdo::Error::ZBus(zbus::Error::InputOutput(Arc::new(io::Error::new( + io::ErrorKind::Interrupted, + "interrupted test operation", + )))); + + assert!(definitive_transport_failure(&disconnected)); + assert!(definitive_transport_failure(&closed)); + assert!(!definitive_transport_failure(&interrupted)); +} + +#[test] +fn probe_error_dispatch_distinguishes_name_loss_transport_loss_and_transient_failure() { + let name_loss = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::NameHasNoOwner("missing test owner".to_string()), + ); + let transport_loss = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::Disconnected("closed test connection".to_string()), + ); + let transient = probe_error_outcome( + unixnotis_core::CONTROL_BUS_NAME, + zbus::fdo::Error::NoReply("temporary test timeout".to_string()), + ); + + assert!(matches!( + name_loss, + BusProbeOutcome::DefinitiveNameLoss { owner: None, .. } + )); + assert!(matches!( + transport_loss, + BusProbeOutcome::DefinitiveTransportFailure(_) + )); + assert!(matches!(transient, BusProbeOutcome::TransientFailure(_))); +} diff --git a/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs new file mode 100644 index 000000000..c079c8955 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/mod.rs @@ -0,0 +1,2 @@ +mod clients; +mod ownership; diff --git a/crates/unixnotis-daemon/src/tests/dbus_owner.rs b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs similarity index 84% rename from crates/unixnotis-daemon/src/tests/dbus_owner.rs rename to crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs index 116ba5951..2f8158f2d 100644 --- a/crates/unixnotis-daemon/src/tests/dbus_owner.rs +++ b/crates/unixnotis-daemon/src/daemon/bus/tests/ownership.rs @@ -1,4 +1,4 @@ -use super::{owner_name_is_self, owner_state_matches}; +use super::super::ownership::{owner_state_matches, wait_for_owner_state}; use std::time::Duration; use zbus::fdo::DBusProxy; @@ -18,6 +18,8 @@ fn owner_state_matches_expected_presence_and_release() { #[test] fn owner_name_is_self_requires_exact_unique_name_match() { + let owner_name_is_self = |unique_name: Option<&str>, owner: &str| unique_name == Some(owner); + // D-Bus unique names are exact tokens, so prefix or suffix matches must not pass assert!(owner_name_is_self(Some(":1.7"), ":1.7")); assert!(!owner_name_is_self(Some(":1.70"), ":1.7")); @@ -34,7 +36,7 @@ async fn wait_for_owner_state_returns_true_when_expected_owner_is_already_presen .to_string(); let bus_name = zbus::names::BusName::try_from(unique_name.as_str()).expect("bus name"); - let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + let matched = wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) .await .expect("wait for owned name"); @@ -48,7 +50,7 @@ async fn wait_for_owner_state_returns_false_when_expected_owner_never_appears() let missing_name = format!("com.unixnotis.TestMissing{}", std::process::id()); let bus_name = zbus::names::BusName::try_from(missing_name.as_str()).expect("bus name"); - let matched = super::wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) + let matched = wait_for_owner_state(&proxy, bus_name, true, Duration::from_millis(10)) .await .expect("wait for missing name"); diff --git a/crates/unixnotis-daemon/src/daemon/control/action.rs b/crates/unixnotis-daemon/src/daemon/control/action.rs new file mode 100644 index 000000000..87d462508 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/action.rs @@ -0,0 +1,99 @@ +//! Validation for application action signals requested by trusted control clients + +use std::future::Future; + +use unixnotis_core::NotificationKey; +use zbus::SignalContext; + +use crate::daemon::notifications::identity::resolve_callback_destination; +use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; + +use super::ControlServer; + +impl ControlServer { + pub(super) async fn invoke_validated_action_generation( + &self, + notification: NotificationKey, + action_key: &str, + confirmed: bool, + ) -> zbus::fdo::Result<()> { + self.invoke_validated_action_generation_with_pre_emit( + notification, + action_key, + confirmed, + || std::future::ready(()), + ) + .await + } + + pub(super) async fn invoke_validated_action_generation_with_pre_emit( + &self, + notification: NotificationKey, + action_key: &str, + confirmed: bool, + pre_emit: F, + ) -> zbus::fdo::Result<()> + where + F: FnOnce() -> Fut, + Fut: Future, + { + // The guard spans validation, destination lookup, signal delivery, and exact cleanup + // A same-ID replacement cannot commit while an ID-only protocol signal is in flight + let _interaction = self.state.interaction_gates.lock(notification.id).await; + let target = { + // Capture one concrete generation while validating the stored action identity + let store = self.state.store.lock().await; + store + .active_action_target_generation(notification, action_key, confirmed) + .ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification is not live or does not advertise this action".to_string(), + ) + })? + }; + let bus_name = resolve_callback_destination( + &self.state.sender_metadata_cache, + self.state.connection(), + target.sender_name.as_deref(), + target.sender_pid, + target.sender_start_time, + ) + .await + .ok_or_else(application_unavailable_error)?; + + // The test seam models concurrent replacement pressure after external liveness work + pre_emit().await; + let is_current = self + .state + .store + .lock() + .await + .is_active_notification_generation(notification.id, &target); + if !is_current { + return Err(zbus::fdo::Error::InvalidArgs( + "notification changed before its action could be invoked".to_string(), + )); + } + + // Scope the signal to the stored owner so unrelated bus listeners cannot observe it + let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) + .map_err(to_fdo_error)? + .set_destination(bus_name.to_owned()); + NotificationServer::action_invoked(&context, notification.id, action_key) + .await + .map_err(to_fdo_error)?; + + // A successful action consumes an ordinary notification after delivery + if !target.is_resident { + self.state + .dismiss_actioned_if_current(notification.id, &target) + .await + .map_err(to_fdo_error)?; + } + Ok(()) + } +} + +fn application_unavailable_error() -> zbus::fdo::Error { + zbus::fdo::Error::Failed("The application is no longer available".to_string()) +} diff --git a/crates/unixnotis-daemon/src/daemon/control/clear.rs b/crates/unixnotis-daemon/src/daemon/control/clear.rs deleted file mode 100644 index 66b595031..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/clear.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::sync::Arc; - -use futures_util::stream::{self, StreamExt}; -use tracing::warn; -use unixnotis_core::{CloseReason, CONTROL_OBJECT_PATH}; -use zbus::SignalContext; - -use super::super::{DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; -use super::ControlServer; - -// Keep clear-all signal fanout bounded to avoid a burst of tiny tasks -const CLEAR_ALL_CONCURRENCY: usize = 64; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct ClearAllSignalPlan { - pub(super) emit_close_signals: bool, - pub(super) emit_snapshot_invalidated: bool, - pub(super) emit_state_changed: bool, -} - -pub(super) const fn clear_all_signal_plan(ids: &[u32]) -> ClearAllSignalPlan { - ClearAllSignalPlan { - // Only active rows need close fanout - emit_close_signals: !ids.is_empty(), - // Even an empty clear can be the only thing that fixes a stale client list - emit_snapshot_invalidated: true, - // Counters still need a refresh chance after the clear path - emit_state_changed: true, - } -} - -pub(super) async fn emit_clear_all_signals(state: &Arc, ids: Vec) { - let signal_plan = clear_all_signal_plan(&ids); - - if signal_plan.emit_close_signals { - let notif_ctx = SignalContext::new(state.connection(), NOTIFICATIONS_OBJECT_PATH).ok(); - let control_ctx = SignalContext::new(state.connection(), CONTROL_OBJECT_PATH).ok(); - if notif_ctx.is_none() { - // The clear already happened - warn!("failed to build notification signal context for clear_all; continuing with local state"); - } - if control_ctx.is_none() { - // The clear already happened - warn!( - "failed to build control signal context for clear_all; continuing with local state" - ); - } - - // Emit close signals with a bounded concurrency limit to avoid task spikes - stream::iter(ids) - .for_each_concurrent(CLEAR_ALL_CONCURRENCY, move |id| { - let notif_ctx = notif_ctx.clone(); - let control_ctx = control_ctx.clone(); - async move { - if let Some(notif_ctx) = notif_ctx.as_ref() { - if let Err(err) = NotificationServer::notification_closed( - notif_ctx, - id, - CloseReason::DismissedByUser as u32, - ) - .await - { - warn!( - ?err, - id, "failed to emit notification_closed during clear_all" - ); - } - } - if let Some(control_ctx) = control_ctx.as_ref() { - if let Err(err) = ControlServer::notification_closed( - control_ctx, - id, - CloseReason::DismissedByUser, - ) - .await - { - warn!( - ?err, - id, "failed to emit control notification_closed during clear_all" - ); - } - } - } - }) - .await; - } - - emit_post_clear_refresh(state, signal_plan).await; -} - -async fn emit_post_clear_refresh(state: &Arc, signal_plan: ClearAllSignalPlan) { - if signal_plan.emit_snapshot_invalidated { - if let Err(err) = state.emit_snapshot_invalidated().await { - // Clients can still fall back to later reconnect seeding if this broadcast is missed - warn!(?err, "failed to emit snapshot_invalidated after clear_all"); - } - } - if signal_plan.emit_state_changed { - if let Err(err) = state.emit_state_changed().await { - // State was updated locally even if listeners missed this broadcast - warn!(?err, "failed to emit state_changed after clear_all"); - } - } -} diff --git a/crates/unixnotis-daemon/src/daemon/control/dnd.rs b/crates/unixnotis-daemon/src/daemon/control/dnd.rs deleted file mode 100644 index 9289a8001..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/dnd.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! DND mutation and persistence helpers for `ControlServer` -//! -//! Keeps toggle/set flow and guarded rollback logic out of the main interface file - -use crate::store::DndWrite; -use tracing::{debug, warn}; - -use super::ControlServer; - -impl ControlServer { - pub(super) async fn apply_dnd_state(&self, enabled: bool) -> zbus::fdo::Result<()> { - let write = { - let mut store = self.state.store.lock().await; - // Set request mutates once under lock and records rollback guards - store.set_dnd(enabled) - }; - self.finalize_dnd_write(write).await - } - - pub(super) async fn apply_toggle_dnd(&self) -> zbus::fdo::Result<()> { - let write = { - let mut store = self.state.store.lock().await; - // Toggle computation and write stay in one critical section - store.toggle_dnd() - }; - self.finalize_dnd_write(write).await - } - - async fn finalize_dnd_write(&self, write: DndWrite) -> zbus::fdo::Result<()> { - if let Some(store) = write.persist.as_ref() { - // Persist outside the main store lock to avoid blocking notify paths on I/O - if let Err(err) = store.persist(write.current) { - warn!(?err, "failed to persist do-not-disturb state"); - // Only rollback if this failing write is still the latest in-memory value - let mut state = self.state.store.lock().await; - let rolled_back = state.rollback_dnd_write_if_current(&write); - if rolled_back { - debug!( - revision = write.revision, - current = write.current, - previous = write.previous, - "rolled back do-not-disturb state after persistence failure" - ); - } else { - debug!( - revision = write.revision, - current = write.current, - "skipped do-not-disturb rollback because newer state already exists" - ); - } - return Err(zbus::fdo::Error::Failed( - "failed to persist do-not-disturb state".to_string(), - )); - } - } - if write.changed { - // Mutation is already committed; signal fanout is best-effort - if let Err(err) = self.state.emit_state_changed().await { - warn!( - ?err, - "do-not-disturb state changed but post-commit signal fanout failed" - ); - } - } - Ok(()) - } -} diff --git a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs index f2b279edc..158a0ce90 100644 --- a/crates/unixnotis-daemon/src/daemon/control/inhibit.rs +++ b/crates/unixnotis-daemon/src/daemon/control/inhibit.rs @@ -2,11 +2,7 @@ //! //! Keeps inhibit/uninhibit flow and best-effort post-commit fanout isolated -use tracing::warn; use zbus::message::Header; -use zbus::SignalContext; - -use unixnotis_core::CONTROL_OBJECT_PATH; use super::{sanitize, ControlServer, MAX_ACTIVE_INHIBITORS}; @@ -25,7 +21,7 @@ impl ControlServer { let normalized_scope = sanitize::normalize_inhibit_scope(scope)?; let sanitized_reason = sanitize::sanitize_inhibit_reason(reason); // Track inhibitors by unique bus name so cleanup on disconnect is reliable - let (id, active, count) = { + let id = { let mut store = self.state.store.lock().await; if store.inhibitor_count() >= MAX_ACTIVE_INHIBITORS { // Hard cap blocks unbounded growth from accidental loops or hostile callers @@ -33,12 +29,9 @@ impl ControlServer { "inhibitor limit reached ({MAX_ACTIVE_INHIBITORS})" ))); } - let id = store.add_inhibitor(sender.to_string(), sanitized_reason, normalized_scope); - let active = store.inhibited(); - let count = store.inhibitor_count(); - (id, active, count) + store.add_inhibitor(sender.to_string(), sanitized_reason, normalized_scope) }; - self.emit_inhibitor_updates(active, count, "added").await; + self.state.publish_inhibitors_changed("added").await; Ok(id) } @@ -53,14 +46,10 @@ impl ControlServer { .ok_or_else(|| zbus::fdo::Error::Failed("missing sender".to_string()))?; let owner = sender.to_string(); // Only the owner can remove it - let (removed, active, count) = { + let removed = { let mut store = self.state.store.lock().await; match store.remove_inhibitor(id, &owner) { - Ok(removed) => { - let active = store.inhibited(); - let count = store.inhibitor_count(); - (removed, active, count) - } + Ok(removed) => removed, Err(err) => { return Err(zbus::fdo::Error::AccessDenied(err.message())); } @@ -70,38 +59,7 @@ impl ControlServer { // Unknown IDs are treated as a no-op to keep clients resilient return Ok(()); } - self.emit_inhibitor_updates(active, count, "removed").await; + self.state.publish_inhibitors_changed("removed").await; Ok(()) } - - async fn emit_inhibitor_updates(&self, active: bool, count: u32, action: &'static str) { - match SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) { - Ok(ctx) => { - // Broadcast inhibitor updates so UI clients can refresh badges - if let Err(err) = Self::inhibitors_changed(&ctx, active, count).await { - warn!( - ?err, - inhibitor_count = count, - action, - "inhibitor state changed but inhibitors_changed signal fanout failed" - ); - } - } - Err(err) => { - warn!( - ?err, - action, - "inhibitor state changed but failed to build signal context for inhibitors_changed" - ); - } - } - // Mutation is already committed; signal fanout is best-effort - if let Err(err) = self.state.emit_state_changed().await { - warn!( - ?err, - action, - "inhibitor state changed but post-commit state_changed signal fanout failed" - ); - } - } } diff --git a/crates/unixnotis-daemon/src/daemon/control/mod.rs b/crates/unixnotis-daemon/src/daemon/control/mod.rs index 4f650ac79..31156f146 100644 --- a/crates/unixnotis-daemon/src/daemon/control/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/mod.rs @@ -1,16 +1,15 @@ //! D-Bus server for com.unixnotis.Control -mod clear; -mod dnd; +mod action; mod inhibit; mod panel; +mod popup; mod query; +mod reply; mod sanitize; mod server; -mod watch; pub use server::ControlServer; -pub use watch::spawn_inhibitor_owner_watch; // Cap inhibitor count so memory use stays bounded even under abusive clients const MAX_ACTIVE_INHIBITORS: u32 = 128; diff --git a/crates/unixnotis-daemon/src/daemon/control/panel.rs b/crates/unixnotis-daemon/src/daemon/control/panel.rs index c4b920316..c28392aa5 100644 --- a/crates/unixnotis-daemon/src/daemon/control/panel.rs +++ b/crates/unixnotis-daemon/src/daemon/control/panel.rs @@ -34,8 +34,11 @@ impl ControlServer { ready: bool, ) -> zbus::fdo::Result<()> { self.authorize_panel_readiness_call(header, method).await?; + let owner = header + .sender() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; // Center reports ready only after it is subscribed to panel_requested - self.state.set_panel_ready(ready); + self.state.set_panel_ready(owner.as_str(), ready); Ok(()) } } diff --git a/crates/unixnotis-daemon/src/daemon/control/popup.rs b/crates/unixnotis-daemon/src/daemon/control/popup.rs new file mode 100644 index 000000000..7d3439458 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/popup.rs @@ -0,0 +1,49 @@ +//! Popup readiness authorization and owner-generation tracking + +use unixnotis_core::{NotificationKey, PopupDeliveryStage}; +use zbus::message::Header; + +use super::ControlServer; +use crate::daemon::auth; + +impl ControlServer { + pub(super) async fn set_popups_ready_state( + &self, + header: &Header<'_>, + method: &'static str, + ready: bool, + ) -> zbus::fdo::Result<()> { + // Executable verification runs before trusting the broker-supplied unique owner + auth::authorize_popup_readiness_call(&self.state, header, method).await?; + let owner = header + .sender() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("missing sender".to_string()))?; + self.state.set_popups_ready(owner.as_str(), ready); + Ok(()) + } + + pub(super) async fn mark_popup_generation_stage( + &self, + key: NotificationKey, + stage: PopupDeliveryStage, + method: &'static str, + header: &Header<'_>, + ) -> zbus::fdo::Result<()> { + auth::authorize_popup_readiness_call(&self.state, header, method).await?; + let update = self + .state + .store + .lock() + .await + .record_popup_delivery_stage(key, stage); + match update { + crate::store::DeliveryStageUpdate::Advanced + | crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond => Ok(()), + crate::store::DeliveryStageUpdate::MissingGeneration => { + Err(zbus::fdo::Error::InvalidArgs( + "notification generation is no longer retained".to_string(), + )) + } + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/control/query.rs b/crates/unixnotis-daemon/src/daemon/control/query.rs index 39852a170..3522ce94f 100644 --- a/crates/unixnotis-daemon/src/daemon/control/query.rs +++ b/crates/unixnotis-daemon/src/daemon/control/query.rs @@ -2,23 +2,32 @@ //! //! Keeps read-only control methods grouped outside the main interface file -use unixnotis_core::{ControlState, InhibitorInfo, NotificationView}; +use unixnotis_core::{ + ControlSnapshot, ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationView, + PopupCandidate, +}; use zbus::message::Header; use super::ControlServer; impl ControlServer { - pub(super) async fn query_state(&self, header: &Header<'_>) -> zbus::fdo::Result { - // State metadata is now treated as privileged control telemetry - self.authorize_control_call(header, "GetState").await?; + pub(super) async fn query_state(&self) -> zbus::fdo::Result { + // Readiness clients receive only aggregate state without notification content // Single lock read keeps state snapshot internally consistent let store = self.state.store.lock().await; - // Cheap state snapshot - Ok(ControlState { - dnd_enabled: store.dnd_enabled(), - history_count: store.history_len() as u32, - inhibited: store.inhibited(), - inhibitor_count: store.inhibitor_count(), + Ok(store.control_state()) + } + + pub(super) async fn query_snapshot( + &self, + header: &Header<'_>, + ) -> zbus::fdo::Result { + self.authorize_control_call(header, "GetSnapshot").await?; + let store = self.state.store.lock().await; + Ok(ControlSnapshot { + state: store.control_state(), + active: store.list_active(), + history: store.list_history(), }) } @@ -44,6 +53,17 @@ impl ControlServer { Ok(store.list_history()) } + pub(super) async fn query_popup_candidates( + &self, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Rule-level suppression persists across reconnects and must be applied by the daemon + self.authorize_control_call(header, "ListPopupCandidates") + .await?; + let store = self.state.store.lock().await; + Ok(store.list_popup_candidates()) + } + pub(super) async fn query_active_notification( &self, id: u32, @@ -56,6 +76,34 @@ impl ControlServer { Ok(store.active_notification_view(id).into_iter().collect()) } + pub(super) async fn query_popup_candidate( + &self, + id: u32, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Admission and content must describe the same committed generation + self.authorize_control_call(header, "GetPopupCandidate") + .await?; + let mut store = self.state.store.lock().await; + Ok(store.popup_candidate(id).into_iter().collect()) + } + + pub(super) async fn query_notification_diagnostics( + &self, + id: u32, + header: &Header<'_>, + ) -> zbus::fdo::Result> { + // Process evidence and notification content share the normal control authorization gate + self.authorize_control_call(header, "GetNotificationDiagnostics") + .await?; + let health = self.state.ui_health(); + let store = self.state.store.lock().await; + Ok(store + .notification_diagnostics(id, &health) + .into_iter() + .collect()) + } + pub(super) async fn query_inhibitors( &self, header: &Header<'_>, diff --git a/crates/unixnotis-daemon/src/daemon/control/reply.rs b/crates/unixnotis-daemon/src/daemon/control/reply.rs new file mode 100644 index 000000000..13529f29d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/reply.rs @@ -0,0 +1,141 @@ +//! KDE-compatible inline reply handling for active notifications + +use std::future::Future; + +use unixnotis_core::Notification; +use zbus::names::BusName; +use zbus::SignalContext; + +use crate::daemon::notifications::identity::resolve_callback_destination; +use crate::daemon::{to_fdo_error, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; + +use super::ControlServer; + +pub(super) const MAX_REPLY_TEXT_BYTES: usize = 4 * 1024; +const APPLICATION_UNAVAILABLE: &str = "The application is no longer available"; + +impl ControlServer { + pub(super) async fn submit_inline_reply( + &self, + id: u32, + generation: u64, + reply_text: &str, + ) -> zbus::fdo::Result<()> { + self.submit_inline_reply_with_post_emit(id, generation, reply_text, || { + std::future::ready(()) + }) + .await + } + + async fn submit_inline_reply_with_post_emit( + &self, + id: u32, + generation: u64, + reply_text: &str, + post_emit: F, + ) -> zbus::fdo::Result<()> + where + F: FnOnce() -> Fut, + Fut: Future, + { + // Text validation happens before any notification lookup or signal work + let reply_text = validate_reply_text(reply_text)?; + // Reply signals carry only a numeric ID, so replacement commits share this exact gate + let _interaction = self.state.interaction_gates.lock(id).await; + let target = { + // Keep the Arc so later cleanup can distinguish a same-ID replacement + let store = self.state.store.lock().await; + store + .active_inline_reply_target(id, generation) + .ok_or_else(|| { + zbus::fdo::Error::InvalidArgs( + "notification generation is stale or does not support inline reply" + .to_string(), + ) + })? + }; + let destination = self.reply_destination(&target).await?; + + let is_current = self + .state + .store + .lock() + .await + .is_active_notification_generation(id, &target); + if !is_current { + return Err(zbus::fdo::Error::InvalidArgs( + "notification changed before its reply could be submitted".to_string(), + )); + } + + // A destination header keeps sensitive reply text visible only to its owning connection + let context = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) + .map_err(to_fdo_error)? + .set_destination(destination); + NotificationServer::notification_replied(&context, id, reply_text) + .await + .map_err(to_fdo_error)?; + // The test seam models an application replacing the row while handling the signal + post_emit().await; + + if !target.is_resident { + // Cleanup applies only if the exact replied generation is still stored + self.state + .dismiss_replied_if_current(id, &target) + .await + .map_err(to_fdo_error)?; + } + // Resident notifications remain active for later updates from the sender + Ok(()) + } + + async fn reply_destination( + &self, + target: &Notification, + ) -> zbus::fdo::Result> { + resolve_callback_destination( + &self.state.sender_metadata_cache, + self.state.connection(), + target.sender_name.as_deref(), + target.sender_pid, + target.sender_start_time, + ) + .await + .ok_or_else(application_unavailable_error) + } +} + +pub(super) fn validate_reply_text(reply_text: &str) -> zbus::fdo::Result<&str> { + // Outer spacing is not message content, while interior Unicode remains byte-for-byte intact + let reply_text = reply_text.trim(); + if reply_text.is_empty() { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text cannot be empty".to_string(), + )); + } + if reply_text.len() > MAX_REPLY_TEXT_BYTES { + // Byte limits match the D-Bus payload and remain stable across Unicode text + return Err(zbus::fdo::Error::InvalidArgs(format!( + "reply text exceeds {MAX_REPLY_TEXT_BYTES} bytes" + ))); + } + if reply_text.contains('\0') { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text contains an embedded NUL".to_string(), + )); + } + if reply_text.contains(['\r', '\n']) { + return Err(zbus::fdo::Error::InvalidArgs( + "reply text must contain one line".to_string(), + )); + } + Ok(reply_text) +} + +fn application_unavailable_error() -> zbus::fdo::Error { + zbus::fdo::Error::Failed(APPLICATION_UNAVAILABLE.to_string()) +} + +#[cfg(test)] +#[path = "tests/reply.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs index 0cdcca653..94febfc89 100644 --- a/crates/unixnotis-daemon/src/daemon/control/sanitize.rs +++ b/crates/unixnotis-daemon/src/daemon/control/sanitize.rs @@ -2,7 +2,7 @@ //! //! These helpers are pure and easy to unit test in isolation -use unixnotis_core::{INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; +use unixnotis_core::{util, INHIBIT_SCOPE_ALL, INHIBIT_SCOPE_POPUPS}; const MAX_INHIBITOR_REASON_BYTES: usize = 256; @@ -13,7 +13,7 @@ pub(super) fn sanitize_inhibit_reason(reason: &str) -> String { // Keep an explicit default for empty reasons to avoid blank UI rows return "manual".to_string(); } - truncate_utf8_bytes(trimmed, MAX_INHIBITOR_REASON_BYTES) + util::truncate_utf8_bytes(trimmed, MAX_INHIBITOR_REASON_BYTES) } pub(super) fn normalize_inhibit_scope(scope: u32) -> zbus::fdo::Result { @@ -31,21 +31,3 @@ pub(super) fn normalize_inhibit_scope(scope: u32) -> zbus::fdo::Result { } Ok(normalized) } - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - if value.len() <= max_bytes { - return value.to_string(); - } - - // Keep the largest character boundary at or before the byte limit - let end = value - .char_indices() - .map(|(index, _)| index) - .take_while(|index| *index <= max_bytes) - .last() - .unwrap_or(0); - value[..end].to_string() -} diff --git a/crates/unixnotis-daemon/src/daemon/control/server.rs b/crates/unixnotis-daemon/src/daemon/control/server.rs index 19efc6676..b19d56dca 100644 --- a/crates/unixnotis-daemon/src/daemon/control/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/server.rs @@ -3,17 +3,13 @@ use std::sync::Arc; use unixnotis_core::{ - CloseReason, ControlState, InhibitorInfo, NotificationView, PanelDebugLevel, PanelRequest, - PopupGateState, + CloseReason, ControlState, InhibitorInfo, NotificationDiagnosticsView, NotificationKey, + NotificationView, PanelDebugLevel, PanelRequest, PopupCandidate, PopupGateState, UiHealth, }; use zbus::message::Header; use zbus::{interface, SignalContext}; -use crate::daemon::{ - auth, to_fdo_error, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, -}; - -use super::clear; +use crate::daemon::{auth, to_fdo_error, DaemonState}; /// D-Bus server for com.unixnotis.Control pub struct ControlServer { @@ -46,6 +42,15 @@ impl ControlServer { auth::authorize_panel_readiness_call(&self.state, header, method).await } + pub(super) async fn authorize_interaction_call( + &self, + header: &Header<'_>, + method: &'static str, + ) -> zbus::fdo::Result<()> { + // Noninteractive control clients cannot assert a UI confirmation result + auth::authorize_interaction_call(&self.state, header, method).await + } + pub(super) fn ensure_panel_available(&self) -> zbus::fdo::Result<()> { // Rejecting here makes panel outages visible instead of silent if self.state.panel_ready() { @@ -56,13 +61,19 @@ impl ControlServer { )) } - pub(super) async fn drain_active_notifications(&self) -> Vec { - let ids = { - let mut store = self.state.store.lock().await; - store.drain_active_ids() - }; - self.state.cancel_expirations(&ids); - ids + pub(super) async fn clear_all_notifications(&self) -> Vec { + let mut store = self.state.store.lock().await; + let keys = store.clear_all(); + // Cancellation follows the same serialized mutation snapshot + self.state.cancel_expirations(&keys); + keys + } + + pub(super) async fn drain_active_notifications(&self) -> Vec { + let mut store = self.state.store.lock().await; + let keys = store.drain_active_keys(); + self.state.cancel_expirations(&keys); + keys } pub(super) async fn clear_saved_history(&self) { @@ -73,11 +84,23 @@ impl ControlServer { #[interface(name = "com.unixnotis.Control")] impl ControlServer { - async fn get_state( + async fn get_api_version(&self) -> u32 { + unixnotis_core::CONTROL_API_VERSION + } + + async fn get_state(&self) -> zbus::fdo::Result { + self.query_state().await + } + + pub(super) async fn get_snapshot( &self, #[zbus(header)] header: Header<'_>, - ) -> zbus::fdo::Result { - self.query_state(&header).await + ) -> zbus::fdo::Result { + self.query_snapshot(&header).await + } + + async fn get_ui_health(&self) -> zbus::fdo::Result { + Ok(self.state.ui_health()) } async fn list_active( @@ -87,6 +110,13 @@ impl ControlServer { self.query_active(&header).await } + async fn list_popup_candidates( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_popup_candidates(&header).await + } + async fn list_history( &self, #[zbus(header)] header: Header<'_>, @@ -102,11 +132,42 @@ impl ControlServer { self.query_active_notification(id, &header).await } + async fn get_popup_candidate( + &self, + id: u32, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_popup_candidate(id, &header).await + } + + async fn get_notification_diagnostics( + &self, + id: u32, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result> { + self.query_notification_diagnostics(id, &header).await + } + async fn open_panel(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.request_panel_command(&header, "OpenPanel", PanelRequest::open()) .await } + async fn refresh_applications( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "RefreshApplications") + .await?; + if self.state.request_desktop_index_refresh() { + // The worker owns rebuild timing, watcher replacement, and publication + return Ok(()); + } + Err(zbus::fdo::Error::Failed( + "desktop application refresh worker is unavailable".to_string(), + )) + } + async fn open_panel_debug( &self, level: PanelDebugLevel, @@ -132,12 +193,21 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "SetDnd").await?; - self.apply_dnd_state(enabled).await + self.state.apply_dnd_state(enabled).await + } + + pub(super) async fn set_dnd_until( + &self, + expires_at: i64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "SetDndUntil").await?; + self.state.apply_dnd_until(expires_at).await } async fn toggle_dnd(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ToggleDnd").await?; - self.apply_toggle_dnd().await + self.state.apply_toggle_dnd().await } async fn inhibit( @@ -164,28 +234,48 @@ impl ControlServer { self.query_inhibitors(&header).await } - async fn dismiss(&self, id: u32, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "Dismiss").await?; - // Delegate to shared state helper so all close signals stay consistent + pub(super) async fn dismiss_generation( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_control_call(&header, "DismissGeneration") + .await?; self.state - .dismiss_from_panel(id) + .dismiss_generation(NotificationKey { id, generation }) .await .map_err(to_fdo_error) } - pub(super) async fn invoke_action( + pub(super) async fn invoke_action_generation( &self, id: u32, + generation: u64, action_key: &str, + confirmed: bool, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { - self.authorize_control_call(&header, "InvokeAction").await?; - // Reuse the freedesktop action signal path for compatibility with listeners - let ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; - NotificationServer::action_invoked(&ctx, id, action_key) - .await - .map_err(to_fdo_error) + self.authorize_interaction_call(&header, "InvokeActionGeneration") + .await?; + self.invoke_validated_action_generation( + NotificationKey { id, generation }, + action_key, + confirmed, + ) + .await + } + + pub(super) async fn reply_notification( + &self, + id: u32, + generation: u64, + reply_text: &str, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.authorize_interaction_call(&header, "ReplyNotification") + .await?; + self.submit_inline_reply(id, generation, reply_text).await } pub(super) async fn clear_all( @@ -193,9 +283,8 @@ impl ControlServer { #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearAll").await?; - let ids = self.drain_active_notifications().await; - self.clear_saved_history().await; - clear::emit_clear_all_signals(&self.state, ids).await; + let ids = self.clear_all_notifications().await; + self.state.publish_notifications_cleared(ids).await; Ok(()) } @@ -205,7 +294,7 @@ impl ControlServer { ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearActive").await?; let ids = self.drain_active_notifications().await; - clear::emit_clear_all_signals(&self.state, ids).await; + self.state.publish_notifications_cleared(ids).await; Ok(()) } @@ -215,7 +304,7 @@ impl ControlServer { ) -> zbus::fdo::Result<()> { self.authorize_control_call(&header, "ClearHistory").await?; self.clear_saved_history().await; - clear::emit_clear_all_signals(&self.state, Vec::new()).await; + self.state.publish_notifications_cleared(Vec::new()).await; Ok(()) } @@ -232,24 +321,68 @@ impl ControlServer { .await } + async fn mark_popups_ready(&self, #[zbus(header)] header: Header<'_>) -> zbus::fdo::Result<()> { + self.set_popups_ready_state(&header, "MarkPopupsReady", true) + .await + } + + async fn mark_popups_not_ready( + &self, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.set_popups_ready_state(&header, "MarkPopupsNotReady", false) + .await + } + + async fn mark_popup_materialized( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.mark_popup_generation_stage( + NotificationKey { id, generation }, + unixnotis_core::PopupDeliveryStage::Materialized, + "MarkPopupMaterialized", + &header, + ) + .await + } + + async fn mark_popup_visible( + &self, + id: u32, + generation: u64, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + self.mark_popup_generation_stage( + NotificationKey { id, generation }, + unixnotis_core::PopupDeliveryStage::Visible, + "MarkPopupVisible", + &header, + ) + .await + } + #[zbus(signal)] pub(crate) async fn notification_added( ctx: &SignalContext<'_>, id: u32, - show_popup: bool, + generation: u64, ) -> zbus::Result<()>; #[zbus(signal)] pub(crate) async fn notification_updated( ctx: &SignalContext<'_>, id: u32, - show_popup: bool, + generation: u64, ) -> zbus::Result<()>; #[zbus(signal)] pub(crate) async fn notification_closed( ctx: &SignalContext<'_>, id: u32, + generation: u64, reason: CloseReason, ) -> zbus::Result<()>; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/action.rs b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs new file mode 100644 index 000000000..46d1fdf62 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/action.rs @@ -0,0 +1,370 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use futures_util::TryStreamExt; +use unixnotis_core::{ + Action, AttributionReason, Notification, NotificationAttribution, NotificationImage, Urgency, +}; +use zbus::fdo::DBusProxy; +use zbus::message::Type; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +use super::super::ControlServer; +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::test_support::daemon_state_for_test; + +#[tokio::test] +async fn validated_action_emits_only_an_advertised_live_action() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = action_signal_stream(&sender).await; + let notification = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&sender, "open"), 0) + .active_notification() + .key() + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect("invoke advertised action"); + + assert_eq!( + next_action_signal(&mut stream).await, + (notification.id, "open".to_string()) + ); + let store = state.store.lock().await; + assert!(store.active_notification_view(notification.id).is_none()); + assert!(store.list_history().is_empty()); +} + +#[tokio::test] +async fn successful_action_keeps_a_resident_notification_active() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut resident = action_notification(&sender, "open"); + resident.is_resident = true; + let notification = { + let mut store = state.store.lock().await; + store.insert(resident, 0).active_notification().key() + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect("resident action should be delivered"); + + assert!(state + .store + .lock() + .await + .active_notification_view(notification.id) + .is_some()); +} + +#[tokio::test] +async fn action_signal_reaches_owner_but_not_unrelated_observer() { + let state = daemon_state_for_test(false).await; + let owner = Connection::session().await.expect("owner session bus"); + let observer = Connection::session().await.expect("observer session bus"); + let mut owner_stream = action_signal_stream(&owner).await; + let mut observer_stream = action_signal_stream(&observer).await; + let notification = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&owner, "open"), 0) + .active_notification() + .key() + }; + + ControlServer::new(state) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect("invoke owner action"); + + assert_eq!( + next_action_signal(&mut owner_stream).await, + (notification.id, "open".to_string()) + ); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + observer_stream.try_next() + ) + .await + .is_err(), + "unrelated observer must not receive action signal" + ); +} + +#[tokio::test] +async fn action_keeps_notification_when_the_owner_disappears() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let notification = { + let mut store = state.store.lock().await; + store + .insert(action_notification(&sender, "open"), 0) + .active_notification() + .key() + }; + let sender_name = sender.unique_name().expect("sender unique name").clone(); + sender.close().await.expect("close sender connection"); + let proxy = DBusProxy::new(state.connection()) + .await + .expect("create bus proxy"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if !proxy + .name_has_owner(sender_name.clone().into()) + .await + .expect("query sender ownership") + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("bus should release the closed sender name"); + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect_err("closed sender must reject the action"); + assert!(state + .store + .lock() + .await + .active_notification_view(notification.id) + .is_some()); +} + +#[tokio::test] +async fn unconfirmed_action_does_not_emit_or_dismiss() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut notification = action_notification(&sender, "open"); + notification.attribution.interactions = unixnotis_core::InteractionPolicies::CONFIRM_ACTIONS; + let key = { + let mut store = state.store.lock().await; + store.insert(notification, 0).active_notification().key() + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(key, "open", false) + .await + .expect_err("confirmation-required action must not run without confirmation"); + assert!(state + .store + .lock() + .await + .active_notification_view(key.id) + .is_some()); +} + +#[tokio::test] +async fn validated_action_rejects_missing_and_stale_action_generations() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let notification = { + let mut store = state.store.lock().await; + let notification = store + .insert(action_notification(&sender, "open"), 0) + .active_notification(); + notification.key() + }; + let server = ControlServer::new(state.clone()); + + server + .invoke_validated_action_generation(notification, "missing", false) + .await + .expect_err("unadvertised action must fail"); +} + +#[tokio::test] +async fn replacement_commit_waits_until_action_signal_is_emitted() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = action_signal_stream(&sender).await; + let mut original = action_notification(&sender, "open"); + original.is_resident = true; + let notification = state + .store + .lock() + .await + .insert(original, 0) + .active_notification() + .key(); + let id = notification.id; + let scheduler = crate::expire::ExpirationScheduler::start(state.clone()); + let replacement_state = state.clone(); + let replacement_sender = sender.clone(); + let (replacement_done_tx, replacement_done_rx) = tokio::sync::oneshot::channel(); + let replacement_committed = Arc::new(AtomicBool::new(false)); + let observed_replacement_commit = Arc::clone(&replacement_committed); + + ControlServer::new(state.clone()) + .invoke_validated_action_generation_with_pre_emit( + notification, + "open", + false, + move || async move { + let replacement = action_notification(&replacement_sender, "different"); + let replacement_committed = Arc::clone(&replacement_committed); + tokio::spawn(async move { + let outcome = replacement_state + .commit_notification_generation(replacement, id, &scheduler) + .await; + let replaced = outcome.replaced; + let committed_id = outcome.active_notification().id; + replacement_committed.store(true, Ordering::Release); + let _sent = replacement_done_tx.send((committed_id, replaced)); + }); + tokio::task::yield_now().await; + assert!( + !observed_replacement_commit.load(Ordering::Acquire), + "replacement commit must remain blocked while the action signal is in flight" + ); + }, + ) + .await + .expect("action must finish before replacement commit"); + + assert_eq!(next_action_signal(&mut stream).await.0, id); + let (committed_id, replaced) = replacement_done_rx + .await + .expect("replacement commit task must finish"); + assert_eq!(committed_id, id); + assert!(replaced); +} + +#[tokio::test] +async fn stale_action_does_not_target_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let (stale_key, replacement_key) = { + let mut store = state.store.lock().await; + let first = store + .insert(action_notification(&sender, "delete"), 0) + .active_notification(); + let stale_key = first.key(); + let second = store + .insert(action_notification(&sender, "delete"), first.id) + .active_notification(); + (stale_key, second.key()) + }; + + ControlServer::new(state.clone()) + .invoke_validated_action_generation(stale_key, "delete", false) + .await + .expect_err("a delayed action must not target a same-ID replacement"); + + let store = state.store.lock().await; + let replacement = store + .active_notification_view(replacement_key.id) + .expect("replacement should remain active"); + assert_eq!(replacement.key(), replacement_key); +} + +#[tokio::test] +async fn validated_action_rejects_a_conflicting_application_claim() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let notification = { + let mut notification = action_notification(&sender, "open"); + notification.attribution = NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ApplicationClaimMismatch, + "application claim mismatch; source /tmp/fake", + "conflict:example-chat".to_string(), + ); + state + .store + .lock() + .await + .insert(notification, 0) + .active_notification() + .key() + }; + + ControlServer::new(state) + .invoke_validated_action_generation(notification, "open", false) + .await + .expect_err("conflicting attribution must not receive an action signal"); +} + +fn action_notification(sender: &Connection, key: &str) -> Notification { + Notification { + id: 0, + generation: 0, + app_name: "ActionApp".to_string(), + app_icon: String::new(), + attribution: NotificationAttribution::verified( + "ActionApp", + "ActionApp", + "org.example.ActionApp", + "", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.ActionApp".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: "Action".to_string(), + body: String::new(), + actions: vec![Action { + key: key.to_string(), + label: "Run".to_string(), + }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: sender.unique_name().map(ToString::to_string), + sender_pid: Some(std::process::id()), + sender_start_time: Some( + crate::daemon::notifications::identity::read_process_start_time(std::process::id()) + .expect("test process should expose a start time"), + ), + sender_executable: None, + } +} + +async fn action_signal_stream(receiver: &Connection) -> MessageStream { + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("ActionInvoked") + .expect("action member") + .path(NOTIFICATIONS_OBJECT_PATH) + .expect("notification path") + .build(); + MessageStream::for_match_rule(rule, receiver, Some(8)) + .await + .expect("action signal stream") +} + +async fn next_action_signal(stream: &mut MessageStream) -> (u32, String) { + let message = tokio::time::timeout(std::time::Duration::from_secs(1), stream.try_next()) + .await + .expect("action signal timeout") + .expect("read action signal") + .expect("action signal stream ended"); + message.body().deserialize().expect("action signal body") +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs index 9ae383e17..211d83bc3 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/mod.rs @@ -1,3 +1,3 @@ -mod clear; +mod action; mod sanitize; mod server; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs new file mode 100644 index 000000000..496606b63 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/control/tests/reply.rs @@ -0,0 +1,434 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use futures_util::TryStreamExt; +use unixnotis_core::{InlineReply, Notification, NotificationImage, Urgency}; +use zbus::fdo::DBusProxy; +use zbus::message::Type; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +use super::super::ControlServer; +use super::{validate_reply_text, MAX_REPLY_TEXT_BYTES}; +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::test_support::daemon_state_for_test; + +#[test] +fn validate_reply_text_keeps_message_content_and_trims_outer_spacing() { + assert_eq!( + validate_reply_text(" See you soon ").expect("valid reply"), + "See you soon" + ); +} + +#[test] +fn validate_reply_text_preserves_unicode_and_bidirectional_content_exactly() { + let messages = [ + "مرحبًا، سأصل قريبًا", + "שלום, אגיע בקרוב", + "Reply 👩🏽‍💻 cafe\u{301}", + "English \u{2067}مرحبا שלום\u{2069} English", + ]; + + for message in messages { + assert_eq!( + validate_reply_text(message).expect("valid Unicode"), + message + ); + } +} + +#[test] +fn validate_reply_text_accepts_exact_byte_limit() { + assert_eq!(MAX_REPLY_TEXT_BYTES, 4_096); + let reply = "🙂".repeat(MAX_REPLY_TEXT_BYTES / "🙂".len()); + + assert_eq!(reply.len(), MAX_REPLY_TEXT_BYTES); + assert_eq!(validate_reply_text(&reply).expect("exact limit"), reply); +} + +#[test] +fn validate_reply_text_rejects_empty_oversized_nul_and_multiline_values() { + assert!(validate_reply_text(" \n\t ").is_err()); + assert!(validate_reply_text(&"x".repeat(MAX_REPLY_TEXT_BYTES + 1)).is_err()); + assert!(validate_reply_text("before\0after").is_err()); + assert!(validate_reply_text("line one\nline two").is_err()); +} + +#[tokio::test] +async fn submit_inline_reply_emits_text_and_removes_nonresident_notification() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state, &sender).await; + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(false, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, generation, " On my way ") + .await + .expect("submit live inline reply"); + + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(text, "On my way"); + assert!(state.store.lock().await.list_active().is_empty()); + assert!(state.store.lock().await.list_history().is_empty()); +} + +#[tokio::test] +async fn submit_inline_reply_keeps_resident_notification_live() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state, &sender).await; + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(true, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, generation, "Another update") + .await + .expect("submit resident inline reply"); + + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(text, "Another update"); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +#[tokio::test] +async fn stale_reply_generation_cannot_target_a_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let (id, old_generation, replacement_generation) = { + let mut store = state.store.lock().await; + let original = store + .insert(reply_notification(false, &sender), 0) + .active_notification(); + let replacement = store + .insert(reply_notification(false, &sender), original.id) + .active_notification(); + (original.id, original.generation, replacement.generation) + }; + + let error = ControlServer::new(state.clone()) + .submit_inline_reply(id, old_generation, "stale draft") + .await + .expect_err("stale reply generation must be rejected"); + + assert!(error.to_string().contains("generation is stale")); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active"); + assert_eq!(active.generation, replacement_generation); +} + +#[tokio::test] +async fn submit_inline_reply_round_trips_unicode_and_exact_byte_limit() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state, &sender).await; + let messages = [ + "مرحبًا، سأصل قريبًا".to_string(), + "שלום, אגיע בקרוב".to_string(), + "Reply 👩🏽‍💻 cafe\u{301}".to_string(), + "English \u{2067}مرحبا שלום\u{2069} English".to_string(), + "🙂".repeat(MAX_REPLY_TEXT_BYTES / "🙂".len()), + ]; + + for message in messages { + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(true, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + + ControlServer::new(state.clone()) + .submit_inline_reply(id, generation, &message) + .await + .expect("submit exact reply text"); + + let (signal_id, signal_text) = next_reply_signal(&mut stream).await; + assert_eq!(signal_id, id); + assert_eq!(signal_text, message); + } +} + +#[tokio::test] +async fn replacement_commit_waits_until_reply_signal_is_emitted() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state, &sender).await; + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(true, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + let replacement_state = state.clone(); + let replacement_sender = sender.clone(); + let scheduler = crate::expire::ExpirationScheduler::start(state.clone()); + let (replacement_done_tx, replacement_done_rx) = tokio::sync::oneshot::channel(); + let replacement_committed = Arc::new(AtomicBool::new(false)); + let observed_replacement_commit = Arc::clone(&replacement_committed); + + ControlServer::new(state.clone()) + .submit_inline_reply_with_post_emit(id, generation, "yes", move || async move { + // This models the sender updating the same row while handling the reply signal + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!((signal_id, text.as_str()), (id, "yes")); + let mut replacement = reply_notification(false, &replacement_sender); + replacement.summary = "Reply received".to_string(); + let replacement_committed = Arc::clone(&replacement_committed); + tokio::spawn(async move { + let outcome = replacement_state + .commit_notification_generation(replacement, id, &scheduler) + .await; + replacement_committed.store(true, Ordering::Release); + let _sent = + replacement_done_tx.send((outcome.active_notification().id, outcome.replaced)); + }); + tokio::task::yield_now().await; + assert!( + !observed_replacement_commit.load(Ordering::Acquire), + "replacement commit must remain blocked while reply delivery is in flight" + ); + }) + .await + .expect("reply with replacement"); + + let (committed_id, replaced) = replacement_done_rx + .await + .expect("replacement commit task must finish"); + assert_eq!(committed_id, id); + assert!(replaced); + + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("same-ID replacement should remain active"); + assert_eq!(active.summary, "Reply received"); +} + +#[tokio::test] +async fn reply_listener_close_removes_replied_notification_without_history() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let mut stream = reply_signal_stream(&state, &sender).await; + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(false, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + let closing_state = state.clone(); + + ControlServer::new(state.clone()) + .submit_inline_reply_with_post_emit(id, generation, "yes", move || async move { + let (signal_id, text) = next_reply_signal(&mut stream).await; + assert_eq!((signal_id, text.as_str()), (id, "yes")); + closing_state + .close_notification(id, unixnotis_core::CloseReason::ClosedByCall) + .await + .expect("sender close should succeed"); + }) + .await + .expect("reply with sender close"); + + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); +} + +#[tokio::test] +async fn submit_inline_reply_rejects_sender_that_no_longer_owns_bus_name() { + let state = daemon_state_for_test(false).await; + let sender = Connection::session().await.expect("sender session bus"); + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(false, &sender), 0) + .active_notification(); + (notification.id, notification.generation) + }; + let sender_name = sender.unique_name().expect("sender unique name").clone(); + sender.close().await.expect("close sender connection"); + let proxy = DBusProxy::new(state.connection()) + .await + .expect("create bus proxy"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let has_owner = proxy + .name_has_owner(sender_name.clone().into()) + .await + .expect("query sender ownership"); + if !has_owner { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("bus should release the closed sender name"); + + let error = ControlServer::new(state.clone()) + .submit_inline_reply(id, generation, "Anyone there?") + .await + .expect_err("closed sender must reject replies"); + + assert!(error + .to_string() + .contains("The application is no longer available")); + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_some()); +} + +#[tokio::test] +async fn inline_reply_signal_reaches_owner_but_not_unrelated_observer() { + let state = daemon_state_for_test(false).await; + let owner = Connection::session().await.expect("owner session bus"); + let observer = Connection::session().await.expect("observer session bus"); + let mut owner_stream = reply_signal_stream(&state, &owner).await; + let mut observer_stream = reply_signal_stream(&state, &observer).await; + let (id, generation) = { + let mut store = state.store.lock().await; + let notification = store + .insert(reply_notification(true, &owner), 0) + .active_notification(); + (notification.id, notification.generation) + }; + + ControlServer::new(state) + .submit_inline_reply(id, generation, "private reply") + .await + .expect("submit owner reply"); + + assert_eq!( + next_reply_signal(&mut owner_stream).await, + (id, "private reply".to_string()) + ); + assert_no_reply_signal(&mut observer_stream).await; +} + +fn reply_notification(is_resident: bool, sender: &Connection) -> Notification { + Notification { + id: 0, + generation: 0, + app_name: "Messages".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated reply test fixture", + "test:verified:messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: vec![unixnotis_core::Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }], + inline_reply: InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }, + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some( + sender + .unique_name() + .expect("sender connection unique name") + .to_string(), + ), + sender_pid: Some(std::process::id()), + sender_start_time: Some( + crate::daemon::notifications::identity::read_process_start_time(std::process::id()) + .expect("test process should expose a start time"), + ), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +async fn reply_signal_stream( + state: &crate::daemon::DaemonState, + receiver: &Connection, +) -> MessageStream { + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("signal sender") + .path(NOTIFICATIONS_OBJECT_PATH) + .expect("notification object path") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("NotificationReplied") + .expect("reply member") + .build(); + MessageStream::for_match_rule(rule, receiver, Some(4)) + .await + .expect("reply signal stream") +} + +async fn assert_no_reply_signal(stream: &mut MessageStream) { + assert!( + tokio::time::timeout(Duration::from_millis(100), stream.try_next()) + .await + .is_err(), + "unrelated observer must not receive reply text" + ); +} + +async fn next_reply_signal(stream: &mut MessageStream) -> (u32, String) { + let signal = tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("reply signal should arrive before timeout") + .expect("reply signal stream should stay open") + .expect("reply signal"); + signal + .body() + .deserialize::<(u32, String)>() + .expect("reply signal body") +} diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs index 5f30612ed..f1dd194dc 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/server.rs +++ b/crates/unixnotis-daemon/src/daemon/control/tests/server.rs @@ -2,23 +2,27 @@ use std::collections::HashMap; use std::time::Duration; use chrono::Utc; -use unixnotis_core::{CloseReason, Config, Notification, NotificationImage, Urgency}; +use unixnotis_core::{CloseReason, Notification, NotificationImage, Urgency}; use zbus::zvariant::OwnedValue; use zbus::Message; use super::super::ControlServer; use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::store::NotificationStore; -use crate::test_support::{daemon_state_for_test, TempRoot}; +use crate::test_support::{daemon_state_for_test, daemon_state_for_test_with_owner}; fn notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, @@ -55,7 +59,7 @@ async fn next_cancel_id( .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => id, + ExpirationCommand::Cancel { id, .. } => id, ExpirationCommand::Schedule { .. } => panic!("clear should cancel expiration"), } } @@ -66,18 +70,24 @@ async fn drain_active_notifications_returns_ids_and_cancels_expirations() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); let server = ControlServer::new(state.clone()); - let ids = { + let keys = { let mut store = state.store.lock().await; - let first = store.insert(notification("first"), 0).notification.id; - let second = store.insert(notification("second"), 0).notification.id; + let first = store + .insert(notification("first"), 0) + .active_notification() + .key(); + let second = store + .insert(notification("second"), 0) + .active_notification() + .key(); vec![second, first] }; let drained = server.drain_active_notifications().await; - assert_eq!(drained, ids); - assert_eq!(next_cancel_id(&mut receiver).await, ids[0]); - assert_eq!(next_cancel_id(&mut receiver).await, ids[1]); + assert_eq!(drained, keys); + assert_eq!(next_cancel_id(&mut receiver).await, keys[0].id); + assert_eq!(next_cancel_id(&mut receiver).await, keys[1].id); assert!(state.store.lock().await.list_active().is_empty()); } @@ -87,7 +97,10 @@ async fn clear_saved_history_removes_archived_notifications() { let server = ControlServer::new(state.clone()); let id = { let mut store = state.store.lock().await; - let id = store.insert(notification("history"), 0).notification.id; + let id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(id, CloseReason::Undefined); id }; @@ -111,65 +124,87 @@ async fn clear_saved_history_removes_archived_notifications() { } #[tokio::test] -async fn apply_dnd_state_rolls_back_when_persistence_fails() { +async fn panel_command_availability_fails_after_ready_owner_disconnects() { let state = daemon_state_for_test(false).await; - let root = TempRoot::new("dnd-persist-failure"); - let state_dir = root.join("state"); - std::fs::create_dir_all(&state_dir).expect("create state dir"); - std::fs::write(state_dir.join("unixnotis"), "not a directory").expect("block dnd parent"); + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + let server = ControlServer::new(state.clone()); + assert!(server.ensure_panel_available().is_ok()); + + state.remove_disconnected_client(":1.20").await; + + let error = server + .ensure_panel_available() + .expect_err("panel command should fail without a ready center owner"); + assert!(error.to_string().contains("unavailable")); +} + +#[tokio::test] +async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); { let mut store = state.store.lock().await; - *store = NotificationStore::new_with_state_dir(Config::default(), state_dir); + store.insert(notification("active"), 0); } - let server = ControlServer::new(state.clone()); + let message = control_header_message("ClearAll"); - let error = server - .apply_dnd_state(true) + server + .clear_all(message.header()) .await - .expect_err("persistence failure should be reported"); + .expect_err("unauthorized clear all should fail"); - assert!(error.to_string().contains("failed to persist")); - assert!(!state.store.lock().await.dnd_enabled()); + assert_eq!(state.store.lock().await.list_active().len(), 1); } #[tokio::test] -async fn apply_toggle_dnd_persists_successful_state_change() { - let state = daemon_state_for_test(false).await; - let root = TempRoot::new("dnd-toggle-success"); - let state_dir = root.join("state"); +async fn authorized_snapshot_is_one_store_consistent_read() { + let state = daemon_state_for_test_with_owner(false, Some(":1.4242")).await; + let server = ControlServer::new(state.clone()); { let mut store = state.store.lock().await; - *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + store.insert(notification("active"), 0); + let history_id = store + .insert(notification("history"), 0) + .active_notification() + .id; + store.close(history_id, CloseReason::Undefined); } - let server = ControlServer::new(state.clone()); - server - .apply_toggle_dnd() + let message = control_header_message("GetSnapshot"); + let snapshot = server + .get_snapshot(message.header()) .await - .expect("toggle should persist"); + .expect("pre-authorized control owner can read a snapshot"); - assert!(state.store.lock().await.dnd_enabled()); - let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) - .expect("read persisted dnd state"); - assert!(persisted.contains("\"dnd_enabled\":true")); + assert_eq!(snapshot.active.len(), 1); + assert_eq!(snapshot.history.len(), 1); + assert_eq!(snapshot.state.history_count, 1); } #[tokio::test] -async fn clear_all_rejects_unauthorized_sender_before_mutating_state() { - let state = daemon_state_for_test(false).await; +async fn authorized_clear_all_removes_active_and_history_together() { + let state = daemon_state_for_test_with_owner(false, Some(":1.4242")).await; let server = ControlServer::new(state.clone()); { let mut store = state.store.lock().await; store.insert(notification("active"), 0); + let history_id = store + .insert(notification("history"), 0) + .active_notification() + .id; + store.close(history_id, CloseReason::Undefined); } - let message = control_header_message("ClearAll"); + let message = control_header_message("ClearAll"); server .clear_all(message.header()) .await - .expect_err("unauthorized clear all should fail"); + .expect("pre-authorized control owner can clear all"); - assert_eq!(state.store.lock().await.list_active().len(), 1); + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); } #[tokio::test] @@ -196,7 +231,10 @@ async fn clear_history_rejects_unauthorized_sender_before_mutating_state() { let server = ControlServer::new(state.clone()); let id = { let mut store = state.store.lock().await; - let id = store.insert(notification("history"), 0).notification.id; + let id = store + .insert(notification("history"), 0) + .active_notification() + .id; store.close(id, CloseReason::Undefined); id }; @@ -217,13 +255,104 @@ async fn clear_history_rejects_unauthorized_sender_before_mutating_state() { } #[tokio::test] -async fn invoke_action_rejects_unauthorized_sender_before_signal_emit() { +async fn generation_dismiss_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let key = state + .store + .lock() + .await + .insert(notification("protected generation"), 0) + .active_notification() + .key(); + let server = ControlServer::new(state.clone()); + let message = control_header_message("DismissGeneration"); + + server + .dismiss_generation(key.id, key.generation, message.header()) + .await + .expect_err("unauthorized generation dismiss should fail"); + + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(key.id) + .expect("unauthorized dismiss must preserve the notification") + .key(), + key + ); +} + +#[tokio::test] +async fn generation_action_rejects_unauthorized_sender_before_validation() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state); + let message = control_header_message("InvokeActionGeneration"); + + server + .invoke_action_generation(7, 11, "default", false, message.header()) + .await + .expect_err("unauthorized generation action should fail"); +} + +#[tokio::test] +async fn popup_render_acknowledgement_rejects_unauthorized_sender() { + let state = daemon_state_for_test(false).await; + let key = state + .store + .lock() + .await + .insert(notification("render acknowledgement"), 0) + .active_notification() + .key(); + let server = ControlServer::new(state.clone()); + let message = control_header_message("MarkPopupVisible"); + + server + .mark_popup_generation_stage( + key, + unixnotis_core::PopupDeliveryStage::Visible, + "MarkPopupVisible", + &message.header(), + ) + .await + .expect_err("unauthorized visibility acknowledgement should fail"); + + assert_ne!( + state + .store + .lock() + .await + .notification_diagnostics(key.id, &unixnotis_core::UiHealth::default()) + .expect("notification diagnostics should remain available") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible + ); +} + +#[tokio::test] +async fn timed_dnd_rejects_unauthorized_sender_before_mutating_state() { + let state = daemon_state_for_test(false).await; + let server = ControlServer::new(state.clone()); + let message = control_header_message("SetDndUntil"); + + server + .set_dnd_until(Utc::now().timestamp() + 600, message.header()) + .await + .expect_err("unauthorized timed DND should fail"); + + assert!(!state.store.lock().await.dnd_enabled()); +} + +#[tokio::test] +async fn inline_reply_rejects_unauthorized_sender_before_live_state_lookup() { let state = daemon_state_for_test(false).await; let server = ControlServer::new(state); - let message = control_header_message("InvokeAction"); + let message = control_header_message("ReplyNotification"); server - .invoke_action(7, "default", message.header()) + .reply_notification(7, 1, "private text", message.header()) .await - .expect_err("unauthorized action should fail"); + .expect_err("unauthorized inline reply should fail"); } diff --git a/crates/unixnotis-daemon/src/daemon/control/watch.rs b/crates/unixnotis-daemon/src/daemon/control/watch.rs deleted file mode 100644 index df8365b97..000000000 --- a/crates/unixnotis-daemon/src/daemon/control/watch.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Name-owner watch for automatic inhibitor cleanup -//! -//! When a controlling client exits, its inhibitors should not remain forever - -use std::sync::Arc; - -use futures_util::StreamExt; -use tracing::warn; -use unixnotis_core::CONTROL_OBJECT_PATH; -use zbus::fdo::DBusProxy; -use zbus::SignalContext; - -use crate::daemon::{ControlServer, DaemonState}; - -pub async fn spawn_inhibitor_owner_watch(state: Arc) -> zbus::Result<()> { - // Subscribe once and process updates in the background - let proxy = DBusProxy::new(state.connection()).await?; - let mut stream = proxy.receive_name_owner_changed().await?; - - tokio::spawn(async move { - while let Some(signal) = stream.next().await { - let args = match signal.args() { - Ok(args) => args, - Err(err) => { - warn!(?err, "failed to decode NameOwnerChanged args"); - continue; - } - }; - - // Ignore owner-acquired events and only process owner-lost events - if args.new_owner().is_some() { - continue; - } - let owner = args.name().to_string(); - - // Remove inhibitors owned by the disconnected bus name - let (changed, active, count) = { - let mut store = state.store.lock().await; - let changed = store.remove_inhibitors_by_owner(&owner); - let active = store.inhibited(); - let count = store.inhibitor_count(); - (changed, active, count) - }; - if !changed { - continue; - } - - // Build signal context each time so failure never blocks store cleanup - let ctx = match SignalContext::new(state.connection(), CONTROL_OBJECT_PATH) { - Ok(ctx) => ctx, - Err(err) => { - warn!(?err, "failed to build signal context for inhibitor cleanup"); - continue; - } - }; - - // Notify listeners so UI can refresh inhibition badges immediately - if let Err(err) = ControlServer::inhibitors_changed(&ctx, active, count).await { - warn!( - ?err, - "failed to emit inhibitors_changed after owner disconnect" - ); - } - if let Err(err) = state.emit_state_changed().await { - warn!(?err, "failed to emit state_changed after owner disconnect"); - } - } - }); - - Ok(()) -} diff --git a/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs new file mode 100644 index 000000000..300080428 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/inhibitors.rs @@ -0,0 +1,41 @@ +//! Inhibitor event fanout after store updates and client disconnects + +use tracing::warn; + +use crate::daemon::{ControlServer, DaemonState}; + +use super::publisher::DaemonEventPublisher; + +impl DaemonState { + pub(in crate::daemon) async fn publish_inhibitors_changed(&self, action: &'static str) { + let _publication = self.events.ordered_publication().await; + let state = { + // Read the count only after ordering so stale captured values cannot fan out later + let store = self.store.lock().await; + store.control_state() + }; + let active = state.inhibited; + let count = state.inhibitor_count; + if let Err(error) = self.events.inhibitors_changed(active, count).await { + warn!( + ?error, + inhibitor_count = count, + action, + "inhibitor mutation committed but inhibitor fanout failed" + ); + } + if let Err(error) = self.events.state_changed(state).await { + warn!( + ?error, + action, "inhibitor mutation committed but state fanout failed" + ); + } + } +} + +impl DaemonEventPublisher { + async fn inhibitors_changed(&self, active: bool, count: u32) -> zbus::Result<()> { + let context = self.control_context()?; + ControlServer::inhibitors_changed(&context, active, count).await + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/mod.rs b/crates/unixnotis-daemon/src/daemon/events/mod.rs new file mode 100644 index 000000000..dec949ca8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/mod.rs @@ -0,0 +1,11 @@ +//! D-Bus event publication after committed daemon mutations + +mod inhibitors; +mod notifications; +mod publisher; +mod state; + +pub(in crate::daemon) use publisher::DaemonEventPublisher; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/events/notifications.rs b/crates/unixnotis-daemon/src/daemon/events/notifications.rs new file mode 100644 index 000000000..e3637bac2 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/notifications.rs @@ -0,0 +1,241 @@ +//! Notification add, update, close, eviction, and bulk-clear fanout + +use futures_util::stream::{self, StreamExt}; +use tracing::warn; +use unixnotis_core::{CloseReason, NotificationKey}; + +use crate::daemon::{ControlServer, DaemonState, NotificationServer, NotificationSignalMode}; + +use super::publisher::{record_first_error, DaemonEventPublisher}; + +const CLEAR_ALL_CONCURRENCY: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ClearAllSignalPlan { + pub(super) publish_close_signals: bool, + pub(super) publish_snapshot_invalidated: bool, + pub(super) publish_state_changed: bool, +} + +pub(super) const fn clear_all_signal_plan(keys: &[NotificationKey]) -> ClearAllSignalPlan { + ClearAllSignalPlan { + publish_close_signals: !keys.is_empty(), + // Empty clears remain a recovery path for stale materialized client views + publish_snapshot_invalidated: true, + publish_state_changed: true, + } +} + +impl DaemonState { + pub(crate) async fn publish_notification_closed( + &self, + key: NotificationKey, + reason: CloseReason, + ) -> zbus::Result<()> { + let mut first_error = self + .events + .notification_closed(key, reason, true) + .await + .err(); + if let Err(error) = self.publish_state_changed().await { + record_first_error(&mut first_error, error); + } + first_error.map_or(Ok(()), Err) + } + + pub(in crate::daemon) async fn publish_notification_dismissed( + &self, + key: NotificationKey, + removed_active: bool, + ) -> zbus::Result<()> { + let mut first_error = self + .events + .notification_closed(key, CloseReason::DismissedByUser, removed_active) + .await + .err(); + if let Err(error) = self.publish_state_changed().await { + record_first_error(&mut first_error, error); + } + first_error.map_or(Ok(()), Err) + } + + pub(in crate::daemon) async fn publish_notification_change( + &self, + mode: NotificationSignalMode, + key: NotificationKey, + replaced: bool, + ) -> zbus::Result<()> { + self.events.notification_change(mode, key, replaced).await + } + + pub(in crate::daemon) async fn publish_evicted_notifications( + &self, + keys: &[NotificationKey], + ) -> zbus::Result<()> { + self.events.evicted_notifications(keys).await + } + + pub(in crate::daemon) async fn publish_notifications_cleared( + &self, + keys: Vec, + ) { + let plan = clear_all_signal_plan(&keys); + if plan.publish_close_signals { + if let Err(error) = self.events.cleared_notifications(keys).await { + warn!( + ?error, + "notification clear committed but close fanout failed" + ); + } + } + if plan.publish_snapshot_invalidated { + if let Err(error) = self.publish_snapshot_invalidated().await { + warn!( + ?error, + "notification clear committed but snapshot invalidation failed" + ); + } + } + if plan.publish_state_changed { + if let Err(error) = self.publish_state_changed().await { + warn!( + ?error, + "notification clear committed but state fanout failed" + ); + } + } + } +} + +impl DaemonEventPublisher { + async fn notification_closed( + &self, + key: NotificationKey, + reason: CloseReason, + publish_freedesktop: bool, + ) -> zbus::Result<()> { + let mut first_error = None; + if publish_freedesktop { + match self.notification_context() { + Ok(context) => { + if let Err(error) = + NotificationServer::notification_closed(&context, key.id, reason as u32) + .await + { + record_first_error(&mut first_error, error); + } + } + Err(error) => record_first_error(&mut first_error, error), + } + } + match self.control_context() { + Ok(context) => { + if let Err(error) = + ControlServer::notification_closed(&context, key.id, key.generation, reason) + .await + { + record_first_error(&mut first_error, error); + } + } + Err(error) => record_first_error(&mut first_error, error), + } + first_error.map_or(Ok(()), Err) + } + + async fn notification_change( + &self, + mode: NotificationSignalMode, + key: NotificationKey, + replaced: bool, + ) -> zbus::Result<()> { + match mode { + NotificationSignalMode::Direct => { + let context = self.control_context()?; + if replaced { + ControlServer::notification_updated(&context, key.id, key.generation).await + } else { + ControlServer::notification_added(&context, key.id, key.generation).await + } + } + NotificationSignalMode::SnapshotOnly => self.snapshot_invalidated().await, + } + } + + async fn evicted_notifications(&self, keys: &[NotificationKey]) -> zbus::Result<()> { + if keys.is_empty() { + return Ok(()); + } + let notification_context = self.notification_context()?; + let control_context = self.control_context()?; + let mut first_error = None; + for &key in keys { + if let Err(error) = NotificationServer::notification_closed( + ¬ification_context, + key.id, + CloseReason::Undefined as u32, + ) + .await + { + record_first_error(&mut first_error, error); + } + if let Err(error) = ControlServer::notification_closed( + &control_context, + key.id, + key.generation, + CloseReason::Undefined, + ) + .await + { + record_first_error(&mut first_error, error); + } + } + first_error.map_or(Ok(()), Err) + } + + async fn cleared_notifications(&self, keys: Vec) -> zbus::Result<()> { + let notification_context = self.notification_context()?; + let control_context = self.control_context()?; + let first_error = std::sync::Mutex::new(None); + + // Contexts are reused and concurrency remains bounded for large configured stores + stream::iter(keys) + .for_each_concurrent(CLEAR_ALL_CONCURRENCY, |key| { + let notification_context = notification_context.clone(); + let control_context = control_context.clone(); + let first_error = &first_error; + async move { + if let Err(error) = NotificationServer::notification_closed( + ¬ification_context, + key.id, + CloseReason::DismissedByUser as u32, + ) + .await + { + let mut first = first_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + record_first_error(&mut first, error); + } + if let Err(error) = ControlServer::notification_closed( + &control_context, + key.id, + key.generation, + CloseReason::DismissedByUser, + ) + .await + { + let mut first = first_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + record_first_error(&mut first, error); + } + } + }) + .await; + + first_error + .into_inner() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .map_or(Ok(()), Err) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/publisher.rs b/crates/unixnotis-daemon/src/daemon/events/publisher.rs new file mode 100644 index 000000000..73d66a9ca --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/publisher.rs @@ -0,0 +1,48 @@ +//! Shared connection state and error policy for daemon event publication + +use std::sync::Mutex as StdMutex; + +use tokio::sync::{Mutex, MutexGuard}; +use unixnotis_core::{ControlState, PopupGateState, CONTROL_OBJECT_PATH}; +use zbus::{Connection, SignalContext}; + +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; + +pub(in crate::daemon) struct DaemonEventPublisher { + connection: Connection, + // One async guard keeps state-bearing signals in capture order across await points + publication_order: Mutex<()>, + // State snapshots are cached here because publication owns duplicate suppression + pub(super) last_state: StdMutex>, + pub(super) last_popup_gate: StdMutex>, +} + +impl DaemonEventPublisher { + pub(in crate::daemon) const fn new(connection: Connection) -> Self { + Self { + connection, + publication_order: Mutex::const_new(()), + last_state: StdMutex::new(None), + last_popup_gate: StdMutex::new(None), + } + } + + pub(super) async fn ordered_publication(&self) -> MutexGuard<'_, ()> { + // The guard spans store capture, signal fanout, and cache acknowledgement + self.publication_order.lock().await + } + + pub(super) fn control_context(&self) -> zbus::Result> { + SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) + } + + pub(super) fn notification_context(&self) -> zbus::Result> { + SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH) + } +} + +pub(super) fn record_first_error(first_error: &mut Option, error: zbus::Error) { + if first_error.is_none() { + *first_error = Some(error); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/events/state.rs b/crates/unixnotis-daemon/src/daemon/events/state.rs new file mode 100644 index 000000000..f451b0435 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/state.rs @@ -0,0 +1,89 @@ +//! Control-state snapshots, popup gates, and cache invalidation signals + +use unixnotis_core::{ControlState, PopupGateState}; + +use crate::daemon::{ControlServer, DaemonState}; + +use super::publisher::{record_first_error, DaemonEventPublisher}; + +impl DaemonState { + pub(in crate::daemon) async fn publish_state_changed(&self) -> zbus::Result<()> { + let _publication = self.events.ordered_publication().await; + let state = { + // Capture after ordering so delayed callers always observe the newest revision + let store = self.store.lock().await; + store.control_state() + }; + self.events.state_changed(state).await + } + + pub(in crate::daemon) async fn publish_snapshot_invalidated(&self) -> zbus::Result<()> { + self.events.snapshot_invalidated().await + } +} + +impl DaemonEventPublisher { + pub(super) async fn state_changed(&self, state: ControlState) -> zbus::Result<()> { + let popup_gate = popup_gate_from_state(&state); + let publish_state = should_publish_cached(&self.last_state, &state); + let publish_popup_gate = should_publish_cached(&self.last_popup_gate, &popup_gate); + if !should_publish_any_state_signal(publish_state, publish_popup_gate) { + return Ok(()); + } + + // One context serves both related signals from the same captured state + let context = self.control_context()?; + let mut first_error = None; + if publish_state { + match ControlServer::state_changed(&context, state.clone()).await { + Ok(()) => update_cached(&self.last_state, state), + Err(error) => record_first_error(&mut first_error, error), + } + } + if publish_popup_gate { + match ControlServer::popup_gate_changed(&context, popup_gate.clone()).await { + Ok(()) => update_cached(&self.last_popup_gate, popup_gate), + Err(error) => record_first_error(&mut first_error, error), + } + } + first_error.map_or(Ok(()), Err) + } + + pub(super) async fn snapshot_invalidated(&self) -> zbus::Result<()> { + let context = self.control_context()?; + ControlServer::snapshot_invalidated(&context).await + } +} + +pub(super) fn should_publish_cached( + cache: &std::sync::Mutex>, + next: &T, +) -> bool { + // Poison recovery preserves availability after a prior panicking task + let cached = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cached.as_ref() != Some(next) +} + +pub(super) fn update_cached(cache: &std::sync::Mutex>, published: T) { + // A cache entry means the corresponding D-Bus send completed successfully + let mut cached = cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cached = Some(published); +} + +pub(super) const fn popup_gate_from_state(state: &ControlState) -> PopupGateState { + PopupGateState { + dnd_enabled: state.dnd_enabled, + inhibited: state.inhibited, + } +} + +pub(super) const fn should_publish_any_state_signal( + publish_state: bool, + publish_popup_gate: bool, +) -> bool { + publish_state || publish_popup_gate +} diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs new file mode 100644 index 000000000..5535578ea --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/tests/cache.rs @@ -0,0 +1,121 @@ +use std::sync::Mutex; + +use unixnotis_core::{ControlState, PopupGateState}; + +use super::super::state::{should_publish_cached, update_cached}; + +#[test] +fn cached_state_emits_first_value_then_suppresses_duplicates() { + let cache = Mutex::new(None); + let state = ControlState { + dnd_enabled: false, + dnd_expires_at: 0, + history_count: 1, + inhibited: false, + inhibitor_count: 0, + }; + + // First value must be emitted because clients have no previous state + assert!(should_publish_cached(&cache, &state)); + update_cached(&cache, state.clone()); + // Identical values should not wake D-Bus subscribers again + assert!(!should_publish_cached(&cache, &state)); +} + +#[test] +fn cached_state_emits_when_any_gate_field_changes() { + let cache = Mutex::new(None); + let open = PopupGateState { + dnd_enabled: false, + inhibited: false, + }; + let dnd = PopupGateState { + dnd_enabled: true, + inhibited: false, + }; + + assert!(should_publish_cached(&cache, &open)); + update_cached(&cache, open); + // A changed popup gate affects visibility policy, so it must emit + assert!(should_publish_cached(&cache, &dnd)); + update_cached(&cache, dnd.clone()); + assert!(!should_publish_cached(&cache, &dnd)); +} + +#[test] +fn cached_state_emits_after_counter_change() { + let cache = Mutex::new(None); + let first = ControlState { + dnd_enabled: false, + dnd_expires_at: 0, + history_count: 0, + inhibited: false, + inhibitor_count: 0, + }; + let changed = ControlState { + history_count: 1, + ..first + }; + + assert!(should_publish_cached(&cache, &first)); + update_cached(&cache, first); + assert!(should_publish_cached(&cache, &changed)); + update_cached(&cache, changed.clone()); + assert!(!should_publish_cached(&cache, &changed)); +} + +#[test] +fn cached_state_emits_when_only_the_dnd_deadline_changes() { + let cache = Mutex::new(None); + let indefinite = ControlState { + dnd_enabled: true, + dnd_expires_at: 0, + history_count: 0, + inhibited: false, + inhibitor_count: 0, + }; + assert!(should_publish_cached(&cache, &indefinite)); + update_cached(&cache, indefinite.clone()); + + let timed = ControlState { + dnd_expires_at: 500, + ..indefinite + }; + + assert!(should_publish_cached(&cache, &timed)); + update_cached(&cache, timed.clone()); + assert!(!should_publish_cached(&cache, &timed)); +} + +#[test] +fn cached_state_recovers_from_poisoned_mutex() { + let cache = Mutex::new(None); + let _ = std::panic::catch_unwind(|| { + let _guard = cache.lock().expect("lock before poison"); + panic!("poison cache"); + }); + + let state = PopupGateState { + dnd_enabled: false, + inhibited: true, + }; + + assert!(should_publish_cached(&cache, &state)); + update_cached(&cache, state.clone()); + assert!(!should_publish_cached(&cache, &state)); +} + +#[test] +fn cached_state_is_not_advanced_until_success_is_recorded() { + let cache = Mutex::new(None); + let state = PopupGateState { + dnd_enabled: true, + inhibited: false, + }; + + assert!(should_publish_cached(&cache, &state)); + assert!(should_publish_cached(&cache, &state)); + + update_cached(&cache, state.clone()); + assert!(!should_publish_cached(&cache, &state)); +} diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs new file mode 100644 index 000000000..ef17870e7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/tests/mod.rs @@ -0,0 +1,3 @@ +mod cache; +mod notifications; +mod state; diff --git a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs similarity index 56% rename from crates/unixnotis-daemon/src/daemon/control/tests/clear.rs rename to crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs index 838119d74..9935462fd 100644 --- a/crates/unixnotis-daemon/src/daemon/control/tests/clear.rs +++ b/crates/unixnotis-daemon/src/daemon/events/tests/notifications.rs @@ -1,53 +1,60 @@ -use super::super::clear::{clear_all_signal_plan, emit_clear_all_signals}; +use super::super::notifications::clear_all_signal_plan; use crate::test_support::daemon_state_for_test; +use unixnotis_core::NotificationKey; + +const fn key(id: u32, generation: u64) -> NotificationKey { + NotificationKey { id, generation } +} #[test] fn clear_all_with_no_active_rows_still_invalidates_snapshot() { let plan = clear_all_signal_plan(&[]); // No live rows means there is nothing to close - assert!(!plan.emit_close_signals); + assert!(!plan.publish_close_signals); // Empty clear is still the escape hatch for stale client rows - assert!(plan.emit_snapshot_invalidated); + assert!(plan.publish_snapshot_invalidated); // State refresh still needs a chance to run - assert!(plan.emit_state_changed); + assert!(plan.publish_state_changed); } #[test] fn clear_all_with_active_rows_keeps_close_fanout_and_refresh() { - let plan = clear_all_signal_plan(&[11, 12]); + let plan = clear_all_signal_plan(&[key(11, 1), key(12, 2)]); // Active rows still need the normal close signals - assert!(plan.emit_close_signals); + assert!(plan.publish_close_signals); // Clients still need a full refresh after the clear - assert!(plan.emit_snapshot_invalidated); - assert!(plan.emit_state_changed); + assert!(plan.publish_snapshot_invalidated); + assert!(plan.publish_state_changed); } #[test] fn clear_all_signal_plan_treats_any_non_empty_id_set_as_close_fanout() { - let plan = clear_all_signal_plan(&[99]); + let plan = clear_all_signal_plan(&[key(99, 3)]); // A single active row still needs both freedesktop and control close fanout - assert!(plan.emit_close_signals); - assert!(plan.emit_snapshot_invalidated); - assert!(plan.emit_state_changed); + assert!(plan.publish_close_signals); + assert!(plan.publish_snapshot_invalidated); + assert!(plan.publish_state_changed); } #[tokio::test] async fn clear_all_without_ids_still_refreshes_cached_control_state() { let state = daemon_state_for_test(false).await; - emit_clear_all_signals(&state, Vec::new()).await; + state.publish_notifications_cleared(Vec::new()).await; // A no-row clear still refreshes state caches so clients can recover stale views assert!(state - .last_emitted_state + .events + .last_state .lock() .expect("state cache lock") .is_some()); assert!(state - .last_emitted_popup_gate + .events + .last_popup_gate .lock() .expect("popup gate cache lock") .is_some()); diff --git a/crates/unixnotis-daemon/src/daemon/events/tests/state.rs b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs new file mode 100644 index 000000000..2dce28a5a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/events/tests/state.rs @@ -0,0 +1,370 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures_util::TryStreamExt; +use tokio::sync::Barrier; +use unixnotis_core::{ + CloseReason, Config, ControlState, NotificationKey, PopupGateState, CONTROL_OBJECT_PATH, +}; +use zbus::message::Type; +use zbus::{Connection, MatchRule, Message, MessageStream}; + +use crate::daemon::NOTIFICATIONS_OBJECT_PATH; +use crate::store::NotificationStore; +use crate::test_support::daemon_state_for_test; + +use super::super::publisher::record_first_error; +use super::super::state::{popup_gate_from_state, should_publish_any_state_signal}; + +async fn signal_stream( + state: &crate::daemon::DaemonState, + path: &str, + interface: &str, + member: &str, +) -> MessageStream { + let receiver = Connection::session().await.expect("receiver session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("sender") + .path(path) + .expect("path") + .interface(interface) + .expect("interface") + .member(member) + .expect("member") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("signal stream") +} + +async fn control_signal_stream(state: &crate::daemon::DaemonState, member: &str) -> MessageStream { + signal_stream(state, CONTROL_OBJECT_PATH, "com.unixnotis.Control", member).await +} + +async fn notifications_signal_stream( + state: &crate::daemon::DaemonState, + member: &str, +) -> MessageStream { + signal_stream( + state, + NOTIFICATIONS_OBJECT_PATH, + "org.freedesktop.Notifications", + member, + ) + .await +} + +async fn next_signal(stream: &mut MessageStream) -> Message { + tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("signal should arrive before timeout") + .expect("signal stream should stay open") + .expect("signal message") +} + +async fn assert_no_signal(stream: &mut MessageStream) { + assert!( + tokio::time::timeout(Duration::from_millis(100), stream.try_next()) + .await + .is_err(), + "signal should not be emitted" + ); +} + +#[test] +fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { + let state = ControlState { + dnd_enabled: true, + dnd_expires_at: 0, + history_count: 99, + inhibited: false, + inhibitor_count: 12, + }; + + let gate = popup_gate_from_state(&state); + + assert!(gate.dnd_enabled); + assert!(!gate.inhibited); +} + +#[test] +fn notification_store_control_state_reads_dnd_history_and_inhibitors() { + let mut store = NotificationStore::new(Config::default()); + + store.set_dnd_until(500); + store.add_inhibitor(":1.test".to_string(), "focus".to_string(), 0); + + let state = store.control_state(); + + assert!(state.dnd_enabled); + assert_eq!(state.dnd_expires_at, 500); + assert!(state.inhibited); + assert_eq!(state.inhibitor_count, 1); + assert_eq!(state.history_count, 0); +} + +#[test] +fn should_publish_any_state_signal_is_false_when_both_cached_values_match() { + assert!(!should_publish_any_state_signal(false, false)); +} + +#[test] +fn should_publish_any_state_signal_is_true_when_control_state_changed() { + assert!(should_publish_any_state_signal(true, false)); +} + +#[test] +fn should_publish_any_state_signal_is_true_when_popup_gate_changed() { + assert!(should_publish_any_state_signal(false, true)); +} + +#[test] +fn should_publish_any_state_signal_is_true_when_both_values_changed() { + assert!(should_publish_any_state_signal(true, true)); +} + +#[test] +fn record_first_error_stores_first_error() { + let mut first_error = None; + + record_first_error(&mut first_error, zbus::Error::Failure("first".to_string())); + + assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); +} + +#[test] +fn record_first_error_keeps_existing_error() { + let mut first_error = Some(zbus::Error::Failure("first".to_string())); + + record_first_error(&mut first_error, zbus::Error::Failure("second".to_string())); + + assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); +} + +#[tokio::test] +async fn publish_notification_closed_sends_freedesktop_and_control_close_signals() { + let state = daemon_state_for_test(false).await; + let mut freedesktop_stream = notifications_signal_stream(&state, "NotificationClosed").await; + let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; + + state + .publish_notification_closed( + NotificationKey { + id: 7, + generation: 70, + }, + CloseReason::ClosedByCall, + ) + .await + .expect("close fanout should emit"); + + let freedesktop_signal = next_signal(&mut freedesktop_stream).await; + let (freedesktop_id, freedesktop_reason) = freedesktop_signal + .body() + .deserialize::<(u32, u32)>() + .expect("freedesktop close body"); + assert_eq!(freedesktop_id, 7); + assert_eq!(freedesktop_reason, CloseReason::ClosedByCall as u32); + + let control_signal = next_signal(&mut control_stream).await; + let (control_id, control_generation, control_reason) = control_signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("control close body"); + assert_eq!(control_id, 7); + assert_eq!(control_generation, 70); + assert_eq!(control_reason as u32, CloseReason::ClosedByCall as u32); +} + +#[tokio::test] +async fn publish_notification_dismissed_sends_control_close_signal() { + let state = daemon_state_for_test(false).await; + let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; + + state + .publish_notification_dismissed( + NotificationKey { + id: 8, + generation: 80, + }, + false, + ) + .await + .expect("dismiss fanout should emit"); + + let control_signal = next_signal(&mut control_stream).await; + let (control_id, control_generation, control_reason) = control_signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("control close body"); + assert_eq!(control_id, 8); + assert_eq!(control_generation, 80); + assert_eq!(control_reason as u32, CloseReason::DismissedByUser as u32); +} + +#[tokio::test] +async fn publish_state_changed_sends_initial_state_and_suppresses_duplicate() { + let state = daemon_state_for_test(false).await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; + + state + .publish_state_changed() + .await + .expect("state changed should emit"); + + let state_signal = next_signal(&mut state_stream).await; + let emitted_state = state_signal + .body() + .deserialize::() + .expect("state body"); + assert!(!emitted_state.dnd_enabled); + assert!(!emitted_state.inhibited); + assert_eq!(emitted_state.history_count, 0); + assert_eq!(emitted_state.inhibitor_count, 0); + + let gate_signal = next_signal(&mut gate_stream).await; + let emitted_gate = gate_signal + .body() + .deserialize::() + .expect("popup gate body"); + assert!(!emitted_gate.dnd_enabled); + assert!(!emitted_gate.inhibited); + + state + .publish_state_changed() + .await + .expect("duplicate state should not fail"); + assert_no_signal(&mut state_stream).await; + assert_no_signal(&mut gate_stream).await; +} + +#[tokio::test] +async fn delayed_state_publisher_captures_latest_revision_before_suppressing_duplicate() { + let state = daemon_state_for_test(false).await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; + let publication = state.events.ordered_publication().await; + let publisher_entered = Arc::new(Barrier::new(2)); + + let delayed_state = state.clone(); + let delayed_entered = publisher_entered.clone(); + let delayed = tokio::spawn(async move { + // The barrier makes the first caller queue behind the held publication guard + delayed_entered.wait().await; + delayed_state.publish_state_changed().await + }); + + publisher_entered.wait().await; + tokio::task::yield_now().await; + state.store.lock().await.set_dnd_until(500); + + let waiting_state = state.clone(); + let waiting = tokio::spawn(async move { waiting_state.publish_state_changed().await }); + tokio::task::yield_now().await; + drop(publication); + + delayed + .await + .expect("delayed publisher task") + .expect("delayed publication"); + waiting + .await + .expect("waiting publisher task") + .expect("waiting publication"); + + let emitted_state = next_signal(&mut state_stream) + .await + .body() + .deserialize::() + .expect("state body"); + assert!(emitted_state.dnd_enabled); + assert_eq!(emitted_state.dnd_expires_at, 500); + + let emitted_gate = next_signal(&mut gate_stream) + .await + .body() + .deserialize::() + .expect("popup gate body"); + assert!(emitted_gate.dnd_enabled); + + assert_no_signal(&mut state_stream).await; + assert_no_signal(&mut gate_stream).await; +} + +#[tokio::test] +async fn delayed_inhibitor_publishers_never_emit_an_older_count_after_a_newer_mutation() { + let state = daemon_state_for_test(false).await; + let mut inhibitor_stream = control_signal_stream(&state, "InhibitorsChanged").await; + let mut state_stream = control_signal_stream(&state, "StateChanged").await; + let publication = state.events.ordered_publication().await; + + state + .store + .lock() + .await + .add_inhibitor(":1.first".to_string(), "first mutation".to_string(), 0); + let first_state = state.clone(); + let first = tokio::spawn(async move { + first_state + .publish_inhibitors_changed("first-test-mutation") + .await; + }); + tokio::task::yield_now().await; + + state.store.lock().await.add_inhibitor( + ":1.second".to_string(), + "second mutation".to_string(), + 0, + ); + let second_state = state.clone(); + let second = tokio::spawn(async move { + second_state + .publish_inhibitors_changed("second-test-mutation") + .await; + }); + drop(publication); + + first.await.expect("first inhibitor publisher"); + second.await.expect("second inhibitor publisher"); + + for _ in 0..2 { + let (active, count) = next_signal(&mut inhibitor_stream) + .await + .body() + .deserialize::<(bool, u32)>() + .expect("inhibitor body"); + assert!(active); + assert_eq!(count, 2); + } + + let emitted_state = next_signal(&mut state_stream) + .await + .body() + .deserialize::() + .expect("state body"); + assert!(emitted_state.inhibited); + assert_eq!(emitted_state.inhibitor_count, 2); + assert_no_signal(&mut state_stream).await; +} + +#[tokio::test] +async fn publish_snapshot_invalidated_sends_snapshot_signal() { + let state = daemon_state_for_test(false).await; + let mut stream = control_signal_stream(&state, "SnapshotInvalidated").await; + + state + .publish_snapshot_invalidated() + .await + .expect("snapshot invalidation should emit"); + + let signal = next_signal(&mut stream).await; + signal.body().deserialize::<()>().expect("empty body"); +} diff --git a/crates/unixnotis-daemon/src/daemon/mod.rs b/crates/unixnotis-daemon/src/daemon/mod.rs index 4a66ec8c0..b309fd2d3 100644 --- a/crates/unixnotis-daemon/src/daemon/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/mod.rs @@ -1,19 +1,24 @@ //! D-Bus server implementation and daemon state coordination mod auth; -mod bus_names; +mod bus; mod control; mod errors; +mod events; mod notifications; -mod signal_burst; mod state; -pub use bus_names::{log_name_reply, request_control_name, request_well_known_name}; -pub use control::spawn_inhibitor_owner_watch; +pub use bus::{ + log_name_reply, monitor_required_bus_names, request_control_name, request_well_known_name, + spawn_client_owner_watch, verify_name_owner, wait_for_owner_state, +}; pub use control::ControlServer; pub use errors::to_fdo_error; +pub use notifications::DesktopIndexSnapshot; +pub use notifications::NotificationIngress; pub use notifications::NotificationServer; -pub(in crate::daemon) use signal_burst::NotificationSignalMode; +pub(in crate::daemon) use notifications::NotificationSignalMode; +pub use notifications::{spawn_desktop_index_refresh, DesktopIdentityIndex}; pub use state::DaemonState; pub const NOTIFICATIONS_OBJECT_PATH: &str = "/org/freedesktop/Notifications"; diff --git a/crates/unixnotis-daemon/src/daemon/signal_burst.rs b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs similarity index 68% rename from crates/unixnotis-daemon/src/daemon/signal_burst.rs rename to crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs index 8af47f8c5..45882dfd6 100644 --- a/crates/unixnotis-daemon/src/daemon/signal_burst.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/flow_control.rs @@ -8,18 +8,15 @@ use std::time::{Duration, Instant}; pub enum NotificationSignalMode { // Normal path: send the precise notification signal Direct, - // Burst path: send one invalidation so clients can rebuild from state + // Burst path: send invalidations so clients can rebuild from committed state SnapshotOnly, - // Extra burst events are skipped until the window resets - Suppress, } #[derive(Clone, Debug)] -pub(super) struct NotificationBurstState { +pub(in crate::daemon) struct NotificationBurstState { window_started: Instant, last_seen: Instant, count: u16, - snapshot_emitted: bool, } const NOTIFICATION_SIGNAL_WINDOW: Duration = Duration::from_secs(1); @@ -28,11 +25,18 @@ const NOTIFICATION_DIRECT_SIGNAL_LIMIT: u16 = 8; // Cap tracked senders so hostile unique names cannot grow memory without bound const NOTIFICATION_SIGNAL_TRACK_LIMIT: usize = 128; -pub(super) fn notification_signal_mode_for_sender( +pub(in crate::daemon) fn notification_signal_mode_for_sender( cache: &StdMutex>, sender: &str, ) -> NotificationSignalMode { - let now = Instant::now(); + notification_signal_mode_for_sender_at(cache, sender, Instant::now()) +} + +fn notification_signal_mode_for_sender_at( + cache: &StdMutex>, + sender: &str, + now: Instant, +) -> NotificationSignalMode { let mut cache = match cache.lock() { Ok(cache) => cache, Err(poisoned) => poisoned.into_inner(), @@ -51,14 +55,12 @@ pub(super) fn notification_signal_mode_for_sender( window_started: now, last_seen: now, count: 0, - snapshot_emitted: false, }); // A fresh window resets the direct-signal allowance for that sender - if now.duration_since(state.window_started) > NOTIFICATION_SIGNAL_WINDOW { + if now.duration_since(state.window_started) >= NOTIFICATION_SIGNAL_WINDOW { state.window_started = now; state.count = 0; - state.snapshot_emitted = false; } state.last_seen = now; state.count = state.count.saturating_add(1); @@ -66,15 +68,10 @@ pub(super) fn notification_signal_mode_for_sender( if state.count <= NOTIFICATION_DIRECT_SIGNAL_LIMIT { return NotificationSignalMode::Direct; } - if !state.snapshot_emitted { - // One snapshot invalidation tells trusted UIs to resync once without replaying the whole burst - state.snapshot_emitted = true; - return NotificationSignalMode::SnapshotOnly; - } - // Extra events inside the same burst window add no value once the snapshot refresh is queued - NotificationSignalMode::Suppress + // Every trailing commit invalidates the prior snapshot because its fetch may already be running + NotificationSignalMode::SnapshotOnly } #[cfg(test)] -#[path = "tests/signal_burst.rs"] +#[path = "tests/flow_control.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs new file mode 100644 index 000000000..bf2d38d22 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/delivery.rs @@ -0,0 +1,94 @@ +//! Reconnect-safe callback destination resolution + +use zbus::fdo::DBusProxy; +use zbus::names::{BusName, UniqueName}; +use zbus::Connection; + +use super::{read_process_start_time, SenderMetadataCache, SENDER_CREDENTIAL_TIMEOUT}; + +pub(in crate::daemon) async fn resolve_callback_destination( + cache: &SenderMetadataCache, + connection: &Connection, + retained_bus_name: Option<&str>, + pid: Option, + start_time: Option, +) -> Option> { + let proxy = DBusProxy::new(connection).await.ok()?; + + if let (Some(pid), Some(start_time)) = (pid, start_time) { + // Stable lifetime evidence permits reconnect-safe address rebinding + if let Some(retained) = retained_bus_name { + if let Some(destination) = verified_destination(&proxy, retained, pid, start_time).await + { + return Some(destination); + } + } + + // A unique bus name is an ephemeral delivery address, not ownership + // Every cached address is verified before callback delivery + for current in cache.sender_candidates_for_process(pid, start_time, retained_bus_name) { + if let Some(destination) = verified_destination(&proxy, ¤t, pid, start_time).await + { + return Some(destination); + } + } + return None; + } + + // Weak evidence may retain one exact live address but can never authorize rebinding + let retained = retained_bus_name?; + let bus_name = BusName::try_from(retained).ok()?.to_owned(); + tokio::time::timeout( + SENDER_CREDENTIAL_TIMEOUT, + proxy.name_has_owner(bus_name.clone()), + ) + .await + .ok()? + .ok()? + .then_some(bus_name) +} + +async fn verified_destination( + proxy: &DBusProxy<'_>, + candidate: &str, + expected_pid: u32, + expected_start_time: u64, +) -> Option> { + let unique_name = UniqueName::try_from(candidate).ok()?.to_owned(); + let start_before = read_process_start_time(expected_pid); + let bus_pid = tokio::time::timeout( + SENDER_CREDENTIAL_TIMEOUT, + proxy.get_connection_unix_process_id(unique_name.clone().into()), + ) + .await + .ok()? + .ok()?; + let start_after = read_process_start_time(expected_pid); + if !credentials_match_lifetime( + bus_pid, + expected_pid, + start_before, + start_after, + expected_start_time, + ) { + return None; + } + Some(unique_name.into()) +} + +fn credentials_match_lifetime( + bus_pid: u32, + expected_pid: u32, + start_before: Option, + start_after: Option, + expected_start_time: u64, +) -> bool { + // Both samples must identify the retained lifetime so PID reuse cannot race delivery + bus_pid == expected_pid + && start_before == Some(expected_start_time) + && start_after == Some(expected_start_time) +} + +#[cfg(test)] +#[path = "tests/delivery.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs new file mode 100644 index 000000000..001e1c772 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/families.rs @@ -0,0 +1,183 @@ +//! Application-family construction and canonical identity selection + +use super::super::super::executable::FileIdentity; +use super::super::model::{ + DesktopApplicationFamily, DesktopIdentityIndex, DesktopRecord, LaunchArgument, +}; +use super::super::names::normalize_desktop_id; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn family_for_record( + &self, + record: &DesktopRecord, + ) -> Option<&DesktopApplicationFamily> { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + let family_index = *self.family_by_record.get(record_index)?.as_ref()?; + self.families.get(family_index) + } + + pub(in crate::daemon::notifications::identity) fn family_index_for_record( + &self, + record: &DesktopRecord, + ) -> Option { + let record_index = self + .records + .iter() + .position(|candidate| std::ptr::eq(candidate, record))?; + self.family_by_record.get(record_index).copied().flatten() + } + + pub(in crate::daemon::notifications::identity) fn canonical_id_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record str { + self.family_for_record(record) + .map_or(record.id.as_str(), |family| family.canonical_id.as_str()) + } + + pub(in crate::daemon::notifications::identity) fn canonical_record_for_record<'record>( + &'record self, + record: &'record DesktopRecord, + ) -> &'record DesktopRecord { + let Some(family) = self.family_for_record(record) else { + return record; + }; + family + .records + .iter() + .filter_map(|index| self.records.get(*index)) + .find(|candidate| { + normalize_desktop_id(&candidate.id) == normalize_desktop_id(&family.canonical_id) + }) + .unwrap_or(record) + } + + pub(in crate::daemon::notifications::identity) fn records_share_family( + &self, + left: &DesktopRecord, + right: &DesktopRecord, + ) -> bool { + match ( + self.family_index_for_record(left), + self.family_index_for_record(right), + ) { + (Some(left), Some(right)) => left == right, + _ => std::ptr::eq(left, right), + } + } + + pub(in crate::daemon::notifications::identity) fn records_form_one_application_family( + &self, + identity: FileIdentity, + system_origin: bool, + ) -> bool { + let families = self + .records_for_executable(identity) + .into_iter() + .filter(|record| record.system_origin == system_origin) + .filter_map(|record| self.family_index_for_record(record)) + .collect::>(); + families.len() == 1 + } + + pub(in crate::daemon::notifications::identity) fn rebuild_application_families(&mut self) { + self.families.clear(); + self.family_by_record.clear(); + for record_index in 0..self.records.len() { + self.index_application_family(record_index); + } + } + + pub(super) fn index_application_family(&mut self, record_index: usize) { + let Some(record) = self.records.get(record_index) else { + self.family_by_record.push(None); + return; + }; + let Some(executable_identity) = record.runtime_executable_identity else { + self.family_by_record.push(None); + return; + }; + let protected_payloads = protected_payload_signature(record); + let family_index = self.families.iter().position(|family| { + family.executable_identity.same_file(executable_identity) + && family.system_origin == record.system_origin + && family.system_association == record.system_association + && family + .install_provenance + .same_application_source(&record.runtime_executable_provenance) + && family.protected_payloads == protected_payloads + && family_names_are_compatible(family, record) + }); + + if let Some(family_index) = family_index { + let family = &mut self.families[family_index]; + family.records.push(record_index); + family.names.extend(record.names.iter().cloned()); + if canonical_id_precedes(&record.id, &family.canonical_id) { + family.canonical_id.clone_from(&record.id); + } + self.family_by_record.push(Some(family_index)); + return; + } + + let family_index = self.families.len(); + self.families.push(DesktopApplicationFamily { + canonical_id: record.id.clone(), + executable_identity, + records: vec![record_index], + names: record.names.clone(), + system_origin: record.system_origin, + system_association: record.system_association, + install_provenance: record.runtime_executable_provenance.clone(), + protected_payloads, + }); + self.family_by_record.push(Some(family_index)); + } +} + +fn protected_payload_signature(record: &DesktopRecord) -> Vec<(usize, u64, u64)> { + record + .launch_spec + .iter() + .flat_map(|spec| spec.arguments.iter().enumerate()) + .filter_map(|(position, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + let (_path, identity) = literal.file.as_ref()?; + (!literal.value.starts_with(b"-")).then_some(( + position, + identity.device, + identity.inode, + )) + }) + .collect() +} + +fn family_names_are_compatible(family: &DesktopApplicationFamily, record: &DesktopRecord) -> bool { + if family.names.iter().any(|name| record.names.contains(name)) { + return true; + } + let family_id = normalize_desktop_id(&family.canonical_id); + let record_id = normalize_desktop_id(&record.id); + id_is_alias_of(&family_id, &record_id) +} + +fn id_is_alias_of(left: &str, right: &str) -> bool { + left == right + || left + .strip_prefix(right) + .is_some_and(|suffix| suffix.starts_with('.')) + || right + .strip_prefix(left) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn canonical_id_precedes(candidate: &str, current: &str) -> bool { + let candidate = normalize_desktop_id(candidate); + let current = normalize_desktop_id(current); + (candidate.len(), candidate.as_str()) < (current.len(), current.as_str()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs new file mode 100644 index 000000000..e1cd5e512 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/lookup.rs @@ -0,0 +1,100 @@ +//! Read-only lookups over the constructed desktop index + +use std::path::PathBuf; + +use super::super::super::executable::FileIdentity; +use super::super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::super::names::{normalize_brand_name, normalize_desktop_id, normalize_name}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn records_for_id( + &self, + id: &str, + ) -> Vec<&DesktopRecord> { + // Duplicate IDs remain separate so origin can be checked by the resolver + self.by_id + .get(&normalize_desktop_id(id)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn records_for_executable( + &self, + identity: FileIdentity, + ) -> Vec<&DesktopRecord> { + // Device and inode avoid trusting a replaceable executable path + self.by_identity + .get(&(identity.device, identity.inode)) + .into_iter() + .flatten() + .filter_map(|index| self.records.get(*index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn records_for_claim( + &self, + claim: &str, + ) -> Vec<&DesktopRecord> { + let normalized = normalize_name(claim); + let mut indices = self + .by_name + .get(&normalized) + .into_iter() + .flatten() + .copied() + .collect::>(); + + // Protected confusable names still resolve to concrete system candidates + let protected = normalize_brand_name(claim); + if !protected.is_empty() { + indices.extend( + self.system_brand_records + .get(&protected) + .into_iter() + .flatten() + .copied(), + ); + } + indices.sort_unstable(); + indices.dedup(); + indices + .into_iter() + .filter_map(|index| self.records.get(index)) + .collect() + } + + pub(in crate::daemon::notifications::identity) fn record_matches_claim( + &self, + record: &DesktopRecord, + claim: &str, + ) -> bool { + let normalized = normalize_name(claim); + record.claim_matches(claim) + || self + .family_for_record(record) + .is_some_and(|family| family.names.contains(&normalized)) + || self + .records_for_claim(claim) + .iter() + .any(|candidate| std::ptr::eq(*candidate, record)) + } + + pub(in crate::daemon::notifications::identity) fn install_provenance_for_path( + &self, + path: PathBuf, + ) -> super::super::provenance::InstallProvenance { + // The caller owns the attribution worker permit while this blocking lookup runs + self.package_ownership.resolve_one(&path) + } + + pub(in crate::daemon::notifications::identity) fn claim_matches_system_app( + &self, + claim: &str, + ) -> bool { + // Confusable spellings share one protected-brand skeleton + let claim = normalize_brand_name(claim); + !claim.is_empty() && self.system_brand_names.contains(&claim) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs new file mode 100644 index 000000000..a210f7792 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mod.rs @@ -0,0 +1,9 @@ +//! Desktop record indexes, family construction, and trusted integration lookup + +mod families; +mod lookup; +mod mutation; +mod trusted; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs new file mode 100644 index 000000000..89611bc2b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/mutation.rs @@ -0,0 +1,62 @@ +//! Mutation of desktop and executable indexes + +use super::super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::super::names::{normalize_brand_name, normalize_desktop_id}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn index_record( + &mut self, + record: DesktopRecord, + ) { + let record_index = self.records.len(); + if record.system_origin { + // Protected branding excludes generic names and launcher aliases + for brand in [&record.display_name, &record.id] { + let brand = normalize_brand_name(brand); + if !brand.is_empty() { + self.system_brand_names.insert(brand.clone()); + self.system_brand_records + .entry(brand) + .or_default() + .push(record_index); + } + } + } + self.by_id + .entry(normalize_desktop_id(&record.id)) + .or_default() + .push(record_index); + for name in &record.names { + self.by_name + .entry(name.clone()) + .or_default() + .push(record_index); + } + // Only records with a reproducible launch contract become executable evidence + if record.association_eligible { + if let Some(identity) = record.runtime_executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + self.records.push(record); + self.index_application_family(record_index); + } + + pub(in crate::daemon::notifications::identity) fn rebuild_executable_index(&mut self) { + self.by_identity.clear(); + for (record_index, record) in self.records.iter().enumerate() { + if !record.association_eligible { + continue; + } + if let Some(identity) = record.runtime_executable_identity { + self.by_identity + .entry((identity.device, identity.inode)) + .or_default() + .push(record_index); + } + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs new file mode 100644 index 000000000..90a97a18b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mod.rs @@ -0,0 +1,2 @@ +mod mutation; +mod trusted; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs new file mode 100644 index 000000000..8d6d3fcd3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/mutation.rs @@ -0,0 +1,85 @@ +//! Executable lookup-index mutation cases + +use std::collections::HashSet; + +use super::super::super::model::{DesktopIdentityIndex, DesktopRecord, LaunchSpec}; +use crate::daemon::notifications::identity::desktop_index::provenance::InstallProvenance; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn executable_index_rebuild_replaces_stale_runtime_identity() { + let old = identity(70); + let new = identity(71); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record(old)); + index.records[0].runtime_executable_identity = Some(new); + index.records[0] + .launch_spec + .as_mut() + .expect("runtime launch specification") + .runtime_executable = new; + + index.rebuild_executable_index(); + + assert!(index.records_for_executable(old).is_empty()); + assert_eq!(index.records_for_executable(new).len(), 1); +} + +#[test] +fn protected_brand_lookup_uses_the_indexed_normalized_record() { + let mut index = DesktopIdentityIndex::default(); + let mut record = record(identity(72)); + record.system_origin = true; + record.display_name = "Example Brand".to_string(); + record.id = "org.example.Brand".to_string(); + index.index_record(record); + + let matched = index.records_for_claim("Example Brand"); + + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].id, "org.example.Brand"); + assert!(index.system_brand_records.values().all(|indices| { + indices + .iter() + .all(|record_index| *record_index < index.records.len()) + })); +} + +fn record(runtime: FileIdentity) -> DesktopRecord { + DesktopRecord { + id: "org.example.App".to_string(), + display_name: "Example App".to_string(), + badge_icon: "example-app".to_string(), + desktop_path: None, + declared_executable_path: Some("/usr/bin/example-app".into()), + declared_executable_identity: Some(runtime), + runtime_executable_path: Some("/usr/bin/example-app".into()), + runtime_executable_identity: Some(runtime), + desktop_identity: None, + desktop_provenance: InstallProvenance::Unknown, + declared_executable_provenance: InstallProvenance::Unknown, + runtime_executable_provenance: InstallProvenance::Unknown, + system_origin: false, + system_association: false, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: runtime, + runtime_executable: runtime, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: false, + }), + names: HashSet::new(), + } +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 1_000, + mode: 0o100_755, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs new file mode 100644 index 000000000..82b1708ab --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/tests/trusted.rs @@ -0,0 +1,76 @@ +//! Trusted portal discovery and identity cases + +use super::super::super::model::DesktopIdentityIndex; +use super::super::trusted::{portal_candidate_paths, portal_identity_is_trusted}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::executable::FileIdentity; +use crate::test_support::TempRoot; + +#[test] +fn portal_discovery_filters_before_applying_the_candidate_limit() { + let root = TempRoot::new("portal-discovery-filter-order"); + for index in 0..300 { + std::fs::write( + root.join(format!("ordinary-library-{index:03}")), + b"fixture", + ) + .expect("write non-portal directory entry"); + } + let portal = root.join("xdg-desktop-portal-example"); + std::fs::write(&portal, b"portal fixture").expect("write portal directory entry"); + + let candidates = portal_candidate_paths(root.path()); + + assert_eq!(candidates, vec![portal]); +} + +#[test] +fn portal_identity_requires_both_system_management_and_executable_file_type() { + let trusted = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + assert!(portal_identity_is_trusted(trusted)); + assert!(!portal_identity_is_trusted(FileIdentity { + uid: 1_000, + ..trusted + })); + assert!(!portal_identity_is_trusted(FileIdentity { + mode: 0o100_644, + ..trusted + })); +} + +#[test] +fn installed_protected_portal_is_indexed_when_available() { + let installed = [ + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ] + .into_iter() + .find_map(|directory| { + portal_candidate_paths(std::path::Path::new(directory)) + .into_iter() + .find_map(|path| { + let evidence = executable_evidence_for_path(&path)?; + portal_identity_is_trusted(evidence.identity).then_some((path, evidence.identity)) + }) + }); + let Some((portal, identity)) = installed else { + // Platforms without an installed portal backend have no system fixture to index + return; + }; + let directory = portal.parent().expect("installed portal parent directory"); + let mut index = DesktopIdentityIndex::default(); + + index.index_trusted_portals_in(directory); + + assert!(index + .trusted_portals + .iter() + .any(|candidate| candidate.identity.same_file(identity))); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs new file mode 100644 index 000000000..e4dd8592b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/index/trusted.rs @@ -0,0 +1,117 @@ +//! Trusted relay and portal integration boundaries + +use std::path::{Path, PathBuf}; + +use super::super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::model::{DesktopIdentityIndex, ExecutableIdentity}; + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications::identity) fn trusted_relay_path( + &self, + identity: FileIdentity, + ) -> Option<&Path> { + self.trusted_relays + .iter() + .find(|relay| relay.identity.same_file(identity)) + .map(|relay| relay.path.as_path()) + } + + pub(in crate::daemon::notifications::identity) fn trusted_portal_path( + &self, + sender_identity: FileIdentity, + sender_path: &Path, + ) -> Option<&Path> { + self.trusted_portals + .iter() + .find(|portal| { + let Some(current) = executable_evidence_for_path(&portal.path) else { + return false; + }; + // Both the running path and installed path must remain under protected roots + trusted_system_executable_path(sender_path) + && trusted_system_executable_path(¤t.canonical_path) + && current.canonical_path == portal.path + && current.identity.same_file(portal.identity) + && current.identity.same_file(sender_identity) + && current.identity.is_system_managed() + && current.identity.is_executable_regular() + }) + .map(|portal| portal.path.as_path()) + } + + pub(in crate::daemon::notifications::identity) fn index_trusted_relay(&mut self, path: &Path) { + let Some(evidence) = executable_evidence_for_path(path) else { + return; + }; + // Writable relay binaries stay ordinary unknown senders + if evidence.identity.is_system_managed() { + self.trusted_relays.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + + pub(in crate::daemon::notifications::identity) fn index_trusted_portals_in( + &mut self, + directory: &Path, + ) { + for path in portal_candidate_paths(directory) { + let Some(evidence) = executable_evidence_for_path(&path) else { + continue; + }; + // Portal authority is accepted only from protected system integration binaries + if portal_identity_is_trusted(evidence.identity) { + self.trusted_portals.push(ExecutableIdentity { + path: evidence.canonical_path, + identity: evidence.identity, + }); + } + } + } +} + +pub(in crate::daemon::notifications::identity) const fn portal_identity_is_trusted( + identity: FileIdentity, +) -> bool { + identity.is_system_managed() && identity.is_executable_regular() +} + +pub(in crate::daemon::notifications::identity) fn portal_candidate_paths( + directory: &Path, +) -> Vec { + const MAX_PORTAL_CANDIDATES: usize = 256; + + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + // Walk every entry in the directory + // Only entries with a matching name count toward the cap + // Filtering first means a directory full of unrelated files cannot hide a real portal + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("xdg-desktop-portal")) + .then_some(path) + }) + .take(MAX_PORTAL_CANDIDATES) + .collect() +} + +fn trusted_system_executable_path(path: &Path) -> bool { + const ROOTS: [&str; 8] = [ + "/bin", + "/lib", + "/lib64", + "/usr/bin", + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ]; + + path.is_absolute() && ROOTS.iter().any(|root| path.starts_with(root)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs new file mode 100644 index 000000000..4a7ef2074 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launch.rs @@ -0,0 +1,130 @@ +//! Desktop `Exec` template parsing and process-command matching + +use std::path::{Path, PathBuf}; + +use gio::prelude::AppInfoExt; + +use super::super::executable::executable_evidence_for_path; +use super::launcher::inspect_package_shell_launcher; +use super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; +use super::program::resolve_program; +use super::wrappers::normalize_launch_command; + +const MAX_EXEC_TEMPLATE_BYTES: usize = 16 * 1024; +const MAX_EXEC_TEMPLATE_ARGUMENTS: usize = 128; + +pub(super) struct BuiltLaunchSpec { + pub(super) declared_path: PathBuf, + pub(super) runtime_path: PathBuf, + pub(super) spec: LaunchSpec, +} + +pub(super) fn build_launch_spec( + desktop: &gio::DesktopAppInfo, + desktop_path: &Path, +) -> Option { + let template = desktop.string("Exec")?; + if template.len() > MAX_EXEC_TEMPLATE_BYTES { + return None; + } + let words = shell_words::split(template.as_str()).ok()?; + if words.is_empty() || words.len() > MAX_EXEC_TEMPLATE_ARGUMENTS { + return None; + } + let normalized = normalize_launch_command(words).ok()?; + let declared_path = resolve_program(Path::new(&normalized.executable))?; + let declared_executable = executable_evidence_for_path(&declared_path)?.identity; + // Inspection never runs a launcher and accepts only one protected literal final target + let package_launcher = inspect_package_shell_launcher(&declared_path, declared_executable); + let runtime_path = package_launcher.as_ref().map_or_else( + || declared_path.clone(), + |binding| binding.target_path.clone(), + ); + let runtime_executable = package_launcher + .as_ref() + .map_or(declared_executable, |binding| binding.target_identity); + + let mut arguments = Vec::with_capacity(normalized.arguments.len()); + let mut literal_files_are_system_managed = true; + for word in normalized.arguments { + let argument = match word.as_str() { + "%f" => LaunchArgument::FieldCode(FieldCode::File), + "%F" => LaunchArgument::FieldCode(FieldCode::Files), + "%u" => LaunchArgument::FieldCode(FieldCode::Url), + "%U" => LaunchArgument::FieldCode(FieldCode::Urls), + "%c" => literal_argument(desktop.display_name().as_bytes().to_vec()), + "%k" => literal_argument(desktop_path.as_os_str().as_encoded_bytes().to_vec()), + "%i" => LaunchArgument::OptionalIcon { + name: desktop + .string("Icon") + .map_or_else(String::new, |icon| icon.to_string()), + }, + _ => { + let literal = percent_literal(&word)?; + let literal = literal_argument(literal.into_bytes()); + if let LaunchArgument::Literal(literal) = &literal { + if let Some((_path, identity)) = &literal.file { + if !identity.is_system_managed() { + literal_files_are_system_managed = false; + } + } else if literal_path_candidate(&literal.value) { + // An unresolved application path cannot support system association + literal_files_are_system_managed = false; + } + } + literal + } + }; + arguments.push(argument); + } + + Some(BuiltLaunchSpec { + declared_path, + runtime_path, + spec: LaunchSpec { + declared_executable, + runtime_executable, + arguments, + environment: normalized.environment, + wrappers: normalized.wrappers, + package_launcher, + literal_files_are_system_managed, + }, + }) +} + +fn literal_argument(value: Vec) -> LaunchArgument { + let file = std::str::from_utf8(&value) + .ok() + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .and_then(|path| { + executable_evidence_for_path(&path).map(|evidence| (path, evidence.identity)) + }); + LaunchArgument::Literal(LiteralArgument { value, file }) +} + +fn literal_path_candidate(value: &[u8]) -> bool { + // Slash-bearing non-option literals are application payload paths even when unresolved + !value.starts_with(b"-") && value.contains(&b'/') +} + +fn percent_literal(word: &str) -> Option { + let mut output = String::with_capacity(word.len()); + let mut characters = word.chars(); + while let Some(character) = characters.next() { + if character != '%' { + output.push(character); + continue; + } + if characters.next()? != '%' { + return None; + } + output.push('%'); + } + Some(output) +} + +#[cfg(test)] +#[path = "tests/launch.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs new file mode 100644 index 000000000..087883aa4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/binding.rs @@ -0,0 +1,46 @@ +//! Package-launcher binding orchestration + +use std::path::Path; + +use super::super::super::executable::FileIdentity; +use super::super::model::PackageLauncherBinding; + +/// Extract one literal runtime target without running or emulating the launcher +pub fn inspect_package_shell_launcher( + path: &Path, + expected_identity: FileIdentity, +) -> Option { + // Reading through one no-follow descriptor binds syntax to the indexed file + let launcher = super::read::read_launcher(path, expected_identity)?; + let target_path = super::syntax::literal_final_exec_target(&launcher.contents)?; + + // The literal target must already be protected before package ownership is queried + let target_identity = super::validation::protected_runtime_target(&target_path)?; + Some(PackageLauncherBinding { + launcher_path: path.to_path_buf(), + launcher_identity: launcher.identity, + launcher_digest: launcher.digest, + target_path, + target_identity, + }) +} + +/// Reopen both files and repeat the literal-target proof before granting authority +pub fn launcher_binding_is_current(binding: &PackageLauncherBinding) -> bool { + let Some(launcher) = + super::read::read_launcher(&binding.launcher_path, binding.launcher_identity) + else { + return false; + }; + if launcher.digest != binding.launcher_digest { + return false; + } + if super::syntax::literal_final_exec_target(&launcher.contents).as_ref() + != Some(&binding.target_path) + { + return false; + } + + super::validation::protected_runtime_target(&binding.target_path) + .is_some_and(|current| current.same_file(binding.target_identity)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs new file mode 100644 index 000000000..958989c01 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/mod.rs @@ -0,0 +1,12 @@ +//! Protected shell-launcher inspection and runtime binding + +mod binding; +mod read; +mod syntax; +mod validation; + +pub(super) use binding::{inspect_package_shell_launcher, launcher_binding_is_current}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs new file mode 100644 index 000000000..e3ff58446 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/read.rs @@ -0,0 +1,107 @@ +//! Bounded descriptor-backed launcher reads + +use std::fs::File; +use std::io::Read; +use std::os::fd::OwnedFd; +use std::path::Path; +use std::time::SystemTime; + +use rustix::fs::{open, Mode, OFlags}; + +use super::super::super::executable::FileIdentity; + +pub(super) const MAX_LAUNCHER_BYTES: u64 = 64 * 1024; +pub(super) const MAX_LAUNCHER_LINES: usize = 1_024; + +pub(super) struct LauncherContents { + pub(super) contents: Vec, + pub(super) identity: FileIdentity, + pub(super) digest: [u8; 32], +} + +pub(super) fn read_launcher( + path: &Path, + expected_identity: FileIdentity, +) -> Option { + // No-follow prevents a launcher path from redirecting inspection through a symlink + let descriptor = open_launcher_descriptor(path)?; + let mut file = File::from(descriptor); + let before = file.metadata().ok()?; + let identity = FileIdentity::from_metadata(&before); + if !identity.same_file(expected_identity) { + return None; + } + if !identity.is_system_managed() { + return None; + } + if !identity.is_executable_regular() { + return None; + } + if !launcher_size_is_supported(before.len()) { + return None; + } + + // One extra byte distinguishes the exact limit from a truncated oversized script + let mut contents = Vec::with_capacity(usize::try_from(before.len()).ok()?); + file.by_ref() + .take(MAX_LAUNCHER_BYTES.saturating_add(1)) + .read_to_end(&mut contents) + .ok()?; + if !launcher_contents_are_supported(&contents) { + return None; + } + + // A second descriptor snapshot rejects replacement or mutation during the read + let after = file.metadata().ok()?; + let after_identity = FileIdentity::from_metadata(&after); + if !snapshot_is_unchanged( + identity, + after_identity, + before.len(), + after.len(), + before.modified().ok()?, + after.modified().ok()?, + ) { + return None; + } + + Some(LauncherContents { + digest: *blake3::hash(&contents).as_bytes(), + contents, + identity, + }) +} + +pub(super) const fn launcher_size_is_supported(size: u64) -> bool { + size <= MAX_LAUNCHER_BYTES +} + +pub(super) fn launcher_contents_are_supported(contents: &[u8]) -> bool { + u64::try_from(contents.len()) + .ok() + .is_some_and(launcher_size_is_supported) + && contents.split(|byte| *byte == b'\n').count() <= MAX_LAUNCHER_LINES +} + +pub(super) fn open_launcher_descriptor(path: &Path) -> Option { + open(path, protected_open_flags(), Mode::empty()).ok() +} + +pub(super) const fn protected_open_flags() -> OFlags { + OFlags::RDONLY + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW) +} + +pub(super) fn snapshot_is_unchanged( + before_identity: FileIdentity, + after_identity: FileIdentity, + before_size: u64, + after_size: u64, + before_modified: SystemTime, + after_modified: SystemTime, +) -> bool { + before_identity.same_file(after_identity) + && before_size == after_size + && before_modified == after_modified +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs new file mode 100644 index 000000000..c7fdf9100 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/syntax.rs @@ -0,0 +1,134 @@ +//! Fail-closed Bash syntax analysis for literal final `exec` targets + +use std::path::{Component, Path, PathBuf}; + +use tree_sitter::{Node, Parser}; + +const MAX_SYNTAX_NODES: usize = 16_384; +const MAX_EXEC_ARGUMENTS: usize = 128; +const FORBIDDEN_COMMANDS: [&str; 7] = [ + ".", "alias", "builtin", "command", "enable", "eval", "source", +]; + +pub(super) fn literal_final_exec_target(source: &[u8]) -> Option { + validate_shell_shebang(source)?; + let source_text = std::str::from_utf8(source).ok()?; + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_bash::LANGUAGE.into()) + .ok()?; + let tree = parser.parse(source_text, None)?; + let root = tree.root_node(); + if root.has_error() { + return None; + } + if root.kind() != "program" { + return None; + } + + // Iterative traversal applies one resource bound to nested substitutions and blocks + let nodes = syntax_nodes(root)?; + if nodes + .iter() + .any(|node| matches!(node.kind(), "function_definition" | "heredoc_redirect")) + { + return None; + } + + let commands = nodes + .iter() + .filter(|node| node.kind() == "command") + .copied() + .collect::>(); + for command in &commands { + let name = command_name(*command, source)?; + if FORBIDDEN_COMMANDS.contains(&name) { + return None; + } + } + + let mut exec_commands = commands + .into_iter() + .filter(|command| command_name(*command, source) == Some("exec")); + let exec = exec_commands.next()?; + if exec_commands.next().is_some() { + return None; + } + if exec.parent().map(|node| node.kind()) != Some("program") { + return None; + } + if last_top_level_statement(root)? != exec || exec.child_by_field_name("redirect").is_some() { + return None; + } + + let mut cursor = exec.walk(); + let arguments = exec + .children_by_field_name("argument", &mut cursor) + .collect::>(); + if arguments.is_empty() { + return None; + } + if arguments.len() > MAX_EXEC_ARGUMENTS { + return None; + } + literal_absolute_path(arguments[0], source) +} + +fn validate_shell_shebang(source: &[u8]) -> Option<()> { + let first_line = source.split(|byte| *byte == b'\n').next()?; + let first_line = std::str::from_utf8(first_line).ok()?.trim_end_matches('\r'); + let command = first_line.strip_prefix("#!")?.trim(); + let words = command.split_ascii_whitespace().collect::>(); + match words.as_slice() { + ["/bin/sh" | "/usr/bin/sh" | "/bin/bash" | "/usr/bin/bash"] + | ["/usr/bin/env", "sh" | "bash"] => Some(()), + _ => None, + } +} + +fn syntax_nodes(root: Node<'_>) -> Option>> { + let mut pending = vec![root]; + let mut nodes = Vec::new(); + while let Some(node) = pending.pop() { + if nodes.len() >= MAX_SYNTAX_NODES { + return None; + } + let mut cursor = node.walk(); + pending.extend(node.children(&mut cursor)); + nodes.push(node); + } + Some(nodes) +} + +fn command_name<'source>(command: Node<'_>, source: &'source [u8]) -> Option<&'source str> { + command.child_by_field_name("name")?.utf8_text(source).ok() +} + +fn last_top_level_statement(root: Node<'_>) -> Option> { + let mut cursor = root.walk(); + root.named_children(&mut cursor) + .filter(|node| node.kind() != "comment") + .last() +} + +fn literal_absolute_path(node: Node<'_>, source: &[u8]) -> Option { + // Only an unquoted word without expansions can select the authenticated target + if node.kind() != "word" { + return None; + } + if node.named_child_count() != 0 { + return None; + } + let value = node.utf8_text(source).ok()?; + if value.contains(['*', '?', '[', ']', '{', '}', '~', '$', '`']) { + return None; + } + let path = Path::new(value); + let mut components = path.components(); + if components.next() != Some(Component::RootDir) + || !components.all(|component| matches!(component, Component::Normal(_))) + { + return None; + } + Some(path.to_path_buf()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/binding.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/binding.rs new file mode 100644 index 000000000..a6e853b03 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/binding.rs @@ -0,0 +1,27 @@ +//! Launcher-binding revalidation failure cases + +use super::super::launcher_binding_is_current; +use crate::daemon::notifications::identity::desktop_index::model::PackageLauncherBinding; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn unprotected_launcher_binding_is_never_current() { + let binding = PackageLauncherBinding { + launcher_path: "/tmp/unixnotis-missing-launcher".into(), + launcher_identity: identity(80), + launcher_digest: [0; 32], + target_path: "/tmp/unixnotis-missing-runtime".into(), + target_identity: identity(81), + }; + + assert!(!launcher_binding_is_current(&binding)); +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 0, + mode: 0o100_755, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/mod.rs new file mode 100644 index 000000000..0afc9ebe7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/mod.rs @@ -0,0 +1,4 @@ +mod binding; +mod read; +mod syntax; +mod validation; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/read.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/read.rs new file mode 100644 index 000000000..04f3cba7b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/read.rs @@ -0,0 +1,131 @@ +//! Launcher file-read boundary cases + +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::time::{Duration, UNIX_EPOCH}; + +use rustix::io::{fcntl_getfd, FdFlags}; + +use super::super::read::{ + launcher_contents_are_supported, launcher_size_is_supported, open_launcher_descriptor, + read_launcher, snapshot_is_unchanged, MAX_LAUNCHER_BYTES, MAX_LAUNCHER_LINES, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::test_support::TempRoot; + +#[test] +fn user_writable_launcher_is_not_inspected() { + let root = TempRoot::new("user-writable-launcher"); + let path = root.join("launcher"); + fs::write(&path, "#!/bin/sh\nexec /usr/bin/true \"$@\"\n").expect("write launcher fixture"); + // Keep the fixture user-writable even when tests run as root in CI + fs::set_permissions(&path, fs::Permissions::from_mode(0o775)) + .expect("make launcher fixture executable"); + let identity = executable_evidence_for_path(&path) + .expect("read launcher fixture identity") + .identity; + + assert!(read_launcher(&path, identity).is_none()); +} + +#[test] +fn launcher_symlink_is_not_followed() { + let root = TempRoot::new("launcher-symlink"); + let target = std::path::Path::new("/usr/bin/true"); + let identity = executable_evidence_for_path(target) + .expect("read target identity") + .identity; + let link = root.join("launcher"); + symlink(target, &link).expect("create launcher symlink fixture"); + + assert!(read_launcher(&link, identity).is_none()); + assert!(open_launcher_descriptor(&link).is_none()); +} + +#[test] +fn oversized_launcher_is_rejected() { + let root = TempRoot::new("oversized-launcher"); + let path = root.join("launcher"); + let contents = format!( + "#!/bin/sh\n# {}\nexec /usr/bin/true\n", + "x".repeat(65 * 1024) + ); + fs::write(&path, contents).expect("write oversized launcher fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("make oversized launcher executable"); + let identity = executable_evidence_for_path(&path) + .expect("read oversized launcher identity") + .identity; + + assert!(read_launcher(&path, identity).is_none()); + assert!(launcher_size_is_supported(MAX_LAUNCHER_BYTES)); + assert!(!launcher_size_is_supported( + MAX_LAUNCHER_BYTES.saturating_add(1) + )); + let exact_bytes = + vec![b'x'; usize::try_from(MAX_LAUNCHER_BYTES).expect("byte limit fits usize")]; + let oversized_bytes = vec![ + b'x'; + usize::try_from(MAX_LAUNCHER_BYTES.saturating_add(1)) + .expect("oversized byte limit fits usize") + ]; + assert!(launcher_contents_are_supported(&exact_bytes)); + assert!(!launcher_contents_are_supported(&oversized_bytes)); + assert!(launcher_contents_are_supported( + &"\n" + .repeat(MAX_LAUNCHER_LINES.saturating_sub(1)) + .into_bytes() + )); + assert!(!launcher_contents_are_supported( + &"\n".repeat(MAX_LAUNCHER_LINES).into_bytes() + )); +} + +#[test] +fn changed_launcher_identity_is_rejected() { + let current = executable_evidence_for_path(std::path::Path::new("/usr/bin/true")) + .expect("current system executable"); + let stale = executable_evidence_for_path(std::path::Path::new("/usr/bin/false")) + .expect("different system executable"); + + assert!(read_launcher(¤t.canonical_path, stale.identity).is_none()); +} + +#[test] +fn launcher_descriptor_is_close_on_exec() { + let descriptor = open_launcher_descriptor(std::path::Path::new("/usr/bin/true")) + .expect("open protected launcher candidate"); + let flags = fcntl_getfd(&descriptor).expect("read launcher descriptor flags"); + + assert!(flags.contains(FdFlags::CLOEXEC)); +} + +#[test] +fn launcher_snapshot_requires_identity_size_and_time_to_remain_equal() { + let current = executable_evidence_for_path(std::path::Path::new("/usr/bin/true")) + .expect("current executable identity") + .identity; + let other = executable_evidence_for_path(std::path::Path::new("/usr/bin/false")) + .expect("other executable identity") + .identity; + let first_time = UNIX_EPOCH + Duration::from_secs(10); + let second_time = UNIX_EPOCH + Duration::from_secs(11); + + assert!(snapshot_is_unchanged( + current, current, 20, 20, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, other, 20, 20, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, current, 20, 21, first_time, first_time + )); + assert!(!snapshot_is_unchanged( + current, + current, + 20, + 20, + first_time, + second_time + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/syntax.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/syntax.rs new file mode 100644 index 000000000..4ca92c575 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/syntax.rs @@ -0,0 +1,137 @@ +//! Shell-launcher syntax acceptance and rejection cases + +use std::path::Path; + +use super::super::syntax::literal_final_exec_target; + +#[test] +fn package_shell_launcher_extracts_literal_final_target() { + let source = b"#!/bin/sh\nexec /usr/lib/example/example \"$@\"\n"; + + assert_eq!( + literal_final_exec_target(source).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); +} + +#[test] +fn package_shell_launcher_allows_dynamic_arguments_after_literal_target() { + let source = br#"#!/usr/bin/env bash + +FLAGS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/example-flags.conf" +if [[ -f "${FLAGS_FILE}" ]]; then + FLAGS="$(sed 's/#.*//' "${FLAGS_FILE}" | tr '\n' ' ')" +fi +exec /usr/lib/example/example $FLAGS "$@" +"#; + + assert_eq!( + literal_final_exec_target(source).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); +} + +#[test] +fn dynamic_exec_targets_are_rejected() { + for source in [ + b"#!/bin/sh\nexec \"$TARGET\" \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec ${TARGET} \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec \"$(find-runtime)\" \"$@\"\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "dynamic executable target must fail closed" + ); + } +} + +#[test] +fn relative_exec_targets_are_rejected() { + for source in [ + b"#!/bin/sh\nexec ./example \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec example \"$@\"\n".as_slice(), + b"#!/bin/sh\nexec /usr/lib/../bin/example \"$@\"\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "relative or normalized executable target must fail closed" + ); + } +} + +#[test] +fn multiple_exec_targets_are_rejected() { + let source = b"#!/bin/sh\nexec /usr/lib/example/first || exec /usr/lib/example/second\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn exec_with_control_operator_is_rejected() { + for source in [ + b"#!/bin/sh\nexec /usr/lib/example/example; fallback\n".as_slice(), + b"#!/bin/sh\nexec /usr/lib/example/example | other\n".as_slice(), + ] { + assert!( + literal_final_exec_target(source).is_none(), + "control operators around the authoritative exec must fail closed" + ); + } +} + +#[test] +fn sourced_launchers_are_rejected() { + for command in ["source helper-script", ". helper-script"] { + let source = format!("#!/bin/sh\n{command}\nexec /usr/lib/example/example \"$@\"\n"); + assert!( + literal_final_exec_target(source.as_bytes()).is_none(), + "sourced code can change final command meaning" + ); + } +} + +#[test] +fn commands_after_exec_are_rejected() { + let source = b"#!/bin/sh\nexec /usr/lib/example/example \"$@\"\necho unreachable\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn unsupported_shell_shebang_is_rejected() { + let source = b"#!/usr/bin/fish\nexec /usr/lib/example/example $argv\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn malformed_shell_syntax_is_rejected_even_with_a_literal_final_exec() { + let source = b"#!/bin/sh\nif then\nexec /usr/lib/example/example \"$@\"\n"; + + assert!(literal_final_exec_target(source).is_none()); +} + +#[test] +fn exec_argument_limit_accepts_the_boundary_and_rejects_one_more() { + let command = |extra_arguments: usize| { + format!( + "#!/bin/sh\nexec /usr/lib/example/example {}\n", + std::iter::repeat_n("$ARG", extra_arguments) + .collect::>() + .join(" ") + ) + }; + let exact = command(127); + let over = command(128); + + assert_eq!( + literal_final_exec_target(exact.as_bytes()).as_deref(), + Some(Path::new("/usr/lib/example/example")) + ); + assert!(literal_final_exec_target(over.as_bytes()).is_none()); +} + +#[test] +fn exec_without_a_target_is_rejected() { + assert!(literal_final_exec_target(b"#!/bin/sh\nexec\n").is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/validation.rs new file mode 100644 index 000000000..22df9eb42 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/tests/validation.rs @@ -0,0 +1,48 @@ +//! Runtime-target file validation cases + +use std::os::unix::fs::symlink; +use std::path::Path; +use std::{fs, os::unix::fs::PermissionsExt}; + +use super::super::validation::protected_runtime_target; +use crate::test_support::TempRoot; + +#[test] +fn protected_runtime_target_accepts_installed_regular_executable() { + let identity = protected_runtime_target(Path::new("/usr/bin/true")) + .expect("installed protected executable"); + + assert!(identity.is_system_managed()); + assert!(identity.is_executable_regular()); +} + +#[test] +fn runtime_target_symlink_is_not_followed() { + let root = TempRoot::new("runtime-target-symlink"); + let path = root.join("runtime"); + symlink("/usr/bin/true", &path).expect("create runtime target symlink fixture"); + + assert!(protected_runtime_target(&path).is_none()); +} + +#[test] +fn changed_runtime_target_identity_is_detected() { + let current = + protected_runtime_target(Path::new("/usr/bin/true")).expect("current runtime target"); + let stale = + protected_runtime_target(Path::new("/usr/bin/false")).expect("different runtime target"); + + assert!(!current.same_file(stale)); +} + +#[test] +fn user_owned_runtime_target_is_rejected() { + let root = TempRoot::new("user-runtime-target"); + let path = root.join("runtime"); + fs::write(&path, "fixture").expect("write runtime target fixture"); + // Keep the fixture user-writable even when tests run as root in CI + fs::set_permissions(&path, fs::Permissions::from_mode(0o775)) + .expect("make runtime target executable"); + + assert!(protected_runtime_target(&path).is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs new file mode 100644 index 000000000..55dcbaaa1 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/launcher/validation.rs @@ -0,0 +1,14 @@ +//! Protected runtime-target validation + +use std::path::Path; + +use super::super::super::executable::FileIdentity; +use super::read::open_launcher_descriptor; + +pub(super) fn protected_runtime_target(path: &Path) -> Option { + // Runtime targets are opened directly so the literal path cannot terminate in a symlink + let descriptor = open_launcher_descriptor(path)?; + let metadata = std::fs::File::from(descriptor).metadata().ok()?; + let identity = FileIdentity::from_metadata(&metadata); + (identity.is_system_managed() && identity.is_executable_regular()).then_some(identity) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs new file mode 100644 index 000000000..6415e44b9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/mod.rs @@ -0,0 +1,35 @@ +//! Desktop application index preserving system and user entry origins + +mod index; +mod launch; +mod launcher; +pub(in crate::daemon::notifications::identity) mod model; +mod names; +mod program; +pub(in crate::daemon::notifications::identity) mod provenance; +mod record; +mod refresh; +mod scan; +mod verification; +mod wrappers; + +pub use model::DesktopIdentityIndex; +pub(super) use model::DesktopRecord; +pub(super) use model::{LaunchFailure, LaunchVerification, VerifiedLaunch}; +pub(super) use names::{normalize_desktop_id, normalize_name}; +pub(super) use provenance::InstallProvenance; +pub use refresh::spawn_desktop_index_refresh; +pub use refresh::DesktopIndexRefreshHandle; +pub use scan::DesktopIndexSnapshot; + +pub(in crate::daemon::notifications::identity) fn verify_record_launch( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + sender_identity: super::FileIdentity, + cmdline: &super::sender::CommandLineEvidence, +) -> LaunchVerification { + verification::verify_record_launch(record, index, sender_identity, cmdline) +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs new file mode 100644 index 000000000..a71d5474e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/model.rs @@ -0,0 +1,180 @@ +//! Indexed desktop records and executable evidence + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; + +use super::super::executable::FileIdentity; +use super::names::normalize_name; +use super::provenance::{InstallProvenance, PackageOwnershipCache}; + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct LaunchSpec { + /// Program named directly by the desktop entry after wrapper normalization + pub(in crate::daemon::notifications::identity) declared_executable: FileIdentity, + /// Program expected to remain after a validated package launcher exits through `exec` + pub(in crate::daemon::notifications::identity) runtime_executable: FileIdentity, + pub(in crate::daemon::notifications::identity) arguments: Vec, + pub(in crate::daemon::notifications::identity) environment: Vec<(Vec, Vec)>, + pub(in crate::daemon::notifications::identity) wrappers: Vec, + pub(in crate::daemon::notifications::identity) package_launcher: Option, + pub(in crate::daemon::notifications::identity) literal_files_are_system_managed: bool, +} + +/// Immutable relationship between a protected launcher and its literal runtime target +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) struct PackageLauncherBinding { + pub(in crate::daemon::notifications::identity) launcher_path: PathBuf, + pub(in crate::daemon::notifications::identity) launcher_identity: FileIdentity, + pub(in crate::daemon::notifications::identity) launcher_digest: [u8; 32], + pub(in crate::daemon::notifications::identity) target_path: PathBuf, + pub(in crate::daemon::notifications::identity) target_identity: FileIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchArgument { + Literal(LiteralArgument), + FieldCode(FieldCode), + OptionalIcon { name: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) struct LiteralArgument { + pub(in crate::daemon::notifications::identity) value: Vec, + pub(in crate::daemon::notifications::identity) file: Option<(PathBuf, FileIdentity)>, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub(in crate::daemon::notifications::identity) enum FieldCode { + File, + Files, + Url, + Urls, +} + +/// Wrapper programs removed before application identity is evaluated +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchWrapper { + Env, +} + +/// Evidence that establishes which application a desktop record launches +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchAuthority { + DedicatedExecutable, + ProtectedPayload, + DynamicOnly, + Ambiguous, +} + +/// Positive launch identity retained for diagnostics and candidate ranking +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum VerifiedLaunch { + DedicatedExecutable, + PackageLauncherTarget, + ProtectedPayload, +} + +/// Stable reason for a launch decision that cannot authenticate the claim +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchFailure { + MissingSenderEvidence, + MissingCommandLine, + UnstructuredCommandLine, + EmptyContractNeedsCommandLine, + UnsupportedWrapper, + LauncherBindingChanged, + AmbiguousDesktopAssociation, + DynamicOnlyContract, + ExecutableMismatch, + ProtectedPayloadMismatch, + RequiredArgumentMismatch, + DesktopClaimMismatch, + NoDesktopCandidate, +} + +/// Three-way launch result keeps missing evidence distinct from contradiction +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::identity) enum LaunchVerification { + Verified(VerifiedLaunch), + InsufficientEvidence(LaunchFailure), + DefinitiveMismatch(LaunchFailure), +} + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct DesktopRecord { + pub(in crate::daemon::notifications::identity) id: String, + pub(in crate::daemon::notifications::identity) display_name: String, + pub(in crate::daemon::notifications::identity) badge_icon: String, + pub(in crate::daemon::notifications::identity) desktop_path: Option, + pub(in crate::daemon::notifications::identity) declared_executable_path: Option, + pub(in crate::daemon::notifications::identity) declared_executable_identity: + Option, + pub(in crate::daemon::notifications::identity) runtime_executable_path: Option, + pub(in crate::daemon::notifications::identity) runtime_executable_identity: + Option, + pub(in crate::daemon::notifications::identity) desktop_identity: Option, + pub(in crate::daemon::notifications::identity) desktop_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) declared_executable_provenance: + InstallProvenance, + pub(in crate::daemon::notifications::identity) runtime_executable_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) system_origin: bool, + pub(in crate::daemon::notifications::identity) system_association: bool, + pub(in crate::daemon::notifications::identity) association_eligible: bool, + pub(in crate::daemon::notifications::identity) launch_spec: Option, + pub(in crate::daemon::notifications::identity) names: HashSet, +} + +impl DesktopRecord { + pub(in crate::daemon::notifications::identity) fn claim_matches(&self, claim: &str) -> bool { + // Normalized aliases cover desktop names without trusting free-form display text + self.names.contains(&normalize_name(claim)) + } +} + +/// Canonical identity shared by equivalent desktop-entry aliases +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct DesktopApplicationFamily { + pub(in crate::daemon::notifications::identity) canonical_id: String, + pub(in crate::daemon::notifications::identity) executable_identity: FileIdentity, + pub(in crate::daemon::notifications::identity) records: Vec, + pub(in crate::daemon::notifications::identity) names: HashSet, + pub(in crate::daemon::notifications::identity) system_origin: bool, + pub(in crate::daemon::notifications::identity) system_association: bool, + pub(in crate::daemon::notifications::identity) install_provenance: InstallProvenance, + pub(in crate::daemon::notifications::identity) protected_payloads: Vec<(usize, u64, u64)>, +} + +#[derive(Debug, Default)] +pub struct DesktopIdentityIndex { + pub(super) records: Vec, + pub(super) families: Vec, + pub(super) family_by_record: Vec>, + pub(super) by_id: HashMap>, + pub(super) by_identity: HashMap<(u64, u64), Vec>, + pub(super) by_name: HashMap>, + pub(super) system_brand_names: HashSet, + // Protected brand keys point directly to records instead of rescanning all desktop entries + pub(super) system_brand_records: HashMap>, + pub(super) communication_desktop_ids: HashSet, + pub(in crate::daemon::notifications::identity) trusted_relays: Vec, + pub(in crate::daemon::notifications::identity) trusted_portals: Vec, + pub(super) package_ownership: Arc, +} + +impl DesktopIdentityIndex { + pub(in crate::daemon::notifications) fn desktop_id_has_communication_role( + &self, + desktop_id: &str, + ) -> bool { + // Wire hints commonly carry mixed case or a trailing .desktop suffix + let normalized = super::names::normalize_desktop_id(desktop_id); + self.communication_desktop_ids.contains(&normalized) && self.by_id.contains_key(&normalized) + } +} + +#[derive(Debug, Clone)] +pub(in crate::daemon::notifications::identity) struct ExecutableIdentity { + pub(in crate::daemon::notifications::identity) path: PathBuf, + pub(in crate::daemon::notifications::identity) identity: FileIdentity, +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs new file mode 100644 index 000000000..134d93569 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/names.rs @@ -0,0 +1,33 @@ +//! Desktop identifiers, aliases, and protected brand normalization + +use unicode_security::skeleton; + +pub(in crate::daemon::notifications::identity) fn normalize_desktop_id(value: &str) -> String { + // Desktop hints commonly include an optional suffix and mixed case + let normalized = value.trim().to_ascii_lowercase(); + normalized + .strip_suffix(".desktop") + .unwrap_or(&normalized) + .to_string() +} + +pub(in crate::daemon::notifications::identity) fn normalize_name(value: &str) -> String { + // Punctuation and case do not create separate branding aliases + let mut normalized = String::with_capacity(value.len()); + for character in value + .chars() + .filter(|character| character.is_alphanumeric()) + { + normalized.extend(character.to_lowercase()); + } + normalized +} + +pub(super) fn normalize_brand_name(value: &str) -> String { + // UTS 39 skeletons collapse common cross-script lookalikes before comparison + let mut normalized = String::with_capacity(value.len()); + for character in skeleton(value).filter(char::is_ascii_alphanumeric) { + normalized.push(character.to_ascii_lowercase()); + } + normalized +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs new file mode 100644 index 000000000..3f17cfa6c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/program.rs @@ -0,0 +1,14 @@ +//! Desktop launch-program parsing and path resolution + +use std::path::{Path, PathBuf}; + +pub(super) fn resolve_program(program: &Path) -> Option { + // Canonical paths are presentation data while device and inode carry the proof + if program.is_absolute() { + return program.canonicalize().ok(); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(program)) + .find_map(|candidate| candidate.canonicalize().ok()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs new file mode 100644 index 000000000..5bcb2d8ec --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/cache.rs @@ -0,0 +1,168 @@ +//! Negative-result cache for package ownership lookups + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use super::query::{detect_package_provider, query_package_ownership}; +use super::InstallProvenance; + +const MAX_OWNERSHIP_PATHS: usize = 16_384; +pub(super) const TRANSIENT_NEGATIVE_TTL: Duration = Duration::from_secs(30); +pub(super) const NOT_OWNED_NEGATIVE_TTL: Duration = Duration::from_mins(5); + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum NegativeCause { + NotOwned, + Timeout, + ProviderFailure, + MalformedOutput, + ProcessTermination, +} + +#[derive(Debug, Clone)] +pub(super) enum CachedProvenance { + Known(InstallProvenance), + Negative { + retry_after: Instant, + cause: NegativeCause, + }, +} + +impl CachedProvenance { + pub(super) fn from_lookup(lookup: OwnershipLookup, now: Instant) -> Self { + match lookup { + OwnershipLookup::Known(provenance) => Self::Known(provenance), + OwnershipLookup::Negative(cause) => Self::Negative { + retry_after: now.checked_add(negative_ttl(cause)).unwrap_or(now), + cause, + }, + } + } + + pub(super) fn needs_refresh(&self, now: Instant) -> bool { + let Self::Negative { retry_after, cause } = self else { + return false; + }; + // Keeping the cause live preserves the distinction used to select retry windows + debug_assert!( + !negative_ttl(*cause).is_zero(), + "negative package-provenance results must remain retryable" + ); + now >= *retry_after + } + + pub(super) fn provenance(&self) -> InstallProvenance { + match self { + Self::Known(provenance) => provenance.clone(), + Self::Negative { .. } => InstallProvenance::Unknown, + } + } +} + +const fn negative_ttl(cause: NegativeCause) -> Duration { + match cause { + NegativeCause::NotOwned => NOT_OWNED_NEGATIVE_TTL, + NegativeCause::Timeout + | NegativeCause::ProviderFailure + | NegativeCause::MalformedOutput + | NegativeCause::ProcessTermination => TRANSIENT_NEGATIVE_TTL, + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) enum OwnershipLookup { + Known(InstallProvenance), + Negative(NegativeCause), +} + +#[derive(Debug, Default)] +pub(in crate::daemon::notifications::identity) struct PackageOwnershipCache { + provider: OnceLock>, + pub(super) entries: Mutex>, +} + +impl PackageOwnershipCache { + pub(in crate::daemon::notifications::identity) fn resolve_many( + &self, + paths: impl IntoIterator, + ) -> HashMap { + // Dedupe before taking the cache lock so repeated desktop aliases stay cheap + let paths = paths + .into_iter() + .take(MAX_OWNERSHIP_PATHS) + .collect::>(); + let now = Instant::now(); + let missing = self.entries.lock().map_or_else( + |_| paths.iter().cloned().collect::>(), + |entries| { + paths + .iter() + .filter(|path| { + entries + .get(*path) + .is_none_or(|entry| entry.needs_refresh(now)) + }) + .cloned() + .collect::>() + }, + ); + + if !missing.is_empty() { + let resolved = self + .provider + .get_or_init(detect_package_provider) + .as_ref() + .map_or_else( + || { + missing + .iter() + .cloned() + .map(|path| { + ( + path, + OwnershipLookup::Negative(NegativeCause::ProviderFailure), + ) + }) + .collect() + }, + |provider| query_package_ownership(provider, &missing), + ); + let resolved_at = Instant::now(); + if let Ok(mut entries) = self.entries.lock() { + for path in missing { + let lookup = resolved + .get(&path) + .cloned() + .unwrap_or(OwnershipLookup::Negative(NegativeCause::ProviderFailure)); + entries.insert(path, CachedProvenance::from_lookup(lookup, resolved_at)); + } + } + } + + self.entries.lock().map_or_else( + |_| HashMap::new(), + |entries| { + paths + .into_iter() + .map(|path| { + let provenance = entries + .get(&path) + .map_or(InstallProvenance::Unknown, CachedProvenance::provenance); + (path, provenance) + }) + .collect() + }, + ) + } + + pub(in crate::daemon::notifications::identity) fn resolve_one( + &self, + path: &Path, + ) -> InstallProvenance { + self.resolve_many([path.to_path_buf()]) + .remove(path) + .unwrap_or(InstallProvenance::Unknown) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs new file mode 100644 index 000000000..cd17343fc --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/mod.rs @@ -0,0 +1,76 @@ +//! Immutable installation ownership used by desktop attribution + +mod cache; +mod process; +mod query; +mod rpm; + +pub(super) use cache::PackageOwnershipCache; + +/// System database that established package ownership +#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] +pub(in crate::daemon::notifications) enum PackageProvider { + Pacman, + Dpkg, + Rpm, +} + +/// Installation source shared by protected desktop and executable files +#[derive(Debug, Clone, Default, Eq, Hash, PartialEq)] +pub(in crate::daemon::notifications) enum InstallProvenance { + Package { + provider: PackageProvider, + package_id: String, + }, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "bundle ownership is part of the closed provenance model before a backend is available" + ) + )] + ImmutableBundle { bundle_id: String }, + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "portal ownership is retained as a separate authority domain" + ) + )] + Portal { app_id: String }, + #[default] + Unknown, +} + +impl InstallProvenance { + pub(in crate::daemon::notifications::identity) fn same_application_source( + &self, + other: &Self, + ) -> bool { + match (self, other) { + ( + Self::Package { + provider: left_provider, + package_id: left_id, + }, + Self::Package { + provider: right_provider, + package_id: right_id, + }, + ) => left_provider == right_provider && left_id == right_id, + ( + Self::ImmutableBundle { bundle_id: left }, + Self::ImmutableBundle { bundle_id: right }, + ) + | (Self::Portal { app_id: left }, Self::Portal { app_id: right }) => left == right, + _ => false, + } + } + + pub(in crate::daemon::notifications::identity) const fn is_known(&self) -> bool { + !matches!(self, Self::Unknown) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs new file mode 100644 index 000000000..73c814caa --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/process.rs @@ -0,0 +1,124 @@ +//! Package-manager subprocess supervision + +use std::io::Read; +use std::os::unix::process::CommandExt; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use rustix::process::{kill_process_group, Pid, Signal}; +use wait_timeout::ChildExt; + +use super::cache::NegativeCause; + +const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); +const PACKAGE_PIPE_DRAIN_TIMEOUT: Duration = Duration::from_millis(50); + +#[derive(Debug)] +pub(super) struct PackageQueryOutput { + pub(super) status: ExitStatus, + pub(super) stdout: Vec, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum PackageQueryFailure { + Spawn, + Wait, + Timeout, + Reader, + PipeDrainTimeout, + OutputLimit, +} + +impl PackageQueryFailure { + pub(super) const fn negative_cause(self) -> NegativeCause { + match self { + Self::Timeout | Self::PipeDrainTimeout => NegativeCause::Timeout, + Self::OutputLimit => NegativeCause::MalformedOutput, + Self::Spawn | Self::Wait | Self::Reader => NegativeCause::ProcessTermination, + } + } +} + +pub(super) fn run_package_query( + command: &mut Command, + output_limit: usize, +) -> Result { + run_package_query_with_timeout(command, output_limit, PACKAGE_QUERY_TIMEOUT) +} + +pub(super) fn run_package_query_with_timeout( + command: &mut Command, + output_limit: usize, + timeout: Duration, +) -> Result { + // A provider may launch helpers that keep the output pipe open after its leader exits + command.process_group(0); + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_error| PackageQueryFailure::Spawn)?; + // The child is its new process-group leader because process_group received zero + let process_group = Pid::from_child(&child); + let Some(stdout) = child.stdout.take() else { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Reader); + }; + let (reader_tx, reader_rx) = mpsc::sync_channel(1); + let reader = std::thread::Builder::new() + .name("unixnotis-package-output".to_string()) + .spawn(move || { + let limit = u64::try_from(output_limit) + .unwrap_or(u64::MAX) + .saturating_add(1); + let mut output = Vec::new(); + let read_result = stdout.take(limit).read_to_end(&mut output); + let _send_result = reader_tx.send(read_result.map(|_bytes| output)); + }) + .map_err(|_error| { + terminate_package_query(&mut child, process_group); + PackageQueryFailure::Reader + })?; + // The result channel owns completion; dropping the handle avoids every unbounded join path + drop(reader); + + let started = Instant::now(); + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Timeout); + } + Err(_error) => { + terminate_package_query(&mut child, process_group); + return Err(PackageQueryFailure::Wait); + } + }; + let remaining = timeout.saturating_sub(started.elapsed()); + let drain_timeout = remaining.min(PACKAGE_PIPE_DRAIN_TIMEOUT); + let stdout = match reader_rx.recv_timeout(drain_timeout) { + Ok(Ok(stdout)) => stdout, + Ok(Err(_)) | Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(PackageQueryFailure::Reader); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + // The leader exited, so only inherited pipe holders remain in its process group + let _kill_result = kill_process_group(process_group, Signal::KILL); + return Err(PackageQueryFailure::PipeDrainTimeout); + } + }; + if stdout.len() > output_limit { + return Err(PackageQueryFailure::OutputLimit); + } + Ok(PackageQueryOutput { status, stdout }) +} + +pub(super) fn terminate_package_query(child: &mut std::process::Child, process_group: Pid) { + // Group termination closes ordinary inherited pipes while the bounded reap avoids startup hangs + if kill_process_group(process_group, Signal::KILL).is_err() { + let _kill_result = child.kill(); + } + let _wait_result = child.wait_timeout(PACKAGE_PIPE_DRAIN_TIMEOUT); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs new file mode 100644 index 000000000..ec9b33fad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/query.rs @@ -0,0 +1,190 @@ +//! Package-provider discovery, batching, and output parsing + +use std::collections::HashMap; +use std::os::unix::ffi::OsStrExt; +use std::path::PathBuf; +use std::process::Command; + +use super::super::super::executable::executable_evidence_for_path; +use super::cache::OwnershipLookup; +use super::process::run_package_query; +use super::rpm::query_rpm_ownership; +use super::{InstallProvenance, PackageProvider}; + +pub(super) const MAX_COMMAND_ARGUMENT_BYTES: usize = 192 * 1024; +pub(super) const MAX_COMMAND_PATHS: usize = 4_096; +const MAX_OWNERSHIP_OUTPUT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PACKAGE_ID_BYTES: usize = 256; + +#[derive(Debug, Clone)] +pub(super) struct PackageProviderCommand { + pub(super) provider: PackageProvider, + pub(super) executable: PathBuf, +} + +pub(super) fn detect_package_provider() -> Option { + [ + ("pacman", PackageProvider::Pacman), + ("dpkg-query", PackageProvider::Dpkg), + ("rpm", PackageProvider::Rpm), + ] + .into_iter() + .find_map(|(program, provider)| { + let executable = unixnotis_core::util::trusted_system_program_path(program)?; + let evidence = executable_evidence_for_path(&executable)?; + // Provider output affects attribution, so user-writable commands are never accepted + (evidence.identity.is_system_managed() && evidence.identity.is_executable_regular()) + .then_some(PackageProviderCommand { + provider, + executable: evidence.canonical_path, + }) + }) +} + +pub(super) fn query_package_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + match provider.provider { + PackageProvider::Pacman => query_in_chunks(provider, paths, &["-Qo"], parse_pacman_output), + PackageProvider::Dpkg => query_in_chunks(provider, paths, &["--search"], parse_dpkg_output), + // RPM output does not retain each selector, so bounded workers query paths separately + PackageProvider::Rpm => query_rpm_ownership(provider, paths), + } +} + +fn query_in_chunks( + provider: &PackageProviderCommand, + paths: &[PathBuf], + arguments: &[&str], + parser: OwnershipOutputParser, +) -> HashMap { + let mut resolved = HashMap::new(); + let mut remaining = paths; + while remaining.split_first().is_some() { + // A one-path floor preserves progress even if a future chunk policy returns zero + let chunk_len = ownership_chunk_len(remaining).max(1).min(remaining.len()); + let (chunk, next) = remaining.split_at(chunk_len); + let mut command = Command::new(&provider.executable); + command + .args(arguments) + .args(chunk) + .env_clear() + .env("LC_ALL", "C"); + match run_package_query(&mut command, MAX_OWNERSHIP_OUTPUT_BYTES) { + Ok(output) => { + let parsed = parser(&output.stdout, chunk, provider.provider); + for path in chunk { + let lookup = parsed.get(path).cloned().map_or_else( + || { + if output.status.success() && output.stdout.is_empty() { + OwnershipLookup::Negative(super::cache::NegativeCause::NotOwned) + } else if output.status.success() { + OwnershipLookup::Negative( + super::cache::NegativeCause::MalformedOutput, + ) + } else { + OwnershipLookup::Negative( + super::cache::NegativeCause::ProviderFailure, + ) + } + }, + OwnershipLookup::Known, + ); + resolved.insert(path.clone(), lookup); + } + } + Err(error) => { + let cause = error.negative_cause(); + resolved.extend( + chunk + .iter() + .cloned() + .map(|path| (path, OwnershipLookup::Negative(cause))), + ); + } + } + remaining = next; + } + resolved +} + +pub(super) fn ownership_chunk_len(paths: &[PathBuf]) -> usize { + let mut bytes = 0_usize; + let mut end = 0_usize; + while end < paths.len() && end < MAX_COMMAND_PATHS { + let next = paths[end].as_os_str().as_bytes().len().saturating_add(1); + // The first path always advances so even an oversized selector cannot stall the scan + if end > 0 && bytes.saturating_add(next) > MAX_COMMAND_ARGUMENT_BYTES { + break; + } + bytes = bytes.saturating_add(next); + end = end.saturating_add(1); + } + end +} + +type OwnershipOutputParser = + fn(&[u8], &[PathBuf], PackageProvider) -> HashMap; + +pub(super) fn parse_pacman_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let marker = b" is owned by "; + let position = line + .windows(marker.len()) + .position(|window| window == marker)?; + let path = expected.get(&line[..position])?; + let package = line.get(position.saturating_add(marker.len())..)?; + let package = package.split(|byte| *byte == b' ').next()?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +pub(super) fn parse_dpkg_output( + output: &[u8], + paths: &[PathBuf], + provider: PackageProvider, +) -> HashMap { + let expected = paths + .iter() + .map(|path| (path.as_os_str().as_bytes(), path)) + .collect::>(); + output + .split(|byte| *byte == b'\n') + .filter_map(|line| { + let position = line.windows(2).rposition(|window| window == b": ")?; + let package = line.get(..position)?.split(|byte| *byte == b',').next()?; + let path = expected.get(line.get(position.saturating_add(2)..)?)?; + package_provenance(provider, package).map(|owner| ((*path).clone(), owner)) + }) + .collect() +} + +pub(super) fn package_provenance( + provider: PackageProvider, + package: &[u8], +) -> Option { + if package.is_empty() + || package.len() > MAX_PACKAGE_ID_BYTES + || package + .iter() + .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace()) + { + return None; + } + Some(InstallProvenance::Package { + provider, + package_id: std::str::from_utf8(package).ok()?.to_string(), + }) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs new file mode 100644 index 000000000..c72fccf56 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/rpm.rs @@ -0,0 +1,106 @@ +//! Bounded RPM ownership queries + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use super::cache::{NegativeCause, OwnershipLookup}; +use super::process::run_package_query_with_timeout; +use super::query::PackageProviderCommand; + +const MAX_RPM_QUERY_PATHS: usize = 4_096; +const MAX_RPM_QUERY_WORKERS: usize = 8; +const RPM_TOTAL_QUERY_TIMEOUT: Duration = Duration::from_secs(2); +const PACKAGE_QUERY_TIMEOUT: Duration = Duration::from_secs(1); +const MAX_PACKAGE_ID_BYTES: usize = 256; + +pub(super) fn query_rpm_ownership( + provider: &PackageProviderCommand, + paths: &[PathBuf], +) -> HashMap { + query_rpm_ownership_with(paths, RPM_TOTAL_QUERY_TIMEOUT, &|path, timeout| { + query_rpm_owner(provider, path, timeout) + }) +} + +pub(super) fn query_rpm_ownership_with( + paths: &[PathBuf], + total_timeout: Duration, + query: &Query, +) -> HashMap +where + Query: Fn(&Path, Duration) -> OwnershipLookup + Sync, +{ + let bounded_len = paths.len().min(MAX_RPM_QUERY_PATHS); + let bounded = &paths[..bounded_len]; + let next = AtomicUsize::new(0); + let results = Mutex::new(HashMap::with_capacity(bounded_len)); + let deadline = Instant::now() + .checked_add(total_timeout) + .unwrap_or_else(Instant::now); + let worker_count = bounded_len.min(MAX_RPM_QUERY_WORKERS); + + std::thread::scope(|scope| { + let mut workers = Vec::with_capacity(worker_count); + for worker in 0..worker_count { + let spawn = std::thread::Builder::new() + .name(format!("unixnotis-rpm-owner-{worker}")) + .spawn_scoped(scope, || loop { + let path_index = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = bounded.get(path_index) else { + break; + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let lookup = query(path, remaining.min(PACKAGE_QUERY_TIMEOUT)); + if let Ok(mut results) = results.lock() { + results.insert(path.clone(), lookup); + } + }); + if let Ok(worker) = spawn { + workers.push(worker); + } + } + for worker in workers { + let _worker_result = worker.join(); + } + }); + + results.into_inner().unwrap_or_default() +} + +pub(super) fn query_rpm_owner( + provider: &PackageProviderCommand, + path: &Path, + timeout: Duration, +) -> OwnershipLookup { + let mut command = std::process::Command::new(&provider.executable); + command + .args(["-qf", "--queryformat", "%{NAME}\n"]) + .arg(path) + .env_clear() + .env("LC_ALL", "C"); + let output = match run_package_query_with_timeout( + &mut command, + MAX_PACKAGE_ID_BYTES.saturating_add(1), + timeout, + ) { + Ok(output) => output, + Err(error) => return OwnershipLookup::Negative(error.negative_cause()), + }; + if !output.status.success() { + return OwnershipLookup::Negative(NegativeCause::ProviderFailure); + } + let package = output.stdout.strip_suffix(b"\n").unwrap_or(&output.stdout); + if package.is_empty() { + return OwnershipLookup::Negative(NegativeCause::NotOwned); + } + super::query::package_provenance(provider.provider, package).map_or( + OwnershipLookup::Negative(NegativeCause::MalformedOutput), + OwnershipLookup::Known, + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs new file mode 100644 index 000000000..a9e5cc020 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/cache.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; +use std::time::Instant; + +use super::super::cache::{ + CachedProvenance, NegativeCause, OwnershipLookup, PackageOwnershipCache, + NOT_OWNED_NEGATIVE_TTL, TRANSIENT_NEGATIVE_TTL, +}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn transient_ownership_failures_expire_before_confirmed_not_owned_entries() { + let now = Instant::now(); + let transient = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::Timeout), now); + let not_owned = + CachedProvenance::from_lookup(OwnershipLookup::Negative(NegativeCause::NotOwned), now); + + assert!(!transient.needs_refresh(now)); + assert!(transient.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(!not_owned.needs_refresh(now + TRANSIENT_NEGATIVE_TTL)); + assert!(not_owned.needs_refresh(now + NOT_OWNED_NEGATIVE_TTL)); + assert_eq!(transient.provenance(), InstallProvenance::Unknown); +} + +#[test] +fn cached_known_provenance_is_returned_for_every_requested_path() { + let path = PathBuf::from("/usr/bin/example-cache-entry"); + let expected = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-cache-entry".to_string(), + }; + let cache = PackageOwnershipCache::default(); + cache + .entries + .lock() + .expect("package cache should be writable") + .insert(path.clone(), CachedProvenance::Known(expected.clone())); + + let resolved = cache.resolve_many([path.clone()]); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get(&path), Some(&expected)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs new file mode 100644 index 000000000..b28293fad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/mod.rs @@ -0,0 +1,5 @@ +mod cache; +mod model; +mod process; +mod query; +mod rpm; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs new file mode 100644 index 000000000..f76b7a7b7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/model.rs @@ -0,0 +1,42 @@ +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn matching_package_sources_establish_one_installation_owner() { + let desktop = InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }; + let executable = desktop.clone(); + + assert!(desktop.same_application_source(&executable)); + assert!( + !desktop.same_application_source(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "shared-runtime".to_string(), + }) + ); + assert!(!desktop.same_application_source(&InstallProvenance::Unknown)); +} +#[test] +fn bundle_and_portal_provenance_require_exact_domain_identity() { + let bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.App".to_string(), + }; + let same_bundle = bundle.clone(); + let other_bundle = InstallProvenance::ImmutableBundle { + bundle_id: "org.example.Other".to_string(), + }; + let portal = InstallProvenance::Portal { + app_id: "org.example.App".to_string(), + }; + let same_portal = portal.clone(); + let other_portal = InstallProvenance::Portal { + app_id: "org.example.Other".to_string(), + }; + + assert!(bundle.same_application_source(&same_bundle)); + assert!(!bundle.same_application_source(&other_bundle)); + assert!(!bundle.same_application_source(&portal)); + assert!(portal.same_application_source(&same_portal)); + assert!(!portal.same_application_source(&other_portal)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs new file mode 100644 index 000000000..c36ce094e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/process.rs @@ -0,0 +1,104 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use super::super::process::{ + run_package_query, run_package_query_with_timeout, PackageQueryFailure, +}; + +#[test] +fn package_query_deadline_stops_a_stalled_provider() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 2"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(20)); + + assert!( + matches!(output, Err(PackageQueryFailure::Timeout)), + "a stalled provider should report its deadline" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "the package provider deadline should stop a stalled process promptly" + ); +} + +#[test] +fn package_query_rejects_output_beyond_the_declared_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 12345"]); + + assert!( + run_package_query_with_timeout(&mut command, 4, Duration::from_secs(1)).is_err(), + "oversized provider output must fail closed" + ); +} + +#[test] +fn package_query_accepts_successful_output_at_the_exact_limit() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf 1234"]); + + let output = run_package_query(&mut command, 4) + .expect("successful provider output at the exact limit should be retained"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"1234"); +} + +#[test] +fn package_query_returns_when_descendant_holds_stdout_open() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "(sleep 2) & exit 0"]); + let started = Instant::now(); + + let output = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + + assert!( + matches!(output, Err(PackageQueryFailure::PipeDrainTimeout)), + "an inherited output pipe should report a bounded drain timeout" + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "an inherited output pipe must not block desktop-index construction" + ); +} +#[test] +fn timed_out_package_provider_is_terminated_before_returning() { + let serial = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should follow the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-package-timeout-{}-{}", + std::process::id(), + serial + )); + fs::create_dir_all(&root).expect("package timeout test root should be created"); + let pid_file = root.join("provider.pid"); + let mut command = Command::new("/bin/sh"); + command + .args([ + "-c", + "printf '%s' \"$$\" > \"$1\"; exec sleep 2", + "unixnotis-package-timeout", + ]) + .arg(&pid_file); + + let result = run_package_query_with_timeout(&mut command, 1024, Duration::from_millis(100)); + assert!(matches!(result, Err(PackageQueryFailure::Timeout))); + let pid = fs::read_to_string(&pid_file).expect("provider should publish its process id"); + let process_path = Path::new("/proc").join(pid.trim()); + let reap_deadline = Instant::now() + Duration::from_millis(250); + while process_path.exists() && Instant::now() < reap_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + + assert!( + !process_path.exists(), + "a timed-out provider must not continue after the ownership query returns" + ); + fs::remove_dir_all(root).expect("package timeout test root should be removable"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs new file mode 100644 index 000000000..8624735e3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/query.rs @@ -0,0 +1,140 @@ +use std::path::PathBuf; + +use super::super::cache::{NegativeCause, OwnershipLookup}; +use super::super::query::{ + ownership_chunk_len, package_provenance, parse_dpkg_output, parse_pacman_output, + query_package_ownership, PackageProviderCommand, MAX_COMMAND_ARGUMENT_BYTES, MAX_COMMAND_PATHS, +}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn pacman_output_is_mapped_to_the_exact_queried_path() { + let desktop = PathBuf::from("/usr/share/applications/example.desktop"); + let executable = PathBuf::from("/usr/bin/example"); + let output = b"/usr/bin/example is owned by example-app 2.0-1\n\ +/usr/share/applications/example.desktop is owned by example-app 2.0-1\n"; + + let ownership = parse_pacman_output( + output, + &[desktop.clone(), executable.clone()], + PackageProvider::Pacman, + ); + + for path in [desktop, executable] { + assert_eq!( + ownership.get(&path), + Some(&InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: "example-app".to_string(), + }), + "the exact queried file should retain its package owner" + ); + } +} + +#[test] +fn dpkg_output_keeps_architecture_qualified_package_identity() { + let executable = PathBuf::from("/usr/bin/example"); + let ownership = parse_dpkg_output( + b"example-app:amd64: /usr/bin/example\n", + std::slice::from_ref(&executable), + PackageProvider::Dpkg, + ); + + assert_eq!( + ownership.get(&executable), + Some(&InstallProvenance::Package { + provider: PackageProvider::Dpkg, + package_id: "example-app:amd64".to_string(), + }) + ); +} + +#[test] +fn malformed_package_identity_is_rejected() { + assert!(package_provenance(PackageProvider::Pacman, b"").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad package").is_none()); + assert!(package_provenance(PackageProvider::Pacman, b"bad\npackage").is_none()); +} +#[test] +fn short_package_paths_share_one_bounded_provider_query() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + assert_eq!(ownership_chunk_len(&paths), paths.len()); +} + +#[test] +fn package_query_chunk_never_exceeds_the_path_count_limit() { + let paths = (0..=MAX_COMMAND_PATHS) + .map(|index| PathBuf::from(format!("p{index}"))) + .collect::>(); + + assert_eq!(ownership_chunk_len(&paths), MAX_COMMAND_PATHS); +} + +#[test] +fn oversized_first_package_selector_still_advances_exactly_one_path() { + let paths = [ + PathBuf::from("x".repeat(MAX_COMMAND_ARGUMENT_BYTES.saturating_add(1))), + PathBuf::from("next"), + ]; + + assert_eq!(ownership_chunk_len(&paths), 1); +} + +#[test] +fn package_query_chunk_accepts_the_exact_argument_byte_limit() { + let first_bytes = MAX_COMMAND_ARGUMENT_BYTES.saturating_sub(3); + let paths = [PathBuf::from("x".repeat(first_bytes)), PathBuf::from("y")]; + + assert_eq!(ownership_chunk_len(&paths), 2); +} + +#[test] +fn ownership_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Pacman, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert_eq!( + resolved.get(&path), + Some(&OwnershipLookup::Negative(NegativeCause::MalformedOutput)), + "successful but unrecognized provider output must remain a transient failure" + ); + } +} + +#[test] +fn rpm_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert!( + resolved.contains_key(&path), + "each RPM selector should receive a classified result" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs new file mode 100644 index 000000000..d34f10ae8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/provenance/tests/rpm.rs @@ -0,0 +1,83 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use super::super::cache::{NegativeCause, OwnershipLookup}; +use super::super::query::{query_package_ownership, PackageProviderCommand}; +use super::super::rpm::{query_rpm_owner, query_rpm_ownership_with}; +use super::super::{InstallProvenance, PackageProvider}; + +#[test] +fn rpm_bulk_resolution_maps_each_queried_path() { + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + PathBuf::from("/usr/share/applications/example.desktop"), + ]; + + let ownership = query_rpm_ownership_with(&paths, Duration::from_secs(1), &|path, _timeout| { + let package_id = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name") + .to_string(); + OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id, + }) + }); + + for path in paths { + let expected = path + .file_name() + .and_then(|name| name.to_str()) + .expect("fixture path should have a UTF-8 file name"); + assert_eq!( + ownership.get(&path), + Some(&OwnershipLookup::Known(InstallProvenance::Package { + provider: PackageProvider::Rpm, + package_id: expected.to_string(), + })), + "each RPM query result must remain bound to its requested path" + ); + } +} +#[test] +fn rpm_query_returns_a_classified_result_for_each_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/echo"), + }; + let paths = [ + PathBuf::from("/usr/bin/example-one"), + PathBuf::from("/usr/bin/example-two"), + ]; + + let resolved = query_package_ownership(&provider, &paths); + + assert_eq!(resolved.len(), paths.len()); + for path in paths { + assert!( + resolved.contains_key(&path), + "each RPM selector should receive a classified result" + ); + } +} + +#[test] +fn failed_rpm_process_is_not_reported_as_a_confirmed_unowned_path() { + let provider = PackageProviderCommand { + provider: PackageProvider::Rpm, + executable: PathBuf::from("/bin/false"), + }; + + let result = query_rpm_owner( + &provider, + Path::new("/usr/bin/example"), + Duration::from_secs(1), + ); + + assert_eq!( + result, + OwnershipLookup::Negative(NegativeCause::ProviderFailure) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs new file mode 100644 index 000000000..322cada44 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/record.rs @@ -0,0 +1,246 @@ +//! Desktop-entry parsing and indexed record construction + +use std::collections::HashSet; +use std::path::Path; + +use gio::prelude::AppInfoExt; + +use super::super::executable::executable_evidence_for_path; +use super::launch::build_launch_spec; +use super::model::{DesktopIdentityIndex, DesktopRecord}; +use super::names::{normalize_desktop_id, normalize_name}; +use super::provenance::InstallProvenance; + +impl DesktopIdentityIndex { + pub(super) fn add_desktop_file(&mut self, path: &Path, system_origin: bool) { + // GIO applies desktop-entry parsing rules before any identity is indexed + let Some(desktop) = gio::DesktopAppInfo::from_filename(path) else { + return; + }; + let Some(id) = desktop + .id() + .map(|value| normalize_desktop_id(value.as_str())) + else { + return; + }; + if id.is_empty() { + return; + } + let display_name = desktop.display_name().to_string(); + if desktop_categories_are_communication(&desktop) { + self.communication_desktop_ids.insert(id.clone()); + } + // Wrapper normalization finds the application executable instead of indexing env itself + let parsed_launch = build_launch_spec(&desktop, path); + let declared_executable_path = parsed_launch + .as_ref() + .map(|launch| launch.declared_path.clone()); + let declared_executable_identity = parsed_launch + .as_ref() + .map(|launch| launch.spec.declared_executable); + let runtime_executable_path = parsed_launch + .as_ref() + .map(|launch| launch.runtime_path.clone()); + let runtime_executable_identity = parsed_launch + .as_ref() + .map(|launch| launch.spec.runtime_executable); + let desktop_identity = executable_evidence_for_path(path).map(|evidence| evidence.identity); + let launch_spec = parsed_launch.map(|launch| launch.spec); + // Every association needs a complete Exec contract instead of a runtime-name exception + let association_eligible = launch_spec.is_some(); + // System association requires protected metadata and a reproducible launch specification + // Package ownership is attached in one bounded batch after scanning finishes + let system_association = false; + let badge_icon = desktop + .string("Icon") + .map_or_else(|| id.clone(), |value| value.to_string()); + let names = association_aliases(&desktop, &id, &display_name); + + self.index_record(DesktopRecord { + id, + display_name, + badge_icon, + desktop_path: Some(path.to_path_buf()), + declared_executable_path, + declared_executable_identity, + runtime_executable_path, + runtime_executable_identity, + desktop_identity, + desktop_provenance: InstallProvenance::Unknown, + declared_executable_provenance: InstallProvenance::Unknown, + runtime_executable_provenance: InstallProvenance::Unknown, + system_origin, + system_association, + association_eligible, + launch_spec, + names, + }); + } + + pub(super) fn finalize_install_provenance(&mut self) { + let paths = self + .records + .iter() + .filter(|record| record.system_origin) + .flat_map(|record| { + record + .desktop_path + .iter() + .chain(record.declared_executable_path.iter()) + .chain(record.runtime_executable_path.iter()) + .cloned() + }) + .collect::>(); + let ownership = self.package_ownership.resolve_many(paths); + + for record in &mut self.records { + if !record.system_origin { + continue; + } + record.desktop_provenance = record + .desktop_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + record.declared_executable_provenance = record + .declared_executable_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + record.runtime_executable_provenance = record + .runtime_executable_path + .as_ref() + .and_then(|path| ownership.get(path)) + .cloned() + .unwrap_or(InstallProvenance::Unknown); + + // A parsed target is promoted only when all protected files share one source + if !runtime_binding_is_valid(record) { + discard_untrusted_launcher_binding(record); + } + + record.system_association = record.association_eligible + && record + .desktop_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .declared_executable_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .runtime_executable_identity + .is_some_and(super::super::executable::FileIdentity::is_system_managed) + && record + .launch_spec + .as_ref() + .is_some_and(|spec| spec.literal_files_are_system_managed) + && record + .desktop_provenance + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) + && installed_identity_is_current( + record.declared_executable_path.as_deref(), + record.declared_executable_identity, + ) + && installed_identity_is_current( + record.runtime_executable_path.as_deref(), + record.runtime_executable_identity, + ); + } + self.rebuild_executable_index(); + self.rebuild_application_families(); + } +} + +fn desktop_categories_are_communication(desktop: &gio::DesktopAppInfo) -> bool { + desktop + .string("Categories") + .is_some_and(|categories| categories.split(';').any(is_communication_category)) +} + +fn is_communication_category(category: &str) -> bool { + matches!( + category.to_ascii_lowercase().as_str(), + "chat" | "instantmessaging" | "email" | "telephony" + ) +} + +fn discard_untrusted_launcher_binding(record: &mut DesktopRecord) { + let Some(spec) = record.launch_spec.as_mut() else { + return; + }; + spec.package_launcher = None; + + // Falling back to the declared file preserves ordinary direct-executable behavior + spec.runtime_executable = spec.declared_executable; + record.runtime_executable_path = record.declared_executable_path.clone(); + record.runtime_executable_identity = record.declared_executable_identity; + record.runtime_executable_provenance = record.declared_executable_provenance.clone(); +} + +fn runtime_binding_is_valid(record: &DesktopRecord) -> bool { + let Some(spec) = record.launch_spec.as_ref() else { + return false; + }; + let direct_identity_matches = spec.declared_executable.same_file(spec.runtime_executable); + let direct_path_matches = record.declared_executable_path == record.runtime_executable_path; + let Some(binding) = spec.package_launcher.as_ref() else { + return direct_identity_matches && direct_path_matches; + }; + + // Package equality is supporting evidence only after the literal file relationship exists + binding + .launcher_identity + .same_file(spec.declared_executable) + && binding.target_identity.same_file(spec.runtime_executable) + && record.declared_executable_path.as_deref() == Some(&binding.launcher_path) + && record.runtime_executable_path.as_deref() == Some(&binding.target_path) + && record + .desktop_provenance + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) +} + +fn installed_identity_is_current( + path: Option<&Path>, + expected: Option, +) -> bool { + let (Some(path), Some(expected)) = (path, expected) else { + return false; + }; + executable_evidence_for_path(path).is_some_and(|current| { + current.identity.same_file(expected) + && current.identity.is_system_managed() + && current.identity.is_executable_regular() + }) +} + +#[cfg(test)] +#[path = "tests/record.rs"] +mod tests; + +fn association_aliases( + desktop: &gio::DesktopAppInfo, + id: &str, + display_name: &str, +) -> HashSet { + // Desktop metadata supplies claim aliases while executable naming stays separate + let mut names = HashSet::from([ + normalize_name(display_name), + normalize_name(desktop.name().as_str()), + normalize_name(id), + ]); + if let Some(generic_name) = desktop.generic_name() { + names.insert(normalize_name(generic_name.as_str())); + } + if let Some(wm_class) = desktop.startup_wm_class() { + names.insert(normalize_name(wm_class.as_str())); + } + names.retain(|name| !name.is_empty()); + names +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs new file mode 100644 index 000000000..117e14663 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/refresh.rs @@ -0,0 +1,359 @@ +//! Debounced desktop-index refresh with atomic snapshot replacement + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use arc_swap::ArcSwap; +use notify::event::{CreateKind, RemoveKind}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +use super::model::DesktopIdentityIndex; + +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(500); +const MIN_REBUILD_INTERVAL: Duration = Duration::from_secs(5); +const REFRESH_SIGNAL_CAPACITY: usize = 1; +const MAX_WATCHED_DIRECTORIES: usize = 4_096; +const FALLBACK_REBUILD_INTERVAL: Duration = Duration::from_secs(90); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefreshTrigger { + Filesystem, + Fallback, + Manual, + WatchError, + RecoveryVerification, +} + +#[derive(Debug, Default)] +struct WatcherHealth { + degraded: AtomicBool, + installed: AtomicBool, +} + +impl WatcherHealth { + fn is_degraded(&self) -> bool { + self.degraded.load(Ordering::Acquire) + } + + fn set_installed(&self, installed: bool) { + self.installed.store(installed, Ordering::Release); + } + + /// Returns true only for the first error from an installed watcher + fn record_error(&self) -> bool { + let first_error = !self.degraded.swap(true, Ordering::AcqRel); + first_error && self.installed.load(Ordering::Acquire) + } + + fn accepts_events(&self) -> bool { + self.installed.load(Ordering::Acquire) + } +} + +struct WatcherInstance { + monitor: W, + active_watches: HashSet, + health: Arc, +} + +type DesktopWatcherInstance = WatcherInstance; + +#[derive(Clone)] +pub struct DesktopIndexRefreshHandle { + refresh_tx: mpsc::Sender, +} + +impl DesktopIndexRefreshHandle { + pub(crate) fn request_manual(&self) -> bool { + match self.refresh_tx.try_send(RefreshTrigger::Manual) { + Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => true, + Err(mpsc::error::TrySendError::Closed(_)) => false, + } + } +} + +pub fn spawn_desktop_index_refresh( + index: Arc>, + watched_directories: Vec, +) -> Result { + let (refresh_tx, refresh_rx) = mpsc::channel(REFRESH_SIGNAL_CAPACITY); + let requested_watches = watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES) + .collect::>(); + let watcher = create_watcher_instance(refresh_tx.clone(), &requested_watches)?; + watcher.health.set_installed(true); + let watch_coverage_incomplete = + has_incomplete_watch_coverage(&requested_watches, &watcher.active_watches); + if watch_coverage_incomplete { + warn!( + requested = requested_watches.len(), + active = watcher.active_watches.len(), + "desktop application watch coverage is incomplete; periodic rebuilds enabled" + ); + } + let worker_refresh_tx = refresh_tx.clone(); + + tokio::spawn(run_refresh_worker( + index, + refresh_rx, + watcher, + worker_refresh_tx, + watch_coverage_incomplete, + )); + + Ok(DesktopIndexRefreshHandle { refresh_tx }) +} + +async fn run_refresh_worker( + index: Arc>, + mut refresh_rx: mpsc::Receiver, + mut watcher: DesktopWatcherInstance, + worker_refresh_tx: mpsc::Sender, + mut watch_coverage_incomplete: bool, +) { + // The watcher stays owned by this task for kernel watches to remain registered + let mut fallback_tick = + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()) + .then(fallback_interval); + let mut last_rebuild = Instant::now() + .checked_sub(MIN_REBUILD_INTERVAL) + .unwrap_or_else(Instant::now); + loop { + let refresh_trigger = match fallback_tick.as_mut() { + Some(tick) => tokio::select! { + signal = refresh_rx.recv() => signal, + _ = tick.tick() => Some(RefreshTrigger::Fallback), + }, + None => refresh_rx.recv().await, + }; + let Some(refresh_trigger) = refresh_trigger else { + break; + }; + update_fallback_timer( + &mut fallback_tick, + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()), + ); + debug!(?refresh_trigger, "desktop application refresh requested"); + tokio::time::sleep(REFRESH_DEBOUNCE).await; + // Drain events that arrived during the debounce window before one complete rebuild + while refresh_rx.try_recv().is_ok() {} + + // Sustained user filesystem activity cannot trigger continuous complete rescans + let remaining = rebuild_delay(last_rebuild.elapsed()); + tokio::time::sleep(remaining).await; + match tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot).await { + Ok(rebuilt) => { + let requested = rebuilt + .watched_directories + .into_iter() + .take(MAX_WATCHED_DIRECTORIES) + .collect::>(); + let mut watcher_recovered = false; + if watcher.health.is_degraded() { + match create_watcher_instance(worker_refresh_tx.clone(), &requested) { + Ok(candidate) => { + watcher_recovered = + install_healthy_replacement(&mut watcher, candidate, &requested); + if watcher_recovered { + debug!( + watched = watcher.active_watches.len(), + "desktop application watcher reconstructed" + ); + queue_recovery_verification(&worker_refresh_tx); + } else { + warn!( + requested = requested.len(), + "replacement desktop watcher was not healthy; retaining degraded watcher and periodic fallback" + ); + } + } + Err(error) => { + warn!(?error, "failed to construct replacement desktop watcher"); + } + } + } + + if !watcher_recovered { + let additions = requested.difference(&watcher.active_watches).cloned(); + let added = add_watch_directories(&mut watcher.monitor, additions); + watcher.active_watches.extend(added); + } + index.store(Arc::new(rebuilt.index)); + if !watcher_recovered { + remove_stale_watches(&mut watcher.monitor, &watcher.active_watches, &requested); + watcher + .active_watches + .retain(|directory| requested.contains(directory)); + } + watch_coverage_incomplete = + has_incomplete_watch_coverage(&requested, &watcher.active_watches); + update_fallback_timer( + &mut fallback_tick, + fallback_required(watch_coverage_incomplete, watcher.health.is_degraded()), + ); + last_rebuild = Instant::now(); + debug!("desktop application identity index refreshed"); + } + Err(error) => { + warn!(?error, "desktop application identity index rebuild failed"); + } + } + } +} + +fn create_watcher_instance( + refresh_tx: mpsc::Sender, + requested: &HashSet, +) -> Result { + let health = Arc::new(WatcherHealth::default()); + let callback_health = Arc::clone(&health); + let mut monitor = notify::recommended_watcher(move |event: notify::Result| { + queue_refresh_event(event, &refresh_tx, &callback_health); + }) + .context("create desktop application watcher")?; + let active_watches = add_watch_directories(&mut monitor, requested.iter().cloned()); + if !registration_is_complete(requested, &active_watches) { + health.degraded.store(true, Ordering::Release); + } + Ok(WatcherInstance { + monitor, + active_watches, + health, + }) +} + +fn has_incomplete_watch_coverage(requested: &HashSet, active: &HashSet) -> bool { + requested.is_empty() || requested != active +} + +fn registration_is_complete(requested: &HashSet, active: &HashSet) -> bool { + requested == active +} + +const fn fallback_required(watch_coverage_incomplete: bool, watcher_degraded: bool) -> bool { + watch_coverage_incomplete || watcher_degraded +} + +fn fallback_interval() -> tokio::time::Interval { + tokio::time::interval_at( + tokio::time::Instant::now() + FALLBACK_REBUILD_INTERVAL, + FALLBACK_REBUILD_INTERVAL, + ) +} + +fn update_fallback_timer(fallback_tick: &mut Option, required: bool) { + match (required, fallback_tick.is_some()) { + (true, false) => *fallback_tick = Some(fallback_interval()), + (false, true) => *fallback_tick = None, + _ => {} + } +} + +const fn rebuild_delay(elapsed: Duration) -> Duration { + MIN_REBUILD_INTERVAL.saturating_sub(elapsed) +} + +fn queue_refresh_event( + event: notify::Result, + refresh_tx: &mpsc::Sender, + health: &WatcherHealth, +) { + match event { + Ok(event) if health.accepts_events() && relevant_desktop_event(&event) => { + // A single pending signal coalesces filesystem bursts without blocking the watcher + let _ = refresh_tx.try_send(RefreshTrigger::Filesystem); + } + Ok(_) => {} + Err(error) => { + warn!(?error, "desktop application watcher reported an error"); + // Setup errors mark only the candidate; installed errors wake the worker once + if health.record_error() { + let _ = refresh_tx.try_send(RefreshTrigger::WatchError); + } + } + } +} + +fn queue_recovery_verification(refresh_tx: &mpsc::Sender) { + let _ = refresh_tx.try_send(RefreshTrigger::RecoveryVerification); +} + +fn install_healthy_replacement( + current: &mut WatcherInstance, + candidate: WatcherInstance, + requested: &HashSet, +) -> bool { + if !registration_is_complete(requested, &candidate.active_watches) + || candidate.health.is_degraded() + { + return false; + } + // The candidate may receive events before the old instance is dropped + candidate.health.set_installed(true); + current.health.set_installed(false); + *current = candidate; + true +} + +fn relevant_desktop_event(event: &Event) -> bool { + // Folder changes alter the bounded nonrecursive watch set + let folder_event = matches!( + event.kind, + EventKind::Create(CreateKind::Folder) | EventKind::Remove(RemoveKind::Folder) + ); + folder_event + || event.paths.iter().any(|path| { + path.extension().and_then(|extension| extension.to_str()) == Some("desktop") + || path.is_dir() + }) +} + +fn add_watch_directories(file_monitor: &mut W, directories: I) -> HashSet +where + W: Watcher, + I: IntoIterator, +{ + let mut registered = HashSet::new(); + let mut failed = 0_usize; + for directory in directories { + if file_monitor + .watch(Path::new(&directory), RecursiveMode::NonRecursive) + .is_ok() + { + registered.insert(directory); + } else { + failed += 1; + } + } + if failed != 0 { + // One bounded summary avoids attacker-controlled path and error log floods + warn!( + failed, + "some desktop application directories could not be watched" + ); + } + registered +} + +fn remove_stale_watches( + file_monitor: &mut W, + active: &HashSet, + requested: &HashSet, +) where + W: Watcher, +{ + for directory in active.difference(requested) { + let _ = file_monitor.unwatch(directory); + } +} + +#[cfg(test)] +#[path = "tests/refresh.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs new file mode 100644 index 000000000..7dd3f7c21 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/scan.rs @@ -0,0 +1,202 @@ +//! Bounded desktop-entry discovery and record construction + +use std::path::{Path, PathBuf}; + +use tracing::debug; + +use super::model::DesktopIdentityIndex; + +const MAX_DESKTOP_RECORDS: usize = 8_192; +const MAX_DIRECTORIES_VISITED: usize = 4_096; +const MAX_ENTRIES_VISITED: usize = 65_536; +const MAX_DIRECTORY_DEPTH: usize = 16; +const MAX_DESKTOP_FILE_BYTES: u64 = 256 * 1024; + +pub struct DesktopIndexSnapshot { + pub index: DesktopIdentityIndex, + pub watched_directories: Vec, +} + +#[derive(Debug, Copy, Clone)] +pub(in crate::daemon::notifications::identity) struct ScanLimits { + pub(super) records: usize, + pub(super) directories: usize, + pub(super) entries: usize, + pub(super) depth: usize, + pub(super) file_bytes: u64, +} + +impl Default for ScanLimits { + fn default() -> Self { + Self { + // Each trust class gets half of every global budget + records: MAX_DESKTOP_RECORDS / 2, + directories: MAX_DIRECTORIES_VISITED / 2, + entries: MAX_ENTRIES_VISITED / 2, + depth: MAX_DIRECTORY_DEPTH, + file_bytes: MAX_DESKTOP_FILE_BYTES, + } + } +} + +#[derive(Debug, Default)] +pub(in crate::daemon::notifications::identity) struct ScanBudget { + pub(super) records: usize, + pub(super) directories: usize, + pub(super) entries: usize, + pub(super) skipped_files: usize, + pub(super) stopped_by: Option<&'static str>, + pub(super) visited_directories: Vec, +} + +impl ScanBudget { + fn stop(&mut self, reason: &'static str) { + self.stopped_by.get_or_insert(reason); + } + + const fn exhausted(&self) -> bool { + self.stopped_by.is_some() + } +} + +impl DesktopIdentityIndex { + #[must_use] + pub(crate) fn build_snapshot() -> DesktopIndexSnapshot { + Self::build_with_roots(desktop_roots(), &ScanLimits::default()) + } + + pub(super) fn build_with_roots( + roots: Vec<(PathBuf, bool)>, + limits: &ScanLimits, + ) -> DesktopIndexSnapshot { + let mut index = Self::default(); + // User-controlled trees and protected trees receive independent resource budgets + let mut user_budget = ScanBudget::default(); + let mut system_budget = ScanBudget::default(); + for (root, system_entry) in roots { + let budget = if system_entry { + &mut system_budget + } else { + &mut user_budget + }; + // Exhausting one trust class must not prevent the other class from being indexed + if !budget.exhausted() { + index.scan_root(&root, system_entry, limits, budget); + } + } + for (scope, budget) in [("user", &user_budget), ("system", &system_budget)] { + if budget.exhausted() || budget.skipped_files != 0 { + // One summary avoids log floods from attacker-controlled application trees + debug!( + scope, + stopped_by = budget.stopped_by.unwrap_or("none"), + records = budget.records, + directories = budget.directories, + entries = budget.entries, + skipped_files = budget.skipped_files, + "desktop application scan reached a safety limit" + ); + } + } + // One ownership batch ties protected desktop and executable files to one install source + index.finalize_install_provenance(); + // Relay trust is tied to the installed file identity instead of its basename + index.index_trusted_relay(Path::new("/usr/bin/notify-send")); + index.index_trusted_relay(Path::new("/usr/local/bin/notify-send")); + // Portal backends carry broker-verified application ids into desktop notifications + for directory in [ + "/usr/lib", + "/usr/libexec", + "/usr/local/lib", + "/usr/local/libexec", + ] { + index.index_trusted_portals_in(Path::new(directory)); + } + let watched_directories = user_budget + .visited_directories + .into_iter() + .chain(system_budget.visited_directories) + .collect(); + DesktopIndexSnapshot { + index, + watched_directories, + } + } + + pub(super) fn scan_root( + &mut self, + root: &Path, + system_entry: bool, + limits: &ScanLimits, + budget: &mut ScanBudget, + ) { + // A bounded iterative walk avoids recursion and unlimited desktop-file growth + let mut pending = vec![(root.to_path_buf(), 0_usize)]; + while let Some((directory, depth)) = pending.pop() { + if budget.directories >= limits.directories { + budget.stop("directory budget"); + return; + } + budget.directories += 1; + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + // Only readable directories can contribute records or useful kernel watches + budget.visited_directories.push(directory); + for entry in entries { + if budget.entries >= limits.entries { + budget.stop("entry budget"); + return; + } + budget.entries += 1; + let Ok(entry) = entry else { + continue; + }; + let path = entry.path(); + let Ok(metadata) = path.symlink_metadata() else { + continue; + }; + let file_type = metadata.file_type(); + if file_type.is_dir() { + if depth >= limits.depth { + budget.stop("directory depth"); + return; + } + pending.push((path, depth + 1)); + continue; + } + if !file_type.is_file() + || path.extension().and_then(|value| value.to_str()) != Some("desktop") + { + continue; + } + if metadata.len() > limits.file_bytes { + budget.skipped_files += 1; + continue; + } + if budget.records >= limits.records { + budget.stop("record budget"); + return; + } + let records_before = self.records.len(); + self.add_desktop_file(&path, system_entry); + budget.records += self.records.len().saturating_sub(records_before); + } + } + } +} + +pub(super) fn desktop_roots() -> Vec<(PathBuf, bool)> { + let mut roots = Vec::new(); + // The user data root remains distinct because its entries are not system evidence + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) + { + roots.push((data_home.join("applications"), false)); + } + let data_dirs = + std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + roots.extend(std::env::split_paths(&data_dirs).map(|root| (root.join("applications"), true))); + roots +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs new file mode 100644 index 000000000..bb5fcbd68 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/launch.rs @@ -0,0 +1,402 @@ +use std::collections::HashSet; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::super::launch::{ + build_launch_spec, MAX_EXEC_TEMPLATE_ARGUMENTS, MAX_EXEC_TEMPLATE_BYTES, +}; +use super::super::model::{FieldCode, LaunchArgument, LaunchSpec, LiteralArgument}; +use crate::daemon::notifications::identity::executable::{ + executable_evidence_for_path, FileIdentity, +}; +use crate::test_support::TempRoot; + +const MAX_PROCESS_ARGUMENTS: usize = 256; + +fn launch_spec_matches_sender( + spec: &LaunchSpec, + sender_identity: FileIdentity, + cmdline: &[Vec], +) -> bool { + if !spec.runtime_executable.same_file(sender_identity) + || cmdline.is_empty() + || cmdline.len() > MAX_PROCESS_ARGUMENTS + { + return false; + } + if !literal_file_identities_are_current(spec) { + return false; + } + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, &cmdline[1..], 0, 0, &mut visited) +} + +fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} + +fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + actual.get(actual_index) == Some(&literal.value) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 1, + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments(template, actual, template_index + 1, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index + 1) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index + 1, + actual_index + 2, + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let values = actual + .get(actual_index..actual_index + count) + .unwrap_or_default(); + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index + 1, + actual_index + count, + visited, + ) { + return true; + } + } + false +} + +fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} + +#[test] +fn fixed_immutable_application_argument_is_matched_exactly() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let immutable_script = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + assert!(immutable_script.identity.is_system_managed()); + + let root = TempRoot::new("launch-spec-shared-runtime"); + let path = root.join("org.example.Script.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Script\nExec=/usr/bin/sh /usr/bin/true %U\n", + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; + + assert!(launch_spec_matches_sender( + &spec, + shell.identity, + &[ + b"/usr/bin/sh".to_vec(), + b"/usr/bin/true".to_vec(), + b"file:///tmp/input".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + shell.identity, + &[ + b"/usr/bin/sh".to_vec(), + b"/tmp/fake-script".to_vec(), + b"file:///tmp/input".to_vec(), + ], + )); +} + +#[test] +fn user_writable_literal_payload_cannot_support_a_system_association() { + let root = TempRoot::new("launch-spec-user-payload"); + let payload = root.join("application-script"); + fs::write(&payload, "exit 0\n").expect("write user payload"); + // Make the fixture mutable even when the test runner itself uses uid zero + fs::set_permissions(&payload, fs::Permissions::from_mode(0o666)) + .expect("make payload user writable"); + let desktop_path = root.join("org.example.UserPayload.desktop"); + fs::write( + &desktop_path, + format!( + "[Desktop Entry]\nType=Application\nName=User Payload\nExec=/usr/bin/sh {}\n", + payload.display() + ), + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&desktop_path).expect("parse desktop entry"); + + let spec = build_launch_spec(&desktop, &desktop_path) + .expect("build launch spec") + .spec; + + assert!(!spec.literal_files_are_system_managed); +} + +#[test] +fn launch_spec_rejects_unmodeled_flags_and_invalid_url_fields() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let root = TempRoot::new("launch-spec-fields"); + let path = root.join("org.example.True.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=True\nExec=/usr/bin/true --fixed %u\n", + ) + .expect("write desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; + + assert!(launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--fixed".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--unexpected".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &[ + b"/usr/bin/true".to_vec(), + b"--fixed".to_vec(), + b"--not-a-url".to_vec(), + ], + )); +} + +#[test] +fn launch_spec_enforces_template_size_and_argument_limits_at_the_boundary() { + let root = TempRoot::new("launch-spec-limits"); + let executable_prefix = "/usr/bin/true "; + + for (name, template, accepted) in [ + ( + "exact-bytes", + format!( + "{executable_prefix}{}", + "x".repeat(MAX_EXEC_TEMPLATE_BYTES - executable_prefix.len()) + ), + true, + ), + ( + "too-many-bytes", + format!( + "{executable_prefix}{}", + "x".repeat(MAX_EXEC_TEMPLATE_BYTES + 1 - executable_prefix.len()) + ), + false, + ), + ( + "exact-arguments", + std::iter::once("/usr/bin/true") + .chain(std::iter::repeat_n("x", MAX_EXEC_TEMPLATE_ARGUMENTS - 1)) + .collect::>() + .join(" "), + true, + ), + ( + "too-many-arguments", + std::iter::once("/usr/bin/true") + .chain(std::iter::repeat_n("x", MAX_EXEC_TEMPLATE_ARGUMENTS)) + .collect::>() + .join(" "), + false, + ), + ] { + let path = root.join(format!("{name}.desktop")); + fs::write( + &path, + format!("[Desktop Entry]\nType=Application\nName=Limits\nExec={template}\n"), + ) + .expect("write boundary desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path) + .unwrap_or_else(|| panic!("parse {name} desktop entry")); + + assert_eq!( + build_launch_spec(&desktop, &path).is_some(), + accepted, + "{name}" + ); + } +} + +#[test] +fn launch_spec_parses_every_supported_desktop_field_code() { + let root = TempRoot::new("launch-spec-field-codes"); + let path = root.join("org.example.Fields.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Fields\nIcon=field-icon\nExec=/usr/bin/true %f %F %u %U %c %k %i\n", + ) + .expect("write field-code desktop entry"); + let desktop = gio::DesktopAppInfo::from_filename(&path).expect("parse desktop entry"); + let spec = build_launch_spec(&desktop, &path) + .expect("build launch spec") + .spec; + + assert!(matches!( + spec.arguments[0], + LaunchArgument::FieldCode(FieldCode::File) + )); + assert!(matches!( + spec.arguments[1], + LaunchArgument::FieldCode(FieldCode::Files) + )); + assert!(matches!( + spec.arguments[2], + LaunchArgument::FieldCode(FieldCode::Url) + )); + assert!(matches!( + spec.arguments[3], + LaunchArgument::FieldCode(FieldCode::Urls) + )); + assert!(matches!( + &spec.arguments[4], + LaunchArgument::Literal(argument) if argument.value == b"Fields" + )); + assert!(matches!( + &spec.arguments[5], + LaunchArgument::Literal(argument) + if argument.value == path.as_os_str().as_encoded_bytes() + )); + assert!(matches!( + &spec.arguments[6], + LaunchArgument::OptionalIcon { name } if name == "field-icon" + )); +} + +#[test] +fn process_matcher_checks_identity_emptiness_and_argument_limits_independently() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system fixture"); + let other = executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other fixture"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let exact_limit = + std::iter::repeat_n(b"input".to_vec(), MAX_PROCESS_ARGUMENTS).collect::>(); + let over_limit = + std::iter::repeat_n(b"input".to_vec(), MAX_PROCESS_ARGUMENTS + 1).collect::>(); + + assert!(launch_spec_matches_sender( + &spec, + executable.identity, + &exact_limit + )); + assert!(!launch_spec_matches_sender( + &spec, + other.identity, + &exact_limit + )); + assert!(!launch_spec_matches_sender(&spec, executable.identity, &[])); + assert!(!launch_spec_matches_sender( + &spec, + executable.identity, + &over_limit + )); +} + +#[test] +fn field_values_reject_empty_and_option_shaped_arguments_independently() { + assert!(!field_value_matches(FieldCode::File, b"")); + assert!(!field_value_matches(FieldCode::Files, b"--option")); + assert!(field_value_matches(FieldCode::File, b"relative-file")); + assert!(field_value_matches( + FieldCode::Url, + b"https://example.invalid/item" + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs new file mode 100644 index 000000000..5ff4b3c5a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/mod.rs @@ -0,0 +1,2 @@ +mod parsing; +mod scan; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs new file mode 100644 index 000000000..e20b614fe --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/parsing.rs @@ -0,0 +1,120 @@ +use std::fs; + +use super::super::model::{LaunchArgument, LaunchWrapper}; +use super::super::DesktopIdentityIndex; +use crate::test_support::TempRoot; + +#[test] +fn dbus_activated_desktop_entry_without_exec_has_no_executable() { + let root = TempRoot::new("desktop-without-exec"); + let path = root.join("org.example.NoExec.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=No Exec\nDBusActivatable=true\n", + ) + .expect("desktop entry without Exec"); + let mut index = DesktopIdentityIndex::default(); + index.add_desktop_file(&path, true); + assert_eq!(index.records.len(), 1); + assert!(index.records[0].runtime_executable_path.is_none()); + assert!(!index.records[0].system_association); +} + +#[test] +fn desktop_entry_exec_is_resolved_to_its_application_program() { + let root = TempRoot::new("desktop-with-exec"); + let path = root.join("org.example.True.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=True\nExec=/usr/bin/true %U\n", + ) + .expect("desktop entry with Exec"); + let mut index = DesktopIdentityIndex::default(); + index.add_desktop_file(&path, true); + + assert_eq!( + index.records[0] + .runtime_executable_path + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new("true")) + ); +} + +#[test] +fn env_wrapped_desktop_entry_indexes_the_wrapped_application() { + let root = TempRoot::new("desktop-env-wrapper"); + let path = root.join("org.example.Wrapped.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Wrapped\nExec=/usr/bin/env FEATURE=1 /usr/bin/true --fixed %u\n", + ) + .expect("desktop entry with env wrapper"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + + let record = &index.records[0]; + assert_eq!( + record + .runtime_executable_path + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new("true")) + ); + let spec = record.launch_spec.as_ref().expect("normalized launch spec"); + assert_eq!(spec.wrappers, [LaunchWrapper::Env]); + assert_eq!(spec.environment, [(b"FEATURE".to_vec(), b"1".to_vec())]); + assert!(matches!( + &spec.arguments[0], + LaunchArgument::Literal(argument) if argument.value == b"--fixed" + )); +} + +#[test] +fn generic_name_is_an_association_alias_but_not_a_protected_brand() { + let root = TempRoot::new("desktop-generic-name"); + let path = root.join("org.example.Browser.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Example Browser\nGenericName=Web Browser\nExec=/usr/bin/true\n", + ) + .expect("desktop entry with generic name"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + + assert!(index.records[0].claim_matches("Web Browser")); + assert!(index.claim_matches_system_app("Example Browser")); + assert!(!index.claim_matches_system_app("Web Browser")); +} + +#[test] +fn desktop_categories_mark_conversation_capable_applications() { + let root = TempRoot::new("desktop-communication-category"); + let path = root.join("org.example.Messages.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Messages\nCategories=Network;InstantMessaging;\nExec=/usr/bin/true\n", + ) + .expect("desktop entry with communication category"); + let mut index = DesktopIdentityIndex::default(); + + index.add_desktop_file(&path, true); + + assert!(index.desktop_id_has_communication_role("org.example.messages")); + + // A communication marker without an indexed desktop record is not enough evidence + let mut role_only = DesktopIdentityIndex::default(); + role_only + .communication_desktop_ids + .insert("org.example.role-only".to_string()); + assert!(!role_only.desktop_id_has_communication_role("org.example.role-only")); + + // An indexed record without a communication category must not gain the role + let mut record_only = DesktopIdentityIndex::default(); + record_only + .by_id + .insert("org.example.record-only".to_string(), vec![0]); + assert!(!record_only.desktop_id_has_communication_role("org.example.record-only")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs new file mode 100644 index 000000000..4cb8cfe94 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/record.rs @@ -0,0 +1,133 @@ +//! Runtime-binding provenance and normalization cases + +use std::collections::HashSet; + +use super::{discard_untrusted_launcher_binding, runtime_binding_is_valid}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopRecord, LaunchSpec, PackageLauncherBinding, +}; +use crate::daemon::notifications::identity::desktop_index::provenance::{ + InstallProvenance, PackageProvider, +}; +use crate::daemon::notifications::identity::executable::FileIdentity; + +#[test] +fn desktop_launcher_and_target_must_share_package() { + let record = launcher_record("example-app", "example-app", "example-app"); + + assert!(runtime_binding_is_valid(&record)); +} + +#[test] +fn launcher_target_from_different_package_is_rejected() { + let record = launcher_record("example-app", "example-app", "other-runtime"); + + assert!(!runtime_binding_is_valid(&record)); +} + +#[test] +fn same_package_without_literal_launcher_relation_is_not_retained() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + record + .launch_spec + .as_mut() + .expect("launcher launch specification") + .package_launcher = None; + + assert!(!runtime_binding_is_valid(&record)); + discard_untrusted_launcher_binding(&mut record); + let spec = record + .launch_spec + .as_ref() + .expect("normalized direct launch specification"); + assert!(spec.declared_executable.same_file(spec.runtime_executable)); + assert_eq!( + record.declared_executable_path, + record.runtime_executable_path + ); +} + +#[test] +fn direct_runtime_path_must_match_declared_path() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + let spec = record + .launch_spec + .as_mut() + .expect("launcher launch specification"); + spec.package_launcher = None; + spec.runtime_executable = spec.declared_executable; + record.runtime_executable_identity = record.declared_executable_identity; + + assert!(!runtime_binding_is_valid(&record)); +} + +#[test] +fn direct_runtime_identity_must_match_declared_identity() { + let mut record = launcher_record("example-app", "example-app", "example-app"); + record + .launch_spec + .as_mut() + .expect("launcher launch specification") + .package_launcher = None; + record.runtime_executable_path = record.declared_executable_path.clone(); + + assert!(!runtime_binding_is_valid(&record)); +} + +fn launcher_record( + desktop_package: &str, + launcher_package: &str, + runtime_package: &str, +) -> DesktopRecord { + let launcher = identity(41); + let runtime = identity(42); + DesktopRecord { + id: "org.example.App".to_string(), + display_name: "Example App".to_string(), + badge_icon: "example-app".to_string(), + desktop_path: Some("/usr/share/applications/org.example.App.desktop".into()), + declared_executable_path: Some("/usr/bin/example-app".into()), + declared_executable_identity: Some(launcher), + runtime_executable_path: Some("/usr/lib/example-app/runtime".into()), + runtime_executable_identity: Some(runtime), + desktop_identity: Some(identity(40)), + desktop_provenance: package(desktop_package), + declared_executable_provenance: package(launcher_package), + runtime_executable_provenance: package(runtime_package), + system_origin: true, + system_association: false, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: launcher, + runtime_executable: runtime, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/example-app".into(), + launcher_identity: launcher, + launcher_digest: [5; 32], + target_path: "/usr/lib/example-app/runtime".into(), + target_identity: runtime, + }), + literal_files_are_system_managed: true, + }), + names: HashSet::new(), + } +} + +fn identity(inode: u64) -> FileIdentity { + FileIdentity { + device: 1, + inode, + uid: 0, + mode: 0o100_755, + } +} + +fn package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs new file mode 100644 index 000000000..763bf6aaf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/refresh.rs @@ -0,0 +1,287 @@ +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use notify::event::{CreateKind, RemoveKind}; +use notify::{Event, EventKind}; + +use std::time::Duration; + +use super::{ + fallback_required, has_incomplete_watch_coverage, install_healthy_replacement, + queue_refresh_event, rebuild_delay, registration_is_complete, relevant_desktop_event, + DesktopIndexRefreshHandle, RefreshTrigger, WatcherHealth, WatcherInstance, +}; +use crate::test_support::TempRoot; + +#[test] +fn desktop_file_changes_request_an_index_refresh() { + let event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn unrelated_regular_file_changes_do_not_request_an_index_refresh() { + let event = Event::new(EventKind::Any).add_path("notes.txt".into()); + + assert!(!relevant_desktop_event(&event)); +} + +#[test] +fn relevant_event_is_queued_for_the_async_refresh_loop() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + let event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + queue_refresh_event(Ok(event), &refresh_tx, &health); + + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Filesystem)); +} + +#[test] +fn unrelated_event_is_not_queued_for_the_async_refresh_loop() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + let event = Event::new(EventKind::Any).add_path("notes.txt".into()); + + queue_refresh_event(Ok(event), &refresh_tx, &health); + + assert_eq!( + refresh_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); +} + +#[test] +fn watcher_errors_request_fallback_refreshes() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + + queue_refresh_event( + Err(notify::Error::generic("watcher failure")), + &refresh_tx, + &health, + ); + + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::WatchError)); + assert!(health.is_degraded()); +} + +#[test] +fn watcher_error_is_retained_when_trigger_channel_is_full() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let health = WatcherHealth::default(); + health.set_installed(true); + let filesystem_event = Event::new(EventKind::Any).add_path("org.example.App.desktop".into()); + + queue_refresh_event(Ok(filesystem_event), &refresh_tx, &health); + queue_refresh_event( + Err(notify::Error::generic("watcher failure")), + &refresh_tx, + &health, + ); + + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Filesystem)); + assert_eq!( + refresh_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); + assert!(health.is_degraded()); +} + +#[test] +fn degraded_watcher_keeps_fallback_after_rebuild_with_full_coverage() { + assert!(fallback_required(true, false)); + assert!(fallback_required(false, true)); + assert!(!fallback_required(false, false)); +} + +#[test] +fn manual_refresh_is_enqueued_without_running_a_second_worker() { + let (refresh_tx, mut refresh_rx) = tokio::sync::mpsc::channel(1); + let handle = DesktopIndexRefreshHandle { refresh_tx }; + + assert!(handle.request_manual()); + assert_eq!(refresh_rx.try_recv(), Ok(RefreshTrigger::Manual)); +} + +#[test] +fn existing_directory_changes_request_watch_set_refresh() { + let root = TempRoot::new("desktop-refresh-directory"); + let directory = root.join("nested"); + fs::create_dir(&directory).expect("create watched directory fixture"); + let event = Event::new(EventKind::Any).add_path(directory); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn removed_directory_events_request_watch_set_refresh() { + let event = + Event::new(EventKind::Remove(RemoveKind::Folder)).add_path("removed-directory".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn created_directory_events_request_watch_set_refresh() { + let event = Event::new(EventKind::Create(CreateKind::Folder)).add_path("new-directory".into()); + + assert!(relevant_desktop_event(&event)); +} + +#[test] +fn rebuild_delay_enforces_the_minimum_interval_without_oversleeping() { + assert_eq!( + rebuild_delay(Duration::from_secs(2)), + Duration::from_secs(3) + ); + assert_eq!(rebuild_delay(Duration::from_secs(5)), Duration::ZERO); + assert_eq!(rebuild_delay(Duration::from_secs(8)), Duration::ZERO); +} + +#[test] +fn incomplete_watch_coverage_requires_periodic_rebuilds() { + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let one_active = HashSet::from([PathBuf::from("/apps/a")]); + let empty = HashSet::new(); + + assert!(has_incomplete_watch_coverage(&requested, &one_active)); + assert!(has_incomplete_watch_coverage(&requested, &empty)); + assert!(has_incomplete_watch_coverage(&empty, &empty)); + assert!(!has_incomplete_watch_coverage(&requested, &requested)); +} + +#[test] +fn equal_watch_counts_do_not_imply_complete_coverage() { + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let active = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/c")]); + + assert!(has_incomplete_watch_coverage(&requested, &active)); + assert!(!registration_is_complete(&requested, &active)); +} + +#[test] +fn setup_errors_mark_a_candidate_without_waking_the_worker() { + let health = WatcherHealth::default(); + + assert!(!health.record_error()); + assert!(health.is_degraded()); +} + +#[test] +fn installed_watcher_error_wakes_the_worker_once() { + let health = WatcherHealth::default(); + health.set_installed(true); + + assert!(health.record_error()); + assert!(!health.record_error()); + assert!(health.is_degraded()); +} + +#[test] +fn partial_replacement_is_rejected() { + let requested = HashSet::from([PathBuf::from("/apps/a"), PathBuf::from("/apps/b")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate = WatcherInstance { + monitor: (), + active_watches: HashSet::from([PathBuf::from("/apps/a")]), + health: Arc::new(WatcherHealth::default()), + }; + + assert!(!install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(current.health.accepts_events()); + assert!(Arc::ptr_eq(¤t.health, &old_health)); +} + +#[test] +fn degraded_replacement_is_rejected() { + let requested = HashSet::from([PathBuf::from("/apps/a")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate_health = Arc::new(WatcherHealth::default()); + candidate_health.record_error(); + let candidate = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: candidate_health, + }; + + assert!(!install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(current.health.accepts_events()); + assert!(Arc::ptr_eq(¤t.health, &old_health)); +} + +#[test] +fn healthy_replacement_transfers_event_ownership() { + let requested = HashSet::from([PathBuf::from("/apps/a")]); + let old_health = Arc::new(WatcherHealth::default()); + old_health.set_installed(true); + old_health.record_error(); + let candidate_health = Arc::new(WatcherHealth::default()); + let candidate_health_for_assertion = Arc::clone(&candidate_health); + let mut current = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: Arc::clone(&old_health), + }; + let candidate = WatcherInstance { + monitor: (), + active_watches: requested.clone(), + health: candidate_health, + }; + + assert!(install_healthy_replacement( + &mut current, + candidate, + &requested + )); + assert!(!current.health.is_degraded()); + assert!(current.health.accepts_events()); + assert!(!old_health.accepts_events()); + old_health.record_error(); + assert!(!current.health.is_degraded()); + assert!(Arc::ptr_eq( + ¤t.health, + &candidate_health_for_assertion + )); +} + +#[test] +fn empty_registration_needs_fallback_but_can_be_replaced() { + let empty = HashSet::new(); + assert!(registration_is_complete(&empty, &empty)); + assert!(has_incomplete_watch_coverage(&empty, &empty)); +} + +#[test] +fn successful_replacement_queues_recovery_verification() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + super::queue_recovery_verification(&tx); + + assert_eq!(rx.try_recv(), Ok(RefreshTrigger::RecoveryVerification)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs new file mode 100644 index 000000000..14437b6ea --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/scan.rs @@ -0,0 +1,178 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use super::super::scan::{ScanBudget, ScanLimits}; +use super::super::DesktopIdentityIndex; +use crate::test_support::TempRoot; + +#[test] +fn scan_rejects_oversized_desktop_files_before_parsing() { + let root = TempRoot::new("desktop-size-budget"); + let path = root.join("large.desktop"); + fs::write(&path, "x".repeat(65)).expect("oversized desktop fixture"); + let limits = ScanLimits { + file_bytes: 64, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert!(index.records.is_empty()); + assert_eq!(budget.skipped_files, 1); +} + +#[test] +fn scan_accepts_a_regular_desktop_file_at_the_exact_size_limit() { + let root = TempRoot::new("desktop-exact-size-budget"); + let contents = "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n"; + fs::write(root.join("exact.desktop"), contents).expect("exact-size desktop fixture"); + let limits = ScanLimits { + file_bytes: u64::try_from(contents.len()).expect("fixture length fits u64"), + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(index.records.len(), 1); + assert_eq!(budget.skipped_files, 0); +} + +#[test] +fn scan_never_follows_a_desktop_file_symlink() { + let root = TempRoot::new("desktop-symlink"); + let target = root.join("target.txt"); + fs::write( + &target, + "[Desktop Entry]\nType=Application\nName=Linked\nExec=/usr/bin/true\n", + ) + .expect("symlink target fixture"); + symlink(&target, root.join("linked.desktop")).expect("desktop symlink fixture"); + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &ScanLimits::default(), &mut budget); + + assert!(index.records.is_empty()); +} + +#[test] +fn scan_stops_when_the_global_entry_budget_is_exhausted() { + let root = TempRoot::new("desktop-entry-budget"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + root.join(name), + "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n", + ) + .expect("desktop fixture"); + } + let limits = ScanLimits { + entries: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.entries, 1); + assert_eq!(budget.stopped_by, Some("entry budget")); + assert!(index.records.len() <= 1); +} + +#[test] +fn scan_stops_before_crossing_the_directory_depth_budget() { + let root = TempRoot::new("desktop-depth-budget"); + fs::create_dir_all(root.join("one/two")).expect("nested application directories"); + let limits = ScanLimits { + depth: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.stopped_by, Some("directory depth")); +} + +#[test] +fn scan_stops_when_the_global_directory_budget_is_exhausted() { + let root = TempRoot::new("desktop-directory-budget"); + fs::create_dir_all(root.join("one")).expect("first application directory"); + fs::create_dir_all(root.join("two")).expect("second application directory"); + let limits = ScanLimits { + directories: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(budget.directories, 1); + assert_eq!(budget.stopped_by, Some("directory budget")); +} + +#[test] +fn scan_stops_when_the_global_record_budget_is_exhausted() { + let root = TempRoot::new("desktop-record-budget"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + root.join(name), + "[Desktop Entry]\nType=Application\nName=App\nExec=/usr/bin/true\n", + ) + .expect("desktop fixture"); + } + let limits = ScanLimits { + records: 1, + ..ScanLimits::default() + }; + let mut budget = ScanBudget::default(); + let mut index = DesktopIdentityIndex::default(); + + index.scan_root(root.path(), false, &limits, &mut budget); + + assert_eq!(index.records.len(), 1); + assert_eq!(budget.stopped_by, Some("record budget")); +} + +#[test] +fn exhausted_user_budget_does_not_block_system_desktop_records() { + let root = TempRoot::new("desktop-separate-budgets"); + let user_root = root.join("user"); + let system_root = root.join("system"); + fs::create_dir_all(&user_root).expect("create user application directory"); + fs::create_dir_all(&system_root).expect("create system application directory"); + for name in ["one.desktop", "two.desktop"] { + fs::write( + user_root.join(name), + "[Desktop Entry]\nType=Application\nName=User App\nExec=/usr/bin/true\n", + ) + .expect("user desktop fixture"); + } + fs::write( + system_root.join("system.desktop"), + "[Desktop Entry]\nType=Application\nName=System App\nExec=/usr/bin/true\n", + ) + .expect("system desktop fixture"); + let limits = ScanLimits { + records: 1, + ..ScanLimits::default() + }; + + let snapshot = DesktopIdentityIndex::build_with_roots( + vec![(user_root, false), (system_root, true)], + &limits, + ); + + assert!(snapshot + .index + .records + .iter() + .any(|record| record.system_origin && record.display_name == "System App")); + assert_eq!(snapshot.watched_directories.len(), 2); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs new file mode 100644 index 000000000..694c97a3d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/tests/wrappers.rs @@ -0,0 +1,114 @@ +use super::{normalize_launch_command, ExecParseError}; +use crate::daemon::notifications::identity::desktop_index::model::LaunchWrapper; + +#[test] +fn env_wrapper_preserves_environment_and_exposes_the_application_command() { + let normalized = normalize_launch_command( + [ + "/usr/bin/env", + "-i", + "-u", + "OLD_VALUE", + "FEATURE=1", + "--", + "example-app", + "--fixed", + "%u", + ] + .into_iter() + .map(str::to_string) + .collect(), + ) + .expect("normalize env command"); + + assert_eq!(normalized.executable, "example-app"); + assert_eq!(normalized.arguments, ["--fixed", "%u"]); + assert_eq!( + normalized.environment, + vec![(b"FEATURE".to_vec(), b"1".to_vec())] + ); + assert_eq!(normalized.wrappers, [LaunchWrapper::Env]); +} + +#[test] +fn nested_env_wrappers_are_normalized_without_application_specific_rules() { + let normalized = normalize_launch_command( + ["env", "A=1", "/usr/bin/env", "B=2", "example-app"] + .into_iter() + .map(str::to_string) + .collect(), + ) + .expect("normalize nested env command"); + + assert_eq!(normalized.executable, "example-app"); + assert_eq!(normalized.environment.len(), 2); + assert_eq!( + normalized.wrappers, + [LaunchWrapper::Env, LaunchWrapper::Env] + ); +} + +#[test] +fn unsupported_or_incomplete_env_syntax_fails_closed() { + for (tokens, expected) in [ + ( + vec!["env".to_string(), "-S".to_string(), "app".to_string()], + ExecParseError::UnsupportedWrapper, + ), + ( + vec!["env".to_string(), "-u".to_string()], + ExecParseError::MalformedEnvCommand, + ), + ( + vec!["env".to_string(), "FEATURE=1".to_string()], + ExecParseError::MissingWrappedCommand, + ), + ] { + assert_eq!(normalize_launch_command(tokens), Err(expected)); + } +} + +#[test] +fn each_supported_env_control_advances_to_the_wrapped_command() { + for tokens in [ + vec!["env", "-i", "example-app"], + vec!["env", "--ignore-environment", "example-app"], + vec!["env", "-u", "OLD_VALUE", "example-app"], + vec!["env", "--unset=OLD_VALUE", "example-app"], + vec!["env", "--", "example-app"], + ] { + let normalized = + normalize_launch_command(tokens.into_iter().map(str::to_string).collect::>()) + .expect("supported env control should expose wrapped command"); + + assert_eq!(normalized.executable, "example-app"); + assert!(normalized.arguments.is_empty()); + } +} + +#[test] +fn environment_names_follow_portable_identifier_rules() { + for accepted in ["A=1", "_A=1", "A_1=value", "A="] { + let normalized = normalize_launch_command(vec![ + "env".to_string(), + accepted.to_string(), + "example-app".to_string(), + ]) + .expect("portable environment assignment"); + + assert_eq!(normalized.environment.len(), 1, "{accepted}"); + } + + for rejected in ["1A=value", "=value", "A-B=value", "A.B=value"] { + let normalized = normalize_launch_command(vec![ + "env".to_string(), + rejected.to_string(), + "example-app".to_string(), + ]) + .expect("invalid assignment becomes the wrapped command"); + + assert_eq!(normalized.executable, rejected, "{rejected}"); + assert_eq!(normalized.arguments, ["example-app"], "{rejected}"); + assert!(normalized.environment.is_empty(), "{rejected}"); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs new file mode 100644 index 000000000..01794aeed --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/authority.rs @@ -0,0 +1,69 @@ +//! Launch-authority classification + +use super::super::model::{ + DesktopIdentityIndex, DesktopRecord, LaunchArgument, LaunchAuthority, LaunchSpec, + LiteralArgument, +}; + +pub(super) fn classify_launch_authority( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> LaunchAuthority { + if spec.arguments.iter().any(is_protected_payload) { + return LaunchAuthority::ProtectedPayload; + } + + if executable_contract_is_dedicated(record, index, spec) { + return LaunchAuthority::DedicatedExecutable; + } + + // Dynamic documents are safe only after the executable establishes the application + if spec.arguments.iter().any(is_dynamic_document_field) { + return LaunchAuthority::DynamicOnly; + } + + LaunchAuthority::Ambiguous +} + +pub(super) fn executable_contract_is_dedicated( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + spec: &LaunchSpec, +) -> bool { + record.system_origin + && record.system_association + && spec.declared_executable.is_system_managed() + && spec.declared_executable.is_executable_regular() + && spec.runtime_executable.is_system_managed() + && spec.runtime_executable.is_executable_regular() + && record + .desktop_provenance + .same_application_source(&record.declared_executable_provenance) + && record + .desktop_provenance + .same_application_source(&record.runtime_executable_provenance) + && index.records_form_one_application_family(spec.runtime_executable, record.system_origin) + && !spec.arguments.iter().any(is_unprotected_fixed_payload) +} +pub(super) fn is_protected_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(LiteralArgument { + file: Some(_), + value, + }) if !value.starts_with(b"-") + ) +} + +pub(super) const fn is_dynamic_document_field(argument: &LaunchArgument) -> bool { + matches!(argument, LaunchArgument::FieldCode(_)) +} + +pub(super) fn is_unprotected_fixed_payload(argument: &LaunchArgument) -> bool { + matches!( + argument, + LaunchArgument::Literal(literal) + if !literal.value.starts_with(b"-") && literal.file.is_none() + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs new file mode 100644 index 000000000..025977cc0 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/contract.rs @@ -0,0 +1,279 @@ +//! Ordered desktop launch-contract matching + +use std::collections::HashSet; + +use super::super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::super::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, VerifiedLaunch, +}; +use super::payload::literal_file_matches; +use super::MAX_PROCESS_ARGUMENTS; + +pub(super) fn verify_dedicated( + command_line: &CommandLineEvidence, + spec: &LaunchSpec, +) -> LaunchVerification { + let verified_launch = if spec.package_launcher.is_some() { + VerifiedLaunch::PackageLauncherTarget + } else { + VerifiedLaunch::DedicatedExecutable + }; + match command_line.quality { + // Launcher targets require the original desktop contract to match observed runtime argv + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.package_launcher.is_some() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + } + // An empty contract cannot distinguish an ordinary switch from an active payload + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable + if spec.arguments.is_empty() => + { + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine) + } + // A nonempty package-backed contract still contributes identity when argv was rewritten + CommandLineQuality::RewrittenProcessTitle + | CommandLineQuality::Truncated + | CommandLineQuality::Unavailable => LaunchVerification::Verified(verified_launch), + CommandLineQuality::Structured => { + let actual = command_line.argv.get(1..).unwrap_or_default(); + if actual.len() <= MAX_PROCESS_ARGUMENTS + && match_ordered_dedicated_contract(spec, actual) + { + LaunchVerification::Verified(verified_launch) + } else { + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + } + } + } +} +pub(super) fn match_ordered_dedicated_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_dedicated_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +pub(super) fn match_dedicated_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + // Only standalone runtime switches are non-authoritative after the fixed contract + return actual[actual_index..] + .iter() + .all(|value| value.starts_with(b"-")); + }; + let next_template = template_index.saturating_add(1); + let matches_expected = match argument { + LaunchArgument::Literal(literal) => { + actual + .get(actual_index) + .is_some_and(|value| value == &literal.value) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(1), + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_dedicated_arguments(template, actual, next_template, actual_index, visited) + || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_dedicated_arguments( + template, + actual, + next_template, + actual_index.saturating_add(2), + visited, + )) + } + LaunchArgument::FieldCode(code) => match_dedicated_field( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + }; + if matches_expected { + return true; + } + + // Unknown positional values can select content, so only skip one self-contained option + actual + .get(actual_index) + .is_some_and(|value| value.starts_with(b"-") && value != b"--icon") + && match_dedicated_arguments( + template, + actual, + template_index, + actual_index.saturating_add(1), + visited, + ) +} + +pub(super) fn match_dedicated_field( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_dedicated_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + +pub(super) fn match_ordered_exec_contract(spec: &LaunchSpec, actual: &[Vec]) -> bool { + let mut visited = HashSet::new(); + match_arguments(&spec.arguments, actual, 0, 0, &mut visited) +} + +pub(super) fn match_arguments( + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + if !visited.insert((template_index, actual_index)) { + return false; + } + let Some(argument) = template.get(template_index) else { + return actual_index == actual.len(); + }; + match argument { + LaunchArgument::Literal(literal) => { + let matches = if literal.file.is_some() { + actual + .get(actual_index) + .is_some_and(|value| literal_file_matches(literal, value)) + } else { + actual.get(actual_index) == Some(&literal.value) + }; + matches + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(1), + visited, + ) + } + LaunchArgument::OptionalIcon { name } => { + match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index, + visited, + ) || (actual + .get(actual_index) + .is_some_and(|value| value == b"--icon") + && actual + .get(actual_index.saturating_add(1)) + .is_some_and(|value| value == name.as_bytes()) + && match_arguments( + template, + actual, + template_index.saturating_add(1), + actual_index.saturating_add(2), + visited, + )) + } + LaunchArgument::FieldCode(code) => match_field_code( + *code, + template, + actual, + template_index, + actual_index, + visited, + ), + } +} + +pub(super) fn match_field_code( + code: FieldCode, + template: &[LaunchArgument], + actual: &[Vec], + template_index: usize, + actual_index: usize, + visited: &mut HashSet<(usize, usize)>, +) -> bool { + let maximum = match code { + FieldCode::File | FieldCode::Url => 1, + FieldCode::Files | FieldCode::Urls => actual.len().saturating_sub(actual_index), + }; + for count in 0..=maximum { + let Some(end) = actual_index.checked_add(count) else { + break; + }; + let Some(values) = actual.get(actual_index..end) else { + break; + }; + if !values.iter().all(|value| field_value_matches(code, value)) { + break; + } + if match_arguments( + template, + actual, + template_index.saturating_add(1), + end, + visited, + ) { + return true; + } + } + false +} + +pub(super) fn field_value_matches(code: FieldCode, value: &[u8]) -> bool { + if value.is_empty() || value.starts_with(b"-") { + return false; + } + match code { + FieldCode::File | FieldCode::Files => true, + FieldCode::Url | FieldCode::Urls => std::str::from_utf8(value) + .ok() + .is_some_and(|value| url::Url::parse(value).is_ok()), + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs new file mode 100644 index 000000000..b8ff95a59 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/mod.rs @@ -0,0 +1,74 @@ +//! Evidence-based desktop launch verification + +mod authority; +mod contract; +mod payload; + +#[cfg(test)] +mod tests; + +use super::super::executable::FileIdentity; +use super::super::sender::CommandLineEvidence; +use super::launcher::launcher_binding_is_current; +use super::model::{ + DesktopIdentityIndex, DesktopRecord, LaunchAuthority, LaunchFailure, LaunchVerification, +}; +use authority::classify_launch_authority; +use contract::verify_dedicated; +use payload::{literal_file_identities_are_current, verify_protected_payload}; + +pub(super) const MAX_PROCESS_ARGUMENTS: usize = 256; + +pub(super) fn verify_record_launch( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + sender_identity: FileIdentity, + command_line: &CommandLineEvidence, +) -> LaunchVerification { + verify_record_launch_with( + record, + index, + sender_identity, + command_line, + launcher_binding_is_current, + ) +} + +pub(super) fn verify_record_launch_with( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + sender_identity: FileIdentity, + command_line: &CommandLineEvidence, + binding_is_current: impl FnOnce(&super::model::PackageLauncherBinding) -> bool, +) -> LaunchVerification { + let Some(spec) = record.launch_spec.as_ref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + }; + if spec.wrappers.len() > 16 || spec.environment.len() > 128 { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + } + if !spec.runtime_executable.same_file(sender_identity) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); + } + if spec + .package_launcher + .as_ref() + .is_some_and(|binding| !binding_is_current(binding)) + { + return LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged); + } + if !literal_file_identities_are_current(spec) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ProtectedPayloadMismatch); + } + + match classify_launch_authority(record, index, spec) { + LaunchAuthority::DedicatedExecutable => verify_dedicated(command_line, spec), + LaunchAuthority::ProtectedPayload => verify_protected_payload(command_line, spec), + LaunchAuthority::DynamicOnly => { + LaunchVerification::InsufficientEvidence(LaunchFailure::DynamicOnlyContract) + } + LaunchAuthority::Ambiguous => { + LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation) + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs new file mode 100644 index 000000000..a88b18116 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/payload.rs @@ -0,0 +1,95 @@ +//! Protected payload and file-identity verification + +use std::collections::HashSet; +use std::path::Path; + +use super::super::super::executable::executable_evidence_for_path; +use super::super::super::sender::{CommandLineEvidence, CommandLineQuality}; +use super::super::model::{ + LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, VerifiedLaunch, +}; +use super::authority::is_protected_payload; +use super::contract::{match_arguments, match_ordered_exec_contract}; +use super::MAX_PROCESS_ARGUMENTS; + +pub(super) fn verify_protected_payload( + command_line: &CommandLineEvidence, + spec: &LaunchSpec, +) -> LaunchVerification { + match command_line.quality { + CommandLineQuality::Unavailable | CommandLineQuality::Truncated => { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine); + } + CommandLineQuality::RewrittenProcessTitle => { + return LaunchVerification::InsufficientEvidence( + LaunchFailure::UnstructuredCommandLine, + ); + } + CommandLineQuality::Structured => {} + } + + let actual = command_line.argv.get(1..).unwrap_or_default(); + if actual.len() > MAX_PROCESS_ARGUMENTS { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnstructuredCommandLine); + } + if match_ordered_exec_contract(spec, actual) { + return LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload); + } + + // A protected file in another argv slot is a decoy, not supporting evidence + // Missing or replaced protected files are equally definitive for structured argv + if protected_payload_position_mismatch(spec, actual) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch); + } + + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) +} +pub(super) fn protected_payload_position_mismatch(spec: &LaunchSpec, actual: &[Vec]) -> bool { + spec.arguments + .iter() + .enumerate() + .filter_map(|(index, argument)| { + let LaunchArgument::Literal(literal) = argument else { + return None; + }; + is_protected_payload(argument).then_some((index, literal)) + }) + .any(|(template_index, literal)| { + !(0..actual.len()).any(|actual_index| { + let mut visited = HashSet::new(); + match_arguments( + &spec.arguments[..template_index], + &actual[..actual_index], + 0, + 0, + &mut visited, + ) && literal_file_matches(literal, &actual[actual_index]) + }) + }) +} + +pub(super) fn literal_file_matches(literal: &LiteralArgument, actual: &[u8]) -> bool { + let Some((_expected_path, expected_identity)) = literal.file.as_ref() else { + return false; + }; + let Ok(actual) = std::str::from_utf8(actual) else { + return false; + }; + executable_evidence_for_path(Path::new(actual)) + .is_some_and(|evidence| evidence.identity.same_file(*expected_identity)) +} + +pub(super) fn literal_file_identities_are_current(spec: &LaunchSpec) -> bool { + spec.arguments.iter().all(|argument| { + let LaunchArgument::Literal(LiteralArgument { + file: Some((path, expected)), + .. + }) = argument + else { + return true; + }; + executable_evidence_for_path(path).is_some_and(|evidence| { + evidence.identity.same_file(*expected) && evidence.identity.is_system_managed() + }) + }) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs new file mode 100644 index 000000000..cd84e6650 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/authority.rs @@ -0,0 +1,286 @@ +use std::collections::HashSet; +use std::path::Path; + +use super::super::authority::{ + classify_launch_authority, executable_contract_is_dedicated, is_protected_payload, +}; +use super::super::verify_record_launch; +use super::support::{record_for_spec, structured_command, test_package}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchSpec, + LaunchVerification, LiteralArgument, VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; + +fn is_dynamic_or_option(argument: &LaunchArgument) -> bool { + match argument { + LaunchArgument::FieldCode(_) | LaunchArgument::OptionalIcon { .. } => true, + LaunchArgument::Literal(literal) => literal.value.starts_with(b"-"), + } +} + +#[test] +fn authority_helpers_distinguish_dynamic_values_options_and_payloads() { + let dynamic = LaunchArgument::FieldCode(FieldCode::Files); + let option = LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }); + let payload = LaunchArgument::Literal(LiteralArgument { + value: b"/usr/share/example/app.bundle".to_vec(), + file: Some(( + "/usr/share/example/app.bundle".into(), + crate::daemon::notifications::identity::FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }, + )), + }); + + assert!(is_dynamic_or_option(&dynamic)); + assert!(is_dynamic_or_option(&option)); + assert!(!is_dynamic_or_option(&payload)); + assert!(!is_protected_payload(&dynamic)); + assert!(is_protected_payload(&payload)); +} +#[test] +fn dynamic_contract_without_shared_provenance_is_not_dedicated() { + for field_code in [FieldCode::Files, FieldCode::Urls] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "Runtime application".to_string(), + badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: None, + desktop_provenance: test_package("runtime-desktop"), + declared_executable_provenance: test_package("runtime"), + runtime_executable_provenance: test_package("runtime"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("single indexed runtime"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a single dynamic record must remain non-authoritative for {field_code:?}" + ); + } +} + +#[test] +fn dedicated_system_application_accepts_dynamic_url_field() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.True".to_string(), + display_name: "True".to_string(), + badge_icon: "true".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("dedicated application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "normal URL arguments must not erase dedicated executable authority" + ); +} + +#[test] +fn dynamic_runtime_requires_matching_immutable_installation_provenance() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Files)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.Runtime".to_string(), + display_name: "True".to_string(), + badge_icon: "runtime".to_string(), + desktop_path: Some("/usr/share/applications/org.example.Runtime.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: Some(executable.identity), + desktop_provenance: test_package("runtime-frontend"), + declared_executable_provenance: test_package("shared-runtime"), + runtime_executable_provenance: test_package("shared-runtime"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::from(["true".to_string()]), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Runtime") + .into_iter() + .next() + .expect("runtime application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "a package-owned shared runtime must not inherit desktop application authority" + ); +} + +#[test] +fn package_backed_file_applications_verify_percent_f_and_percent_capital_f() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + for (field_code, actual) in [ + (FieldCode::File, vec!["/usr/bin/true", "/tmp/image.png"]), + ( + FieldCode::Files, + vec!["/usr/bin/true", "/tmp/first.png", "/tmp/second.png"], + ), + ] { + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![LaunchArgument::FieldCode(field_code)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Viewer", &spec); + record.desktop_provenance = test_package("example-viewer"); + record.declared_executable_provenance = test_package("example-viewer"); + record.runtime_executable_provenance = test_package("example-viewer"); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Viewer") + .into_iter() + .next() + .expect("file application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DedicatedExecutable, + "immutable application ownership should support {field_code:?}" + ); + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&actual), + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "the ordered {field_code:?} contract should accept matching document arguments" + ); + } +} +#[test] +fn dedicated_authority_accepts_document_fields_but_rejects_unprotected_fixed_payloads() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + for (arguments, expected) in [ + (Vec::new(), true), + (vec![LaunchArgument::FieldCode(FieldCode::Url)], true), + (vec![LaunchArgument::FieldCode(FieldCode::File)], true), + ( + vec![LaunchArgument::Literal(LiteralArgument { + value: b"runtime-selected-payload".to_vec(), + file: None, + })], + false, + ), + ] { + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record_for_spec("org.example.True", &spec)); + let record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("indexed dedicated boundary record"); + + assert_eq!( + executable_contract_is_dedicated(record, &index, &spec), + expected, + "arguments={:?}", + spec.arguments + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs new file mode 100644 index 000000000..b24a49743 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/contract.rs @@ -0,0 +1,294 @@ +use std::path::Path; + +use super::super::contract::{ + field_value_matches, match_ordered_dedicated_contract, match_ordered_exec_contract, + verify_dedicated, +}; +use super::support::structured_command; +use crate::daemon::notifications::identity::desktop_index::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +#[test] +fn ordered_contract_preserves_repeated_literals_and_field_positions() { + let identity = executable_evidence_for_path(Path::new("/usr/bin/true")) + .expect("system executable") + .identity; + let spec = LaunchSpec { + declared_executable: identity, + runtime_executable: identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"safe".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--mode".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert!(match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"safe".to_vec(), + b"--mode".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); + assert!(!match_ordered_exec_contract( + &spec, + &[ + b"--mode".to_vec(), + b"--mode".to_vec(), + b"safe".to_vec(), + b"https://example.invalid/item".to_vec(), + ], + )); +} + +#[test] +fn dedicated_contract_does_not_accept_reordered_fixed_options() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--first".to_vec(), + file: None, + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--second".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--first", "--second"]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--second", "--first"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--display=x11", + "--first", + "--tray", + "--second", + "--verbose", + ]), + &spec, + ), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) + ); + assert_eq!( + verify_dedicated( + &structured_command(&[ + "/usr/bin/true", + "--first", + "/tmp/unexpected-payload", + "--second", + ]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn empty_dedicated_contract_rejects_positional_payload() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "/tmp/attacker-payload"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn empty_contract_with_unstructured_argv_is_not_verified() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + for quality in [ + CommandLineQuality::RewrittenProcessTitle, + CommandLineQuality::Truncated, + CommandLineQuality::Unavailable, + ] { + let command_line = CommandLineEvidence { + argv: Vec::new(), + quality, + }; + + assert_eq!( + verify_dedicated(&command_line, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::EmptyContractNeedsCommandLine), + "an empty contract with {quality:?} argv must stay non-authoritative" + ); + } +} + +#[test] +fn empty_contract_accepts_only_non_positional_switches() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + for arguments in [ + vec!["/usr/bin/true"], + vec!["/usr/bin/true", "--verbose"], + vec!["/usr/bin/true", "--display=x11", "-q"], + ] { + assert_eq!( + verify_dedicated(&structured_command(&arguments), &spec), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + "standalone switches should remain compatible: {arguments:?}" + ); + } + assert_eq!( + verify_dedicated( + &structured_command(&["/usr/bin/true", "--title", "untrusted-value"]), + &spec, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a separate option value is positional without an ordered contract" + ); +} +#[test] +fn optional_icon_contract_preserves_its_flag_and_value_relationship() { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: vec![ + LaunchArgument::OptionalIcon { + name: "example-icon".to_string(), + }, + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert_optional_icon_contract(match_ordered_exec_contract, &spec, "protected"); + assert_optional_icon_contract(match_ordered_dedicated_contract, &spec, "dedicated"); +} + +#[test] +fn field_values_reject_empty_options_and_malformed_urls() { + assert!(!field_value_matches(FieldCode::File, b"")); + assert!(!field_value_matches(FieldCode::Files, b"--runtime-option")); + assert!(field_value_matches(FieldCode::File, b"relative-file")); + assert!(field_value_matches( + FieldCode::Url, + b"https://example.invalid/item" + )); + assert!(!field_value_matches(FieldCode::Urls, b"not a URL")); + assert!(!field_value_matches(FieldCode::Url, &[0xff])); +} + +type ContractMatcher = fn(&LaunchSpec, &[Vec]) -> bool; + +fn assert_optional_icon_contract(matcher: ContractMatcher, spec: &LaunchSpec, label: &str) { + for (actual, expected) in [ + (vec![b"--fixed".to_vec()], true), + ( + vec![ + b"--icon".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + true, + ), + ( + vec![ + b"--badge".to_vec(), + b"example-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + ( + vec![ + b"--icon".to_vec(), + b"other-icon".to_vec(), + b"--fixed".to_vec(), + ], + false, + ), + (vec![b"--icon".to_vec(), b"--fixed".to_vec()], false), + ] { + assert_eq!( + matcher(spec, &actual), + expected, + "{label}: actual={actual:?}" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs new file mode 100644 index 000000000..8d0a07ce4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/mod.rs @@ -0,0 +1,5 @@ +mod authority; +mod contract; +mod payload; +mod record; +mod support; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs new file mode 100644 index 000000000..5139f3ff8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/payload.rs @@ -0,0 +1,169 @@ +use std::path::Path; + +use super::super::payload::{ + literal_file_identities_are_current, literal_file_matches, verify_protected_payload, +}; +use super::super::MAX_PROCESS_ARGUMENTS; +use super::support::structured_command; +use crate::daemon::notifications::identity::desktop_index::model::{ + FieldCode, LaunchArgument, LaunchFailure, LaunchSpec, LaunchVerification, LiteralArgument, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +#[test] +fn protected_payload_verification_requires_current_file_identity_and_fixed_arguments() { + let shell = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system shell"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let other = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("other system payload"); + let payload_argument = LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + }; + let spec = LaunchSpec { + declared_executable: shell.identity, + runtime_executable: shell.identity, + arguments: vec![ + LaunchArgument::Literal(payload_argument.clone()), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + + assert!(literal_file_matches(&payload_argument, b"/usr/bin/true")); + assert!(!literal_file_matches(&payload_argument, b"/usr/bin/false")); + assert!(!literal_file_matches(&payload_argument, &[0xff])); + assert!(literal_file_identities_are_current(&spec)); + + let mut stale_spec = spec.clone(); + let LaunchArgument::Literal(stale_payload) = &mut stale_spec.arguments[0] else { + panic!("payload fixture should remain literal"); + }; + stale_payload.file = Some(("/usr/bin/true".into(), other.identity)); + assert!(!literal_file_identities_are_current(&stale_spec)); + + let verified = structured_command(&["/usr/bin/sh", "/usr/bin/true", "--fixed"]); + assert_eq!( + verify_protected_payload(&verified, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); + + let wrong_payload = structured_command(&["/usr/bin/sh", "/usr/bin/false", "--fixed"]); + assert_eq!( + verify_protected_payload(&wrong_payload, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) + ); + + let missing_argument = structured_command(&["/usr/bin/sh", "/usr/bin/true"]); + assert_eq!( + verify_protected_payload(&missing_argument, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch) + ); +} + +#[test] +fn trusted_payload_cannot_be_used_as_a_decoy_argument() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let spec = LaunchSpec { + declared_executable: runtime.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let sender = structured_command(&["/usr/bin/sh", "/usr/bin/false", "/usr/bin/true"]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch), + "a protected file after the active payload must not authenticate the runtime" + ); +} + +#[test] +fn variable_width_field_before_protected_payload_does_not_create_false_conflict() { + let payload = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("protected payload"); + let spec = LaunchSpec { + declared_executable: payload.identity, + runtime_executable: payload.identity, + arguments: vec![ + LaunchArgument::FieldCode(FieldCode::Files), + LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some((Path::new("/usr/bin/true").to_path_buf(), payload.identity)), + }), + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let sender = structured_command(&[ + "/usr/bin/true", + "/tmp/one.txt", + "/tmp/two.txt", + "/usr/bin/true", + "--unexpected", + ]); + + assert_eq!( + verify_protected_payload(&sender, &spec), + LaunchVerification::InsufficientEvidence(LaunchFailure::RequiredArgumentMismatch), + "a matched protected payload must not become contradictory because a later option differs" + ); +} +#[test] +fn protected_payload_accepts_exactly_the_bounded_argument_limit() { + let runtime = executable_evidence_for_path(Path::new("/usr/bin/sh")).expect("system runtime"); + let payload = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system payload"); + let mut arguments = vec![LaunchArgument::Literal(LiteralArgument { + value: b"/usr/bin/true".to_vec(), + file: Some(("/usr/bin/true".into(), payload.identity)), + })]; + arguments.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| { + LaunchArgument::Literal(LiteralArgument { + value: b"--fixed".to_vec(), + file: None, + }) + })); + let spec = LaunchSpec { + declared_executable: runtime.identity, + runtime_executable: runtime.identity, + arguments, + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let mut argv = vec![b"/usr/bin/sh".to_vec(), b"/usr/bin/true".to_vec()]; + argv.extend((1..MAX_PROCESS_ARGUMENTS).map(|_| b"--fixed".to_vec())); + let command = CommandLineEvidence { + argv, + quality: CommandLineQuality::Structured, + }; + + assert_eq!(command.argv.len().saturating_sub(1), MAX_PROCESS_ARGUMENTS); + assert_eq!( + verify_protected_payload(&command, &spec), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs new file mode 100644 index 000000000..a385bfe41 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/record.rs @@ -0,0 +1,248 @@ +use std::collections::HashSet; +use std::path::Path; + +use super::super::authority::classify_launch_authority; +use super::super::{verify_record_launch, verify_record_launch_with}; +use super::support::{record_for_spec, structured_command, test_package}; +use crate::daemon::notifications::identity::desktop_index::model::{ + DesktopIdentityIndex, DesktopRecord, FieldCode, LaunchArgument, LaunchAuthority, LaunchFailure, + LaunchSpec, LaunchVerification, LaunchWrapper, LiteralArgument, PackageLauncherBinding, + VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::CommandLineEvidence; + +#[test] +fn package_launcher_target_verifies_with_matching_runtime_and_ordered_contract() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let binding = PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [7; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }; + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![ + LaunchArgument::Literal(LiteralArgument { + value: b"--".to_vec(), + file: None, + }), + LaunchArgument::FieldCode(FieldCode::Url), + ], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(binding), + literal_files_are_system_managed: true, + }; + let package = test_package("example-chat"); + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_path = Some("/usr/bin/false".into()); + record.declared_executable_identity = Some(launcher.identity); + record.runtime_executable_path = Some("/usr/bin/true".into()); + record.runtime_executable_identity = Some(runtime.identity); + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + let verification = verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&[ + "/usr/bin/true", + "--password-store=desktop", + "--display=x11", + "--", + ]), + |_| true, + ); + + assert_eq!( + verification, + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) + ); +} + +#[test] +fn package_launcher_target_requires_current_binding_and_structured_arguments() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [3; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let mut record = record_for_spec("org.example.Chat", &spec); + record.declared_executable_identity = Some(launcher.identity); + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.Chat") + .into_iter() + .next() + .expect("launcher-backed application record"); + + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &structured_command(&["/usr/bin/true"]), + |_| false, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::LauncherBindingChanged) + ); + assert_eq!( + verify_record_launch_with( + indexed, + &index, + runtime.identity, + &CommandLineEvidence::default(), + |_| true, + ), + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + ); +} + +#[test] +fn shared_launcher_target_does_not_merge_incompatible_application_families() { + let launcher = + executable_evidence_for_path(Path::new("/usr/bin/false")).expect("system launcher"); + let runtime = executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system runtime"); + let spec = LaunchSpec { + declared_executable: launcher.identity, + runtime_executable: runtime.identity, + arguments: vec![LaunchArgument::FieldCode(FieldCode::Url)], + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: Some(PackageLauncherBinding { + launcher_path: "/usr/bin/false".into(), + launcher_identity: launcher.identity, + launcher_digest: [9; 32], + target_path: "/usr/bin/true".into(), + target_identity: runtime.identity, + }), + literal_files_are_system_managed: true, + }; + let package = test_package("example-suite"); + let mut first = record_for_spec("org.example.First", &spec); + let mut second = record_for_spec("org.example.Second", &spec); + for record in [&mut first, &mut second] { + record.desktop_provenance = package.clone(); + record.declared_executable_provenance = package.clone(); + record.runtime_executable_provenance = package.clone(); + } + let mut index = DesktopIdentityIndex::default(); + index.index_record(first); + index.index_record(second); + let indexed = index + .records_for_id("org.example.First") + .into_iter() + .next() + .expect("first application record"); + + assert_eq!( + classify_launch_authority(indexed, &index, &spec), + LaunchAuthority::DynamicOnly, + "one package-owned runtime shared by unrelated families must remain non-authoritative" + ); +} + +#[test] +fn launch_verification_enforces_wrapper_and_environment_limits_at_the_boundary() { + for (wrapper_count, environment_count, expected) in [ + ( + 16, + 0, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 17, + 0, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ( + 0, + 128, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ), + ( + 0, + 129, + LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper), + ), + ] { + let executable = + executable_evidence_for_path(Path::new("/usr/bin/true")).expect("system executable"); + let spec = LaunchSpec { + declared_executable: executable.identity, + runtime_executable: executable.identity, + arguments: Vec::new(), + environment: std::iter::repeat_n((b"A".to_vec(), b"1".to_vec()), environment_count) + .collect(), + wrappers: std::iter::repeat_n(LaunchWrapper::Env, wrapper_count).collect(), + package_launcher: None, + literal_files_are_system_managed: true, + }; + let record = DesktopRecord { + id: "org.example.True".to_string(), + display_name: "Boundary".to_string(), + badge_icon: "boundary".to_string(), + desktop_path: Some("/usr/share/applications/org.example.True.desktop".into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(executable.identity), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(executable.identity), + desktop_identity: None, + desktop_provenance: test_package("true"), + declared_executable_provenance: test_package("true"), + runtime_executable_provenance: test_package("true"), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec), + names: HashSet::new(), + }; + let mut index = DesktopIdentityIndex::default(); + index.index_record(record); + let indexed = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("indexed boundary record"); + + assert_eq!( + verify_record_launch( + indexed, + &index, + executable.identity, + &structured_command(&["/usr/bin/true"]), + ), + expected, + "wrapper_count={wrapper_count}, environment_count={environment_count}" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs new file mode 100644 index 000000000..fa3ed031e --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/verification/tests/support.rs @@ -0,0 +1,45 @@ +use std::collections::HashSet; + +use crate::daemon::notifications::identity::desktop_index::model::{DesktopRecord, LaunchSpec}; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::InstallProvenance; +use crate::daemon::notifications::identity::sender::{CommandLineEvidence, CommandLineQuality}; + +pub(super) fn record_for_spec(id: &str, spec: &LaunchSpec) -> DesktopRecord { + DesktopRecord { + id: id.to_string(), + display_name: "Contract application".to_string(), + badge_icon: "contract".to_string(), + desktop_path: Some(format!("/usr/share/applications/{id}.desktop").into()), + declared_executable_path: Some("/usr/bin/true".into()), + declared_executable_identity: Some(spec.declared_executable), + runtime_executable_path: Some("/usr/bin/true".into()), + runtime_executable_identity: Some(spec.runtime_executable), + desktop_identity: None, + desktop_provenance: test_package(id), + declared_executable_provenance: test_package(id), + runtime_executable_provenance: test_package(id), + system_origin: true, + system_association: true, + association_eligible: true, + launch_spec: Some(spec.clone()), + names: HashSet::new(), + } +} + +pub(super) fn test_package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} + +pub(super) fn structured_command(arguments: &[&str]) -> CommandLineEvidence { + CommandLineEvidence { + argv: arguments + .iter() + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + quality: CommandLineQuality::Structured, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs new file mode 100644 index 000000000..b6c7dd1f4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/desktop_index/wrappers.rs @@ -0,0 +1,128 @@ +//! Generic launch-wrapper normalization before executable identity is resolved + +use super::model::LaunchWrapper; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NormalizedLaunchCommand { + pub(super) executable: String, + pub(super) arguments: Vec, + pub(super) environment: Vec<(Vec, Vec)>, + pub(super) wrappers: Vec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(super) enum ExecParseError { + EmptyCommand, + MalformedEnvCommand, + MissingWrappedCommand, + UnsupportedWrapper, +} + +pub(super) fn normalize_launch_command( + tokens: Vec, +) -> Result { + if tokens.is_empty() { + return Err(ExecParseError::EmptyCommand); + } + + let mut current = tokens; + let mut environment = Vec::new(); + let mut wrappers = Vec::new(); + while let Some(prefix) = unwrap_env(¤t)? { + // Each wrapper consumes a strict prefix and leaves one complete command + environment.extend(prefix.environment); + wrappers.push(prefix.wrapper); + current = prefix.remaining_command; + } + + let mut current = current.into_iter(); + let executable = current.next().ok_or(ExecParseError::EmptyCommand)?; + Ok(NormalizedLaunchCommand { + executable, + arguments: current.collect(), + environment, + wrappers, + }) +} + +struct NormalizedPrefix { + remaining_command: Vec, + environment: Vec<(Vec, Vec)>, + wrapper: LaunchWrapper, +} + +fn unwrap_env(tokens: &[String]) -> Result, ExecParseError> { + let Some(first) = tokens.first() else { + return Err(ExecParseError::EmptyCommand); + }; + if first != "env" && first != "/usr/bin/env" { + return Ok(None); + } + + let mut index = 1; + let mut environment = Vec::new(); + while let Some(token) = tokens.get(index) { + if token == "--" { + advance_index(&mut index, 1)?; + break; + } + if token == "-i" || token == "--ignore-environment" { + advance_index(&mut index, 1)?; + continue; + } + if token == "-u" { + if tokens.get(index + 1).is_none() { + return Err(ExecParseError::MalformedEnvCommand); + } + advance_index(&mut index, 2)?; + continue; + } + if token.starts_with("--unset=") { + advance_index(&mut index, 1)?; + continue; + } + if token.starts_with('-') { + // Options such as -S change tokenization and need a dedicated safe parser + return Err(ExecParseError::UnsupportedWrapper); + } + if let Some((name, value)) = parse_environment_assignment(token) { + environment.push((name.as_bytes().to_vec(), value.as_bytes().to_vec())); + advance_index(&mut index, 1)?; + continue; + } + break; + } + + if index >= tokens.len() { + return Err(ExecParseError::MissingWrappedCommand); + } + Ok(Some(NormalizedPrefix { + remaining_command: tokens[index..].to_vec(), + environment, + wrapper: LaunchWrapper::Env, + })) +} + +fn advance_index(index: &mut usize, amount: usize) -> Result<(), ExecParseError> { + // Checked progress prevents malformed input from wrapping the parser cursor + *index = index + .checked_add(amount) + .ok_or(ExecParseError::MalformedEnvCommand)?; + Ok(()) +} + +fn parse_environment_assignment(value: &str) -> Option<(&str, &str)> { + let (name, assigned) = value.split_once('=')?; + let mut characters = name.chars(); + let first = characters.next()?; + if !(first == '_' || first.is_ascii_alphabetic()) + || !characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + return None; + } + Some((name, assigned)) +} + +#[cfg(test)] +#[path = "tests/wrappers.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs new file mode 100644 index 000000000..cde564f24 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/executable.rs @@ -0,0 +1,86 @@ +//! Stable executable identity captured from open file metadata + +use std::fs::{File, Metadata}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub(in crate::daemon) struct FileIdentity { + pub(super) device: u64, + pub(super) inode: u64, + pub(super) uid: u32, + pub(super) mode: u32, +} + +impl FileIdentity { + pub(super) fn from_metadata(metadata: &Metadata) -> Self { + Self { + device: metadata.dev(), + inode: metadata.ino(), + uid: metadata.uid(), + mode: metadata.mode(), + } + } + + pub(super) const fn same_file(self, other: Self) -> bool { + // Device and inode survive symlink aliases and ordinary path spelling changes + self.device == other.device && self.inode == other.inode + } + + pub(super) const fn is_system_managed(self) -> bool { + // Same-user attackers cannot replace a root-owned non-writable file + self.uid == 0 && self.mode & 0o022 == 0 + } + + pub(super) const fn is_executable_regular(self) -> bool { + // Authority binaries must be regular files with at least one execute bit + self.mode & 0o170_000 == 0o100_000 && self.mode & 0o111 != 0 + } + + pub(super) fn group_fragment(self) -> String { + // Group keys expose no path while remaining stable for the running file + format!("{}:{}", self.device, self.inode) + } +} + +#[derive(Debug, Clone)] +pub(in crate::daemon) struct ExecutableEvidence { + pub(in crate::daemon) canonical_path: PathBuf, + pub(in crate::daemon) identity: FileIdentity, +} + +pub(in crate::daemon) fn executable_evidence_for_pid(pid: u32) -> Option { + let proc_executable = PathBuf::from(format!("/proc/{pid}/exe")); + // Opening the procfs link binds metadata to the running file instead of a mutable path + let file = File::open(&proc_executable).ok()?; + let identity = FileIdentity::from_metadata(&file.metadata().ok()?); + let live_path = std::fs::read_link(&proc_executable).ok()?; + if live_path.as_os_str().as_bytes().ends_with(b" (deleted)") { + // Deleted mappings no longer have a protected installed path to revalidate + return None; + } + let canonical_path = proc_executable + .canonicalize() + .or(Ok::(live_path)) + .ok()?; + Some(ExecutableEvidence { + canonical_path, + identity, + }) +} + +pub(super) fn executable_evidence_for_path(path: &Path) -> Option { + // Open-file metadata prevents a path replacement from changing the checked identity + let file = File::open(path).ok()?; + let identity = FileIdentity::from_metadata(&file.metadata().ok()?); + let canonical_path = path.canonicalize().ok()?; + Some(ExecutableEvidence { + canonical_path, + identity, + }) +} + +#[cfg(test)] +#[path = "tests/executable.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs new file mode 100644 index 000000000..bc5ff2eca --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/mod.rs @@ -0,0 +1,23 @@ +//! Daemon-owned application association from process and desktop metadata + +mod delivery; +mod desktop_index; +mod executable; +mod policy; +mod resolver; +mod sender; +mod sender_cache; + +pub(in crate::daemon) use delivery::resolve_callback_destination; +pub use desktop_index::DesktopIndexRefreshHandle; +pub use desktop_index::DesktopIndexSnapshot; +pub use desktop_index::{spawn_desktop_index_refresh, DesktopIdentityIndex}; +pub(in crate::daemon) use executable::{executable_evidence_for_pid, FileIdentity}; +pub(in crate::daemon) use resolver::resolve_attribution_owned; +pub(in crate::daemon::notifications) use resolver::resolve_attribution_with_deadline; +pub(super) use sender::SenderMetadata; +pub(in crate::daemon) use sender::{ + read_process_start_time, resolve_sender_metadata, SenderMetadataStatus, + SENDER_CREDENTIAL_TIMEOUT, +}; +pub(in crate::daemon) use sender_cache::SenderMetadataCache; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs new file mode 100644 index 000000000..bd3954c13 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/policy.rs @@ -0,0 +1,12 @@ +//! Interaction decisions kept independent from presentation association + +use unixnotis_core::{InlineReplyPolicy, InteractionPolicies}; + +pub(super) const fn inline_reply_policy(interactions: InteractionPolicies) -> InlineReplyPolicy { + // The resolver owns this policy instead of deriving text authority from branding + interactions.inline_reply +} + +#[cfg(test)] +#[path = "tests/policy.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs new file mode 100644 index 000000000..c0c8ee903 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/candidates.rs @@ -0,0 +1,380 @@ +//! Candidate filtering, ranking, and ambiguity handling + +use std::collections::HashSet; + +use unixnotis_core::{ + AttributionReason, AttributionStatus, InteractionPolicies, NotificationAttribution, +}; + +use super::super::desktop_index::{ + normalize_desktop_id, normalize_name, DesktopIdentityIndex, DesktopRecord, LaunchFailure, + LaunchVerification, VerifiedLaunch, +}; +use super::super::sender::SenderMetadata; +use super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::evidence::{candidate_proves_conflict, lineage_association, sender_claim_relation}; +use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; +use super::resolution::{ + conflict_from_candidate, owner_bound_default_interactions, policy_resolution, + recognized_resolution, sender_claim_group_key, +}; +use super::{AppClaim, AttributionResolution}; + +pub(super) fn resolve_unverified_candidates( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + hint_records: &[&DesktopRecord], + results: &[CandidateVerification<'_>], +) -> AttributionResolution { + let matching_results = results + .iter() + .filter(|result| { + index.record_matches_claim(result.record, claim.reported_name) + || (claim.reported_name.trim().is_empty() + && hint_records + .iter() + .any(|record| std::ptr::eq(*record, result.record))) + }) + .collect::>(); + + // A same-user ancestor can explain a helper without authenticating helper-owned actions + if let Some((record, detail)) = lineage_association(sender, index, &matching_results) { + let failure = matching_results + .iter() + .find(|result| std::ptr::eq(result.record, record)) + .map_or(LaunchFailure::ExecutableMismatch, |result| result.failure()); + return with_diagnostics( + recognized_resolution(claim, sender, record, index, failure, &detail), + claim, + sender, + Some(record), + LaunchVerification::InsufficientEvidence(failure), + ); + } + + // Only protected records can turn a caller-provided label into a conflict + let protected_mismatches = matching_results + .iter() + .copied() + .filter(|result| { + result.is_definitive_mismatch() + && result.record.system_origin + && result.record.system_association + && candidate_proves_conflict(sender, index, result) + }) + .collect::>(); + if let Some(first) = protected_mismatches.first().copied() { + // Distinct protected families with the same label are ambiguous, not suspicious + if !protected_mismatches + .iter() + .all(|candidate| index.records_share_family(first.record, candidate.record)) + { + let detail = "Multiple protected desktop application families matched the claim"; + return with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::AmbiguousDesktopRecords, + detail, + sender_claim_group_key( + AttributionStatus::Unresolved, + claim.reported_name, + sender, + ), + )), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence( + LaunchFailure::AmbiguousDesktopAssociation, + ), + ); + } + let mismatch = protected_mismatches + .into_iter() + .max_by_key(|candidate| { + normalize_desktop_id(&candidate.record.id) + == normalize_desktop_id(index.canonical_id_for_record(candidate.record)) + }) + .unwrap_or(first); + return conflict_from_candidate(claim, sender, index, mismatch.record, mismatch.failure()); + } + + if let Some(resolution) = + ambiguous_protected_family_resolution(claim, sender, index, &matching_results) + { + return resolution; + } + + // Application branding requires evidence that connects the sender to the candidate + if let Some(candidate) = matching_results + .iter() + .max_by_key(|result| record_trust_rank(result.record)) + { + return resolve_matching_candidate(claim, sender, index, candidate); + } + + unresolved_candidate_resolution(claim, sender, index) +} + +fn resolve_matching_candidate( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + candidate: &CandidateVerification<'_>, +) -> AttributionResolution { + let failure = candidate.failure(); + match sender_claim_relation(sender, index, candidate.record) { + SenderClaimRelation::ClaimedApplication => recognized_candidate_resolution( + claim, + sender, + index, + candidate, + launch_failure_label(failure), + ), + SenderClaimRelation::SamePackageHelper => recognized_candidate_resolution( + claim, + sender, + index, + candidate, + "Sender belongs to the same installed application package but was not strongly bound", + ), + SenderClaimRelation::DifferentInstalledPackage => unresolved_claim_resolution( + claim, + sender, + candidate, + "Sender belongs to a separate installed package without a positive application association", + InteractionPolicies::DENY, + ), + SenderClaimRelation::UnknownExecutable => unresolved_claim_resolution( + claim, + sender, + candidate, + "No positive sender association with the claimed application was established", + owner_bound_default_interactions(sender), + ), + SenderClaimRelation::DifferentVerifiedApplication => { + conflict_from_candidate(claim, sender, index, candidate.record, failure) + } + SenderClaimRelation::TrustedRelay => trusted_relay_resolution(claim, sender, index) + .unwrap_or_else(|| { + unresolved_claim_resolution( + claim, + sender, + candidate, + "The relay executable could not be revalidated", + InteractionPolicies::DENY, + ) + }), + } +} + +fn recognized_candidate_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + candidate: &CandidateVerification<'_>, + detail: &str, +) -> AttributionResolution { + let failure = candidate.failure(); + with_diagnostics( + recognized_resolution(claim, sender, candidate.record, index, failure, detail), + claim, + sender, + Some(candidate.record), + candidate.verification, + ) +} + +fn unresolved_claim_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + candidate: &CandidateVerification<'_>, + detail: &str, + interactions: InteractionPolicies, +) -> AttributionResolution { + let detail = sender.sender_executable.as_deref().map_or_else( + || detail.to_string(), + |path| format!("{detail}; source {path}"), + ); + with_diagnostics( + policy_resolution({ + let mut attribution = NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::NoDesktopCandidate, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + ); + attribution.interactions = interactions; + attribution + }), + claim, + sender, + Some(candidate.record), + candidate.verification, + ) +} + +fn ambiguous_protected_family_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + matching_results: &[&CandidateVerification<'_>], +) -> Option { + let protected_families = matching_results + .iter() + .filter(|result| result.record.system_association) + .filter_map(|result| index.family_index_for_record(result.record)) + .collect::>(); + if protected_families.len() <= 1 { + return None; + } + + let detail = "Multiple protected desktop application families matched the claim"; + Some(with_diagnostics( + policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::AmbiguousDesktopRecords, + detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::AmbiguousDesktopAssociation), + )) +} + +fn unresolved_candidate_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let reason = if index.claim_matches_system_app(claim.reported_name) { + AttributionReason::NoDesktopCandidate + } else if sender.sender_executable_identity.is_none() { + AttributionReason::MissingSenderEvidence + } else { + AttributionReason::NoDesktopCandidate + }; + let detail = sender.sender_executable.as_deref().map_or_else( + || "No reliable desktop application candidate was found".to_string(), + |path| format!("No desktop application matched source {path}"), + ); + let mut attribution = NotificationAttribution::unresolved( + claim.reported_name, + reason, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + ); + attribution.interactions = owner_bound_default_interactions(sender); + with_diagnostics( + policy_resolution(attribution), + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::NoDesktopCandidate), + ) +} + +pub(super) fn trusted_relay_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option { + let identity = sender.sender_executable_identity?; + let path = index.trusted_relay_path(identity)?; + let group_key = format!( + "relay:{}:{}", + identity.group_fragment(), + normalize_name(claim.reported_name) + ); + let attribution = NotificationAttribution::relay( + claim.reported_name, + &format!("Sent through {}", path.display()), + group_key, + ); + let mut resolution = with_diagnostics( + policy_resolution(attribution), + claim, + sender, + None, + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.reason = "verified trusted relay executable".to_string(); + Some(resolution) +} + +pub(super) fn strongest_verified_result<'record>( + results: &[CandidateVerification<'record>], + reported_name: &str, + index: &DesktopIdentityIndex, +) -> Option> { + let missing_name = reported_name.trim().is_empty(); + // Rank every verified candidate before deciding whether the strongest tier is ambiguous + let verified = results + .iter() + .filter(|result| { + matches!(result.verification, LaunchVerification::Verified(_)) + && (missing_name || index.record_matches_claim(result.record, reported_name)) + }) + .collect::>(); + let maximum_rank = verified + .iter() + .map(|result| record_trust_rank(result.record)) + .max()?; + let strongest = verified + .into_iter() + .filter(|result| record_trust_rank(result.record) == maximum_rank) + .collect::>(); + let families = strongest + .iter() + .filter_map(|result| index.family_index_for_record(result.record)) + .collect::>(); + // maximum_rank guarantees at least one strongest candidate + if families.len() != 1 { + return None; + } + let preferred = strongest.into_iter().min_by_key(|candidate| { + let canonical = index.canonical_id_for_record(candidate.record); + let normalized_id = normalize_desktop_id(&candidate.record.id); + let is_alias = normalized_id != normalize_desktop_id(canonical); + (is_alias, normalized_id) + })?; + let LaunchVerification::Verified(launch) = preferred.verification else { + return None; + }; + Some(VerifiedDesktopRecord(preferred.record, launch)) +} + +pub(super) const fn record_trust_rank(record: &DesktopRecord) -> u8 { + if record.system_association { + 2 + } else { + 1 + } +} + +pub(super) fn preferred_record<'record>( + records: &[&'record DesktopRecord], +) -> &'record DesktopRecord { + records + .iter() + .copied() + .max_by_key(|record| record_trust_rank(record)) + .expect("caller checks that a desktop candidate exists") +} + +pub(super) fn extend_unique_records<'record>( + records: &mut Vec<&'record DesktopRecord>, + additions: Vec<&'record DesktopRecord>, +) { + for record in additions { + if !records + .iter() + .any(|existing| std::ptr::eq(*existing, record)) + { + records.push(record); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs new file mode 100644 index 000000000..a1818e691 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/diagnostics.rs @@ -0,0 +1,119 @@ +//! Conversion from daemon launch evidence into stable diagnostic wire values + +use unixnotis_core::{ + AttributionDiagnostics, AttributionStatus, CommandLineQualityView, LaunchAuthorityView, + LaunchVerificationView, RecordTrust, +}; + +use super::super::desktop_index::{ + DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, +}; +use super::super::sender::{CommandLineQuality, SenderMetadata}; +use super::{AppClaim, AttributionResolution}; + +pub(super) fn with_diagnostics( + mut resolution: AttributionResolution, + claim: AppClaim<'_>, + sender: &SenderMetadata, + record: Option<&DesktopRecord>, + verification: LaunchVerification, +) -> AttributionResolution { + let (verification_view, launch_authority, reason) = match verification { + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::DedicatedExecutable, + "verified by dedicated executable identity", + ), + LaunchVerification::Verified(VerifiedLaunch::PackageLauncherTarget) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::DedicatedExecutable, + "verified by protected package launcher and runtime identity", + ), + LaunchVerification::Verified(VerifiedLaunch::ProtectedPayload) => ( + LaunchVerificationView::Verified, + LaunchAuthorityView::ProtectedPayload, + "verified by executable and protected payload identity", + ), + LaunchVerification::DefinitiveMismatch(failure) + if resolution.attribution.status == AttributionStatus::Conflict => + { + ( + LaunchVerificationView::DefinitiveMismatch, + launch_authority_for_failure(failure), + launch_failure_label(failure), + ) + } + LaunchVerification::InsufficientEvidence(failure) + | LaunchVerification::DefinitiveMismatch(failure) => ( + LaunchVerificationView::InsufficientEvidence, + launch_authority_for_failure(failure), + launch_failure_label(failure), + ), + }; + resolution.diagnostics = AttributionDiagnostics { + claimed_name: claim.reported_name.to_string(), + claimed_desktop_entry: claim.desktop_entry.unwrap_or_default().to_string(), + sender_executable: sender.sender_executable.clone().unwrap_or_default(), + matched_desktop_id: record.map_or_else(String::new, |record| record.id.clone()), + record_trust: record.map_or(RecordTrust::None, |record| { + if record.system_origin { + RecordTrust::System + } else { + RecordTrust::User + } + }), + launch_authority, + command_line_quality: command_line_quality_view(sender.command_line.quality), + verification: verification_view, + reason: reason.to_string(), + }; + resolution +} + +pub(super) const fn launch_failure_label(reason: LaunchFailure) -> &'static str { + match reason { + LaunchFailure::MissingSenderEvidence => "missing sender process evidence", + LaunchFailure::MissingCommandLine => "missing command-line evidence", + LaunchFailure::UnstructuredCommandLine => "unstructured command-line evidence", + LaunchFailure::EmptyContractNeedsCommandLine => { + "empty launch contract requires structured command-line evidence" + } + LaunchFailure::UnsupportedWrapper => "unsupported launch wrapper", + LaunchFailure::LauncherBindingChanged => "package launcher binding changed", + LaunchFailure::AmbiguousDesktopAssociation => "ambiguous desktop association", + LaunchFailure::DynamicOnlyContract => "dynamic-only launch contract", + LaunchFailure::ExecutableMismatch => "executable identity mismatch", + LaunchFailure::ProtectedPayloadMismatch => "protected application payload mismatch", + LaunchFailure::RequiredArgumentMismatch => "required launch argument mismatch", + LaunchFailure::DesktopClaimMismatch => "desktop claim mismatch", + LaunchFailure::NoDesktopCandidate => "no desktop application candidate", + } +} + +const fn launch_authority_for_failure(failure: LaunchFailure) -> LaunchAuthorityView { + match failure { + LaunchFailure::DynamicOnlyContract => LaunchAuthorityView::DynamicOnly, + LaunchFailure::AmbiguousDesktopAssociation => LaunchAuthorityView::Ambiguous, + LaunchFailure::EmptyContractNeedsCommandLine | LaunchFailure::LauncherBindingChanged => { + LaunchAuthorityView::DedicatedExecutable + } + LaunchFailure::ProtectedPayloadMismatch + | LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine => LaunchAuthorityView::ProtectedPayload, + LaunchFailure::MissingSenderEvidence + | LaunchFailure::NoDesktopCandidate + | LaunchFailure::ExecutableMismatch + | LaunchFailure::RequiredArgumentMismatch + | LaunchFailure::DesktopClaimMismatch + | LaunchFailure::UnsupportedWrapper => LaunchAuthorityView::None, + } +} + +const fn command_line_quality_view(quality: CommandLineQuality) -> CommandLineQualityView { + match quality { + CommandLineQuality::Structured => CommandLineQualityView::Structured, + CommandLineQuality::RewrittenProcessTitle => CommandLineQualityView::RewrittenProcessTitle, + CommandLineQuality::Truncated => CommandLineQualityView::Truncated, + CommandLineQuality::Unavailable => CommandLineQualityView::Unavailable, + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs new file mode 100644 index 000000000..2ece310d0 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/evidence.rs @@ -0,0 +1,179 @@ +//! Sender, lineage, and contradiction evidence evaluation + +use super::super::desktop_index::{ + verify_record_launch, DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, + VerifiedLaunch, +}; +use super::super::executable::{executable_evidence_for_path, FileIdentity}; +use super::super::sender::{CommandLineEvidence, SenderMetadata}; +use super::model::{CandidateVerification, SenderClaimRelation}; + +pub(super) fn lineage_association<'record>( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + results: &[&CandidateVerification<'record>], +) -> Option<(&'record DesktopRecord, String)> { + for ancestor in &sender.ancestors { + for result in results { + let record = result.record; + if !record.system_association + || !record + .runtime_executable_identity + .is_some_and(|identity| identity.same_file(ancestor.executable_identity)) + { + continue; + } + let verification = verify_ancestor_record(record, index, ancestor.executable_identity); + if matches!(verification, LaunchVerification::Verified(_)) + || (record + .launch_spec + .as_ref() + .is_some_and(|spec| spec.package_launcher.is_some()) + && matches!( + verification, + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine) + )) + { + return Some(( + record, + format!( + "Same-user ancestor {} matched the application executable", + ancestor.executable + ), + )); + } + } + } + None +} + +fn verify_ancestor_record( + record: &DesktopRecord, + index: &DesktopIdentityIndex, + identity: FileIdentity, +) -> LaunchVerification { + let Some(path) = record.runtime_executable_path.as_deref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + let Some(current) = executable_evidence_for_path(path) else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + if !current_system_identity_matches_sender(current.identity, identity) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + verify_record_launch(record, index, identity, &CommandLineEvidence::default()) +} + +pub(super) fn verify_record_sender( + record: &DesktopRecord, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> LaunchVerification { + if !record.association_eligible { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + } + let Some(record_identity) = record.runtime_executable_identity else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::UnsupportedWrapper); + }; + let Some(sender_identity) = sender.sender_executable_identity else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence); + }; + if !record_identity.same_file(sender_identity) { + return LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch); + } + + if record.system_association { + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + let Some(path) = record.runtime_executable_path.as_deref() else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + }; + let Some(current) = executable_evidence_for_path(path) else { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return LaunchVerification::InsufficientEvidence(LaunchFailure::ExecutableMismatch); + } + return verify_record_launch(record, index, sender_identity, &sender.command_line); + } + + // Exact user-local executable identity is recognition evidence without action authority + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable) +} + +pub(super) fn candidate_proves_conflict( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + candidate: &CandidateVerification<'_>, +) -> bool { + match candidate.failure() { + // A structured protected payload or verified application claim is direct evidence + LaunchFailure::ProtectedPayloadMismatch | LaunchFailure::DesktopClaimMismatch => true, + // Executable inequality matters only after another immutable owner is established + LaunchFailure::ExecutableMismatch => matches!( + sender_claim_relation(sender, index, candidate.record), + SenderClaimRelation::DifferentVerifiedApplication + ), + LaunchFailure::MissingSenderEvidence + | LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine + | LaunchFailure::EmptyContractNeedsCommandLine + | LaunchFailure::UnsupportedWrapper + | LaunchFailure::LauncherBindingChanged + | LaunchFailure::AmbiguousDesktopAssociation + | LaunchFailure::DynamicOnlyContract + | LaunchFailure::RequiredArgumentMismatch + | LaunchFailure::NoDesktopCandidate => false, + } +} + +pub(super) fn sender_claim_relation( + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + claimed_record: &DesktopRecord, +) -> SenderClaimRelation { + let Some(sender_identity) = sender.sender_executable_identity else { + return SenderClaimRelation::UnknownExecutable; + }; + if index.trusted_relay_path(sender_identity).is_some() { + return SenderClaimRelation::TrustedRelay; + } + if claimed_record + .runtime_executable_identity + .is_some_and(|identity| identity.same_file(sender_identity)) + { + return SenderClaimRelation::ClaimedApplication; + } + if index + .records_for_executable(sender_identity) + .into_iter() + .any(|record| record.system_association) + { + // Exact same-family executable identity returned above before this lookup + return SenderClaimRelation::DifferentVerifiedApplication; + } + + if sender + .install_provenance + .same_application_source(&claimed_record.runtime_executable_provenance) + { + return SenderClaimRelation::SamePackageHelper; + } + if sender.install_provenance.is_known() + && claimed_record.runtime_executable_provenance.is_known() + { + // Package inequality rules out a same-package helper but does not identify another app + return SenderClaimRelation::DifferentInstalledPackage; + } + SenderClaimRelation::UnknownExecutable +} + +pub(super) const fn current_system_identity_matches_sender( + current: FileIdentity, + sender_identity: FileIdentity, +) -> bool { + current.same_file(sender_identity) + && current.is_system_managed() + && current.is_executable_regular() +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs new file mode 100644 index 000000000..26c59352b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/mod.rs @@ -0,0 +1,17 @@ +//! Ordered application attribution from process, portal, and desktop evidence + +mod candidates; +mod diagnostics; +mod evidence; +mod model; +mod pipeline; +mod resolution; +mod sender_context; +mod validation; + +pub(in crate::daemon) use model::{AppClaim, AttributionResolution}; +pub(in crate::daemon) use pipeline::resolve_attribution_owned; +pub(in crate::daemon::notifications) use pipeline::resolve_attribution_with_deadline; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs new file mode 100644 index 000000000..1dc97a35a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/model.rs @@ -0,0 +1,55 @@ +//! Internal value types shared by resolver stages + +use unixnotis_core::{AttributionDiagnostics, InlineReplyPolicy, NotificationAttribution}; + +use super::super::desktop_index::{ + DesktopRecord, LaunchFailure, LaunchVerification, VerifiedLaunch, +}; + +#[derive(Clone, Copy)] +pub(in crate::daemon) struct AppClaim<'claim> { + pub(in crate::daemon) reported_name: &'claim str, + pub(in crate::daemon) desktop_entry: Option<&'claim str>, +} + +pub(in crate::daemon) struct AttributionResolution { + pub(in crate::daemon) attribution: NotificationAttribution, + pub(in crate::daemon) diagnostics: AttributionDiagnostics, + pub(in crate::daemon) inline_reply_policy: InlineReplyPolicy, +} + +#[derive(Clone, Copy)] +pub(super) struct VerifiedDesktopRecord<'record>( + pub(super) &'record DesktopRecord, + pub(super) VerifiedLaunch, +); + +#[derive(Clone, Copy)] +pub(super) struct CandidateVerification<'record> { + pub(super) record: &'record DesktopRecord, + pub(super) verification: LaunchVerification, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum SenderClaimRelation { + ClaimedApplication, + DifferentVerifiedApplication, + DifferentInstalledPackage, + SamePackageHelper, + UnknownExecutable, + TrustedRelay, +} + +impl CandidateVerification<'_> { + pub(super) const fn is_definitive_mismatch(&self) -> bool { + matches!(self.verification, LaunchVerification::DefinitiveMismatch(_)) + } + + pub(super) const fn failure(&self) -> LaunchFailure { + match self.verification { + LaunchVerification::Verified(_) => LaunchFailure::DesktopClaimMismatch, + LaunchVerification::InsufficientEvidence(reason) + | LaunchVerification::DefinitiveMismatch(reason) => reason, + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs new file mode 100644 index 000000000..b13784c69 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/pipeline.rs @@ -0,0 +1,272 @@ +//! Ordered attribution pipeline and candidate orchestration + +use std::future::Future; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tracing::warn; +use unixnotis_core::{AttributionStatus, InteractionPolicies, RecordTrust}; + +use super::super::desktop_index::{ + DesktopIdentityIndex, LaunchFailure, LaunchVerification, VerifiedLaunch, +}; +use super::super::sender::{refresh_sender_security_evidence, SenderMetadata}; +use super::candidates::{ + extend_unique_records, preferred_record, resolve_unverified_candidates, + strongest_verified_result, trusted_relay_resolution, +}; +use super::diagnostics::with_diagnostics; +use super::evidence::verify_record_sender; +use super::model::{AppClaim, AttributionResolution, CandidateVerification}; +use super::resolution::{ + conflict_from_candidate, resolution_for_portal_record, resolution_for_record, + trusted_portal_path, unknown_reply_denied, +}; +use super::sender_context::enrich_sender_install_provenance_blocking; +use super::validation::validate_desktop_id; + +const ATTRIBUTION_WORKER_SLOTS: usize = 8; + +// The ingress deadline covers the one-second package query, its bounded pipe +// drain, and the procfs/index work around that query +pub(in crate::daemon::notifications) const ATTRIBUTION_TIMEOUT: Duration = + Duration::from_millis(1_500); + +fn attribution_worker_pool() -> Arc { + static POOL: OnceLock> = OnceLock::new(); + Arc::clone(POOL.get_or_init(|| Arc::new(Semaphore::new(ATTRIBUTION_WORKER_SLOTS)))) +} + +fn try_attribution_worker_from(pool: &Arc) -> Option { + Arc::clone(pool).try_acquire_owned().ok() +} + +/// Production entry point that moves procfs and filesystem work off Tokio workers +pub(in crate::daemon) async fn resolve_attribution_owned( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, +) -> AttributionResolution { + resolve_attribution_owned_with( + reported_name, + desktop_entry, + sender, + index, + enrich_sender_install_provenance_blocking, + ) + .await +} + +pub(in crate::daemon::notifications) async fn resolve_attribution_with_deadline( + reported_name: String, + desktop_entry: Option, + sender: &SenderMetadata, + resolution: F, +) -> AttributionResolution +where + F: Future, +{ + tokio::time::timeout(ATTRIBUTION_TIMEOUT, resolution) + .await + .ok() + .unwrap_or_else(|| { + warn!("notification attribution timed out and failed closed"); + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + unknown_reply_denied(claim, sender, "attribution timed out") + }) +} + +pub(super) async fn resolve_attribution_owned_with( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, + enrich: F, +) -> AttributionResolution +where + F: FnOnce(&mut SenderMetadata, &DesktopIdentityIndex) + Send + 'static, +{ + resolve_attribution_owned_with_pool( + reported_name, + desktop_entry, + sender, + index, + attribution_worker_pool(), + enrich, + ) + .await +} + +pub(super) async fn resolve_attribution_owned_with_pool( + reported_name: String, + desktop_entry: Option, + sender: SenderMetadata, + index: Arc, + worker_pool: Arc, + enrich: F, +) -> AttributionResolution +where + F: FnOnce(&mut SenderMetadata, &DesktopIdentityIndex) + Send + 'static, +{ + let Some(initial_permit) = try_attribution_worker_from(&worker_pool) else { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied(claim, &sender, "attribution worker capacity exhausted"); + }; + let fallback_sender = sender.clone(); + // The server owns the single wall-clock deadline for this operation + // This layer only limits concurrent blocking attribution work + let result = tokio::task::spawn_blocking({ + let reported_name = reported_name.clone(); + let desktop_entry = desktop_entry.clone(); + let index = Arc::clone(&index); + let sender = sender.clone(); + move || { + // The permit stays in the closure until every blocking operation exits + let _permit = initial_permit; + let sender = refresh_sender_security_evidence(&sender); + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + // The first pass can decide that package ownership is unnecessary + let initial = resolve_with_evidence(claim, &sender, &index); + let needs = needs_sender_provenance( + initial.attribution.status, + initial.attribution.interactions, + claim_has_index_candidate(claim, &index), + ); + if !needs { + return initial; + } + + let mut sender = sender; + // Enrichment is blocking and remains inside the same worker slot + enrich(&mut sender, &index); + // Missing or failed provenance must not erase useful safe attribution + if !sender.install_provenance.is_known() { + return initial; + } + resolve_with_evidence(claim, &sender, &index) + } + }) + .await; + let Ok(resolution) = result else { + let claim = AppClaim { + reported_name: &reported_name, + desktop_entry: desktop_entry.as_deref(), + }; + return unknown_reply_denied(claim, &fallback_sender, "attribution worker stopped"); + }; + resolution +} + +pub(super) fn needs_sender_provenance( + status: AttributionStatus, + interactions: InteractionPolicies, + claim_has_candidate: bool, +) -> bool { + // Package lookup is useful only while it can positively bind a denied helper + interactions == InteractionPolicies::DENY + && (status == AttributionStatus::Recognized + || (status == AttributionStatus::Unresolved && claim_has_candidate)) +} + +pub(super) fn claim_has_index_candidate(claim: AppClaim<'_>, index: &DesktopIdentityIndex) -> bool { + if !claim.reported_name.trim().is_empty() + && !index.records_for_claim(claim.reported_name).is_empty() + { + return true; + } + + claim + .desktop_entry + .and_then(validate_desktop_id) + .is_some_and(|desktop_id| !index.records_for_id(&desktop_id).is_empty()) +} + +pub(super) fn resolve_with_evidence( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let desktop_entry = claim.desktop_entry.and_then(validate_desktop_id); + let hint_records = desktop_entry + .as_deref() + .map_or_else(Vec::new, |desktop_id| index.records_for_id(desktop_id)); + if desktop_entry.is_some() + && !hint_records.is_empty() + && trusted_portal_path(sender, index).is_some() + { + // A trusted portal executable associates branding but cannot authenticate the origin + let record = preferred_record(&hint_records); + if !claim.reported_name.trim().is_empty() + && !index.record_matches_claim(record, claim.reported_name) + { + // A portal desktop id and a different caller label remain contradictory evidence + let mut resolution = conflict_from_candidate( + claim, + sender, + index, + record, + LaunchFailure::DesktopClaimMismatch, + ); + resolution.diagnostics.record_trust = RecordTrust::Portal; + resolution.diagnostics.reason = + "portal application id contradicted the reported name".to_string(); + return resolution; + } + let mut resolution = with_diagnostics( + resolution_for_portal_record(record, claim.reported_name, sender, index), + claim, + sender, + Some(record), + LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + ); + resolution.diagnostics.record_trust = RecordTrust::Portal; + resolution.diagnostics.reason = "portal-mediated application association".to_string(); + return resolution; + } + + // Hints, executable identity, and claimed names contribute candidates without granting trust + let mut candidates = hint_records.clone(); + if let Some(identity) = sender.sender_executable_identity { + extend_unique_records(&mut candidates, index.records_for_executable(identity)); + } + if !claim.reported_name.trim().is_empty() { + extend_unique_records( + &mut candidates, + index.records_for_claim(claim.reported_name), + ); + } + let results = candidates + .iter() + .map(|record| CandidateVerification { + record, + verification: verify_record_sender(record, sender, index), + }) + .collect::>(); + + if let Some(record) = strongest_verified_result(&results, claim.reported_name, index) { + return with_diagnostics( + resolution_for_record(record, claim.reported_name, sender, index), + claim, + sender, + Some(record.0), + LaunchVerification::Verified(record.1), + ); + } + + // A verified relay identifies itself but never authenticates the forwarded label + if let Some(resolution) = trusted_relay_resolution(claim, sender, index) { + return resolution; + } + + resolve_unverified_candidates(claim, sender, index, &hint_records, &results) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs new file mode 100644 index 000000000..9315368b9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/resolution.rs @@ -0,0 +1,292 @@ +//! Structured attribution construction and trust-domain grouping + +use unixnotis_core::{ + AttributionDiagnostics, AttributionReason, AttributionStatus, IdentityAssurance, + InteractionPolicies, NotificationAttribution, +}; + +use super::super::desktop_index::{ + normalize_name, DesktopIdentityIndex, DesktopRecord, LaunchFailure, LaunchVerification, + VerifiedLaunch, +}; +use super::super::policy::inline_reply_policy; +use super::super::sender::{SenderMetadata, SenderMetadataStatus}; +use super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::model::VerifiedDesktopRecord; +use super::{AppClaim, AttributionResolution}; + +pub(super) const fn owner_bound_default_interactions( + sender: &SenderMetadata, +) -> InteractionPolicies { + if sender.has_stable_callback_owner() { + InteractionPolicies::OWNER_BOUND_DEFAULT + } else { + InteractionPolicies::DENY + } +} + +pub(in crate::daemon) fn unknown_reply_denied( + claim: AppClaim<'_>, + sender: &SenderMetadata, + reason: &str, +) -> AttributionResolution { + let reason = match sender.status { + SenderMetadataStatus::CredentialLookupTimedOut => { + "sender metadata: credential lookup timed out" + } + SenderMetadataStatus::CredentialLookupFailed => "sender metadata: credential lookup failed", + SenderMetadataStatus::MissingSenderName => "sender metadata: sender name missing", + SenderMetadataStatus::Complete | SenderMetadataStatus::ProcessEvidenceUnavailable => reason, + }; + let detail = sender.sender_executable.as_deref().map_or_else( + || reason.to_string(), + |path| format!("{reason}; source {path}"), + ); + let resolution = policy_resolution(NotificationAttribution::unresolved( + claim.reported_name, + AttributionReason::MissingSenderEvidence, + &detail, + sender_claim_group_key(AttributionStatus::Unresolved, claim.reported_name, sender), + )); + with_diagnostics( + resolution, + claim, + sender, + None, + LaunchVerification::InsufficientEvidence(LaunchFailure::MissingSenderEvidence), + ) +} + +pub(super) fn resolution_for_portal_record( + record: &DesktopRecord, + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let portal = trusted_portal_path(sender, index).map_or_else( + || "desktop portal".to_string(), + |path| path.display().to_string(), + ); + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let attribution = NotificationAttribution::associated( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + &format!("Mediated by {portal}"), + format!("associated:portal-app:{canonical_id}"), + ); + policy_resolution(attribution) +} + +pub(super) fn trusted_portal_path<'index>( + sender: &SenderMetadata, + index: &'index DesktopIdentityIndex, +) -> Option<&'index std::path::Path> { + let identity = sender.sender_executable_identity?; + let path = std::path::Path::new(sender.sender_executable.as_deref()?); + index.trusted_portal_path(identity, path) +} + +pub(super) fn resolution_for_record( + verified: VerifiedDesktopRecord<'_>, + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> AttributionResolution { + let record = verified.0; + if !reported_name.trim().is_empty() && !index.record_matches_claim(record, reported_name) { + return conflict_resolution( + reported_name, + sender, + record, + index, + LaunchFailure::DesktopClaimMismatch, + ); + } + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let source = record + .runtime_executable_path + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_default(); + if record.system_association { + let reason = match verified.1 { + VerifiedLaunch::DedicatedExecutable | VerifiedLaunch::PackageLauncherTarget => { + AttributionReason::ExactSystemExecutable + } + VerifiedLaunch::ProtectedPayload => AttributionReason::ProtectedPayloadMatch, + }; + return policy_resolution(NotificationAttribution::associated( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + reason, + &source, + format!("associated:system-app:{canonical_id}"), + )); + } + + let origin = record.desktop_identity.map_or_else( + || "unknown".to_string(), + super::super::executable::FileIdentity::group_fragment, + ); + policy_resolution(NotificationAttribution::associated( + &canonical.display_name, + reported_name, + canonical_id, + &canonical.badge_icon, + IdentityAssurance::UserAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactUserExecutable, + &source, + format!( + "recognized:user-app:{origin}:{canonical_id}:{}", + sender_identity_fragment(sender) + ), + )) +} + +pub(super) fn recognized_resolution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + record: &DesktopRecord, + index: &DesktopIdentityIndex, + failure: LaunchFailure, + detail: &str, +) -> AttributionResolution { + let canonical = index.canonical_record_for_record(record); + let canonical_id = index.canonical_id_for_record(record); + let source = sender.sender_executable.as_deref().map_or_else( + || detail.to_string(), + |path| format!("{detail}; source {path}"), + ); + let group_key = if record.system_origin { + format!( + "recognized:system-app:{canonical_id}:{}", + sender_identity_fragment(sender) + ) + } else { + let origin = record.desktop_identity.map_or_else( + || "unknown".to_string(), + super::super::executable::FileIdentity::group_fragment, + ); + format!( + "recognized:user-app:{origin}:{canonical_id}:{}", + sender_identity_fragment(sender) + ) + }; + let assurance = if record.system_origin { + IdentityAssurance::SystemAssociated + } else { + IdentityAssurance::UserAssociated + }; + policy_resolution(NotificationAttribution::associated( + &canonical.display_name, + claim.reported_name, + canonical_id, + &canonical.badge_icon, + assurance, + owner_bound_default_interactions(sender), + attribution_reason_for_failure(failure), + &source, + group_key, + )) +} + +pub(super) fn conflict_from_candidate( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, + record: &DesktopRecord, + failure: LaunchFailure, +) -> AttributionResolution { + with_diagnostics( + conflict_resolution(claim.reported_name, sender, record, index, failure), + claim, + sender, + Some(record), + LaunchVerification::DefinitiveMismatch(failure), + ) +} + +fn conflict_resolution( + reported_name: &str, + sender: &SenderMetadata, + record: &DesktopRecord, + index: &DesktopIdentityIndex, + failure: LaunchFailure, +) -> AttributionResolution { + let label = launch_failure_label(failure); + let detail = sender.sender_executable.as_deref().map_or_else( + || label.to_string(), + |path| format!("{label}; source {path}"), + ); + let desktop_id = index.canonical_id_for_record(record); + policy_resolution(NotificationAttribution::conflict( + reported_name, + desktop_id, + attribution_reason_for_failure(failure), + &detail, + sender_claim_group_key(AttributionStatus::Conflict, reported_name, sender), + )) +} + +pub(super) fn policy_resolution(attribution: NotificationAttribution) -> AttributionResolution { + AttributionResolution { + inline_reply_policy: inline_reply_policy(attribution.interactions), + attribution, + diagnostics: AttributionDiagnostics::default(), + } +} + +const fn attribution_reason_for_failure(failure: LaunchFailure) -> AttributionReason { + match failure { + LaunchFailure::MissingSenderEvidence => AttributionReason::MissingSenderEvidence, + LaunchFailure::MissingCommandLine + | LaunchFailure::UnstructuredCommandLine + | LaunchFailure::EmptyContractNeedsCommandLine => AttributionReason::MissingCommandLine, + LaunchFailure::UnsupportedWrapper | LaunchFailure::LauncherBindingChanged => { + AttributionReason::UnsupportedWrapper + } + LaunchFailure::AmbiguousDesktopAssociation | LaunchFailure::RequiredArgumentMismatch => { + AttributionReason::AmbiguousDesktopRecords + } + LaunchFailure::DynamicOnlyContract => AttributionReason::DynamicLaunchContract, + LaunchFailure::NoDesktopCandidate => AttributionReason::NoDesktopCandidate, + LaunchFailure::ExecutableMismatch => AttributionReason::ExecutableMismatch, + LaunchFailure::ProtectedPayloadMismatch => AttributionReason::ProtectedPayloadMismatch, + LaunchFailure::DesktopClaimMismatch => AttributionReason::ApplicationClaimMismatch, + } +} + +pub(super) fn sender_claim_group_key( + status: AttributionStatus, + reported_name: &str, + sender: &SenderMetadata, +) -> String { + let claim = normalize_name(reported_name); + let prefix = match status { + AttributionStatus::Unresolved => "unresolved", + AttributionStatus::Conflict => "conflict", + AttributionStatus::Verified | AttributionStatus::Recognized | AttributionStatus::Relay => { + "unknown" + } + }; + format!("{prefix}:{}:{claim}", sender_identity_fragment(sender)) +} + +fn sender_identity_fragment(sender: &SenderMetadata) -> String { + sender.sender_executable_identity.map_or_else( + || "missing".to_string(), + super::super::executable::FileIdentity::group_fragment, + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs new file mode 100644 index 000000000..0ad05e140 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/sender_context.rs @@ -0,0 +1,33 @@ +//! Live sender metadata used by the attribution pipeline + +use super::super::desktop_index::DesktopIdentityIndex; +use super::super::executable::executable_evidence_for_path; +use super::super::sender::SenderMetadata; +use super::evidence::current_system_identity_matches_sender; + +pub(super) fn enrich_sender_install_provenance_blocking( + sender: &mut SenderMetadata, + index: &DesktopIdentityIndex, +) { + if sender.install_provenance.is_known() { + return; + } + let (Some(path), Some(sender_identity)) = ( + sender.sender_executable.as_deref(), + sender.sender_executable_identity, + ) else { + return; + }; + if !sender_identity.is_system_managed() || !sender_identity.is_executable_regular() { + return; + } + + // Reopen the executable before package ownership can affect attribution + let Some(current) = executable_evidence_for_path(std::path::Path::new(path)) else { + return; + }; + if !current_system_identity_matches_sender(current.identity, sender_identity) { + return; + } + sender.install_provenance = index.install_provenance_for_path(current.canonical_path); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs new file mode 100644 index 000000000..06efb9e67 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates.rs @@ -0,0 +1,4 @@ +//! Candidate selection and family-ranking tests + +mod claims; +mod families; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs new file mode 100644 index 000000000..ba8192994 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/claims.rs @@ -0,0 +1,127 @@ +//! Application-name and desktop-hint association cases + +use super::super::*; + +#[test] +fn mismatched_desktop_hint_does_not_become_claim_evidence() { + let protected_identity = identity(100, 1_000, 0); + let record = system_record( + "org.example.Protected", + "Protected App", + "/usr/bin/protected", + protected_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let hint_records = index.records_for_id("org.example.Protected"); + let results = hint_records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + }) + .collect::>(); + let mut different = sender("/usr/bin/different", identity(101, 1_010, 0)); + different.install_provenance = package("different-app"); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Unrelated label", + desktop_entry: Some("org.example.Protected"), + }, + &different, + &index, + &hint_records, + &results, + ); + + assert_eq!( + resolution.attribution.status, + AttributionStatus::Unresolved, + "a caller-controlled desktop hint cannot make a different label contradictory" + ); +} + +#[test] +fn stable_unresolved_sender_keeps_only_the_protocol_default_action() { + let mut metadata = sender("/usr/bin/example", identity(100, 1_000, 0)); + metadata.sender_pid = Some(42); + metadata.sender_start_time = Some(4_200); + metadata.sender_uid = Some(1_000); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Example Application", + desktop_entry: None, + }, + &metadata, + &DesktopIdentityIndex::default(), + &[], + &[], + ); + + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} + +#[test] +fn canonical_conflict_candidate_supplies_the_stable_failure_reason() { + let executable = identity(102, 1_020, 0); + let mut canonical = system_record( + "org.example.Canonical", + "Example App", + "/usr/bin/example", + executable, + ); + let mut alias = system_record( + "org.example.Canonical.NewWindow", + "Example App", + "/usr/bin/example", + executable, + ); + for record in [&mut canonical, &mut alias] { + record.desktop_provenance = package("example-app"); + record.declared_executable_provenance = package("example-app"); + record.runtime_executable_provenance = package("example-app"); + } + let different_identity = identity(103, 1_030, 0); + let different_record = system_record( + "org.example.Different", + "Different App", + "/usr/bin/different", + different_identity, + ); + let index = + DesktopIdentityIndex::from_records(vec![alias, canonical, different_record], Vec::new()); + let records = index.records_for_executable(executable); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: if record.id == "org.example.Canonical" { + LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch) + } else { + LaunchVerification::DefinitiveMismatch(LaunchFailure::ProtectedPayloadMismatch) + }, + }) + .collect::>(); + let different = sender("/usr/bin/different", different_identity); + + let resolution = resolve_unverified_candidates( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &different, + &index, + &[], + &results, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!( + resolution.attribution.reason, + unixnotis_core::AttributionReason::ExecutableMismatch + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs new file mode 100644 index 000000000..bcbd2b9e6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/candidates/families.rs @@ -0,0 +1,185 @@ +//! Canonical desktop application-family cases + +use super::super::*; + +#[test] +fn equivalent_desktop_aliases_use_one_canonical_application_identity() { + let (app_path, app_identity) = installed_system_executable(); + let mut canonical = system_record("org.example.True", "Example App", &app_path, app_identity); + canonical.badge_icon = "example-app".to_string(); + let mut alias = system_record( + "org.example.True.NewWindow", + "Example App New Window", + &app_path, + app_identity, + ); + alias.badge_icon = "example-app-new-window".to_string(); + canonical.desktop_provenance = package("example-app"); + canonical.declared_executable_provenance = package("example-app"); + canonical.runtime_executable_provenance = package("example-app"); + alias.desktop_provenance = package("example-app"); + alias.declared_executable_provenance = package("example-app"); + alias.runtime_executable_provenance = package("example-app"); + + let resolve_alias = |records| { + let index = DesktopIdentityIndex::from_records(records, Vec::new()); + resolve_with_evidence( + AppClaim { + reported_name: "Example App New Window", + desktop_entry: Some("org.example.True.NewWindow"), + }, + &sender(&app_path, app_identity), + &index, + ) + }; + let canonical_first = resolve_alias(vec![canonical.clone(), alias.clone()]); + let alias_first = resolve_alias(vec![alias, canonical]); + + for resolution in [&canonical_first, &alias_first] { + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "Example App"); + assert_eq!(resolution.attribution.badge_icon, "example-app"); + assert_eq!( + resolution.attribution.group_key, + "associated:system-app:org.example.True" + ); + } + assert_eq!( + canonical_first.attribution.group_key, alias_first.attribution.group_key, + "family grouping must not depend on desktop-index insertion order" + ); +} + +#[test] +fn fuzzy_name_substrings_do_not_merge_distinct_application_families() { + let executable = identity(93, 930, 0); + let mut first = system_record( + "org.example.Primary", + "Example", + "/usr/bin/example", + executable, + ); + let mut second = system_record( + "org.example.Remote", + "Example Remote", + "/usr/bin/example", + executable, + ); + for record in [&mut first, &mut second] { + record.desktop_provenance = package("example-suite"); + record.declared_executable_provenance = package("example-suite"); + record.runtime_executable_provenance = package("example-suite"); + } + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(executable); + + assert_eq!(records.len(), 2); + assert!( + !index.records_share_family(records[0], records[1]), + "substring-overlapping display names are not application identity evidence" + ); +} + +#[test] +fn duplicate_desktop_ids_do_not_make_distinct_families_equal() { + let first_identity = identity(94, 940, 0); + let second_identity = identity(95, 950, 0); + let first = system_record( + "org.example.Duplicate", + "First application", + "/usr/bin/first", + first_identity, + ); + let second = system_record( + "org.example.Duplicate", + "Second application", + "/usr/bin/second", + second_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_id("org.example.Duplicate"); + + assert_eq!(records.len(), 2); + assert!( + !index.records_share_family(records[0], records[1]), + "a reused desktop id cannot replace concrete family identity" + ); +} + +#[test] +fn stronger_verified_family_wins_after_weaker_families_are_ambiguous() { + let first = DesktopRecord::fixture( + "org.example.UserOne", + "Shared App", + "/home/user/one", + identity(96, 960, 1_000), + false, + ); + let second = DesktopRecord::fixture( + "org.example.UserTwo", + "Shared App", + "/home/user/two", + identity(97, 970, 1_000), + false, + ); + let system = system_record( + "org.example.System", + "Shared App", + "/usr/bin/system-app", + identity(98, 980, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![first, second, system], Vec::new()); + let records = index.records_for_claim("Shared App"); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }) + .collect::>(); + + let selected = strongest_verified_result(&results, "Shared App", &index) + .expect("the strongest unambiguous family should be selected"); + + assert_eq!(selected.0.id, "org.example.System"); +} + +#[test] +fn strongest_verified_family_selects_its_canonical_record() { + let executable = identity(99, 990, 0); + let mut canonical = system_record( + "org.example.Canonical", + "Example App", + "/usr/bin/example", + executable, + ); + let mut alias = system_record( + "org.example.Canonical.NewWindow", + "Example App New Window", + "/usr/bin/example", + executable, + ); + for record in [&mut canonical, &mut alias] { + record.desktop_provenance = package("example-app"); + record.declared_executable_provenance = package("example-app"); + record.runtime_executable_provenance = package("example-app"); + } + let index = DesktopIdentityIndex::from_records(vec![alias, canonical], Vec::new()); + let records = index.records_for_executable(executable); + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }) + .collect::>(); + + let selected = strongest_verified_result(&results, "Example App New Window", &index) + .expect("one verified application family should have a canonical selection"); + + assert_eq!(selected.0.id, "org.example.Canonical"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs new file mode 100644 index 000000000..d8c4b0b02 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/diagnostics.rs @@ -0,0 +1,56 @@ +//! Structured resolver diagnostic regressions + +use super::super::diagnostics::{launch_failure_label, with_diagnostics}; +use super::super::resolution::recognized_resolution; +use super::*; + +#[test] +fn empty_contract_diagnostic_explains_why_command_line_evidence_is_required() { + assert_eq!( + launch_failure_label(LaunchFailure::EmptyContractNeedsCommandLine), + "empty launch contract requires structured command-line evidence" + ); +} + +#[test] +fn nonconflicting_mismatch_is_reported_as_insufficient_evidence() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(202, 2_020, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let metadata = sender("/usr/libexec/example-helper", identity(203, 2_030, 0)); + let claim = AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }; + let resolution = recognized_resolution( + claim, + &metadata, + record, + &index, + LaunchFailure::ExecutableMismatch, + "helper could not be strongly bound", + ); + + let resolution = with_diagnostics( + resolution, + claim, + &metadata, + Some(record), + LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs new file mode 100644 index 000000000..5e718334c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence.rs @@ -0,0 +1,5 @@ +//! Sender and process-evidence tests + +mod helpers; +mod identity; +mod runtime; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs new file mode 100644 index 000000000..bc04daac3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/helpers.rs @@ -0,0 +1,372 @@ +//! Helper-process and process-lineage association cases + +use super::super::super::evidence::{lineage_association, sender_claim_relation}; +use super::super::*; + +#[test] +fn helper_process_lineage_is_recognized_without_becoming_suspicious() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![ + system_record("org.example.True", "Example App", &app_path, app_identity) + .with_launch_literals(&["--application-mode"]), + ], + Vec::new(), + ); + let helper_identity = identity(88, 880, 0); + let mut helper = sender("/usr/libexec/example-helper", helper_identity); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_080, + start_time: 7_070, + uid: 0, + executable: app_path, + executable_identity: app_identity, + }); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.display_name, "Example App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert!(resolution + .attribution + .diagnostic_detail + .contains("Same-user ancestor")); +} + +#[test] +fn stale_ancestor_identity_does_not_create_a_lineage_association() { + let (app_path, live_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }; + let record = system_record("org.example.True", "Example App", &app_path, stale_identity) + .with_launch_literals(&["--application-mode"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut helper = sender("/usr/libexec/example-helper", identity(204, 2_040, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_081, + start_time: 7_071, + uid: 0, + executable: app_path, + executable_identity: stale_identity, + }); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("Same-user ancestor")); +} + +#[test] +fn lineage_rejects_a_candidate_with_a_different_indexed_executable() { + let (app_path, live_identity) = installed_system_executable(); + let indexed = system_record("org.example.True", "Example App", &app_path, live_identity) + .with_launch_literals(&["--application-mode"]); + let index = DesktopIdentityIndex::from_records(vec![indexed.clone()], Vec::new()); + let mut mismatched = indexed; + mismatched.runtime_executable_identity = Some(FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }); + let result = CandidateVerification { + record: &mismatched, + verification: LaunchVerification::DefinitiveMismatch(LaunchFailure::ExecutableMismatch), + }; + let mut helper = sender("/usr/libexec/example-helper", identity(206, 2_060, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_082, + start_time: 7_072, + uid: 0, + executable: app_path, + executable_identity: live_identity, + }); + + assert!(lineage_association(&helper, &index, &[&result]).is_none()); +} + +#[test] +fn direct_protected_payload_without_command_line_is_not_lineage_evidence() { + let (app_path, app_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); + let indexed = system_record("org.example.True", "Example App", &app_path, app_identity) + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); + let index = DesktopIdentityIndex::from_records(vec![indexed], Vec::new()); + let record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("protected-payload record should be indexed"); + let result = CandidateVerification { + record, + verification: LaunchVerification::InsufficientEvidence(LaunchFailure::MissingCommandLine), + }; + let mut helper = sender("/usr/libexec/example-helper", identity(207, 2_070, 0)); + helper.ancestors.push(ProcessLineageEvidence { + pid: 8_083, + start_time: 7_073, + uid: 0, + executable: app_path, + executable_identity: app_identity, + }); + + assert!( + lineage_association(&helper, &index, &[&result]).is_none(), + "missing command-line evidence is accepted only for a validated package launcher" + ); +} + +#[test] +fn unknown_executable_cannot_borrow_installed_app_identity() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let helper = sender("/tmp/random-script", identity(89, 890, 1_000)); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.attribution.claimed_name, "Example App"); + assert!(resolution.attribution.desktop_id.is_empty()); + assert_eq!( + resolution.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn verified_and_unresolved_senders_never_share_an_application_group() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let verified = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender(&app_path, app_identity), + &index, + ); + let unresolved = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender("/opt/example/helper", identity(90, 900, 1_000)), + &index, + ); + + assert_eq!(verified.attribution.status, AttributionStatus::Recognized); + assert_eq!( + verified.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); + assert_eq!(unresolved.attribution.status, AttributionStatus::Unresolved); + assert_ne!( + verified.attribution.group_key, unresolved.attribution.group_key, + "different trust domains must remain separate even for one canonical application" + ); +} + +#[test] +fn same_package_helper_is_recognized() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let mut helper = sender("/usr/lib/example/helper", identity(91, 910, 0)); + helper.install_provenance = package("org.example.True"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &helper, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + let claimed_record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("claimed record should be indexed"); + assert_eq!( + sender_claim_relation(&helper, &index, claimed_record), + SenderClaimRelation::SamePackageHelper + ); + assert!(resolution + .attribution + .diagnostic_detail + .contains("same installed application package")); +} + +#[test] +fn different_package_cannot_borrow_installed_app_identity() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "Example App", + &app_path, + app_identity, + )], + Vec::new(), + ); + let mut different_package = sender("/usr/libexec/example-helper", identity(92, 920, 0)); + different_package.install_provenance = package("org.example.Integration"); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &different_package, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.attribution.claimed_name, "Example App"); + assert!(resolution.attribution.desktop_id.is_empty()); + assert_eq!( + resolution.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence, + "the launch mismatch remains diagnostic evidence without proving impersonation" + ); + let claimed_record = index + .records_for_id("org.example.True") + .into_iter() + .next() + .expect("claimed record should be indexed"); + assert_eq!( + sender_claim_relation(&different_package, &index, claimed_record), + SenderClaimRelation::DifferentInstalledPackage + ); +} + +#[test] +fn verified_different_application_is_conflict() { + let (app_path, app_identity) = installed_system_executable(); + let other_identity = identity(93, 930, 0); + let index = DesktopIdentityIndex::from_records( + vec![ + system_record("org.example.True", "Example App", &app_path, app_identity), + system_record( + "org.example.Other", + "Other App", + "/usr/bin/other-app", + other_identity, + ), + ], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender("/usr/bin/other-app", other_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::DefinitiveMismatch + ); +} + +#[test] +fn user_record_owning_sender_executable_cannot_prove_a_conflict() { + let claimed_identity = identity(104, 1_040, 0); + let user_identity = identity(105, 1_050, 1_000); + let claimed = system_record( + "org.example.Claimed", + "Claimed App", + "/usr/bin/claimed", + claimed_identity, + ); + let user = DesktopRecord::fixture( + "org.example.Local", + "Local App", + "/home/user/bin/local", + user_identity, + false, + ); + let index = DesktopIdentityIndex::from_records(vec![claimed, user], Vec::new()); + let claimed_record = index + .records_for_id("org.example.Claimed") + .into_iter() + .next() + .expect("claimed record should be indexed"); + + assert_eq!( + sender_claim_relation( + &sender("/home/user/bin/local", user_identity), + &index, + claimed_record, + ), + SenderClaimRelation::UnknownExecutable, + "a user desktop record is not immutable contradictory ownership" + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs new file mode 100644 index 000000000..cdd6a49ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/identity.rs @@ -0,0 +1,27 @@ +//! Immutable sender-executable identity checks + +use super::super::super::evidence::current_system_identity_matches_sender; +use super::super::*; + +#[test] +fn reopened_system_identity_must_remain_protected_and_executable() { + let (_, trusted) = installed_system_executable(); + let unprotected = FileIdentity { + uid: 1_000, + ..trusted + }; + let non_executable = FileIdentity { + mode: 0o100_644, + ..trusted + }; + + assert!(current_system_identity_matches_sender(trusted, trusted)); + assert!(!current_system_identity_matches_sender( + unprotected, + trusted + )); + assert!(!current_system_identity_matches_sender( + non_executable, + trusted + )); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs new file mode 100644 index 000000000..f4fab6faf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/evidence/runtime.rs @@ -0,0 +1,379 @@ +//! Shared runtime and protected-payload regressions + +use super::super::*; + +#[test] +fn python_desktop_entry_cannot_trust_an_unrelated_python_process() { + let python_identity = identity(20, 200, 0); + let mut record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + python_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: Some("org.example.PasswordManager"), + }, + &sender("/usr/bin/python3", python_identity), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn unlisted_runtimes_cannot_associate_a_different_application_payload() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/pypy3", + "/usr/share/app/main.py", + "/tmp/fake.py", + ), + (2, "/usr/bin/gjs", "/usr/share/app/main.js", "/tmp/fake.js"), + ( + 3, + "/usr/bin/dotnet", + "/usr/share/app/Example.dll", + "/tmp/Fake.dll", + ), + ] { + let runtime_identity = identity(50, 500 + serial, 0); + let record = system_record( + "org.example.RuntimeApp", + "Runtime App", + executable, + runtime_identity, + ) + .with_launch_literals(&[expected]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Runtime App", + desktop_entry: Some("org.example.RuntimeApp"), + }, + &sender_with_arguments(executable, runtime_identity, &[actual]), + &index, + ); + + assert_ne!( + resolution.attribution.status, + AttributionStatus::Verified, + "{executable} accepted a different application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn java_cannot_associate_a_different_jar() { + let java_identity = identity(51, 510, 0); + let record = system_record( + "org.example.JavaApp", + "Java App", + "/usr/bin/java", + java_identity, + ) + .with_launch_literals(&["-jar", "/usr/share/java/example.jar"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Java App", + desktop_entry: Some("org.example.JavaApp"), + }, + &sender_with_arguments("/usr/bin/java", java_identity, &["-jar", "/tmp/fake.jar"]), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn matching_fixed_system_application_argument_allows_association() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); + let record = system_record( + "org.example.ScriptApp", + "Script App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Script App", + desktop_entry: Some("org.example.ScriptApp"), + }, + &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn arbitrary_executable_name_still_requires_its_fixed_application_payload() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.CustomRuntime", + "Custom Runtime App", + &launcher_path, + launcher_identity, + ) + .with_launch_literals(&["/usr/share/custom-runtime/application.bin"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Custom Runtime App", + desktop_entry: Some("org.example.CustomRuntime"), + }, + &sender_with_arguments( + &launcher_path, + launcher_identity, + &["/tmp/attacker-controlled.bin"], + ), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn empty_dedicated_contract_is_recognized_when_command_line_is_unavailable() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.True", + "Command Line App", + &launcher_path, + launcher_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut missing_command_line = sender(&launcher_path, launcher_identity); + missing_command_line.command_line = CommandLineEvidence::default(); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Command Line App", + desktop_entry: Some("org.example.True"), + }, + &missing_command_line, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn nonempty_dedicated_contract_can_rely_on_exact_executable_evidence() { + let (launcher_path, launcher_identity) = installed_system_executable(); + let record = system_record( + "org.example.True", + "Command Line App", + &launcher_path, + launcher_identity, + ) + .with_launch_literals(&["--background"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut missing_command_line = sender(&launcher_path, launcher_identity); + missing_command_line.command_line = CommandLineEvidence::default(); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Command Line App", + desktop_entry: Some("org.example.True"), + }, + &missing_command_line, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn no_hint_shared_runtimes_with_wrong_payloads_are_denied() { + for (serial, executable, expected, actual) in [ + ( + 1_u64, + "/usr/bin/python3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 2, + "/usr/bin/pypy3", + "/usr/share/password-manager/main.py", + "/tmp/fake.py", + ), + ( + 3, + "/usr/bin/gjs", + "/usr/share/password-manager/main.js", + "/tmp/fake.js", + ), + ( + 4, + "/usr/bin/dotnet", + "/usr/share/password-manager/PasswordManager.dll", + "/tmp/Fake.dll", + ), + ( + 5, + "/usr/bin/java", + "/usr/share/password-manager/password-manager.jar", + "/tmp/fake.jar", + ), + ] { + let runtime_identity = identity(60, 600 + serial, 0); + let fixed_arguments = if executable == "/usr/bin/java" { + vec!["-jar", expected] + } else { + vec![expected] + }; + let sender_arguments = if executable == "/usr/bin/java" { + vec!["-jar", actual] + } else { + vec![actual] + }; + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + executable, + runtime_identity, + ) + .with_launch_literals(&fixed_arguments); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments(executable, runtime_identity, &sender_arguments), + &index, + ); + + assert_ne!( + resolution.attribution.status, + AttributionStatus::Verified, + "{executable} accepted a different no-hint application payload" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn no_hint_shared_runtime_with_matching_protected_payload_is_allowed() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let (payload_path, payload_identity) = installed_system_executable(); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&[&payload_path]) + .with_protected_launch_file(&payload_path, payload_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Password Manager", + desktop_entry: None, + }, + &sender_with_arguments(&runtime_path, runtime_identity, &[&payload_path]), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn no_hint_wrong_payload_with_unrelated_claim_remains_unknown() { + let runtime_identity = identity(62, 620, 0); + let record = system_record( + "org.example.PasswordManager", + "Example Password Manager", + "/usr/bin/python3", + runtime_identity, + ) + .with_launch_literals(&["/usr/share/password-manager/main.py"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Unrelated Local Script", + desktop_entry: None, + }, + &sender_with_arguments("/usr/bin/python3", runtime_identity, &["/tmp/local.py"]), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn dynamic_only_contract_is_unverified_instead_of_suspicious() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let mut first = system_record( + "org.example.Dynamic", + "Dynamic App", + &runtime_path, + runtime_identity, + ); + first + .launch_spec + .as_mut() + .expect("dynamic launch spec") + .arguments = vec![LaunchArgument::FieldCode(FieldCode::Files)]; + let second = system_record( + "org.example.Other", + "Other App", + &runtime_path, + runtime_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Dynamic App", + desktop_entry: Some("org.example.Dynamic"), + }, + &sender_with_arguments(&runtime_path, runtime_identity, &["/tmp/payload"]), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); + assert_eq!( + resolution.diagnostics.launch_authority, + LaunchAuthorityView::DynamicOnly + ); + assert_eq!( + resolution.diagnostics.matched_desktop_id, + "org.example.Dynamic" + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs new file mode 100644 index 000000000..4e6f9e31c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/mod.rs @@ -0,0 +1,67 @@ +//! Resolver behavior tests grouped by evidence path + +use std::collections::HashSet; +use std::path::PathBuf; + +use unixnotis_core::{ + AttributionStatus, CommandLineQualityView, InlineReplyPolicy, InteractionPolicies, + LaunchAuthorityView, LaunchVerificationView, RecordTrust, +}; + +use super::candidates::{resolve_unverified_candidates, strongest_verified_result}; +use super::evidence::verify_record_sender; +use super::model::{CandidateVerification, SenderClaimRelation, VerifiedDesktopRecord}; +use super::pipeline::{ + claim_has_index_candidate, needs_sender_provenance, resolve_attribution_owned_with, + resolve_attribution_owned_with_pool, resolve_attribution_with_deadline, resolve_with_evidence, + ATTRIBUTION_TIMEOUT, +}; +use super::sender_context::enrich_sender_install_provenance_blocking; +use super::AppClaim; +use crate::daemon::notifications::identity::desktop_index::model::{ + ExecutableIdentity, FieldCode, LaunchArgument, LaunchSpec, LiteralArgument, +}; +use crate::daemon::notifications::identity::desktop_index::provenance::PackageProvider; +use crate::daemon::notifications::identity::desktop_index::{ + normalize_name, DesktopIdentityIndex, DesktopRecord, InstallProvenance, LaunchFailure, + LaunchVerification, VerifiedLaunch, +}; +use crate::daemon::notifications::identity::executable::executable_evidence_for_path; +use crate::daemon::notifications::identity::sender::{ + refresh_sender_security_evidence, CommandLineEvidence, CommandLineQuality, + ProcessLineageEvidence, SenderMetadata, +}; +use crate::daemon::notifications::identity::FileIdentity; + +mod support; + +use support::*; + +pub(super) async fn resolve_attribution( + claim: AppClaim<'_>, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> super::model::AttributionResolution { + // Test-only direct entry point keeps asynchronous package enrichment covered + let mut sender = refresh_sender_security_evidence(sender); + let initial = resolve_with_evidence(claim, &sender, index); + let needs_provenance = needs_sender_provenance( + initial.attribution.status, + initial.attribution.interactions, + claim_has_index_candidate(claim, index), + ); + if !needs_provenance { + return initial; + } + enrich_sender_install_provenance_blocking(&mut sender, index); + resolve_with_evidence(claim, &sender, index) +} + +mod candidates; +mod diagnostics; +mod evidence; +mod model; +mod pipeline; +mod resolution; +mod sender_context; +mod validation; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs new file mode 100644 index 000000000..d3ec61c52 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/model.rs @@ -0,0 +1,29 @@ +//! Resolver value-model tests + +use super::super::model::CandidateVerification; +use super::*; + +#[test] +fn candidate_verification_preserves_mismatch_kind_and_verified_fallback() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(201, 2_010, 0), + ); + let mismatch = CandidateVerification { + record: &record, + verification: LaunchVerification::DefinitiveMismatch( + LaunchFailure::ProtectedPayloadMismatch, + ), + }; + let verified = CandidateVerification { + record: &record, + verification: LaunchVerification::Verified(VerifiedLaunch::DedicatedExecutable), + }; + + assert!(mismatch.is_definitive_mismatch()); + assert_eq!(mismatch.failure(), LaunchFailure::ProtectedPayloadMismatch); + assert!(!verified.is_definitive_mismatch()); + assert_eq!(verified.failure(), LaunchFailure::DesktopClaimMismatch); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs new file mode 100644 index 000000000..0f2dd8588 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline.rs @@ -0,0 +1,6 @@ +//! End-to-end resolver-pipeline tests + +mod dedicated; +mod portal; +mod provenance; +mod spoof; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs new file mode 100644 index 000000000..c02fc9c00 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/dedicated.rs @@ -0,0 +1,303 @@ +//! Dedicated executable contract cases + +use super::super::*; + +#[test] +fn dedicated_system_identity_is_associated_without_inline_reply_authority() { + let (app_path, app_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.True", + "True Chat", + &app_path, + app_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "True Chat", + desktop_entry: Some("org.example.True.desktop"), + }, + &sender(&app_path, app_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); + assert_eq!(resolution.attribution.display_name, "True Chat"); + assert_eq!( + resolution.attribution.group_key, + "associated:system-app:org.example.True" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), + unixnotis_core::ApplicationActionPolicy::Allow + ); + assert_eq!( + resolution.attribution.action_button_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm + ); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("unverified")); +} + +#[test] +fn dedicated_system_binary_rejects_runtime_added_flags_outside_the_exec_contract() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("example-chat", "Example Chat", &app_path, app_identity) + .with_launch_literals(&["--", "example-chat://expected"]); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + // Some Electron applications send an empty name and add runtime flags after activation + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments( + &app_path, + app_identity, + &["--password-store=kwallet6", "--ozone-platform=x11", "--"], + ), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn dedicated_executable_accepts_extra_non_identity_runtime_flags() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.True", "Example App", &app_path, app_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &sender_with_arguments( + &app_path, + app_identity, + &["--display-backend=x11", "--tray"], + ), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); +} + +#[test] +fn empty_dedicated_contract_with_rewritten_argv_is_recognized_not_conflicting() { + let (app_path, app_identity) = installed_system_executable(); + let record = system_record("org.example.True", "Example App", &app_path, app_identity); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let mut rewritten = sender(&app_path, app_identity); + rewritten.command_line = CommandLineEvidence { + argv: vec![format!("{app_path} --runtime-flag").into_bytes()], + quality: CommandLineQuality::RewrittenProcessTitle, + }; + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.True"), + }, + &rewritten, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!( + resolution.diagnostics.command_line_quality, + CommandLineQualityView::RewrittenProcessTitle + ); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); + assert_eq!( + resolution.diagnostics.launch_authority, + LaunchAuthorityView::DedicatedExecutable + ); +} + +#[test] +fn verified_executable_recovers_from_stale_desktop_hint() { + let (app_path, app_identity) = installed_system_executable(); + let mut stale_user_entry = DesktopRecord::fixture( + "example-chat", + "Example Chat", + "/usr/bin/env", + identity(90, 900, 0), + false, + ); + // An env wrapper cannot associate the user entry with the dedicated application process + stale_user_entry.association_eligible = false; + stale_user_entry.system_association = false; + let system_entry = system_record("example-chat-true", "Example Chat", &app_path, app_identity); + let index = + DesktopIdentityIndex::from_records(vec![stale_user_entry, system_entry], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Chat", + // Electron can derive this hint from a differently named local desktop file + desktop_entry: Some("example-chat"), + }, + &sender(&app_path, app_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.display_name, "Example Chat"); + assert_eq!(resolution.attribution.desktop_id, "example-chat-true"); +} + +#[test] +fn empty_claim_cannot_choose_between_apps_sharing_one_dedicated_binary() { + let (runtime_path, runtime_identity) = installed_system_executable(); + let first = system_record( + "org.example.First", + "First App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=first"]); + let second = system_record( + "org.example.Second", + "Second App", + &runtime_path, + runtime_identity, + ) + .with_launch_literals(&["--app-id=second"]); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: None, + }, + &sender_with_arguments(&runtime_path, runtime_identity, &["--unmodeled"]), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn duplicate_desktop_id_prefers_the_protected_record() { + let (app_path, app_identity) = installed_system_executable(); + let user_record = DesktopRecord::fixture("true", "Example App", &app_path, app_identity, false); + let mut system_record = system_record("true", "Example App", &app_path, app_identity); + system_record.badge_icon = "protected-example".to_string(); + let index = DesktopIdentityIndex::from_records(vec![user_record, system_record], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate desktop id should keep one verified record"); + + assert!(verified.0.system_association); + assert_eq!(verified.0.badge_icon, "protected-example"); +} + +#[test] +fn duplicate_protected_desktop_id_keeps_stable_index_order() { + let (app_path, app_identity) = installed_system_executable(); + let mut first = system_record("true", "Example App", &app_path, app_identity); + first.badge_icon = "first-example".to_string(); + let mut second = system_record("true", "Example App", &app_path, app_identity); + second.badge_icon = "second-example".to_string(); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + let records = index.records_for_executable(app_identity); + + let verified = + verified_executable_record(&records, "", &sender(&app_path, app_identity), &index) + .expect("duplicate protected records should keep one verified record"); + + assert_eq!(verified.0.badge_icon, "first-example"); +} + +#[test] +fn stale_cached_system_identity_is_denied_for_explicit_and_no_hint_routes() { + let (system_path, cached_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected App", + &system_path, + cached_identity, + )], + Vec::new(), + ); + let untrusted_identities = [ + FileIdentity { + uid: 1_000, + ..cached_identity + }, + FileIdentity { + mode: 0o100_777, + ..cached_identity + }, + ]; + + for desktop_entry in [Some("org.example.Protected"), None] { + for sender_identity in untrusted_identities { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry, + }, + &sender(&system_path, sender_identity), + &index, + ); + + assert_ne!( + resolution.attribution.status, + AttributionStatus::Verified, + "stale system identity accepted for hint {desktop_entry:?}" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } + } +} + +#[test] +fn user_desktop_identity_denies_reply_until_backend_confirmation_exists() { + let app_identity = identity(6, 60, 1000); + let index = DesktopIdentityIndex::from_records( + vec![DesktopRecord::fixture( + "org.example.LocalApp", + "Local App", + "/home/user/bin/local-app", + app_identity, + false, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.LocalApp"), + }, + &sender("/home/user/bin/local-app", app_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs new file mode 100644 index 000000000..c795f97df --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/portal.rs @@ -0,0 +1,185 @@ +//! Trusted portal attribution regressions + +use super::super::*; + +#[test] +fn unmediated_flatpak_process_cannot_become_portal_associated() { + let flatpak_identity = identity(21, 210, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Flatpak App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/bin/flatpak", flatpak_identity), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn an_empty_app_name_does_not_turn_an_untrusted_relay_into_a_portal() { + let flatpak_identity = identity(24, 240, 0); + let relay_identity = identity(25, 250, 0); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender("/usr/lib/untrusted-relay", relay_identity), + &index, + ); + + assert_ne!(resolution.attribution.status, AttributionStatus::Verified); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn portal_mediated_flatpak_uses_broker_associated_desktop_identity() { + let flatpak_identity = identity(22, 220, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + // The GTK portal backend forwards an empty app name and desktop-entry hint + reported_name: "", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::PortalAssociated + ); + assert_eq!(resolution.attribution.display_name, "Flatpak App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm + ); + assert_eq!( + resolution.attribution.action_button_policy(), + unixnotis_core::ApplicationActionPolicy::Confirm + ); +} + +#[test] +fn trusted_portal_accepts_a_matching_nonempty_application_name() { + let flatpak_identity = identity(26, 260, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Flatpak App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!(resolution.attribution.display_name, "Flatpak App"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn trusted_portal_reports_a_name_that_contradicts_its_verified_application_id() { + let flatpak_identity = identity(27, 270, 0); + let (portal_path, portal_identity) = installed_system_executable(); + let mut record = system_record( + "org.example.FlatpakApp", + "Flatpak App", + "/usr/bin/flatpak", + flatpak_identity, + ); + record.association_eligible = false; + record.system_association = false; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Different App", + desktop_entry: Some("org.example.FlatpakApp"), + }, + &sender(&portal_path, portal_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!(resolution.diagnostics.record_trust, RecordTrust::Portal); +} + +#[test] +fn trusted_portal_rejects_a_stale_indexed_inode() { + let (portal_path, live_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: live_identity.inode.saturating_add(1), + ..live_identity + }; + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), stale_identity); + + assert!(index + .trusted_portal_path(live_identity, std::path::Path::new(&portal_path)) + .is_none()); +} + +#[test] +fn trusted_portal_rejects_a_live_path_outside_protected_roots() { + let (portal_path, portal_identity) = installed_system_executable(); + let index = DesktopIdentityIndex::from_records(Vec::new(), Vec::new()) + .with_trusted_portal(PathBuf::from(&portal_path), portal_identity); + + assert!(index + .trusted_portal_path( + portal_identity, + std::path::Path::new("/tmp/xdg-desktop-portal") + ) + .is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs new file mode 100644 index 000000000..750b5fdc5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/provenance.rs @@ -0,0 +1,362 @@ +//! Async provenance enrichment in the resolver pipeline + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; + +use super::super::*; + +#[test] +fn provenance_enrichment_is_limited_to_denied_association_candidates() { + for (status, policies, has_candidate, expected) in [ + ( + AttributionStatus::Recognized, + InteractionPolicies::DENY, + false, + true, + ), + ( + AttributionStatus::Unresolved, + InteractionPolicies::DENY, + true, + true, + ), + ( + AttributionStatus::Unresolved, + InteractionPolicies::DENY, + false, + false, + ), + ( + AttributionStatus::Recognized, + InteractionPolicies::NATIVE_COMPATIBILITY, + true, + false, + ), + ( + AttributionStatus::Conflict, + InteractionPolicies::DENY, + true, + false, + ), + ] { + assert_eq!( + needs_sender_provenance(status, policies, has_candidate), + expected, + "status={status:?}, policies={policies:?}, has_candidate={has_candidate}" + ); + } +} + +#[test] +fn provenance_candidate_lookup_accepts_only_indexed_name_or_desktop_id() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(41, 42, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + assert!(claim_has_index_candidate( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &index, + )); + assert!(claim_has_index_candidate( + AppClaim { + reported_name: "", + desktop_entry: Some("org.example.App"), + }, + &index, + )); + assert!(!claim_has_index_candidate( + AppClaim { + reported_name: "Unknown App", + desktop_entry: Some("org.example.Missing"), + }, + &index, + )); +} + +#[tokio::test] +async fn recognized_helper_is_reresolved_with_live_package_provenance() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read the helper executable identity"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read the application executable identity"); + let ownership_index = DesktopIdentityIndex::default(); + let helper_provenance = ownership_index.install_provenance_for_path(helper_path.clone()); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(helper_provenance.is_known()); + assert!(helper_provenance.same_application_source(&app_provenance)); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let resolution = resolve_attribution( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender(&helper_path.display().to_string(), helper_evidence.identity), + &index, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert!(resolution + .attribution + .diagnostic_detail + .contains("same installed application package")); +} + +#[tokio::test] +async fn slow_valid_provenance_is_not_cut_off_by_a_short_inner_deadline() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance.clone(); + let index = Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())); + + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender(&helper_path.display().to_string(), helper_evidence.identity), + index, + move |sender, _| { + // Package ownership can exceed the old 500 ms inner deadline + std::thread::sleep(Duration::from_millis(650)); + sender.install_provenance = app_provenance; + }, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); +} + +#[tokio::test] +async fn failed_provenance_keeps_the_initial_safe_resolution() { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance.clone(); + let index = Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())); + let mut sender = sender(&helper_path.display().to_string(), helper_evidence.identity); + sender.install_provenance = app_provenance; + + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + |sender, _| { + // Model a provider failure without granting a stronger result + sender.install_provenance = InstallProvenance::Unknown; + }, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); +} + +#[tokio::test] +async fn ingress_deadline_fails_closed_after_the_real_resolver_exceeds_budget() { + let (index, sender) = same_package_helper_fixture(); + let slow_resolution = async { + let resolution = resolve_attribution_owned_with( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender.clone(), + index, + move |_, _| { + std::thread::sleep(ATTRIBUTION_TIMEOUT + Duration::from_millis(250)); + }, + ) + .await; + // Keep the injected production future beyond the outer ingress budget + tokio::time::sleep(ATTRIBUTION_TIMEOUT + Duration::from_millis(250)).await; + resolution + }; + let resolution = resolve_attribution_with_deadline( + "Example App".to_string(), + Some("org.example.App".to_string()), + &sender, + slow_resolution, + ) + .await; + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::DENY + ); + assert!(resolution + .attribution + .diagnostic_detail + .contains("attribution timed out")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn cancelled_attribution_keeps_worker_permits_until_blocking_jobs_exit() { + let (index, sender) = same_package_helper_fixture(); + let worker_pool = Arc::new(tokio::sync::Semaphore::new(8)); + let started = Arc::new(AtomicUsize::new(0)); + let finished = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Barrier::new(9)); + let mut tasks = Vec::with_capacity(8); + + for _ in 0..8 { + let index = Arc::clone(&index); + let sender = sender.clone(); + let worker_pool = Arc::clone(&worker_pool); + let started = Arc::clone(&started); + let finished = Arc::clone(&finished); + let release = Arc::clone(&release); + tasks.push(tokio::spawn(resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + worker_pool, + move |_, _| { + started.fetch_add(1, Ordering::AcqRel); + release.wait(); + finished.fetch_add(1, Ordering::Release); + }, + ))); + } + + tokio::time::timeout(Duration::from_secs(2), async { + while started.load(Ordering::Acquire) != 8 { + tokio::task::yield_now().await; + } + }) + .await + .expect("all attribution workers should enter blocking enrichment"); + + for task in &tasks { + task.abort(); + } + + let blocked = resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender.clone(), + Arc::clone(&index), + Arc::clone(&worker_pool), + |_, _| {}, + ) + .await; + assert!(blocked + .attribution + .diagnostic_detail + .contains("attribution worker capacity exhausted")); + + release.wait(); + tokio::time::timeout(Duration::from_secs(2), async { + while finished.load(Ordering::Acquire) != 8 { + tokio::task::yield_now().await; + } + }) + .await + .expect("worker permits should be released after blocking jobs exit"); + + let available = resolve_attribution_owned_with_pool( + "Example App".to_string(), + Some("org.example.App".to_string()), + sender, + index, + worker_pool, + |_, _| {}, + ) + .await; + assert!(!available + .attribution + .diagnostic_detail + .contains("attribution worker capacity exhausted")); +} + +fn same_package_helper_fixture() -> (Arc, SenderMetadata) { + let helper_path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find the installed helper fixture"); + let app_path = unixnotis_core::util::trusted_system_program_path("false") + .expect("find the installed application fixture"); + let helper_evidence = + executable_evidence_for_path(&helper_path).expect("read helper executable evidence"); + let app_evidence = + executable_evidence_for_path(&app_path).expect("read application executable evidence"); + let ownership_index = DesktopIdentityIndex::default(); + let app_provenance = ownership_index.install_provenance_for_path(app_path.clone()); + assert!(app_provenance.is_known()); + + let mut record = system_record( + "org.example.App", + "Example App", + &app_path.display().to_string(), + app_evidence.identity, + ); + record.desktop_provenance = app_provenance.clone(); + record.declared_executable_provenance = app_provenance.clone(); + record.runtime_executable_provenance = app_provenance; + + ( + Arc::new(DesktopIdentityIndex::from_records(vec![record], Vec::new())), + sender(&helper_path.display().to_string(), helper_evidence.identity), + ) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs new file mode 100644 index 000000000..c88fb3892 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/pipeline/spoof.rs @@ -0,0 +1,393 @@ +//! Spoofing and conflicting-identity regressions + +use super::super::*; + +#[test] +fn sender_metadata_timeout_is_unresolved_not_conflict() { + let protected_identity = identity(39, 390, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Protected", + "Protected", + "/usr/bin/protected", + protected_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected", + desktop_entry: None, + }, + &SenderMetadata::default(), + &index, + ); + + assert_eq!( + resolution.attribution.status, + AttributionStatus::Unresolved, + "a timed-out sender lookup cannot prove application association" + ); + assert_eq!(resolution.attribution.display_name, "Unknown application"); +} + +#[test] +fn user_shadow_cannot_join_the_system_desktop_group() { + let system_identity = identity(30, 300, 0); + let user_identity = identity(31, 310, 1000); + let system = system_record( + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + system_identity, + ); + let mut user = DesktopRecord::fixture( + "org.example.Chat", + "Example Chat", + "/home/user/bin/example-chat", + user_identity, + false, + ); + user.desktop_identity = Some(identity(32, 320, 1000)); + let index = DesktopIdentityIndex::from_records(vec![user, system], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Chat", + desktop_entry: Some("org.example.Chat"), + }, + &sender("/home/user/bin/example-chat", user_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert!(resolution + .attribution + .group_key + .starts_with("recognized:user-app:")); + assert_ne!( + resolution.attribution.group_key, + "verified:system-app:org.example.Chat" + ); +} + +#[test] +fn user_desktop_mismatch_cannot_manufacture_a_conflict() { + let user_identity = identity(34, 340, 1_000); + let hostile_identity = identity(35, 350, 1_000); + let mut user = DesktopRecord::fixture( + "org.example.Local", + "Local App", + "/home/user/bin/local-app", + user_identity, + false, + ); + user.desktop_identity = Some(identity(36, 360, 1_000)); + let index = DesktopIdentityIndex::from_records(vec![user], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Local App", + desktop_entry: Some("org.example.Local"), + }, + &sender("/tmp/unrelated", hostile_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn protected_conflict_evidence_outranks_a_user_desktop_shadow() { + let protected_identity = identity(37, 370, 0); + let user_identity = identity(38, 380, 1_000); + let hostile_identity = identity(39, 390, 0); + let protected = system_record( + "org.example.Protected", + "Protected App", + "/usr/bin/protected-app", + protected_identity, + ); + let mut user = DesktopRecord::fixture( + "org.example.Protected.Handler", + "Protected App", + "/home/user/bin/protected-handler", + user_identity, + false, + ); + user.desktop_identity = Some(identity(40, 400, 1_000)); + let unrelated = system_record( + "org.example.Unrelated", + "Unrelated App", + "/usr/bin/unrelated", + hostile_identity, + ); + let index = DesktopIdentityIndex::from_records(vec![user, protected, unrelated], Vec::new()); + let different = sender("/usr/bin/unrelated", hostile_identity); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Protected App", + desktop_entry: Some("org.example.Protected.Handler"), + }, + &different, + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.attribution.desktop_id, "org.example.Protected"); + assert_eq!(resolution.diagnostics.record_trust, RecordTrust::System); +} + +#[test] +fn ambiguous_protected_records_are_unresolved_not_conflicting() { + let first = system_record( + "org.example.First", + "Shared Label", + "/usr/bin/first-app", + identity(41, 410, 0), + ); + let second = system_record( + "org.example.Second", + "Shared Label", + "/usr/bin/second-app", + identity(42, 420, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![first, second], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Shared Label", + desktop_entry: None, + }, + &sender("/usr/bin/unrelated", identity(43, 430, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!( + resolution.attribution.reason, + unixnotis_core::AttributionReason::AmbiguousDesktopRecords + ); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); +} + +#[test] +fn visually_confusable_system_brand_without_association_is_unresolved() { + let app_identity = identity(40, 400, 0); + let hostile_identity = identity(41, 410, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, + )], + Vec::new(), + ); + + for claim in ["Sіgnal", "Signaⅼ"] { + let resolution = resolve_with_evidence( + AppClaim { + reported_name: claim, + desktop_entry: None, + }, + &sender("/tmp/fake", hostile_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + } +} + +#[test] +fn basename_spoof_without_immutable_owner_is_unresolved_without_actions() { + let app_identity = identity(1, 10, 0); + let hostile_identity = identity(7, 70, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Chat", + desktop_entry: None, + }, + &sender("/tmp/example-chat", hostile_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!( + resolution.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.diagnostics.verification, + LaunchVerificationView::InsufficientEvidence + ); + assert_ne!(resolution.attribution.group_key, "desktop:org.example.Chat"); +} + +#[test] +fn exact_protected_name_without_positive_association_stays_unresolved() { + let keepass_identity = identity(2, 20, 0); + let hostile_identity = identity(8, 80, 1000); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.keepassxc.KeePassXC", + "KeePassXC", + "/usr/bin/keepassxc", + keepass_identity, + )], + Vec::new(), + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "KeePassXC", + desktop_entry: None, + }, + &sender("/tmp/keepassxc", hostile_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn exact_system_notify_send_identity_is_a_non_replying_relay() { + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Relay); + assert_eq!( + resolution.attribution.display_name, + "Command-line notification" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(!resolution + .attribution + .diagnostic_detail + .contains("unverified")); +} + +#[test] +fn trusted_relay_uses_command_line_identity() { + let app_identity = identity(1, 10, 0); + let relay_identity = identity(3, 30, 0); + let index = DesktopIdentityIndex::from_records( + vec![system_record( + "org.example.Chat", + "Example Chat", + "/usr/bin/example-chat", + app_identity, + )], + vec![(PathBuf::from("/usr/bin/notify-send"), relay_identity)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example Chat", + desktop_entry: None, + }, + &sender("/usr/bin/notify-send", relay_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Relay); + assert_eq!( + resolution.attribution.display_name, + "Command-line notification" + ); + assert_eq!(resolution.attribution.claimed_name, "Example Chat"); + assert_eq!( + resolution.attribution.badge_icon, + "utilities-terminal-symbolic" + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_ne!(resolution.attribution.status, AttributionStatus::Conflict); + assert_ne!(resolution.attribution.group_key, "desktop:org.example.Chat"); +} + +#[test] +fn malicious_notify_send_basename_is_not_a_trusted_relay() { + let real_relay = identity(3, 30, 0); + let hostile_identity = identity(9, 90, 1000); + let index = DesktopIdentityIndex::from_records( + Vec::new(), + vec![(PathBuf::from("/usr/bin/notify-send"), real_relay)], + ); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Screenshot", + desktop_entry: None, + }, + &sender("/tmp/notify-send", hostile_identity), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn owned_dbus_application_name_without_executable_evidence_remains_unverified() { + let app_identity = identity(4, 40, 0); + let mut record = DesktopRecord::fixture( + "org.example.App", + "Example App", + "/usr/bin/example-app", + app_identity, + true, + ); + record.runtime_executable_identity = None; + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + + let resolution = resolve_with_evidence( + AppClaim { + reported_name: "Example App", + desktop_entry: Some("org.example.App"), + }, + &sender("/usr/lib/example-launcher", identity(5, 50, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.attribution.display_name, "Unknown application"); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution + .attribution + .diagnostic_detail + .contains("/usr/lib/example-launcher")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs new file mode 100644 index 000000000..99168ab4a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/resolution.rs @@ -0,0 +1,203 @@ +//! Attribution construction and grouping tests + +use super::super::resolution::{ + owner_bound_default_interactions, recognized_resolution, resolution_for_record, + sender_claim_group_key, unknown_reply_denied, +}; +use super::*; +use crate::daemon::notifications::identity::sender::SenderMetadataStatus; +use unixnotis_core::ApplicationActionPolicy; + +#[test] +fn sender_claim_group_key_is_nonempty_and_bound_to_sender_identity() { + let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + + let unresolved = + sender_claim_group_key(AttributionStatus::Unresolved, "Example App", &metadata); + let conflict = sender_claim_group_key(AttributionStatus::Conflict, "Example App", &metadata); + + assert_eq!(unresolved, "unresolved:106:1060:exampleapp"); + assert_eq!(conflict, "conflict:106:1060:exampleapp"); + assert_ne!(unresolved, conflict); +} + +#[test] +fn verified_record_with_a_contradictory_name_becomes_conflict() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/bin/example-app", + identity(205, 2_050, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let resolution = resolution_for_record( + VerifiedDesktopRecord(record, VerifiedLaunch::DedicatedExecutable), + "Different App", + &sender("/usr/bin/example-app", identity(205, 2_050, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Conflict); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); +} + +#[test] +fn package_launcher_target_preserves_only_compatible_default_activation() { + let record = system_record( + "org.example.App", + "Example App", + "/usr/lib/example-app/runtime", + identity(207, 2_070, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.App") + .into_iter() + .next() + .expect("fixture record should be indexed"); + + let resolution = resolution_for_record( + VerifiedDesktopRecord(record, VerifiedLaunch::PackageLauncherTarget), + "Example App", + &sender("/usr/lib/example-app/runtime", identity(207, 2_070, 0)), + &index, + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Recognized); + assert_eq!( + resolution.attribution.assurance, + unixnotis_core::IdentityAssurance::SystemAssociated + ); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert_eq!( + resolution.attribution.default_activation_policy(), + ApplicationActionPolicy::Allow + ); + assert_eq!( + resolution.attribution.action_button_policy(), + ApplicationActionPolicy::Confirm + ); +} + +#[test] +fn missing_sender_reply_resolution_is_unresolved_and_noninteractive() { + let metadata = SenderMetadata::default(); + let resolution = unknown_reply_denied( + AppClaim { + reported_name: "Example App", + desktop_entry: None, + }, + &metadata, + "sender metadata unavailable", + ); + + assert_eq!(resolution.attribution.status, AttributionStatus::Unresolved); + assert_eq!(resolution.inline_reply_policy, InlineReplyPolicy::Deny); + assert!(resolution + .attribution + .diagnostic_detail + .contains("sender metadata unavailable")); +} + +#[test] +fn sender_credential_timeout_is_preserved_in_diagnostics() { + let metadata = SenderMetadata { + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + }; + let resolution = unknown_reply_denied( + AppClaim { + reported_name: "Example Chat", + desktop_entry: None, + }, + &metadata, + "sender metadata unavailable", + ); + + assert!(resolution + .attribution + .diagnostic_detail + .contains("credential lookup timed out")); +} + +#[test] +fn stable_callback_owner_gets_only_default_activation_authority() { + let mut metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + metadata.sender_pid = Some(42); + metadata.sender_start_time = Some(4_200); + metadata.sender_uid = Some(1_000); + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} + +#[test] +fn incomplete_callback_owner_gets_no_interaction_authority() { + let metadata = sender("/usr/bin/example", identity(106, 1_060, 0)); + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::DENY + ); +} + +#[test] +fn credential_timeout_never_gets_owner_bound_default_authority() { + let metadata = SenderMetadata { + sender_name: Some(":1.43".to_string()), + sender_pid: Some(43), + sender_start_time: Some(4_300), + sender_uid: Some(1_000), + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + }; + + assert_eq!( + owner_bound_default_interactions(&metadata), + InteractionPolicies::DENY + ); +} + +#[test] +fn recognized_candidate_with_a_stable_owner_exposes_only_default_activation() { + let record = system_record( + "org.example.Application", + "Example Application", + "/usr/bin/example", + identity(107, 1_070, 0), + ); + let index = DesktopIdentityIndex::from_records(vec![record], Vec::new()); + let record = index + .records_for_id("org.example.Application") + .into_iter() + .next() + .expect("fixture record should be indexed"); + let mut metadata = sender("/usr/bin/example", identity(107, 1_070, 0)); + metadata.sender_pid = Some(43); + metadata.sender_start_time = Some(4_300); + metadata.sender_uid = Some(1_000); + + let resolution = recognized_resolution( + AppClaim { + reported_name: "Example Application", + desktop_entry: None, + }, + &metadata, + record, + &index, + LaunchFailure::ExecutableMismatch, + "generic stable-owner fixture", + ); + + assert_eq!( + resolution.attribution.interactions, + InteractionPolicies::OWNER_BOUND_DEFAULT + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs new file mode 100644 index 000000000..2c1a4c9e5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/sender_context.rs @@ -0,0 +1,71 @@ +//! Live sender-context enrichment tests + +use super::super::sender_context::enrich_sender_install_provenance_blocking; +use super::*; + +#[tokio::test] +async fn provenance_enrichment_preserves_known_ownership_without_lookup() { + let expected = package("example-app"); + let mut metadata = SenderMetadata { + install_provenance: expected.clone(), + ..SenderMetadata::default() + }; + + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); + + assert_eq!(metadata.install_provenance, expected); +} + +#[tokio::test] +async fn provenance_enrichment_keeps_unknown_when_process_identity_is_missing() { + let mut metadata = SenderMetadata::default(); + + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); + + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); +} + +#[tokio::test] +async fn provenance_enrichment_resolves_a_reopened_system_executable() { + let (path, executable_identity) = installed_system_executable(); + let mut metadata = sender(&path, executable_identity); + + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); + + assert!(metadata.install_provenance.is_known()); +} + +#[tokio::test] +async fn provenance_enrichment_rejects_untrusted_or_nonexecutable_sender_metadata() { + let (path, executable_identity) = installed_system_executable(); + let invalid_identities = [ + FileIdentity { + uid: 1_000, + ..executable_identity + }, + FileIdentity { + mode: 0o100_644, + ..executable_identity + }, + ]; + + for invalid_identity in invalid_identities { + let mut metadata = sender(&path, invalid_identity); + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); + } +} + +#[tokio::test] +async fn provenance_enrichment_rejects_a_stale_executable_identity() { + let (path, executable_identity) = installed_system_executable(); + let stale_identity = FileIdentity { + inode: executable_identity.inode.saturating_add(1), + ..executable_identity + }; + let mut metadata = sender(&path, stale_identity); + + enrich_sender_install_provenance_blocking(&mut metadata, &DesktopIdentityIndex::default()); + + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs new file mode 100644 index 000000000..901d58270 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/support.rs @@ -0,0 +1,243 @@ +//! Shared resolver fixtures and synthetic process-evidence builders + +use super::*; + +pub(super) trait DesktopRecordFixture { + fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + ) -> Self; + + fn with_launch_literals(self, arguments: &[&str]) -> Self; + + fn with_protected_launch_file(self, path: &str, identity: FileIdentity) -> Self; +} + +impl DesktopRecordFixture for DesktopRecord { + fn fixture( + id: &str, + display_name: &str, + executable_path: &str, + identity: FileIdentity, + system_entry: bool, + ) -> Self { + Self { + id: id.to_string(), + display_name: display_name.to_string(), + badge_icon: id.to_string(), + desktop_path: Some(PathBuf::from(format!( + "/usr/share/applications/{id}.desktop" + ))), + declared_executable_path: Some(PathBuf::from(executable_path)), + declared_executable_identity: Some(identity), + runtime_executable_path: Some(PathBuf::from(executable_path)), + runtime_executable_identity: Some(identity), + desktop_identity: Some(identity), + desktop_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, + declared_executable_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, + runtime_executable_provenance: if system_entry { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: id.to_string(), + } + } else { + InstallProvenance::Unknown + }, + system_origin: system_entry, + system_association: system_entry, + association_eligible: true, + launch_spec: Some(LaunchSpec { + declared_executable: identity, + runtime_executable: identity, + arguments: Vec::new(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }), + names: HashSet::from([normalize_name(display_name)]), + } + } + + fn with_launch_literals(mut self, arguments: &[&str]) -> Self { + let executable = self + .runtime_executable_identity + .expect("launch fixture needs executable identity"); + self.launch_spec = Some(LaunchSpec { + declared_executable: executable, + runtime_executable: executable, + arguments: arguments + .iter() + .map(|value| { + LaunchArgument::Literal(LiteralArgument { + value: value.as_bytes().to_vec(), + file: None, + }) + }) + .collect(), + environment: Vec::new(), + wrappers: Vec::new(), + package_launcher: None, + literal_files_are_system_managed: true, + }); + self + } + + fn with_protected_launch_file(mut self, path: &str, identity: FileIdentity) -> Self { + let spec = self + .launch_spec + .as_mut() + .expect("launch fixture needs a launch specification"); + let literal = spec + .arguments + .iter_mut() + .find_map(|argument| match argument { + LaunchArgument::Literal(literal) if literal.value == path.as_bytes() => { + Some(literal) + } + _ => None, + }) + .expect("protected launch path must exist in the fixture contract"); + literal.file = Some((PathBuf::from(path), identity)); + self + } +} + +pub(super) trait DesktopIdentityIndexFixture { + fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self; + + fn with_trusted_portal(self, path: PathBuf, identity: FileIdentity) -> Self; +} + +impl DesktopIdentityIndexFixture for DesktopIdentityIndex { + fn from_records( + records: Vec, + trusted_relays: Vec<(PathBuf, FileIdentity)>, + ) -> Self { + let mut index = Self::default(); + for record in records { + index.index_record(record); + } + index.trusted_relays = trusted_relays + .into_iter() + .map(|(path, identity)| ExecutableIdentity { path, identity }) + .collect(); + index + } + + fn with_trusted_portal(mut self, path: PathBuf, identity: FileIdentity) -> Self { + index_trusted_portal(&mut self, path, identity); + self + } +} + +fn index_trusted_portal(index: &mut DesktopIdentityIndex, path: PathBuf, identity: FileIdentity) { + index + .trusted_portals + .push(ExecutableIdentity { path, identity }); +} + +pub(super) fn identity(device: u64, inode: u64, uid: u32) -> FileIdentity { + FileIdentity { + device, + inode, + uid, + mode: 0o100_755, + } +} + +pub(super) fn package(package_id: &str) -> InstallProvenance { + InstallProvenance::Package { + provider: PackageProvider::Pacman, + package_id: package_id.to_string(), + } +} + +pub(super) fn sender(path: &str, identity: FileIdentity) -> SenderMetadata { + SenderMetadata { + sender_name: Some(":1.42".to_string()), + sender_executable: Some(path.to_string()), + sender_executable_identity: Some(identity), + command_line: CommandLineEvidence { + argv: vec![path.as_bytes().to_vec()], + quality: CommandLineQuality::Structured, + }, + ..SenderMetadata::default() + } +} + +pub(super) fn sender_with_arguments( + path: &str, + identity: FileIdentity, + arguments: &[&str], +) -> SenderMetadata { + let mut metadata = sender(path, identity); + metadata.command_line = CommandLineEvidence { + argv: std::iter::once(path) + .chain(arguments.iter().copied()) + .map(|argument| argument.as_bytes().to_vec()) + .collect(), + quality: CommandLineQuality::Structured, + }; + metadata +} + +pub(super) fn system_record( + id: &str, + name: &str, + path: &str, + identity: FileIdentity, +) -> DesktopRecord { + DesktopRecord::fixture(id, name, path, identity, true) +} + +pub(super) fn installed_system_executable() -> (String, FileIdentity) { + let path = unixnotis_core::util::trusted_system_program_path("true") + .expect("find a protected system executable"); + let evidence = executable_evidence_for_path(&path).expect("read system executable evidence"); + assert!( + evidence.identity.is_system_managed(), + "fixture executable should be system managed" + ); + assert!( + evidence.identity.is_executable_regular(), + "fixture executable should be a regular executable" + ); + (path.display().to_string(), evidence.identity) +} + +pub(super) fn verified_executable_record<'record>( + records: &[&'record DesktopRecord], + reported_name: &str, + sender: &SenderMetadata, + index: &DesktopIdentityIndex, +) -> Option> { + let results = records + .iter() + .map(|record| CandidateVerification { + record, + verification: verify_record_sender(record, sender, index), + }) + .collect::>(); + strongest_verified_result(&results, reported_name, index) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs new file mode 100644 index 000000000..474d78bc4 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/tests/validation.rs @@ -0,0 +1,20 @@ +//! Desktop-identifier validation tests + +use super::super::validation::validate_desktop_id; + +#[test] +fn desktop_id_validation_accepts_ids_and_rejects_paths_or_control_characters() { + assert_eq!( + validate_desktop_id("org.example.App.desktop").as_deref(), + Some("org.example.App") + ); + assert_eq!(validate_desktop_id("../example"), None); + assert_eq!(validate_desktop_id("org.example.\nApp"), None); + assert_eq!(validate_desktop_id("."), None); + assert_eq!(validate_desktop_id(".desktop"), None); + assert_eq!( + validate_desktop_id(&"a".repeat(256)).map(|id| id.len()), + Some(256) + ); + assert_eq!(validate_desktop_id(&"a".repeat(257)), None); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs new file mode 100644 index 000000000..04f86fb88 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/resolver/validation.rs @@ -0,0 +1,20 @@ +//! Validation for caller-provided desktop identifiers + +const MAX_DESKTOP_ID_BYTES: usize = 256; + +pub(super) fn validate_desktop_id(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() + || value.len() > MAX_DESKTOP_ID_BYTES + || value.contains(['/', '\\', '\0']) + || value.chars().any(char::is_control) + { + return None; + } + + let value = value.strip_suffix(".desktop").unwrap_or(value); + if value == "." || value == ".." || value.is_empty() { + return None; + } + Some(value.to_string()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs new file mode 100644 index 000000000..9ddad94dd --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender.rs @@ -0,0 +1,450 @@ +//! Sender metadata helpers for incoming Notify/CloseNotification calls +//! +//! Sender details are optional and best-effort, so failures here must not reject +//! notification delivery + +use std::fs::File; +use std::future::Future; +use std::io::Read; + +use zbus::fdo::DBusProxy; +use zbus::message::Header; +use zbus::Connection; + +use super::sender_cache::SenderMetadataCache; +use super::{executable_evidence_for_pid, FileIdentity}; +use crate::daemon::notifications::identity::desktop_index::InstallProvenance; + +const MAX_PROCESS_CMDLINE_BYTES: u64 = 128 * 1024; +const MAX_PROCESS_ARGUMENTS: usize = 256; +const MAX_PROCESS_ANCESTORS: usize = 8; +pub(in crate::daemon) const SENDER_CREDENTIAL_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(500); + +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum CommandLineQuality { + Structured, + RewrittenProcessTitle, + Truncated, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon::notifications) struct CommandLineEvidence { + pub(in crate::daemon::notifications) argv: Vec>, + pub(in crate::daemon::notifications) quality: CommandLineQuality, +} + +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq)] +pub(in crate::daemon) enum SenderMetadataStatus { + Complete, + MissingSenderName, + CredentialLookupFailed, + CredentialLookupTimedOut, + #[default] + ProcessEvidenceUnavailable, +} + +/// Stable executable evidence for one same-user process ancestor +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) struct ProcessLineageEvidence { + pub(in crate::daemon::notifications) pid: u32, + pub(in crate::daemon::notifications) start_time: u64, + pub(in crate::daemon::notifications) uid: u32, + pub(in crate::daemon::notifications) executable: String, + pub(in crate::daemon::notifications) executable_identity: FileIdentity, +} + +#[derive(Debug, Clone, Default)] +pub(in crate::daemon) struct SenderMetadata { + // Unique bus sender name (:1.x) used for ownership checks + pub(in crate::daemon::notifications) sender_name: Option, + // Process id is paired with start time so reused pids do not inherit ownership + pub(in crate::daemon::notifications) sender_pid: Option, + // Linux start time identifies one concrete process lifetime + pub(in crate::daemon::notifications) sender_start_time: Option, + // The bus credential is used to bound process-lineage inspection + pub(in crate::daemon::notifications) sender_uid: Option, + // Executable path is presentation-only evidence for diagnostics and source labels + pub(in crate::daemon::notifications) sender_executable: Option, + // Device and inode bind policy to the open running executable rather than its basename + pub(in crate::daemon::notifications) sender_executable_identity: Option, + // Package or bundle ownership is supporting evidence for helper and conflict decisions + pub(in crate::daemon::notifications) install_provenance: InstallProvenance, + // Quality is explicit because processes may rewrite the visible procfs argument memory + pub(in crate::daemon::notifications) command_line: CommandLineEvidence, + // Ancestors remain supporting evidence and never grant actions by themselves + pub(in crate::daemon::notifications) ancestors: Vec, + // The stage that failed remains visible to diagnostics instead of becoming generic unknown + pub(in crate::daemon::notifications) status: SenderMetadataStatus, +} + +impl SenderMetadata { + /// A callback can be returned only to one concrete process lifetime + /// This is delivery evidence, not application identity evidence + pub(in crate::daemon::notifications) const fn has_stable_callback_owner(&self) -> bool { + self.sender_name.is_some() + && self.sender_pid.is_some() + && self.sender_start_time.is_some() + && self.sender_uid.is_some() + && !matches!( + self.status, + SenderMetadataStatus::MissingSenderName + | SenderMetadataStatus::CredentialLookupFailed + | SenderMetadataStatus::CredentialLookupTimedOut + ) + } +} + +fn metadata_with_status( + sender_name: Option, + status: SenderMetadataStatus, +) -> SenderMetadata { + SenderMetadata { + sender_name, + status, + ..SenderMetadata::default() + } +} + +fn metadata_from_credentials( + sender_name: Option, + process_id: Option, + user_id: Option, +) -> SenderMetadata { + let status = if user_id.is_some() && process_id.is_some() { + SenderMetadataStatus::ProcessEvidenceUnavailable + } else { + SenderMetadataStatus::CredentialLookupFailed + }; + SenderMetadata { + sender_name, + sender_pid: process_id, + // Process start time turns the reusable pid into one lifetime identity + sender_start_time: process_id.and_then(read_process_start_time), + sender_uid: user_id, + status, + ..SenderMetadata::default() + } +} + +pub(in crate::daemon) async fn resolve_sender_metadata( + cache: &SenderMetadataCache, + connection: &Connection, + header: &Header<'_>, +) -> SenderMetadata { + // Sender lookup failures are non-fatal and should degrade to "unknown" + let sender_name = header.sender().map(|sender| sender.as_str().to_string()); + let Some(sender_name_str) = sender_name.as_deref() else { + return metadata_with_status(sender_name, SenderMetadataStatus::MissingSenderName); + }; + + // Unique names are stable for one bus connection and safe cache identities + if let Some(metadata) = cache.get(sender_name_str) { + return metadata; + } + let cache_key = sender_name_str.to_string(); + + let Ok(bus_name) = zbus::names::BusName::try_from(sender_name_str) else { + return metadata_with_status(sender_name, SenderMetadataStatus::CredentialLookupFailed); + }; + + let Ok(proxy) = DBusProxy::new(connection).await else { + return metadata_with_status(sender_name, SenderMetadataStatus::CredentialLookupFailed); + }; + + // Credentials are the only asynchronous pre-attribution work + let (connection_user_id, connection_process_id) = resolve_connection_credentials( + proxy.get_connection_unix_user(bus_name.clone()), + proxy.get_connection_unix_process_id(bus_name), + ) + .await; + let metadata = + metadata_from_credentials(sender_name, connection_process_id, connection_user_id); + // Credentials remain cached while process evidence is refreshed inside the worker + if metadata.sender_pid.is_some() && metadata.sender_uid.is_some() { + cache.insert(cache_key, metadata.clone()); + } + metadata +} + +async fn resolve_connection_credentials( + user_id: U, + process_id: P, +) -> (Option, Option) +where + U: Future>, + P: Future>, +{ + let (user_id, process_id) = tokio::join!(user_id, process_id); + (user_id.ok(), process_id.ok()) +} + +pub(super) fn refresh_sender_security_evidence(metadata: &SenderMetadata) -> SenderMetadata { + let mut refreshed = metadata.clone(); + let Some(pid) = metadata.sender_pid else { + return refreshed; + }; + if metadata.sender_uid.is_none() + && matches!( + metadata.status, + SenderMetadataStatus::CredentialLookupFailed + | SenderMetadataStatus::CredentialLookupTimedOut + | SenderMetadataStatus::MissingSenderName + ) + { + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; + return refreshed; + } + // Fresh credential metadata has no expected lifetime yet; capture it in this worker + let expected_start = metadata + .sender_start_time + .or_else(|| read_process_start_time(pid)); + let Some(expected_start) = expected_start else { + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; + return refreshed; + }; + + // Refresh every process-derived field before a security-sensitive association decision + let start_before = read_process_start_time(pid); + let executable = executable_evidence_for_pid(pid); + let command_line = read_process_cmdline(pid, executable.as_ref()); + let start_after = read_process_start_time(pid); + if !process_lifetime_matches(start_before, expected_start, start_after) { + // Stale cache entries retain bus context but lose all application identity authority + refreshed.sender_start_time = None; + refreshed.sender_uid = None; + refreshed.sender_executable = None; + refreshed.sender_executable_identity = None; + refreshed.command_line = CommandLineEvidence::default(); + refreshed.ancestors.clear(); + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; + return refreshed; + } + + if metadata + .sender_uid + .is_some_and(|uid| read_process_real_uid(pid) != Some(uid)) + { + refreshed.sender_start_time = None; + refreshed.sender_uid = None; + refreshed.sender_executable = None; + refreshed.sender_executable_identity = None; + refreshed.command_line = CommandLineEvidence::default(); + refreshed.ancestors.clear(); + refreshed.status = SenderMetadataStatus::ProcessEvidenceUnavailable; + return refreshed; + } + + refreshed.sender_executable = executable + .as_ref() + .map(|evidence| evidence.canonical_path.display().to_string()); + refreshed.sender_executable_identity = executable.map(|evidence| evidence.identity); + refreshed.command_line = command_line; + refreshed.ancestors = metadata + .sender_uid + .map_or_else(Vec::new, |uid| collect_process_lineage(pid, uid)); + refreshed.sender_start_time = Some(expected_start); + refreshed.status = if refreshed.sender_executable_identity.is_some() { + SenderMetadataStatus::Complete + } else { + SenderMetadataStatus::ProcessEvidenceUnavailable + }; + refreshed +} + +fn process_lifetime_matches( + start_before: Option, + expected_start: u64, + start_after: Option, +) -> bool { + start_before == Some(expected_start) && start_after == Some(expected_start) +} + +#[cfg(target_os = "linux")] +pub(in crate::daemon) fn read_process_start_time(pid: u32) -> Option { + // /proc//stat keeps the process lifetime tick count in field 22 + let path = format!("/proc/{pid}/stat"); + let contents = std::fs::read_to_string(path).ok()?; + parse_process_stat(&contents).map(|stat| stat.start_time) +} + +#[cfg(target_os = "linux")] +fn read_process_real_uid(pid: u32) -> Option { + let path = format!("/proc/{pid}/status"); + std::fs::read_to_string(path) + .ok()? + .lines() + .find_map(|line| line.strip_prefix("Uid:"))? + .split_whitespace() + .next()? + .parse() + .ok() +} + +#[cfg(target_os = "linux")] +fn collect_process_lineage(pid: u32, uid: u32) -> Vec { + let Some(sender_stat) = read_process_stat(pid) else { + return Vec::new(); + }; + let mut parent_pid = sender_stat.parent_pid; + let mut lineage = Vec::new(); + + for _ in 0..MAX_PROCESS_ANCESTORS { + if parent_pid <= 1 || read_process_real_uid(parent_pid) != Some(uid) { + break; + } + let Some(before) = read_process_stat(parent_pid) else { + break; + }; + // Crossing a login session is outside the sender's application launch scope + if before.session_id != sender_stat.session_id { + break; + } + let Some(executable) = executable_evidence_for_pid(parent_pid) else { + break; + }; + let Some(after) = read_process_stat(parent_pid) else { + break; + }; + if before != after { + break; + } + lineage.push(ProcessLineageEvidence { + pid: parent_pid, + start_time: before.start_time, + uid, + executable: executable.canonical_path.display().to_string(), + executable_identity: executable.identity, + }); + parent_pid = before.parent_pid; + } + lineage +} + +#[cfg(target_os = "linux")] +fn read_process_cmdline( + pid: u32, + executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { + let path = format!("/proc/{pid}/cmdline"); + let mut bytes = Vec::new(); + let Some(file) = File::open(path).ok() else { + return CommandLineEvidence::default(); + }; + if file + .take(MAX_PROCESS_CMDLINE_BYTES + 1) + .read_to_end(&mut bytes) + .is_err() + { + return CommandLineEvidence::default(); + } + if bytes.len() as u64 > MAX_PROCESS_CMDLINE_BYTES { + return CommandLineEvidence { + argv: Vec::new(), + quality: CommandLineQuality::Truncated, + }; + } + let Some(argv) = parse_process_cmdline(bytes) else { + return CommandLineEvidence::default(); + }; + classify_command_line(argv, executable) +} + +#[cfg(target_os = "linux")] +fn parse_process_cmdline(mut bytes: Vec) -> Option>> { + if bytes.is_empty() + || bytes.len() as u64 > MAX_PROCESS_CMDLINE_BYTES + || bytes.last() != Some(&0) + { + return None; + } + bytes.pop(); + let arguments = bytes + .split(|byte| *byte == 0) + .map(<[u8]>::to_vec) + .collect::>(); + (!arguments.is_empty() && arguments.len() <= MAX_PROCESS_ARGUMENTS).then_some(arguments) +} + +#[cfg(not(target_os = "linux"))] +pub(in crate::daemon) fn read_process_start_time(_pid: u32) -> Option { + // Non-Linux builds fall back to bus-name ownership only + None +} + +#[cfg(not(target_os = "linux"))] +fn read_process_real_uid(_pid: u32) -> Option { + None +} + +#[cfg(not(target_os = "linux"))] +fn collect_process_lineage(_pid: u32, _uid: u32) -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "linux"))] +fn read_process_cmdline( + _pid: u32, + _executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { + CommandLineEvidence::default() +} + +fn classify_command_line( + argv: Vec>, + executable: Option<&super::executable::ExecutableEvidence>, +) -> CommandLineEvidence { + let rewritten = executable.is_some_and(|executable| { + argv.as_slice().first().is_some_and(|value| { + let prefix = executable.canonical_path.as_os_str().as_encoded_bytes(); + value.starts_with(prefix) && value.iter().any(u8::is_ascii_whitespace) + }) && argv.len() == 1 + }); + CommandLineEvidence { + argv, + quality: if rewritten { + CommandLineQuality::RewrittenProcessTitle + } else { + CommandLineQuality::Structured + }, + } +} + +#[cfg(all(target_os = "linux", test))] +fn parse_process_start_time(stat: &str) -> Option { + parse_process_stat(stat).map(|stat| stat.start_time) +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ProcessStat { + parent_pid: u32, + session_id: u32, + start_time: u64, +} + +#[cfg(target_os = "linux")] +fn read_process_stat(pid: u32) -> Option { + let path = format!("/proc/{pid}/stat"); + parse_process_stat(&std::fs::read_to_string(path).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_process_stat(stat: &str) -> Option { + // The comm field is wrapped in parentheses and may contain spaces + let end = stat.rfind(')')?; + let remainder = stat.get(end + 2..)?; + let fields = remainder.split_whitespace().collect::>(); + // Field three starts here so parent, session, and start time use fixed offsets + Some(ProcessStat { + parent_pid: fields.get(1)?.parse().ok()?, + session_id: fields.get(3)?.parse().ok()?, + start_time: fields.get(19)?.parse().ok()?, + }) +} + +#[cfg(test)] +#[path = "tests/sender.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs new file mode 100644 index 000000000..ce1f9306d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/sender_cache.rs @@ -0,0 +1,120 @@ +//! Bounded sender identity cache keyed by unique D-Bus names + +use std::collections::HashMap; +use std::sync::Mutex; + +use super::sender::SenderMetadata; + +const MAX_CACHED_SENDERS: usize = 256; + +pub(in crate::daemon) struct SenderMetadataCache { + state: Mutex, +} + +struct CacheState { + entries: HashMap, + sequence: u64, +} + +struct CacheEntry { + metadata: SenderMetadata, + last_used: u64, +} + +impl SenderMetadataCache { + pub(in crate::daemon) fn new() -> Self { + Self { + state: Mutex::new(CacheState { + entries: HashMap::new(), + sequence: 0, + }), + } + } + + pub(super) fn get(&self, sender: &str) -> Option { + // A poisoned cache fails closed and forces fresh sender resolution + let mut state = self.state.lock().ok()?; + let sequence = state.next_sequence(); + let entry = state.entries.get_mut(sender)?; + entry.last_used = sequence; + Some(entry.metadata.clone()) + } + + pub(in crate::daemon) fn insert(&self, sender: String, metadata: SenderMetadata) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let sequence = state.next_sequence(); + // The least recently used connection yields before the fixed bound is exceeded + if !state.entries.contains_key(&sender) && state.entries.len() >= MAX_CACHED_SENDERS { + state.evict_oldest(); + } + state.entries.insert( + sender, + CacheEntry { + metadata, + last_used: sequence, + }, + ); + } + + pub(in crate::daemon) fn remove(&self, sender: &str) { + if let Ok(mut state) = self.state.lock() { + state.entries.remove(sender); + } + } + + pub(in crate::daemon) fn sender_candidates_for_process( + &self, + pid: u32, + start_time: u64, + excluded: Option<&str>, + ) -> Vec { + let Ok(state) = self.state.lock() else { + return Vec::new(); + }; + // Try every matching address because a newer cache entry may already be stale + let mut candidates = state + .entries + .iter() + .filter(|(sender, entry)| { + excluded != Some(sender.as_str()) + && entry.metadata.sender_pid == Some(pid) + && entry.metadata.sender_start_time == Some(start_time) + }) + .map(|(sender, entry)| (entry.last_used, sender.clone())) + .collect::>(); + // Newest-first lookup prefers reconnects while retaining older valid fallbacks + candidates.sort_unstable_by(|left, right| { + right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)) + }); + candidates + .into_iter() + .map(|(_last_used, sender)| sender) + .collect() + } +} + +impl CacheState { + const fn next_sequence(&mut self) -> u64 { + // Wrapping preserves ordering for realistic cache lifetimes without panicking + self.sequence = self.sequence.wrapping_add(1); + self.sequence + } + + fn evict_oldest(&mut self) { + // A tiny bounded map keeps a linear selection cheaper than another index + let oldest = self + .entries + .iter() + .min_by_key(|(_sender, entry)| entry.last_used) + .map(|(sender, _entry)| sender.clone()); + if let Some(sender) = oldest { + self.entries.remove(&sender); + } + } +} + +#[cfg(test)] +#[path = "tests/sender_cache.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs new file mode 100644 index 000000000..d4215ba98 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/delivery.rs @@ -0,0 +1,155 @@ +use super::*; +use crate::daemon::notifications::identity::{SenderMetadata, SenderMetadataStatus}; + +fn cache_sender(cache: &SenderMetadataCache, connection: &Connection, start_time: u64) { + let sender = connection + .unique_name() + .expect("test connection should have a unique name") + .to_string(); + cache.insert( + sender.clone(), + SenderMetadata { + sender_name: Some(sender), + sender_pid: Some(std::process::id()), + sender_start_time: Some(start_time), + sender_uid: Some(rustix::process::geteuid().as_raw()), + status: SenderMetadataStatus::Complete, + ..SenderMetadata::default() + }, + ); +} + +#[test] +fn callback_credentials_require_every_process_lifetime_component() { + assert!(credentials_match_lifetime(42, 42, Some(7), Some(7), 7)); + assert!(!credentials_match_lifetime(41, 42, Some(7), Some(7), 7)); + assert!(!credentials_match_lifetime(42, 42, Some(6), Some(7), 7)); + assert!(!credentials_match_lifetime(42, 42, Some(7), Some(6), 7)); +} + +#[tokio::test] +async fn callback_destination_follows_same_process_to_a_new_bus_name() { + let cache = SenderMetadataCache::new(); + let first = Connection::session() + .await + .expect("first session connection"); + let retained = first + .unique_name() + .expect("first connection unique name") + .to_string(); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &first, start_time); + first.close().await.expect("close first connection"); + + let second = Connection::session() + .await + .expect("second session connection"); + cache_sender(&cache, &second, start_time); + let destination = resolve_callback_destination( + &cache, + &second, + Some(&retained), + Some(std::process::id()), + Some(start_time), + ) + .await + .expect("same process lifetime should resolve its new address"); + + assert_eq!( + destination.as_str(), + second + .unique_name() + .expect("second connection unique name") + .as_str() + ); +} + +#[tokio::test] +async fn callback_destination_rejects_a_different_process_lifetime() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + + let destination = resolve_callback_destination( + &cache, + &connection, + connection.unique_name().map(|name| name.as_str()), + Some(std::process::id()), + Some(start_time.saturating_add(1)), + ) + .await; + + assert!(destination.is_none()); +} + +#[tokio::test] +async fn callback_destination_keeps_the_exact_live_name_without_lifetime_evidence() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let retained = connection + .unique_name() + .expect("session connection unique name") + .to_string(); + + let destination = + resolve_callback_destination(&cache, &connection, Some(&retained), None, None) + .await + .expect("an exact live address should remain usable"); + + assert_eq!(destination.as_str(), retained); +} + +#[tokio::test] +async fn callback_destination_without_lifetime_evidence_never_rebinds() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + + let destination = + resolve_callback_destination(&cache, &connection, Some(":1.999999"), None, None).await; + + assert!(destination.is_none()); +} + +#[tokio::test] +async fn callback_destination_tries_older_process_candidates_after_a_stale_newest_entry() { + let cache = SenderMetadataCache::new(); + let connection = Connection::session().await.expect("session connection"); + let start_time = read_process_start_time(std::process::id()) + .expect("current process should expose a start time"); + cache_sender(&cache, &connection, start_time); + cache.insert( + ":1.999999".to_string(), + SenderMetadata { + sender_name: Some(":1.999999".to_string()), + sender_pid: Some(std::process::id()), + sender_start_time: Some(start_time), + sender_uid: Some(rustix::process::geteuid().as_raw()), + status: SenderMetadataStatus::Complete, + ..SenderMetadata::default() + }, + ); + + let destination = resolve_callback_destination( + &cache, + &connection, + Some(":1.retired"), + Some(std::process::id()), + Some(start_time), + ) + .await + .expect("an older verified address should survive a stale cache entry"); + + assert_eq!( + destination.as_str(), + connection + .unique_name() + .expect("session connection unique name") + .as_str() + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs new file mode 100644 index 000000000..6a5bad57f --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/executable.rs @@ -0,0 +1,127 @@ +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::process::{Command, Stdio}; + +use super::*; + +#[test] +fn system_managed_identity_requires_root_ownership_without_shared_writes() { + let protected = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + + assert!(protected.is_system_managed()); + assert!(!FileIdentity { + uid: 1000, + ..protected + } + .is_system_managed()); + assert!(!FileIdentity { + mode: 0o100_775, + ..protected + } + .is_system_managed()); + assert!(!FileIdentity { + mode: 0o100_757, + ..protected + } + .is_system_managed()); +} + +#[test] +fn executable_regular_identity_rejects_directories_and_missing_execute_bits() { + let executable = FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }; + assert!(executable.is_executable_regular()); + assert!(!FileIdentity { + mode: 0o100_644, + ..executable + } + .is_executable_regular()); + assert!(!FileIdentity { + mode: 0o040_755, + ..executable + } + .is_executable_regular()); +} + +#[test] +fn same_file_uses_device_and_inode_instead_of_mutable_labels() { + let first = FileIdentity { + device: 5, + inode: 8, + uid: 0, + mode: 0o100_755, + }; + let relabeled = FileIdentity { + uid: 1000, + mode: 0o100_777, + ..first + }; + + assert!(first.same_file(relabeled)); + assert!(!first.same_file(FileIdentity { inode: 9, ..first })); +} + +#[test] +fn executable_path_evidence_matches_open_file_metadata() { + let executable = std::env::current_exe().expect("current test executable path"); + let evidence = executable_evidence_for_path(&executable).expect("current executable evidence"); + let metadata = std::fs::metadata(&executable).expect("current executable metadata"); + + assert!(evidence.canonical_path.is_absolute()); + assert_eq!(evidence.identity.device, metadata.dev()); + assert_eq!(evidence.identity.inode, metadata.ino()); +} + +#[test] +fn missing_executable_path_has_no_identity_evidence() { + assert!(executable_evidence_for_path(std::path::Path::new( + "/path/that/does/not/exist/unixnotis" + )) + .is_none()); +} + +#[test] +fn deleted_running_executable_has_no_trusted_identity_evidence() { + let root = crate::test_support::TempRoot::new("deleted-running-executable"); + let source = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find protected sleep executable"); + let executable = root.join("temporary-sleep"); + std::fs::copy(source, &executable).expect("copy sleep executable"); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)) + .expect("make copied executable runnable"); + let mut child = Command::new(&executable) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn copied executable"); + // Wait until the child has completed exec before unlinking its file + // Otherwise a fast scheduler can remove the path while the child is still + // in the fork/exec transition and make the regression test timing-sensitive + let proc_executable = std::path::PathBuf::from(format!("/proc/{}/exe", child.id())); + let mut exec_ready = false; + for _ in 0..100 { + if std::fs::read_link(&proc_executable).is_ok_and(|path| path == executable) { + exec_ready = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + assert!(exec_ready, "child did not finish exec before unlink"); + std::fs::remove_file(&executable).expect("unlink running executable"); + + let evidence = executable_evidence_for_pid(child.id()); + + child.kill().expect("stop copied executable"); + child.wait().expect("reap copied executable"); + assert!(evidence.is_none()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs new file mode 100644 index 000000000..0052ca180 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/policy.rs @@ -0,0 +1,27 @@ +use unixnotis_core::{InlineReplyPolicy, InteractionPolicies}; + +use super::inline_reply_policy; + +#[test] +fn only_authenticated_policy_allows_inline_replies() { + assert_eq!( + inline_reply_policy(InteractionPolicies::AUTHENTICATED), + InlineReplyPolicy::Allow, + "authenticated interaction policy should retain reply authority" + ); +} + +#[test] +fn every_same_user_association_policy_denies_inline_replies() { + for policies in [ + InteractionPolicies::NATIVE_COMPATIBILITY, + InteractionPolicies::CONFIRM_ACTIONS, + InteractionPolicies::DENY, + ] { + assert_eq!( + inline_reply_policy(policies), + InlineReplyPolicy::Deny, + "same-user execution cannot authenticate credential-like reply text" + ); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs new file mode 100644 index 000000000..93a9ba046 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender.rs @@ -0,0 +1,290 @@ +use super::*; + +fn stable_process_evidence( + start_before: Option, + evidence: Option, + start_after: Option, +) -> (Option, Option) { + // Both lifetime reads must name the same process before executable evidence is trusted + if start_before.is_some() && start_before == start_after { + (start_before, evidence) + } else { + (None, None) + } +} + +#[tokio::test] +async fn credential_reads_run_concurrently_within_the_supported_deadline() { + let started = std::time::Instant::now(); + let (uid, pid) = resolve_connection_credentials( + async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok::(1_000) + }, + async { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + Ok::(42) + }, + ) + .await; + + assert!(started.elapsed() < std::time::Duration::from_millis(350)); + assert_eq!((uid, pid), (Some(1_000), Some(42))); +} + +#[tokio::test] +async fn failed_credential_read_is_returned_without_process_evidence() { + let (uid, pid) = resolve_connection_credentials( + async { Err::(zbus::Error::Failure("uid unavailable".into())) }, + async { Ok::(42) }, + ) + .await; + + assert_eq!(uid, None); + assert_eq!(pid, Some(42)); +} + +#[test] +fn credential_metadata_keeps_sender_identity_and_failure_stage() { + let metadata = metadata_from_credentials(Some(":1.42".to_string()), Some(42), Some(1_000)); + + assert_eq!(metadata.sender_name.as_deref(), Some(":1.42")); + assert_eq!(metadata.sender_pid, Some(42)); + assert_eq!(metadata.sender_uid, Some(1_000)); + assert_eq!(metadata.install_provenance, InstallProvenance::Unknown); + assert_eq!( + metadata.status, + SenderMetadataStatus::ProcessEvidenceUnavailable + ); + + let failed = metadata_from_credentials(Some(":1.43".to_string()), Some(43), None); + assert_eq!(failed.status, SenderMetadataStatus::CredentialLookupFailed); + assert_eq!(failed.install_provenance, InstallProvenance::Unknown); +} + +#[cfg(target_os = "linux")] +#[test] +fn credential_metadata_captures_the_complete_current_process_lifetime() { + let pid = std::process::id(); + let expected_start = + read_process_start_time(pid).expect("current process start time should exist"); + + let metadata = metadata_from_credentials(Some(":1.44".to_string()), Some(pid), Some(1_000)); + + assert_eq!(metadata.sender_pid, Some(pid)); + assert_eq!(metadata.sender_start_time, Some(expected_start)); +} + +#[test] +fn status_metadata_preserves_sender_name_and_failure_status() { + let metadata = metadata_with_status( + Some(":1.99".to_string()), + SenderMetadataStatus::CredentialLookupTimedOut, + ); + + assert_eq!(metadata.sender_name.as_deref(), Some(":1.99")); + assert_eq!( + metadata.status, + SenderMetadataStatus::CredentialLookupTimedOut + ); + assert!(metadata.sender_pid.is_none()); + assert!(metadata.sender_uid.is_none()); +} + +#[test] +fn stable_callback_owner_requires_a_complete_process_lifetime_binding() { + let stable = SenderMetadata { + sender_name: Some(":1.42".to_string()), + sender_pid: Some(42), + sender_start_time: Some(420), + sender_uid: Some(1_000), + status: SenderMetadataStatus::ProcessEvidenceUnavailable, + ..SenderMetadata::default() + }; + assert!(stable.has_stable_callback_owner()); + + let mut missing_process = stable.clone(); + missing_process.sender_start_time = None; + assert!(!missing_process.has_stable_callback_owner()); + + let mut failed_lookup = stable; + failed_lookup.status = SenderMetadataStatus::CredentialLookupTimedOut; + assert!(!failed_lookup.has_stable_callback_owner()); +} + +#[cfg(target_os = "linux")] +#[test] +fn parse_process_start_time_handles_spaces_in_comm() { + let stat = "42 (player with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; + assert_eq!(parse_process_start_time(stat), Some(987_654)); +} + +#[cfg(target_os = "linux")] +#[test] +fn parse_process_start_time_rejects_missing_or_invalid_fields() { + assert!(parse_process_start_time("42 no-closing-paren").is_none()); + assert!(parse_process_start_time("42 (app) S 1 2 3").is_none()); + + let stat = "42 (app) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 nope 20"; + assert!(parse_process_start_time(stat).is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn process_cmdline_parser_preserves_argument_boundaries_and_rejects_truncation() { + assert_eq!( + parse_process_cmdline(b"/usr/bin/python3\0/usr/share/app.py\0".to_vec()), + Some(vec![ + b"/usr/bin/python3".to_vec(), + b"/usr/share/app.py".to_vec(), + ]) + ); + assert!(parse_process_cmdline(b"/usr/bin/python3\0truncated".to_vec()).is_none()); + assert!(parse_process_cmdline(Vec::new()).is_none()); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn process_metadata_helpers_read_current_process_on_linux() { + let pid = std::process::id(); + + let executable = + executable_evidence_for_pid(pid).expect("current process executable should be readable"); + assert!(executable.canonical_path.is_absolute()); + + let start_time = read_process_start_time(pid).expect("current process start time should exist"); + assert!(start_time > 1); + let command_line = read_process_cmdline(pid, Some(&executable)); + assert_eq!(command_line.quality, CommandLineQuality::Structured); + assert!(!command_line.argv.is_empty()); +} + +#[test] +fn stable_process_evidence_keeps_matching_lifetime_observations() { + assert_eq!( + stable_process_evidence(Some(42), Some("evidence"), Some(42)), + (Some(42), Some("evidence")) + ); +} + +#[test] +fn stable_process_evidence_discards_pid_reuse_or_missing_observations() { + assert_eq!( + stable_process_evidence(Some(42), Some("evidence"), Some(43)), + (None, None) + ); + assert_eq!( + stable_process_evidence(None, Some("evidence"), None), + (None, None) + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_reloads_current_process_evidence() { + let pid = std::process::id(); + let start_time = read_process_start_time(pid).expect("current process start time"); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(start_time), + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert_eq!(refreshed.sender_start_time, Some(start_time)); + assert!(refreshed.sender_executable_identity.is_some()); + assert_eq!( + refreshed.command_line.quality, + CommandLineQuality::Structured + ); + assert!(!refreshed.command_line.argv.is_empty()); +} + +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_clears_evidence_for_a_stale_process_lifetime() { + let pid = std::process::id(); + let stale_start = read_process_start_time(pid) + .expect("current process start time") + .saturating_add(1); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(stale_start), + sender_executable: Some("/usr/bin/trusted-app".to_string()), + sender_executable_identity: Some(FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }), + command_line: CommandLineEvidence { + argv: vec![b"/usr/bin/trusted-app".to_vec()], + quality: CommandLineQuality::Structured, + }, + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert!(refreshed.sender_start_time.is_none()); + assert!(refreshed.sender_executable.is_none()); + assert!(refreshed.sender_executable_identity.is_none()); + assert_eq!( + refreshed.command_line.quality, + CommandLineQuality::Unavailable + ); + assert!(refreshed.command_line.argv.is_empty()); +} + +#[cfg(target_os = "linux")] +#[test] +fn security_refresh_rejects_a_sender_uid_that_changed() { + let pid = std::process::id(); + let start_time = read_process_start_time(pid).expect("current process start time"); + let uid = read_process_real_uid(pid).expect("current process uid"); + let original = SenderMetadata { + sender_pid: Some(pid), + sender_start_time: Some(start_time), + sender_uid: Some(uid.wrapping_add(1)), + ..SenderMetadata::default() + }; + + let refreshed = refresh_sender_security_evidence(&original); + + assert_eq!( + refreshed.status, + SenderMetadataStatus::ProcessEvidenceUnavailable + ); + assert!(refreshed.sender_executable_identity.is_none()); + assert!(refreshed.command_line.argv.is_empty()); +} + +#[test] +fn rewritten_process_title_is_kept_as_unstructured_evidence() { + let executable = super::super::executable::ExecutableEvidence { + canonical_path: "/opt/example/example-app".into(), + identity: FileIdentity { + device: 1, + inode: 2, + uid: 0, + mode: 0o100_755, + }, + }; + let evidence = classify_command_line( + vec![b"/opt/example/example-app --runtime-flag".to_vec()], + Some(&executable), + ); + + assert_eq!(evidence.quality, CommandLineQuality::RewrittenProcessTitle); + assert_eq!(evidence.argv.len(), 1); +} + +#[test] +fn process_lifetime_match_requires_both_reads_to_equal_the_cached_start() { + assert!(process_lifetime_matches(Some(42), 42, Some(42))); + assert!(!process_lifetime_matches(Some(41), 42, Some(42))); + assert!(!process_lifetime_matches(Some(42), 42, Some(43))); + assert!(!process_lifetime_matches(None, 42, Some(42))); + assert!(!process_lifetime_matches(Some(42), 42, None)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs new file mode 100644 index 000000000..e0cef4806 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/identity/tests/sender_cache.rs @@ -0,0 +1,88 @@ +use super::{SenderMetadataCache, MAX_CACHED_SENDERS}; +use crate::daemon::notifications::identity::sender::{ + CommandLineEvidence, SenderMetadata, SenderMetadataStatus, +}; + +fn metadata(sender: &str, pid: u32) -> SenderMetadata { + SenderMetadata { + sender_name: Some(sender.to_string()), + sender_pid: Some(pid), + sender_start_time: Some(u64::from(pid)), + sender_uid: None, + sender_executable: Some(format!("/usr/bin/app-{pid}")), + sender_executable_identity: None, + install_provenance: + crate::daemon::notifications::identity::desktop_index::InstallProvenance::default(), + command_line: CommandLineEvidence::default(), + ancestors: Vec::new(), + status: SenderMetadataStatus::Complete, + } +} + +#[test] +fn sender_cache_reuses_exact_unique_name_and_removes_disconnected_owner() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.42".to_string(), metadata(":1.42", 42)); + + assert_eq!( + cache.get(":1.42").and_then(|value| value.sender_pid), + Some(42) + ); + assert!(cache.get(":1.43").is_none()); + + cache.remove(":1.42"); + assert!(cache.get(":1.42").is_none()); +} + +#[test] +fn sender_cache_evicts_least_recently_used_entry_at_capacity() { + let cache = SenderMetadataCache::new(); + for index in 0..MAX_CACHED_SENDERS { + let sender = format!(":1.{index}"); + cache.insert(sender.clone(), metadata(&sender, index as u32 + 1)); + } + assert!(cache.get(":1.0").is_some()); + + cache.insert( + ":1.replacement".to_string(), + metadata(":1.replacement", 999), + ); + + assert!(cache.get(":1.1").is_none()); + assert!(cache.get(":1.0").is_some()); + assert!(cache.get(":1.replacement").is_some()); +} + +#[test] +fn sender_candidates_require_both_pid_and_process_start_time() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.exact".to_string(), metadata(":1.exact", 42)); + let mut wrong_start = metadata(":1.wrong-start", 42); + wrong_start.sender_start_time = Some(43); + cache.insert(":1.wrong-start".to_string(), wrong_start); + let mut wrong_pid = metadata(":1.wrong-pid", 99); + wrong_pid.sender_start_time = Some(42); + cache.insert(":1.wrong-pid".to_string(), wrong_pid); + + assert_eq!( + cache.sender_candidates_for_process(42, 42, None), + [":1.exact"] + ); +} + +#[test] +fn sender_candidates_exclude_the_retained_address_and_are_newest_first() { + let cache = SenderMetadataCache::new(); + cache.insert(":1.stale".to_string(), metadata(":1.stale", 42)); + cache.insert(":1.current".to_string(), metadata(":1.current", 42)); + cache.insert(":1.newest".to_string(), metadata(":1.newest", 42)); + + assert_eq!( + cache.sender_candidates_for_process(42, 42, Some(":1.stale")), + [":1.newest", ":1.current"] + ); + assert_eq!( + cache.sender_candidates_for_process(42, 42, Some(":1.newest")), + [":1.current", ":1.stale"] + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs new file mode 100644 index 000000000..25e834cc3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/limits.rs @@ -0,0 +1,26 @@ +//! Bounds for untrusted notification payload data +//! +//! Keeping limits in one file makes audits and tuning easier + +pub(in crate::daemon::notifications) const MAX_APP_NAME_BYTES: usize = 256; +// Icon names/paths can be longer than app names, but still need a hard cap +pub(in crate::daemon::notifications) const MAX_APP_ICON_BYTES: usize = 1024; +// Summary is shown prominently, so keep it short and bounded +pub(in crate::daemon::notifications) const MAX_SUMMARY_BYTES: usize = 1024; +// Body can be larger, but still needs a strict upper bound +pub(in crate::daemon::notifications) const MAX_BODY_BYTES: usize = 16 * 1024; +// Category is used for grouping and rules, so keep values compact +pub(in crate::daemon::notifications) const MAX_CATEGORY_BYTES: usize = 256; +// Keep action rows compact so one notification cannot stretch list layout +// This limit is shared by popup and center action rendering expectations +pub(in crate::daemon::notifications) const MAX_ACTIONS: usize = 8; +// Action keys are internal identifiers +pub(in crate::daemon::notifications) const MAX_ACTION_KEY_BYTES: usize = 128; +// Action labels are user-facing button text +pub(in crate::daemon::notifications) const MAX_ACTION_LABEL_BYTES: usize = 256; +// Limit hint map size so map copies stay cheap +pub(in crate::daemon::notifications) const MAX_HINT_ENTRIES: usize = 16; +// Hint keys are short protocol labels +pub(in crate::daemon::notifications) const MAX_HINT_KEY_BYTES: usize = 64; +// String hints can be descriptive, but still capped for memory safety +pub(in crate::daemon::notifications) const MAX_HINT_STRING_BYTES: usize = 2048; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs new file mode 100644 index 000000000..c96a9f47b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/metrics.rs @@ -0,0 +1,67 @@ +//! Allocation-free counters for notification ingress pressure + +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum RejectedRequest { + NotifyQuota, + NotifyConcurrency, + CloseQuota, +} + +pub(in crate::daemon::notifications) struct IngressMetrics { + notify_quota_rejections: AtomicU64, + notify_concurrency_rejections: AtomicU64, + close_quota_rejections: AtomicU64, + active_handlers: AtomicUsize, + peak_active_handlers: AtomicUsize, +} + +pub(in crate::daemon::notifications) struct ActiveHandler<'a> { + metrics: &'a IngressMetrics, +} + +impl IngressMetrics { + pub(in crate::daemon::notifications) const fn new() -> Self { + Self { + notify_quota_rejections: AtomicU64::new(0), + notify_concurrency_rejections: AtomicU64::new(0), + close_quota_rejections: AtomicU64::new(0), + active_handlers: AtomicUsize::new(0), + peak_active_handlers: AtomicUsize::new(0), + } + } + + pub(in crate::daemon::notifications) fn record_rejection( + &self, + rejected: RejectedRequest, + ) -> u64 { + let counter = match rejected { + RejectedRequest::NotifyQuota => &self.notify_quota_rejections, + RejectedRequest::NotifyConcurrency => &self.notify_concurrency_rejections, + RejectedRequest::CloseQuota => &self.close_quota_rejections, + }; + counter.fetch_add(1, Ordering::Relaxed).saturating_add(1) + } + + pub(in crate::daemon::notifications) fn enter_handler(&self) -> ActiveHandler<'_> { + let active = self + .active_handlers + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + // Atomic maximum records concurrency peaks without locks or retry-loop bookkeeping + self.peak_active_handlers + .fetch_max(active, Ordering::Relaxed); + ActiveHandler { metrics: self } + } +} + +impl Drop for ActiveHandler<'_> { + fn drop(&mut self) { + self.metrics.active_handlers.fetch_sub(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +#[path = "tests/metrics.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs new file mode 100644 index 000000000..29b39597b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/mod.rs @@ -0,0 +1,6 @@ +//! Request admission, payload bounds, and notification construction + +pub(super) mod limits; +pub(super) mod metrics; +pub(super) mod payload; +pub(super) mod quota; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs new file mode 100644 index 000000000..c8beca1be --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/build.rs @@ -0,0 +1,227 @@ +//! Convert bounded wire fields into a stored notification + +use std::collections::HashMap; + +use unixnotis_core::{ + util, Action, AttributionDiagnostics, ImageData, InlineReply, InlineReplyPolicy, Notification, + NotificationAttribution, NotificationImage, NotificationVisualRole, Urgency, +}; +use zbus::zvariant::OwnedValue; + +use super::super::super::identity::SenderMetadata; +use super::super::limits::{ + MAX_APP_ICON_BYTES, MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_SUMMARY_BYTES, +}; +use super::sanitize::parse_actions; +use super::visuals::{may_materialize_application_icon, normalize_avatar_visual, SenderVisualRole}; +use super::{owned_to_string, sanitize_hints_for_storage}; + +pub(in crate::daemon::notifications) struct NotificationInput { + pub(in crate::daemon::notifications) app_name: String, + pub(in crate::daemon::notifications) app_icon: String, + pub(in crate::daemon::notifications) summary: String, + pub(in crate::daemon::notifications) body: String, + pub(in crate::daemon::notifications) actions: Vec, + pub(in crate::daemon::notifications) hints: HashMap, + pub(in crate::daemon::notifications) image_data: Option, + pub(in crate::daemon::notifications) sender_visual_data: Option, + pub(in crate::daemon::notifications) sender_visual: Option, + pub(in crate::daemon::notifications) sender_visual_role: SenderVisualRole, + pub(in crate::daemon::notifications) sender: SenderMetadata, + pub(in crate::daemon::notifications) attribution: NotificationAttribution, + pub(in crate::daemon::notifications) attribution_diagnostics: AttributionDiagnostics, + pub(in crate::daemon::notifications) inline_reply_policy: InlineReplyPolicy, + pub(in crate::daemon::notifications) expire_timeout: i32, +} + +// Keep message pixels and sender pixels separate until their roles are stored +struct ImageBuildInput { + // Explicit message attachment pixels + image_data: Option, + // Communication image-data promoted by the trusted daemon + sender_visual_data: Option, + // A bounded local sender visual, when attribution allows it + sender_visual: Option, + // The semantic role selected from attribution and communication evidence + sender_visual_role: SenderVisualRole, +} + +pub(in crate::daemon::notifications) fn build_notification( + input: NotificationInput, +) -> Notification { + let NotificationInput { + app_name, + app_icon, + summary, + body, + actions, + hints, + image_data, + sender_visual_data, + sender_visual, + sender_visual_role, + sender, + attribution, + attribution_diagnostics, + inline_reply_policy, + expire_timeout, + } = input; + + let urgency = Urgency::from_hint(hints.get("urgency")); + let category = hints + .get("category") + .and_then(owned_to_string) + .map(|value| { + util::truncate_utf8_bytes( + &util::sanitize_inline_display_text(&value), + MAX_CATEGORY_BYTES, + ) + }); + let is_transient = hints + .get("transient") + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false); + let is_resident = hints + .get("resident") + .and_then(|value| bool::try_from(value).ok()) + .unwrap_or(false); + let image = build_image( + &app_name, + &app_icon, + &hints, + ImageBuildInput { + image_data, + sender_visual_data, + sender_visual, + sender_visual_role, + }, + &attribution, + ); + + let actions = parse_actions(actions); + let inline_reply = parse_inline_reply(&actions, &hints); + let app_name = util::sanitize_inline_display_text(&app_name); + let summary = util::sanitize_display_text(&summary); + let body = util::sanitize_display_text(&body); + + Notification { + id: 0, + generation: 0, + app_name: if app_name.is_empty() { + "Unknown".to_string() + } else { + util::truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) + }, + app_icon: if super::visuals::local_avatar_path(&app_icon).is_some() { + String::new() + } else { + util::truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES) + }, + attribution, + attribution_diagnostics, + summary: util::fold_text_for_layout( + &util::truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), + util::MAX_DISPLAY_TOKEN_WIDTH, + ), + body: util::fold_text_for_layout( + &util::truncate_utf8_bytes(&body, MAX_BODY_BYTES), + util::MAX_DISPLAY_TOKEN_WIDTH, + ), + actions, + inline_reply, + inline_reply_policy, + hints: sanitize_hints_for_storage(hints, urgency), + urgency, + category, + is_transient, + is_resident, + suppress_popup: false, + suppress_sound: false, + image, + expire_timeout, + received_at: chrono::Utc::now(), + sender_name: sender.sender_name, + sender_pid: sender.sender_pid, + sender_start_time: sender.sender_start_time, + sender_executable: sender.sender_executable, + } +} + +fn build_image( + app_name: &str, + app_icon: &str, + hints: &HashMap, + input: ImageBuildInput, + attribution: &NotificationAttribution, +) -> NotificationImage { + let ImageBuildInput { + image_data, + sender_visual_data, + sender_visual, + sender_visual_role, + } = input; + // Keep daemon-selected badge identity separate from sender-provided pixels + let mut image = NotificationImage::from_hints(app_name, app_icon, hints); + image.badge_icon.clone_from(&attribution.badge_icon); + let (wire_sender_visual, content_image) = match sender_visual_role { + SenderVisualRole::ConversationAvatar + if sender_visual_data.is_some() || sender_visual.is_some() => + { + (sender_visual_data, image_data) + } + // Direct payload builders may provide only image-data for a communication avatar + SenderVisualRole::ConversationAvatar => (image_data, None), + SenderVisualRole::ApplicationProvidedIcon | SenderVisualRole::None => { + (sender_visual_data, image_data) + } + }; + if let Some(image_data) = content_image.and_then(NotificationImage::normalize_image_data) { + image.content_image = image_data; + } + let sender_visual = match sender_visual_role { + // Bounded wire pixels are conversation presentation, not application identity evidence + SenderVisualRole::ConversationAvatar => wire_sender_visual.or_else(|| { + may_materialize_application_icon(attribution) + .then_some(sender_visual) + .flatten() + }), + // Decorative application art still requires a positive local association + SenderVisualRole::ApplicationProvidedIcon => may_materialize_application_icon(attribution) + .then_some(wire_sender_visual.or(sender_visual)) + .flatten(), + SenderVisualRole::None => None, + }; + if let Some(visual) = sender_visual.and_then(normalize_avatar_visual) { + image.sender_visual_role = match sender_visual_role { + SenderVisualRole::ConversationAvatar => NotificationVisualRole::ConversationAvatar, + SenderVisualRole::ApplicationProvidedIcon => { + NotificationVisualRole::ApplicationProvidedIcon + } + SenderVisualRole::None => NotificationVisualRole::None, + }; + image.sender_visual = visual; + } + image +} + +fn parse_inline_reply(actions: &[Action], hints: &HashMap) -> InlineReply { + let Some(action) = actions.iter().find(|action| action.key == "inline-reply") else { + return InlineReply::default(); + }; + + InlineReply { + available: true, + label: action.label.clone(), + placeholder: reply_hint_text(hints, "x-kde-reply-placeholder-text"), + submit_label: reply_hint_text(hints, "x-kde-reply-submit-button-text"), + submit_icon: reply_hint_text(hints, "x-kde-reply-submit-button-icon-name"), + } +} + +fn reply_hint_text(hints: &HashMap, key: &str) -> String { + let Some(value) = hints.get(key).and_then(owned_to_string) else { + return String::new(); + }; + let clean = util::sanitize_inline_display_text(&value); + util::truncate_utf8_bytes(&clean, super::super::limits::MAX_HINT_STRING_BYTES) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs new file mode 100644 index 000000000..55330daab --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/mod.rs @@ -0,0 +1,16 @@ +//! Bounded notification payload construction + +mod build; +mod sanitize; +mod visuals; + +pub(in crate::daemon::notifications) use build::{build_notification, NotificationInput}; +pub(in crate::daemon::notifications) use sanitize::{owned_to_string, sanitize_hints_for_storage}; +pub(in crate::daemon::notifications) use visuals::{ + materialize_sender_visual, may_materialize_content_image, sender_visual_path_allowed, + sender_visual_role, wire_image_role, SenderVisualRole, WireImageRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs new file mode 100644 index 000000000..5018832f7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/sanitize.rs @@ -0,0 +1,91 @@ +//! Bounded hint and action sanitization + +use std::collections::HashMap; + +use unixnotis_core::{util, Urgency}; +use zbus::zvariant::{OwnedValue, Value}; + +use super::super::limits::{ + MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_HINT_ENTRIES, + MAX_HINT_KEY_BYTES, MAX_HINT_STRING_BYTES, +}; + +pub(in crate::daemon::notifications) fn sanitize_hints_for_storage( + hints: HashMap, + canonical_urgency: Urgency, +) -> HashMap { + let mut sanitized = HashMap::with_capacity(hints.len().min(MAX_HINT_ENTRIES)); + + for (key, value) in hints { + // Stop before retaining more hint entries than the model can expose + if sanitized.len() >= MAX_HINT_ENTRIES { + break; + } + + let key = util::truncate_utf8_bytes(key.trim(), MAX_HINT_KEY_BYTES); + if key.is_empty() { + continue; + } + + // Only hints with a defined daemon or presentation meaning survive storage + let value = match key.as_str() { + "sound-name" | "sound-file" | "category" => owned_to_string(&value).and_then(|text| { + let bounded = util::truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); + string_to_owned_value(&bounded) + }), + "transient" | "resident" | "suppress-sound" => { + bool::try_from(&value).ok().map(OwnedValue::from) + } + // `Notification::urgency` is the single source of truth. The retained wire + // hint is reconstructed from that canonical value so policy cannot diverge + "urgency" => Some(OwnedValue::from(canonical_urgency.as_u32())), + _ => None, + }; + + // Unknown values are intentionally dropped instead of being echoed to clients + if let Some(value) = value { + sanitized.insert(key, value); + } + } + + sanitized +} + +pub(in crate::daemon::notifications) fn string_to_owned_value(value: &str) -> Option { + OwnedValue::try_from(Value::from(value)).ok() +} + +pub(in crate::daemon::notifications) fn owned_to_string(value: &OwnedValue) -> Option { + value + .try_clone() + .ok() + .and_then(|owned| String::try_from(owned).ok()) +} + +pub(in crate::daemon::notifications) fn parse_actions( + raw: Vec, +) -> Vec { + // The wire format is a flat key/label sequence, so incomplete pairs are ignored + let action_capacity = (raw.len() / 2).min(MAX_ACTIONS); + let mut actions = Vec::with_capacity(action_capacity); + let mut iter = raw.into_iter(); + + while let Some(key) = iter.next() { + let Some(label) = iter.next() else { + break; + }; + // Stop before creating more action state than the UI can render + if actions.len() >= MAX_ACTIONS { + break; + } + actions.push(unixnotis_core::Action { + key: util::truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), + label: util::truncate_utf8_bytes( + &util::sanitize_inline_display_text(&label), + MAX_ACTION_LABEL_BYTES, + ), + }); + } + + actions +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs new file mode 100644 index 000000000..9423047b7 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/build.rs @@ -0,0 +1,527 @@ +use super::*; + +#[test] +fn retained_urgency_hint_always_matches_canonical_notification_urgency() { + for raw in 0..=u8::MAX { + let notification = build_notification(NotificationInput { + app_name: "app".to_string(), + app_icon: String::new(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + hints: HashMap::from([("urgency".to_string(), OwnedValue::from(raw))]), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + let stored = u32::try_from( + notification + .hints + .get("urgency") + .expect("canonical urgency hint should be retained"), + ) + .expect("canonical urgency hint should use an unsigned integer"); + + assert_eq!( + stored, + notification.urgency.as_u32(), + "raw urgency {raw} must not diverge from the canonical field" + ); + } +} + +#[test] +fn build_notification_clamps_summary_and_body_sizes() { + let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); + let body = "B".repeat(MAX_BODY_BYTES + 512); + + let notification = build_notification(NotificationInput { + app_name: "app".to_string(), + app_icon: "icon".to_string(), + summary, + body, + actions: Vec::new(), + hints: HashMap::::new(), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.summary.len() <= MAX_SUMMARY_BYTES); + assert!(notification.body.len() <= MAX_BODY_BYTES); +} + +#[test] +fn build_notification_rejects_content_pixels_above_retained_limit() { + let notification = build_notification(NotificationInput { + app_name: "Example viewer".to_string(), + app_icon: "example-viewer".to_string(), + summary: "Image".to_string(), + body: "Attachment".to_string(), + actions: Vec::new(), + hints: HashMap::::new(), + image_data: Some(unixnotis_core::ImageData { + width: 512, + height: 512, + rowstride: 512 * 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![0; 512 * 512 * 4], + }), + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.image.content_image.data.is_empty()); +} + +#[test] +fn build_notification_strips_display_spoofing_controls() { + let notification = build_notification(NotificationInput { + app_name: "mail\u{202E}exe\nfake".to_string(), + app_icon: "icon".to_string(), + summary: "safe\u{202E}spoof".to_string(), + body: "line1\nline2\u{2066}tail".to_string(), + actions: vec!["default".to_string(), "Open\u{202E}".to_string()], + hints: HashMap::::new(), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + sender_executable_identity: None, + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.app_name, "mailexe fake"); + assert_eq!(notification.summary, "safespoof"); + assert_eq!(notification.body, "line1\nline2tail"); + assert_eq!(notification.actions[0].label, "Open"); +} + +#[test] +fn build_notification_collects_inline_reply_action_and_kde_labels() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Write a reply").expect("placeholder value"), + ); + hints.insert( + "x-kde-reply-submit-button-text".to_string(), + string_to_owned_value("Send now").expect("submit label value"), + ); + hints.insert( + "x-kde-reply-submit-button-icon-name".to_string(), + string_to_owned_value("mail-send-symbolic").expect("submit icon value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints, + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_executable: Some("/usr/bin/messages".to_string()), + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable /usr/bin/messages", + "system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!(notification.inline_reply.label, "Reply"); + assert_eq!(notification.inline_reply.placeholder, "Write a reply"); + assert_eq!(notification.inline_reply.submit_label, "Send now"); + assert_eq!(notification.inline_reply.submit_icon, "mail-send-symbolic"); +} + +#[test] +fn build_notification_keeps_protocol_reply_metadata_separate_from_denied_policy() { + let notification = build_notification(NotificationInput { + app_name: "Password Manager".to_string(), + app_icon: "password-manager".to_string(), + summary: "Sign in".to_string(), + body: "Enter the account password".to_string(), + actions: vec!["inline-reply".to_string(), "Password".to_string()], + hints: HashMap::new(), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata { + sender_name: Some(":1.hostile".to_string()), + sender_executable: Some("/usr/bin/unknown-client".to_string()), + ..SenderMetadata::default() + }, + attribution: unixnotis_core::NotificationAttribution::conflict( + "Password Manager", + "org.example.PasswordManager", + AttributionReason::ExecutableMismatch, + "source /usr/bin/unknown-client", + "executable:1:2".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); + let view = notification.to_view(); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!( + view.attribution.status, + unixnotis_core::AttributionStatus::Conflict + ); +} + +#[test] +fn build_notification_keeps_unknown_sender_reply_policy_denied() { + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["inline-reply".to_string(), "Reply".to_string()], + hints: HashMap::new(), + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Messages", + AttributionReason::MissingSenderEvidence, + "", + "unknown:messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.inline_reply.available); + assert_eq!( + notification.inline_reply_policy, + unixnotis_core::InlineReplyPolicy::Deny + ); + let view = notification.to_view(); + assert_eq!(view.app_name, "Unknown application"); + assert_eq!( + view.attribution.status, + unixnotis_core::AttributionStatus::Unresolved + ); +} + +#[test] +fn build_notification_ignores_reply_hints_without_explicit_action() { + let mut hints = HashMap::::new(); + hints.insert( + "x-kde-reply-placeholder-text".to_string(), + string_to_owned_value("Decoy reply").expect("placeholder value"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: String::new(), + actions: vec!["default".to_string(), "Open".to_string()], + hints, + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(!notification.inline_reply.available); + assert!(notification.inline_reply.placeholder.is_empty()); +} + +#[test] +fn conversation_avatar_never_changes_badge_or_unresolved_identity() { + let avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example Chat".to_string(), + app_icon: "/tmp/contact.png".to_string(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + sender_visual_data: None, + sender_visual: Some(avatar), + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!(notification.attribution.display_name, "Unknown application"); + assert_eq!( + notification.attribution.badge_icon, + "application-x-executable-symbolic" + ); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); +} + +#[test] +fn sender_image_path_is_not_retained_in_notification_model() { + let mut hints = HashMap::new(); + hints.insert( + "image-path".to_string(), + string_to_owned_value("/tmp/message-image.png").expect("image path"), + ); + + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints, + image_data: None, + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert!(notification.image.content_image.data.is_empty()); + assert!(!notification.hints.contains_key("image-path")); +} + +#[test] +fn associated_communication_image_data_becomes_a_bounded_conversation_avatar() { + let image = unixnotis_core::ImageData { + width: 128, + height: 128, + rowstride: 128 * 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7; 128 * 128 * 4], + }; + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category"), + )]), + image_data: Some(image), + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(notification.image.content_image.data.is_empty()); + assert_eq!(notification.image.sender_visual.width, 64); + assert_eq!(notification.image.sender_visual.height, 64); + assert!(notification.image.sender_visual.data.len() <= 64 * 64 * 4); + + // The production view keeps the bounded avatar role and leaves message content empty + let view = notification.to_view(); + assert_eq!( + view.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(!view.image.sender_visual.data.is_empty()); + assert!(view.image.content_image.data.is_empty()); +} + +#[test] +fn unassociated_communication_image_data_stays_untrusted_content() { + let image = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7, 8, 9, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: Some(image), + sender_visual_data: None, + sender_visual: None, + sender_visual_role: SenderVisualRole::None, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Messages", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); + assert!(!notification.image.content_image.data.is_empty()); + assert!(notification.image.sender_visual.data.is_empty()); +} + +#[test] +fn unresolved_communication_keeps_bounded_wire_pixels_as_conversation_avatar() { + let avatar = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![7, 8, 9, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example Chat".to_string(), + app_icon: "/tmp/untrusted-avatar.png".to_string(), + summary: "Conversation".to_string(), + body: "Message".to_string(), + actions: Vec::new(), + hints: HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category"), + )]), + image_data: None, + sender_visual_data: Some(avatar), + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert_eq!(notification.image.sender_visual.data, vec![7, 8, 9, 255]); + assert!(notification.image.content_image.data.is_empty()); + assert!(notification.app_icon.is_empty()); + assert_eq!( + notification.attribution.assurance, + unixnotis_core::IdentityAssurance::Unresolved + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs new file mode 100644 index 000000000..38cb91312 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/mod.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use zbus::zvariant::OwnedValue; + +pub(super) use super::super::super::identity::SenderMetadata; +pub(super) use super::super::limits::{MAX_ACTIONS, MAX_BODY_BYTES, MAX_SUMMARY_BYTES}; +pub(super) use super::build::{build_notification, NotificationInput}; +pub(super) use super::sanitize::{ + owned_to_string, parse_actions, sanitize_hints_for_storage, string_to_owned_value, +}; +pub(super) use super::visuals::{ + avatar_buffer_size_allowed, avatar_file_size_allowed, bounded_decode_dimension, + materialize_sender_visual, may_materialize_application_icon, sender_visual_file_allowed, + sender_visual_path_allowed, MAX_SENDER_VISUAL_BYTES, +}; +pub(super) use super::visuals::{sender_visual_role, SenderVisualRole}; +pub(super) use super::visuals::{wire_image_role, WireImageRole}; + +pub(super) use unixnotis_core::{ + ApplicationActionPolicy, AttributionReason, IdentityAssurance, InteractionPolicies, +}; + +mod build; +mod sanitize; +mod visuals; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs new file mode 100644 index 000000000..7004f08f8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/sanitize.rs @@ -0,0 +1,81 @@ +use super::*; +#[test] +fn parse_actions_caps_pairs() { + let mut raw = Vec::new(); + for idx in 0..(MAX_ACTIONS + 10) { + raw.push(format!("key-{idx}")); + raw.push(format!("label-{idx}")); + } + + let actions = parse_actions(raw); + assert_eq!(actions.len(), MAX_ACTIONS); +} + +#[test] +fn parse_actions_ignores_dangling_key_without_label() { + let actions = parse_actions(vec![ + "default".to_string(), + "Open".to_string(), + "orphan-key".to_string(), + ]); + + // D-Bus action arrays are pairs; a trailing key cannot produce a safe button + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[0].label, "Open"); +} + +#[test] +fn parse_actions_reserves_capacity_for_complete_pairs_only() { + let actions = parse_actions(vec![ + "default".to_string(), + "Open".to_string(), + "dismiss".to_string(), + "Dismiss".to_string(), + ]); + + assert_eq!(actions.len(), 2); + assert_eq!(actions.capacity(), 2); +} + +#[test] +fn sanitize_hints_drops_untrusted_and_bounds_strings() { + let mut hints = HashMap::::new(); + hints.insert("transient".to_string(), OwnedValue::from(true)); + hints.insert("urgency".to_string(), OwnedValue::from(9u32)); + hints.insert( + "sound-name".to_string(), + string_to_owned_value(&"n".repeat(5000)).expect("sound-name"), + ); + hints.insert("image-data".to_string(), OwnedValue::from(123u32)); + hints.insert( + "x-custom".to_string(), + string_to_owned_value("custom").expect("custom"), + ); + + let sanitized = sanitize_hints_for_storage(hints, unixnotis_core::Urgency::Normal); + assert_eq!(sanitized.len(), 3); + assert!(sanitized.contains_key("transient")); + assert!(sanitized.contains_key("sound-name")); + assert_eq!( + u32::try_from(sanitized.get("urgency").expect("urgency")), + Ok(1) + ); + + let sound_name = owned_to_string( + sanitized + .get("sound-name") + .expect("sound-name should remain"), + ) + .expect("sound-name should be string"); + assert!(sound_name.len() <= 2048); +} + +#[test] +fn owned_to_string_accepts_only_string_values() { + assert_eq!( + owned_to_string(&string_to_owned_value("sound").expect("string")).as_deref(), + Some("sound") + ); + assert_eq!(owned_to_string(&OwnedValue::from(7u32)), None); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs new file mode 100644 index 000000000..1c49eb221 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/tests/visuals.rs @@ -0,0 +1,435 @@ +use super::super::visuals::{ + downsample_avatar, local_avatar_path, valid_percent_escapes, MAX_DECODE_DIMENSION, +}; +use super::*; +use image::codecs::png::PngEncoder; +use image::{ExtendedColorType, ImageEncoder}; +#[test] +fn associated_sender_role_accepts_inline_reply_and_message_categories() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Messages", + "Messages", + "org.example.Messages", + "messages", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactUserExecutable, + "associated executable", + "recognized:system-app:org.example.Messages:sender".to_string(), + ); + let index = super::super::super::super::identity::DesktopIdentityIndex::default(); + + assert_eq!( + sender_visual_role( + &attribution, + &index, + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + "", + ), + SenderVisualRole::ConversationAvatar + ); + assert_eq!( + wire_image_role( + &attribution, + &index, + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + ), + WireImageRole::ConversationAvatar + ); + + let mut hints = HashMap::new(); + hints.insert( + "category".to_string(), + string_to_owned_value("im.received").expect("category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &hints, &[], ""), + SenderVisualRole::ConversationAvatar + ); + + let mut exact = HashMap::new(); + exact.insert( + "category".to_string(), + string_to_owned_value("im").expect("exact category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &exact, &[], ""), + SenderVisualRole::ConversationAvatar + ); + + let mut unrelated = HashMap::new(); + unrelated.insert( + "category".to_string(), + string_to_owned_value("other").expect("unrelated category value"), + ); + assert_eq!( + sender_visual_role(&attribution, &index, &unrelated, &[], ""), + SenderVisualRole::None + ); + assert_eq!( + sender_visual_role(&attribution, &index, &HashMap::new(), &[], ""), + SenderVisualRole::None + ); +} + +#[test] +fn associated_noncommunication_path_is_a_small_application_visual() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.Player:sender".to_string(), + ); + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &[], + "/tmp/application-icon.png", + ); + + assert_eq!(role, SenderVisualRole::ApplicationProvidedIcon); + assert!(sender_visual_path_allowed(role, &attribution)); + assert!(!sender_visual_path_allowed( + SenderVisualRole::None, + &attribution + )); +} + +#[test] +fn trusted_conversation_avatar_path_remains_allowed() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.Chat:sender".to_string(), + ); + + assert!(sender_visual_path_allowed( + SenderVisualRole::ConversationAvatar, + &attribution, + )); +} + +#[test] +fn portal_communication_keeps_wire_avatar_role_without_allowing_host_path_access() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Portal app", + "Portal app", + "org.example.PortalApp", + "portal-app", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal supplied app id", + "recognized:portal:org.example.PortalApp".to_string(), + ); + assert!(!may_materialize_application_icon(&attribution)); + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::new(), + &["inline-reply".to_string(), "Reply".to_string()], + "/tmp/untrusted-avatar.png", + ); + + assert_eq!(role, SenderVisualRole::ConversationAvatar); + assert!(!sender_visual_path_allowed(role, &attribution)); +} + +#[test] +fn unresolved_communication_role_never_authorizes_sender_filesystem_paths() { + let attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ); + let role = sender_visual_role( + &attribution, + &super::super::super::super::identity::DesktopIdentityIndex::default(), + &HashMap::from([( + "category".to_string(), + string_to_owned_value("im.received").expect("category value"), + )]), + &[], + "/tmp/untrusted-avatar.png", + ); + + assert_eq!(role, SenderVisualRole::ConversationAvatar); + assert!(!sender_visual_path_allowed(role, &attribution)); +} + +#[test] +fn associated_noncommunication_icon_is_retained_as_a_decorative_visual() { + let icon = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let notification = build_notification(NotificationInput { + app_name: "Example player".to_string(), + app_icon: "example-player".to_string(), + summary: "Track".to_string(), + body: "Artist".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: None, + sender_visual_data: None, + sender_visual: Some(icon), + sender_visual_role: SenderVisualRole::ApplicationProvidedIcon, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::associated( + "Example player", + "Example player", + "org.example.Player", + "example-player", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected local association", + "associated:system-app:org.example.Player".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon + ); + assert_eq!(notification.image.badge_icon, "example-player"); +} + +#[test] +fn decorative_visual_materialization_is_independent_of_click_authority() { + let attribution = unixnotis_core::NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example", + IdentityAssurance::SystemAssociated, + InteractionPolicies::DENY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated executable", + "associated:system-app:org.example.App:sender".to_string(), + ); + + assert!(may_materialize_application_icon(&attribution)); + assert!(attribution.may_materialize_content_image()); + assert_eq!( + attribution.default_activation_policy(), + ApplicationActionPolicy::Deny + ); +} + +#[test] +fn large_avatar_is_downsampled_to_the_storage_bound() { + let source = vec![255_u8; 256 * 128 * 4]; + let (width, height, data) = downsample_avatar(256, 128, source, 64).expect("downsample"); + assert_eq!((width, height), (64, 32)); + assert_eq!(data.len(), 64 * 32 * 4); +} + +#[test] +fn avatar_downsampling_rejects_zero_dimensions_and_keeps_exact_size_images() { + assert!(downsample_avatar(0, 1, Vec::new(), 64).is_none()); + assert!(downsample_avatar(1, 0, Vec::new(), 64).is_none()); + + let source = vec![7_u8; 64 * 64 * 4]; + let source_ptr = source.as_ptr(); + let (width, height, data) = downsample_avatar(64, 64, source, 64).expect("exact bound"); + assert_eq!((width, height), (64, 64)); + assert_eq!(data.as_ptr(), source_ptr); +} + +#[test] +fn avatar_downsampling_maps_horizontal_and_vertical_pixels_by_scale() { + // Keep the source height unchanged after scaling so the early-return guard + // must compare both dimensions rather than accepting one matching value + let mut horizontal = vec![0_u8; 128 * 4]; + for x in 0..128 { + horizontal[x * 4] = u8::try_from(x).expect("horizontal fixture value"); + } + let (width, height, data) = + downsample_avatar(128, 1, horizontal, 64).expect("horizontal downsample"); + assert_eq!((width, height), (64, 1)); + assert_eq!(data[4], 2); + + let mut vertical = vec![0_u8; 64 * 128 * 4]; + for y in 0..128 { + vertical[y * 64 * 4] = u8::try_from(y).expect("vertical fixture value"); + } + let (width, height, data) = + downsample_avatar(64, 128, vertical, 64).expect("vertical downsample"); + assert_eq!((width, height), (32, 64)); + assert_eq!(data[32 * 4], 2); +} + +#[cfg(target_os = "linux")] +#[test] +fn fifo_avatar_path_is_rejected_without_opening_a_blocking_reader() { + let directory = std::env::temp_dir().join(format!( + "unixnotis-avatar-fifo-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + std::fs::create_dir(&directory).expect("create temporary directory"); + let path = directory.join("avatar.fifo"); + let path_string = path.to_string_lossy().into_owned(); + let status = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo available"); + assert!(status.success()); + assert!(materialize_sender_visual(&path_string, 64).is_none()); + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_dir(directory); +} + +#[test] +fn absolute_avatar_path_is_materialized_into_bounded_raster_data() { + // This is a tiny 1x1 RGBA PNG used only to exercise the real decoder + let png = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, + 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unixnotis-avatar-{suffix}.png")); + std::fs::write(&path, png).expect("write avatar fixture"); + + let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); + let _ = std::fs::remove_file(&path); + + let avatar = avatar.expect("valid avatar should decode"); + assert_eq!((avatar.width, avatar.height), (1, 1)); + assert_eq!(avatar.channels, 4); + assert_eq!(avatar.data.len(), 4); +} + +#[test] +fn large_file_visual_is_decoded_before_avatar_downsampling() { + let pixels = vec![128_u8; 256 * 256 * 4]; + let mut png = Vec::new(); + PngEncoder::new(&mut png) + .write_image(&pixels, 256, 256, ExtendedColorType::Rgba8) + .expect("encode large avatar fixture"); + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let path = std::env::temp_dir().join(format!("unixnotis-large-avatar-{suffix}.png")); + std::fs::write(&path, png).expect("write large avatar fixture"); + + let avatar = materialize_sender_visual(path.to_str().expect("utf8 fixture path"), 64); + let _ = std::fs::remove_file(&path); + + let avatar = avatar.expect("large source should be decoded before downsampling"); + assert_eq!((avatar.width, avatar.height), (64, 64)); + assert_eq!(avatar.data.len(), 64 * 64 * 4); +} + +#[test] +fn avatar_size_limits_accept_the_boundary_and_reject_one_byte_over() { + assert!(avatar_file_size_allowed(MAX_SENDER_VISUAL_BYTES)); + assert!(!avatar_file_size_allowed(MAX_SENDER_VISUAL_BYTES + 1)); + assert!(avatar_buffer_size_allowed(MAX_SENDER_VISUAL_BYTES as usize)); + assert!(!avatar_buffer_size_allowed( + MAX_SENDER_VISUAL_BYTES as usize + 1 + )); +} + +#[test] +fn sender_visual_file_policy_requires_a_regular_file_and_bounded_size() { + assert!(sender_visual_file_allowed(true, MAX_SENDER_VISUAL_BYTES)); + assert!(!sender_visual_file_allowed(false, MAX_SENDER_VISUAL_BYTES)); + assert!(!sender_visual_file_allowed( + true, + MAX_SENDER_VISUAL_BYTES + 1 + )); +} + +#[test] +fn sender_visual_decode_dimension_has_a_stable_upper_bound() { + assert_eq!(bounded_decode_dimension(64), 64); + assert_eq!(bounded_decode_dimension(512), 512); + assert_eq!( + bounded_decode_dimension(MAX_DECODE_DIMENSION), + MAX_DECODE_DIMENSION + ); + assert_eq!(bounded_decode_dimension(513), 512); +} + +#[test] +fn relative_or_missing_avatar_path_is_rejected() { + assert!(materialize_sender_visual("avatar.png", 64).is_none()); + assert!(materialize_sender_visual("/path/that/does/not/exist.png", 64).is_none()); +} + +#[test] +fn local_avatar_uri_decodes_local_file_paths() { + assert_eq!( + local_avatar_path("file:///tmp/avatar%20one.png") + .expect("encoded local path") + .to_string_lossy(), + "/tmp/avatar one.png" + ); + assert_eq!( + local_avatar_path("file://localhost/tmp/avatar.png") + .expect("localhost path") + .to_string_lossy(), + "/tmp/avatar.png" + ); +} + +#[test] +fn local_avatar_uri_rejects_remote_or_ambiguous_paths() { + for value in [ + "file://example.test/tmp/avatar.png", + "file:///tmp/avatar.png?download=1", + "file:///tmp/avatar.png#fragment", + "file:///tmp/%00avatar.png", + "file:///tmp/%ZZavatar.png", + ] { + assert!( + local_avatar_path(value).is_none(), + "unexpectedly accepted {value}" + ); + } +} + +#[test] +fn percent_escape_validation_requires_two_hex_digits() { + assert!(valid_percent_escapes("%20")); + assert!(valid_percent_escapes("file:///tmp/avatar%20one.png")); + assert!(valid_percent_escapes("file:///tmp/avatar%2Fone.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%2.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%GG.png")); + assert!(!valid_percent_escapes("file:///tmp/avatar%")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs new file mode 100644 index 000000000..0a4bdfccf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/payload/visuals.rs @@ -0,0 +1,389 @@ +//! Sender-provided visual materialization + +use std::collections::HashMap; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; + +use std::os::unix::ffi::OsStrExt; + +use rustix::fs::{openat2, Mode, OFlags, ResolveFlags, CWD}; +use unixnotis_core::{ + decode_image_asset_contents, AssetPolicy, ImageData, NotificationAttribution, + DEFAULT_ICON_ASSET_EXTENSIONS, DEFAULT_ICON_ASSET_MAX_HEIGHT, DEFAULT_ICON_ASSET_MAX_PIXELS, + DEFAULT_ICON_ASSET_MAX_WIDTH, +}; +use url::Url; +use zbus::zvariant::OwnedValue; + +use crate::daemon::notifications::identity::DesktopIdentityIndex; + +use super::owned_to_string; + +pub(in crate::daemon::notifications::ingress) const MAX_SENDER_VISUAL_BYTES: u64 = 2_097_152; +pub(in crate::daemon::notifications) const MAX_STORED_AVATAR_DIMENSION: u32 = 64; +pub(in crate::daemon::notifications::ingress) const MAX_DECODE_DIMENSION: u32 = + MAX_STORED_AVATAR_DIMENSION * 8; +pub(in crate::daemon::notifications) const MAX_STORED_CONTENT_DIMENSION: u32 = 256; + +pub(in crate::daemon::notifications) const CONVERSATION_AVATAR_TIMEOUT: Duration = + Duration::from_millis(500); + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum SenderVisualRole { + None, + ConversationAvatar, + ApplicationProvidedIcon, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications) enum WireImageRole { + ContentImage, + ConversationAvatar, +} + +pub(in crate::daemon::notifications) const fn may_materialize_application_icon( + attribution: &NotificationAttribution, +) -> bool { + attribution.may_materialize_application_icon() +} + +pub(in crate::daemon::notifications) const fn may_materialize_content_image( + attribution: &NotificationAttribution, +) -> bool { + attribution.may_materialize_content_image() +} + +pub(in crate::daemon::notifications) fn wire_image_role( + attribution: &NotificationAttribution, + index: &DesktopIdentityIndex, + hints: &HashMap, + actions: &[String], +) -> WireImageRole { + // Communication metadata selects a presentation slot without authenticating the application + if actions + .chunks_exact(2) + .any(|pair| pair.first().is_some_and(|key| key == "inline-reply")) + { + return WireImageRole::ConversationAvatar; + } + + // Category hints remain presentation input, not identity proof + let explicit_metadata = + hints + .get("category") + .and_then(owned_to_string) + .is_some_and(|category| { + let category = category.to_ascii_lowercase(); + ["im", "chat", "message", "email", "mail"] + .iter() + .any(|marker| category.split('.').any(|part| part == *marker)) + }); + // Desktop categories cover clients that omit the optional wire category + let desktop_metadata = index.desktop_id_has_communication_role(&attribution.desktop_id); + // A claimed desktop entry is presentation metadata only; it never proves identity + let claimed_desktop_metadata = hints + .get("desktop-entry") + .and_then(owned_to_string) + .is_some_and(|desktop_id| index.desktop_id_has_communication_role(&desktop_id)); + if explicit_metadata || desktop_metadata || claimed_desktop_metadata { + WireImageRole::ConversationAvatar + } else { + WireImageRole::ContentImage + } +} + +pub(in crate::daemon::notifications) fn sender_visual_role( + attribution: &NotificationAttribution, + index: &DesktopIdentityIndex, + hints: &HashMap, + actions: &[String], + app_icon: &str, +) -> SenderVisualRole { + // Wire pixels and local application artwork use separate authorization decisions + if matches!( + wire_image_role(attribution, index, hints, actions), + WireImageRole::ConversationAvatar + ) { + return SenderVisualRole::ConversationAvatar; + } + if may_materialize_application_icon(attribution) && local_avatar_path(app_icon).is_some() { + SenderVisualRole::ApplicationProvidedIcon + } else { + SenderVisualRole::None + } +} + +pub(in crate::daemon::notifications) const fn sender_visual_path_allowed( + role: SenderVisualRole, + attribution: &NotificationAttribution, +) -> bool { + // Local paths remain forbidden for unresolved, conflicting, and relay senders + // A positively associated sender may use a path for either visual presentation role + matches!( + role, + SenderVisualRole::ConversationAvatar | SenderVisualRole::ApplicationProvidedIcon + ) && may_materialize_application_icon(attribution) +} + +pub(in crate::daemon::notifications) fn materialize_sender_visual( + app_icon: &str, + max_dimension: u32, +) -> Option { + // Convert the sender value to a local path before touching the filesystem + let path = local_avatar_path(app_icon)?; + let descriptor = openat2( + CWD, + &path, + OFlags::RDONLY + .union(OFlags::NONBLOCK) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::empty(), + ResolveFlags::NO_MAGICLINKS, + ) + .ok()?; + let mut file = std::fs::File::from(descriptor); + // Metadata is taken from the opened descriptor, not from a second path lookup + let metadata = file.metadata().ok()?; + if !sender_visual_file_allowed(metadata.is_file(), metadata.len()) { + return None; + } + + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_SENDER_VISUAL_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + if !avatar_buffer_size_allowed(bytes.len()) { + return None; + } + + // Keep the decoder bound independent from the UI-requested size + let target_dimension = bounded_decode_dimension(max_dimension); + let decode_dimension = MAX_DECODE_DIMENSION; + // Encoded and decoded source limits remain fixed while the final target stays role-specific + let decode_pixels = u64::from(decode_dimension).checked_mul(u64::from(decode_dimension))?; + let policy = AssetPolicy { + max_bytes: MAX_SENDER_VISUAL_BYTES, + max_width: DEFAULT_ICON_ASSET_MAX_WIDTH.min(decode_dimension), + max_height: DEFAULT_ICON_ASSET_MAX_HEIGHT.min(decode_dimension), + max_pixels: DEFAULT_ICON_ASSET_MAX_PIXELS.min(decode_pixels), + allowed_extensions: DEFAULT_ICON_ASSET_EXTENSIONS, + }; + // Downsample only after the source has passed the independent decode policy + let decoded = decode_image_asset_contents(&path, &bytes, policy).ok()?; + let (width, height, rgba) = downsample_avatar( + decoded.width, + decoded.height, + decoded.rgba, + target_dimension, + )?; + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let rowstride = width.checked_mul(4)?; + let expected = usize::try_from(rowstride) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + (rgba.len() == expected).then_some(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) +} + +pub(in crate::daemon::notifications::ingress) fn downsample_avatar( + width: u32, + height: u32, + rgba: Vec, + max_dimension: u32, +) -> Option<(u32, u32, Vec)> { + if width == 0 || height == 0 { + return None; + } + let source_pixels = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + if rgba.len() != source_pixels.checked_mul(4)? { + return None; + } + + let (target_width, target_height) = if width >= height { + ( + max_dimension.min(width), + width_to_height(width, height, max_dimension), + ) + } else { + ( + height_to_width(width, height, max_dimension), + max_dimension.min(height), + ) + }; + let target_pixels = usize::try_from(target_width) + .ok()? + .checked_mul(usize::try_from(target_height).ok()?)?; + let max_pixels = usize::try_from(max_dimension) + .ok()? + .checked_mul(usize::try_from(max_dimension).ok()?)?; + if target_pixels > max_pixels { + return None; + } + if target_width == width && target_height == height { + return Some((width, height, rgba)); + } + + let mut output = vec![0u8; target_pixels.checked_mul(4)?]; + for target_y in 0..target_height { + let source_y = usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(height).ok()?)? + / usize::try_from(target_height).ok()?; + for target_x in 0..target_width { + let source_x = usize::try_from(target_x) + .ok()? + .checked_mul(usize::try_from(width).ok()?)? + / usize::try_from(target_width).ok()?; + let source_index = source_y + .checked_mul(usize::try_from(width).ok()?)? + .checked_add(source_x)? + .checked_mul(4)?; + let target_index = usize::try_from(target_y) + .ok()? + .checked_mul(usize::try_from(target_width).ok()?)? + .checked_add(usize::try_from(target_x).ok()?)? + .checked_mul(4)?; + output[target_index..target_index + 4] + .copy_from_slice(&rgba[source_index..source_index + 4]); + } + } + Some((target_width, target_height, output)) +} + +pub(in crate::daemon::notifications) fn normalize_avatar_visual( + image: ImageData, +) -> Option { + // Wire images use the shared validator before entering this final avatar boundary + let image = unixnotis_core::NotificationImage::normalize_image_data(image)?; + let width = u32::try_from(image.width).ok()?; + let height = u32::try_from(image.height).ok()?; + let row_bytes = usize::try_from(width).ok()?.checked_mul(4)?; + let source_stride = usize::try_from(image.rowstride).ok()?; + if image.channels != 4 || source_stride < row_bytes { + return None; + } + let required = source_stride.checked_mul(usize::try_from(height).ok()?)?; + if image.data.len() < required { + return None; + } + + // Strip protocol row padding before the bounded downsampler runs + let mut rgba = vec![0_u8; row_bytes.checked_mul(usize::try_from(height).ok()?)?]; + for row in 0..usize::try_from(height).ok()? { + let source_start = row.checked_mul(source_stride)?; + let target_start = row.checked_mul(row_bytes)?; + rgba[target_start..target_start + row_bytes] + .copy_from_slice(&image.data[source_start..source_start + row_bytes]); + } + let (width, height, rgba) = + downsample_avatar(width, height, rgba, MAX_STORED_AVATAR_DIMENSION)?; + let width = i32::try_from(width).ok()?; + let height = i32::try_from(height).ok()?; + let rowstride = width.checked_mul(4)?; + Some(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) +} + +fn width_to_height(width: u32, height: u32, target_width: u32) -> u32 { + if width <= target_width { + return height; + } + u64::from(height) + .saturating_mul(u64::from(target_width)) + .checked_div(u64::from(width)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +fn height_to_width(width: u32, height: u32, target_height: u32) -> u32 { + if height <= target_height { + return width; + } + u64::from(width) + .saturating_mul(u64::from(target_height)) + .checked_div(u64::from(height)) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1) +} + +pub(in crate::daemon::notifications::ingress) const fn avatar_file_size_allowed(size: u64) -> bool { + size <= MAX_SENDER_VISUAL_BYTES +} + +pub(in crate::daemon::notifications::ingress) const fn avatar_buffer_size_allowed( + size: usize, +) -> bool { + size <= MAX_SENDER_VISUAL_BYTES as usize +} + +pub(in crate::daemon::notifications::ingress) const fn sender_visual_file_allowed( + is_regular: bool, + size: u64, +) -> bool { + is_regular && avatar_file_size_allowed(size) +} + +pub(in crate::daemon::notifications::ingress) fn bounded_decode_dimension(requested: u32) -> u32 { + std::cmp::min(requested, MAX_DECODE_DIMENSION) +} + +pub(in crate::daemon::notifications::ingress) fn local_avatar_path(value: &str) -> Option { + if value.starts_with('/') { + return Some(PathBuf::from(value)); + } + if !valid_percent_escapes(value) { + return None; + } + let url = Url::parse(value).ok()?; + if url.scheme() != "file" || url.query().is_some() || url.fragment().is_some() { + return None; + } + match url.host_str() { + None | Some("" | "localhost") => {} + Some(_) => return None, + } + let path = url.to_file_path().ok()?; + (!path.as_os_str().as_bytes().contains(&0)).then_some(path) +} + +pub(in crate::daemon::notifications::ingress) fn valid_percent_escapes(value: &str) -> bool { + // Url accepts some malformed percent text literally, so reject it before parsing + let mut bytes = value.as_bytes().iter().copied(); + while let Some(byte) = bytes.next() { + if byte != b'%' { + continue; + } + let Some(first) = bytes.next() else { + return false; + }; + let Some(second) = bytes.next() else { + return false; + }; + if !first.is_ascii_hexdigit() || !second.is_ascii_hexdigit() { + return false; + } + } + true +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs new file mode 100644 index 000000000..bc8ec9c6f --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/quota.rs @@ -0,0 +1,363 @@ +//! Bounded notification ingress policy + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Instant; + +const GLOBAL_BURST: f64 = 120.0; +const GLOBAL_REFILL_PER_SECOND: f64 = 60.0; +const SENDER_BURST: f64 = 40.0; +const SENDER_REFILL_PER_SECOND: f64 = 20.0; +const CLOSE_GLOBAL_BURST: f64 = 480.0; +const CLOSE_GLOBAL_REFILL_PER_SECOND: f64 = 240.0; +const CLOSE_ATTEMPT_GLOBAL_BURST: f64 = 960.0; +const CLOSE_ATTEMPT_GLOBAL_REFILL_PER_SECOND: f64 = 480.0; +const CLOSE_SENDER_BURST: f64 = 160.0; +const CLOSE_SENDER_REFILL_PER_SECOND: f64 = 80.0; +const OVERFLOW_BURST: f64 = 10.0; +const OVERFLOW_REFILL_PER_SECOND: f64 = 5.0; +const CLOSE_OVERFLOW_BURST: f64 = 40.0; +const CLOSE_OVERFLOW_REFILL_PER_SECOND: f64 = 20.0; +const MAX_TRACKED_PRINCIPALS: usize = 256; +const PRINCIPAL_IDLE_TTL_SECONDS: u64 = 60; + +/// Stable process-lifetime identity used for per-caller ingress fairness +#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)] +pub(in crate::daemon::notifications) struct QuotaPrincipal { + uid: u32, + pid: u32, + start_time: u64, +} + +impl QuotaPrincipal { + pub(in crate::daemon::notifications) const fn new(uid: u32, pid: u32, start_time: u64) -> Self { + Self { + uid, + pid, + start_time, + } + } +} + +pub(in crate::daemon::notifications) struct NotificationQuota { + state: Mutex, + policy: QuotaPolicy, +} + +#[derive(Clone, Copy)] +struct QuotaPolicy { + global_burst: f64, + global_refill_per_second: f64, + sender_burst: f64, + sender_refill_per_second: f64, + overflow_burst: f64, + overflow_refill_per_second: f64, + attempt_global_burst: Option, + attempt_global_refill_per_second: Option, +} + +struct QuotaState { + // Mutation work and rejected-request work use separate process-wide ceilings + global: TokenBucket, + attempt_global: Option, + principals: HashMap, + overflow: TokenBucket, +} + +/// One result describes the complete hierarchical admission decision +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::daemon::notifications) enum Admission { + Allowed, + GlobalLimited, + PrincipalLimited, +} + +impl Admission { + pub(in crate::daemon::notifications) const fn is_allowed(self) -> bool { + matches!(self, Self::Allowed) + } +} + +struct PrincipalBucket { + bucket: TokenBucket, + last_seen: Instant, +} + +struct TokenBucket { + tokens: f64, + capacity: f64, + refill_per_second: f64, + last_refill: Instant, +} + +impl NotificationQuota { + pub(in crate::daemon::notifications) fn new_notify() -> Self { + Self::new_at(Instant::now()) + } + + pub(in crate::daemon::notifications) fn new_close() -> Self { + Self::new_close_at(Instant::now()) + } + + fn new_at(now: Instant) -> Self { + Self::with_policy( + now, + QuotaPolicy { + global_burst: GLOBAL_BURST, + global_refill_per_second: GLOBAL_REFILL_PER_SECOND, + sender_burst: SENDER_BURST, + sender_refill_per_second: SENDER_REFILL_PER_SECOND, + overflow_burst: OVERFLOW_BURST, + overflow_refill_per_second: OVERFLOW_REFILL_PER_SECOND, + attempt_global_burst: None, + attempt_global_refill_per_second: None, + }, + ) + } + + fn new_close_at(now: Instant) -> Self { + Self::with_policy( + now, + QuotaPolicy { + global_burst: CLOSE_GLOBAL_BURST, + global_refill_per_second: CLOSE_GLOBAL_REFILL_PER_SECOND, + sender_burst: CLOSE_SENDER_BURST, + sender_refill_per_second: CLOSE_SENDER_REFILL_PER_SECOND, + overflow_burst: CLOSE_OVERFLOW_BURST, + overflow_refill_per_second: CLOSE_OVERFLOW_REFILL_PER_SECOND, + attempt_global_burst: Some(CLOSE_ATTEMPT_GLOBAL_BURST), + attempt_global_refill_per_second: Some(CLOSE_ATTEMPT_GLOBAL_REFILL_PER_SECOND), + }, + ) + } + + fn with_policy(now: Instant, policy: QuotaPolicy) -> Self { + Self { + state: Mutex::new(QuotaState { + global: TokenBucket::new(policy.global_burst, policy.global_refill_per_second, now), + attempt_global: policy + .attempt_global_burst + .zip(policy.attempt_global_refill_per_second) + .map(|(burst, refill)| TokenBucket::new(burst, refill, now)), + principals: HashMap::new(), + overflow: TokenBucket::new( + policy.overflow_burst, + policy.overflow_refill_per_second, + now, + ), + }), + policy, + } + } + + pub(in crate::daemon::notifications) fn try_admit_close_attempt( + &self, + principal: Option, + now: Instant, + ) -> Admission { + let Ok(mut state) = self.state.lock() else { + return Admission::GlobalLimited; + }; + state.prune_principal_buckets(now); + // Process churn cannot mint work after this shared attempt budget is empty + if !state.attempt_global_has_token(now) { + return Admission::GlobalLimited; + } + if !state.principal_has_token(principal, now, self.policy) { + return Admission::PrincipalLimited; + } + let principal_taken = state.take_principal_token(principal, now, self.policy); + let attempt_global_taken = state.take_attempt_global_token(now); + debug_assert!( + principal_taken, + "checked close principal token must remain available" + ); + debug_assert!( + attempt_global_taken, + "checked close attempt token must remain available" + ); + Admission::Allowed + } + + pub(in crate::daemon::notifications) fn try_admit_notify( + &self, + principal: Option, + now: Instant, + ) -> Admission { + self.try_admit_hierarchical(principal, now) + } + + pub(in crate::daemon::notifications) fn try_admit_close_commit( + &self, + now: Instant, + ) -> Admission { + let Ok(mut state) = self.state.lock() else { + return Admission::GlobalLimited; + }; + // Only an authorized close consumes the protected mutation budget + state.global.refill(now); + if !state.global.has_token() { + return Admission::GlobalLimited; + } + let global_taken = state.global.take_token(); + debug_assert!( + global_taken, + "checked close commit token must remain available" + ); + Admission::Allowed + } + + fn try_admit_hierarchical(&self, principal: Option, now: Instant) -> Admission { + let Ok(mut state) = self.state.lock() else { + return Admission::GlobalLimited; + }; + state.prune_principal_buckets(now); + state.global.refill(now); + + // Check both budgets before decrementing either one + // A shared rejection also avoids mutating principal LRU admission state + if !state.global.has_token() { + return Admission::GlobalLimited; + } + if !state.principal_has_token(principal, now, self.policy) { + return Admission::PrincipalLimited; + } + + let principal_taken = state.take_principal_token(principal, now, self.policy); + let global_taken = state.global.take_token(); + debug_assert!( + principal_taken, + "checked principal token must remain available" + ); + debug_assert!(global_taken, "checked global token must remain available"); + Admission::Allowed + } +} + +impl QuotaState { + fn attempt_global_has_token(&mut self, now: Instant) -> bool { + self.attempt_global.as_mut().is_some_and(|bucket| { + bucket.refill(now); + bucket.has_token() + }) + } + + fn take_attempt_global_token(&mut self, now: Instant) -> bool { + self.attempt_global.as_mut().is_some_and(|bucket| { + bucket.refill(now); + bucket.take_token() + }) + } + + fn prune_principal_buckets(&mut self, now: Instant) { + self.principals.retain(|_principal, bucket| { + bucket.bucket.refill(now); + let idle = now.saturating_duration_since(bucket.last_seen).as_secs() + >= PRINCIPAL_IDLE_TTL_SECONDS; + // Only a fully restored idle principal may release its bounded map slot + !(idle && bucket.bucket.is_full()) + }); + } + + fn principal_has_token( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> bool { + self.principal_bucket_mut(principal, now, policy) + .is_some_and(|bucket| bucket.has_token()) + } + + fn take_principal_token( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> bool { + self.principal_bucket_mut(principal, now, policy) + .is_some_and(TokenBucket::take_token) + } + + fn principal_bucket_mut( + &mut self, + principal: Option, + now: Instant, + policy: QuotaPolicy, + ) -> Option<&mut TokenBucket> { + let Some(principal) = principal else { + // Callers without stable process evidence share one deliberately small allowance + self.overflow.refill(now); + return Some(&mut self.overflow); + }; + + if !self.principals.contains_key(&principal) + && self.principals.len() >= MAX_TRACKED_PRINCIPALS + { + // Stable newcomers displace the least-recent entry instead of falling off a quota cliff + if let Some(oldest) = self + .principals + .iter() + .min_by_key(|(_key, bucket)| bucket.last_seen) + .map(|(key, _bucket)| *key) + { + self.principals.remove(&oldest); + } + } + + let principal_bucket = + self.principals + .entry(principal) + .or_insert_with(|| PrincipalBucket { + bucket: TokenBucket::new( + policy.sender_burst, + policy.sender_refill_per_second, + now, + ), + last_seen: now, + }); + principal_bucket.last_seen = now; + principal_bucket.bucket.refill(now); + Some(&mut principal_bucket.bucket) + } +} + +impl TokenBucket { + const fn new(capacity: f64, refill_per_second: f64, now: Instant) -> Self { + Self { + tokens: capacity, + capacity, + refill_per_second, + last_refill: now, + } + } + + fn refill(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.last_refill); + self.tokens = elapsed + .as_secs_f64() + .mul_add(self.refill_per_second, self.tokens) + .min(self.capacity); + self.last_refill = now; + } + + fn has_token(&self) -> bool { + self.tokens >= 1.0 + } + + fn is_full(&self) -> bool { + self.tokens >= self.capacity + } + + fn take_token(&mut self) -> bool { + if !self.has_token() { + return false; + } + self.tokens -= 1.0; + true + } +} + +#[cfg(test)] +#[path = "tests/quota.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/metrics.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/metrics.rs new file mode 100644 index 000000000..505d9d552 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/metrics.rs @@ -0,0 +1,61 @@ +//! Notification ingress metric tests + +use std::sync::atomic::Ordering; + +use super::{IngressMetrics, RejectedRequest}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IngressMetricsSnapshot { + notify_quota_rejections: u64, + notify_concurrency_rejections: u64, + close_quota_rejections: u64, + active_handlers: usize, + peak_active_handlers: usize, +} + +fn snapshot(metrics: &IngressMetrics) -> IngressMetricsSnapshot { + IngressMetricsSnapshot { + notify_quota_rejections: metrics.notify_quota_rejections.load(Ordering::Relaxed), + notify_concurrency_rejections: metrics + .notify_concurrency_rejections + .load(Ordering::Relaxed), + close_quota_rejections: metrics.close_quota_rejections.load(Ordering::Relaxed), + active_handlers: metrics.active_handlers.load(Ordering::Relaxed), + peak_active_handlers: metrics.peak_active_handlers.load(Ordering::Relaxed), + } +} + +#[test] +fn rejection_counters_are_kept_separate_by_request_path() { + let metrics = IngressMetrics::new(); + + assert_eq!(metrics.record_rejection(RejectedRequest::NotifyQuota), 1); + assert_eq!(metrics.record_rejection(RejectedRequest::NotifyQuota), 2); + assert_eq!( + metrics.record_rejection(RejectedRequest::NotifyConcurrency), + 1 + ); + assert_eq!(metrics.record_rejection(RejectedRequest::CloseQuota), 1); + + let snapshot = snapshot(&metrics); + assert_eq!(snapshot.notify_quota_rejections, 2); + assert_eq!(snapshot.notify_concurrency_rejections, 1); + assert_eq!(snapshot.close_quota_rejections, 1); +} + +#[test] +fn handler_guard_tracks_current_and_peak_concurrency_without_leaking_activity() { + let metrics = IngressMetrics::new(); + + let first = metrics.enter_handler(); + let second = metrics.enter_handler(); + assert_eq!(snapshot(&metrics).active_handlers, 2); + assert_eq!(snapshot(&metrics).peak_active_handlers, 2); + drop(second); + assert_eq!(snapshot(&metrics).active_handlers, 1); + drop(first); + + let snapshot = snapshot(&metrics); + assert_eq!(snapshot.active_handlers, 0); + assert_eq!(snapshot.peak_active_handlers, 2); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs new file mode 100644 index 000000000..925ed06cc --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/ingress/tests/quota.rs @@ -0,0 +1,371 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use super::{ + Admission, NotificationQuota, PrincipalBucket, QuotaPrincipal, QuotaState, TokenBucket, + CLOSE_GLOBAL_BURST, CLOSE_SENDER_BURST, GLOBAL_BURST, MAX_TRACKED_PRINCIPALS, + PRINCIPAL_IDLE_TTL_SECONDS, SENDER_BURST, +}; + +fn principal(index: u32) -> QuotaPrincipal { + QuotaPrincipal::new(1_000, index, u64::from(index) + 10) +} + +fn principal_bucket(now: Instant) -> PrincipalBucket { + PrincipalBucket { + bucket: TokenBucket::new(SENDER_BURST, 1.0, now), + last_seen: now, + } +} + +fn quota_state(now: Instant) -> QuotaState { + QuotaState { + global: TokenBucket::new(GLOBAL_BURST, 1.0, now), + attempt_global: None, + principals: HashMap::new(), + overflow: TokenBucket::new(10.0, 1.0, now), + } +} + +#[test] +fn hierarchical_admission_charges_neither_bucket_when_principal_is_limited() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + let caller = principal(10); + + for _request in 0..SENDER_BURST as usize { + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::Allowed + ); + } + let global_before = quota.state.lock().expect("quota state").global.tokens; + + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::PrincipalLimited + ); + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .global + .tokens + .to_bits(), + global_before.to_bits(), + "principal rejection must not spend a shared token" + ); +} + +#[test] +fn hierarchical_admission_charges_neither_bucket_when_global_is_limited() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + let caller = principal(10); + assert!(quota.try_admit_notify(Some(caller), now).is_allowed()); + { + let mut state = quota.state.lock().expect("quota state"); + state.global.tokens = 0.0; + } + let principal_before = quota + .state + .lock() + .expect("quota state") + .principals + .get(&caller) + .expect("principal bucket") + .bucket + .tokens; + + assert_eq!( + quota.try_admit_notify(Some(caller), now), + Admission::GlobalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!( + state + .principals + .get(&caller) + .expect("principal bucket") + .bucket + .tokens + .to_bits(), + principal_before.to_bits(), + "global rejection must not spend a caller token" + ); +} + +#[test] +fn global_rejection_does_not_evict_an_established_principal() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + { + let mut state = quota.state.lock().expect("quota state"); + state.global.tokens = 0.0; + for index in 0..MAX_TRACKED_PRINCIPALS { + state.principals.insert( + principal(index as u32), + PrincipalBucket { + bucket: TokenBucket::new(SENDER_BURST, 1.0, now), + last_seen: now + Duration::from_nanos(index as u64), + }, + ); + } + } + let newcomer = principal(u32::MAX); + + assert_eq!( + quota.try_admit_notify(Some(newcomer), now), + Admission::GlobalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert!(state.principals.contains_key(&principal(0))); + assert!(!state.principals.contains_key(&newcomer)); +} + +#[test] +fn close_commit_charges_only_global_capacity_and_refills_over_time() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + + for _request in 0..CLOSE_GLOBAL_BURST as usize { + assert!(quota.try_admit_close_commit(now).is_allowed()); + } + assert_eq!(quota.try_admit_close_commit(now), Admission::GlobalLimited); + assert!(quota + .try_admit_close_commit(now + Duration::from_millis(5)) + .is_allowed()); + assert!(quota + .state + .lock() + .expect("quota state") + .principals + .is_empty()); +} + +#[test] +fn successful_close_sequence_charges_one_principal_token_per_operation() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + let caller = principal(10); + + for _request in 0..CLOSE_SENDER_BURST as usize { + assert!( + quota + .try_admit_close_attempt(Some(caller), now) + .is_allowed(), + "every documented caller burst token must admit one successful close" + ); + assert!(quota.try_admit_close_commit(now).is_allowed()); + } + + assert_eq!( + quota.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); + let state = quota.state.lock().expect("quota state"); + assert_eq!( + state.global.tokens.to_bits(), + (CLOSE_GLOBAL_BURST - CLOSE_SENDER_BURST).to_bits(), + "each successful close must consume one shared commit token" + ); +} + +#[test] +fn close_attempts_use_a_separate_higher_principal_budget() { + let now = Instant::now(); + let notify = NotificationQuota::new_at(now); + let close = NotificationQuota::new_close_at(now); + let caller = principal(10); + + for _request in 0..SENDER_BURST as usize { + assert!(notify.try_admit_notify(Some(caller), now).is_allowed()); + assert!(close + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); + } + assert_eq!( + notify.try_admit_notify(Some(caller), now), + Admission::PrincipalLimited + ); + for _request in SENDER_BURST as usize..CLOSE_SENDER_BURST as usize { + assert!(close + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); + } + assert_eq!( + close.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); +} + +#[test] +fn close_attempt_admission_never_charges_shared_mutation_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + let global_before = quota.state.lock().expect("quota state").global.tokens; + + assert!(quota + .try_admit_close_attempt(Some(principal(10)), now) + .is_allowed()); + + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .global + .tokens + .to_bits(), + global_before.to_bits(), + "an ownership-rejected close must leave the shared mutation budget untouched" + ); +} + +#[test] +fn principal_rejection_does_not_charge_shared_close_attempt_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + let caller = principal(10); + + for _request in 0..CLOSE_SENDER_BURST as usize { + assert!(quota + .try_admit_close_attempt(Some(caller), now) + .is_allowed()); + } + let before = quota + .state + .lock() + .expect("quota state") + .attempt_global + .as_ref() + .expect("close attempt bucket") + .tokens; + + assert_eq!( + quota.try_admit_close_attempt(Some(caller), now), + Admission::PrincipalLimited + ); + assert_eq!( + quota + .state + .lock() + .expect("quota state") + .attempt_global + .as_ref() + .expect("close attempt bucket") + .tokens + .to_bits(), + before.to_bits(), + "caller rejection must not spend shared close-attempt capacity" + ); +} + +#[test] +fn stable_principal_churn_cannot_mint_unbounded_close_attempt_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + + for index in 0..960_u32 { + assert_eq!( + quota.try_admit_close_attempt(Some(principal(index)), now), + Admission::Allowed, + "every documented global attempt token should admit one cold principal" + ); + } + + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert_eq!(state.global.tokens.to_bits(), CLOSE_GLOBAL_BURST.to_bits()); + drop(state); + assert_eq!( + quota.try_admit_close_attempt(Some(principal(u32::MAX)), now), + Admission::GlobalLimited, + "a new process identity must not create capacity after the attempt budget is empty" + ); +} + +#[test] +fn stable_newcomer_displaces_the_least_recent_principal_at_capacity() { + let now = Instant::now(); + let quota = NotificationQuota::new_close_at(now); + for index in 0..MAX_TRACKED_PRINCIPALS { + let observed = now + Duration::from_nanos(index as u64); + assert!(quota + .try_admit_close_attempt(Some(principal(index as u32)), observed) + .is_allowed()); + } + let newcomer = principal(u32::MAX); + + assert!(quota + .try_admit_close_attempt(Some(newcomer), now + Duration::from_secs(1)) + .is_allowed()); + let state = quota.state.lock().expect("quota state"); + assert_eq!(state.principals.len(), MAX_TRACKED_PRINCIPALS); + assert!(!state.principals.contains_key(&principal(0))); + assert!(state.principals.contains_key(&newcomer)); +} + +#[test] +fn unknown_principals_remain_in_one_restricted_bucket() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + + for _request in 0..10 { + assert!(quota.try_admit_notify(None, now).is_allowed()); + } + assert_eq!( + quota.try_admit_notify(None, now), + Admission::PrincipalLimited + ); + assert!(quota + .state + .lock() + .expect("quota state") + .principals + .is_empty()); +} + +#[test] +fn principal_pruning_removes_only_fully_refilled_idle_entries() { + let now = Instant::now(); + let mut state = quota_state(now); + let expired_idle = principal(1); + let throttled = principal(2); + let recent = principal(3); + state.principals.insert(expired_idle, principal_bucket(now)); + let mut depleted = principal_bucket(now); + depleted.bucket.tokens = 0.0; + depleted.bucket.refill_per_second = 0.0; + state.principals.insert(throttled, depleted); + state + .principals + .insert(recent, principal_bucket(now + Duration::from_secs(1))); + + state.prune_principal_buckets(now + Duration::from_secs(PRINCIPAL_IDLE_TTL_SECONDS)); + + assert!(!state.principals.contains_key(&expired_idle)); + assert!(state.principals.contains_key(&throttled)); + assert!(state.principals.contains_key(&recent)); +} + +#[test] +fn reconnect_address_churn_does_not_reset_a_process_principal_bucket() { + let now = Instant::now(); + let quota = NotificationQuota::new_at(now); + let same_process = QuotaPrincipal::new(1_000, 42, 99); + + let mut admitted = 0usize; + for _transport_connection in 0..MAX_TRACKED_PRINCIPALS + 64 { + admitted += usize::from(quota.try_admit_notify(Some(same_process), now).is_allowed()); + } + + assert_eq!(admitted, SENDER_BURST as usize); + assert_eq!( + quota.try_admit_notify(Some(same_process), now), + Admission::PrincipalLimited + ); + assert_eq!(quota.state.lock().expect("quota state").principals.len(), 1); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/limits.rs deleted file mode 100644 index 3aa076134..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/limits.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Bounds for untrusted notification payload data -//! -//! Keeping limits in one file makes audits and tuning easier - -pub(super) const MAX_APP_NAME_BYTES: usize = 256; -// Icon names/paths can be longer than app names, but still need a hard cap -pub(super) const MAX_APP_ICON_BYTES: usize = 1024; -// Summary is shown prominently, so keep it short and bounded -pub(super) const MAX_SUMMARY_BYTES: usize = 1024; -// Body can be larger, but still needs a strict upper bound -pub(super) const MAX_BODY_BYTES: usize = 16 * 1024; -// Category is used for grouping and rules, so keep values compact -pub(super) const MAX_CATEGORY_BYTES: usize = 256; -// Keep action rows compact so one notification cannot stretch list layout -// This limit is shared by popup and center action rendering expectations -pub(super) const MAX_ACTIONS: usize = 8; -// Action keys are internal identifiers -pub(super) const MAX_ACTION_KEY_BYTES: usize = 128; -// Action labels are user-facing button text -pub(super) const MAX_ACTION_LABEL_BYTES: usize = 256; -// Limit hint map size so map copies stay cheap -pub(super) const MAX_HINT_ENTRIES: usize = 16; -// Hint keys are short protocol labels -pub(super) const MAX_HINT_KEY_BYTES: usize = 64; -// String hints can be descriptive, but still capped for memory safety -pub(super) const MAX_HINT_STRING_BYTES: usize = 2048; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs index b67ddddf9..188e0c9d6 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/mod.rs @@ -1,8 +1,15 @@ //! D-Bus server for org.freedesktop.Notifications -mod limits; -mod payload; -mod sender; +mod flow_control; +pub(in crate::daemon) mod identity; +mod ingress; mod server; +pub(in crate::daemon) use flow_control::{ + notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, +}; +pub use identity::DesktopIndexSnapshot; +pub(in crate::daemon) use identity::SenderMetadataCache; +pub use identity::{spawn_desktop_index_refresh, DesktopIdentityIndex}; +pub use server::NotificationIngress; pub use server::NotificationServer; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/payload.rs deleted file mode 100644 index 0edf19233..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/payload.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Payload construction and sanitization for notifications -//! -//! This module turns raw D-Bus values into bounded internal model values - -use std::cmp::Ordering; -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use unicode_width::UnicodeWidthChar; -use unixnotis_core::{util, Action, Config, Notification, NotificationImage, Urgency}; -use zbus::zvariant::{OwnedValue, Value}; - -use super::limits::{ - MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, - MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_CATEGORY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, - MAX_HINT_STRING_BYTES, MAX_SUMMARY_BYTES, -}; -use super::sender::SenderMetadata; - -// Unbroken tokens longer than this are folded with an ellipsis to avoid UI overflow spikes -const MAX_CONTIGUOUS_TOKEN_CHARS: usize = 96; - -pub(super) struct NotificationInput { - pub(super) app_name: String, - pub(super) app_icon: String, - pub(super) summary: String, - pub(super) body: String, - pub(super) actions: Vec, - pub(super) hints: HashMap, - pub(super) sender: SenderMetadata, - pub(super) expire_timeout: i32, -} - -pub(super) fn build_notification(input: NotificationInput) -> Notification { - let NotificationInput { - app_name, - app_icon, - summary, - body, - actions, - hints, - sender, - expire_timeout, - } = input; - - // Read shared hint data first - let urgency = Urgency::from_hint(hints.get("urgency")); - let category = hints - .get("category") - .and_then(owned_to_string) - .map(|value| { - // Category stays on one line - truncate_utf8_bytes( - &util::sanitize_inline_display_text(&value), - MAX_CATEGORY_BYTES, - ) - }); - let is_transient = hints - .get("transient") - .and_then(|value| bool::try_from(value).ok()) - .unwrap_or(false); - let is_resident = hints - .get("resident") - .and_then(|value| bool::try_from(value).ok()) - .unwrap_or(false); - let image = NotificationImage::from_hints(&app_name, &app_icon, &hints); - // Clean text before storing it - let app_name = util::sanitize_inline_display_text(&app_name); - let summary = util::sanitize_display_text(&summary); - let body = util::sanitize_display_text(&body); - - Notification { - id: 0, - app_name: if app_name.is_empty() { - // Keep explicit fallback text for empty callers - "Unknown".to_string() - } else { - truncate_utf8_bytes(&app_name, MAX_APP_NAME_BYTES) - }, - app_icon: truncate_utf8_bytes(&app_icon, MAX_APP_ICON_BYTES), - // Truncate bytes first, then fold long contiguous runs to keep UTF-8 boundaries valid - // Fold very long unbroken runs so renderer width remains bounded - summary: normalize_text_for_layout( - &truncate_utf8_bytes(&summary, MAX_SUMMARY_BYTES), - MAX_CONTIGUOUS_TOKEN_CHARS, - ), - // Apply the same order for body so renderer sees consistent text constraints - // Body can be much larger, so apply the same run-folding protection here - body: normalize_text_for_layout( - &truncate_utf8_bytes(&body, MAX_BODY_BYTES), - MAX_CONTIGUOUS_TOKEN_CHARS, - ), - actions: parse_actions(actions), - // Keep only needed hints - hints: sanitize_hints_for_storage(hints), - urgency, - category, - is_transient, - is_resident, - suppress_popup: false, - suppress_sound: false, - image, - expire_timeout, - received_at: chrono::Utc::now(), - sender_name: sender.sender_name, - sender_pid: sender.sender_pid, - sender_start_time: sender.sender_start_time, - sender_executable: sender.sender_executable, - } -} - -pub(super) fn resolve_expiration(config: &Config, notification: &Notification) -> Option { - // Resident notifications never auto-expire - if notification.is_resident { - return None; - } - - let timeout_ms = match notification.expire_timeout.cmp(&0) { - // Explicit timeout=0 disables auto-expiration - Ordering::Equal => return None, - // Positive values are caller-provided milliseconds - Ordering::Greater => notification.expire_timeout as u64, - // Negative values request defaults by urgency - Ordering::Less => match notification.urgency { - Urgency::Critical => config.popups.critical_timeout_ms?, - _ => config.popups.default_timeout_ms, - }, - }; - - if timeout_ms == 0 { - return None; - } - - Some(Instant::now() + Duration::from_millis(timeout_ms)) -} - -fn parse_actions(raw: Vec) -> Vec { - // Actions come in key and label pairs - let mut actions = Vec::with_capacity(raw.len().min(MAX_ACTIONS)); - let mut iter = raw.into_iter(); - - // The protocol sends actions as [key, label, key, label, ...] - while let Some(key) = iter.next() { - if let Some(label) = iter.next() { - if actions.len() >= MAX_ACTIONS { - // Hard stop keeps button rows bounded even when sender floods action pairs - break; - } - actions.push(Action { - // Key is protocol data - key: truncate_utf8_bytes(&key, MAX_ACTION_KEY_BYTES), - // Label is shown to the user - label: truncate_utf8_bytes( - &util::sanitize_inline_display_text(&label), - MAX_ACTION_LABEL_BYTES, - ), - }); - } - } - actions -} - -fn sanitize_hints_for_storage(hints: HashMap) -> HashMap { - // Pre-sizing avoids rehash churn on adversarial hint fanout - let mut sanitized = HashMap::with_capacity(hints.len().min(MAX_HINT_ENTRIES)); - - for (key, value) in hints { - if sanitized.len() >= MAX_HINT_ENTRIES { - break; - } - - let key = truncate_utf8_bytes(key.trim(), MAX_HINT_KEY_BYTES); - if key.is_empty() { - continue; - } - - let value = match key.as_str() { - // Keep only hints that matter for daemon behavior and rendering - "sound-name" | "sound-file" | "category" => owned_to_string(&value).and_then(|text| { - // Keep hint text small - let bounded = truncate_utf8_bytes(&text, MAX_HINT_STRING_BYTES); - string_to_owned_value(&bounded) - }), - "transient" | "resident" | "suppress-sound" => { - bool::try_from(&value).ok().map(OwnedValue::from) - } - "urgency" => parse_urgency_hint(&value).map(OwnedValue::from), - _ => None, - }; - - if let Some(value) = value { - sanitized.insert(key, value); - } - } - - sanitized -} - -fn string_to_owned_value(value: &str) -> Option { - OwnedValue::try_from(Value::from(value)).ok() -} - -fn parse_urgency_hint(value: &OwnedValue) -> Option { - // Accept both byte and integer variants from mixed clients - if let Ok(raw) = u8::try_from(value) { - return Some(u32::from(raw).min(2)); - } - if let Ok(raw) = u32::try_from(value) { - return Some(raw.min(2)); - } - None -} - -fn owned_to_string(value: &OwnedValue) -> Option { - value - .try_clone() - .ok() - .and_then(|owned| String::try_from(owned).ok()) -} - -fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { - if max_bytes == 0 { - return String::new(); - } - if value.len() <= max_bytes { - // Fast path for common short payloads - return value.to_string(); - } - - // Walk character ends instead of decrementing a byte index; a mutated loop - // counter must not be able to hang while handling untrusted notification text - let end = value - .char_indices() - .map(|(index, ch)| index + ch.len_utf8()) - .take_while(|end| *end <= max_bytes) - .last() - .unwrap_or(0); - value[..end].to_string() -} - -fn normalize_text_for_layout(value: &str, max_contiguous: usize) -> String { - if value.is_empty() || max_contiguous == 0 { - return value.to_string(); - } - - // Reserve original length to keep this pass allocation-stable on long input - let mut out = String::with_capacity(value.len()); - let mut run_width = 0usize; - let mut folded_run = false; - - // Walk characters so non-ASCII content remains valid after normalization - // Width is tracked in display columns instead of char count to handle wide glyphs - for ch in value.chars() { - if ch.is_whitespace() { - // Whitespace resets contiguous-run accounting - run_width = 0; - folded_run = false; - out.push(ch); - continue; - } - - let width = display_width(ch); - if run_width.saturating_add(width) <= max_contiguous { - // Short runs stay as they are - out.push(ch); - run_width = run_width.saturating_add(width); - continue; - } - - // Add one ellipsis when a contiguous token crosses the safety threshold - if !folded_run { - let ellipsis_width = display_width('…'); - // Keep final run width bounded by trimming the current run tail first - while run_width.saturating_add(ellipsis_width) > max_contiguous { - // Pop one char at a time - let Some(last) = out.pop() else { - break; - }; - run_width = run_width.saturating_sub(display_width(last)); - } - if run_width.saturating_add(ellipsis_width) <= max_contiguous { - out.push('…'); - run_width = run_width.saturating_add(ellipsis_width); - } - folded_run = true; - } - // Remaining chars in this run are dropped until whitespace appears again - } - - out -} - -fn display_width(ch: char) -> usize { - // Width estimators in downstream UI surfaces often treat joiners/selectors as visible slots - // Counting them here keeps folded output safely within those stricter layouts - if matches!( - ch, - '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FE0E}' | '\u{FE0F}' - ) { - return 1; - } - UnicodeWidthChar::width_cjk(ch).unwrap_or(0) -} - -#[cfg(test)] -#[path = "tests/payload.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/sender.rs deleted file mode 100644 index 552d6334f..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/sender.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Sender metadata helpers for incoming Notify/CloseNotification calls -//! -//! Sender details are optional and best-effort, so failures here must not reject -//! notification delivery - -use std::path::Path; - -use zbus::fdo::DBusProxy; -use zbus::message::Header; -use zbus::Connection; - -#[derive(Debug, Clone)] -pub(super) struct SenderMetadata { - // Unique bus sender name (:1.x) used for ownership checks - pub(super) sender_name: Option, - // Process id is paired with start time so reused pids do not inherit ownership - pub(super) sender_pid: Option, - // Linux start time identifies one concrete process lifetime - pub(super) sender_start_time: Option, - // Executable path is used for diagnostics and app-name mismatch logging - pub(super) sender_executable: Option, -} - -pub(super) async fn resolve_sender_metadata( - connection: &Connection, - header: &Header<'_>, -) -> SenderMetadata { - // Sender lookup failures are non-fatal and should degrade to "unknown" - let sender_name = header.sender().map(|sender| sender.as_str().to_string()); - let Some(sender_name_str) = sender_name.as_deref() else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - }; - - let Ok(bus_name) = zbus::names::BusName::try_from(sender_name_str) else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - }; - - let Ok(proxy) = DBusProxy::new(connection).await else { - return SenderMetadata { - sender_name, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - }; - - // PID and executable come from the bus owner, not caller-provided payload fields - let sender_pid = proxy.get_connection_unix_process_id(bus_name).await.ok(); - let sender_start_time = sender_pid.and_then(read_process_start_time); - let sender_executable = match sender_pid { - Some(pid) => read_process_executable_path(pid) - .await - .map(|path| path.display().to_string()), - None => None, - }; - - SenderMetadata { - sender_name, - sender_pid, - sender_start_time, - sender_executable, - } -} - -pub(super) fn app_name_matches_sender(app_name: &str, sender_executable: &str) -> bool { - // This check is advisory only; many apps use display names that differ from binary names - let app = app_name.trim().to_ascii_lowercase(); - if app.is_empty() { - return true; - } - - let Some(exe_name) = Path::new(sender_executable) - .file_name() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - else { - return true; - }; - - app == exe_name || app.replace(' ', "-") == exe_name || exe_name.contains(&app) -} - -#[cfg(target_os = "linux")] -async fn read_process_executable_path(pid: u32) -> Option { - // Linux path to the executable behind this process id - let path = format!("/proc/{pid}/exe"); - tokio::fs::read_link(path).await.ok() -} - -#[cfg(target_os = "linux")] -fn read_process_start_time(pid: u32) -> Option { - // /proc//stat keeps the process lifetime tick count in field 22 - let path = format!("/proc/{pid}/stat"); - let contents = std::fs::read_to_string(path).ok()?; - parse_process_start_time(&contents) -} - -#[cfg(not(target_os = "linux"))] -async fn read_process_executable_path(_pid: u32) -> Option { - // On other platforms this metadata is optional - None -} - -#[cfg(not(target_os = "linux"))] -fn read_process_start_time(_pid: u32) -> Option { - // Non-Linux builds fall back to bus-name ownership only - None -} - -#[cfg(target_os = "linux")] -fn parse_process_start_time(stat: &str) -> Option { - // The comm field is wrapped in parentheses and may contain spaces - let end = stat.rfind(')')?; - let remainder = stat.get(end + 2..)?; - // Field 3 starts here, so field 22 lives at index 19 - let start_time = remainder.split_whitespace().nth(19)?; - start_time.parse().ok() -} - -#[cfg(test)] -#[path = "tests/sender.rs"] -mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs new file mode 100644 index 000000000..1abcd552b --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/avatar.rs @@ -0,0 +1,47 @@ +//! Bounded worker support for sender-provided conversation artwork + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Semaphore; + +const AVATAR_WORKER_SLOTS: usize = 4; + +fn avatar_worker_pool() -> Arc { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + Arc::clone(POOL.get_or_init(|| Arc::new(Semaphore::new(AVATAR_WORKER_SLOTS)))) +} + +pub(super) async fn run_avatar_worker(work: F, deadline: Duration) -> Option +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + run_avatar_worker_with_pool(avatar_worker_pool(), work, deadline).await +} + +pub(super) async fn run_avatar_worker_with_pool( + pool: Arc, + work: F, + deadline: Duration, +) -> Option +where + T: Send + 'static, + F: FnOnce() -> T + Send + 'static, +{ + // try_acquire makes overload fail closed instead of queuing unbounded work + let permit = pool.try_acquire_owned().ok()?; + let task = tokio::task::spawn_blocking(move || { + // Keep this permit in the blocking closure so timeout cancellation cannot release it early + let _permit = permit; + work() + }); + tokio::time::timeout(deadline, task) + .await + .ok() + .and_then(Result::ok) +} + +#[cfg(test)] +#[path = "tests/avatar.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs index 5da4b3a75..80b0fe2eb 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/capabilities.rs @@ -1,9 +1,10 @@ pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { - // Capabilities are static except for optional sound support + // Advertise only semantics preserved by normalization. Notification bodies + // are intentionally sanitized to display text, so body-markup is unsupported let mut caps = vec![ "actions".to_string(), + "inline-reply".to_string(), "body".to_string(), - "body-markup".to_string(), "icon-static".to_string(), ]; if supports_sound { @@ -13,5 +14,5 @@ pub(super) fn notification_capabilities(supports_sound: bool) -> Vec { } #[cfg(test)] -#[path = "../tests/capabilities.rs"] +#[path = "tests/capabilities.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs index 10cc0bbb2..05e1c0203 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/close.rs @@ -1,11 +1,11 @@ +use std::time::Instant; use tracing::debug; use unixnotis_core::CloseReason; use zbus::message::Header; -use crate::daemon::to_fdo_error; - use super::NotificationServer; -use crate::daemon::notifications::sender::resolve_sender_metadata; +use crate::daemon::notifications::identity::resolve_sender_metadata; +use crate::daemon::notifications::ingress::metrics::RejectedRequest; impl NotificationServer { pub(super) async fn close_notification_if_owned( @@ -15,35 +15,90 @@ impl NotificationServer { ) -> zbus::fdo::Result<()> { debug!(id, "close notification requested"); - // Close requests are ownership checked and become no-op when unauthorized - let sender = resolve_sender_metadata(self.state.connection(), header).await; - let Some(sender_name) = sender.sender_name.as_deref() else { - return Ok(()); - }; - - let owned = { - let store = self.state.store.lock().await; - // Ownership check allows reconnect-safe close by same sender pid - store.is_notification_owned_by( + // Unauthorized close targets collapse into one generic protocol failure + let sender = resolve_sender_metadata( + &self.state.sender_metadata_cache, + self.state.connection(), + header, + ) + .await; + let principal = super::quota_principal(&sender); + if !self + .close_quota + .try_admit_close_attempt(principal, Instant::now()) + .is_allowed() + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::CloseQuota); + debug!(rejected, "close request rejected by principal quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification close quota exceeded".to_string(), + )); + } + // Replacement commits share this gate so one close request targets one generation + let _interaction = self.state.interaction_gates.lock(id).await; + let removed = { + let mut store = self.state.store.lock().await; + let authorization = store.close_authorization( id, - sender_name, + sender.sender_name.as_deref(), + sender.sender_pid, + sender.sender_start_time, + ); + let crate::store::CloseAuthorization::OwnedActive(expected) = authorization else { + debug!( + id, + sender = sender.sender_name.as_deref().unwrap_or("unknown"), + sender_pid = sender.sender_pid, + "notification close target is not closable" + ); + // Invalid attempts charge only their caller and never consume shared mutation capacity + return Err(generic_close_error()); + }; + if !self + .close_quota + .try_admit_close_commit(Instant::now()) + .is_allowed() + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::CloseQuota); + debug!(rejected, "owned close rejected by global commit quota"); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification close quota exceeded".to_string(), + )); + } + + // Admission and removal share one store lock so only a real mutation spends global quota + store.close_owned_active_generation( + expected, + sender.sender_name.as_deref(), sender.sender_pid, sender.sender_start_time, + CloseReason::ClosedByCall, ) }; - if !owned { + let Some(removed) = removed else { debug!( id, - sender = sender_name, + sender = sender.sender_name.as_deref().unwrap_or("unknown"), sender_pid = sender.sender_pid, - "ignoring close for unowned notification" + "notification close target is not closable" ); - return Ok(()); - } + // A concurrent replacement or close stays indistinguishable from every invalid target + return Err(generic_close_error()); + }; + self.state.cancel_expiration(removed.key()); self.state - .close_notification(id, CloseReason::ClosedByCall) + .publish_notification_closed(removed.key(), CloseReason::ClosedByCall) .await - .map_err(to_fdo_error) + .map_err(crate::daemon::to_fdo_error) } } + +const fn generic_close_error() -> zbus::fdo::Error { + // One empty generic failure prevents existence and ownership disclosure + zbus::fdo::Error::Failed(String::new()) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs index 81df34ceb..3a588bb0b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/flow.rs @@ -1,26 +1,31 @@ use std::collections::HashMap; -use std::time::Instant; - -use tracing::debug; -use unixnotis_core::{CloseReason, Notification, CONTROL_OBJECT_PATH}; +use std::sync::Arc; +use tracing::{debug, warn}; +use unixnotis_core::{ImageData, Notification, NotificationKey}; use zbus::message::Header; use zbus::zvariant::OwnedValue; -use zbus::SignalContext; -use crate::daemon::notifications::payload::{ - build_notification, resolve_expiration, NotificationInput, +use crate::daemon::notifications::identity::{ + resolve_attribution_owned, resolve_attribution_with_deadline, SenderMetadata, +}; +use crate::daemon::notifications::identity::{ + resolve_sender_metadata, SenderMetadataStatus, SENDER_CREDENTIAL_TIMEOUT, }; -use crate::daemon::notifications::sender::{app_name_matches_sender, resolve_sender_metadata}; -use crate::daemon::{ - to_fdo_error, ControlServer, NotificationSignalMode, NOTIFICATIONS_OBJECT_PATH, +use crate::daemon::notifications::ingress::payload::{ + build_notification, materialize_sender_visual, may_materialize_content_image, owned_to_string, + sender_visual_role, wire_image_role, NotificationInput, SenderVisualRole, WireImageRole, + CONVERSATION_AVATAR_TIMEOUT, MAX_STORED_AVATAR_DIMENSION, MAX_STORED_CONTENT_DIMENSION, }; -use crate::store::InsertOutcome; +use crate::daemon::{to_fdo_error, NotificationSignalMode}; +use crate::store::{CommitDisposition, InsertOutcome, SuppressedNotification}; +use super::avatar::run_avatar_worker; +use super::reply_lifecycle::NotifyCompletion; +use super::wire_hints::WireHints; use super::NotificationServer; struct StoredNotification { outcome: InsertOutcome, - expiration: Option, } struct WireNotification { @@ -30,6 +35,8 @@ struct WireNotification { body: String, actions: Vec, hints: HashMap, + wire_image_data: Option, + image_path: Option, expire_timeout: i32, } @@ -38,7 +45,7 @@ impl NotificationServer { clippy::too_many_arguments, reason = "the freedesktop notification method defines this wire-level argument list" )] - pub(super) async fn ingest_notify( + pub(super) async fn ingest_notify_deferred( &self, app_name: String, replaces_id: u32, @@ -46,10 +53,10 @@ impl NotificationServer { summary: String, body: String, actions: Vec, - hints: HashMap, - header: &Header<'_>, + hints: WireHints, + sender: SenderMetadata, expire_timeout: i32, - ) -> zbus::fdo::Result { + ) -> zbus::fdo::Result { let _ = Self::log_received_notification( &app_name, &summary, @@ -57,6 +64,7 @@ impl NotificationServer { replaces_id, expire_timeout, ); + let (hints, wire_image_data, image_path) = hints.into_parts(); let notification = self .notification_from_wire( WireNotification { @@ -66,14 +74,15 @@ impl NotificationServer { body, actions, hints, + wire_image_data, + image_path, expire_timeout, }, - header, + sender, ) .await; let stored = self.store_notification(notification, replaces_id).await; - self.finish_notification_change(stored.outcome, stored.expiration) - .await + self.finish_notification_change(stored.outcome).await } fn log_received_notification( @@ -104,21 +113,100 @@ impl NotificationServer { true } + pub(super) async fn resolve_sender(&self, header: &Header<'_>) -> SenderMetadata { + // Sender metadata helps with ownership checks and diagnostics + if let Ok(sender) = tokio::time::timeout( + SENDER_CREDENTIAL_TIMEOUT, + resolve_sender_metadata( + &self.state.sender_metadata_cache, + self.state.connection(), + header, + ), + ) + .await + { + sender + } else { + warn!("notification sender credentials timed out and failed closed"); + timed_out_sender_metadata() + } + } + async fn notification_from_wire( &self, input: WireNotification, - header: &Header<'_>, + sender: SenderMetadata, ) -> Notification { - // Sender metadata helps with ownership checks and diagnostics - let sender = resolve_sender_metadata(self.state.connection(), header).await; - if sender_app_name_mismatch(&input.app_name, sender.sender_executable.as_deref()) { + let desktop_entry = input.hints.get("desktop-entry").and_then(owned_to_string); + let desktop_identity_index = self.state.desktop_identity_index.load_full(); + // This is the only attribution deadline, including package enrichment + let resolution = resolve_attribution_with_deadline( + input.app_name.clone(), + desktop_entry.clone(), + &sender, + resolve_attribution_owned( + input.app_name.clone(), + desktop_entry.clone(), + sender.clone(), + std::sync::Arc::clone(&desktop_identity_index), + ), + ) + .await; + let wire_image_role = wire_image_role( + &resolution.attribution, + &desktop_identity_index, + &input.hints, + &input.actions, + ); + let sender_visual_role = sender_visual_role( + &resolution.attribution, + &desktop_identity_index, + &input.hints, + &input.actions, + &input.app_icon, + ); + let sender_visual = materialize_sender_visual_for_role( + sender_visual_role, + &resolution.attribution, + input.app_icon.clone(), + ) + .await; + let materialized_content = + materialize_content_visual(&resolution.attribution, input.image_path.as_deref()).await; + let (image_data, wire_sender_visual) = normalize_wire_image_for_role( + wire_image_role, + input.wire_image_data, + materialized_content, + ); + let stored_sender_visual_role = if wire_sender_visual.is_some() { + SenderVisualRole::ConversationAvatar + } else { + sender_visual_role + }; + if matches!( + resolution.attribution.status, + unixnotis_core::AttributionStatus::Conflict + ) { debug!( app_name = %input.app_name, sender = sender.sender_name.as_deref().unwrap_or("unknown"), sender_executable = sender.sender_executable.as_deref().unwrap_or("unknown"), - "notification app_name does not match sender executable" + detail = %resolution.attribution.diagnostic_detail, + "notification application claim conflicts with sender evidence" ); } + debug!( + claim = %resolution.diagnostics.claimed_name, + desktop_entry = %resolution.diagnostics.claimed_desktop_entry, + sender_executable = %resolution.diagnostics.sender_executable, + matched_desktop_id = %resolution.diagnostics.matched_desktop_id, + record_origin = ?resolution.diagnostics.record_trust, + launch_authority = ?resolution.diagnostics.launch_authority, + cmdline_quality = ?resolution.diagnostics.command_line_quality, + verification = ?resolution.diagnostics.verification, + reason = %resolution.diagnostics.reason, + "notification attribution decided" + ); // Build a safe notification record from untrusted wire data build_notification(NotificationInput { @@ -128,7 +216,14 @@ impl NotificationServer { body: input.body, actions: input.actions, hints: input.hints, + image_data, + sender_visual_data: wire_sender_visual, + sender_visual, + sender_visual_role: stored_sender_visual_role, sender, + attribution: resolution.attribution, + attribution_diagnostics: resolution.diagnostics, + inline_reply_policy: resolution.inline_reply_policy, expire_timeout: input.expire_timeout, }) } @@ -138,141 +233,207 @@ impl NotificationServer { notification: Notification, replaces_id: u32, ) -> StoredNotification { - // Store mutation and expiration scheduling happen under one lock scope - let (outcome, expiration) = { - let mut store = self.state.store.lock().await; - let outcome = store.insert(notification, replaces_id); - let expiration = if outcome.dropped { - None - } else { - // Resolve timeout after insertion so rule-mapped fields are already final - let expiration = resolve_expiration(store.config(), &outcome.notification); - store.set_expiration(outcome.notification.id, expiration); - expiration - }; - (outcome, expiration) - }; - StoredNotification { - outcome, - expiration, - } + // Shared state owns generation serialization for every current and future caller + let outcome = self + .state + .commit_notification_generation(notification, replaces_id, &self.scheduler) + .await; + StoredNotification { outcome } } - fn handle_dropped_notification(outcome: &InsertOutcome) -> Option { - if !outcome.dropped { - return None; - } + fn suppressed_notification(outcome: &InsertOutcome) -> Option { + let suppressed = outcome.suppressed()?; debug!( - id = outcome.notification.id, - app = %outcome.notification.app_name, - "notification dropped due to active inhibitor" + id = suppressed.id, + generation = suppressed.generation, + owner_pid = suppressed.owner.map(|owner| owner.pid), + "notification content dropped due to active inhibitor" ); - Some(outcome.notification.id) + Some(suppressed) } - fn schedule_and_play(&self, outcome: &InsertOutcome, expiration: Option) { - self.scheduler.schedule(outcome.notification.id, expiration); + fn play_sound(&self, notification: &Notification, allow_sound: bool) -> bool { // Sound is best-effort and decided by rules and per-notification hints self.state .sound - .play_from_hints(&outcome.notification.hints, outcome.allow_sound); + .play_from_hints(¬ification.hints, allow_sound) } - async fn emit_notification_change(&self, outcome: &InsertOutcome) -> zbus::fdo::Result<()> { - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; - match self + async fn emit_notification_change( + &self, + notification: &Notification, + replaced: bool, + ) -> zbus::fdo::Result<()> { + let mode = self .state - .notification_signal_mode(outcome.notification.sender_name.as_deref()) - { - NotificationSignalMode::Direct => { - if outcome.replaced { - // Only the id crosses the broadcast signal - // Trusted UIs fetch the live payload through the authorized control API - ControlServer::notification_updated( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } else { - // New notification broadcasts only the id for the same confidentiality reason - ControlServer::notification_added( - &control_ctx, - outcome.notification.id, - outcome.show_popup, - ) - .await - .map_err(to_fdo_error)?; - } - } - NotificationSignalMode::SnapshotOnly => { - debug!( - id = outcome.notification.id, - sender = outcome.notification.sender_name.as_deref().unwrap_or("unknown"), - "notification burst detected; using snapshot invalidation instead of per-row signal" - ); - self.state - .emit_snapshot_invalidated() - .await - .map_err(to_fdo_error)?; - } - NotificationSignalMode::Suppress => {} + .notification_signal_mode(notification.sender_name.as_deref()); + if mode == NotificationSignalMode::SnapshotOnly { + debug!( + id = notification.id, + sender = notification.sender_name.as_deref().unwrap_or("unknown"), + "notification burst detected; using snapshot invalidation instead of per-row signal" + ); } - Ok(()) + self.state + .publish_notification_change(mode, notification.key(), replaced) + .await + .map_err(to_fdo_error) } async fn finish_notification_change( &self, outcome: InsertOutcome, - expiration: Option, - ) -> zbus::fdo::Result { - if let Some(id) = Self::handle_dropped_notification(&outcome) { - return Ok(id); + ) -> zbus::fdo::Result { + let notification = match &outcome.disposition { + CommitDisposition::Active(notification) => Arc::clone(notification), + CommitDisposition::SuppressedDropAll(suppressed) => { + let suppressed = *suppressed; + let _ = Self::suppressed_notification(&outcome); + return Ok(NotifyCompletion { + id: suppressed.id, + suppressed: Some(suppressed), + }); + } + }; + let _sound_accepted = self.play_sound(¬ification, outcome.allow_sound); + debug!( + id = notification.id, + decision = ?outcome.popup_admission, + "notification popup admission decided" + ); + if outcome.popup_admission.should_show() && self.state.should_warn_popups_unready() { + warn!( + id = notification.id, + "popup admitted while popup renderer is not ready" + ); } - - self.schedule_and_play(&outcome, expiration); - self.emit_notification_change(&outcome).await?; - // Evicted items are announced so UIs can remove stale rows - self.handle_evicted(outcome.evicted).await?; - self.state - .emit_state_changed() + let id = notification.id; + let key = notification.key(); + if let Err(error) = self + .emit_notification_change(¬ification, outcome.replaced) .await - .map_err(to_fdo_error)?; + { + warn!(?error, id, "notification committed but live fanout failed"); + self.state + .store + .lock() + .await + .record_popup_delivery_stage(key, unixnotis_core::PopupDeliveryStage::FanoutFailed); + // Snapshot invalidation gives connected clients one best-effort recovery route + let _ = self.state.publish_snapshot_invalidated().await; + } + // Evicted items are announced so UIs can remove stale rows + if let Err(error) = self.handle_evicted(outcome.evicted).await { + warn!( + ?error, + id, "notification committed but eviction fanout failed" + ); + } + if let Err(error) = self.state.publish_state_changed().await { + warn!(?error, id, "notification committed but state fanout failed"); + } - Ok(outcome.notification.id) + Ok(NotifyCompletion { + id, + suppressed: None, + }) } - async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { + pub(super) async fn publish_suppressed_close(&self, suppressed: SuppressedNotification) { + let key = NotificationKey { + id: suppressed.id, + generation: suppressed.generation, + }; + if let Err(error) = self + .state + .publish_notification_closed(key, unixnotis_core::CloseReason::Undefined) + .await + { + warn!( + ?error, + id = suppressed.id, + generation = suppressed.generation, + "suppressed notification close fanout failed" + ); + } + } + + async fn handle_evicted(&self, evicted: Vec) -> zbus::fdo::Result<()> { if evicted.is_empty() { // Fast path avoids context allocation when no eviction happened return Ok(()); } - self.state.cancel_expirations(&evicted); - - let notif_ctx = SignalContext::new(self.state.connection(), NOTIFICATIONS_OBJECT_PATH) - .map_err(to_fdo_error)?; - let control_ctx = SignalContext::new(self.state.connection(), CONTROL_OBJECT_PATH) - .map_err(to_fdo_error)?; + self.state + .publish_evicted_notifications(&evicted) + .await + .map_err(to_fdo_error) + } +} - for id in evicted { - // Emit both freedesktop and control close signals for consistent subscribers - Self::notification_closed(¬if_ctx, id, CloseReason::Undefined as u32) - .await - .map_err(to_fdo_error)?; - ControlServer::notification_closed(&control_ctx, id, CloseReason::Undefined) - .await - .map_err(to_fdo_error)?; +fn normalize_wire_image_for_role( + role: WireImageRole, + wire_image_data: Option, + materialized_content: Option, +) -> (Option, Option) { + match role { + WireImageRole::ConversationAvatar => { + // Communication artwork becomes a small sender visual before model storage + let sender_visual = wire_image_data + .and_then(|image| image.into_storage_image(MAX_STORED_AVATAR_DIMENSION)); + (materialized_content, sender_visual) + } + // Non-communication artwork uses the larger content-image storage bound + WireImageRole::ContentImage => { + let content_image = wire_image_data + .and_then(|image| image.into_storage_image(MAX_STORED_CONTENT_DIMENSION)) + .or(materialized_content); + (content_image, None) } - Ok(()) } } -fn sender_app_name_mismatch(app_name: &str, sender_executable: Option<&str>) -> bool { - sender_executable.is_some_and(|exe| !app_name_matches_sender(app_name, exe)) +async fn materialize_sender_visual_for_role( + role: SenderVisualRole, + attribution: &unixnotis_core::NotificationAttribution, + app_icon: String, +) -> Option { + if !super::super::ingress::payload::sender_visual_path_allowed(role, attribution) { + return None; + } + run_avatar_worker( + move || materialize_sender_visual(&app_icon, 64), + CONVERSATION_AVATAR_TIMEOUT, + ) + .await + .flatten() +} + +async fn materialize_content_visual( + attribution: &unixnotis_core::NotificationAttribution, + image_path: Option<&str>, +) -> Option { + if !may_materialize_content_image(attribution) { + return None; + } + let path = image_path + .filter(|path| !path.trim().is_empty()) + .map(str::to_owned)?; + run_avatar_worker( + move || materialize_sender_visual(&path, MAX_STORED_CONTENT_DIMENSION), + CONVERSATION_AVATAR_TIMEOUT, + ) + .await + .flatten() +} + +fn timed_out_sender_metadata() -> SenderMetadata { + // Timeout status prevents incomplete credentials from being treated as identity evidence + SenderMetadata { + status: SenderMetadataStatus::CredentialLookupTimedOut, + ..SenderMetadata::default() + } } #[cfg(test)] -#[path = "../tests/flow.rs"] +#[path = "tests/flow.rs"] mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs new file mode 100644 index 000000000..415489338 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/ingress.rs @@ -0,0 +1,150 @@ +//! Raw notification method guard applied before zbus deserializes owned payload fields + +use std::collections::HashMap; +use std::fmt::Write; + +use zbus::names::{InterfaceName, MemberName}; +use zbus::object_server::{DispatchResult, Interface, SignalContext}; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{Connection, Message, ObjectServer}; + +use super::notify_body::{preflight_notify, PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES}; +use super::reply_lifecycle::PostReplyKey; +use super::NotificationServer; + +/// Object-server adapter that rejects oversized Notify bodies before typed allocation +pub struct NotificationIngress { + inner: NotificationServer, +} + +impl NotificationIngress { + pub const fn new(inner: NotificationServer) -> Self { + Self { inner } + } +} + +#[zbus::export::async_trait::async_trait] +impl Interface for NotificationIngress { + fn name() -> InterfaceName<'static> { + ::name() + } + + fn spawn_tasks_for_methods(&self) -> bool { + self.inner.spawn_tasks_for_methods() + } + + async fn get(&self, property_name: &str) -> Option> { + self.inner.get(property_name).await + } + + async fn get_all(&self) -> zbus::fdo::Result> { + self.inner.get_all().await + } + + fn set<'call>( + &'call self, + property_name: &'call str, + value: &'call Value<'_>, + context: &'call SignalContext<'_>, + ) -> DispatchResult<'call> { + self.inner.set(property_name, value, context) + } + + async fn set_mut( + &mut self, + property_name: &str, + value: &Value<'_>, + context: &SignalContext<'_>, + ) -> Option> { + self.inner.set_mut(property_name, value, context).await + } + + fn call<'call>( + &'call self, + server: &'call ObjectServer, + connection: &'call Connection, + message: &'call Message, + name: MemberName<'call>, + ) -> DispatchResult<'call> { + if notify_has_unix_fds(name.as_str(), message.header().unix_fds()) { + // Notify has no descriptor-bearing fields, so attached descriptors are always invalid + return DispatchResult::new_async(connection, message, async { + Err::<(), _>(zbus::fdo::Error::InvalidArgs( + "Notify does not accept Unix file descriptors".to_string(), + )) + }); + } + if notify_body_is_oversized(name.as_str(), message.body().len()) { + // Construct the D-Bus error without asking the typed interface to decode the body + return DispatchResult::new_async(connection, message, async { + Err::<(), _>(zbus::fdo::Error::LimitsExceeded(format!( + "Notify body exceeds {MAX_NOTIFY_WIRE_BODY_BYTES} bytes" + ))) + }); + } + if name.as_bytes() == b"Notify" { + if let Err(error) = preflight_notify(message) { + // Structural limits are checked from borrowed bytes before owned argument decoding + return DispatchResult::new_async(connection, message, async move { + match error { + PreflightError::LimitsExceeded(reason) => { + Err::<(), _>(zbus::fdo::Error::LimitsExceeded(reason.to_string())) + } + PreflightError::Malformed(reason) => { + Err::<(), _>(zbus::fdo::Error::InvalidArgs(reason.to_string())) + } + } + }); + } + } + let is_notify = name.as_bytes() == b"Notify"; + let dispatch = self.inner.call(server, connection, message, name); + if !is_notify { + return dispatch; + } + + let request = PostReplyKey::from_header(&message.header()); + match dispatch { + DispatchResult::Async(future) => DispatchResult::Async(Box::pin(async move { + // The generated handler sends the method reply before this future completes + let reply_result = future.await; + let suppressed = self.inner.post_reply_lifecycle.take(&request).await; + if reply_result.is_ok() { + if let Some(suppressed) = suppressed { + // The signal now enters the connection after the successful reply + self.inner.publish_suppressed_close(suppressed).await; + } + } + reply_result + })), + other => other, + } + } + + fn call_mut<'call>( + &'call mut self, + server: &'call ObjectServer, + connection: &'call Connection, + message: &'call Message, + name: MemberName<'call>, + ) -> DispatchResult<'call> { + // NotificationServer currently has no mutable methods, but delegation preserves its API + self.inner.call_mut(server, connection, message, name) + } + + fn introspect_to_writer(&self, writer: &mut dyn Write, level: usize) { + self.inner.introspect_to_writer(writer, level); + } +} + +fn notify_body_is_oversized(member: &str, body_len: usize) -> bool { + member.as_bytes() == b"Notify" && body_len > MAX_NOTIFY_WIRE_BODY_BYTES +} + +fn notify_has_unix_fds(member: &str, unix_fds: Option) -> bool { + member.as_bytes() == b"Notify" && unix_fds.is_some_and(|count| count != 0) +} + +#[cfg(test)] +#[path = "tests/ingress.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs index eb9c8b73b..46b7f6710 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/interface.rs @@ -1,37 +1,62 @@ //! Notification D-Bus interface implementation -use std::collections::HashMap; use std::sync::Arc; +use std::time::Instant; +use tokio::sync::Semaphore; +use tracing::debug; use zbus::message::Header; -use zbus::zvariant::OwnedValue; use zbus::{interface, SignalContext}; use crate::expire::ExpirationScheduler; use super::capabilities::notification_capabilities; +use super::reply_lifecycle::{PostReplyKey, PostReplyLifecycle, RetainError}; +use super::wire_hints::WireHints; +use crate::daemon::notifications::ingress::metrics::{IngressMetrics, RejectedRequest}; +use crate::daemon::notifications::ingress::quota::NotificationQuota; use crate::daemon::DaemonState; +const MAX_CONCURRENT_NOTIFY_HANDLERS: usize = 8; + /// D-Bus server for org.freedesktop.Notifications pub struct NotificationServer { // Shared daemon state for store access, sounds, and signal emission pub(super) state: Arc, // Scheduler handles expiration deadlines without blocking D-Bus handlers pub(super) scheduler: ExpirationScheduler, + // Shared token buckets reject sustained sender and process-wide floods + pub(super) notify_quota: NotificationQuota, + // Close requests are cheaper but still trigger sender identity and store work + pub(super) close_quota: NotificationQuota, + // Expensive sender and payload work has a fixed concurrency ceiling + notify_slots: Semaphore, + // Counters expose pressure without retaining attacker-controlled labels + pub(super) ingress_metrics: IngressMetrics, + // DropAll lifecycle records wait here until the matching reply is sent + pub(super) post_reply_lifecycle: PostReplyLifecycle, } impl NotificationServer { - pub const fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { + pub fn new(state: Arc, scheduler: ExpirationScheduler) -> Self { // Keep constructor minimal and explicit - Self { state, scheduler } + Self { + state, + scheduler, + notify_quota: NotificationQuota::new_notify(), + close_quota: NotificationQuota::new_close(), + notify_slots: Semaphore::const_new(MAX_CONCURRENT_NOTIFY_HANDLERS), + ingress_metrics: IngressMetrics::new(), + post_reply_lifecycle: PostReplyLifecycle::default(), + } } } #[interface(name = "org.freedesktop.Notifications")] impl NotificationServer { pub(super) async fn get_capabilities(&self) -> Vec { - // Advertise sound support only when the configured backend can deliver it - notification_capabilities(self.state.sound.supports_sound()) + // Advertise sender sound support only when every promised hint is implemented + notification_capabilities(self.state.sound.supports_fdo_sound_capability()) } #[expect( @@ -46,23 +71,69 @@ impl NotificationServer { summary: String, body: String, actions: Vec, - hints: HashMap, + hints: WireHints, #[zbus(header)] header: Header<'_>, expire_timeout: i32, ) -> zbus::fdo::Result { + let _slot = self.notify_slots.try_acquire().map_err(|_error| { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyConcurrency); + debug!( + rejected, + "notification request rejected by concurrency limit" + ); + zbus::fdo::Error::LimitsExceeded( + "too many concurrent notification requests".to_string(), + ) + })?; + let _activity = self.ingress_metrics.enter_handler(); + let sender = self.resolve_sender(&header).await; + if !self + .notify_quota + .try_admit_notify(super::quota_principal(&sender), Instant::now()) + .is_allowed() + { + let rejected = self + .ingress_metrics + .record_rejection(RejectedRequest::NotifyQuota); + debug!( + rejected, + "notification request rejected by hierarchical quota" + ); + return Err(zbus::fdo::Error::LimitsExceeded( + "notification ingress quota exceeded".to_string(), + )); + } // The interface adapter forwards the authenticated header with the exact wire payload - self.ingest_notify( - app_name, - replaces_id, - app_icon, - summary, - body, - actions, - hints, - &header, - expire_timeout, - ) - .await + let completion = self + .ingest_notify_deferred( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + sender, + expire_timeout, + ) + .await?; + if let Some(suppressed) = completion.suppressed { + let request = PostReplyKey::from_header(&header); + self.post_reply_lifecycle + .retain(request, suppressed) + .await + .map_err(|error| match error { + RetainError::CapacityExceeded => zbus::fdo::Error::LimitsExceeded( + "notification lifecycle queue is full".to_string(), + ), + RetainError::DuplicateSerial => zbus::fdo::Error::Failed( + "notification lifecycle request collision".to_string(), + ), + })?; + } + Ok(completion.id) } pub(super) async fn close_notification( @@ -70,6 +141,7 @@ impl NotificationServer { id: u32, #[zbus(header)] header: Header<'_>, ) -> zbus::fdo::Result<()> { + let _activity = self.ingress_metrics.enter_handler(); // Ownership checks remain in the shared close path used by all D-Bus callers self.close_notification_if_owned(id, &header).await } @@ -99,4 +171,12 @@ impl NotificationServer { id: u32, action_key: &str, ) -> zbus::Result<()>; + + #[zbus(signal)] + // KDE-compatible senders receive the entered text through this extension signal + pub(crate) async fn notification_replied( + ctx: &SignalContext<'_>, + id: u32, + reply_text: &str, + ) -> zbus::Result<()>; } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs index 3d8e2a52c..1193220e3 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/mod.rs @@ -1,12 +1,33 @@ //! Freedesktop notification D-Bus server and request handling +mod avatar; mod capabilities; mod close; mod flow; +mod ingress; mod interface; +mod notify_body; +mod reply_lifecycle; +mod wire_hints; +pub use ingress::NotificationIngress; pub use interface::NotificationServer; +use super::identity::SenderMetadata; +use super::ingress::quota::QuotaPrincipal; + +fn quota_principal(sender: &SenderMetadata) -> Option { + Some(QuotaPrincipal::new( + sender.sender_uid?, + sender.sender_pid?, + sender.sender_start_time?, + )) +} + #[cfg(test)] #[path = "tests/interface.rs"] mod tests; + +#[cfg(test)] +#[path = "tests/quota_principal.rs"] +mod quota_principal_tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs new file mode 100644 index 000000000..e616b8c2a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/cursor.rs @@ -0,0 +1,179 @@ +//! Checked byte cursor with D-Bus alignment and primitive readers + +use zbus::zvariant::Endian; + +use super::limits::{PreflightError, StringBudget}; + +pub(super) struct Cursor<'a> { + bytes: &'a [u8], + absolute_start: usize, + endian: Endian, + offset: usize, +} + +impl<'a> Cursor<'a> { + pub(super) const fn new(bytes: &'a [u8], absolute_start: usize, endian: Endian) -> Self { + Self { + bytes, + absolute_start, + endian, + offset: 0, + } + } + + pub(super) const fn position(&self) -> usize { + self.offset + } + + pub(super) const fn is_finished(&self) -> bool { + self.offset == self.bytes.len() + } + + pub(super) fn align(&mut self, alignment: usize) -> Result<(), PreflightError> { + // D-Bus alignment is relative to the whole message rather than this body slice + let absolute = self + .absolute_start + .checked_add(self.offset) + .ok_or(PreflightError::Malformed("Notify alignment overflowed"))?; + let padding = (alignment - absolute % alignment) % alignment; + self.advance(padding) + } + + pub(super) fn advance(&mut self, bytes: usize) -> Result<(), PreflightError> { + // Checked offsets turn malformed lengths into errors instead of wraparound + let end = self + .offset + .checked_add(bytes) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify body is truncated")); + } + self.offset = end; + Ok(()) + } + + pub(super) fn read_fixed( + &mut self, + alignment: usize, + bytes: usize, + ) -> Result<(), PreflightError> { + self.align(alignment)?; + self.advance(bytes) + } + + pub(super) fn read_u8(&mut self) -> Result { + let value = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset += 1; + Ok(value) + } + + pub(super) fn read_u32(&mut self) -> Result { + self.align(4)?; + let end = self + .offset + .checked_add(4) + .ok_or(PreflightError::Malformed("Notify offset overflowed"))?; + let bytes = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify body is truncated"))?; + self.offset = end; + Ok(self.endian.read_u32(bytes)) + } + + pub(super) fn read_string( + &mut self, + limit: usize, + budget: &mut StringBudget, + ) -> Result<&'a [u8], PreflightError> { + // Length is rejected before a slice is exposed to later parsing + let length = usize::try_from(self.read_u32()?).map_err(|_conversion_error| { + PreflightError::LimitsExceeded("Notify string is too large") + })?; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit", + )); + } + budget.add(length)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify string offset overflowed"))?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify string is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify string is missing its terminator", + )); + } + Ok(value) + } + + pub(super) fn read_signature(&mut self) -> Result<&'a [u8], PreflightError> { + let length = usize::from(self.read_u8()?); + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed( + "Notify signature offset overflowed", + ))?; + let signature = self + .bytes + .get(self.offset..end) + .ok_or(PreflightError::Malformed("Notify signature is truncated"))?; + self.offset = end; + if self.read_u8()? != 0 { + return Err(PreflightError::Malformed( + "Notify signature is missing its terminator", + )); + } + Ok(signature) + } + + pub(super) fn begin_array( + &mut self, + element_alignment: usize, + ) -> Result { + // Array byte lengths are validated before any element walk begins + let length = usize::try_from(self.read_u32()?).map_err(|_conversion_error| { + PreflightError::LimitsExceeded("Notify array is too large") + })?; + self.align(element_alignment)?; + let end = self + .offset + .checked_add(length) + .ok_or(PreflightError::Malformed("Notify array offset overflowed"))?; + if end > self.bytes.len() { + return Err(PreflightError::Malformed("Notify array is truncated")); + } + Ok(end) + } + + pub(super) const fn finish_array(&self, end: usize) -> Result<(), PreflightError> { + if self.offset == end { + Ok(()) + } else { + Err(PreflightError::Malformed( + "Notify array elements do not match its byte length", + )) + } + } + + pub(super) fn remaining_to(&self, end: usize) -> Result { + end.checked_sub(self.offset) + .ok_or(PreflightError::Malformed( + "Notify array cursor passed its end", + )) + } + + pub(super) const fn finish_at(&mut self, end: usize) { + self.offset = end; + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs new file mode 100644 index 000000000..55979254f --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/limits.rs @@ -0,0 +1,43 @@ +//! Limits and errors shared by raw Notify body readers + +// Common native clients send decoded 1024x1024 RGBA application or contact images +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_IMAGE_BYTES: usize = + 4 * 1024 * 1024; +// Keep the wire geometry bound explicit even when a sparse row layout uses fewer bytes +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_IMAGE_DIMENSION: u32 = 1024; +// The image allowance plus bounded strings, actions, hints, and D-Bus alignment +pub(in crate::daemon::notifications::server) const MAX_NOTIFY_WIRE_BODY_BYTES: usize = + MAX_NOTIFY_WIRE_IMAGE_BYTES + 128 * 1024; +pub(super) const MAX_NON_IMAGE_ARRAY_BYTES: usize = 16 * 1024; +pub(super) const MAX_NON_IMAGE_STRING_BYTES: usize = 64 * 1024; +pub(super) const MAX_NESTED_CONTAINER_ELEMENTS: usize = 64; +pub(super) const MAX_SIGNATURE_DEPTH: usize = 16; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(in crate::daemon::notifications::server) enum PreflightError { + LimitsExceeded(&'static str), + Malformed(&'static str), +} + +#[derive(Default)] +pub(super) struct StringBudget { + bytes: usize, +} + +impl StringBudget { + pub(super) fn add(&mut self, bytes: usize) -> Result<(), PreflightError> { + // One cumulative budget prevents many valid strings from amplifying memory + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or(PreflightError::LimitsExceeded( + "Notify string budget overflowed", + ))?; + if self.bytes > MAX_NON_IMAGE_STRING_BYTES { + return Err(PreflightError::LimitsExceeded( + "Notify contains too much non-image string data", + )); + } + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs new file mode 100644 index 000000000..56c771a5f --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/mod.rs @@ -0,0 +1,16 @@ +//! Raw Notify body validation before typed D-Bus decoding + +mod cursor; +mod limits; +mod signature; +mod validator; +mod value; + +pub(super) use limits::{ + PreflightError, MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, + MAX_NOTIFY_WIRE_IMAGE_DIMENSION, +}; +pub(super) use validator::preflight_notify; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs new file mode 100644 index 000000000..0456f94ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/signature.rs @@ -0,0 +1,108 @@ +//! Bounded parser for variant-contained D-Bus signatures + +use super::limits::{PreflightError, MAX_NESTED_CONTAINER_ELEMENTS, MAX_SIGNATURE_DEPTH}; + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum SignatureType { + Basic(u8), + Variant, + Array(Box), + Structure(Vec), + DictEntry(Vec), +} + +impl SignatureType { + pub(super) const fn alignment(&self) -> usize { + match self { + Self::Basic(b'n' | b'q') => 2, + Self::Basic(b'b' | b'i' | b'u' | b'h' | b's' | b'o') | Self::Array(_) => 4, + Self::Basic(b'x' | b't' | b'd') | Self::Structure(_) | Self::DictEntry(_) => 8, + Self::Basic(_) | Self::Variant => 1, + } + } +} + +pub(super) struct SignatureParser<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> SignatureParser<'a> { + pub(super) fn one(bytes: &'a [u8]) -> Result { + // A variant signature must describe exactly one complete value + let mut parser = Self { bytes, offset: 0 }; + let value_type = parser.parse_type(0)?; + if parser.offset != bytes.len() { + return Err(PreflightError::Malformed( + "Notify variant signature has trailing types", + )); + } + Ok(value_type) + } + + fn parse_type(&mut self, depth: usize) -> Result { + // Parsing the tiny signature first makes the later byte walk deterministic + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep", + )); + } + let kind = *self + .bytes + .get(self.offset) + .ok_or(PreflightError::Malformed( + "Notify variant signature is empty", + ))?; + self.offset += 1; + match kind { + b'y' | b'b' | b'n' | b'q' | b'i' | b'u' | b'x' | b't' | b'd' | b's' | b'o' | b'g' + | b'h' => Ok(SignatureType::Basic(kind)), + b'v' => Ok(SignatureType::Variant), + b'a' => Ok(SignatureType::Array(Box::new(self.parse_type(depth + 1)?))), + b'(' => self.parse_fields(b')', depth).map(SignatureType::Structure), + b'{' => self.parse_fields(b'}', depth).and_then(|fields| { + if fields.len() == 2 { + Ok(SignatureType::DictEntry(fields)) + } else { + Err(PreflightError::Malformed( + "Notify dictionary entry has an invalid signature", + )) + } + }), + _ => Err(PreflightError::Malformed( + "Notify variant signature contains an invalid type", + )), + } + } + + fn parse_fields( + &mut self, + terminator: u8, + depth: usize, + ) -> Result, PreflightError> { + let mut fields = Vec::new(); + loop { + // Container signatures are bounded independently from data element counts + let Some(kind) = self.bytes.get(self.offset).copied() else { + return Err(PreflightError::Malformed( + "Notify container signature is unterminated", + )); + }; + if kind == terminator { + self.offset += 1; + if fields.is_empty() { + return Err(PreflightError::Malformed( + "Notify container signature is empty", + )); + } + return Ok(fields); + } + if fields.len() >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify container signature has too many fields", + )); + } + fields.push(self.parse_type(depth + 1)?); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs new file mode 100644 index 000000000..f5e1f0d25 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/actions.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; + +use super::super::{preflight_notify, PreflightError}; +use super::support::notify_message; +use crate::daemon::notifications::server::notify_body::MAX_NOTIFY_WIRE_BODY_BYTES; + +#[test] +fn under_wire_limit_tiny_action_flood_is_rejected() { + let actions = (0..20_000).map(|_| "a".to_string()).collect(); + let message = notify_message("app", "", "summary", "", actions, HashMap::new()); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn action_array_accepts_eight_pairs_and_rejects_the_next_element() { + let exact = vec!["a".to_string(); 16]; + let exact_message = notify_message("app", "", "", "", exact, HashMap::new()); + assert_eq!(preflight_notify(&exact_message), Ok(())); + + let over = vec!["a".to_string(); 17]; + let over_message = notify_message("app", "", "", "", over, HashMap::new()); + assert_eq!( + preflight_notify(&over_message), + Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements" + )) + ); +} + +#[test] +fn action_key_and_label_keep_independent_field_limits() { + let oversized_key = + "k".repeat(crate::daemon::notifications::ingress::limits::MAX_ACTION_KEY_BYTES + 1); + let key_message = notify_message( + "app", + "", + "", + "", + vec![oversized_key, "label".to_string()], + HashMap::new(), + ); + assert_eq!( + preflight_notify(&key_message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); + + let oversized_label = + "l".repeat(crate::daemon::notifications::ingress::limits::MAX_ACTION_LABEL_BYTES + 1); + let label_message = notify_message( + "app", + "", + "", + "", + vec!["key".to_string(), oversized_label], + HashMap::new(), + ); + assert_eq!( + preflight_notify(&label_message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs new file mode 100644 index 000000000..75d3538ec --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/body.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, Value}; +use zbus::Message; + +use super::super::{preflight_notify, PreflightError}; +use super::support::notify_message; + +#[test] +fn ordinary_notify_body_passes_structural_preflight() { + let message = notify_message( + "Example", + "example", + "Summary", + "Body", + vec!["default".to_string(), "Open".to_string()], + HashMap::new(), + ); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn notify_method_with_the_wrong_body_signature_is_rejected() { + let message = Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&("only-one-field",)) + .expect("wrong-signature message"); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::Malformed("Notify has an invalid signature")) + ); +} + +#[test] +fn field_string_limit_is_enforced_before_owned_string_creation() { + let summary = "s".repeat(crate::daemon::notifications::ingress::limits::MAX_SUMMARY_BYTES + 1); + let message = notify_message("app", "", &summary, "", Vec::new(), HashMap::new()); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify string exceeds its field limit" + )) + ); +} + +#[test] +fn cumulative_string_budget_accepts_its_exact_limit() { + let hints = (0..16) + .map(|index| { + // Hint keys consume 38 bytes, so one shorter value keeps the total at 64 KiB + let first_length = if index == 0 { 2_010 } else { 2_048 }; + let values = Value::from(vec!["h".repeat(first_length), "h".repeat(2_048)]); + ( + format!("h{index}"), + OwnedValue::try_from(values).expect("owned exact-budget strings"), + ) + }) + .collect(); + let message = notify_message("", "", "", "", Vec::new(), hints); + + assert_eq!(preflight_notify(&message), Ok(())); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs new file mode 100644 index 000000000..18e164836 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/cursor.rs @@ -0,0 +1,95 @@ +use zbus::zvariant::Endian; + +use super::super::cursor::Cursor; +use super::super::limits::StringBudget; +use super::super::PreflightError; + +#[test] +fn cursor_rejects_fixed_reads_past_the_body() { + let mut cursor = Cursor::new(&[0_u8; 3], 0, Endian::Little); + + assert_eq!( + cursor.read_fixed(4, 4), + Err(PreflightError::Malformed("Notify body is truncated")) + ); +} + +#[test] +fn cursor_rejects_string_without_nul_terminator() { + let bytes = [1_u8, 0, 0, 0, b'x', b'!']; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + + assert_eq!( + cursor.read_string(8, &mut budget), + Err(PreflightError::Malformed( + "Notify string is missing its terminator" + )) + ); +} + +#[test] +fn cursor_rejects_truncated_signature_and_bad_terminator() { + let truncated = [2_u8, b'a']; + let mut truncated_cursor = Cursor::new(&truncated, 0, Endian::Little); + assert_eq!( + truncated_cursor.read_signature(), + Err(PreflightError::Malformed("Notify signature is truncated")) + ); + + let bad_terminator = [1_u8, b's', b'!']; + let mut terminator_cursor = Cursor::new(&bad_terminator, 0, Endian::Little); + assert_eq!( + terminator_cursor.read_signature(), + Err(PreflightError::Malformed( + "Notify signature is missing its terminator" + )) + ); +} + +#[test] +fn cursor_reads_big_endian_u32_after_absolute_alignment() { + let bytes = [0_u8, 0, 0, 0x01, 0x02, 0x03, 0x04]; + let mut cursor = Cursor::new(&bytes, 1, Endian::Big); + + assert_eq!(cursor.read_u32(), Ok(0x0102_0304)); + assert_eq!(cursor.position(), 7); +} + +#[test] +fn cursor_rejects_array_length_beyond_remaining_bytes() { + let bytes = [8_u8, 0, 0, 0, 1, 2, 3, 4]; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + + assert_eq!( + cursor.begin_array(4), + Err(PreflightError::Malformed("Notify array is truncated")) + ); +} + +#[test] +fn cursor_reports_completion_only_after_consuming_every_byte() { + let mut cursor = Cursor::new(&[7_u8], 0, Endian::Little); + + assert!(!cursor.is_finished()); + assert_eq!(cursor.advance(1), Ok(())); + assert!(cursor.is_finished()); +} + +#[test] +fn array_cursor_accepts_an_exact_body_and_rejects_element_mismatch() { + let bytes = [4_u8, 0, 0, 0, 1, 2, 3, 4]; + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + + let end = cursor.begin_array(4).expect("exact array body"); + assert_eq!(end, bytes.len()); + assert_eq!( + cursor.finish_array(end), + Err(PreflightError::Malformed( + "Notify array elements do not match its byte length" + )) + ); + + cursor.advance(4).expect("consume array body"); + assert_eq!(cursor.finish_array(end), Ok(())); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs new file mode 100644 index 000000000..5be493c81 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/hints.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, SerializeValue, Value}; +use zbus::Message; + +use super::super::{preflight_notify, PreflightError}; +use super::support::notify_message; +use crate::daemon::notifications::server::notify_body::MAX_NOTIFY_WIRE_BODY_BYTES; + +#[test] +fn hint_entry_flood_is_rejected_before_map_allocation() { + let hints = (0..17) + .map(|index| (format!("hint-{index}"), OwnedValue::from(index as u32))) + .collect(); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify hint dictionary has too many entries" + )) + ); +} + +#[test] +fn contiguous_image_array_keeps_its_separate_large_allowance() { + let message = notify_message_with_image(1024 * 1024); + + assert_eq!(preflight_notify(&message), Ok(())); +} + +#[test] +fn image_array_above_native_allowance_is_rejected_below_the_wire_limit() { + let message = notify_message_with_image(4 * 1024 * 1024 + 1); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance" + )) + ); +} + +fn notify_message_with_image(image_bytes: usize) -> Message { + let image = ( + 256_i32, + 256_i32, + 1024_i32, + true, + 8_i32, + 4_i32, + vec![0_u8; image_bytes], + ); + let hints = HashMap::from([("image-data", SerializeValue(&image))]); + + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&( + "app", + 0_u32, + "", + "summary", + "", + Vec::::new(), + hints, + 0_i32, + )) + .expect("Notify message") +} + +#[test] +fn non_image_byte_array_does_not_inherit_the_image_allowance() { + let mut hints = HashMap::new(); + hints.insert( + "x-example-bytes".to_string(), + OwnedValue::try_from(Value::from(vec![0_u8; 16 * 1024 + 1])).expect("owned byte hint"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance" + )) + ); +} + +#[test] +fn cumulative_nested_string_data_is_bounded() { + let text = "h".repeat(crate::daemon::notifications::ingress::limits::MAX_HINT_STRING_BYTES); + let hints = (0..16) + .map(|index| { + let values = Value::from(vec![text.as_str(); 4]); + ( + format!("hint-{index}"), + OwnedValue::try_from(values).expect("owned nested strings"), + ) + }) + .collect(); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert!(message.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify contains too much non-image string data" + )) + ); +} + +#[test] +fn nested_non_image_array_fanout_is_bounded() { + let nested = Value::from(vec!["x"; 65]); + let mut hints = HashMap::new(); + hints.insert( + "x-example-values".to_string(), + OwnedValue::try_from(nested).expect("owned nested string array"), + ); + let message = notify_message("app", "", "summary", "", Vec::new(), hints); + + assert_eq!( + preflight_notify(&message), + Err(PreflightError::LimitsExceeded( + "Notify nested array has too many elements" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs new file mode 100644 index 000000000..41cd89bb8 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/limits.rs @@ -0,0 +1,16 @@ +use super::super::limits::{ + MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, MAX_NON_IMAGE_STRING_BYTES, + MAX_NOTIFY_WIRE_BODY_BYTES, MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_NOTIFY_WIRE_IMAGE_DIMENSION, + MAX_SIGNATURE_DEPTH, +}; + +#[test] +fn raw_body_limits_keep_the_reviewed_byte_and_depth_boundaries() { + assert_eq!(MAX_NOTIFY_WIRE_IMAGE_BYTES, 4_194_304); + assert_eq!(MAX_NOTIFY_WIRE_IMAGE_DIMENSION, 1024); + assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 4_325_376); + assert_eq!(MAX_NON_IMAGE_ARRAY_BYTES, 16_384); + assert_eq!(MAX_NON_IMAGE_STRING_BYTES, 65_536); + assert_eq!(MAX_NESTED_CONTAINER_ELEMENTS, 64); + assert_eq!(MAX_SIGNATURE_DEPTH, 16); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs new file mode 100644 index 000000000..7a77f0d05 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/mod.rs @@ -0,0 +1,10 @@ +//! Raw Notify body regression coverage split by structural responsibility + +mod actions; +mod body; +mod cursor; +mod hints; +mod limits; +mod signature; +mod support; +mod value; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs new file mode 100644 index 000000000..3803940a3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/signature.rs @@ -0,0 +1,107 @@ +use super::super::limits::{PreflightError, MAX_SIGNATURE_DEPTH}; +use super::super::signature::{SignatureParser, SignatureType}; + +#[test] +fn signature_parser_accepts_one_nested_dictionary_array() { + assert_eq!( + SignatureParser::one(b"a{sv}"), + Ok(SignatureType::Array(Box::new(SignatureType::DictEntry( + vec![SignatureType::Basic(b's'), SignatureType::Variant] + )))) + ); +} + +#[test] +fn signature_parser_rejects_empty_trailing_and_unknown_types() { + assert_eq!( + SignatureParser::one(b""), + Err(PreflightError::Malformed( + "Notify variant signature is empty" + )) + ); + assert_eq!( + SignatureParser::one(b"ss"), + Err(PreflightError::Malformed( + "Notify variant signature has trailing types" + )) + ); + assert_eq!( + SignatureParser::one(b"z"), + Err(PreflightError::Malformed( + "Notify variant signature contains an invalid type" + )) + ); +} + +#[test] +fn signature_parser_rejects_empty_unterminated_and_invalid_containers() { + assert_eq!( + SignatureParser::one(b"()"), + Err(PreflightError::Malformed( + "Notify container signature is empty" + )) + ); + assert_eq!( + SignatureParser::one(b"(s"), + Err(PreflightError::Malformed( + "Notify container signature is unterminated" + )) + ); + assert_eq!( + SignatureParser::one(b"{s}"), + Err(PreflightError::Malformed( + "Notify dictionary entry has an invalid signature" + )) + ); +} + +#[test] +fn signature_parser_rejects_nesting_beyond_the_depth_limit() { + let mut signature = vec![b'a'; MAX_SIGNATURE_DEPTH + 2]; + signature.push(b'y'); + + assert_eq!( + SignatureParser::one(&signature), + Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep" + )) + ); +} + +#[test] +fn signature_parser_accepts_the_exact_depth_limit() { + let mut signature = vec![b'a'; MAX_SIGNATURE_DEPTH]; + signature.push(b'y'); + + assert!(SignatureParser::one(&signature).is_ok()); +} + +#[test] +fn signature_alignment_matches_each_dbus_wire_class() { + assert_eq!(SignatureType::Basic(b'y').alignment(), 1); + assert_eq!(SignatureType::Basic(b'n').alignment(), 2); + assert_eq!(SignatureType::Basic(b'u').alignment(), 4); + assert_eq!(SignatureType::Basic(b'x').alignment(), 8); + assert_eq!( + SignatureType::Array(Box::new(SignatureType::Basic(b'y'))).alignment(), + 4 + ); + assert_eq!( + SignatureType::Structure(vec![SignatureType::Basic(b'y')]).alignment(), + 8 + ); +} + +#[test] +fn nested_structure_signatures_enforce_the_depth_limit() { + let mut signature = vec![b'('; MAX_SIGNATURE_DEPTH + 2]; + signature.push(b'y'); + signature.extend(std::iter::repeat_n(b')', MAX_SIGNATURE_DEPTH + 2)); + + assert_eq!( + SignatureParser::one(&signature), + Err(PreflightError::LimitsExceeded( + "Notify variant signature is too deep" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs new file mode 100644 index 000000000..076d9ecc6 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/support.rs @@ -0,0 +1,24 @@ +//! Shared raw Notify message fixture + +use std::collections::HashMap; + +use zbus::zvariant::OwnedValue; +use zbus::Message; + +pub(super) fn notify_message( + app_name: &str, + app_icon: &str, + summary: &str, + body: &str, + actions: Vec, + hints: HashMap, +) -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .build(&( + app_name, 0_u32, app_icon, summary, body, actions, hints, 0_i32, + )) + .expect("Notify message") +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs new file mode 100644 index 000000000..ce21b7dd9 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/tests/value.rs @@ -0,0 +1,116 @@ +use zbus::zvariant::Endian; + +use super::super::cursor::Cursor; +use super::super::limits::{PreflightError, StringBudget, MAX_SIGNATURE_DEPTH}; +use super::super::signature::SignatureType; + +#[test] +fn primitive_value_skips_consume_their_complete_wire_payloads() { + let cases: &[(SignatureType, &[u8])] = &[ + (SignatureType::Basic(b'y'), &[7]), + (SignatureType::Basic(b'n'), &[1, 0]), + (SignatureType::Basic(b'q'), &[1, 0]), + (SignatureType::Basic(b'x'), &[0; 8]), + (SignatureType::Basic(b't'), &[0; 8]), + (SignatureType::Basic(b'd'), &[0; 8]), + (SignatureType::Basic(b'g'), &[1, b's', 0]), + ]; + + for (value_type, bytes) in cases { + let mut cursor = Cursor::new(bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + + assert_eq!(cursor.skip_value(value_type, &mut budget, false, 0), Ok(())); + assert!(cursor.is_finished(), "wire type was not fully consumed"); + } +} + +#[test] +fn value_skip_accepts_exact_depth_and_rejects_the_next_level() { + let value_type = SignatureType::Basic(b'y'); + let mut accepted = Cursor::new(&[1], 0, Endian::Little); + let mut accepted_budget = StringBudget::default(); + assert_eq!( + accepted.skip_value( + &value_type, + &mut accepted_budget, + false, + MAX_SIGNATURE_DEPTH + ), + Ok(()) + ); + + let mut rejected = Cursor::new(&[1], 0, Endian::Little); + let mut rejected_budget = StringBudget::default(); + assert_eq!( + rejected.skip_value( + &value_type, + &mut rejected_budget, + false, + MAX_SIGNATURE_DEPTH + 1 + ), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_variants_enforce_the_value_depth_limit() { + let mut bytes = Vec::new(); + for _ in 0..=MAX_SIGNATURE_DEPTH { + bytes.extend_from_slice(&[1, b'v', 0]); + } + bytes.extend_from_slice(&[1, b'y', 0, 7]); + + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&SignatureType::Variant, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_arrays_enforce_the_value_depth_limit() { + let mut value_type = SignatureType::Basic(b'y'); + let mut bytes = vec![7_u8]; + // The innermost byte array is consumed in place, so one extra array reaches the guard + for _ in 0..=MAX_SIGNATURE_DEPTH + 1 { + let mut container = u32::try_from(bytes.len()) + .expect("nested array length") + .to_le_bytes() + .to_vec(); + container.extend(bytes); + bytes = container; + value_type = SignatureType::Array(Box::new(value_type)); + } + + let mut cursor = Cursor::new(&bytes, 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&value_type, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} + +#[test] +fn nested_structures_enforce_the_value_depth_limit() { + let mut value_type = SignatureType::Basic(b'y'); + for _ in 0..=MAX_SIGNATURE_DEPTH { + value_type = SignatureType::Structure(vec![value_type]); + } + + let mut cursor = Cursor::new(&[7], 0, Endian::Little); + let mut budget = StringBudget::default(); + assert_eq!( + cursor.skip_value(&value_type, &mut budget, false, 0), + Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep" + )) + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs new file mode 100644 index 000000000..1e18a8e71 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/validator.rs @@ -0,0 +1,97 @@ +//! Validation flow for the fixed Notify D-Bus body shape + +use zbus::Message; + +use super::cursor::Cursor; +use super::limits::{PreflightError, StringBudget}; +use super::signature::SignatureParser; +use crate::daemon::notifications::ingress::limits::{ + MAX_ACTIONS, MAX_ACTION_KEY_BYTES, MAX_ACTION_LABEL_BYTES, MAX_APP_ICON_BYTES, + MAX_APP_NAME_BYTES, MAX_BODY_BYTES, MAX_HINT_ENTRIES, MAX_HINT_KEY_BYTES, MAX_SUMMARY_BYTES, +}; + +const NOTIFY_SIGNATURE: &str = "susssasa{sv}i"; + +pub(in crate::daemon::notifications::server) fn preflight_notify( + message: &Message, +) -> Result<(), PreflightError> { + let body = message.body(); + // The wire shape is checked before the typed interface creates owned containers + if body + .signature() + .as_ref() + .map(ToString::to_string) + .as_deref() + != Some(NOTIFY_SIGNATURE) + { + return Err(PreflightError::Malformed("Notify has an invalid signature")); + } + + let data = body.data(); + let context = data.context(); + let mut cursor = Cursor::new(data.bytes(), context.position(), context.endian()); + let mut budget = StringBudget::default(); + + // Fields follow the exact org.freedesktop.Notifications Notify order + cursor.read_string(MAX_APP_NAME_BYTES, &mut budget)?; + cursor.read_fixed(4, 4)?; + cursor.read_string(MAX_APP_ICON_BYTES, &mut budget)?; + cursor.read_string(MAX_SUMMARY_BYTES, &mut budget)?; + cursor.read_string(MAX_BODY_BYTES, &mut budget)?; + preflight_actions(&mut cursor, &mut budget)?; + preflight_hints(&mut cursor, &mut budget)?; + cursor.read_fixed(4, 4)?; + if !cursor.is_finished() { + return Err(PreflightError::Malformed("Notify body has trailing data")); + } + Ok(()) +} + +fn preflight_actions( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(4)?; + let mut count = 0_usize; + while cursor.position() < end { + // Actions alternate key and label, with eight complete pairs allowed + if count >= MAX_ACTIONS * 2 { + return Err(PreflightError::LimitsExceeded( + "Notify action array has too many elements", + )); + } + let limit = if count.is_multiple_of(2) { + MAX_ACTION_KEY_BYTES + } else { + MAX_ACTION_LABEL_BYTES + }; + cursor.read_string(limit, budget)?; + count += 1; + } + cursor.finish_array(end) +} + +fn preflight_hints( + cursor: &mut Cursor<'_>, + budget: &mut StringBudget, +) -> Result<(), PreflightError> { + let end = cursor.begin_array(8)?; + let mut count = 0_usize; + while cursor.position() < end { + // Entry count is bounded before zbus can construct the owned map + if count >= MAX_HINT_ENTRIES { + return Err(PreflightError::LimitsExceeded( + "Notify hint dictionary has too many entries", + )); + } + cursor.align(8)?; + let key = cursor.read_string(MAX_HINT_KEY_BYTES, budget)?; + // Only standard image aliases receive the larger byte-array allowance + let image_hint = matches!(key, b"image-data" | b"image_data" | b"icon_data"); + let signature = cursor.read_signature()?; + let value_type = SignatureParser::one(signature)?; + cursor.skip_value(&value_type, budget, image_hint, 0)?; + count += 1; + } + cursor.finish_array(end) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs new file mode 100644 index 000000000..72a47b161 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/notify_body/value.rs @@ -0,0 +1,87 @@ +//! Recursive variant-value traversal without owned payload construction + +use crate::daemon::notifications::ingress::limits::MAX_HINT_STRING_BYTES; + +use super::cursor::Cursor; +use super::limits::{ + PreflightError, StringBudget, MAX_NESTED_CONTAINER_ELEMENTS, MAX_NON_IMAGE_ARRAY_BYTES, + MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_SIGNATURE_DEPTH, +}; +use super::signature::{SignatureParser, SignatureType}; + +impl Cursor<'_> { + pub(super) fn skip_value( + &mut self, + value_type: &SignatureType, + budget: &mut StringBudget, + image_hint: bool, + depth: usize, + ) -> Result<(), PreflightError> { + // Recursive variants and containers share one small depth limit + if depth > MAX_SIGNATURE_DEPTH { + return Err(PreflightError::LimitsExceeded( + "Notify variant nesting is too deep", + )); + } + match value_type { + SignatureType::Basic(kind) => match kind { + b'y' => self.advance(1), + b'n' | b'q' => self.read_fixed(2, 2), + b'b' | b'i' | b'u' | b'h' => self.read_fixed(4, 4), + b'x' | b't' | b'd' => self.read_fixed(8, 8), + b's' | b'o' => self.read_string(MAX_HINT_STRING_BYTES, budget).map(drop), + b'g' => { + let signature = self.read_signature()?; + budget.add(signature.len()) + } + _ => Err(PreflightError::Malformed( + "Notify variant has an unsupported basic type", + )), + }, + SignatureType::Variant => { + let signature = self.read_signature()?; + let nested = SignatureParser::one(signature)?; + self.skip_value(&nested, budget, image_hint, depth + 1) + } + SignatureType::Array(element) => { + let end = self.begin_array(element.alignment())?; + if matches!(element.as_ref(), SignatureType::Basic(b'y')) { + // Raw bytes are skipped in place without constructing a vector + let length = self.remaining_to(end)?; + let limit = if image_hint { + MAX_NOTIFY_WIRE_IMAGE_BYTES + } else { + MAX_NON_IMAGE_ARRAY_BYTES + }; + if length > limit { + return Err(PreflightError::LimitsExceeded( + "Notify byte array exceeds its allowance", + )); + } + self.finish_at(end); + return Ok(()); + } + + let mut count = 0_usize; + while self.position() < end { + // Non-byte arrays receive an element cap as well as the wire-byte cap + if count >= MAX_NESTED_CONTAINER_ELEMENTS { + return Err(PreflightError::LimitsExceeded( + "Notify nested array has too many elements", + )); + } + self.skip_value(element, budget, image_hint, depth + 1)?; + count += 1; + } + self.finish_array(end) + } + SignatureType::Structure(fields) | SignatureType::DictEntry(fields) => { + self.align(8)?; + for field in fields { + self.skip_value(field, budget, image_hint, depth + 1)?; + } + Ok(()) + } + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs new file mode 100644 index 000000000..b213a9d0c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/reply_lifecycle.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; +use std::num::NonZeroU32; + +use tokio::sync::Mutex; +use zbus::message::Header; + +use crate::store::SuppressedNotification; + +const MAX_PENDING_SUPPRESSED_CLOSES: usize = 128; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RetainError { + CapacityExceeded, + DuplicateSerial, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) struct PostReplyKey { + sender: Option, + serial: NonZeroU32, +} + +impl PostReplyKey { + pub(super) fn from_header(header: &Header<'_>) -> Self { + Self { + // The bus name is transport correlation only and grants no ownership + sender: header.sender().map(ToString::to_string), + serial: header.primary().serial_num(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct NotifyCompletion { + pub(super) id: u32, + pub(super) suppressed: Option, +} + +/// Content-free lifecycle work held until its method reply crosses D-Bus +#[derive(Default)] +pub(super) struct PostReplyLifecycle { + // Sender and serial together identify one in-flight method reply + pending: Mutex>, +} + +impl PostReplyLifecycle { + pub(super) async fn retain( + &self, + request: PostReplyKey, + suppressed: SuppressedNotification, + ) -> Result<(), RetainError> { + let mut pending = self.pending.lock().await; + // An in-flight request must never identify two returned IDs + if pending.contains_key(&request) { + return Err(RetainError::DuplicateSerial); + } + // A stalled transport cannot grow deferred lifecycle memory without bound + if pending.len() >= MAX_PENDING_SUPPRESSED_CLOSES { + return Err(RetainError::CapacityExceeded); + } + pending.insert(request, suppressed); + Ok(()) + } + + pub(super) async fn take(&self, request: &PostReplyKey) -> Option { + self.pending.lock().await.remove(request) + } +} + +#[cfg(test)] +#[path = "tests/reply_lifecycle.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs new file mode 100644 index 000000000..e9fe135f3 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/avatar.rs @@ -0,0 +1,48 @@ +//! Tests for bounded conversation-avatar work + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::Duration; + +use tokio::sync::Semaphore; + +use super::super::avatar::{run_avatar_worker, run_avatar_worker_with_pool}; + +#[tokio::test] +async fn public_avatar_worker_runs_a_completed_job() { + assert_eq!( + run_avatar_worker(|| 7_u8, Duration::from_secs(1)).await, + Some(7) + ); +} + +#[tokio::test] +async fn avatar_worker_capacity_fails_closed_without_queueing() { + let pool = Arc::new(Semaphore::new(1)); + let release = Arc::new(AtomicBool::new(false)); + let held_release = Arc::clone(&release); + + let first = run_avatar_worker_with_pool( + Arc::clone(&pool), + move || { + while !held_release.load(Ordering::Acquire) { + std::thread::yield_now(); + } + 1_u8 + }, + Duration::from_millis(10), + ); + assert_eq!(first.await, None); + + let second = + run_avatar_worker_with_pool(Arc::clone(&pool), || 2_u8, Duration::from_millis(10)).await; + assert_eq!(second, None); + + release.store(true, Ordering::Release); + tokio::time::sleep(Duration::from_millis(20)).await; + + let recovered = run_avatar_worker_with_pool(pool, || 3_u8, Duration::from_millis(100)).await; + assert_eq!(recovered, Some(3)); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs similarity index 64% rename from crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs rename to crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs index b762cb239..6303b50e4 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/capabilities.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/capabilities.rs @@ -1,10 +1,12 @@ +//! Notification server capability tests + use super::notification_capabilities; #[test] fn notification_capabilities_without_sound_keeps_static_contract() { let caps = notification_capabilities(false); - assert_eq!(caps, ["actions", "body", "body-markup", "icon-static"]); + assert_eq!(caps, ["actions", "inline-reply", "body", "icon-static"]); } #[test] @@ -13,6 +15,6 @@ fn notification_capabilities_adds_sound_only_when_backend_supports_it() { assert_eq!( caps, - ["actions", "body", "body-markup", "icon-static", "sound"] + ["actions", "inline-reply", "body", "icon-static", "sound"] ); } diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs new file mode 100644 index 000000000..8751e4557 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/flow.rs @@ -0,0 +1,643 @@ +//! Notification server flow tests + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use futures_util::TryStreamExt; +use tracing::Level; +use tracing_subscriber::filter::LevelFilter; +use unixnotis_core::{ + CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, +}; +use zbus::message::{Header, Type}; +use zbus::zvariant::{OwnedValue, Value}; +use zbus::{Connection, MatchRule, Message, MessageStream}; + +use crate::daemon::notifications::identity::{SenderMetadata, SenderMetadataStatus}; +use crate::daemon::notifications::ingress::payload::{ + build_notification, NotificationInput, SenderVisualRole, WireImageRole, +}; +use crate::daemon::{DaemonState, NotificationServer}; +use crate::expire::ExpirationScheduler; +use crate::sound::SoundSettings; +use crate::store::{ + CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, +}; +use crate::test_support::daemon_state_for_test; + +impl NotificationServer { + #[expect( + clippy::too_many_arguments, + reason = "the freedesktop notification method defines this wire-level argument list" + )] + async fn ingest_notify( + &self, + app_name: String, + replaces_id: u32, + app_icon: String, + summary: String, + body: String, + actions: Vec, + hints: super::super::wire_hints::WireHints, + header: &Header<'_>, + expire_timeout: i32, + ) -> zbus::fdo::Result { + let sender = self.resolve_sender(header).await; + let completion = self + .ingest_notify_deferred( + app_name, + replaces_id, + app_icon, + summary, + body, + actions, + hints, + sender, + expire_timeout, + ) + .await?; + if let Some(suppressed) = completion.suppressed { + self.publish_suppressed_close(suppressed).await; + } + Ok(completion.id) + } +} + +#[test] +fn timed_out_sender_metadata_remains_explicitly_untrusted() { + assert_eq!( + super::timed_out_sender_metadata().status, + SenderMetadataStatus::CredentialLookupTimedOut + ); +} + +fn notification_with_id(id: u32) -> Arc { + Arc::new(Notification { + id, + generation: 1, + app_name: "app".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: "summary".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + hints: HashMap::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: -1, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(42), + sender_start_time: Some(77), + sender_executable: Some("/usr/bin/test-app".to_string()), + }) +} + +fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { + let disposition = if dropped { + CommitDisposition::SuppressedDropAll(SuppressedNotification { + id, + generation: 1, + owner: Some(StableProcessIdentity { + pid: 42, + start_time: 77, + }), + }) + } else { + CommitDisposition::Active(notification_with_id(id)) + }; + InsertOutcome { + disposition, + replaced: false, + popup_admission: if dropped { + PopupAdmission::Suppressed(PopupSuppressionReason::DropAllInhibitor) + } else { + PopupAdmission::Show + }, + allow_sound: !dropped, + evicted: Vec::new(), + expiration: None, + } +} + +fn notify_header_message() -> Message { + Message::method("/org/freedesktop/Notifications", "Notify") + .expect("method builder") + .interface("org.freedesktop.Notifications") + .expect("interface") + .sender(":1.42") + .expect("sender") + .build(&()) + .expect("message") +} + +async fn daemon_state_with_config(config: Config) -> Arc { + use arc_swap::ArcSwap; + + use crate::daemon::DesktopIdentityIndex; + + let connection = Connection::session().await.expect("session bus"); + let sound = SoundSettings::from_config(&config, None); + let store = NotificationStore::new_with_state_store(config, None); + DaemonState::new_with_store( + connection, + store, + sound, + false, + Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + None, + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn notification_server_sound_dispatch_reports_accepted_and_blocked_outcomes() { + use std::os::unix::fs::PermissionsExt; + + use crate::system_tools::routing::use_fake_tool_bin; + use crate::test_support::TempRoot; + + let root = TempRoot::new("notification-flow-sound"); + let player = root.join("canberra-gtk-play"); + std::fs::write(&player, "#!/bin/sh\nexit 0\n").expect("write fake sound player"); + let mut permissions = std::fs::metadata(&player) + .expect("fake sound player metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(player, permissions).expect("make fake sound player executable"); + let _tools = use_fake_tool_bin(root.path()); + let mut config = Config::default(); + config.sound.enabled = true; + config.sound.default_name = Some("message-new".to_string()); + let state = daemon_state_with_config(config).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state, scheduler); + let notification = notification_with_id(9); + + assert!(server.play_sound(¬ification, true)); + assert!(!server.play_sound(¬ification, false)); +} + +async fn control_signal_stream(state: &DaemonState, member: &str) -> MessageStream { + let receiver = Connection::session().await.expect("receiver session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection has unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("sender") + .path(CONTROL_OBJECT_PATH) + .expect("path") + .interface("com.unixnotis.Control") + .expect("interface") + .member(member) + .expect("member") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("signal stream") +} + +async fn next_signal(stream: &mut MessageStream) -> Message { + tokio::time::timeout(Duration::from_millis(500), stream.try_next()) + .await + .expect("signal should arrive before timeout") + .expect("signal stream should stay open") + .expect("signal message") +} + +#[test] +fn suppressed_notification_returns_content_free_lifecycle_for_dropped_payload() { + let outcome = insert_outcome(9, true); + + let suppressed = NotificationServer::suppressed_notification(&outcome) + .expect("DropAll outcome should retain lifecycle identity"); + + assert_eq!(suppressed.id, 9); + assert_eq!(suppressed.generation, 1); + assert!(matches!( + outcome.disposition, + CommitDisposition::SuppressedDropAll(_) + )); +} + +#[test] +fn suppressed_notification_returns_none_for_stored_payload() { + let outcome = insert_outcome(9, false); + + let suppressed = NotificationServer::suppressed_notification(&outcome); + + assert_eq!(suppressed, None); +} + +#[test] +fn log_received_notification_reports_false_when_debug_is_disabled() { + let subscriber = tracing_subscriber::fmt() + .with_max_level(LevelFilter::INFO) + .finish(); + + let logged = tracing::subscriber::with_default(subscriber, || { + NotificationServer::log_received_notification("app", "summary", "body", 0, 100) + }); + + assert!(!logged); +} + +#[test] +fn log_received_notification_reports_true_when_debug_is_enabled() { + let subscriber = tracing_subscriber::fmt() + .with_max_level(Level::DEBUG) + .finish(); + + let logged = tracing::subscriber::with_default(subscriber, || { + NotificationServer::log_received_notification("app", "summary", "body", 0, 100) + }); + + assert!(logged); +} + +#[test] +fn conversation_avatar_wire_image_is_stored_with_the_avatar_role_and_bound() { + // Model the validated wire object immediately before notification flow routing + let wire_image = super::super::wire_hints::WireImageData::from_parts( + 320, + 320, + 320 * 4, + true, + 8, + 4, + vec![19_u8; 320 * 320 * 4], + ) + .expect("320x320 communication image should pass wire validation"); + // The communication role must send the wire image down the sender-visual branch + let (content_image, sender_visual_data) = super::normalize_wire_image_for_role( + WireImageRole::ConversationAvatar, + Some(wire_image), + None, + ); + let notification = build_notification(NotificationInput { + app_name: "Messages".to_string(), + app_icon: String::new(), + summary: "New message".to_string(), + body: "Hello".to_string(), + actions: Vec::new(), + hints: HashMap::new(), + image_data: content_image, + sender_visual_data, + sender_visual: None, + sender_visual_role: SenderVisualRole::ConversationAvatar, + sender: SenderMetadata::default(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Messages", + "Messages", + "org.example.Messages", + "messages", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "exact system executable", + "verified:system-app:org.example.Messages".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + expire_timeout: 0, + }); + + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert_eq!( + ( + notification.image.sender_visual.width, + notification.image.sender_visual.height + ), + (64, 64) + ); + assert_eq!(notification.image.sender_visual.data.len(), 64 * 64 * 4); + assert!(notification.image.content_image.data.is_empty()); +} + +#[tokio::test] +async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let category = OwnedValue::try_from(Value::from("im.received")).expect("category hint"); + let hints = HashMap::from([("category".to_string(), category)]); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + hints.into(), + &header, + 0, + ) + .await + .expect("notify should store"); + let second_id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "next".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("second notify should store"); + + let store = state.store.lock().await; + let active = store.active_notification_view(id).expect("active view"); + assert_eq!(id, 1); + assert_eq!(second_id, 2); + assert_eq!(active.id, id); + assert_eq!(active.summary, "summary"); + assert_eq!(active.category, "im.received"); +} + +#[tokio::test] +async fn drop_all_returns_an_id_then_emits_one_content_free_close_lifecycle() { + let mut config = Config::default(); + config.inhibit.mode = unixnotis_core::InhibitMode::DropAll; + let state = daemon_state_with_config(config).await; + state + .store + .lock() + .await + .add_inhibitor("test-owner".to_string(), "privacy".to_string(), 0); + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationClosed").await; + + let id = server + .ingest_notify( + "sensitive app".to_string(), + 0, + String::new(), + "secret summary".to_string(), + "secret body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("DropAll Notify should return its lifecycle ID"); + + { + let store = state.store.lock().await; + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); + } + let signal = next_signal(&mut stream).await; + let (closed_id, generation, reason) = signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("content-free close body"); + assert_eq!(closed_id, id); + assert_ne!(generation, 0); + assert_eq!(reason, CloseReason::Undefined); +} + +#[tokio::test] +async fn ingest_notify_schedules_expiration_for_positive_transient_timeout() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut hints = HashMap::new(); + hints.insert("transient".to_string(), OwnedValue::from(true)); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "expires".to_string(), + "body".to_string(), + Vec::new(), + hints.into(), + &header, + 25, + ) + .await + .expect("notify should store"); + + let view = state + .store + .lock() + .await + .active_notification_view(id) + .expect("positive-timeout notification should be active initially"); + assert_eq!(view.popup_hide_after_ms, 25); + + for _ in 0..30 { + if state + .store + .lock() + .await + .active_notification_view(id) + .is_none() + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + + panic!("notification should expire after scheduled timeout"); +} + +#[tokio::test] +async fn ingest_notify_expires_ordinary_positive_timeout() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "persistent action".to_string(), + "body".to_string(), + vec!["default".to_string(), "View".to_string()], + HashMap::new().into(), + &header, + 25, + ) + .await + .expect("notify should store"); + + for _ in 0..30 { + let store = state.store.lock().await; + if store.active_notification_view(id).is_none() { + let history = store.list_history(); + let archived = history + .iter() + .find(|notification| notification.id == id) + .expect("expired notification should be archived"); + assert_eq!(archived.popup_hide_after_ms, 0); + assert_eq!(archived.actions.len(), 1); + return; + } + drop(store); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + panic!("ordinary positive timeout should expire the active record"); +} + +#[tokio::test] +async fn default_popup_display_timeout_does_not_archive_the_active_notification() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "keeps actions live".to_string(), + "body".to_string(), + vec!["default".to_string(), "View".to_string()], + HashMap::new().into(), + &header, + -1, + ) + .await + .expect("notify should store"); + + tokio::time::sleep(Duration::from_millis(40)).await; + let store = state.store.lock().await; + let active = store + .active_notification_view(id) + .expect("default popup timeout must not close active storage"); + assert_eq!(active.summary, "keeps actions live"); + assert_eq!(active.actions.len(), 1); + assert_eq!( + active.popup_hide_after_ms, + Config::default().popups.default_timeout_ms + ); +} + +#[tokio::test] +async fn ingest_notify_emits_notification_added_signal() { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationAdded").await; + + let id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "summary".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("notify should store"); + + let signal = next_signal(&mut stream).await; + let (signal_id, generation) = signal + .body() + .deserialize::<(u32, u64)>() + .expect("notification added body"); + assert_eq!(signal_id, id); + assert_eq!( + generation, + state + .store + .lock() + .await + .active_notification_view(id) + .expect("signalled notification should remain active") + .generation + ); +} + +#[tokio::test] +async fn ingest_notify_emits_control_close_for_evicted_active_notification() { + let mut config = Config::default(); + config.history.max_active = 1; + let state = daemon_state_with_config(config).await; + let scheduler = ExpirationScheduler::start(state.clone()); + let server = NotificationServer::new(state.clone(), scheduler); + let message = notify_header_message(); + let header = message.header(); + let mut stream = control_signal_stream(&state, "NotificationClosed").await; + + let first_id = server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "first".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("first notify should store"); + server + .ingest_notify( + "app".to_string(), + 0, + String::new(), + "second".to_string(), + "body".to_string(), + Vec::new(), + HashMap::new().into(), + &header, + 0, + ) + .await + .expect("second notify should store"); + + let signal = next_signal(&mut stream).await; + let (signal_id, signal_generation, reason) = signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("notification closed body"); + assert_eq!(signal_id, first_id); + assert!(signal_generation > 0); + assert_eq!(reason as u32, CloseReason::Undefined as u32); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs new file mode 100644 index 000000000..b7f5b1c74 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/ingress.rs @@ -0,0 +1,698 @@ +use std::collections::HashMap; +use std::os::fd::AsFd; +use std::sync::Arc; +use std::time::Duration; + +use zbus::zvariant::{OwnedValue, SerializeValue, Structure, Value}; +use zbus::{Connection, Message}; + +use super::super::notify_body::MAX_NOTIFY_WIRE_IMAGE_BYTES; +use super::{ + notify_body_is_oversized, notify_has_unix_fds, NotificationIngress, MAX_NOTIFY_WIRE_BODY_BYTES, +}; +use crate::daemon::{NotificationServer, NOTIFICATIONS_OBJECT_PATH}; +use crate::expire::ExpirationScheduler; +use crate::store::test_support::make_notification_with_sender; +use crate::test_support::{daemon_state_for_test, env_lock, EnvVarGuard, TempRoot}; + +const NOTIFICATIONS_INTERFACE: &str = "org.freedesktop.Notifications"; +// Four-megabyte D-Bus fixtures need headroom when the full test binary runs in parallel +const TEST_NOTIFY_TIMEOUT: Duration = Duration::from_secs(10); + +#[test] +fn notify_wire_limit_applies_only_to_oversized_notify_calls() { + assert_eq!(MAX_NOTIFY_WIRE_BODY_BYTES, 4_325_376); + assert!(!notify_body_is_oversized( + "Notify", + MAX_NOTIFY_WIRE_BODY_BYTES + )); + assert!(notify_body_is_oversized( + "Notify", + MAX_NOTIFY_WIRE_BODY_BYTES + 1 + )); + assert!(!notify_body_is_oversized( + "CloseNotification", + MAX_NOTIFY_WIRE_BODY_BYTES + 1 + )); +} + +#[test] +fn unix_file_descriptors_are_rejected_only_for_notify_calls() { + assert!(!notify_has_unix_fds("Notify", None)); + assert!(!notify_has_unix_fds("Notify", Some(0))); + assert!(notify_has_unix_fds("Notify", Some(1))); + assert!(!notify_has_unix_fds("CloseNotification", Some(1))); +} + +#[test] +fn raw_message_header_exposes_attached_unix_file_descriptor_count() { + let file = std::fs::File::open("/dev/null").expect("open descriptor fixture"); + let descriptor = zbus::zvariant::Fd::from(file.as_fd()); + let message = zbus::Message::method(NOTIFICATIONS_OBJECT_PATH, "Notify") + .expect("method builder") + .interface(NOTIFICATIONS_INTERFACE) + .expect("notification interface") + .build(&(descriptor,)) + .expect("descriptor-bearing message"); + + assert_eq!(message.header().unix_fds(), Some(1)); + assert!(notify_has_unix_fds("Notify", message.header().unix_fds())); +} + +#[tokio::test] +async fn oversized_body_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let body = "b".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), HashMap::new(), body).await; +} + +#[tokio::test] +async fn oversized_action_array_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let actions = vec!["a".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1)]; + + assert_oversized_notify_rejected(&state, &client, actions, HashMap::new(), String::new()).await; +} + +#[tokio::test] +async fn under_wire_limit_tiny_action_flood_never_reaches_typed_notify() { + let (state, client) = notification_ingress().await; + let actions = (0..20_000).map(|_| "a".to_string()).collect::>(); + let probe = zbus::Message::method(NOTIFICATIONS_OBJECT_PATH, "Notify") + .expect("method builder") + .interface(NOTIFICATIONS_INTERFACE) + .expect("notification interface") + .build(&( + "app", + 0_u32, + "", + "summary", + "", + &actions, + HashMap::::new(), + 0_i32, + )) + .expect("action flood probe"); + assert!(probe.body().len() < MAX_NOTIFY_WIRE_BODY_BYTES); + + assert_oversized_notify_rejected(&state, &client, actions, HashMap::new(), String::new()).await; +} + +#[tokio::test] +async fn oversized_hint_map_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let mut hints = HashMap::new(); + let value = Value::from("h".repeat(MAX_NOTIFY_WIRE_BODY_BYTES + 1)); + hints.insert( + "category".to_string(), + OwnedValue::try_from(value).expect("owned hint string"), + ); + + assert_oversized_notify_rejected(&state, &client, Vec::new(), hints, String::new()).await; +} + +#[tokio::test] +async fn oversized_image_array_is_rejected_before_notify_deserialization() { + let (state, client) = notification_ingress().await; + let error = send_image_notification(&state, &client, 1, 1, 4, MAX_NOTIFY_WIRE_IMAGE_BYTES + 1) + .await + .expect_err("image above the wire limit must fail"); + + assert!( + error.to_string().contains("LimitsExceeded"), + "unexpected D-Bus error: {error}" + ); + assert!(state.store.lock().await.list_active().is_empty()); +} + +#[tokio::test] +async fn native_image_above_retained_limit_is_downsampled_before_storage() { + let (state, client) = notification_ingress().await; + let reply = send_image_notification(&state, &client, 1_024, 1_024, 4_096, 1_024 * 1_024 * 4) + .await + .expect("normal native image must not discard the text notification"); + let id = reply.body().deserialize::().expect("notification id"); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + + assert_eq!(active.summary, "summary"); + assert_eq!( + ( + active.image.content_image.width, + active.image.content_image.height + ), + (256, 256) + ); + assert_eq!(active.image.content_image.data.len(), 256 * 256 * 4); +} + +#[tokio::test] +async fn native_image_within_retained_limit_reaches_the_notification_model() { + let (state, client) = notification_ingress().await; + let reply = send_image_notification(&state, &client, 128, 128, 512, 128 * 128 * 4) + .await + .expect("bounded native image should reach the typed interface"); + let id = reply.body().deserialize::().expect("notification id"); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + + assert!(!active.image.content_image.data.is_empty()); + assert_eq!(active.image.content_image.data.len(), 128 * 128 * 4); +} + +#[tokio::test] +async fn bounded_unknown_variant_does_not_break_notification_delivery() { + let (state, client) = notification_ingress().await; + let hints = HashMap::from([("sender-pid".to_string(), OwnedValue::from(42_u32))]); + let reply = send_owned_hints_notification(&state, &client, hints) + .await + .expect("bounded unknown hint should be ignored"); + let id = reply.body().deserialize::().expect("notification id"); + + assert_eq!(id, 1); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +#[tokio::test] +async fn supported_wire_hints_keep_text_boolean_and_both_urgency_types() { + let (state, client) = notification_ingress().await; + let category = + OwnedValue::try_from(Value::from("im.received")).expect("owned category hint string"); + let first_hints = HashMap::from([ + ("category".to_string(), category), + ("transient".to_string(), OwnedValue::from(true)), + ("urgency".to_string(), OwnedValue::from(2_u8)), + ]); + let first_reply = send_owned_hints_notification(&state, &client, first_hints) + .await + .expect("supported byte urgency hints should reach the typed interface"); + let first_id = first_reply + .body() + .deserialize::() + .expect("first notification id"); + let second_hints = HashMap::from([("urgency".to_string(), OwnedValue::from(1_u32))]); + let second_reply = send_owned_hints_notification(&state, &client, second_hints) + .await + .expect("supported integer urgency hints should reach the typed interface"); + let second_id = second_reply + .body() + .deserialize::() + .expect("second notification id"); + let store = state.store.lock().await; + let first = store + .active_notification_view(first_id) + .expect("first notification should be retained"); + let second = store + .active_notification_view(second_id) + .expect("second notification should be retained"); + + assert_eq!(first.category, "im.received"); + assert!(first.is_transient); + assert_eq!(first.urgency, 2); + assert_eq!(second.urgency, 1); +} + +#[tokio::test] +async fn claimed_communication_desktop_entry_keeps_wire_avatar_untrusted() { + let (state, client) = notification_ingress().await; + let root = TempRoot::new("claimed-communication-avatar"); + let applications = root.path().join("applications"); + std::fs::create_dir_all(&applications).expect("create desktop application fixture root"); + std::fs::write( + applications.join("org.example.Chat.desktop"), + "[Desktop Entry]\nType=Application\nName=Example Chat\nCategories=Network;InstantMessaging;\nExec=/usr/bin/true\n", + ) + .expect("write communication desktop fixture"); + let index = { + let _environment_lock = env_lock(); + let _data_home = EnvVarGuard::set("XDG_DATA_HOME", root.path()); + let _data_dirs = EnvVarGuard::set("XDG_DATA_DIRS", root.path()); + crate::daemon::DesktopIdentityIndex::build_snapshot().index + }; + assert!(index.desktop_id_has_communication_role("ORG.EXAMPLE.CHAT.DESKTOP")); + state.desktop_identity_index.store(Arc::new(index)); + + let hints = HashMap::from([ + ( + "desktop-entry".to_string(), + OwnedValue::try_from(Value::from("ORG.EXAMPLE.CHAT.DESKTOP")) + .expect("desktop-entry hint"), + ), + ( + "image-data".to_string(), + owned_rgba_pixel([220, 20, 20, 255]), + ), + ]); + let untrusted_icon = root + .path() + .join("untrusted-application-icon.png") + .to_string_lossy() + .into_owned(); + let id = send_notification_with_hints( + &state, + &client, + "Unrelated sender claim", + &untrusted_icon, + hints, + ) + .await + .expect("claimed communication notification should be accepted") + .body() + .deserialize::() + .expect("notification id"); + + let notification = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ); + assert!(!notification.image.sender_visual.data.is_empty()); + assert!(notification.image.content_image.data.is_empty()); + assert_eq!( + notification.image.claimed_desktop_id, + "ORG.EXAMPLE.CHAT.DESKTOP" + ); + assert!(!notification.attribution.may_materialize_application_icon()); + assert_ne!( + notification.attribution.assurance, + unixnotis_core::IdentityAssurance::Authenticated + ); +} + +#[tokio::test] +async fn claimed_noncommunication_desktop_entry_keeps_wire_image_as_content() { + let (state, client) = notification_ingress().await; + let root = TempRoot::new("claimed-content-image"); + let applications = root.path().join("applications"); + std::fs::create_dir_all(&applications).expect("create desktop application fixture root"); + std::fs::write( + applications.join("example-viewer.desktop"), + "[Desktop Entry]\nType=Application\nName=Example Viewer\nCategories=Graphics;Viewer;\nExec=/usr/bin/true\n", + ) + .expect("write noncommunication desktop fixture"); + let index = { + let _environment_lock = env_lock(); + let _data_home = EnvVarGuard::set("XDG_DATA_HOME", root.path()); + let _data_dirs = EnvVarGuard::set("XDG_DATA_DIRS", root.path()); + crate::daemon::DesktopIdentityIndex::build_snapshot().index + }; + state.desktop_identity_index.store(Arc::new(index)); + + let hints = HashMap::from([ + ( + "desktop-entry".to_string(), + OwnedValue::try_from(Value::from("example-viewer.desktop")) + .expect("desktop-entry hint"), + ), + ( + "image-data".to_string(), + owned_rgba_pixel([20, 40, 220, 255]), + ), + ]); + let id = send_notification_with_hints(&state, &client, "Unrelated viewer claim", "", hints) + .await + .expect("claimed noncommunication notification should be accepted") + .body() + .deserialize::() + .expect("notification id"); + + let notification = state + .store + .lock() + .await + .active_notification_view(id) + .expect("notification should be retained"); + assert_eq!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::None + ); + assert!(notification.image.sender_visual.data.is_empty()); + assert!(!notification.image.content_image.data.is_empty()); + assert_eq!( + notification.image.claimed_desktop_id, + "example-viewer.desktop" + ); +} + +#[tokio::test] +async fn image_hint_aliases_follow_standard_precedence_independent_of_wire_order() { + let (state, client) = notification_ingress().await; + let all_aliases = HashMap::from([ + ("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255])), + ("image_data".to_string(), owned_rgba_pixel([2, 0, 0, 255])), + ("image-data".to_string(), owned_rgba_pixel([1, 0, 0, 255])), + ]); + let standard_id = send_owned_hints_notification(&state, &client, all_aliases) + .await + .expect("standard image alias should decode") + .body() + .deserialize::() + .expect("standard image notification id"); + let legacy_aliases = HashMap::from([ + ("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255])), + ("image_data".to_string(), owned_rgba_pixel([2, 0, 0, 255])), + ]); + let legacy_id = send_owned_hints_notification(&state, &client, legacy_aliases) + .await + .expect("legacy image alias should decode") + .body() + .deserialize::() + .expect("legacy image notification id"); + let icon_only = HashMap::from([("icon_data".to_string(), owned_rgba_pixel([3, 0, 0, 255]))]); + let icon_id = send_owned_hints_notification(&state, &client, icon_only) + .await + .expect("legacy icon alias should decode") + .body() + .deserialize::() + .expect("legacy icon notification id"); + let store = state.store.lock().await; + + assert_eq!( + store + .active_notification_view(standard_id) + .expect("standard image notification") + .image + .content_image + .data, + [1, 0, 0, 255] + ); + assert_eq!( + store + .active_notification_view(legacy_id) + .expect("legacy image notification") + .image + .content_image + .data, + [2, 0, 0, 255] + ); + assert_eq!( + store + .active_notification_view(icon_id) + .expect("legacy icon notification") + .image + .content_image + .data, + [3, 0, 0, 255] + ); +} + +#[tokio::test] +async fn supported_hint_with_wrong_signature_is_rejected_without_daemon_failure() { + let (state, client) = notification_ingress().await; + let invalid_hints = [ + HashMap::from([("category".to_string(), OwnedValue::from(true))]), + HashMap::from([( + "transient".to_string(), + OwnedValue::try_from(Value::from("yes")).expect("owned boolean mismatch"), + )]), + HashMap::from([( + "urgency".to_string(), + OwnedValue::try_from(Value::from("high")).expect("owned urgency mismatch"), + )]), + HashMap::from([( + "image-data".to_string(), + OwnedValue::try_from(Value::from("pixels")).expect("owned image mismatch"), + )]), + ]; + + for hints in invalid_hints { + let error = send_owned_hints_notification(&state, &client, hints) + .await + .expect_err("known hint with wrong signature must fail"); + assert!( + error + .to_string() + .contains("notification hint has an unexpected D-Bus signature"), + "unexpected mismatched-hint error: {error}" + ); + } + + assert!(state.store.lock().await.list_active().is_empty()); + let recovery = send_owned_hints_notification(&state, &client, HashMap::new()) + .await + .expect("valid notification should still work after rejected hints"); + assert_eq!( + recovery + .body() + .deserialize::() + .expect("recovery notification id"), + 1 + ); +} + +#[tokio::test] +async fn bounded_notify_body_reaches_the_typed_interface() { + let (state, client) = notification_ingress().await; + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ( + "app", + 0_u32, + "", + "summary", + "bounded body", + Vec::::new(), + HashMap::::new(), + 0_i32, + ); + + let reply = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ) + .await + .expect("bounded Notify should reach typed handler"); + let id = reply.body().deserialize::().expect("notification id"); + + assert_eq!(id, 1); + assert_eq!(state.store.lock().await.list_active().len(), 1); +} + +async fn notification_ingress() -> (std::sync::Arc, Connection) { + let state = daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + state + .connection() + .object_server() + .at( + NOTIFICATIONS_OBJECT_PATH, + NotificationIngress::new(NotificationServer::new(state.clone(), scheduler)), + ) + .await + .expect("register guarded notification interface"); + let client = Connection::session().await.expect("notification client"); + (state, client) +} + +#[tokio::test] +async fn close_errors_hide_missing_foreign_and_history_only_id_existence() { + let (state, client) = notification_ingress().await; + let (foreign_id, history_id) = { + let mut store = state.store.lock().await; + let foreign = store + .insert( + make_notification_with_sender("foreign", ":1.foreign", 999_998, 41), + 0, + ) + .active_notification(); + let history = store + .insert( + make_notification_with_sender("history", ":1.foreign", 999_999, 42), + 0, + ) + .active_notification(); + store.close(history.id, unixnotis_core::CloseReason::Expired); + (foreign.id, history.id) + }; + + let missing = close_method_error(&state, &client, u32::MAX).await; + let foreign = close_method_error(&state, &client, foreign_id).await; + let history = close_method_error(&state, &client, history_id).await; + + assert_eq!(missing, foreign); + assert_eq!(foreign, history); + assert_eq!( + missing, + ( + "org.freedesktop.DBus.Error.Failed".to_string(), + Some(String::new()) + ) + ); +} + +async fn close_method_error( + state: &crate::daemon::DaemonState, + client: &Connection, + id: u32, +) -> (String, Option) { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let error = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "CloseNotification", + &id, + ) + .await + .expect_err("non-closable notification should return a D-Bus error"); + match error { + zbus::Error::MethodError(name, message, _reply) => (name.to_string(), message), + other => panic!("expected a D-Bus method error, got {other:?}"), + } +} + +async fn send_image_notification( + state: &crate::daemon::DaemonState, + client: &Connection, + width: i32, + height: i32, + rowstride: i32, + image_bytes: usize, +) -> zbus::Result { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let image = ( + width, + height, + rowstride, + true, + 8_i32, + 4_i32, + vec![0_u8; image_bytes], + ); + let hints = HashMap::from([("image-data", SerializeValue(&image))]); + let payload = ( + "app", + 0_u32, + "", + "summary", + "body", + Vec::::new(), + hints, + 0_i32, + ); + + tokio::time::timeout( + TEST_NOTIFY_TIMEOUT, + client.call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ), + ) + .await + .expect("Notify response timed out") +} + +async fn send_owned_hints_notification( + state: &crate::daemon::DaemonState, + client: &Connection, + hints: HashMap, +) -> zbus::Result { + send_notification_with_hints(state, client, "app", "", hints).await +} + +async fn send_notification_with_hints( + state: &crate::daemon::DaemonState, + client: &Connection, + app_name: &str, + app_icon: &str, + hints: HashMap, +) -> zbus::Result { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ( + app_name, + 0_u32, + app_icon, + "summary", + "body", + Vec::::new(), + hints, + 0_i32, + ); + + tokio::time::timeout( + TEST_NOTIFY_TIMEOUT, + client.call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ), + ) + .await + .expect("Notify response timed out") +} + +fn owned_rgba_pixel(data: [u8; 4]) -> OwnedValue { + let image = Structure::from((1_i32, 1_i32, 4_i32, true, 8_i32, 4_i32, data.to_vec())); + OwnedValue::try_from(Value::from(image)).expect("owned one-pixel image hint") +} + +async fn assert_oversized_notify_rejected( + state: &crate::daemon::DaemonState, + client: &Connection, + actions: Vec, + hints: HashMap, + body: String, +) { + let destination = state + .connection() + .unique_name() + .expect("daemon unique name") + .clone(); + let payload = ("app", 0_u32, "", "summary", body, actions, hints, 0_i32); + + let error = client + .call_method( + Some(destination), + NOTIFICATIONS_OBJECT_PATH, + Some(NOTIFICATIONS_INTERFACE), + "Notify", + &payload, + ) + .await + .expect_err("oversized Notify body must fail"); + + assert!( + error.to_string().contains("LimitsExceeded"), + "unexpected D-Bus error: {error}" + ); + assert!(state.store.lock().await.list_active().is_empty()); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs index 1a48a30c6..d7c697c2b 100644 --- a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/interface.rs @@ -27,7 +27,7 @@ async fn get_capabilities_returns_freedesktop_capability_contract() { assert!(capabilities.contains(&"actions".to_string())); assert!(capabilities.contains(&"body".to_string())); - assert!(capabilities.contains(&"body-markup".to_string())); + assert!(!capabilities.contains(&"body-markup".to_string())); assert!(capabilities.contains(&"icon-static".to_string())); assert!(!capabilities.contains(&"xyzzy".to_string())); } @@ -67,7 +67,7 @@ async fn notify_wrapper_stores_notification_and_returns_assigned_id() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), header.clone(), 0, ) @@ -99,7 +99,7 @@ async fn close_notification_wrapper_removes_owned_active_notification() { "summary".to_string(), "body".to_string(), Vec::new(), - HashMap::new(), + HashMap::new().into(), header.clone(), 0, ) diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs new file mode 100644 index 000000000..2e129a300 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/quota_principal.rs @@ -0,0 +1,33 @@ +use super::{quota_principal, QuotaPrincipal, SenderMetadata}; + +#[test] +fn quota_principal_requires_and_preserves_the_complete_process_lifetime() { + let complete = SenderMetadata { + sender_uid: Some(1_000), + sender_pid: Some(42), + sender_start_time: Some(77), + ..SenderMetadata::default() + }; + + assert_eq!( + quota_principal(&complete), + Some(QuotaPrincipal::new(1_000, 42, 77)) + ); + + for incomplete in [ + SenderMetadata { + sender_uid: None, + ..complete.clone() + }, + SenderMetadata { + sender_pid: None, + ..complete.clone() + }, + SenderMetadata { + sender_start_time: None, + ..complete + }, + ] { + assert_eq!(quota_principal(&incomplete), None); + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs new file mode 100644 index 000000000..0ddd311ad --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/tests/reply_lifecycle.rs @@ -0,0 +1,115 @@ +use std::num::NonZeroU32; + +use crate::store::{StableProcessIdentity, SuppressedNotification}; + +use super::{PostReplyKey, PostReplyLifecycle, RetainError, MAX_PENDING_SUPPRESSED_CLOSES}; + +fn suppressed(id: u32, generation: u64) -> SuppressedNotification { + SuppressedNotification { + id, + generation, + owner: Some(StableProcessIdentity { + pid: id, + start_time: generation, + }), + } +} + +fn request(sender: &str, serial: u32) -> PostReplyKey { + PostReplyKey { + sender: Some(sender.to_string()), + serial: NonZeroU32::new(serial).expect("non-zero request serial"), + } +} + +#[tokio::test] +async fn retained_lifecycle_is_removed_only_by_its_request_serial() { + let lifecycle = PostReplyLifecycle::default(); + let first_request = request(":1.11", 11); + let second_request = request(":1.11", 12); + let first = suppressed(21, 31); + let second = suppressed(22, 32); + + lifecycle + .retain(first_request.clone(), first) + .await + .expect("first request serial should be vacant"); + lifecycle + .retain(second_request.clone(), second) + .await + .expect("second request serial should be vacant"); + + assert_eq!(lifecycle.take(&second_request).await, Some(second)); + assert_eq!(lifecycle.take(&first_request).await, Some(first)); + assert_eq!(lifecycle.take(&first_request).await, None); +} + +#[tokio::test] +async fn duplicate_in_flight_serial_keeps_the_original_lifecycle() { + let lifecycle = PostReplyLifecycle::default(); + let request = request(":1.41", 41); + let original = suppressed(51, 61); + let duplicate = suppressed(52, 62); + + lifecycle + .retain(request.clone(), original) + .await + .expect("first request serial should be vacant"); + assert_eq!( + lifecycle.retain(request.clone(), duplicate).await, + Err(RetainError::DuplicateSerial) + ); + assert_eq!(lifecycle.take(&request).await, Some(original)); +} + +#[tokio::test] +async fn equal_serials_from_different_senders_keep_independent_lifecycles() { + let lifecycle = PostReplyLifecycle::default(); + let first_request = request(":1.51", 1); + let second_request = request(":1.52", 1); + let first = suppressed(71, 81); + let second = suppressed(72, 82); + + lifecycle + .retain(first_request.clone(), first) + .await + .expect("first sender should have an independent serial space"); + lifecycle + .retain(second_request.clone(), second) + .await + .expect("second sender should have an independent serial space"); + + assert_eq!(lifecycle.take(&first_request).await, Some(first)); + assert_eq!(lifecycle.take(&second_request).await, Some(second)); +} + +#[tokio::test] +async fn pending_lifecycle_capacity_is_hard_bounded_and_reusable() { + let lifecycle = PostReplyLifecycle::default(); + + for serial in 1..=MAX_PENDING_SUPPRESSED_CLOSES { + let serial = u32::try_from(serial).expect("test capacity fits u32"); + let request = request(":1.capacity", serial); + lifecycle + .retain(request, suppressed(serial, u64::from(serial))) + .await + .expect("exact queue capacity should be accepted"); + } + + let overflow_serial = + u32::try_from(MAX_PENDING_SUPPRESSED_CLOSES + 1).expect("test overflow serial fits u32"); + let overflow_request = request(":1.capacity", overflow_serial); + assert_eq!( + lifecycle + .retain(overflow_request.clone(), suppressed(overflow_serial, 1)) + .await, + Err(RetainError::CapacityExceeded) + ); + + let released_request = request(":1.capacity", 1); + assert!(lifecycle.take(&released_request).await.is_some()); + lifecycle + .retain(overflow_request, suppressed(overflow_serial, 2)) + .await + .expect("released capacity should admit the next lifecycle"); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs new file mode 100644 index 000000000..d0b564d2d --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/decode.rs @@ -0,0 +1,196 @@ +//! Key-aware variant decoding for the freedesktop notification hint map + +use std::collections::HashMap; + +use serde::de::{DeserializeSeed, Deserializer, Error as _, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; +use zbus::zvariant::{OwnedValue, Signature, Value}; + +use super::image_bytes::{BoundedImageBytes, WireImageData}; +use super::WireHints; + +impl<'de> Deserialize<'de> for WireHints { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(WireHintsVisitor) + } +} + +struct WireHintsVisitor; + +impl<'de> Visitor<'de> for WireHintsVisitor { + type Value = WireHints; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a freedesktop notification hint dictionary") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = HashMap::with_capacity(map.size_hint().unwrap_or_default()); + let mut standard_image = None; + let mut legacy_image = None; + let mut legacy_icon = None; + let mut image_path = None; + + while let Some(key) = map.next_key::()? { + let Some(kind) = HintKind::for_key(&key) else { + // Raw preflight bounds unknown values before this owned fallback runs + map.next_value::()?; + continue; + }; + let decoded = map.next_value_seed(HintVariantSeed { kind })?; + match decoded { + DecodedHint::Text(text) => { + let value = owned_string(&text).map_err(A::Error::custom)?; + if matches!(key.as_str(), "image-path" | "image_path") { + image_path = value + .try_clone() + .ok() + .and_then(|owned| String::try_from(owned).ok()); + } + values.insert(key, value); + } + DecodedHint::Bool(value) => { + values.insert(key, OwnedValue::from(value)); + } + DecodedHint::Urgency(value) => { + values.insert(key, OwnedValue::from(value)); + } + DecodedHint::Image(Some(image)) => { + // Keep each protocol alias separate so arrival order cannot change precedence + match key.as_str() { + "image-data" => standard_image = Some(image), + "image_data" => legacy_image = Some(image), + "icon_data" => legacy_icon = Some(image), + _ => {} + } + } + DecodedHint::Image(None) => {} + } + } + + Ok(WireHints { + values, + wire_image_data: standard_image.or(legacy_image).or(legacy_icon), + image_path, + }) + } +} + +#[derive(Clone, Copy)] +enum HintKind { + Text, + Bool, + Urgency, + Image, +} + +impl HintKind { + fn for_key(key: &str) -> Option { + match key { + "desktop-entry" + | "category" + | "image-path" + | "image_path" + | "sound-name" + | "sound-file" + | "x-kde-reply-placeholder-text" + | "x-kde-reply-submit-button-text" + | "x-kde-reply-submit-button-icon-name" => Some(Self::Text), + "transient" | "resident" | "suppress-sound" => Some(Self::Bool), + "urgency" => Some(Self::Urgency), + "image-data" | "image_data" | "icon_data" => Some(Self::Image), + _ => None, + } + } +} + +enum DecodedHint { + Text(String), + Bool(bool), + Urgency(u32), + Image(Option), +} + +struct HintVariantSeed { + kind: HintKind, +} + +impl<'de> DeserializeSeed<'de> for HintVariantSeed { + type Value = DecodedHint; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(HintVariantVisitor { kind: self.kind }) + } +} + +struct HintVariantVisitor { + kind: HintKind, +} + +impl<'de> Visitor<'de> for HintVariantVisitor { + type Value = DecodedHint; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a typed notification hint variant") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let signature = sequence + .next_element::>()? + .ok_or_else(|| A::Error::invalid_length(0, &self))?; + match self.kind { + HintKind::Text if signature.as_str() == "s" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Text(value)), + ) + } + HintKind::Bool if signature.as_str() == "b" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Bool(value)), + ) + } + HintKind::Urgency if signature.as_str() == "y" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Urgency(u32::from(value))), + ) + } + HintKind::Urgency if signature.as_str() == "u" => { + sequence.next_element::()?.map_or_else( + || Err(A::Error::invalid_length(1, &self)), + |value| Ok(DecodedHint::Urgency(value)), + ) + } + HintKind::Image if signature.as_str() == "(iiibiiay)" => { + let raw = sequence + .next_element::<(i32, i32, i32, bool, i32, i32, BoundedImageBytes)>()? + .ok_or_else(|| A::Error::invalid_length(1, &self))?; + let image = raw + .6 + .into_wire_image(raw.0, raw.1, raw.2, raw.3, raw.4, raw.5); + Ok(DecodedHint::Image(image)) + } + HintKind::Text | HintKind::Bool | HintKind::Urgency | HintKind::Image => Err( + A::Error::custom("notification hint has an unexpected D-Bus signature"), + ), + } + } +} + +fn owned_string(value: &str) -> zbus::zvariant::Result { + OwnedValue::try_from(Value::from(value)) +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs new file mode 100644 index 000000000..b48d02d98 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/image_bytes.rs @@ -0,0 +1,253 @@ +//! Allocation-bounded byte-array decoding for optional notification images + +use serde::de::{SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use unixnotis_core::{ImageData, NotificationImage}; + +use super::super::notify_body::{MAX_NOTIFY_WIRE_IMAGE_BYTES, MAX_NOTIFY_WIRE_IMAGE_DIMENSION}; + +/// Raw image bytes retained only under the D-Bus wire budget +#[derive(Debug, Default)] +pub(super) struct BoundedImageBytes { + data: Option>, +} + +/// Validated wire pixels that have not entered the retained notification model +#[derive(Debug)] +pub(in crate::daemon::notifications::server) struct WireImageData { + width: u32, + height: u32, + rowstride: usize, + channels: u8, + data: Vec, +} + +impl WireImageData { + pub(in crate::daemon::notifications::server) fn from_parts( + width: i32, + height: i32, + rowstride: i32, + has_alpha: bool, + bits_per_sample: i32, + channels: i32, + data: Vec, + ) -> Option { + // Reject metadata before any pixel index is calculated + if bits_per_sample != 8 { + return None; + } + let width = u32::try_from(width).ok()?; + let height = u32::try_from(height).ok()?; + if width == 0 + || height == 0 + || width > MAX_NOTIFY_WIRE_IMAGE_DIMENSION + || height > MAX_NOTIFY_WIRE_IMAGE_DIMENSION + { + return None; + } + let channels = u8::try_from(channels).ok()?; + // The protocol's alpha flag and channel count describe the same pixel layout + // Reject contradictory metadata rather than guessing how to reinterpret it + let expected_channels = if has_alpha { 4 } else { 3 }; + if channels != expected_channels { + return None; + } + if data.is_empty() || data.len() > MAX_NOTIFY_WIRE_IMAGE_BYTES { + return None; + } + + // The stride must cover every visible pixel in each non-final row + let width_usize = usize::try_from(width).ok()?; + let height_usize = usize::try_from(height).ok()?; + let channels_usize = usize::from(channels); + let row_bytes = width_usize.checked_mul(channels_usize)?; + let rowstride = usize::try_from(rowstride).ok()?; + if rowstride < row_bytes { + return None; + } + // `rowstride` is the distance between consecutive row starts. Padding after + // the final visible row is not required, so validate through its last pixel + let required_bytes = (height_usize - 1) + .checked_mul(rowstride)? + .checked_add(row_bytes)?; + if data.len() < required_bytes { + return None; + } + + // Extra row padding stays transient and is discarded during output sampling + Some(Self { + width, + height, + rowstride, + channels, + data, + }) + } + + pub(in crate::daemon::notifications::server) fn into_storage_image( + self, + requested_dimension: u32, + ) -> Option { + // Clamp the requested output to the persistent model's dimension policy + let model_dimension = u32::try_from(NotificationImage::retained_dimension_limit()).ok()?; + let target_dimension = requested_dimension.min(model_dimension); + if target_dimension == 0 { + return None; + } + + let Self { + width, + height, + rowstride, + channels, + data, + } = self; + let (target_width, target_height) = target_dimensions(width, height, target_dimension)?; + let target_pixels = usize::try_from(target_width) + .ok()? + .checked_mul(usize::try_from(target_height).ok()?)?; + let output_len = target_pixels.checked_mul(4)?; + let mut rgba = vec![0_u8; output_len]; + let channels = usize::from(channels); + let source_width = usize::try_from(width).ok()?; + let source_height = usize::try_from(height).ok()?; + let target_width_usize = usize::try_from(target_width).ok()?; + let target_height_usize = usize::try_from(target_height).ok()?; + + // Sample source pixels directly so a large wire raster never becomes a second full copy + for target_y in 0..target_height_usize { + let source_y = target_y + .checked_mul(source_height)? + .checked_div(target_height_usize)?; + for target_x in 0..target_width_usize { + let source_x = target_x + .checked_mul(source_width)? + .checked_div(target_width_usize)?; + let source_index = source_y + .checked_mul(rowstride)? + .checked_add(source_x.checked_mul(channels)?)?; + let source_end = source_index.checked_add(channels)?; + let source_pixel = data.get(source_index..source_end)?; + let target_index = target_y + .checked_mul(target_width_usize)? + .checked_add(target_x)? + .checked_mul(4)?; + let target_pixel = rgba.get_mut(target_index..target_index + 4)?; + target_pixel[..3].copy_from_slice(&source_pixel[..3]); + target_pixel[3] = if channels == 4 { source_pixel[3] } else { 255 }; + } + } + + // The retained validator remains the final model boundary after downsampling + let width = i32::try_from(target_width).ok()?; + let height = i32::try_from(target_height).ok()?; + let rowstride = width.checked_mul(4)?; + NotificationImage::normalize_image_data(ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: rgba, + }) + } +} + +impl BoundedImageBytes { + pub(super) fn into_wire_image( + self, + width: i32, + height: i32, + rowstride: i32, + has_alpha: bool, + bits_per_sample: i32, + channels: i32, + ) -> Option { + let data = self.data?; + WireImageData::from_parts( + width, + height, + rowstride, + has_alpha, + bits_per_sample, + channels, + data, + ) + } +} + +impl<'de> Deserialize<'de> for BoundedImageBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(BoundedImageBytesVisitor) + } +} + +struct BoundedImageBytesVisitor; + +impl<'de> Visitor<'de> for BoundedImageBytesVisitor { + type Value = BoundedImageBytes; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a bounded notification image byte array") + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + // The wire limit is separate from the smaller persistent image budget + let wire_image_limit = MAX_NOTIFY_WIRE_IMAGE_BYTES; + let mut data = Some(Vec::new()); + + while let Some(byte) = sequence.next_element::()? { + let Some(retained) = data.as_mut() else { + continue; + }; + if retained.len() == wire_image_limit { + // Release a partial buffer as soon as the wire allowance is crossed + data = None; + continue; + } + retained.push(byte); + } + + Ok(BoundedImageBytes { data }) + } +} + +fn target_dimensions(width: u32, height: u32, target_dimension: u32) -> Option<(u32, u32)> { + // Preserve source proportions while keeping both output axes within the target + if width >= height { + Some(( + target_dimension.min(width), + scaled_dimension(height, width, target_dimension.min(width)), + )) + } else { + Some(( + scaled_dimension(width, height, target_dimension.min(height)), + target_dimension.min(height), + )) + } +} + +fn scaled_dimension(value: u32, source_dimension: u32, target_dimension: u32) -> u32 { + if source_dimension <= target_dimension { + return value; + } + // Checked arithmetic keeps future limit changes from turning geometry into a wrap + u64::from(value) + .checked_mul(u64::from(target_dimension)) + .and_then(|scaled| scaled.checked_div(u64::from(source_dimension))) + .and_then(|scaled| u32::try_from(scaled).ok()) + .unwrap_or(1) + .max(1) +} + +#[cfg(test)] +#[path = "tests/image_bytes.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs new file mode 100644 index 000000000..35a505dbf --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/mod.rs @@ -0,0 +1,53 @@ +//! Bounded deserialization for caller-provided notification hints + +mod decode; +mod image_bytes; + +use std::collections::HashMap; + +use zbus::zvariant::{OwnedValue, Signature, Type}; + +pub(super) use self::image_bytes::WireImageData; + +/// Hints decoded without expanding large byte arrays into per-byte dynamic values +#[derive(Debug, Default)] +pub(super) struct WireHints { + values: HashMap, + wire_image_data: Option, + image_path: Option, +} + +impl WireHints { + pub(super) fn into_parts( + self, + ) -> ( + HashMap, + Option, + Option, + ) { + (self.values, self.wire_image_data, self.image_path) + } +} + +impl From> for WireHints { + fn from(values: HashMap) -> Self { + // Internal tests and helpers may still supply an already-decoded hint map + let image_path = values + .get("image-path") + .or_else(|| values.get("image_path")) + .and_then(|value| value.try_clone().ok()) + .and_then(|value| String::try_from(value).ok()); + Self { + values, + wire_image_data: None, + image_path, + } + } +} + +impl Type for WireHints { + fn signature() -> Signature<'static> { + // This is the standard freedesktop notification hint dictionary + Signature::from_static_str_unchecked("a{sv}") + } +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs new file mode 100644 index 000000000..50af429a5 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/notifications/server/wire_hints/tests/image_bytes.rs @@ -0,0 +1,117 @@ +use super::*; + +#[test] +fn large_wire_avatar_is_downsampled_before_model_validation() { + let wire = WireImageData::from_parts(320, 320, 320 * 4, true, 8, 4, vec![17_u8; 320 * 320 * 4]) + .expect("320x320 wire avatar should be valid"); + + let image = wire + .into_storage_image(64) + .expect("valid wire avatar should be reduced to storage size"); + + assert_eq!((image.width, image.height), (64, 64)); + assert_eq!(image.data.len(), 64 * 64 * 4); + assert!(image.data.iter().all(|byte| *byte == 17)); +} + +#[test] +fn non_square_wire_images_preserve_aspect_ratio_during_downsampling() { + let wire = WireImageData::from_parts(320, 160, 320 * 4, true, 8, 4, vec![23_u8; 320 * 160 * 4]) + .expect("non-square wire image should be valid"); + + let image = wire + .into_storage_image(64) + .expect("non-square wire image should normalize"); + + assert_eq!((image.width, image.height), (64, 32)); + assert_eq!(image.data.len(), 64 * 32 * 4); +} + +#[test] +fn maximum_wire_raster_is_reduced_to_the_content_model_bound() { + let wire = WireImageData::from_parts( + 1024, + 1024, + 1024 * 4, + true, + 8, + 4, + vec![31_u8; MAX_NOTIFY_WIRE_IMAGE_BYTES], + ) + .expect("maximum documented wire raster should be valid"); + + let image = wire + .into_storage_image(256) + .expect("maximum wire raster should be reduced before storage"); + + assert_eq!((image.width, image.height), (256, 256)); + assert_eq!(image.data.len(), 256 * 256 * 4); +} + +#[test] +fn padded_rgb_wire_rows_are_tightly_packed_as_rgba() { + let mut data = vec![0xee_u8; 2 * 8]; + data[..6].copy_from_slice(&[1, 2, 3, 4, 5, 6]); + data[8..14].copy_from_slice(&[7, 8, 9, 10, 11, 12]); + let wire = WireImageData::from_parts(2, 2, 8, false, 8, 3, data) + .expect("padded RGB rows should be valid"); + + let image = wire + .into_storage_image(2) + .expect("padded RGB rows should normalize"); + + assert_eq!( + image.data, + [1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255] + ); +} + +#[test] +fn alpha_flag_and_channel_count_must_describe_the_same_layout() { + assert!(WireImageData::from_parts(1, 1, 4, false, 8, 4, vec![0; 4]).is_none()); + assert!(WireImageData::from_parts(1, 1, 3, true, 8, 3, vec![0; 3]).is_none()); +} + +#[test] +fn final_wire_row_does_not_require_trailing_stride_padding() { + assert!(WireImageData::from_parts(1, 2, 4, false, 8, 3, vec![0; 7]).is_some()); + assert!(WireImageData::from_parts(1, 2, 4, false, 8, 3, vec![0; 6]).is_none()); +} + +#[test] +fn wire_image_metadata_and_bounds_fail_closed() { + let valid_data = vec![0_u8; 4]; + assert!(WireImageData::from_parts(0, 1, 4, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 0, 4, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1025, 1, 4100, true, 8, 4, vec![0; 4100]).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 16, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 2, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 3, true, 8, 4, valid_data.clone()).is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 4, vec![0; 3]).is_none()); + assert!(WireImageData::from_parts( + 1, + 1, + 4, + true, + 8, + 4, + vec![0; MAX_NOTIFY_WIRE_IMAGE_BYTES + 1] + ) + .is_none()); + assert!(WireImageData::from_parts(1, 1, 4, true, 8, 4, valid_data) + .expect("valid image") + .into_storage_image(0) + .is_none()); +} + +#[test] +fn byte_array_decoder_reports_the_expected_input_shape() { + let error = BoundedImageBytes::deserialize(serde::de::value::UnitDeserializer::< + serde::de::value::Error, + >::new()) + .expect_err("unit input is not a byte sequence"); + + assert!(error + .to_string() + .contains("a bounded notification image byte array")); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs deleted file mode 100644 index 82b6046ed..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/flow.rs +++ /dev/null @@ -1,335 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use chrono::Utc; -use futures_util::TryStreamExt; -use tracing::Level; -use tracing_subscriber::filter::LevelFilter; -use unixnotis_core::{ - CloseReason, Config, Notification, NotificationImage, Urgency, CONTROL_OBJECT_PATH, -}; -use zbus::message::Type; -use zbus::{Connection, MatchRule, Message, MessageStream}; - -use crate::daemon::{DaemonState, NotificationServer}; -use crate::expire::ExpirationScheduler; -use crate::sound::SoundSettings; -use crate::store::{InsertOutcome, NotificationStore}; -use crate::test_support::daemon_state_for_test; - -fn notification_with_id(id: u32) -> Arc { - Arc::new(Notification { - id, - app_name: "app".to_string(), - app_icon: String::new(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: -1, - received_at: Utc::now(), - sender_name: Some(":1.test".to_string()), - sender_pid: Some(42), - sender_start_time: Some(77), - sender_executable: Some("/usr/bin/test-app".to_string()), - }) -} - -fn insert_outcome(id: u32, dropped: bool) -> InsertOutcome { - InsertOutcome { - notification: notification_with_id(id), - replaced: false, - show_popup: !dropped, - allow_sound: !dropped, - evicted: Vec::new(), - dropped, - } -} - -#[test] -fn sender_app_name_mismatch_is_false_without_executable_metadata() { - assert!(!super::sender_app_name_mismatch("Calendar", None)); -} - -#[test] -fn sender_app_name_mismatch_is_false_when_app_matches_executable() { - assert!(!super::sender_app_name_mismatch( - "UnixNotis Center", - Some("/usr/bin/unixnotis-center"), - )); -} - -#[test] -fn sender_app_name_mismatch_is_true_when_app_does_not_match_executable() { - assert!(super::sender_app_name_mismatch( - "Calendar", - Some("/usr/bin/firefox"), - )); -} - -fn notify_header_message() -> Message { - Message::method("/org/freedesktop/Notifications", "Notify") - .expect("method builder") - .interface("org.freedesktop.Notifications") - .expect("interface") - .sender(":1.42") - .expect("sender") - .build(&()) - .expect("message") -} - -async fn daemon_state_with_config(config: Config) -> Arc { - let connection = Connection::session().await.expect("session bus"); - let sound = SoundSettings::from_config(&config); - let store = NotificationStore::new_with_state_store(config, None); - DaemonState::new_with_store(connection, store, sound, false) -} - -async fn control_signal_stream(state: &DaemonState, member: &str) -> MessageStream { - let receiver = Connection::session().await.expect("receiver session bus"); - let sender = state - .connection() - .unique_name() - .expect("daemon connection has unique name") - .to_string(); - let rule = MatchRule::builder() - .msg_type(Type::Signal) - .sender(sender.as_str()) - .expect("sender") - .path(CONTROL_OBJECT_PATH) - .expect("path") - .interface("com.unixnotis.Control") - .expect("interface") - .member(member) - .expect("member") - .build(); - MessageStream::for_match_rule(rule, &receiver, Some(8)) - .await - .expect("signal stream") -} - -async fn next_signal(stream: &mut MessageStream) -> Message { - tokio::time::timeout(Duration::from_millis(500), stream.try_next()) - .await - .expect("signal should arrive before timeout") - .expect("signal stream should stay open") - .expect("signal message") -} - -#[test] -fn handle_dropped_notification_returns_id_for_dropped_payload() { - let outcome = insert_outcome(9, true); - - let id = NotificationServer::handle_dropped_notification(&outcome); - - assert_eq!(id, Some(9)); -} - -#[test] -fn handle_dropped_notification_returns_none_for_stored_payload() { - let outcome = insert_outcome(9, false); - - let id = NotificationServer::handle_dropped_notification(&outcome); - - assert_eq!(id, None); -} - -#[test] -fn log_received_notification_reports_false_when_debug_is_disabled() { - let subscriber = tracing_subscriber::fmt() - .with_max_level(LevelFilter::INFO) - .finish(); - - let logged = tracing::subscriber::with_default(subscriber, || { - NotificationServer::log_received_notification("app", "summary", "body", 0, 100) - }); - - assert!(!logged); -} - -#[test] -fn log_received_notification_reports_true_when_debug_is_enabled() { - let subscriber = tracing_subscriber::fmt() - .with_max_level(Level::DEBUG) - .finish(); - - let logged = tracing::subscriber::with_default(subscriber, || { - NotificationServer::log_received_notification("app", "summary", "body", 0, 100) - }); - - assert!(logged); -} - -#[tokio::test] -async fn ingest_notify_stores_notifications_and_returns_assigned_ids() { - let state = daemon_state_for_test(false).await; - let scheduler = ExpirationScheduler::start(state.clone()); - let server = NotificationServer::new(state.clone(), scheduler); - let message = notify_header_message(); - let header = message.header(); - - let id = server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "summary".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 0, - ) - .await - .expect("notify should store"); - let second_id = server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "next".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 0, - ) - .await - .expect("second notify should store"); - - let store = state.store.lock().await; - let active = store.active_notification_view(id).expect("active view"); - assert_eq!(id, 1); - assert_eq!(second_id, 2); - assert_eq!(active.id, id); - assert_eq!(active.summary, "summary"); -} - -#[tokio::test] -async fn ingest_notify_schedules_expiration_for_positive_timeout() { - let state = daemon_state_for_test(false).await; - let scheduler = ExpirationScheduler::start(state.clone()); - let server = NotificationServer::new(state.clone(), scheduler); - let message = notify_header_message(); - let header = message.header(); - - let id = server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "expires".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 25, - ) - .await - .expect("notify should store"); - - for _ in 0..30 { - if state - .store - .lock() - .await - .active_notification_view(id) - .is_none() - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - - panic!("notification should expire after scheduled timeout"); -} - -#[tokio::test] -async fn ingest_notify_emits_notification_added_signal() { - let state = daemon_state_for_test(false).await; - let scheduler = ExpirationScheduler::start(state.clone()); - let server = NotificationServer::new(state.clone(), scheduler); - let message = notify_header_message(); - let header = message.header(); - let mut stream = control_signal_stream(&state, "NotificationAdded").await; - - let id = server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "summary".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 0, - ) - .await - .expect("notify should store"); - - let signal = next_signal(&mut stream).await; - let (signal_id, show_popup) = signal - .body() - .deserialize::<(u32, bool)>() - .expect("notification added body"); - assert_eq!(signal_id, id); - assert!(show_popup); -} - -#[tokio::test] -async fn ingest_notify_emits_control_close_for_evicted_active_notification() { - let mut config = Config::default(); - config.history.max_active = 1; - let state = daemon_state_with_config(config).await; - let scheduler = ExpirationScheduler::start(state.clone()); - let server = NotificationServer::new(state.clone(), scheduler); - let message = notify_header_message(); - let header = message.header(); - let mut stream = control_signal_stream(&state, "NotificationClosed").await; - - let first_id = server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "first".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 0, - ) - .await - .expect("first notify should store"); - server - .ingest_notify( - "app".to_string(), - 0, - String::new(), - "second".to_string(), - "body".to_string(), - Vec::new(), - HashMap::new(), - &header, - 0, - ) - .await - .expect("second notify should store"); - - let signal = next_signal(&mut stream).await; - let (signal_id, reason) = signal - .body() - .deserialize::<(u32, CloseReason)>() - .expect("notification closed body"); - assert_eq!(signal_id, first_id); - assert_eq!(reason as u32, CloseReason::Undefined as u32); -} diff --git a/crates/unixnotis-daemon/src/daemon/tests/signal_burst.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs similarity index 72% rename from crates/unixnotis-daemon/src/daemon/tests/signal_burst.rs rename to crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs index b4ee6ae91..3ce85f6c5 100644 --- a/crates/unixnotis-daemon/src/daemon/tests/signal_burst.rs +++ b/crates/unixnotis-daemon/src/daemon/notifications/tests/flow_control.rs @@ -3,12 +3,13 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; use super::{ - notification_signal_mode_for_sender, NotificationBurstState, NotificationSignalMode, - NOTIFICATION_DIRECT_SIGNAL_LIMIT, NOTIFICATION_SIGNAL_TRACK_LIMIT, NOTIFICATION_SIGNAL_WINDOW, + notification_signal_mode_for_sender, notification_signal_mode_for_sender_at, + NotificationBurstState, NotificationSignalMode, NOTIFICATION_DIRECT_SIGNAL_LIMIT, + NOTIFICATION_SIGNAL_TRACK_LIMIT, NOTIFICATION_SIGNAL_WINDOW, }; #[test] -fn notification_signal_mode_falls_back_after_burst_limit() { +fn notification_signal_mode_invalidates_every_trailing_burst_commit() { let cache = Mutex::new(HashMap::::new()); for _ in 0..NOTIFICATION_DIRECT_SIGNAL_LIMIT { @@ -18,16 +19,18 @@ fn notification_signal_mode_falls_back_after_burst_limit() { ); } - // One snapshot tells clients to resync without flooding the bus + // The first overflow switches clients to the bounded snapshot path assert_eq!( notification_signal_mode_for_sender(&cache, ":1.55"), NotificationSignalMode::SnapshotOnly ); - // Further events inside the same burst window are redundant - assert_eq!( - notification_signal_mode_for_sender(&cache, ":1.55"), - NotificationSignalMode::Suppress - ); + // Later commits need their own invalidation because the first fetch may already be in flight + for _ in 0..3 { + assert_eq!( + notification_signal_mode_for_sender(&cache, ":1.55"), + NotificationSignalMode::SnapshotOnly + ); + } } #[test] @@ -41,7 +44,6 @@ fn notification_signal_mode_caps_unique_senders_without_blocking_known_sender() window_started: now, last_seen: now, count: 1, - snapshot_emitted: false, }, ); } @@ -72,7 +74,6 @@ fn notification_signal_mode_prunes_expired_senders_before_track_limit_check() { window_started: stale, last_seen: stale, count: 1, - snapshot_emitted: false, }, ); } @@ -98,7 +99,6 @@ fn notification_signal_mode_resets_existing_sender_after_window_expires() { // Recent last_seen keeps the sender in the map so only the per-sender window resets last_seen: now, count: NOTIFICATION_DIRECT_SIGNAL_LIMIT + 1, - snapshot_emitted: true, }, ); let cache = Mutex::new(seeded); @@ -109,3 +109,24 @@ fn notification_signal_mode_resets_existing_sender_after_window_expires() { NotificationSignalMode::Direct ); } + +#[test] +fn notification_signal_mode_resets_at_the_exact_window_boundary() { + let now = Instant::now(); + let window_started = now + .checked_sub(NOTIFICATION_SIGNAL_WINDOW) + .expect("test clock should represent the burst boundary"); + let cache = Mutex::new(HashMap::from([( + ":1.boundary".to_string(), + NotificationBurstState { + window_started, + last_seen: now, + count: NOTIFICATION_DIRECT_SIGNAL_LIMIT + 1, + }, + )])); + + assert_eq!( + notification_signal_mode_for_sender_at(&cache, ":1.boundary", now), + NotificationSignalMode::Direct + ); +} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs deleted file mode 100644 index 62675a8e1..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/payload.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use zbus::zvariant::OwnedValue; - -use super::{ - build_notification, display_width, normalize_text_for_layout, owned_to_string, parse_actions, - parse_urgency_hint, resolve_expiration, sanitize_hints_for_storage, string_to_owned_value, - truncate_utf8_bytes, NotificationInput, SenderMetadata, MAX_ACTIONS, MAX_BODY_BYTES, - MAX_SUMMARY_BYTES, -}; -use unixnotis_core::{Config, NotificationImage, Urgency}; - -#[test] -fn truncate_utf8_bytes_preserves_character_boundaries() { - let value = "abc🙂def"; - let truncated = truncate_utf8_bytes(value, 5); - assert_eq!(truncated, "abc"); -} - -#[test] -fn truncate_utf8_bytes_keeps_exact_boundary_and_handles_zero_limit() { - assert_eq!(truncate_utf8_bytes("abc", 3), "abc"); - assert_eq!(truncate_utf8_bytes("abc", 0), ""); - assert_eq!(truncate_utf8_bytes("éé", 3), "é"); -} - -#[test] -fn build_notification_clamps_summary_and_body_sizes() { - let summary = "S".repeat(MAX_SUMMARY_BYTES + 128); - let body = "B".repeat(MAX_BODY_BYTES + 512); - - let notification = build_notification(NotificationInput { - app_name: "app".to_string(), - app_icon: "icon".to_string(), - summary, - body, - actions: Vec::new(), - hints: HashMap::::new(), - sender: SenderMetadata { - sender_name: Some(":1.test".to_string()), - sender_pid: Some(42), - sender_start_time: Some(77), - sender_executable: Some("/usr/bin/test-app".to_string()), - }, - expire_timeout: 0, - }); - - assert!(notification.summary.len() <= MAX_SUMMARY_BYTES); - assert!(notification.body.len() <= MAX_BODY_BYTES); -} - -#[test] -fn build_notification_strips_display_spoofing_controls() { - let notification = build_notification(NotificationInput { - app_name: "mail\u{202E}exe\nfake".to_string(), - app_icon: "icon".to_string(), - summary: "safe\u{202E}spoof".to_string(), - body: "line1\nline2\u{2066}tail".to_string(), - actions: vec!["default".to_string(), "Open\u{202E}".to_string()], - hints: HashMap::::new(), - sender: SenderMetadata { - sender_name: Some(":1.test".to_string()), - sender_pid: Some(42), - sender_start_time: Some(77), - sender_executable: Some("/usr/bin/test-app".to_string()), - }, - expire_timeout: 0, - }); - - assert_eq!(notification.app_name, "mailexe fake"); - assert_eq!(notification.summary, "safespoof"); - assert_eq!(notification.body, "line1\nline2tail"); - assert_eq!(notification.actions[0].label, "Open"); -} - -#[test] -fn parse_actions_caps_pairs() { - let mut raw = Vec::new(); - for idx in 0..(MAX_ACTIONS + 10) { - raw.push(format!("key-{idx}")); - raw.push(format!("label-{idx}")); - } - - let actions = parse_actions(raw); - assert_eq!(actions.len(), MAX_ACTIONS); -} - -#[test] -fn parse_actions_ignores_dangling_key_without_label() { - let actions = parse_actions(vec![ - "default".to_string(), - "Open".to_string(), - "orphan-key".to_string(), - ]); - - // D-Bus action arrays are pairs; a trailing key cannot produce a safe button - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].key, "default"); - assert_eq!(actions[0].label, "Open"); -} - -#[test] -fn sanitize_hints_drops_untrusted_and_bounds_strings() { - let mut hints = HashMap::::new(); - hints.insert("transient".to_string(), OwnedValue::from(true)); - hints.insert("urgency".to_string(), OwnedValue::from(9u32)); - hints.insert( - "sound-name".to_string(), - string_to_owned_value(&"n".repeat(5000)).expect("sound-name"), - ); - hints.insert("image-data".to_string(), OwnedValue::from(123u32)); - hints.insert( - "x-custom".to_string(), - string_to_owned_value("custom").expect("custom"), - ); - - let sanitized = sanitize_hints_for_storage(hints); - assert_eq!(sanitized.len(), 3); - assert!(sanitized.contains_key("transient")); - assert!(sanitized.contains_key("sound-name")); - assert_eq!( - u32::try_from(sanitized.get("urgency").expect("urgency")), - Ok(2) - ); - - let sound_name = owned_to_string( - sanitized - .get("sound-name") - .expect("sound-name should remain"), - ) - .expect("sound-name should be string"); - assert!(sound_name.len() <= 2048); -} - -#[test] -fn parse_urgency_hint_accepts_byte_and_integer_values_with_cap() { - assert_eq!(parse_urgency_hint(&OwnedValue::from(0u8)), Some(0)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(1u32)), Some(1)); - assert_eq!(parse_urgency_hint(&OwnedValue::from(99u32)), Some(2)); - assert_eq!( - parse_urgency_hint(&string_to_owned_value("high").expect("string")), - None - ); -} - -#[test] -fn owned_to_string_accepts_only_string_values() { - assert_eq!( - owned_to_string(&string_to_owned_value("sound").expect("string")).as_deref(), - Some("sound") - ); - assert_eq!(owned_to_string(&OwnedValue::from(7u32)), None); -} - -#[test] -fn resolve_expiration_respects_protocol_and_config_rules() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 5_000; - config.popups.critical_timeout_ms = Some(9_000); - - let mut notification = unixnotis_core::Notification { - id: 1, - app_name: "app".to_string(), - app_icon: String::new(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: -1, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.urgency = Urgency::Critical; - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = 0; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.expire_timeout = 100; - notification.is_resident = true; - assert!(resolve_expiration(&config, ¬ification).is_none()); - - notification.is_resident = false; - let before = Instant::now(); - let deadline = resolve_expiration(&config, ¬ification).expect("explicit timeout"); - assert!(deadline > before); - assert!(deadline <= Instant::now() + Duration::from_millis(500)); - - notification.expire_timeout = -1; - notification.urgency = Urgency::Critical; - config.popups.critical_timeout_ms = None; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} - -#[test] -fn resolve_expiration_treats_positive_timeout_as_caller_owned_even_when_default_is_zero() { - let mut config = Config::default(); - config.popups.default_timeout_ms = 0; - let mut notification = unixnotis_core::Notification { - id: 1, - app_name: "app".to_string(), - app_icon: String::new(), - summary: "summary".to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 25, - received_at: chrono::Utc::now(), - sender_name: None, - sender_pid: None, - sender_start_time: None, - sender_executable: None, - }; - - assert!(resolve_expiration(&config, ¬ification).is_some()); - - notification.expire_timeout = -1; - assert!(resolve_expiration(&config, ¬ification).is_none()); -} - -#[test] -fn normalize_text_for_layout_folds_long_unbroken_tokens() { - let input = "x".repeat(200); - let normalized = normalize_text_for_layout(&input, 96); - assert!(normalized.contains('…')); - let longest = normalized - .split_whitespace() - .map(|part| part.chars().filter(char::is_ascii_alphanumeric).count()) - .max() - .unwrap_or(0); - assert!(longest <= 96); -} - -#[test] -fn normalize_text_for_layout_returns_input_when_limit_is_zero() { - assert_eq!(normalize_text_for_layout("unchanged", 0), "unchanged"); -} - -#[test] -fn normalize_text_for_layout_keeps_exact_width_token_without_ellipsis() { - let input = "x".repeat(96); - let normalized = normalize_text_for_layout(&input, 96); - assert_eq!(normalized, input); -} - -#[test] -fn normalize_text_for_layout_resets_run_after_whitespace() { - let input = format!("{} {}", "x".repeat(96), "y".repeat(96)); - let normalized = normalize_text_for_layout(&input, 96); - assert_eq!(normalized, input); -} - -#[test] -fn normalize_text_for_layout_keeps_char_count_bound_with_ellipsis() { - let input = "x".repeat(200); - let normalized = normalize_text_for_layout(&input, 96); - assert!(normalized.contains('…')); - // Ellipsis is width 2 in CJK width mode, so the text keeps 94 ASCII chars plus ellipsis - assert_eq!(normalized.chars().count(), 95); -} - -#[test] -fn normalize_text_for_layout_trims_only_as_much_as_needed_for_ellipsis() { - assert_eq!(normalize_text_for_layout("xxxx", 3), "x…"); -} - -#[test] -fn normalize_text_for_layout_limits_wide_glyph_runs() { - let input = "界".repeat(120); - let normalized = normalize_text_for_layout(&input, 96); - let width: usize = normalized.chars().map(display_width).sum(); - assert!(width <= 96); -} - -#[test] -fn normalize_text_for_layout_limits_emoji_joiner_runs() { - let input = "👨\u{200D}👩\u{200D}👧\u{200D}👦".repeat(80); - let normalized = normalize_text_for_layout(&input, 96); - let width: usize = normalized.chars().map(display_width).sum(); - assert!(width <= 96); -} - -#[test] -fn display_width_counts_wide_and_joiner_characters_for_layout_safety() { - assert!(display_width('界') > 1); - assert_eq!(display_width('\u{200D}'), 1); -} diff --git a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs b/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs deleted file mode 100644 index fc9ce1b2f..000000000 --- a/crates/unixnotis-daemon/src/daemon/notifications/tests/sender.rs +++ /dev/null @@ -1,62 +0,0 @@ -use super::*; - -#[test] -fn app_name_matches_sender_accepts_empty_or_missing_executable_name() { - // Empty app names are common for simple clients and should not produce warnings - assert!(app_name_matches_sender(" ", "/usr/bin/notify-send")); - // A path without a final file name cannot prove spoofing, so it stays advisory-only - assert!(app_name_matches_sender("Calendar", "/")); -} - -#[test] -fn app_name_matches_sender_accepts_exact_hyphenated_and_contained_names() { - assert!(app_name_matches_sender("firefox", "/usr/bin/firefox")); - assert!(app_name_matches_sender( - "UnixNotis Center", - "/opt/unixnotis/bin/unixnotis-center" - )); - assert!(app_name_matches_sender( - "discord", - "/opt/discord/DiscordCanaryDiscord" - )); -} - -#[test] -fn app_name_matches_sender_rejects_unrelated_display_name() { - assert!(!app_name_matches_sender("Calendar", "/usr/bin/firefox")); - assert!(!app_name_matches_sender( - "noticenterctl", - "/usr/bin/unixnotis-center" - )); -} - -#[cfg(target_os = "linux")] -#[test] -fn parse_process_start_time_handles_spaces_in_comm() { - let stat = "42 (player with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; - assert_eq!(parse_process_start_time(stat), Some(987_654)); -} - -#[cfg(target_os = "linux")] -#[test] -fn parse_process_start_time_rejects_missing_or_invalid_fields() { - assert!(parse_process_start_time("42 no-closing-paren").is_none()); - assert!(parse_process_start_time("42 (app) S 1 2 3").is_none()); - - let stat = "42 (app) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 nope 20"; - assert!(parse_process_start_time(stat).is_none()); -} - -#[cfg(target_os = "linux")] -#[tokio::test] -async fn process_metadata_helpers_read_current_process_on_linux() { - let pid = std::process::id(); - - let exe = read_process_executable_path(pid) - .await - .expect("current process executable should be readable"); - assert!(exe.is_absolute()); - - let start_time = read_process_start_time(pid).expect("current process start time should exist"); - assert!(start_time > 1); -} diff --git a/crates/unixnotis-daemon/src/daemon/state/cache.rs b/crates/unixnotis-daemon/src/daemon/state/cache.rs deleted file mode 100644 index 08f108c88..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/cache.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Mutex as StdMutex; - -pub(in crate::daemon::state) fn should_emit_cached( - cache: &StdMutex>, - value: &T, -) -> bool { - // Sync mutex is enough here because this cache is tiny and never held across await points - let mut last_value = match cache.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - if last_value - .as_ref() - .is_some_and(|previous| previous == value) - { - // Identical state would only burn CPU in zbus and the listeners - return false; - } - // Clone once on change so later comparisons stay allocation-free for equal values - *last_value = Some(value.clone()); - true -} diff --git a/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs b/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs new file mode 100644 index 000000000..b3566c626 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/interaction_gates.rs @@ -0,0 +1,34 @@ +//! Bounded serialization for same-ID notification interactions + +use tokio::sync::{Mutex, MutexGuard}; + +const INTERACTION_GATE_SHARDS: usize = 128; + +/// Fixed interaction locks prevent an attacker-controlled notification ID space from growing state +pub(in crate::daemon) struct InteractionGates { + shards: Box<[Mutex<()>]>, +} + +impl InteractionGates { + pub(in crate::daemon) fn new() -> Self { + let shards = (0..INTERACTION_GATE_SHARDS) + .map(|_index| Mutex::new(())) + .collect::>() + .into_boxed_slice(); + Self { shards } + } + + pub(in crate::daemon) async fn lock(&self, id: u32) -> MutexGuard<'_, ()> { + // IDs sharing a shard serialize conservatively while memory remains strictly bounded + let index = interaction_gate_index(id); + self.shards[index].lock().await + } +} + +fn interaction_gate_index(id: u32) -> usize { + usize::try_from(id).unwrap_or(usize::MAX) % INTERACTION_GATE_SHARDS +} + +#[cfg(test)] +#[path = "tests/interaction_gates.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/daemon/state/mod.rs b/crates/unixnotis-daemon/src/daemon/state/mod.rs index 90143ea83..5a413d914 100644 --- a/crates/unixnotis-daemon/src/daemon/state/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/mod.rs @@ -1,12 +1,13 @@ //! Shared daemon state and signal fanout coordination -mod cache; +mod interaction_gates; mod model; -mod notifications; -mod runtime; -mod scheduler; -mod signals; +mod notification_commit; +mod notification_lifecycle; +mod schedulers; +mod status; +pub(in crate::daemon) use interaction_gates::InteractionGates; pub use model::DaemonState; #[cfg(test)] diff --git a/crates/unixnotis-daemon/src/daemon/state/model.rs b/crates/unixnotis-daemon/src/daemon/state/model.rs index 3f0ec7f7f..01c47368d 100644 --- a/crates/unixnotis-daemon/src/daemon/state/model.rs +++ b/crates/unixnotis-daemon/src/daemon/state/model.rs @@ -1,40 +1,81 @@ +use std::collections::HashMap; use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock, RwLock as StdRwLock}; +use arc_swap::ArcSwap; use tokio::sync::Mutex; -use unixnotis_core::{Config, ControlState, PopupGateState}; +use unixnotis_core::Config; use zbus::Connection; +use crate::daemon::auth::{ + build_trusted_control_snapshots_for_current_executable, TrustedExecutableSnapshot, +}; +use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; use crate::store::NotificationStore; -use crate::daemon::signal_burst::NotificationBurstState; +use crate::daemon::events::DaemonEventPublisher; +use crate::daemon::notifications::identity::{DesktopIdentityIndex, DesktopIndexRefreshHandle}; +use crate::daemon::notifications::NotificationBurstState; +use crate::daemon::notifications::SenderMetadataCache; +use crate::daemon::state::InteractionGates; + +#[derive(Clone, Default)] +#[expect( + clippy::struct_excessive_bools, + reason = "the control protocol exposes four independent readiness flags" +)] +pub(in crate::daemon::state) struct UiHealthState { + pub(in crate::daemon::state) center_process_running: bool, + pub(in crate::daemon::state) center_ready: bool, + pub(in crate::daemon::state) panel_ready_owner: Option, + pub(in crate::daemon::state) popups_process_running: bool, + pub(in crate::daemon::state) popups_ready: bool, + pub(in crate::daemon::state) popups_ready_owner: Option, + pub(in crate::daemon::state) revision: u64, +} /// Shared daemon state guarded behind an async mutex pub struct DaemonState { pub store: Mutex, + // Action, reply, and replacement commits for one numeric ID share this bounded gate + pub(in crate::daemon) interaction_gates: InteractionGates, + // This map is built before the control object is exported and never rebuilt from callers + pub(in crate::daemon) trusted_executables: Arc>, /// Immutable sound settings resolved at startup pub sound: SoundSettings, pub(in crate::daemon::state) connection: Connection, // Panel control should only succeed once the center has subscribed // This avoids accepting requests that no live listener can receive - pub(in crate::daemon::state) panel_ready: AtomicBool, - pub(in crate::daemon::state) popups_running: AtomicBool, + // One lock keeps process, readiness, owner, and revision values coherent + pub(in crate::daemon::state) ui_health: StdRwLock, + pub(in crate::daemon::state) popups_unready_warning_emitted: AtomicBool, // Scheduler is installed after state startup so close paths can cancel timers pub(in crate::daemon::state) scheduler: OnceLock, // Warn once if scheduler-backed operations happen before install pub(in crate::daemon::state) scheduler_missing_warned: AtomicBool, - // Cache the last control-state snapshot so no-op signals can be skipped - pub(in crate::daemon) last_emitted_state: StdMutex>, - // Popup UIs only care about the gate, not panel history counters - pub(in crate::daemon) last_emitted_popup_gate: StdMutex>, + // Timed DND has one coalesced wall-clock deadline + pub(in crate::daemon::state) dnd_scheduler: OnceLock, + pub(in crate::daemon::state) dnd_scheduler_missing_warned: AtomicBool, + // DND persistence and timer replacement must commit in mutation order + pub(in crate::daemon::state) dnd_write_lock: Mutex<()>, + // Connection-facing signal policy stays outside mutable domain state + pub(in crate::daemon) events: DaemonEventPublisher, // Burst tracking lets one noisy sender fall back to snapshot invalidation // instead of forcing a storm of full add/update fanout pub(in crate::daemon::state) notification_signal_bursts: StdMutex>, + // Unique sender identities avoid repeated bus and procfs lookups during bursts + pub(in crate::daemon) sender_metadata_cache: SenderMetadataCache, + // Readers load one immutable snapshot while filesystem refresh swaps the complete index + pub(crate) desktop_identity_index: Arc>, + // The refresh worker owns watcher replacement and atomic index publication + pub(in crate::daemon::state) desktop_index_refresh: OnceLock, // Trial mode allows local rebuild loops without forcing daemon restarts for control auth pub(in crate::daemon::state) trial_mode: bool, + // Normal startup supplies None; private-bus protocol tests can inject one unique owner + pub(in crate::daemon::state) preauthorized_control_owner: Option, } impl DaemonState { @@ -43,9 +84,18 @@ impl DaemonState { config: Config, sound: SoundSettings, trial_mode: bool, + desktop_identity_index: Arc>, + preauthorized_control_owner: Option, ) -> Arc { let store = NotificationStore::new(config); - Self::new_with_store(connection, store, sound, trial_mode) + Self::new_with_store( + connection, + store, + sound, + trial_mode, + desktop_identity_index, + preauthorized_control_owner, + ) } pub(crate) fn new_with_store( @@ -53,24 +103,52 @@ impl DaemonState { store: NotificationStore, sound: SoundSettings, trial_mode: bool, + desktop_identity_index: Arc>, + preauthorized_control_owner: Option, ) -> Arc { // One construction path keeps scheduler, signal cache, and popup state in sync + let trusted_executables = + Arc::new(build_trusted_control_snapshots_for_current_executable()); Arc::new(Self { store: Mutex::new(store), + interaction_gates: InteractionGates::new(), + trusted_executables, sound, - connection, - panel_ready: AtomicBool::new(false), - popups_running: AtomicBool::new(false), + connection: connection.clone(), + ui_health: StdRwLock::new(UiHealthState::default()), + popups_unready_warning_emitted: AtomicBool::new(false), scheduler: OnceLock::new(), scheduler_missing_warned: AtomicBool::new(false), - last_emitted_state: StdMutex::new(None), - last_emitted_popup_gate: StdMutex::new(None), + dnd_scheduler: OnceLock::new(), + dnd_scheduler_missing_warned: AtomicBool::new(false), + dnd_write_lock: Mutex::new(()), + events: DaemonEventPublisher::new(connection), notification_signal_bursts: StdMutex::new(std::collections::HashMap::new()), + sender_metadata_cache: SenderMetadataCache::new(), + desktop_identity_index, + desktop_index_refresh: OnceLock::new(), trial_mode, + preauthorized_control_owner, }) } pub(crate) const fn connection(&self) -> &Connection { &self.connection } + + pub(in crate::daemon) fn trusted_executables( + &self, + ) -> &HashMap { + &self.trusted_executables + } + + pub(crate) fn set_desktop_index_refresh(&self, handle: DesktopIndexRefreshHandle) { + let _ = self.desktop_index_refresh.set(handle); + } + + pub(crate) fn request_desktop_index_refresh(&self) -> bool { + self.desktop_index_refresh + .get() + .is_some_and(DesktopIndexRefreshHandle::request_manual) + } } diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs b/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs new file mode 100644 index 000000000..479c2bf22 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/notification_commit.rs @@ -0,0 +1,36 @@ +//! Serialized notification generation commits + +use unixnotis_core::Notification; + +use crate::expire::ExpirationScheduler; +use crate::store::{CommitDisposition, InsertOutcome}; + +use super::DaemonState; + +impl DaemonState { + pub(in crate::daemon) async fn commit_notification_generation( + &self, + notification: Notification, + replaces_id: u32, + scheduler: &ExpirationScheduler, + ) -> InsertOutcome { + // Every nonzero replacement request shares the ID gate with actions and inline replies + let _interaction = if replaces_id == 0 { + None + } else { + Some(self.interaction_gates.lock(replaces_id).await) + }; + let mut store = self.store.lock().await; + let outcome = store.insert_with_ui_health(notification, replaces_id, &self.ui_health()); + if let CommitDisposition::Active(notification) = &outcome.disposition { + // The committed generation and its expiration ticket become visible together + store.set_expiration(notification, outcome.expiration); + scheduler.schedule(notification.id, notification.generation, outcome.expiration); + } + for key in &outcome.evicted { + // Eviction cancels the exact generation removed by the same commit + scheduler.schedule(key.id, key.generation, None); + } + outcome + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs new file mode 100644 index 000000000..c654b5a55 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/notification_lifecycle.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use tracing::warn; +use unixnotis_core::{Notification, NotificationKey}; + +use super::DaemonState; + +impl DaemonState { + pub async fn dismiss_generation(&self, key: NotificationKey) -> zbus::Result<()> { + let outcome = { + let mut store = self.store.lock().await; + let outcome = store.dismiss_generation(key); + if let Some(removed) = outcome.removed_active { + self.cancel_expiration(removed); + } + outcome + }; + + if !outcome.removed_any() { + return Err(zbus::Error::Failure( + "notification generation is no longer current".to_string(), + )); + } + + let removed_active = outcome.removed_active.is_some(); + let removed = outcome + .removed_active + .or(outcome.removed_history) + .expect("a removed generation must retain its exact key"); + if let Err(err) = self + .publish_notification_dismissed(removed, removed_active) + .await + { + warn!( + ?err, + id = key.id, + generation = key.generation, + "generation-safe dismiss committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } + + pub async fn dismiss_replied_if_current( + &self, + id: u32, + expected: &Arc, + ) -> zbus::Result { + let outcome = { + // Object identity prevents an older action from deleting a same-ID replacement + let mut store = self.store.lock().await; + let outcome = store.dismiss_replied_generation(id, expected); + if let Some(key) = outcome.removed_active { + self.cancel_expiration(key); + } + outcome + }; + if !outcome.removed_any() { + return Ok(false); + } + + let removed_active = outcome.removed_active.is_some(); + let key = outcome + .removed_active + .or(outcome.removed_history) + .expect("a removed reply target must retain its generation"); + if let Err(err) = self + .publish_notification_dismissed(key, removed_active) + .await + { + warn!( + ?err, + id, "generation-safe dismiss committed but one or more D-Bus signals failed" + ); + } + Ok(true) + } + + pub async fn dismiss_actioned_if_current( + &self, + id: u32, + expected: &Arc, + ) -> zbus::Result { + let removed = { + // Action completion removes only the exact active generation + let mut store = self.store.lock().await; + let removed = store.dismiss_active_if_current(id, expected); + if removed { + self.cancel_expiration(expected.key()); + } + removed + }; + + if !removed { + // A replacement or concurrent close already won the store race + return Ok(false); + } + + // Actioned notifications are dismissed, not archived as expired history + if let Err(error) = self + .publish_notification_dismissed(expected.key(), true) + .await + { + warn!( + ?error, + id, + generation = expected.generation, + "actioned notification was removed but close publication failed" + ); + } + Ok(true) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/notifications.rs deleted file mode 100644 index baf9bd763..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/notifications.rs +++ /dev/null @@ -1,51 +0,0 @@ -use tracing::warn; -use unixnotis_core::CloseReason; - -use super::DaemonState; - -impl DaemonState { - pub async fn close_notification(&self, id: u32, reason: CloseReason) -> zbus::Result<()> { - let removed = { - let mut store = self.store.lock().await; - store.close(id, reason) - }; - if removed.is_none() { - return Ok(()); - } - // Timer cancel happens before signal fanout so stale wakeups stop right away - self.cancel_expiration(id); - - if let Err(err) = self.emit_close_fanout(id, reason).await { - warn!( - ?err, - id, - reason = reason as u32, - "notification close committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } - - pub async fn dismiss_from_panel(&self, id: u32) -> zbus::Result<()> { - let outcome = { - let mut store = self.store.lock().await; - store.dismiss_from_panel(id) - }; - - if !outcome.removed_any() { - return Ok(()); - } - - if outcome.removed_active { - // Panel dismiss removes the active entry, so its timer must go too - self.cancel_expiration(id); - } - if let Err(err) = self.emit_dismiss_fanout(id, outcome.removed_active).await { - warn!( - ?err, - id, "panel dismiss committed but one or more D-Bus signals failed" - ); - } - Ok(()) - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/runtime.rs deleted file mode 100644 index a67e7ccce..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/runtime.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::sync::atomic::Ordering; - -use crate::daemon::signal_burst::{notification_signal_mode_for_sender, NotificationSignalMode}; - -use super::DaemonState; - -impl DaemonState { - pub(crate) fn set_panel_ready(&self, ready: bool) { - // SeqCst keeps state changes easy to follow during crash recovery - self.panel_ready.store(ready, Ordering::SeqCst); - } - - pub(crate) fn set_popups_running(&self, running: bool) { - // Popup health is tracked for supervision and diagnostics - self.popups_running.store(running, Ordering::SeqCst); - } - - pub(crate) fn panel_ready(&self) -> bool { - self.panel_ready.load(Ordering::SeqCst) - } - - pub(crate) fn notification_signal_mode( - &self, - sender_name: Option<&str>, - ) -> NotificationSignalMode { - notification_signal_mode_for_sender( - &self.notification_signal_bursts, - sender_name.unwrap_or(""), - ) - } - - pub(crate) const fn trial_mode(&self) -> bool { - self.trial_mode - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/scheduler.rs deleted file mode 100644 index 0f39d7574..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/scheduler.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::sync::atomic::Ordering; - -use tracing::warn; - -use crate::expire::ExpirationScheduler; - -use super::DaemonState; - -impl DaemonState { - pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { - // Scheduler is wired once during daemon startup - if self.scheduler.set(scheduler).is_err() { - warn!("expiration scheduler was already installed; ignoring duplicate initialization"); - return; - } - self.scheduler_missing_warned.store(false, Ordering::SeqCst); - } - - fn scheduler(&self) -> Option { - // Cloning the sender handle is cheap and keeps await points simple - let scheduler = self.scheduler.get().cloned(); - if scheduler.is_none() && self.mark_missing_scheduler_warning_needed() { - warn!("expiration scheduler is unavailable during live daemon operation"); - } - scheduler - } - - pub(in crate::daemon::state) fn mark_missing_scheduler_warning_needed(&self) -> bool { - !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) - } - - pub(in crate::daemon) fn cancel_expiration(&self, id: u32) { - // Missing scheduler means startup is still incomplete, so skip quietly - let Some(scheduler) = self.scheduler() else { - return; - }; - scheduler.schedule(id, None); - } - - pub fn cancel_expirations(&self, ids: &[u32]) { - // Cancel timers for every removed active id so stale wakeups do not build up - // Per-id cancel keeps the existing lazy heap design simple and predictable - let Some(scheduler) = self.scheduler() else { - return; - }; - for id in ids { - scheduler.schedule(*id, None); - } - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/schedulers.rs b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs new file mode 100644 index 000000000..695a1d117 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/schedulers.rs @@ -0,0 +1,172 @@ +//! Expiration and timed-DND scheduler ownership for shared daemon state + +use std::sync::atomic::Ordering; + +use tokio::sync::MutexGuard; +use tracing::{debug, warn}; + +use crate::dnd_expiration::DndExpirationScheduler; +use crate::expire::ExpirationScheduler; +use crate::store::DndWrite; +use unixnotis_core::NotificationKey; + +use super::DaemonState; + +const MAX_DND_DURATION_SECONDS: i64 = 366 * 24 * 60 * 60; + +impl DaemonState { + pub(in crate::daemon) async fn lock_dnd_write(&self) -> MutexGuard<'_, ()> { + // One writer keeps disk state and the scheduled deadline in the same order + self.dnd_write_lock.lock().await + } + + pub fn set_dnd_scheduler(&self, scheduler: DndExpirationScheduler) { + if self.dnd_scheduler.set(scheduler).is_err() { + warn!("DND scheduler was already installed; ignoring duplicate initialization"); + return; + } + self.dnd_scheduler_missing_warned + .store(false, Ordering::SeqCst); + } + + pub(crate) fn schedule_dnd_expiration(&self, expires_at: Option) { + let Some(scheduler) = self.dnd_scheduler.get() else { + if !self + .dnd_scheduler_missing_warned + .swap(true, Ordering::SeqCst) + { + warn!("DND scheduler is unavailable during live daemon operation"); + } + return; + }; + scheduler.schedule(expires_at); + } + + pub(in crate::daemon) async fn apply_dnd_state(&self, enabled: bool) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // The store records the previous revision so failed persistence can roll back safely + store.set_dnd(enabled) + }; + self.finalize_dnd_write(write).await + } + + pub(in crate::daemon) async fn apply_dnd_until( + &self, + expires_at: i64, + ) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let now = chrono::Utc::now().timestamp(); + let duration = expires_at.saturating_sub(now); + if duration <= 0 || duration > MAX_DND_DURATION_SECONDS { + return Err(zbus::fdo::Error::InvalidArgs( + "DND expiration must be within the next 366 days".to_string(), + )); + } + let write = { + let mut store = self.store.lock().await; + store.set_dnd_until(expires_at) + }; + self.finalize_dnd_write(write).await + } + + pub(in crate::daemon) async fn apply_toggle_dnd(&self) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // Toggle computation and mutation share one store revision + store.toggle_dnd() + }; + self.finalize_dnd_write(write).await + } + + pub(crate) async fn apply_dnd_expiration(&self, expires_at: i64) -> zbus::fdo::Result<()> { + let _write_guard = self.lock_dnd_write().await; + let write = { + let mut store = self.store.lock().await; + // Stale timers cannot disable a newer timed or indefinite DND value + store.expire_dnd_if_current(expires_at, chrono::Utc::now().timestamp()) + }; + self.finalize_dnd_write(write).await + } + + async fn finalize_dnd_write(&self, write: DndWrite) -> zbus::fdo::Result<()> { + if let Some(store) = write.persist.as_ref() { + // Disk I/O stays outside the notification-store lock + if let Err(error) = store.persist(write.current, write.current_expires_at) { + warn!(?error, "failed to persist do-not-disturb state"); + let mut state = self.store.lock().await; + let rolled_back = state.rollback_dnd_write_if_current(&write); + if rolled_back { + debug!( + revision = write.revision, + current = write.current, + previous = write.previous, + "rolled back do-not-disturb state after persistence failure" + ); + } else { + debug!( + revision = write.revision, + current = write.current, + "skipped do-not-disturb rollback because newer state already exists" + ); + } + return Err(zbus::fdo::Error::Failed( + "failed to persist do-not-disturb state".to_string(), + )); + } + } + if write.changed { + // Timer and signal updates follow the durable state transition + self.schedule_dnd_expiration(write.current_expires_at); + if let Err(error) = self.publish_state_changed().await { + warn!( + ?error, + "do-not-disturb state changed but post-commit signal fanout failed" + ); + } + } + Ok(()) + } + + pub fn set_scheduler(&self, scheduler: ExpirationScheduler) { + // Scheduler is wired once during daemon startup + if self.scheduler.set(scheduler).is_err() { + warn!("expiration scheduler was already installed; ignoring duplicate initialization"); + return; + } + self.scheduler_missing_warned.store(false, Ordering::SeqCst); + } + + fn scheduler(&self) -> Option { + // Cloning the sender handle is cheap and keeps await points simple + let scheduler = self.scheduler.get().cloned(); + if scheduler.is_none() && self.mark_missing_scheduler_warning_needed() { + warn!("expiration scheduler is unavailable during live daemon operation"); + } + scheduler + } + + pub(in crate::daemon::state) fn mark_missing_scheduler_warning_needed(&self) -> bool { + !self.scheduler_missing_warned.swap(true, Ordering::SeqCst) + } + + pub(in crate::daemon) fn cancel_expiration(&self, key: NotificationKey) { + // Missing scheduler means startup is still incomplete, so skip quietly + let Some(scheduler) = self.scheduler() else { + return; + }; + scheduler.schedule(key.id, key.generation, None); + } + + pub fn cancel_expirations(&self, keys: &[NotificationKey]) { + // Per-id cancel keeps the lazy expiration heap bounded without rebuilding it here + let Some(scheduler) = self.scheduler() else { + return; + }; + for key in keys { + scheduler.schedule(key.id, key.generation, None); + } + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/signals.rs b/crates/unixnotis-daemon/src/daemon/state/signals.rs deleted file mode 100644 index 96ab7fa2b..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/signals.rs +++ /dev/null @@ -1,199 +0,0 @@ -use unixnotis_core::{CloseReason, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; -use zbus::SignalContext; - -use crate::daemon::{ControlServer, NotificationServer, NOTIFICATIONS_OBJECT_PATH}; -use crate::store::NotificationStore; - -use super::cache::should_emit_cached; -use super::DaemonState; - -impl DaemonState { - // Sends all the "this notification closed" messages that different listeners expect - pub(in crate::daemon) async fn emit_close_fanout( - &self, - id: u32, - reason: CloseReason, - ) -> zbus::Result<()> { - // Keep the first thing that goes wrong, but still try to send every signal - let mut first_error = None; - - // Tell the standard notification interface that this notification closed - self.emit_freedesktop_close(id, reason as u32, &mut first_error) - .await; - - // Tell this daemon's control interface that the same notification closed - self.emit_control_close(id, reason, &mut first_error).await; - - // After a close, the stored state may look different, so tell clients about that too - if let Err(err) = self.emit_state_changed().await { - record_signal_error(&mut first_error, err); - } - - // Return success only if every attempted signal avoided errors - first_error.map_or(Ok(()), Err) - } - - // Sends the signals needed when a notification is dismissed by the user - pub(in crate::daemon) async fn emit_dismiss_fanout( - &self, - id: u32, - removed_active: bool, - ) -> zbus::Result<()> { - // Save the first error, while still giving the other signals a chance to run - let mut first_error = None; - - // Only the active notification needs the freedesktop close signal here - if removed_active { - self.emit_freedesktop_close(id, CloseReason::DismissedByUser as u32, &mut first_error) - .await; - } - - // The control side is always told that the notification was dismissed - self.emit_control_close(id, CloseReason::DismissedByUser, &mut first_error) - .await; - - // Let clients know the visible daemon state may have changed after dismissal - if let Err(err) = self.emit_state_changed().await { - record_signal_error(&mut first_error, err); - } - - // Give back the first error if any signal failed - first_error.map_or(Ok(()), Err) - } - - // Sends the close signal on the standard desktop notifications interface - async fn emit_freedesktop_close( - &self, - id: u32, - reason: u32, - first_error: &mut Option, - ) { - // Build the D-Bus signal context for the normal notifications object path - match SignalContext::new(&self.connection, NOTIFICATIONS_OBJECT_PATH) { - Ok(notif_ctx) => { - // Send the actual "notification closed" signal to desktop clients - if let Err(err) = - NotificationServer::notification_closed(¬if_ctx, id, reason).await - { - // Remember this error only if no earlier signal already failed - record_signal_error(first_error, err); - } - } - // If the signal context cannot be made, remember that as the signal error - Err(err) => record_signal_error(first_error, err), - } - } - - // Sends the close signal on this daemon's control interface - async fn emit_control_close( - &self, - id: u32, - reason: CloseReason, - first_error: &mut Option, - ) { - // Build the D-Bus signal context for the control object path - match SignalContext::new(&self.connection, CONTROL_OBJECT_PATH) { - Ok(control_ctx) => { - // Send the control-layer close event with the richer CloseReason enum - if let Err(err) = ControlServer::notification_closed(&control_ctx, id, reason).await - { - // Store the first failure so callers can still hear about a problem - record_signal_error(first_error, err); - } - } - // If the control signal context fails, treat it like any other signal failure - Err(err) => record_signal_error(first_error, err), - } - } - - // Rebuilds the current public state and tells clients only if something changed - pub(in crate::daemon) async fn emit_state_changed(&self) -> zbus::Result<()> { - // Lock the store briefly so we can take a clean snapshot of the current state - let state = { - let store = self.store.lock().await; - control_state_from_store(&store) - }; - - // Work out whether popups should currently be allowed from that state - let popup_gate = popup_gate_from_state(&state); - - // Duplicate broadcasts add D-Bus churn without changing UI behavior - let should_emit_state = should_emit_cached(&self.last_emitted_state, &state); - - // Avoid sending the popup gate signal if clients already know this value - let should_emit_popup_gate = should_emit_cached(&self.last_emitted_popup_gate, &popup_gate); - - // If neither value changed, there is nothing useful to send - if !should_emit_any_state_signal(should_emit_state, should_emit_popup_gate) { - return Ok(()); - } - - // Create one control context and reuse it for whichever state signals are needed - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - - // Keep the first send error while still trying the other state signal - let mut first_error = None; - - // Send the full state update only when the cached state says it is new - if should_emit_state { - if let Err(err) = ControlServer::state_changed(&control_ctx, state).await { - record_signal_error(&mut first_error, err); - } - } - - // Send the popup gate update only when that specific value changed - if should_emit_popup_gate { - if let Err(err) = ControlServer::popup_gate_changed(&control_ctx, popup_gate).await { - record_signal_error(&mut first_error, err); - } - } - - // Report the first signal error, or success if both needed signals worked - first_error.map_or(Ok(()), Err) - } - - // Tells clients to throw away their cached snapshot and fetch a fresh one - pub async fn emit_snapshot_invalidated(&self) -> zbus::Result<()> { - // This signal tells clients their local materialized view may be stale - let control_ctx = SignalContext::new(&self.connection, CONTROL_OBJECT_PATH)?; - ControlServer::snapshot_invalidated(&control_ctx).await - } -} - -pub(in crate::daemon::state) fn control_state_from_store( - store: &NotificationStore, -) -> ControlState { - // Panel consumers still need history and inhibitor counters in one snapshot - ControlState { - dnd_enabled: store.dnd_enabled(), - history_count: store.history_len() as u32, - inhibited: store.inhibited(), - inhibitor_count: store.inhibitor_count(), - } -} - -pub(in crate::daemon::state) const fn popup_gate_from_state( - state: &ControlState, -) -> PopupGateState { - // Popup policy only depends on the gate, so history churn should not wake it up - PopupGateState { - dnd_enabled: state.dnd_enabled, - inhibited: state.inhibited, - } -} - -pub(in crate::daemon::state) const fn should_emit_any_state_signal( - should_emit_state: bool, - should_emit_popup_gate: bool, -) -> bool { - should_emit_state || should_emit_popup_gate -} - -pub(in crate::daemon::state) fn record_signal_error( - first_error: &mut Option, - err: zbus::Error, -) { - if first_error.is_none() { - *first_error = Some(err); - } -} diff --git a/crates/unixnotis-daemon/src/daemon/state/status.rs b/crates/unixnotis-daemon/src/daemon/state/status.rs new file mode 100644 index 000000000..d2834621a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/status.rs @@ -0,0 +1,124 @@ +use std::sync::atomic::Ordering; + +use unixnotis_core::UiHealth; + +use crate::daemon::notifications::{notification_signal_mode_for_sender, NotificationSignalMode}; + +use super::DaemonState; + +impl DaemonState { + pub(crate) fn set_panel_ready(&self, owner: &str, ready: bool) { + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ready { + // The latest successful handshake owns the active readiness lease + health.panel_ready_owner = Some(owner.to_string()); + health.center_ready = true; + } else if health.panel_ready_owner.as_deref() == Some(owner) { + // Only the matching center generation can clear its lease + health.panel_ready_owner = None; + health.center_ready = false; + } + health.revision = health.revision.saturating_add(1); + } + + pub(crate) fn set_center_process_running(&self, running: bool) { + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + health.center_process_running = running; + // Every process generation must complete its own subscription handshake + health.panel_ready_owner = None; + health.center_ready = false; + health.revision = health.revision.saturating_add(1); + } + + pub(crate) fn set_popups_process_running(&self, running: bool) { + // Popup health is tracked for supervision and diagnostics + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + health.popups_process_running = running; + if !running { + health.popups_ready_owner = None; + health.popups_ready = false; + } + health.revision = health.revision.saturating_add(1); + } + + pub(crate) fn set_popups_ready(&self, owner: &str, ready: bool) { + let mut health = self + .ui_health + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if ready { + health.popups_ready_owner = Some(owner.to_string()); + health.popups_ready = true; + self.popups_unready_warning_emitted + .store(false, Ordering::SeqCst); + } else if health.popups_ready_owner.as_deref() == Some(owner) { + health.popups_ready_owner = None; + health.popups_ready = false; + } + health.revision = health.revision.saturating_add(1); + } + + pub(crate) fn panel_ready(&self) -> bool { + self.ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .center_ready + } + + pub(crate) fn popups_ready(&self) -> bool { + self.ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .popups_ready + } + + pub(crate) fn should_warn_popups_unready(&self) -> bool { + !self.popups_ready() + && self + .popups_unready_warning_emitted + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + } + + pub(crate) fn ui_health(&self) -> UiHealth { + // A single read lock prevents mixed fields and revision values + let health = self + .ui_health + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + UiHealth { + center_process_running: health.center_process_running, + center_ready: health.center_ready, + popups_process_running: health.popups_process_running, + popups_ready: health.popups_ready, + revision: health.revision, + } + } + + pub(crate) fn notification_signal_mode( + &self, + sender_name: Option<&str>, + ) -> NotificationSignalMode { + notification_signal_mode_for_sender( + &self.notification_signal_bursts, + sender_name.unwrap_or(""), + ) + } + + pub(crate) const fn trial_mode(&self) -> bool { + self.trial_mode + } + + pub(in crate::daemon) fn control_owner_is_preauthorized(&self, owner: &str) -> bool { + self.preauthorized_control_owner.as_deref() == Some(owner) + } +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs b/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs deleted file mode 100644 index 703e69dd3..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/tests/cache.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::sync::Mutex; - -use unixnotis_core::{ControlState, PopupGateState}; - -use super::super::cache::should_emit_cached; - -#[test] -fn cached_state_emits_first_value_then_suppresses_duplicates() { - let cache = Mutex::new(None); - let state = ControlState { - dnd_enabled: false, - history_count: 1, - inhibited: false, - inhibitor_count: 0, - }; - - // First value must be emitted because clients have no previous state - assert!(should_emit_cached(&cache, &state)); - // Identical values should not wake D-Bus subscribers again - assert!(!should_emit_cached(&cache, &state)); -} - -#[test] -fn cached_state_emits_when_any_gate_field_changes() { - let cache = Mutex::new(None); - let open = PopupGateState { - dnd_enabled: false, - inhibited: false, - }; - let dnd = PopupGateState { - dnd_enabled: true, - inhibited: false, - }; - - assert!(should_emit_cached(&cache, &open)); - // A changed popup gate affects visibility policy, so it must emit - assert!(should_emit_cached(&cache, &dnd)); - assert!(!should_emit_cached(&cache, &dnd)); -} - -#[test] -fn cached_state_emits_after_counter_change() { - let cache = Mutex::new(None); - let first = ControlState { - dnd_enabled: false, - history_count: 0, - inhibited: false, - inhibitor_count: 0, - }; - let changed = ControlState { - history_count: 1, - ..first - }; - - assert!(should_emit_cached(&cache, &first)); - assert!(should_emit_cached(&cache, &changed)); - assert!(!should_emit_cached(&cache, &changed)); -} - -#[test] -fn cached_state_recovers_from_poisoned_mutex() { - let cache = Mutex::new(None); - let _ = std::panic::catch_unwind(|| { - let _guard = cache.lock().expect("lock before poison"); - panic!("poison cache"); - }); - - let state = PopupGateState { - dnd_enabled: false, - inhibited: true, - }; - - assert!(should_emit_cached(&cache, &state)); - assert!(!should_emit_cached(&cache, &state)); -} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs b/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs new file mode 100644 index 000000000..865243d4c --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/interaction_gates.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use super::{interaction_gate_index, InteractionGates, INTERACTION_GATE_SHARDS}; + +#[test] +fn every_protocol_id_maps_inside_the_fixed_interaction_shards() { + assert_eq!(interaction_gate_index(0), 0); + assert_eq!(interaction_gate_index(127), 127); + assert_eq!(interaction_gate_index(128), 0); + assert!(interaction_gate_index(u32::MAX) < INTERACTION_GATE_SHARDS); +} + +#[tokio::test] +async fn same_id_waits_for_the_existing_interaction_guard() { + let gates = Arc::new(InteractionGates::new()); + let first = gates.lock(42).await; + let waiting_gates = Arc::clone(&gates); + let waiting = tokio::spawn(async move { + let _second = waiting_gates.lock(42).await; + }); + + tokio::task::yield_now().await; + assert!( + !waiting.is_finished(), + "same-ID work must remain serialized" + ); + drop(first); + waiting.await.expect("waiting interaction task"); +} + +#[tokio::test] +async fn different_shards_can_progress_independently() { + let gates = Arc::new(InteractionGates::new()); + let _first = gates.lock(1).await; + let other_gates = Arc::clone(&gates); + let other = tokio::spawn(async move { + let _second = other_gates.lock(2).await; + }); + + tokio::time::timeout(std::time::Duration::from_millis(100), other) + .await + .expect("different shard should not wait") + .expect("different-shard interaction task"); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs index a526c7c32..9dc4c09db 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/mod.rs @@ -1,5 +1,4 @@ -mod cache; -mod notifications; -mod runtime; +mod notification_lifecycle; mod scheduler; -mod signals; +mod status; +mod support; diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs new file mode 100644 index 000000000..39973332a --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/notification_lifecycle.rs @@ -0,0 +1,285 @@ +use std::collections::HashMap; +use std::time::Duration; + +use chrono::Utc; +use unixnotis_core::{CloseReason, Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; + +use crate::expire::{ExpirationCommand, ExpirationScheduler}; +use crate::test_support::daemon_state_for_test; + +fn notification(summary: &str) -> Notification { + Notification { + id: 0, + generation: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +async fn next_cancel_id( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u32 { + let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) + .await + .expect("cancel command should arrive") + .expect("scheduler channel should stay open"); + match command { + ExpirationCommand::Cancel { id, .. } => id, + ExpirationCommand::Schedule { .. } => panic!("dismiss should cancel expiration"), + } +} + +#[tokio::test] +async fn generation_dismiss_removes_matching_history_without_canceling_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let key = { + let mut store = state.store.lock().await; + let inserted = store.insert(notification("history"), 0); + let key = inserted.active_notification().key(); + store.close(key.id, CloseReason::Expired); + key + }; + + state + .dismiss_generation(key) + .await + .expect("matching history generation dismiss should succeed"); + + assert!(receiver.try_recv().is_err()); + assert!(state + .store + .lock() + .await + .list_history() + .into_iter() + .all(|view| view.key() != key)); +} + +#[tokio::test] +async fn generation_dismiss_rejects_a_missing_notification() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + + state + .dismiss_generation(unixnotis_core::NotificationKey { + id: 999, + generation: 1, + }) + .await + .expect_err("missing generation dismiss should fail"); + + assert!(receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn generation_safe_dismiss_keeps_replacement_and_its_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (id, original) = { + let mut store = state.store.lock().await; + let original = store + .insert(notification("original"), 0) + .active_notification(); + let id = original.id; + let replacement = store.insert(notification("replacement"), id); + assert!(replacement.replaced); + (id, original) + }; + + let removed = state + .dismiss_replied_if_current(id, &original) + .await + .expect("stale generation dismiss should remain a no-op"); + + assert!(!removed); + assert!(receiver.try_recv().is_err()); + let active = state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active"); + assert_eq!(active.summary, "replacement"); +} + +#[tokio::test] +async fn generation_safe_panel_dismiss_rejects_a_stale_same_id_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (stale_key, replacement_key) = { + let mut store = state.store.lock().await; + let original = store + .insert(notification("original"), 0) + .active_notification(); + let replacement = store + .insert(notification("replacement"), original.id) + .active_notification(); + (original.key(), replacement.key()) + }; + + state + .dismiss_generation(stale_key) + .await + .expect_err("stale generation dismiss should fail"); + + assert!(receiver.try_recv().is_err()); + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(replacement_key.id) + .expect("replacement should remain active") + .key(), + replacement_key + ); +} + +#[tokio::test] +async fn generation_safe_panel_dismiss_removes_and_cancels_the_current_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let key = state + .store + .lock() + .await + .insert(notification("current"), 0) + .active_notification() + .key(); + + state + .dismiss_generation(key) + .await + .expect("current generation dismiss should succeed"); + + assert_eq!(next_cancel_id(&mut receiver).await, key.id); + assert!(state + .store + .lock() + .await + .active_notification_view(key.id) + .is_none()); +} + +#[tokio::test] +async fn action_dismissal_removes_only_the_current_active_generation() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let target = state + .store + .lock() + .await + .insert(notification("action"), 0) + .active_notification(); + + assert!(state + .dismiss_actioned_if_current(target.id, &target) + .await + .expect("action dismissal should succeed")); + assert_eq!(next_cancel_id(&mut receiver).await, target.id); + let store = state.store.lock().await; + assert!(store.active_notification_view(target.id).is_none()); + assert!(store.list_history().is_empty()); +} + +#[tokio::test] +async fn action_dismissal_keeps_a_same_id_replacement() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let (id, original) = { + let mut store = state.store.lock().await; + let original = store + .insert(notification("original"), 0) + .active_notification(); + let replacement = store.insert(notification("replacement"), original.id); + assert!(replacement.replaced); + (original.id, original) + }; + + assert!(!state + .dismiss_actioned_if_current(id, &original) + .await + .expect("stale action dismissal should be a no-op")); + assert!(receiver.try_recv().is_err()); + assert_eq!( + state + .store + .lock() + .await + .active_notification_view(id) + .expect("replacement should remain active") + .summary, + "replacement" + ); +} + +#[tokio::test] +async fn close_notification_removes_active_notification_and_cancels_timer() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + let id = { + let mut store = state.store.lock().await; + store + .insert(notification("close"), 0) + .active_notification() + .id + }; + + state + .close_notification(id, CloseReason::ClosedByCall) + .await + .expect("close should succeed"); + + assert_eq!(next_cancel_id(&mut receiver).await, id); + assert!(state + .store + .lock() + .await + .active_notification_view(id) + .is_none()); +} + +#[tokio::test] +async fn close_notification_missing_id_is_noop() { + let state = daemon_state_for_test(false).await; + let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); + state.set_scheduler(scheduler); + + state + .close_notification(777, CloseReason::ClosedByCall) + .await + .expect("missing close should succeed"); + + assert!(receiver.try_recv().is_err()); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs b/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs deleted file mode 100644 index 77b1b13ae..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/tests/notifications.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::collections::HashMap; -use std::time::Duration; - -use chrono::Utc; -use unixnotis_core::{CloseReason, Notification, NotificationImage, Urgency}; -use zbus::zvariant::OwnedValue; - -use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::test_support::daemon_state_for_test; - -fn notification(summary: &str) -> Notification { - Notification { - id: 0, - app_name: "TestApp".to_string(), - app_icon: String::new(), - summary: summary.to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 0, - received_at: Utc::now(), - sender_name: Some(":1.test".to_string()), - sender_pid: Some(1234), - sender_start_time: Some(555), - sender_executable: Some("/usr/bin/test-app".to_string()), - } -} - -async fn next_cancel_id( - receiver: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> u32 { - let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) - .await - .expect("cancel command should arrive") - .expect("scheduler channel should stay open"); - match command { - ExpirationCommand::Cancel { id } => id, - ExpirationCommand::Schedule { .. } => panic!("dismiss should cancel expiration"), - } -} - -#[tokio::test] -async fn dismiss_from_panel_removes_active_notification_and_cancels_timer() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - let id = { - let mut store = state.store.lock().await; - store.insert(notification("active"), 0).notification.id - }; - - state - .dismiss_from_panel(id) - .await - .expect("panel dismiss should succeed"); - - assert_eq!(next_cancel_id(&mut receiver).await, id); - assert!(state - .store - .lock() - .await - .active_notification_view(id) - .is_none()); -} - -#[tokio::test] -async fn dismiss_from_panel_removes_history_without_canceling_timer() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - let id = { - let mut store = state.store.lock().await; - let inserted = store.insert(notification("history"), 0); - let id = inserted.notification.id; - store.close(id, CloseReason::DismissedByUser); - id - }; - - state - .dismiss_from_panel(id) - .await - .expect("history dismiss should succeed"); - - assert!(receiver.try_recv().is_err()); - assert!(state - .store - .lock() - .await - .list_history() - .into_iter() - .all(|view| view.id != id)); -} - -#[tokio::test] -async fn dismiss_from_panel_missing_id_is_noop() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - - state - .dismiss_from_panel(999) - .await - .expect("missing dismiss should succeed"); - - assert!(receiver.try_recv().is_err()); -} - -#[tokio::test] -async fn close_notification_removes_active_notification_and_cancels_timer() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - let id = { - let mut store = state.store.lock().await; - store.insert(notification("close"), 0).notification.id - }; - - state - .close_notification(id, CloseReason::ClosedByCall) - .await - .expect("close should succeed"); - - assert_eq!(next_cancel_id(&mut receiver).await, id); - assert!(state - .store - .lock() - .await - .active_notification_view(id) - .is_none()); -} - -#[tokio::test] -async fn close_notification_missing_id_is_noop() { - let state = daemon_state_for_test(false).await; - let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); - state.set_scheduler(scheduler); - - state - .close_notification(777, CloseReason::ClosedByCall) - .await - .expect("missing close should succeed"); - - assert!(receiver.try_recv().is_err()); -} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs b/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs deleted file mode 100644 index b11a2998e..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/tests/runtime.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::test_support::daemon_state_for_test; -use std::sync::atomic::Ordering; - -use super::super::DaemonState; - -impl DaemonState { - pub(crate) fn popups_running(&self) -> bool { - // Test assertions observe the same sequentially consistent flag used by supervision - self.popups_running.load(Ordering::SeqCst) - } -} - -#[tokio::test] -async fn daemon_state_boolean_flags_reflect_runtime_updates() { - let state = daemon_state_for_test(true).await; - - assert!(state.trial_mode()); - assert!(!state.panel_ready()); - assert!(!state.popups_running()); - - // These atomics gate user-visible command handling, so getters must reflect writes exactly - state.set_panel_ready(true); - state.set_popups_running(true); - - assert!(state.panel_ready()); - assert!(state.popups_running()); -} - -#[tokio::test] -async fn daemon_state_boolean_flags_can_return_to_false() { - let state = daemon_state_for_test(true).await; - - state.set_panel_ready(true); - state.set_popups_running(true); - state.set_panel_ready(false); - state.set_popups_running(false); - - assert!(!state.panel_ready()); - assert!(!state.popups_running()); -} - -#[tokio::test] -async fn daemon_state_trial_mode_can_be_disabled() { - let state = daemon_state_for_test(false).await; - - // Trial mode changes control authorization, so false must stay observable - assert!(!state.trial_mode()); -} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs index 33d8a6eb5..21bdd88e9 100644 --- a/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs +++ b/crates/unixnotis-daemon/src/daemon/state/tests/scheduler.rs @@ -1,7 +1,123 @@ use std::time::Duration; +use chrono::Utc; +use unixnotis_core::{Config, NotificationKey}; + use crate::expire::{ExpirationCommand, ExpirationScheduler}; -use crate::test_support::daemon_state_for_test; +use crate::store::NotificationStore; +use crate::test_support::{daemon_state_for_test, TempRoot}; + +fn key(id: u32) -> NotificationKey { + NotificationKey { + id, + generation: u64::from(id), + } +} + +#[tokio::test] +async fn dnd_state_rolls_back_when_persistence_fails() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-persist-failure"); + let state_dir = root.join("state"); + std::fs::create_dir_all(&state_dir).expect("create state dir"); + std::fs::write(state_dir.join("unixnotis"), "not a directory").expect("block DND parent"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir); + } + + let error = state + .apply_dnd_state(true) + .await + .expect_err("persistence failure should be reported"); + + assert!(error.to_string().contains("failed to persist")); + assert!(!state.store.lock().await.dnd_enabled()); +} + +#[tokio::test] +async fn toggled_dnd_persists_the_successful_state_change() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-toggle-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + + state + .apply_toggle_dnd() + .await + .expect("toggle should persist"); + + assert!(state.store.lock().await.dnd_enabled()); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted DND state"); + assert!(persisted.contains("\"dnd_enabled\":true")); +} + +#[tokio::test] +async fn timed_dnd_validates_and_persists_a_future_deadline() { + let state = daemon_state_for_test(false).await; + let root = TempRoot::new("dnd-timed-success"); + let state_dir = root.join("state"); + { + let mut store = state.store.lock().await; + *store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + } + let expires_at = Utc::now().timestamp() + 3_600; + + state + .apply_dnd_until(expires_at) + .await + .expect("timed DND should persist"); + + let store = state.store.lock().await; + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + drop(store); + let persisted = std::fs::read_to_string(state_dir.join("unixnotis").join("state.json")) + .expect("read persisted timed DND state"); + assert!(persisted.contains(&format!("\"expires_at\":{expires_at}"))); +} + +#[tokio::test] +async fn timed_dnd_rejects_past_and_excessive_deadlines_without_mutation() { + let state = daemon_state_for_test(false).await; + let now = Utc::now().timestamp(); + + assert!(state.apply_dnd_until(now - 1).await.is_err()); + assert!(state + .apply_dnd_until(now + 367 * 24 * 60 * 60) + .await + .is_err()); + + let store = state.store.lock().await; + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); +} + +#[tokio::test] +async fn dnd_updates_wait_for_the_prior_persistence_commit() { + let state = daemon_state_for_test(false).await; + let guard = state.lock_dnd_write().await; + let mut update = Box::pin(state.apply_dnd_state(true)); + + assert!( + tokio::time::timeout(Duration::from_millis(25), &mut update) + .await + .is_err(), + "later DND update should wait for the current writer" + ); + assert!(!state.store.lock().await.dnd_enabled()); + + drop(guard); + tokio::time::timeout(Duration::from_millis(500), update) + .await + .expect("DND update should resume after the prior commit") + .expect("DND update should succeed"); + assert!(state.store.lock().await.dnd_enabled()); +} #[tokio::test] async fn cancel_expiration_sends_cancel_command_when_scheduler_is_installed() { @@ -9,14 +125,16 @@ async fn cancel_expiration_sends_cancel_command_when_scheduler_is_installed() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); - state.cancel_expiration(42); + state.cancel_expiration(key(42)); let command = tokio::time::timeout(Duration::from_millis(100), receiver.recv()) .await .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => assert_eq!(id, 42), + ExpirationCommand::Cancel { id, generation } => { + assert_eq!((id, generation), (42, 42)); + } ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } } @@ -27,7 +145,7 @@ async fn cancel_expirations_sends_cancel_for_each_id_in_order() { let (scheduler, mut receiver) = ExpirationScheduler::channel_for_test(); state.set_scheduler(scheduler); - state.cancel_expirations(&[7, 8, 9]); + state.cancel_expirations(&[key(7), key(8), key(9)]); let mut ids = Vec::new(); for _ in 0..3 { @@ -36,7 +154,7 @@ async fn cancel_expirations_sends_cancel_for_each_id_in_order() { .expect("cancel command should arrive") .expect("scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => ids.push(id), + ExpirationCommand::Cancel { id, .. } => ids.push(id), ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } } @@ -53,14 +171,16 @@ async fn duplicate_scheduler_install_keeps_original_sender() { state.set_scheduler(first_scheduler); state.set_scheduler(second_scheduler); - state.cancel_expiration(11); + state.cancel_expiration(key(11)); let command = tokio::time::timeout(Duration::from_millis(100), first_receiver.recv()) .await .expect("original scheduler should receive cancel") .expect("original scheduler channel should stay open"); match command { - ExpirationCommand::Cancel { id } => assert_eq!(id, 11), + ExpirationCommand::Cancel { id, generation } => { + assert_eq!((id, generation), (11, 11)); + } ExpirationCommand::Schedule { .. } => panic!("cancel should not schedule a deadline"), } assert!(second_receiver.try_recv().is_err()); @@ -70,8 +190,8 @@ async fn duplicate_scheduler_install_keeps_original_sender() { async fn missing_scheduler_cancel_is_a_noop() { let state = daemon_state_for_test(false).await; - state.cancel_expiration(1); - state.cancel_expirations(&[2, 3]); + state.cancel_expiration(key(1)); + state.cancel_expirations(&[key(2), key(3)]); assert!(!state.mark_missing_scheduler_warning_needed()); } diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs b/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs deleted file mode 100644 index a4e28fb9d..000000000 --- a/crates/unixnotis-daemon/src/daemon/state/tests/signals.rs +++ /dev/null @@ -1,243 +0,0 @@ -use std::time::Duration; - -use futures_util::TryStreamExt; -use unixnotis_core::{CloseReason, Config, ControlState, PopupGateState, CONTROL_OBJECT_PATH}; -use zbus::message::Type; -use zbus::{Connection, MatchRule, Message, MessageStream}; - -use crate::daemon::NOTIFICATIONS_OBJECT_PATH; -use crate::store::NotificationStore; -use crate::test_support::daemon_state_for_test; - -use super::super::signals::{ - control_state_from_store, popup_gate_from_state, record_signal_error, - should_emit_any_state_signal, -}; - -async fn signal_stream( - state: &super::super::DaemonState, - path: &str, - interface: &str, - member: &str, -) -> MessageStream { - let receiver = Connection::session().await.expect("receiver session bus"); - let sender = state - .connection() - .unique_name() - .expect("daemon connection has unique name") - .to_string(); - let rule = MatchRule::builder() - .msg_type(Type::Signal) - .sender(sender.as_str()) - .expect("sender") - .path(path) - .expect("path") - .interface(interface) - .expect("interface") - .member(member) - .expect("member") - .build(); - MessageStream::for_match_rule(rule, &receiver, Some(8)) - .await - .expect("signal stream") -} - -async fn control_signal_stream(state: &super::super::DaemonState, member: &str) -> MessageStream { - signal_stream(state, CONTROL_OBJECT_PATH, "com.unixnotis.Control", member).await -} - -async fn notifications_signal_stream( - state: &super::super::DaemonState, - member: &str, -) -> MessageStream { - signal_stream( - state, - NOTIFICATIONS_OBJECT_PATH, - "org.freedesktop.Notifications", - member, - ) - .await -} - -async fn next_signal(stream: &mut MessageStream) -> Message { - tokio::time::timeout(Duration::from_millis(500), stream.try_next()) - .await - .expect("signal should arrive before timeout") - .expect("signal stream should stay open") - .expect("signal message") -} - -async fn assert_no_signal(stream: &mut MessageStream) { - assert!( - tokio::time::timeout(Duration::from_millis(100), stream.try_next()) - .await - .is_err(), - "signal should not be emitted" - ); -} - -#[test] -fn popup_gate_from_state_ignores_history_and_inhibitor_counts() { - let state = ControlState { - dnd_enabled: true, - history_count: 99, - inhibited: false, - inhibitor_count: 12, - }; - - let gate = popup_gate_from_state(&state); - - assert!(gate.dnd_enabled); - assert!(!gate.inhibited); -} - -#[test] -fn control_state_from_store_reads_dnd_history_and_inhibitors() { - let mut store = NotificationStore::new(Config::default()); - - store.set_dnd(true); - store.add_inhibitor(":1.test".to_string(), "focus".to_string(), 0); - - let state = control_state_from_store(&store); - - assert!(state.dnd_enabled); - assert!(state.inhibited); - assert_eq!(state.inhibitor_count, 1); - assert_eq!(state.history_count, 0); -} - -#[test] -fn should_emit_any_state_signal_is_false_when_both_cached_values_match() { - assert!(!should_emit_any_state_signal(false, false)); -} - -#[test] -fn should_emit_any_state_signal_is_true_when_control_state_changed() { - assert!(should_emit_any_state_signal(true, false)); -} - -#[test] -fn should_emit_any_state_signal_is_true_when_popup_gate_changed() { - assert!(should_emit_any_state_signal(false, true)); -} - -#[test] -fn should_emit_any_state_signal_is_true_when_both_values_changed() { - assert!(should_emit_any_state_signal(true, true)); -} - -#[test] -fn record_signal_error_stores_first_error() { - let mut first_error = None; - - record_signal_error(&mut first_error, zbus::Error::Failure("first".to_string())); - - assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); -} - -#[test] -fn record_signal_error_keeps_existing_error() { - let mut first_error = Some(zbus::Error::Failure("first".to_string())); - - record_signal_error(&mut first_error, zbus::Error::Failure("second".to_string())); - - assert_eq!(first_error, Some(zbus::Error::Failure("first".to_string()))); -} - -#[tokio::test] -async fn emit_close_fanout_sends_freedesktop_and_control_close_signals() { - let state = daemon_state_for_test(false).await; - let mut freedesktop_stream = notifications_signal_stream(&state, "NotificationClosed").await; - let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; - - state - .emit_close_fanout(7, CloseReason::ClosedByCall) - .await - .expect("close fanout should emit"); - - let freedesktop_signal = next_signal(&mut freedesktop_stream).await; - let (freedesktop_id, freedesktop_reason) = freedesktop_signal - .body() - .deserialize::<(u32, u32)>() - .expect("freedesktop close body"); - assert_eq!(freedesktop_id, 7); - assert_eq!(freedesktop_reason, CloseReason::ClosedByCall as u32); - - let control_signal = next_signal(&mut control_stream).await; - let (control_id, control_reason) = control_signal - .body() - .deserialize::<(u32, CloseReason)>() - .expect("control close body"); - assert_eq!(control_id, 7); - assert_eq!(control_reason as u32, CloseReason::ClosedByCall as u32); -} - -#[tokio::test] -async fn emit_dismiss_fanout_sends_control_close_signal() { - let state = daemon_state_for_test(false).await; - let mut control_stream = control_signal_stream(&state, "NotificationClosed").await; - - state - .emit_dismiss_fanout(8, false) - .await - .expect("dismiss fanout should emit"); - - let control_signal = next_signal(&mut control_stream).await; - let (control_id, control_reason) = control_signal - .body() - .deserialize::<(u32, CloseReason)>() - .expect("control close body"); - assert_eq!(control_id, 8); - assert_eq!(control_reason as u32, CloseReason::DismissedByUser as u32); -} - -#[tokio::test] -async fn emit_state_changed_sends_initial_state_and_suppresses_duplicate() { - let state = daemon_state_for_test(false).await; - let mut state_stream = control_signal_stream(&state, "StateChanged").await; - let mut gate_stream = control_signal_stream(&state, "PopupGateChanged").await; - - state - .emit_state_changed() - .await - .expect("state changed should emit"); - - let state_signal = next_signal(&mut state_stream).await; - let emitted_state = state_signal - .body() - .deserialize::() - .expect("state body"); - assert!(!emitted_state.dnd_enabled); - assert!(!emitted_state.inhibited); - assert_eq!(emitted_state.history_count, 0); - assert_eq!(emitted_state.inhibitor_count, 0); - - let gate_signal = next_signal(&mut gate_stream).await; - let emitted_gate = gate_signal - .body() - .deserialize::() - .expect("popup gate body"); - assert!(!emitted_gate.dnd_enabled); - assert!(!emitted_gate.inhibited); - - state - .emit_state_changed() - .await - .expect("duplicate state should not fail"); - assert_no_signal(&mut state_stream).await; - assert_no_signal(&mut gate_stream).await; -} - -#[tokio::test] -async fn emit_snapshot_invalidated_sends_snapshot_signal() { - let state = daemon_state_for_test(false).await; - let mut stream = control_signal_stream(&state, "SnapshotInvalidated").await; - - state - .emit_snapshot_invalidated() - .await - .expect("snapshot invalidation should emit"); - - let signal = next_signal(&mut stream).await; - signal.body().deserialize::<()>().expect("empty body"); -} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/status.rs b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs new file mode 100644 index 000000000..f6fa43791 --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/status.rs @@ -0,0 +1,135 @@ +use crate::test_support::{daemon_state_for_test, daemon_state_for_test_with_owner}; + +#[tokio::test] +async fn daemon_state_boolean_flags_reflect_runtime_updates() { + let state = daemon_state_for_test(true).await; + + assert!(state.trial_mode()); + assert!(!state.panel_ready()); + assert!(!state.ui_health().popups_process_running); + + // These health flags gate user-visible command handling, so getters must reflect writes exactly + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + state.set_popups_process_running(true); + + assert!(state.panel_ready()); + assert!(state.ui_health().popups_process_running); + state.set_popups_ready(":1.10", true); + + let health = state.ui_health(); + assert!(health.center_process_running); + assert!(health.center_ready); + assert!(health.popups_process_running); + assert!(health.popups_ready); +} + +#[tokio::test] +async fn daemon_state_boolean_flags_can_return_to_false() { + let state = daemon_state_for_test(true).await; + + state.set_panel_ready(":1.20", true); + state.set_center_process_running(true); + state.set_popups_process_running(true); + state.set_panel_ready(":1.20", false); + state.set_center_process_running(false); + state.set_popups_process_running(false); + + assert!(!state.panel_ready()); + assert!(!state.ui_health().popups_process_running); +} + +#[tokio::test] +async fn popup_readiness_can_only_be_cleared_by_its_owner_generation() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.set_popups_ready(":1.11", false); + assert!(state.popups_ready()); + + state.set_popups_ready(":1.10", false); + assert!(!state.popups_ready()); +} + +#[tokio::test] +async fn panel_owner_loss_clears_readiness_and_panel_availability() { + let state = daemon_state_for_test(true).await; + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + assert!(state.panel_ready()); + + state.remove_disconnected_client(":1.20").await; + + assert!(!state.panel_ready()); +} + +#[tokio::test] +async fn delayed_old_panel_disconnect_keeps_new_owner_ready() { + let state = daemon_state_for_test(true).await; + state.set_center_process_running(true); + state.set_panel_ready(":1.20", true); + state.set_panel_ready(":1.21", true); + + state.remove_disconnected_client(":1.20").await; + + assert!(state.panel_ready()); + assert!(state.ui_health().center_ready); + state.remove_disconnected_client(":1.21").await; + assert!(!state.panel_ready()); +} + +#[tokio::test] +async fn popup_owner_loss_clears_readiness_for_the_matching_generation() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.remove_disconnected_client(":1.10").await; + + assert!(!state.popups_ready()); +} + +#[tokio::test] +async fn stopped_popup_process_clears_composite_readiness() { + let state = daemon_state_for_test(true).await; + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + + state.set_popups_process_running(false); + + let health = state.ui_health(); + assert!(!health.popups_process_running); + assert!(!health.popups_ready); +} + +#[tokio::test] +async fn daemon_state_trial_mode_can_be_disabled() { + let state = daemon_state_for_test(false).await; + + // Trial mode changes control authorization, so false must stay observable + assert!(!state.trial_mode()); +} + +#[tokio::test] +async fn popup_unready_warning_is_emitted_only_once_until_ready() { + let state = daemon_state_for_test(true).await; + + assert!(state.should_warn_popups_unready()); + assert!(!state.should_warn_popups_unready()); + + state.set_popups_process_running(true); + state.set_popups_ready(":1.10", true); + assert!(!state.should_warn_popups_unready()); + state.set_popups_ready(":1.10", false); + + assert!(state.should_warn_popups_unready()); +} + +#[tokio::test] +async fn control_owner_preauthorization_matches_only_the_current_owner() { + let state = daemon_state_for_test_with_owner(true, Some(":1.42")).await; + + assert!(state.control_owner_is_preauthorized(":1.42")); + assert!(!state.control_owner_is_preauthorized(":1.43")); +} diff --git a/crates/unixnotis-daemon/src/daemon/state/tests/support.rs b/crates/unixnotis-daemon/src/daemon/state/tests/support.rs new file mode 100644 index 000000000..6f5b4abdb --- /dev/null +++ b/crates/unixnotis-daemon/src/daemon/state/tests/support.rs @@ -0,0 +1,37 @@ +use tracing::warn; +use unixnotis_core::CloseReason; + +use crate::daemon::DaemonState; + +impl DaemonState { + pub(crate) async fn close_notification( + &self, + id: u32, + reason: CloseReason, + ) -> zbus::Result<()> { + let removed = { + let mut store = self.store.lock().await; + let removed = store.close(id, reason); + if let Some(notification) = removed.as_ref() { + // Cancellation is ordered before a replacement can acquire the store lock + self.cancel_expiration(notification.key()); + } + removed + }; + let Some(removed) = removed else { + return Ok(()); + }; + if let Err(error) = self + .publish_notification_closed(removed.key(), reason) + .await + { + warn!( + ?error, + id, + reason = reason as u32, + "notification close committed but one or more D-Bus signals failed" + ); + } + Ok(()) + } +} diff --git a/crates/unixnotis-daemon/src/dnd_expiration.rs b/crates/unixnotis-daemon/src/dnd_expiration.rs new file mode 100644 index 000000000..e082d7828 --- /dev/null +++ b/crates/unixnotis-daemon/src/dnd_expiration.rs @@ -0,0 +1,81 @@ +//! Single-deadline scheduler for timed Do Not Disturb state + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::watch; +use tracing::warn; + +use crate::daemon::DaemonState; + +const MAX_CLOCK_RECHECK: Duration = Duration::from_mins(1); +const PERSIST_RETRY_DELAY: Duration = Duration::from_secs(5); + +/// Coalescing scheduler handle for the one active DND deadline +#[derive(Clone)] +pub struct DndExpirationScheduler { + sender: watch::Sender>, +} + +impl DndExpirationScheduler { + pub fn start(state: Arc) -> Self { + // A watch channel keeps only the newest deadline during rapid menu changes + let (sender, mut receiver) = watch::channel(None); + tokio::spawn(async move { + loop { + let expires_at = *receiver.borrow_and_update(); + let Some(expires_at) = expires_at else { + // No deadline means indefinite or disabled DND + if receiver.changed().await.is_err() { + break; + } + continue; + }; + + let delay = delay_until_recheck(chrono::Utc::now().timestamp(), expires_at); + if delay.is_zero() { + // The store verifies this is still the current deadline before mutating + if let Err(err) = state.apply_dnd_expiration(expires_at).await { + warn!( + ?err, + expires_at, "failed to expire timed do-not-disturb state" + ); + // A persistence outage must not create a tight retry loop + tokio::time::sleep(PERSIST_RETRY_DELAY).await; + } + continue; + } + + tokio::select! { + changed = receiver.changed() => { + if changed.is_err() { + break; + } + } + () = tokio::time::sleep(delay) => { + // Wall time is checked again so clock adjustments cannot skip expiry + } + } + } + }); + + Self { sender } + } + + pub fn schedule(&self, expires_at: Option) { + // Replacing the watch value cancels the previous logical deadline + self.sender.send_replace(expires_at); + } +} + +fn delay_until_recheck(now: i64, expires_at: i64) -> Duration { + let remaining = expires_at.saturating_sub(now); + if remaining <= 0 { + return Duration::ZERO; + } + Duration::from_secs(remaining as u64).min(MAX_CLOCK_RECHECK) +} + +#[cfg(test)] +#[path = "tests/dnd_expiration.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/expire.rs b/crates/unixnotis-daemon/src/expire.rs index 8fe7251cc..6a623a89c 100644 --- a/crates/unixnotis-daemon/src/expire.rs +++ b/crates/unixnotis-daemon/src/expire.rs @@ -9,12 +9,13 @@ use tokio::sync::mpsc; use tracing::warn; use crate::daemon::DaemonState; +use crate::store::ExpirationTicket; use unixnotis_core::CloseReason; /// Commands sent to the expiration scheduler pub enum ExpirationCommand { - Schedule { id: u32, deadline: Instant }, - Cancel { id: u32 }, + Schedule { ticket: ExpirationTicket }, + Cancel { id: u32, generation: u64 }, } /// Asynchronous expiration manager backed by a priority queue @@ -29,9 +30,9 @@ impl ExpirationScheduler { tokio::spawn(async move { let mut heap: BinaryHeap = BinaryHeap::new(); // Tracks the latest deadline per notification to discard stale heap entries - let mut scheduled: HashMap = HashMap::new(); + let mut scheduled: HashMap = HashMap::new(); loop { - let next_deadline = heap.peek().map(|item| item.deadline); + let next_deadline = heap.peek().map(|item| item.ticket.deadline); if next_deadline.is_none() { let Some(cmd) = receiver.recv().await else { break; @@ -51,47 +52,45 @@ impl ExpirationScheduler { () = tokio::time::sleep_until(deadline.into()) => { let now = Instant::now(); while let Some(item) = heap.peek() { - if item.deadline > now { + if item.ticket.deadline > now { break; } let Some(item) = heap.pop() else { break; }; - let is_current = scheduled - .get(&item.id) - .is_some_and(|deadline| *deadline == item.deadline); + let is_current = + scheduled.get(&item.ticket.id) == Some(&item.ticket); if !is_current { continue; } - // Verify the deadline is still current before closing the notification - let expiration = { - let store = state.store.lock().await; - store.expiration_for(item.id) + // Validation and removal share the same store lock + let removed = { + let mut store = state.store.lock().await; + store.expire_if_current(item.ticket) }; - let is_still_current = expiration - .is_some_and(|deadline| deadline == item.deadline); - if is_still_current { - // Remove the scheduled entry only once the deadline is confirmed - // to still be active. This avoids dropping new schedules created - // while the expiration task was waiting on the store lock - if scheduled.get(&item.id) == Some(&item.deadline) { - scheduled.remove(&item.id); - } - // Expiration closes must be observable so signal/state failures - // are visible in logs instead of being silently ignored - if let Err(err) = - state.close_notification(item.id, CloseReason::Expired).await + // Remove only the exact scheduler generation that was inspected + if scheduled.get(&item.ticket.id) == Some(&item.ticket) { + scheduled.remove(&item.ticket.id); + } + if removed.is_some() { + // Fanout happens only after the exact generation was removed + if let Err(err) = state + .publish_notification_closed( + unixnotis_core::NotificationKey { + id: item.ticket.id, + generation: item.ticket.generation, + }, + CloseReason::Expired, + ) + .await { warn!( ?err, - id = item.id, + id = item.ticket.id, + generation = item.ticket.generation, "failed to close expired notification" ); } - } else if scheduled.get(&item.id) == Some(&item.deadline) { - // The store no longer expects this deadline (dismissed or updated), - // so drop the stale schedule to avoid repeated checks - scheduled.remove(&item.id); } } maybe_compact(&mut heap, &scheduled); @@ -104,10 +103,16 @@ impl ExpirationScheduler { Self { sender } } - pub fn schedule(&self, id: u32, deadline: Option) { + pub fn schedule(&self, id: u32, generation: u64, deadline: Option) { let command = match deadline { - Some(deadline) => ExpirationCommand::Schedule { id, deadline }, - None => ExpirationCommand::Cancel { id }, + Some(deadline) => ExpirationCommand::Schedule { + ticket: ExpirationTicket { + id, + generation, + deadline, + }, + }, + None => ExpirationCommand::Cancel { id, generation }, }; if let Err(err) = self.sender.send(command) { warn!(?err, "expiration schedule request dropped"); @@ -117,13 +122,12 @@ impl ExpirationScheduler { #[derive(Debug, Copy, Clone)] struct ExpirationItem { - id: u32, - deadline: Instant, + ticket: ExpirationTicket, } impl PartialEq for ExpirationItem { fn eq(&self, other: &Self) -> bool { - self.deadline.eq(&other.deadline) + self.ticket.eq(&other.ticket) } } @@ -137,30 +141,48 @@ impl PartialOrd for ExpirationItem { impl Ord for ExpirationItem { fn cmp(&self, other: &Self) -> Ordering { - // Reverse ordering to make BinaryHeap a min-heap on deadline - other.deadline.cmp(&self.deadline) + // Reverse every field so BinaryHeap remains a deterministic min-heap + other + .ticket + .deadline + .cmp(&self.ticket.deadline) + .then_with(|| other.ticket.generation.cmp(&self.ticket.generation)) + .then_with(|| other.ticket.id.cmp(&self.ticket.id)) } } fn apply_command( cmd: ExpirationCommand, heap: &mut BinaryHeap, - scheduled: &mut HashMap, + scheduled: &mut HashMap, ) { match cmd { - ExpirationCommand::Schedule { id, deadline } => { - // Keep the newest deadline and push to the heap for ordering - scheduled.insert(id, deadline); - heap.push(ExpirationItem { id, deadline }); + ExpirationCommand::Schedule { ticket } => { + // Older commands cannot replace a later committed generation + let may_replace = scheduled + .get(&ticket.id) + .is_none_or(|current| current.generation <= ticket.generation); + if may_replace { + scheduled.insert(ticket.id, ticket); + heap.push(ExpirationItem { ticket }); + } } - ExpirationCommand::Cancel { id } => { - // Cancel only updates the tracking map; stale heap entries are ignored - scheduled.remove(&id); + ExpirationCommand::Cancel { id, generation } => { + // A delayed close from an older generation must preserve a replacement timer + let may_remove = scheduled + .get(&id) + .is_some_and(|current| current.generation <= generation); + if may_remove { + scheduled.remove(&id); + } } } } -fn maybe_compact(heap: &mut BinaryHeap, scheduled: &HashMap) { +fn maybe_compact( + heap: &mut BinaryHeap, + scheduled: &HashMap, +) { // Count how many expiration entries are still real and expected to happen let live = scheduled.len(); @@ -182,11 +204,8 @@ fn maybe_compact(heap: &mut BinaryHeap, scheduled: &HashMap Result<()> { ensure_wayland_session(Duration::from_secs(20)) .await .context("wait for Wayland session")?; - runtime::run(&args, config).await + Box::pin(runtime::run(&args, config)).await } diff --git a/crates/unixnotis-daemon/src/runtime/daemon.rs b/crates/unixnotis-daemon/src/runtime/daemon.rs index 998e12c7e..d1a017c90 100644 --- a/crates/unixnotis-daemon/src/runtime/daemon.rs +++ b/crates/unixnotis-daemon/src/runtime/daemon.rs @@ -1,10 +1,13 @@ //! Live notification service runtime +use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Result}; +use arc_swap::ArcSwap; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use zbus::fdo::DBusProxy; use zbus::Connection; @@ -12,32 +15,54 @@ use super::shutdown::shutdown_signal; use crate::child_process::{spawn_center_supervisor, spawn_popups_supervisor}; use crate::cli::Args; use crate::daemon::{ - log_name_reply, request_control_name, request_well_known_name, spawn_inhibitor_owner_watch, - ControlServer, DaemonState, NotificationServer, NOTIFICATIONS_OBJECT_PATH, + log_name_reply, monitor_required_bus_names, request_control_name, request_well_known_name, + spawn_client_owner_watch, spawn_desktop_index_refresh, verify_name_owner, ControlServer, + DaemonState, DesktopIdentityIndex, NotificationIngress, NotificationServer, + NOTIFICATIONS_OBJECT_PATH, }; -use crate::dbus_owner::log_current_owner; +use crate::dnd_expiration::DndExpirationScheduler; use crate::expire::ExpirationScheduler; use crate::sound::SoundSettings; -use unixnotis_core::{Config, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH}; +use unixnotis_core::{Config, CONTROL_BUS_NAME, CONTROL_OBJECT_PATH, NOTIFICATIONS_BUS_NAME}; pub(super) async fn run_daemon( args: &Args, config: Config, connection: &Connection, dbus_proxy: &DBusProxy<'_>, - notifications_name: zbus::names::BusName<'_>, + desktop_identity_index: Arc>, + watched_desktop_directories: Vec, + preauthorized_control_owner: Option, ) -> Result<()> { // Resolve sound settings once to avoid repeated filesystem work - let sound_settings = SoundSettings::from_config(&config); - let state = DaemonState::new(connection.clone(), config, sound_settings, args.trial); + let sound_settings = SoundSettings::from_config(&config, args.config.as_deref()); + let state = DaemonState::new( + connection.clone(), + config, + sound_settings, + args.trial, + desktop_identity_index, + preauthorized_control_owner, + ); + match spawn_desktop_index_refresh( + state.desktop_identity_index.clone(), + watched_desktop_directories, + ) { + Ok(handle) => state.set_desktop_index_refresh(handle), + Err(error) => warn!(?error, "desktop application refresh watcher is unavailable"), + } let scheduler = ExpirationScheduler::start(state.clone()); state.set_scheduler(scheduler.clone()); + let dnd_scheduler = DndExpirationScheduler::start(state.clone()); + state.set_dnd_scheduler(dnd_scheduler); + let dnd_expires_at = state.store.lock().await.dnd_expires_at(); + state.schedule_dnd_expiration(dnd_expires_at); connection .object_server() .at( NOTIFICATIONS_OBJECT_PATH, - NotificationServer::new(state.clone(), scheduler), + NotificationIngress::new(NotificationServer::new(state.clone(), scheduler)), ) .await?; connection @@ -45,6 +70,20 @@ pub(super) async fn run_daemon( .at(CONTROL_OBJECT_PATH, ControlServer::new(state.clone())) .await?; + // The standard notification name is the first externally visible readiness gate + let reply = match request_well_known_name(connection, args.trial).await { + Ok(reply) => reply, + Err(zbus::Error::NameTaken) => { + return Err(anyhow!( + "org.freedesktop.Notifications is already owned and unavailable to this process" + )); + } + Err(error) => return Err(error.into()), + }; + log_name_reply(&reply); + verify_name_owner(dbus_proxy, connection, NOTIFICATIONS_BUS_NAME).await?; + + // The private control name is published last and means the daemon is ready let control_reply = request_control_name(connection).await?; match control_reply { zbus::fdo::RequestNameReply::PrimaryOwner => { @@ -53,40 +92,22 @@ pub(super) async fn run_daemon( zbus::fdo::RequestNameReply::AlreadyOwner => { info!(CONTROL_BUS_NAME, "already owns control bus name"); } - _ => { + zbus::fdo::RequestNameReply::InQueue | zbus::fdo::RequestNameReply::Exists => { return Err(anyhow!( "control bus name is already owned; another unixnotis instance may be running" )); } } + verify_name_owner(dbus_proxy, connection, CONTROL_BUS_NAME).await?; - let reply = request_well_known_name(connection, args.trial).await?; - log_name_reply(&reply); - let owner_is_self = match log_current_owner(dbus_proxy, connection, notifications_name).await { - Ok(value) => value, - Err(err) => { - warn!(?err, "failed to query current notification owner"); - false - } - }; - if !args.trial - && !matches!( - reply, - zbus::fdo::RequestNameReply::PrimaryOwner | zbus::fdo::RequestNameReply::AlreadyOwner - ) - { - return Err(anyhow!( - "org.freedesktop.Notifications is already owned; retry with --trial" - )); - } - if args.trial && !owner_is_self { - return Err(anyhow!( - "org.freedesktop.Notifications is still owned by another daemon; stop it or use --restore systemd if managed by systemd --user" - )); + // A zero-duration run verifies service registration without launching UI processes + if skip_ui_for_zero_duration(args.run_seconds) { + info!("zero-duration daemon startup completed"); + return Ok(()); } - if let Err(err) = spawn_inhibitor_owner_watch(state.clone()).await { - warn!(?err, "failed to start inhibitor owner watcher"); + if let Err(err) = spawn_client_owner_watch(state.clone()).await { + warn!(?err, "failed to start client owner watcher"); } // Both UI processes share one shutdown flag and reap their current child before exit @@ -95,15 +116,13 @@ pub(super) async fn run_daemon( let center_task = spawn_center_supervisor(args.clone(), state, shutdown_rx); info!("unixnotis-daemon running"); - match args.run_seconds { - Some(seconds) => { - let timeout = tokio::time::sleep(Duration::from_secs(seconds)); - tokio::select! { - () = shutdown_signal() => {}, - () = timeout => info!(seconds, "run-seconds elapsed, shutting down"), - } - } - None => shutdown_signal().await, + let runtime_result = wait_for_runtime_exit(args.run_seconds, connection.clone()).await; + + if let Err(failure) = &runtime_result { + error!( + error = ?failure, + "session bus connection failed; daemon will exit for supervisor restart" + ); } if let Err(err) = shutdown_tx.send(true) { @@ -115,5 +134,34 @@ pub(super) async fn run_daemon( if let Err(err) = center_task.await { warn!(?err, "center supervisor task failed"); } - Ok(()) + runtime_result } + +async fn wait_for_runtime_exit(run_seconds: Option, connection: Connection) -> Result<()> { + let bus_health = monitor_required_bus_names(connection.clone()); + tokio::pin!(bus_health); + if let Some(seconds) = run_seconds { + let timeout = tokio::time::sleep(Duration::from_secs(seconds)); + tokio::select! { + () = shutdown_signal() => Ok(()), + result = &mut bus_health => result, + () = timeout => { + info!(seconds, "run-seconds elapsed, shutting down"); + Ok(()) + }, + } + } else { + tokio::select! { + () = shutdown_signal() => Ok(()), + result = &mut bus_health => result, + } + } +} + +const fn skip_ui_for_zero_duration(run_seconds: Option) -> bool { + matches!(run_seconds, Some(0)) +} + +#[cfg(test)] +#[path = "tests/daemon.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/runtime/runner.rs b/crates/unixnotis-daemon/src/runtime/runner.rs index 42e99c517..2faa7cf2d 100644 --- a/crates/unixnotis-daemon/src/runtime/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/runner.rs @@ -1,19 +1,53 @@ //! Daemon runtime and trial cleanup coordination +use std::sync::Arc; + use anyhow::{Context, Result}; +use arc_swap::ArcSwap; +use zbus::connection::Builder; use zbus::fdo::DBusProxy; -use zbus::Connection; use crate::cli::Args; +use crate::daemon::{DesktopIdentityIndex, DesktopIndexSnapshot}; use crate::trial_mode::{prepare_trial, TrialState}; -use unixnotis_core::{Config, NOTIFICATIONS_BUS_NAME}; +use unixnotis_core::{log_session_bus_identity, Config, NOTIFICATIONS_BUS_NAME}; use super::{daemon, trial_cleanup}; +const DAEMON_DBUS_QUEUE_CAPACITY: usize = 16; + pub async fn run(args: &Args, config: Config) -> Result<()> { - let connection = Connection::session() + let builder = Builder::session().context("create session bus connection")?; + Box::pin(run_with_builder(args, config, builder)).await +} + +async fn run_with_builder(args: &Args, config: Config, builder: Builder<'_>) -> Result<()> { + Box::pin(run_with_builder_inner(args, config, builder, None)).await +} + +async fn run_with_builder_inner( + args: &Args, + config: Config, + builder: Builder<'_>, + preauthorized_control_owner: Option, +) -> Result<()> { + let connection = builder + .max_queued(DAEMON_DBUS_QUEUE_CAPACITY) + .build() .await .context("connect to session bus")?; + log_session_bus_identity(&connection, "daemon") + .await + .context("read daemon session-bus identity")?; + // Finish the bounded filesystem scan before either well-known name can become visible + let desktop_index_snapshot = tokio::task::spawn_blocking(DesktopIdentityIndex::build_snapshot) + .await + .context("desktop identity index task failed")?; + let DesktopIndexSnapshot { + index: desktop_identity_index, + watched_directories, + } = desktop_index_snapshot; + let desktop_identity_index = Arc::new(ArcSwap::from_pointee(desktop_identity_index)); let dbus_proxy = DBusProxy::new(&connection).await?; let notifications_name = zbus::names::BusName::try_from(NOTIFICATIONS_BUS_NAME)?; let mut trial_state = if trial_requested(args) { @@ -28,7 +62,9 @@ pub async fn run(args: &Args, config: Config) -> Result<()> { config, &connection, &dbus_proxy, - notifications_name.clone(), + desktop_identity_index, + watched_directories, + preauthorized_control_owner, ) .await; let restore_result = trial_cleanup::finish_trial( diff --git a/crates/unixnotis-daemon/src/runtime/tests/daemon.rs b/crates/unixnotis-daemon/src/runtime/tests/daemon.rs new file mode 100644 index 000000000..b438e6d51 --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/daemon.rs @@ -0,0 +1,8 @@ +use super::skip_ui_for_zero_duration; + +#[test] +fn only_zero_duration_runs_skip_ui_startup() { + assert!(skip_ui_for_zero_duration(Some(0))); + assert!(!skip_ui_for_zero_duration(Some(1))); + assert!(!skip_ui_for_zero_duration(None)); +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs new file mode 100644 index 000000000..e94efb88d --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle.rs @@ -0,0 +1,449 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use clap::Parser; +use futures_util::StreamExt; +use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::fdo::DBusProxy; +use zbus::message::Type; +use zbus::names::BusName; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, ConnectionBuilder, MatchRule, MessageStream}; + +use super::super::{run_with_builder, run_with_builder_inner}; +use crate::cli::Args; +use unixnotis_core::Config; + +#[path = "dbus_lifecycle/private_bus.rs"] +mod private_bus; + +use private_bus::PrivateBus; + +async fn connect(address: &str) -> Connection { + ConnectionBuilder::address(address) + .expect("parse private broker address") + .build() + .await + .expect("connect to private broker") +} + +fn spawn_daemon(address: String, run_seconds: u64) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder(&args, Config::default(), builder)).await + }) +} + +fn spawn_daemon_with_trusted_sender( + address: String, + run_seconds: u64, + trusted_sender: String, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder_inner( + &args, + Config::default(), + builder, + Some(trusted_sender), + )) + .await + }) +} + +fn spawn_daemon_with_config_and_trusted_sender( + address: String, + run_seconds: u64, + config: Config, + trusted_sender: String, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + let args = Args::try_parse_from([ + "unixnotis-daemon", + "--run-seconds", + &run_seconds.to_string(), + ]) + .expect("parse bounded daemon command"); + let builder = zbus::connection::Builder::address(address.as_str()) + .expect("parse daemon broker address"); + Box::pin(run_with_builder_inner( + &args, + config, + builder, + Some(trusted_sender), + )) + .await + }) +} + +async fn owner(dbus: &DBusProxy<'_>, name: &'static str) -> Option { + let name = BusName::try_from(name).expect("static bus name"); + dbus.get_name_owner(name) + .await + .ok() + .map(|owner| owner.to_string()) +} + +async fn wait_for_both_owners(connection: &Connection) -> (String, String) { + let dbus = DBusProxy::new(connection) + .await + .expect("create broker proxy"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let (Some(notifications), Some(control)) = ( + owner(&dbus, NOTIFICATIONS_BUS_NAME).await, + owner(&dbus, CONTROL_BUS_NAME).await, + ) { + return (notifications, control); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("daemon should acquire both names") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn startup_publishes_both_names_with_one_ready_owner() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 1); + + let (notifications_owner, control_owner) = wait_for_both_owners(&client).await; + assert_eq!( + notifications_owner, control_owner, + "both service names must belong to the ready daemon connection" + ); + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + let capabilities_started = Instant::now(); + tokio::time::timeout(Duration::from_secs(2), notifications.get_capabilities()) + .await + .expect("GetCapabilities must be bounded") + .expect("GetCapabilities must succeed"); + assert!( + capabilities_started.elapsed() < Duration::from_millis(500), + "GetCapabilities exceeded the shared-runner latency budget" + ); + let information_started = Instant::now(); + let server = tokio::time::timeout( + Duration::from_secs(2), + notifications.get_server_information(), + ) + .await + .expect("GetServerInformation must be bounded") + .expect("GetServerInformation must succeed"); + assert_eq!(server.0, "UnixNotis"); + assert!( + information_started.elapsed() < Duration::from_millis(500), + "GetServerInformation exceeded the shared-runner latency budget" + ); + + let cold_started = Instant::now(); + tokio::time::timeout( + Duration::from_secs(2), + notifications.notify( + "Lifecycle test", + 0, + "", + "Cold notification", + "First attribution lookup", + Vec::new(), + HashMap::new(), + 1_000, + ), + ) + .await + .expect("cold Notify must be bounded") + .expect("cold Notify must succeed"); + assert!( + cold_started.elapsed() < Duration::from_secs(1), + "cold Notify exceeded the shared-runner latency budget" + ); + let warm_started = Instant::now(); + tokio::time::timeout( + Duration::from_secs(2), + notifications.notify( + "Lifecycle test", + 0, + "", + "Warm notification", + "Cached sender metadata", + Vec::new(), + HashMap::new(), + 1_000, + ), + ) + .await + .expect("warm Notify must be bounded") + .expect("warm Notify must succeed"); + assert!( + warm_started.elapsed() < Duration::from_millis(500), + "warm Notify exceeded the shared-runner latency budget" + ); + + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + tokio::time::timeout(Duration::from_secs(2), control.get_state()) + .await + .expect("GetState must be bounded") + .expect("GetState must succeed"); + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn private_session_bus_accepts_full_notification_view_after_added_signal() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let trusted_sender = client + .unique_name() + .expect("private session bus assigns a unique client name") + .to_string(); + let daemon = spawn_daemon_with_trusted_sender(bus.address.clone(), 3, trusted_sender); + let owners_before = wait_for_both_owners(&client).await; + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + let mut added = control + .receive_notification_added() + .await + .expect("subscribe before sending notification"); + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + + let id = notifications + .notify( + "Private bus wire test", + 0, + "", + "Complete notification view", + "The private bus must accept the nested enum payload", + Vec::new(), + HashMap::from([("urgency".to_string(), OwnedValue::from(2_u8))]), + 2_000, + ) + .await + .expect("Notify should return an assigned id"); + let signal = tokio::time::timeout(Duration::from_secs(2), added.next()) + .await + .expect("NotificationAdded must arrive promptly") + .expect("NotificationAdded stream must remain open"); + let signal_args = signal.args().expect("decode NotificationAdded arguments"); + assert_eq!(*signal_args.id(), id); + + // This is the exact authorized pull that previously exposed an invalid D-Bus body + let views = control + .get_active_notification(id) + .await + .expect("GetActiveNotification must return a valid D-Bus body"); + assert_eq!(views.len(), 1); + assert_eq!(views[0].id, id); + assert_eq!(views[0].summary, "Complete notification view"); + assert_eq!(views[0].urgency, 2); + let popup_candidates = control + .list_popup_candidates() + .await + .expect("ListPopupCandidates must return a valid D-Bus body"); + assert_eq!(popup_candidates.len(), 1); + assert_eq!(popup_candidates[0].id, id); + assert!( + !daemon.is_finished(), + "serializing NotificationView must not disconnect the daemon" + ); + assert_eq!(wait_for_both_owners(&client).await, owners_before); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn drop_all_notify_reply_precedes_its_notification_closed_signal_on_the_bus() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let trusted_sender = client + .unique_name() + .expect("private session bus assigns a unique client name") + .to_string(); + let mut config = Config::default(); + config.inhibit.mode = unixnotis_core::InhibitMode::DropAll; + let daemon = + spawn_daemon_with_config_and_trusted_sender(bus.address.clone(), 3, config, trusted_sender); + let (notifications_owner, _control_owner) = wait_for_both_owners(&client).await; + let control = ControlProxy::new(&client) + .await + .expect("create control proxy"); + control + .inhibit("DropAll ordering test", 0) + .await + .expect("activate DropAll inhibitor"); + + let close_rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(notifications_owner.as_str()) + .expect("notification daemon sender") + .path("/org/freedesktop/Notifications") + .expect("notification object path") + .interface("org.freedesktop.Notifications") + .expect("notification interface") + .member("NotificationClosed") + .expect("notification close member") + .build(); + let mut closed = MessageStream::for_match_rule(close_rule, &client, Some(4)) + .await + .expect("subscribe to freedesktop close signals before Notify"); + let payload = ( + "DropAll wire test", + 0_u32, + "", + "discarded summary", + "discarded body", + Vec::::new(), + HashMap::::new(), + 0_i32, + ); + + let reply = client + .call_method( + Some(NOTIFICATIONS_BUS_NAME), + "/org/freedesktop/Notifications", + Some("org.freedesktop.Notifications"), + "Notify", + &payload, + ) + .await + .expect("DropAll Notify should return a method reply"); + let id = reply.body().deserialize::().expect("notification id"); + let close = tokio::time::timeout(Duration::from_secs(2), closed.next()) + .await + .expect("NotificationClosed should arrive promptly") + .expect("NotificationClosed stream should remain open") + .expect("NotificationClosed message should decode"); + let (closed_id, reason) = close + .body() + .deserialize::<(u32, u32)>() + .expect("freedesktop close arguments"); + + assert_eq!(closed_id, id); + assert_eq!(reason, unixnotis_core::CloseReason::Undefined as u32); + assert!( + reply.recv_position() < close.recv_position(), + "Notify reply must cross the bus before NotificationClosed" + ); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_bus_loss_makes_the_daemon_exit_with_failure() { + let mut bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 30); + let _owners = wait_for_both_owners(&client).await; + + bus.terminate(); + let result = tokio::time::timeout(Duration::from_secs(8), daemon) + .await + .expect("daemon must notice session bus loss") + .expect("join daemon task"); + assert!( + result.is_err(), + "session bus loss must return a daemon failure" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notification_during_health_probing_keeps_daemon_generation_alive() { + let bus = PrivateBus::start(); + let client = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 4); + let owners_before = wait_for_both_owners(&client).await; + let notifications = NotificationsProxy::new(&client) + .await + .expect("create notifications proxy"); + + // Cross the first one-second health interval before committing the notification + tokio::time::sleep(Duration::from_millis(1_100)).await; + let id = notifications + .notify( + "Health overlap test", + 0, + "", + "Notification during probe", + "The daemon generation must remain alive", + Vec::new(), + HashMap::new(), + 3_000, + ) + .await + .expect("Notify should return an assigned id"); + assert_ne!(id, 0); + + tokio::time::sleep(Duration::from_millis(1_100)).await; + assert!( + !daemon.is_finished(), + "one healthy probe interval must not retire the daemon generation" + ); + let owners_after = wait_for_both_owners(&client).await; + assert_eq!(owners_after, owners_before); + + daemon + .await + .expect("join daemon task") + .expect("bounded daemon run"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn competing_notification_owner_prevents_control_publication() { + let bus = PrivateBus::start(); + let competitor = connect(&bus.address).await; + competitor + .request_name(NOTIFICATIONS_BUS_NAME) + .await + .expect("competitor owns notification name"); + let observer = connect(&bus.address).await; + let daemon = spawn_daemon(bus.address.clone(), 5); + + let result = tokio::time::timeout(Duration::from_secs(10), daemon) + .await + .expect("competing owner should fail startup promptly") + .expect("join daemon task"); + let error = result.expect_err("competing notification owner must fail startup"); + assert!( + error + .to_string() + .contains("already owned and unavailable to this process"), + "unexpected competing-owner error: {error:#}" + ); + let dbus = DBusProxy::new(&observer) + .await + .expect("create observer proxy"); + assert!( + owner(&dbus, CONTROL_BUS_NAME).await.is_none(), + "control readiness must never publish after notification ownership fails" + ); +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs new file mode 100644 index 000000000..8fe3c070e --- /dev/null +++ b/crates/unixnotis-daemon/src/runtime/tests/dbus_lifecycle/private_bus.rs @@ -0,0 +1,160 @@ +use std::io::{self, BufRead, BufReader, Read}; +use std::path::PathBuf; +use std::process::{Child, ChildStdout, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{sync_channel, RecvTimeoutError}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const BUS_READY_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_BUS_ADDRESS_BYTES: usize = 4 * 1024; + +// Parallel lifecycle tests need independent socket directories +static NEXT_BUS: AtomicUsize = AtomicUsize::new(0); + +pub(super) struct PrivateBus { + child: Child, + socket: PathBuf, + pub(super) address: String, +} + +impl PrivateBus { + pub(super) fn start() -> Self { + let socket = bus_socket(); + let listen_address = format!("unix:path={}", socket.display()); + + // Resolve from protected roots because tests may temporarily replace PATH + let daemon = unixnotis_core::util::trusted_system_program_path("dbus-daemon") + .expect("find dbus-daemon in a trusted system directory"); + let mut child = Command::new(daemon) + .args([ + "--session", + "--nofork", + "--nopidfile", + "--nosyslog", + "--print-address=1", + &format!("--address={listen_address}"), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("start private D-Bus session bus"); + + // The first output line proves that the requested listener is ready + let stdout = child.stdout.take().expect("capture private bus address"); + let address = read_bus_address(&mut child, stdout, &listen_address) + .expect("read private D-Bus session bus address promptly"); + + Self { + child, + socket, + address, + } + } + + pub(super) fn terminate(&mut self) { + // Reaping the daemon prevents process and socket leaks between tests + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for PrivateBus { + fn drop(&mut self) { + self.terminate(); + let _ = std::fs::remove_file(&self.socket); + if let Some(parent) = self.socket.parent() { + let _ = std::fs::remove_dir(parent); + } + } +} + +fn read_bus_address( + child: &mut Child, + stdout: ChildStdout, + expected_prefix: &str, +) -> io::Result { + let (sender, receiver) = sync_channel(1); + + // A worker keeps the pipe read from blocking the test indefinitely + std::thread::spawn(move || { + let mut address = String::new(); + let limit = u64::try_from(MAX_BUS_ADDRESS_BYTES + 1) + .expect("private bus address limit should fit in u64"); + let result = BufReader::new(stdout) + .take(limit) + .read_line(&mut address) + .and_then(|read| validate_address_line(read, address)); + let _ = sender.send(result); + }); + + let result = match receiver.recv_timeout(BUS_READY_TIMEOUT) { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + "private D-Bus session bus did not report its address promptly", + )), + Err(RecvTimeoutError::Disconnected) => Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "private D-Bus address reader stopped unexpectedly", + )), + } + .and_then(|address| validate_listener(address, expected_prefix)); + + if result.is_err() { + // Startup failures occur before a guard exists, so cleanup happens here + let _ = child.kill(); + let _ = child.wait(); + } + + result +} + +fn validate_address_line(read: usize, address: String) -> io::Result { + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "private D-Bus session bus closed before reporting its address", + )); + } + if read > MAX_BUS_ADDRESS_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "private D-Bus session bus address exceeded the test limit", + )); + } + if !address.ends_with('\n') { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "private D-Bus session bus address did not end with a newline", + )); + } + + Ok(address.trim().to_string()) +} + +fn validate_listener(address: String, expected_prefix: &str) -> io::Result { + if address.starts_with(expected_prefix) { + Ok(address) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "private D-Bus session bus returned an unexpected address", + )) + } +} + +fn bus_socket() -> PathBuf { + // Time, process, and serial values keep concurrent test roots independent + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock must be after the Unix epoch") + .as_nanos(); + let serial = NEXT_BUS.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "unixnotis-runtime-dbus-{}-{stamp}-{serial}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create private D-Bus directory"); + root.join("bus.sock") +} diff --git a/crates/unixnotis-daemon/src/runtime/tests/runner.rs b/crates/unixnotis-daemon/src/runtime/tests/runner.rs index 863e0d31d..364a2457d 100644 --- a/crates/unixnotis-daemon/src/runtime/tests/runner.rs +++ b/crates/unixnotis-daemon/src/runtime/tests/runner.rs @@ -1,7 +1,18 @@ use clap::Parser; -use super::trial_requested; +use std::process::Command; + +use super::{run, run_with_builder, trial_requested}; use crate::cli::Args; +use unixnotis_core::Config; +use zbus::connection::Builder; + +#[path = "dbus_lifecycle.rs"] +mod dbus_lifecycle; + +const RUNTIME_CHILD_ENV: &str = "UNIXNOTIS_RUNTIME_TEST_CHILD"; +const RUNTIME_ERROR_TEST: &str = + "runtime::runner::tests::public_runtime_returns_error_when_session_bus_is_unreachable"; #[test] fn trial_preparation_is_enabled_only_by_the_trial_flag() { @@ -12,3 +23,57 @@ fn trial_preparation_is_enabled_only_by_the_trial_flag() { assert!(!trial_requested(&normal)); assert!(trial_requested(&trial)); } + +#[tokio::test(flavor = "current_thread")] +async fn runtime_reports_an_unreachable_session_bus() { + let args = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); + let builder = Builder::address("unix:path=/nonexistent/unixnotis-test-session-bus") + .expect("valid unreachable bus address"); + + let error = Box::pin(run_with_builder(&args, Config::default(), builder)) + .await + .expect_err("unreachable session bus should reject startup"); + + assert!( + error.to_string().contains("connect to session bus"), + "unexpected error: {error:#}" + ); +} + +#[test] +fn public_runtime_returns_error_when_session_bus_is_unreachable() { + if std::env::var_os(RUNTIME_CHILD_ENV).is_some() { + // The child owns its environment, so no parallel test can observe the fake bus address + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build isolated runtime test executor"); + let args = Args::try_parse_from(["unixnotis-daemon"]).expect("parse normal daemon command"); + let error = runtime + .block_on(Box::pin(run(&args, Config::default()))) + .expect_err("public runtime must propagate an unreachable session bus"); + assert!( + error.to_string().contains("session bus"), + "unexpected public runtime error: {error:#}" + ); + return; + } + + // A child process scopes the D-Bus environment mutation to this one regression + let test_binary = std::env::current_exe().expect("resolve current daemon test binary"); + let status = Command::new(test_binary) + .args(["--exact", RUNTIME_ERROR_TEST, "--nocapture"]) + .env(RUNTIME_CHILD_ENV, "1") + .env( + "DBUS_SESSION_BUS_ADDRESS", + "unix:path=/nonexistent/unixnotis-public-runtime-session-bus", + ) + .env_remove("DBUS_STARTER_ADDRESS") + .status() + .expect("run isolated public runtime regression"); + + assert!( + status.success(), + "isolated public runtime regression must pass" + ); +} diff --git a/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs b/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs index 78af24863..5332f1872 100644 --- a/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs +++ b/crates/unixnotis-daemon/src/runtime/trial_cleanup.rs @@ -7,7 +7,7 @@ use zbus::fdo::DBusProxy; use zbus::Connection; use crate::cli::Args; -use crate::dbus_owner::wait_for_owner_state; +use crate::daemon::wait_for_owner_state; use crate::trial_mode::{self, restore_previous, TrialState}; use unixnotis_core::NOTIFICATIONS_BUS_NAME; diff --git a/crates/unixnotis-daemon/src/sound/backend.rs b/crates/unixnotis-daemon/src/sound/backend.rs index 2b32eb4d0..63798881e 100644 --- a/crates/unixnotis-daemon/src/sound/backend.rs +++ b/crates/unixnotis-daemon/src/sound/backend.rs @@ -1,4 +1,4 @@ -use unixnotis_core::program_in_path; +use crate::system_tools; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub(super) enum SoundBackend { @@ -14,13 +14,13 @@ pub(super) enum SoundBackend { pub(super) fn detect_backend() -> SoundBackend { // Prefer canberra first because it supports both sound names and files - if program_in_path("canberra-gtk-play") { + if system_tools::program_path("canberra-gtk-play").is_ok() { return SoundBackend::Canberra; } - if program_in_path("pw-play") { + if system_tools::program_path("pw-play").is_ok() { return SoundBackend::PwPlay; } - if program_in_path("paplay") { + if system_tools::program_path("paplay").is_ok() { return SoundBackend::PaPlay; } SoundBackend::None diff --git a/crates/unixnotis-daemon/src/sound/command.rs b/crates/unixnotis-daemon/src/sound/command.rs index 47c8c512a..e21873e05 100644 --- a/crates/unixnotis-daemon/src/sound/command.rs +++ b/crates/unixnotis-daemon/src/sound/command.rs @@ -1,3 +1,5 @@ +use std::ffi::OsString; +use std::fs::File; use std::process::Stdio; use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -8,46 +10,74 @@ use tokio::time::timeout; use tracing::{debug, warn}; use unixnotis_core::util; +use crate::system_tools; + use super::SoundSource; const SOUND_COMMAND_TIMEOUT: Duration = Duration::from_secs(3); // Small cap prevents unbounded process fanout during notification bursts const SOUND_MAX_CONCURRENT: usize = 2; -pub(super) fn play_with_canberra(source: SoundSource) { +pub(super) fn play_with_canberra(source: SoundSource) -> bool { // canberra supports both symbolic names and direct files let mut args = Vec::new(); + let mut display_args = Vec::new(); + let mut keepalive = None; match source { SoundSource::Name(name) => { - args.push("-i".to_string()); - args.push(name); + args.push(OsString::from("-i")); + args.push(OsString::from(name)); + display_args.clone_from(&args); } - SoundSource::File(path) => { - args.push("-f".to_string()); - args.push(path.to_string_lossy().to_string()); + SoundSource::File(file) => { + args.push(OsString::from("-f")); + args.push(file.playback_path().into_os_string()); + display_args.push(OsString::from("-f")); + display_args.push(file.path().as_os_str().to_os_string()); + keepalive = Some(file.keepalive()); } } - spawn_sound_command("canberra", "canberra-gtk-play", &args); + spawn_sound_command( + "canberra", + "canberra-gtk-play", + &args, + &display_args, + keepalive, + ) } -pub(super) fn play_with_pw_play(source: SoundSource) { +pub(super) fn play_with_pw_play(source: SoundSource) -> bool { // pw-play accepts only direct file playback - let SoundSource::File(path) = source else { + let SoundSource::File(file) = source else { warn!("pw-play backend does not support sound-name hints"); - return; + return false; }; - let args = vec![path.to_string_lossy().to_string()]; - spawn_sound_command("pw-play", "pw-play", &args); + let args = vec![file.playback_path().into_os_string()]; + let display_args = vec![file.path().as_os_str().to_os_string()]; + spawn_sound_command( + "pw-play", + "pw-play", + &args, + &display_args, + Some(file.keepalive()), + ) } -pub(super) fn play_with_paplay(source: SoundSource) { +pub(super) fn play_with_paplay(source: SoundSource) -> bool { // paplay accepts only direct file playback - let SoundSource::File(path) = source else { + let SoundSource::File(file) = source else { warn!("paplay backend does not support sound-name hints"); - return; + return false; }; - let args = vec![path.to_string_lossy().to_string()]; - spawn_sound_command("paplay", "paplay", &args); + let args = vec![file.playback_path().into_os_string()]; + let display_args = vec![file.path().as_os_str().to_os_string()]; + spawn_sound_command( + "paplay", + "paplay", + &args, + &display_args, + Some(file.keepalive()), + ) } fn sound_semaphore() -> &'static Arc { @@ -56,30 +86,35 @@ fn sound_semaphore() -> &'static Arc { SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(SOUND_MAX_CONCURRENT))) } -fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { +fn spawn_sound_command( + backend: &'static str, + program: &str, + args: &[OsString], + display_args: &[OsString], + keepalive: Option>, +) -> bool { let limiter = sound_semaphore().clone(); // try_acquire keeps this call non-blocking on hot paths let permit = if let Ok(permit) = limiter.try_acquire_owned() { permit } else { debug!(backend, "sound command skipped (concurrency limit reached)"); - return; - }; - let command_str = if args.is_empty() { - program.to_string() - } else { - format!("{program} {}", args.join(" ")) + return false; }; + let command_str = sound_command_display(program, display_args); let command_snip = util::log_snippet(&command_str); - let mut command = Command::new(program); - command - .args(args) - // Child process has no need for inherited stdio streams - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - // Ensure child exits if task is dropped early - .kill_on_drop(true); + let mut command = match build_sound_command(program, args) { + Ok(command) => command, + Err(err) => { + warn!( + backend, + program, + ?err, + "trusted sound backend is unavailable" + ); + return false; + } + }; match command.spawn() { Ok(child) => { let pid = child.id(); @@ -92,8 +127,11 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { tokio::spawn(async move { // Keep the permit owned until this child exits or gets killed let _permit = permit; + // Keep descriptor-backed paths valid for the complete decoder lifetime + let _keepalive = keepalive; reap_sound_child(backend, command_snip, pid, child).await; }); + true } Err(err) => { warn!( @@ -102,10 +140,63 @@ fn spawn_sound_command(backend: &'static str, program: &str, args: &[String]) { ?err, "failed to spawn sound command" ); + false } } } +fn build_sound_command(program: &str, args: &[OsString]) -> std::io::Result { + let mut command = system_tools::tokio_command(program)?; + command + // OsString keeps every valid Unix path byte intact + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // Dropped tasks must not leave playback children behind + .kill_on_drop(true); + apply_sound_environment(&mut command); + Ok(command) +} + +fn apply_sound_environment(command: &mut Command) { + const PASSTHROUGH: [&str; 12] = [ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LC_ALL", + "PIPEWIRE_REMOTE", + "PULSE_SERVER", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + ]; + + // Decoder helpers receive only session routing data and a fixed system search path + command.env_clear().env( + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ); + for name in PASSTHROUGH { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } +} + +fn sound_command_display(program: &str, args: &[OsString]) -> String { + let mut display = program.to_string(); + for argument in args { + // Lossy text is restricted to bounded diagnostics, never execution + display.push(' '); + display.push_str(&argument.to_string_lossy()); + } + display +} + async fn reap_sound_child( backend: &'static str, command_snip: String, diff --git a/crates/unixnotis-daemon/src/sound/mod.rs b/crates/unixnotis-daemon/src/sound/mod.rs index 831c46af7..035a7f33d 100644 --- a/crates/unixnotis-daemon/src/sound/mod.rs +++ b/crates/unixnotis-daemon/src/sound/mod.rs @@ -4,5 +4,8 @@ mod backend; mod command; mod resolve; mod settings; +mod source; +mod wav; -pub use settings::{SoundSettings, SoundSource}; +pub use settings::SoundSettings; +use source::{SoundFile, SoundSource}; diff --git a/crates/unixnotis-daemon/src/sound/resolve.rs b/crates/unixnotis-daemon/src/sound/resolve.rs index a0bbe0b06..4ee26fcde 100644 --- a/crates/unixnotis-daemon/src/sound/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/resolve.rs @@ -3,21 +3,30 @@ use std::fs; use std::path::{Path, PathBuf}; use tracing::{debug, info}; +use unixnotis_core::filesystem::{open_regular_file, ContainedPath}; use unixnotis_core::{util, Config}; use zbus::zvariant::OwnedValue; -use super::SoundSource; +use super::{wav::is_safe_pcm_wav, SoundFile, SoundSource}; const MAX_SOUND_FILE_BYTES: u64 = 16 * 1024 * 1024; -pub(super) fn resolve_hint_sound(hints: &HashMap) -> Option { - // sound-file has priority because it is the most explicit payload - if let Some(file) = hint_string(hints, "sound-file") { - let path = resolve_sound_file(&file); - if validate_sound_file_path(&path) { - return Some(SoundSource::File(path)); +pub(super) fn resolve_hint_sound( + hints: &HashMap, + allow_file_hints: bool, + allowed_dirs: &[PathBuf], +) -> Option { + // File hints cross into host decoders and stay disabled unless explicitly allowed + if allow_file_hints { + if let Some(file) = hint_string(hints, "sound-file") { + let path = resolve_sound_file(&file); + if path_is_allowed(&path, allowed_dirs) { + if let Some(file) = open_sound_file(&path, true) { + return Some(SoundSource::File(file)); + } + } + debug!(path = %path.display(), "ignoring invalid sound-file hint"); } - debug!(path = %path.display(), "ignoring invalid sound-file hint"); } // Fall back to event name when file path is missing or invalid if let Some(name) = hint_string(hints, "sound-name") { @@ -26,21 +35,44 @@ pub(super) fn resolve_hint_sound(hints: &HashMap) -> Option< None } -pub(super) fn resolve_default_file(config: &Config) -> Option { +pub(super) fn resolve_default_file( + config: &Config, + config_dir: Option<&Path>, +) -> Option { // First choice is an explicit default file if let Some(path) = config.sound.default_file.as_ref() { - let resolved = resolve_config_path(path).or_else(|| Some(PathBuf::from(path))); - return resolved.filter(|path| validate_sound_file_path(path)); + let resolved = resolve_config_path(path, config_dir); + return resolved.and_then(|path| open_sound_file(&path, false)); } // Second choice is scanning a configured directory for the first valid audio file if let Some(dir) = config.sound.default_dir.as_ref() { - if let Some(path) = resolve_config_path(dir).or_else(|| Some(PathBuf::from(dir))) { + if let Some(path) = resolve_config_path(dir, config_dir) { return choose_first_sound_file(&path); } } None } +pub(super) fn resolve_config_dir(config_path: Option<&Path>) -> Option { + // An explicit daemon path owns relative assets even when the environment selects another file + let config_path = config_path + .map(Path::to_path_buf) + .or_else(|| Config::active_config_path().ok())?; + config_path.parent().map(Path::to_path_buf) +} + +pub(super) fn resolve_allowed_file_hint_dirs( + config: &Config, + config_dir: Option<&Path>, +) -> Vec { + config + .sound + .allowed_file_hint_dirs + .iter() + .filter_map(|path| resolve_config_path(path, config_dir)) + .collect() +} + pub(super) fn hint_bool(hints: &HashMap, key: &str) -> Option { // Borrowed conversion avoids cloning large values hints.get(key).and_then(|value| bool::try_from(value).ok()) @@ -97,18 +129,18 @@ fn percent_decode_path(value: &str) -> Option { String::from_utf8(out).ok() } -fn resolve_config_path(value: &str) -> Option { +fn resolve_config_path(value: &str, config_dir: Option<&Path>) -> Option { // Expand "~" so config remains short and portable let path = util::expand_tilde(value); let path = PathBuf::from(path.as_ref()); if path.is_absolute() { return Some(path); } - let base = Config::default_config_dir().ok()?; + let base = config_dir?; Some(base.join(path)) } -fn choose_first_sound_file(dir: &Path) -> Option { +fn choose_first_sound_file(dir: &Path) -> Option { // Missing directory is treated as no default instead of an error path let entries = fs::read_dir(dir).ok()?; let mut candidates = Vec::new(); @@ -121,15 +153,18 @@ fn choose_first_sound_file(dir: &Path) -> Option { } // Deterministic ordering keeps startup behavior stable between runs candidates.sort(); - let selected = candidates.into_iter().next(); - if let Some(path) = selected.as_ref() { + for path in candidates { + let Some(selected) = open_sound_file(&path, false) else { + continue; + }; let name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("sound file"); info!(name, "using default notification sound file"); + return Some(selected); } - selected + None } fn has_audio_extension(path: &Path) -> bool { @@ -143,12 +178,35 @@ fn has_audio_extension(path: &Path) -> bool { ) } -fn validate_sound_file_path(path: &Path) -> bool { - let Ok(meta) = fs::metadata(path) else { - return false; - }; - // Regular files with a bounded size avoid device and FIFO abuse - meta.is_file() && meta.len() <= MAX_SOUND_FILE_BYTES && has_audio_extension(path) +fn open_sound_file(path: &Path, require_safe_hint_format: bool) -> Option { + if !has_audio_extension(path) { + return None; + } + // One descriptor binds all checks and later playback to the same regular file + let file = open_regular_file(path).ok()?; + let metadata = file.metadata().ok()?; + if metadata.len() > MAX_SOUND_FILE_BYTES { + return None; + } + if require_safe_hint_format && !has_safe_hint_format(path, &file, metadata.len()) { + return None; + } + Some(SoundFile::new(path.to_path_buf(), file)) +} + +fn path_is_allowed(path: &Path, allowed_dirs: &[PathBuf]) -> bool { + path.is_absolute() + && allowed_dirs + .iter() + .any(|root| ContainedPath::resolve(root, path).is_ok()) +} + +fn has_safe_hint_format(path: &Path, file: &fs::File, file_len: u64) -> bool { + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + extension.eq_ignore_ascii_case("wav") && is_safe_pcm_wav(file, file_len) } fn hint_string(hints: &HashMap, key: &str) -> Option { diff --git a/crates/unixnotis-daemon/src/sound/settings.rs b/crates/unixnotis-daemon/src/sound/settings.rs index 0113a2947..afb85ecc6 100644 --- a/crates/unixnotis-daemon/src/sound/settings.rs +++ b/crates/unixnotis-daemon/src/sound/settings.rs @@ -1,7 +1,7 @@ //! Notification sound playback and backend selection use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -11,7 +11,11 @@ use zbus::zvariant::OwnedValue; use super::backend::{detect_backend, SoundBackend}; use super::command::{play_with_canberra, play_with_paplay, play_with_pw_play}; -use super::resolve::{hint_bool, resolve_default_file, resolve_hint_sound}; +use super::resolve::{ + hint_bool, resolve_allowed_file_hint_dirs, resolve_config_dir, resolve_default_file, + resolve_hint_sound, +}; +use super::SoundSource; const SOUND_MIN_INTERVAL: Duration = Duration::from_millis(150); @@ -21,24 +25,22 @@ pub struct SoundSettings { enabled: bool, // Detected backend that is safe to call on this machine backend: SoundBackend, + // File hints are an explicit compatibility opt-in + allow_file_hints: bool, + // Every accepted file hint must remain beneath one configured directory + allowed_file_hint_dirs: Vec, // Fallback event name used by canberra-style backends default_name: Option, // Fallback audio file path when hint does not supply one - default_file: Option, + default_file: Option, // Last successful play request used for burst throttling last_played: Mutex>, } -#[derive(Debug, Clone)] -pub enum SoundSource { - Name(String), - File(PathBuf), -} - impl SoundSettings { /// Build sound settings from configuration and resolve any custom paths - pub fn from_config(config: &Config) -> Self { - // Backend discovery is done once during startup to avoid repeated PATH scans + pub fn from_config(config: &Config, config_path: Option<&Path>) -> Self { + // Backend discovery is done once during startup to avoid repeated trusted-path scans let backend = detect_backend(); debug!(?backend, "sound backend selected"); if Self::should_warn_missing_backend(config.sound.enabled, backend) { @@ -46,21 +48,34 @@ impl SoundSettings { } // Resolve config paths once so notification hot paths stay cheap - let default_file = resolve_default_file(config); + let config_dir = resolve_config_dir(config_path); + let default_file = resolve_default_file(config, config_dir.as_deref()); + let allowed_file_hint_dirs = resolve_allowed_file_hint_dirs(config, config_dir.as_deref()); Self { enabled: config.sound.enabled, backend, + allow_file_hints: config.sound.allow_file_hints, + allowed_file_hint_dirs, default_name: config.sound.default_name.clone(), default_file, last_played: Mutex::new(None), } } - /// Return true when sound playback is enabled and a backend is available - pub fn supports_sound(&self) -> bool { + /// Return true when internal notification playback can use a configured backend + pub fn has_playback_backend(&self) -> bool { self.enabled && self.backend != SoundBackend::None } + /// Return true when sender-requested freedesktop sound semantics are available + pub fn supports_fdo_sound_capability(&self) -> bool { + // The specification requires `sound-file` and `suppress-sound` support when + // advertising `sound`, so an empty or disabled file policy must fail closed + self.has_playback_backend() + && self.allow_file_hints + && !self.allowed_file_hint_dirs.is_empty() + } + /// Resolve a sound source from hints or defaults and play if allowed pub fn play_from_hints(&self, hints: &HashMap, allow_sound: bool) -> bool { // Hard gates first to keep the common no-sound path fast @@ -71,17 +86,13 @@ impl SoundSettings { if hint_bool(hints, "suppress-sound").unwrap_or(false) { return false; } - // Small cooldown avoids noisy bursts when apps spam fast updates - if !self.should_play_now() { - return false; - } - // Hint source wins, then fallback source from config - let source = resolve_hint_sound(hints).or_else(|| self.default_source()); - if let Some(source) = source { - return self.play(source); - } - false + let source = resolve_hint_sound(hints, self.allow_file_hints, &self.allowed_file_hint_dirs) + .or_else(|| self.default_source()); + let Some(source) = source else { + return false; + }; + self.play_with_cooldown(source, Instant::now()) } fn should_warn_missing_backend(sound_enabled: bool, backend: SoundBackend) -> bool { @@ -101,30 +112,18 @@ impl SoundSettings { fn play(&self, source: SoundSource) -> bool { // Backend-specific launcher keeps this method tiny and testable match self.backend { - SoundBackend::Canberra => { - play_with_canberra(source); - true - } - SoundBackend::PwPlay => { - play_with_pw_play(source); - true - } - SoundBackend::PaPlay => { - play_with_paplay(source); - true - } + SoundBackend::Canberra => play_with_canberra(source), + SoundBackend::PwPlay => play_with_pw_play(source), + SoundBackend::PaPlay => play_with_paplay(source), SoundBackend::None => false, } } - fn should_play_now(&self) -> bool { - self.should_play_at(Instant::now()) - } - - fn should_play_at(&self, now: Instant) -> bool { - let Ok(mut guard) = self.last_played.lock() else { - // A poisoned lock should not disable alerts forever - return true; + fn play_with_cooldown(&self, source: SoundSource, now: Instant) -> bool { + let mut guard = match self.last_played.lock() { + Ok(guard) => guard, + // Recover the timestamp so a prior panic cannot disable alerts forever + Err(poisoned) => poisoned.into_inner(), }; if let Some(last) = *guard { // Skip playback if requests are too close together @@ -132,7 +131,12 @@ impl SoundSettings { return false; } } - // Record now only when the request is accepted + // Cooldown measures accepted playback, not notification attempts + // Missing, unsupported, concurrency-rejected, and spawn-failed sources must + // not suppress the next legitimate sound + if !self.play(source) { + return false; + } *guard = Some(now); true } diff --git a/crates/unixnotis-daemon/src/sound/source.rs b/crates/unixnotis-daemon/src/sound/source.rs new file mode 100644 index 000000000..1d0ec2b55 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/source.rs @@ -0,0 +1,49 @@ +//! Descriptor-pinned sound inputs + +use std::fs::File; +use std::os::fd::AsRawFd; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub(super) struct SoundFile { + // The original path is retained only for diagnostics and policy checks + path: PathBuf, + // The open file pins the validated object until the playback child exits + file: Arc, +} + +impl SoundFile { + pub(super) fn new(path: PathBuf, file: File) -> Self { + Self { + path, + file: Arc::new(file), + } + } + + pub(super) fn path(&self) -> &Path { + &self.path + } + + pub(super) fn playback_path(&self) -> PathBuf { + // The child opens the daemon's retained descriptor instead of resolving the source again + PathBuf::from("/proc") + .join(std::process::id().to_string()) + .join("fd") + .join(self.file.as_raw_fd().to_string()) + } + + pub(super) fn keepalive(&self) -> Arc { + self.file.clone() + } +} + +#[derive(Debug, Clone)] +pub(super) enum SoundSource { + Name(String), + File(SoundFile), +} + +#[cfg(test)] +#[path = "tests/source.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/sound/tests/command.rs b/crates/unixnotis-daemon/src/sound/tests/command.rs index 01b0325ac..8b16fdf63 100644 --- a/crates/unixnotis-daemon/src/sound/tests/command.rs +++ b/crates/unixnotis-daemon/src/sound/tests/command.rs @@ -1,4 +1,60 @@ use super::*; +use crate::system_tools::routing::use_fake_tool_bin; +use crate::test_support::TempRoot; + +fn install_fake_sound_tool(root: &TempRoot, name: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = root.join(name); + std::fs::write(&path, "#!/bin/sh\n: > \"$0.called\"\n").expect("write fake sound tool"); + let mut permissions = std::fs::metadata(&path) + .expect("fake sound tool metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("make fake sound tool executable"); + path +} + +fn fake_sound_tool(name: &str) -> (TempRoot, crate::system_tools::routing::FakeToolBinGuard) { + let root = TempRoot::new("sound-command"); + install_fake_sound_tool(&root, name); + let guard = use_fake_tool_bin(root.path()); + (root, guard) +} + +async fn launch_until_marker(path: &std::path::Path, mut launch: impl FnMut()) { + tokio::time::timeout(Duration::from_secs(5), async { + while !path.exists() { + // Other sound tests can briefly occupy the process-wide playback permits + launch(); + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("sound backend should create its marker"); + // The marker is written immediately before exit, so allow the reaper to release its permit + tokio::time::sleep(Duration::from_millis(20)).await; +} + +#[cfg(unix)] +#[test] +fn sound_command_preserves_non_utf8_argument_bytes() { + use std::os::unix::ffi::OsStringExt; + + let (_root, _tools) = fake_sound_tool("sound-player"); + let path = OsString::from_vec(b"/tmp/sound-\xff.ogg".to_vec()); + let command = build_sound_command("sound-player", std::slice::from_ref(&path)) + .expect("build trusted sound command"); + let args = command.as_std().get_args().collect::>(); + let display = sound_command_display("sound-player", std::slice::from_ref(&path)); + + assert_eq!(args, vec![path.as_os_str()]); + assert_eq!(display, "sound-player /tmp/sound-�.ogg"); + assert!(command + .as_std() + .get_envs() + .any(|(name, value)| name == "PATH" && value.is_some())); +} #[cfg(target_os = "linux")] #[tokio::test] @@ -12,3 +68,34 @@ async fn reaps_short_lived_command() { let child = command.spawn().expect("spawn true"); reap_sound_child("test", "true".to_string(), child.id(), child).await; } + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "current_thread")] +async fn every_sound_backend_launches_its_trusted_tool() { + let root = TempRoot::new("sound-backends"); + let canberra = install_fake_sound_tool(&root, "canberra-gtk-play"); + let pw_play = install_fake_sound_tool(&root, "pw-play"); + let paplay = install_fake_sound_tool(&root, "paplay"); + let _tools = use_fake_tool_bin(root.path()); + let sound_path = root.join("sound.wav"); + std::fs::write(&sound_path, b"sound fixture").expect("write sound fixture"); + + launch_until_marker(&canberra.with_extension("called"), || { + play_with_canberra(SoundSource::Name("message-new".to_string())); + }) + .await; + + let file = std::fs::File::open(&sound_path).expect("open sound fixture for pw-play"); + let pw_source = crate::sound::SoundFile::new(sound_path.clone(), file); + launch_until_marker(&pw_play.with_extension("called"), || { + play_with_pw_play(SoundSource::File(pw_source.clone())); + }) + .await; + + let file = std::fs::File::open(&sound_path).expect("open sound fixture for paplay"); + let paplay_source = crate::sound::SoundFile::new(sound_path, file); + launch_until_marker(&paplay.with_extension("called"), || { + play_with_paplay(SoundSource::File(paplay_source.clone())); + }) + .await; +} diff --git a/crates/unixnotis-daemon/src/sound/tests/resolve.rs b/crates/unixnotis-daemon/src/sound/tests/resolve.rs index 1afcfaeba..6f009ea27 100644 --- a/crates/unixnotis-daemon/src/sound/tests/resolve.rs +++ b/crates/unixnotis-daemon/src/sound/tests/resolve.rs @@ -1,5 +1,5 @@ use super::*; -use crate::test_support::{env_lock, EnvVarGuard, TempRoot}; +use crate::test_support::TempRoot; use zbus::zvariant::Value; fn string_value(value: &str) -> OwnedValue { @@ -9,7 +9,13 @@ fn string_value(value: &str) -> OwnedValue { } fn write_sound_file(path: &Path) { - fs::write(path, b"sound").expect("write sound file"); + let contents = match path.extension().and_then(|extension| extension.to_str()) { + Some(extension) if extension.eq_ignore_ascii_case("wav") => { + b"RIFF\x26\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00\x44\xac\x00\x00\x88\x58\x01\x00\x02\x00\x10\x00data\x02\x00\x00\x00\x00\x00".as_slice() + } + _ => b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01vorbis".as_slice(), + }; + fs::write(path, contents).expect("write sound file"); } #[test] @@ -37,9 +43,9 @@ fn percent_decode_path_rejects_nul_and_keeps_utf8_valid() { } #[test] -fn resolve_hint_sound_prefers_valid_sound_file_and_falls_back_to_name() { +fn resolve_hint_sound_requires_opt_in_allowed_directory_and_safe_format() { let root = TempRoot::new("sound-hints"); - let sound = root.join("alert.ogg"); + let sound = root.join("alert.wav"); write_sound_file(&sound); let mut hints = HashMap::new(); @@ -49,39 +55,83 @@ fn resolve_hint_sound_prefers_valid_sound_file_and_falls_back_to_name() { ); hints.insert("sound-name".to_string(), string_value("message-new")); - match resolve_hint_sound(&hints).expect("sound-file should resolve") { - SoundSource::File(path) => assert_eq!(path, sound), + match resolve_hint_sound(&hints, true, &[root.path().to_path_buf()]) + .expect("sound-file should resolve") + { + SoundSource::File(file) => assert_eq!(file.path(), sound), SoundSource::Name(name) => panic!("sound file should win over name: {name}"), } + match resolve_hint_sound(&hints, false, &[root.path().to_path_buf()]) + .expect("sound-name should remain when file hints are disabled") + { + SoundSource::Name(name) => assert_eq!(name, "message-new"), + SoundSource::File(file) => panic!("disabled sound file was accepted: {:?}", file.path()), + } + + match resolve_hint_sound(&hints, true, &[]) + .expect("sound-name should remain when no directory is allowed") + { + SoundSource::Name(name) => assert_eq!(name, "message-new"), + SoundSource::File(file) => panic!("uncontained sound file was accepted: {:?}", file.path()), + } + hints.insert( "sound-file".to_string(), string_value("/missing/not-a-sound.ogg"), ); - match resolve_hint_sound(&hints).expect("sound-name should remain fallback") { + match resolve_hint_sound(&hints, true, &[root.path().to_path_buf()]) + .expect("sound-name should remain fallback") + { SoundSource::Name(name) => assert_eq!(name, "message-new"), - SoundSource::File(path) => panic!("invalid sound file should not be used: {path:?}"), + SoundSource::File(file) => { + panic!("invalid sound file should not be used: {:?}", file.path()) + } } } #[test] fn resolve_default_file_uses_relative_config_path_and_validates_file() { - let _guard = env_lock(); let root = TempRoot::new("sound-default-file"); - let config_dir = root.join("xdg"); - let unixnotis_dir = config_dir.join("unixnotis"); + let unixnotis_dir = root.join("unixnotis"); fs::create_dir_all(&unixnotis_dir).expect("create config dir"); let sound = unixnotis_dir.join("relative.ogg"); write_sound_file(&sound); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", &config_dir); - let mut config = Config::default(); config.sound.default_file = Some("relative.ogg".to_string()); - assert_eq!(resolve_default_file(&config), Some(sound)); + let selected = resolve_default_file(&config, Some(&unixnotis_dir)) + .expect("relative default should resolve"); + assert_eq!(selected.path(), sound); config.sound.default_file = Some("relative.txt".to_string()); - assert!(resolve_default_file(&config).is_none()); + assert!(resolve_default_file(&config, Some(&unixnotis_dir)).is_none()); +} + +#[test] +fn config_directory_and_allowed_hint_paths_follow_the_active_config() { + let root = TempRoot::new("sound-config-paths"); + let config_path = root.join("profile/config.toml"); + let config_dir = config_path.parent().expect("config path has parent"); + let absolute = root.join("shared-sounds"); + let mut config = Config::default(); + config.sound.allowed_file_hint_dirs = vec![ + "relative-sounds".to_string(), + absolute.to_string_lossy().into_owned(), + ]; + + assert_eq!( + resolve_config_dir(Some(&config_path)).as_deref(), + Some(config_dir) + ); + assert_eq!( + resolve_allowed_file_hint_dirs(&config, Some(config_dir)), + vec![config_dir.join("relative-sounds"), absolute.clone()] + ); + assert_eq!( + resolve_allowed_file_hint_dirs(&config, None), + vec![absolute] + ); } #[test] @@ -94,7 +144,7 @@ fn choose_first_sound_file_filters_extensions_and_sorts_deterministically() { let selected = choose_first_sound_file(root.path()).expect("sound file should be selected"); assert_eq!( - selected.file_name().and_then(|name| name.to_str()), + selected.path().file_name().and_then(|name| name.to_str()), Some("b-first.OGG") ); } @@ -125,9 +175,10 @@ fn has_audio_extension_accepts_supported_audio_extensions_only() { } #[test] -fn validate_sound_file_path_rejects_missing_oversized_and_non_audio_files() { +fn sound_file_open_rejects_missing_oversized_and_non_audio_files() { let root = TempRoot::new("sound-validate"); let valid = root.join("valid.ogg"); + let exact_limit = root.join("exact-limit.ogg"); let oversized = root.join("oversized.ogg"); let wrong_ext = root.join("valid.txt"); write_sound_file(&valid); @@ -136,15 +187,67 @@ fn validate_sound_file_path_rejects_missing_oversized_and_non_audio_files() { .expect("create oversized sound") .set_len(MAX_SOUND_FILE_BYTES + 1) .expect("resize oversized sound"); + fs::File::create(&exact_limit) + .expect("create exact-limit sound") + .set_len(MAX_SOUND_FILE_BYTES) + .expect("resize exact-limit sound"); + + assert!(open_sound_file(&valid, false).is_some()); + assert!(open_sound_file(&exact_limit, false).is_some()); + assert!(open_sound_file(&oversized, false).is_none()); + assert!(open_sound_file(&wrong_ext, false).is_none()); + assert!(open_sound_file(&root.join("missing.ogg"), false).is_none()); +} + +#[test] +fn hint_format_validation_rejects_spoofed_and_complex_audio_formats() { + let root = TempRoot::new("sound-hint-format"); + let spoofed = root.join("spoofed.ogg"); + let mp3 = root.join("sound.mp3"); + let pcm = root.join("sound.wav"); + fs::write(&spoofed, b"not an ogg file").expect("write spoofed Ogg file"); + fs::write(&mp3, b"ID3\x04\x00\x00").expect("write MP3 file"); + write_sound_file(&pcm); + + assert!(open_sound_file(&spoofed, true).is_none()); + assert!(open_sound_file(&mp3, true).is_none()); + assert!(open_sound_file(&pcm, true).is_some()); +} + +#[test] +fn safe_hint_format_accepts_only_structurally_valid_pcm_wave() { + let root = TempRoot::new("sound-safe-format"); + let valid_wav = root.join("valid.wav"); + let compressed_wav = root.join("compressed.wav"); + let ogg = root.join("valid.ogg"); + + write_sound_file(&valid_wav); + let mut bytes = fs::read(&valid_wav).expect("read PCM fixture"); + bytes[20..22].copy_from_slice(&3u16.to_le_bytes()); + fs::write(&compressed_wav, &bytes).expect("write compressed WAV fixture"); + write_sound_file(&ogg); + + assert!(open_sound_file(&valid_wav, true).is_some()); + assert!(open_sound_file(&compressed_wav, true).is_none()); + assert!(open_sound_file(&ogg, true).is_none()); +} + +#[cfg(unix)] +#[test] +fn sound_file_open_rejects_symbolic_links() { + use std::os::unix::fs::symlink; + + let root = TempRoot::new("sound-symlink"); + let target = root.join("target.ogg"); + let link = root.join("link.ogg"); + write_sound_file(&target); + symlink(&target, &link).expect("create sound symlink"); - assert!(validate_sound_file_path(&valid)); - assert!(!validate_sound_file_path(&oversized)); - assert!(!validate_sound_file_path(&wrong_ext)); - assert!(!validate_sound_file_path(&root.join("missing.ogg"))); + assert!(open_sound_file(&link, false).is_none()); } #[cfg(target_os = "linux")] #[test] -fn validate_sound_file_path_rejects_device_nodes() { - assert!(!validate_sound_file_path(Path::new("/dev/zero"))); +fn sound_file_open_rejects_device_nodes() { + assert!(open_sound_file(Path::new("/dev/zero"), false).is_none()); } diff --git a/crates/unixnotis-daemon/src/sound/tests/settings.rs b/crates/unixnotis-daemon/src/sound/tests/settings.rs index 4e7ad6e2c..359a621ef 100644 --- a/crates/unixnotis-daemon/src/sound/tests/settings.rs +++ b/crates/unixnotis-daemon/src/sound/tests/settings.rs @@ -1,10 +1,15 @@ use super::*; +use crate::sound::SoundFile; +use crate::system_tools::routing::use_fake_tool_bin; +use crate::test_support::TempRoot; use zbus::zvariant::{OwnedValue, Value}; fn settings(enabled: bool, backend: SoundBackend) -> SoundSettings { SoundSettings { enabled, backend, + allow_file_hints: false, + allowed_file_hint_dirs: Vec::new(), default_name: Some("message-new-instant".to_string()), default_file: None, last_played: Mutex::new(None), @@ -30,11 +35,45 @@ fn last_played_is_set(settings: &SoundSettings) -> bool { .is_some() } +fn install_fake_canberra(root: &TempRoot) { + use std::os::unix::fs::PermissionsExt; + + let path = root.join("canberra-gtk-play"); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake canberra tool"); + let mut permissions = std::fs::metadata(&path) + .expect("fake canberra metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("make fake canberra executable"); +} + +fn sound_name_hints(name: &str) -> HashMap { + HashMap::from([( + "sound-name".to_string(), + Value::from(name) + .try_into() + .expect("sound name should convert"), + )]) +} + #[test] -fn supports_sound_requires_enabled_config_and_backend() { - assert!(settings(true, SoundBackend::Canberra).supports_sound()); - assert!(!settings(false, SoundBackend::Canberra).supports_sound()); - assert!(!settings(true, SoundBackend::None).supports_sound()); +fn playback_backend_requires_enabled_config_and_available_tool() { + assert!(settings(true, SoundBackend::Canberra).has_playback_backend()); + assert!(!settings(false, SoundBackend::Canberra).has_playback_backend()); + assert!(!settings(true, SoundBackend::None).has_playback_backend()); +} + +#[test] +fn fdo_sound_capability_requires_allowed_file_hints_and_backend() { + let mut sound = settings(true, SoundBackend::Canberra); + assert!(!sound.supports_fdo_sound_capability()); + + sound.allow_file_hints = true; + sound.allowed_file_hint_dirs = vec![PathBuf::from("/allowed")]; + assert!(sound.supports_fdo_sound_capability()); + + sound.backend = SoundBackend::None; + assert!(!sound.supports_fdo_sound_capability()); } #[test] @@ -55,11 +94,15 @@ fn missing_backend_warning_policy_requires_enabled_sound_without_backend() { #[test] fn default_source_prefers_file_before_event_name() { + let root = TempRoot::new("sound-settings-default"); + let path = root.join("default.ogg"); + std::fs::write(&path, b"sound").expect("write default sound"); + let file = std::fs::File::open(&path).expect("open default sound"); let mut sound = settings(true, SoundBackend::Canberra); - sound.default_file = Some(PathBuf::from("/tmp/unixnotis-test.ogg")); + sound.default_file = Some(SoundFile::new(path.clone(), file)); match sound.default_source().expect("default source") { - SoundSource::File(path) => assert_eq!(path, PathBuf::from("/tmp/unixnotis-test.ogg")), + SoundSource::File(file) => assert_eq!(file.path(), path), SoundSource::Name(name) => panic!("file fallback should win over event name: {name}"), } @@ -88,9 +131,12 @@ fn play_from_hints_does_not_consume_throttle_when_global_or_notification_gate_bl assert!(!last_played_is_set(&suppressed)); } -#[test] -fn play_from_hints_uses_default_source_and_records_allowed_attempt() { - let sound = settings(true, SoundBackend::PwPlay); +#[tokio::test(flavor = "current_thread")] +async fn play_from_hints_uses_default_source_and_records_allowed_attempt() { + let root = TempRoot::new("sound-settings-play"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); + let sound = settings(true, SoundBackend::Canberra); assert!(sound.play_from_hints(&HashMap::new(), true)); @@ -102,7 +148,7 @@ fn play_from_hints_reports_false_when_no_backend_is_available() { let sound = settings(true, SoundBackend::None); assert!(!sound.play_from_hints(&HashMap::new(), true)); - assert!(last_played_is_set(&sound)); + assert!(!last_played_is_set(&sound)); } #[test] @@ -111,32 +157,37 @@ fn play_from_hints_returns_false_when_no_source_is_available() { sound.default_name = None; assert!(!sound.play_from_hints(&HashMap::new(), true)); - assert!(last_played_is_set(&sound)); + assert!(!last_played_is_set(&sound)); } -#[test] -fn should_play_now_records_first_request_and_throttles_immediate_repeat() { +#[tokio::test(flavor = "current_thread")] +async fn accepted_playback_records_cooldown_and_throttles_immediate_repeat() { + let root = TempRoot::new("sound-settings-cooldown"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); let sound = settings(true, SoundBackend::Canberra); - assert!(sound.should_play_now()); - assert!(!sound.should_play_now()); + assert!(sound.play_from_hints(&HashMap::new(), true)); + assert!(!sound.play_from_hints(&HashMap::new(), true)); } -#[test] -fn should_play_now_accepts_when_last_play_is_older_than_interval() { - let sound = settings(true, SoundBackend::Canberra); - let now = Instant::now(); - *sound.last_played.lock().expect("last_played lock") = Some( - now.checked_sub(SOUND_MIN_INTERVAL) - .expect("test clock should represent the previous playback window"), - ); +#[tokio::test(flavor = "current_thread")] +async fn unusable_request_does_not_suppress_next_valid_sound() { + let root = TempRoot::new("sound-settings-failed-then-valid"); + install_fake_canberra(&root); + let _tools = use_fake_tool_bin(root.path()); + let mut sound = settings(true, SoundBackend::Canberra); + sound.default_name = None; - assert!(sound.should_play_at(now)); + assert!(!sound.play_from_hints(&HashMap::new(), true)); + assert!(!last_played_is_set(&sound)); + assert!(sound.play_from_hints(&sound_name_hints("message-new"), true)); + assert!(last_played_is_set(&sound)); } #[test] fn play_reports_whether_backend_dispatch_was_available() { - assert!(settings(true, SoundBackend::PwPlay) + assert!(!settings(true, SoundBackend::PwPlay) .play(SoundSource::Name("message-new-instant".to_string()))); assert!(!settings(true, SoundBackend::None) .play(SoundSource::Name("message-new-instant".to_string()))); diff --git a/crates/unixnotis-daemon/src/sound/tests/source.rs b/crates/unixnotis-daemon/src/sound/tests/source.rs new file mode 100644 index 000000000..47e9fdf77 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/tests/source.rs @@ -0,0 +1,21 @@ +use std::fs; + +use super::SoundFile; +use crate::test_support::TempRoot; + +#[test] +fn playback_path_uses_the_retained_descriptor() { + let root = TempRoot::new("sound-source"); + let path = root.join("alert.wav"); + fs::write(&path, b"descriptor-backed sound").expect("write sound fixture"); + let file = fs::File::open(&path).expect("open sound fixture"); + let sound = SoundFile::new(path, file); + + let playback_path = sound.playback_path(); + + assert!(playback_path.starts_with(format!("/proc/{}/fd", std::process::id()))); + assert_eq!( + fs::read(playback_path).expect("read retained descriptor path"), + b"descriptor-backed sound" + ); +} diff --git a/crates/unixnotis-daemon/src/sound/tests/wav.rs b/crates/unixnotis-daemon/src/sound/tests/wav.rs new file mode 100644 index 000000000..4ed393320 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/tests/wav.rs @@ -0,0 +1,172 @@ +use super::*; +use crate::test_support::TempRoot; +use std::io::Write; + +fn chunk(name: &[u8; 4], contents: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(8 + contents.len() + (contents.len() & 1)); + bytes.extend_from_slice(name); + bytes.extend_from_slice( + &u32::try_from(contents.len()) + .expect("chunk size") + .to_le_bytes(), + ); + bytes.extend_from_slice(contents); + if contents.len() & 1 == 1 { + bytes.push(0); + } + bytes +} + +fn pcm_format(channels: u16, sample_rate: u32, bits_per_sample: u16) -> Vec { + let block_align = channels * (bits_per_sample / 8); + let byte_rate = sample_rate * u32::from(block_align); + let mut format = Vec::with_capacity(16); + format.extend_from_slice(&1u16.to_le_bytes()); + format.extend_from_slice(&channels.to_le_bytes()); + format.extend_from_slice(&sample_rate.to_le_bytes()); + format.extend_from_slice(&byte_rate.to_le_bytes()); + format.extend_from_slice(&block_align.to_le_bytes()); + format.extend_from_slice(&bits_per_sample.to_le_bytes()); + format +} + +fn wave(chunks: &[Vec]) -> Vec { + let payload_len = 4usize + chunks.iter().map(Vec::len).sum::(); + let mut bytes = Vec::with_capacity(payload_len + 8); + bytes.extend_from_slice(b"RIFF"); + bytes.extend_from_slice( + &u32::try_from(payload_len) + .expect("RIFF payload size") + .to_le_bytes(), + ); + bytes.extend_from_slice(b"WAVE"); + for item in chunks { + bytes.extend_from_slice(item); + } + bytes +} + +fn validate(bytes: &[u8]) -> bool { + let root = TempRoot::new("sound-wav-parser"); + let path = root.join("sound.wav"); + let mut file = fs::File::create(path).expect("create WAVE fixture"); + file.write_all(bytes).expect("write WAVE fixture"); + drop(file); + let file = fs::File::open(root.join("sound.wav")).expect("open WAVE fixture"); + is_safe_pcm_wav(&file, bytes.len() as u64) +} + +fn canonical_wave() -> Vec { + wave(&[ + chunk(b"fmt ", &pcm_format(1, 44_100, 16)), + chunk(b"data", &[0; 2]), + ]) +} + +#[test] +fn riff_and_wave_identifiers_are_validated_independently() { + let mut wrong_riff = canonical_wave(); + wrong_riff[..4].copy_from_slice(b"JUNK"); + let mut wrong_wave = canonical_wave(); + wrong_wave[8..12].copy_from_slice(b"AVI "); + + assert!(!validate(&wrong_riff)); + assert!(!validate(&wrong_wave)); +} + +#[test] +fn canonical_pcm_wave_requires_format_then_nonempty_aligned_data() { + let format = chunk(b"fmt ", &pcm_format(2, 48_000, 16)); + let data = chunk(b"data", &[0; 8]); + + assert!(validate(&wave(&[format.clone(), data.clone()]))); + assert!(!validate(&wave(&[data, format.clone()]))); + assert!(!validate(&wave(&[format.clone(), chunk(b"data", &[])]))); + assert!(!validate(&wave(&[format, chunk(b"data", &[0; 3])]))); +} + +#[test] +fn chunk_boundaries_prevent_fake_format_and_data_markers() { + let fake_format = chunk( + b"JUNK", + b"fmt \x10\0\0\0\x01\0\x01\0\x44\xac\0\0\x88\x58\x01\0\x02\0\x10\0data\x02\0\0\0\0\0", + ); + let compressed = { + let mut value = pcm_format(1, 44_100, 16); + value[0..2].copy_from_slice(&3u16.to_le_bytes()); + chunk(b"fmt ", &value) + }; + + assert!(!validate(&wave(std::slice::from_ref(&fake_format)))); + assert!(!validate(&wave(&[ + fake_format, + compressed, + chunk(b"data", &[0; 2]), + ]))); +} + +#[test] +fn odd_unknown_chunks_use_declared_padding_without_hiding_following_chunks() { + let junk = chunk(b"JUNK", b"x"); + let format = chunk(b"fmt ", &pcm_format(1, 44_100, 16)); + let data = chunk(b"data", &[0; 2]); + + assert!(validate(&wave(&[junk, format, data]))); +} + +#[test] +fn chunk_budget_accepts_the_limit_and_rejects_one_more_chunk() { + let mut chunks = vec![chunk(b"JUNK", &[]); MAX_WAV_CHUNKS - 2]; + chunks.push(chunk(b"fmt ", &pcm_format(1, 44_100, 16))); + chunks.push(chunk(b"data", &[0; 2])); + + assert!(validate(&wave(&chunks))); + + chunks.insert(0, chunk(b"JUNK", &[])); + assert!(!validate(&wave(&chunks))); +} + +#[test] +fn pcm_format_bounds_and_derived_rates_must_be_consistent() { + for invalid in [ + pcm_format(0, 44_100, 16), + pcm_format(3, 44_100, 16), + pcm_format(1, 7_999, 16), + pcm_format(1, 192_001, 16), + pcm_format(1, 44_100, 12), + ] { + assert!(!validate(&wave(&[ + chunk(b"fmt ", &invalid), + chunk(b"data", &[0; 4]), + ]))); + } + + let mut wrong_align = pcm_format(1, 44_100, 16); + wrong_align[12..14].copy_from_slice(&4u16.to_le_bytes()); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &wrong_align), + chunk(b"data", &[0; 4]), + ]))); + + let mut wrong_rate = pcm_format(1, 44_100, 16); + wrong_rate[8..12].copy_from_slice(&1u32.to_le_bytes()); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &wrong_rate), + chunk(b"data", &[0; 4]), + ]))); +} + +#[test] +fn riff_length_truncation_duplicate_format_and_extended_format_fail_closed() { + let format = chunk(b"fmt ", &pcm_format(1, 44_100, 16)); + let data = chunk(b"data", &[0; 2]); + let mut wrong_length = wave(&[format.clone(), data.clone()]); + wrong_length[4..8].copy_from_slice(&0u32.to_le_bytes()); + + assert!(!validate(&wrong_length)); + assert!(!validate(&wave(&[format.clone(), format, data]))); + assert!(!validate(&wave(&[ + chunk(b"fmt ", &[0; 18]), + chunk(b"data", &[0; 2]), + ]))); +} diff --git a/crates/unixnotis-daemon/src/sound/wav.rs b/crates/unixnotis-daemon/src/sound/wav.rs new file mode 100644 index 000000000..302d50693 --- /dev/null +++ b/crates/unixnotis-daemon/src/sound/wav.rs @@ -0,0 +1,143 @@ +//! Structural validation for notification-supplied PCM WAVE files + +use std::fs; +use std::os::unix::fs::FileExt; + +const RIFF_HEADER_BYTES: u64 = 12; +const CHUNK_HEADER_BYTES: u64 = 8; +const PCM_FORMAT_BYTES: u32 = 16; +const MAX_WAV_CHUNKS: usize = 1_024; +const MIN_SAMPLE_RATE: u32 = 8_000; +const MAX_SAMPLE_RATE: u32 = 192_000; +const MAX_CHANNELS: u16 = 2; + +#[derive(Clone, Copy)] +struct PcmFormat { + block_align: u16, +} + +pub(super) fn is_safe_pcm_wav(file: &fs::File, file_len: u64) -> bool { + let mut riff_header = [0u8; RIFF_HEADER_BYTES as usize]; + if file.read_exact_at(&mut riff_header, 0).is_err() + || &riff_header[..4] != b"RIFF" + || &riff_header[8..] != b"WAVE" + { + return false; + } + + // RIFF size excludes the leading identifier and size field + let Some(declared_len) = read_u32(&riff_header[4..8]).map(|size| u64::from(size) + 8) else { + return false; + }; + if declared_len != file_len { + return false; + } + + let mut cursor = RIFF_HEADER_BYTES; + let mut pcm_format = None; + let mut found_data = false; + let mut chunk_count = 0usize; + + while cursor < file_len { + chunk_count += 1; + if chunk_count > MAX_WAV_CHUNKS { + return false; + } + + let mut chunk_header = [0u8; CHUNK_HEADER_BYTES as usize]; + if file.read_exact_at(&mut chunk_header, cursor).is_err() { + return false; + } + let Some(chunk_size) = read_u32(&chunk_header[4..]) else { + return false; + }; + let data_start = match cursor.checked_add(CHUNK_HEADER_BYTES) { + Some(offset) => offset, + None => return false, + }; + let data_end = match data_start.checked_add(u64::from(chunk_size)) { + Some(offset) => offset, + None => return false, + }; + // RIFF chunks use one padding byte after odd-sized payloads + let padded_end = match data_end.checked_add(u64::from(chunk_size & 1)) { + Some(offset) => offset, + None => return false, + }; + + match &chunk_header[..4] { + b"fmt " => { + // Multiple format declarations create decoder-dependent interpretation + if pcm_format.is_some() { + return false; + } + pcm_format = read_pcm_format(file, data_start, chunk_size); + if pcm_format.is_none() { + return false; + } + } + b"data" => { + // The format must be known before audio bytes are accepted + let Some(format) = pcm_format else { + return false; + }; + if found_data || chunk_size == 0 || chunk_size % u32::from(format.block_align) != 0 + { + return false; + } + found_data = true; + } + _ => {} + } + + cursor = padded_end; + } + + // Exact cursor equality rejects truncated chunks and bytes outside declared chunk framing + cursor == file_len && found_data +} + +fn read_pcm_format(file: &fs::File, offset: u64, chunk_size: u32) -> Option { + // Restrict file hints to the fixed-size canonical PCM format block + if chunk_size != PCM_FORMAT_BYTES { + return None; + } + let mut format = [0u8; PCM_FORMAT_BYTES as usize]; + file.read_exact_at(&mut format, offset).ok()?; + + let audio_format = read_u16(&format[0..2])?; + let channels = read_u16(&format[2..4])?; + let sample_rate = read_u32(&format[4..8])?; + let byte_rate = read_u32(&format[8..12])?; + let block_align = read_u16(&format[12..14])?; + let bits_per_sample = read_u16(&format[14..16])?; + + if audio_format != 1 + || !(1..=MAX_CHANNELS).contains(&channels) + || !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) + || !matches!(bits_per_sample, 8 | 16 | 24 | 32) + { + return None; + } + + let bytes_per_sample = bits_per_sample.checked_div(8)?; + let expected_align = channels.checked_mul(bytes_per_sample)?; + let expected_rate = sample_rate.checked_mul(u32::from(expected_align))?; + if block_align != expected_align || byte_rate != expected_rate { + return None; + } + + Some(PcmFormat { block_align }) +} + +fn read_u16(bytes: &[u8]) -> Option { + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_u32(bytes: &[u8]) -> Option { + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +#[cfg(test)] +#[path = "tests/wav.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/core.rs b/crates/unixnotis-daemon/src/store/core.rs deleted file mode 100644 index f60176caf..000000000 --- a/crates/unixnotis-daemon/src/store/core.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::collections::HashMap; - -use indexmap::IndexMap; -use tracing::{debug, warn}; -use unixnotis_core::{Config, NotificationView}; - -use super::{DndStateStore, HistoryStore, NotificationStore, DND_STATE_VERSION}; - -impl NotificationStore { - pub fn new(config: Config) -> Self { - // Default constructor attempts to bind persistence to XDG state dir - let dnd_state_store = DndStateStore::new(); - Self::new_with_state_store(config, dnd_state_store) - } - - pub(crate) fn new_with_state_store( - config: Config, - dnd_state_store: Option, - ) -> Self { - // Config default is used unless a valid persisted value overrides it - let mut dnd_enabled = config.general.dnd_default; - if let Some(store) = dnd_state_store.as_ref() { - match store.load() { - Ok(Some(state)) if state.version == DND_STATE_VERSION => { - // Versioned state prevents accidental decode of incompatible formats - dnd_enabled = state.dnd_enabled; - debug!(dnd_enabled, "loaded persisted do-not-disturb state"); - } - Ok(Some(state)) => { - // Unknown version is ignored but logged for troubleshooting - warn!( - version = state.version, - "unsupported dnd state version; ignoring persisted value" - ); - } - Ok(None) => {} - Err(err) => { - // Persistence failures must never block daemon startup - warn!(?err, "failed to read persisted do-not-disturb state"); - } - } - } - - Self { - // IDs start at 1 to preserve protocol expectations - next_id: 1, - dnd_enabled, - dnd_revision: 0, - config, - active: IndexMap::new(), - history: HistoryStore::new(), - expirations: HashMap::new(), - dnd_state_store, - next_inhibitor_id: 1, - inhibitors: HashMap::new(), - inhibited: false, - inhibitor_count: 0, - } - } - - pub const fn config(&self) -> &Config { - &self.config - } - - pub const fn inhibited(&self) -> bool { - self.inhibited - } - - pub const fn inhibitor_count(&self) -> u32 { - self.inhibitor_count - } - - pub fn list_active(&self) -> Vec { - // Reverse iteration returns newest entries first for panel rendering - self.active - .values() - .rev() - .map(|notification| notification.to_list_view()) - .collect() - } - - pub fn list_history(&self) -> Vec { - // HistoryStore already returns newest first - self.history.list_views() - } - - pub fn active_notification_view(&self, id: u32) -> Option { - // Active rows use the richer popup-oriented view because add/update signals - // are consumed by trusted UIs that may need current image payloads - self.active - .get(&id) - .map(|notification| notification.to_view()) - } - - pub fn history_len(&self) -> usize { - // Exposed for diagnostics and test assertions - self.history.len() - } - - pub fn clear_history(&mut self) { - // Explicit history wipe used by CLI and control commands - self.history.clear(); - } -} diff --git a/crates/unixnotis-daemon/src/store/dnd.rs b/crates/unixnotis-daemon/src/store/dnd.rs deleted file mode 100644 index 8a4b00b01..000000000 --- a/crates/unixnotis-daemon/src/store/dnd.rs +++ /dev/null @@ -1,56 +0,0 @@ -use super::{DndWrite, NotificationStore}; - -impl NotificationStore { - pub const fn dnd_enabled(&self) -> bool { - self.dnd_enabled - } - - pub fn set_dnd(&mut self, enabled: bool) -> DndWrite { - // Shared mutation path keeps set and toggle behavior aligned - self.write_dnd(enabled) - } - - pub fn toggle_dnd(&mut self) -> DndWrite { - // Toggle and write happen under one lock at the call site - self.write_dnd(!self.dnd_enabled) - } - - pub(crate) const fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { - // No-op writes do not need rollback - if !write.changed { - return false; - } - // Guarded rollback avoids clobbering newer successful writes - if self.dnd_revision != write.revision || self.dnd_enabled != write.current { - return false; - } - self.dnd_enabled = write.previous; - // Rollback is also a state transition - self.dnd_revision = self.dnd_revision.saturating_add(1); - true - } - - fn write_dnd(&mut self, enabled: bool) -> DndWrite { - let previous = self.dnd_enabled; - if previous == enabled { - // Returning unchanged avoids unnecessary disk writes and state signals - return DndWrite { - changed: false, - previous, - current: previous, - revision: self.dnd_revision, - persist: None, - }; - } - self.dnd_enabled = enabled; - self.dnd_revision = self.dnd_revision.saturating_add(1); - // Persist outside the store lock so notification flow stays responsive - DndWrite { - changed: true, - previous, - current: enabled, - revision: self.dnd_revision, - persist: self.dnd_state_store.clone(), - } - } -} diff --git a/crates/unixnotis-daemon/src/store/dnd/mod.rs b/crates/unixnotis-daemon/src/store/dnd/mod.rs new file mode 100644 index 000000000..17a0d927a --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/mod.rs @@ -0,0 +1,9 @@ +//! Do-not-disturb state changes and persistence + +pub(in crate::store) mod persistence; +mod state; + +pub(in crate::store) use persistence::{DndStateStore, DND_STATE_VERSION}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/state.rs b/crates/unixnotis-daemon/src/store/dnd/persistence.rs similarity index 63% rename from crates/unixnotis-daemon/src/store/state.rs rename to crates/unixnotis-daemon/src/store/dnd/persistence.rs index f76706cdc..854eb61b1 100644 --- a/crates/unixnotis-daemon/src/store/state.rs +++ b/crates/unixnotis-daemon/src/store/dnd/persistence.rs @@ -11,14 +11,16 @@ use serde::{Deserialize, Serialize}; use unixnotis_core::filesystem::write_file_atomic; use unixnotis_core::util; -pub(super) const DND_STATE_VERSION: u32 = 1; -pub(super) const DND_STATE_FILE: &str = "state.json"; +pub(in crate::store) const DND_STATE_VERSION: u32 = 1; +pub(in crate::store) const DND_STATE_FILE: &str = "state.json"; #[derive(Debug, Serialize, Deserialize)] -pub(super) struct PersistedDndState { - pub(super) version: u32, - pub(super) dnd_enabled: bool, - pub(super) updated_at: Option, +pub(in crate::store) struct PersistedDndState { + pub(in crate::store) version: u32, + pub(in crate::store) dnd_enabled: bool, + #[serde(default)] + pub(in crate::store) expires_at: Option, + pub(in crate::store) updated_at: Option, } #[derive(Debug, Clone)] @@ -27,17 +29,17 @@ pub struct DndStateStore { } impl DndStateStore { - pub(super) fn new() -> Option { + pub(in crate::store) fn new() -> Option { let state_dir = util::resolve_state_dir()?; Some(Self::from_state_dir(state_dir)) } - pub(super) fn from_state_dir(state_dir: PathBuf) -> Self { + pub(in crate::store) fn from_state_dir(state_dir: PathBuf) -> Self { let path = state_dir.join("unixnotis").join(DND_STATE_FILE); Self { path } } - pub(super) fn load(&self) -> io::Result> { + pub(in crate::store) fn load(&self) -> io::Result> { let contents = match fs::read_to_string(&self.path) { Ok(contents) => contents, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), @@ -48,10 +50,12 @@ impl DndStateStore { Ok(Some(parsed)) } - pub(crate) fn persist(&self, enabled: bool) -> io::Result<()> { + pub(crate) fn persist(&self, enabled: bool, expires_at: Option) -> io::Result<()> { let payload = PersistedDndState { version: DND_STATE_VERSION, dnd_enabled: enabled, + // Disabled state never keeps a stale deadline on disk + expires_at: enabled.then_some(expires_at).flatten(), updated_at: Some(Utc::now().to_rfc3339()), }; let body = serde_json::to_vec(&payload)?; diff --git a/crates/unixnotis-daemon/src/store/dnd/state.rs b/crates/unixnotis-daemon/src/store/dnd/state.rs new file mode 100644 index 000000000..08ee9d5f4 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/state.rs @@ -0,0 +1,89 @@ +use crate::store::{DndWrite, NotificationStore}; + +impl NotificationStore { + pub const fn dnd_enabled(&self) -> bool { + self.dnd_enabled + } + + pub const fn dnd_expires_at(&self) -> Option { + self.dnd_expires_at + } + + pub fn set_dnd(&mut self, enabled: bool) -> DndWrite { + // A plain set always means indefinite when enabled + self.write_dnd(enabled, None) + } + + pub fn set_dnd_until(&mut self, expires_at: i64) -> DndWrite { + // Validation happens at the control boundary before this state mutation + self.write_dnd(true, Some(expires_at)) + } + + pub fn toggle_dnd(&mut self) -> DndWrite { + // Toggle and write happen under one lock at the call site + self.write_dnd(!self.dnd_enabled, None) + } + + pub fn expire_dnd_if_current(&mut self, expires_at: i64, now: i64) -> DndWrite { + if !self.dnd_enabled || self.dnd_expires_at != Some(expires_at) || expires_at > now { + // A replaced or not-yet-due schedule cannot alter current state + return self.unchanged_dnd_write(); + } + self.write_dnd(false, None) + } + + pub(crate) fn rollback_dnd_write_if_current(&mut self, write: &DndWrite) -> bool { + // No-op writes do not need rollback + if !write.changed { + return false; + } + // Guarded rollback avoids clobbering newer successful writes + if self.dnd_revision != write.revision + || self.dnd_enabled != write.current + || self.dnd_expires_at != write.current_expires_at + { + return false; + } + self.dnd_enabled = write.previous; + self.dnd_expires_at = write.previous_expires_at; + // Rollback is also a state transition + self.dnd_revision = self.dnd_revision.saturating_add(1); + true + } + + fn write_dnd(&mut self, enabled: bool, expires_at: Option) -> DndWrite { + // Disabled DND cannot retain a deadline + let expires_at = enabled.then_some(expires_at).flatten(); + let previous = self.dnd_enabled; + let previous_expires_at = self.dnd_expires_at; + if previous == enabled && previous_expires_at == expires_at { + // Returning unchanged avoids unnecessary disk writes and state signals + return self.unchanged_dnd_write(); + } + self.dnd_enabled = enabled; + self.dnd_expires_at = expires_at; + self.dnd_revision = self.dnd_revision.saturating_add(1); + // Persist outside the store lock so notification flow stays responsive + DndWrite { + changed: true, + previous, + previous_expires_at, + current: enabled, + current_expires_at: expires_at, + revision: self.dnd_revision, + persist: self.dnd_state_store.clone(), + } + } + + const fn unchanged_dnd_write(&self) -> DndWrite { + DndWrite { + changed: false, + previous: self.dnd_enabled, + previous_expires_at: self.dnd_expires_at, + current: self.dnd_enabled, + current_expires_at: self.dnd_expires_at, + revision: self.dnd_revision, + persist: None, + } + } +} diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs b/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs new file mode 100644 index 000000000..ce87e3631 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/mod.rs @@ -0,0 +1,3 @@ +mod persistence; +mod state; +mod support; diff --git a/crates/unixnotis-daemon/src/store/tests/dnd.rs b/crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs similarity index 57% rename from crates/unixnotis-daemon/src/store/tests/dnd.rs rename to crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs index 0d6353340..e7a552200 100644 --- a/crates/unixnotis-daemon/src/store/tests/dnd.rs +++ b/crates/unixnotis-daemon/src/store/dnd/tests/persistence.rs @@ -1,4 +1,4 @@ -use super::*; +use super::support::*; #[test] fn dnd_state_overrides_default() { @@ -31,7 +31,7 @@ fn dnd_state_invalid_payload_falls_back_to_default() { #[test] fn dnd_state_store_load_returns_none_when_file_is_missing() { let state_dir = make_temp_state_dir("dnd-missing-file"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); // A first run has no state file yet, which should not be treated as corruption let loaded = state_store @@ -47,7 +47,7 @@ fn dnd_state_store_load_reports_non_missing_filesystem_errors() { let state_dir = make_temp_state_dir("dnd-path-is-directory"); let path = state_dir.join("unixnotis").join(DND_STATE_FILE); std::fs::create_dir_all(&path).expect("create directory at state file path"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); // Wrong path shape is a real filesystem problem and should not look like first run let err = state_store @@ -87,6 +87,29 @@ fn dnd_state_persists_on_change() { cleanup_temp_dir(&state_dir); } +#[test] +fn timed_dnd_persists_the_absolute_deadline() { + let state_dir = make_temp_state_dir("dnd-timed-write"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = chrono::Utc::now().timestamp() + 3_600; + let write = store.set_dnd_until(expires_at); + write + .persist + .as_ref() + .expect("timed DND state store") + .persist(write.current, write.current_expires_at) + .expect("persist timed DND"); + + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + let persisted: PersistedDndState = + serde_json::from_slice(&std::fs::read(path).expect("read timed DND state")) + .expect("parse timed DND state"); + + assert!(persisted.dnd_enabled); + assert_eq!(persisted.expires_at, Some(expires_at)); + cleanup_temp_dir(&state_dir); +} + #[cfg(unix)] #[test] fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { @@ -98,10 +121,10 @@ fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { let outside = state_dir.join("outside.json"); std::fs::write(&outside, "keep").expect("write outside state"); symlink(&outside, state_parent.join(DND_STATE_FILE)).expect("create state symlink"); - let state_store = super::super::state::DndStateStore::from_state_dir(state_dir.clone()); + let state_store = super::super::persistence::DndStateStore::from_state_dir(state_dir.clone()); let error = state_store - .persist(true) + .persist(true, None) .expect_err("state symlink should be rejected"); assert_ne!(error.kind(), std::io::ErrorKind::NotFound); @@ -113,81 +136,55 @@ fn dnd_state_persistence_rejects_symlink_without_touching_outside_file() { } #[test] -fn dnd_toggle_flips_state_in_one_store_mutation() { - let state_dir = make_temp_state_dir("dnd-toggle"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let first = store.toggle_dnd(); - assert!(first.changed); - assert!(!first.previous); - assert!(first.current); - assert!(store.dnd_enabled()); - - let second = store.toggle_dnd(); - assert!(second.changed); - assert!(second.previous); - assert!(!second.current); - assert!(!store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn stale_dnd_rollback_cannot_overwrite_newer_write() { - let state_dir = make_temp_state_dir("dnd-stale-rollback"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - - let write_a = store.set_dnd(true); - assert!(store.dnd_enabled()); - - let write_b = store.set_dnd(false); - assert!(write_b.changed); - assert!(!store.dnd_enabled()); - - // Simulate late failure from write_a and verify guarded rollback is rejected - let rolled_back = store.rollback_dnd_write_if_current(&write_a); - assert!(!rolled_back); - assert!(!store.dnd_enabled()); - - cleanup_temp_dir(&state_dir); -} - -#[test] -fn stale_dnd_rollback_cannot_overwrite_when_current_value_matches_old_write() { - let state_dir = make_temp_state_dir("dnd-stale-current-matches"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); +fn future_timed_dnd_is_loaded_with_its_deadline() { + let state_dir = make_temp_state_dir("dnd-future-deadline"); + let expires_at = chrono::Utc::now().timestamp() + 3_600; + let state = PersistedDndState { + version: DND_STATE_VERSION, + dnd_enabled: true, + expires_at: Some(expires_at), + updated_at: None, + }; + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write( + &path, + serde_json::to_vec(&state).expect("serialize timed state"), + ) + .expect("write timed state"); - let write_a = store.set_dnd(true); - let _write_b = store.set_dnd(false); - let _write_c = store.set_dnd(true); + let store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); - // Revision must win even when the current value happens to match the stale write - assert!(!store.rollback_dnd_write_if_current(&write_a)); assert!(store.dnd_enabled()); - + assert_eq!(store.dnd_expires_at(), Some(expires_at)); cleanup_temp_dir(&state_dir); } #[test] -fn dnd_rollback_restores_state_when_write_is_still_current() { - let state_dir = make_temp_state_dir("dnd-rollback"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); +fn expired_timed_dnd_is_disabled_during_startup_and_cleared_on_disk() { + let state_dir = make_temp_state_dir("dnd-expired-deadline"); + let state = PersistedDndState { + version: DND_STATE_VERSION, + dnd_enabled: true, + expires_at: Some(chrono::Utc::now().timestamp() - 1), + updated_at: None, + }; + let path = state_dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write( + &path, + serde_json::to_vec(&state).expect("serialize expired state"), + ) + .expect("write expired state"); - let write = store.set_dnd(true); - assert!(store.dnd_enabled()); + let store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let persisted: PersistedDndState = + serde_json::from_slice(&std::fs::read(&path).expect("read corrected persisted state")) + .expect("parse corrected state"); - // Simulate persistence failure with no newer writes in between - let rolled_back = store.rollback_dnd_write_if_current(&write); - assert!(rolled_back); assert!(!store.dnd_enabled()); - + assert_eq!(store.dnd_expires_at(), None); + assert!(!persisted.dnd_enabled); + assert_eq!(persisted.expires_at, None); cleanup_temp_dir(&state_dir); } diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/state.rs b/crates/unixnotis-daemon/src/store/dnd/tests/state.rs new file mode 100644 index 000000000..b7448ee0b --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/state.rs @@ -0,0 +1,140 @@ +use super::support::*; + +#[test] +fn plain_dnd_enable_replaces_a_timed_deadline_with_indefinite_state() { + let state_dir = make_temp_state_dir("dnd-timed-to-indefinite"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = chrono::Utc::now().timestamp() + 600; + + let timed = store.set_dnd_until(expires_at); + assert!(timed.changed); + assert_eq!(store.dnd_expires_at(), Some(expires_at)); + + let indefinite = store.set_dnd(true); + assert!(indefinite.changed); + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn expiration_mutation_requires_the_current_due_deadline() { + let state_dir = make_temp_state_dir("dnd-current-expiration"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let expires_at = 500; + store.set_dnd_until(expires_at); + + assert!( + !store + .expire_dnd_if_current(expires_at + 1, expires_at) + .changed + ); + assert!( + !store + .expire_dnd_if_current(expires_at, expires_at - 1) + .changed + ); + assert!(store.dnd_enabled()); + + let expired = store.expire_dnd_if_current(expires_at, expires_at); + assert!(expired.changed); + assert!(!store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), None); + cleanup_temp_dir(&state_dir); +} + +#[test] +fn dnd_toggle_flips_state_in_one_store_mutation() { + let state_dir = make_temp_state_dir("dnd-toggle"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let first = store.toggle_dnd(); + assert!(first.changed); + assert!(!first.previous); + assert!(first.current); + assert!(store.dnd_enabled()); + + let second = store.toggle_dnd(); + assert!(second.changed); + assert!(second.previous); + assert!(!second.current); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn stale_dnd_rollback_cannot_overwrite_newer_write() { + let state_dir = make_temp_state_dir("dnd-stale-rollback"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write_a = store.set_dnd(true); + assert!(store.dnd_enabled()); + + let write_b = store.set_dnd(false); + assert!(write_b.changed); + assert!(!store.dnd_enabled()); + + // Simulate late failure from write_a and verify guarded rollback is rejected + let rolled_back = store.rollback_dnd_write_if_current(&write_a); + assert!(!rolled_back); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn stale_dnd_rollback_cannot_overwrite_when_current_value_matches_old_write() { + let state_dir = make_temp_state_dir("dnd-stale-current-matches"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write_a = store.set_dnd(true); + let _write_b = store.set_dnd(false); + let _write_c = store.set_dnd(true); + + // Revision must win even when the current value happens to match the stale write + assert!(!store.rollback_dnd_write_if_current(&write_a)); + assert!(store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn dnd_rollback_restores_state_when_write_is_still_current() { + let state_dir = make_temp_state_dir("dnd-rollback"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + + let write = store.set_dnd(true); + assert!(store.dnd_enabled()); + + // Simulate persistence failure with no newer writes in between + let rolled_back = store.rollback_dnd_write_if_current(&write); + assert!(rolled_back); + assert!(!store.dnd_enabled()); + + cleanup_temp_dir(&state_dir); +} + +#[test] +fn failed_timed_write_rollback_restores_the_previous_deadline() { + let state_dir = make_temp_state_dir("dnd-timed-rollback"); + let mut store = NotificationStore::new_with_state_dir(Config::default(), state_dir.clone()); + let original = chrono::Utc::now().timestamp() + 600; + let replacement = original + 600; + store.set_dnd_until(original); + + let write = store.set_dnd_until(replacement); + assert!(store.rollback_dnd_write_if_current(&write)); + + assert!(store.dnd_enabled()); + assert_eq!(store.dnd_expires_at(), Some(original)); + cleanup_temp_dir(&state_dir); +} diff --git a/crates/unixnotis-daemon/src/store/dnd/tests/support.rs b/crates/unixnotis-daemon/src/store/dnd/tests/support.rs new file mode 100644 index 000000000..370eca4cb --- /dev/null +++ b/crates/unixnotis-daemon/src/store/dnd/tests/support.rs @@ -0,0 +1,5 @@ +pub(super) use unixnotis_core::Config; + +pub(super) use super::super::persistence::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; +pub(super) use crate::store::test_support::*; +pub(super) use crate::store::NotificationStore; diff --git a/crates/unixnotis-daemon/src/store/history.rs b/crates/unixnotis-daemon/src/store/history.rs deleted file mode 100644 index 6848c8bd4..000000000 --- a/crates/unixnotis-daemon/src/store/history.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Notification history storage with ordering -//! -//! Kept in a dedicated module so store.rs can focus on active notifications -//! and cross-cutting policy decisions - -use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; - -use unixnotis_core::{Notification, NotificationView}; - -pub(super) struct HistoryStore { - entries: HashMap>, - order: VecDeque, -} - -impl HistoryStore { - pub(super) fn new() -> Self { - Self { - entries: HashMap::new(), - order: VecDeque::new(), - } - } - - pub(super) fn len(&self) -> usize { - self.entries.len() - } - - pub(super) fn contains(&self, id: &u32) -> bool { - self.entries.contains_key(id) - } - - pub(super) fn get(&self, id: &u32) -> Option<&Arc> { - self.entries.get(id) - } - - pub(super) fn clear(&mut self) { - self.entries.clear(); - self.order.clear(); - } - - pub(super) fn list_views(&self) -> Vec { - let mut views = Vec::with_capacity(self.entries.len()); - for id in self.order.iter().rev() { - if let Some(notification) = self.entries.get(id) { - views.push(notification.to_list_view()); - } - } - views - } - - pub(super) fn remove(&mut self, id: &u32) -> Option> { - let removed = self.entries.remove(id); - if removed.is_some() { - // Removal is infrequent compared to insertion; pay the cost here to keep order clean - self.order.retain(|entry| entry != id); - } - removed - } - - pub(super) fn insert(&mut self, notification: Arc) { - let id = notification.id; - if self.entries.contains_key(&id) { - // Avoid duplicate IDs in order when a notification is replaced - self.order.retain(|entry| *entry != id); - } - self.entries.insert(id, notification); - self.order.push_back(id); - } - - pub(super) fn evict_to_limit(&mut self, max_entries: usize) { - if max_entries == 0 { - self.clear(); - return; - } - - while self.entries.len() > max_entries { - let Some(id) = self.order.pop_front() else { - // Recover ordering when entries outlive the recorded order - self.order.extend(self.entries.keys().copied()); - if self.order.is_empty() { - break; - } - continue; - }; - - if self.entries.remove(&id).is_none() { - continue; - } - } - - if self.entries.is_empty() { - self.order.clear(); - } - } -} diff --git a/crates/unixnotis-daemon/src/store/inhibitor_api.rs b/crates/unixnotis-daemon/src/store/inhibitors/api.rs similarity index 89% rename from crates/unixnotis-daemon/src/store/inhibitor_api.rs rename to crates/unixnotis-daemon/src/store/inhibitors/api.rs index 61f601e90..e02962fa8 100644 --- a/crates/unixnotis-daemon/src/store/inhibitor_api.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/api.rs @@ -1,7 +1,7 @@ use unixnotis_core::InhibitMode; -use super::inhibit::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; -use super::NotificationStore; +use super::model::{inhibits_popups, Inhibitor, InhibitorOwnerMismatch}; +use crate::store::NotificationStore; impl NotificationStore { pub fn add_inhibitor(&mut self, owner: String, reason: String, scope: u32) -> u64 { @@ -67,12 +67,12 @@ impl NotificationStore { inhibitor.owner.clone(), )); } - // Stable order keeps CLI output and tests deterministic - inhibitors.sort_by_key(|(id, _, _, _)| *id); + // Unique monotonic IDs provide deterministic output without stable sorting + inhibitors.sort_unstable_by_key(|(id, _, _, _)| *id); inhibitors } - pub(super) const fn should_drop_inhibited(&self) -> bool { + pub(in crate::store) const fn should_drop_inhibited(&self) -> bool { // DropAll means suppression happens before insertion and history work self.inhibited && matches!(self.config.inhibit.mode, InhibitMode::DropAll) } diff --git a/crates/unixnotis-daemon/src/store/inhibitors/mod.rs b/crates/unixnotis-daemon/src/store/inhibitors/mod.rs new file mode 100644 index 000000000..92f12f082 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/mod.rs @@ -0,0 +1,9 @@ +//! Inhibitor bookkeeping and suppression state + +mod api; +mod model; + +pub(in crate::store) use model::Inhibitor; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/inhibit.rs b/crates/unixnotis-daemon/src/store/inhibitors/model.rs similarity index 96% rename from crates/unixnotis-daemon/src/store/inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibitors/model.rs index 3bcc83b4c..07dce447e 100644 --- a/crates/unixnotis-daemon/src/store/inhibit.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/model.rs @@ -5,7 +5,7 @@ use unixnotis_core::INHIBIT_SCOPE_POPUPS; #[derive(Debug, Clone)] -pub(super) struct Inhibitor { +pub(in crate::store) struct Inhibitor { pub(super) id: u64, pub(super) owner: String, pub(super) reason: String, diff --git a/crates/unixnotis-daemon/src/store/tests/inhibit.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs similarity index 72% rename from crates/unixnotis-daemon/src/store/tests/inhibit.rs rename to crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs index 8d7b6e9c6..f427a1a3f 100644 --- a/crates/unixnotis-daemon/src/store/tests/inhibit.rs +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/api.rs @@ -1,30 +1,17 @@ -use super::*; +use unixnotis_core::Config; -#[test] -fn inhibit_no_popups_suppresses_show_popup() { - let mut config = Config::default(); - config.inhibit.mode = InhibitMode::NoPopups; - let mut store = NotificationStore::new(config); - store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); - - let outcome = store.insert(make_notification("inhibited"), 0); - assert!(!outcome.dropped); - assert!(!outcome.show_popup); - assert!(!outcome.allow_sound); - assert_eq!(store.list_active().len(), 1); -} +use crate::store::NotificationStore; #[test] -fn inhibit_drop_all_skips_storage() { - let mut config = Config::default(); - config.inhibit.mode = InhibitMode::DropAll; - let mut store = NotificationStore::new(config); - store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); - - let outcome = store.insert(make_notification("inhibited"), 0); - assert!(outcome.dropped); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 0); +fn inhibitor_owner_mismatch_is_rejected() { + let mut store = NotificationStore::new(Config::default()); + let id = store.add_inhibitor("owner-a".to_string(), "reason".to_string(), 0); + + let error = store + .remove_inhibitor(id, "owner-b") + .expect_err("owner mismatch should error"); + + assert!(error.message().contains("owner-a")); } #[test] diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs new file mode 100644 index 000000000..50c3bb76c --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/mod.rs @@ -0,0 +1,2 @@ +mod api; +mod model; diff --git a/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs new file mode 100644 index 000000000..5221f46ae --- /dev/null +++ b/crates/unixnotis-daemon/src/store/inhibitors/tests/model.rs @@ -0,0 +1,40 @@ +use unixnotis_core::{Config, InhibitMode}; + +use crate::store::test_support::make_notification; +use crate::store::{CommitDisposition, NotificationStore}; + +#[test] +fn inhibit_no_popups_suppresses_show_popup() { + let mut config = Config::default(); + config.inhibit.mode = InhibitMode::NoPopups; + let mut store = NotificationStore::new(config); + store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); + + let outcome = store.insert(make_notification("inhibited"), 0); + assert!(outcome.suppressed().is_none()); + assert!(!outcome.popup_admission.should_show()); + assert!(!outcome.allow_sound); + assert_eq!(store.list_active().len(), 1); +} + +#[test] +fn inhibit_drop_all_skips_storage() { + let mut config = Config::default(); + config.inhibit.mode = InhibitMode::DropAll; + let mut store = NotificationStore::new(config); + store.add_inhibitor("owner".to_string(), "focus".to_string(), 0); + + let outcome = store.insert(make_notification("inhibited"), 0); + let suppressed = outcome + .suppressed() + .expect("DropAll must retain only lifecycle identity"); + assert_eq!(suppressed.id, 1); + assert_eq!(suppressed.generation, 1); + assert_eq!(suppressed.owner.expect("stable test owner").pid, 1234); + assert!(matches!( + outcome.disposition, + CommitDisposition::SuppressedDropAll(_) + )); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 0); +} diff --git a/crates/unixnotis-daemon/src/store/lifecycle.rs b/crates/unixnotis-daemon/src/store/lifecycle.rs deleted file mode 100644 index 8beaa986d..000000000 --- a/crates/unixnotis-daemon/src/store/lifecycle.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::sync::Arc; -use std::time::Instant; - -use unixnotis_core::{ - popup_allowed_by_state, should_archive_closed_notification, CloseReason, ControlState, - Notification, Urgency, -}; - -use super::{DismissOutcome, InsertOutcome, NotificationStore}; - -// Hard ceiling for concurrently active notifications to protect panel/popups stability -const ACTIVE_HARD_CAP: usize = 12; - -impl NotificationStore { - pub fn insert(&mut self, mut notification: Notification, replaces_id: u32) -> InsertOutcome { - // Rule transforms happen before any storage decision - self.apply_rules(&mut notification); - if self.should_drop_inhibited() { - // DropAll mode still assigns an ID so call sites can log consistent metadata - let assigned_id = self.next_id(); - notification.id = assigned_id; - let notification = Arc::new(notification); - return InsertOutcome { - show_popup: false, - allow_sound: false, - notification, - replaced: false, - evicted: Vec::new(), - dropped: true, - }; - } - - // replaces_id is valid only when it points to an existing, owned notification - let has_replaces_id = replaces_id != 0; - let replaced = has_replaces_id - && self.can_replace_notification_for_sender( - replaces_id, - notification.sender_name.as_deref(), - notification.sender_pid, - notification.sender_start_time, - ); - // Replacement preserves ID only when sender ownership is confirmed - let assigned_id = if replaced { - replaces_id - } else { - self.next_id() - }; - notification.id = assigned_id; - - // Drop stale copies before inserting the fresh one - self.active.shift_remove(&assigned_id); - self.history.remove(&assigned_id); - self.expirations.remove(&assigned_id); - - let notification = Arc::new(notification); - // Active map keeps insertion order so oldest eviction is deterministic - self.active.insert(assigned_id, notification.clone()); - // Enforce active cap immediately so UI never sees oversized active sets - let evicted = self.enforce_active_limit(); - - InsertOutcome { - show_popup: self.should_show_popup(¬ification), - allow_sound: self.should_play_sound(¬ification), - notification, - replaced, - evicted, - dropped: false, - } - } - - pub fn close(&mut self, id: u32, reason: CloseReason) -> Option> { - // Active removal and expiration cleanup always happen together - let removed = self.active.shift_remove(&id); - self.expirations.remove(&id); - if let Some(notification) = removed.clone() { - // Closed rows and panel rows should follow the same archive rule - self.push_history(notification, reason); - } - removed - } - - pub fn dismiss_from_panel(&mut self, id: u32) -> DismissOutcome { - // Panel dismissal can target active, history, or both - let removed_active = self.active.shift_remove(&id).is_some(); - if removed_active { - self.expirations.remove(&id); - } - - let removed_history = self.history.remove(&id).is_some(); - - DismissOutcome { - removed_active, - removed_history, - } - } - - pub fn drain_active_ids(&mut self) -> Vec { - // Drain in one pass so callers do not need repeated lookups - let ids = self.active.keys().rev().copied().collect(); - self.active.clear(); - self.expirations.clear(); - ids - } - - pub fn set_expiration(&mut self, id: u32, deadline: Option) { - // None removes a stale timer for resident or already-dismissed notifications - match deadline { - Some(deadline) => { - self.expirations.insert(id, deadline); - } - None => { - self.expirations.remove(&id); - } - } - } - - pub fn expiration_for(&self, id: u32) -> Option { - self.expirations.get(&id).copied() - } - - fn enforce_active_limit(&mut self) -> Vec { - // Config limit still applies, but active list never exceeds the global safety cap - let max_active = self.config.history.max_active.min(ACTIVE_HARD_CAP); - if max_active == 0 { - // max_active=0 means archive everything immediately - let mut evicted = Vec::new(); - while let Some((id, notification)) = self.active.shift_remove_index(0) { - // Evicted notifications should not retain pending expiration entries - self.expirations.remove(&id); - // Active-cap eviction behaves like a daemon-side close for history policy - self.push_history(notification, CloseReason::Undefined); - evicted.push(id); - } - return evicted; - } - - let mut evicted = Vec::new(); - while self.active.len() > max_active { - // remove_index(0) always pops the oldest notification first - if let Some((id, notification)) = self.active.shift_remove_index(0) { - // Eviction path mirrors close path so state stays consistent - self.expirations.remove(&id); - // Evicted rows still need the same archive rule as any other close - self.push_history(notification, CloseReason::Undefined); - evicted.push(id); - } else { - // Defensive break for impossible map/index mismatch cases - break; - } - } - evicted - } - - fn push_history(&mut self, notification: Arc, reason: CloseReason) { - if self.config.history.max_entries == 0 { - // Clear keeps memory bounded when history feature is disabled - self.history.clear(); - return; - } - // One shared archive rule keeps daemon and center close handling aligned - if !should_archive_closed_notification( - reason, - notification.is_transient, - self.config.history.transient_to_history, - ) { - return; - } - // to_history strips non-history-only fields and keeps stored payload compact - let stored = Arc::new(notification.to_history()); - self.history.insert(stored); - self.history.evict_to_limit(self.config.history.max_entries); - } - - const fn should_show_popup(&self, notification: &Notification) -> bool { - // Rule-level popup suppression is highest priority - if notification.suppress_popup { - return false; - } - // Shared gate keeps daemon admission aligned with popup-side cleanup - popup_allowed_by_state( - notification.urgency as u8, - &ControlState { - dnd_enabled: self.dnd_enabled, - history_count: 0, - inhibited: self.inhibited, - inhibitor_count: self.inhibitor_count, - }, - ) - } - - fn should_play_sound(&self, notification: &Notification) -> bool { - // Rule-level silence always wins - if notification.suppress_sound { - return false; - } - // Inhibitors should suppress sound too so focus/presentation mode stays quiet - if self.inhibited { - return false; - } - // DND still keeps critical notification sounds enabled - if self.dnd_enabled { - return notification.urgency == Urgency::Critical; - } - true - } -} diff --git a/crates/unixnotis-daemon/src/store/mod.rs b/crates/unixnotis-daemon/src/store/mod.rs index c49462191..ae5970bda 100644 --- a/crates/unixnotis-daemon/src/store/mod.rs +++ b/crates/unixnotis-daemon/src/store/mod.rs @@ -1,23 +1,18 @@ //! Notification store with ordering, history, and suppression policies -// Focused modules keep policy and lifecycle logic isolated and easier to test -mod core; mod dnd; -mod history; -mod identity; -mod inhibit; -mod inhibitor_api; -mod lifecycle; -mod rules; -mod state; -mod types; +mod inhibitors; +mod model; +mod notifications; +mod runtime; -// Internal store primitives used by the main NotificationStore type -use history::HistoryStore; -use inhibit::Inhibitor; -use state::{DndStateStore, DND_STATE_VERSION}; -pub use types::DndWrite; -pub use types::{DismissOutcome, InsertOutcome, NotificationStore}; +pub use model::{ + CloseAuthorization, CommitDisposition, DeliveryStageUpdate, DismissOutcome, DndWrite, + ExpirationTicket, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, +}; +#[cfg(test)] +pub mod test_support; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-daemon/src/store/model.rs b/crates/unixnotis-daemon/src/store/model.rs new file mode 100644 index 000000000..5664627f9 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/model.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use indexmap::IndexMap; +use unixnotis_core::{ + Config, Notification, NotificationKey, PopupAdmissionView, PopupDecisionRecord, +}; + +use super::dnd::DndStateStore; +use super::inhibitors::Inhibitor; +use super::notifications::HistoryStore; + +/// Mutable notification state owned by the daemon +pub struct NotificationStore { + // Immutable runtime config snapshot + pub(super) config: Config, + // Next candidate id for allocation + pub(super) next_id: u32, + // Commit generations never reuse identity when replacements preserve an ID + pub(super) next_generation: u64, + // Active notifications in insertion order + pub(super) active: IndexMap>, + // Archived notifications with bounded retention + pub(super) history: HistoryStore, + // Arrival-time popup decisions outlive active state while history retains the generation + pub(super) popup_decisions: HashMap, + // Monotonic popup deadlines stay daemon-local and are never serialized + pub(super) popup_timings: HashMap, + // Exact expiration identity per active notification generation + pub(super) expirations: HashMap, + // Effective DND switch after loading persisted state + pub(super) dnd_enabled: bool, + // Wall-clock deadline survives daemon restarts; None means indefinite + pub(super) dnd_expires_at: Option, + // Monotonic in-memory revision for DND writes + pub(super) dnd_revision: u64, + // Optional persistence layer for DND; absent store keeps behavior in-memory + pub(super) dnd_state_store: Option, + // Token counter for inhibitors; never reused in a process + pub(super) next_inhibitor_id: u64, + // Active inhibitors keyed by token for quick lookup/removal + pub(super) inhibitors: HashMap, + // Cached flags avoid rescanning inhibitors on every notification + pub(super) inhibited: bool, + pub(super) inhibitor_count: u32, +} + +pub struct InsertOutcome { + // Commit kind keeps content-bearing and content-free lifecycles structurally distinct + pub disposition: CommitDisposition, + // True when insertion replaced an existing id + pub replaced: bool, + // Structured popup policy keeps suppression causes available to diagnostics + pub popup_admission: PopupAdmission, + // Whether sound playback is allowed for this payload + pub allow_sound: bool, + // Active ids evicted because max_active was exceeded + pub evicted: Vec, + // Commit-time daemon deadline for this exact generation + pub expiration: Option, +} + +/// Result of committing one protocol notification request +pub enum CommitDisposition { + // Ordinary notifications retain their normalized content in active storage + Active(Arc), + // DropAll retains no sender-controlled presentation content + SuppressedDropAll(SuppressedNotification), +} + +/// Content-free lifecycle identity for a `DropAll` notification +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SuppressedNotification { + pub id: u32, + pub generation: u64, + // Stable process identity is retained only when both components are established + pub owner: Option, +} + +/// Process-lifetime ownership principal independent of a D-Bus unique name +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct StableProcessIdentity { + pub pid: u32, + pub start_time: u64, +} + +/// Deliberately collapsed result of authorizing a protocol close request +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloseAuthorization { + OwnedActive(NotificationKey), + NotClosable, +} + +impl InsertOutcome { + pub const fn suppressed(&self) -> Option { + match &self.disposition { + CommitDisposition::Active(_) => None, + CommitDisposition::SuppressedDropAll(suppressed) => Some(*suppressed), + } + } +} + +/// Daemon-only popup lifetime for one committed generation +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct PopupTiming { + // None keeps the popup eligible until delivery because zero disables automatic hiding + pub(super) deadline: Option, +} + +/// Exact identity required to expire one committed notification +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ExpirationTicket { + pub id: u32, + pub generation: u64, + pub deadline: Instant, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PopupAdmission { + Show, + Suppressed(PopupSuppressionReason), +} + +impl PopupAdmission { + pub const fn should_show(self) -> bool { + matches!(self, Self::Show) + } + + pub const fn to_view(self) -> PopupAdmissionView { + match self { + Self::Show => PopupAdmissionView::Show, + Self::Suppressed(PopupSuppressionReason::Rule) => PopupAdmissionView::Rule, + Self::Suppressed(PopupSuppressionReason::Dnd) => PopupAdmissionView::Dnd, + Self::Suppressed( + PopupSuppressionReason::Inhibitor | PopupSuppressionReason::DropAllInhibitor, + ) => PopupAdmissionView::Inhibitor, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PopupSuppressionReason { + Rule, + Dnd, + Inhibitor, + DropAllInhibitor, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeliveryStageUpdate { + Advanced, + AlreadyAtOrBeyond, + MissingGeneration, +} + +pub struct DndWrite { + // True when the in-memory DND value changed + pub(crate) changed: bool, + // Value seen before this write + pub(crate) previous: bool, + // Deadline paired with the previous switch value + pub(crate) previous_expires_at: Option, + // Value written by this operation + pub(crate) current: bool, + // Deadline paired with the current switch value + pub(crate) current_expires_at: Option, + // Monotonic revision captured for guarded rollback + pub(crate) revision: u64, + // Persistence backend used outside the store lock + pub(crate) persist: Option, +} + +pub struct DismissOutcome { + // Exact active generation removed by the operation + pub removed_active: Option, + // Exact history generation removed by the operation + pub removed_history: Option, +} + +impl DismissOutcome { + pub const fn removed_any(&self) -> bool { + // Convenience helper for callers that only need yes/no + self.removed_active.is_some() || self.removed_history.is_some() + } +} diff --git a/crates/unixnotis-daemon/src/store/notifications/history.rs b/crates/unixnotis-daemon/src/store/notifications/history.rs new file mode 100644 index 000000000..a7356d969 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/history.rs @@ -0,0 +1,154 @@ +//! Notification history storage with ordering +//! +//! Kept in a dedicated module so store.rs can focus on active notifications +//! and cross-cutting policy decisions + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Weak}; + +use unixnotis_core::{Notification, NotificationKey, NotificationView, PopupDecisionRecord}; + +struct HistoryEntry { + notification: Arc, + // Weak source identity supports race-safe cleanup without retaining live payloads + source: Weak, +} + +pub(in crate::store) struct HistoryStore { + entries: HashMap, + order: VecDeque, +} + +impl HistoryStore { + pub(in crate::store) fn new() -> Self { + Self { + entries: HashMap::new(), + order: VecDeque::new(), + } + } + + pub(in crate::store) fn len(&self) -> usize { + self.entries.len() + } + + pub(in crate::store) fn contains(&self, id: &u32) -> bool { + self.entries.contains_key(id) + } + + pub(in crate::store) fn get(&self, id: &u32) -> Option<&Arc> { + self.entries.get(id).map(|entry| &entry.notification) + } + + pub(in crate::store) fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + } + + pub(in crate::store) fn list_views( + &self, + popup_decisions: &HashMap, + ) -> Vec { + let mut views = Vec::with_capacity(self.entries.len()); + for id in self.order.iter().rev() { + if let Some(entry) = self.entries.get(id) { + let mut view = entry.notification.to_list_view(); + if let Some(decision) = popup_decisions.get(&entry.notification.key()) { + view.popup_decision.clone_from(decision); + } + views.push(view); + } + } + views + } + + pub(in crate::store) fn contains_generation(&self, key: NotificationKey) -> bool { + self.entries + .get(&key.id) + .is_some_and(|entry| entry.notification.generation == key.generation) + } + + pub(in crate::store) fn remove(&mut self, id: &u32) -> Option> { + let removed = self.entries.remove(id).map(|entry| entry.notification); + if removed.is_some() { + // Removal is infrequent compared to insertion; pay the cost here to keep order clean + self.order.retain(|entry| entry != id); + } + removed + } + + pub(in crate::store) fn remove_generation( + &mut self, + key: NotificationKey, + ) -> Option> { + // Numeric IDs can be reused, so history removal must compare the committed generation + let generation_matches = self + .entries + .get(&key.id) + .is_some_and(|entry| entry.notification.generation == key.generation); + generation_matches.then(|| self.remove(&key.id)).flatten() + } + + pub(in crate::store) fn insert(&mut self, notification: Arc) { + let id = notification.id; + if self.entries.contains_key(&id) { + // Avoid duplicate IDs in order when a notification is replaced + self.order.retain(|entry| *entry != id); + } + self.entries.insert( + id, + HistoryEntry { + notification, + source: Weak::new(), + }, + ); + self.order.push_back(id); + } + + pub(in crate::store) fn set_source(&mut self, id: u32, source: Weak) { + if let Some(entry) = self.entries.get_mut(&id) { + entry.source = source; + } + } + + pub(in crate::store) fn remove_if_source( + &mut self, + id: u32, + expected: &Arc, + ) -> Option> { + let source_matches = self + .entries + .get(&id) + .and_then(|entry| entry.source.upgrade()) + .is_some_and(|source| Arc::ptr_eq(&source, expected)); + if !source_matches { + return None; + } + self.remove(&id) + } + + pub(in crate::store) fn evict_to_limit(&mut self, max_entries: usize) { + if max_entries == 0 { + self.clear(); + return; + } + + while self.entries.len() > max_entries { + let Some(id) = self.order.pop_front() else { + // Recover ordering when entries outlive the recorded order + self.order.extend(self.entries.keys().copied()); + if self.order.is_empty() { + break; + } + continue; + }; + + if self.entries.remove(&id).is_none() { + continue; + } + } + + if self.entries.is_empty() { + self.order.clear(); + } + } +} diff --git a/crates/unixnotis-daemon/src/store/notifications/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/insertion.rs new file mode 100644 index 000000000..a36f05f23 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/insertion.rs @@ -0,0 +1,272 @@ +use std::sync::Arc; + +use unixnotis_core::{ + popup_allowed_by_state, should_archive_closed_notification, CloseReason, ControlState, + Notification, NotificationKey, UiHealth, Urgency, +}; + +use crate::store::{ + CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission, PopupSuppressionReason, + StableProcessIdentity, SuppressedNotification, +}; + +use super::timeout::resolve_timeout_policy; + +// Each resolved sender principal receives an isolated active-state budget +const ACTIVE_PER_PRINCIPAL_HARD_CAP: usize = 12; +// The emergency ceiling remains large enough that one normal sender cannot displace another +const ABSOLUTE_ACTIVE_HARD_CAP: usize = 128; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +enum ActivePrincipal { + Stable(StableProcessIdentity), + BusName(zbus::names::OwnedUniqueName), + Unknown, +} + +impl NotificationStore { + pub fn insert_with_ui_health( + &mut self, + mut notification: Notification, + replaces_id: u32, + ui_health: &UiHealth, + ) -> InsertOutcome { + // Rule transforms happen before any storage decision + self.apply_rules(&mut notification); + let timeout_policy = resolve_timeout_policy(&self.config, ¬ification); + if self.should_drop_inhibited() { + // DropAll discards notification content, not protocol lifecycle + // Only process-lifetime identity survives long enough to close the returned ID + let assigned_id = self.next_id(); + let generation = self.next_generation; + self.next_generation = self + .next_generation + .checked_add(1) + .expect("notification generation space must not be exhausted"); + let owner = notification + .sender_pid + .zip(notification.sender_start_time) + .map(|(pid, start_time)| StableProcessIdentity { pid, start_time }); + return InsertOutcome { + popup_admission: PopupAdmission::Suppressed( + PopupSuppressionReason::DropAllInhibitor, + ), + allow_sound: false, + disposition: CommitDisposition::SuppressedDropAll(SuppressedNotification { + id: assigned_id, + generation, + owner, + }), + replaced: false, + evicted: Vec::new(), + expiration: None, + }; + } + + // replaces_id is valid only when it points to an existing, owned notification + let has_replaces_id = replaces_id != 0; + let replaced = has_replaces_id + && self.can_replace_notification_for_sender( + replaces_id, + notification.sender_name.as_deref(), + notification.sender_pid, + notification.sender_start_time, + ); + // Replacement preserves ID only when sender ownership is confirmed + let assigned_id = if replaced { + replaces_id + } else { + self.next_id() + }; + notification.id = assigned_id; + // A replacement keeps its protocol ID but always receives a fresh commit identity + notification.generation = self.next_generation; + self.next_generation = self + .next_generation + .checked_add(1) + .expect("notification generation space must not be exhausted"); + + // Drop stale copies before inserting the fresh one + self.active.shift_remove(&assigned_id); + self.history.remove(&assigned_id); + self.expirations.remove(&assigned_id); + self.popup_decisions + .retain(|key, _decision| key.id != assigned_id); + self.popup_timings + .retain(|key, _timing| key.id != assigned_id); + + let admitted_at = std::time::Instant::now(); + let expiration = timeout_policy + .active_close_after + // Overflow disables automatic expiration instead of reversing it into immediate close + .and_then(|duration| admitted_at.checked_add(duration)); + let notification = Arc::new(notification); + // Active map keeps insertion order so principal-local eviction is deterministic + self.active.insert(assigned_id, notification.clone()); + // Replacement already removed its previous generation and therefore consumes one slot + let evicted = self.enforce_active_limits(active_principal(¬ification)); + + let popup_admission = self.popup_admission(¬ification); + self.record_popup_commit_environment_at( + notification.key(), + popup_admission, + ui_health, + timeout_policy.popup_hide_after_ms, + admitted_at, + ); + InsertOutcome { + popup_admission, + allow_sound: self.should_play_sound(¬ification), + disposition: CommitDisposition::Active(notification), + replaced, + evicted, + expiration, + } + } + + fn enforce_active_limits(&mut self, admitted: ActivePrincipal) -> Vec { + let per_principal_limit = self + .config + .history + .max_active + .min(ACTIVE_PER_PRINCIPAL_HARD_CAP); + let mut evicted = Vec::new(); + while self.active_count_for(&admitted) > per_principal_limit { + // A sender over its budget can remove only that sender's oldest active generation + let Some(key) = self.evict_oldest_for_principal(&admitted) else { + break; + }; + evicted.push(key); + } + + while self.active.len() > ABSOLUTE_ACTIVE_HARD_CAP { + // At the emergency boundary, the largest consumer yields first + // Equal shares prefer the newly admitted principal so established clients stay intact + let victim = self + .largest_active_principal(&admitted) + .unwrap_or_else(|| admitted.clone()); + let Some(key) = self.evict_oldest_for_principal(&victim) else { + break; + }; + evicted.push(key); + } + evicted + } + + fn active_count_for(&self, principal: &ActivePrincipal) -> usize { + self.active + .values() + .filter(|notification| &active_principal(notification) == principal) + .count() + } + + fn largest_active_principal(&self, admitted: &ActivePrincipal) -> Option { + let mut counts = std::collections::HashMap::new(); + for notification in self.active.values() { + let count = counts + .entry(active_principal(notification)) + .or_insert(0usize); + *count = count.saturating_add(1); + } + let admitted_count = counts.get(admitted).copied().unwrap_or(0); + let largest = counts.values().copied().max()?; + if admitted_count == largest { + return Some(admitted.clone()); + } + counts + .into_iter() + .find_map(|(principal, count)| (count == largest).then_some(principal)) + } + + fn evict_oldest_for_principal( + &mut self, + principal: &ActivePrincipal, + ) -> Option { + let index = self + .active + .values() + .position(|notification| &active_principal(notification) == principal)?; + let (id, notification) = self.active.shift_remove_index(index)?; + let key = notification.key(); + self.expirations.remove(&id); + self.push_history(notification, CloseReason::Undefined); + Some(key) + } + + pub(super) fn push_history(&mut self, notification: Arc, reason: CloseReason) { + if self.config.history.max_entries == 0 { + // Clear keeps memory bounded when history feature is disabled + self.history.clear(); + return; + } + // One shared archive rule keeps daemon and center close handling aligned + if !should_archive_closed_notification( + reason, + notification.is_transient, + self.config.history.transient_to_history, + ) { + return; + } + // Keep only weak source identity alongside the compact history payload + let source = Arc::downgrade(¬ification); + // to_history strips non-history-only fields and keeps stored payload compact + let stored = Arc::new(notification.to_history()); + let id = stored.id; + self.history.insert(stored); + self.history.set_source(id, source); + self.history.evict_to_limit(self.config.history.max_entries); + } + + pub(crate) fn popup_admission(&self, notification: &Notification) -> PopupAdmission { + // Rule-level popup suppression is highest priority + if notification.suppress_popup { + return PopupAdmission::Suppressed(PopupSuppressionReason::Rule); + } + if self.inhibited { + return PopupAdmission::Suppressed(PopupSuppressionReason::Inhibitor); + } + // Shared gate keeps daemon admission aligned with popup-side cleanup + if popup_allowed_by_state( + notification.urgency as u8, + &ControlState { + dnd_enabled: self.dnd_enabled, + dnd_expires_at: self.dnd_expires_at.unwrap_or(0), + history_count: 0, + inhibited: self.inhibited, + inhibitor_count: self.inhibitor_count, + }, + ) { + PopupAdmission::Show + } else { + PopupAdmission::Suppressed(PopupSuppressionReason::Dnd) + } + } + + fn should_play_sound(&self, notification: &Notification) -> bool { + // Rule-level silence always wins + if notification.suppress_sound { + return false; + } + // Inhibitors should suppress sound too so focus/presentation mode stays quiet + if self.inhibited { + return false; + } + // DND still keeps critical notification sounds enabled + if self.dnd_enabled { + return notification.urgency == Urgency::Critical; + } + true + } +} + +fn active_principal(notification: &Notification) -> ActivePrincipal { + if let Some((pid, start_time)) = notification.sender_pid.zip(notification.sender_start_time) { + return ActivePrincipal::Stable(StableProcessIdentity { pid, start_time }); + } + // A unique bus address is weaker than process identity but still isolates live connections + notification + .sender_name + .as_deref() + .and_then(|sender| zbus::names::OwnedUniqueName::try_from(sender).ok()) + .map_or(ActivePrincipal::Unknown, ActivePrincipal::BusName) +} diff --git a/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs new file mode 100644 index 000000000..358671770 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/lifecycle.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; +use std::time::Instant; + +use unixnotis_core::{CloseReason, Notification, NotificationKey}; + +use crate::store::{DismissOutcome, ExpirationTicket, NotificationStore}; + +impl NotificationStore { + pub fn close(&mut self, id: u32, reason: CloseReason) -> Option> { + // Active removal and expiration cleanup always happen together + let removed = self.active.shift_remove(&id); + self.expirations.remove(&id); + if let Some(notification) = removed.clone() { + // Closed rows and panel rows should follow the same archive rule + self.push_history(notification, reason); + } + self.prune_popup_decisions(); + removed + } + + pub fn dismiss_generation(&mut self, key: NotificationKey) -> DismissOutcome { + // Validate the generation before mutating either active or retained history state + let active_matches = self + .active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation); + let removed_active = if active_matches { + let removed = self.active.shift_remove(&key.id); + self.expirations.remove(&key.id); + removed.map(|notification| notification.key()) + } else { + None + }; + let removed_history = if removed_active.is_some() { + None + } else { + self.history + .remove_generation(key) + .map(|notification| notification.key()) + }; + + let outcome = DismissOutcome { + removed_active, + removed_history, + }; + self.prune_popup_decisions(); + outcome + } + + pub fn dismiss_active_if_current(&mut self, id: u32, expected: &Arc) -> bool { + // A replacement can reuse the numeric ID but never the same Arc allocation + let is_current = self + .active + .get(&id) + .is_some_and(|active| Arc::ptr_eq(active, expected)); + if !is_current { + // Keep a replacement that arrived while an earlier action was in flight + return false; + } + + self.active.shift_remove(&id); + self.expirations.remove(&id); + // Action cleanup must not leave a replayable popup decision behind + self.prune_popup_decisions(); + true + } + + pub fn dismiss_replied_generation( + &mut self, + id: u32, + expected: &Arc, + ) -> DismissOutcome { + let removed_active = self + .dismiss_active_if_current(id, expected) + .then(|| expected.key()); + let removed_history = if removed_active.is_some() { + // Active cleanup already removed the exact generation + None + } else if self.active.contains_key(&id) { + // Any remaining active entry is a replacement with the same numeric id + None + } else { + // A close may archive the replied generation before reply cleanup resumes + self.history + .remove_if_source(id, expected) + .map(|notification| notification.key()) + }; + let outcome = DismissOutcome { + removed_active, + removed_history, + }; + self.prune_popup_decisions(); + outcome + } + + pub fn drain_active_keys(&mut self) -> Vec { + // Drain in one pass so callers do not need repeated lookups + let keys = self + .active + .values() + .rev() + .map(|notification| notification.key()) + .collect(); + self.active.clear(); + self.expirations.clear(); + self.prune_popup_decisions(); + keys + } + + /// Clear active and archived notifications at one store linearization point + pub fn clear_all(&mut self) -> Vec { + let keys = self.drain_active_keys(); + self.clear_history(); + self.prune_popup_decisions(); + keys + } + + pub fn set_expiration( + &mut self, + notification: &Arc, + deadline: Option, + ) -> Option { + // None removes a stale timer for resident or already-dismissed notifications + if let Some(deadline) = deadline { + let ticket = ExpirationTicket { + id: notification.id, + generation: notification.generation, + deadline, + }; + self.expirations.insert(notification.id, ticket); + Some(ticket) + } else { + self.expirations.remove(¬ification.id); + None + } + } + + pub fn expire_if_current(&mut self, ticket: ExpirationTicket) -> Option> { + // Both identities must match inside this one store-lock critical section + let current = self.active.get(&ticket.id)?; + if current.generation != ticket.generation { + return None; + } + if self.expirations.get(&ticket.id) != Some(&ticket) { + return None; + } + + // Removal, timer cleanup, and history insertion commit atomically + let removed = self.active.shift_remove(&ticket.id)?; + self.expirations.remove(&ticket.id); + self.push_history(removed.clone(), CloseReason::Expired); + self.prune_popup_decisions(); + Some(removed) + } +} diff --git a/crates/unixnotis-daemon/src/store/notifications/mod.rs b/crates/unixnotis-daemon/src/store/notifications/mod.rs new file mode 100644 index 000000000..8dceef3b4 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/mod.rs @@ -0,0 +1,13 @@ +//! Active notification lifecycle, history, ownership, and rule policy + +mod history; +mod insertion; +mod lifecycle; +mod ownership; +pub(super) mod rules; +mod timeout; + +pub(super) use history::HistoryStore; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-daemon/src/store/identity.rs b/crates/unixnotis-daemon/src/store/notifications/ownership.rs similarity index 63% rename from crates/unixnotis-daemon/src/store/identity.rs rename to crates/unixnotis-daemon/src/store/notifications/ownership.rs index 3e80108d0..aa1639a0e 100644 --- a/crates/unixnotis-daemon/src/store/identity.rs +++ b/crates/unixnotis-daemon/src/store/notifications/ownership.rs @@ -1,21 +1,44 @@ +use std::sync::Arc; use tracing::warn; -use unixnotis_core::Notification; -use super::NotificationStore; +use unixnotis_core::{CloseReason, Notification, NotificationKey}; + +use crate::store::{CloseAuthorization, NotificationStore}; impl NotificationStore { - pub fn is_notification_owned_by( + pub fn close_authorization( &self, id: u32, - sender: &str, + sender: Option<&str>, sender_pid: Option, sender_start_time: Option, - ) -> bool { - // Ownership checks are valid only against active notifications + ) -> CloseAuthorization { let Some(notification) = self.active.get(&id) else { - return false; + return CloseAuthorization::NotClosable; }; - notification_is_owned_by(notification, Some(sender), sender_pid, sender_start_time) + if notification_is_owned_by(notification, sender, sender_pid, sender_start_time) { + CloseAuthorization::OwnedActive(notification.key()) + } else { + CloseAuthorization::NotClosable + } + } + + pub fn close_owned_active_generation( + &mut self, + expected: NotificationKey, + sender: Option<&str>, + sender_pid: Option, + sender_start_time: Option, + reason: CloseReason, + ) -> Option> { + // SECURITY: missing and foreign-owned IDs collapse before leaving the store + // CloseNotification therefore cannot become a notification-existence oracle + match self.close_authorization(expected.id, sender, sender_pid, sender_start_time) { + CloseAuthorization::OwnedActive(current) if current == expected => { + self.close(expected.id, reason) + } + CloseAuthorization::OwnedActive(_) | CloseAuthorization::NotClosable => None, + } } pub(super) fn next_id(&mut self) -> u32 { @@ -57,8 +80,9 @@ impl NotificationStore { sender_pid: Option, sender_start_time: Option, ) -> bool { - // Replacement is allowed only for the sender that owns the original notification - let Some(existing) = self.active.get(&id).or_else(|| self.history.get(&id)) else { + // Protocol replacement authority ends when an ID leaves `active` + // History is presentation-only state and cannot resurrect a closed object + let Some(existing) = self.active.get(&id) else { return false; }; notification_is_owned_by(existing, sender, sender_pid, sender_start_time) diff --git a/crates/unixnotis-daemon/src/store/rules.rs b/crates/unixnotis-daemon/src/store/notifications/rules.rs similarity index 67% rename from crates/unixnotis-daemon/src/store/rules.rs rename to crates/unixnotis-daemon/src/store/notifications/rules.rs index bbe5c1258..4b6a97c9e 100644 --- a/crates/unixnotis-daemon/src/store/rules.rs +++ b/crates/unixnotis-daemon/src/store/notifications/rules.rs @@ -1,6 +1,6 @@ -use unixnotis_core::{Notification, RuleConfig, Urgency}; +use unixnotis_core::{IdentityAssurance, Notification, RuleConfig, Urgency}; -use super::NotificationStore; +use crate::store::NotificationStore; impl NotificationStore { pub(super) fn apply_rules(&self, notification: &mut Notification) { @@ -17,7 +17,21 @@ impl NotificationStore { fn rule_matches(rule: &RuleConfig, notification: &Notification) -> bool { // Every configured filter is ANDed together if let Some(app) = rule.app.as_ref() { - if !contains_ci(¬ification.app_name, app) { + // SECURITY: `Notification::app_name` is sender-controlled protocol metadata + // `app` rules use daemon-resolved attribution only. Matching the raw claim is + // intentionally opt-in through `claimed_app` + let attribution = ¬ification.attribution; + if !assurance_allows_app_rule(attribution.assurance) { + return false; + } + let matches_display = contains_ci(&attribution.display_name, app); + let matches_desktop = contains_ci(&attribution.desktop_id, app); + if !matches_display && !matches_desktop { + return false; + } + } + if let Some(claimed_app) = rule.claimed_app.as_ref() { + if !contains_ci(¬ification.app_name, claimed_app) { return false; } } @@ -46,6 +60,17 @@ fn rule_matches(rule: &RuleConfig, notification: &Notification) -> bool { true } +pub(super) const fn assurance_allows_app_rule(assurance: IdentityAssurance) -> bool { + // Positive enumeration makes new assurance variants fail closed by default + matches!( + assurance, + IdentityAssurance::Authenticated + | IdentityAssurance::SystemAssociated + | IdentityAssurance::PortalAssociated + | IdentityAssurance::UserAssociated + ) +} + fn apply_rule(rule: &RuleConfig, notification: &mut Notification) { // Optional fields mutate only when set in the matching rule if let Some(no_popup) = rule.no_popup { @@ -55,7 +80,7 @@ fn apply_rule(rule: &RuleConfig, notification: &mut Notification) { notification.suppress_sound = silent; } if let Some(force_urgency) = rule.force_urgency { - notification.urgency = Urgency::from(force_urgency); + notification.set_urgency(Urgency::from(force_urgency)); } if let Some(expire_timeout_ms) = rule.expire_timeout_ms { // Clamp protects against large config values that overflow i32 timeout fields diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/history.rs b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs new file mode 100644 index 000000000..eba514cc2 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/history.rs @@ -0,0 +1,94 @@ +use super::support::*; + +#[test] +fn max_entries_zero_drops_history_on_close() { + let mut store = make_store_with_limits(10, 0); + let outcome = store.insert(make_notification("first"), 0); + + store.close(outcome.active_notification().id, CloseReason::Expired); + + assert_eq!(store.history_len(), 0); +} + +#[test] +fn history_eviction_keeps_most_recent_entries() { + let mut store = make_store_with_limits(0, 2); + store.insert(make_notification("first"), 0); + store.insert(make_notification("second"), 0); + store.insert(make_notification("third"), 0); + + let history = store.list_history(); + + assert_eq!(history.len(), 2); + assert_eq!(history[0].summary, "third"); + assert_eq!(history[1].summary, "second"); +} + +#[test] +fn history_reinsert_replaces_existing_order_entry() { + let mut store = make_store_with_limits(0, 10); + let first = store.insert(make_notification("first"), 0); + let mut replacement = make_notification("replacement"); + replacement.id = first.active_notification().id; + + store.history.insert(Arc::new(replacement)); + + let history = store.list_history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, first.active_notification().id); + assert_eq!(history[0].summary, "replacement"); +} + +#[test] +fn transient_close_obeys_the_history_policy() { + for (enabled, expected) in [(false, 0), (true, 1)] { + let mut config = Config::default(); + config.history.transient_to_history = enabled; + let mut store = NotificationStore::new(config); + let mut notification = make_notification("transient"); + notification.is_transient = true; + let outcome = store.insert(notification, 0); + + store.close(outcome.active_notification().id, CloseReason::Expired); + + assert_eq!(store.history_len(), expected); + } +} + +#[test] +fn clear_history_removes_archived_notifications() { + let mut store = make_store_with_limits(10, 10); + let first = store.insert(make_notification("first"), 0); + store.close(first.active_notification().id, CloseReason::Expired); + + store.clear_history(); + + assert_eq!(store.history_len(), 0); + assert!(store.list_history().is_empty()); +} + +#[test] +fn history_generation_checks_and_removal_require_the_exact_commit_key() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("archived"), 0) + .active_notification(); + let current = notification.key(); + let stale = unixnotis_core::NotificationKey { + id: current.id, + generation: current.generation.saturating_add(1), + }; + store.close(current.id, CloseReason::Expired); + + assert!(store.history.contains_generation(current)); + assert!(!store.history.contains_generation(stale)); + assert!(store.history.remove_generation(stale).is_none()); + assert!(store.history.contains_generation(current)); + + let removed = store + .history + .remove_generation(current) + .expect("current generation should be removable"); + assert_eq!(removed.key(), current); + assert!(!store.history.contains_generation(current)); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs new file mode 100644 index 000000000..fe81c5d06 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/insertion.rs @@ -0,0 +1,265 @@ +use super::support::*; + +#[test] +fn max_active_zero_archives_immediately() { + let mut store = make_store_with_limits(0, 10); + + let outcome = store.insert(make_notification("first"), 0); + assert_eq!(outcome.evicted.len(), 1); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 1); + + store.insert(make_notification("second"), 0); + assert!(store.list_active().is_empty()); + assert_eq!(store.history_len(), 2); +} + +#[test] +fn max_active_evicts_oldest_to_history() { + let mut store = make_store_with_limits(1, 10); + store.insert(make_notification("first"), 0); + + let outcome = store.insert(make_notification("second"), 0); + + assert_eq!(outcome.evicted.len(), 1); + let active = store.list_active(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].summary, "second"); + assert_eq!(store.history_len(), 1); +} + +#[test] +fn max_active_hard_cap_limits_even_when_config_is_higher() { + let mut store = make_store_with_limits(32, 64); + for index in 0..18 { + store.insert(make_notification(&format!("entry-{index}")), 0); + } + + let active = store.list_active(); + let history = store.list_history(); + + assert_eq!(active.len(), 12); + assert_eq!(history.len(), 6); + assert_eq!(active[0].summary, "entry-17"); + assert_eq!(active[11].summary, "entry-6"); +} + +#[test] +fn noisy_principal_cannot_evict_another_principals_active_notifications() { + let mut store = make_store_with_limits(5, 128); + let protected = (0..5) + .map(|index| { + store + .insert( + make_notification_with_sender( + &format!("protected-{index}"), + ":1.protected", + 10, + 100, + ), + 0, + ) + .active_notification() + .key() + }) + .collect::>(); + + for index in 0..50 { + store.insert( + make_notification_with_sender(&format!("noisy-{index}"), ":1.noisy", 20, 200), + 0, + ); + } + + let active = store.list_active(); + for key in protected { + assert!( + active.iter().any(|notification| notification.key() == key), + "a different principal must not evict protected active state" + ); + } + assert_eq!(active.len(), 10); +} + +#[test] +fn distinct_bus_senders_remain_isolated_without_process_metadata() { + let mut store = make_store_with_limits(5, 128); + let protected = (0..5) + .map(|index| { + let mut notification = make_notification(&format!("protected-bus-{index}")); + notification.sender_name = Some(":1.100".to_string()); + notification.sender_pid = None; + notification.sender_start_time = None; + store.insert(notification, 0).active_notification().key() + }) + .collect::>(); + + for index in 0..20 { + let mut notification = make_notification(&format!("noisy-bus-{index}")); + notification.sender_name = Some(":1.200".to_string()); + notification.sender_pid = None; + notification.sender_start_time = None; + store.insert(notification, 0); + } + + let active = store.list_active(); + assert!( + protected + .iter() + .all(|key| active.iter().any(|notification| notification.key() == *key)), + "a degraded sender must not evict a different unique bus connection" + ); + assert_eq!(active.len(), 10); +} + +#[test] +fn absolute_active_cap_keeps_exact_capacity_and_evicts_the_admitted_tie() { + let mut store = make_store_with_limits(12, 256); + let mut admitted_oldest = None; + for principal in 0..12_u32 { + let count = if principal < 8 { 11 } else { 10 }; + for index in 0..count { + let outcome = store.insert( + make_notification_with_sender( + &format!("principal-{principal}-{index}"), + &format!(":1.{principal}"), + principal.saturating_add(1), + u64::from(principal).saturating_add(100), + ), + 0, + ); + if principal == 0 && index == 0 { + admitted_oldest = Some(outcome.active_notification().key()); + } + assert!( + outcome.evicted.is_empty(), + "the exact global capacity must not evict active state" + ); + } + } + assert_eq!(store.list_active().len(), 128); + + let outcome = store.insert( + make_notification_with_sender("principal-0-tie", ":1.0", 1, 100), + 0, + ); + + assert_eq!(store.list_active().len(), 128); + assert_eq!(outcome.evicted, vec![admitted_oldest.expect("oldest key")]); +} + +#[test] +fn absolute_active_cap_evicts_a_largest_existing_share_not_the_newcomer() { + let mut store = make_store_with_limits(12, 256); + for principal in 0..10_u32 { + for index in 0..12 { + store.insert( + make_notification_with_sender( + &format!("incumbent-{principal}-{index}"), + &format!(":1.{principal}"), + principal.saturating_add(1), + u64::from(principal).saturating_add(100), + ), + 0, + ); + } + } + let mut newcomer_keys = Vec::new(); + let mut final_outcome = None; + for index in 0..9 { + let outcome = store.insert( + make_notification_with_sender(&format!("newcomer-{index}"), ":1.newcomer", 999, 9_999), + 0, + ); + newcomer_keys.push(outcome.active_notification().key()); + final_outcome = Some(outcome); + } + let outcome = final_outcome.expect("newcomer outcome"); + + assert_eq!(store.list_active().len(), 128); + assert_eq!(outcome.evicted.len(), 1); + assert!( + !newcomer_keys.contains(&outcome.evicted[0]), + "a smaller newcomer share must not be selected as the emergency victim" + ); + let active = store.list_active(); + assert!( + newcomer_keys + .iter() + .all(|key| active.iter().any(|notification| notification.key() == *key)), + "every newcomer generation must survive when a larger share exists" + ); +} + +#[test] +fn zero_history_limit_keeps_active_notifications_and_drops_evictions() { + let mut active_store = make_store_with_limits(2, 0); + active_store.insert(make_notification("first"), 0); + let active = active_store.insert(make_notification("second"), 0); + assert!(active.evicted.is_empty()); + assert_eq!(active_store.list_active().len(), 2); + + let mut evicting_store = make_store_with_limits(0, 0); + let evicted = evicting_store.insert(make_notification("first"), 0); + assert_eq!(evicted.evicted.len(), 1); + assert!(evicting_store.list_active().is_empty()); + assert_eq!(evicting_store.history_len(), 0); +} + +#[test] +fn insert_outcome_reflects_popup_and_sound_policy() { + let state_dir = make_temp_state_dir("insert-outcome-policy"); + let mut config = Config::default(); + config.general.dnd_default = false; + let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); + let allowed = store.insert(make_notification("normal"), 0); + assert!(allowed.popup_admission.should_show()); + assert!(allowed.allow_sound); + + let dnd_state_dir = make_temp_state_dir("insert-outcome-dnd"); + let mut dnd_config = Config::default(); + dnd_config.general.dnd_default = true; + let mut dnd_store = NotificationStore::new_with_state_dir(dnd_config, dnd_state_dir.clone()); + let normal = dnd_store.insert(make_notification("normal dnd"), 0); + assert!(!normal.popup_admission.should_show()); + assert!(!normal.allow_sound); + + let mut critical = make_notification("critical dnd"); + critical.urgency = unixnotis_core::Urgency::Critical; + let critical = dnd_store.insert(critical, 0); + assert!(critical.popup_admission.should_show()); + assert!(critical.allow_sound); + + let mut silent = make_notification("silent"); + silent.suppress_sound = true; + let silent = store.insert(silent, 0); + assert!(!silent.allow_sound); + + cleanup_temp_dir(&state_dir); + cleanup_temp_dir(&dnd_state_dir); +} + +#[test] +fn popup_candidates_exclude_notifications_suppressed_by_rules() { + let mut store = make_store_with_limits(4, 4); + let mut notification = make_notification("rule-suppressed"); + notification.suppress_popup = true; + + let outcome = store.insert(notification, 0); + + assert!(!outcome.popup_admission.should_show()); + assert_eq!(store.list_active().len(), 1); + assert!(store.list_popup_candidates().is_empty()); +} + +#[test] +fn popup_candidates_include_notifications_allowed_by_rules() { + let mut store = make_store_with_limits(4, 4); + let outcome = store.insert(make_notification("popup-allowed"), 0); + + let candidates = store.list_popup_candidates(); + + assert!(outcome.popup_admission.should_show()); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].summary, "popup-allowed"); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs new file mode 100644 index 000000000..022870b89 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/lifecycle.rs @@ -0,0 +1,144 @@ +use super::support::*; +use crate::store::ExpirationTicket; + +fn expiration_for(store: &NotificationStore, id: u32) -> Option { + // Test-only inspection stays beside lifecycle regressions instead of production methods + store.expirations.get(&id).copied() +} + +#[test] +fn drain_active_keys_returns_newest_first_and_clears_expirations() { + let mut store = make_store_with_limits(10, 10); + let first = store.insert(make_notification("first"), 0); + let second = store.insert(make_notification("second"), 0); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + store.set_expiration(&first.active_notification(), Some(deadline)); + + let keys = store.drain_active_keys(); + + assert_eq!( + keys, + vec![ + second.active_notification().key(), + first.active_notification().key() + ] + ); + assert!(store.list_active().is_empty()); + assert_eq!(expiration_for(&store, first.active_notification().id), None); +} + +#[test] +fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert(make_notification("timer"), 0); + let first = std::time::Instant::now() + std::time::Duration::from_secs(1); + let second = std::time::Instant::now() + std::time::Duration::from_secs(2); + + let first_ticket = store + .set_expiration(&outcome.active_notification(), Some(first)) + .expect("positive deadline should create a ticket"); + assert_eq!( + expiration_for(&store, outcome.active_notification().id), + Some(first_ticket) + ); + + let second_ticket = store + .set_expiration(&outcome.active_notification(), Some(second)) + .expect("replacement deadline should create a ticket"); + assert_eq!( + expiration_for(&store, outcome.active_notification().id), + Some(second_ticket) + ); + + store.set_expiration(&outcome.active_notification(), None); + assert_eq!( + expiration_for(&store, outcome.active_notification().id), + None + ); +} + +#[test] +fn generation_safe_reply_dismissal_keeps_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let mut original = make_notification("original"); + original.inline_reply.available = true; + original.actions.push(unixnotis_core::Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let original = store.insert(original, 0).active_notification(); + let id = original.id; + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + + assert!(!store.dismiss_active_if_current(id, &original)); + assert_eq!( + store + .active_notification_view(id) + .expect("replacement should remain active") + .summary, + "replacement" + ); + assert!(store.dismiss_active_if_current(id, &replacement.active_notification())); + assert!(store.active_notification_view(id).is_none()); +} + +#[test] +fn stale_panel_dismissal_keeps_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let stale_key = original.key(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + + let outcome = store.dismiss_generation(stale_key); + + assert!(!outcome.removed_any()); + assert_eq!( + store + .active_notification_view(replacement.id) + .expect("replacement should remain active") + .key(), + replacement.key() + ); +} + +#[test] +fn replied_generation_is_removed_after_sender_archives_it() { + let mut store = make_store_with_limits(12, 20); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let id = original.id; + store.close(id, CloseReason::ClosedByCall); + assert_eq!(store.list_history().len(), 1); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(outcome.removed_active.is_none()); + assert_eq!(outcome.removed_history, Some(original.key())); + assert!(store.list_history().is_empty()); +} + +#[test] +fn replied_generation_cleanup_keeps_archived_same_id_replacement() { + let mut store = make_store_with_limits(12, 20); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let id = original.id; + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + store.close(id, CloseReason::ClosedByCall); + + let outcome = store.dismiss_replied_generation(id, &original); + + assert!(!outcome.removed_any()); + let history = store.list_history(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].summary, "replacement"); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs new file mode 100644 index 000000000..6aa43b49b --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/mod.rs @@ -0,0 +1,7 @@ +mod history; +mod insertion; +mod lifecycle; +mod ownership; +mod rules; +mod support; +mod timeout; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs new file mode 100644 index 000000000..92c4df309 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/ownership.rs @@ -0,0 +1,344 @@ +use super::support::*; + +fn is_notification_owned_by( + store: &NotificationStore, + id: u32, + sender: &str, + sender_pid: Option, + sender_start_time: Option, +) -> bool { + matches!( + store.close_authorization(id, Some(sender), sender_pid, sender_start_time), + crate::store::CloseAuthorization::OwnedActive(_) + ) +} + +#[test] +fn replace_id_in_history_allocates_new_id_and_preserves_history() { + let mut store = make_store_with_limits(2, 10); + + let first = store.insert(make_notification("first"), 0); + store.close(first.active_notification().id, CloseReason::Expired); + assert_eq!(store.history_len(), 1); + + // History cannot restore replacement authority for an inactive protocol ID + let replaced = store.insert( + make_notification("replacement"), + first.active_notification().id, + ); + assert!(!replaced.replaced); + assert_ne!( + replaced.active_notification().id, + first.active_notification().id + ); + assert_eq!(store.history_len(), 1); + + let active = store.list_active(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].summary, "replacement"); + + // Closing the new notification archives it independently from the original ID + store.close(replaced.active_notification().id, CloseReason::Expired); + let history = store.list_history(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].summary, "replacement"); + assert_eq!(history[1].summary, "first"); +} + +#[test] +fn active_owned_id_replaces_while_active_foreign_and_missing_ids_do_not() { + let mut store = make_store_with_limits(5, 10); + let owned = store.insert( + make_notification_with_sender("owned", ":1.owner", 101, 11), + 0, + ); + let foreign = store.insert( + make_notification_with_sender("foreign", ":1.foreign", 202, 22), + 0, + ); + + let owned_replacement = store.insert( + make_notification_with_sender("owned replacement", ":1.owner", 101, 11), + owned.active_notification().id, + ); + let foreign_attempt = store.insert( + make_notification_with_sender("foreign attempt", ":1.owner", 101, 11), + foreign.active_notification().id, + ); + let missing_attempt = store.insert( + make_notification_with_sender("missing attempt", ":1.owner", 101, 11), + u32::MAX, + ); + + assert!(owned_replacement.replaced); + assert_eq!( + owned_replacement.active_notification().id, + owned.active_notification().id + ); + assert!(!foreign_attempt.replaced); + assert_ne!( + foreign_attempt.active_notification().id, + foreign.active_notification().id + ); + assert!(!missing_attempt.replaced); + assert_ne!(missing_attempt.active_notification().id, u32::MAX); +} + +#[test] +fn replace_id_rejected_for_different_sender() { + let mut store = make_store_with_limits(2, 10); + + let first = store.insert( + make_notification_with_sender("first", ":1.sender-a", 101, 1), + 0, + ); + store.close(first.active_notification().id, CloseReason::Expired); + assert_eq!(store.history_len(), 1); + + // Cross-sender replacement must allocate a fresh id and keep prior history intact + let replaced = store.insert( + make_notification_with_sender("replacement", ":1.sender-b", 202, 2), + first.active_notification().id, + ); + assert!(!replaced.replaced); + assert_ne!( + replaced.active_notification().id, + first.active_notification().id + ); + assert_eq!(store.history_len(), 1); +} + +#[test] +fn is_notification_owned_by_matches_sender() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert( + make_notification_with_sender("owned", ":1.owner", 1234, 55), + 0, + ); + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.owner", + Some(1234), + Some(55) + )); + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.other", + Some(5678), + Some(66) + )); +} + +#[test] +fn is_notification_owned_by_accepts_exact_sender_without_process_match() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert( + make_notification_with_sender("owned", ":1.owner", 1234, 55), + 0, + ); + + // Bus names are stronger than pid metadata, which may be absent or stale + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.owner", + Some(5678), + Some(66) + )); +} + +#[test] +fn is_notification_owned_by_accepts_same_process_after_reconnect() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert( + make_notification_with_sender("owned", ":1.owner-a", 1234, 55), + 0, + ); + // A new bus name from the same process lifetime should still be treated as owner + assert!(is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.owner-b", + Some(1234), + Some(55) + )); +} + +#[test] +fn is_notification_owned_by_rejects_reused_pid_with_new_start_time() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert( + make_notification_with_sender("owned", ":1.owner-a", 1234, 55), + 0, + ); + // Same pid is not enough once the original process lifetime has ended + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.owner-b", + Some(1234), + Some(77) + )); +} + +#[test] +fn is_notification_owned_by_rejects_pid_match_without_start_time() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert( + make_notification_with_sender("owned", ":1.owner-a", 1234, 55), + 0, + ); + + // Pid reuse is common enough that start time must be part of process ownership + assert!(!is_notification_owned_by( + &store, + outcome.active_notification().id, + ":1.owner-b", + Some(1234), + None + )); +} + +#[test] +fn close_authorization_collapses_missing_foreign_and_history_only_ids() { + let mut store = make_store_with_limits(10, 10); + let active = store + .insert( + make_notification_with_sender("active", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + let archived = store + .insert( + make_notification_with_sender("archived", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + store.close(archived.id, CloseReason::Expired); + + let missing = store.close_authorization(u32::MAX, Some(":1.owner"), Some(1234), Some(55)); + let foreign = store.close_authorization(active.id, Some(":1.foreign"), Some(9876), Some(66)); + let history = store.close_authorization(archived.id, Some(":1.owner"), Some(1234), Some(55)); + + assert_eq!(missing, crate::store::CloseAuthorization::NotClosable); + assert_eq!(foreign, crate::store::CloseAuthorization::NotClosable); + assert_eq!(history, crate::store::CloseAuthorization::NotClosable); + assert_eq!(missing, foreign); + assert_eq!(foreign, history); +} + +#[test] +fn close_owned_active_generation_removes_only_the_authorized_live_object() { + let mut store = make_store_with_limits(10, 10); + let active = store + .insert( + make_notification_with_sender("active", ":1.owner-a", 1234, 55), + 0, + ) + .active_notification(); + + let removed = store + .close_owned_active_generation( + active.key(), + Some(":1.owner-b"), + Some(1234), + Some(55), + CloseReason::ClosedByCall, + ) + .expect("same process lifetime should close after reconnect"); + + assert_eq!(removed.key(), active.key()); + assert!(store.list_active().is_empty()); +} + +#[test] +fn close_owned_active_generation_rejects_a_same_id_replacement() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert( + make_notification_with_sender("original", ":1.owner", 1234, 55), + 0, + ) + .active_notification(); + let replacement = store + .insert( + make_notification_with_sender("replacement", ":1.owner", 1234, 55), + original.id, + ) + .active_notification(); + + let removed = store.close_owned_active_generation( + original.key(), + Some(":1.owner"), + Some(1234), + Some(55), + CloseReason::ClosedByCall, + ); + + assert!(removed.is_none()); + assert_eq!( + store.active.get(&replacement.id).map(|item| item.key()), + Some(replacement.key()) + ); +} + +#[test] +fn replacement_allows_same_process_after_bus_reconnect() { + let mut store = make_store_with_limits(2, 10); + + let first = store.insert( + make_notification_with_sender("first", ":1.owner-a", 1234, 55), + 0, + ); + + let replacement = store.insert( + make_notification_with_sender("replacement", ":1.owner-b", 1234, 55), + first.active_notification().id, + ); + + // Same process lifetime can replace after the bus name changes + assert!(replacement.replaced); + assert_eq!( + replacement.active_notification().id, + first.active_notification().id + ); +} + +#[test] +fn next_id_skips_used_ids_within_used_window() { + let mut store = make_store_with_limits(5, 5); + store.next_id = 1; + + let mut active = make_notification("active"); + active.id = 1; + store.active.insert(1, Arc::new(active)); + + let mut history = make_notification("history"); + history.id = 3; + store.history.insert(Arc::new(history)); + + assert_eq!(store.next_id(), 2); +} + +#[test] +fn next_id_skips_ids_that_exist_only_in_history() { + let mut store = make_store_with_limits(5, 5); + store.next_id = 7; + + let mut history = make_notification("history-only"); + history.id = 7; + store.history.insert(Arc::new(history)); + + assert_eq!(store.next_id(), 8); +} + +#[test] +fn next_id_wraps_internal_cursor_back_to_one_after_max_id() { + let mut store = make_store_with_limits(5, 5); + store.next_id = u32::MAX; + + assert_eq!(store.next_id(), u32::MAX); + assert_eq!(store.next_id, 1); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs new file mode 100644 index 000000000..98f44f2c0 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/rules.rs @@ -0,0 +1,246 @@ +use super::support::*; +use crate::store::notifications::rules::assurance_allows_app_rule; + +#[test] +fn contains_ci_matches_ascii() { + assert!(contains_ci("Example-Chat", "example")); + assert!(contains_ci("example-chat", "Example")); + assert!(!contains_ci("example-chat", "brave")); + assert!(contains_ci("mixedCase", "case")); + assert!(contains_ci("mixedCase", "")); + assert!(contains_ci("same", "same")); + assert!(!contains_ci("short", "longer")); +} + +#[test] +fn rules_require_all_filters_and_apply_every_mutation() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + name: Some("test-rule".to_string()), + app: Some("test".to_string()), + claimed_app: None, + summary: Some("hello".to_string()), + body: Some("body".to_string()), + category: Some("chat".to_string()), + urgency: Some(unixnotis_core::RuleUrgency::Normal), + no_popup: Some(true), + silent: Some(true), + force_urgency: Some(unixnotis_core::RuleUrgency::Critical), + expire_timeout_ms: Some(1234), + resident: Some(true), + transient: Some(true), + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("hello summary"); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Test Application", + "claimed", + "org.example.Test", + "test-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.Test".to_string(), + ); + notification.body = "body text".to_string(); + notification.category = Some("chat.message".to_string()); + notification.urgency = unixnotis_core::Urgency::Normal; + notification.hints.insert( + "urgency".to_string(), + zbus::zvariant::OwnedValue::from(1_u32), + ); + + store.apply_rules(&mut notification); + + assert!(notification.suppress_popup); + assert!(notification.suppress_sound); + assert_eq!(notification.urgency, unixnotis_core::Urgency::Critical); + assert_eq!( + notification + .hints + .get("urgency") + .and_then(|value| value.try_clone().ok()) + .and_then(|value| u32::try_from(value).ok()), + Some(notification.urgency.as_u32()) + ); + assert_eq!(notification.expire_timeout, 1234); + assert!(notification.is_resident); + assert!(notification.is_transient); +} + +#[test] +fn trusted_app_rule_does_not_match_spoofed_claim_or_escalate_urgency() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + force_urgency: Some(unixnotis_core::RuleUrgency::Critical), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("spoofed claim"); + notification.app_name = "TrustedApp".to_string(); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Unrelated Application", + "TrustedApp", + "org.example.Unrelated", + "unrelated", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.Unrelated".to_string(), + ); + + store.apply_rules(&mut notification); + + assert_eq!(notification.urgency, unixnotis_core::Urgency::Normal); +} + +#[test] +fn trusted_app_rule_matches_resolved_display_name_or_desktop_id() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("trusted identity"); + notification.app_name = "Unrelated Claim".to_string(); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Trusted Application", + "Unrelated Claim", + "org.example.TrustedApp", + "trusted-app", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "test identity", + "verified:org.example.TrustedApp".to_string(), + ); + + store.apply_rules(&mut notification); + + assert!(notification.suppress_popup); +} + +#[test] +fn claimed_app_rule_intentionally_matches_sender_claim() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + claimed_app: Some("TrustedApp".to_string()), + silent: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("claimed identity"); + notification.app_name = "TrustedApp".to_string(); + + store.apply_rules(&mut notification); + + assert!(notification.suppress_sound); +} + +#[test] +fn trusted_app_rule_rejects_unresolved_and_conflicting_attribution() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + app: Some("TrustedApp".to_string()), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut unresolved = make_notification("unresolved"); + unresolved.app_name = "TrustedApp".to_string(); + unresolved.attribution = unixnotis_core::NotificationAttribution::unresolved( + "TrustedApp", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "test identity", + "unknown:trusted-app".to_string(), + ); + let mut conflict = make_notification("conflict"); + conflict.app_name = "TrustedApp".to_string(); + conflict.attribution = unixnotis_core::NotificationAttribution::conflict( + "TrustedApp", + "org.example.TrustedApp", + unixnotis_core::AttributionReason::ExecutableMismatch, + "test identity", + "conflict:trusted-app".to_string(), + ); + + store.apply_rules(&mut unresolved); + store.apply_rules(&mut conflict); + + assert!(!unresolved.suppress_popup); + assert!(!conflict.suppress_popup); +} + +#[test] +fn app_rules_accept_only_explicitly_resolved_assurance_levels() { + use unixnotis_core::IdentityAssurance; + + for assurance in [ + IdentityAssurance::Authenticated, + IdentityAssurance::SystemAssociated, + IdentityAssurance::PortalAssociated, + IdentityAssurance::UserAssociated, + ] { + assert!(assurance_allows_app_rule(assurance)); + } + for assurance in [ + IdentityAssurance::Unresolved, + IdentityAssurance::Conflict, + IdentityAssurance::Relay, + ] { + assert!(!assurance_allows_app_rule(assurance)); + } +} + +#[test] +fn rules_do_not_match_missing_category_or_wrong_urgency() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + category: Some("chat".to_string()), + urgency: Some(unixnotis_core::RuleUrgency::Critical), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("hello"); + notification.urgency = unixnotis_core::Urgency::Normal; + + store.apply_rules(&mut notification); + assert!(!notification.suppress_popup); + + notification.category = Some("chat".to_string()); + store.apply_rules(&mut notification); + assert!(!notification.suppress_popup); +} + +#[test] +fn rules_do_not_match_wrong_category_even_when_urgency_matches() { + let config = Config { + rules: vec![unixnotis_core::RuleConfig { + category: Some("chat".to_string()), + urgency: Some(unixnotis_core::RuleUrgency::Critical), + no_popup: Some(true), + ..unixnotis_core::RuleConfig::default() + }], + ..Config::default() + }; + let store = NotificationStore::new(config); + let mut notification = make_notification("hello"); + notification.category = Some("email".to_string()); + notification.urgency = unixnotis_core::Urgency::Critical; + + store.apply_rules(&mut notification); + + assert!(!notification.suppress_popup); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/support.rs b/crates/unixnotis-daemon/src/store/notifications/tests/support.rs new file mode 100644 index 000000000..2bb137b6c --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/support.rs @@ -0,0 +1,7 @@ +pub(super) use std::sync::Arc; + +pub(super) use unixnotis_core::{CloseReason, Config}; + +pub(super) use super::super::rules::contains_ci; +pub(super) use crate::store::test_support::*; +pub(super) use crate::store::NotificationStore; diff --git a/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs new file mode 100644 index 000000000..38718487d --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/tests/timeout.rs @@ -0,0 +1,131 @@ +use std::time::Duration; + +use super::super::timeout::resolve_timeout_policy; +use super::support::make_notification; +use unixnotis_core::{Config, Urgency}; + +#[test] +fn zero_protocol_timeout_disables_both_clocks() { + let config = Config::default(); + let mut notification = make_notification("never"); + notification.expire_timeout = 0; + + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 0, + active_close_after: None, + } + ); +} + +#[test] +fn positive_protocol_timeout_closes_nonresident_notifications() { + let config = Config::default(); + let mut notification = make_notification("bounded"); + notification.expire_timeout = 30_000; + + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 30_000, + active_close_after: Some(Duration::from_secs(30)), + } + ); +} + +#[test] +fn positive_protocol_timeout_closes_transient_notifications() { + let config = Config::default(); + let mut notification = make_notification("transient-bounded"); + notification.expire_timeout = 30_000; + notification.is_transient = true; + + assert_eq!( + resolve_timeout_policy(&config, ¬ification), + super::super::timeout::ResolvedTimeoutPolicy { + popup_hide_after_ms: 30_000, + active_close_after: Some(Duration::from_secs(30)), + } + ); +} + +#[test] +fn resident_positive_timeout_still_expires_the_active_notification() { + let config = Config::default(); + let mut notification = make_notification("resident"); + notification.expire_timeout = 30_000; + notification.is_resident = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 30_000); + assert_eq!(policy.active_close_after, Some(Duration::from_secs(30))); +} + +#[test] +fn critical_positive_timeout_hides_popup_without_expiring_active_notification() { + let config = Config::default(); + let mut notification = make_notification("critical bounded popup"); + notification.expire_timeout = 30_000; + notification.urgency = Urgency::Critical; + + let policy = resolve_timeout_policy(&config, ¬ification); + + assert_eq!(policy.popup_hide_after_ms, 30_000); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn default_normal_timeout_hides_but_keeps_active_record() { + let config = Config::default(); + let mut notification = make_notification("normal"); + notification.expire_timeout = -1; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn default_critical_without_timeout_stays_visible() { + let config = Config::default(); + let mut notification = make_notification("critical"); + notification.expire_timeout = -1; + notification.urgency = Urgency::Critical; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, 0); + assert_eq!(policy.active_close_after, None); +} + +#[test] +fn transient_default_timeout_closes_without_history_by_default() { + let config = Config::default(); + let mut notification = make_notification("transient"); + notification.expire_timeout = -1; + notification.is_transient = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!( + policy.active_close_after, + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} + +#[test] +fn resident_transient_default_timeout_uses_time_policy_independent_of_actions() { + let config = Config::default(); + let mut notification = make_notification("resident transient"); + notification.expire_timeout = -1; + notification.is_transient = true; + notification.is_resident = true; + + let policy = resolve_timeout_policy(&config, ¬ification); + + assert_eq!(policy.popup_hide_after_ms, config.popups.default_timeout_ms); + assert_eq!( + policy.active_close_after, + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} diff --git a/crates/unixnotis-daemon/src/store/notifications/timeout.rs b/crates/unixnotis-daemon/src/store/notifications/timeout.rs new file mode 100644 index 000000000..a41b11125 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/notifications/timeout.rs @@ -0,0 +1,62 @@ +//! Resolve popup visibility and daemon lifetime clocks at commit time + +use std::time::Duration; + +use unixnotis_core::{Config, Notification, Urgency}; + +/// Sanitized timeout decisions shared by the daemon scheduler and popup view +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ResolvedTimeoutPolicy { + /// Zero keeps the banner visible until an explicit close or replacement + pub(super) popup_hide_after_ms: u64, + /// None keeps the active record available for panel actions indefinitely + pub(super) active_close_after: Option, +} + +/// Resolve both clocks after rule mutations and before the generation is committed +pub(super) fn resolve_timeout_policy( + config: &Config, + notification: &Notification, +) -> ResolvedTimeoutPolicy { + let configured_popup_ms = match notification.urgency { + Urgency::Critical => config.popups.critical_timeout_ms.unwrap_or(0), + _ => config.popups.default_timeout_ms, + }; + + match notification.expire_timeout { + // A zero protocol timeout disables both automatic clocks + 0 => ResolvedTimeoutPolicy { + popup_hide_after_ms: 0, + active_close_after: None, + }, + // Positive protocol values close normal notifications regardless of resident state + timeout if timeout > 0 => { + let timeout_ms = timeout as u64; + ResolvedTimeoutPolicy { + popup_hide_after_ms: timeout_ms, + // `resident` controls post-action dismissal. It does not override the + // notification's explicit expiration timeout + active_close_after: (notification.urgency != Urgency::Critical) + .then(|| Duration::from_millis(timeout_ms)), + } + } + // The default protocol value uses UnixNotis display policy + _ => { + // Critical popup visibility and active-notification lifetime are separate + // Critical alerts may leave the screen, but stay active until explicitly closed + let active_close_after = if notification.urgency != Urgency::Critical + && notification.is_transient + && configured_popup_ms > 0 + { + Some(Duration::from_millis(configured_popup_ms)) + } else { + None + }; + + ResolvedTimeoutPolicy { + popup_hide_after_ms: configured_popup_ms, + active_close_after, + } + } + } +} diff --git a/crates/unixnotis-daemon/src/store/runtime.rs b/crates/unixnotis-daemon/src/store/runtime.rs new file mode 100644 index 000000000..6185a669d --- /dev/null +++ b/crates/unixnotis-daemon/src/store/runtime.rs @@ -0,0 +1,412 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use indexmap::IndexMap; +use tracing::{debug, warn}; +use unixnotis_core::{ + ApplicationActionPolicy, Config, ControlState, Notification, NotificationDiagnosticsView, + NotificationKey, NotificationView, PopupAdmissionView, PopupCandidate, PopupDecisionRecord, + PopupDeliveryStage, UiHealth, +}; + +use super::dnd::{DndStateStore, DND_STATE_VERSION}; +use super::model::{DeliveryStageUpdate, NotificationStore, PopupTiming}; +use super::notifications::HistoryStore; + +impl NotificationStore { + pub fn new(config: Config) -> Self { + // Default constructor attempts to bind persistence to XDG state dir + let dnd_state_store = DndStateStore::new(); + Self::new_with_state_store(config, dnd_state_store) + } + + pub(crate) fn new_with_state_store( + config: Config, + dnd_state_store: Option, + ) -> Self { + // Config default is used unless a valid persisted value overrides it + let mut dnd_enabled = config.general.dnd_default; + let mut dnd_expires_at = None; + if let Some(store) = dnd_state_store.as_ref() { + match store.load() { + Ok(Some(state)) if state.version == DND_STATE_VERSION => { + // Versioned state prevents accidental decode of incompatible formats + dnd_enabled = state.dnd_enabled; + dnd_expires_at = state.dnd_enabled.then_some(state.expires_at).flatten(); + // A deadline that passed while the daemon was stopped must not revive DND + if dnd_expires_at.is_some_and(|expires_at| expires_at <= unix_now_seconds()) { + dnd_enabled = false; + dnd_expires_at = None; + if let Err(err) = store.persist(false, None) { + warn!(?err, "failed to clear expired do-not-disturb state"); + } + } + debug!( + dnd_enabled, + ?dnd_expires_at, + "loaded persisted do-not-disturb state" + ); + } + Ok(Some(state)) => { + // Unknown version is ignored but logged for troubleshooting + warn!( + version = state.version, + "unsupported dnd state version; ignoring persisted value" + ); + } + Ok(None) => {} + Err(err) => { + // Persistence failures must never block daemon startup + warn!(?err, "failed to read persisted do-not-disturb state"); + } + } + } + + Self { + // IDs start at 1 to preserve protocol expectations + next_id: 1, + // Generation zero stays reserved for payloads not committed to the store + next_generation: 1, + dnd_enabled, + dnd_expires_at, + dnd_revision: 0, + config, + active: IndexMap::new(), + history: HistoryStore::new(), + popup_decisions: HashMap::new(), + popup_timings: HashMap::new(), + expirations: HashMap::new(), + dnd_state_store, + next_inhibitor_id: 1, + inhibitors: HashMap::new(), + inhibited: false, + inhibitor_count: 0, + } + } + + pub const fn inhibited(&self) -> bool { + self.inhibited + } + + pub const fn inhibitor_count(&self) -> u32 { + self.inhibitor_count + } + + pub fn control_state(&self) -> ControlState { + // One canonical snapshot prevents query and event paths from drifting apart + ControlState { + dnd_enabled: self.dnd_enabled(), + dnd_expires_at: self.dnd_expires_at().unwrap_or(0), + history_count: self.history_len() as u32, + inhibited: self.inhibited(), + inhibitor_count: self.inhibitor_count(), + } + } + + pub fn list_active(&self) -> Vec { + // Reverse iteration returns newest entries first for panel rendering + self.active + .values() + .rev() + .map(|notification| self.list_view_with_popup_decision(notification)) + .collect() + } + + pub fn list_history(&self) -> Vec { + // HistoryStore already returns newest first + self.history.list_views(&self.popup_decisions) + } + + pub fn list_popup_candidates(&self) -> Vec { + let now = Instant::now(); + // Newest-first ordering matches ListActive while excluding persistent no-popup rules + self.active + .values() + .rev() + .filter(|notification| { + !notification.suppress_popup + && self + .popup_decisions + .get(¬ification.key()) + .is_some_and(|decision| { + matches!( + decision.admission_at_commit, + PopupAdmissionView::Show | PopupAdmissionView::RendererUnavailable + ) && decision.delivery_stage.rank() < PopupDeliveryStage::Visible.rank() + && self.popup_deadline_is_current(notification.key(), now) + }) + }) + .map(|notification| self.list_view_with_popup_timing(notification, now)) + .collect() + } + + pub fn active_notification_view(&self, id: u32) -> Option { + // Active rows use the richer popup-oriented view because add/update signals + // are consumed by trusted UIs that may need current image payloads + self.active + .get(&id) + .map(|notification| self.view_with_popup_decision(notification)) + } + + pub fn popup_candidate(&mut self, id: u32) -> Option { + let now = Instant::now(); + // Payload and its arrival-time policy are read from one store-lock snapshot + let notification = self.active.get(&id)?; + let key = notification.key(); + let decision = self.popup_decisions.get(&key)?; + // A generation that was already rendered must not be fetched again after + // a delayed signal or renderer reconnect + if decision.delivery_stage.rank() >= PopupDeliveryStage::Visible.rank() { + return None; + } + // Popup lifetime begins at daemon admission, not renderer availability + // Renderer downtime must never make stale content a fresh full-duration popup + if !self.popup_deadline_is_current(key, now) { + return None; + } + let admission = decision.admission_at_commit; + let view = self.view_with_popup_timing(notification, now); + if admission.should_show() { + self.record_popup_delivery_stage(key, PopupDeliveryStage::RendererFetched); + } + Some(PopupCandidate { + notification: view, + admission, + }) + } + + pub fn notification_diagnostics( + &self, + id: u32, + _ui_health: &UiHealth, + ) -> Option { + let notification = self.active.get(&id).or_else(|| self.history.get(&id))?; + let decision = self.popup_decisions.get(¬ification.key())?; + + Some(NotificationDiagnosticsView { + id, + generation: notification.generation, + stored: true, + attribution: notification.attribution_diagnostics.clone(), + identity_assurance: notification.attribution.assurance, + interaction_policies: notification.attribution.interactions, + popup_admission: decision.admission_at_commit, + renderer_process_running: decision.renderer_process_running_at_commit, + renderer_ready: decision.renderer_ready_at_commit, + renderer_health_revision: decision.renderer_health_revision_at_commit, + configured_max_visible: decision.max_visible_at_commit, + decided_at_unix_ms: decision.decided_at_unix_ms, + delivery_stage: decision.delivery_stage, + }) + } + + pub(super) fn record_popup_commit_environment_at( + &mut self, + key: NotificationKey, + admission: super::PopupAdmission, + ui_health: &UiHealth, + popup_hide_after_ms: u64, + admitted_at: Instant, + ) { + let max_visible = u32::try_from(self.config.popups.max_visible).unwrap_or(u32::MAX); + let effective_admission = if !admission.should_show() { + admission.to_view() + } else if max_visible == 0 { + PopupAdmissionView::RendererDisabled + } else if ui_health.popups_process_running && ui_health.popups_ready { + PopupAdmissionView::Show + } else { + PopupAdmissionView::RendererUnavailable + }; + let delivery_stage = if effective_admission.should_show() { + PopupDeliveryStage::Admitted + } else { + PopupDeliveryStage::Suppressed + }; + self.popup_decisions.insert( + key, + PopupDecisionRecord { + admission_at_commit: effective_admission, + renderer_process_running_at_commit: ui_health.popups_process_running, + renderer_ready_at_commit: ui_health.popups_ready, + renderer_health_revision_at_commit: ui_health.revision, + max_visible_at_commit: max_visible, + decided_at_unix_ms: chrono::Utc::now().timestamp_millis(), + delivery_stage, + popup_hide_after_ms, + }, + ); + let deadline = (popup_hide_after_ms != 0) + .then(|| Duration::from_millis(popup_hide_after_ms)) + .and_then(|duration| admitted_at.checked_add(duration)); + // None means indefinite both for the explicit zero sentinel and defensive clock overflow + self.popup_timings.insert(key, PopupTiming { deadline }); + } + + pub fn record_popup_delivery_stage( + &mut self, + key: NotificationKey, + next: PopupDeliveryStage, + ) -> DeliveryStageUpdate { + let Some(decision) = self.popup_decisions.get_mut(&key) else { + return DeliveryStageUpdate::MissingGeneration; + }; + // Duplicate or delayed acknowledgements cannot rewrite retained history + if next.rank() <= decision.delivery_stage.rank() { + return DeliveryStageUpdate::AlreadyAtOrBeyond; + } + decision.delivery_stage = next; + DeliveryStageUpdate::Advanced + } + + pub(super) fn prune_popup_decisions(&mut self) { + self.popup_decisions.retain(|key, _decision| { + self.active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation) + || self.history.contains_generation(*key) + }); + self.popup_timings.retain(|key, _timing| { + self.active + .get(&key.id) + .is_some_and(|notification| notification.generation == key.generation) + || self.history.contains_generation(*key) + }); + } + + fn view_with_popup_decision(&self, notification: &Notification) -> NotificationView { + let mut view = notification.to_view(); + if let Some(decision) = self.popup_decisions.get(¬ification.key()) { + view.popup_decision.clone_from(decision); + view.popup_hide_after_ms = decision.popup_hide_after_ms; + } + view + } + + fn list_view_with_popup_decision(&self, notification: &Notification) -> NotificationView { + let mut view = notification.to_list_view(); + if let Some(decision) = self.popup_decisions.get(¬ification.key()) { + view.popup_decision.clone_from(decision); + view.popup_hide_after_ms = decision.popup_hide_after_ms; + } + view + } + + pub(super) fn popup_deadline_is_current(&self, key: NotificationKey, now: Instant) -> bool { + self.popup_timings + .get(&key) + .is_some_and(|timing| timing.deadline.is_none_or(|deadline| now < deadline)) + } + + fn view_with_popup_timing( + &self, + notification: &Notification, + now: Instant, + ) -> NotificationView { + let mut view = self.view_with_popup_decision(notification); + view.popup_hide_after_ms = self.remaining_popup_ms(notification.key(), now); + view + } + + fn list_view_with_popup_timing( + &self, + notification: &Notification, + now: Instant, + ) -> NotificationView { + let mut view = self.list_view_with_popup_decision(notification); + view.popup_hide_after_ms = self.remaining_popup_ms(notification.key(), now); + view + } + + fn remaining_popup_ms(&self, key: NotificationKey, now: Instant) -> u64 { + let Some(timing) = self.popup_timings.get(&key) else { + return 0; + }; + let Some(deadline) = timing.deadline else { + return 0; + }; + let remaining = deadline.saturating_duration_since(now); + // Sub-millisecond positive durations must not become the renderer's no-timeout sentinel + u64::try_from(remaining.as_millis()) + .unwrap_or(u64::MAX) + .max(1) + } + + pub fn active_inline_reply_target( + &self, + id: u32, + generation: u64, + ) -> Option> { + let notification = self.active.get(&id)?; + // Both fields must agree so malformed internal data cannot widen reply access + let has_reply_action = notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + (notification.inline_reply.available + && notification.generation == generation + && notification.attribution.interactions.inline_reply + == unixnotis_core::InlineReplyPolicy::Allow + && notification.inline_reply_policy == unixnotis_core::InlineReplyPolicy::Allow + && has_reply_action) + .then(|| Arc::clone(notification)) + } + + pub fn active_action_target_generation( + &self, + key: unixnotis_core::NotificationKey, + action_key: &str, + confirmed: bool, + ) -> Option> { + let notification = self.active.get(&key.id)?; + if notification.generation != key.generation { + return None; + } + // "inline-reply" is a fake action key used only by the reply text method + // Block it here even though action_policy already rejects it — that way a caller + // that skips the policy check still cannot reach the reply action + if action_key == "inline-reply" { + return None; + } + // Confirmation is meaningful only for actions the resolver explicitly marked confirmable + let policy = notification.attribution.action_policy(action_key); + let authorized = match policy { + ApplicationActionPolicy::Allow => true, + ApplicationActionPolicy::Confirm => confirmed, + ApplicationActionPolicy::Deny => false, + }; + if !authorized { + return None; + } + // Exact matching prevents a trusted control caller from inventing application actions + notification + .actions + .iter() + .any(|action| action.key == action_key) + .then(|| Arc::clone(notification)) + } + + pub fn is_active_notification_generation(&self, id: u32, expected: &Arc) -> bool { + // Arc identity distinguishes a same-ID replacement from the row that was clicked + self.active + .get(&id) + .is_some_and(|active| Arc::ptr_eq(active, expected)) + } + + pub fn history_len(&self) -> usize { + // Exposed for diagnostics and test assertions + self.history.len() + } + + pub fn clear_history(&mut self) { + // Explicit history wipe used by CLI and control commands + self.history.clear(); + self.prune_popup_decisions(); + } +} + +fn unix_now_seconds() -> i64 { + // Chrono handles pre-epoch clocks without panicking + chrono::Utc::now().timestamp() +} diff --git a/crates/unixnotis-daemon/src/store/test_support.rs b/crates/unixnotis-daemon/src/store/test_support.rs new file mode 100644 index 000000000..fe888baf8 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/test_support.rs @@ -0,0 +1,119 @@ +//! Shared notification and persistence fixtures for store tests + +use std::collections::HashMap; + +use chrono::Utc; +use unixnotis_core::{Config, Notification, NotificationImage, Urgency}; +use zbus::zvariant::OwnedValue; + +use super::dnd::persistence::{PersistedDndState, DND_STATE_FILE}; +use super::dnd::DndStateStore; +use super::model::NotificationStore; + +impl NotificationStore { + pub(crate) fn new_with_state_dir(config: Config, state_dir: std::path::PathBuf) -> Self { + // Isolated persistence roots keep tests away from the live XDG state directory + let state_store = Some(DndStateStore::from_state_dir(state_dir)); + Self::new_with_state_store(config, state_store) + } +} + +pub(in crate::store) fn make_notification(summary: &str) -> Notification { + Notification { + id: 0, + generation: 0, + app_name: "TestApp".to_string(), + app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::verified( + "TestApp", + "TestApp", + "org.example.TestApp", + "", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "test:verified:test-app".to_string(), + ), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), + summary: summary.to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + hints: HashMap::::new(), + urgency: Urgency::Normal, + category: None, + is_transient: false, + is_resident: false, + suppress_popup: false, + suppress_sound: false, + image: NotificationImage::default(), + expire_timeout: 0, + received_at: Utc::now(), + sender_name: Some(":1.test".to_string()), + sender_pid: Some(1234), + sender_start_time: Some(555), + sender_executable: Some("/usr/bin/test-app".to_string()), + } +} + +pub fn make_notification_with_sender( + summary: &str, + sender: &str, + pid: u32, + start_time: u64, +) -> Notification { + let mut notification = make_notification(summary); + notification.sender_name = Some(sender.to_string()); + notification.sender_pid = Some(pid); + notification.sender_start_time = Some(start_time); + notification +} + +pub(in crate::store) fn make_store_with_limits( + max_active: usize, + max_entries: usize, +) -> NotificationStore { + let mut config = Config::default(); + // Test helper uses explicit limits so each case isolates one policy branch + config.history.max_active = max_active; + config.history.max_entries = max_entries; + NotificationStore::new(config) +} + +pub(in crate::store) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + path.push(format!("unixnotis-test-{label}-{pid}-{nanos}")); + std::fs::create_dir_all(&path).expect("create temp state dir"); + path +} + +pub(in crate::store) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { + let state = PersistedDndState { + version, + dnd_enabled: enabled, + expires_at: None, + updated_at: Some("2025-01-01T00:00:00Z".to_string()), + }; + let payload = serde_json::to_string(&state).expect("serialize state"); + let path = dir.join("unixnotis").join(DND_STATE_FILE); + std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); + std::fs::write(&path, payload).expect("write state"); +} + +pub(in crate::store) fn cleanup_temp_dir(dir: &std::path::Path) { + let _ = std::fs::remove_dir_all(dir); +} + +pub(in crate::store) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { + let write = store.set_dnd(enabled); + if let Some(state_store) = write.persist.as_ref() { + state_store + .persist(write.current, write.current_expires_at) + .expect("persist dnd state"); + } + write.changed +} diff --git a/crates/unixnotis-daemon/src/store/tests/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/lifecycle.rs deleted file mode 100644 index f507ae4d1..000000000 --- a/crates/unixnotis-daemon/src/store/tests/lifecycle.rs +++ /dev/null @@ -1,306 +0,0 @@ -use super::*; - -#[test] -fn max_active_zero_archives_immediately() { - let mut store = make_store_with_limits(0, 10); - - let outcome = store.insert(make_notification("first"), 0); - assert_eq!(outcome.evicted.len(), 1); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 1); - - store.insert(make_notification("second"), 0); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 2); -} - -#[test] -fn config_accessor_returns_runtime_config_snapshot() { - let mut config = Config::default(); - config.history.max_entries = 77; - config.history.max_active = 3; - let store = NotificationStore::new(config); - - assert_eq!(store.config().history.max_entries, 77); - assert_eq!(store.config().history.max_active, 3); -} - -#[test] -fn max_active_evicts_oldest_to_history() { - let mut store = make_store_with_limits(1, 10); - - store.insert(make_notification("first"), 0); - let outcome = store.insert(make_notification("second"), 0); - - assert_eq!(outcome.evicted.len(), 1); - let active = store.list_active(); - assert_eq!(active.len(), 1); - assert_eq!(active[0].summary, "second"); - assert_eq!(store.history_len(), 1); -} - -#[test] -fn max_active_hard_cap_limits_even_when_config_is_higher() { - // Config may request a larger active window, but runtime hard-cap protects UI stability - let mut store = make_store_with_limits(32, 64); - - for idx in 0..18 { - // Insert in-order so expected active/history boundaries are easy to assert - store.insert(make_notification(&format!("entry-{idx}")), 0); - } - - let active = store.list_active(); - let history = store.list_history(); - - assert_eq!(active.len(), 12); - assert_eq!(history.len(), 6); - // Newest remains at front after cap-based eviction - assert_eq!(active[0].summary, "entry-17"); - // Oldest retained active entry starts where cap boundary begins - assert_eq!(active[11].summary, "entry-6"); -} - -#[test] -fn max_entries_zero_drops_history_on_close() { - let mut store = make_store_with_limits(10, 0); - - let outcome = store.insert(make_notification("first"), 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 0); -} - -#[test] -fn max_entries_zero_keeps_active_notifications_when_active_limit_allows() { - let mut store = make_store_with_limits(2, 0); - - store.insert(make_notification("first"), 0); - let outcome = store.insert(make_notification("second"), 0); - - assert!(outcome.evicted.is_empty()); - assert_eq!(store.list_active().len(), 2); - assert_eq!(store.history_len(), 0); -} - -#[test] -fn history_eviction_keeps_most_recent_entries() { - let mut store = make_store_with_limits(0, 2); - - store.insert(make_notification("first"), 0); - store.insert(make_notification("second"), 0); - store.insert(make_notification("third"), 0); - - // History listing returns most-recent-first order - let history = store.list_history(); - assert_eq!(history.len(), 2); - assert_eq!(history[0].summary, "third"); - assert_eq!(history[1].summary, "second"); -} - -#[test] -fn history_reinsert_replaces_existing_order_entry() { - let mut store = make_store_with_limits(0, 10); - let first = store.insert(make_notification("first"), 0); - assert_eq!(store.history_len(), 1); - - let mut replacement = make_notification("replacement"); - replacement.id = first.notification.id; - store.history.insert(Arc::new(replacement)); - - // Replacing an archived id must not leave a stale duplicate in history order - let history = store.list_history(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].id, first.notification.id); - assert_eq!(history[0].summary, "replacement"); -} - -#[test] -fn max_entries_zero_drops_history_on_insert() { - let mut store = make_store_with_limits(0, 0); - - let outcome = store.insert(make_notification("first"), 0); - - // Eviction should archive the active entry, then drop it due to the zero history limit - assert_eq!(outcome.evicted.len(), 1); - assert!(store.list_active().is_empty()); - assert_eq!(store.history_len(), 0); -} - -#[test] -fn transient_close_skips_history_when_config_disables_it() { - let mut config = Config::default(); - // This case is the policy that the center must mirror exactly - config.history.transient_to_history = false; - let mut store = NotificationStore::new(config); - - let mut notification = make_notification("transient"); - notification.is_transient = true; - let outcome = store.insert(notification, 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 0); -} - -#[test] -fn transient_close_archives_when_config_allows_it() { - let mut config = Config::default(); - // Explicit opt-in should keep the closed row in history - config.history.transient_to_history = true; - let mut store = NotificationStore::new(config); - - let mut notification = make_notification("transient"); - notification.is_transient = true; - let outcome = store.insert(notification, 0); - store.close(outcome.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 1); -} - -#[test] -fn next_id_skips_used_ids_within_used_window() { - let mut store = make_store_with_limits(5, 5); - store.next_id = 1; - - let mut active = make_notification("active"); - active.id = 1; - store.active.insert(1, Arc::new(active)); - - let mut history = make_notification("history"); - history.id = 3; - store.history.insert(Arc::new(history)); - - let id = store.next_id(); - assert_eq!(id, 2); -} - -#[test] -fn next_id_skips_ids_that_exist_only_in_history() { - let mut store = make_store_with_limits(5, 5); - store.next_id = 7; - - let mut history = make_notification("history-only"); - history.id = 7; - store.history.insert(Arc::new(history)); - - // History IDs still belong to notification identity and must not be reused - assert_eq!(store.next_id(), 8); -} - -#[test] -fn next_id_wraps_internal_cursor_back_to_one_after_max_id() { - let mut store = make_store_with_limits(5, 5); - store.next_id = u32::MAX; - - assert_eq!(store.next_id(), u32::MAX); - // The stored cursor must not remain zero after wrapping past u32::MAX - assert_eq!(store.next_id, 1); -} - -#[test] -fn clear_history_removes_archived_notifications() { - let mut store = make_store_with_limits(10, 10); - let first = store.insert(make_notification("first"), 0); - store.close(first.notification.id, CloseReason::Expired); - - assert_eq!(store.history_len(), 1); - store.clear_history(); - assert_eq!(store.history_len(), 0); - assert!(store.list_history().is_empty()); -} - -#[test] -fn dismiss_outcome_reports_any_removed_side() { - assert!(crate::store::DismissOutcome { - removed_active: true, - removed_history: false, - } - .removed_any()); - assert!(crate::store::DismissOutcome { - removed_active: false, - removed_history: true, - } - .removed_any()); - assert!(!crate::store::DismissOutcome { - removed_active: false, - removed_history: false, - } - .removed_any()); -} - -#[test] -fn active_notification_view_returns_current_active_payload() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert(make_notification("visible"), 0); - - let view = store - .active_notification_view(outcome.notification.id) - .expect("active notification should be visible"); - - assert_eq!(view.id, outcome.notification.id); - assert_eq!(view.summary, "visible"); -} - -#[test] -fn drain_active_ids_returns_newest_first_and_clears_expirations() { - let mut store = make_store_with_limits(10, 10); - let first = store.insert(make_notification("first"), 0); - let second = store.insert(make_notification("second"), 0); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - store.set_expiration(first.notification.id, Some(deadline)); - - let ids = store.drain_active_ids(); - - assert_eq!(ids, vec![second.notification.id, first.notification.id]); - assert!(store.list_active().is_empty()); - assert_eq!(store.expiration_for(first.notification.id), None); -} - -#[test] -fn expiration_bookkeeping_sets_replaces_and_removes_deadlines() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert(make_notification("timer"), 0); - let first = std::time::Instant::now() + std::time::Duration::from_secs(1); - let second = std::time::Instant::now() + std::time::Duration::from_secs(2); - - store.set_expiration(outcome.notification.id, Some(first)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(first)); - - store.set_expiration(outcome.notification.id, Some(second)); - assert_eq!(store.expiration_for(outcome.notification.id), Some(second)); - - store.set_expiration(outcome.notification.id, None); - assert_eq!(store.expiration_for(outcome.notification.id), None); -} - -#[test] -fn insert_outcome_reflects_popup_and_sound_policy() { - let state_dir = make_temp_state_dir("insert-outcome-policy"); - let mut config = Config::default(); - config.general.dnd_default = false; - let mut store = NotificationStore::new_with_state_dir(config, state_dir.clone()); - let allowed = store.insert(make_notification("normal"), 0); - assert!(allowed.show_popup); - assert!(allowed.allow_sound); - - let dnd_state_dir = make_temp_state_dir("insert-outcome-dnd"); - let mut dnd_config = Config::default(); - dnd_config.general.dnd_default = true; - let mut dnd_store = NotificationStore::new_with_state_dir(dnd_config, dnd_state_dir.clone()); - let normal = dnd_store.insert(make_notification("normal dnd"), 0); - assert!(!normal.show_popup); - assert!(!normal.allow_sound); - - let mut critical = make_notification("critical dnd"); - critical.urgency = unixnotis_core::Urgency::Critical; - let critical = dnd_store.insert(critical, 0); - assert!(critical.show_popup); - assert!(critical.allow_sound); - - let mut silent = make_notification("silent"); - silent.suppress_sound = true; - let silent = store.insert(silent, 0); - assert!(!silent.allow_sound); - - cleanup_temp_dir(&state_dir); - cleanup_temp_dir(&dnd_state_dir); -} diff --git a/crates/unixnotis-daemon/src/store/tests/mod.rs b/crates/unixnotis-daemon/src/store/tests/mod.rs index 5209f7d9d..db5908f25 100644 --- a/crates/unixnotis-daemon/src/store/tests/mod.rs +++ b/crates/unixnotis-daemon/src/store/tests/mod.rs @@ -1,107 +1,3 @@ -//! Store regression coverage and persistence validation - -use super::rules::contains_ci; -use super::state::{PersistedDndState, DND_STATE_FILE, DND_STATE_VERSION}; -use super::NotificationStore; -use chrono::Utc; -use std::collections::HashMap; -use std::sync::Arc; -use unixnotis_core::{CloseReason, Config, InhibitMode, Notification, NotificationImage, Urgency}; -use zbus::zvariant::OwnedValue; - -impl NotificationStore { - pub(crate) fn new_with_state_dir(config: Config, state_dir: std::path::PathBuf) -> Self { - // Isolated persistence roots keep tests away from the live XDG state directory - let state_store = Some(super::DndStateStore::from_state_dir(state_dir)); - Self::new_with_state_store(config, state_store) - } -} - -mod dnd; -mod inhibit; -mod lifecycle; -mod ownership; -mod rules; - -pub(super) fn make_notification(summary: &str) -> Notification { - Notification { - id: 0, - app_name: "TestApp".to_string(), - app_icon: String::new(), - summary: summary.to_string(), - body: String::new(), - actions: Vec::new(), - hints: HashMap::::new(), - urgency: Urgency::Normal, - category: None, - is_transient: false, - is_resident: false, - suppress_popup: false, - suppress_sound: false, - image: NotificationImage::default(), - expire_timeout: 0, - received_at: Utc::now(), - sender_name: Some(":1.test".to_string()), - sender_pid: Some(1234), - sender_start_time: Some(555), - sender_executable: Some("/usr/bin/test-app".to_string()), - } -} - -pub(super) fn make_notification_with_sender( - summary: &str, - sender: &str, - pid: u32, - start_time: u64, -) -> Notification { - let mut notification = make_notification(summary); - notification.sender_name = Some(sender.to_string()); - notification.sender_pid = Some(pid); - notification.sender_start_time = Some(start_time); - notification -} - -pub(super) fn make_store_with_limits(max_active: usize, max_entries: usize) -> NotificationStore { - let mut config = Config::default(); - // Test helper uses explicit limits so each case isolates one policy branch - config.history.max_active = max_active; - config.history.max_entries = max_entries; - NotificationStore::new(config) -} - -pub(super) fn make_temp_state_dir(label: &str) -> std::path::PathBuf { - let mut path = std::env::temp_dir(); - let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .map_or(0, |duration| duration.as_nanos()); - path.push(format!("unixnotis-test-{label}-{pid}-{nanos}")); - std::fs::create_dir_all(&path).expect("create temp state dir"); - path -} - -pub(super) fn write_dnd_state(dir: &std::path::Path, enabled: bool, version: u32) { - let state = PersistedDndState { - version, - dnd_enabled: enabled, - updated_at: Some("2025-01-01T00:00:00Z".to_string()), - }; - let payload = serde_json::to_string(&state).expect("serialize state"); - let path = dir.join("unixnotis").join(DND_STATE_FILE); - std::fs::create_dir_all(path.parent().expect("state parent")).expect("create state directory"); - std::fs::write(&path, payload).expect("write state"); -} - -pub(super) fn cleanup_temp_dir(dir: &std::path::Path) { - let _ = std::fs::remove_dir_all(dir); -} - -pub(super) fn apply_dnd_update(store: &mut NotificationStore, enabled: bool) -> bool { - let write = store.set_dnd(enabled); - if let Some(state_store) = write.persist.as_ref() { - state_store - .persist(write.current) - .expect("persist dnd state"); - } - write.changed -} +mod model; +mod runtime; +mod support; diff --git a/crates/unixnotis-daemon/src/store/tests/model.rs b/crates/unixnotis-daemon/src/store/tests/model.rs new file mode 100644 index 000000000..286e255cd --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/model.rs @@ -0,0 +1,27 @@ +use crate::store::DismissOutcome; +use unixnotis_core::NotificationKey; + +#[test] +fn dismiss_outcome_reports_any_removed_side() { + assert!(DismissOutcome { + removed_active: Some(NotificationKey { + id: 1, + generation: 1, + }), + removed_history: None, + } + .removed_any()); + assert!(DismissOutcome { + removed_active: None, + removed_history: Some(NotificationKey { + id: 2, + generation: 2, + }), + } + .removed_any()); + assert!(!DismissOutcome { + removed_active: None, + removed_history: None, + } + .removed_any()); +} diff --git a/crates/unixnotis-daemon/src/store/tests/ownership.rs b/crates/unixnotis-daemon/src/store/tests/ownership.rs deleted file mode 100644 index bf84f7baa..000000000 --- a/crates/unixnotis-daemon/src/store/tests/ownership.rs +++ /dev/null @@ -1,163 +0,0 @@ -use super::*; - -#[test] -fn replace_id_in_history_reuses_id_and_clears_entry() { - let mut store = make_store_with_limits(2, 10); - - let first = store.insert(make_notification("first"), 0); - store.close(first.notification.id, CloseReason::Expired); - assert_eq!(store.history_len(), 1); - - // Replacement should reuse the original ID and remove the history entry - let replaced = store.insert(make_notification("replacement"), first.notification.id); - assert!(replaced.replaced); - assert_eq!(replaced.notification.id, first.notification.id); - assert_eq!(store.history_len(), 0); - - let active = store.list_active(); - assert_eq!(active.len(), 1); - assert_eq!(active[0].summary, "replacement"); - - // Closing the replacement should re-add a single history entry for the updated notification - store.close(replaced.notification.id, CloseReason::Expired); - let history = store.list_history(); - assert_eq!(history.len(), 1); - assert_eq!(history[0].summary, "replacement"); -} - -#[test] -fn replace_id_rejected_for_different_sender() { - let mut store = make_store_with_limits(2, 10); - - let first = store.insert( - make_notification_with_sender("first", ":1.sender-a", 101, 1), - 0, - ); - store.close(first.notification.id, CloseReason::Expired); - assert_eq!(store.history_len(), 1); - - // Cross-sender replacement must allocate a fresh id and keep prior history intact - let replaced = store.insert( - make_notification_with_sender("replacement", ":1.sender-b", 202, 2), - first.notification.id, - ); - assert!(!replaced.replaced); - assert_ne!(replaced.notification.id, first.notification.id); - assert_eq!(store.history_len(), 1); -} - -#[test] -fn inhibit_owner_mismatch_is_rejected() { - let mut store = make_store_with_limits(10, 10); - let id = store.add_inhibitor("owner-a".to_string(), "reason".to_string(), 0); - let err = store - .remove_inhibitor(id, "owner-b") - .expect_err("owner mismatch should error"); - assert!(err.message().contains("owner-a")); -} - -#[test] -fn is_notification_owned_by_matches_sender() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert( - make_notification_with_sender("owned", ":1.owner", 1234, 55), - 0, - ); - assert!(store.is_notification_owned_by( - outcome.notification.id, - ":1.owner", - Some(1234), - Some(55) - )); - assert!(!store.is_notification_owned_by( - outcome.notification.id, - ":1.other", - Some(5678), - Some(66) - )); -} - -#[test] -fn is_notification_owned_by_accepts_exact_sender_without_process_match() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert( - make_notification_with_sender("owned", ":1.owner", 1234, 55), - 0, - ); - - // Bus names are stronger than pid metadata, which may be absent or stale - assert!(store.is_notification_owned_by( - outcome.notification.id, - ":1.owner", - Some(5678), - Some(66) - )); -} - -#[test] -fn is_notification_owned_by_accepts_same_process_after_reconnect() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert( - make_notification_with_sender("owned", ":1.owner-a", 1234, 55), - 0, - ); - // A new bus name from the same process lifetime should still be treated as owner - assert!(store.is_notification_owned_by( - outcome.notification.id, - ":1.owner-b", - Some(1234), - Some(55) - )); -} - -#[test] -fn is_notification_owned_by_rejects_reused_pid_with_new_start_time() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert( - make_notification_with_sender("owned", ":1.owner-a", 1234, 55), - 0, - ); - // Same pid is not enough once the original process lifetime has ended - assert!(!store.is_notification_owned_by( - outcome.notification.id, - ":1.owner-b", - Some(1234), - Some(77) - )); -} - -#[test] -fn is_notification_owned_by_rejects_pid_match_without_start_time() { - let mut store = make_store_with_limits(10, 10); - let outcome = store.insert( - make_notification_with_sender("owned", ":1.owner-a", 1234, 55), - 0, - ); - - // Pid reuse is common enough that start time must be part of process ownership - assert!(!store.is_notification_owned_by( - outcome.notification.id, - ":1.owner-b", - Some(1234), - None - )); -} - -#[test] -fn replacement_allows_same_process_after_bus_reconnect() { - let mut store = make_store_with_limits(2, 10); - - let first = store.insert( - make_notification_with_sender("first", ":1.owner-a", 1234, 55), - 0, - ); - - let replacement = store.insert( - make_notification_with_sender("replacement", ":1.owner-b", 1234, 55), - first.notification.id, - ); - - // Same process lifetime can replace after the bus name changes - assert!(replacement.replaced); - assert_eq!(replacement.notification.id, first.notification.id); -} diff --git a/crates/unixnotis-daemon/src/store/tests/rules.rs b/crates/unixnotis-daemon/src/store/tests/rules.rs deleted file mode 100644 index 215c09e36..000000000 --- a/crates/unixnotis-daemon/src/store/tests/rules.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; - -#[test] -fn contains_ci_matches_ascii() { - assert!(contains_ci("Signal-Desktop", "signal")); - assert!(contains_ci("signal-desktop", "Signal")); - assert!(!contains_ci("signal-desktop", "brave")); - assert!(contains_ci("mixedCase", "case")); - assert!(contains_ci("mixedCase", "")); - assert!(contains_ci("same", "same")); - assert!(!contains_ci("short", "longer")); -} - -#[test] -fn rules_require_all_filters_and_apply_every_mutation() { - let config = Config { - rules: vec![unixnotis_core::RuleConfig { - name: Some("test-rule".to_string()), - app: Some("test".to_string()), - summary: Some("hello".to_string()), - body: Some("body".to_string()), - category: Some("chat".to_string()), - urgency: Some(unixnotis_core::RuleUrgency::Normal), - no_popup: Some(true), - silent: Some(true), - force_urgency: Some(unixnotis_core::RuleUrgency::Critical), - expire_timeout_ms: Some(1234), - resident: Some(true), - transient: Some(true), - }], - ..Config::default() - }; - let store = NotificationStore::new(config); - let mut notification = make_notification("hello summary"); - notification.body = "body text".to_string(); - notification.category = Some("chat.message".to_string()); - notification.urgency = unixnotis_core::Urgency::Normal; - - store.apply_rules(&mut notification); - - assert!(notification.suppress_popup); - assert!(notification.suppress_sound); - assert_eq!(notification.urgency, unixnotis_core::Urgency::Critical); - assert_eq!(notification.expire_timeout, 1234); - assert!(notification.is_resident); - assert!(notification.is_transient); -} - -#[test] -fn rules_do_not_match_missing_category_or_wrong_urgency() { - let config = Config { - rules: vec![unixnotis_core::RuleConfig { - category: Some("chat".to_string()), - urgency: Some(unixnotis_core::RuleUrgency::Critical), - no_popup: Some(true), - ..unixnotis_core::RuleConfig::default() - }], - ..Config::default() - }; - let store = NotificationStore::new(config); - let mut notification = make_notification("hello"); - notification.urgency = unixnotis_core::Urgency::Normal; - - store.apply_rules(&mut notification); - assert!(!notification.suppress_popup); - - notification.category = Some("chat".to_string()); - store.apply_rules(&mut notification); - assert!(!notification.suppress_popup); -} - -#[test] -fn rules_do_not_match_wrong_category_even_when_urgency_matches() { - let config = Config { - rules: vec![unixnotis_core::RuleConfig { - category: Some("chat".to_string()), - urgency: Some(unixnotis_core::RuleUrgency::Critical), - no_popup: Some(true), - ..unixnotis_core::RuleConfig::default() - }], - ..Config::default() - }; - let store = NotificationStore::new(config); - let mut notification = make_notification("hello"); - notification.category = Some("email".to_string()); - notification.urgency = unixnotis_core::Urgency::Critical; - - store.apply_rules(&mut notification); - - assert!(!notification.suppress_popup); -} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs new file mode 100644 index 000000000..5de57ddd8 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/action_target.rs @@ -0,0 +1,255 @@ +use std::sync::Arc; + +use unixnotis_core::{ + Action, AttributionReason, IdentityAssurance, InteractionPolicies, NotificationAttribution, +}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn active_action_target_requires_an_exact_action_on_the_live_generation() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("action"); + notification.attribution = NotificationAttribution::verified( + "Action source", + "Action source", + "org.example.ActionSource", + "", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.ActionSource".to_string(), + ); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + let original = store.insert(notification, 0).active_notification(); + let id = original.id; + let key = original.key(); + + let target = store + .active_action_target_generation(key, "open", false) + .expect("stored action should resolve"); + assert!(Arc::ptr_eq(&target, &original)); + assert!(store + .active_action_target_generation(key, "missing", false) + .is_none()); + assert!(store.is_active_notification_generation(id, &original)); + + let replacement = store.insert(make_notification("replacement"), id); + assert!(replacement.replaced); + assert!(!store.is_active_notification_generation(id, &original)); + assert!(store + .active_action_target_generation(key, "open", false) + .is_none()); +} + +#[test] +fn active_action_target_denies_every_unverified_sender_class() { + for attribution in [ + NotificationAttribution::recognized( + "User application", + "User application", + "org.example.UserApplication", + "", + AttributionReason::ExactUserExecutable, + "exact user executable", + "user-app:org.example.UserApplication".to_string(), + ), + NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::NoDesktopCandidate, + "source /tmp/fake", + "unknown:example-chat".to_string(), + ), + NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", + "conflict:example-chat".to_string(), + ), + NotificationAttribution::relay( + "Example Chat", + "trusted relay /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ), + ] { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("untrusted action"); + notification.attribution = attribution; + notification.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + let key = store.insert(notification, 0).active_notification().key(); + + assert!( + store + .active_action_target_generation(key, "default", true) + .is_none(), + "weak attribution should not expose application actions" + ); + } +} + +#[test] +fn owner_bound_unresolved_sender_allows_only_the_advertised_default_action() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("owner-bound default"); + notification.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + notification.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + notification.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "delete".to_string(), + label: "Delete".to_string(), + }, + ]; + let key = store.insert(notification, 0).active_notification().key(); + + assert!(store + .active_action_target_generation(key, "default", false) + .is_some()); + assert!(store + .active_action_target_generation(key, "made-up-action", false) + .is_none()); + assert!(store + .active_action_target_generation(key, "delete", true) + .is_none()); +} + +#[test] +fn native_association_allows_default_but_requires_confirmation_for_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + ]; + let key = store.insert(notification, 0).active_notification().key(); + + assert!( + store + .active_action_target_generation(key, "default", false) + .is_some(), + "native default activation should retain compatibility" + ); + assert!( + store + .active_action_target_generation(key, "archive", false) + .is_none(), + "additional native action must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, "archive", true) + .is_some(), + "additional native action should accept explicit trusted-UI confirmation" + ); +} + +#[test] +fn portal_association_requires_confirmation_for_default_and_buttons() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("portal associated actions"); + notification.attribution = NotificationAttribution::associated( + "Example Portal App", + "Example Portal App", + "org.example.PortalApp", + "org.example.PortalApp", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal app id without confinement provenance", + "associated:portal-app:org.example.PortalApp".to_string(), + ); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: String::new(), + }, + Action { + key: "open".to_string(), + label: "Open".to_string(), + }, + ]; + let key = store.insert(notification, 0).active_notification().key(); + + for action_key in ["default", "open"] { + assert!( + store + .active_action_target_generation(key, action_key, false) + .is_none(), + "portal action {action_key:?} must reject an unconfirmed request" + ); + assert!( + store + .active_action_target_generation(key, action_key, true) + .is_some(), + "portal action {action_key:?} should accept trusted-UI confirmation" + ); + } +} + +#[test] +fn active_action_target_rejects_inline_reply_even_when_confirmed() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("inline-reply action target"); + notification.attribution = NotificationAttribution::verified( + "Verified source", + "Verified source", + "org.example.Verified", + "", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.Verified".to_string(), + ); + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + let key = store.insert(notification, 0).active_notification().key(); + + assert!( + store + .active_action_target_generation(key, "inline-reply", true) + .is_none(), + "inline-reply must be rejected through action dispatch even with confirmed=true" + ); + assert!( + store + .active_action_target_generation(key, "open", false) + .is_some(), + "unrelated actions must still resolve normally" + ); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/config.rs b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs new file mode 100644 index 000000000..409390652 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/config.rs @@ -0,0 +1,28 @@ +use unixnotis_core::Config; + +use crate::store::test_support::{make_notification, make_store_with_limits}; +use crate::store::NotificationStore; + +#[test] +fn config_accessor_returns_runtime_config_snapshot() { + let mut config = Config::default(); + config.history.max_entries = 77; + config.history.max_active = 3; + let store = NotificationStore::new(config); + + assert_eq!(store.config.history.max_entries, 77); + assert_eq!(store.config.history.max_active, 3); +} + +#[test] +fn active_notification_view_returns_current_active_payload() { + let mut store = make_store_with_limits(10, 10); + let outcome = store.insert(make_notification("visible"), 0); + + let view = store + .active_notification_view(outcome.active_notification().id) + .expect("active notification should be visible"); + + assert_eq!(view.id, outcome.active_notification().id); + assert_eq!(view.summary, "visible"); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs new file mode 100644 index 000000000..9c437960a --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/inline_reply.rs @@ -0,0 +1,132 @@ +use unixnotis_core::{ + Action, AttributionReason, CloseReason, IdentityAssurance, InlineReply, InlineReplyPolicy, + InteractionPolicies, NotificationAttribution, +}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn active_inline_reply_target_requires_a_live_explicit_reply_action() { + let mut store = make_store_with_limits(12, 20); + let ordinary = store + .insert(make_notification("ordinary"), 0) + .active_notification(); + let mut reply = make_notification("reply"); + reply.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let reply = store.insert(reply, 0).active_notification(); + + assert!(store + .active_inline_reply_target(ordinary.id, ordinary.generation) + .is_none()); + let target = store + .active_inline_reply_target(reply.id, reply.generation) + .expect("reply target"); + assert_eq!(target.id, reply.id); + assert!(!target.is_resident); + assert!(store + .active_inline_reply_target(reply.id, reply.generation.saturating_sub(1)) + .is_none()); +} + +#[test] +fn inline_reply_target_reports_resident_state_and_rejects_history_entries() { + let mut store = make_store_with_limits(12, 20); + let mut reply = make_notification("resident reply"); + reply.inline_reply.available = true; + reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + reply.is_resident = true; + let reply = store.insert(reply, 0).active_notification(); + + assert!( + store + .active_inline_reply_target(reply.id, reply.generation) + .expect("resident reply target") + .is_resident + ); + + let key = reply.key(); + assert!( + store + .active_action_target_generation(key, "inline-reply", true) + .is_none(), + "inline-reply action must be rejected through action dispatch even when confirmed" + ); + + store.close(reply.id, CloseReason::Expired); + + assert!(store + .active_inline_reply_target(reply.id, reply.generation) + .is_none()); + assert!(store.list_history().iter().any(|view| view.id == reply.id)); +} + +#[test] +fn inline_reply_metadata_without_the_protocol_action_is_rejected() { + let mut store = make_store_with_limits(12, 20); + let mut malformed = make_notification("metadata only"); + malformed.inline_reply.available = true; + let malformed = store.insert(malformed, 0).active_notification(); + + assert!(store + .active_inline_reply_target(malformed.id, malformed.generation) + .is_none()); +} + +#[test] +fn inline_reply_policy_denies_a_complete_reply_action() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("unassociated reply"); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Deny; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let notification = store.insert(notification, 0).active_notification(); + + assert!(store + .active_inline_reply_target(notification.id, notification.generation) + .is_none()); +} + +#[test] +fn native_association_denies_reply_even_if_protocol_metadata_claims_allow() { + let mut store = make_store_with_limits(12, 20); + let mut notification = make_notification("native associated reply"); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.inline_reply.available = true; + notification.inline_reply_policy = InlineReplyPolicy::Allow; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let notification = store.insert(notification, 0).active_notification(); + + assert!( + store + .active_inline_reply_target(notification.id, notification.generation) + .is_none(), + "native executable association cannot authorize credential-like reply input" + ); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs new file mode 100644 index 000000000..aaa2eb7c5 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/lifecycle.rs @@ -0,0 +1,30 @@ +use unixnotis_core::{CloseReason, NotificationKey}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; + +#[test] +fn clear_all_removes_active_history_and_expiration_state_together() { + let mut store = make_store_with_limits(10, 10); + let active = store + .insert(make_notification("active"), 0) + .active_notification(); + let archived = store + .insert(make_notification("archived"), 0) + .active_notification(); + store.close(archived.id, CloseReason::Expired); + store.set_expiration(&active, Some(std::time::Instant::now())); + + let removed = store.clear_all(); + + assert_eq!( + removed, + vec![NotificationKey { + id: active.id, + generation: active.generation, + }] + ); + assert!(store.list_active().is_empty()); + assert!(store.list_history().is_empty()); + assert!(store.expirations.is_empty()); + assert!(store.popup_decisions.is_empty()); +} diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs new file mode 100644 index 000000000..08f5f9fcc --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/mod.rs @@ -0,0 +1,5 @@ +mod action_target; +mod config; +mod inline_reply; +mod lifecycle; +mod popup; diff --git a/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs new file mode 100644 index 000000000..e8f712a74 --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/runtime/popup.rs @@ -0,0 +1,608 @@ +use std::time::{Duration, Instant}; + +use unixnotis_core::{CloseReason, Config, PopupAdmissionView}; + +use crate::store::test_support::{make_notification, make_store_with_limits}; +use crate::store::NotificationStore; + +#[test] +fn popup_candidate_pairs_rule_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("allowed"), 0) + .active_notification(); + let mut suppressed = make_notification("rule suppressed"); + suppressed.suppress_popup = true; + let replacement = store.insert(suppressed, original.id).active_notification(); + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain an active popup candidate"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "rule suppressed"); + assert_eq!(candidate.admission, PopupAdmissionView::Rule); +} + +#[test] +fn popup_candidate_pairs_dnd_suppression_with_replacement_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("allowed"), 0) + .active_notification(); + store.set_dnd(true); + let replacement = store + .insert(make_notification("dnd suppressed"), original.id) + .active_notification(); + + let candidate = store + .popup_candidate(original.id) + .expect("replacement should remain active during DND"); + + assert_eq!(candidate.notification.generation, replacement.generation); + assert_eq!(candidate.notification.summary, "dnd suppressed"); + assert_eq!(candidate.admission, PopupAdmissionView::Dnd); +} + +#[test] +fn notification_diagnostics_preserve_arrival_state_after_runtime_state_changes() { + let mut store = make_store_with_limits(10, 10); + let visible = store + .insert(make_notification("visible"), 0) + .active_notification(); + let unavailable = store + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) + .expect("active notification diagnostics"); + + assert_eq!( + unavailable.popup_admission, + PopupAdmissionView::RendererUnavailable + ); + assert!(!unavailable.renderer_process_running); + assert!(!unavailable.renderer_ready); + + store.set_dnd(true); + let dnd_suppressed = store + .insert(make_notification("DND suppressed"), 0) + .active_notification(); + store.set_dnd(false); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + let suppressed = store + .notification_diagnostics(dnd_suppressed.id, &ready) + .expect("DND diagnostics"); + + assert_eq!(suppressed.popup_admission, PopupAdmissionView::Dnd); + assert!(!suppressed.renderer_process_running); + assert!(!suppressed.renderer_ready); +} + +#[test] +fn notification_diagnostics_require_both_renderer_process_and_readiness() { + let mut store = make_store_with_limits(10, 10); + for (process_running, ready, expected) in [ + (false, false, PopupAdmissionView::RendererUnavailable), + (true, false, PopupAdmissionView::RendererUnavailable), + (false, true, PopupAdmissionView::RendererUnavailable), + (true, true, PopupAdmissionView::Show), + ] { + let health = unixnotis_core::UiHealth { + popups_process_running: process_running, + popups_ready: ready, + ..unixnotis_core::UiHealth::default() + }; + let visible = store + .insert(make_notification("visible"), 0) + .active_notification(); + store.record_popup_commit_environment( + visible.key(), + crate::store::PopupAdmission::Show, + &health, + 0, + ); + let diagnostics = store + .notification_diagnostics(visible.id, &unixnotis_core::UiHealth::default()) + .expect("active notification diagnostics"); + + assert_eq!( + diagnostics.popup_admission, expected, + "process_running={process_running}, ready={ready}" + ); + } +} + +#[test] +fn popup_diagnostics_keep_the_readiness_revision_sampled_at_commit() { + let mut store = make_store_with_limits(10, 10); + let health = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + revision: 17, + ..unixnotis_core::UiHealth::default() + }; + let notification = store + .insert_with_ui_health(make_notification("revision"), 0, &health) + .active_notification(); + + let diagnostics = store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("notification diagnostics"); + + assert_eq!(diagnostics.renderer_health_revision, 17); +} + +#[test] +fn disabled_popups_are_recorded_when_max_visible_is_zero() { + let mut config = Config::default(); + config.popups.max_visible = 0; + let mut store = NotificationStore::new(config); + let notification = store + .insert(make_notification("disabled"), 0) + .active_notification(); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + 0, + ); + + let diagnostics = store + .notification_diagnostics(notification.id, &ready) + .expect("disabled popup diagnostics"); + + assert_eq!( + diagnostics.popup_admission, + PopupAdmissionView::RendererDisabled + ); + assert_eq!(diagnostics.configured_max_visible, 0); +} + +#[test] +fn archived_notification_keeps_its_arrival_popup_explanation() { + let mut store = make_store_with_limits(10, 10); + store.set_dnd(true); + let notification = store + .insert(make_notification("archived DND"), 0) + .active_notification(); + store.close(notification.id, CloseReason::Expired); + store.set_dnd(false); + + let diagnostics = store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("history diagnostics should remain available"); + + assert_eq!(diagnostics.generation, notification.generation); + assert_eq!(diagnostics.popup_admission, PopupAdmissionView::Dnd); +} + +#[test] +fn popup_delivery_stage_advances_for_fetch_and_render_acknowledgement() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + store.record_popup_commit_environment( + notification.key(), + crate::store::PopupAdmission::Show, + &ready, + 0, + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("admitted popup candidate"); + assert_eq!(candidate.admission, PopupAdmissionView::Show); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("fetched diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::RendererFetched + ); + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store + .notification_diagnostics(notification.id, &ready) + .expect("rendered diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible + ); +} + +#[test] +fn visible_popup_candidate_cannot_be_fetched_again_after_reconnect() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("visible once"), 0) + .active_notification(); + + assert!(store.popup_candidate(notification.id).is_some()); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert!( + store.popup_candidate(notification.id).is_none(), + "visible generations stay active for panel actions but cannot re-enter popups" + ); +} + +#[test] +fn delivery_stage_never_moves_backward() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::RendererFetched, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond + ); + + assert_eq!( + store + .notification_diagnostics(notification.id, &unixnotis_core::UiHealth::default()) + .expect("delivery diagnostics") + .delivery_stage, + unixnotis_core::PopupDeliveryStage::Visible, + "later duplicate fetches must not regress delivery history" + ); +} + +#[test] +fn duplicate_popup_stage_acknowledgement_is_idempotent() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("delivery"), 0) + .active_notification(); + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::AlreadyAtOrBeyond, + "a retained generation must accept a duplicate renderer callback" + ); +} + +#[test] +fn popup_stage_acknowledgement_rejects_a_missing_generation() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let _replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + + assert_eq!( + store.record_popup_delivery_stage( + original.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::MissingGeneration, + "a stale generation must remain distinct from an idempotent current callback" + ); +} + +#[test] +fn popup_candidate_list_requires_policy_and_arrival_decision_to_allow_rendering() { + let mut store = make_store_with_limits(10, 10); + let ready = unixnotis_core::UiHealth { + popups_process_running: true, + popups_ready: true, + ..unixnotis_core::UiHealth::default() + }; + + let mut rule_suppressed = make_notification("persistent suppression"); + rule_suppressed.suppress_popup = true; + let rule_suppressed = store.insert(rule_suppressed, 0).active_notification(); + store.record_popup_commit_environment( + rule_suppressed.key(), + crate::store::PopupAdmission::Show, + &ready, + 0, + ); + + let arrival_suppressed = store + .insert(make_notification("arrival suppression"), 0) + .active_notification(); + store.record_popup_commit_environment( + arrival_suppressed.key(), + crate::store::PopupAdmission::Suppressed(crate::store::PopupSuppressionReason::Rule), + &ready, + 0, + ); + + let admitted = store + .insert(make_notification("admitted"), 0) + .active_notification(); + store.record_popup_commit_environment( + admitted.key(), + crate::store::PopupAdmission::Show, + &ready, + 0, + ); + + let candidates = store.list_popup_candidates(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].key(), admitted.key()); +} + +#[test] +fn visible_popup_generations_are_not_seeded_after_renderer_reconnect() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("already visible"), 0) + .active_notification(); + + assert_eq!(store.list_popup_candidates().len(), 1); + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Visible, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + + // The active panel row remains available, but a restarted popup renderer + // must not receive a generation that already reached the visible stage + assert_eq!(store.list_popup_candidates().len(), 0); + assert_eq!(store.list_active().len(), 1); +} + +#[test] +fn materialized_but_not_visible_popup_remains_eligible_for_reconnect_seed() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("overflow"), 0) + .active_notification(); + + assert_eq!( + store.record_popup_delivery_stage( + notification.key(), + unixnotis_core::PopupDeliveryStage::Materialized, + ), + crate::store::DeliveryStageUpdate::Advanced + ); + assert_eq!(store.list_popup_candidates().len(), 1); +} + +#[test] +fn popup_renderer_outage_does_not_restart_an_expired_admission_deadline() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("renderer unavailable"), 0) + .active_notification(); + let unavailable = unixnotis_core::UiHealth::default(); + let admitted_at = Instant::now() + .checked_sub(Duration::from_millis(51)) + .expect("test admission instant should be representable"); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unavailable, + 50, + admitted_at, + ); + + assert!( + store.list_popup_candidates().is_empty(), + "an expired popup must not be seeded after renderer recovery" + ); + assert!( + store.popup_candidate(notification.id).is_none(), + "an expired popup must not receive a fresh timeout" + ); + assert_eq!( + store.list_active().len(), + 1, + "popup expiration must not destroy the active notification" + ); +} + +#[test] +fn popup_materialization_returns_only_the_remaining_admission_time() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("remaining deadline"), 0) + .active_notification(); + let admitted_at = Instant::now() + .checked_sub(Duration::from_millis(25)) + .expect("test admission instant should be representable"); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + 100, + admitted_at, + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("unexpired popup candidate"); + assert!( + (1..=75).contains(&candidate.notification.popup_hide_after_ms), + "materialization must return the remaining timeout" + ); +} + +#[test] +fn popup_deadline_is_expired_at_the_exact_admission_boundary() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("exact deadline"), 0) + .active_notification(); + let deadline = Instant::now(); + store.popup_timings.insert( + notification.key(), + crate::store::model::PopupTiming { + deadline: Some(deadline), + }, + ); + + assert!(!store.popup_deadline_is_current(notification.key(), deadline)); +} + +#[test] +fn popup_materialization_keeps_zero_as_the_no_automatic_hide_value() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("no automatic hide"), 0) + .active_notification(); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + 0, + Instant::now(), + ); + + let candidate = store + .popup_candidate(notification.id) + .expect("indefinite popup should remain eligible"); + assert_eq!(candidate.notification.popup_hide_after_ms, 0); +} + +#[test] +fn popup_deadline_overflow_becomes_indefinite_instead_of_immediate() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("overflow-safe deadline"), 0) + .active_notification(); + store.record_popup_commit_environment_at( + notification.key(), + crate::store::PopupAdmission::Show, + &unixnotis_core::UiHealth::default(), + u64::MAX, + Instant::now(), + ); + + assert!( + store.popup_deadline_is_current(notification.key(), Instant::now()), + "timer overflow must not expire a popup at admission" + ); + let timing = store + .popup_timings + .get(¬ification.key()) + .expect("popup timing"); + assert!( + timing + .deadline + .is_none_or(|deadline| deadline > Instant::now()), + "a representable large timer must remain in the future" + ); +} + +#[test] +fn popup_decisions_are_pruned_after_their_active_and_history_generations_are_removed() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("retained"), 0) + .active_notification(); + + assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); + store.close(notification.id, CloseReason::Expired); + assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); + + store.clear_history(); + assert!(store.popup_decisions.is_empty()); + assert!(store.popup_timings.is_empty()); +} + +#[test] +fn action_dismissal_prunes_the_removed_generation_popup_decision() { + let mut store = make_store_with_limits(10, 10); + let notification = store + .insert(make_notification("actioned"), 0) + .active_notification(); + + assert!(store.popup_decisions.contains_key(¬ification.key())); + assert!(store.popup_timings.contains_key(¬ification.key())); + assert!(store.dismiss_active_if_current(notification.id, ¬ification)); + assert!(store.popup_decisions.is_empty()); + assert!(store.popup_timings.is_empty()); +} + +#[test] +fn popup_pruning_removes_a_stale_timing_for_a_same_id_replacement() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + store.popup_timings.insert( + original.key(), + crate::store::model::PopupTiming { deadline: None }, + ); + + store.prune_popup_decisions(); + + assert!(!store.popup_timings.contains_key(&original.key())); + assert!(store.popup_timings.contains_key(&replacement.key())); +} + +#[test] +fn popup_replacement_discards_the_prior_generation_timing_at_commit() { + let mut store = make_store_with_limits(10, 10); + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + + let retained_for_id = store + .popup_timings + .keys() + .filter(|key| key.id == original.id) + .copied() + .collect::>(); + assert_eq!(retained_for_id, [replacement.key()]); + assert!(!store.popup_timings.contains_key(&original.key())); +} diff --git a/crates/unixnotis-daemon/src/store/tests/support.rs b/crates/unixnotis-daemon/src/store/tests/support.rs new file mode 100644 index 000000000..07f1365be --- /dev/null +++ b/crates/unixnotis-daemon/src/store/tests/support.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use std::time::Instant; + +use unixnotis_core::{Notification, NotificationKey, UiHealth}; + +use crate::store::{CommitDisposition, InsertOutcome, NotificationStore, PopupAdmission}; + +impl NotificationStore { + pub(crate) fn insert(&mut self, notification: Notification, replaces_id: u32) -> InsertOutcome { + // Store tests use a neutral renderer snapshot unless a case provides one explicitly + self.insert_with_ui_health(notification, replaces_id, &UiHealth::default()) + } + + pub(crate) fn record_popup_commit_environment( + &mut self, + key: NotificationKey, + admission: PopupAdmission, + ui_health: &UiHealth, + popup_hide_after_ms: u64, + ) { + self.record_popup_commit_environment_at( + key, + admission, + ui_health, + popup_hide_after_ms, + Instant::now(), + ); + } +} + +impl InsertOutcome { + pub(crate) fn active_notification(&self) -> Arc { + match &self.disposition { + CommitDisposition::Active(notification) => Arc::clone(notification), + CommitDisposition::SuppressedDropAll(_) => { + panic!("active insertion outcome must retain its notification") + } + } + } +} diff --git a/crates/unixnotis-daemon/src/store/types.rs b/crates/unixnotis-daemon/src/store/types.rs deleted file mode 100644 index af02166fd..000000000 --- a/crates/unixnotis-daemon/src/store/types.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; - -use indexmap::IndexMap; -use unixnotis_core::{Config, Notification}; - -use super::{DndStateStore, HistoryStore, Inhibitor}; - -/// Mutable notification state owned by the daemon -pub struct NotificationStore { - // Immutable runtime config snapshot - pub(super) config: Config, - // Next candidate id for allocation - pub(super) next_id: u32, - // Active notifications in insertion order - pub(super) active: IndexMap>, - // Archived notifications with bounded retention - pub(super) history: HistoryStore, - // Optional expiration deadline per active id - pub(super) expirations: HashMap, - // Effective DND switch after loading persisted state - pub(super) dnd_enabled: bool, - // Monotonic in-memory revision for DND writes - pub(super) dnd_revision: u64, - // Optional persistence layer for DND; absent store keeps behavior in-memory - pub(super) dnd_state_store: Option, - // Token counter for inhibitors; never reused in a process - pub(super) next_inhibitor_id: u64, - // Active inhibitors keyed by token for quick lookup/removal - pub(super) inhibitors: HashMap, - // Cached flags avoid rescanning inhibitors on every notification - pub(super) inhibited: bool, - pub(super) inhibitor_count: u32, -} - -pub struct InsertOutcome { - // Stored notification instance returned to callers - pub notification: Arc, - // True when insertion replaced an existing id - pub replaced: bool, - // Whether popup rendering is allowed for this payload - pub show_popup: bool, - // Whether sound playback is allowed for this payload - pub allow_sound: bool, - // Active ids evicted because max_active was exceeded - pub evicted: Vec, - // True when payload was intentionally dropped by inhibit mode - pub dropped: bool, -} - -pub struct DndWrite { - // True when the in-memory DND value changed - pub(crate) changed: bool, - // Value seen before this write - pub(crate) previous: bool, - // Value written by this operation - pub(crate) current: bool, - // Monotonic revision captured for guarded rollback - pub(crate) revision: u64, - // Persistence backend used outside the store lock - pub(crate) persist: Option, -} - -pub struct DismissOutcome { - // True when an active entry was removed - pub removed_active: bool, - // True when a history entry was removed - pub removed_history: bool, -} - -impl DismissOutcome { - pub const fn removed_any(&self) -> bool { - // Convenience helper for callers that only need yes/no - self.removed_active || self.removed_history - } -} diff --git a/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs b/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs new file mode 100644 index 000000000..6a362e4a8 --- /dev/null +++ b/crates/unixnotis-daemon/src/system_tools/tests/lookup.rs @@ -0,0 +1,8 @@ +use super::super::lookup::trusted_program_path; + +#[test] +fn production_lookup_rejects_empty_and_path_shaped_programs_before_scanning() { + assert!(trusted_program_path("").is_none()); + assert!(trusted_program_path("relative/tool").is_none()); + assert!(trusted_program_path("/absolute/tool").is_none()); +} diff --git a/crates/unixnotis-daemon/src/system_tools/tests/mod.rs b/crates/unixnotis-daemon/src/system_tools/tests/mod.rs index 264b05ea0..beec7dcd4 100644 --- a/crates/unixnotis-daemon/src/system_tools/tests/mod.rs +++ b/crates/unixnotis-daemon/src/system_tools/tests/mod.rs @@ -1 +1,2 @@ mod command; +mod lookup; diff --git a/crates/unixnotis-daemon/src/system_tools/tests/routing.rs b/crates/unixnotis-daemon/src/system_tools/tests/routing.rs index 253172b37..83236d7ca 100644 --- a/crates/unixnotis-daemon/src/system_tools/tests/routing.rs +++ b/crates/unixnotis-daemon/src/system_tools/tests/routing.rs @@ -5,10 +5,8 @@ pub(super) fn trusted_program_path(program: &str) -> Option { if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { return None; } - if fake_tool_bin_is_set() { - return fake_program_path(program); - } - super::lookup::trusted_program_path(program) + // Unit tests never resolve or launch tools installed on the host + fake_program_path(program) } fn executable_file(path: &Path) -> bool { @@ -30,13 +28,6 @@ fn executable_mode(_metadata: &std::fs::Metadata) -> bool { true } -fn fake_tool_bin_is_set() -> bool { - fake_tool_bin() - .lock() - .expect("fake tool bin lock") - .is_some() -} - fn fake_program_path(program: &str) -> Option { let fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); let candidate = fake_bin.as_ref()?.join(program); @@ -60,6 +51,7 @@ pub fn use_fake_tool_bin(path: &Path) -> FakeToolBinGuard { .expect("fake tool bin test lock"); let mut fake_bin = fake_tool_bin().lock().expect("fake tool bin lock"); let previous = fake_bin.replace(path.to_path_buf()); + drop(fake_bin); FakeToolBinGuard { _lock: lock, previous, @@ -72,6 +64,7 @@ fn fake_tool_bin() -> &'static Mutex> { } fn fake_tool_bin_test_lock() -> &'static Mutex<()> { + // Async command tests may resume on another worker, so fixtures are process-global and serial static FAKE_TOOL_BIN_TEST_LOCK: OnceLock> = OnceLock::new(); FAKE_TOOL_BIN_TEST_LOCK.get_or_init(|| Mutex::new(())) } diff --git a/crates/unixnotis-daemon/src/tests/cli.rs b/crates/unixnotis-daemon/src/tests/cli.rs index 3f1436960..f914f66f7 100644 --- a/crates/unixnotis-daemon/src/tests/cli.rs +++ b/crates/unixnotis-daemon/src/tests/cli.rs @@ -1,4 +1,4 @@ -use clap::Parser; +use clap::{CommandFactory, Parser}; use super::{Args, RestoreStrategy}; @@ -33,3 +33,12 @@ fn args_parse_trial_restore_process_and_run_seconds() { assert_eq!(args.restore_wait_ms, 125); assert_eq!(args.run_seconds, Some(9)); } + +#[test] +fn daemon_help_lists_the_supported_entrypoint_flags() { + let help = Args::command().render_help().to_string(); + + assert!(help.contains("Usage:")); + assert!(help.contains("--check")); + assert!(help.contains("--trial")); +} diff --git a/crates/unixnotis-daemon/src/tests/dnd_expiration.rs b/crates/unixnotis-daemon/src/tests/dnd_expiration.rs new file mode 100644 index 000000000..67b35f363 --- /dev/null +++ b/crates/unixnotis-daemon/src/tests/dnd_expiration.rs @@ -0,0 +1,42 @@ +use std::time::Duration; + +use super::{delay_until_recheck, DndExpirationScheduler, MAX_CLOCK_RECHECK}; +use crate::test_support::daemon_state_for_test; + +#[test] +fn delay_until_recheck_returns_zero_for_due_and_past_deadlines() { + assert_eq!(delay_until_recheck(100, 100), Duration::ZERO); + assert_eq!(delay_until_recheck(101, 100), Duration::ZERO); +} + +#[test] +fn delay_until_recheck_caps_long_waits_for_wall_clock_changes() { + assert_eq!(delay_until_recheck(100, 110), Duration::from_secs(10)); + assert_eq!(delay_until_recheck(100, 10_000), MAX_CLOCK_RECHECK); +} + +#[tokio::test] +async fn scheduler_disables_dnd_when_the_current_deadline_is_due() { + let state = daemon_state_for_test(false).await; + let expires_at = chrono::Utc::now().timestamp(); + { + let mut store = state.store.lock().await; + store.set_dnd_until(expires_at); + } + let scheduler = DndExpirationScheduler::start(state.clone()); + state.set_dnd_scheduler(scheduler.clone()); + + scheduler.schedule(Some(expires_at)); + + tokio::time::timeout(Duration::from_millis(500), async { + loop { + if !state.store.lock().await.dnd_enabled() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("due DND deadline should be processed promptly"); + assert_eq!(state.store.lock().await.dnd_expires_at(), None); +} diff --git a/crates/unixnotis-daemon/src/tests/expire.rs b/crates/unixnotis-daemon/src/tests/expire.rs index 7674b1224..c4a9012f0 100644 --- a/crates/unixnotis-daemon/src/tests/expire.rs +++ b/crates/unixnotis-daemon/src/tests/expire.rs @@ -7,26 +7,58 @@ impl ExpirationScheduler { } } use chrono::Utc; +use futures_util::TryStreamExt; use std::collections::HashMap; use std::time::Duration; -use unixnotis_core::{Notification, NotificationImage, Urgency}; +use unixnotis_core::{ + Notification, NotificationImage, Urgency, CONTROL_INTERFACE, CONTROL_OBJECT_PATH, +}; +use zbus::message::Type; use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +fn ticket(id: u32, generation: u64, deadline: Instant) -> ExpirationTicket { + ExpirationTicket { + id, + generation, + deadline, + } +} #[test] fn expiration_heap_orders_by_deadline() { let now = Instant::now(); let mut heap = BinaryHeap::new(); heap.push(ExpirationItem { - id: 1, - deadline: now + Duration::from_secs(2), + ticket: ticket(1, 1, now + Duration::from_secs(2)), }); heap.push(ExpirationItem { - id: 2, - deadline: now + Duration::from_secs(1), + ticket: ticket(2, 2, now + Duration::from_secs(1)), }); let first = heap.pop().expect("first item"); - assert_eq!(first.id, 2); + assert_eq!(first.ticket.id, 2); +} + +#[test] +fn expiration_items_are_equal_only_for_the_same_complete_ticket() { + let deadline = Instant::now() + Duration::from_secs(1); + let item = ExpirationItem { + ticket: ticket(7, 3, deadline), + }; + + assert_eq!( + item, + ExpirationItem { + ticket: ticket(7, 3, deadline) + } + ); + assert_ne!( + item, + ExpirationItem { + ticket: ticket(7, 4, deadline) + } + ); } #[test] @@ -37,26 +69,52 @@ fn apply_command_tracks_latest_schedule() { apply_command( ExpirationCommand::Schedule { - id: 7, - deadline: now + Duration::from_secs(5), + ticket: ticket(7, 1, now + Duration::from_secs(5)), }, &mut heap, &mut scheduled, ); apply_command( ExpirationCommand::Schedule { - id: 7, - deadline: now + Duration::from_secs(3), + ticket: ticket(7, 2, now + Duration::from_secs(3)), }, &mut heap, &mut scheduled, ); assert_eq!(scheduled.len(), 1); - assert_eq!(scheduled.get(&7), Some(&(now + Duration::from_secs(3)))); + assert_eq!( + scheduled.get(&7), + Some(&ticket(7, 2, now + Duration::from_secs(3))) + ); assert_eq!(heap.len(), 2); } +#[test] +fn late_older_schedule_cannot_replace_newer_generation() { + let now = Instant::now(); + let mut heap = BinaryHeap::new(); + let mut scheduled = HashMap::new(); + let newer = ticket(7, 2, now + Duration::from_secs(10)); + + // This order reproduces delivery after two store commits were reversed + apply_command( + ExpirationCommand::Schedule { ticket: newer }, + &mut heap, + &mut scheduled, + ); + apply_command( + ExpirationCommand::Schedule { + ticket: ticket(7, 1, now + Duration::from_secs(1)), + }, + &mut heap, + &mut scheduled, + ); + + assert_eq!(scheduled.get(&7), Some(&newer)); + assert_eq!(heap.len(), 1); +} + #[test] fn apply_command_cancel_removes_schedule() { let now = Instant::now(); @@ -65,14 +123,16 @@ fn apply_command_cancel_removes_schedule() { apply_command( ExpirationCommand::Schedule { - id: 9, - deadline: now + Duration::from_secs(2), + ticket: ticket(9, 4, now + Duration::from_secs(2)), }, &mut heap, &mut scheduled, ); apply_command( - ExpirationCommand::Cancel { id: 9 }, + ExpirationCommand::Cancel { + id: 9, + generation: 4, + }, &mut heap, &mut scheduled, ); @@ -80,24 +140,51 @@ fn apply_command_cancel_removes_schedule() { assert!(scheduled.is_empty()); } +#[test] +fn late_older_cancel_preserves_newer_generation() { + let now = Instant::now(); + let mut heap = BinaryHeap::new(); + let mut scheduled = HashMap::new(); + let newer = ticket(9, 5, now + Duration::from_secs(2)); + apply_command( + ExpirationCommand::Schedule { ticket: newer }, + &mut heap, + &mut scheduled, + ); + + apply_command( + ExpirationCommand::Cancel { + id: 9, + generation: 4, + }, + &mut heap, + &mut scheduled, + ); + + assert_eq!(scheduled.get(&9), Some(&newer)); +} + #[test] fn maybe_compact_rebuilds_from_scheduled() { let now = Instant::now(); let mut heap = BinaryHeap::new(); let mut scheduled = HashMap::new(); - scheduled.insert(1_u32, now + Duration::from_secs(1)); + scheduled.insert(1_u32, ticket(1, 1, now + Duration::from_secs(1))); for id in 0..129_u32 { heap.push(ExpirationItem { - id, - deadline: now + Duration::from_secs(u64::from(id) + 1), + ticket: ticket( + id, + u64::from(id) + 1, + now + Duration::from_secs(u64::from(id) + 1), + ), }); } maybe_compact(&mut heap, &scheduled); assert_eq!(heap.len(), scheduled.len()); let item = heap.pop().expect("rebuilt item"); - assert_eq!(item.id, 1); + assert_eq!(item.ticket.id, 1); } #[tokio::test] @@ -107,21 +194,21 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { state.set_scheduler(scheduler.clone()); let deadline = Instant::now() + Duration::from_millis(20); - let id = { + let key = { let mut store = state.store.lock().await; let outcome = store.insert(make_notification("expires"), 0); - let id = outcome.notification.id; - store.set_expiration(id, Some(deadline)); - id + let key = outcome.active_notification().key(); + store.set_expiration(&outcome.active_notification(), Some(deadline)); + key }; - scheduler.schedule(id, Some(deadline)); + scheduler.schedule(key.id, key.generation, Some(deadline)); let expired = tokio::time::timeout(Duration::from_secs(1), async { loop { let is_active = { let store = state.store.lock().await; - store.active_notification_view(id).is_some() + store.active_notification_view(key.id).is_some() }; if !is_active { break; @@ -133,17 +220,117 @@ async fn scheduler_closes_notification_at_scheduled_deadline() { assert!(expired.is_ok()); let store = state.store.lock().await; - assert_eq!(store.expiration_for(id), None); + assert!(store.active_notification_view(key.id).is_none()); + assert!(store.list_history().iter().any(|notification| { + notification.id == key.id && notification.generation == key.generation + })); +} + +#[tokio::test] +async fn old_timer_never_closes_or_signals_for_same_id_replacement() { + let state = crate::test_support::daemon_state_for_test(false).await; + let scheduler = ExpirationScheduler::start(state.clone()); + state.set_scheduler(scheduler.clone()); + let mut closed_signals = control_closed_stream(&state).await; + let old_deadline = Instant::now() + Duration::from_millis(30); + + // Holding the store lock forces the expired worker to wait at its commit point + let mut store = state.store.lock().await; + let original = store + .insert(make_notification("original"), 0) + .active_notification(); + store.set_expiration(&original, Some(old_deadline)); + scheduler.schedule(original.id, original.generation, Some(old_deadline)); + tokio::time::sleep(Duration::from_millis(80)).await; + + let replacement = store + .insert(make_notification("replacement"), original.id) + .active_notification(); + let replacement_deadline = Instant::now() + Duration::from_millis(250); + store.set_expiration(&replacement, Some(replacement_deadline)); + scheduler.schedule( + replacement.id, + replacement.generation, + Some(replacement_deadline), + ); + drop(store); + + // The stale timer now resumes but cannot remove the replacement generation + tokio::time::sleep(Duration::from_millis(60)).await; + let active = state + .store + .lock() + .await + .active_notification_view(replacement.id) + .expect("replacement should remain active after the old deadline"); + assert_eq!(active.generation, replacement.generation); + assert_eq!(active.summary, "replacement"); + assert!( + tokio::time::timeout(Duration::from_millis(60), closed_signals.try_next()) + .await + .is_err(), + "old generation must not emit a close signal" + ); + + // The replacement keeps its own schedule and expires normally + let signal = tokio::time::timeout(Duration::from_millis(500), closed_signals.try_next()) + .await + .expect("replacement close signal should arrive") + .expect("close signal stream should remain healthy") + .expect("replacement close signal"); + let (closed_id, closed_generation, reason) = signal + .body() + .deserialize::<(u32, u64, CloseReason)>() + .expect("notification close signal body"); + assert_eq!(closed_id, replacement.id); + assert_eq!(closed_generation, replacement.generation); + assert_eq!(reason as u32, CloseReason::Expired as u32); + assert!(state + .store + .lock() + .await + .active_notification_view(replacement.id) + .is_none()); +} + +async fn control_closed_stream(state: &DaemonState) -> MessageStream { + let receiver = Connection::session() + .await + .expect("receiver should connect to the test session bus"); + let sender = state + .connection() + .unique_name() + .expect("daemon connection should have a unique name") + .to_string(); + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(sender.as_str()) + .expect("daemon sender should be a valid bus name") + .path(CONTROL_OBJECT_PATH) + .expect("control object path should be valid") + .interface(CONTROL_INTERFACE) + .expect("control interface should be valid") + .member("NotificationClosed") + .expect("close member should be valid") + .build(); + MessageStream::for_match_rule(rule, &receiver, Some(8)) + .await + .expect("close signal subscription should succeed") } fn make_notification(summary: &str) -> Notification { Notification { id: 0, + generation: 0, app_name: "TestApp".to_string(), app_icon: String::new(), + attribution: unixnotis_core::NotificationAttribution::default(), + attribution_diagnostics: unixnotis_core::AttributionDiagnostics::default(), summary: summary.to_string(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, hints: HashMap::::new(), urgency: Urgency::Normal, category: None, diff --git a/crates/unixnotis-daemon/src/tests/support.rs b/crates/unixnotis-daemon/src/tests/support.rs index 049d21b70..ca2dcc4dd 100644 --- a/crates/unixnotis-daemon/src/tests/support.rs +++ b/crates/unixnotis-daemon/src/tests/support.rs @@ -6,10 +6,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::sync::Arc; +use arc_swap::ArcSwap; use unixnotis_core::Config; use zbus::Connection; -use crate::daemon::DaemonState; +use crate::daemon::{DaemonState, DesktopIdentityIndex}; use crate::sound::SoundSettings; use crate::store::NotificationStore; @@ -18,18 +19,33 @@ pub fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() - .expect("env lock should not be poisoned") + // A failed subprocess test must not make every later environment test fail + .unwrap_or_else(std::sync::PoisonError::into_inner) } pub async fn daemon_state_for_test(trial_mode: bool) -> Arc { + daemon_state_for_test_with_owner(trial_mode, None).await +} + +pub async fn daemon_state_for_test_with_owner( + trial_mode: bool, + control_owner: Option<&str>, +) -> Arc { // Signal-heavy daemon tests only need a session connection and default state let connection = Connection::session() .await .expect("session bus should be available for daemon signal tests"); let config = Config::default(); - let sound = SoundSettings::from_config(&config); + let sound = SoundSettings::from_config(&config, None); let store = NotificationStore::new_with_state_store(config, None); - DaemonState::new_with_store(connection, store, sound, trial_mode) + DaemonState::new_with_store( + connection, + store, + sound, + trial_mode, + Arc::new(ArcSwap::from_pointee(DesktopIdentityIndex::default())), + control_owner.map(str::to_owned), + ) } pub struct EnvVarGuard { diff --git a/crates/unixnotis-daemon/src/trial_mode/control.rs b/crates/unixnotis-daemon/src/trial_mode/control.rs index f5cfffc2d..df611640e 100644 --- a/crates/unixnotis-daemon/src/trial_mode/control.rs +++ b/crates/unixnotis-daemon/src/trial_mode/control.rs @@ -76,16 +76,18 @@ pub(super) async fn stop_active_owner( } RestoreStrategy::Systemd => { // Strict systemd mode errors if the matched unit is not active - if !is_unit_active(known.unit).await { + let unit = known.systemd_unit.ok_or_else(|| { + anyhow!("{} does not publish a known systemd user unit", known.name) + })?; + if !is_unit_active(unit).await { return Err(anyhow!( - "systemd restore requested but {} is not active", - known.unit + "systemd restore requested but {unit} is not active" )); } - stop_via_systemd(known.unit).await?; - debug!(unit = known.unit, "trial mode: restore via systemd"); + stop_via_systemd(unit).await?; + debug!(unit, "trial mode: restore via systemd"); Ok(Some(RestoreAction::Systemd { - unit: known.unit.to_string(), + unit: unit.to_string(), })) } RestoreStrategy::Process => { @@ -103,13 +105,16 @@ pub(super) async fn stop_active_owner( } RestoreStrategy::Auto => { // Auto prefers systemd when unit is active, otherwise process restore - if is_unit_active(known.unit).await { - stop_via_systemd(known.unit).await?; - debug!(unit = known.unit, "trial mode: restore via systemd (auto)"); - Ok(Some(RestoreAction::Systemd { - unit: known.unit.to_string(), - })) - } else { + if let Some(unit) = known.systemd_unit { + if is_unit_active(unit).await { + stop_via_systemd(unit).await?; + debug!(unit, "trial mode: restore via systemd (auto)"); + return Ok(Some(RestoreAction::Systemd { + unit: unit.to_string(), + })); + } + } + { let (program, args) = build_restart_command(owner, comm)?; // Auto mode follows the same prepare-before-stop transaction as strict mode stop_via_process(pid).await?; diff --git a/crates/unixnotis-daemon/src/trial_mode/owner.rs b/crates/unixnotis-daemon/src/trial_mode/owner.rs index 52aee70a2..d9460ed21 100644 --- a/crates/unixnotis-daemon/src/trial_mode/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/owner.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use tokio::fs; use tokio::time::timeout; use tracing::warn; @@ -7,32 +7,27 @@ use zbus::fdo::DBusProxy; use crate::system_tools; +use super::state::NotificationOwnerState; use super::{DetectedDaemon, OwnerInfo, KNOWN_DAEMONS, TRIAL_COMMAND_TIMEOUT}; pub(super) async fn detect_owner( dbus_proxy: &DBusProxy<'_>, notifications_name: zbus::names::BusName<'_>, -) -> Result> { +) -> Result { // Quick owner check avoids extra calls when Notifications is unclaimed - let has_owner = match dbus_proxy.name_has_owner(notifications_name.clone()).await { - Ok(value) => value, - Err(err) => { - warn!(?err, "failed to query D-Bus owner state"); - false - } - }; + let has_owner = dbus_proxy + .name_has_owner(notifications_name.clone()) + .await + .context("query Notifications ownership")?; if !has_owner { - return Ok(None); + return Ok(NotificationOwnerState::Unowned); } let owner = dbus_proxy .get_name_owner(notifications_name) .await - .ok() - .map(|name| name.to_string()); - let Some(unique_name) = owner else { - return Ok(None); - }; + .context("resolve Notifications owner")?; + let unique_name = owner.to_string(); // Resolve PID from unique bus name when possible let pid = if let Ok(bus_name) = zbus::names::BusName::try_from(unique_name.as_str()) { @@ -43,16 +38,41 @@ pub(super) async fn detect_owner( } else { None }; - let comm = match pid { - Some(pid) => read_comm(pid).await, - None => None, - }; let args = match pid { Some(pid) => read_args(pid).await, None => None, }; + // Argv keeps long executable names intact while /proc comm truncates after 15 bytes + let comm = args + .as_deref() + .and_then(command_program_name) + .or(match pid { + Some(pid) => read_comm(pid).await, + None => None, + }); - Ok(Some(OwnerInfo { pid, comm, args })) + Ok(NotificationOwnerState::Owned(OwnerInfo { + unique_name, + pid, + comm, + args, + })) +} + +pub(super) async fn ensure_owner_is_current( + dbus_proxy: &DBusProxy<'_>, + notifications_name: zbus::names::BusName<'_>, + inspected: &OwnerInfo, +) -> Result<()> { + let current = dbus_proxy + .get_name_owner(notifications_name) + .await + .context("revalidate Notifications owner before trial stop")?; + anyhow::ensure!( + current.as_str() == inspected.unique_name, + "Notifications owner changed during trial preparation; refusing to stop either process" + ); + Ok(()) } pub(super) async fn detect_known_daemons(owner: &Option) -> Vec { @@ -61,7 +81,10 @@ pub(super) async fn detect_known_daemons(owner: &Option) -> Vec is_unit_active(unit).await, + None => false, + }; let is_owner = owner_name == Some(daemon.name); entries.push(DetectedDaemon { name: daemon.name.to_string(), @@ -73,6 +96,15 @@ pub(super) async fn detect_known_daemons(owner: &Option) -> Vec Option { + let program = args.first()?; + std::path::Path::new(program) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) +} + pub(super) fn print_detected_daemons(daemons: &[DetectedDaemon], owner: &Option) { println!("Detected notification daemons:"); let mut owner_listed = false; diff --git a/crates/unixnotis-daemon/src/trial_mode/prompt.rs b/crates/unixnotis-daemon/src/trial_mode/prompt.rs index efc0890bb..e2110c996 100644 --- a/crates/unixnotis-daemon/src/trial_mode/prompt.rs +++ b/crates/unixnotis-daemon/src/trial_mode/prompt.rs @@ -12,7 +12,15 @@ pub(super) fn confirm_trial() -> Result { io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; - let input = input.trim().to_ascii_lowercase(); // Any response outside y/yes is treated as no - Ok(matches!(input.as_str(), "y" | "yes")) + Ok(is_trial_confirmation(&input)) } + +fn is_trial_confirmation(input: &str) -> bool { + let input = input.trim(); + input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") +} + +#[cfg(test)] +#[path = "tests/prompt.rs"] +mod tests; diff --git a/crates/unixnotis-daemon/src/trial_mode/state.rs b/crates/unixnotis-daemon/src/trial_mode/state.rs index baa46f3a8..cd6d51f9c 100644 --- a/crates/unixnotis-daemon/src/trial_mode/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/state.rs @@ -9,7 +9,7 @@ use tracing::debug; use zbus::fdo::DBusProxy; use crate::cli::Args; -use crate::dbus_owner::wait_for_owner_state; +use crate::daemon::wait_for_owner_state; use super::{control, owner, prompt}; @@ -35,6 +35,8 @@ pub enum RestoreAction { } pub struct OwnerInfo { + // Exact broker address is revalidated immediately before any stop operation + pub(super) unique_name: String, // D-Bus owner PID when available pub(super) pid: Option, // Process name from /proc or ps @@ -43,6 +45,11 @@ pub struct OwnerInfo { pub(super) args: Option>, } +pub(in crate::trial_mode) enum NotificationOwnerState { + Unowned, + Owned(OwnerInfo), +} + pub struct DetectedDaemon { pub(super) name: String, pub(super) systemd_active: bool, @@ -50,33 +57,8 @@ pub struct DetectedDaemon { pub(super) is_owner: bool, } -pub struct KnownDaemon { - pub(super) name: &'static str, - pub(super) unit: &'static str, -} - -pub const KNOWN_DAEMONS: &[KnownDaemon] = &[ - KnownDaemon { - name: "mako", - unit: "mako.service", - }, - KnownDaemon { - name: "dunst", - unit: "dunst.service", - }, - KnownDaemon { - name: "swaync", - unit: "swaync.service", - }, - KnownDaemon { - name: "notify-osd", - unit: "notify-osd.service", - }, - KnownDaemon { - name: "quickshell", - unit: "quickshell.service", - }, -]; +pub const KNOWN_DAEMONS: &[unixnotis_core::KnownNotificationDaemon] = + unixnotis_core::KNOWN_NOTIFICATION_DAEMONS; pub const TRIAL_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); @@ -87,23 +69,24 @@ pub async fn prepare_trial( ) -> Result { debug!("trial mode detection started"); // Step 1: resolve the current D-Bus owner for Notifications - let owner = owner::detect_owner(dbus_proxy, notifications_name.clone()).await?; - if owner.is_none() { - debug!("trial mode: no current notification owner"); - return Ok(TrialState::default()); - } + let owner = match owner::detect_owner(dbus_proxy, notifications_name.clone()).await? { + NotificationOwnerState::Unowned => { + debug!("trial mode: no current notification owner"); + return Ok(TrialState::default()); + } + NotificationOwnerState::Owned(owner) => owner, + }; - if let Some(info) = owner.as_ref() { - debug!( - pid = info.pid, - comm = info.comm.as_deref().unwrap_or("unknown"), - "trial mode: current owner detected" - ); - } + debug!( + pid = owner.pid, + comm = owner.comm.as_deref().unwrap_or("unknown"), + "trial mode: current owner detected" + ); // Step 2: collect known daemon status so prompt output is actionable - let daemons = owner::detect_known_daemons(&owner).await; - owner::print_detected_daemons(&daemons, &owner); + let owner_view = Some(owner); + let daemons = owner::detect_known_daemons(&owner_view).await; + owner::print_detected_daemons(&daemons, &owner_view); if !args.yes { // Prompt runs on a blocking worker to keep async runtime responsive @@ -115,9 +98,10 @@ pub async fn prepare_trial( } } - let Some(owner) = owner else { - return Err(anyhow!("no current owner detected for trial mode")); + let Some(owner) = owner_view else { + return Err(anyhow!("trial owner state disappeared before revalidation")); }; + owner::ensure_owner_is_current(dbus_proxy, notifications_name.clone(), &owner).await?; // Step 3: stop current owner and capture restore plan when applicable let restore_action = control::stop_active_owner(args, &owner).await?; diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/control.rs b/crates/unixnotis-daemon/src/trial_mode/tests/control.rs index 7ec387aa8..bc0044d9a 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/control.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/control.rs @@ -46,6 +46,7 @@ impl Drop for TempDirGuard { #[test] fn restart_command_preserves_captured_argv_without_trusted_lookup() { let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: Some(vec![ @@ -67,6 +68,7 @@ fn restart_command_resolves_missing_argv_fallback_from_trusted_tools() { root.write_executable("mako", "#!/bin/sh\nexit 0\n"); let _tools = use_fake_tool_bin(&root.path); let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -86,6 +88,7 @@ fn restart_command_rejects_missing_argv_fallback_when_not_trusted() { let empty_trusted = TempDirGuard::new("empty-trusted"); let _tools = use_fake_tool_bin(&empty_trusted.path); let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -147,6 +150,7 @@ async fn process_restore_is_fully_constructed_before_owner_is_stopped() { run_seconds: None, }; let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: None, @@ -174,6 +178,7 @@ async fn stop_active_owner_returns_systemd_restore_action_in_auto_mode() { run_seconds: None, }; let owner = OwnerInfo { + unique_name: ":1.test".to_string(), pid: Some(42), comm: Some("mako".to_string()), args: Some(vec!["/usr/bin/mako".to_string()]), diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs b/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs index 591fe60e2..f8672ac40 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/known_daemons.rs @@ -9,5 +9,24 @@ fn known_daemons_include_quickshell_owner() { .expect("quickshell should be known"); // The unit name lets auto restore prefer systemd when available - assert_eq!(quickshell.unit, "quickshell.service"); + assert_eq!(quickshell.systemd_unit, None); +} + +#[test] +fn known_daemons_include_fnott_owner_and_real_service_unit() { + let fnott = KNOWN_DAEMONS + .iter() + .find(|daemon| daemon.name == "fnott") + .expect("fnott should be known"); + + assert_eq!(fnott.systemd_unit, Some("fnott.service")); +} + +#[test] +fn trial_and_installer_share_the_complete_daemon_catalog() { + assert!(KNOWN_DAEMONS.len() >= 16); + assert!(KNOWN_DAEMONS + .iter() + .any(|daemon| daemon.name == "lxqt-notificationd")); + assert!(KNOWN_DAEMONS.iter().any(|daemon| daemon.name == "runst")); } diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs index d0c4c91db..10881c3c8 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/owner.rs @@ -1,91 +1,69 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::time::{SystemTime, UNIX_EPOCH}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::Connection; -use crate::system_tools::routing::use_fake_tool_bin; - -use super::{is_unit_active, pgrep_exact, read_args, read_comm}; - -struct TempDirGuard { - path: std::path::PathBuf, -} - -impl TempDirGuard { - fn new(label: &str) -> Self { - let stamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let path = std::env::temp_dir().join(format!( - "unixnotis-trial-owner-{label}-{}-{stamp}", - std::process::id() - )); - fs::create_dir_all(&path).expect("create temp dir"); - Self { path } - } - - fn write_executable(&self, name: &str, contents: &str) { - let path = self.path.join(name); - fs::write(&path, contents).expect("write fake tool"); - let mut permissions = fs::metadata(&path) - .expect("fake tool metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(path, permissions).expect("chmod fake tool"); - } -} - -impl Drop for TempDirGuard { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} +use super::{detect_owner, ensure_owner_is_current}; +use crate::trial_mode::state::NotificationOwnerState; #[tokio::test] -async fn is_unit_active_uses_trusted_systemctl_exit_status() { - let root = TempDirGuard::new("systemctl-active"); - root.write_executable( - "systemctl", - "#!/bin/sh\ncase \"$*\" in *mako.service*) exit 0;; *) exit 3;; esac\n", +async fn broker_failure_does_not_become_an_unowned_notification_name() { + let connection = Connection::session().await.expect("session bus connection"); + let proxy = DBusProxy::new(&connection).await.expect("D-Bus proxy"); + connection.close().await.expect("close test bus connection"); + let notifications = + BusName::try_from(unixnotis_core::NOTIFICATIONS_BUS_NAME).expect("Notifications bus name"); + + assert!( + detect_owner(&proxy, notifications).await.is_err(), + "broker failure must remain an error" ); - let _tools = use_fake_tool_bin(&root.path); - - assert!(is_unit_active("mako.service").await); - assert!(!is_unit_active("dunst.service").await); -} - -#[tokio::test] -async fn pgrep_exact_parses_only_numeric_pids() { - let root = TempDirGuard::new("pgrep"); - root.write_executable("pgrep", "#!/bin/sh\nprintf '12\\nnot-a-pid\\n34\\n'\n"); - let _tools = use_fake_tool_bin(&root.path); - - let pids = pgrep_exact("mako").await; - - assert_eq!(pids, [12, 34]); } #[tokio::test] -async fn read_comm_uses_trusted_ps_fallback_when_procfs_is_missing() { - let root = TempDirGuard::new("comm"); - root.write_executable("ps", "#!/bin/sh\nprintf 'mako\\n'\n"); - let _tools = use_fake_tool_bin(&root.path); - - let comm = read_comm(u32::MAX).await; - - assert_eq!(comm.as_deref(), Some("mako")); -} - -#[tokio::test] -async fn read_args_uses_trusted_ps_fallback_when_procfs_is_missing() { - let root = TempDirGuard::new("args"); - root.write_executable( - "ps", - "#!/bin/sh\nprintf '/usr/bin/mako --config mako.conf\\n'\n", - ); - let _tools = use_fake_tool_bin(&root.path); - - let args = read_args(u32::MAX).await.expect("fallback args"); - - assert_eq!(args, ["/usr/bin/mako", "--config", "mako.conf"]); +async fn owner_handoff_after_inspection_blocks_the_stop_precondition() { + let owner_a = Connection::session().await.expect("first owner connection"); + let owner_b = Connection::session() + .await + .expect("second owner connection"); + let observer = Connection::session().await.expect("observer connection"); + let name = format!("com.unixnotis.TrialOwner.p{}", std::process::id()); + owner_a + .request_name(name.as_str()) + .await + .expect("first owner acquires test name"); + let proxy = DBusProxy::new(&observer) + .await + .expect("observer D-Bus proxy"); + let inspected = match detect_owner( + &proxy, + BusName::try_from(name.as_str()).expect("test bus name"), + ) + .await + .expect("inspect first owner") + { + NotificationOwnerState::Owned(owner) => owner, + NotificationOwnerState::Unowned => panic!("test name must be owned"), + }; + owner_a + .release_name(name.as_str()) + .await + .expect("first owner releases test name"); + owner_b + .request_name(name.as_str()) + .await + .expect("second owner acquires test name"); + + let error = ensure_owner_is_current( + &proxy, + BusName::try_from(name.as_str()).expect("test bus name"), + &inspected, + ) + .await + .expect_err("owner handoff must block process stopping"); + + assert!(error.to_string().contains("owner changed")); + owner_b + .release_name(name.as_str()) + .await + .expect("release second test owner"); } diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs b/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs new file mode 100644 index 000000000..53026dca6 --- /dev/null +++ b/crates/unixnotis-daemon/src/trial_mode/tests/prompt.rs @@ -0,0 +1,13 @@ +use super::is_trial_confirmation; + +#[test] +fn trial_confirmation_accepts_trimmed_ascii_yes_values() { + assert!(is_trial_confirmation(" y ")); + assert!(is_trial_confirmation("YES")); +} + +#[test] +fn trial_confirmation_rejects_empty_and_unrecognized_values() { + assert!(!is_trial_confirmation("")); + assert!(!is_trial_confirmation("true")); +} diff --git a/crates/unixnotis-daemon/src/trial_mode/tests/state.rs b/crates/unixnotis-daemon/src/trial_mode/tests/state.rs index 4c467e996..e6d9ba105 100644 --- a/crates/unixnotis-daemon/src/trial_mode/tests/state.rs +++ b/crates/unixnotis-daemon/src/trial_mode/tests/state.rs @@ -1,6 +1,7 @@ use anyhow::anyhow; -use super::{restore_after_prepare_failure, RestoreAction, TrialState}; +use super::{prepare_trial, restore_after_prepare_failure, RestoreAction, TrialState}; +use crate::cli::{Args, RestoreStrategy}; impl TrialState { pub(crate) const fn with_restore_action_for_test(action: RestoreAction) -> Self { @@ -25,3 +26,27 @@ fn preparation_failure_restores_once_and_preserves_both_errors() { assert!(message.contains("trial restoration also failed")); assert!(trial.take_restore_action().is_none()); } + +#[tokio::test] +async fn trial_preparation_propagates_broker_failure_instead_of_assuming_unowned() { + let connection = zbus::Connection::session() + .await + .expect("session bus connection"); + let proxy = zbus::fdo::DBusProxy::new(&connection) + .await + .expect("D-Bus proxy"); + connection.close().await.expect("close test bus connection"); + let notifications = zbus::names::BusName::try_from(unixnotis_core::NOTIFICATIONS_BUS_NAME) + .expect("Notifications bus name"); + let args = Args { + config: None, + trial: true, + restore: RestoreStrategy::Auto, + yes: true, + restore_wait_ms: 1, + check: false, + run_seconds: None, + }; + + assert!(prepare_trial(&args, &proxy, notifications).await.is_err()); +} diff --git a/crates/unixnotis-daemon/tests/cli.rs b/crates/unixnotis-daemon/tests/cli.rs deleted file mode 100644 index 762cd0071..000000000 --- a/crates/unixnotis-daemon/tests/cli.rs +++ /dev/null @@ -1,21 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn daemon_help_prints_usage_from_entrypoint() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-daemon")) - .arg("--help") - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage:")); - assert!(stdout.contains("--check")); - assert!(stdout.contains("--trial")); - Ok(()) - } -} diff --git a/crates/unixnotis-installer/Cargo.toml b/crates/unixnotis-installer/Cargo.toml index bfca880fd..b8840d4bc 100644 --- a/crates/unixnotis-installer/Cargo.toml +++ b/crates/unixnotis-installer/Cargo.toml @@ -6,13 +6,17 @@ license.workspace = true [dependencies] anyhow.workspace = true +libc.workspace = true crossterm.workspace = true ratatui.workspace = true toml.workspace = true serde_json.workspace = true semver.workspace = true serde.workspace = true -chrono.workspace = true rustix.workspace = true +sha2.workspace = true +tokio.workspace = true unixnotis-core = { path = "../unixnotis-core" } unicode-width.workspace = true +wait-timeout.workspace = true +zbus.workspace = true diff --git a/crates/unixnotis-installer/src/actions/binaries.rs b/crates/unixnotis-installer/src/actions/binaries.rs index e095c2d84..25830e584 100644 --- a/crates/unixnotis-installer/src/actions/binaries.rs +++ b/crates/unixnotis-installer/src/actions/binaries.rs @@ -2,24 +2,34 @@ use std::collections::BTreeSet; use std::fs; -use std::path::PathBuf; -use std::process::Command; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use crate::managed_binaries::{is_managed_binary_name, validate_managed_binary_names}; use crate::paths::InstallPaths; -use unixnotis_core::program_in_path; +use crate::toolchain::{cargo_command, resolve_cargo}; pub(super) fn resolve_install_binaries(paths: &InstallPaths) -> Result> { + let cargo = if paths.is_release_archive() { + None + } else { + Some(resolve_cargo()?) + }; + resolve_install_binaries_with_cargo(paths, cargo.as_deref()) +} + +pub(super) fn resolve_install_binaries_with_cargo( + paths: &InstallPaths, + cargo: Option<&Path>, +) -> Result> { // Prefer the installer metadata list when it is present. let metadata_list = load_install_binaries_from_metadata(paths)?; - let cargo_available = program_in_path("cargo"); if !metadata_list.is_empty() { // Validate against cargo metadata when available to catch stale entries. - if cargo_available && !paths.is_release_archive() { + if let Some(cargo) = cargo { // An empty Cargo inventory is an error and cannot widen the declared list - let available = load_install_binaries_from_cargo_metadata(paths)?; + let available = load_install_binaries_from_cargo_metadata(paths, cargo)?; let missing = metadata_list .iter() .filter(|name| !available.contains(*name)) @@ -36,8 +46,8 @@ pub(super) fn resolve_install_binaries(paths: &InstallPaths) -> Result Result // Release archives already contain built binaries under their local bin directory return Ok(paths.repo_root.clone()); } - let metadata = load_cargo_metadata(paths)?; + let cargo = resolve_cargo()?; + resolve_target_directory_with_cargo(paths, &cargo) +} + +pub(super) fn resolve_target_directory_with_cargo( + paths: &InstallPaths, + cargo: &Path, +) -> Result { + let metadata = load_cargo_metadata(paths, cargo)?; Ok(metadata.target_directory) } @@ -77,6 +95,7 @@ fn legacy_binaries() -> Vec { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string(), ] @@ -182,14 +201,17 @@ struct ReleaseManifest { binaries: Vec, } -fn load_install_binaries_from_cargo_metadata(paths: &InstallPaths) -> Result> { - let metadata = load_cargo_metadata(paths)?; +fn load_install_binaries_from_cargo_metadata( + paths: &InstallPaths, + cargo: &Path, +) -> Result> { + let metadata = load_cargo_metadata(paths, cargo)?; extract_bins_from_metadata(&metadata) } -fn load_cargo_metadata(paths: &InstallPaths) -> Result { +fn load_cargo_metadata(paths: &InstallPaths, cargo: &Path) -> Result { // cargo metadata is the most robust source of workspace targets. - let output = Command::new("cargo") + let output = cargo_command(cargo)? .args(["metadata", "--no-deps", "--format-version", "1"]) .current_dir(&paths.repo_root) .output() diff --git a/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs b/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs index e72b03600..b3f834c19 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/tests/write.rs @@ -4,7 +4,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::super::super::{write_build_accel_config, BuildAccelDetection, BuildAccelOutcome}; use super::super::detect::detect_build_accel_config_status; use super::super::model::BuildAccelConfigStatus; -use super::super::write::atomic_temp_path; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -178,19 +177,18 @@ fn write_build_accel_config_rejects_wrapper_symlink_without_touching_target() { #[cfg(unix)] #[test] -fn write_build_accel_config_bypasses_preexisting_temp_symlink_without_touching_it() { - let root = test_root("build-accel-temp-symlink"); +fn write_build_accel_config_rejects_config_symlink_without_touching_target() { + let root = test_root("build-accel-config-symlink"); let cargo_dir = root.join(".cargo"); let config_path = cargo_dir.join("config.toml"); let protected = root.join("protected"); fs::create_dir_all(&cargo_dir).expect("cargo dir"); fs::write( - &config_path, - "# Generated by unixnotis-installer\nold = true\n", + &protected, + "# Generated by unixnotis-installer\nprotected = true\n", ) - .expect("config"); - fs::write(&protected, "protected").expect("protected"); - symlink(&protected, atomic_temp_path(&config_path)).expect("temp symlink"); + .expect("protected config"); + symlink(&protected, &config_path).expect("config symlink"); let detection = BuildAccelDetection { sccache_installed: true, mold_installed: false, @@ -201,14 +199,15 @@ fn write_build_accel_config_bypasses_preexisting_temp_symlink_without_touching_i let outcome = write_build_accel_config(&root, &detection); - assert!(matches!(outcome, BuildAccelOutcome::UpdatedExisting { .. })); - assert!(fs::read_to_string(&config_path) - .expect("config updated") - .contains("rustc-wrapper")); + assert!(matches!(outcome, BuildAccelOutcome::Failed(_))); assert_eq!( - fs::read_to_string(&protected).expect("protected remains"), - "protected" + fs::read_to_string(&protected).expect("protected config remains"), + "# Generated by unixnotis-installer\nprotected = true\n" ); + assert!(fs::symlink_metadata(&config_path) + .expect("config link remains") + .file_type() + .is_symlink()); let _ = fs::remove_dir_all(root); } diff --git a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs index 7889c7b8c..a59416dd7 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/wrapper.rs @@ -1,8 +1,8 @@ //! Wrapper script generation for optional build acceleration -use std::{fs, path::Path}; +use std::path::Path; -use crate::safe_write::write_text_with_mode; +use unixnotis_core::filesystem::write_file_atomic; pub(in crate::actions::build::accel) fn format_build_accel_config() -> String { // A wrapper script keeps builds working if accelerator tools disappear later @@ -22,11 +22,8 @@ pub(in crate::actions::build::accel) fn format_build_accel_config() -> String { pub(in crate::actions::build::accel) fn write_wrapper_script( wrapper_path: &Path, ) -> Result<(), String> { - if let Some(parent) = wrapper_path.parent() { - // Create the wrapper parent first so the later config write has a valid target - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - } - write_text_with_mode(wrapper_path, &wrapper_script(), 0o755).map_err(|err| err.to_string()) + write_file_atomic(wrapper_path, wrapper_script().as_bytes(), 0o755) + .map_err(|err| err.to_string()) } pub(in crate::actions::build::accel) fn wrapper_script() -> String { diff --git a/crates/unixnotis-installer/src/actions/build/accel/write.rs b/crates/unixnotis-installer/src/actions/build/accel/write.rs index bd85adb07..e7c2fc74d 100644 --- a/crates/unixnotis-installer/src/actions/build/accel/write.rs +++ b/crates/unixnotis-installer/src/actions/build/accel/write.rs @@ -1,8 +1,9 @@ //! Build acceleration config writes and updates -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; +use std::fs; +use std::path::Path; + +use unixnotis_core::filesystem::write_file_atomic; use super::model::{BuildAccelDetection, BuildAccelOutcome}; use super::wrapper::{format_build_accel_config, write_wrapper_script}; @@ -25,18 +26,12 @@ pub fn write_build_accel_config( } let content = format_build_accel_config(); - if let Some(parent) = config_path.parent() { - // Create `.cargo/` before any write so both wrapper and config land in one known place - if let Err(err) = fs::create_dir_all(parent) { - return BuildAccelOutcome::Failed(err.to_string()); - } - } // Write the wrapper first so the config never points at a missing script if let Err(err) = write_wrapper_script(&wrapper_path) { return BuildAccelOutcome::Failed(err); } - if let Err(err) = write_atomic(&config_path, &content) { + if let Err(err) = write_file_atomic(&config_path, content.as_bytes(), 0o644) { return BuildAccelOutcome::Failed(err.to_string()); } @@ -70,7 +65,7 @@ fn update_existing_config( if let Err(err) = write_wrapper_script(wrapper_path) { return BuildAccelOutcome::Failed(err); } - if let Err(err) = write_atomic(config_path, &content) { + if let Err(err) = write_file_atomic(config_path, content.as_bytes(), 0o644) { return BuildAccelOutcome::Failed(err.to_string()); } @@ -80,66 +75,3 @@ fn update_existing_config( used_mold: detection.mold_installed, } } - -fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> { - // A sibling temp file keeps rename atomic on common Unix filesystems - let parent = path.parent().ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing parent directory") - })?; - fs::create_dir_all(parent)?; - let (tmp_path, mut temp_file) = create_atomic_temp_file(path)?; - temp_file - .write_all(contents.as_bytes()) - .inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - temp_file.flush().inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - drop(temp_file); - fs::rename(&tmp_path, path).inspect_err(|_err| { - let _ = fs::remove_file(&tmp_path); - })?; - Ok(()) -} - -pub(super) fn atomic_temp_path(path: &Path) -> PathBuf { - // Temp paths must be predictable to clean up, but create_new keeps existing paths untrusted - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let tmp_name = format!("{file_name}.tmp-{}", std::process::id()); - path.with_file_name(tmp_name) -} - -fn create_atomic_temp_file(path: &Path) -> std::io::Result<(PathBuf, std::fs::File)> { - for attempt in 0..16 { - let temp_path = atomic_temp_path_attempt(path, attempt); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { - Ok(file) => return Ok((temp_path, file)), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "could not allocate a safe build config temporary path", - )) -} - -fn atomic_temp_path_attempt(path: &Path, attempt: u8) -> PathBuf { - if attempt == 0 { - return atomic_temp_path(path); - } - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - path.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/build/compile.rs b/crates/unixnotis-installer/src/actions/build/compile.rs index 0865df90a..686ea1dfa 100644 --- a/crates/unixnotis-installer/src/actions/build/compile.rs +++ b/crates/unixnotis-installer/src/actions/build/compile.rs @@ -2,7 +2,11 @@ use anyhow::{anyhow, Result}; -use super::super::{binaries::resolve_install_binaries, log_line, run_command, ActionContext}; +use crate::toolchain::{cargo_command, resolve_cargo}; + +use super::super::{ + binaries::resolve_install_binaries_with_cargo, log_line, run_command, ActionContext, +}; pub fn run_build(ctx: &mut ActionContext) -> Result<()> { if ctx.paths.is_release_archive() { @@ -13,15 +17,18 @@ pub fn run_build(ctx: &mut ActionContext) -> Result<()> { // Build release artifacts before copying them into the user bin directory log_line(ctx, "Building release binaries"); + // Resolve once so metadata discovery and the build use the same executable + let cargo = resolve_cargo()?; + // Resolve the managed binary list from installer metadata instead of guessing package names - let binaries = resolve_install_binaries(ctx.paths)?; + let binaries = resolve_install_binaries_with_cargo(ctx.paths, Some(&cargo))?; if binaries.is_empty() { return Err(anyhow!("no installable binaries discovered for build")); } // Installer metadata stores executable names because the same list drives copy and removal // Cargo needs those values as binary targets since a binary can differ from its package name - let mut build = std::process::Command::new("cargo"); + let mut build = cargo_command(&cargo)?; build.args(["build", "--release"]); add_binary_targets(&mut build, &binaries); @@ -45,7 +52,7 @@ fn verify_release_binaries(ctx: &mut ActionContext) -> Result<()> { log_line(ctx, "Using bundled release binaries"); // The same resolver feeds build, install, and uninstall so the managed set cannot drift - let binaries = resolve_install_binaries(ctx.paths)?; + let binaries = super::super::binaries::resolve_install_binaries(ctx.paths)?; if binaries.is_empty() { return Err(anyhow!( "release manifest did not list installable binaries" diff --git a/crates/unixnotis-installer/src/actions/build/tests/compile.rs b/crates/unixnotis-installer/src/actions/build/tests/compile.rs index a3793d243..a8837da8a 100644 --- a/crates/unixnotis-installer/src/actions/build/tests/compile.rs +++ b/crates/unixnotis-installer/src/actions/build/tests/compile.rs @@ -75,10 +75,9 @@ fn run_build_rejects_release_archive_with_missing_bundled_binary() { let _ = fs::remove_dir_all(root); } -fn test_context<'a>(detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { +fn test_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { let (tx, _rx) = mpsc::sync_channel::(32); ActionContext { - detection, paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/config/backup/listing.rs b/crates/unixnotis-installer/src/actions/config/backup/listing.rs new file mode 100644 index 000000000..94ff7f28c --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/listing.rs @@ -0,0 +1,31 @@ +//! Backup-directory listing for the installer restore view + +use std::fs; +use std::path::{Path, PathBuf}; + +pub(in crate::actions::config::backup) const BACKUP_PREFIX: &str = "Backup-"; + +pub(in crate::actions::config::backup) fn list_backup_dirs(config_dir: &Path) -> Vec { + // A missing config directory simply means there is nothing to restore + let Ok(entries) = fs::read_dir(config_dir) else { + return Vec::new(); + }; + + entries + .filter_map(std::result::Result::ok) + .filter_map(|entry| { + // Restore only real directories so backup-like files cannot enter the picker + let file_type = entry.file_type().ok()?; + if !file_type.is_dir() { + return None; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + // The prefix keeps unrelated user directories out of the restore list + if !name.starts_with(BACKUP_PREFIX) { + return None; + } + Some(entry.path()) + }) + .collect() +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/mod.rs index a68405b2c..0b19589c5 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/mod.rs @@ -1,20 +1,17 @@ //! Config backup entry points +mod listing; mod restore; -mod retention; +mod restore_transaction; mod settings; mod snapshot; -mod write; // Keep config reads separate from dated backup directory churn pub(in crate::actions::config) use settings::{ensure_installer_config, load_installer_config}; // Backup file copies stay separate from restore logic so reset paths stay easy to scan -pub(in crate::actions::config) use retention::create_backup_dir; -pub(in crate::actions::config) use snapshot::backup_existing_file; pub use restore::restore_config; pub use snapshot::list_backup_dirs_for_ui; -pub use write::write_atomic; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/restore.rs index 5b18f06ca..e3044f0f2 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/restore.rs @@ -1,80 +1,119 @@ -//! Backup restore helpers and path guards +//! Transactional backup restore planning and commit +use std::collections::HashSet; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::Config; +use unixnotis_core::filesystem::open_regular_file; +use unixnotis_core::{Config, DEFAULT_SCRIPTS, MAX_CONFIG_BYTES}; use crate::paths::format_with_home; use super::super::super::{log_line, ActionContext}; -use super::retention::BACKUP_PREFIX; -use super::write::write_atomic; +use super::listing::BACKUP_PREFIX; + +pub(super) const MAX_RESTORE_FILE_BYTES: u64 = 16 * 1024 * 1024; + +struct RestorePlan { + config_path: PathBuf, + files: Vec, + warnings: Vec, +} + +struct RestoreFile { + label: String, + target: PathBuf, + mode: u32, + contents: Vec, +} pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { let Some(backup_dir) = ctx.restore_backup.clone() else { return Err(anyhow!("no backup directory selected")); }; - - // Derive the config root from the selected backup so tests do not depend on env state let config_dir = backup_dir .parent() .ok_or_else(|| anyhow!("backup directory missing parent"))? .to_path_buf(); - let config_path = config_dir.join("config.toml"); + validate_backup_directory_name(&backup_dir)?; - let backup_name = backup_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); - if !backup_name.starts_with(BACKUP_PREFIX) { - return Err(anyhow!("backup directory name is not recognized")); - } + // A durable journal makes an interrupted earlier restore safe before another plan is built + super::restore_transaction::recover_pending_restore(&config_dir)?; - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; log_line( ctx, format!("Restoring config from {}", format_with_home(&backup_dir)), ); - - // Restore config.toml first so restored theme paths drive the rest of the write targets - let config_backup = backup_dir.join("config.toml"); - if config_backup.exists() { - let contents = fs::read_to_string(&config_backup) - .with_context(|| "failed to read backup config.toml")?; - write_atomic(&config_path, &contents).with_context(|| "failed to restore config.toml")?; - log_line( - ctx, - format!("Restored config.toml -> {}", format_with_home(&config_path)), - ); - } else { + // Planning reads, parses, resolves, and bounds every source before any live file changes + let plan = build_restore_plan(&backup_dir, &config_dir)?; + for warning in &plan.warnings { + log_line(ctx, format!("Warning: {warning}")); + } + apply_restore_plan(&plan)?; + for file in &plan.files { log_line( ctx, - "Warning: backup missing config.toml; leaving current file unchanged".to_string(), + format!( + "Restored {} -> {}", + file.label, + format_with_home(&file.target) + ), ); } + Ok(()) +} - let config = if config_path.exists() { - match Config::load_from_path(&config_path) { - Ok(config) => config, - Err(err) => { - log_line( - ctx, - format!( - "Warning: failed to parse restored config.toml ({err:?}); using defaults" - ), - ); - Config::default() - } - } +fn validate_backup_directory_name(backup_dir: &Path) -> Result<()> { + let backup_name = backup_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if !backup_name.starts_with(BACKUP_PREFIX) { + return Err(anyhow!("backup directory name is not recognized")); + } + Ok(()) +} + +fn build_restore_plan(backup_dir: &Path, config_dir: &Path) -> Result { + let config_path = config_dir.join("config.toml"); + let backup_config = backup_dir.join("config.toml"); + let mut files = Vec::new(); + let mut warnings = Vec::new(); + + let (config, config_restore) = if backup_entry_exists(&backup_config)? { + let contents = read_backup_file_bounded(&backup_config, MAX_CONFIG_BYTES) + .context("failed to read backup config.toml")?; + let text = std::str::from_utf8(&contents) + .map_err(|_error| anyhow!("backup config.toml is not valid UTF-8"))?; + // Parser details may contain private configuration values, so the public error stays stable + let config = Config::parse(text) + .map_err(|_error| anyhow!("backup config.toml is not valid schema v5"))?; + ( + config, + Some(RestoreFile { + label: "config.toml".to_string(), + target: config_path.clone(), + mode: 0o644, + contents, + }), + ) + } else if backup_entry_exists(&config_path)? { + warnings.push("backup missing config.toml; leaving current file unchanged".to_string()); + ( + Config::load_from_path(&config_path) + .map_err(|_error| anyhow!("live config.toml is not valid schema v5"))?, + None, + ) } else { - Config::default() + warnings.push("backup missing config.toml; leaving current file unchanged".to_string()); + (Config::default(), None) }; - let theme_paths = config - .resolve_theme_paths_from(&config_dir) - .map_err(|err| anyhow!(err.to_string()))?; + let theme_paths = config + .resolve_theme_paths_from(config_dir) + .map_err(|error| anyhow!(error.to_string()))?; let theme_targets = [ ("base.css", theme_paths.base_css), ("panel.css", theme_paths.panel_css), @@ -82,42 +121,155 @@ pub fn restore_config(ctx: &mut ActionContext) -> Result<()> { ("widgets.css", theme_paths.widgets_css), ("media.css", theme_paths.media_css), ]; - for (name, target) in theme_targets { - let source = backup_dir.join(name); - if !source.exists() { - log_line( - ctx, - format!("Warning: backup missing {name}; leaving current file unchanged"), - ); - continue; - } - if !is_restore_target_allowed(&config_dir, &target) { - log_line( - ctx, - format!( - "Warning: skipped restoring {} because target escapes config dir ({})", - name, - format_with_home(&target) - ), - ); - continue; + plan_optional_file( + &mut files, + &mut warnings, + backup_dir, + config_dir, + name, + target, + 0o644, + )?; + } + + for script in DEFAULT_SCRIPTS { + let name = Path::new(script.relative_path) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("script path has no UTF-8 file name"))?; + plan_optional_file( + &mut files, + &mut warnings, + backup_dir, + config_dir, + script.relative_path, + config_dir.join(script.relative_path), + 0o755, + ) + .with_context(|| format!("plan restore for {name}"))?; + } + + if let Some(config_restore) = config_restore { + // Config is the final visibility switch after every referenced payload is durable + files.push(config_restore); + } + + reject_duplicate_targets(&files)?; + Ok(RestorePlan { + config_path, + files, + warnings, + }) +} + +fn plan_optional_file( + files: &mut Vec, + warnings: &mut Vec, + backup_dir: &Path, + config_dir: &Path, + label: &str, + target: PathBuf, + mode: u32, +) -> Result<()> { + let source_name = Path::new(label) + .file_name() + .ok_or_else(|| anyhow!("restore label has no file name"))?; + let source = backup_dir.join(source_name); + if !backup_entry_exists(&source)? { + warnings.push(format!( + "backup missing {label}; leaving current file unchanged" + )); + return Ok(()); + } + if !is_restore_target_allowed(config_dir, &target) { + warnings.push(format!( + "skipped restoring {label} because target escapes config dir ({})", + format_with_home(&target) + )); + return Ok(()); + } + let contents = read_backup_file_bounded(&source, MAX_RESTORE_FILE_BYTES) + .with_context(|| format!("failed to read backup {label}"))?; + files.push(RestoreFile { + label: label.to_string(), + target, + mode, + contents, + }); + Ok(()) +} + +fn reject_duplicate_targets(files: &[RestoreFile]) -> Result<()> { + let mut targets = HashSet::new(); + for file in files { + let normalized = normalize_path_for_compare(&file.target); + if !targets.insert(normalized) { + return Err(anyhow!( + "backup maps multiple files to the same live restore target" + )); } - if let Some(parent) = target.parent() { - // Create parents for custom theme paths before writing restored content - fs::create_dir_all(parent) - .with_context(|| format!("failed to create parent dir for {name}"))?; + } + Ok(()) +} + +fn apply_restore_plan(plan: &RestorePlan) -> Result<()> { + let config_dir = plan + .config_path + .parent() + .ok_or_else(|| anyhow!("live config path has no parent directory"))?; + let writes = plan + .files + .iter() + .map(|file| super::restore_transaction::RestoreWrite { + label: &file.label, + target: &file.target, + mode: file.mode, + contents: &file.contents, + }) + .collect::>(); + super::restore_transaction::apply_restore_transaction(config_dir, &writes, || { + // Reloading the published config catches an unexpected filesystem race before commit + if plan.config_path.exists() { + Config::load_from_path(&plan.config_path) + .map_err(|_error| anyhow!("restored config.toml failed post-commit validation"))?; } - let contents = - fs::read_to_string(&source).with_context(|| format!("failed to read backup {name}"))?; - write_atomic(&target, &contents).with_context(|| format!("failed to restore {name}"))?; - log_line( - ctx, - format!("Restored {} -> {}", name, format_with_home(&target)), - ); + Ok(()) + }) +} + +fn backup_entry_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_metadata) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), } +} - Ok(()) +fn read_backup_file_bounded(path: &Path, max_bytes: u64) -> Result> { + // Pin the object and reject links or special files before reading any payload bytes + let file = open_regular_file(path).with_context(|| format!("open {}", path.display()))?; + let size = file + .metadata() + .with_context(|| format!("inspect {}", path.display()))? + .len(); + if size > max_bytes { + return Err(anyhow!( + "restore file exceeds {max_bytes} bytes: {}", + path.display() + )); + } + let mut contents = Vec::with_capacity(usize::try_from(size).unwrap_or(usize::MAX)); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut contents) + .with_context(|| format!("read {}", path.display()))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(anyhow!( + "restore file grew beyond {max_bytes} bytes: {}", + path.display() + )); + } + Ok(contents) } pub(in crate::actions::config::backup) fn is_restore_target_allowed( @@ -130,21 +282,18 @@ pub(in crate::actions::config::backup) fn is_restore_target_allowed( } fn normalize_path_for_compare(path: &Path) -> PathBuf { - // Canonicalize when possible, then fall back to lexical cleanup for missing paths + // Existing objects are resolved first so an in-tree symlink cannot redirect a restore if let Ok(canonical) = fs::canonicalize(path) { return canonical; } let absolute = if path.is_absolute() { path.to_path_buf() } else { - match std::env::current_dir() { - Ok(current_dir) => current_dir.join(path), - Err(_) => path.to_path_buf(), - } + std::env::current_dir().map_or_else( + |_error| path.to_path_buf(), + |current_dir| current_dir.join(path), + ) }; - if let Ok(canonical) = fs::canonicalize(&absolute) { - return canonical; - } if let Some(parent) = absolute.parent() { if let Ok(parent_canonical) = fs::canonicalize(parent) { if let Some(name) = absolute.file_name() { @@ -166,3 +315,7 @@ fn normalize_path_for_compare(path: &Path) -> PathBuf { } normalized } + +#[cfg(test)] +#[path = "tests/restore_validation.rs"] +mod validation_tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs b/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs new file mode 100644 index 000000000..aea1977aa --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/restore_transaction.rs @@ -0,0 +1,413 @@ +//! Durable multi-file restore publication and recovery + +use std::collections::HashSet; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use unixnotis_core::filesystem::{ + create_directory_all, open_regular_file, read_regular_file_bounded, remove_directory_tree, + remove_regular_file, write_file_atomic, write_file_if_missing, CreateDirectoryOutcome, +}; + +use super::restore::MAX_RESTORE_FILE_BYTES; + +const RESTORE_JOURNAL_FILE: &str = ".unixnotis-restore-pending.json"; +const RESTORE_TRANSACTION_PREFIX: &str = ".unixnotis-restore-"; +const RESTORE_JOURNAL_SCHEMA: u32 = 1; +const MAX_RESTORE_JOURNAL_BYTES: u64 = 256 * 1024; +const TRANSACTION_DIRECTORY_ATTEMPTS: u8 = 16; +static RESTORE_TRANSACTION_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub(super) struct RestoreWrite<'a> { + pub(super) label: &'a str, + pub(super) target: &'a Path, + pub(super) mode: u32, + pub(super) contents: &'a [u8], +} + +#[derive(Debug, Deserialize, Serialize)] +struct RestoreJournal { + schema_version: u32, + transaction_dir: String, + entries: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct RestoreJournalEntry { + target: PathBuf, + staged: PathBuf, + staged_size: u64, + previous: PreviousFile, +} + +#[derive(Debug, Deserialize, Serialize)] +enum PreviousFile { + Missing, + Existing { + rollback: PathBuf, + size: u64, + mode: u32, + }, +} + +pub(super) fn apply_restore_transaction( + config_dir: &Path, + writes: &[RestoreWrite<'_>], + post_validate: impl FnOnce() -> Result<()>, +) -> Result<()> { + apply_restore_transaction_with_writer(config_dir, writes, post_validate, write_file_atomic) +} + +fn apply_restore_transaction_with_writer( + config_dir: &Path, + writes: &[RestoreWrite<'_>], + post_validate: impl FnOnce() -> Result<()>, + mut publish: impl FnMut(&Path, &[u8], u32) -> std::io::Result<()>, +) -> Result<()> { + // One journal owns the config tree so separate restores cannot overlap + if pending_journal(config_dir)?.is_some() { + return Err(anyhow!( + "an incomplete restore transaction must be recovered before another restore" + )); + } + let journal = prepare_restore_transaction(config_dir, writes)?; + let transaction_dir = config_dir.join(&journal.transaction_dir); + + // Every payload comes from the bounded staged copy recorded in the journal + let operation = (|| { + for (write, entry) in writes.iter().zip(&journal.entries) { + let staged = read_exact_transaction_file( + &transaction_dir.join(&entry.staged), + entry.staged_size, + )?; + publish(write.target, &staged, write.mode) + .with_context(|| format!("failed to restore {}", write.label))?; + } + post_validate() + })(); + if let Err(error) = operation { + // Failed publication keeps recovery authority until rollback is complete + return Err(rollback_or_retain(config_dir, &journal, error)); + } + + // Journal removal is the transaction commit point + finish_transaction(config_dir, &journal)?; + Ok(()) +} + +pub(super) fn recover_pending_restore(config_dir: &Path) -> Result { + let Some(journal) = pending_journal(config_dir)? else { + return Ok(false); + }; + // Recovery trusts only a fully validated local journal + validate_journal(&journal)?; + rollback_transaction(config_dir, &journal) + .context("recover interrupted config restore transaction")?; + finish_transaction(config_dir, &journal)?; + Ok(true) +} + +fn prepare_restore_transaction( + config_dir: &Path, + writes: &[RestoreWrite<'_>], +) -> Result { + create_directory_all(config_dir, 0o700).context("create config directory for restore")?; + let transaction_dir = reserve_transaction_directory(config_dir)?; + let prepared = (|| { + // Staged and rollback data stay private to this transaction + create_directory_all(&transaction_dir.join("staged"), 0o700) + .context("create restore staging directory")?; + create_directory_all(&transaction_dir.join("rollback"), 0o700) + .context("create restore rollback directory")?; + + let mut entries = Vec::with_capacity(writes.len()); + let mut targets = HashSet::new(); + for (index, write) in writes.iter().enumerate() { + // Targets are stored relative to the pinned config root + let target = relative_target(config_dir, write.target)?; + if !targets.insert(target.clone()) { + return Err(anyhow!( + "restore transaction contains duplicate live targets" + )); + } + let staged = PathBuf::from("staged").join(index.to_string()); + // Payload staging happens before any live target can change + write_file_atomic(&transaction_dir.join(&staged), write.contents, write.mode) + .with_context(|| format!("stage restore payload for {}", write.label))?; + let previous = + snapshot_previous_file(write.target, &transaction_dir, index, write.label)?; + entries.push(RestoreJournalEntry { + target, + staged, + staged_size: u64::try_from(write.contents.len()).unwrap_or(u64::MAX), + previous, + }); + } + let journal = RestoreJournal { + schema_version: RESTORE_JOURNAL_SCHEMA, + transaction_dir: transaction_dir + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("restore transaction directory name is not UTF-8"))? + .to_string(), + entries, + }; + validate_journal(&journal)?; + let bytes = serde_json::to_vec_pretty(&journal).context("serialize restore journal")?; + if !journal_size_is_allowed(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) { + return Err(anyhow!("restore journal exceeds its safe byte limit")); + } + if !write_file_if_missing(&config_dir.join(RESTORE_JOURNAL_FILE), &bytes, 0o600) + .context("publish restore transaction journal")? + { + return Err(anyhow!("restore transaction journal already exists")); + } + Ok(journal) + })(); + if prepared.is_err() { + let _cleanup = remove_directory_tree(&transaction_dir); + } + prepared +} + +fn snapshot_previous_file( + target: &Path, + transaction_dir: &Path, + index: usize, + label: &str, +) -> Result { + let file = match open_regular_file(target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PreviousFile::Missing) + } + Err(error) => { + return match fs::symlink_metadata(target) { + Ok(_metadata) => Err(anyhow!("restore target is not a regular file: {label}")), + Err(metadata_error) => Err(error).context(format!( + "inspect restore target for {label}: {metadata_error}" + )), + } + } + }; + // One retained descriptor keeps rollback mode, length, and bytes on the same object + let metadata = file + .metadata() + .with_context(|| format!("inspect live restore target for {label}"))?; + if metadata.len() > MAX_RESTORE_FILE_BYTES { + return Err(anyhow!( + "live restore target exceeds its safe byte limit: {label}" + )); + } + let mut contents = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or(usize::MAX)); + file.take(MAX_RESTORE_FILE_BYTES.saturating_add(1)) + .read_to_end(&mut contents) + .with_context(|| format!("snapshot live restore target for {label}"))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) > MAX_RESTORE_FILE_BYTES { + return Err(anyhow!( + "live restore target grew beyond its safe byte limit: {label}" + )); + } + let rollback = PathBuf::from("rollback").join(index.to_string()); + let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o777; + write_file_atomic(&transaction_dir.join(&rollback), &contents, mode) + .with_context(|| format!("stage restore rollback for {label}"))?; + Ok(PreviousFile::Existing { + rollback, + size: u64::try_from(contents.len()).unwrap_or(u64::MAX), + mode, + }) +} + +fn rollback_transaction(config_dir: &Path, journal: &RestoreJournal) -> Result<()> { + let transaction_dir = config_dir.join(&journal.transaction_dir); + let mut errors = Vec::new(); + // Reverse order mirrors publication and limits partial dependency exposure + for entry in journal.entries.iter().rev() { + let target = config_dir.join(&entry.target); + let result = match &entry.previous { + PreviousFile::Missing => remove_regular_file(&target) + .map(|_removed| ()) + .map_err(anyhow::Error::from), + PreviousFile::Existing { + rollback, + size, + mode, + } => read_exact_transaction_file(&transaction_dir.join(rollback), *size).and_then( + |contents| { + write_file_atomic(&target, &contents, *mode).map_err(anyhow::Error::from) + }, + ), + }; + if let Err(error) = result { + // Every remaining target is attempted before reporting incomplete recovery + errors.push(format!("{}: {error}", target.display())); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "restore rollback was incomplete: {}", + errors.join("; ") + )) + } +} + +fn rollback_or_retain( + config_dir: &Path, + journal: &RestoreJournal, + operation_error: anyhow::Error, +) -> anyhow::Error { + match rollback_transaction(config_dir, journal) { + Ok(()) => match finish_transaction(config_dir, journal) { + Ok(()) => operation_error, + Err(cleanup_error) => operation_error.context(format!( + "restore rollback completed but journal cleanup failed: {cleanup_error:#}" + )), + }, + Err(rollback_error) => operation_error.context(format!( + "restore rollback was retained for recovery: {rollback_error:#}" + )), + } +} + +fn finish_transaction(config_dir: &Path, journal: &RestoreJournal) -> Result<()> { + remove_regular_file(&config_dir.join(RESTORE_JOURNAL_FILE)) + .context("remove committed restore journal")?; + // The journal is the authority, so scratch cleanup becomes harmless after its removal + let _cleanup = remove_directory_tree(&config_dir.join(&journal.transaction_dir)); + Ok(()) +} + +fn pending_journal(config_dir: &Path) -> Result> { + let path = config_dir.join(RESTORE_JOURNAL_FILE); + // Raw journal bytes are bounded before JSON allocation + let bytes = match read_regular_file_bounded(&path, MAX_RESTORE_JOURNAL_BYTES) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("read pending restore journal"), + }; + let journal = serde_json::from_slice(&bytes).context("parse pending restore journal")?; + validate_journal(&journal)?; + Ok(Some(journal)) +} + +fn validate_journal(journal: &RestoreJournal) -> Result<()> { + // Unknown schemas never gain filesystem authority + if journal.schema_version != RESTORE_JOURNAL_SCHEMA { + return Err(anyhow!( + "unsupported restore journal schema {}", + journal.schema_version + )); + } + validate_transaction_directory_name(&journal.transaction_dir)?; + let mut targets = HashSet::new(); + for entry in &journal.entries { + // Journal paths allow normal relative components only + validate_relative_path(&entry.target)?; + validate_relative_path(&entry.staged)?; + if !matches!(entry.staged.components().next(), Some(Component::Normal(root)) if root == "staged") + { + return Err(anyhow!("restore journal contains an invalid staged path")); + } + if !targets.insert(entry.target.clone()) { + return Err(anyhow!("restore journal contains duplicate live targets")); + } + if let PreviousFile::Existing { rollback, .. } = &entry.previous { + validate_relative_path(rollback)?; + if !matches!(rollback.components().next(), Some(Component::Normal(root)) if root == "rollback") + { + return Err(anyhow!("restore journal contains an invalid rollback path")); + } + } + } + Ok(()) +} + +fn relative_target(config_dir: &Path, target: &Path) -> Result { + let relative = target.strip_prefix(config_dir).map_err(|_error| { + anyhow!( + "restore target escapes the live config directory: {}", + target.display() + ) + })?; + validate_relative_path(relative)?; + Ok(relative.to_path_buf()) +} + +fn validate_relative_path(path: &Path) -> Result<()> { + if path.as_os_str().is_empty() + || !path + .components() + .all(|component| matches!(component, Component::Normal(_))) + { + return Err(anyhow!("restore journal contains an unsafe relative path")); + } + Ok(()) +} + +fn validate_transaction_directory_name(name: &str) -> Result<()> { + let path = Path::new(name); + if !name.starts_with(RESTORE_TRANSACTION_PREFIX) + || path.components().count() != 1 + || !matches!(path.components().next(), Some(Component::Normal(_))) + { + return Err(anyhow!( + "restore journal contains an unsafe transaction directory" + )); + } + Ok(()) +} + +const fn journal_size_is_allowed(size: u64) -> bool { + size <= MAX_RESTORE_JOURNAL_BYTES +} + +const fn transaction_file_size_is_allowed(size: u64) -> bool { + size <= MAX_RESTORE_FILE_BYTES +} + +fn reserve_transaction_directory(config_dir: &Path) -> Result { + // Process, time, counter, and bounded retry values avoid attacker-selected names + for attempt in 0..TRANSACTION_DIRECTORY_ATTEMPTS { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let counter = RESTORE_TRANSACTION_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = config_dir.join(format!( + "{RESTORE_TRANSACTION_PREFIX}{}-{nanos}-{counter}-{attempt}", + std::process::id() + )); + if create_directory_all(&path, 0o700)? == CreateDirectoryOutcome::TargetCreated { + return Ok(path); + } + } + Err(anyhow!( + "unable to reserve a unique restore transaction directory" + )) +} + +fn read_exact_transaction_file(path: &Path, expected_size: u64) -> Result> { + // Journal lengths remain bounded before reading staged or rollback content + if !transaction_file_size_is_allowed(expected_size) { + return Err(anyhow!( + "restore transaction file exceeds its safe byte limit" + )); + } + let contents = read_regular_file_bounded(path, expected_size) + .with_context(|| format!("read restore transaction file {}", path.display()))?; + if u64::try_from(contents.len()).unwrap_or(u64::MAX) != expected_size { + return Err(anyhow!("restore transaction file size changed")); + } + Ok(contents) +} + +#[cfg(test)] +#[path = "tests/restore_transaction.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/config/backup/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/retention.rs deleted file mode 100644 index 6c765a431..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/retention.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Backup directory creation and retention policy helpers - -use std::fs; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::Local; - -use crate::paths::format_with_home; - -use super::super::super::{log_line, ActionContext}; - -pub(in crate::actions::config::backup) const BACKUP_PREFIX: &str = "Backup-"; - -pub(in crate::actions::config) fn create_backup_dir( - ctx: &mut ActionContext, - config_dir: &Path, - keep: usize, -) -> Result> { - if keep == 0 { - log_line(ctx, "Backups disabled (installer.toml keep = 0)"); - return Ok(None); - } - - // Each reset gets its own dated directory so filenames stay simple - let stamp = backup_stamp_from_system_time()?; - let base_name = format!("{BACKUP_PREFIX}{stamp}"); - let mut candidate = config_dir.join(base_name); - - // If a backup already exists for that day, add a zero-padded suffix - let mut suffix = 1; - while candidate.exists() { - candidate = config_dir.join(format!("{BACKUP_PREFIX}{stamp}-{suffix:03}")); - suffix += 1; - } - - fs::create_dir_all(&candidate).with_context(|| "failed to create backup directory")?; - log_line( - ctx, - format!("Backup directory created: {}", format_with_home(&candidate)), - ); - - prune_old_backups_except(ctx, config_dir, keep, Some(candidate.as_path()))?; - Ok(Some(candidate)) -} - -pub(in crate::actions::config::backup) fn list_backup_dirs(config_dir: &Path) -> Vec { - let Ok(entries) = fs::read_dir(config_dir) else { - return Vec::new(); - }; - - entries - .filter_map(std::result::Result::ok) - .filter_map(|entry| { - let file_type = entry.file_type().ok()?; - if !file_type.is_dir() { - return None; - } - let name = entry.file_name(); - let name = name.to_string_lossy(); - if !name.starts_with(BACKUP_PREFIX) { - return None; - } - Some(entry.path()) - }) - .collect() -} - -pub(in crate::actions::config::backup) fn prune_old_backups_except( - ctx: &mut ActionContext, - config_dir: &Path, - keep: usize, - protected_backup: Option<&Path>, -) -> Result<()> { - if keep == 0 { - return Ok(()); - } - - let mut backups = list_backup_dirs(config_dir); - // YYYY-MM-DD names and zero-padded suffixes sort in age order - backups.sort(); - - if backups.len() <= keep { - return Ok(()); - } - - let mut excess = backups.len().saturating_sub(keep); - for path in backups { - if excess == 0 { - break; - } - if protected_backup.is_some_and(|protected| protected == path) { - continue; - } - if let Err(err) = fs::remove_dir_all(&path) { - log_line( - ctx, - format!( - "Warning: failed to remove old backup {}: {}", - format_with_home(&path), - err - ), - ); - } else { - log_line( - ctx, - format!("Removed old backup {}", format_with_home(&path)), - ); - } - excess -= 1; - } - - Ok(()) -} - -fn backup_stamp_from_system_time() -> Result { - // Use chrono for a stable YYYY-MM-DD stamp without hand-rolled time math - Ok(Local::now().format("%Y-%m-%d").to_string()) -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/settings.rs b/crates/unixnotis-installer/src/actions/config/backup/settings.rs index 9090f1623..27a505671 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/settings.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/settings.rs @@ -1,48 +1,20 @@ //! Installer backup settings and config file helpers -use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; -use serde::Deserialize; - use crate::paths::format_with_home; +use anyhow::Result; use super::super::super::{log_line, ActionContext}; -use super::write::write_atomic; - -const INSTALLER_CONFIG_FILE: &str = "installer.toml"; -const INSTALLER_CONFIG_TEMPLATE: &str = r"# UnixNotis installer settings -# Backup retention for config/theme resets -[backups] -keep = 3 -"; - -#[derive(Debug, Default, Deserialize)] -#[serde(default)] -pub(in crate::actions::config) struct InstallerConfig { - pub(in crate::actions::config) backups: BackupConfig, -} -#[derive(Debug, Deserialize)] -#[serde(default)] -pub(in crate::actions::config) struct BackupConfig { - // Number of dated backup directories to keep in the config root - pub(in crate::actions::config) keep: usize, -} - -impl Default for BackupConfig { - fn default() -> Self { - Self { keep: 3 } - } -} +pub(in crate::actions::config) use unixnotis_core::InstallerConfig; pub(in crate::actions::config) fn ensure_installer_config( ctx: &mut ActionContext, config_dir: &Path, ) -> Result { - let config_path = config_dir.join(INSTALLER_CONFIG_FILE); - if config_path.exists() { + let (config_path, created) = unixnotis_core::ensure_installer_config(config_dir)?; + if !created { log_line( ctx, format!( @@ -53,8 +25,6 @@ pub(in crate::actions::config) fn ensure_installer_config( return Ok(config_path); } - write_atomic(&config_path, INSTALLER_CONFIG_TEMPLATE) - .with_context(|| "failed to write installer.toml")?; log_line( ctx, format!( @@ -67,25 +37,6 @@ pub(in crate::actions::config) fn ensure_installer_config( pub(in crate::actions::config) fn load_installer_config( config_dir: &Path, - ctx: &mut ActionContext, -) -> InstallerConfig { - let config_path = config_dir.join(INSTALLER_CONFIG_FILE); - let Ok(contents) = fs::read_to_string(&config_path) else { - return InstallerConfig::default(); - }; - - match toml::from_str(&contents) { - Ok(config) => config, - Err(err) => { - log_line( - ctx, - format!( - "Warning: invalid installer config at {}: {}", - format_with_home(&config_path), - err - ), - ); - InstallerConfig::default() - } - } +) -> Result { + unixnotis_core::load_installer_config(config_dir) } diff --git a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs index 684f4b576..96bbd15a2 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/snapshot.rs @@ -1,47 +1,18 @@ //! Backup snapshot helpers for config and theme files -use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use anyhow::{Context, Result}; use unixnotis_core::Config; -use crate::paths::format_with_home; - -use super::super::super::{log_line, ActionContext}; -use super::retention::list_backup_dirs; - -pub(in crate::actions::config) fn backup_existing_file( - ctx: &mut ActionContext, - path: &Path, - label: &str, - backup_dir: Option<&Path>, -) -> Result<()> { - if !path.exists() { - return Ok(()); - } - - let Some(backup_dir) = backup_dir else { - return Ok(()); - }; - - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let backup_path = backup_dir.join(file_name.as_ref()); - - // Copy first so the live file stays intact until replacement succeeds - fs::copy(path, &backup_path).with_context(|| format!("failed to backup {label}"))?; - log_line( - ctx, - format!("Backed up {} to {}", label, format_with_home(&backup_path)), - ); - Ok(()) -} +use super::listing::list_backup_dirs; pub fn list_backup_dirs_for_ui() -> Vec { + // The restore screen remains usable when default path discovery fails let Ok(config_dir) = Config::default_config_dir() else { return Vec::new(); }; + // Stable ordering keeps keyboard selection and redraws predictable let mut backups = list_backup_dirs(&config_dir); backups.sort(); backups diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs new file mode 100644 index 000000000..043c70d01 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/listing.rs @@ -0,0 +1,22 @@ +use std::fs; +use std::path::PathBuf; + +use super::super::listing::list_backup_dirs; + +#[test] +fn list_backup_dirs_filters_non_backup_entries_and_files() { + let root = PathBuf::from("target").join(format!( + "unixnotis-installer-backup-list-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + let _ = fs::create_dir_all(&root); + fs::create_dir_all(root.join("Backup-2026-06-01")).expect("backup dir"); + fs::create_dir_all(root.join("Other-2026-06-01")).expect("foreign dir"); + fs::write(root.join("Backup-2026-06-02"), "not a dir").expect("backup-like file"); + + let backups = list_backup_dirs(&root); + + assert_eq!(backups, vec![root.join("Backup-2026-06-01")]); + let _ = fs::remove_dir_all(&root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs index fbf70fde4..76fc13407 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/mod.rs @@ -1,3 +1,4 @@ +mod listing; mod restore; -mod retention; -mod write; +mod settings; +mod support; diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs index 46afd30f2..894b34f1b 100644 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore.rs @@ -1,12 +1,14 @@ use super::super::restore::{is_restore_target_allowed, restore_config}; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; +use crate::test_support::current_config_text; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; +use unixnotis_core::{reset_config_to_defaults, ResetConfigOptions, DEFAULT_SCRIPTS}; #[test] fn restore_config_uses_restored_theme_paths() { @@ -29,7 +31,11 @@ popup_css = "themes/custom/popup.css" widgets_css = "themes/custom/widgets.css" media_css = "themes/custom/media.css" "#; - fs::write(backup_dir.join("config.toml"), config_toml).expect("write config"); + fs::write( + backup_dir.join("config.toml"), + current_config_text(config_toml), + ) + .expect("write config"); fs::write(backup_dir.join("base.css"), "base").expect("write base"); fs::write(backup_dir.join("panel.css"), "panel").expect("write panel"); fs::write(backup_dir.join("popup.css"), "popup").expect("write popup"); @@ -37,14 +43,9 @@ media_css = "themes/custom/media.css" fs::write(backup_dir.join("media.css"), "media").expect("write media"); // Restore path selection is driven through ActionContext just like runtime - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -75,9 +76,9 @@ media_css = "themes/custom/media.css" #[test] fn restore_target_guard_blocks_paths_outside_config_dir() { // Guard should allow in-tree writes and reject out-of-tree targets - let config_dir = PathBuf::from("/tmp/unixnotis-restore-guard"); + let config_dir = std::env::temp_dir().join("unixnotis-restore-guard"); let inside = config_dir.join("themes/base.css"); - let outside = PathBuf::from("/tmp/unixnotis-escape.css"); + let outside = std::env::temp_dir().join("unixnotis-escape.css"); assert!(is_restore_target_allowed(&config_dir, &inside)); assert!(!is_restore_target_allowed(&config_dir, &outside)); } @@ -104,21 +105,20 @@ fn restore_config_skips_absolute_theme_targets() { "[theme]\nbase_css = \"{}\"\npanel_css = \"panel.css\"\npopup_css = \"popup.css\"\nwidgets_css = \"widgets.css\"\nmedia_css = \"media.css\"\n", escaped_target.display() ); - fs::write(backup_dir.join("config.toml"), config_toml).expect("write config"); + fs::write( + backup_dir.join("config.toml"), + current_config_text(&config_toml), + ) + .expect("write config"); fs::write(backup_dir.join("base.css"), "base").expect("write base"); fs::write(backup_dir.join("panel.css"), "panel").expect("write panel"); fs::write(backup_dir.join("popup.css"), "popup").expect("write popup"); fs::write(backup_dir.join("widgets.css"), "widgets").expect("write widgets"); fs::write(backup_dir.join("media.css"), "media").expect("write media"); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -142,3 +142,185 @@ fn restore_config_skips_absolute_theme_targets() { let _ = fs::remove_file(&escaped_target); let _ = fs::remove_dir_all(&root); } + +#[test] +fn restore_config_restores_all_bundled_scripts_and_executable_modes() { + let _lock = crate::test_support::env::test_env_lock(); + let root = PathBuf::from("target").join(format!( + "unixnotis-installer-script-restore-test-{}", + std::process::id() + )); + let config_dir = root.join("unixnotis"); + fs::create_dir_all(config_dir.join("scripts")).expect("create script directory"); + fs::write(config_dir.join("config.toml"), current_config_text("")).expect("write config"); + + // Seed every bundled script with distinct user content and non-default permissions + for (index, script) in DEFAULT_SCRIPTS.iter().enumerate() { + let path = config_dir.join(script.relative_path); + fs::write(&path, format!("custom script {index}\n")).expect("write custom script"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("set custom script mode"); + } + + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: config_dir.clone(), + backup_retention: 1, + }) + .expect("reset should create a restorable script backup"); + let backup_dir = report.backup_dir.expect("reset backup directory"); + + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(16); + let mut ctx = crate::actions::ActionContext { + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + restore_config(&mut ctx).expect("restore should restore bundled scripts"); + + for (index, script) in DEFAULT_SCRIPTS.iter().enumerate() { + let path = config_dir.join(script.relative_path); + assert_eq!( + fs::read_to_string(&path).expect("read restored script"), + format!("custom script {index}\n") + ); + assert_eq!( + fs::metadata(&path) + .expect("restored script metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); + } + + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn malformed_backup_config_fails_before_any_live_file_changes() { + let _lock = crate::test_support::env::test_env_lock(); + let root = std::env::temp_dir().join(format!( + "unixnotis-invalid-restore-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos() + )); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-invalid"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + fs::write( + config_dir.join("config.toml"), + current_config_text("[theme]\nbase_css = \"live.css\"\n"), + ) + .expect("write live config"); + fs::write(config_dir.join("live.css"), "live theme\n").expect("write live theme"); + fs::write(backup_dir.join("config.toml"), "config_version = 5\n[") + .expect("write malformed backup config"); + fs::write(backup_dir.join("base.css"), "backup theme\n").expect("write backup theme"); + let before = snapshot_tree(&config_dir); + + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut ctx = crate::actions::ActionContext { + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = restore_config(&mut ctx).expect_err("malformed backup must fail closed"); + + assert!(error.to_string().contains("not valid schema v5")); + assert_eq!( + snapshot_tree(&config_dir), + before, + "validation failure must leave the complete live tree unchanged" + ); + fs::remove_dir_all(root).expect("remove restore test root"); +} + +#[test] +fn restore_target_snapshot_failure_happens_before_any_file_is_published() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-rollback"); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-snapshot-rollback"); + fs::create_dir_all(&backup_dir).expect("create backup directory"); + let original_config = current_config_text("[theme]\npanel_css = \"live-panel.css\"\n"); + fs::write(config_dir.join("config.toml"), &original_config).expect("write live config"); + fs::write(config_dir.join("live-panel.css"), "live panel\n").expect("write live panel"); + fs::create_dir(config_dir.join("blocked-panel.css")).expect("create invalid target directory"); + fs::write( + backup_dir.join("config.toml"), + current_config_text("[theme]\npanel_css = \"blocked-panel.css\"\n"), + ) + .expect("write backup config"); + fs::write(backup_dir.join("panel.css"), "restored panel\n").expect("write backup panel"); + let before = snapshot_tree(&config_dir); + + let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut ctx = crate::actions::ActionContext { + paths: &paths, + install_state: None, + log_tx: tx, + action_mode: ActionMode::Install, + restore_backup: Some(backup_dir), + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = restore_config(&mut ctx) + .expect_err("an invalid later target must roll back an earlier config replacement"); + + assert!(error + .to_string() + .contains("restore target is not a regular file")); + assert_eq!( + snapshot_tree(&config_dir), + before, + "snapshot failure must happen before any live file is published" + ); + fs::remove_dir_all(root).expect("remove restore rollback fixture"); +} + +fn snapshot_tree(root: &std::path::Path) -> Vec<(PathBuf, Vec, u32)> { + fn visit( + root: &std::path::Path, + directory: &std::path::Path, + snapshot: &mut Vec<(PathBuf, Vec, u32)>, + ) { + let mut entries = fs::read_dir(directory) + .expect("read snapshot directory") + .collect::, _>>() + .expect("collect snapshot entries"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).expect("snapshot metadata"); + if metadata.is_dir() { + visit(root, &path, snapshot); + } else { + snapshot.push(( + path.strip_prefix(root) + .expect("snapshot relative path") + .to_path_buf(), + fs::read(&path).expect("snapshot file"), + metadata.permissions().mode() & 0o777, + )); + } + } + } + + let mut snapshot = Vec::new(); + visit(root, root, &mut snapshot); + snapshot +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs new file mode 100644 index 000000000..f6cc26b91 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_transaction.rs @@ -0,0 +1,319 @@ +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use super::super::restore::MAX_RESTORE_FILE_BYTES; +use super::{ + apply_restore_transaction, apply_restore_transaction_with_writer, journal_size_is_allowed, + pending_journal, prepare_restore_transaction, read_exact_transaction_file, + recover_pending_restore, snapshot_previous_file, transaction_file_size_is_allowed, + validate_journal, validate_relative_path, validate_transaction_directory_name, PreviousFile, + RestoreJournal, RestoreJournalEntry, RestoreWrite, MAX_RESTORE_JOURNAL_BYTES, +}; + +#[test] +fn restore_transaction_rolls_back_every_published_file_after_a_late_failure() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-rollback"); + fs::create_dir_all(&root).expect("create restore transaction fixture"); + let first = root.join("first.css"); + let second = root.join("second.css"); + fs::write(&first, "old first").expect("write first live file"); + fs::write(&second, "old second").expect("write second live file"); + fs::set_permissions(&first, fs::Permissions::from_mode(0o640)).expect("set first live mode"); + let writes = [ + RestoreWrite { + label: "first.css", + target: &first, + mode: 0o644, + contents: b"new first", + }, + RestoreWrite { + label: "second.css", + target: &second, + mode: 0o644, + contents: b"new second", + }, + ]; + let mut calls = 0usize; + + let error = apply_restore_transaction_with_writer( + &root, + &writes, + || Ok(()), + |target, contents, mode| { + calls = calls.saturating_add(1); + if calls == 2 { + return Err(io::Error::other("injected second publish failure")); + } + unixnotis_core::filesystem::write_file_atomic(target, contents, mode) + }, + ) + .expect_err("a late publish failure must fail the complete restore"); + + assert!(error.to_string().contains("failed to restore second.css")); + assert_eq!( + fs::read_to_string(&first).expect("read restored first"), + "old first" + ); + assert_eq!( + fs::read_to_string(&second).expect("read restored second"), + "old second" + ); + assert_eq!( + fs::metadata(&first) + .expect("inspect restored first") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert!(pending_journal(&root) + .expect("inspect pending journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore transaction fixture"); +} + +#[test] +fn failed_restore_removes_a_new_file_published_before_the_failure() { + let root = crate::test_support::fs::unique_temp_path("restore-created-rollback"); + fs::create_dir_all(&root).expect("create new-file rollback fixture"); + let created = root.join("created.css"); + let blocker = root.join("blocker.css"); + fs::write(&blocker, "old blocker").expect("write blocker file"); + let writes = [ + RestoreWrite { + label: "created.css", + target: &created, + mode: 0o644, + contents: b"new created", + }, + RestoreWrite { + label: "blocker.css", + target: &blocker, + mode: 0o644, + contents: b"new blocker", + }, + ]; + let mut calls = 0usize; + + apply_restore_transaction_with_writer( + &root, + &writes, + || Ok(()), + |target, contents, mode| { + calls = calls.saturating_add(1); + if calls == 2 { + return Err(io::Error::other("injected blocker failure")); + } + unixnotis_core::filesystem::write_file_atomic(target, contents, mode) + }, + ) + .expect_err("failed restore must remove a newly published target"); + + assert!(!created.exists()); + assert_eq!( + fs::read_to_string(blocker).expect("read blocker"), + "old blocker" + ); + fs::remove_dir_all(root).expect("remove new-file rollback fixture"); +} + +#[test] +fn restore_snapshot_rejects_special_targets_and_nonmissing_lookup_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-errors"); + fs::create_dir_all(&root).expect("create restore snapshot error fixture"); + let directory = root.join("directory.css"); + fs::create_dir(&directory).expect("create directory target"); + let directory_write = [RestoreWrite { + label: "directory.css", + target: &directory, + mode: 0o644, + contents: b"new", + }]; + let directory_error = apply_restore_transaction(&root, &directory_write, || Ok(())) + .expect_err("directory target must fail before publication"); + assert!(directory_error + .to_string() + .contains("restore target is not a regular file")); + + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("write invalid parent"); + let invalid_target = regular_parent.join("child"); + let invalid_write = [RestoreWrite { + label: "child", + target: &invalid_target, + mode: 0o644, + contents: b"new", + }]; + assert!(apply_restore_transaction(&root, &invalid_write, || Ok(())).is_err()); + assert!(pending_journal(&root) + .expect("inspect failed journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore snapshot error fixture"); +} + +#[test] +fn pending_restore_probe_propagates_nonmissing_journal_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-journal-probe-error"); + fs::create_dir_all(&root).expect("create restore journal error fixture"); + fs::create_dir(root.join(".unixnotis-restore-pending.json")) + .expect("create invalid journal directory"); + + assert!(recover_pending_restore(&root).is_err()); + fs::remove_dir_all(root).expect("remove restore journal error fixture"); +} + +#[test] +fn restore_transaction_byte_domains_accept_the_exact_limit_only() { + assert!(journal_size_is_allowed(MAX_RESTORE_JOURNAL_BYTES)); + assert!(!journal_size_is_allowed( + MAX_RESTORE_JOURNAL_BYTES.saturating_add(1) + )); + assert!(transaction_file_size_is_allowed(MAX_RESTORE_FILE_BYTES)); + assert!(!transaction_file_size_is_allowed( + MAX_RESTORE_FILE_BYTES.saturating_add(1) + )); +} + +#[test] +fn restore_snapshot_accepts_a_live_file_at_the_exact_byte_limit() { + let root = crate::test_support::fs::unique_temp_path("restore-snapshot-exact-limit"); + let transaction = root.join("transaction"); + fs::create_dir_all(transaction.join("rollback")).expect("create rollback directory"); + let target = root.join("config.toml"); + let file = fs::File::create(&target).expect("create exact-limit live file"); + file.set_len(MAX_RESTORE_FILE_BYTES) + .expect("size exact-limit live file"); + + let previous = snapshot_previous_file(&target, &transaction, 0, "config.toml") + .expect("snapshot exact-limit live file"); + + assert!(matches!( + previous, + PreviousFile::Existing { + size: MAX_RESTORE_FILE_BYTES, + .. + } + )); + fs::remove_dir_all(root).expect("remove exact-limit snapshot fixture"); +} + +#[test] +fn restore_journal_validation_rejects_unsafe_paths_names_schemas_and_duplicates() { + assert!(validate_relative_path(Path::new("theme/panel.css")).is_ok()); + assert!(validate_relative_path(Path::new("")).is_err()); + assert!(validate_relative_path(Path::new("../outside")).is_err()); + assert!(validate_transaction_directory_name(".unixnotis-restore-safe").is_ok()); + assert!(validate_transaction_directory_name("wrong-prefix").is_err()); + assert!(validate_transaction_directory_name(".unixnotis-restore-bad/child").is_err()); + + let entry = RestoreJournalEntry { + target: PathBuf::from("config.toml"), + staged: PathBuf::from("staged/0"), + staged_size: 0, + previous: PreviousFile::Missing, + }; + let mut journal = RestoreJournal { + schema_version: 1, + transaction_dir: ".unixnotis-restore-safe".to_string(), + entries: vec![entry], + }; + assert!(validate_journal(&journal).is_ok()); + journal.schema_version = 2; + assert!(validate_journal(&journal).is_err()); + journal.schema_version = 1; + journal.entries[0].staged = PathBuf::from("rollback/0"); + assert!(validate_journal(&journal).is_err()); + journal.entries[0].staged = PathBuf::from("staged/0"); + journal.entries.push(RestoreJournalEntry { + target: PathBuf::from("config.toml"), + staged: PathBuf::from("staged/1"), + staged_size: 0, + previous: PreviousFile::Existing { + rollback: PathBuf::from("wrong/1"), + size: 0, + mode: 0o644, + }, + }); + assert!(validate_journal(&journal).is_err()); + journal.entries[1].target = PathBuf::from("other.css"); + assert!(validate_journal(&journal).is_err()); +} + +#[test] +fn interrupted_restore_journal_restores_original_files_on_recovery() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-recovery"); + fs::create_dir_all(&root).expect("create restore recovery fixture"); + let target = root.join("config.toml"); + fs::write(&target, "old config").expect("write old config"); + let writes = [RestoreWrite { + label: "config.toml", + target: &target, + mode: 0o644, + contents: b"new config", + }]; + + let journal = prepare_restore_transaction(&root, &writes).expect("prepare restore journal"); + let staged = read_exact_transaction_file( + &root + .join(&journal.transaction_dir) + .join(&journal.entries[0].staged), + journal.entries[0].staged_size, + ) + .expect("read staged config"); + unixnotis_core::filesystem::write_file_atomic(&target, &staged, 0o644) + .expect("simulate published config before process exit"); + assert_eq!( + fs::read_to_string(&target).expect("read interrupted config"), + "new config" + ); + + assert!(recover_pending_restore(&root).expect("recover interrupted restore")); + assert_eq!( + fs::read_to_string(&target).expect("read recovered config"), + "old config" + ); + assert!(pending_journal(&root) + .expect("inspect recovered journal") + .is_none()); + fs::remove_dir_all(root).expect("remove restore recovery fixture"); +} + +#[test] +fn successful_restore_commits_all_files_and_removes_its_journal() { + let root = crate::test_support::fs::unique_temp_path("restore-transaction-success"); + fs::create_dir_all(&root).expect("create successful restore fixture"); + let existing = root.join("existing.css"); + let created = root.join("created.css"); + fs::write(&existing, "old").expect("write existing file"); + let writes = [ + RestoreWrite { + label: "existing.css", + target: &existing, + mode: 0o644, + contents: b"new existing", + }, + RestoreWrite { + label: "created.css", + target: &created, + mode: 0o600, + contents: b"new created", + }, + ]; + + apply_restore_transaction(&root, &writes, || Ok(())).expect("commit restore transaction"); + + assert_eq!( + fs::read_to_string(existing).expect("read existing file"), + "new existing" + ); + assert_eq!( + fs::read_to_string(created).expect("read created file"), + "new created" + ); + assert!(pending_journal(&root) + .expect("inspect committed journal") + .is_none()); + fs::remove_dir_all(root).expect("remove successful restore fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs new file mode 100644 index 000000000..30947b227 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/restore_validation.rs @@ -0,0 +1,108 @@ +use std::fs; +use std::path::PathBuf; + +use super::{ + backup_entry_exists, build_restore_plan, read_backup_file_bounded, reject_duplicate_targets, + validate_backup_directory_name, RestoreFile, MAX_RESTORE_FILE_BYTES, +}; + +#[test] +fn restore_file_budget_keeps_its_declared_byte_domain() { + assert_eq!(MAX_RESTORE_FILE_BYTES, 16_777_216); +} + +#[test] +fn restore_source_reader_accepts_exact_limit_and_rejects_one_extra_byte() { + let root = crate::test_support::fs::unique_temp_path("restore-reader-boundary"); + fs::create_dir_all(&root).expect("create restore reader fixture"); + let source = root.join("source"); + fs::write(&source, vec![b'x'; 4_096]).expect("write exact-limit source"); + + assert_eq!( + read_backup_file_bounded(&source, 4_096) + .expect("exact restore source limit") + .len(), + 4_096 + ); + fs::write(&source, vec![b'x'; 4_097]).expect("write oversized source"); + assert!(read_backup_file_bounded(&source, 4_096).is_err()); + fs::remove_dir_all(root).expect("remove restore reader fixture"); +} + +#[test] +fn backup_directory_validation_rejects_unrecognized_names() { + assert!(validate_backup_directory_name(&PathBuf::from("Backup-valid")).is_ok()); + assert!(validate_backup_directory_name(&PathBuf::from("unrecognized")).is_err()); +} + +#[test] +fn duplicate_restore_targets_are_rejected_before_commit() { + let target = std::env::temp_dir().join("unixnotis-duplicate-restore-target"); + let files = [ + RestoreFile { + label: "config.toml".to_string(), + target: target.clone(), + mode: 0o644, + contents: Vec::new(), + }, + RestoreFile { + label: "base.css".to_string(), + target, + mode: 0o644, + contents: Vec::new(), + }, + ]; + + assert!(reject_duplicate_targets(&files).is_err()); +} + +#[test] +fn backup_entry_probe_propagates_lookup_errors() { + let root = crate::test_support::fs::unique_temp_path("restore-entry-probe-error"); + fs::create_dir_all(&root).expect("create restore probe fixture"); + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("write invalid parent"); + + assert!( + backup_entry_exists(®ular_parent.join("target")).is_err(), + "lookup errors must not become absent backup entries" + ); + fs::remove_dir_all(root).expect("remove restore probe fixture"); +} + +#[test] +fn restore_plan_publishes_supporting_payloads_before_config() { + let root = crate::test_support::fs::unique_temp_path("restore-config-last"); + let config_dir = root.join("unixnotis"); + let backup_dir = config_dir.join("Backup-config-last"); + fs::create_dir_all(&backup_dir).expect("create restore plan fixture"); + fs::write( + backup_dir.join("config.toml"), + crate::test_support::current_config_text(""), + ) + .expect("write backup config"); + fs::write(backup_dir.join("base.css"), "restored base\n") + .expect("write supporting theme payload"); + + let plan = build_restore_plan(&backup_dir, &config_dir).expect("build restore plan"); + let labels = plan + .files + .iter() + .map(|file| file.label.as_str()) + .collect::>(); + let base_index = labels + .iter() + .position(|label| *label == "base.css") + .expect("supporting base theme in plan"); + let config_index = labels + .iter() + .position(|label| *label == "config.toml") + .expect("config in plan"); + + assert_eq!(labels.last(), Some(&"config.toml")); + assert!( + base_index < config_index, + "supporting theme payload must publish before its config reference" + ); + fs::remove_dir_all(root).expect("remove restore plan fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs deleted file mode 100644 index c54c5c5ec..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/retention.rs +++ /dev/null @@ -1,190 +0,0 @@ -use super::super::create_backup_dir; -use super::super::retention::{list_backup_dirs, prune_old_backups_except}; -use super::super::settings::BackupConfig; -use crate::app::events::UiMessage; -use crate::detect::Detection; -use crate::model::ActionMode; -use crate::paths::InstallPaths; -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{mpsc, Arc}; - -fn prune_old_backups( - ctx: &mut crate::actions::ActionContext, - config_dir: &std::path::Path, - keep: usize, -) -> anyhow::Result<()> { - // Direct retention tests do not need to protect a newly created backup - prune_old_backups_except(ctx, config_dir, keep, None) -} - -#[test] -fn prune_old_backups_keeps_newest() { - let _lock = crate::test_support::env::test_env_lock(); - // Backup names are date-ordered, so lexical sort can drive retention - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-prune-test-{}", - std::process::id() - )); - let _ = fs::create_dir_all(&root); - let names = [ - "Backup-2024-01-01", - "Backup-2024-01-02", - "Backup-2024-01-03", - "Backup-2024-01-04", - ]; - for name in names { - let _ = fs::create_dir_all(root.join(name)); - } - - // Minimal installer context for pruning logic - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - prune_old_backups(&mut ctx, &root, 2).expect("prune should succeed"); - - // Only the two newest entries should remain - let mut remaining = list_backup_dirs(&root) - .into_iter() - .map(|path: std::path::PathBuf| { - path.file_name() - .expect("backup directory should have a file name") - .to_string_lossy() - .to_string() - }) - .collect::>(); - remaining.sort(); - assert_eq!( - remaining, - vec![ - "Backup-2024-01-03".to_string(), - "Backup-2024-01-04".to_string() - ] - ); - - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn backup_config_defaults_to_three() { - // Default retention should match installer template behavior - let config = BackupConfig::default(); - assert_eq!(config.keep, 3); -} - -#[test] -fn create_backup_dir_keeps_new_directory_when_retention_is_full() { - let _lock = crate::test_support::env::test_env_lock(); - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-create-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - for name in [ - "Backup-2026-05-31-003", - "Backup-2026-05-31-004", - "Backup-2026-05-31-005", - ] { - let _ = fs::create_dir_all(root.join(name)); - } - - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let backup_dir = create_backup_dir(&mut ctx, &root, 3) - .expect("backup directory should be created") - .expect("backups should be enabled"); - - assert!( - backup_dir.exists(), - "new backup directory must survive retention pruning" - ); - assert_eq!(list_backup_dirs(&root).len(), 3); - - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn create_backup_dir_returns_none_when_retention_is_disabled() { - let _lock = crate::test_support::env::test_env_lock(); - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-disabled-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); - let (tx, rx) = mpsc::sync_channel::(8); - let mut ctx = crate::actions::ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let backup = create_backup_dir(&mut ctx, &root, 0).expect("disabled backups should succeed"); - - // keep = 0 is an explicit opt-out and must not create a backup directory - assert!(backup.is_none()); - assert!(list_backup_dirs(&root).is_empty()); - let log = rx.try_recv().expect("disabled backup log"); - assert!(matches!( - log, - UiMessage::Worker(crate::app::events::WorkerEvent::LogLine(message)) - if message.contains("Backups disabled") - )); - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn list_backup_dirs_filters_non_backup_entries_and_files() { - let root = PathBuf::from("target").join(format!( - "unixnotis-installer-backup-list-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let _ = fs::create_dir_all(&root); - fs::create_dir_all(root.join("Backup-2026-06-01")).expect("backup dir"); - fs::create_dir_all(root.join("Other-2026-06-01")).expect("foreign dir"); - fs::write(root.join("Backup-2026-06-02"), "not a dir").expect("backup-like file"); - - let backups = list_backup_dirs(&root); - - // Restore UI must show only installer backup directories, not similarly named files - assert_eq!(backups, vec![root.join("Backup-2026-06-01")]); - let _ = fs::remove_dir_all(&root); -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs new file mode 100644 index 000000000..4157fdb8a --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/settings.rs @@ -0,0 +1,40 @@ +//! Installer settings file tests + +use std::fs; + +use crate::detect::Detection; + +use super::super::settings::ensure_installer_config; +use super::support::{test_context, test_paths}; + +#[test] +fn installer_config_is_created_once_and_preserves_existing_settings() { + let root = crate::test_support::fs::unique_temp_path("installer-settings"); + let config_dir = root.join("config"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + + let config_path = ensure_installer_config(&mut context, &config_dir) + .expect("installer config should be created"); + + assert_eq!(config_path, config_dir.join("installer.toml")); + assert_eq!( + fs::read_to_string(&config_path).expect("read installer config"), + "# UnixNotis installer settings\n# Backup retention for config/theme resets\n[backups]\nkeep = 3\n" + ); + + fs::write(&config_path, "[backups]\nkeep = 9\n").expect("customize installer config"); + let retained = ensure_installer_config(&mut context, &config_dir) + .expect("existing installer config should be retained"); + + assert_eq!(retained, config_path); + assert_eq!( + fs::read_to_string(&retained).expect("read retained installer config"), + "[backups]\nkeep = 9\n" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs new file mode 100644 index 000000000..680754a0f --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/backup/tests/support.rs @@ -0,0 +1,32 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +pub(super) fn test_paths(root: &std::path::Path) -> InstallPaths { + InstallPaths { + repo_root: root.to_path_buf(), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user(root.join("service")), + } +} + +pub(super) fn test_context<'a>( + _detection: &'a Detection, + paths: &'a InstallPaths, +) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(8); + ActionContext { + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} diff --git a/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs b/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs deleted file mode 100644 index df8985641..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/tests/write.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::super::write::{atomic_temp_path, write_atomic}; -use std::fs; -use std::path::PathBuf; - -#[cfg(unix)] -use std::os::unix::fs::symlink; - -#[cfg(unix)] -#[test] -fn write_atomic_bypasses_preexisting_temp_symlink_without_touching_it() { - let root = test_root("backup-atomic-temp-symlink"); - let target = root.join("config.toml"); - let protected = root.join("protected"); - let temp_path = atomic_temp_path(&target); - fs::write(&target, "old").expect("target"); - fs::write(&protected, "protected").expect("protected"); - symlink(&protected, &temp_path).expect("temp symlink"); - - write_atomic(&target, "new").expect("alternate temp path"); - - assert_eq!(fs::read_to_string(&target).expect("target updated"), "new"); - assert_eq!( - fs::read_to_string(&protected).expect("protected remains"), - "protected" - ); - assert!(fs::symlink_metadata(&temp_path) - .expect("temp remains") - .file_type() - .is_symlink()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn write_atomic_bypasses_stale_temp_regular_file() { - let root = test_root("backup-atomic-temp-regular"); - let target = root.join("config.toml"); - let temp_path = atomic_temp_path(&target); - fs::write(&target, "old").expect("target"); - fs::write(&temp_path, "stale").expect("stale temp"); - - write_atomic(&target, "new").expect("alternate temp path"); - - assert_eq!(fs::read_to_string(&target).expect("target updated"), "new"); - assert_eq!( - fs::read_to_string(&temp_path).expect("temp remains"), - "stale" - ); - let _ = fs::remove_dir_all(root); -} - -fn test_root(name: &str) -> PathBuf { - // Target-local roots keep symlink tests contained inside the repository build directory - let root = - PathBuf::from("target").join(format!("unixnotis-installer-{name}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("test root"); - root -} diff --git a/crates/unixnotis-installer/src/actions/config/backup/write.rs b/crates/unixnotis-installer/src/actions/config/backup/write.rs deleted file mode 100644 index fa2f45742..000000000 --- a/crates/unixnotis-installer/src/actions/config/backup/write.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Shared atomic writes for backup-related file updates - -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::Path; - -pub fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> { - // A sibling temp file avoids leaving a partially written target behind - let (temp_path, mut temp_file) = create_atomic_temp_file(path)?; - temp_file - .write_all(contents.as_bytes()) - .inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - })?; - temp_file.flush().inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - })?; - drop(temp_file); - fs::rename(&temp_path, path).inspect_err(|_err| { - let _ = fs::remove_file(&temp_path); - }) -} - -pub(super) fn atomic_temp_path(path: &Path) -> std::path::PathBuf { - // The name stays beside the target so the final rename remains on the same filesystem - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let temp_name = format!("{file_name}.tmp-{}", std::process::id()); - path.with_file_name(temp_name) -} - -fn create_atomic_temp_file(path: &Path) -> io::Result<(std::path::PathBuf, fs::File)> { - for attempt in 0..16 { - let temp_path = atomic_temp_path_attempt(path, attempt); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { - Ok(file) => return Ok((temp_path, file)), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a safe backup temporary path", - )) -} - -fn atomic_temp_path_attempt(path: &Path, attempt: u8) -> std::path::PathBuf { - if attempt == 0 { - return atomic_temp_path(path); - } - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - path.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/config/provision.rs b/crates/unixnotis-installer/src/actions/config/provision.rs index 404894eb0..c4f5f734c 100644 --- a/crates/unixnotis-installer/src/actions/config/provision.rs +++ b/crates/unixnotis-installer/src/actions/config/provision.rs @@ -1,21 +1,23 @@ //! Config and theme file creation or reset logic -use std::fs; -use std::path::Path; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use unixnotis_core::Config; +use unixnotis_core::{ + filesystem::open_regular_file, + filesystem::{create_directory_all, write_file_atomic, write_file_if_missing, ContainedPath}, + render_default_config_toml, reset_config_to_defaults, Config, ResetConfigOptions, + CURRENT_CONFIG_VERSION, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, + DEFAULT_POPUP_CSS, DEFAULT_WIDGETS_CSS, +}; use crate::paths::format_with_home; use super::super::{log_line, ActionContext}; -use super::backup::{ - backup_existing_file, create_backup_dir, ensure_installer_config, load_installer_config, - write_atomic, -}; +use super::backup::{ensure_installer_config, load_installer_config}; pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { - let config = Config::default(); let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; let config_path = Config::default_config_path().map_err(|err| anyhow!(err.to_string()))?; log_line( @@ -23,143 +25,83 @@ pub fn ensure_config(ctx: &mut ActionContext) -> Result<()> { format!("Config directory: {}", format_with_home(&config_dir)), ); - // Create the config root first so later file writes do not race missing parents - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; - - if config_path.exists() { + let config = if config_path.exists() { log_line( ctx, format!("Config file present: {}", format_with_home(&config_path)), ); + + // Existing theme paths are part of the configuration contract + Config::load_from_path(&config_path).map_err(|error| { + // Parser details may contain private config text, so only a stable summary is shown + anyhow!( + "existing configuration cannot be loaded ({}); schema v{} is required; use Reset config to back it up and create current defaults", + error.shareable_summary(), + CURRENT_CONFIG_VERSION + ) + })? } else { + let config = Config::default(); // Write a default config so there is always a working base to edit let config_toml = render_default_config_toml(&config)?; - write_atomic(&config_path, &config_toml).with_context(|| "failed to write config.toml")?; + write_file_atomic(&config_path, config_toml.as_bytes(), 0o644) + .with_context(|| "failed to write config.toml")?; log_line( ctx, format!("Config file created: {}", format_with_home(&config_path)), ); - } + + config + }; ensure_installer_config(ctx, &config_dir)?; ensure_default_scripts(ctx, &config_dir)?; + for provision in ensure_default_theme_files(&config, &config_dir)? { + let path = format_with_home(&provision.path); + let message = match provision.status { + ThemeFileStatus::Created => format!("Default theme CSS created: {path}"), + ThemeFileStatus::Present => format!("Default theme CSS present: {path}"), + ThemeFileStatus::ExternalManaged => { + format!("External theme CSS preserved: {path}") + } + ThemeFileStatus::ExternalMissing => { + format!("External theme CSS missing; runtime fallback remains active: {path}") + } + ThemeFileStatus::ExternalUnsafe => { + format!("External theme CSS is unsafe; runtime fallback remains active: {path}") + } + }; + log_line(ctx, message); + } - let theme_paths = config - .resolve_theme_paths() - .map_err(|err| anyhow!(err.to_string()))?; - let theme_entries = [ - ("base.css", &theme_paths.base_css), - ("panel.css", &theme_paths.panel_css), - ("popup.css", &theme_paths.popup_css), - ("widgets.css", &theme_paths.widgets_css), - ("media.css", &theme_paths.media_css), - ]; - - let pre_existing = theme_entries - .iter() - .map(|(_, path)| path.exists()) - .collect::>(); + log_line(ctx, "Theme CSS provisioning complete".to_string()); - config - .ensure_theme_files(&theme_paths) - .map_err(|err| anyhow!(err.to_string()))?; + Ok(()) +} - for ((name, path), existed) in theme_entries.iter().zip(pre_existing.iter()) { - let status = if *existed { "present" } else { "created" }; +pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { + let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; + ensure_installer_config(ctx, &config_dir)?; + let installer_config = load_installer_config(&config_dir).context("load installer settings")?; + let report = reset_config_to_defaults(&ResetConfigOptions { + config_dir: config_dir.clone(), + backup_retention: installer_config.backups.keep, + }) + .context("reset configuration to defaults")?; + if let Some(backup_dir) = report.backup_dir { log_line( ctx, format!( - "Theme file {}: {} ({})", - name, - status, - format_with_home(path) + "Backed up existing configuration to {}", + format_with_home(&backup_dir) ), ); } - - Ok(()) -} - -pub fn reset_config(ctx: &mut ActionContext) -> Result<()> { - let config = Config::default(); - let config_dir = Config::default_config_dir().map_err(|err| anyhow!(err.to_string()))?; - let config_path = Config::default_config_path().map_err(|err| anyhow!(err.to_string()))?; - - fs::create_dir_all(&config_dir).with_context(|| "failed to create config directory")?; - ensure_installer_config(ctx, &config_dir)?; - - let installer_config = load_installer_config(&config_dir, ctx); - let backup_dir = create_backup_dir(ctx, &config_dir, installer_config.backups.keep)?; - - // Preserve the live config before writing defaults over it - backup_existing_file(ctx, &config_path, "config.toml", backup_dir.as_deref())?; - - let config_toml = render_default_config_toml(&config)?; - write_atomic(&config_path, &config_toml).with_context(|| "failed to write config.toml")?; log_line( ctx, - format!( - "Reset config file to defaults: {}", - format_with_home(&config_path) - ), - ); - - let theme_paths = config - .resolve_theme_paths() - .map_err(|err| anyhow!(err.to_string()))?; - - // Backup theme files before reset so user styling is still recoverable - backup_existing_file( - ctx, - &theme_paths.base_css, - "base.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.panel_css, - "panel.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.popup_css, - "popup.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.widgets_css, - "widgets.css", - backup_dir.as_deref(), - )?; - backup_existing_file( - ctx, - &theme_paths.media_css, - "media.css", - backup_dir.as_deref(), - )?; - backup_default_scripts(ctx, &config_dir, backup_dir.as_deref())?; - - write_atomic(&theme_paths.base_css, unixnotis_core::DEFAULT_BASE_CSS) - .with_context(|| "failed to write base.css")?; - write_atomic(&theme_paths.panel_css, unixnotis_core::DEFAULT_PANEL_CSS) - .with_context(|| "failed to write panel.css")?; - write_atomic(&theme_paths.popup_css, unixnotis_core::DEFAULT_POPUP_CSS) - .with_context(|| "failed to write popup.css")?; - write_atomic( - &theme_paths.widgets_css, - unixnotis_core::DEFAULT_WIDGETS_CSS, - ) - .with_context(|| "failed to write widgets.css")?; - write_atomic(&theme_paths.media_css, unixnotis_core::DEFAULT_MEDIA_CSS) - .with_context(|| "failed to write media.css")?; - write_default_scripts(&config_dir)?; - - log_line( - ctx, - format!("Reset theme files in {}", format_with_home(&config_dir)), + "Reset config file and bundled scripts to defaults".to_string(), ); + log_line(ctx, "Reset theme CSS files to current defaults".to_string()); Ok(()) } @@ -187,39 +129,77 @@ fn ensure_default_scripts(ctx: &mut ActionContext, config_dir: &Path) -> Result< Ok(()) } -fn backup_default_scripts( - ctx: &mut ActionContext, - config_dir: &Path, - backup_dir: Option<&Path>, -) -> Result<()> { - for script in unixnotis_core::DEFAULT_SCRIPTS { - let path = config_dir.join(script.relative_path); - backup_existing_file(ctx, &path, script.relative_path, backup_dir)?; - } - Ok(()) +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum ThemeFileStatus { + Created, + Present, + ExternalManaged, + ExternalMissing, + ExternalUnsafe, } -pub(in crate::actions::config) fn write_default_scripts(config_dir: &Path) -> Result<()> { - Config::write_default_scripts_in(config_dir).map_err(|err| anyhow!(err.to_string())) +#[derive(Debug, Eq, PartialEq)] +struct ThemeFileProvision { + path: PathBuf, + status: ThemeFileStatus, } -pub(in crate::actions::config) fn render_default_config_toml(config: &Config) -> Result { - let mut config_toml = toml::to_string_pretty(config).map_err(|err| anyhow!(err.to_string()))?; - let panel_height_line = format!("height = {}\n", config.panel.height); - let panel_height_block = format!( - "# Vertical size as a percent of usable monitor height after margins\n\ -# and reserved work area\n\ -height = {}\n\ -\n\ -# Exact pixel height override for advanced users\n\ -# height_override = 1487\n", - config.panel.height - ); +fn ensure_default_theme_files( + config: &Config, + config_dir: &Path, +) -> Result> { + // Use the generated configuration paths so provisioning matches runtime loading + let paths = config + .resolve_theme_paths_from(config_dir) + .map_err(|error| anyhow!(error.to_string()))?; + let files = [ + (paths.base_css, DEFAULT_BASE_CSS), + (paths.panel_css, DEFAULT_PANEL_CSS), + (paths.popup_css, DEFAULT_POPUP_CSS), + (paths.widgets_css, DEFAULT_WIDGETS_CSS), + (paths.media_css, DEFAULT_MEDIA_CSS), + ]; - if !config_toml.contains(&panel_height_line) { - return Err(anyhow!("default config template missing panel height line")); - } + files + .into_iter() + .map(|(path, contents)| { + // Provisioning may only create files beneath the active config directory + let path = match ContainedPath::resolve(config_dir, &path) { + Ok(contained) => contained.absolute(), + Err(_) => { + return Ok(ThemeFileProvision { + status: classify_external_theme_file(&path), + path, + }); + } + }; + // Nested configured paths need secure parents before exclusive creation + if let Some(parent) = path.parent() { + create_directory_all(parent, 0o700) + .with_context(|| format!("create theme directory {}", parent.display()))?; + } + // Exclusive creation preserves custom files and rejects unsafe targets + let created = write_file_if_missing(&path, contents.as_bytes(), 0o644) + .with_context(|| format!("provision {}", path.display()))?; + Ok(ThemeFileProvision { + path, + status: if created { + ThemeFileStatus::Created + } else { + ThemeFileStatus::Present + }, + }) + }) + .collect() +} - config_toml = config_toml.replacen(&panel_height_line, &panel_height_block, 1); - Ok(config_toml) +pub(super) fn classify_external_theme_file(path: &Path) -> ThemeFileStatus { + match open_regular_file(path) { + Ok(file) => { + drop(file); + ThemeFileStatus::ExternalManaged + } + Err(error) if error.kind() == ErrorKind::NotFound => ThemeFileStatus::ExternalMissing, + Err(_) => ThemeFileStatus::ExternalUnsafe, + } } diff --git a/crates/unixnotis-installer/src/actions/config/state.rs b/crates/unixnotis-installer/src/actions/config/state.rs index fc7e9aa61..a9186856d 100644 --- a/crates/unixnotis-installer/src/actions/config/state.rs +++ b/crates/unixnotis-installer/src/actions/config/state.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use unixnotis_core::filesystem::{remove_empty_directory, remove_regular_file}; use unixnotis_core::util; use crate::paths::format_with_home; @@ -75,11 +76,7 @@ pub(in crate::actions::config) fn remove_state_file( ) -> std::io::Result { let state_file = state_root.join(DND_STATE_FILE); // Remove the persisted DND file first because that is the main cleanup target - let removed_file = match fs::remove_file(&state_file) { - Ok(()) => true, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => false, - Err(err) => return Err(err), - }; + let removed_file = remove_regular_file(&state_file)?; if !removed_file { // Nothing changed, so there is no follow-up directory cleanup to attempt @@ -109,9 +106,9 @@ fn cleanup_empty_state_dir(state_root: &Path) -> DirCleanupOutcome { match is_dir_empty(state_root) { Ok(false) => DirCleanupOutcome::KeptNotEmpty, // Only try removing the dir after confirming it is empty - Ok(true) => match fs::remove_dir(state_root) { - Ok(()) => DirCleanupOutcome::Removed, - Err(_) => DirCleanupOutcome::RemoveFailed, + Ok(true) => match remove_empty_directory(state_root) { + Ok(true) => DirCleanupOutcome::Removed, + Ok(false) | Err(_) => DirCleanupOutcome::RemoveFailed, }, // Surface read_dir problems separately so they can be logged upstream Err(_) => DirCleanupOutcome::InspectFailed, diff --git a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs index 59ad514db..6d63de3cd 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/default_template.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/default_template.rs @@ -1,8 +1,7 @@ use std::fs; use std::path::PathBuf; -use super::super::provision::{render_default_config_toml, write_default_scripts}; -use unixnotis_core::Config; +use unixnotis_core::{render_default_config_toml, Config}; #[test] fn default_config_template_documents_panel_height_modes() { @@ -15,6 +14,14 @@ fn default_config_template_documents_panel_height_modes() { .any(|line| line.trim() == "height_override = 1487")); } +#[test] +fn default_config_template_documents_reduced_motion() { + let config_toml = render_default_config_toml(&Config::default()).expect("render config"); + + assert!(config_toml.contains("# Disable panel animation and moving text")); + assert!(config_toml.contains("reduced_motion = false")); +} + #[test] fn default_config_template_omits_removed_theme_override_layer() { let config_toml = render_default_config_toml(&Config::default()).expect("render config"); @@ -32,9 +39,9 @@ fn default_config_template_uses_shipped_night_scripts() { // The default config stays functional while backend logic lives in editable scripts assert!(night_block.contains("enabled = true")); - assert!(night_block.contains("state_cmd = \"scripts/unixnotis-blue-light-state\"")); - assert!(night_block.contains("on_cmd = \"scripts/unixnotis-blue-light-on\"")); - assert!(night_block.contains("off_cmd = \"scripts/unixnotis-blue-light-off\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-state\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-on\"")); + assert!(night_block.contains("program = \"scripts/unixnotis-blue-light-off\"")); assert!(!night_block.contains("gammastep")); assert!(!night_block.contains("hyprsunset")); assert!(!night_block.contains("wlsunset")); @@ -64,7 +71,7 @@ fn write_default_scripts_creates_executable_helpers() { )); let _ = fs::remove_dir_all(&root); - write_default_scripts(&root).expect("write default scripts"); + Config::write_default_scripts_in(&root).expect("write default scripts"); for script in unixnotis_core::DEFAULT_SCRIPTS { let path = root.join(script.relative_path); diff --git a/crates/unixnotis-installer/src/actions/config/tests/mod.rs b/crates/unixnotis-installer/src/actions/config/tests/mod.rs index ba43b8dbc..c11aebaaf 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/mod.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/mod.rs @@ -1,2 +1,3 @@ mod default_template; +mod provision; mod state_cleanup; diff --git a/crates/unixnotis-installer/src/actions/config/tests/provision.rs b/crates/unixnotis-installer/src/actions/config/tests/provision.rs new file mode 100644 index 000000000..75e07c5e1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/config/tests/provision.rs @@ -0,0 +1,599 @@ +//! End-to-end configuration provisioning tests + +use std::fs; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; +use crate::test_support::current_config_text; +use crate::test_support::env::{test_env_lock, EnvGuard}; +use unixnotis_core::{ + Config, DEFAULT_BASE_CSS, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, + DEFAULT_WIDGETS_CSS, +}; + +use super::super::provision::{ + classify_external_theme_file, ensure_config, reset_config, ThemeFileStatus, +}; + +fn test_paths(root: &std::path::Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user(root.join("service")), + } +} + +fn test_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(64); + ActionContext { + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +#[test] +fn ensure_config_provisions_default_css_and_preserves_the_live_config() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + + ensure_config(&mut context).expect("default config should be provisioned"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + let config_text = fs::read_to_string(&config_path).expect("read generated config"); + toml::from_str::(&config_text).expect("generated config should parse"); + assert!(config_dir.join("installer.toml").is_file()); + for (name, expected) in [ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", DEFAULT_POPUP_CSS), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ] { + assert!( + config_dir.join(name).is_file(), + "new installs should create {name}" + ); + assert_eq!( + fs::read_to_string(config_dir.join(name)).expect("read default theme CSS"), + expected, + "new installs should use bundled {name}" + ); + } + assert!(!config_dir.join("theme.toml").exists()); + for script in unixnotis_core::DEFAULT_SCRIPTS { + assert!(config_dir.join(script.relative_path).is_file()); + } + + fs::write(&config_path, current_config_text("custom = true\n")).expect("customize live config"); + fs::write(config_dir.join("popup.css"), "/* custom popup */\n").expect("customize popup CSS"); + ensure_config(&mut context).expect("existing config should be preserved"); + assert_eq!( + fs::read_to_string(&config_path).expect("read retained config"), + current_config_text("custom = true\n") + ); + assert_eq!( + fs::read_to_string(config_dir.join("popup.css")).expect("read retained popup CSS"), + "/* custom popup */\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn ensure_config_provisions_the_existing_configured_theme_paths() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-configured-theme-paths"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config directory"); + fs::write( + config_dir.join("config.toml"), + current_config_text( + "[theme]\nbase_css = \"themes/base.css\"\npanel_css = \"themes/panel.css\"\npopup_css = \"themes/popup.css\"\nwidgets_css = \"themes/widgets.css\"\nmedia_css = \"themes/media.css\"\n", + ), + ) + .expect("write configured theme paths"); + fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); + fs::write(config_dir.join("themes/popup.css"), "/* custom popup */\n") + .expect("seed custom configured popup"); + + ensure_config(&mut context).expect("configured theme paths should be provisioned"); + + for (name, expected) in [ + ("base.css", DEFAULT_BASE_CSS), + ("panel.css", DEFAULT_PANEL_CSS), + ("popup.css", "/* custom popup */\n"), + ("widgets.css", DEFAULT_WIDGETS_CSS), + ("media.css", DEFAULT_MEDIA_CSS), + ] { + assert_eq!( + fs::read_to_string(config_dir.join("themes").join(name)) + .expect("read configured theme file"), + expected, + "configured theme paths must be the provisioned targets" + ); + assert!( + !config_dir.join(name).exists(), + "installer must not create unrelated root-level {name}" + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn ensure_config_rejects_v4_with_reset_guidance_and_preserves_the_file() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-v4-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("create legacy config directory"); + let legacy = current_config_text("").replacen("config_version = 5", "config_version = 4", 1); + fs::write(&config_path, &legacy).expect("write legacy config fixture"); + + let error = ensure_config(&mut context).expect_err("v4 config must remain a clean break"); + + assert_eq!( + error.to_string(), + "existing configuration cannot be loaded (Configuration TOML or schema is invalid); schema v5 is required; use Reset config to back it up and create current defaults" + ); + assert_eq!( + fs::read_to_string(&config_path).expect("read preserved legacy config"), + legacy, + "failed installation must not modify the legacy config" + ); + fs::remove_dir_all(root).expect("remove legacy config fixture"); +} + +#[cfg(unix)] +#[test] +fn ensure_config_preserves_external_theme_files_without_creating_missing_or_unsafe_targets() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-external-theme-paths"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(&config_dir).expect("create config directory"); + let external_root = root.join("external-theme"); + fs::create_dir_all(&external_root).expect("create external theme directory"); + let external_base = external_root.join("base.css"); + let external_popup = external_root.join("popup.css"); + let external_panel = external_root.join("panel.css"); + let external_widgets = external_root.join("widgets.css"); + let external_media = external_root.join("media.css"); + fs::write(&external_base, "/* external base */\n").expect("seed external base"); + fs::write(&external_popup, "/* external popup */\n").expect("seed external popup"); + let external_target = root.join("external-target.css"); + fs::write(&external_target, "/* external target */\n").expect("seed external target"); + symlink(&external_target, &external_widgets).expect("create external symlink"); + fs::create_dir(&external_media).expect("create external special target"); + fs::write( + config_dir.join("config.toml"), + current_config_text(&format!( + "[theme]\nbase_css = {:?}\npopup_css = {:?}\npanel_css = {:?}\nwidgets_css = {:?}\nmedia_css = {:?}\n", + external_base.to_string_lossy(), + external_popup.to_string_lossy(), + external_panel.to_string_lossy(), + external_widgets.to_string_lossy(), + external_media.to_string_lossy(), + )), + ) + .expect("write external theme paths"); + + ensure_config(&mut context).expect("external theme paths must remain compatible"); + + assert_eq!( + fs::read_to_string(&external_base).expect("read external base"), + "/* external base */\n" + ); + assert_eq!( + fs::read_to_string(&external_popup).expect("read external popup"), + "/* external popup */\n" + ); + assert!( + !external_panel.exists(), + "missing external files must stay absent" + ); + assert!( + external_media.is_dir(), + "external directories must remain intact" + ); + assert_eq!( + fs::read_link(&external_widgets).expect("read external symlink"), + external_target + ); + assert_eq!( + fs::read_to_string(&external_target).expect("read external symlink target"), + "/* external target */\n" + ); + for name in [ + "base.css", + "panel.css", + "popup.css", + "widgets.css", + "media.css", + ] { + assert!( + !config_dir.join(name).exists(), + "external theme paths must not create root-level {name}" + ); + } + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn external_theme_file_status_matches_runtime_file_safety() { + use std::os::unix::fs::symlink; + + let root = crate::test_support::fs::unique_temp_path("external-theme-status"); + fs::create_dir_all(&root).expect("create status fixture"); + let missing = root.join("missing.css"); + let regular = root.join("regular.css"); + let directory = root.join("directory.css"); + let symlink_path = root.join("symlink.css"); + let target = root.join("target.css"); + fs::write(®ular, "/* regular */\n").expect("seed regular file"); + fs::create_dir(&directory).expect("seed directory target"); + fs::write(&target, "/* target */\n").expect("seed symlink target"); + symlink(&target, &symlink_path).expect("seed symlink target"); + + assert_eq!( + classify_external_theme_file(&missing), + ThemeFileStatus::ExternalMissing + ); + assert_eq!( + classify_external_theme_file(®ular), + ThemeFileStatus::ExternalManaged + ); + assert_eq!( + classify_external_theme_file(&directory), + ThemeFileStatus::ExternalUnsafe + ); + assert_eq!( + classify_external_theme_file(&symlink_path), + ThemeFileStatus::ExternalUnsafe + ); + + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn ensure_config_rejects_theme_symlinks_without_touching_the_target() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-theme-symlink"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("initial install should succeed"); + + let config_dir = xdg_root.join("unixnotis"); + let target = root.join("outside-popup.css"); + fs::write(&target, "/* outside target */\n").expect("seed outside CSS"); + fs::remove_file(config_dir.join("popup.css")).expect("remove provisioned popup CSS"); + symlink(&target, config_dir.join("popup.css")).expect("create popup symlink"); + + ensure_config(&mut context).expect_err("theme symlink should fail closed"); + + assert_eq!( + fs::read_to_string(&target).expect("read outside CSS target"), + "/* outside target */\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[cfg(unix)] +#[test] +fn ensure_config_rejects_configured_theme_symlinks_without_touching_the_target() { + use std::os::unix::fs::symlink; + + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("ensure-configured-theme-symlink"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let config_dir = xdg_root.join("unixnotis"); + fs::create_dir_all(config_dir.join("themes")).expect("create configured theme directory"); + fs::write( + config_dir.join("config.toml"), + current_config_text("[theme]\npopup_css = \"themes/popup.css\"\n"), + ) + .expect("write configured popup path"); + let target = root.join("outside-popup.css"); + fs::write(&target, "/* outside target */\n").expect("seed outside CSS"); + symlink(&target, config_dir.join("themes/popup.css")).expect("create configured symlink"); + + ensure_config(&mut context).expect_err("configured theme symlink should fail closed"); + + assert_eq!( + fs::read_to_string(&target).expect("read outside CSS target"), + "/* outside target */\n" + ); + assert_eq!( + fs::read_link(config_dir.join("themes/popup.css")).expect("read retained symlink"), + target + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_config_backs_up_custom_files_and_restores_configured_css_defaults() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-config"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed default config"); + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::write(&config_path, "custom = true\n").expect("customize config"); + fs::write(config_dir.join("base.css"), "/* custom */\n").expect("customize theme"); + let script_path = config_dir.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path); + fs::write(&script_path, "#!/bin/sh\nexit 9\n").expect("customize script"); + + reset_config(&mut context).expect("config reset should succeed"); + + let config_text = fs::read_to_string(&config_path).expect("read reset config"); + let reset = toml::from_str::(&config_text).expect("reset config should parse"); + assert_ne!(config_text, "custom = true\n"); + assert_eq!(reset.theme.base_css, "base.css"); + assert_eq!( + fs::read_to_string(config_dir.join("base.css")).expect("read reset theme"), + DEFAULT_BASE_CSS, + "reset must restore the active configured stylesheet" + ); + assert!( + !config_dir.join("theme.toml").exists(), + "ordinary reset must not materialize a stock theme manifest" + ); + assert_eq!( + fs::read_to_string(&script_path).expect("read reset script"), + unixnotis_core::DEFAULT_SCRIPTS[0].contents + ); + + let backup_dir = fs::read_dir(&config_dir) + .expect("read config directory") + .filter_map(Result::ok) + .find(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_dir()) + && entry.file_name().to_string_lossy().starts_with("Backup-") + }) + .expect("reset should create a backup") + .path(); + assert_eq!( + fs::read_to_string(backup_dir.join("config.toml")).expect("read config backup"), + "custom = true\n" + ); + assert_eq!( + fs::read_to_string(backup_dir.join("base.css")).expect("read theme backup"), + "/* custom */\n" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn installer_and_core_reset_wrappers_produce_the_same_files() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-parity"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + let installer_dir = xdg_root.join("unixnotis"); + let core_dir = root.join("core-config"); + + let seed = |directory: &std::path::Path| { + fs::create_dir_all(directory.join("scripts")).expect("create reset fixture"); + fs::write(directory.join("config.toml"), "custom = true\n").expect("seed config"); + fs::write(directory.join("installer.toml"), "[backups]\nkeep = 3\n") + .expect("seed settings"); + fs::write(directory.join("panel.css"), "custom panel\n").expect("seed theme"); + fs::write( + directory.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path), + "custom script\n", + ) + .expect("seed script"); + }; + seed(&installer_dir); + seed(&core_dir); + + reset_config(&mut context).expect("installer reset should succeed"); + unixnotis_core::reset_config_to_defaults(&unixnotis_core::ResetConfigOptions { + config_dir: core_dir.clone(), + backup_retention: 3, + }) + .expect("core reset should succeed"); + + for relative in [ + "config.toml", + "panel.css", + "scripts/unixnotis-blue-light-state", + ] { + assert_eq!( + fs::read(installer_dir.join(relative)).expect("read installer result"), + fs::read(core_dir.join(relative)).expect("read core result"), + "reset wrappers must write the same {relative}" + ); + } + let installer_backup = fs::read_dir(&installer_dir) + .expect("read installer backups") + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .expect("installer backup"); + let core_backup = fs::read_dir(&core_dir) + .expect("read core backups") + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .expect("core backup"); + for name in ["config.toml", "panel.css"] { + assert_eq!( + fs::read(installer_backup.path().join(name)).expect("read installer backup"), + fs::read(core_backup.path().join(name)).expect("read core backup"), + ); + } + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_rejects_invalid_installer_settings_without_changes() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-invalid-settings"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed reset fixture"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + let script_path = config_dir.join(unixnotis_core::DEFAULT_SCRIPTS[0].relative_path); + fs::write(&config_path, "custom config\n").expect("customize config"); + fs::write(config_dir.join("panel.css"), "custom panel\n").expect("customize theme"); + fs::write(&script_path, "custom script\n").expect("customize script"); + fs::write(config_dir.join("installer.toml"), "[backups\n").expect("corrupt installer settings"); + let before_config = fs::read(&config_path).expect("read config before reset"); + let before_theme = fs::read(config_dir.join("panel.css")).expect("read theme before reset"); + let before_script = fs::read(&script_path).expect("read script before reset"); + + let error = reset_config(&mut context).expect_err("invalid settings must abort reset"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(&config_path).expect("read config after reset"), + before_config + ); + assert_eq!( + fs::read(config_dir.join("panel.css")).expect("read theme after reset"), + before_theme + ); + assert_eq!( + fs::read(&script_path).expect("read script after reset"), + before_script + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "invalid settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reset_rejects_non_file_installer_settings_without_changes() { + let _lock = test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("reset-directory-settings"); + let xdg_root = root.join("xdg"); + let _xdg = EnvGuard::set("XDG_CONFIG_HOME", xdg_root.as_os_str()); + let _home = EnvGuard::set("HOME", root.join("home").as_os_str()); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = test_paths(&root); + let mut context = test_context(&detection, &paths); + ensure_config(&mut context).expect("seed reset fixture"); + + let config_dir = xdg_root.join("unixnotis"); + let config_path = config_dir.join("config.toml"); + fs::write(&config_path, "custom config\n").expect("customize config"); + fs::remove_file(config_dir.join("installer.toml")).expect("remove settings file"); + fs::create_dir(config_dir.join("installer.toml")).expect("create settings directory"); + let before_config = fs::read(&config_path).expect("read config before reset"); + + let error = reset_config(&mut context).expect_err("directory settings must abort reset"); + + assert!(error.to_string().contains("installer settings")); + assert_eq!( + fs::read(&config_path).expect("read config after reset"), + before_config + ); + assert_eq!( + fs::read_dir(&config_dir) + .expect("read reset directory") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("Backup-")) + .count(), + 0, + "non-file settings must not create a backup" + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs index c06cc8579..24178640a 100644 --- a/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs +++ b/crates/unixnotis-installer/src/actions/config/tests/state_cleanup.rs @@ -3,13 +3,13 @@ use super::super::state::{ DirCleanupOutcome, DND_STATE_FILE, }; use std::fs; +use std::os::unix::fs::symlink; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; use crate::actions::ActionContext; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -124,6 +124,29 @@ fn remove_state_file_propagates_non_missing_filesystem_errors() { let _ = fs::remove_file(&root); } +#[test] +fn remove_state_file_rejects_symlink_without_touching_its_target() { + let root = crate::test_support::fs::unique_temp_path("remove-state-symlink"); + let state_root = root.join("unixnotis"); + let state_file = state_root.join(DND_STATE_FILE); + let protected = root.join("protected"); + fs::create_dir_all(&state_root).expect("create state directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &state_file).expect("create state link"); + + remove_state_file(&state_root).expect_err("state link should be rejected"); + + assert_eq!( + fs::read_to_string(&protected).expect("read protected file"), + "protected" + ); + assert!(fs::symlink_metadata(&state_file) + .expect("state link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + #[test] fn remove_state_uses_xdg_state_home_and_deletes_persisted_state() { let _lock = crate::test_support::env::test_env_lock(); @@ -142,13 +165,8 @@ fn remove_state_uses_xdg_state_home_and_deletes_persisted_state() { bin_dir: state_home.join("bin"), service: ServiceManager::systemd_user(state_home.join("service")), }; - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let (log_tx, log_rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx, diff --git a/crates/unixnotis-installer/src/actions/conflicts.rs b/crates/unixnotis-installer/src/actions/conflicts.rs index c18d58a6c..ca6b2f3f6 100644 --- a/crates/unixnotis-installer/src/actions/conflicts.rs +++ b/crates/unixnotis-installer/src/actions/conflicts.rs @@ -1,73 +1,202 @@ -//! Cross-backend service-manager conflict detection +//! Fail-closed cross-backend service-manager conflict detection -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::paths::InstallPaths; +use crate::service_manager::contract::{ServiceManagerAvailability, ServiceProbeState}; +use crate::service_manager::{ServiceArtifactState, ServiceManager}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(in crate::actions) enum ServiceManagerConflictKind { + Active, + Installed, + PartialInstall, + UnsafeArtifact, + Indeterminate, +} #[derive(Clone)] pub(in crate::actions) struct ServiceManagerConflict { - // User-facing manager name for the backend that appears to own UnixNotis already pub(in crate::actions) manager_label: &'static str, - // Artifact wording stays backend-specific so errors are clear for s6/runit directories pub(in crate::actions) artifact_label: &'static str, - // Primary artifact path gives the user one concrete place to inspect pub(in crate::actions) artifact_path: PathBuf, - // Installed means every steady artifact for the other backend matches the safe shape - pub(in crate::actions) installed: bool, - // Active means the other backend's native runtime probe says its daemon is running - pub(in crate::actions) active: bool, + pub(in crate::actions) kinds: Vec, + pub(in crate::actions) artifact_paths: Vec, + pub(in crate::actions) detail: Option, +} + +struct ArtifactInspection { + expected: Vec, + missing: usize, + unsafe_paths: Vec, + error: Option, +} + +enum RuntimeInspection { + Unavailable, + State(ServiceProbeState), + Indeterminate(String), } pub(in crate::actions) fn detect_service_manager_conflict_state( paths: &InstallPaths, ) -> (Vec, Vec) { let mut conflicts = Vec::new(); - let mut warnings = Vec::new(); - // Selected-backend reinstall is valid, but sibling backends must not keep owning the daemon for manager in paths.alternate_service_managers() { let manager = match manager { Ok(manager) => manager, - Err(err) => { - // A broken non-selected backend path should be visible but should not block install - warnings.push(err.to_string()); + Err(error) => { + // An invalid alternate root is unknown ownership state, never proof of absence + conflicts.push(ServiceManagerConflict { + manager_label: "alternate service manager", + artifact_label: "service artifacts", + artifact_path: PathBuf::new(), + kinds: vec![ServiceManagerConflictKind::Indeterminate], + artifact_paths: Vec::new(), + detail: Some(error.to_string()), + }); continue; } }; - // Artifact ownership uses the same safe shape checks as selected-backend state - let artifacts = manager.artifacts(&paths.bin_dir); - let installed = !artifacts.is_empty() - && artifacts - .iter() - .all(crate::service_manager::ServiceArtifact::is_present_safely); - // Active probes are best-effort because missing tools should not become false conflicts - let active = match manager.active_probe() { - Some(probe) => match probe.evaluate() { - Ok(active) => active, - Err(err) => { - // Probe failures do not block install, but they should not disappear either - warnings.push(format!( - "could not check whether {} is active: {err}", - manager.label() - )); - false - } - }, - None => false, - }; + let inspection = inspect_artifacts(&manager, &paths.bin_dir); + let mut kinds = Vec::new(); + add_artifact_conflict_kind(&inspection, &mut kinds); + let mut inspection_error = inspection.error; + let runtime = inspect_runtime(&manager); + if matches!(runtime, RuntimeInspection::Unavailable) && kinds.is_empty() { + // No transport and no artifacts means this alternate backend owns nothing here + continue; + } + add_runtime_conflict_kind(runtime, manager.label(), &mut kinds, &mut inspection_error); + // An unavailable or absent manager with no artifacts owns no live UnixNotis service + // Existing artifacts still retain their installed, partial, or unsafe conflict kind + if kinds.is_empty() { + continue; + } + + let mut artifact_paths = inspection.expected; + artifact_paths.extend(inspection.unsafe_paths); + artifact_paths.sort(); + artifact_paths.dedup(); + conflicts.push(ServiceManagerConflict { + manager_label: manager.label(), + artifact_label: manager.artifact_label(), + artifact_path: manager.primary_artifact_path(), + kinds, + artifact_paths, + detail: inspection_error, + }); + } - // Only real evidence should block install; probe errors are treated as not active - if installed || active { - conflicts.push(ServiceManagerConflict { - manager_label: manager.label(), - artifact_label: manager.artifact_label(), - artifact_path: manager.primary_artifact_path(), - installed, - active, - }); + // Indeterminate states are conflicts now, so no fail-open warning channel remains + (conflicts, Vec::new()) +} + +fn inspect_artifacts(manager: &ServiceManager, bin_dir: &Path) -> ArtifactInspection { + let mut inspection = ArtifactInspection { + expected: Vec::new(), + missing: 0, + unsafe_paths: Vec::new(), + error: None, + }; + for artifact in manager.artifacts(bin_dir) { + match artifact.inspect() { + Ok(ServiceArtifactState::Expected) => inspection.expected.push(artifact.path), + Ok(ServiceArtifactState::Missing) => { + inspection.missing = inspection.missing.saturating_add(1); + } + Ok(ServiceArtifactState::UnexpectedObject) => { + inspection.unsafe_paths.push(artifact.path); + } + Err(error) => { + inspection.error = Some(format!( + "could not inspect {} at {}: {error}", + manager.artifact_label(), + artifact.path.display() + )); + break; + } + } + } + inspection +} + +fn add_artifact_conflict_kind( + inspection: &ArtifactInspection, + kinds: &mut Vec, +) { + let kind = if inspection.error.is_some() { + Some(ServiceManagerConflictKind::Indeterminate) + } else if !inspection.unsafe_paths.is_empty() { + Some(ServiceManagerConflictKind::UnsafeArtifact) + } else if !inspection.expected.is_empty() && inspection.missing == 0 { + Some(ServiceManagerConflictKind::Installed) + } else if inspection.expected.is_empty() { + None + } else { + Some(ServiceManagerConflictKind::PartialInstall) + }; + kinds.extend(kind); +} + +fn inspect_runtime(manager: &ServiceManager) -> RuntimeInspection { + match manager.availability_state() { + Ok(Some(ServiceManagerAvailability::Unavailable)) => RuntimeInspection::Unavailable, + Ok(Some(ServiceManagerAvailability::Available) | None) => { + match manager.active_probe().evaluate_state() { + Ok(state) => RuntimeInspection::State(state), + Err(error) => RuntimeInspection::Indeterminate(format!( + "could not establish whether {} is active: {error}", + manager.label() + )), + } + } + Ok(Some(ServiceManagerAvailability::Indeterminate)) => { + RuntimeInspection::Indeterminate(format!( + "{} returned an indeterminate manager availability state", + manager.label() + )) } + Err(error) => RuntimeInspection::Indeterminate(format!( + "could not establish whether {} is reachable: {error}", + manager.label() + )), } +} - (conflicts, warnings) +fn add_runtime_conflict_kind( + runtime: RuntimeInspection, + manager_label: &str, + kinds: &mut Vec, + detail: &mut Option, +) { + let runtime_detail = match runtime { + RuntimeInspection::State(ServiceProbeState::Active) => { + kinds.push(ServiceManagerConflictKind::Active); + None + } + RuntimeInspection::State(ServiceProbeState::Indeterminate) => { + kinds.push(ServiceManagerConflictKind::Indeterminate); + Some(format!( + "{manager_label} returned an indeterminate service state" + )) + } + RuntimeInspection::Indeterminate(message) => { + kinds.push(ServiceManagerConflictKind::Indeterminate); + Some(message) + } + RuntimeInspection::Unavailable + | RuntimeInspection::State( + ServiceProbeState::Unavailable + | ServiceProbeState::Absent + | ServiceProbeState::Inactive, + ) => None, + }; + if detail.is_none() { + *detail = runtime_detail; + } + kinds.sort_unstable(); + kinds.dedup(); } diff --git a/crates/unixnotis-installer/src/actions/context.rs b/crates/unixnotis-installer/src/actions/context.rs index 7986e40e0..502084ad3 100644 --- a/crates/unixnotis-installer/src/actions/context.rs +++ b/crates/unixnotis-installer/src/actions/context.rs @@ -5,15 +5,12 @@ use std::sync::atomic::AtomicBool; use std::sync::{mpsc::SyncSender, Arc}; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; -use super::install_state::InstallState; +use super::install::InstallState; pub struct ActionContext<'a> { - // Read-only compatibility snapshot collected before the action begins - pub detection: &'a Detection, // All filesystem and service-manager paths for the selected backend pub paths: &'a InstallPaths, // Cached install state keeps the progress view aligned with the selected action diff --git a/crates/unixnotis-installer/src/actions/daemon.rs b/crates/unixnotis-installer/src/actions/daemon.rs deleted file mode 100644 index 2f10c2d80..000000000 --- a/crates/unixnotis-installer/src/actions/daemon.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! Stop and verify the currently running notification daemon - -use std::process::Stdio; -use std::thread; -use std::time::{Duration, Instant}; - -use anyhow::{anyhow, Context, Result}; - -use super::{log_line, run_command, ActionContext}; -use crate::system_tools; - -pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { - let Some(owner) = ctx.detection.owner.as_ref() else { - log_line(ctx, "No active notification daemon detected."); - return Ok(()); - }; - - let owner_pid = owner.pid; - let owner_comm = owner.comm.as_deref(); - // Prefer the bus-reported command name, but fall back to PID matching when comm is unavailable - let known = owner_comm - .and_then(|comm| { - ctx.detection - .daemons - .iter() - .find(|daemon| daemon.name == comm) - }) - .or_else(|| { - owner_pid.and_then(|pid| { - ctx.detection - .daemons - .iter() - .find(|daemon| daemon.running_pids.contains(&pid)) - }) - }); - - if let Some(daemon) = known { - if owner_comm.is_none() { - log_line( - ctx, - format!( - "Active owner detected without command name; matched pid to {}", - daemon.name - ), - ); - } - if daemon.systemd_active { - let is_unixnotis = daemon.name == "unixnotis-daemon"; - log_line(ctx, format!("Stopping systemd unit {}", daemon.unit)); - let (label, command) = if is_unixnotis { - // Reinstall can race with session hooks that start the daemon when the bus name drops - // The irreversible stop job keeps that start request from canceling the stop in flight - let spec = ctx - .paths - .service - .stop_for_reinstall_command() - .ok_or_else(|| { - anyhow!("service manager cannot stop unixnotis for reinstall") - })?; - (spec.label().to_string(), spec.to_command()?) - } else { - let mut command = system_tools::command("systemctl") - .context("failed to locate trusted systemctl")?; - command.args(["--user", "disable", "--now", daemon.unit.as_str()]); - ( - format!("systemctl --user disable --now {}", daemon.unit), - command, - ) - }; - if let Err(err) = run_command(ctx, &label, command, None) { - if is_systemd_unit_inactive(&daemon.unit)? { - // A canceled stop job can still leave the unit stopped, which satisfies reinstall - log_line( - ctx, - format!( - "Systemd unit {} is inactive after stop error; continuing.", - daemon.unit - ), - ); - return Ok(()); - } - return Err(err); - } - return Ok(()); - } - - if let Some(pid) = owner_pid { - log_line(ctx, format!("Stopping {} (pid {})", daemon.name, pid)); - // If the process is already gone, the stop goal is satisfied - if !pid_alive(pid)? { - log_line(ctx, format!("Process {pid} already stopped.")); - return Ok(()); - } - // Re-check the command name to avoid signaling a recycled PID - if !pid_matches_comm(pid, &daemon.name)? { - // Re-check liveness to treat a natural exit as success - if !pid_alive(pid)? { - log_line(ctx, format!("Process {pid} already stopped.")); - return Ok(()); - } - return Err(anyhow!( - "pid {} no longer matches expected daemon {}; aborting stop", - pid, - daemon.name - )); - } - let status = system_tools::command("kill") - .context("failed to locate trusted kill")? - .args(["-TERM", &pid.to_string()]) - .status() - .context("failed to terminate notification daemon")?; - if status.success() { - wait_for_exit(ctx, pid, &daemon.name)?; - return Ok(()); - } - return Err(anyhow!("failed to stop {}", daemon.name)); - } - } - - if let Some(comm) = owner_comm { - let message = format!( - "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." - ); - log_line(ctx, message.clone()); - return Err(anyhow!(message)); - } - if let Some(pid) = owner_pid { - let message = format!( - "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." - ); - log_line(ctx, message.clone()); - return Err(anyhow!(message)); - } - let message = "Detected owner is not managed by a known unit; stop it manually before install." - .to_string(); - log_line(ctx, message.clone()); - Err(anyhow!(message)) -} - -fn wait_for_exit(ctx: &mut ActionContext, pid: u32, expected_comm: &str) -> Result<()> { - let start = Instant::now(); - let timeout = Duration::from_secs(5); - let poll = Duration::from_millis(100); - - while start.elapsed() < timeout { - if !pid_alive(pid)? { - log_line(ctx, format!("Process {pid} stopped.")); - return Ok(()); - } - // PID reuse protection verifies the command name during the wait loop - if !pid_matches_comm(pid, expected_comm)? { - return Err(anyhow!( - "pid {pid} no longer matches expected daemon {expected_comm}; aborting wait" - )); - } - thread::sleep(poll); - } - - Err(anyhow!("process {pid} did not exit after 5s")) -} - -fn pid_alive(pid: u32) -> Result { - if pid == 0 || pid > i32::MAX as u32 { - return Ok(false); - } - - let status = system_tools::command("kill") - .context("failed to locate trusted kill")? - .args(["-0", &pid.to_string()]) - // Dead-PID probes are expected during waits, so keep kill diagnostics out of the TUI - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .with_context(|| format!("failed to probe pid {pid}"))?; - Ok(status.success()) -} - -fn pid_matches_comm(pid: u32, expected: &str) -> Result { - // Validate the process name with ps before sending signals to avoid PID reuse hazards - let output = system_tools::command("ps") - .context("failed to locate trusted ps")? - .args(["-p", &pid.to_string(), "-o", "comm="]) - .output() - .with_context(|| format!("failed to read comm for pid {pid}"))?; - if !output.status.success() { - return Ok(false); - } - let comm = String::from_utf8_lossy(&output.stdout); - let comm = comm.trim(); - if comm.is_empty() { - return Ok(false); - } - Ok(comm == expected) -} - -fn is_systemd_unit_inactive(unit: &str) -> Result { - // A failed stop command is only recoverable when systemd agrees the unit is no longer running - let output = system_tools::command("systemctl") - .context("failed to locate trusted systemctl")? - .args(["--user", "is-active", unit]) - .output() - .with_context(|| format!("failed to check systemd unit state for {unit}"))?; - let state = String::from_utf8_lossy(&output.stdout); - let state = state.trim(); - if state.is_empty() && !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!( - "failed to read systemd unit state for {unit}: {}", - stderr.trim() - )); - } - Ok(systemd_stop_error_is_satisfied_by_state(state)) -} - -fn systemd_stop_error_is_satisfied_by_state(state: &str) -> bool { - // Only known non-running states should turn a failed stop command into success - matches!(state.trim(), "inactive" | "failed" | "unknown") -} - -#[cfg(test)] -#[path = "tests/daemon.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/mod.rs b/crates/unixnotis-installer/src/actions/daemon/mod.rs new file mode 100644 index 000000000..20f0e7793 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/mod.rs @@ -0,0 +1,17 @@ +//! Notification-daemon lifecycle boundaries used by installer actions + +mod name_reservation; +mod process_handle; +mod quiescence; +mod stop; + +pub use name_reservation::DaemonActivationReservation; +pub use quiescence::{ + ensure_selected_service_inactive, wait_until_no_conflicting_live_daemon, + wait_until_selected_service_inactive, STOP_QUIESCENCE_TIMEOUT, +}; +pub use stop::stop_active_daemon; + +#[cfg(test)] +#[path = "tests/support.rs"] +mod test_support; diff --git a/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs b/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs new file mode 100644 index 000000000..4f21b16e1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/name_reservation.rs @@ -0,0 +1,100 @@ +//! Exclusive daemon-activation reservation for the release switch boundary + +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use zbus::fdo::{RequestNameFlags, RequestNameReply}; + +const RESERVATION_TIMEOUT: Duration = Duration::from_secs(2); + +pub struct DaemonActivationReservation { + backing: Box, +} + +trait ReservationBacking {} + +struct LiveReservationBacking { + // Keep the connection before the runtime so the names are released while + // the runtime that owns the connection is still alive + _connection: zbus::Connection, + _runtime: tokio::runtime::Runtime, +} + +impl ReservationBacking for LiveReservationBacking {} + +impl DaemonActivationReservation { + pub fn acquire() -> Result { + Self::acquire_names(&[ + unixnotis_core::NOTIFICATIONS_BUS_NAME, + unixnotis_core::CONTROL_BUS_NAME, + ]) + } + + fn acquire_names(names: &[&str]) -> Result { + if names.is_empty() { + bail!("daemon activation reservation requires at least one D-Bus name") + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create daemon-activation reservation runtime")?; + let address = format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ); + let connection = runtime.block_on(async { + let builder = zbus::connection::Builder::address(address.as_str()) + .context("prepare stable user-bus reservation connection")?; + let connection = tokio::time::timeout(RESERVATION_TIMEOUT, builder.build()) + .await + .context("daemon-activation reservation connection timed out")? + .context("connect to stable user bus for daemon-activation reservation")?; + for &name in names { + let reply = match tokio::time::timeout( + RESERVATION_TIMEOUT, + connection.request_name_with_flags(name, RequestNameFlags::DoNotQueue.into()), + ) + .await + .with_context(|| format!("D-Bus activation reservation for {name} timed out"))? + { + Ok(reply) => reply, + Err(zbus::Error::NameTaken) => { + bail!("D-Bus activation name {name} became owned before release activation") + } + Err(error) => { + return Err(error).with_context(|| { + format!("request D-Bus activation reservation for {name}") + }) + } + }; + match reply { + RequestNameReply::PrimaryOwner | RequestNameReply::AlreadyOwner => {} + RequestNameReply::InQueue | RequestNameReply::Exists => { + bail!("D-Bus activation name {name} became owned before release activation") + } + } + } + // Dropping this one connection releases every name if a later request failed + Ok(connection) + })?; + + Ok(Self { + backing: Box::new(LiveReservationBacking { + _connection: connection, + _runtime: runtime, + }), + }) + } +} + +impl Drop for DaemonActivationReservation { + fn drop(&mut self) { + // Keep the capability backing explicit while its boxed owner performs + // the normal connection-before-runtime drop sequence + let _ = &self.backing; + } +} + +#[cfg(test)] +#[path = "tests/name_reservation.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs new file mode 100644 index 000000000..e358557a0 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/process_handle.rs @@ -0,0 +1,205 @@ +//! Stable Linux process handles used while stopping an unmanaged daemon + +use std::fs; +use std::os::fd::OwnedFd; +use std::thread; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use rustix::event::{poll, PollFd, PollFlags, Timespec}; +use rustix::process::{kill_process, pidfd_open, pidfd_send_signal, Pid, PidfdFlags, Signal}; + +const PROCESS_EXIT_TIMEOUT: Duration = Duration::from_secs(5); +const FALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(super) enum ProcessState { + Gone, + Running(ProcessHandle), +} + +pub(super) struct ProcessHandle { + pid: Pid, + start_time: u64, + pidfd: Option, + exit_timeout: Duration, +} + +impl ProcessHandle { + pub(super) fn open(raw_pid: u32, expected_program: &str) -> Result { + let Some(pid) = process_id(raw_pid) else { + return Ok(ProcessState::Gone); + }; + + // pidfd keeps the process identity stable even if the numeric PID is later reused + let pidfd = match pidfd_open(pid, PidfdFlags::empty()) { + Ok(pidfd) => Some(pidfd), + Err(rustix::io::Errno::SRCH) => return Ok(ProcessState::Gone), + // Older Linux kernels need the start-time guarded fallback below + Err(rustix::io::Errno::NOSYS) => None, + Err(error) => { + return Err(anyhow!( + "failed to open stable handle for pid {raw_pid}: {error}" + )) + } + }; + + // Read lifetime evidence around program validation so fallback signaling fails closed + let Some(start_before) = read_process_start_time(raw_pid)? else { + return Ok(ProcessState::Gone); + }; + if !process_matches_program(raw_pid, expected_program) { + return Err(anyhow!( + "pid {raw_pid} no longer matches expected daemon {expected_program}; aborting stop" + )); + } + let Some(start_after) = read_process_start_time(raw_pid)? else { + return Ok(ProcessState::Gone); + }; + if start_before != start_after { + return Err(anyhow!( + "pid {raw_pid} changed while its identity was checked; aborting stop" + )); + } + + Ok(ProcessState::Running(Self { + pid, + start_time: start_before, + pidfd, + exit_timeout: PROCESS_EXIT_TIMEOUT, + })) + } + + pub(super) fn terminate(&self) -> Result<()> { + if let Some(pidfd) = &self.pidfd { + // The signal targets the opened process object instead of a reusable number + return pidfd_send_signal(pidfd, Signal::TERM) + .context("failed to terminate notification daemon through pidfd"); + } + + // The fallback repeats the lifetime read immediately before the numeric signal + self.require_current_lifetime()?; + kill_process(self.pid, Signal::TERM) + .context("failed to terminate notification daemon through native signal") + } + + pub(super) fn wait_for_exit(&self) -> Result<()> { + if let Some(pidfd) = &self.pidfd { + return wait_for_pidfd(pidfd, self.exit_timeout); + } + + // A finite poll budget keeps fallback shutdown bounded even if the clock changes + for poll_index in 0..fallback_poll_count(self.exit_timeout) { + match read_process_start_time(self.pid.as_raw_pid().cast_unsigned())? { + None => return Ok(()), + // A new lifetime means the original target exited and must not be inspected + Some(current) if current != self.start_time => return Ok(()), + Some(_) => { + let elapsed = FALLBACK_POLL_INTERVAL.saturating_mul(poll_index); + thread::sleep( + FALLBACK_POLL_INTERVAL.min(self.exit_timeout.saturating_sub(elapsed)), + ); + } + } + } + + Err(anyhow!( + "process {} did not exit after {:?}", + self.pid.as_raw_pid(), + self.exit_timeout + )) + } + + fn require_current_lifetime(&self) -> Result<()> { + let current = read_process_start_time(self.pid.as_raw_pid().cast_unsigned())?; + if current == Some(self.start_time) { + return Ok(()); + } + Err(anyhow!( + "pid {} changed before signaling; aborting stop", + self.pid.as_raw_pid() + )) + } +} + +fn fallback_poll_count(timeout: Duration) -> u32 { + let polls = timeout + .as_nanos() + .div_ceil(FALLBACK_POLL_INTERVAL.as_nanos()); + u32::try_from(polls).unwrap_or(u32::MAX) +} + +fn wait_for_pidfd(pidfd: &OwnedFd, timeout: Duration) -> Result<()> { + let mut descriptors = [PollFd::new(pidfd, PollFlags::IN)]; + let timeout = Timespec { + // Poll accepts signed seconds, so durations above its range saturate safely + tv_sec: i64::try_from(timeout.as_secs()).unwrap_or(i64::MAX), + tv_nsec: i64::from(timeout.subsec_nanos()), + }; + poll(&mut descriptors, Some(&timeout)) + .context("failed while waiting for notification daemon pidfd")?; + if descriptors[0].revents().contains(PollFlags::IN) { + return Ok(()); + } + Err(anyhow!("process did not exit after {timeout:?}")) +} + +fn process_id(raw_pid: u32) -> Option { + let raw_pid = i32::try_from(raw_pid).ok()?; + Pid::from_raw(raw_pid) +} + +fn process_matches_program(pid: u32, expected: &str) -> bool { + // Argv preserves daemon basenames longer than Linux's 15-byte comm field + crate::detect::read_cmdline_program(pid) + .or_else(|| read_proc_comm(pid)) + .is_some_and(|program| program == expected) +} + +fn read_proc_comm(pid: u32) -> Option { + let contents = fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + parse_proc_comm(&contents) +} + +fn parse_proc_comm(contents: &str) -> Option { + let comm = contents.trim(); + (!comm.is_empty()).then(|| comm.to_string()) +} + +fn read_process_start_time(pid: u32) -> Result> { + let path = format!("/proc/{pid}/stat"); + read_process_start_time_from_path(std::path::Path::new(&path)) +} + +fn read_process_start_time_from_path(path: &std::path::Path) -> Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if process_state_is_missing(&error) => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to read process state from {}", path.display())) + } + }; + parse_process_start_time(&contents) + .map(Some) + .ok_or_else(|| anyhow!("failed to parse process start time from {}", path.display())) +} + +fn process_state_is_missing(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::NotFound +} + +fn parse_process_start_time(stat: &str) -> Option { + // The command field is parenthesized and may itself contain spaces + let command_end = stat.rfind(')')?; + let fields_after_command = stat.get(command_end + 2..)?; + // Field 3 begins here, placing the process start time at zero-based index 19 + fields_after_command + .split_whitespace() + .nth(19)? + .parse() + .ok() +} + +#[cfg(test)] +#[path = "tests/process_handle.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/quiescence.rs b/crates/unixnotis-installer/src/actions/daemon/quiescence.rs new file mode 100644 index 000000000..b821516ad --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/quiescence.rs @@ -0,0 +1,127 @@ +//! Bounded checks that prove the notification runtime is no longer active + +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Context, Result}; + +pub const STOP_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(5); +const STOP_QUIESCENCE_POLL_INTERVAL: Duration = Duration::from_millis(25); + +fn ensure_no_conflicting_live_daemon_until( + paths: &crate::paths::InstallPaths, + deadline: Instant, +) -> Result<()> { + // This check runs at the final generation-switch boundary, not from the UI snapshot + let owner = crate::detect::notification_owner_for_mutation_until(deadline) + .context("recheck notification ownership before binary activation")?; + if let Some(owner) = owner { + return Err(anyhow!( + "notification daemon appeared before binary activation (owner {owner}); retry installation" + )); + } + ensure_selected_service_inactive_until(paths, deadline) +} + +pub(in crate::actions) fn ensure_selected_service_inactive_until( + paths: &crate::paths::InstallPaths, + deadline: Instant, +) -> Result<()> { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "selected service probe deadline elapsed", + ) + .into()); + } + let state = paths + .service + .active_probe() + .evaluate_state_with_timeout(remaining) + .context("recheck selected service manager before binary activation")?; + match state { + crate::service_manager::contract::ServiceProbeState::Absent + | crate::service_manager::contract::ServiceProbeState::Inactive => Ok(()), + crate::service_manager::contract::ServiceProbeState::Active => Err(anyhow!( + "UnixNotis service became active again before binary activation" + )), + crate::service_manager::contract::ServiceProbeState::Unavailable => Err(anyhow!( + "selected service manager became unavailable before binary activation" + )), + crate::service_manager::contract::ServiceProbeState::Indeterminate => Err(anyhow!( + "selected service manager returned an indeterminate state before binary activation" + )), + } +} + +pub fn ensure_selected_service_inactive(paths: &crate::paths::InstallPaths) -> Result<()> { + let deadline = Instant::now() + .checked_add(crate::service_manager::contract::ServiceProbe::default_timeout()) + .ok_or_else(|| anyhow!("selected service check deadline exceeded the monotonic clock"))?; + ensure_selected_service_inactive_until(paths, deadline) +} + +pub fn wait_until_no_conflicting_live_daemon( + paths: &crate::paths::InstallPaths, + timeout: Duration, +) -> Result<()> { + wait_until_no_conflicting_live_daemon_with_probe( + timeout, + STOP_QUIESCENCE_POLL_INTERVAL, + |deadline| ensure_no_conflicting_live_daemon_until(paths, deadline), + ) +} + +pub fn wait_until_selected_service_inactive( + paths: &crate::paths::InstallPaths, + timeout: Duration, +) -> Result<()> { + // A held activation reservation makes broker ownership intentionally non-empty + wait_until_no_conflicting_live_daemon_with_probe( + timeout, + STOP_QUIESCENCE_POLL_INTERVAL, + |deadline| ensure_selected_service_inactive_until(paths, deadline), + ) +} + +fn wait_until_no_conflicting_live_daemon_with_probe( + timeout: Duration, + poll_interval: Duration, + mut probe: F, +) -> Result<()> +where + F: FnMut(Instant) -> Result<()>, +{ + let deadline = Instant::now() + .checked_add(timeout) + .ok_or_else(|| anyhow!("daemon quiescence deadline exceeded the monotonic clock"))?; + let poll_interval = poll_interval.max(Duration::from_millis(1)); + let max_attempts = timeout + .as_nanos() + .checked_div(poll_interval.as_nanos()) + .unwrap_or(0) + .saturating_add(1); + let max_attempts = usize::try_from(max_attempts).unwrap_or(usize::MAX); + let mut last_error = None; + for _attempt in 0..max_attempts { + match probe(deadline) { + Ok(()) => return Ok(()), + Err(error) => { + let now = Instant::now(); + last_error = Some(error); + let remaining = deadline.saturating_duration_since(now); + if remaining.is_zero() { + break; + } + // Bounded polling handles service-manager success before broker ownership disappears + std::thread::sleep(poll_interval.min(remaining)); + } + } + } + let error = last_error.ok_or_else(|| anyhow!("daemon quiescence probe did not run"))?; + Err(error).context("notification runtime did not become quiescent before rollback deadline") +} + +#[cfg(test)] +#[path = "tests/quiescence.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/stop.rs b/crates/unixnotis-installer/src/actions/daemon/stop.rs new file mode 100644 index 000000000..0e9266d96 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/stop.rs @@ -0,0 +1,215 @@ +//! Exact-owner shutdown for notification daemons discovered during installation + +use anyhow::{anyhow, Context, Result}; + +use super::process_handle::{ProcessHandle, ProcessState}; +use super::quiescence::{wait_until_no_conflicting_live_daemon, STOP_QUIESCENCE_TIMEOUT}; +use crate::actions::{log_line, run_command, ActionContext}; +use crate::system_tools; + +pub fn stop_active_daemon(ctx: &mut ActionContext) -> Result<()> { + let detection = crate::detect::detect_for_mutation() + .context("refresh notification ownership immediately before stopping the daemon")?; + if let Some(expected_unique_name) = detection + .owner + .as_ref() + .and_then(|owner| owner.unique_name.as_deref()) + { + // Process metadata has authority only while the inspected broker owner remains current + crate::detect::ensure_owner_is_current(expected_unique_name) + .context("revalidate notification ownership immediately before stopping the daemon")?; + } + // A successful manager command is only the start of shutdown; broker and + // service state must converge before the next installer step is marked done + stop_active_daemon_with_quiescence(ctx, &detection, |paths| { + wait_until_no_conflicting_live_daemon(paths, STOP_QUIESCENCE_TIMEOUT) + }) +} + +fn stop_active_daemon_with_quiescence( + ctx: &mut ActionContext, + detection: &crate::detect::Detection, + wait_for_quiescence: Q, +) -> Result<()> +where + Q: FnOnce(&crate::paths::InstallPaths) -> Result<()>, +{ + let stop_result = stop_active_daemon_with_detection(ctx, detection); + let quiescence_result = wait_for_quiescence(ctx.paths); + + match (stop_result, quiescence_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(stop_error), Ok(())) => { + // Runtime truth wins when a service-manager command reports a stale failure + log_line( + ctx, + format!( + "Warning: stop command failed after runtime became quiescent ({stop_error:#})" + ), + ); + Ok(()) + } + (Ok(()), Err(state_error)) => Err(state_error).context( + "service manager reported a successful stop but notification runtime remains live", + ), + (Err(stop_error), Err(state_error)) => Err(state_error).context(format!( + "failed to stop notification daemon ({stop_error:#}); runtime remains live or indeterminate" + )), + } +} + +fn stop_active_daemon_with_detection( + ctx: &mut ActionContext, + detection: &crate::detect::Detection, +) -> Result<()> { + let Some(owner) = detection.owner.as_ref() else { + log_line(ctx, "No active notification daemon detected."); + return Ok(()); + }; + + let owner_pid = owner.pid; + let owner_comm = owner.comm.as_deref(); + // Prefer the bus-reported command name, but fall back to PID matching when comm is unavailable + let known = owner_comm + .and_then(|comm| detection.daemons.iter().find(|daemon| daemon.name == comm)) + .or_else(|| { + owner_pid.and_then(|pid| { + detection + .daemons + .iter() + .find(|daemon| daemon.running_pids.contains(&pid)) + }) + }); + + if let Some(daemon) = known { + if owner_comm.is_none() { + log_line( + ctx, + format!( + "Active owner detected without command name; matched pid to {}", + daemon.name + ), + ); + } + if daemon.systemd_active { + return stop_systemd_daemon(ctx, daemon); + } + + if let Some(pid) = owner_pid { + return stop_process_daemon(ctx, &daemon.name, pid); + } + } + + unmanaged_owner_error(ctx, owner_comm, owner_pid) +} + +fn stop_systemd_daemon( + ctx: &mut ActionContext, + daemon: &crate::detect::DetectedDaemon, +) -> Result<()> { + let is_unixnotis = daemon.name == "unixnotis-daemon"; + log_line(ctx, format!("Stopping systemd unit {}", daemon.unit)); + let (label, command) = if is_unixnotis { + // Reinstall can race with session hooks that start the daemon when the bus name drops + // The irreversible stop job keeps that start request from canceling the stop in flight + let spec = ctx.paths.service.stop_for_reinstall_command(); + (spec.label().to_string(), spec.to_command()?) + } else { + let mut command = + system_tools::command("systemctl").context("failed to locate trusted systemctl")?; + command.args(["--user", "disable", "--now", daemon.unit.as_str()]); + ( + format!("systemctl --user disable --now {}", daemon.unit), + command, + ) + }; + if let Err(error) = run_command(ctx, &label, command, None) { + if is_systemd_unit_inactive(&daemon.unit)? { + // A canceled stop job can still leave the unit stopped, which satisfies reinstall + log_line( + ctx, + format!( + "Systemd unit {} is inactive after stop error; continuing.", + daemon.unit + ), + ); + return Ok(()); + } + return Err(error); + } + Ok(()) +} + +fn stop_process_daemon(ctx: &mut ActionContext, daemon_name: &str, pid: u32) -> Result<()> { + log_line(ctx, format!("Stopping {daemon_name} (pid {pid})")); + // A stable process handle prevents a recycled PID from receiving the signal + let handle = match ProcessHandle::open(pid, daemon_name)? { + ProcessState::Gone => { + log_line(ctx, format!("Process {pid} already stopped.")); + return Ok(()); + } + ProcessState::Running(handle) => handle, + }; + handle.terminate()?; + handle.wait_for_exit()?; + log_line(ctx, format!("Process {pid} stopped.")); + Ok(()) +} + +fn unmanaged_owner_error( + ctx: &mut ActionContext, + owner_comm: Option<&str>, + owner_pid: Option, +) -> Result<()> { + // Preserve the strongest broker identity available in the manual-stop instruction + let message = owner_comm.map_or_else( + || { + owner_pid.map_or_else( + || { + "Detected owner is not managed by a known unit; stop it manually before install." + .to_string() + }, + |pid| { + format!( + "Detected owner pid {pid} is not managed by a known unit; stop it manually before install." + ) + }, + ) + }, + |comm| { + format!( + "Detected owner '{comm}' is not managed by a known unit; stop it manually before install." + ) + }, + ); + log_line(ctx, message.clone()); + Err(anyhow!(message)) +} + +fn is_systemd_unit_inactive(unit: &str) -> Result { + // A failed stop command is only recoverable when systemd agrees the unit is no longer running + let output = system_tools::command("systemctl") + .context("failed to locate trusted systemctl")? + .args(["--user", "is-active", unit]) + .output() + .with_context(|| format!("failed to check systemd unit state for {unit}"))?; + let state = String::from_utf8_lossy(&output.stdout); + let state = state.trim(); + if state.is_empty() && !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!( + "failed to read systemd unit state for {unit}: {}", + stderr.trim() + )); + } + Ok(systemd_stop_error_is_satisfied_by_state(state)) +} + +fn systemd_stop_error_is_satisfied_by_state(state: &str) -> bool { + // Only known non-running states should turn a failed stop command into success + matches!(state.trim(), "inactive" | "failed" | "unknown") +} + +#[cfg(test)] +#[path = "tests/stop.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs b/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs new file mode 100644 index 000000000..789928bed --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/name_reservation.rs @@ -0,0 +1,97 @@ +use super::DaemonActivationReservation; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +struct TestReservationBacking { + observer: Arc, +} + +impl super::ReservationBacking for TestReservationBacking {} + +impl Drop for TestReservationBacking { + fn drop(&mut self) { + self.observer.store(false, Ordering::Release); + } +} + +impl DaemonActivationReservation { + pub(crate) fn test_guard(observer: Arc) -> Self { + observer.store(true, Ordering::Release); + Self { + backing: Box::new(TestReservationBacking { observer }), + } + } +} + +fn acquire_name(name: &str) -> anyhow::Result { + DaemonActivationReservation::acquire_names(&[name]) +} + +#[test] +fn reservation_excludes_another_connection_until_the_guard_drops() { + let name = format!( + "io.github.unixnotis.InstallerReservation{}", + std::process::id() + ); + let first = + acquire_name(&name).expect("first connection should reserve the isolated test name"); + + let error = match acquire_name(&name) { + Ok(_unexpected) => panic!("a second connection acquired the reserved test name"), + Err(error) => error, + }; + + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected competing reservation error: {error:#}" + ); + drop(first); + acquire_name(&name).expect("the name should become available after the guard drops"); +} + +#[test] +fn reservation_blocks_both_activation_names_until_the_guard_drops() { + let suffix = std::process::id(); + let notifications = format!("io.github.unixnotis.InstallerNotificationsReservation{suffix}"); + let control = format!("io.github.unixnotis.InstallerControlReservation{suffix}"); + let first = DaemonActivationReservation::acquire_names(&[¬ifications, &control]) + .expect("one connection should reserve both activation names"); + + for name in [¬ifications, &control] { + let error = match acquire_name(name) { + Ok(_unexpected) => { + panic!("a second connection acquired a reserved activation name") + } + Err(error) => error, + }; + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected competing reservation error: {error:#}" + ); + } + + drop(first); + DaemonActivationReservation::acquire_names(&[¬ifications, &control]) + .expect("both names should become available after the guard drops"); +} + +#[test] +fn failed_second_name_request_releases_the_first_name() { + let suffix = std::process::id(); + let occupied = format!("io.github.unixnotis.OccupiedReservation{suffix}"); + let released = format!("io.github.unixnotis.ReleasedReservation{suffix}"); + let owner = + acquire_name(&occupied).expect("the competing connection should reserve the second name"); + + let error = match DaemonActivationReservation::acquire_names(&[&released, &occupied]) { + Ok(_unexpected) => panic!("a reservation succeeded after its second name was taken"), + Err(error) => error, + }; + assert!( + error.to_string().contains("D-Bus activation name"), + "unexpected partial reservation error: {error:#}" + ); + + acquire_name(&released).expect("the first name must be released when the second request fails"); + drop(owner); +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs new file mode 100644 index 000000000..c6eb17f35 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/process_handle.rs @@ -0,0 +1,216 @@ +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use super::*; + +const CHILD_EXEC_TIMEOUT: Duration = Duration::from_secs(2); +const CHILD_EXEC_POLL_INTERVAL: Duration = Duration::from_millis(1); + +fn spawn_ready_sleep_child() -> Child { + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep child"); + let deadline = Instant::now() + CHILD_EXEC_TIMEOUT; + + // Command::spawn can return before the child replaces the test executable + while !process_matches_program(child.id(), "sleep") { + match child.try_wait() { + Ok(Some(status)) => panic!("sleep child exited before exec completed: {status}"), + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + panic!("inspect sleep child before exec completed: {error}"); + } + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("sleep child did not complete exec within {CHILD_EXEC_TIMEOUT:?}"); + } + std::thread::sleep(CHILD_EXEC_POLL_INTERVAL); + } + + child +} + +#[test] +fn process_start_time_parser_handles_spaces_in_the_command_name() { + let stat = "42 (daemon with spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; + + assert_eq!(parse_process_start_time(stat), Some(987_654)); +} + +#[test] +fn process_start_time_parser_rejects_missing_and_invalid_fields() { + assert!(parse_process_start_time("42 missing-parenthesis").is_none()); + assert!(parse_process_start_time("42 (daemon) S 1 2 3").is_none()); + + let invalid = "42 (daemon) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 invalid 20"; + assert!(parse_process_start_time(invalid).is_none()); +} + +#[test] +fn process_handle_rejects_a_mismatched_program_before_signaling() { + let Err(error) = ProcessHandle::open(std::process::id(), "not-the-test-process") else { + panic!("mismatched program must fail closed"); + }; + + assert!(error + .to_string() + .contains("no longer matches expected daemon")); +} + +#[test] +fn fallback_poll_budget_rounds_up_and_keeps_zero_immediate() { + assert_eq!(fallback_poll_count(Duration::ZERO), 0); + assert_eq!(fallback_poll_count(Duration::from_nanos(1)), 1); + assert_eq!(fallback_poll_count(FALLBACK_POLL_INTERVAL), 1); + assert_eq!( + fallback_poll_count(FALLBACK_POLL_INTERVAL + Duration::from_nanos(1)), + 2 + ); +} + +#[test] +fn proc_comm_reader_reports_the_live_name_and_rejects_missing_processes() { + let expected = std::fs::read_to_string("/proc/self/comm") + .expect("read current process comm") + .trim() + .to_string(); + + assert_eq!( + read_proc_comm(std::process::id()).as_deref(), + Some(expected.as_str()) + ); + assert_eq!(read_proc_comm(i32::MAX as u32), None); +} + +#[test] +fn proc_comm_parser_rejects_blank_names_and_trims_kernel_newlines() { + assert_eq!( + parse_proc_comm("unixnotis-daemon\n").as_deref(), + Some("unixnotis-daemon") + ); + assert_eq!(parse_proc_comm(" \n\t"), None); +} + +#[test] +fn pidfd_signal_and_wait_stop_the_exact_child_process() { + let mut child = spawn_ready_sleep_child(); + let pid = child.id(); + + let handle = match ProcessHandle::open(pid, "sleep").expect("open sleep process handle") { + ProcessState::Running(handle) => handle, + ProcessState::Gone => panic!("sleep child should still be running"), + }; + handle.terminate().expect("terminate exact sleep child"); + handle.wait_for_exit().expect("wait for exact sleep child"); + + let status = child.wait().expect("reap sleep child"); + assert!(!status.success()); +} + +#[test] +fn invalid_process_ids_are_treated_as_already_gone() { + assert!(matches!( + ProcessHandle::open(0, "daemon").expect("zero pid should be harmless"), + ProcessState::Gone + )); + assert!(matches!( + ProcessHandle::open(u32::MAX, "daemon").expect("oversized pid should be harmless"), + ProcessState::Gone + )); +} + +#[test] +fn current_process_start_time_is_read_from_proc() { + let start_time = read_process_start_time(std::process::id()) + .expect("read current process state") + .expect("current process should exist"); + + assert!(start_time > 1); +} + +#[test] +fn missing_process_start_time_returns_none() { + assert_eq!( + read_process_start_time(i32::MAX as u32).expect("missing process is not an I/O failure"), + None + ); +} + +#[test] +fn only_not_found_process_state_errors_mean_the_process_exited() { + assert!(process_state_is_missing(&std::io::Error::from( + std::io::ErrorKind::NotFound + ))); + assert!(!process_state_is_missing(&std::io::Error::from( + std::io::ErrorKind::PermissionDenied + ))); +} + +#[test] +fn fallback_lifetime_check_accepts_current_and_rejects_stale_start_times() { + let raw_pid = std::process::id(); + let pid = process_id(raw_pid).expect("current process id"); + let start_time = read_process_start_time(raw_pid) + .expect("read current process state") + .expect("current process should exist"); + let current = ProcessHandle { + pid, + start_time, + pidfd: None, + exit_timeout: Duration::from_millis(10), + }; + let stale = ProcessHandle { + pid, + start_time: start_time.saturating_add(1), + pidfd: None, + exit_timeout: Duration::from_millis(10), + }; + + current + .require_current_lifetime() + .expect("matching fallback lifetime"); + assert!(current.wait_for_exit().is_err()); + assert!(stale.require_current_lifetime().is_err()); + stale + .wait_for_exit() + .expect("a different lifetime means the original process exited"); +} + +#[test] +fn pidfd_wait_times_out_while_the_exact_process_is_still_running() { + let mut child = spawn_ready_sleep_child(); + let mut handle = match ProcessHandle::open(child.id(), "sleep").expect("open sleep handle") { + ProcessState::Running(handle) => handle, + ProcessState::Gone => panic!("sleep child should still be running"), + }; + handle.exit_timeout = Duration::from_millis(10); + + assert!(handle.wait_for_exit().is_err()); + + child.kill().expect("stop sleep child"); + child.wait().expect("reap sleep child"); +} + +#[test] +fn non_process_io_errors_are_not_collapsed_into_a_missing_process() { + let root = std::env::temp_dir().join(format!( + "unixnotis-process-state-directory-{}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create process state directory"); + + let result = read_process_start_time_from_path(&root); + + let _ = std::fs::remove_dir(&root); + assert!(result.is_err()); +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs b/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs new file mode 100644 index 000000000..3ed4a545c --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/quiescence.rs @@ -0,0 +1,139 @@ +use crate::test_support::fs::write_executable; + +use super::super::test_support::{fake_daemon_tool_root, test_install_paths}; +use super::{ + ensure_selected_service_inactive_until, wait_until_no_conflicting_live_daemon, + wait_until_no_conflicting_live_daemon_with_probe, +}; + +fn one_shot_live_daemon_check(paths: &crate::paths::InstallPaths) -> anyhow::Result<()> { + let deadline = std::time::Instant::now() + .checked_add(crate::service_manager::contract::ServiceProbe::default_timeout()) + .ok_or_else(|| anyhow::anyhow!("daemon check deadline exceeded the monotonic clock"))?; + super::ensure_no_conflicting_live_daemon_until(paths, deadline) +} + +#[test] +fn daemon_quiescence_wait_retries_until_broker_and_manager_are_inactive() { + let attempts = std::cell::Cell::new(0usize); + + wait_until_no_conflicting_live_daemon_with_probe( + std::time::Duration::from_secs(1), + std::time::Duration::ZERO, + |_deadline| { + let attempt = attempts.get(); + attempts.set(attempt.saturating_add(1)); + if attempt < 2 { + Err(anyhow::anyhow!("runtime still live")) + } else { + Ok(()) + } + }, + ) + .expect("runtime should become quiescent after bounded retries"); + + assert_eq!(attempts.get(), 3); +} + +#[test] +fn daemon_quiescence_wait_preserves_indeterminate_state_at_timeout() { + let attempts = std::cell::Cell::new(0usize); + + let error = wait_until_no_conflicting_live_daemon_with_probe( + std::time::Duration::ZERO, + std::time::Duration::ZERO, + |_deadline| { + attempts.set(attempts.get().saturating_add(1)); + Err(anyhow::anyhow!("broker inspection failed")) + }, + ) + .expect_err("indeterminate runtime state must fail closed at the deadline"); + + assert_eq!(attempts.get(), 1); + assert!(error + .to_string() + .contains("notification runtime did not become quiescent")); +} + +#[test] +fn production_quiescence_wait_rejects_an_elapsed_deadline() { + let paths = test_install_paths(); + + wait_until_no_conflicting_live_daemon(&paths, std::time::Duration::ZERO) + .expect_err("an elapsed production deadline must fail closed"); +} + +#[test] +fn selected_service_recheck_rejects_an_active_manager() { + let root = fake_daemon_tool_root("active-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=loaded\\nActiveState=active\\n'\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + let error = ensure_selected_service_inactive_until(&paths, deadline) + .expect_err("an active selected service must block activation"); + + assert!(error.to_string().contains("became active again")); + std::fs::remove_dir_all(root).expect("remove active service fixture"); +} + +#[test] +fn selected_service_recheck_rejects_an_operational_probe_failure() { + let root = fake_daemon_tool_root("indeterminate-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'Failed to connect to bus\\n' >&2\nexit 1\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + let error = ensure_selected_service_inactive_until(&paths, deadline) + .expect_err("an indeterminate selected service state must block activation"); + + assert!(error.to_string().contains("indeterminate state")); + std::fs::remove_dir_all(root).expect("remove indeterminate service fixture"); +} + +#[test] +fn selected_service_recheck_accepts_an_absent_unit() { + let root = fake_daemon_tool_root("absent-selected-service"); + write_executable( + &root.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=not-found\nActiveState=inactive\n'\n", + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let paths = test_install_paths(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + + ensure_selected_service_inactive_until(&paths, deadline) + .expect("an absent selected service is safe before first binary activation"); + + std::fs::remove_dir_all(root).expect("remove absent service fixture"); +} + +#[test] +fn generation_precommit_rejects_a_daemon_that_reappeared_after_stop() { + let root = fake_daemon_tool_root("fresh-owner-before-commit"); + write_executable( + &root.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.100\"\\n' ;; *'status :1.100'*) printf 'Comm=unixnotis-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let paths = test_install_paths(); + + let error = one_shot_live_daemon_check(&paths) + .expect_err("a daemon appearing before activation must block the generation switch"); + + assert!( + error + .to_string() + .contains("notification daemon appeared before binary activation"), + "unexpected precommit owner error: {error:#}" + ); + std::fs::remove_dir_all(root).expect("remove precommit owner fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs b/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs new file mode 100644 index 000000000..8ada6afab --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/stop.rs @@ -0,0 +1,249 @@ +use std::process::{Command, Stdio}; +use std::sync::mpsc; + +use crate::app::events::UiMessage; +use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; +use crate::test_support::fs::write_executable; + +use super::super::test_support::{ + action_context, fake_daemon_tool_root, known_daemon_detection, test_install_paths, +}; +use super::{ + is_systemd_unit_inactive, stop_active_daemon, stop_active_daemon_with_detection, + stop_active_daemon_with_quiescence, systemd_stop_error_is_satisfied_by_state, +}; + +#[test] +fn stop_active_daemon_errors_for_unmanaged_owner() { + let detection = Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: None, + comm: Some("unknown-daemon".to_string()), + }), + daemons: Vec::new(), + }; + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(4); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon_with_detection(&mut context, &detection) + .expect_err("unmanaged owner must block install"); + + assert!(error.to_string().contains("not managed by a known unit")); +} + +#[test] +fn stop_active_daemon_refreshes_ownership_after_the_initial_snapshot() { + let root = fake_daemon_tool_root("fresh-owner-before-stop"); + write_executable( + &root.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.99\"\\n' ;; *'status :1.99'*) printf 'Comm=appeared-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon(&mut context) + .expect_err("a daemon appearing after initial detection must block install"); + + assert!( + error.to_string().contains("not managed by a known unit"), + "unexpected fresh-owner error: {error:#}" + ); + std::fs::remove_dir_all(root).expect("remove fresh owner fixture"); +} + +#[test] +fn stop_active_daemon_terminates_the_exact_non_systemd_owner() { + let sleep = unixnotis_core::util::trusted_system_program_path("sleep") + .expect("find sleep in a trusted system directory"); + let mut child = Command::new(sleep) + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sleep daemon"); + wait_for_child_program(&mut child, "sleep"); + let detection = Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: Some(child.id()), + comm: Some("sleep".to_string()), + }), + daemons: vec![DetectedDaemon { + name: "sleep".to_string(), + unit: "sleep.service".to_string(), + systemd_active: false, + systemd_error: None, + running_pids: vec![child.id()], + is_owner: true, + }], + }; + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_detection(&mut context, &detection) + .expect("stable process stop should succeed"); + + let status = wait_for_child_exit(&mut child); + assert!(!status.success()); +} + +#[test] +fn stop_active_daemon_stops_unixnotis_without_disabling_its_unit() { + let root = fake_daemon_tool_root("unixnotis-reinstall-stop"); + let calls = root.join("systemctl-calls"); + write_executable( + &root.join("systemctl"), + &format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\n", calls.display()), + ); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_detection(&mut context, &detection) + .expect("reinstall stop should succeed"); + + let calls = std::fs::read_to_string(&calls).expect("systemctl calls"); + assert_eq!(calls.trim(), "--user stop unixnotis-daemon.service"); + let _cleanup = std::fs::remove_dir_all(root); +} + +#[test] +fn stop_does_not_report_success_when_quiescence_check_still_fails() { + let root = fake_daemon_tool_root("stop-quiescence-required"); + write_executable(&root.join("systemctl"), "#!/bin/sh\nexit 0\n"); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + let error = stop_active_daemon_with_quiescence(&mut context, &detection, |_paths| { + Err(anyhow::anyhow!("runtime is still live")) + }) + .expect_err("successful stop command must not bypass a live runtime"); + + assert!(format!("{error:#}").contains("runtime is still live")); + std::fs::remove_dir_all(root).expect("remove stop quiescence fixture"); +} + +#[test] +fn stop_command_failure_is_accepted_only_after_runtime_quiescence() { + let root = fake_daemon_tool_root("stop-command-stale-failure"); + write_executable(&root.join("systemctl"), "#!/bin/sh\nexit 1\n"); + let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); + let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); + let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); + let paths = test_install_paths(); + let (tx, _rx) = mpsc::sync_channel::(8); + let mut context = action_context(&paths, tx); + + stop_active_daemon_with_quiescence(&mut context, &detection, |_paths| Ok(())) + .expect("a stale stop failure is safe after runtime quiescence"); + + std::fs::remove_dir_all(root).expect("remove stale stop fixture"); +} + +#[test] +fn systemd_stop_error_can_continue_when_unit_is_inactive() { + // A failed stop is acceptable only when systemd reports a non-running state + assert!(systemd_stop_error_is_satisfied_by_state("inactive")); +} + +#[test] +fn systemd_stop_error_can_continue_when_unit_is_failed() { + // Failed units no longer own the notification bus, so reinstall may continue + assert!(systemd_stop_error_is_satisfied_by_state("failed")); +} + +#[test] +fn systemd_stop_error_still_fails_when_unit_stays_active() { + assert!(!systemd_stop_error_is_satisfied_by_state("active")); +} + +#[test] +fn systemd_stop_error_still_fails_when_unit_is_transitioning() { + assert!(!systemd_stop_error_is_satisfied_by_state("deactivating")); +} + +#[test] +fn systemd_stop_error_still_fails_when_state_is_empty() { + // Empty output means the manager did not provide enough proof that stopping succeeded + assert!(!systemd_stop_error_is_satisfied_by_state("")); +} + +#[test] +fn systemd_stop_error_trims_state_output_before_matching() { + // systemctl prints a trailing newline in normal output + assert!(systemd_stop_error_is_satisfied_by_state(" inactive\n")); + assert!(systemd_stop_error_is_satisfied_by_state("\tunknown ")); +} + +#[test] +fn systemd_stop_error_rejects_unrecognized_non_running_words() { + // Only explicit systemd states should satisfy a failed stop + assert!(!systemd_stop_error_is_satisfied_by_state("dead")); + assert!(!systemd_stop_error_is_satisfied_by_state("stopped")); +} + +#[test] +fn is_systemd_unit_inactive_reads_trusted_systemctl_state() { + let _lock = crate::test_support::env::test_env_lock(); + let root = fake_daemon_tool_root("systemctl-state"); + let fake_bin = root.join("bin"); + std::fs::create_dir_all(&fake_bin).expect("fake bin"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$3\" in inactive.service) echo inactive; exit 3 ;; active.service) echo active; exit 0 ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert!(is_systemd_unit_inactive("inactive.service").expect("inactive state")); + assert!(!is_systemd_unit_inactive("active.service").expect("active state")); + let error = + is_systemd_unit_inactive("missing.service").expect_err("empty failed status is an error"); + assert!(error + .to_string() + .contains("failed to read systemd unit state")); + + let _cleanup = std::fs::remove_dir_all(root); +} + +fn wait_for_child_exit(child: &mut std::process::Child) -> std::process::ExitStatus { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().expect("inspect stopped sleep daemon") { + return status; + } + if std::time::Instant::now() >= deadline { + let _kill = child.kill(); + let _reaped = child.wait(); + panic!("daemon stop did not terminate the expected process before deadline"); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +fn wait_for_child_program(child: &mut std::process::Child, expected: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if crate::detect::read_cmdline_program(child.id()).as_deref() == Some(expected) { + return; + } + if std::time::Instant::now() >= deadline { + let _kill = child.kill(); + let _reaped = child.wait(); + panic!("child did not enter expected program {expected} before deadline"); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} diff --git a/crates/unixnotis-installer/src/actions/daemon/tests/support.rs b/crates/unixnotis-installer/src/actions/daemon/tests/support.rs new file mode 100644 index 000000000..6a346deff --- /dev/null +++ b/crates/unixnotis-installer/src/actions/daemon/tests/support.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; +use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +pub(super) fn known_daemon_detection( + name: &str, + systemd_active: bool, + running_pids: Vec, +) -> Detection { + Detection { + owner: Some(OwnerInfo { + unique_name: None, + pid: Some(42), + comm: Some(name.to_string()), + }), + daemons: vec![DetectedDaemon { + name: name.to_string(), + unit: format!("{name}.service"), + systemd_active, + systemd_error: None, + running_pids, + is_owner: true, + }], + } +} + +pub(super) fn test_install_paths() -> InstallPaths { + InstallPaths { + repo_root: std::env::temp_dir(), + bin_dir: std::env::temp_dir(), + service: ServiceManager::systemd_user(std::env::temp_dir()), + } +} + +pub(super) fn action_context( + paths: &InstallPaths, + log_tx: mpsc::SyncSender, +) -> ActionContext<'_> { + ActionContext { + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +pub(super) fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "unixnotis-daemon-{label}-{}-{stamp}", + std::process::id() + )); + let _cleanup = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("fake daemon tool bin"); + root +} diff --git a/crates/unixnotis-installer/src/actions/environment/mod.rs b/crates/unixnotis-installer/src/actions/environment/mod.rs index 07ddd2e07..c139f7ea4 100644 --- a/crates/unixnotis-installer/src/actions/environment/mod.rs +++ b/crates/unixnotis-installer/src/actions/environment/mod.rs @@ -5,7 +5,6 @@ mod sync; pub use shell_path::{ensure_shell_path_entry, remove_shell_path_entry}; pub use sync::sync_user_environment; -pub use sync::HYPR_IMPORT_VARS; #[cfg(test)] #[path = "tests/mod.rs"] diff --git a/crates/unixnotis-installer/src/actions/environment/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/shell_path.rs index 942bf18cf..66c270b6b 100644 --- a/crates/unixnotis-installer/src/actions/environment/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/shell_path.rs @@ -5,9 +5,10 @@ use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use unixnotis_core::filesystem::write_file_atomic_preserving_mode; use crate::paths::format_with_home; -use crate::safe_write::{reject_unsafe_write_target, write_text_preserving_mode}; +use crate::write_target::reject_unsafe_write_target; use super::super::{log_line, ActionContext}; @@ -129,11 +130,6 @@ pub(in crate::actions::environment) fn ensure_path_entry_in_file( return Ok(false); } - if let Some(parent) = file.parent() { - fs::create_dir_all(parent) - .map_err(|err| anyhow!("failed to create {}: {}", parent.display(), err))?; - } - let export_line = format!( "export PATH=\"{}:$PATH\"", format_path_for_shell_line(home, bin_dir) @@ -147,7 +143,7 @@ pub(in crate::actions::environment) fn ensure_path_entry_in_file( updated.push_str(&export_line); updated.push('\n'); - write_text_preserving_mode(file, &updated, 0o644) + write_file_atomic_preserving_mode(file, updated.as_bytes(), 0o644) .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; Ok(true) } @@ -212,7 +208,7 @@ pub(in crate::actions::environment) fn remove_path_entry_from_file( } // Write the cleaned startup file back to disk - write_text_preserving_mode(file, &updated, 0o644) + write_file_atomic_preserving_mode(file, updated.as_bytes(), 0o644) .map_err(|err| anyhow!("failed to write {}: {}", file.display(), err))?; Ok(true) } @@ -246,18 +242,17 @@ pub(in crate::actions::environment) fn format_path_for_shell_line( bin_dir: &Path, ) -> String { // Prefer `$HOME` when possible so startup files stay portable across usernames - if let Ok(stripped) = bin_dir.strip_prefix(home) { - let tail = stripped.to_string_lossy(); - - // If the bin directory is exactly the home directory, `$HOME` alone is enough - if tail.is_empty() { - "$HOME".to_string() - } else { - // Convert the home-relative suffix into a shell-friendly `$HOME/...` path - format!("$HOME/{}", tail.trim_start_matches('/')) - } - } else { - // Fall back to the absolute path when the bin directory is outside home - bin_dir.display().to_string() - } + bin_dir.strip_prefix(home).map_or_else( + |_error| bin_dir.display().to_string(), + |stripped| { + let tail = stripped.to_string_lossy(); + // `$HOME` alone covers the exact home directory + if tail.is_empty() { + "$HOME".to_string() + } else { + // The home-relative suffix keeps startup files portable across usernames + format!("$HOME/{}", tail.trim_start_matches('/')) + } + }, + ) } diff --git a/crates/unixnotis-installer/src/actions/environment/sync.rs b/crates/unixnotis-installer/src/actions/environment/sync.rs index 78255cf36..a1f4dff95 100644 --- a/crates/unixnotis-installer/src/actions/environment/sync.rs +++ b/crates/unixnotis-installer/src/actions/environment/sync.rs @@ -5,23 +5,12 @@ use std::env; use anyhow::{anyhow, Result}; use unixnotis_core::program_in_path; +use unixnotis_core::service_manager::validate_session_bus_address; use super::super::{ install::write_service_artifact, log_line, run_command_without_stdout, ActionContext, }; -pub const HYPR_IMPORT_VARS: [&str; 8] = [ - // Keep this list narrow so debug output and service environments do not inherit full shells - "WAYLAND_DISPLAY", - "XDG_CURRENT_DESKTOP", - "XDG_SESSION_TYPE", - "XDG_SESSION_DESKTOP", - "DISPLAY", - "XDG_RUNTIME_DIR", - // Nonstandard session buses need the explicit address inherited by the login session - "DBUS_SESSION_BUS_ADDRESS", - "PATH", -]; const HYPR_REQUIRED_VARS: [&str; 2] = ["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]; pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { @@ -55,7 +44,9 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { // Import only the known session variables that are actually present in this process // Missing optional values are left alone so SSH, nested, and unusual sessions still work - let vars = HYPR_IMPORT_VARS + let import_var_names = ctx.paths.service.import_variable_names(); + validate_persisted_bus_address(import_var_names)?; + let vars = import_var_names .iter() .copied() .filter_map(|var| env::var(var).ok().map(|value| (var, value))) @@ -64,41 +55,41 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { let message = "no session environment variables found to import for the service manager"; log_line(ctx, format!("Error: {message}")); return Err(anyhow!(message)); - } else { - let env_artifacts = ctx - .paths - .service - .environment_sync_artifacts(&HYPR_IMPORT_VARS, &vars); - for artifact in &env_artifacts { - // Artifact-based managers persist a small envdir instead of importing into a daemon - write_service_artifact(ctx, artifact)?; - updated = true; - } - if !env_artifacts.is_empty() { - log_line(ctx, "Environment synced with service environment files"); - } + } - let specs = ctx - .paths - .service - .environment_sync_commands(&vars, dbus_update_available); - for spec in specs { - log_line(ctx, format!("Syncing environment with {}", spec.program())); - // Import commands can echo names or values on stdout on some setups - let command = match spec.to_command() { - Ok(command) => command, - Err(err) => { - log_line(ctx, format!("Warning: {err}")); - continue; - } - }; - if let Err(err) = run_command_without_stdout(ctx, spec.label(), command, None) { + let env_artifacts = ctx + .paths + .service + .environment_sync_artifacts(import_var_names, &vars); + for artifact in &env_artifacts { + // Artifact-based managers persist a small envdir instead of importing into a daemon + write_service_artifact(ctx, artifact)?; + updated = true; + } + if !env_artifacts.is_empty() { + log_line(ctx, "Environment synced with service environment files"); + } + + let specs = ctx + .paths + .service + .environment_sync_commands(&vars, dbus_update_available); + for spec in specs { + log_line(ctx, format!("Syncing environment with {}", spec.program())); + // Import commands can echo names or values on stdout on some setups + let command = match spec.to_command() { + Ok(command) => command, + Err(err) => { log_line(ctx, format!("Warning: {err}")); continue; } - log_line(ctx, format!("Environment synced with {}", spec.program())); - updated = true; + }; + if let Err(err) = run_command_without_stdout(ctx, spec.label(), command, None) { + log_line(ctx, format!("Warning: {err}")); + continue; } + log_line(ctx, format!("Environment synced with {}", spec.program())); + updated = true; } if !updated { @@ -111,3 +102,14 @@ pub fn sync_user_environment(ctx: &mut ActionContext) -> Result<()> { // Service start or restart stays owned by the caller so install avoids double boot Ok(()) } + +fn validate_persisted_bus_address(import_var_names: &[&str]) -> Result<()> { + if !import_var_names.contains(&"DBUS_SESSION_BUS_ADDRESS") { + return Ok(()); + } + let Ok(address) = env::var("DBUS_SESSION_BUS_ADDRESS") else { + return Ok(()); + }; + // Direct managers may persist only the stable runtime bus for this uid + validate_session_bus_address(&address, rustix::process::getuid().as_raw()).map_err(Into::into) +} diff --git a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs index f57ccbfdb..a79cba947 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/shell_path.rs @@ -12,7 +12,6 @@ use super::super::shell_path::{ }; use crate::actions::ActionContext; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -280,17 +279,12 @@ fn remove_shell_path_entry_removes_managed_block_from_selected_startup_files() { let _home = EnvGuard::set("HOME", &home); let _shell = EnvGuard::set("SHELL", "/bin/bash"); let (tx, rx) = mpsc::sync_channel::(16); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir, service: ServiceManager::systemd_user(home.join(".config/systemd/user")), }; let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/environment/tests/sync.rs b/crates/unixnotis-installer/src/actions/environment/tests/sync.rs index b769f7da9..6e32a7a73 100644 --- a/crates/unixnotis-installer/src/actions/environment/tests/sync.rs +++ b/crates/unixnotis-installer/src/actions/environment/tests/sync.rs @@ -4,7 +4,6 @@ use std::sync::{mpsc, Arc}; use crate::actions::{run_command_without_stdout, ActionContext}; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -14,17 +13,12 @@ fn env_sync_command_stdout_is_not_copied_into_logs() { // Command lookup reads PATH, so it must not race tests that replace process env let _lock = crate::test_support::env::test_env_lock(); let (tx, rx) = mpsc::sync_channel::(16); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: std::env::temp_dir(), bin_dir: std::env::temp_dir().join("bin"), service: ServiceManager::systemd_user(std::env::temp_dir().join("systemd")), }; let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/format/daemon_status.rs b/crates/unixnotis-installer/src/actions/format/daemon_status.rs index f842a7c8e..c696c9c17 100644 --- a/crates/unixnotis-installer/src/actions/format/daemon_status.rs +++ b/crates/unixnotis-installer/src/actions/format/daemon_status.rs @@ -2,18 +2,18 @@ use crate::detect::DetectedDaemon; -pub fn summarize_owner(owner: &Option) -> String { - match owner { - Some(info) => { +pub fn summarize_owner(owner: Option<&crate::detect::OwnerInfo>) -> String { + owner.map_or_else( + || "none detected".to_string(), + |info| { // Keep missing fields readable instead of showing an empty tuple let name = info.comm.as_deref().unwrap_or("unknown"); let pid = info .pid .map_or_else(|| "unknown".to_string(), |pid| pid.to_string()); format!("{name} (pid {pid})") - } - None => "none detected".to_string(), - } + }, + ) } pub const fn daemon_has_displayable_status(daemon: &DetectedDaemon) -> bool { diff --git a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs index aaea269e1..93981f9f9 100644 --- a/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs +++ b/crates/unixnotis-installer/src/actions/format/tests/daemon_status.rs @@ -5,17 +5,18 @@ use crate::detect::{DetectedDaemon, OwnerInfo}; fn summarize_owner_includes_comm_and_pid() { // Verifies formatted owner output includes both fields when available. let owner = OwnerInfo { + unique_name: None, pid: Some(4242), comm: Some("unixnotis-daemon".to_string()), }; - let rendered = summarize_owner(&Some(owner)); + let rendered = summarize_owner(Some(&owner)); assert_eq!(rendered, "unixnotis-daemon (pid 4242)"); } #[test] fn summarize_owner_handles_missing_owner() { // Ensures the empty-owner branch renders a stable placeholder string. - let rendered = summarize_owner(&None); + let rendered = summarize_owner(None); assert_eq!(rendered, "none detected"); } diff --git a/crates/unixnotis-installer/src/actions/hyprland/manage.rs b/crates/unixnotis-installer/src/actions/hyprland/manage.rs index fdc3acc32..b4dcb609b 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/manage.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/manage.rs @@ -1,6 +1,7 @@ //! Hyprland bootstrap flow for install and uninstall use std::fs; +use std::path::Path; use super::super::{log_line, ActionContext}; use super::block::strip_hyprland_bootstrap_block; @@ -8,10 +9,13 @@ use super::detect::{ has_import_command_with_vars, has_legacy_dbus_update, has_startup_command, hyprland_startup_line, }; -use super::paths::{existing_hyprland_config_targets, hyprland_config_target}; +use super::paths::{ + existing_hyprland_config_targets, hyprland_config_target, HyprlandConfigSyntax, +}; use super::write_target::resolve_hyprland_write_path; use crate::paths::format_with_home; -use crate::safe_write::{reject_unsafe_write_target, write_text_preserving_mode}; +use crate::write_target::reject_unsafe_write_target; +use unixnotis_core::filesystem::write_file_atomic_preserving_mode; pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { // Resolve the active top-level config before deciding which syntax to write @@ -51,19 +55,8 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { ); return; } - let contents = match fs::read_to_string(&write_path) { - Ok(contents) => contents, - Err(err) => { - log_line( - ctx, - format!( - "Warning: failed to read {}: {}", - format_with_home(&hypr_config), - err - ), - ); - return; - } + let Some(contents) = read_hyprland_config(ctx, &write_path, &hypr_config) else { + return; }; // Strip any managed block first so missing lines can be rebuilt cleanly @@ -84,12 +77,13 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { // Add only the lines that are still missing from the live config let mut additions = Vec::new(); // User-managed equivalents outside the installer block should not be duplicated + let import_variables = ctx.paths.service.import_variable_names(); for command in ctx .paths .service - .hyprland_startup_commands(&super::super::HYPR_IMPORT_VARS) + .hyprland_startup_commands(import_variables) { - if hyprland_command_present(&stripped, &command) { + if hyprland_command_present(&stripped, &command, import_variables) { continue; } additions.push(hyprland_startup_line(target.syntax, &command)); @@ -98,7 +92,9 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { if additions.is_empty() { // If the live file already has everything, drop stale managed blocks and stop if block_found { - if let Err(err) = write_text_preserving_mode(&write_path, &stripped, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(&write_path, stripped.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), @@ -117,16 +113,35 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { return; } - let mut updated_contents = stripped; + write_hyprland_bootstrap( + ctx, + &write_path, + &hypr_config, + target.syntax, + &additions, + stripped, + ); +} + +fn write_hyprland_bootstrap( + ctx: &mut ActionContext, + write_path: &Path, + hypr_config: &Path, + syntax: HyprlandConfigSyntax, + additions: &[String], + mut updated_contents: String, +) { + // Keep publication in one helper so the discovery path remains easy to audit if !updated_contents.ends_with('\n') { updated_contents.push('\n'); } updated_contents.push_str(&super::block::render_hyprland_bootstrap_block( - target.syntax, - &additions, + syntax, additions, )); - if let Err(err) = write_text_preserving_mode(&write_path, &updated_contents, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(write_path, updated_contents.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), @@ -136,18 +151,36 @@ pub(in crate::actions) fn ensure_hyprland_autostart(ctx: &mut ActionContext) { ctx, format!( "Updated Hyprland config at {}", - format_with_home(&hypr_config) + format_with_home(hypr_config) ), ); } } -fn hyprland_command_present(contents: &str, command: &str) -> bool { +fn read_hyprland_config( + ctx: &mut ActionContext, + write_path: &Path, + display_path: &Path, +) -> Option { + fs::read_to_string(write_path) + .map_err(|error| { + log_line( + ctx, + format!( + "Warning: failed to read {}: {error}", + format_with_home(display_path) + ), + ); + }) + .ok() +} + +fn hyprland_command_present(contents: &str, command: &str, import_variables: &[&str]) -> bool { if command.starts_with("dbus-update-activation-environment") { return has_legacy_dbus_update(contents) || has_startup_command(contents, command); } if command.contains("import-environment") { - return has_import_command_with_vars(contents, &super::super::HYPR_IMPORT_VARS); + return has_import_command_with_vars(contents, import_variables); } has_startup_command(contents, command) } @@ -206,7 +239,9 @@ pub(in crate::actions) fn remove_hyprland_autostart(ctx: &mut ActionContext) { continue; } - if let Err(err) = write_text_preserving_mode(&write_path, &strip_result.stripped, 0o644) { + if let Err(err) = + write_file_atomic_preserving_mode(&write_path, strip_result.stripped.as_bytes(), 0o644) + { log_line( ctx, format!("Warning: failed to update Hyprland config: {err}"), diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs index 8f86fdf0b..c8875cfaf 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/bootstrap_block.rs @@ -2,7 +2,6 @@ use super::super::block::{ strip_hyprland_bootstrap_block, HYPR_BOOTSTRAP_END, HYPR_BOOTSTRAP_START, }; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use std::path::Path; @@ -13,14 +12,9 @@ use std::sync::{mpsc, Arc}; fn strip_hyprland_bootstrap_block_handles_malformed_block() { let _lock = crate::test_support::env::test_env_lock(); // Confirms malformed markers leave the original content intact for safe append - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -39,14 +33,9 @@ fn strip_hyprland_bootstrap_block_handles_malformed_block() { fn strip_hyprland_bootstrap_block_removes_managed_block() { let _lock = crate::test_support::env::test_env_lock(); // Ensures a well-formed block is removed and the remaining content is preserved - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -65,14 +54,9 @@ fn strip_hyprland_bootstrap_block_removes_managed_block() { #[test] fn strip_hyprland_bootstrap_block_removes_all_blocks() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -92,14 +76,9 @@ fn strip_hyprland_bootstrap_block_removes_all_blocks() { #[test] fn strip_hyprland_bootstrap_block_removes_comment_prefixes_without_residue() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -121,14 +100,9 @@ fn strip_hyprland_bootstrap_block_removes_comment_prefixes_without_residue() { #[test] fn strip_hyprland_bootstrap_block_matches_exact_hyprlang_marker_comments() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -150,14 +124,9 @@ fn strip_hyprland_bootstrap_block_matches_exact_hyprlang_marker_comments() { #[test] fn strip_hyprland_bootstrap_block_ignores_marker_text_inside_lua_strings() { let _lock = crate::test_support::env::test_env_lock(); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = crate::actions::ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs index 8f5b3168a..222ed1a5d 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/config_format.rs @@ -2,7 +2,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use super::super::super::HYPR_IMPORT_VARS; use super::super::block::render_hyprland_bootstrap_block; use super::super::detect::{ has_import_command_with_vars, has_legacy_dbus_update, has_startup_command, @@ -75,7 +74,8 @@ fn existing_hyprland_config_targets_include_both_migration_formats() { #[test] fn rendered_lua_bootstrap_is_detected_as_complete() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - let commands = manager.hyprland_startup_commands(&HYPR_IMPORT_VARS); + let import_variables = manager.import_variable_names(); + let commands = manager.hyprland_startup_commands(import_variables); let lines = commands .iter() .map(|command| hyprland_startup_line(HyprlandConfigSyntax::Lua, command)) @@ -91,7 +91,14 @@ fn rendered_lua_bootstrap_is_detected_as_complete() { assert!(commands .iter() .all(|command| has_startup_command(&block, command) - || has_import_command_with_vars(&block, &HYPR_IMPORT_VARS))); + || has_import_command_with_vars(&block, import_variables))); + assert!(block.contains("systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS")); + let import_line = block + .lines() + .find(|line| line.contains("import-environment")) + .expect("rendered systemd import line"); + assert!(!import_line.contains("DBUS_SESSION_BUS_ADDRESS")); + assert!(!import_line.contains(" PATH")); } #[test] @@ -108,9 +115,13 @@ fn commented_lua_bootstrap_commands_are_ignored() { #[test] fn partial_import_environment_command_is_not_complete() { let contents = "exec-once = systemctl --user import-environment WAYLAND_DISPLAY\n"; + let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); // The installer needs every expected session variable before it can skip rebuilding the line - assert!(!has_import_command_with_vars(contents, &HYPR_IMPORT_VARS)); + assert!(!has_import_command_with_vars( + contents, + manager.import_variable_names() + )); } #[test] diff --git a/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs b/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs index 4e71b85bc..ed677f95a 100644 --- a/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs +++ b/crates/unixnotis-installer/src/actions/hyprland/tests/symlink_safety.rs @@ -2,7 +2,6 @@ use super::super::block::{HYPR_BOOTSTRAP_END, HYPR_BOOTSTRAP_START}; use super::super::{ensure_hyprland_autostart, remove_hyprland_autostart}; use crate::actions::ActionContext; use crate::app::events::UiMessage; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; @@ -28,10 +27,6 @@ fn hyprland_autostart_supports_config_symlink_to_regular_file_inside_home() { symlink(&target, &config_link).expect("config symlink"); let _home = EnvGuard::set("HOME", &home); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -39,7 +34,6 @@ fn hyprland_autostart_supports_config_symlink_to_regular_file_inside_home() { }; let (tx, rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -85,10 +79,6 @@ fn ensure_hyprland_autostart_rejects_config_symlink_outside_home() { symlink(&outside, &config_link).expect("config symlink"); let _home = EnvGuard::set("HOME", &home); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -96,7 +86,6 @@ fn ensure_hyprland_autostart_rejects_config_symlink_outside_home() { }; let (tx, rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, @@ -134,10 +123,6 @@ fn remove_hyprland_autostart_strips_managed_block_from_real_config() { ) .expect("hypr config"); let _xdg = EnvGuard::set("XDG_CONFIG_HOME", &config_home); - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let paths = InstallPaths { repo_root: root.clone(), bin_dir: root.join("bin"), @@ -145,7 +130,6 @@ fn remove_hyprland_autostart_strips_managed_block_from_real_config() { }; let (tx, _rx) = mpsc::sync_channel::(8); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/install/binaries.rs b/crates/unixnotis-installer/src/actions/install/binaries.rs index f79fa669e..83a5fb076 100644 --- a/crates/unixnotis-installer/src/actions/install/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/binaries.rs @@ -1,10 +1,12 @@ //! Binary install and uninstall helpers -use std::fs::{self, File, OpenOptions}; -use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + read_symlink, remove_directory_tree, remove_regular_file, remove_symlink_if_target, + RemoveSymlinkOutcome, +}; use crate::managed_binaries::validate_managed_binary_names; use crate::paths::format_with_home; @@ -15,16 +17,35 @@ use super::super::{ }, log_line, ActionContext, }; +use crate::actions::daemon::DaemonActivationReservation; +use crate::actions::releases::install_release_generation_transaction; -pub fn install_binaries(ctx: &mut ActionContext) -> Result<()> { +pub fn install_binaries( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + let (binaries, release_dir) = resolve_install_inputs(ctx)?; + let generation = install_release_generation_transaction( + ctx.paths, + &release_dir, + &binaries, + || Ok(()), + || Ok(()), + || crate::actions::daemon::ensure_selected_service_inactive(ctx.paths), + )?; + log_installed_generation(ctx, &binaries, &generation); + Ok(()) +} + +pub(in crate::actions::install) fn resolve_install_inputs( + ctx: &mut ActionContext, +) -> Result<(Vec, PathBuf)> { // Read the managed binary list from installer metadata so install and uninstall stay aligned let binaries = resolve_install_binaries(ctx.paths)?; // Cargo metadata is the only reliable way to find the active release target directory let release_dir = resolve_release_dir(ctx)?; - fs::create_dir_all(&ctx.paths.bin_dir).with_context(|| "failed to create bin directory")?; - - // Check every source first so install never leaves a half-updated bin directory behind + // Check every source before the versioned release transaction allocates staging state let mut missing = Vec::new(); for binary in &binaries { let source = release_dir.join(binary); @@ -42,14 +63,27 @@ pub fn install_binaries(ctx: &mut ActionContext) -> Result<()> { // Validate again at the copy boundary so future discovery changes cannot widen file access let binaries = validate_managed_binary_names(binaries) .with_context(|| "refusing to install an unmanaged binary path")?; + Ok((binaries, release_dir)) +} + +pub(in crate::actions::install) fn log_installed_generation( + ctx: &mut ActionContext, + binaries: &[String], + generation: &str, +) { + log_line( + ctx, + format!("Activated complete UnixNotis release generation {generation}"), + ); for binary in binaries { - let source = release_dir.join(&binary); - let destination = ctx.paths.bin_dir.join(&binary); - // One helper handles both source builds and downloaded archives after source resolution - copy_binary(ctx, &source, &destination)?; + log_line( + ctx, + format!( + "Installed {binary} -> {}", + format_with_home(&ctx.paths.bin_dir.join(binary)) + ), + ); } - - Ok(()) } pub fn remove_binaries(ctx: &mut ActionContext) -> Result<()> { @@ -72,10 +106,39 @@ pub(in crate::actions::install) fn remove_resolved_binaries( // Uninstall is destructive, so validate again immediately before building removal paths let binaries = validate_managed_binary_names(binaries) .with_context(|| "refusing to remove an unmanaged binary path")?; + let expected_root = crate::actions::releases::entrypoint_target(); for binary in binaries { let path = ctx.paths.bin_dir.join(binary); - if path.exists() { - fs::remove_file(&path).with_context(|| "failed to remove binary")?; + let removed = match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + match remove_symlink_if_target( + &path, + &expected_root.join(path.file_name().unwrap_or_default()), + )? { + RemoveSymlinkOutcome::Removed => true, + RemoveSymlinkOutcome::Missing => false, + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "refusing to remove unmanaged binary link {} -> {}", + path.display(), + actual.display() + )) + } + } + } + Ok(metadata) if metadata.file_type().is_file() => { + remove_regular_file(&path).with_context(|| "failed to remove legacy binary")? + } + Ok(_metadata) => { + return Err(anyhow!( + "refusing to remove non-file binary entrypoint {}", + path.display() + )) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).with_context(|| format!("inspect {}", path.display())), + }; + if removed { log_line(ctx, format!("Removed binary {}", format_with_home(&path))); } else { log_line( @@ -85,6 +148,26 @@ pub(in crate::actions::install) fn remove_resolved_binaries( } } + let install_root = ctx.paths.installed_release_root()?; + if let Some(current_target) = read_symlink(&ctx.paths.installed_current_link()?)? { + match remove_symlink_if_target(&ctx.paths.installed_current_link()?, ¤t_target)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => {} + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "current release link changed during uninstall to {}", + actual.display() + )) + } + } + } + let pending = ctx.paths.installed_pending_manifest()?; + if pending.exists() { + remove_regular_file(&pending).context("remove pending release state")?; + } + if install_root.exists() { + remove_directory_tree(&install_root).context("remove installed release generations")?; + } + Ok(()) } @@ -104,107 +187,3 @@ fn resolve_release_dir(ctx: &mut ActionContext) -> Result { })?; Ok(target_dir.join("release")) } - -fn copy_binary(ctx: &mut ActionContext, source: &Path, destination: &Path) -> Result<()> { - if !source.exists() { - return Err(anyhow!( - "missing build artifact: {}", - format_with_home(source) - )); - } - - let source_display = format_with_home(source); - let destination_display = format_with_home(destination); - // Stage the copy beside the final file so the rename can replace atomically - let temp_path = stage_binary_copy_with_retry(source, destination).map_err(|err| { - anyhow!("failed to stage {source_display} -> {destination_display}: {err}") - })?; - - // Rename replaces the destination in one step so there is no missing-binary window - if let Err(err) = fs::rename(&temp_path, destination) { - let _ = fs::remove_file(&temp_path); - return Err(anyhow!( - "failed to install {source_display} -> {destination_display}: {err}" - )); - } - log_line( - ctx, - format!( - "Installed {} -> {}", - source.file_name().unwrap_or_default().to_string_lossy(), - format_with_home(destination) - ), - ); - Ok(()) -} - -pub(super) fn binary_temp_path(destination: &Path) -> PathBuf { - // The temp file sits beside the final binary so rename stays atomic - let temp_name = format!( - "{}.tmp-{}", - destination - .file_name() - .unwrap_or_default() - .to_string_lossy(), - std::process::id() - ); - destination.with_file_name(temp_name) -} - -fn stage_binary_copy(source: &Path, temp_path: &Path) -> io::Result<()> { - // create_new refuses attacker-created symlinks or stale files at the temp path - let mut input = File::open(source)?; - let mut output = OpenOptions::new() - .write(true) - .create_new(true) - .open(temp_path)?; - io::copy(&mut input, &mut output).inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - })?; - output.sync_all().inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - })?; - let permissions = fs::metadata(source)?.permissions(); - fs::set_permissions(temp_path, permissions).inspect_err(|_err| { - let _ = fs::remove_file(temp_path); - }) -} - -pub(in crate::actions::install) fn stage_binary_copy_with_retry( - source: &Path, - destination: &Path, -) -> io::Result { - for attempt in 0..16 { - let temp_path = binary_temp_path_attempt(destination, attempt); - match stage_binary_copy(source, &temp_path) { - Ok(()) => return Ok(temp_path), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error), - } - } - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not allocate a safe temporary binary path", - )) -} - -pub(in crate::actions::install) fn binary_temp_path_attempt( - destination: &Path, - attempt: u8, -) -> PathBuf { - if attempt == 0 { - return binary_temp_path(destination); - } - let file_name = destination - .file_name() - .unwrap_or_default() - .to_string_lossy(); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - destination.with_file_name(format!( - "{file_name}.tmp-{}-{nonce}-{attempt}", - std::process::id() - )) -} diff --git a/crates/unixnotis-installer/src/actions/install_state.rs b/crates/unixnotis-installer/src/actions/install/install_state.rs similarity index 52% rename from crates/unixnotis-installer/src/actions/install_state.rs rename to crates/unixnotis-installer/src/actions/install/install_state.rs index 3dcb762c4..affa94fd9 100644 --- a/crates/unixnotis-installer/src/actions/install_state.rs +++ b/crates/unixnotis-installer/src/actions/install/install_state.rs @@ -5,8 +5,9 @@ use std::path::PathBuf; use crate::paths::InstallPaths; use crate::service_manager::ServiceArtifact; -use super::binaries::resolve_install_binaries_best_effort; -use super::conflicts::{detect_service_manager_conflict_state, ServiceManagerConflict}; +use super::super::binaries::resolve_install_binaries_best_effort; +use super::super::conflicts::{detect_service_manager_conflict_state, ServiceManagerConflict}; +use super::super::releases::{inspect_installed_generation, BinaryHealth}; #[derive(Clone)] pub(in crate::actions) struct BinaryState { @@ -14,8 +15,8 @@ pub(in crate::actions) struct BinaryState { pub(in crate::actions) name: String, // Concrete install path shown in logs when a binary is missing or present pub(in crate::actions) path: PathBuf, - // Existence is enough here because binary copying owns replacement safety later - pub(in crate::actions) exists: bool, + // Read-side health uses the same generation, link, type, size, and digest invariants as install + pub(in crate::actions) health: BinaryHealth, } #[derive(Clone)] @@ -39,20 +40,85 @@ pub struct InstallState { pub(in crate::actions) service_conflict_warnings: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstallationDisposition { + // No selected-manager artifact or managed binary entrypoint was found + NotInstalled, + // Every binary belongs to one verified generation and manager state is trustworthy + InstalledHealthy, + // A managed footprint exists but at least one required health invariant failed + RepairRequired, +} + +impl InstallationDisposition { + pub const fn label(self) -> &'static str { + match self { + Self::NotInstalled => "not installed", + Self::InstalledHealthy => "healthy", + Self::RepairRequired => "repair required", + } + } +} + impl InstallState { pub fn is_installed(&self) -> bool { - // Treat installed as binaries plus the service artifact; runtime status is separate - !self.binaries.is_empty() - && self.binaries.iter().all(|binary| binary.exists) + // Healthy means both filesystem integrity and manager inspection are trustworthy + self.healthy_generation().is_some() && self.service_artifact_exists + && self.service_enabled_error.is_none() + && self.service_active_error.is_none() + && self.service_conflicts.is_empty() } pub fn is_fully_installed(&self) -> bool { self.is_installed() && self.service_active } - pub const fn service_enabled(&self) -> bool { - self.service_enabled + pub fn disposition(&self) -> InstallationDisposition { + if self.is_installed() { + InstallationDisposition::InstalledHealthy + } else if self.has_installation_footprint() { + InstallationDisposition::RepairRequired + } else { + InstallationDisposition::NotInstalled + } + } + + pub fn installed_version(&self) -> Option<&str> { + self.healthy_generation().map(|(_, version)| version) + } + + fn has_installation_footprint(&self) -> bool { + self.service_artifact_exists + || self + .binaries + .iter() + .any(|binary| !matches!(binary.health, BinaryHealth::Missing)) + } + + fn healthy_generation(&self) -> Option<(&str, &str)> { + let BinaryHealth::Healthy { + generation, + package_version, + .. + } = &self.binaries.first()?.health + else { + return None; + }; + // A set of individually valid binaries is still invalid when generations differ + self.binaries + .iter() + .all(|binary| { + matches!( + &binary.health, + BinaryHealth::Healthy { + generation: candidate_generation, + package_version: candidate_version, + .. + } if candidate_generation == generation && candidate_version == package_version + ) + }) + .then_some((generation, package_version)) } } @@ -60,15 +126,12 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { // Keep install state aligned with installer binary discovery // Best-effort resolution keeps install state usable even if workspace metadata is broken let (binaries, warning) = resolve_install_binaries_best_effort(paths); - let binaries = binaries + let binaries = inspect_installed_generation(paths, &binaries) .into_iter() - .map(|name| { - let path = paths.bin_dir.join(&name); - BinaryState { - name, - exists: path.exists(), - path, - } + .map(|(name, health)| BinaryState { + path: paths.bin_dir.join(&name), + name, + health, }) .collect::>(); @@ -78,10 +141,7 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { // Enabled state decides whether reinstall can skip `enable --now` // Some backends store enablement as installer-owned artifacts instead of manager state let mut service_enabled_error = None; - let service_enabled = if let Some(enabled) = paths.service.enabled_by_artifacts() { - // Artifact-backed managers prove enablement through installer-owned filesystem state - enabled - } else { + let service_enabled = paths.service.enabled_by_artifacts().unwrap_or_else(|| { if let Some(spec) = paths.service.is_enabled_command() { match spec.to_command().and_then(|mut command| command.status()) { // Command-backed managers still use the native manager status probe @@ -97,22 +157,28 @@ pub fn check_install_state(paths: &InstallPaths) -> InstallState { Some("service manager has no enabled-state command".to_string()); false } - }; + }); // Active state still matters for the install summary shown in the UI let mut service_active_error = None; - let service_active = if let Some(probe) = paths.service.active_probe() { - match probe.evaluate() { - // Active probes can be plain exit status or stdout parsing, depending on backend - Ok(active) => active, - Err(err) => { - service_active_error = Some(err.to_string()); - false - } + let service_active = match paths.service.active_probe().evaluate_state() { + Ok(crate::service_manager::contract::ServiceProbeState::Active) => true, + Ok( + crate::service_manager::contract::ServiceProbeState::Absent + | crate::service_manager::contract::ServiceProbeState::Inactive, + ) => false, + Ok(crate::service_manager::contract::ServiceProbeState::Unavailable) => { + service_active_error = Some("selected service manager is unavailable".to_string()); + false + } + Ok(crate::service_manager::contract::ServiceProbeState::Indeterminate) => { + service_active_error = + Some("selected service manager state is indeterminate".to_string()); + false + } + Err(err) => { + service_active_error = Some(err.to_string()); + false } - } else { - // Backends without active state still allow install, but cannot claim a running service - service_active_error = Some("service manager has no active-state command".to_string()); - false }; let (service_conflicts, service_conflict_warnings) = diff --git a/crates/unixnotis-installer/src/actions/install/installation_channel.rs b/crates/unixnotis-installer/src/actions/install/installation_channel.rs new file mode 100644 index 000000000..6b9b9a927 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/installation_channel.rs @@ -0,0 +1,319 @@ +//! Active systemd unit channel classification for source-install safety + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use super::super::{log_line, ActionContext}; + +const SYSTEM_UNIT_ROOT: &str = "/usr/lib/systemd/user"; +const SYSTEM_BINARY_ROOT: &str = "/usr/bin"; +const MAX_SYSTEMCTL_OUTPUT_BYTES: usize = 32 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallationChannel { + HomeLocal, + SystemPackage, + Mixed, + Unknown, +} + +#[derive(Debug, Eq, PartialEq)] +enum ActiveUnitMetadata { + // No loaded unit leaves the home-local channel available + Absent, + // Session-only masks are safe to clear during an explicit installation + RuntimeMasked, + // Persistent masks reflect a user decision and must remain untouched + PersistentMasked, + // Loaded units retain both paths so mixed channels cannot pass unnoticed + Paths { + fragment: PathBuf, + executable: PathBuf, + }, +} + +pub(in crate::actions) fn reject_conflicting_installation_channel( + ctx: &mut ActionContext, +) -> Result<()> { + if !ctx.paths.service.is_systemd() { + return Ok(()); + } + let metadata = active_unit_metadata()?; + let paths = match metadata { + ActiveUnitMetadata::Absent => return Ok(()), + ActiveUnitMetadata::RuntimeMasked => { + // A temporary mask can hide a package unit, so inspect its fixed artifacts first + let Some(paths) = installed_system_package_paths_at( + Path::new(SYSTEM_UNIT_ROOT), + Path::new(SYSTEM_BINARY_ROOT), + )? + else { + return Ok(()); + }; + paths + } + ActiveUnitMetadata::PersistentMasked => { + bail!( + "UnixNotis systemd unit is persistently masked; run `systemctl --user unmask unixnotis-daemon.service` before installing" + ) + } + ActiveUnitMetadata::Paths { + fragment, + executable, + } => (fragment, executable), + }; + reject_channel(ctx, &paths.0, &paths.1) +} + +fn reject_channel(ctx: &mut ActionContext, fragment: &Path, executable: &Path) -> Result<()> { + let channel = classify_installation_channel( + fragment, + executable, + ctx.paths.service.artifact_root(), + &ctx.paths.bin_dir, + ); + reject_classified_channel(ctx, channel, fragment, executable) +} + +fn reject_classified_channel( + ctx: &mut ActionContext, + channel: InstallationChannel, + fragment: &Path, + executable: &Path, +) -> Result<()> { + match channel { + InstallationChannel::HomeLocal => Ok(()), + InstallationChannel::SystemPackage => { + log_channel_conflict(ctx, "system package", fragment, executable); + bail!( + "the system-package UnixNotis installation must be removed with its package manager before a home-local install" + ) + } + InstallationChannel::Mixed => { + log_channel_conflict(ctx, "mixed", fragment, executable); + bail!( + "mixed UnixNotis installation channels detected; repair the unit and executable paths before installing" + ) + } + InstallationChannel::Unknown => { + log_channel_conflict(ctx, "unrecognized", fragment, executable); + bail!( + "the active UnixNotis unit uses an unrecognized installation channel; automatic replacement is unsafe" + ) + } + } +} + +fn active_unit_metadata() -> Result { + let mut command = crate::system_tools::command("systemctl")?; + command.args([ + "--user", + "show", + "unixnotis-daemon.service", + "--property=LoadState", + "--property=UnitFileState", + "--property=FragmentPath", + "--property=ExecStart", + "--no-pager", + ]); + let output = command + .output() + .context("inspect active UnixNotis systemd unit")?; + let text = validate_systemctl_output(output)?; + parse_active_unit_metadata(&text) +} + +fn validate_systemctl_output(output: std::process::Output) -> Result { + if output.stdout.len() > MAX_SYSTEMCTL_OUTPUT_BYTES + || output.stderr.len() > MAX_SYSTEMCTL_OUTPUT_BYTES + { + bail!("systemctl unit metadata exceeded the safe output limit"); + } + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "failed to inspect UnixNotis systemd unit (status {}): {}", + output.status, + stderr.trim() + ); + } + String::from_utf8(output.stdout).context("systemctl unit metadata was not UTF-8") +} + +fn parse_active_unit_metadata(text: &str) -> Result { + match property_value(text, "LoadState") { + Some("not-found") => return Ok(ActiveUnitMetadata::Absent), + Some("masked") => { + return if property_value(text, "UnitFileState") == Some("masked-runtime") { + Ok(ActiveUnitMetadata::RuntimeMasked) + } else { + Ok(ActiveUnitMetadata::PersistentMasked) + }; + } + Some("loaded") => {} + Some(state) => bail!("systemctl reported unusable UnixNotis unit load state {state}"), + None => bail!("systemctl omitted UnixNotis unit load state metadata"), + } + + let fragment = property_value(text, "FragmentPath").map(PathBuf::from); + let executable = property_value(text, "ExecStart") + .and_then(parse_exec_start_path) + .map(PathBuf::from); + match (fragment, executable) { + (Some(fragment), Some(executable)) => Ok(ActiveUnitMetadata::Paths { + fragment, + executable, + }), + _ => bail!("systemctl returned incomplete UnixNotis unit path metadata"), + } +} + +fn installed_system_package_paths_at( + unit_root: &Path, + binary_root: &Path, +) -> Result> { + // Fixed package locations remain visible even when systemd reports only a runtime mask + let fragment = unit_root.join("unixnotis-daemon.service"); + let executable = binary_root.join("unixnotis-daemon"); + let fragment_exists = path_entry_exists(&fragment)?; + let executable_exists = path_entry_exists(&executable)?; + match (fragment_exists, executable_exists) { + (false, false) => Ok(None), + (true, true) => Ok(Some((fragment, executable))), + _ => bail!( + "incomplete system-package UnixNotis artifacts detected; repair or remove the package before installing" + ), + } +} + +fn path_entry_exists(path: &Path) -> Result { + // Metadata on the directory entry detects dangling links without following them + match fs::symlink_metadata(path) { + Ok(_metadata) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn property_value<'a>(text: &'a str, name: &str) -> Option<&'a str> { + text.lines() + .find_map(|line| line.strip_prefix(name)?.strip_prefix('=')) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn parse_exec_start_path(value: &str) -> Option<&str> { + let path = value + .split(';') + .find_map(|field| { + field + .trim() + .trim_start_matches('{') + .trim() + .strip_prefix("path=") + })? + .trim(); + (!path.is_empty()).then_some(path) +} + +fn classify_installation_channel( + fragment: &Path, + executable: &Path, + home_unit_root: &Path, + home_binary_root: &Path, +) -> InstallationChannel { + classify_installation_channel_at( + fragment, + executable, + home_unit_root, + home_binary_root, + Path::new(SYSTEM_UNIT_ROOT), + Path::new(SYSTEM_BINARY_ROOT), + ) +} + +fn classify_installation_channel_at( + fragment: &Path, + executable: &Path, + home_unit_root: &Path, + home_binary_root: &Path, + system_unit_root: &Path, + system_binary_root: &Path, +) -> InstallationChannel { + let unit_channel = path_channel(fragment, home_unit_root, system_unit_root); + let home_release_root = home_binary_root + .parent() + .map(|root| root.join("lib").join("unixnotis")); + let binary_channel = binary_path_channel( + executable, + home_binary_root, + home_release_root.as_deref(), + system_binary_root, + ); + match (unit_channel, binary_channel) { + (Some(InstallationChannel::HomeLocal), Some(InstallationChannel::HomeLocal)) => { + InstallationChannel::HomeLocal + } + (Some(InstallationChannel::SystemPackage), Some(InstallationChannel::SystemPackage)) => { + InstallationChannel::SystemPackage + } + (Some(_), Some(_)) => InstallationChannel::Mixed, + _ => InstallationChannel::Unknown, + } +} + +fn binary_path_channel( + path: &Path, + home_binary_root: &Path, + home_release_root: Option<&Path>, + system_binary_root: &Path, +) -> Option { + let path = fs::canonicalize(path).ok()?; + if fs::canonicalize(home_binary_root) + .ok() + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); + } + if home_release_root + .and_then(|root| fs::canonicalize(root).ok()) + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); + } + fs::canonicalize(system_binary_root) + .ok() + .filter(|root| path.starts_with(root)) + .map(|_root| InstallationChannel::SystemPackage) +} + +fn path_channel(path: &Path, home_root: &Path, system_root: &Path) -> Option { + // Resolve the object and both policy roots so lexical symlink placement has no authority + let path = fs::canonicalize(path).ok()?; + if fs::canonicalize(home_root) + .ok() + .is_some_and(|root| path.starts_with(root)) + { + return Some(InstallationChannel::HomeLocal); + } + fs::canonicalize(system_root) + .ok() + .filter(|root| path.starts_with(root)) + .map(|_root| InstallationChannel::SystemPackage) +} + +fn log_channel_conflict(ctx: &mut ActionContext, label: &str, fragment: &Path, executable: &Path) { + log_line( + ctx, + format!("Error: {label} UnixNotis installation channel"), + ); + log_line(ctx, format!("- unit: {}", fragment.display())); + log_line(ctx, format!("- executable: {}", executable.display())); +} + +#[cfg(test)] +#[path = "tests/installation_channel.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/installer_lock.rs b/crates/unixnotis-installer/src/actions/install/installer_lock.rs new file mode 100644 index 000000000..c981835e8 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/installer_lock.rs @@ -0,0 +1,68 @@ +//! Process-wide installer action serialization + +use std::fs::{self, File}; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use rustix::fs::{flock, open, FlockOperation, Mode, OFlags}; +use rustix::process::geteuid; + +const INSTALLER_LOCK_FILE: &str = "unixnotis-installer.lock"; + +#[derive(Debug)] +pub struct InstallerLock { + // Retaining the descriptor retains the kernel lock for the complete action + _file: File, +} + +impl InstallerLock { + pub fn acquire_for_session() -> Result { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .ok_or_else(|| anyhow!("XDG_RUNTIME_DIR is required for installer serialization"))?; + let runtime_dir = fs::canonicalize(runtime_dir) + .context("resolve the session runtime directory for installer serialization")?; + let metadata = fs::metadata(&runtime_dir) + .context("inspect the session runtime directory for installer serialization")?; + if !owned_expected_object(metadata.is_dir(), metadata.uid(), geteuid().as_raw()) { + return Err(anyhow!( + "session runtime directory is not an owned directory" + )); + } + Self::acquire_at(&runtime_dir.join(INSTALLER_LOCK_FILE)) + } + + fn acquire_at(path: &Path) -> Result { + // NOFOLLOW prevents a lock-file link from redirecting the retained descriptor + let descriptor = open( + path, + OFlags::RDWR + .union(OFlags::CREATE) + .union(OFlags::CLOEXEC) + .union(OFlags::NOFOLLOW), + Mode::RUSR.union(Mode::WUSR), + ) + .context("open the installer action lock")?; + let file = File::from(descriptor); + let metadata = file + .metadata() + .context("inspect the installer action lock")?; + if !owned_expected_object(metadata.is_file(), metadata.uid(), geteuid().as_raw()) { + return Err(anyhow!( + "installer action lock is not an owned regular file" + )); + } + flock(&file, FlockOperation::NonBlockingLockExclusive) + .map_err(|error| anyhow!(error)) + .context("another UnixNotis installer action is already running")?; + Ok(Self { _file: file }) + } +} + +const fn owned_expected_object(expected_kind: bool, actual_uid: u32, effective_uid: u32) -> bool { + expected_kind && actual_uid == effective_uid +} + +#[cfg(test)] +#[path = "tests/installer_lock.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/mod.rs b/crates/unixnotis-installer/src/actions/install/mod.rs index cd60647e2..19aca83ba 100644 --- a/crates/unixnotis-installer/src/actions/install/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/mod.rs @@ -2,13 +2,25 @@ // Binary copy and cleanup live apart from service management so filesystem writes stay focused mod binaries; +mod install_state; +mod installation_channel; +mod installer_lock; // Service artifact writes and startup behavior stay together because they share // service-manager state mod service; pub use binaries::{install_binaries, remove_binaries}; +pub use install_state::{check_install_state, InstallState, InstallationDisposition}; +pub(super) use installation_channel::reject_conflicting_installation_channel; +pub use installer_lock::InstallerLock; +pub use service::uninstall_service; pub use service::write_service_artifact; -pub use service::{enable_service, install_service, uninstall_service}; +pub use service::{enforce_service_readiness, rollback_failed_activation}; +pub use service::{ + install_service_under_reservation, prepare_service_start_under_reservation, + restart_previous_service, rollback_pending_under_activation_reservation, + start_service_and_verify, +}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/service/dirs.rs b/crates/unixnotis-installer/src/actions/install/service/dirs.rs index 93d29cd08..4c67739aa 100644 --- a/crates/unixnotis-installer/src/actions/install/service/dirs.rs +++ b/crates/unixnotis-installer/src/actions/install/service/dirs.rs @@ -1,85 +1,63 @@ //! Service artifact directory creation and guarded directory removal +use std::ffi::OsStr; use std::fs; -use std::io::ErrorKind; -use std::path::{Component, Path, PathBuf}; +use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, ensure_marked_directory, remove_empty_directory, + remove_marked_directory_tree, CreateDirectoryOutcome, +}; use crate::paths::format_with_home; -use crate::service_manager::{ - managed_directory_marker, managed_directory_marker_is_valid, MANAGED_DIRECTORY_MARKER_CONTENTS, +use crate::service_manager::contract::{ + MANAGED_DIRECTORY_MARKER, MANAGED_DIRECTORY_MARKER_CONTENTS, }; -use super::super::super::config::backup::write_atomic; -use super::files::ensure_regular_artifact_file_path; - pub(in crate::actions::install::service) fn write_directory_artifact(path: &Path) -> Result { - // Plain directories are container nodes only, so they must already be real directories - let existed_before = ensure_artifact_directory_path(path)?; - // Parent and final directory creation share the same no-symlink walk - ensure_directory_without_symlink(path) + // The descriptor-backed result reflects the final component even after a create collision + let outcome = create_directory_all(path, 0o755) .with_context(|| format!("failed to create {}", format_with_home(path)))?; - Ok(!existed_before) + Ok(outcome == CreateDirectoryOutcome::TargetCreated) } pub(in crate::actions::install::service) fn write_managed_directory(path: &Path) -> Result { - // Managed directories are the only artifact type allowed to contain nested backend files - let existed_before = ensure_artifact_directory_path(path)?; - // Create the directory before marker validation so first install can seed ownership - ensure_directory_without_symlink(path) - .with_context(|| format!("failed to create {}", format_with_home(path)))?; - - let marker = managed_directory_marker(path); - if existed_before && !managed_directory_marker_is_valid(&marker) { - // Existing service directories need proof of ownership before UnixNotis manages them - return Err(anyhow!( - "refusing to manage unmarked service directory at {}", - format_with_home(path) - )); - } - - ensure_regular_artifact_file_path(&marker)?; - let marker_changed = match fs::read_to_string(&marker) { - // Marker contents stay tiny and exact so foreign files are not treated as ownership - Ok(existing) if existing == MANAGED_DIRECTORY_MARKER_CONTENTS => false, - Ok(_) | Err(_) => { - // The marker itself is written atomically so partial writes do not grant ownership - write_atomic(&marker, MANAGED_DIRECTORY_MARKER_CONTENTS) - .with_context(|| format!("failed to write {}", format_with_home(&marker)))?; - true - } - }; - - Ok(!existed_before || marker_changed) + let outcome = ensure_marked_directory( + path, + 0o755, + OsStr::new(MANAGED_DIRECTORY_MARKER), + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + 0o644, + ) + .map_err(|error| match error.kind() { + std::io::ErrorKind::PermissionDenied => anyhow!( + "refusing to manage unmarked service directory at {}: {}", + format_with_home(path), + error + ), + _ => anyhow!( + "refusing unsafe service directory at {}: {}", + format_with_home(path), + error + ), + })?; + Ok(outcome == CreateDirectoryOutcome::TargetCreated) } pub(in crate::actions::install::service) fn ensure_directory_without_symlink( path: &Path, ) -> Result<()> { - // Build the path one component at a time so an existing parent link cannot redirect writes - let mut current = PathBuf::new(); - for component in path.components() { - match component { - // Windows prefixes are kept for correctness even though the installer is Unix-oriented - Component::Prefix(prefix) => current.push(prefix.as_os_str()), - Component::RootDir => current.push(component.as_os_str()), - // Current-directory components do not change the resolved location - Component::CurDir => {} - Component::ParentDir => { - // Parent traversal would make artifact ownership impossible to reason about - return Err(anyhow!( - "refusing parent traversal in service artifact path {}", - format_with_home(path) - )); - } - Component::Normal(part) => { - current.push(part); - inspect_or_create_directory_component(path, ¤t)?; - } - } - } - Ok(()) + // Core keeps one descriptor per component so parent swaps cannot redirect creation + create_directory_all(path, 0o755) + .map(|_outcome| ()) + .map_err(|error| { + anyhow!( + "refusing unsafe service directory path {}: {}", + format_with_home(path), + error + ) + }) } pub(in crate::actions::install::service) fn service_artifact_path_is_present(path: &Path) -> bool { @@ -106,121 +84,42 @@ pub(in crate::actions::install::service) fn remove_empty_service_directory( )); } - fs::remove_dir(path).with_context(|| format!("failed to remove {}", format_with_home(path))) -} - -pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path) -> Result<()> { - let marker = managed_directory_marker(path); - // Managed directories can contain backend files, so the marker gates recursive removal - if !managed_directory_marker_is_valid(&marker) { - return Err(anyhow!( - "refusing to recursively remove unmarked service directory at {}", - format_with_home(path) - )); - } - - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("failed to inspect {}", format_with_home(path)))?; - // Recheck the root immediately before deletion so a swapped symlink is not removed - if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() { - return Err(anyhow!( - "refusing to recursively remove unsafe service directory at {}", - format_with_home(path) - )); - } - - remove_managed_directory_tree(path) - .with_context(|| format!("failed to remove {}", format_with_home(path))) -} - -fn inspect_or_create_directory_component(full_path: &Path, current: &Path) -> Result<()> { - // Every component is checked with symlink_metadata so the link itself is inspected - match fs::symlink_metadata(current) { - // symlink_metadata checks the path itself, not the linked target - Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!( - "refusing symlink parent component {}", - format_with_home(current) - )), - Ok(metadata) if metadata.is_dir() => Ok(()), - Ok(_) => Err(anyhow!( - "refusing non-directory parent component {}", - format_with_home(current) - )), - // Missing components are created one at a time to avoid create_dir_all following links - Err(err) if err.kind() == ErrorKind::NotFound => fs::create_dir(current) - .with_context(|| format!("failed to create {}", format_with_home(current))), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(current))) - } - } - .with_context(|| format!("while preparing {}", format_with_home(full_path))) -} - -fn ensure_artifact_directory_path(path: &Path) -> Result { - // Directory artifacts are container paths, so replacing files or links would be surprising - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow!( - "cannot replace symlink service directory at {}", - format_with_home(path) - )), - Ok(metadata) if !metadata.is_dir() => Err(anyhow!( - "cannot replace non-directory service artifact at {}", + if remove_empty_directory(path) + .with_context(|| format!("failed to remove {}", format_with_home(path)))? + { + Ok(()) + } else { + Err(anyhow!( + "service directory disappeared before removal at {}", format_with_home(path) - )), - Ok(_) => Ok(true), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(false), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(path))) - } + )) } } -fn remove_managed_directory_tree(path: &Path) -> Result<()> { - // Each level is inspected before reading children so symlink swaps do not get followed - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("failed to inspect {}", format_with_home(path)))?; - if metadata.file_type().is_symlink() { - return Err(anyhow!( - "refusing symlink inside managed service directory at {}", - format_with_home(path) - )); - } - if !metadata.file_type().is_dir() { - return Err(anyhow!( - "refusing non-directory inside managed service directory at {}", +pub(in crate::actions::install::service) fn remove_managed_directory(path: &Path) -> Result<()> { + let removed = remove_marked_directory_tree( + path, + OsStr::new(MANAGED_DIRECTORY_MARKER), + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + ) + .map_err(|error| match error.kind() { + std::io::ErrorKind::PermissionDenied => anyhow!( + "refusing to recursively remove unmarked service directory at {}: {}", + format_with_home(path), + error + ), + _ => anyhow!( + "refusing to recursively remove unsafe service directory at {}: {}", + format_with_home(path), + error + ), + })?; + if removed { + Ok(()) + } else { + Err(anyhow!( + "managed service directory disappeared before removal at {}", format_with_home(path) - )); - } - - for entry in - fs::read_dir(path).with_context(|| format!("failed to read {}", format_with_home(path)))? - { - let entry = entry.with_context(|| format!("failed to read {}", format_with_home(path)))?; - let child = entry.path(); - let child_metadata = fs::symlink_metadata(&child) - .with_context(|| format!("failed to inspect {}", format_with_home(&child)))?; - - if child_metadata.file_type().is_symlink() { - // Backend-owned service directories should not need symlink children - // Failing closed avoids deleting or traversing a path that changed under the installer - return Err(anyhow!( - "refusing symlink inside managed service directory at {}", - format_with_home(&child) - )); - } - if child_metadata.file_type().is_dir() { - remove_managed_directory_tree(&child)?; - } else if child_metadata.file_type().is_file() { - fs::remove_file(&child) - .with_context(|| format!("failed to remove {}", format_with_home(&child)))?; - } else { - // Sockets, fifos, and device nodes should not appear in installer-owned service trees - return Err(anyhow!( - "refusing special file inside managed service directory at {}", - format_with_home(&child) - )); - } + )) } - - fs::remove_dir(path).with_context(|| format!("failed to remove {}", format_with_home(path))) } diff --git a/crates/unixnotis-installer/src/actions/install/service/files.rs b/crates/unixnotis-installer/src/actions/install/service/files.rs index 6094abac6..1d6e7617d 100644 --- a/crates/unixnotis-installer/src/actions/install/service/files.rs +++ b/crates/unixnotis-installer/src/actions/install/service/files.rs @@ -7,12 +7,16 @@ use std::os::unix::fs::PermissionsExt; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + ensure_exact_file, ensure_exact_file_pair, regular_file_contents_equal, remove_empty_directory, + remove_regular_file, remove_regular_file_pair_if_contents, set_file_mode, write_file_atomic, + write_file_atomic_preserving_mode, EnsureExactFileOutcome, EnsureExactFilePairOutcome, + RemoveExactFileOutcome, +}; use crate::paths::format_with_home; use crate::service_manager::MANAGED_DIRECTORY_MARKER_CONTENTS; -use super::super::super::config::backup::write_atomic; - pub(in crate::actions::install::service) fn write_regular_service_file( path: &Path, contents: &str, @@ -20,7 +24,7 @@ pub(in crate::actions::install::service) fn write_regular_service_file( artifact_label: &str, ) -> Result { // Refuse unsafe existing paths before looking at file contents - ensure_regular_artifact_file_path(path)?; + let path_exists = ensure_regular_artifact_file_path(path)?; let mode_changed = match mode { Some(mode) => { #[cfg(unix)] @@ -38,30 +42,37 @@ pub(in crate::actions::install::service) fn write_regular_service_file( } None => false, }; - let changed = match fs::read_to_string(path) { - // Stable contents keep reinstall quiet and avoid unnecessary manager reloads - Ok(existing) if existing == contents => false, - Ok(_) | Err(_) => { - // Atomic writes avoid half-written service definitions on interruption - write_atomic(path, contents) - .with_context(|| format!("failed to write {artifact_label}"))?; - true + let contents_changed = if path_exists { + let maximum_size = u64::try_from(contents.len()).unwrap_or(u64::MAX); + // One no-follow descriptor owns both the size gate and bounded byte comparison + match regular_file_contents_equal(path, contents.as_bytes(), maximum_size) { + Ok(equal) => !equal, + Err(error) if error.kind() == ErrorKind::NotFound => true, + Err(error) => { + return Err(error).with_context(|| format!("failed to compare {artifact_label}")); + } } + } else { + true }; - if let Some(mode) = mode { - // Only artifacts that requested a mode receive chmod + if contents_changed { + // Explicit modes keep service scripts independent of process umask + mode.map_or_else( + || write_file_atomic_preserving_mode(path, contents.as_bytes(), 0o644), + |mode| write_file_atomic(path, contents.as_bytes(), mode), + ) + .with_context(|| format!("failed to write {artifact_label}"))?; + } else if mode_changed { #[cfg(unix)] - { - if changed || mode_changed { - // Mode is explicit because service scripts must not depend on process umask - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; - } + if let Some(mode) = mode { + // Descriptor-based chmod keeps a swapped pathname from redirecting the update + set_file_mode(path, mode) + .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; } } - Ok(changed || mode_changed) + Ok(contents_changed || mode_changed) } pub(in crate::actions::install::service) fn write_shared_service_file( @@ -71,27 +82,40 @@ pub(in crate::actions::install::service) fn write_shared_service_file( artifact_label: &str, created_marker: Option<&Path>, ) -> Result { - // Shared files are setup anchors, not UnixNotis-owned replacement targets - let existed_before = ensure_regular_artifact_file_path(path)?; - if existed_before { - let existing = fs::read_to_string(path) - .with_context(|| format!("failed to read {}", format_with_home(path)))?; - if existing != contents { + if let Some(marker) = created_marker { + let outcome = ensure_exact_file_pair( + path, + contents.as_bytes(), + mode.unwrap_or(0o644), + marker, + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + 0o644, + ) + .with_context(|| format!("failed to write {artifact_label} and its ownership marker"))?; + return match outcome { + EnsureExactFilePairOutcome::Created => Ok(true), + EnsureExactFilePairOutcome::AlreadyExact + | EnsureExactFilePairOutcome::AlreadyExactUnowned => Ok(false), + EnsureExactFilePairOutcome::ContentsMismatch => Err(anyhow!( + "refusing to overwrite shared service artifact at {}", + format_with_home(path) + )), + }; + } + + let outcome = ensure_exact_file(path, contents.as_bytes(), mode.unwrap_or(0o644)) + .with_context(|| format!("failed to write {artifact_label}"))?; + match outcome { + EnsureExactFileOutcome::ContentsMismatch => { return Err(anyhow!( "refusing to overwrite shared service artifact at {}", format_with_home(path) )); } - apply_artifact_mode_if_needed(path, mode)?; - return Ok(false); + EnsureExactFileOutcome::AlreadyExact => return Ok(false), + EnsureExactFileOutcome::Created => {} } - // Missing shared files can be seeded because no user contents are being replaced - write_atomic(path, contents).with_context(|| format!("failed to write {artifact_label}"))?; - apply_artifact_mode_if_needed(path, mode)?; - if let Some(marker) = created_marker { - write_shared_creation_marker(marker)?; - } Ok(true) } @@ -100,18 +124,20 @@ pub(in crate::actions::install::service) fn remove_shared_service_file( created_marker: &Path, expected_contents: &str, ) -> Result { - if !shared_creation_marker_is_valid(created_marker) { - // No marker means the shared file predated UnixNotis or has unknown ownership - return Ok(false); - } - if !shared_file_contents_match(path, expected_contents)? { - // User edits after install turn the file back into shared user state - return Ok(false); + let outcome = remove_regular_file_pair_if_contents( + path, + expected_contents.as_bytes(), + created_marker, + MANAGED_DIRECTORY_MARKER_CONTENTS.as_bytes(), + ) + .with_context(|| format!("failed to remove {}", format_with_home(path)))?; + match outcome { + RemoveExactFileOutcome::Missing | RemoveExactFileOutcome::ContentsMismatch => Ok(false), + RemoveExactFileOutcome::Removed => { + remove_empty_shared_layout_dirs(path)?; + Ok(true) + } } - remove_regular_service_file(path)?; - remove_regular_service_file(created_marker)?; - remove_empty_shared_layout_dirs(path)?; - Ok(true) } #[cfg(unix)] @@ -125,67 +151,6 @@ pub(in crate::actions::install) fn current_mode(path: &Path) -> Result) -> Result<()> { - let Some(mode) = mode else { - return Ok(()); - }; - - #[cfg(unix)] - { - if current_mode(path)? != Some(mode) { - // Shared support files still need explicit modes when the backend requests one - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .with_context(|| format!("failed to chmod {}", format_with_home(path)))?; - } - Ok(()) - } - - #[cfg(not(unix))] - { - Err(anyhow!( - "cannot apply executable mode {} on non-Unix platforms", - mode - )) - } -} - -fn write_shared_creation_marker(path: &Path) -> Result<()> { - ensure_regular_artifact_file_path(path)?; - write_atomic(path, MANAGED_DIRECTORY_MARKER_CONTENTS) - .with_context(|| format!("failed to write {}", format_with_home(path))) -} - -fn shared_creation_marker_is_valid(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - if !metadata.file_type().is_file() { - return false; - } - fs::read_to_string(path).is_ok_and(|contents| contents == MANAGED_DIRECTORY_MARKER_CONTENTS) -} - -fn shared_file_contents_match(path: &Path, expected_contents: &str) -> Result { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), - Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect {}", format_with_home(path))); - } - }; - if !metadata.file_type().is_file() { - // A marker does not make a replaced symlink, socket, or directory removable - return Err(anyhow!( - "refusing to remove non-regular shared service artifact at {}", - format_with_home(path) - )); - } - fs::read_to_string(path) - .map(|contents| contents == expected_contents) - .with_context(|| format!("failed to read {}", format_with_home(path))) -} - fn remove_empty_shared_layout_dirs(path: &Path) -> Result<()> { let Some(parent) = path.parent() else { return Ok(()); @@ -197,16 +162,9 @@ fn remove_empty_shared_layout_dirs(path: &Path) -> Result<()> { } fn remove_dir_if_empty(path: &Path) -> Result<()> { - match fs::remove_dir(path) { - Ok(()) => Ok(()), - Err(err) - if matches!( - err.kind(), - ErrorKind::NotFound | ErrorKind::DirectoryNotEmpty - ) => - { - Ok(()) - } + match remove_empty_directory(path) { + Ok(true | false) => Ok(()), + Err(err) if matches!(err.kind(), ErrorKind::DirectoryNotEmpty) => Ok(()), Err(err) => { Err(err).with_context(|| format!("failed to remove {}", format_with_home(path))) } @@ -259,5 +217,14 @@ pub(in crate::actions::install::service) fn remove_regular_service_file(path: &P )); } - fs::remove_file(path).with_context(|| format!("failed to remove {}", format_with_home(path))) + if remove_regular_file(path) + .with_context(|| format!("failed to remove {}", format_with_home(path)))? + { + Ok(()) + } else { + Err(anyhow!( + "service file disappeared before removal at {}", + format_with_home(path) + )) + } } diff --git a/crates/unixnotis-installer/src/actions/install/service/flow.rs b/crates/unixnotis-installer/src/actions/install/service/flow.rs index 545e8886f..17c212d3f 100644 --- a/crates/unixnotis-installer/src/actions/install/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/service/flow.rs @@ -4,6 +4,7 @@ use std::sync::atomic::Ordering; use anyhow::{Context, Result}; +use crate::actions::DaemonActivationReservation; use crate::paths::format_with_home; use super::super::super::{ @@ -21,7 +22,19 @@ use super::lifecycle::{ }; use super::refresh::refresh_service_artifacts; -pub fn install_service(ctx: &mut ActionContext) -> Result<()> { +pub(in crate::actions::install) fn install_service(ctx: &mut ActionContext) -> Result<()> { + install_service_impl(ctx) +} + +pub fn install_service_under_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + // The reservation is held by the worker while service artifacts are replaced + install_service(ctx) +} + +fn install_service_impl(ctx: &mut ActionContext) -> Result<()> { match write_service_artifacts(ctx)? { ServiceArtifactWrite::CreatedOrUpdated => { log_line( @@ -44,7 +57,7 @@ pub fn install_service(ctx: &mut ActionContext) -> Result<()> { Ok(()) } -pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { +pub(in crate::actions) fn prepare_service_start(ctx: &mut ActionContext) -> Result<()> { if ctx.service_reload_required.load(Ordering::Acquire) { // Refresh work can be a single reload command or a backend-owned database update refresh_service_artifacts(ctx)?; @@ -65,7 +78,23 @@ pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { return Err(err); } remove_pre_start_artifacts(ctx)?; + Ok(()) +} + +pub fn prepare_service_start_under_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result<()> { + // Manager refresh and pre-start cleanup remain inside the activation exclusion + prepare_service_start(ctx) +} + +pub fn start_service_and_verify(ctx: &mut ActionContext, readiness: F) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ run_service_start(ctx)?; + readiness(ctx)?; // Shell startup files are updated so new terminals can resolve the installed commands if let Err(err) = ensure_shell_path_entry(ctx) { @@ -80,25 +109,131 @@ pub fn enable_service(ctx: &mut ActionContext) -> Result<()> { Ok(()) } -pub fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { - let artifacts = ctx.paths.service.install_artifacts(&ctx.paths.bin_dir); - let artifact_exists = artifacts.iter().any(service_artifact_path_exists); - let unsafe_artifact_exists = log_unsafe_service_artifacts(ctx, &artifacts); +pub fn rollback_failed_activation( + ctx: &mut ActionContext, + readiness: &F, + activation_error: anyhow::Error, +) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ + rollback_failed_activation_with_quiescence(ctx, readiness, activation_error, |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }) +} - if artifact_exists { - if let Some(spec) = ctx.paths.service.disable_now_command() { - if let Err(err) = run_command_spec(ctx, &spec) { - log_line(ctx, format!("Warning: {err}")); - } - } else { +pub(in crate::actions::install) fn rollback_failed_activation_with_quiescence( + ctx: &mut ActionContext, + readiness: &F, + activation_error: anyhow::Error, + mut wait_for_quiescence: Q, +) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, + Q: FnMut(&crate::paths::InstallPaths) -> Result<()>, +{ + if !crate::actions::releases::pending_release_exists(ctx.paths)? { + return Err(activation_error); + } + let restart_previous = + crate::actions::releases::pending_release_has_runtime_rollback(ctx.paths)?; + // Disk generation must not move backward while the failed new daemon is still live + let stop = ctx.paths.service.stop_for_reinstall_command(); + let stop_result = run_command_spec(ctx, &stop); + let quiescence_result = wait_for_quiescence(ctx.paths); + match (stop_result, quiescence_result) { + (Ok(()), Ok(())) => {} + (Err(stop_error), Ok(())) => { + // Live state is authoritative when the manager command reports a stale failure + log_line( + ctx, + format!( + "Warning: rejected release stop command failed after runtime became quiescent ({stop_error:#})" + ), + ); + } + (Ok(()), Err(state_error)) => { + return Err(activation_error.context(format!( + "service manager reported a successful stop but the rejected runtime remains live: {state_error:#}" + ))); + } + (Err(stop_error), Err(state_error)) => { + return Err(activation_error.context(format!( + "failed to stop the rejected release before rollback: {stop_error:#}; runtime remains live or indeterminate: {state_error:#}" + ))); + } + } + // Current may move backward only after both broker and manager state prove quiescence + crate::actions::releases::rollback_pending_release(ctx.paths) + .context("roll back rejected binary release generation")?; + if restart_previous { + run_service_start(ctx).context("restart previous release generation")?; + readiness(ctx).context("previous release did not recover after rollback")?; + } + Err(activation_error) +} + +pub fn rollback_pending_under_activation_reservation( + ctx: &mut ActionContext, + _reservation: &DaemonActivationReservation, +) -> Result { + let restart_previous = + crate::actions::releases::pending_release_has_runtime_rollback(ctx.paths)?; + // Direct service-manager starts remain possible while the D-Bus names are reserved + let stop = ctx.paths.service.stop_for_reinstall_command(); + let stop_result = run_command_spec(ctx, &stop); + let service_quiescence_result = crate::actions::daemon::wait_until_selected_service_inactive( + ctx.paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ); + match (stop_result, service_quiescence_result) { + (Ok(()), Ok(())) => {} + (Err(stop_error), Ok(())) => { log_line( ctx, format!( - "Skipping disable; {} has no disable command", - ctx.paths.service.label() + "Warning: rejected release stop command failed after service became inactive ({stop_error:#})" ), ); } + (Ok(()), Err(state_error)) => { + return Err( + state_error.context("service remained active after the guarded release failure") + ); + } + (Err(stop_error), Err(state_error)) => { + return Err(state_error.context(format!( + "failed to stop the rejected release while activation remained reserved ({stop_error:#})" + ))); + } + } + + crate::actions::releases::rollback_pending_release(ctx.paths) + .context("roll back rejected binary release generation while activation is reserved")?; + Ok(restart_previous) +} + +pub fn restart_previous_service(ctx: &mut ActionContext, readiness: &F) -> Result<()> +where + F: Fn(&mut ActionContext) -> Result<()>, +{ + run_service_start(ctx).context("restart previous release generation")?; + readiness(ctx).context("previous release did not recover after rollback") +} + +pub fn uninstall_service(ctx: &mut ActionContext) -> Result<()> { + let artifacts = ctx.paths.service.install_artifacts(&ctx.paths.bin_dir); + let artifact_exists = artifacts.iter().any(service_artifact_path_exists); + let unsafe_artifact_exists = log_unsafe_service_artifacts(ctx, &artifacts); + + if artifact_exists { + let spec = ctx.paths.service.disable_now_command(); + if let Err(err) = run_command_spec(ctx, &spec) { + log_line(ctx, format!("Warning: {err}")); + } for artifact in artifacts.iter().rev() { if service_artifact_path_conflicts(artifact) { diff --git a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs index 303d2e4d0..2254223b6 100644 --- a/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/service/lifecycle.rs @@ -1,6 +1,6 @@ //! Service lifecycle command helpers -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result}; use crate::paths::format_with_home; use crate::service_manager::CommandSpec; @@ -19,7 +19,7 @@ fn service_start_mode(ctx: &ActionContext) -> ServiceStartMode { service_start_mode_from_enabled( ctx.install_state .as_ref() - .map(crate::actions::install_state::InstallState::service_enabled), + .map(|state| state.service_enabled), ) } @@ -35,6 +35,18 @@ pub(in crate::actions::install) fn service_start_mode_from_enabled( } pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> Result<()> { + if let Some(spec) = ctx.paths.service.prepare_start_command() { + // A runtime mask is temporary session state and should not defeat explicit installation + log_line( + ctx, + format!( + "Clearing temporary service mask for {}", + ctx.paths.service.service_name() + ), + ); + run_command_spec(ctx, &spec).context("clear temporary service mask")?; + } + match service_start_mode(ctx) { ServiceStartMode::EnableAndStart => { // First install still needs the symlink creation done by `enable` @@ -42,11 +54,7 @@ pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> ctx, format!("Enabling and starting {}", ctx.paths.service.service_name()), ); - let spec = ctx - .paths - .service - .enable_now_command() - .ok_or_else(|| anyhow!("service manager cannot enable and start service"))?; + let spec = ctx.paths.service.enable_now_command(); run_command_spec(ctx, &spec) } ServiceStartMode::StartOnly => { @@ -55,11 +63,7 @@ pub(in crate::actions::install) fn run_service_start(ctx: &mut ActionContext) -> ctx, format!("Starting {}", ctx.paths.service.service_name()), ); - let spec = ctx - .paths - .service - .start_command() - .ok_or_else(|| anyhow!("service manager cannot start service"))?; + let spec = ctx.paths.service.start_command(); run_command_spec(ctx, &spec) } } diff --git a/crates/unixnotis-installer/src/actions/install/service/mod.rs b/crates/unixnotis-installer/src/actions/install/service/mod.rs index 8918c1944..6991f31a0 100644 --- a/crates/unixnotis-installer/src/actions/install/service/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/service/mod.rs @@ -3,10 +3,18 @@ pub(in crate::actions::install) mod artifacts; mod dirs; pub(in crate::actions::install) mod files; -mod flow; +pub(in crate::actions::install) mod flow; pub(in crate::actions::install) mod lifecycle; +mod readiness; pub(in crate::actions::install) mod refresh; pub(in crate::actions::install) mod symlinks; pub use artifacts::write_service_artifact; -pub use flow::{enable_service, install_service, uninstall_service}; +pub use flow::install_service_under_reservation; +pub use flow::rollback_failed_activation; +pub use flow::uninstall_service; +pub use flow::{ + prepare_service_start_under_reservation, restart_previous_service, + rollback_pending_under_activation_reservation, start_service_and_verify, +}; +pub use readiness::enforce_service_readiness; diff --git a/crates/unixnotis-installer/src/actions/install/service/readiness.rs b/crates/unixnotis-installer/src/actions/install/service/readiness.rs new file mode 100644 index 000000000..77e7a7633 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/service/readiness.rs @@ -0,0 +1,188 @@ +//! Post-start D-Bus readiness enforcement and bounded failure diagnostics + +use std::future::Future; +use std::time::Duration; + +use anyhow::{bail, ensure, Context, Result}; +use unixnotis_core::{ControlProxy, NotificationsProxy, CONTROL_BUS_NAME, NOTIFICATIONS_BUS_NAME}; +use zbus::{fdo::DBusProxy, names::BusName, Connection}; + +use super::super::super::{log_line, run_command, ActionContext}; + +const INSTALL_READINESS_TIMEOUT: Duration = Duration::from_secs(20); +const DBUS_METHOD_TIMEOUT: Duration = Duration::from_secs(2); +const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub fn enforce_service_readiness(ctx: &mut ActionContext) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("create installer readiness runtime")?; + let address = stable_user_bus_address(); + let result = runtime.block_on(async { + let builder = zbus::connection::Builder::address(address.as_str()) + .context("prepare stable user-bus connection")?; + let connection = tokio::time::timeout(DBUS_METHOD_TIMEOUT, builder.build()) + .await + .context("stable user-bus connection timed out")? + .context("connect to stable user bus")?; + let readiness = + wait_until_ready_with_probe(INSTALL_READINESS_TIMEOUT, || probe_readiness(&connection)) + .await; + if let Err(error) = readiness { + let owners = readiness_owner_diagnostics(&connection).await; + return Err(error.context(owners)); + } + Ok(()) + }); + + if let Err(error) = result { + log_line(ctx, format!("UnixNotis readiness failed: {error:#}")); + if ctx.paths.service.is_systemd() { + log_systemd_failure_diagnostics(ctx); + } + return Err(error.context("UnixNotis did not become ready after service start")); + } + log_line(ctx, "UnixNotis D-Bus readiness verified"); + Ok(()) +} + +async fn wait_until_ready_with_probe(timeout: Duration, mut probe: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let deadline = tokio::time::Instant::now() + timeout; + loop { + let last_failure = match probe().await { + Ok(()) => return Ok(()), + Err(error) => format!("{error:#}"), + }; + let now = tokio::time::Instant::now(); + if now >= deadline { + bail!("UnixNotis readiness timed out; last observation: {last_failure}"); + } + tokio::time::sleep(READINESS_POLL_INTERVAL.min(deadline - now)).await; + } +} + +async fn probe_readiness(connection: &Connection) -> Result<()> { + let dbus = DBusProxy::new(connection) + .await + .context("create readiness D-Bus proxy")?; + let notification_owner = get_owner(&dbus, NOTIFICATIONS_BUS_NAME).await?; + let control_owner = get_owner(&dbus, CONTROL_BUS_NAME).await?; + ensure!( + notification_owner == control_owner, + "D-Bus owners differ: notifications={notification_owner}, control={control_owner}" + ); + + let control = ControlProxy::new(connection) + .await + .context("create control readiness proxy")?; + tokio::time::timeout(DBUS_METHOD_TIMEOUT, control.get_state()) + .await + .context("Control.GetState timed out")? + .context("Control.GetState failed")?; + + let notifications = NotificationsProxy::new(connection) + .await + .context("create notification readiness proxy")?; + tokio::time::timeout(DBUS_METHOD_TIMEOUT, notifications.get_server_information()) + .await + .context("GetServerInformation timed out")? + .context("GetServerInformation failed")?; + Ok(()) +} + +async fn get_owner(dbus: &DBusProxy<'_>, name: &'static str) -> Result { + let name = BusName::try_from(name).context("invalid readiness bus name")?; + let owner = tokio::time::timeout(DBUS_METHOD_TIMEOUT, dbus.get_name_owner(name.clone())) + .await + .context("D-Bus owner lookup timed out")? + .with_context(|| format!("{name} has no owner"))?; + Ok(owner.to_string()) +} + +async fn readiness_owner_diagnostics(connection: &Connection) -> String { + let Ok(dbus) = DBusProxy::new(connection).await else { + return "owner diagnostics unavailable: failed to create D-Bus proxy".to_string(); + }; + let notifications = diagnostic_owner(&dbus, NOTIFICATIONS_BUS_NAME).await; + let control = diagnostic_owner(&dbus, CONTROL_BUS_NAME).await; + format!("D-Bus owners: {NOTIFICATIONS_BUS_NAME}={notifications}; {CONTROL_BUS_NAME}={control}") +} + +async fn diagnostic_owner(dbus: &DBusProxy<'_>, name: &'static str) -> String { + let Ok(name) = BusName::try_from(name) else { + return "".to_string(); + }; + match tokio::time::timeout(DBUS_METHOD_TIMEOUT, dbus.get_name_owner(name)).await { + Ok(Ok(owner)) => owner.to_string(), + Ok(Err(error)) => format!("<{error}>"), + Err(_) => "".to_string(), + } +} + +fn stable_user_bus_address() -> String { + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) +} + +fn log_systemd_failure_diagnostics(ctx: &mut ActionContext) { + let show_args = [ + "--user", + "show", + "unixnotis-daemon.service", + "-p", + "LoadState", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "Result", + "-p", + "ExecMainStatus", + "-p", + "FragmentPath", + "-p", + "ExecStart", + ]; + match crate::system_tools::command("systemctl") { + Ok(mut command) => { + command.args(show_args); + let _ = run_command(ctx, "systemctl readiness diagnostics", command, None); + } + Err(error) => log_line( + ctx, + format!("Warning: systemctl readiness diagnostics unavailable ({error})"), + ), + } + + match crate::system_tools::command("journalctl") { + Ok(mut command) => { + // The line count and shared log reader cap both dimensions of diagnostic output + command.args([ + "--user", + "-u", + "unixnotis-daemon.service", + "-n", + "100", + "--no-pager", + "--output=short-monotonic", + ]); + let _ = run_command(ctx, "journalctl readiness diagnostics", command, None); + } + Err(error) => log_line( + ctx, + format!("Warning: journal readiness diagnostics unavailable ({error})"), + ), + } +} + +#[cfg(test)] +#[path = "tests/readiness.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/install/service/refresh.rs b/crates/unixnotis-installer/src/actions/install/service/refresh.rs index 8013a6132..0a5c9779c 100644 --- a/crates/unixnotis-installer/src/actions/install/service/refresh.rs +++ b/crates/unixnotis-installer/src/actions/install/service/refresh.rs @@ -1,15 +1,12 @@ //! Service-manager refresh execution after artifact changes use std::fs; -use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; - -#[cfg(unix)] -use std::os::unix::fs as unix_fs; +use unixnotis_core::filesystem::replace_symlink_atomic; use crate::paths::format_with_home; use crate::service_manager::{CommandSpec, S6DatabaseRefresh, ServiceArtifactRefresh}; @@ -263,11 +260,12 @@ pub(in crate::actions::install) fn strip_ansi_csi_sequences(line: &str) -> Strin } pub(in crate::actions::install) fn truncate_diagnostic(mut line: String, max_len: usize) -> String { + const ELLIPSIS: &str = "..."; + if line.len() <= max_len { return line; } - const ELLIPSIS: &str = "..."; if max_len <= ELLIPSIS.len() { // Very small budgets still need valid UTF-8 and must not exceed the caller limit return ELLIPSIS[..max_len].to_string(); @@ -305,70 +303,16 @@ fn next_s6_compiled_database(plan: &S6DatabaseRefresh) -> Result { fn switch_s6_compiled_link(plan: &S6DatabaseRefresh, compiled: &Path) -> Result<()> { let link = plan.compiled_link(); - reject_unsafe_existing_compiled_link(&link)?; - - let temp_link = plan - .rc_root() - .join(format!(".compiled-unixnotis-next-{}", std::process::id())); - // Only UnixNotis-created symlink temp files can be reused between failed attempts - remove_stale_temp_link(&temp_link)?; - - #[cfg(unix)] - { - // s6-rc-init expects the boot database path to be a symlink to a compiled database - unix_fs::symlink(compiled, &temp_link) - .with_context(|| format!("failed to create {}", format_with_home(&temp_link)))?; - fs::rename(&temp_link, &link).with_context(|| { - format!( - "failed to atomically switch s6 compiled database symlink {}", - format_with_home(&link) - ) - })?; - } - - #[cfg(not(unix))] - { - let _ = compiled; - let _ = temp_link; - return Err(anyhow!( - "s6 database symlinks require Unix filesystem support" - )); - } - + // s6-rc-init expects one stable boot link, so publish the new target in one rename + replace_symlink_atomic(&link, compiled).with_context(|| { + format!( + "failed to atomically switch s6 compiled database symlink {}", + format_with_home(&link) + ) + })?; Ok(()) } -fn reject_unsafe_existing_compiled_link(link: &Path) -> Result<()> { - match fs::symlink_metadata(link) { - // Existing compiled links are expected; regular files or directories are user state - Ok(metadata) if metadata.file_type().is_symlink() => Ok(()), - Ok(_) => Err(anyhow!( - "refusing to replace non-symlink s6 compiled database path {}", - format_with_home(link) - )), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(link))) - } - } -} - -fn remove_stale_temp_link(temp_link: &Path) -> Result<()> { - match fs::symlink_metadata(temp_link) { - // Removing only a symlink keeps a hostile or accidental directory from being replaced - Ok(metadata) if metadata.file_type().is_symlink() => fs::remove_file(temp_link) - .with_context(|| format!("failed to remove {}", format_with_home(temp_link))), - Ok(_) => Err(anyhow!( - "refusing to replace non-symlink temp s6 database path {}", - format_with_home(temp_link) - )), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(temp_link))) - } - } -} - fn path_is_live_directory(path: &Path) -> bool { fs::metadata(path) // s6 live roots are normally symlinks, and the symlink name is the command contract diff --git a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs index 956bf9cb9..ab1487114 100644 --- a/crates/unixnotis-installer/src/actions/install/service/symlinks.rs +++ b/crates/unixnotis-installer/src/actions/install/service/symlinks.rs @@ -1,10 +1,12 @@ //! Service artifact symlink creation and safe removal -use std::fs; use std::io::ErrorKind; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_symlink_if_missing, remove_symlink_if_target, CreateSymlinkOutcome, RemoveSymlinkOutcome, +}; use crate::paths::format_with_home; @@ -12,75 +14,51 @@ pub(in crate::actions::install) fn write_service_symlink( path: &Path, target: &Path, ) -> Result { - if let Ok(existing) = fs::read_link(path) { - if existing == target { - // Relative links are compared as stored, matching how the backend declared them - return Ok(false); - } - // A different target means another owner may be using this enablement path - return Err(anyhow!( + // Relative targets are compared exactly as stored by the service backend + match create_symlink_if_missing(path, target) { + Ok(CreateSymlinkOutcome::Created) => Ok(true), + Ok(CreateSymlinkOutcome::Unchanged) => Ok(false), + Ok(CreateSymlinkOutcome::TargetMismatch(existing)) => Err(anyhow!( "cannot replace service symlink {} because it points to {} instead of {}", format_with_home(path), format_with_home(&existing), format_with_home(target) - )); + )), + Err(error) if error.kind() == ErrorKind::InvalidInput => Err(anyhow!( + "cannot replace non-symlink service artifact at {}", + format_with_home(path) + )), + Err(error) => Err(error).with_context(|| { + format!( + "failed to inspect or create symlink {}", + format_with_home(path) + ) + }), } - // Existing non-links are left alone so enablement links cannot overwrite user files - reject_existing_non_symlink(path)?; - - // Create the link exactly as the backend requested, often with a relative target - std::os::unix::fs::symlink(target, path) - .with_context(|| format!("failed to create symlink {}", format_with_home(path)))?; - Ok(true) } pub(in crate::actions::install) fn remove_service_symlink( path: &Path, expected_target: &Path, ) -> Result<()> { - // Symlink artifacts are removed only when both the type and target still match - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - // Missing links are already gone, which makes uninstall idempotent - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect {}", format_with_home(path))); - } - }; - if !metadata.file_type().is_symlink() { - return Err(anyhow!( - "refusing to remove non-symlink service artifact at {}", - format_with_home(path) - )); - } - - let actual_target = fs::read_link(path) - .with_context(|| format!("failed to read symlink {}", format_with_home(path)))?; - if actual_target != expected_target { - // A changed link target means ownership is no longer proven - return Err(anyhow!( + // Core compares the stored target and unlinks relative to the same stable parent descriptor + match remove_symlink_if_target(path, expected_target) { + Ok(RemoveSymlinkOutcome::Missing | RemoveSymlinkOutcome::Removed) => Ok(()), + Ok(RemoveSymlinkOutcome::TargetMismatch(actual_target)) => Err(anyhow!( "refusing to remove symlink {} because it points to {} instead of {}", format_with_home(path), format_with_home(&actual_target), format_with_home(expected_target) - )); - } - - fs::remove_file(path).with_context(|| format!("failed to remove {}", format_with_home(path))) -} - -fn reject_existing_non_symlink(path: &Path) -> Result<()> { - match fs::symlink_metadata(path) { - // Any existing non-link at the enablement path belongs to the user or another manager - Ok(_) => Err(anyhow!( - "cannot replace non-symlink service artifact at {}", + )), + Err(error) if error.kind() == ErrorKind::InvalidInput => Err(anyhow!( + "refusing to remove non-symlink service artifact at {}", format_with_home(path) )), - // NotFound means write_service_symlink can safely create the link - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(err) => { - Err(err).with_context(|| format!("failed to inspect {}", format_with_home(path))) - } + Err(error) => Err(error).with_context(|| { + format!( + "failed to inspect or remove symlink {}", + format_with_home(path) + ) + }), } } diff --git a/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs b/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs new file mode 100644 index 000000000..a71790ebc --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/service/tests/readiness.rs @@ -0,0 +1,51 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use anyhow::anyhow; + +use super::{stable_user_bus_address, wait_until_ready_with_probe}; + +#[tokio::test] +async fn installer_rejects_process_start_success_without_dbus_readiness() { + let status = std::process::Command::new("true") + .status() + .expect("fake service start command should run"); + assert!(status.success()); + + let probes = AtomicUsize::new(0); + let error = wait_until_ready_with_probe(Duration::from_millis(15), || { + probes.fetch_add(1, Ordering::Relaxed); + std::future::ready(Err(anyhow!("both required names have no owner"))) + }) + .await + .expect_err("a successful process start must not satisfy D-Bus readiness"); + + assert!(error.to_string().contains("readiness timed out")); + assert!(error.to_string().contains("both required names")); + assert!(probes.load(Ordering::Relaxed) >= 2); +} + +#[tokio::test] +async fn readiness_gate_returns_after_the_first_complete_probe() { + let probes = AtomicUsize::new(0); + + wait_until_ready_with_probe(Duration::from_secs(1), || { + probes.fetch_add(1, Ordering::Relaxed); + std::future::ready(Ok(())) + }) + .await + .expect("complete readiness should pass"); + + assert_eq!(probes.load(Ordering::Relaxed), 1); +} + +#[test] +fn stable_bus_address_uses_the_current_numeric_uid() { + assert_eq!( + stable_user_bus_address(), + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) + ); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs index 9e1b1d8c3..deea7a4fc 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/binaries.rs @@ -3,15 +3,35 @@ use std::fs; use crate::detect::Detection; use crate::model::ActionMode; -use super::super::binaries::{ - binary_temp_path, binary_temp_path_attempt, remove_resolved_binaries, - stage_binary_copy_with_retry, -}; -use super::super::{install_binaries, remove_binaries}; +use super::super::binaries::remove_resolved_binaries; +use super::super::binaries::{log_installed_generation, resolve_install_inputs}; +use super::super::remove_binaries; use super::support::{test_context, test_paths, test_root, write_fake_workspace}; +fn install_binaries_with_guards( + ctx: &mut crate::actions::ActionContext, + mut precommit: F, + mut reserve_activation: R, +) -> anyhow::Result<()> +where + F: FnMut(&crate::paths::InstallPaths) -> anyhow::Result<()>, + R: FnMut(&crate::paths::InstallPaths) -> anyhow::Result, +{ + let (binaries, release_dir) = resolve_install_inputs(ctx)?; + let generation = crate::actions::releases::install_release_generation_transaction( + ctx.paths, + &release_dir, + &binaries, + || precommit(ctx.paths), + || reserve_activation(ctx.paths), + || Ok(()), + )?; + log_installed_generation(ctx, &binaries, &generation); + Ok(()) +} + #[cfg(unix)] -use std::os::unix::fs::symlink; +use std::os::unix::fs::{symlink, PermissionsExt}; #[test] fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { @@ -40,6 +60,8 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { let source = paths.repo_root.join("target").join("release").join(binary); fs::create_dir_all(source.parent().expect("release dir")).expect("make release dir"); fs::write(&source, format!("binary:{binary}")).expect("write fake binary"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set fake binary mode"); } let detection = Detection { @@ -48,7 +70,8 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("install should copy binaries"); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("install should copy binaries"); for binary in [ "unixnotis-daemon", @@ -63,6 +86,14 @@ fn install_binaries_copies_all_managed_binaries_and_runtime_helpers() { fs::read_to_string(&installed).expect("read installed binary"), format!("binary:{binary}") ); + assert_eq!( + fs::metadata(&installed) + .expect("installed binary metadata") + .permissions() + .mode() + & 0o777, + 0o755 + ); } let _ = fs::remove_dir_all(&root); @@ -80,7 +111,8 @@ fn install_binaries_copies_from_release_archive_bin_dir() { }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("release archive install should copy binaries"); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("release archive install should copy binaries"); for binary in [ "unixnotis-daemon", @@ -100,9 +132,63 @@ fn install_binaries_copies_from_release_archive_bin_dir() { let _ = fs::remove_dir_all(&root); } +#[test] +fn binary_install_runs_the_live_precommit_gate_and_activates_the_release() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("install-binaries-public-boundary"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + let source = paths + .repo_root + .join("target") + .join("release") + .join("unixnotis-daemon"); + fs::create_dir_all(source.parent().expect("release source parent")) + .expect("create release source directory"); + fs::write(&source, "public boundary binary").expect("write release source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set release source mode"); + let fake_bin = root.join("fake-tools"); + fs::create_dir_all(&fake_bin).expect("create fake tools directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\nprintf 'b false\\n'\n", + ); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'LoadState=not-found\\nActiveState=inactive\\n'\n", + ); + let _system_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let _manager_tools = + crate::service_manager::contract::command_routing::use_fake_command_bin(&fake_bin); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + + install_binaries_with_guards( + &mut ctx, + |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }, + |_paths| Ok(()), + ) + .expect("binary install should activate one generation after the live gate"); + + assert_eq!( + fs::read_to_string(paths.bin_dir.join("unixnotis-daemon")).expect("read installed binary"), + "public boundary binary" + ); + fs::remove_dir_all(root).expect("remove public install fixture"); +} + #[cfg(unix)] #[test] -fn install_binaries_bypasses_preexisting_temp_symlink_without_touching_it() { +fn install_binaries_rejects_destination_symlink_without_touching_its_target() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("install-binaries-temp-symlink"); write_fake_workspace( @@ -126,67 +212,35 @@ fn install_binaries_bypasses_preexisting_temp_symlink_without_touching_it() { let source = paths.repo_root.join("target").join("release").join(binary); fs::create_dir_all(source.parent().expect("release dir")).expect("make release dir"); fs::write(&source, format!("binary:{binary}")).expect("write fake binary"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set fake binary mode"); } fs::create_dir_all(&paths.bin_dir).expect("bin dir"); let destination = paths.bin_dir.join("unixnotis-daemon"); - let temp_path = binary_temp_path(&destination); let protected = root.join("protected"); fs::write(&protected, "protected").expect("protected"); - symlink(&protected, &temp_path).expect("temp symlink"); + symlink(&protected, &destination).expect("destination symlink"); let detection = Detection { owner: None, daemons: Vec::new(), }; let mut ctx = test_context(&detection, &paths, ActionMode::Install); - install_binaries(&mut ctx).expect("alternate temp path should bypass stale symlink"); + let error = install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect_err("destination symlink should fail"); + assert!( + error.to_string().contains("unmanaged target"), + "unexpected destination error: {error:#}" + ); assert_eq!( fs::read_to_string(&protected).expect("protected remains"), "protected" ); - assert!(fs::symlink_metadata(&temp_path) - .expect("temp symlink remains") + assert!(fs::symlink_metadata(&destination) + .expect("destination symlink remains") .file_type() .is_symlink()); - assert!(destination.exists()); - let _ = fs::remove_dir_all(&root); -} - -#[test] -fn binary_temp_path_attempt_uses_stable_first_path_and_unique_retry_path() { - let destination = std::env::temp_dir().join("unixnotis-daemon"); - - let first = binary_temp_path_attempt(&destination, 0); - let retry = binary_temp_path_attempt(&destination, 1); - - // The stable first path makes stale-file handling deterministic and testable - assert_eq!(first, binary_temp_path(&destination)); - // Retry paths carry the attempt so a collision cannot repeat the first candidate - assert_ne!(retry, first); - assert!(retry - .file_name() - .expect("retry file name") - .to_string_lossy() - .ends_with("-1")); -} - -#[test] -fn stage_binary_copy_propagates_errors_other_than_path_collisions() { - let root = std::env::temp_dir().join(format!( - "unixnotis-installer-binary-stage-error-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - let source_dir = root.join("source-directory"); - let destination = root.join("unixnotis-daemon"); - fs::create_dir_all(&source_dir).expect("make invalid directory source"); - - let error = stage_binary_copy_with_retry(&source_dir, &destination) - .expect_err("a source read error must not be treated as a temp collision"); - - assert_ne!(error.kind(), std::io::ErrorKind::AlreadyExists); - assert!(!destination.exists()); let _ = fs::remove_dir_all(&root); } @@ -242,6 +296,109 @@ fn remove_binaries_removes_all_managed_binaries_and_runtime_helpers() { let _ = fs::remove_dir_all(&root); } +#[test] +fn remove_binaries_accepts_only_the_managed_generation_entrypoints() { + let root = test_root("remove-managed-generation-binaries"); + write_fake_workspace(&root, &["unixnotis-daemon", "unixnotis-center"]); + let paths = test_paths(&root); + for binary in ["unixnotis-daemon", "unixnotis-center"] { + let source = paths.repo_root.join("target").join("release").join(binary); + fs::create_dir_all(source.parent().expect("release source parent")) + .expect("create release source directory"); + fs::write(&source, format!("managed:{binary}")).expect("write release source"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)) + .expect("set release source mode"); + } + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + install_binaries_with_guards(&mut ctx, |_paths| Ok(()), |_paths| Ok(())) + .expect("install managed generation"); + + remove_binaries(&mut ctx).expect("remove managed generation"); + + assert!(fs::symlink_metadata(paths.bin_dir.join("unixnotis-daemon")).is_err()); + assert!(fs::symlink_metadata(paths.bin_dir.join("unixnotis-center")).is_err()); + assert!(!paths + .installed_release_root() + .expect("installed release root") + .exists()); + fs::remove_dir_all(root).expect("remove managed uninstall fixture"); +} + +#[test] +fn remove_binaries_rejects_a_directory_entrypoint_with_a_stable_error() { + let root = test_root("remove-binaries-directory-entrypoint"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + fs::create_dir_all(paths.bin_dir.join("unixnotis-daemon")) + .expect("create directory entrypoint"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + let error = remove_binaries(&mut ctx).expect_err("directory entrypoint must fail closed"); + + assert!(error.to_string().contains("non-file binary entrypoint")); + assert!(paths.bin_dir.join("unixnotis-daemon").is_dir()); + fs::remove_dir_all(root).expect("remove directory entrypoint fixture"); +} + +#[test] +fn resolved_binary_removal_propagates_entrypoint_inspection_errors() { + let root = test_root("remove-binaries-inspection-error"); + let paths = test_paths(&root); + fs::create_dir_all(paths.bin_dir.parent().expect("binary parent")) + .expect("create binary parent"); + fs::write(&paths.bin_dir, "not a directory").expect("create invalid binary root"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + let error = remove_resolved_binaries(&mut ctx, vec!["unixnotis-daemon".to_string()]) + .expect_err("entrypoint metadata errors must not become missing files"); + + assert!(error.to_string().contains("inspect")); + fs::remove_file(&paths.bin_dir).expect("remove invalid binary root"); + fs::remove_dir_all(root).expect("remove inspection error fixture"); +} + +#[cfg(unix)] +#[test] +fn remove_binaries_rejects_symlink_without_touching_its_target() { + let root = test_root("remove-binaries-symlink"); + write_fake_workspace(&root, &["unixnotis-daemon"]); + let paths = test_paths(&root); + let protected = root.join("protected"); + let installed = paths.bin_dir.join("unixnotis-daemon"); + fs::create_dir_all(&paths.bin_dir).expect("create bin directory"); + fs::write(&protected, "protected").expect("write protected file"); + symlink(&protected, &installed).expect("create installed binary link"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Uninstall); + + remove_binaries(&mut ctx).expect_err("binary link should be rejected"); + + assert_eq!( + fs::read_to_string(&protected).expect("read protected file"), + "protected" + ); + assert!(fs::symlink_metadata(&installed) + .expect("installed link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(root); +} + #[test] fn remove_binaries_never_removes_a_file_outside_the_bin_directory() { let root = test_root("remove-binaries-contained"); @@ -317,6 +474,9 @@ fn write_fake_release_archive(root: &std::path::Path) { "unixnotis-css-validate", "noticenterctl", ] { - fs::write(bin_dir.join(binary), format!("release:{binary}")).expect("release binary"); + let path = bin_dir.join(binary); + fs::write(&path, format!("release:{binary}")).expect("release binary"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("set release binary mode"); } } diff --git a/crates/unixnotis-installer/src/actions/install/tests/install_state.rs b/crates/unixnotis-installer/src/actions/install/tests/install_state.rs new file mode 100644 index 000000000..c36510d77 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/install_state.rs @@ -0,0 +1,209 @@ +use super::{BinaryState, InstallState, InstallationDisposition}; +use crate::actions::releases::BinaryHealth; +use crate::service_manager::{ServiceArtifact, ServiceArtifactKind}; + +use super::service_artifacts_are_present; + +#[test] +fn empty_service_artifact_list_is_not_installed() { + // A backend with no artifacts has not proved ownership of anything on disk + assert!(!service_artifacts_are_present(&[])); +} + +#[test] +fn installation_disposition_labels_are_distinct_and_actionable() { + assert_eq!( + InstallationDisposition::NotInstalled.label(), + "not installed" + ); + assert_eq!(InstallationDisposition::InstalledHealthy.label(), "healthy"); + assert_eq!( + InstallationDisposition::RepairRequired.label(), + "repair required" + ); +} + +#[test] +fn missing_service_artifact_list_is_not_installed() { + let artifact = ServiceArtifact { + // Use a fixed missing path because this test only needs the safe-presence negative path + path: std::env::temp_dir().join("unixnotis-missing-service-artifact"), + kind: ServiceArtifactKind::File, + contents: Some(String::new()), + mode: None, + }; + + // Non-empty lists still need every artifact to match the expected safe shape + assert!(!service_artifacts_are_present(&[artifact])); +} + +#[test] +fn install_state_requires_non_empty_binary_list_all_binaries_and_service_artifact() { + let base = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + // Full install state needs at least one binary, every binary present, and a safe service artifact + assert!(base.is_installed()); + assert_eq!( + base.disposition(), + InstallationDisposition::InstalledHealthy + ); + assert_eq!(base.installed_version(), Some("1.2.0")); + + let mut no_binaries = base.clone(); + no_binaries.binaries.clear(); + assert!(!no_binaries.is_installed()); + assert_eq!( + no_binaries.disposition(), + InstallationDisposition::RepairRequired + ); + + let mut missing_binary = base.clone(); + missing_binary.binaries[0].health = BinaryHealth::Missing; + assert!(!missing_binary.is_installed()); + assert_eq!( + missing_binary.disposition(), + InstallationDisposition::RepairRequired + ); + + let mut missing_service = base; + missing_service.service_artifact_exists = false; + assert!(!missing_service.is_installed()); + assert_eq!( + missing_service.disposition(), + InstallationDisposition::RepairRequired + ); +} + +#[test] +fn install_state_with_no_binary_or_service_footprint_is_not_installed() { + let state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Missing, + }], + service_artifact_exists: false, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert_eq!(state.disposition(), InstallationDisposition::NotInstalled); +} + +#[test] +fn indeterminate_selected_manager_requires_repair_for_present_binaries() { + let state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: false, + service_active: false, + service_enabled_error: None, + service_active_error: Some("manager state is indeterminate".to_string()), + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert!(!state.is_installed()); + assert_eq!(state.disposition(), InstallationDisposition::RepairRequired); +} + +#[test] +fn fully_installed_requires_running_service_and_enabled_accessor_tracks_field() { + let mut state = InstallState { + binaries: vec![BinaryState { + name: "unixnotis-daemon".to_string(), + path: std::env::temp_dir().join("unixnotis-daemon"), + health: BinaryHealth::Healthy { + generation: "test-generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "test-digest".to_string(), + }, + }], + service_artifact_exists: true, + service_enabled: true, + service_active: false, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + // Enabled state and active state are separate; install summary should not conflate them + assert!(state.is_installed()); + assert!(state.service_enabled); + assert!(!state.is_fully_installed()); + + state.service_active = true; + + assert!(state.is_fully_installed()); +} + +#[test] +fn install_state_rejects_individually_healthy_binaries_from_different_generations() { + let binary = |name: &str, generation: &str| BinaryState { + name: name.to_string(), + path: std::env::temp_dir().join(name), + health: BinaryHealth::Healthy { + generation: generation.to_string(), + package_version: "1.2.0".to_string(), + digest: format!("digest-{name}"), + }, + }; + let state = InstallState { + binaries: vec![ + binary("unixnotis-daemon", "generation-a"), + binary("unixnotis-center", "generation-b"), + ], + service_artifact_exists: true, + service_enabled: true, + service_active: true, + service_enabled_error: None, + service_active_error: None, + binary_warning: None, + service_conflicts: Vec::new(), + service_conflict_warnings: Vec::new(), + }; + + assert!( + !state.is_installed(), + "different release generations must never form one installed state" + ); + assert_eq!( + state.disposition(), + InstallationDisposition::RepairRequired, + "mixed release generations must be presented as a repair" + ); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs b/crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs new file mode 100644 index 000000000..15bbec361 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/installation_channel.rs @@ -0,0 +1,625 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use super::{ + active_unit_metadata, classify_installation_channel, classify_installation_channel_at, + installed_system_package_paths_at, parse_active_unit_metadata, parse_exec_start_path, + path_entry_exists, property_value, reject_channel, reject_classified_channel, + reject_conflicting_installation_channel, validate_systemctl_output, ActiveUnitMetadata, + InstallationChannel, MAX_SYSTEMCTL_OUTPUT_BYTES, SYSTEM_BINARY_ROOT, SYSTEM_UNIT_ROOT, +}; +use crate::actions::ActionContext; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; +use std::os::unix::process::ExitStatusExt; + +struct TestHomeLayout { + unit_root: PathBuf, + binary_root: PathBuf, +} + +fn test_home_layout(label: &str) -> TestHomeLayout { + // Each test gets an isolated home layout instead of assuming an account path + let home = crate::test_support::fs::unique_temp_path(label).join("home"); + TestHomeLayout { + unit_root: home.join(".config").join("systemd").join("user"), + binary_root: home.join(".local").join("bin"), + } +} + +fn test_context(root: &Path) -> (Detection, InstallPaths) { + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + (detection, paths) +} + +fn action_context<'a>(_detection: &'a Detection, paths: &'a InstallPaths) -> ActionContext<'a> { + let (log_tx, _log_rx) = mpsc::sync_channel::(32); + ActionContext { + paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +#[test] +fn matching_home_and_system_paths_select_one_installation_channel() { + let home = test_home_layout("installation-channel-matching"); + let system = test_home_layout("installation-channel-matching-system"); + materialize_channel(&home); + materialize_channel(&system); + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &home.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::HomeLocal + ); + assert_eq!( + classify_installation_channel_at( + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::SystemPackage + ); +} + +#[test] +fn one_existing_channel_does_not_require_the_other_policy_root_to_exist() { + let home = test_home_layout("installation-channel-one-root-home"); + let system = test_home_layout("installation-channel-one-root-system"); + materialize_channel(&system); + + assert_eq!( + classify_installation_channel_at( + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::SystemPackage + ); + + fs::remove_dir_all( + system + .unit_root + .ancestors() + .nth(4) + .expect("system fixture root"), + ) + .expect("remove system fixture"); + materialize_channel(&home); + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &home.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::HomeLocal + ); + fs::remove_dir_all( + home.unit_root + .ancestors() + .nth(4) + .expect("home fixture root"), + ) + .expect("remove home fixture"); +} + +#[test] +fn crossed_unit_and_binary_paths_are_always_mixed() { + let home = test_home_layout("installation-channel-crossed"); + let system = test_home_layout("installation-channel-crossed-system"); + materialize_channel(&home); + materialize_channel(&system); + let home_unit = home.unit_root.join("unixnotis-daemon.service"); + let home_binary = home.binary_root.join("unixnotis-daemon"); + let system_unit = system.unit_root.join("unixnotis-daemon.service"); + let system_binary = system.binary_root.join("unixnotis-daemon"); + for (unit, binary) in [(&home_unit, &system_binary), (&system_unit, &home_binary)] { + assert_eq!( + classify_installation_channel_at( + unit, + binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Mixed + ); + } +} + +#[test] +fn custom_paths_are_not_silently_treated_as_home_or_package_installs() { + let root = crate::test_support::fs::unique_temp_path("installation-channel-custom"); + let home = test_home_layout("installation-channel-custom-home"); + assert_eq!( + classify_installation_channel( + &root.join("custom-units").join("unixnotis-daemon.service"), + &root.join("custom-bin").join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + ), + InstallationChannel::Unknown + ); +} + +#[test] +fn channel_classification_follows_cross_channel_symlink_targets() { + use std::os::unix::fs::symlink; + + let home = test_home_layout("installation-channel-link-home"); + let system = test_home_layout("installation-channel-link-system"); + materialize_channel(&home); + materialize_channel(&system); + let linked_binary = home.binary_root.join("linked-daemon"); + symlink(system.binary_root.join("unixnotis-daemon"), &linked_binary) + .expect("create home-to-system binary link"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &linked_binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Mixed + ); + + let linked_unit = system.unit_root.join("linked.service"); + symlink( + home.unit_root.join("unixnotis-daemon.service"), + &linked_unit, + ) + .expect("create system-to-home unit link"); + assert_eq!( + classify_installation_channel_at( + &linked_unit, + &system.binary_root.join("unixnotis-daemon"), + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Mixed + ); +} + +#[test] +fn dangling_channel_link_is_unknown() { + use std::os::unix::fs::symlink; + + let home = test_home_layout("installation-channel-dangling-home"); + let system = test_home_layout("installation-channel-dangling-system"); + materialize_channel(&home); + materialize_channel(&system); + let dangling = home.binary_root.join("dangling-daemon"); + symlink(home.binary_root.join("missing-daemon"), &dangling).expect("create dangling link"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &dangling, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Unknown + ); +} + +#[test] +fn unrelated_object_under_the_local_prefix_is_not_a_managed_binary_channel() { + let home = test_home_layout("installation-channel-local-prefix-home"); + let system = test_home_layout("installation-channel-local-prefix-system"); + materialize_channel(&home); + materialize_channel(&system); + let local_root = home.binary_root.parent().expect("local root"); + let unrelated_root = local_root.join("share").join("unrelated"); + fs::create_dir_all(&unrelated_root).expect("create unrelated local directory"); + let unrelated_binary = unrelated_root.join("unixnotis-daemon"); + fs::write(&unrelated_binary, "unrelated binary").expect("write unrelated local binary"); + + assert_eq!( + classify_installation_channel_at( + &home.unit_root.join("unixnotis-daemon.service"), + &unrelated_binary, + &home.unit_root, + &home.binary_root, + &system.unit_root, + &system.binary_root, + ), + InstallationChannel::Unknown + ); +} + +fn materialize_channel(layout: &TestHomeLayout) { + fs::create_dir_all(&layout.unit_root).expect("create channel unit root"); + fs::create_dir_all(&layout.binary_root).expect("create channel binary root"); + fs::write( + layout.unit_root.join("unixnotis-daemon.service"), + "[Service]\n", + ) + .expect("write channel unit"); + fs::write(layout.binary_root.join("unixnotis-daemon"), "binary").expect("write channel binary"); +} + +#[test] +fn systemd_exec_start_parser_reads_only_the_structured_path_field() { + let home = test_home_layout("exec-start-parser"); + let executable = home.binary_root.join("unixnotis-daemon"); + let executable = executable.to_string_lossy(); + let metadata = format!("{{ path={executable} ; argv[]={executable} ; ignore_errors=no ; }}"); + assert_eq!(parse_exec_start_path(&metadata), Some(executable.as_ref())); + assert_eq!(parse_exec_start_path("argv[]=/tmp/fake"), None); +} + +#[test] +fn systemd_property_parser_requires_an_exact_nonempty_key() { + let home = test_home_layout("property-parser"); + let fragment = home.unit_root.join("unixnotis-daemon.service"); + let executable = home.binary_root.join("unixnotis-daemon"); + let output = format!( + "FragmentPath={}\nExecStart={{ path={} ; }}\n", + fragment.display(), + executable.display() + ); + + assert_eq!( + property_value(&output, "FragmentPath"), + Some(fragment.to_string_lossy().as_ref()) + ); + assert_eq!( + property_value(&output, "ExecStart"), + Some(format!("{{ path={} ; }}", executable.display()).as_str()) + ); + assert_eq!(property_value(&output, "Path"), None); + assert_eq!(property_value("FragmentPath=\n", "FragmentPath"), None); + assert_eq!( + property_value("FragmentPathx=/tmp/wrong\n", "FragmentPath"), + None + ); +} + +#[test] +fn systemd_unit_metadata_accepts_loaded_units_and_absent_units() { + let home = test_home_layout("loaded-unit-metadata"); + let fragment = home.unit_root.join("unixnotis-daemon.service"); + let executable = home.binary_root.join("unixnotis-daemon"); + let loaded = format!( + "LoadState=loaded\nUnitFileState=enabled\nFragmentPath={}\nExecStart={{ path={} ; }}\n", + fragment.display(), + executable.display() + ); + assert_eq!( + parse_active_unit_metadata(&loaded).expect("loaded metadata should parse"), + ActiveUnitMetadata::Paths { + fragment, + executable, + } + ); + + let absent = "LoadState=not-found\nUnitFileState=\nFragmentPath=\nExecStart=\n"; + assert_eq!( + parse_active_unit_metadata(absent).expect("an absent unit should not be active"), + ActiveUnitMetadata::Absent + ); +} + +#[test] +fn runtime_mask_is_recoverable_during_explicit_installation() { + let masked = "LoadState=masked\nUnitFileState=masked-runtime\nFragmentPath=\nExecStart=\n"; + + assert_eq!( + parse_active_unit_metadata(masked).expect("runtime mask metadata should parse"), + ActiveUnitMetadata::RuntimeMasked + ); +} + +#[test] +fn persistent_mask_remains_distinct_from_temporary_state() { + let masked = "LoadState=masked\nUnitFileState=masked\nFragmentPath=\nExecStart=\n"; + + assert_eq!( + parse_active_unit_metadata(masked).expect("persistent mask metadata should parse"), + ActiveUnitMetadata::PersistentMasked + ); +} + +#[test] +fn loaded_unit_still_requires_complete_channel_metadata() { + let home = test_home_layout("incomplete-unit-metadata"); + let incomplete = format!( + "LoadState=loaded\nUnitFileState=disabled\nFragmentPath={}\nExecStart=\n", + home.unit_root.join("unixnotis-daemon.service").display() + ); + let error = parse_active_unit_metadata(&incomplete) + .expect_err("loaded units require an executable path"); + + assert_eq!( + error.to_string(), + "systemctl returned incomplete UnixNotis unit path metadata" + ); +} + +#[test] +fn package_artifact_probe_distinguishes_complete_absent_and_partial_installs() { + let root = crate::test_support::fs::unique_temp_path("package-artifact-probe"); + let unit_root = root.join("units"); + let binary_root = root.join("bin"); + fs::create_dir_all(&unit_root).expect("create test unit root"); + fs::create_dir_all(&binary_root).expect("create test binary root"); + + assert_eq!( + installed_system_package_paths_at(&unit_root, &binary_root) + .expect("missing package artifacts should be accepted"), + None + ); + + fs::write(unit_root.join("unixnotis-daemon.service"), "[Service]\n") + .expect("create package unit fixture"); + let partial = installed_system_package_paths_at(&unit_root, &binary_root) + .expect_err("partial package artifacts should fail closed"); + assert_eq!( + partial.to_string(), + "incomplete system-package UnixNotis artifacts detected; repair or remove the package before installing" + ); + + fs::write(binary_root.join("unixnotis-daemon"), []).expect("create package binary fixture"); + assert_eq!( + installed_system_package_paths_at(&unit_root, &binary_root) + .expect("complete package artifacts should be detected"), + Some(( + unit_root.join("unixnotis-daemon.service"), + binary_root.join("unixnotis-daemon") + )) + ); + fs::remove_dir_all(root).expect("remove package artifact fixture"); +} + +#[test] +fn path_entry_probe_propagates_errors_other_than_missing_paths() { + let root = crate::test_support::fs::unique_temp_path("package-artifact-probe-error"); + fs::write(&root, []).expect("create regular file fixture"); + + let error = path_entry_exists(&root.join("child")) + .expect_err("a child below a regular file must report its metadata error"); + + assert_ne!( + error + .downcast_ref::() + .map(std::io::Error::kind), + Some(std::io::ErrorKind::NotFound), + "non-missing metadata errors must remain distinguishable" + ); + fs::remove_file(root).expect("remove regular file fixture"); +} + +#[test] +fn systemctl_probe_returns_runtime_mask_metadata_without_dynamic_unit_paths() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("systemctl-runtime-mask"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'LoadState=masked' 'UnitFileState=masked-runtime' 'FragmentPath=' 'ExecStart='\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert_eq!( + active_unit_metadata().expect("systemctl metadata should be inspected"), + ActiveUnitMetadata::RuntimeMasked + ); + fs::remove_dir_all(root).expect("remove fake systemctl fixture"); +} + +#[test] +fn systemctl_probe_failure_is_not_treated_as_an_absent_unit() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("systemctl-inspection-failure"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'user manager unavailable\\n' >&2\nexit 1\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = active_unit_metadata().expect_err("failed inspection must remain unknown"); + + assert!(error + .to_string() + .contains("failed to inspect UnixNotis systemd unit")); + assert!(error.to_string().contains("user manager unavailable")); + fs::remove_dir_all(root).expect("remove fake systemctl fixture"); +} + +#[test] +fn systemctl_metadata_budget_accepts_exact_stream_limits_and_rejects_oversize() { + assert_eq!(MAX_SYSTEMCTL_OUTPUT_BYTES, 32_768); + let output = |stdout: Vec, stderr: Vec, status| std::process::Output { + status: std::process::ExitStatus::from_raw(status), + stdout, + stderr, + }; + let exact = vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES]; + + assert_eq!( + validate_systemctl_output(output(exact.clone(), Vec::new(), 0)) + .expect("exact stdout budget") + .len(), + MAX_SYSTEMCTL_OUTPUT_BYTES + ); + let exact_stderr = validate_systemctl_output(output(Vec::new(), exact, 1)) + .expect_err("failed systemctl output remains an error"); + assert!(exact_stderr + .to_string() + .contains("failed to inspect UnixNotis systemd unit")); + assert!(validate_systemctl_output(output( + vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES + 1], + Vec::new(), + 0, + )) + .is_err()); + assert!(validate_systemctl_output(output( + Vec::new(), + vec![b'x'; MAX_SYSTEMCTL_OUTPUT_BYTES + 1], + 1, + )) + .is_err()); +} + +#[test] +fn installation_channel_guard_rejects_a_persistent_mask_through_the_real_action_boundary() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("channel-guard-persistent-mask"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake command directory"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'LoadState=masked' 'UnitFileState=masked' 'FragmentPath=' 'ExecStart='\n", + ); + let _path = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let (detection, paths) = test_context(&root); + let mut context = action_context(&detection, &paths); + + let error = reject_conflicting_installation_channel(&mut context) + .expect_err("persistent mask must stop the real install check"); + + assert_eq!( + error.to_string(), + "UnixNotis systemd unit is persistently masked; run `systemctl --user unmask unixnotis-daemon.service` before installing" + ); + fs::remove_dir_all(root).expect("remove persistent mask fixture"); +} + +#[test] +fn conflict_dispatcher_rejects_system_package_paths() { + let root = crate::test_support::fs::unique_temp_path("channel-dispatch-package"); + let (_detection, paths) = test_context(&root); + let (log_tx, log_rx) = mpsc::sync_channel::(8); + let mut context = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = reject_classified_channel( + &mut context, + InstallationChannel::SystemPackage, + &Path::new(SYSTEM_UNIT_ROOT).join("unixnotis-daemon.service"), + &Path::new(SYSTEM_BINARY_ROOT).join("unixnotis-daemon"), + ) + .expect_err("system package channel must stop home-local installation"); + + assert_eq!( + error.to_string(), + "the system-package UnixNotis installation must be removed with its package manager before a home-local install" + ); + let lines = log_rx + .try_iter() + .filter_map(|message| match message { + UiMessage::Worker(WorkerEvent::LogLine(line)) => Some(line), + _ => None, + }) + .collect::>(); + assert!(lines + .iter() + .any(|line| { line == "Error: system package UnixNotis installation channel" })); + assert!(lines.iter().any(|line| { + line == &format!( + "- unit: {}", + Path::new(SYSTEM_UNIT_ROOT) + .join("unixnotis-daemon.service") + .display() + ) + })); + assert!(lines.iter().any(|line| { + line == &format!( + "- executable: {}", + Path::new(SYSTEM_BINARY_ROOT) + .join("unixnotis-daemon") + .display() + ) + })); +} + +#[test] +fn resolved_channel_boundary_rejects_objects_outside_managed_roots() { + let root = crate::test_support::fs::unique_temp_path("channel-boundary-unknown"); + let home = test_home_layout("channel-boundary-unknown-home"); + let system = test_home_layout("channel-boundary-unknown-system"); + materialize_channel(&home); + materialize_channel(&system); + let (detection, paths) = test_context(&root); + let mut context = action_context(&detection, &paths); + + let error = reject_channel( + &mut context, + &system.unit_root.join("unixnotis-daemon.service"), + &system.binary_root.join("unixnotis-daemon"), + ) + .expect_err("unrecognized resolved objects must reach the channel conflict policy"); + + assert!(error + .to_string() + .contains("unrecognized installation channel")); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all( + home.unit_root + .ancestors() + .nth(4) + .expect("home fixture root"), + ) + .ok(); + fs::remove_dir_all( + system + .unit_root + .ancestors() + .nth(4) + .expect("system fixture root"), + ) + .ok(); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs b/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs new file mode 100644 index 000000000..57f451730 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/installer_lock.rs @@ -0,0 +1,50 @@ +use std::os::unix::fs::symlink; + +use super::{owned_expected_object, InstallerLock}; + +#[test] +fn lock_ownership_requires_both_the_expected_shape_and_effective_user() { + assert!(owned_expected_object(true, 1_000, 1_000)); + assert!(!owned_expected_object(false, 1_000, 1_000)); + assert!(!owned_expected_object(true, 1_001, 1_000)); + assert!(!owned_expected_object(false, 1_001, 1_000)); +} + +#[test] +fn second_installer_cannot_acquire_the_same_action_lock() { + let root = test_root("contended"); + let lock_path = root.join("installer.lock"); + let first = InstallerLock::acquire_at(&lock_path).expect("first action lock"); + + let error = InstallerLock::acquire_at(&lock_path).expect_err("second action must be rejected"); + + assert!( + error + .to_string() + .contains("another UnixNotis installer action is already running"), + "unexpected contention error: {error:#}" + ); + drop(first); + InstallerLock::acquire_at(&lock_path).expect("released action lock"); + std::fs::remove_dir_all(root).expect("remove lock fixture"); +} + +#[test] +fn installer_lock_rejects_a_symlink_target() { + let root = test_root("symlink"); + let target = root.join("target"); + std::fs::write(&target, b"not a lock").expect("write symlink target"); + let lock_path = root.join("installer.lock"); + symlink(&target, &lock_path).expect("create lock symlink"); + + InstallerLock::acquire_at(&lock_path).expect_err("lock symlink must be rejected"); + + std::fs::remove_dir_all(root).expect("remove lock fixture"); +} + +fn test_root(label: &str) -> std::path::PathBuf { + let root = crate::test_support::fs::unique_temp_path(&format!("installer-lock-{label}")); + let _cleanup = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create lock fixture"); + root +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs index ba4c17ff3..69f8ef361 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/backend_idempotence.rs @@ -7,7 +7,8 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::service_manager::ServiceManager; -use super::super::super::service::{install_service, uninstall_service}; +use super::super::super::service::flow::install_service; +use super::super::super::service::uninstall_service; use super::super::support::{test_context, test_root}; use super::flow_support::{flow_env, flow_paths, lock_env, write_fake_tools, FakeToolMode}; @@ -102,7 +103,8 @@ fn every_backend_wrong_primary_artifact_shape_fails_without_mutation() { assert!( err.to_string().contains("symlink") || err.to_string().contains("unsafe") - || err.to_string().contains("not managed"), + || err.to_string().contains("not managed") + || err.to_string().contains("unmarked"), "{name} error should explain the unsafe shape: {err}" ); assert_eq!( diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs index 2117fd5c8..566ffd100 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow.rs @@ -6,7 +6,7 @@ use crate::service_manager::ServiceManager; use super::flow_support::{ assert_call_order, flow_env, flow_paths, lock_env, read_calls, run_install_and_enable, - service_flow_root, write_fake_tools, FakeToolMode, + service_flow_root, standard_bus_address, write_fake_tools, FakeToolMode, }; #[test] @@ -38,8 +38,10 @@ fn systemd_install_flow_runs_reload_env_import_and_enable() { &calls, &[ "program=systemctl argv=[--user][daemon-reload]", + "program=systemctl argv=[--user][unset-environment][DBUS_SESSION_BUS_ADDRESS]", "program=dbus-update-activation-environment argv=[WAYLAND_DISPLAY]", "program=systemctl argv=[--user][--no-pager][import-environment][WAYLAND_DISPLAY]", + "program=systemctl argv=[--user][--runtime][unmask][unixnotis-daemon.service]", "program=systemctl argv=[--user][enable][--now][unixnotis-daemon.service]", ], ); @@ -108,7 +110,7 @@ fn runit_install_flow_syncs_envdir_before_removing_down_and_starting() { assert_eq!( fs::read_to_string(service_dir.join("env").join("DBUS_SESSION_BUS_ADDRESS")) .expect("runit D-Bus address should be persisted"), - "unix:path=/tmp/unixnotis-bus\n" + format!("{}\n", standard_bus_address()) ); assert!( fs::symlink_metadata(service_dir.join("down")).is_err(), @@ -160,7 +162,7 @@ fn s6_install_flow_compiles_database_then_changes_service() { .join("DBUS_SESSION_BUS_ADDRESS") ) .expect("s6 D-Bus address should be persisted"), - "unix:path=/tmp/unixnotis-bus\n" + format!("{}\n", standard_bus_address()) ); let calls = read_calls(&log_path); assert_call_order( diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs new file mode 100644 index 000000000..85a70d968 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/generation.rs @@ -0,0 +1,233 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::actions::install::service::flow::rollback_failed_activation_with_quiescence; +use crate::actions::releases::{commit_pending_release, pending_release_exists}; +use crate::detect::Detection; +use crate::model::ActionMode; +use crate::service_manager::ServiceManager; + +use super::super::super::support::test_context; +use super::super::flow_support::{ + enable_service_with_readiness_and_quiescence, flow_env, flow_paths, install_release_generation, + lock_env, service_flow_root, write_fake_tools, FakeToolMode, +}; + +#[test] +fn failed_new_generation_readiness_restores_and_rechecks_the_previous_runtime() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-readiness-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + let readiness_calls = AtomicUsize::new(0); + + let error = enable_service_with_readiness_and_quiescence( + &mut ctx, + |_ctx| { + if readiness_calls.fetch_add(1, Ordering::AcqRel) == 0 { + Err(anyhow::anyhow!("new generation failed readiness")) + } else { + Ok(()) + } + }, + |_paths| Ok(()), + ) + .expect_err("failed new generation must report activation failure after rollback"); + + assert!(error + .to_string() + .contains("new generation failed readiness")); + assert_eq!(readiness_calls.load(Ordering::Acquire), 2); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current link"), + std::path::Path::new("releases").join(old_generation) + ); + assert_eq!( + fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + "old generation" + ); + fs::remove_dir_all(root).expect("remove readiness rollback fixture"); +} + +#[test] +fn failure_after_binary_activation_restores_and_restarts_the_previous_runtime() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-later-step-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + let readiness_calls = AtomicUsize::new(0); + + let error = rollback_failed_activation_with_quiescence( + &mut ctx, + &|_ctx| { + readiness_calls.fetch_add(1, Ordering::AcqRel); + Ok(()) + }, + anyhow::anyhow!("service artifact installation failed"), + |_paths| Ok(()), + ) + .expect_err("a later step failure must remain an installation failure"); + + assert!(error + .to_string() + .contains("service artifact installation failed")); + assert_eq!(readiness_calls.load(Ordering::Acquire), 1); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current link"), + std::path::Path::new("releases").join(old_generation) + ); + assert_eq!( + fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + "old generation" + ); + fs::remove_dir_all(root).expect("remove later-step rollback fixture"); +} + +#[test] +fn successful_stop_result_cannot_roll_back_while_runtime_remains_live() { + let _lock = lock_env(); + let root = service_flow_root("install-generation-live-runtime-blocks-rollback"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + let source = root.join("release-source"); + fs::create_dir_all(&source).expect("create release source"); + let binary = "unixnotis-daemon".to_string(); + write_binary(&source.join(&binary), "old generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install prior generation"); + commit_pending_release(&paths).expect("commit prior generation"); + write_binary(&source.join(&binary), "new generation"); + let new_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate pending generation"); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let mut ctx = test_context(&detection, &paths, ActionMode::Install); + + let error = rollback_failed_activation_with_quiescence( + &mut ctx, + &|_ctx| Ok(()), + anyhow::anyhow!("new generation failed readiness"), + |_paths| Err(anyhow::anyhow!("notification owner is still live")), + ) + .expect_err("a live rejected runtime must block disk rollback"); + + assert!(error + .to_string() + .contains("service manager reported a successful stop")); + assert_eq!( + fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retain active generation"), + std::path::Path::new("releases").join(new_generation) + ); + assert!(pending_release_exists(&paths).expect("retain pending rollback journal")); + fs::remove_dir_all(root).expect("remove live runtime rollback fixture"); +} + +fn write_binary(path: &std::path::Path, contents: &str) { + fs::write(path, contents).expect("write release binary"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("set release binary mode"); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs index ed205c35b..135b51d86 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/mod.rs @@ -1,3 +1,4 @@ +mod generation; mod runit; mod s6_uninstall; mod systemd_dinit; diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs index 5e2fd20d8..c8ce63fff 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/runit.rs @@ -28,7 +28,11 @@ fn runit_envdir_sync_failure_keeps_down_gate() { let err = run_enable_only(&paths).expect_err("envdir sync should fail"); // The down gate must remain because env sync failed before the service was allowed to start - assert!(format!("{err:#}").contains("cannot replace symlink service directory")); + assert!( + format!("{err:#}").contains("failed to create") + && format!("{err:#}").contains("Not a directory"), + "unexpected envdir safety error: {err:#}" + ); assert!(service_dir.join("down").is_file()); let calls = if log_path.exists() { read_calls(&log_path) diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs index 27a85cfbd..03a0e7d17 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_failures/systemd_dinit.rs @@ -79,3 +79,36 @@ fn dinit_install_fails_before_start_when_setenv_fails() { ); let _ = fs::remove_dir_all(&root); } + +#[test] +fn systemd_install_fails_before_enable_when_runtime_unmask_fails() { + let _lock = lock_env(); + let root = service_flow_root("install-fail-systemd-unmask"); + let log_path = root.join("calls.log"); + let fake_bin = root.join("fake-bin"); + let _fake_tools = write_fake_tools(&fake_bin, &log_path, FakeToolMode::Default); + let _env = flow_env(&root); + let _failure = fake_failure_env("systemctl", "unmask"); + let paths = flow_paths( + &root, + ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + ); + + let error = run_install_and_enable(&paths).expect_err("runtime unmask should fail"); + + assert!(error.to_string().contains("clear temporary service mask")); + let calls = read_calls(&log_path); + assert!(calls + .iter() + .any(|call| call.contains("[--user][--runtime][unmask][unixnotis-daemon.service]"))); + assert!( + !calls.iter().any(|call| call.contains("[enable][--now]")), + "service enable must not run after a failed runtime unmask" + ); + fs::remove_dir_all(root).expect("remove systemd unmask failure fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs index 586c1093c..34abd5ec0 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/flow_support.rs @@ -3,6 +3,8 @@ use std::fs; use std::path::Path; use std::sync::MutexGuard; +use anyhow::Context; + use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; @@ -10,7 +12,11 @@ use crate::service_manager::contract::command_routing::use_fake_command_bin; use crate::service_manager::ServiceManager; use crate::test_support::fs::write_executable; -use super::super::super::service::{enable_service, install_service, uninstall_service}; +use super::super::super::service::flow::{ + install_service, prepare_service_start, rollback_failed_activation_with_quiescence, + start_service_and_verify, +}; +use super::super::super::service::uninstall_service; use super::super::support::{test_context, test_root}; pub(super) fn lock_env() -> MutexGuard<'static, ()> { @@ -18,6 +24,27 @@ pub(super) fn lock_env() -> MutexGuard<'static, ()> { crate::test_support::env::test_env_lock() } +pub(super) fn install_release_generation( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, +) -> anyhow::Result +where + F: FnMut() -> anyhow::Result<()>, + R: FnMut() -> anyhow::Result, +{ + crate::actions::releases::install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + || Ok(()), + ) +} + pub(super) struct EnvGuard { // Tests mutate process-wide env, so each guard owns one variable restoration key: &'static str, @@ -62,6 +89,55 @@ pub(super) enum FakeToolMode { RunitSv, } +pub(super) fn enable_service_with_readiness( + ctx: &mut crate::actions::ActionContext, + readiness: F, +) -> anyhow::Result<()> +where + F: Fn(&mut crate::actions::ActionContext) -> anyhow::Result<()>, +{ + enable_service_with_readiness_and_quiescence(ctx, readiness, |paths| { + crate::actions::daemon::wait_until_no_conflicting_live_daemon( + paths, + crate::actions::daemon::STOP_QUIESCENCE_TIMEOUT, + ) + }) +} + +pub(super) fn enable_service_with_readiness_and_quiescence( + ctx: &mut crate::actions::ActionContext, + readiness: F, + mut wait_for_quiescence: Q, +) -> anyhow::Result<()> +where + F: Fn(&mut crate::actions::ActionContext) -> anyhow::Result<()>, + Q: FnMut(&crate::paths::InstallPaths) -> anyhow::Result<()>, +{ + let result = (|| { + prepare_service_start(ctx)?; + start_service_and_verify(ctx, &readiness) + })(); + match result { + Ok(()) => { + crate::actions::releases::commit_pending_release(ctx.paths) + .context("commit ready binary release generation")?; + Ok(()) + } + Err(error) => { + if crate::actions::releases::pending_release_exists(ctx.paths)? { + rollback_failed_activation_with_quiescence( + ctx, + &readiness, + error, + &mut wait_for_quiescence, + ) + } else { + Err(error) + } + } + } +} + pub(super) fn run_install_and_enable(paths: &InstallPaths) -> anyhow::Result<()> { let detection = Detection { owner: None, @@ -70,7 +146,7 @@ pub(super) fn run_install_and_enable(paths: &InstallPaths) -> anyhow::Result<()> let mut ctx = test_context(&detection, paths, ActionMode::Install); // Run the same two public install phases used by the TUI worker install_service(&mut ctx)?; - enable_service(&mut ctx) + enable_service_with_readiness(&mut ctx, |_| Ok(())) } pub(super) fn run_install_only(paths: &InstallPaths) -> anyhow::Result<()> { @@ -88,7 +164,7 @@ pub(super) fn run_enable_only(paths: &InstallPaths) -> anyhow::Result<()> { daemons: Vec::new(), }; let mut ctx = test_context(&detection, paths, ActionMode::Install); - enable_service(&mut ctx) + enable_service_with_readiness(&mut ctx, |_| Ok(())) } pub(super) fn run_uninstall_only(paths: &InstallPaths) -> anyhow::Result<()> { @@ -121,10 +197,17 @@ pub(super) fn flow_env(root: &Path) -> Vec { EnvGuard::set("XDG_SESSION_TYPE", "wayland"), EnvGuard::set("XDG_SESSION_DESKTOP", "Hyprland"), EnvGuard::set("DISPLAY", ":99"), - EnvGuard::set("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/unixnotis-bus"), + EnvGuard::set("DBUS_SESSION_BUS_ADDRESS", standard_bus_address()), ] } +pub(super) fn standard_bus_address() -> String { + format!( + "unix:path=/run/user/{}/bus", + rustix::process::getuid().as_raw() + ) +} + pub(super) fn write_fake_tools(fake_bin: &Path, log_path: &Path, mode: FakeToolMode) -> impl Drop { fs::create_dir_all(fake_bin).expect("make fake bin"); // All tools listed here are backends or helper commands used by the service install flow diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs index 985c78556..35fd328d5 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/lifecycle.rs @@ -8,8 +8,9 @@ use crate::detect::Detection; use crate::model::ActionMode; use crate::service_manager::ServiceManager; +use super::super::super::service::flow::install_service; use super::super::super::service::lifecycle::{service_start_mode_from_enabled, ServiceStartMode}; -use super::super::super::service::{install_service, uninstall_service, write_service_artifact}; +use super::super::super::service::{uninstall_service, write_service_artifact}; use super::super::support::{test_context, test_paths, test_root}; use super::expected_primary_artifact_contents; use super::flow_support::{flow_env, lock_env, write_fake_tools, FakeToolMode}; @@ -21,15 +22,16 @@ use super::flow_support::{flow_env, lock_env, write_fake_tools, FakeToolMode}; fn install_service_skips_rewrite_when_unit_is_already_current() { let root = test_root("install-service-unchanged"); let paths = test_paths(&root); - fs::create_dir_all(paths.service.artifact_root()).expect("make service artifact dir"); - // Seed exactly what the backend would render so the installer should stay quiet - let expected = expected_primary_artifact_contents(&paths); - fs::write(paths.service.primary_artifact_path(), &expected).expect("write current artifact"); - let detection = Detection { owner: None, daemons: Vec::new(), }; + let setup_ctx = test_context(&detection, &paths, ActionMode::Install); + // Seed every artifact because the systemd service and D-Bus activation file form one install + for artifact in paths.service.artifacts(&paths.bin_dir) { + write_service_artifact(&setup_ctx, &artifact).expect("write current service artifact"); + } + let expected = expected_primary_artifact_contents(&paths); let mut ctx = test_context(&detection, &paths, ActionMode::Install); // Start as true so the test proves install_service actively clears stale reload state let reload_required = Arc::new(AtomicBool::new(true)); diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs index 14790ff0c..54b649958 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/uninstall_safety.rs @@ -71,6 +71,37 @@ fn uninstall_does_not_remove_non_matching_symlink() { let _ = fs::remove_dir_all(&root); } +#[test] +fn uninstall_rejects_symlinked_parent_for_symlink_artifact() { + let root = test_root("install-service-keep-linked-symlink-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_link = outside.join("service-link"); + fs::create_dir_all(&outside).expect("make outside directory"); + symlink("service", &outside_link).expect("create outside service link"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-link"), + kind: ServiceArtifactKind::Symlink { + target: "service".into(), + }, + contents: None, + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_link(&outside_link).expect("outside service link remains"), + std::path::PathBuf::from("service") + ); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} + #[test] fn uninstall_rejects_symlink_file_artifact() { let root = test_root("install-service-keep-file-symlink"); @@ -95,6 +126,35 @@ fn uninstall_rejects_symlink_file_artifact() { let _ = fs::remove_dir_all(&root); } +#[test] +fn uninstall_rejects_symlinked_parent_for_file_artifact() { + let root = test_root("install-service-keep-linked-file-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_file = outside.join("service-file"); + fs::create_dir_all(&outside).expect("make outside directory"); + fs::write(&outside_file, "service").expect("write outside service file"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-file"), + kind: ServiceArtifactKind::File, + contents: Some("service".to_string()), + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert_eq!( + fs::read_to_string(outside_file).expect("outside service file remains"), + "service" + ); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} + #[test] fn uninstall_rejects_unmarked_managed_directory() { let root = test_root("install-service-unmarked-remove"); @@ -168,7 +228,7 @@ fn uninstall_rejects_symlink_inside_managed_directory() { let err = remove_service_artifact(&artifact).expect_err("child link should be rejected"); // The full error chain carries the child-link refusal below the outer removal context - assert!(format!("{err:#}").contains("refusing symlink inside managed service directory")); + assert!(format!("{err:#}").contains("refusing unsafe entry inside directory tree")); assert_eq!( fs::read_link(&child_link).expect("child link should remain untouched"), target @@ -202,7 +262,7 @@ fn uninstall_rejects_socket_inside_managed_directory() { let err = remove_service_artifact(&artifact).expect_err("socket child should be rejected"); // The recursive remover fails closed and does not delete the containing service directory - assert!(format!("{err:#}").contains("refusing special file inside managed service directory")); + assert!(format!("{err:#}").contains("refusing unsafe entry inside directory tree")); assert!(fs::symlink_metadata(&socket_path) .expect("socket child should remain") .file_type() @@ -210,3 +270,28 @@ fn uninstall_rejects_socket_inside_managed_directory() { assert!(service_dir.exists()); let _ = fs::remove_dir_all(&root); } + +#[test] +fn uninstall_rejects_symlinked_parent_for_directory_artifact() { + let root = test_root("install-service-keep-linked-directory-parent"); + let outside = root.join("outside"); + let linked_parent = root.join("linked-parent"); + let outside_directory = outside.join("service-directory"); + fs::create_dir_all(&outside_directory).expect("make outside service directory"); + symlink(&outside, &linked_parent).expect("link service parent"); + let artifact = ServiceArtifact { + path: linked_parent.join("service-directory"), + kind: ServiceArtifactKind::Directory, + contents: None, + mode: None, + }; + + remove_service_artifact(&artifact).expect_err("linked parent should be rejected"); + + assert!(outside_directory.exists()); + assert!(fs::symlink_metadata(linked_parent) + .expect("parent link remains") + .file_type() + .is_symlink()); + let _ = fs::remove_dir_all(&root); +} diff --git a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs index abafb1cf9..0121422a8 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/service/writes.rs @@ -120,6 +120,44 @@ fn write_service_artifact_reports_executable_mode_changes() { let _ = fs::remove_dir_all(&root); } +#[test] +fn write_service_artifact_preserves_mode_when_no_mode_is_requested() { + let root = test_root("install-service-preserve-file-mode"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let artifact = ServiceArtifact { + path: root.join("service.conf"), + kind: ServiceArtifactKind::File, + contents: Some("new contents\n".to_string()), + mode: None, + }; + fs::create_dir_all(&root).expect("make service root"); + fs::write(&artifact.path, "old contents\n").expect("seed service file"); + fs::set_permissions(&artifact.path, fs::Permissions::from_mode(0o640)) + .expect("seed service file mode"); + + let changed = write_service_artifact(&ctx, &artifact).expect("service file should update"); + + assert!(changed); + assert_eq!( + fs::read_to_string(&artifact.path).expect("read updated service file"), + "new contents\n" + ); + assert_eq!( + fs::metadata(&artifact.path) + .expect("service file metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn write_managed_directory_artifact_creates_ownership_marker() { let root = test_root("install-service-managed-directory"); @@ -200,7 +238,7 @@ fn write_service_artifact_rejects_symlink_parent_component() { let err = write_service_artifact(&ctx, &artifact).expect_err("symlink parent is unsafe"); // The target directory proves the writer did not follow the linked parent - assert!(format!("{err:#}").contains("refusing symlink parent")); + assert!(format!("{err:#}").contains("refusing unsafe service directory path")); assert!(!target.join("service-file").exists()); let _ = fs::remove_dir_all(&root); } @@ -302,6 +340,68 @@ fn write_shared_service_file_refuses_to_overwrite_user_content() { let _ = fs::remove_dir_all(&root); } +#[test] +fn write_shared_service_file_preserves_an_exact_unowned_file() { + let root = test_root("install-service-shared-unowned-file"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let marker = root.join("default").join(".unixnotis-created-type"); + let artifact = ServiceArtifact { + path: root.join("default").join("type"), + kind: ServiceArtifactKind::SharedFile { + created_marker: Some(marker.clone()), + }, + contents: Some("bundle\n".to_string()), + mode: Some(0o644), + }; + fs::create_dir_all(artifact.path.parent().expect("shared file parent")) + .expect("create shared file parent"); + fs::write(&artifact.path, "bundle\n").expect("seed exact unmarked shared file"); + + let unchanged = write_service_artifact(&ctx, &artifact).expect("accept exact unowned file"); + + assert!(!unchanged); + assert!(!marker.exists()); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn write_shared_service_file_rolls_back_when_the_marker_conflicts() { + let root = test_root("install-service-shared-marker-conflict"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + let marker = root.join("default").join(".unixnotis-created-type"); + let artifact = ServiceArtifact { + path: root.join("default").join("type"), + kind: ServiceArtifactKind::SharedFile { + created_marker: Some(marker.clone()), + }, + contents: Some("bundle\n".to_string()), + mode: Some(0o644), + }; + fs::create_dir_all(marker.parent().expect("marker parent")).expect("create shared file parent"); + fs::write(&marker, "foreign\n").expect("seed foreign marker"); + + let error = write_service_artifact(&ctx, &artifact) + .expect_err("conflicting marker should reject the pair"); + + assert!(error.to_string().contains("refusing to overwrite")); + assert!(!artifact.path.exists()); + assert_eq!( + fs::read_to_string(marker).expect("read foreign marker"), + "foreign\n" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn remove_shared_service_file_only_removes_marker_owned_file() { let root = test_root("install-service-shared-file-remove"); @@ -413,7 +513,7 @@ fn remove_shared_service_file_handles_missing_and_invalid_paths_safely() { let error = remove_service_artifact(&invalid) .expect_err("filesystem errors must not look like missing shared files"); - assert!(format!("{error:#}").contains("failed to inspect")); + assert!(format!("{error:#}").contains("failed to remove")); assert!(marker.exists()); let directory_artifact = ServiceArtifact { @@ -429,9 +529,10 @@ fn remove_shared_service_file_handles_missing_and_invalid_paths_safely() { let error = remove_service_artifact(&directory_artifact) .expect_err("a directory must never be removed as a shared file"); - assert!(error - .to_string() - .contains("non-regular shared service artifact")); + assert!( + format!("{error:#}").contains("non-regular file target"), + "unexpected shared directory error: {error:#}" + ); assert!(directory_artifact.path.is_dir()); let _ = fs::remove_dir_all(&root); } @@ -574,6 +675,24 @@ fn service_symlink_helpers_distinguish_missing_paths_from_filesystem_errors() { let _ = fs::remove_dir_all(&root); } +#[test] +fn service_symlink_removal_rejects_a_regular_file_without_deleting_it() { + let root = test_root("install-service-remove-regular-link"); + let artifact = root.join("service-link"); + fs::create_dir_all(&root).expect("make service root"); + fs::write(&artifact, "user data").expect("write regular artifact"); + + let error = remove_service_symlink(&artifact, std::path::Path::new("service")) + .expect_err("regular artifacts must not be removed as links"); + + assert!(format!("{error:#}").contains("refusing to remove non-symlink")); + assert_eq!( + fs::read_to_string(&artifact).expect("read preserved artifact"), + "user data" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn install_replaces_regular_owned_artifact_but_rejects_unsafe_existing_path() { let root = test_root("install-service-owned-replace"); @@ -648,6 +767,40 @@ fn install_replaces_regular_owned_artifact_but_rejects_unsafe_existing_path() { let _ = fs::remove_dir_all(&root); } +#[test] +fn install_replaces_an_oversized_sparse_service_file_without_reading_it() { + let root = test_root("install-service-oversized-regular-file"); + let paths = test_paths(&root); + let detection = Detection { + owner: None, + daemons: Vec::new(), + }; + let ctx = test_context(&detection, &paths, ActionMode::Install); + fs::create_dir_all(&root).expect("create service root"); + let path = root.join("service-file"); + let oversized = fs::File::create(&path).expect("create sparse service file"); + oversized + .set_len(1_073_741_824) + .expect("extend sparse service file"); + drop(oversized); + let artifact = ServiceArtifact { + path: path.clone(), + kind: ServiceArtifactKind::File, + contents: Some("service\n".to_string()), + mode: None, + }; + + let changed = + write_service_artifact(&ctx, &artifact).expect("replace oversized regular service file"); + + assert!(changed); + assert_eq!( + fs::read_to_string(path).expect("read replaced service file"), + "service\n" + ); + let _ = fs::remove_dir_all(&root); +} + #[test] fn write_service_artifact_rejects_socket_artifact_path() { let root = test_root("install-service-special-file-reject"); @@ -670,7 +823,7 @@ fn write_service_artifact_rejects_socket_artifact_path() { let err = write_service_artifact(&ctx, &artifact).expect_err("socket path is unsafe"); - // The socket remains untouched and the writer fails before read_to_string can block on it + // The socket remains untouched and the writer fails before descriptor comparison can block assert!(err .to_string() .contains("cannot replace non-regular service artifact")); diff --git a/crates/unixnotis-installer/src/actions/install/tests/support.rs b/crates/unixnotis-installer/src/actions/install/tests/support.rs index 881609fcb..805790a4d 100644 --- a/crates/unixnotis-installer/src/actions/install/tests/support.rs +++ b/crates/unixnotis-installer/src/actions/install/tests/support.rs @@ -65,13 +65,12 @@ pub(super) fn write_fake_workspace(root: &std::path::Path, binaries: &[&str]) { } pub(super) fn test_context<'a>( - detection: &'a Detection, + _detection: &'a Detection, paths: &'a InstallPaths, action_mode: ActionMode, ) -> ActionContext<'a> { let (tx, _rx) = mpsc::sync_channel::(32); ActionContext { - detection, paths, install_state: None, log_tx: tx, diff --git a/crates/unixnotis-installer/src/actions/mod.rs b/crates/unixnotis-installer/src/actions/mod.rs index 3c3c4865a..53624fa7e 100644 --- a/crates/unixnotis-installer/src/actions/mod.rs +++ b/crates/unixnotis-installer/src/actions/mod.rs @@ -10,9 +10,9 @@ mod environment; mod format; mod hyprland; mod install; -mod install_state; mod plan; mod process; +mod releases; mod state; pub use build::{ @@ -20,21 +20,29 @@ pub use build::{ BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome, }; pub use context::ActionContext; +pub use daemon::ensure_selected_service_inactive; +pub use daemon::DaemonActivationReservation; pub use format::{ daemon_has_displayable_status, daemon_status_is_warning, format_daemon_status, summarize_owner, }; -pub use install_state::{check_install_state, InstallState}; -pub use plan::{build_plan, run_step, steps_from_plan, StepKind}; +pub use plan::run_step_with_reservation; +pub use plan::{build_plan, steps_from_plan, StepKind}; pub use build::run_build; pub use config::backup::{list_backup_dirs_for_ui, restore_config}; pub use config::{ensure_config, remove_state, reset_config}; pub use daemon::stop_active_daemon; -pub use environment::{ - ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment, HYPR_IMPORT_VARS, +pub use environment::{ensure_shell_path_entry, remove_shell_path_entry, sync_user_environment}; +pub use install::{ + check_install_state, enforce_service_readiness, rollback_failed_activation, InstallState, + InstallationDisposition, InstallerLock, }; +pub use install::{install_binaries, remove_binaries, uninstall_service}; pub use install::{ - enable_service, install_binaries, install_service, remove_binaries, uninstall_service, + install_service_under_reservation, prepare_service_start_under_reservation, + restart_previous_service, rollback_pending_under_activation_reservation, + start_service_and_verify, }; pub use process::{log_line, run_command, run_command_without_stdout}; +pub use releases::{commit_pending_release, pending_release_exists}; pub use state::check_install_state_step; diff --git a/crates/unixnotis-installer/src/actions/plan.rs b/crates/unixnotis-installer/src/actions/plan.rs index f1fe947d5..b9648e8ba 100644 --- a/crates/unixnotis-installer/src/actions/plan.rs +++ b/crates/unixnotis-installer/src/actions/plan.rs @@ -3,14 +3,14 @@ //! Keeps the sequencing logic in one place so install, uninstall, and reset //! flows stay predictable -use anyhow::Result; +use anyhow::{bail, Context, Result}; use crate::model::{ActionMode, ActionStep, StepStatus}; use super::{ - check_install_state_step, enable_service, ensure_config, install_binaries, install_service, + check_install_state_step, ensure_config, install_binaries, install_service_under_reservation, remove_binaries, remove_state, reset_config, restore_config, run_build, stop_active_daemon, - uninstall_service, ActionContext, + uninstall_service, ActionContext, DaemonActivationReservation, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -65,7 +65,11 @@ pub fn steps_from_plan(plan: &[StepKind]) -> Vec { .collect() } -pub fn run_step(step: StepKind, ctx: &mut ActionContext) -> Result<()> { +pub fn run_step_with_reservation( + step: StepKind, + ctx: &mut ActionContext, + reservation: Option<&DaemonActivationReservation>, +) -> Result<()> { match step { StepKind::InstallCheck => check_install_state_step(ctx), StepKind::StopDaemon => stop_active_daemon(ctx), @@ -73,9 +77,17 @@ pub fn run_step(step: StepKind, ctx: &mut ActionContext) -> Result<()> { StepKind::EnsureConfig => ensure_config(ctx), StepKind::ResetConfig => reset_config(ctx), StepKind::RestoreConfig => restore_config(ctx), - StepKind::InstallBinaries => install_binaries(ctx), - StepKind::InstallService => install_service(ctx), - StepKind::EnableService => enable_service(ctx), + StepKind::InstallBinaries => install_binaries( + ctx, + reservation.context("binary installation requires daemon activation reservation")?, + ), + StepKind::InstallService => install_service_under_reservation( + ctx, + reservation.context("service installation requires daemon activation reservation")?, + ), + StepKind::EnableService => { + bail!("EnableService must use the install lifecycle handoff") + } StepKind::UninstallService => uninstall_service(ctx), StepKind::RemoveBinaries => remove_binaries(ctx), StepKind::RemoveState => remove_state(ctx), diff --git a/crates/unixnotis-installer/src/actions/process.rs b/crates/unixnotis-installer/src/actions/process.rs index 05370461a..c34879d3e 100644 --- a/crates/unixnotis-installer/src/actions/process.rs +++ b/crates/unixnotis-installer/src/actions/process.rs @@ -81,13 +81,13 @@ fn run_command_with_output( let stdout_handle = stdout.map(|stream| { let tx = log_tx.clone(); let label = label_string.clone(); - thread::spawn(move || read_stream(stream, tx, label, "stdout")) + thread::spawn(move || read_stream(stream, &tx, &label, "stdout")) }); let stderr_handle = stderr.map(|stream| { let tx = log_tx.clone(); let label = label_string.clone(); - thread::spawn(move || read_stream(stream, tx, label, "stderr")) + thread::spawn(move || read_stream(stream, &tx, &label, "stderr")) }); let status = child @@ -114,7 +114,8 @@ fn run_command_with_output( } pub fn log_line(ctx: &mut ActionContext, line: impl Into) { - send_log_line(&ctx.log_tx, line.into()); + let line = line.into(); + send_log_line(&ctx.log_tx, &line); } fn sanitize_log_line(line: &str) -> String { @@ -134,8 +135,8 @@ fn sanitize_log_line_with_source_truncation(line: &str, source_truncated: bool) fn read_stream( stream: impl std::io::Read, - tx: SyncSender, - label: String, + tx: &SyncSender, + label: &str, stream_name: &str, ) { let mut reader = BufReader::new(stream); @@ -146,13 +147,13 @@ fn read_stream( Ok(Some(source_truncated)) => { // Invalid subprocess bytes are replaced only after the retained input is bounded let line = String::from_utf8_lossy(&line); - send_log_line_with_source_truncation(&tx, &line, source_truncated); + send_log_line_with_source_truncation(tx, &line, source_truncated); } Ok(None) => break, Err(err) => { send_log_line( - &tx, - format!("Warning: log stream error for {label} ({stream_name}): {err}"), + tx, + &format!("Warning: log stream error for {label} ({stream_name}): {err}"), ); break; } @@ -202,8 +203,8 @@ fn read_bounded_log_line( } } -fn send_log_line(tx: &SyncSender, line: String) { - let line = sanitize_log_line(&line); +fn send_log_line(tx: &SyncSender, line: &str) { + let line = sanitize_log_line(line); send_sanitized_log_line(tx, line); } diff --git a/crates/unixnotis-installer/src/actions/releases/entrypoints.rs b/crates/unixnotis-installer/src/actions/releases/entrypoints.rs new file mode 100644 index 000000000..d70b1a7b9 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/entrypoints.rs @@ -0,0 +1,259 @@ +//! Binary entrypoint planning, publication, and crash recovery + +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, create_symlink_if_missing, read_symlink, remove_directory_tree, + remove_symlink_if_target, rename_regular_file_no_replace, CreateSymlinkOutcome, + RemoveSymlinkOutcome, RenameRegularFileOutcome, +}; + +use crate::paths::InstallPaths; + +use super::manifest::entrypoint_target; +use super::transaction::{verify_release_target, PendingRelease, PENDING_RELEASE_SCHEMA_VERSION}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EntrypointState { + Missing, + Regular, + ManagedSymlink, +} + +pub(in crate::actions::releases) fn plan_entrypoint_changes( + paths: &InstallPaths, + binaries: &[String], + generation: &str, +) -> Result { + // Planning inspects live entrypoints but never changes them + let link_root = entrypoint_target(); + let previous_current = read_symlink(&paths.installed_current_link()?)?; + if let Some(previous) = previous_current.as_ref() { + verify_release_target(paths, previous) + .context("verify current generation before retaining it for rollback")?; + } + let (legacy_entrypoints, created_entrypoints) = + classify_entrypoint_changes(paths, binaries, &link_root)?; + + Ok(PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: generation.to_string(), + new_current: PathBuf::from("releases").join(generation), + previous_current, + legacy_entrypoints, + created_entrypoints, + }) +} + +pub(in crate::actions::releases) fn apply_entrypoint_changes( + paths: &InstallPaths, + pending: &PendingRelease, +) -> Result<()> { + // The durable journal must exist before this function is called + create_directory_all(&paths.bin_dir, 0o755).context("create binary entrypoint directory")?; + let rollback_dir = rollback_bin_dir(paths, pending)?; + create_directory_all(&rollback_dir, 0o700).context("create binary rollback directory")?; + + // Legacy files move to the generation-scoped rollback area before links appear + for name in &pending.legacy_entrypoints { + let entry = paths.bin_dir.join(name); + let backup = rollback_dir.join(name); + match rename_regular_file_no_replace(&entry, &backup)? { + RenameRegularFileOutcome::Renamed => {} + RenameRegularFileOutcome::SourceMissing => { + return Err(anyhow!( + "binary entrypoint disappeared before migration: {name}" + )); + } + RenameRegularFileOutcome::DestinationExists => { + return Err(anyhow!( + "binary rollback entry already exists: {}", + backup.display() + )); + } + } + } + + let expected_root = entrypoint_target(); + // Every public binary name resolves through the one current-generation switch + for name in pending + .legacy_entrypoints + .iter() + .chain(&pending.created_entrypoints) + { + let entry = paths.bin_dir.join(name); + let expected = expected_root.join(name); + match create_symlink_if_missing(&entry, &expected)? { + CreateSymlinkOutcome::Created | CreateSymlinkOutcome::Unchanged => {} + CreateSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "binary entrypoint changed to unmanaged target {}", + actual.display() + )); + } + } + } + Ok(()) +} + +pub(in crate::actions::releases) fn rollback_entrypoint_changes( + paths: &InstallPaths, + pending: &PendingRelease, +) -> Result<()> { + let expected_root = entrypoint_target(); + let rollback_dir = rollback_bin_dir(paths, pending)?; + + // Journal state permits recovery before, during, or after each legacy move + for name in &pending.legacy_entrypoints { + rollback_legacy_entrypoint( + &paths.bin_dir.join(name), + &rollback_dir.join(name), + &expected_root.join(name), + name, + )?; + } + // Newly created links contain no legacy bytes and can be removed directly + for name in &pending.created_entrypoints { + rollback_created_entrypoint(&paths.bin_dir.join(name), &expected_root.join(name), name)?; + } + + let rollback_generation = paths.installed_rollback_root()?.join(&pending.generation); + if rollback_generation.exists() { + remove_directory_tree(&rollback_generation).context("remove completed rollback data")?; + } + Ok(()) +} + +fn classify_entrypoint_changes( + paths: &InstallPaths, + binaries: &[String], + expected_root: &Path, +) -> Result<(Vec, Vec)> { + let mut legacy = Vec::new(); + let mut created = Vec::new(); + // Managed links need no per-entrypoint rollback record + for name in binaries { + match inspect_entrypoint(&paths.bin_dir.join(name), &expected_root.join(name))? { + EntrypointState::Missing => created.push(name.clone()), + EntrypointState::Regular => legacy.push(name.clone()), + EntrypointState::ManagedSymlink => {} + } + } + Ok((legacy, created)) +} + +fn rollback_legacy_entrypoint( + entry: &Path, + backup: &Path, + expected: &Path, + name: &str, +) -> Result<()> { + let entry_state = inspect_entrypoint(entry, expected)?; + let backup_state = inspect_backup(backup)?; + match (entry_state, backup_state) { + // The move never started or a prior recovery already restored it + (EntrypointState::Regular, EntrypointState::Missing) => Ok(()), + // The move completed but link creation did not + (EntrypointState::Missing, EntrypointState::Regular) => restore_backup(backup, entry, name), + // Both the move and managed-link publication completed + (EntrypointState::ManagedSymlink, EntrypointState::Regular) => { + remove_expected_entrypoint(entry, expected)?; + restore_backup(backup, entry, name) + } + (EntrypointState::Regular, EntrypointState::Regular) => Err(anyhow!( + "binary rollback has both live and backup files: {name}" + )), + (EntrypointState::Missing, EntrypointState::Missing) => { + Err(anyhow!("binary rollback lost both copies: {name}")) + } + (EntrypointState::ManagedSymlink, EntrypointState::Missing) => Err(anyhow!( + "binary rollback source is missing behind managed entrypoint: {name}" + )), + (_, EntrypointState::ManagedSymlink) => Err(anyhow!( + "binary rollback copy is an unexpected symbolic link: {name}" + )), + } +} + +fn rollback_created_entrypoint(entry: &Path, expected: &Path, name: &str) -> Result<()> { + match inspect_entrypoint(entry, expected)? { + // Link creation never started or a prior recovery already removed it + EntrypointState::Missing => Ok(()), + EntrypointState::ManagedSymlink => remove_expected_entrypoint(entry, expected), + EntrypointState::Regular => Err(anyhow!( + "new binary entrypoint changed to a regular file during rollback: {name}" + )), + } +} + +fn inspect_entrypoint(path: &Path, expected: &Path) -> Result { + // Link metadata keeps classification on the entrypoint itself + match fs::symlink_metadata(path) { + Err(error) if error.kind() == ErrorKind::NotFound => Ok(EntrypointState::Missing), + Ok(metadata) if metadata.file_type().is_file() => Ok(EntrypointState::Regular), + Ok(metadata) if metadata.file_type().is_symlink() => { + let actual = fs::read_link(path) + .with_context(|| format!("inspect binary entrypoint {}", path.display()))?; + if actual == expected { + Ok(EntrypointState::ManagedSymlink) + } else { + Err(anyhow!( + "binary entrypoint {} points to an unmanaged target {}", + path.display(), + actual.display() + )) + } + } + Ok(_metadata) => Err(anyhow!( + "binary entrypoint is not a regular file or managed link: {}", + path.display() + )), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn inspect_backup(path: &Path) -> Result { + // Backups must remain regular files and never redirect recovery + match fs::symlink_metadata(path) { + Err(error) if error.kind() == ErrorKind::NotFound => Ok(EntrypointState::Missing), + Ok(metadata) if metadata.file_type().is_file() => Ok(EntrypointState::Regular), + Ok(metadata) if metadata.file_type().is_symlink() => Ok(EntrypointState::ManagedSymlink), + Ok(_metadata) => Err(anyhow!( + "binary rollback copy is not a regular file: {}", + path.display() + )), + Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), + } +} + +fn restore_backup(backup: &Path, entry: &Path, name: &str) -> Result<()> { + match rename_regular_file_no_replace(backup, entry)? { + RenameRegularFileOutcome::Renamed => Ok(()), + RenameRegularFileOutcome::SourceMissing => { + Err(anyhow!("binary rollback source disappeared: {name}")) + } + RenameRegularFileOutcome::DestinationExists => { + Err(anyhow!("binary rollback destination changed: {name}")) + } + } +} + +fn remove_expected_entrypoint(entry: &Path, expected: &Path) -> Result<()> { + match remove_symlink_if_target(entry, expected)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => Ok(()), + RemoveSymlinkOutcome::TargetMismatch(actual) => Err(anyhow!( + "binary entrypoint changed during rollback to {}", + actual.display() + )), + } +} + +fn rollback_bin_dir(paths: &InstallPaths, pending: &PendingRelease) -> Result { + Ok(paths + .installed_rollback_root()? + .join(&pending.generation) + .join("bin")) +} diff --git a/crates/unixnotis-installer/src/actions/releases/manifest.rs b/crates/unixnotis-installer/src/actions/releases/manifest.rs new file mode 100644 index 000000000..1a5b39d19 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/manifest.rs @@ -0,0 +1,376 @@ +//! Release manifest construction and installed generation verification + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use unixnotis_core::filesystem::{open_regular_file, read_regular_file_bounded}; + +use crate::managed_binaries::is_managed_binary_name; +use crate::paths::InstallPaths; + +pub(super) const INSTALLED_MANIFEST_FILE: &str = "manifest.json"; +const INSTALLED_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub(in crate::actions::releases) const MAX_INSTALLED_MANIFEST_BYTES: u64 = 256 * 1024; +pub(in crate::actions::releases) const HASH_BUFFER_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct InstalledReleaseManifest { + pub(super) schema_version: u32, + pub(super) package_version: String, + pub(super) build_id: String, + pub(super) binaries: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(super) struct BinaryManifest { + pub(super) size: u64, + pub(super) sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::actions) enum BinaryHealth { + Missing, + Healthy { + generation: String, + package_version: String, + digest: String, + }, + WrongType, + NotExecutable, + BrokenLink, + WrongGeneration, + HashMismatch, + Unsafe(String), +} + +impl BinaryHealth { + pub(in crate::actions) const fn label(&self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Healthy { .. } => "healthy", + Self::WrongType => "wrong type", + Self::NotExecutable => "not executable", + Self::BrokenLink => "broken link", + Self::WrongGeneration => "wrong generation", + Self::HashMismatch => "hash mismatch", + Self::Unsafe(_) => "unsafe", + } + } +} + +pub(super) fn build_manifest(sources: &[(String, PathBuf)]) -> Result { + let mut binaries = BTreeMap::new(); + for (name, source) in sources { + // One no-follow descriptor ties size, mode, and digest to the same source object + let mut file = + open_regular_file(source).with_context(|| format!("open build artifact {name}"))?; + let metadata = file + .metadata() + .with_context(|| format!("inspect build artifact {name}"))?; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(anyhow!("build artifact is not executable: {name}")); + } + binaries.insert( + name.clone(), + BinaryManifest { + size: metadata.len(), + sha256: sha256_open_file(&mut file, source) + .with_context(|| format!("hash build artifact {name}"))?, + }, + ); + } + let package_version = env!("CARGO_PKG_VERSION").to_string(); + let build_id = release_build_id(&package_version, &binaries); + Ok(InstalledReleaseManifest { + schema_version: INSTALLED_MANIFEST_SCHEMA_VERSION, + package_version, + build_id, + binaries, + }) +} + +pub(super) fn verify_release_directory( + release_dir: &Path, + expected: &InstalledReleaseManifest, +) -> Result<()> { + let stored = read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE))?; + if &stored != expected { + return Err(anyhow!( + "installed release manifest does not match staged generation" + )); + } + for (name, binary) in &stored.binaries { + let path = release_dir.join("bin").join(name); + let mut file = open_regular_file(&path) + .with_context(|| format!("open installed release binary {name}"))?; + let metadata = file + .metadata() + .with_context(|| format!("inspect installed release binary {name}"))?; + if metadata.len() != binary.size { + return Err(anyhow!( + "installed release binary shape or size mismatch: {name}" + )); + } + if metadata.permissions().mode() & 0o111 == 0 { + return Err(anyhow!( + "installed release binary is not executable: {name}" + )); + } + if sha256_open_file(&mut file, &path)? != binary.sha256 { + return Err(anyhow!("installed release binary digest mismatch: {name}")); + } + } + Ok(()) +} + +pub(in crate::actions) fn inspect_installed_generation( + paths: &InstallPaths, + binaries: &[String], +) -> Vec<(String, BinaryHealth)> { + let current = match paths.installed_current_link() { + Ok(current) => current, + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + let current_target = match std::fs::read_link(¤t) { + Ok(target) => target, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return binaries + .iter() + .cloned() + .map(|name| { + let entry = paths.bin_dir.join(&name); + let health = classify_missing_generation_entry(&entry); + (name, health) + }) + .collect() + } + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + if !is_release_target(¤t_target) { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + } + let release_dir = paths + .installed_release_root() + .map(|root| root.join(¤t_target)); + let Ok(release_dir) = release_dir else { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + }; + let manifest = match read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE)) { + Ok(manifest) => manifest, + Err(error) => { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::Unsafe(error.to_string()))) + .collect() + } + }; + let expected_generation = format!("{}-{}", manifest.package_version, manifest.build_id); + // The current link names the same generation proven by the content manifest digest + if current_target.file_name().and_then(|name| name.to_str()) + != Some(expected_generation.as_str()) + { + return binaries + .iter() + .cloned() + .map(|name| (name, BinaryHealth::WrongGeneration)) + .collect(); + } + let generation = manifest.build_id.clone(); + let entry_target = entrypoint_target(); + + binaries + .iter() + .map(|name| { + let entry = paths.bin_dir.join(name); + let health = inspect_binary_entry( + &entry, + &entry_target.join(name), + &release_dir, + &manifest, + name, + &generation, + ); + (name.clone(), health) + }) + .collect() +} + +fn is_release_target(target: &Path) -> bool { + let mut components = target.components(); + matches!( + (components.next(), components.next(), components.next()), + ( + Some(std::path::Component::Normal(root)), + Some(std::path::Component::Normal(_generation)), + None + ) if root == "releases" + ) +} + +fn classify_missing_generation_entry(entry: &Path) -> BinaryHealth { + match std::fs::symlink_metadata(entry) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BinaryHealth::Missing, + Ok(metadata) if metadata.file_type().is_symlink() => BinaryHealth::BrokenLink, + Ok(_metadata) => BinaryHealth::WrongGeneration, + Err(error) => BinaryHealth::Unsafe(error.to_string()), + } +} + +fn inspect_binary_entry( + entry: &Path, + expected_link: &Path, + release_dir: &Path, + manifest: &InstalledReleaseManifest, + name: &str, + generation: &str, +) -> BinaryHealth { + let metadata = match std::fs::symlink_metadata(entry) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return BinaryHealth::Missing, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + if !metadata.file_type().is_symlink() { + return BinaryHealth::WrongType; + } + match std::fs::read_link(entry) { + Ok(target) if target == expected_link => {} + Ok(_target) => return BinaryHealth::WrongGeneration, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + } + let Some(expected) = manifest.binaries.get(name) else { + return BinaryHealth::WrongGeneration; + }; + let binary = release_dir.join("bin").join(name); + // The retained descriptor keeps health metadata and hashing on one exact object + let mut file = match open_regular_file(&binary) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return BinaryHealth::BrokenLink + } + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + let metadata = match file.metadata() { + Ok(metadata) => metadata, + Err(error) => return BinaryHealth::Unsafe(error.to_string()), + }; + if metadata.len() != expected.size { + return BinaryHealth::WrongType; + } + if metadata.permissions().mode() & 0o111 == 0 { + return BinaryHealth::NotExecutable; + } + match sha256_open_file(&mut file, &binary) { + Ok(digest) if digest == expected.sha256 => BinaryHealth::Healthy { + generation: generation.to_string(), + package_version: manifest.package_version.clone(), + digest, + }, + Ok(_digest) => BinaryHealth::HashMismatch, + Err(error) => BinaryHealth::Unsafe(error.to_string()), + } +} + +pub(super) fn read_manifest(path: &Path) -> Result { + let bytes = read_regular_file_bounded(path, MAX_INSTALLED_MANIFEST_BYTES) + .with_context(|| format!("read installed release manifest {}", path.display()))?; + let manifest: InstalledReleaseManifest = + serde_json::from_slice(&bytes).with_context(|| "parse installed release manifest")?; + if manifest.schema_version != INSTALLED_MANIFEST_SCHEMA_VERSION { + return Err(anyhow!( + "unsupported installed release manifest schema {}", + manifest.schema_version + )); + } + if manifest.binaries.is_empty() + || manifest + .binaries + .keys() + .any(|name| !is_managed_binary_name(name)) + { + return Err(anyhow!( + "installed release manifest contains unmanaged binary names" + )); + } + let expected_build_id = release_build_id(&manifest.package_version, &manifest.binaries); + if manifest.build_id != expected_build_id { + return Err(anyhow!( + "installed release manifest build identity is inconsistent" + )); + } + Ok(manifest) +} + +pub(super) fn manifest_bytes(manifest: &InstalledReleaseManifest) -> Result> { + serde_json::to_vec_pretty(manifest).with_context(|| "serialize installed release manifest") +} + +pub(in crate::actions) fn entrypoint_target() -> PathBuf { + PathBuf::from("..") + .join("lib") + .join("unixnotis") + .join("current") + .join("bin") +} + +fn release_build_id(package_version: &str, binaries: &BTreeMap) -> String { + let mut digest = Sha256::new(); + digest.update(package_version.as_bytes()); + for (name, binary) in binaries { + digest.update(name.as_bytes()); + digest.update(binary.size.to_le_bytes()); + digest.update(binary.sha256.as_bytes()); + } + format_digest(&digest.finalize()) +} + +fn sha256_open_file(file: &mut File, path: &Path) -> Result { + let mut digest = Sha256::new(); + let mut buffer = vec![0u8; HASH_BUFFER_BYTES].into_boxed_slice(); + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("read {}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format_digest(&digest.finalize())) +} + +fn format_digest(bytes: &[u8]) -> String { + use std::fmt::Write; + + let mut output = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); + } + output +} diff --git a/crates/unixnotis-installer/src/actions/releases/mod.rs b/crates/unixnotis-installer/src/actions/releases/mod.rs new file mode 100644 index 000000000..bb7c48626 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/mod.rs @@ -0,0 +1,17 @@ +//! Versioned release generation installation and recovery + +mod entrypoints; +mod manifest; +mod transaction; + +pub(in crate::actions) use manifest::{ + entrypoint_target, inspect_installed_generation, BinaryHealth, +}; +pub use transaction::rollback_pending_release; +pub use transaction::{ + commit_pending_release, install_release_generation_transaction, pending_release_exists, + pending_release_has_runtime_rollback, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs b/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs new file mode 100644 index 000000000..6d631b0f1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/entrypoints.rs @@ -0,0 +1,98 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::rollback_entrypoint_changes; +use super::super::transaction::{PendingRelease, PENDING_RELEASE_SCHEMA_VERSION}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +const BINARY: &str = "unixnotis-daemon"; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn pending() -> PendingRelease { + PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: "test-generation".to_string(), + new_current: PathBuf::from("releases/test-generation"), + previous_current: None, + legacy_entrypoints: vec![BINARY.to_string()], + created_entrypoints: Vec::new(), + } +} + +#[test] +fn rollback_rejects_a_symbolic_link_instead_of_a_regular_backup() { + let root = crate::test_support::fs::unique_temp_path("release-backup-link"); + let paths = paths(&root); + let pending = pending(); + let backup = rollback_backup(&paths, &pending); + fs::create_dir_all(backup.parent().expect("backup parent")).expect("create backup parent"); + symlink("unexpected", &backup).expect("create invalid backup link"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("a symbolic-link backup must fail closed"); + + assert!(error.to_string().contains("unexpected symbolic link")); + fs::remove_dir_all(root).expect("remove backup link fixture"); +} + +#[test] +fn rollback_rejects_a_directory_instead_of_a_regular_backup() { + let root = crate::test_support::fs::unique_temp_path("release-backup-directory"); + let paths = paths(&root); + let pending = pending(); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::write(paths.bin_dir.join(BINARY), "legacy").expect("write live legacy binary"); + let backup = rollback_backup(&paths, &pending); + fs::create_dir_all(&backup).expect("create invalid backup directory"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("a directory backup must fail closed"); + + assert!(error.to_string().contains("not a regular file")); + fs::remove_dir_all(root).expect("remove backup directory fixture"); +} + +#[test] +fn rollback_propagates_backup_lookup_errors_instead_of_treating_them_as_missing() { + let root = crate::test_support::fs::unique_temp_path("release-backup-lookup-error"); + let paths = paths(&root); + let pending = pending(); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::write(paths.bin_dir.join(BINARY), "legacy").expect("write live legacy binary"); + let rollback_generation = paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation); + fs::create_dir_all(rollback_generation.parent().expect("rollback parent")) + .expect("create rollback parent"); + fs::write(&rollback_generation, "not a directory").expect("write invalid rollback object"); + + let error = rollback_entrypoint_changes(&paths, &pending) + .expect_err("backup lookup errors must remain errors"); + + assert!(error.to_string().contains("inspect")); + fs::remove_dir_all(root).expect("remove backup lookup fixture"); +} + +fn rollback_backup(paths: &InstallPaths, pending: &PendingRelease) -> PathBuf { + paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation) + .join("bin") + .join(BINARY) +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/health.rs b/crates/unixnotis-installer/src/actions/releases/tests/health.rs new file mode 100644 index 000000000..bc8041d69 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/health.rs @@ -0,0 +1,260 @@ +use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use super::super::manifest::{entrypoint_target, inspect_installed_generation, BinaryHealth}; +use super::super::transaction::commit_pending_release; +use super::install_release_generation; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn install_one_binary(label: &str) -> (PathBuf, InstallPaths, String, PathBuf) { + let root = crate::test_support::fs::unique_temp_path(label); + let source = root.join("source"); + fs::create_dir_all(&source).expect("create release source"); + let name = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&name), "healthy payload"); + let paths = paths(&root); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&name), + || Ok(()), + || Ok(()), + ) + .expect("install health fixture"); + commit_pending_release(&paths).expect("commit health fixture"); + let binary = paths + .installed_releases_dir() + .expect("releases directory") + .join(&generation) + .join("bin") + .join(&name); + (root, paths, name, binary) +} + +fn health_for(paths: &InstallPaths, name: &str) -> BinaryHealth { + inspect_installed_generation(paths, &[name.to_string()]) + .into_iter() + .next() + .expect("one binary health result") + .1 +} + +#[test] +fn installed_generation_health_distinguishes_every_binary_failure_class() { + let (root, paths, name, binary) = install_one_binary("release-health-classes"); + let entry = paths.bin_dir.join(&name); + let expected_entry = entrypoint_target().join(&name); + let original = fs::read(&binary).expect("read original release binary"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Healthy { .. } + )); + + fs::remove_file(&entry).expect("remove managed entrypoint"); + assert_eq!(health_for(&paths, &name), BinaryHealth::Missing); + symlink(&expected_entry, &entry).expect("restore managed entrypoint"); + + fs::remove_file(&entry).expect("remove managed entrypoint"); + fs::write(&entry, "legacy file").expect("write wrong entrypoint type"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongType); + fs::remove_file(&entry).expect("remove wrong entrypoint type"); + symlink(Path::new("unmanaged-target"), &entry).expect("write wrong entrypoint link"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongGeneration); + fs::remove_file(&entry).expect("remove wrong entrypoint link"); + symlink(&expected_entry, &entry).expect("restore managed entrypoint"); + + fs::set_permissions(&binary, fs::Permissions::from_mode(0o644)) + .expect("remove executable bits"); + assert_eq!(health_for(&paths, &name), BinaryHealth::NotExecutable); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)) + .expect("restore executable bits"); + + fs::write(&binary, vec![b'x'; original.len()]).expect("write same-size changed binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::HashMismatch); + fs::write(&binary, &original[..original.len() - 1]).expect("write truncated binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongType); + + fs::remove_file(&binary).expect("remove release binary"); + assert_eq!(health_for(&paths, &name), BinaryHealth::BrokenLink); + fs::create_dir(&binary).expect("create unsafe release binary object"); + assert!(matches!(health_for(&paths, &name), BinaryHealth::Unsafe(_))); + + fs::remove_dir_all(root).expect("remove release health fixture"); +} + +#[test] +fn missing_generation_classifies_missing_broken_and_legacy_entrypoints() { + let root = crate::test_support::fs::unique_temp_path("release-health-no-current"); + let paths = paths(&root); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + symlink("missing-target", paths.bin_dir.join("broken")).expect("create broken entrypoint"); + fs::write(paths.bin_dir.join("legacy"), "legacy binary").expect("create legacy entrypoint"); + + let health = inspect_installed_generation( + &paths, + &[ + "missing".to_string(), + "broken".to_string(), + "legacy".to_string(), + ], + ); + + assert_eq!(health[0].1, BinaryHealth::Missing); + assert_eq!(health[1].1, BinaryHealth::BrokenLink); + assert_eq!(health[2].1, BinaryHealth::WrongGeneration); + fs::remove_dir_all(root).expect("remove missing generation fixture"); +} + +#[test] +fn invalid_current_release_objects_never_count_as_an_installed_generation() { + let regular_root = crate::test_support::fs::unique_temp_path("release-health-current-file"); + let regular_paths = paths(®ular_root); + let current = regular_paths + .installed_current_link() + .expect("current link path"); + fs::create_dir_all(current.parent().expect("current parent")).expect("create install root"); + fs::write(¤t, "not a link").expect("create wrong current object"); + assert!(matches!( + health_for(®ular_paths, "unixnotis-daemon"), + BinaryHealth::Unsafe(_) + )); + + let foreign_root = crate::test_support::fs::unique_temp_path("release-health-foreign-link"); + let foreign_paths = paths(&foreign_root); + let current = foreign_paths + .installed_current_link() + .expect("current link path"); + fs::create_dir_all(current.parent().expect("current parent")).expect("create install root"); + symlink(Path::new("foreign").join("generation"), ¤t) + .expect("create foreign current link"); + assert_eq!( + health_for(&foreign_paths, "unixnotis-daemon"), + BinaryHealth::WrongGeneration + ); + + fs::remove_dir_all(regular_root).expect("remove current file fixture"); + fs::remove_dir_all(foreign_root).expect("remove foreign current fixture"); +} + +#[test] +fn entrypoint_lookup_errors_remain_unsafe_with_and_without_a_current_generation() { + let missing_root = crate::test_support::fs::unique_temp_path("release-health-entry-error"); + let missing_paths = paths(&missing_root); + fs::create_dir_all(missing_paths.bin_dir.parent().expect("entrypoint parent")) + .expect("create entrypoint parent"); + fs::write(&missing_paths.bin_dir, "not a directory").expect("create invalid entrypoint root"); + assert!(matches!( + health_for(&missing_paths, "unixnotis-daemon"), + BinaryHealth::Unsafe(_) + )); + + let (installed_root, installed_paths, name, _binary) = + install_one_binary("release-health-installed-entry-error"); + fs::remove_file(installed_paths.bin_dir.join(&name)).expect("remove managed entrypoint"); + fs::remove_dir(&installed_paths.bin_dir).expect("remove entrypoint directory"); + fs::write(&installed_paths.bin_dir, "not a directory") + .expect("create invalid installed entrypoint root"); + assert!(matches!( + health_for(&installed_paths, &name), + BinaryHealth::Unsafe(_) + )); + + fs::remove_dir_all(missing_root).expect("remove missing entrypoint error fixture"); + fs::remove_dir_all(installed_root).expect("remove installed entrypoint error fixture"); +} + +#[test] +fn installed_generation_recomputes_manifest_build_identity() { + let (root, paths, name, binary) = install_one_binary("release-health-build-identity"); + let manifest_path = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory") + .join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read installed manifest")) + .expect("parse installed manifest"); + manifest["build_id"] = serde_json::Value::String("forged-build-id".to_string()); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize changed manifest"), + ) + .expect("write changed manifest"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Unsafe(detail) if detail.contains("build identity is inconsistent") + )); + fs::remove_dir_all(root).expect("remove build identity fixture"); +} + +#[test] +fn installed_generation_requires_the_current_directory_to_match_its_manifest_identity() { + let (root, paths, name, binary) = install_one_binary("release-health-directory-identity"); + let generation_dir = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory"); + let renamed = generation_dir + .parent() + .expect("release generations parent") + .join("renamed-generation"); + fs::rename(generation_dir, &renamed).expect("rename generation away from manifest identity"); + let current = paths + .installed_current_link() + .expect("current generation link"); + fs::remove_file(¤t).expect("remove prior current link"); + symlink(Path::new("releases").join("renamed-generation"), ¤t) + .expect("point current at renamed generation"); + + assert_eq!(health_for(&paths, &name), BinaryHealth::WrongGeneration); + fs::remove_dir_all(root).expect("remove directory identity fixture"); +} + +#[test] +fn installed_generation_rejects_unmanaged_manifest_binary_names() { + let (root, paths, name, binary) = install_one_binary("release-health-managed-names"); + let manifest_path = binary + .parent() + .and_then(Path::parent) + .expect("release generation directory") + .join("manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).expect("read installed manifest")) + .expect("parse installed manifest"); + let existing = manifest["binaries"][&name].clone(); + manifest["binaries"]["../outside"] = existing; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize changed manifest"), + ) + .expect("write changed manifest"); + + assert!(matches!( + health_for(&paths, &name), + BinaryHealth::Unsafe(detail) if detail.contains("unmanaged binary names") + )); + fs::remove_dir_all(root).expect("remove managed-name fixture"); +} + +fn write_test_binary(path: &Path, contents: &str) { + fs::write(path, contents).expect("write release test binary"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/journal.rs b/crates/unixnotis-installer/src/actions/releases/tests/journal.rs new file mode 100644 index 000000000..df9425ef0 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/journal.rs @@ -0,0 +1,167 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::plan_entrypoint_changes; +use super::super::transaction::{ + is_managed_current_target, pending_release_has_runtime_rollback, read_pending, + validate_pending_targets, write_pending, PendingRelease, MAX_PENDING_MANIFEST_BYTES, + PENDING_RELEASE_SCHEMA_VERSION, +}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +fn pending(new_current: &str, previous_current: Option<&str>) -> PendingRelease { + PendingRelease { + schema_version: PENDING_RELEASE_SCHEMA_VERSION, + generation: Path::new(new_current) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("missing") + .to_string(), + new_current: PathBuf::from(new_current), + previous_current: previous_current.map(PathBuf::from), + legacy_entrypoints: Vec::new(), + created_entrypoints: Vec::new(), + } +} + +#[test] +fn pending_release_journal_keeps_its_declared_byte_limit() { + assert_eq!(MAX_PENDING_MANIFEST_BYTES, 262_144); +} + +#[test] +fn pending_journal_rejects_obsolete_recovery_semantics_before_publication() { + let root = crate::test_support::fs::unique_temp_path("release-journal-schema"); + let journal = root.join("pending-install.json"); + fs::create_dir_all(&root).expect("create journal schema fixture"); + let mut obsolete = pending("releases/new", None); + obsolete.schema_version = PENDING_RELEASE_SCHEMA_VERSION.saturating_sub(1); + + let error = write_pending(&journal, &obsolete) + .expect_err("obsolete entrypoint recovery semantics must fail closed"); + + assert!(error + .to_string() + .contains("unsupported pending release schema")); + assert!(fs::symlink_metadata(&journal).is_err()); + fs::remove_dir_all(root).expect("remove journal schema fixture"); +} + +#[test] +fn managed_current_targets_require_exactly_one_release_generation_component() { + assert!(is_managed_current_target(Path::new("releases/generation"))); + for target in [ + "generation", + "foreign/generation", + "releases", + "releases/generation/extra", + "/releases/generation", + ] { + assert!( + !is_managed_current_target(Path::new(target)), + "unmanaged target was accepted: {target}" + ); + } +} + +#[test] +fn pending_journal_rejects_an_unmanaged_new_or_previous_target() { + assert!(validate_pending_targets(&pending("foreign/new", None)).is_err()); + assert!(validate_pending_targets(&pending("releases/new", Some("foreign/previous"))).is_err()); + assert!(validate_pending_targets(&pending("releases/new", Some("releases/previous"))).is_ok()); + + let mut inconsistent = pending("releases/new", None); + inconsistent.generation = "different-generation".to_string(); + assert!(validate_pending_targets(&inconsistent).is_err()); + + let mut unmanaged_binary = pending("releases/new", None); + unmanaged_binary + .legacy_entrypoints + .push("../outside".to_string()); + assert!(validate_pending_targets(&unmanaged_binary).is_err()); + + let mut duplicate_binary = pending("releases/new", None); + duplicate_binary + .legacy_entrypoints + .push("unixnotis-daemon".to_string()); + duplicate_binary + .created_entrypoints + .push("unixnotis-daemon".to_string()); + assert!(validate_pending_targets(&duplicate_binary).is_err()); +} + +#[test] +fn pending_runtime_rollback_state_distinguishes_fresh_and_prior_installs() { + let root = crate::test_support::fs::unique_temp_path("release-journal-runtime-rollback"); + let paths = paths(&root); + let journal = paths + .installed_pending_manifest() + .expect("pending journal path"); + fs::create_dir_all(journal.parent().expect("journal parent")).expect("create journal parent"); + + write_pending(&journal, &pending("releases/new", None)).expect("write fresh journal"); + assert!( + !pending_release_has_runtime_rollback(&paths).expect("inspect fresh journal"), + "a fresh install has no prior runtime to restart" + ); + + write_pending( + &journal, + &pending("releases/new", Some("releases/previous")), + ) + .expect("write upgrade journal"); + assert!( + pending_release_has_runtime_rollback(&paths).expect("inspect upgrade journal"), + "an upgrade must retain prior runtime recovery" + ); + fs::remove_dir_all(root).expect("remove runtime rollback fixture"); +} + +#[test] +fn pending_journal_inspection_propagates_non_missing_filesystem_errors() { + let root = crate::test_support::fs::unique_temp_path("release-journal-read-error"); + fs::create_dir_all(&root).expect("create journal fixture"); + let journal = root.join("pending-install.json"); + fs::create_dir(&journal).expect("create invalid journal directory"); + + assert!( + read_pending(&journal).is_err(), + "an invalid journal object must not become an absent transaction" + ); + fs::remove_dir_all(root).expect("remove journal error fixture"); +} + +#[test] +fn entrypoint_preparation_rejects_special_objects_and_inspection_errors() { + let root = crate::test_support::fs::unique_temp_path("release-entrypoint-invalid"); + let paths = paths(&root); + fs::create_dir_all(&paths.bin_dir).expect("create entrypoint directory"); + fs::create_dir(paths.bin_dir.join("directory-entry")).expect("create invalid entrypoint"); + + let special_error = + plan_entrypoint_changes(&paths, &["directory-entry".to_string()], "test-generation") + .expect_err("directory entrypoint must fail closed"); + assert!(special_error + .to_string() + .contains("not a regular file or managed link")); + + let oversized_name = "x".repeat(4_096); + let inspection_error = plan_entrypoint_changes(&paths, &[oversized_name], "test-generation") + .expect_err("an entrypoint inspection error must not become a missing file"); + assert!(inspection_error.to_string().contains("inspect")); + fs::remove_dir_all(root).expect("remove invalid entrypoint fixture"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs b/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs new file mode 100644 index 000000000..7c92ad77e --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/manifest.rs @@ -0,0 +1,134 @@ +use super::super::manifest::{ + build_manifest, verify_release_directory, BinaryHealth, HASH_BUFFER_BYTES, + INSTALLED_MANIFEST_FILE, MAX_INSTALLED_MANIFEST_BYTES, +}; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn manifest_records_a_digest_for_every_declared_binary() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-digests"); + std::fs::create_dir_all(root.join("bin")).expect("create release test root"); + let source = root.join("source"); + write_test_binary(&source, "binary payload"); + + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + + let binary = manifest + .binaries + .get("unixnotis-daemon") + .expect("daemon manifest entry"); + assert_eq!(binary.size, 14); + assert_eq!(binary.sha256.len(), 64); + std::fs::remove_dir_all(root).expect("remove release manifest fixture"); +} + +#[test] +fn release_verification_rejects_a_changed_binary_digest() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-mismatch"); + let source = root.join("source"); + let release = root.join("release"); + std::fs::create_dir_all(release.join("bin")).expect("create release fixture"); + write_test_binary(&source, "original"); + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + write_test_binary(&release.join("bin").join("unixnotis-daemon"), "changed!"); + std::fs::write( + release.join(INSTALLED_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let error = verify_release_directory(&release, &manifest) + .expect_err("changed binary must fail verification"); + + assert!(error.to_string().contains("digest mismatch")); + std::fs::remove_dir_all(root).expect("remove release mismatch fixture"); +} + +#[test] +fn release_security_limits_keep_their_declared_byte_domains() { + assert_eq!(MAX_INSTALLED_MANIFEST_BYTES, 262_144); + assert_eq!(HASH_BUFFER_BYTES, 65_536); +} + +#[test] +fn binary_health_labels_cover_every_installed_state() { + let states = [ + (BinaryHealth::Missing, "missing"), + ( + BinaryHealth::Healthy { + generation: "generation".to_string(), + package_version: "1.2.0".to_string(), + digest: "digest".to_string(), + }, + "healthy", + ), + (BinaryHealth::WrongType, "wrong type"), + (BinaryHealth::NotExecutable, "not executable"), + (BinaryHealth::BrokenLink, "broken link"), + (BinaryHealth::WrongGeneration, "wrong generation"), + (BinaryHealth::HashMismatch, "hash mismatch"), + (BinaryHealth::Unsafe("detail".to_string()), "unsafe"), + ]; + + for (state, expected) in states { + assert_eq!(state.label(), expected); + } + assert!(!matches!( + BinaryHealth::Missing, + BinaryHealth::Healthy { .. } + )); + assert!(!matches!( + BinaryHealth::WrongType, + BinaryHealth::Healthy { .. } + )); +} + +#[test] +fn manifest_construction_rejects_a_non_executable_source() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-source-mode"); + let source = root.join("unixnotis-daemon"); + std::fs::create_dir_all(&root).expect("create source mode fixture"); + std::fs::write(&source, "binary payload").expect("write non-executable source"); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o644)) + .expect("set non-executable mode"); + + let error = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect_err("non-executable sources must not enter a release manifest"); + + assert!(error.to_string().contains("not executable")); + std::fs::remove_dir_all(root).expect("remove source mode fixture"); +} + +#[test] +fn release_verification_rejects_a_non_executable_installed_binary() { + let root = crate::test_support::fs::unique_temp_path("release-manifest-installed-mode"); + let source = root.join("source"); + let release = root.join("release"); + std::fs::create_dir_all(release.join("bin")).expect("create release fixture"); + write_test_binary(&source, "binary payload"); + let manifest = build_manifest(&[("unixnotis-daemon".to_string(), source)]) + .expect("build release manifest"); + let installed = release.join("bin").join("unixnotis-daemon"); + std::fs::write(&installed, "binary payload").expect("write installed binary"); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)) + .expect("remove installed executable bits"); + std::fs::write( + release.join(INSTALLED_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + + let error = verify_release_directory(&release, &manifest) + .expect_err("non-executable installed binaries must fail verification"); + + assert!(error.to_string().contains("not executable")); + std::fs::remove_dir_all(root).expect("remove installed mode fixture"); +} + +fn write_test_binary(path: &std::path::Path, contents: &str) { + std::fs::write(path, contents).expect("write release test binary"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/mod.rs b/crates/unixnotis-installer/src/actions/releases/tests/mod.rs new file mode 100644 index 000000000..cbfdc15fd --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/mod.rs @@ -0,0 +1,57 @@ +mod entrypoints; +mod health; +mod journal; +mod manifest; +mod recovery; +mod transaction; + +use std::path::Path; + +use anyhow::Result; + +use super::transaction::install_release_generation_transaction; +use crate::paths::InstallPaths; + +pub(super) fn install_release_generation( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, +{ + install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + || Ok(()), + ) +} + +pub(super) fn install_release_generation_with_reservation_check( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, + reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + install_release_generation_transaction( + paths, + release_source, + binaries, + precommit, + reserve_activation, + reserved_check, + ) +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs b/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs new file mode 100644 index 000000000..a577163f1 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/recovery.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use super::super::entrypoints::plan_entrypoint_changes; +use super::super::manifest::entrypoint_target; +use super::super::transaction::{ + pending_release_exists, rollback_pending_release, write_pending, PendingRelease, +}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +const LEGACY_BINARIES: [&str; 4] = [ + "unixnotis-daemon", + "unixnotis-popups", + "unixnotis-center", + "unixnotis-svg-renderer", +]; +const CREATED_BINARIES: [&str; 2] = ["unixnotis-css-validate", "noticenterctl"]; + +#[derive(Clone, Copy)] +struct CrashBoundary { + label: &'static str, + rollback_directory: bool, + moved: usize, + linked: usize, + current_switched: bool, +} + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +#[test] +fn every_entrypoint_crash_boundary_recovers_one_complete_legacy_layout() { + for boundary in crash_boundaries() { + let root = + crate::test_support::fs::unique_temp_path(&format!("release-crash-{}", boundary.label)); + let paths = paths(&root); + let pending = prepare_journaled_legacy_layout(&paths); + apply_crash_prefix(&paths, &pending, boundary); + + assert!( + rollback_pending_release(&paths).expect("recover crash boundary"), + "{} did not find its durable journal", + boundary.label + ); + assert_recovered_legacy_layout(&paths, boundary.label); + fs::remove_dir_all(root).expect("remove crash recovery fixture"); + } +} + +fn crash_boundaries() -> [CrashBoundary; 9] { + [ + CrashBoundary { + label: "journal-only", + rollback_directory: false, + moved: 0, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "rollback-directory-created", + rollback_directory: true, + moved: 0, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "first-legacy-moved", + rollback_directory: true, + moved: 1, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "half-legacy-moved", + rollback_directory: true, + moved: 2, + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "all-legacy-moved", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: 0, + current_switched: false, + }, + CrashBoundary { + label: "first-link-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: 1, + current_switched: false, + }, + CrashBoundary { + label: "half-links-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: usize::midpoint(LEGACY_BINARIES.len(), CREATED_BINARIES.len()), + current_switched: false, + }, + CrashBoundary { + label: "all-links-created", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: LEGACY_BINARIES.len() + CREATED_BINARIES.len(), + current_switched: false, + }, + CrashBoundary { + label: "current-switched-before-readiness", + rollback_directory: true, + moved: LEGACY_BINARIES.len(), + linked: LEGACY_BINARIES.len() + CREATED_BINARIES.len(), + current_switched: true, + }, + ] +} + +fn assert_recovered_legacy_layout(paths: &InstallPaths, boundary: &str) { + for name in LEGACY_BINARIES { + assert_eq!( + fs::read_to_string(paths.bin_dir.join(name)).expect("read restored legacy binary"), + format!("legacy:{name}"), + "{boundary} did not restore {name}" + ); + } + for name in CREATED_BINARIES { + assert!( + fs::symlink_metadata(paths.bin_dir.join(name)).is_err(), + "{boundary} retained newly created entrypoint {name}" + ); + } + assert!( + fs::symlink_metadata(paths.installed_current_link().expect("current path")).is_err(), + "{boundary} retained the unready generation" + ); + assert!( + !pending_release_exists(paths).expect("inspect recovered journal"), + "{boundary} retained a completed recovery journal" + ); +} + +#[test] +fn recovery_fails_closed_when_legacy_live_and_backup_states_conflict() { + for conflict in ["both-copies", "neither-copy"] { + let root = + crate::test_support::fs::unique_temp_path(&format!("release-conflict-{conflict}")); + let paths = paths(&root); + let pending = prepare_journaled_legacy_layout(&paths); + let name = LEGACY_BINARIES[0]; + let rollback = rollback_bin_dir(&paths, &pending); + fs::create_dir_all(&rollback).expect("create rollback directory"); + match conflict { + "both-copies" => { + fs::write(rollback.join(name), "duplicate backup").expect("write duplicate backup"); + } + "neither-copy" => { + fs::remove_file(paths.bin_dir.join(name)).expect("remove legacy live copy"); + } + _ => unreachable!("the conflict table lists every case"), + } + + let error = rollback_pending_release(&paths) + .expect_err("ambiguous rollback state must fail closed"); + + assert!( + error.to_string().contains("binary rollback"), + "unexpected recovery error for {conflict}: {error:#}" + ); + assert!( + pending_release_exists(&paths).expect("retain failed recovery journal"), + "failed recovery must retain its journal" + ); + fs::remove_dir_all(root).expect("remove conflict recovery fixture"); + } +} + +fn prepare_journaled_legacy_layout(paths: &InstallPaths) -> PendingRelease { + fs::create_dir_all(&paths.bin_dir).expect("create legacy bin directory"); + for name in LEGACY_BINARIES { + fs::write(paths.bin_dir.join(name), format!("legacy:{name}")).expect("write legacy binary"); + } + fs::create_dir_all( + paths + .installed_release_root() + .expect("installed release root"), + ) + .expect("create release journal directory"); + let binaries = LEGACY_BINARIES + .into_iter() + .chain(CREATED_BINARIES) + .map(str::to_string) + .collect::>(); + let pending = plan_entrypoint_changes(paths, &binaries, "test-generation") + .expect("plan entrypoint changes"); + write_pending( + &paths + .installed_pending_manifest() + .expect("pending journal path"), + &pending, + ) + .expect("write durable pending journal"); + pending +} + +fn apply_crash_prefix(paths: &InstallPaths, pending: &PendingRelease, boundary: CrashBoundary) { + let rollback = rollback_bin_dir(paths, pending); + if boundary.rollback_directory { + fs::create_dir_all(&rollback).expect("create rollback directory"); + } + for name in LEGACY_BINARIES.into_iter().take(boundary.moved) { + fs::rename(paths.bin_dir.join(name), rollback.join(name)).expect("move legacy binary"); + } + + let expected = entrypoint_target(); + for name in LEGACY_BINARIES + .into_iter() + .chain(CREATED_BINARIES) + .take(boundary.linked) + { + symlink(expected.join(name), paths.bin_dir.join(name)).expect("create managed entrypoint"); + } + if boundary.current_switched { + symlink( + &pending.new_current, + paths.installed_current_link().expect("current path"), + ) + .expect("switch current generation"); + } +} + +fn rollback_bin_dir(paths: &InstallPaths, pending: &PendingRelease) -> PathBuf { + paths + .installed_rollback_root() + .expect("rollback root") + .join(&pending.generation) + .join("bin") +} diff --git a/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs b/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs new file mode 100644 index 000000000..a18378e43 --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/tests/transaction.rs @@ -0,0 +1,504 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::super::manifest::build_manifest; +use super::super::transaction::{ + commit_pending_release, pending_release_exists, rollback_pending_release, + stage_release_with_copy, +}; +use super::{install_release_generation, install_release_generation_with_reservation_check}; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; + +fn paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +#[test] +fn successful_install_switches_every_entrypoint_to_one_generation() { + let root = crate::test_support::fs::unique_temp_path("release-transaction-success"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("payload:{binary}")); + } + let paths = paths(&root); + + let generation = install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("install release generation"); + + let current = std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("current release link"); + assert_eq!(current, Path::new("releases").join(&generation)); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read linked binary"), + format!("payload:{binary}") + ); + } + assert!(commit_pending_release(&paths).expect("commit pending release")); + std::fs::remove_dir_all(root).expect("remove release transaction fixture"); +} + +#[test] +fn failed_precommit_restores_all_legacy_entrypoints() { + let root = crate::test_support::fs::unique_temp_path("release-transaction-precommit"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy binary directory"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("new:{binary}")); + std::fs::write(paths.bin_dir.join(binary), format!("old:{binary}")) + .expect("write legacy binary"); + } + + let checks = std::cell::Cell::new(0usize); + let error = install_release_generation_with_reservation_check( + &paths, + &source, + &binaries, + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + if check == 0 { + Ok(()) + } else { + Err(anyhow::anyhow!("daemon restarted")) + } + }, + || Ok(()), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + if check == 1 { + Err(anyhow::anyhow!("daemon restarted")) + } else { + Ok(()) + } + }, + ) + .expect_err("failed precommit must roll back"); + + assert!(format!("{error:#}").contains("daemon restarted")); + assert_eq!(checks.get(), 2); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + format!("old:{binary}") + ); + } + assert!(!rollback_pending_release(&paths).expect("no pending rollback should remain")); + std::fs::remove_dir_all(root).expect("remove release rollback fixture"); +} + +#[test] +fn durable_journal_precedes_the_first_live_entrypoint_mutation() { + let root = crate::test_support::fs::unique_temp_path("release-journal-order"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy binary directory"); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "new generation"); + std::fs::write(paths.bin_dir.join(&binary), "legacy generation").expect("write legacy binary"); + let checks = std::cell::Cell::new(0usize); + + install_release_generation_with_reservation_check( + &paths, + &source, + std::slice::from_ref(&binary), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + let metadata = std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect live entrypoint at precommit"); + match check { + 0 => { + assert!(!pending_release_exists(&paths).expect("inspect initial journal")); + assert!(metadata.file_type().is_file()); + } + _ => panic!("unexpected precommit check {check}"), + } + Ok(()) + }, + || Ok(()), + || { + let check = checks.get(); + checks.set(check.saturating_add(1)); + let metadata = std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect live entrypoint at reserved check"); + match check { + 1 => { + assert!(!pending_release_exists(&paths).expect("inspect initial journal")); + assert!(metadata.file_type().is_file()); + } + 2 => { + assert!(!pending_release_exists(&paths).expect("inspect pre-recovery journal")); + assert!( + metadata.file_type().is_file(), + "service check must run before pending recovery" + ); + } + 3 => { + assert!(pending_release_exists(&paths).expect("inspect durable journal")); + assert!( + metadata.file_type().is_file(), + "journal must be durable before the legacy entrypoint moves" + ); + } + 4 => { + assert!(pending_release_exists(&paths).expect("inspect activation journal")); + assert!( + metadata.file_type().is_symlink(), + "entrypoint publication must finish before the generation switch check" + ); + } + _ => panic!("unexpected reserved check {check}"), + } + Ok(()) + }, + ) + .expect("install journal ordering generation"); + + assert_eq!(checks.get(), 5); + assert!(commit_pending_release(&paths).expect("commit journal ordering generation")); + std::fs::remove_dir_all(root).expect("remove journal ordering fixture"); +} + +#[test] +fn activation_reservation_is_held_before_layout_and_until_current_switches() { + struct SwitchObserver { + current: std::path::PathBuf, + observed_switch: std::rc::Rc>, + } + + impl Drop for SwitchObserver { + fn drop(&mut self) { + self.observed_switch + .set(std::fs::read_link(&self.current).is_ok()); + } + } + + let root = crate::test_support::fs::unique_temp_path("release-reservation-order"); + let source = root.join("source"); + let paths = paths(&root); + std::fs::create_dir_all(&source).expect("create binary source"); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "reserved generation"); + std::fs::create_dir_all(&paths.bin_dir).expect("create legacy entrypoint directory"); + std::fs::write(paths.bin_dir.join(&binary), "legacy generation") + .expect("write legacy entrypoint"); + let checks = std::cell::Cell::new(0usize); + let reservation_calls = std::cell::Cell::new(0usize); + let observed_switch = std::rc::Rc::new(std::cell::Cell::new(false)); + + install_release_generation_with_reservation_check( + &paths, + &source, + std::slice::from_ref(&binary), + || { + checks.set(checks.get().saturating_add(1)); + Ok(()) + }, + || { + assert_eq!( + checks.get(), + 1, + "reservation must follow the initial unowned-state check" + ); + assert!( + std::fs::symlink_metadata(paths.bin_dir.join(&binary)) + .expect("inspect entrypoint before reservation") + .file_type() + .is_file(), + "activation reservation must precede entrypoint mutation" + ); + assert!( + !pending_release_exists(&paths).expect("inspect journal before reservation"), + "activation reservation must precede journal publication" + ); + reservation_calls.set(reservation_calls.get().saturating_add(1)); + Ok(SwitchObserver { + current: paths.installed_current_link().expect("current link path"), + observed_switch: std::rc::Rc::clone(&observed_switch), + }) + }, + || Ok(()), + ) + .expect("activate generation under reservation"); + + assert_eq!(reservation_calls.get(), 1); + assert!( + observed_switch.get(), + "activation reservation must remain alive through the current-link switch" + ); + assert!(commit_pending_release(&paths).expect("commit reserved generation")); + std::fs::remove_dir_all(root).expect("remove reservation ordering fixture"); +} + +#[test] +fn failure_copying_any_binary_publishes_no_partial_generation() { + for failed_index in 0..3 { + let root = crate::test_support::fs::unique_temp_path(&format!( + "release-copy-failure-{failed_index}" + )); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let sources = ["first", "middle", "final"] + .into_iter() + .map(|name| { + let path = source.join(name); + write_test_binary(&path, name); + (name.to_string(), path) + }) + .collect::>(); + let manifest = build_manifest(&sources).expect("build test manifest"); + let paths = paths(&root); + let mut copied = 0usize; + + let error = stage_release_with_copy( + &paths, + "copy-failure", + &sources, + &manifest, + |source, destination| { + if copied == failed_index { + return Err(anyhow::anyhow!("injected copy failure")); + } + copied = copied.saturating_add(1); + unixnotis_core::filesystem::copy_file_atomic(source, destination) + .map_err(anyhow::Error::from) + }, + ) + .expect_err("injected copy failure must abort staging"); + + assert!(error.to_string().contains("stage release binary")); + let entries = std::fs::read_dir(paths.installed_releases_dir().expect("releases path")) + .expect("read releases directory") + .collect::, _>>() + .expect("collect releases directory"); + assert!( + entries.is_empty(), + "copy failure {failed_index} published partial release state" + ); + std::fs::remove_dir_all(root).expect("remove copy failure fixture"); + } +} + +#[test] +fn readiness_rollback_restores_the_complete_previous_generation() { + let root = crate::test_support::fs::unique_temp_path("release-readiness-rollback"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create binary source"); + let binaries = ["unixnotis-daemon", "unixnotis-center"] + .into_iter() + .map(str::to_string) + .collect::>(); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("old:{binary}")); + } + let paths = paths(&root); + let old_generation = + install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("install old generation"); + commit_pending_release(&paths).expect("commit old generation"); + for binary in &binaries { + write_test_binary(&source.join(binary), &format!("new:{binary}")); + } + install_release_generation(&paths, &source, &binaries, || Ok(()), || Ok(())) + .expect("activate pending new generation"); + + assert!(rollback_pending_release(&paths).expect("roll back failed readiness")); + + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("restored current release"), + Path::new("releases").join(old_generation) + ); + for binary in &binaries { + assert_eq!( + std::fs::read_to_string(paths.bin_dir.join(binary)).expect("read restored binary"), + format!("old:{binary}") + ); + } + std::fs::remove_dir_all(root).expect("remove readiness rollback fixture"); +} + +#[test] +fn recovery_finishes_when_current_was_restored_before_a_crash() { + let root = crate::test_support::fs::unique_temp_path("release-idempotent-current-rollback"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create idempotent rollback source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install previous generation"); + commit_pending_release(&paths).expect("commit previous generation"); + write_test_binary(&source.join(&binary), "new generation"); + install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate new generation"); + let previous_target = Path::new("releases").join(&old_generation); + unixnotis_core::filesystem::replace_symlink_atomic( + &paths.installed_current_link().expect("current path"), + &previous_target, + ) + .expect("simulate completed current-link rollback"); + + assert!(rollback_pending_release(&paths).expect("finish idempotent rollback")); + + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retained previous generation"), + previous_target + ); + assert!(!pending_release_exists(&paths).expect("journal removed after recovery")); + std::fs::remove_dir_all(root).expect("remove idempotent rollback fixture"); +} + +#[test] +fn readiness_rollback_refuses_a_previous_generation_that_changed_after_activation() { + let root = crate::test_support::fs::unique_temp_path("release-rollback-revalidation"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create rollback revalidation source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "old generation"); + let old_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install previous generation"); + commit_pending_release(&paths).expect("commit previous generation"); + write_test_binary(&source.join(&binary), "new generation"); + let new_generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("activate new generation"); + let old_binary = paths + .installed_releases_dir() + .expect("installed releases directory") + .join(old_generation) + .join("bin") + .join(&binary); + std::fs::write(old_binary, "corrupted old!").expect("corrupt previous generation"); + + let error = rollback_pending_release(&paths) + .expect_err("changed previous generation must not be reactivated"); + + assert!(error + .to_string() + .contains("verify previous release generation before rollback")); + assert_eq!( + std::fs::read_link(paths.installed_current_link().expect("current path")) + .expect("retain current generation"), + Path::new("releases").join(new_generation) + ); + assert!(pending_release_exists(&paths).expect("retain pending rollback journal")); + std::fs::remove_dir_all(root).expect("remove rollback revalidation fixture"); +} + +#[test] +fn successful_commits_retain_only_current_and_previous_verified_generations() { + let root = crate::test_support::fs::unique_temp_path("release-retention"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create retention source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + let mut generations = Vec::new(); + + for payload in ["generation one", "generation two", "generation three"] { + write_test_binary(&source.join(&binary), payload); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install retention generation"); + assert!(commit_pending_release(&paths).expect("commit retention generation")); + generations.push(generation); + } + + let releases = paths + .installed_releases_dir() + .expect("installed releases directory"); + assert!(!releases.join(&generations[0]).exists()); + assert!(releases.join(&generations[1]).exists()); + assert!(releases.join(&generations[2]).exists()); + std::fs::remove_dir_all(root).expect("remove retention fixture"); +} + +#[test] +fn readiness_commit_revalidates_the_generation_before_discarding_rollback() { + let root = crate::test_support::fs::unique_temp_path("release-commit-revalidation"); + let source = root.join("source"); + std::fs::create_dir_all(&source).expect("create revalidation source"); + let paths = paths(&root); + let binary = "unixnotis-daemon".to_string(); + write_test_binary(&source.join(&binary), "generation payload"); + let generation = install_release_generation( + &paths, + &source, + std::slice::from_ref(&binary), + || Ok(()), + || Ok(()), + ) + .expect("install pending generation"); + let installed_binary = paths + .installed_releases_dir() + .expect("installed releases directory") + .join(generation) + .join("bin") + .join(&binary); + std::fs::write(&installed_binary, "changed payload!!") + .expect("corrupt pending generation after activation"); + + assert!(commit_pending_release(&paths).is_err()); + assert!(rollback_pending_release(&paths).expect("pending rollback remains recoverable")); + std::fs::remove_dir_all(root).expect("remove revalidation fixture"); +} + +fn write_test_binary(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write release test binary"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .expect("make release test binary executable"); +} diff --git a/crates/unixnotis-installer/src/actions/releases/transaction.rs b/crates/unixnotis-installer/src/actions/releases/transaction.rs new file mode 100644 index 000000000..73b833a6b --- /dev/null +++ b/crates/unixnotis-installer/src/actions/releases/transaction.rs @@ -0,0 +1,448 @@ +//! Durable release staging, atomic activation, and rollback + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use unixnotis_core::filesystem::{ + copy_file_atomic, create_directory_all, read_regular_file_bounded, read_symlink, + remove_directory_tree, remove_regular_file, remove_symlink_if_target, + rename_directory_no_replace, replace_symlink_atomic, write_file_atomic, RemoveSymlinkOutcome, + RenameDirectoryOutcome, +}; + +use crate::managed_binaries::is_managed_binary_name; +use crate::paths::InstallPaths; + +use super::entrypoints::{ + apply_entrypoint_changes, plan_entrypoint_changes, rollback_entrypoint_changes, +}; +use super::manifest::{ + build_manifest, manifest_bytes, read_manifest, verify_release_directory, + InstalledReleaseManifest, INSTALLED_MANIFEST_FILE, +}; + +pub(in crate::actions::releases) const MAX_PENDING_MANIFEST_BYTES: u64 = 256 * 1024; +pub(in crate::actions::releases) const PENDING_RELEASE_SCHEMA_VERSION: u32 = 2; + +#[derive(Debug, Deserialize, Serialize)] +pub(in crate::actions::releases) struct PendingRelease { + pub(in crate::actions::releases) schema_version: u32, + pub(in crate::actions::releases) generation: String, + pub(in crate::actions::releases) new_current: PathBuf, + pub(in crate::actions::releases) previous_current: Option, + pub(in crate::actions::releases) legacy_entrypoints: Vec, + pub(in crate::actions::releases) created_entrypoints: Vec, +} + +pub fn install_release_generation_transaction( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + precommit: F, + reserve_activation: R, + reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + install_release_generation_with_checks( + paths, + release_source, + binaries, + precommit, + reserve_activation, + reserved_check, + ) +} + +fn install_release_generation_with_checks( + paths: &InstallPaths, + release_source: &Path, + binaries: &[String], + mut precommit: F, + mut reserve_activation: R, + mut reserved_check: C, +) -> Result +where + F: FnMut() -> Result<()>, + R: FnMut() -> Result, + C: FnMut() -> Result<()>, +{ + let sources = binaries + .iter() + .map(|name| (name.clone(), release_source.join(name))) + .collect::>(); + let manifest = build_manifest(&sources)?; + let generation = format!("{}-{}", manifest.package_version, manifest.build_id); + let release_dir = stage_release(paths, &generation, &sources, &manifest)?; + verify_release_directory(&release_dir, &manifest)?; + + // The first check runs before reserving the broker name, so it proves that no + // existing notification daemon owns the runtime boundary + precommit().context("verify daemon state before binary layout mutation")?; + + // Hold activation exclusion before recovery, journaling, or entrypoint mutation + // Later checks omit the broker owner because this reservation is expected + let _activation_reservation = + reserve_activation().context("reserve daemon activation before binary layout mutation")?; + reserved_check().context("verify selected service before binary layout mutation")?; + + commit_staged_release(paths, binaries, &generation, &mut reserved_check) +} + +fn commit_staged_release( + paths: &InstallPaths, + binaries: &[String], + generation: &str, + reserved_check: &mut C, +) -> Result +where + C: FnMut() -> Result<()>, +{ + // Recovery can change current and entrypoints, so prove the selected service is still stopped + if let Err(error) = reserved_check() { + return Err(error.context("verify selected service before pending-release recovery")); + } + rollback_pending_release(paths).context("recover incomplete prior binary installation")?; + let pending = plan_entrypoint_changes(paths, binaries, generation)?; + let pending_path = paths.installed_pending_manifest()?; + // Atomic publication synchronizes both the journal file and its parent directory + // No live entrypoint may change before this durable recovery authority exists + write_pending(&pending_path, &pending)?; + + // Planning and journal I/O may take time, so prove quiescence again at first mutation + if let Err(error) = reserved_check() { + return Err(rollback_with_context(paths, error)); + } + if let Err(error) = apply_entrypoint_changes(paths, &pending) { + return Err(rollback_with_context( + paths, + error.context("publish managed binary entrypoints"), + )); + } + + // Runtime state is sampled again immediately before the atomic generation switch + if let Err(error) = reserved_check() { + return Err(rollback_with_context(paths, error)); + } + if let Err(error) = + replace_symlink_atomic(&paths.installed_current_link()?, &pending.new_current) + { + return Err(rollback_with_context( + paths, + anyhow!(error).context("atomically switch installed release generation"), + )); + } + + // The journal covers every entrypoint mutation and the exact selected generation + Ok(generation.to_string()) +} + +fn stage_release( + paths: &InstallPaths, + generation: &str, + sources: &[(String, PathBuf)], + manifest: &InstalledReleaseManifest, +) -> Result { + stage_release_with_copy( + paths, + generation, + sources, + manifest, + |source, destination| copy_file_atomic(source, destination).map_err(anyhow::Error::from), + ) +} + +pub(in crate::actions::releases) fn stage_release_with_copy( + paths: &InstallPaths, + generation: &str, + sources: &[(String, PathBuf)], + manifest: &InstalledReleaseManifest, + mut copy_binary: F, +) -> Result +where + F: FnMut(&Path, &Path) -> Result<()>, +{ + let releases = paths.installed_releases_dir()?; + create_directory_all(&releases, 0o755).context("create installed releases directory")?; + let final_dir = releases.join(generation); + if final_dir.exists() { + verify_release_directory(&final_dir, manifest)?; + return Ok(final_dir); + } + + let staging = releases.join(format!(".staging-{generation}-{}", std::process::id())); + if staging.exists() { + remove_directory_tree(&staging).context("remove abandoned release staging directory")?; + } + create_directory_all(&staging.join("bin"), 0o755) + .context("create release staging directory")?; + let stage_result = (|| { + for (name, source) in sources { + copy_binary(source, &staging.join("bin").join(name)) + .with_context(|| format!("stage release binary {name}"))?; + } + write_file_atomic( + &staging.join(INSTALLED_MANIFEST_FILE), + &manifest_bytes(manifest)?, + 0o644, + ) + .context("write staged release manifest")?; + verify_release_directory(&staging, manifest)?; + match rename_directory_no_replace(&staging, &final_dir)? { + RenameDirectoryOutcome::Renamed => Ok(()), + RenameDirectoryOutcome::DestinationExists => { + remove_directory_tree(&staging)?; + verify_release_directory(&final_dir, manifest) + } + RenameDirectoryOutcome::SourceMissing => Err(anyhow!( + "release staging directory disappeared before publication" + )), + } + })(); + if stage_result.is_err() { + let _cleanup = remove_directory_tree(&staging); + } + stage_result?; + Ok(final_dir) +} + +pub fn rollback_pending_release(paths: &InstallPaths) -> Result { + let pending_path = paths.installed_pending_manifest()?; + let Some(pending) = read_pending(&pending_path)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + rollback_release_state(paths, &pending)?; + remove_regular_file(&pending_path).context("remove pending release manifest")?; + Ok(true) +} + +fn rollback_release_state(paths: &InstallPaths, pending: &PendingRelease) -> Result<()> { + let current = paths.installed_current_link()?; + let visible_current = read_symlink(¤t)?; + if visible_current.as_ref() == Some(&pending.new_current) { + if let Some(previous) = pending.previous_current.as_ref() { + // Rollback authority depends on the previous generation remaining byte-for-byte valid + verify_release_target(paths, previous) + .context("verify previous release generation before rollback")?; + replace_symlink_atomic(¤t, previous) + .context("restore prior release generation")?; + } else { + match remove_symlink_if_target(¤t, &pending.new_current)? { + RemoveSymlinkOutcome::Removed | RemoveSymlinkOutcome::Missing => {} + RemoveSymlinkOutcome::TargetMismatch(actual) => { + return Err(anyhow!( + "current release changed during rollback to {}", + actual.display() + )); + } + } + } + } else { + let already_restored = pending.previous_current.as_ref().map_or_else( + || visible_current.is_none(), + |previous| visible_current.as_ref() == Some(previous), + ); + if !already_restored { + let actual = visible_current.map_or_else( + || "missing".to_string(), + |target| target.display().to_string(), + ); + return Err(anyhow!( + "current release changed during rollback to {actual}" + )); + } + if let Some(previous) = pending.previous_current.as_ref() { + // A crash may leave current restored while the journal still needs entrypoint cleanup + verify_release_target(paths, previous) + .context("verify already restored release generation during rollback")?; + } + } + rollback_entrypoint_changes(paths, pending)?; + Ok(()) +} + +pub fn commit_pending_release(paths: &InstallPaths) -> Result { + let pending_path = paths.installed_pending_manifest()?; + let Some(pending) = read_pending(&pending_path)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + if read_symlink(&paths.installed_current_link()?)?.as_ref() != Some(&pending.new_current) { + return Err(anyhow!("installed release changed before readiness commit")); + } + verify_release_target(paths, &pending.new_current) + .context("verify ready release generation before commit")?; + // Retention is still reversible because current and previous generations remain untouched + prune_release_generations(paths, &pending) + .context("prune superseded installed release generations")?; + remove_regular_file(&pending_path).context("remove committed pending release manifest")?; + let rollback_generation = paths.installed_rollback_root()?.join(&pending.generation); + if rollback_generation.exists() { + // Scratch cleanup follows the journal commit point and cannot make activation fail + let _cleanup = remove_directory_tree(&rollback_generation); + } + Ok(true) +} + +pub(in crate::actions::releases) fn verify_release_target( + paths: &InstallPaths, + target: &Path, +) -> Result { + if !is_managed_current_target(target) { + return Err(anyhow!( + "current release link points outside the managed releases directory" + )); + } + let release_dir = paths.installed_release_root()?.join(target); + let manifest = read_manifest(&release_dir.join(INSTALLED_MANIFEST_FILE))?; + let expected_generation = format!("{}-{}", manifest.package_version, manifest.build_id); + if target.file_name().and_then(|name| name.to_str()) != Some(expected_generation.as_str()) { + return Err(anyhow!( + "current release directory does not match its manifest generation" + )); + } + verify_release_directory(&release_dir, &manifest)?; + Ok(manifest) +} + +fn prune_release_generations(paths: &InstallPaths, pending: &PendingRelease) -> Result<()> { + let releases = paths.installed_releases_dir()?; + let retained = [ + pending.new_current.file_name(), + pending + .previous_current + .as_ref() + .and_then(|target| target.file_name()), + ] + .into_iter() + .flatten() + .collect::>(); + for entry in fs::read_dir(&releases).context("read installed release generations")? { + let entry = entry.context("read installed release generation entry")?; + let file_name = entry.file_name(); + if retained.contains(file_name.as_os_str()) { + continue; + } + let file_type = entry + .file_type() + .context("inspect installed release generation entry")?; + if !file_type.is_dir() { + return Err(anyhow!( + "installed releases directory contains an unmanaged object: {}", + entry.path().display() + )); + } + if file_name.to_string_lossy().starts_with(".staging-") { + remove_directory_tree(&entry.path()).context("remove abandoned release staging")?; + continue; + } + let manifest = read_manifest(&entry.path().join(INSTALLED_MANIFEST_FILE))?; + let expected_name = format!("{}-{}", manifest.package_version, manifest.build_id); + if file_name != std::ffi::OsStr::new(&expected_name) { + return Err(anyhow!( + "installed release directory name does not match its manifest" + )); + } + verify_release_directory(&entry.path(), &manifest)?; + remove_directory_tree(&entry.path()).context("remove superseded release generation")?; + } + Ok(()) +} + +pub fn pending_release_has_runtime_rollback(paths: &InstallPaths) -> Result { + let Some(pending) = read_pending(&paths.installed_pending_manifest()?)? else { + return Ok(false); + }; + validate_pending_journal(&pending)?; + Ok(pending.previous_current.is_some() || !pending.legacy_entrypoints.is_empty()) +} + +pub fn pending_release_exists(paths: &InstallPaths) -> Result { + Ok(read_pending(&paths.installed_pending_manifest()?)?.is_some()) +} + +pub(in crate::actions::releases) fn write_pending( + path: &Path, + pending: &PendingRelease, +) -> Result<()> { + validate_pending_journal(pending)?; + let bytes = serde_json::to_vec_pretty(pending).context("serialize pending release")?; + write_file_atomic(path, &bytes, 0o600).context("write pending release manifest") +} + +pub(in crate::actions::releases) fn read_pending(path: &Path) -> Result> { + match read_regular_file_bounded(path, MAX_PENDING_MANIFEST_BYTES) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .context("parse pending release manifest"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).context("read pending release manifest"), + } +} + +pub(in crate::actions::releases) fn validate_pending_targets( + pending: &PendingRelease, +) -> Result<()> { + let expected_new_current = PathBuf::from("releases").join(&pending.generation); + if pending.new_current != expected_new_current + || !is_managed_current_target(&pending.new_current) + || pending + .previous_current + .as_ref() + .is_some_and(|target| !is_managed_current_target(target)) + { + return Err(anyhow!( + "pending release contains an unmanaged current-link target" + )); + } + let mut names = std::collections::HashSet::new(); + for name in pending + .legacy_entrypoints + .iter() + .chain(&pending.created_entrypoints) + { + if !is_managed_binary_name(name) || !names.insert(name) { + return Err(anyhow!( + "pending release contains an invalid or duplicate binary entrypoint" + )); + } + } + Ok(()) +} + +fn validate_pending_journal(pending: &PendingRelease) -> Result<()> { + if pending.schema_version != PENDING_RELEASE_SCHEMA_VERSION { + return Err(anyhow!( + "unsupported pending release schema {}", + pending.schema_version + )); + } + validate_pending_targets(pending) +} + +pub(in crate::actions::releases) fn is_managed_current_target(target: &Path) -> bool { + let mut components = target.components(); + matches!( + (components.next(), components.next(), components.next()), + ( + Some(std::path::Component::Normal(root)), + Some(std::path::Component::Normal(_generation)), + None + ) if root == "releases" + ) +} + +fn rollback_with_context(paths: &InstallPaths, error: anyhow::Error) -> anyhow::Error { + match rollback_pending_release(paths) { + Ok(_rolled_back) => error, + Err(rollback_error) => error.context(format!( + "binary release rollback also failed: {rollback_error:#}" + )), + } +} diff --git a/crates/unixnotis-installer/src/actions/state.rs b/crates/unixnotis-installer/src/actions/state.rs index 0e6af8f88..41e7effd2 100644 --- a/crates/unixnotis-installer/src/actions/state.rs +++ b/crates/unixnotis-installer/src/actions/state.rs @@ -8,7 +8,9 @@ use crate::model::ActionMode; use crate::paths::format_with_home; use crate::service_manager::ReadinessIssue; -use super::{context::ActionContext, install_state::check_install_state, log_line}; +use super::conflicts::ServiceManagerConflictKind; +use super::install::{check_install_state, reject_conflicting_installation_channel}; +use super::{context::ActionContext, log_line, InstallState}; pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { // Use cached install state when available to keep the UI consistent with the plan @@ -27,7 +29,7 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { log_line(ctx, "Warning: no installable binaries discovered"); } for binary in &state.binaries { - let status = if binary.exists { "present" } else { "missing" }; + let status = binary.health.label(); log_line( ctx, format!( @@ -37,6 +39,9 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { format_with_home(&binary.path) ), ); + if let super::releases::BinaryHealth::Unsafe(detail) = &binary.health { + log_line(ctx, format!(" inspection failure: {detail}")); + } } let service_artifact_status = if state.service_artifact_exists { @@ -55,47 +60,17 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { ); if let Some(err) = state.service_active_error.as_ref() { log_line(ctx, format!("- service status check failed: {err}")); - } - if let Some(err) = state.service_enabled_error.as_ref() { - log_line(ctx, format!("- service enable check failed: {err}")); - } - for warning in &state.service_conflict_warnings { - // Non-selected backend path issues are diagnostics, not blockers for the selected backend - log_line( - ctx, - format!("Warning: could not inspect another service manager ({warning})"), - ); - } - if !state.service_conflicts.is_empty() { - // Block before build/copy/write steps so two managers never race to restart the daemon - for conflict in &state.service_conflicts { - if conflict.active { - log_line( - ctx, - format!( - "Error: UnixNotis is active under {}; selected backend is {}", - conflict.manager_label, - ctx.paths.service.label() - ), - ); - } - if conflict.installed { - log_line( - ctx, - format!( - "Error: {} already exists under {} at {}", - conflict.artifact_label, - conflict.manager_label, - format_with_home(&conflict.artifact_path) - ), - ); - } - } return Err(anyhow!( - "UnixNotis already appears managed by another service manager; uninstall or migrate it before installing with {}", + "cannot establish whether {} is active; refusing to install while service ownership is indeterminate", ctx.paths.service.label() )); } + if let Some(err) = state.service_enabled_error.as_ref() { + log_line(ctx, format!("- service enable check failed: {err}")); + } + reject_service_manager_conflicts(ctx, &state)?; + // The source installer must not shadow or combine with package-owned systemd artifacts + reject_conflicting_installation_channel(ctx)?; let mut readiness_errors = Vec::new(); for issue in ctx.paths.service.readiness_issues() { match issue { @@ -116,6 +91,11 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { readiness_errors.join("; ") )); } + log_install_summary(ctx, &state); + Ok(()) +} + +fn log_install_summary(ctx: &mut ActionContext, state: &InstallState) { log_line( ctx, format!( @@ -130,7 +110,6 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { if state.service_active { "yes" } else { "no" } ), ); - if state.is_fully_installed() { if matches!(ctx.action_mode, ActionMode::Install) { log_line( @@ -156,8 +135,93 @@ pub fn check_install_state_step(ctx: &mut ActionContext) -> Result<()> { } else { log_line(ctx, "Install will continue and update missing items."); } +} - Ok(()) +fn reject_service_manager_conflicts(ctx: &mut ActionContext, state: &InstallState) -> Result<()> { + for warning in &state.service_conflict_warnings { + // Non-selected backend path issues are diagnostics, not blockers for the selected backend + log_line( + ctx, + format!("Warning: could not inspect another service manager ({warning})"), + ); + } + if state.service_conflicts.is_empty() { + return Ok(()); + } + + // Block before build/copy/write steps so two managers never race to restart the daemon + for conflict in &state.service_conflicts { + if conflict.kinds.contains(&ServiceManagerConflictKind::Active) { + log_line( + ctx, + format!( + "Error: UnixNotis is active under {}; selected backend is {}", + conflict.manager_label, + ctx.paths.service.label() + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::Installed) + { + log_line( + ctx, + format!( + "Error: {} already exists under {} at {}", + conflict.artifact_label, + conflict.manager_label, + format_with_home(&conflict.artifact_path) + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::PartialInstall) + { + log_line( + ctx, + format!( + "Error: incomplete {} remains under {}", + conflict.artifact_label, conflict.manager_label + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::UnsafeArtifact) + { + log_line( + ctx, + format!( + "Error: unsafe {} objects remain under {}", + conflict.artifact_label, conflict.manager_label + ), + ); + } + if conflict + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate) + { + log_line( + ctx, + format!( + "Error: cannot establish whether {} owns or runs UnixNotis", + conflict.manager_label + ), + ); + } + for path in &conflict.artifact_paths { + log_line(ctx, format!("- leftover: {}", format_with_home(path))); + } + if let Some(detail) = conflict.detail.as_ref() { + log_line(ctx, format!("- inspection failure: {detail}")); + } + } + Err(anyhow!( + "UnixNotis already appears managed by another service manager; uninstall or migrate it before installing with {}", + ctx.paths.service.label() + )) } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/actions/tests/binaries.rs b/crates/unixnotis-installer/src/actions/tests/binaries.rs index ceb3678d5..8a6d81932 100644 --- a/crates/unixnotis-installer/src/actions/tests/binaries.rs +++ b/crates/unixnotis-installer/src/actions/tests/binaries.rs @@ -1,16 +1,16 @@ use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use super::{ discover_installed_binaries, extract_bins_from_metadata, legacy_binaries, parse_install_binaries_metadata, parse_release_manifest_binaries, resolve_install_binaries, - resolve_install_binaries_best_effort, resolve_target_directory, CargoMetadata, + resolve_install_binaries_best_effort, resolve_install_binaries_with_cargo, + resolve_target_directory, resolve_target_directory_with_cargo, CargoMetadata, }; use crate::paths::InstallPaths; use crate::service_manager::ServiceManager; -use crate::test_support::env::EnvGuard; -use crate::test_support::fs::write_executable; #[test] fn parse_install_binaries_metadata_reads_entries() { @@ -118,11 +118,10 @@ fn release_resolution_does_not_compare_archive_names_with_cargo_metadata() { let root = test_root("release-resolution"); let paths = test_paths(&root); write_release_manifest(&paths, &["noticenterctl"]); - let fake_bin = write_fake_cargo( + let _fake_bin = write_fake_cargo( &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"unixnotis-daemon","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); let binaries = resolve_install_binaries(&paths).expect("release manifest should be authoritative"); @@ -138,9 +137,8 @@ fn workspace_resolution_rejects_declared_names_when_cargo_has_no_binary_targets( let paths = test_paths(&root); write_workspace_manifest(&paths, Some(&["noticenterctl"])); let fake_bin = write_fake_cargo(&root, r#"{"target_directory":"target","packages":[]}"#); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths) + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) .expect_err("an empty Cargo inventory must not widen declared names"); assert!(error.to_string().contains("managed binary list")); @@ -157,9 +155,9 @@ fn workspace_resolution_accepts_declared_names_present_in_cargo_metadata() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"noticenterctl","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = resolve_install_binaries(&paths).expect("matching target should be accepted"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("matching target should be accepted"); assert_eq!(binaries, vec!["noticenterctl".to_string()]); let _ = fs::remove_dir_all(root); @@ -175,10 +173,9 @@ fn workspace_resolution_ignores_internal_binary_targets() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"css-check","kind":["bin"]},{"name":"unixnotis-center","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = - resolve_install_binaries(&paths).expect("internal tools must not block source installs"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("internal tools must not block source installs"); assert_eq!(binaries, vec!["unixnotis-center".to_string()]); let _ = fs::remove_dir_all(root); @@ -194,9 +191,9 @@ fn workspace_resolution_rejects_declared_names_missing_from_cargo_metadata() { &root, r#"{"target_directory":"target","packages":[{"targets":[{"name":"unixnotis-daemon","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths).expect_err("missing target should be rejected"); + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect_err("missing target should be rejected"); assert!(error.to_string().contains("noticenterctl")); let _ = fs::remove_dir_all(root); @@ -209,9 +206,9 @@ fn workspace_resolution_rejects_an_empty_declared_and_discovered_set() { let paths = test_paths(&root); write_workspace_manifest(&paths, None); let fake_bin = write_fake_cargo(&root, r#"{"target_directory":"target","packages":[]}"#); - let _path = EnvGuard::set("PATH", &fake_bin); - let error = resolve_install_binaries(&paths).expect_err("empty discovery must fail closed"); + let error = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect_err("empty discovery must fail closed"); assert!(error.to_string().contains("no installable binaries")); let _ = fs::remove_dir_all(root); @@ -227,13 +224,14 @@ fn workspace_resolution_uses_cargo_targets_when_the_declared_list_is_missing() { &root, r#"{"target_directory":"build-output","packages":[{"targets":[{"name":"noticenterctl","kind":["bin"]}]}]}"#, ); - let _path = EnvGuard::set("PATH", &fake_bin); - let binaries = resolve_install_binaries(&paths).expect("cargo targets should provide fallback"); + let binaries = resolve_install_binaries_with_cargo(&paths, Some(&fake_bin.join("cargo"))) + .expect("cargo targets should provide fallback"); assert_eq!(binaries, vec!["noticenterctl".to_string()]); assert_eq!( - resolve_target_directory(&paths).expect("cargo target directory"), + resolve_target_directory_with_cargo(&paths, &fake_bin.join("cargo")) + .expect("cargo target directory"), PathBuf::from("build-output") ); let _ = fs::remove_dir_all(root); @@ -361,6 +359,7 @@ fn checked_in_workspace_resolves_against_real_cargo_targets() { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string() ] @@ -450,6 +449,7 @@ fn legacy_binaries_keep_full_installed_surface() { "unixnotis-daemon".to_string(), "unixnotis-popups".to_string(), "unixnotis-center".to_string(), + "unixnotis-svg-renderer".to_string(), "unixnotis-css-validate".to_string(), "noticenterctl".to_string() ] @@ -504,11 +504,11 @@ fn write_release_manifest(paths: &InstallPaths, binaries: &[&str]) { } fn write_fake_cargo(root: &std::path::Path, metadata: &str) -> PathBuf { - let fake_bin = root.join("fake-bin"); + let fake_bin = root.join("home").join(".cargo").join("bin"); fs::create_dir_all(&fake_bin).expect("fake cargo directory"); - write_executable( - &fake_bin.join("cargo"), - &format!("#!/bin/sh\nprintf '%s\\n' '{metadata}'\n"), - ); + let cargo = fake_bin.join("cargo"); + fs::write(&cargo, format!("#!/bin/sh\nprintf '%s\\n' '{metadata}'\n")) + .expect("write fake cargo"); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).expect("set fake cargo mode"); fake_bin } diff --git a/crates/unixnotis-installer/src/actions/tests/daemon.rs b/crates/unixnotis-installer/src/actions/tests/daemon.rs deleted file mode 100644 index 96c3f6318..000000000 --- a/crates/unixnotis-installer/src/actions/tests/daemon.rs +++ /dev/null @@ -1,405 +0,0 @@ -use std::sync::atomic::AtomicBool; -use std::sync::{mpsc, Arc}; - -use crate::actions::ActionContext; -use crate::app::events::UiMessage; -use crate::detect::{DetectedDaemon, Detection, OwnerInfo}; -use crate::model::ActionMode; -use crate::paths::InstallPaths; -use crate::service_manager::ServiceManager; -use crate::test_support::fs::write_executable; - -use super::{ - is_systemd_unit_inactive, pid_alive, pid_matches_comm, stop_active_daemon, - systemd_stop_error_is_satisfied_by_state, wait_for_exit, -}; - -#[test] -fn stop_active_daemon_errors_for_unmanaged_owner() { - let detection = Detection { - owner: Some(crate::detect::OwnerInfo { - pid: None, - comm: Some("unknown-daemon".to_string()), - }), - daemons: Vec::new(), - }; - let paths = InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - }; - let (tx, _rx) = mpsc::sync_channel::(4); - let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let error = stop_active_daemon(&mut ctx).expect_err("unmanaged owner must block install"); - - assert!(error.to_string().contains("not managed by a known unit")); -} - -#[test] -fn stop_active_daemon_uses_owner_command_match_before_pid_fallback() { - let root = fake_daemon_tool_root("owner-command-match"); - let state = root.join("kill-state"); - write_executable( - &root.join("kill"), - &format!( - "#!/bin/sh\nif [ \"$1\" = \"-0\" ]; then if [ -e {0} ]; then exit 1; fi; : > {0}; fi\nexit 0\n", - state.display() - ), - ); - write_executable(&root.join("ps"), "#!/bin/sh\nprintf 'mako\\n'\n"); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("matching owner command should stop daemon"); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn stop_active_daemon_skips_process_inspection_when_pid_is_already_gone() { - let root = fake_daemon_tool_root("already-gone"); - let ps_marker = root.join("ps-ran"); - write_executable(&root.join("kill"), "#!/bin/sh\nexit 1\n"); - write_executable( - &root.join("ps"), - &format!("#!/bin/sh\nprintf hit > {}\nexit 0\n", ps_marker.display()), - ); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("already stopped process should be accepted"); - - assert!(!ps_marker.exists()); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn stop_active_daemon_accepts_natural_exit_after_command_mismatch() { - let root = fake_daemon_tool_root("natural-exit"); - let state = root.join("kill-state"); - let ps_marker = root.join("ps-ran"); - write_executable( - &root.join("kill"), - &format!( - "#!/bin/sh\nif [ -e {0} ]; then exit 1; fi\n: > {0}\nexit 0\n", - state.display() - ), - ); - write_executable( - &root.join("ps"), - &format!( - "#!/bin/sh\nprintf hit > {}\nprintf 'different-daemon\\n'\n", - ps_marker.display() - ), - ); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("mako", false, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("natural process exit should satisfy stop"); - - assert!(ps_marker.exists()); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn stop_active_daemon_stops_unixnotis_without_disabling_its_unit() { - let root = fake_daemon_tool_root("unixnotis-reinstall-stop"); - let calls = root.join("systemctl-calls"); - write_executable( - &root.join("systemctl"), - &format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\n", calls.display()), - ); - let _commands = crate::service_manager::contract::command_routing::use_fake_command_bin(&root); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - let detection = known_daemon_detection("unixnotis-daemon", true, Vec::new()); - let paths = test_install_paths(); - let (tx, _rx) = mpsc::sync_channel::(8); - let mut ctx = action_context(&detection, &paths, tx); - - stop_active_daemon(&mut ctx).expect("reinstall stop should succeed"); - - let calls = std::fs::read_to_string(&calls).expect("systemctl calls"); - assert_eq!(calls.trim(), "--user stop unixnotis-daemon.service"); - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn systemd_stop_error_can_continue_when_unit_is_inactive() { - // A failed stop is acceptable only when systemd reports a non-running state - assert!(systemd_stop_error_is_satisfied_by_state("inactive")); -} - -#[test] -fn systemd_stop_error_can_continue_when_unit_is_failed() { - // Failed units no longer own the notification bus, so reinstall may continue - assert!(systemd_stop_error_is_satisfied_by_state("failed")); -} - -#[test] -fn systemd_stop_error_still_fails_when_unit_stays_active() { - assert!(!systemd_stop_error_is_satisfied_by_state("active")); -} - -#[test] -fn systemd_stop_error_still_fails_when_unit_is_transitioning() { - assert!(!systemd_stop_error_is_satisfied_by_state("deactivating")); -} - -#[test] -fn systemd_stop_error_still_fails_when_state_is_empty() { - // Empty output means the manager did not provide enough proof that stopping succeeded - assert!(!systemd_stop_error_is_satisfied_by_state("")); -} - -#[test] -fn systemd_stop_error_trims_state_output_before_matching() { - // systemctl prints a trailing newline in normal output - assert!(systemd_stop_error_is_satisfied_by_state(" inactive\n")); - assert!(systemd_stop_error_is_satisfied_by_state("\tunknown ")); -} - -#[test] -fn systemd_stop_error_rejects_unrecognized_non_running_words() { - // Only explicit systemd states should satisfy a failed stop - assert!(!systemd_stop_error_is_satisfied_by_state("dead")); - assert!(!systemd_stop_error_is_satisfied_by_state("stopped")); -} - -#[test] -fn is_systemd_unit_inactive_reads_trusted_systemctl_state() { - let _lock = crate::test_support::env::test_env_lock(); - let root = std::env::temp_dir().join(format!( - "unixnotis-daemon-systemctl-state-{}", - std::process::id() - )); - let fake_bin = root.join("bin"); - std::fs::create_dir_all(&fake_bin).expect("fake bin"); - write_executable( - &fake_bin.join("systemctl"), - "#!/bin/sh\ncase \"$3\" in inactive.service) echo inactive; exit 3 ;; active.service) echo active; exit 0 ;; *) exit 1 ;; esac\n", - ); - let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); - - assert!(is_systemd_unit_inactive("inactive.service").expect("inactive state")); - assert!(!is_systemd_unit_inactive("active.service").expect("active state")); - let error = - is_systemd_unit_inactive("missing.service").expect_err("empty failed status is an error"); - assert!(error - .to_string() - .contains("failed to read systemd unit state")); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn pid_alive_reports_current_process_as_alive() { - let pid = std::process::id(); - - // The current test process should always satisfy a kill -0 probe - assert!(pid_alive(pid).expect("current pid probe")); -} - -#[test] -fn pid_alive_reports_impossible_pid_as_not_alive() { - let alive = pid_alive(u32::MAX).expect("invalid pid probe should still run"); - - // A non-existent PID must not be treated as safe to signal - assert!(!alive); -} - -#[test] -fn pid_alive_probes_largest_valid_process_id() { - let root = fake_daemon_tool_root("max-pid"); - write_executable(&root.join("kill"), "#!/bin/sh\nexit 0\n"); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&root); - - assert!(pid_alive(i32::MAX as u32).expect("largest valid pid probe")); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn pid_alive_reports_zero_pid_as_not_alive() { - let alive = pid_alive(0).expect("zero pid probe should still run"); - - // PID 0 targets the caller's process group, not one daemon process - assert!(!alive); -} - -#[test] -fn pid_alive_ignores_kill_from_inherited_path() { - let _lock = crate::test_support::env::test_env_lock(); - let root = - std::env::temp_dir().join(format!("unixnotis-daemon-kill-path-{}", std::process::id())); - let path_bin = root.join("path-bin"); - let trusted_bin = root.join("trusted-bin"); - let marker = root.join("path-kill-ran"); - std::fs::create_dir_all(&path_bin).expect("path bin"); - std::fs::create_dir_all(&trusted_bin).expect("trusted bin"); - write_executable( - &path_bin.join("kill"), - &format!("#!/bin/sh\nprintf hit > {}\nexit 0\n", marker.display()), - ); - write_executable(&trusted_bin.join("kill"), "#!/bin/sh\nexit 0\n"); - let _path = EnvGuard::set("PATH", &path_bin); - let _tools = crate::system_tools::routing::use_fake_tool_bin(&trusted_bin); - - assert!(pid_alive(std::process::id()).expect("trusted pid probe")); - assert!(!marker.exists()); - - let _ = std::fs::remove_dir_all(root); -} - -#[test] -fn pid_matches_comm_rejects_wrong_process_name() { - let pid = std::process::id(); - - let matches = pid_matches_comm(pid, "definitely-not-unixnotis").expect("comm probe"); - - // PID reuse protection depends on rejecting mismatched command names - assert!(!matches); -} - -#[test] -fn pid_matches_comm_accepts_current_process_name_from_ps() { - let pid = std::process::id(); - let expected = std::fs::read_to_string(format!("/proc/{pid}/comm")) - .expect("proc should expose the current process name") - .trim() - .to_string(); - - let matches = pid_matches_comm(pid, &expected).expect("comm probe"); - - // A matching process name is the only case where stop logic may signal the PID - assert!(matches); -} - -#[test] -fn wait_for_exit_aborts_immediately_when_pid_name_no_longer_matches() { - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; - let paths = InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - }; - let (tx, _rx) = mpsc::sync_channel::(4); - let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: None, - log_tx: tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - }; - - let err = wait_for_exit( - &mut ctx, - std::process::id(), - "definitely-not-current-process", - ) - .expect_err("mismatched comm should abort"); - - // The wait loop must fail before sleeping when PID reuse is detected - assert!(err - .to_string() - .contains("no longer matches expected daemon")); -} - -fn fake_daemon_tool_root(label: &str) -> std::path::PathBuf { - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock moved backwards") - .as_nanos(); - let root = std::env::temp_dir().join(format!( - "unixnotis-daemon-{label}-{}-{stamp}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).expect("fake daemon tool bin"); - root -} - -fn known_daemon_detection(name: &str, systemd_active: bool, running_pids: Vec) -> Detection { - Detection { - owner: Some(OwnerInfo { - pid: Some(42), - comm: Some(name.to_string()), - }), - daemons: vec![DetectedDaemon { - name: name.to_string(), - unit: format!("{name}.service"), - systemd_active, - systemd_error: None, - running_pids, - is_owner: true, - }], - } -} - -fn test_install_paths() -> InstallPaths { - InstallPaths { - repo_root: std::env::temp_dir(), - bin_dir: std::env::temp_dir(), - service: ServiceManager::systemd_user(std::env::temp_dir()), - } -} - -fn action_context<'a>( - detection: &'a Detection, - paths: &'a InstallPaths, - log_tx: mpsc::SyncSender, -) -> ActionContext<'a> { - ActionContext { - detection, - paths, - install_state: None, - log_tx, - action_mode: ActionMode::Install, - restore_backup: None, - service_reload_required: Arc::new(AtomicBool::new(false)), - } -} - -struct EnvGuard { - name: &'static str, - previous: Option, -} - -impl EnvGuard { - fn set(name: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(name); - std::env::set_var(name, value); - Self { name, previous } - } -} - -impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } -} diff --git a/crates/unixnotis-installer/src/actions/tests/install_state.rs b/crates/unixnotis-installer/src/actions/tests/install_state.rs deleted file mode 100644 index 2ae38138e..000000000 --- a/crates/unixnotis-installer/src/actions/tests/install_state.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::path::PathBuf; - -use super::{BinaryState, InstallState}; -use crate::service_manager::{ServiceArtifact, ServiceArtifactKind}; - -use super::service_artifacts_are_present; - -#[test] -fn empty_service_artifact_list_is_not_installed() { - // A backend with no artifacts has not proved ownership of anything on disk - assert!(!service_artifacts_are_present(&[])); -} - -#[test] -fn missing_service_artifact_list_is_not_installed() { - let artifact = ServiceArtifact { - // Use a fixed missing path because this test only needs the safe-presence negative path - path: PathBuf::from("/tmp/unixnotis-missing-service-artifact"), - kind: ServiceArtifactKind::File, - contents: Some(String::new()), - mode: None, - }; - - // Non-empty lists still need every artifact to match the expected safe shape - assert!(!service_artifacts_are_present(&[artifact])); -} - -#[test] -fn install_state_requires_non_empty_binary_list_all_binaries_and_service_artifact() { - let base = InstallState { - binaries: vec![BinaryState { - name: "unixnotis-daemon".to_string(), - path: PathBuf::from("/tmp/unixnotis-daemon"), - exists: true, - }], - service_artifact_exists: true, - service_enabled: false, - service_active: false, - service_enabled_error: None, - service_active_error: None, - binary_warning: None, - service_conflicts: Vec::new(), - service_conflict_warnings: Vec::new(), - }; - - // Full install state needs at least one binary, every binary present, and a safe service artifact - assert!(base.is_installed()); - - let mut no_binaries = base.clone(); - no_binaries.binaries.clear(); - assert!(!no_binaries.is_installed()); - - let mut missing_binary = base.clone(); - missing_binary.binaries[0].exists = false; - assert!(!missing_binary.is_installed()); - - let mut missing_service = base; - missing_service.service_artifact_exists = false; - assert!(!missing_service.is_installed()); -} - -#[test] -fn fully_installed_requires_running_service_and_enabled_accessor_tracks_field() { - let mut state = InstallState { - binaries: vec![BinaryState { - name: "unixnotis-daemon".to_string(), - path: PathBuf::from("/tmp/unixnotis-daemon"), - exists: true, - }], - service_artifact_exists: true, - service_enabled: true, - service_active: false, - service_enabled_error: None, - service_active_error: None, - binary_warning: None, - service_conflicts: Vec::new(), - service_conflict_warnings: Vec::new(), - }; - - // Enabled state and active state are separate; install summary should not conflate them - assert!(state.is_installed()); - assert!(state.service_enabled()); - assert!(!state.is_fully_installed()); - - state.service_active = true; - - assert!(state.is_fully_installed()); -} diff --git a/crates/unixnotis-installer/src/actions/tests/plan.rs b/crates/unixnotis-installer/src/actions/tests/plan.rs index a597fda58..3b8de1679 100644 --- a/crates/unixnotis-installer/src/actions/tests/plan.rs +++ b/crates/unixnotis-installer/src/actions/tests/plan.rs @@ -1,6 +1,13 @@ +use std::sync::atomic::AtomicBool; +use std::sync::{mpsc, Arc}; + +use crate::actions::ActionContext; +use crate::app::events::UiMessage; use crate::model::ActionMode; +use crate::paths::InstallPaths; +use crate::service_manager::ServiceManager; -use super::{build_plan, steps_from_plan, StepKind}; +use super::{build_plan, run_step_with_reservation, steps_from_plan, StepKind}; #[test] fn install_plan_stays_focused_on_build_and_install() { @@ -62,3 +69,57 @@ fn steps_from_plan_uses_user_visible_labels() { ] ); } + +#[test] +fn restore_step_dispatches_to_validation_and_rejects_a_missing_backup() { + let root = crate::test_support::fs::unique_temp_path("plan-restore-dispatch"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user(root.join("units")), + }; + let (log_tx, _log_rx) = mpsc::sync_channel::(4); + let mut ctx = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Reset, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = run_step_with_reservation(StepKind::RestoreConfig, &mut ctx, None) + .expect_err("restore dispatch must preserve missing-backup validation"); + + assert!(error.to_string().contains("no backup directory selected")); +} + +#[test] +fn binary_install_rejects_calls_without_the_worker_owned_activation_guard() { + let root = crate::test_support::fs::unique_temp_path("plan-guarded-binary-install"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user(root.join("units")), + }; + let (log_tx, _log_rx) = mpsc::sync_channel::(4); + let mut ctx = ActionContext { + paths: &paths, + install_state: None, + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = run_step_with_reservation(StepKind::InstallBinaries, &mut ctx, None) + .expect_err("binary publication must require the worker-owned activation guard"); + + assert!(error + .to_string() + .contains("binary installation requires daemon activation reservation")); + assert!( + !paths.bin_dir.exists(), + "guard rejection must not mutate binaries" + ); +} diff --git a/crates/unixnotis-installer/src/actions/tests/process.rs b/crates/unixnotis-installer/src/actions/tests/process.rs index 4409c12bf..aac6ca34d 100644 --- a/crates/unixnotis-installer/src/actions/tests/process.rs +++ b/crates/unixnotis-installer/src/actions/tests/process.rs @@ -127,7 +127,7 @@ fn send_log_line_delivers_worker_log_event() { let _guard = lock_dropped_log_state(); let (tx, rx) = mpsc::sync_channel(1); - send_log_line(&tx, "hello".to_string()); + send_log_line(&tx, "hello"); let event = rx.try_recv().expect("log event"); assert!(matches!( @@ -141,7 +141,7 @@ fn send_log_line_sanitizes_before_queueing() { let _guard = lock_dropped_log_state(); let (tx, rx) = mpsc::sync_channel(1); - send_log_line(&tx, "unsafe\u{1b}[2Jline".to_string()); + send_log_line(&tx, "unsafe\u{1b}[2Jline"); let event = rx.try_recv().expect("log event"); assert!(matches!( diff --git a/crates/unixnotis-installer/src/actions/tests/state.rs b/crates/unixnotis-installer/src/actions/tests/state.rs index fbba21a03..067e14920 100644 --- a/crates/unixnotis-installer/src/actions/tests/state.rs +++ b/crates/unixnotis-installer/src/actions/tests/state.rs @@ -6,7 +6,6 @@ use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc}; use crate::app::events::{UiMessage, WorkerEvent}; -use crate::detect::Detection; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::service_manager::contract::command_routing::use_fake_command_bin; @@ -15,6 +14,7 @@ use crate::service_manager::{ServiceManager, MANAGED_DIRECTORY_MARKER_CONTENTS}; use crate::test_support::fs::write_executable; use super::{check_install_state, check_install_state_step, ActionContext}; +use crate::actions::conflicts::ServiceManagerConflictKind; #[test] fn dinit_artifact_backed_enablement_does_not_log_missing_enabled_command_error() { @@ -44,6 +44,43 @@ fn dinit_artifact_backed_enablement_does_not_log_missing_enabled_command_error() let _ = fs::remove_dir_all(root); } +#[test] +fn install_check_without_readiness_errors_logs_summary_and_succeeds() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("successful-install-check-summary"); + let _env = service_scan_env(&root); + let _fake_commands = fake_inactive_manager_commands(&root); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + let state = check_install_state(&paths); + let (log_tx, log_rx) = mpsc::sync_channel::(64); + let mut ctx = ActionContext { + paths: &paths, + install_state: Some(state), + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + check_install_state_step(&mut ctx).expect("a warning-only backend must pass install checks"); + + let logs = log_rx.try_iter().collect::>(); + assert!(logs.iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) if line == "- service enabled: no" + ))); + assert!(logs.iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) + if line == "Install will continue and update missing items." + ))); + fs::remove_dir_all(root).expect("remove successful install check fixture"); +} + #[test] fn install_state_rejects_foreign_runit_service_directory() { let _lock = crate::test_support::env::test_env_lock(); @@ -120,8 +157,12 @@ fn different_backend_artifacts_are_reported_as_install_conflict() { assert_eq!(state.service_conflicts.len(), 1); assert_eq!(state.service_conflicts[0].manager_label, "dinit --user"); - assert!(state.service_conflicts[0].installed); - assert!(!state.service_conflicts[0].active); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Installed)); + assert!(!state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Active)); let _ = fs::remove_dir_all(root); } @@ -159,13 +200,15 @@ fn same_backend_different_root_artifacts_are_reported_as_install_conflict() { state.service_conflicts[0].artifact_path, default_root.join("unixnotis-daemon") ); - assert!(state.service_conflicts[0].installed); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Installed)); let _ = fs::remove_dir_all(root); } #[test] -fn active_probe_errors_are_reported_as_conflict_warnings() { +fn active_probe_errors_are_fail_closed_as_indeterminate_conflicts() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("active-probe-warning"); let _env = service_scan_env(&root); @@ -176,10 +219,14 @@ fn active_probe_errors_are_reported_as_conflict_warnings() { // Non-executable command files force Command::status to return an io error fs::set_permissions(&systemctl, fs::Permissions::from_mode(0o644)) .expect("chmod non-executable systemctl"); - for command in ["sv", "s6-svstat"] { - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); let _fake_bin = use_fake_command_bin(&fake_bin); let paths = InstallPaths { repo_root: repo_root(), @@ -189,15 +236,351 @@ fn active_probe_errors_are_reported_as_conflict_warnings() { let state = check_install_state(&paths); - assert!(state.service_conflicts.is_empty()); - assert!(state - .service_conflict_warnings - .iter() - .any(|warning| warning.contains("could not check whether systemd --user is active"))); + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + assert!(state.service_conflicts[0].detail.as_deref().is_some_and( + |detail| detail.contains("could not establish whether systemd --user is reachable") + )); let _ = fs::remove_dir_all(root); } +#[test] +fn unavailable_alternate_managers_without_artifacts_do_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unavailable-empty-alternates"); + let _env = service_scan_env(&root); + let fake_bin = root.join("selected-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create selected manager tool directory"); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 1\n"); + // Strict test routing makes every omitted alternate manager program unavailable + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "missing irrelevant manager programs must not create ownership conflicts" + ); + fs::remove_dir_all(root).expect("remove unavailable alternate fixture"); +} + +#[test] +fn installed_sv_without_supervised_unixnotis_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("runit-tool-without-supervision"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'fail: %s: runsv not running\\n' \"$2\"\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unsupervised runit service must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove runit absence fixture"); +} + +#[test] +fn live_runit_service_without_artifacts_blocks_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("live-runit-without-artifacts"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\ncase \"$1\" in -V) exit 100 ;; status) printf 'run: %s: (pid 123) 2s\\n' \"$2\"; exit 0 ;; *) exit 100 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!( + state.service_conflicts[0].manager_label, + "runit user services" + ); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Active)); + fs::remove_dir_all(root).expect("remove live runit fixture"); +} + +#[test] +fn installed_s6_svstat_without_supervisor_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("s6-tool-without-supervision"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable(&fake_bin.join("s6-svstat"), "#!/bin/sh\nexit 1\n"); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "a missing s6 supervisor must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove s6 absence fixture"); +} + +#[test] +fn installed_dinitctl_without_loaded_unixnotis_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("dinit-tool-without-loaded-service"); + let _env = service_scan_env(&root); + let fake_bin = root.join("manager-bin"); + fs::create_dir_all(&fake_bin).expect("create manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\ncase \"$*\" in *' list') exit 0 ;; *' status '*) printf 'dinitctl: service not loaded.\\n' >&2; exit 1 ;; *) exit 1 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unloaded dinit service must be classified as absent" + ); + fs::remove_dir_all(root).expect("remove dinit absence fixture"); +} + +#[test] +fn installed_systemctl_without_user_manager_does_not_block_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("systemctl-without-user-manager"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'Failed to connect to bus\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unreachable alternate systemd user manager owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove unavailable systemd fixture"); +} + +#[test] +fn offline_systemd_user_manager_without_artifacts_does_not_block_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("offline-systemd-manager"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'offline\\n'\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an explicitly offline alternate systemd manager owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove offline systemd fixture"); +} + +#[test] +fn unknown_systemd_manager_failure_blocks_dinit_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unknown-systemd-manager-failure"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf 'systemctl query failed unexpectedly\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + fs::remove_dir_all(root).expect("remove unknown systemd fixture"); +} + +#[test] +fn installed_dinitctl_without_user_daemon_does_not_block_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("dinitctl-without-user-daemon"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'dinit-client: connecting to socket failed\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert!( + state.service_conflicts.is_empty(), + "an unreachable alternate dinit daemon owns no clean backend" + ); + fs::remove_dir_all(root).expect("remove unavailable dinit fixture"); +} + +#[test] +fn unknown_dinit_manager_failure_blocks_systemd_install() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("unknown-dinit-manager-failure"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'dinitctl query failed unexpectedly\\n' >&2\nexit 1\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = systemd_paths(&root); + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "dinit --user"); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::Indeterminate)); + fs::remove_dir_all(root).expect("remove unknown dinit fixture"); +} + +#[test] +fn reachable_alternate_manager_with_ambiguous_service_state_blocks_installation() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("reachable-manager-ambiguous-state"); + let _env = service_scan_env(&root); + let fake_bin = root.join("alternate-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create alternate manager tool directory"); + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n'; exit 0 ;; *' show '*) printf 'ambiguous-state\\n'; exit 0 ;; *) exit 1 ;; esac\n", + ); + let _fake_bin = use_fake_command_bin(&fake_bin); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert_eq!(state.service_conflicts[0].manager_label, "systemd --user"); + assert_eq!( + state.service_conflicts[0] + .kinds + .iter() + .filter(|kind| **kind == ServiceManagerConflictKind::Indeterminate) + .count(), + 1 + ); + fs::remove_dir_all(root).expect("remove ambiguous alternate fixture"); +} + +#[test] +fn partial_alternate_backend_artifacts_block_installation() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("partial-alternate-backend"); + let _env = service_scan_env(&root); + let fake_bin = root.join("selected-manager-bin"); + fs::create_dir_all(&fake_bin).expect("create selected manager tool directory"); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 1\n"); + // The dinit artifact remains authoritative even when dinitctl is unavailable + let _fake_commands = use_fake_command_bin(&fake_bin); + let dinit_root = root.join("home").join(".config").join("dinit.d"); + fs::create_dir_all(&dinit_root).expect("create partial dinit root"); + let binary = root.join("bin").join("unixnotis-daemon"); + fs::write( + dinit_root.join("unixnotis-daemon"), + format!("type = process\ncommand = {}\n", binary.display()), + ) + .expect("write partial dinit service"); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + + let state = check_install_state(&paths); + + assert_eq!(state.service_conflicts.len(), 1); + assert!(state.service_conflicts[0] + .kinds + .contains(&ServiceManagerConflictKind::PartialInstall)); + assert_eq!( + state.service_conflicts[0].artifact_paths, + [dinit_root.join("unixnotis-daemon")] + ); + fs::remove_dir_all(root).expect("remove partial backend fixture"); +} + #[test] fn install_check_blocks_when_different_backend_is_active() { let _lock = crate::test_support::env::test_env_lock(); @@ -206,14 +589,21 @@ fn install_check_blocks_when_different_backend_is_active() { let fake_bin = root.join("fake-bin"); let fake_systemctl = fake_bin.join("systemctl"); fs::create_dir_all(&fake_bin).expect("fake bin"); - for command in ["dinitctl", "sv", "s6-svstat"] { - // Only systemd should look active; every other backend probe should stay inactive - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'Service: unixnotis-daemon\\n State: STOPPED\\n'\n", + ); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); write_executable( &fake_systemctl, - "#!/bin/sh\ncase \" $* \" in *\" is-active \"*) exit 0 ;; *) exit 1 ;; esac\n", + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n' ;; *) printf 'LoadState=loaded\\nActiveState=active\\n' ;; esac\n", ); let _fake_bin = use_fake_command_bin(&fake_bin); let paths = InstallPaths { @@ -221,13 +611,8 @@ fn install_check_blocks_when_different_backend_is_active() { bin_dir: root.join("bin"), service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), }; - let detection = Detection { - owner: None, - daemons: Vec::new(), - }; let (log_tx, log_rx) = mpsc::sync_channel::(16); let mut ctx = ActionContext { - detection: &detection, paths: &paths, install_state: None, log_tx, @@ -252,6 +637,42 @@ fn install_check_blocks_when_different_backend_is_active() { let _ = fs::remove_dir_all(root); } +#[test] +fn install_check_blocks_when_selected_manager_activity_is_indeterminate() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("selected-backend-indeterminate"); + let _env = service_scan_env(&root); + let _fake_commands = fake_inactive_manager_commands(&root); + let paths = InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::dinit_user(root.join("home").join(".config").join("dinit.d")), + }; + let mut state = check_install_state(&paths); + state.service_active_error = Some("selected active probe failed".to_string()); + state.service_conflicts.clear(); + let (log_tx, log_rx) = mpsc::sync_channel::(16); + let mut ctx = ActionContext { + paths: &paths, + install_state: Some(state), + log_tx, + action_mode: ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + }; + + let error = check_install_state_step(&mut ctx) + .expect_err("an unknown selected-manager state must stop installation immediately"); + + assert!(error.to_string().contains("ownership is indeterminate")); + assert!(log_rx.try_iter().any(|event| matches!( + event, + UiMessage::Worker(WorkerEvent::LogLine(line)) + if line.contains("service status check failed: selected active probe failed") + ))); + fs::remove_dir_all(root).expect("remove selected-manager fixture"); +} + fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() @@ -311,15 +732,43 @@ fn service_scan_env(root: &Path) -> Vec { fn fake_inactive_manager_commands(root: &Path) -> impl Drop { let fake_bin = root.join("fake-inactive-bin"); fs::create_dir_all(&fake_bin).expect("fake inactive bin"); - for command in ["systemctl", "dinitctl", "sv", "s6-svstat"] { - // Exit 1 models a healthy inactive service for every active-state probe style - let path = fake_bin.join(command); - write_executable(&path, "#!/bin/sh\nexit 1\n"); - } + write_inactive_systemctl(&fake_bin); + write_executable( + &fake_bin.join("dinitctl"), + "#!/bin/sh\nprintf 'Service: unixnotis-daemon\\n State: STOPPED\\n'\n", + ); + write_executable( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf 'down: unixnotis\\n'\n", + ); + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\nprintf 'false\\n'\n", + ); // Active probes are command-backed, so route them away from the host managers use_fake_command_bin(&fake_bin) } +fn write_inactive_systemctl(fake_bin: &Path) { + write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in *' is-system-running'*) printf 'running\\n'; exit 0 ;; *' show '*) printf 'LoadState=not-found\\nActiveState=inactive\\n'; exit 0 ;; *) exit 1 ;; esac\n", + ); +} + +fn systemd_paths(root: &Path) -> InstallPaths { + InstallPaths { + repo_root: repo_root(), + bin_dir: root.join("bin"), + service: ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + struct EnvGuard { key: &'static str, old: Option, diff --git a/crates/unixnotis-installer/src/app/events.rs b/crates/unixnotis-installer/src/app/events.rs index 110a37991..7d071050f 100644 --- a/crates/unixnotis-installer/src/app/events.rs +++ b/crates/unixnotis-installer/src/app/events.rs @@ -19,7 +19,20 @@ pub enum UiMessage { pub enum WorkerEvent { StepStarted(usize), StepCompleted(usize), - StepFailed(usize, String), + StepFailed { + index: usize, + // The summary stays short enough for the progress header + summary: String, + // The complete anyhow chain stays in the bounded log view + detail: String, + }, + RecoveryRequired { + index: usize, + // The summary stays short enough for the progress header + summary: String, + // The complete anyhow chain stays in the bounded log view + detail: String, + }, LogLine(String), Finished, } diff --git a/crates/unixnotis-installer/src/app/handlers.rs b/crates/unixnotis-installer/src/app/handlers.rs index 4cd8ce08a..d6e8aa438 100644 --- a/crates/unixnotis-installer/src/app/handlers.rs +++ b/crates/unixnotis-installer/src/app/handlers.rs @@ -14,28 +14,29 @@ use crate::app::{App, MenuItem, ProgressState, Screen}; use crate::model::ActionMode; use crate::paths::InstallPaths; use crate::terminal::TerminalGuard; +use crate::ui; -pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Option { match key.code { - KeyCode::Char('q' | 'Q') => Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Up | KeyCode::Char('k') => { if app.menu_index > 0 { app.menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { if app.menu_index + 1 < App::menu_items().len() { app.menu_index += 1; } - Ok(None) + None } KeyCode::Char('r' | 'R') => { app.refresh(); - Ok(None) + None } KeyCode::Enter => match app.selected_menu() { - MenuItem::Quit => Ok(Some(ExitAction::None)), + MenuItem::Quit => Some(ExitAction::None), MenuItem::Action(mode) => { if mode == ActionMode::Reset { // Reset uses a submenu to avoid accidental destructive actions @@ -44,32 +45,32 @@ pub fn handle_welcome_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } -pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { app.screen = Screen::Welcome; - Ok(None) + None } KeyCode::Up | KeyCode::Char('k') => { // Clamp selection to keep navigation predictable in small terminals if app.reset_menu_index > 0 { app.reset_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { // Reset menu has three entries; enforce bounds if app.reset_menu_index < 2 { app.reset_menu_index += 1; } - Ok(None) + None } KeyCode::Enter => { match app.reset_menu_index { @@ -87,31 +88,31 @@ pub fn handle_reset_menu_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } -pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Option { match key.code { KeyCode::Esc => { app.screen = Screen::ResetMenu; - Ok(None) + None } KeyCode::Up | KeyCode::Char('k') => { // Backup selection should never underflow if app.restore_menu_index > 0 { app.restore_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { // Only advance selection when there are backup entries if app.restore_menu_index + 1 < app.restore_backups.len() { app.restore_menu_index += 1; } - Ok(None) + None } KeyCode::Enter => { // Restore proceeds only when a backup is selected @@ -119,9 +120,9 @@ pub fn handle_restore_select_key(app: &mut App, key: KeyEvent) -> Result Ok(None), + _ => None, } } @@ -153,7 +154,17 @@ pub fn handle_confirm_key( return Ok(Some(ExitAction::RunTrial { repo_root })); } ActionMode::Install | ActionMode::Uninstall | ActionMode::Reset => { - start_action(app, terminal_guard, ui_tx, mode)?; + start_action( + app, + |app| { + terminal_guard + .terminal_mut() + .draw(|frame| ui::draw(frame, app))?; + Ok(()) + }, + ui_tx, + mode, + )?; } } @@ -163,13 +174,17 @@ pub fn handle_confirm_key( } } -pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Option { if matches!(app.progress_state, ProgressState::Running) { - return Ok(None); + return None; + } + if matches!(app.progress_state, ProgressState::RecoveryRequired) { + // The worker still owns the installer lock and activation names + return matches!(key.code, KeyCode::Char('q' | 'Q')).then_some(ExitAction::None); } if let Some(ready_at) = app.progress_ready_at { if Instant::now() < ready_at { - return Ok(None); + return None; } } match key.code { @@ -183,41 +198,41 @@ pub fn handle_progress_key(app: &mut App, key: KeyEvent) -> Result Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Esc => { app.screen = Screen::Welcome; - Ok(None) + None } - _ => Ok(None), + _ => None, } } -pub fn handle_build_accel_key(app: &mut App, key: KeyEvent) -> Result> { +pub fn handle_build_accel_key(app: &mut App, key: KeyEvent) -> Option { match key.code { - KeyCode::Char('q' | 'Q') => Ok(Some(ExitAction::None)), + KeyCode::Char('q' | 'Q') => Some(ExitAction::None), KeyCode::Up | KeyCode::Char('k') => { if app.build_accel_menu_index > 0 { app.build_accel_menu_index -= 1; } - Ok(None) + None } KeyCode::Down | KeyCode::Char('j') => { if app.build_accel_menu_index + 1 < app.build_accel_menu_len() { app.build_accel_menu_index += 1; } - Ok(None) + None } KeyCode::Esc => { reset_to_menu(app); - Ok(None) + None } KeyCode::Enter => { handle_build_accel_enter(app); - Ok(None) + None } - _ => Ok(None), + _ => None, } } diff --git a/crates/unixnotis-installer/src/app/runtime.rs b/crates/unixnotis-installer/src/app/runtime.rs index dba6c7ad1..094d608d0 100644 --- a/crates/unixnotis-installer/src/app/runtime.rs +++ b/crates/unixnotis-installer/src/app/runtime.rs @@ -31,7 +31,7 @@ pub fn run_app(terminal_guard: &mut TerminalGuard, app: &mut App) -> Result { - if let Some(exit) = handle_event(app, terminal_guard, &ui_tx, input)? { + if let Some(exit) = handle_event(app, terminal_guard, &ui_tx, &input)? { return Ok(exit); } } @@ -54,18 +54,17 @@ fn handle_event( app: &mut App, terminal_guard: &mut TerminalGuard, ui_tx: &mpsc::SyncSender, - event: Event, + event: &Event, ) -> Result> { match event { Event::Key(key) => match app.screen { - Screen::Welcome => handle_welcome_key(app, key), - Screen::Confirm(mode) => handle_confirm_key(app, terminal_guard, ui_tx, key, mode), - Screen::ResetMenu => handle_reset_menu_key(app, key), - Screen::RestoreSelect => handle_restore_select_key(app, key), - Screen::Progress(_) => handle_progress_key(app, key), - Screen::BuildAccel => handle_build_accel_key(app, key), + Screen::Welcome => Ok(handle_welcome_key(app, *key)), + Screen::Confirm(mode) => handle_confirm_key(app, terminal_guard, ui_tx, *key, mode), + Screen::ResetMenu => Ok(handle_reset_menu_key(app, *key)), + Screen::RestoreSelect => Ok(handle_restore_select_key(app, *key)), + Screen::Progress(_) => Ok(handle_progress_key(app, *key)), + Screen::BuildAccel => Ok(handle_build_accel_key(app, *key)), }, - Event::Resize(_, _) => Ok(None), _ => Ok(None), } } diff --git a/crates/unixnotis-installer/src/app/state.rs b/crates/unixnotis-installer/src/app/state.rs index ba2054579..bd242e50d 100644 --- a/crates/unixnotis-installer/src/app/state.rs +++ b/crates/unixnotis-installer/src/app/state.rs @@ -1,6 +1,6 @@ //! UI state and event handling for the installer TUI -use crate::actions::{check_install_state, InstallState}; +use crate::actions::{check_install_state, InstallState, InstallationDisposition}; use crate::actions::{BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome}; use crate::checks::Checks; use crate::detect::Detection; @@ -21,6 +21,8 @@ pub enum ProgressState { Completed, // Action failed Failed, + // Recovery could not prove the disk/runtime state safe; the worker remains alive + RecoveryRequired, } #[cfg(test)] @@ -193,23 +195,28 @@ impl App { } } - pub fn build_accel_menu_len(&self) -> usize { + pub const fn build_accel_menu_len(&self) -> usize { // Keep menu length aligned with the chosen mode to avoid invalid indices match self.build_accel_menu_mode() { BuildAccelMenuMode::ReturnOnly => 1, - BuildAccelMenuMode::EnableOrSkip => 2, - BuildAccelMenuMode::Reinstall => 2, + BuildAccelMenuMode::EnableOrSkip | BuildAccelMenuMode::Reinstall => 2, } } pub fn action_label(&self, mode: ActionMode) -> &'static str { match mode { ActionMode::Install => self.install_label(), - ActionMode::Reset => "Reset config", _ => mode.label(), } } + pub fn installation_disposition(&self) -> InstallationDisposition { + self.install_state.as_ref().map_or( + InstallationDisposition::NotInstalled, + InstallState::disposition, + ) + } + pub fn refresh_backups(&mut self) { // Refresh the list of available backup directories for restore self.restore_backups = crate::actions::list_backup_dirs_for_ui(); @@ -217,15 +224,10 @@ impl App { } fn install_label(&self) -> &'static str { - // Installed state is derived from filesystem presence, not runtime health - if self - .install_state - .as_ref() - .is_some_and(crate::actions::InstallState::is_installed) - { - "Reinstall" - } else { - "Install" + match self.installation_disposition() { + InstallationDisposition::NotInstalled => "Install", + InstallationDisposition::InstalledHealthy => "Reinstall", + InstallationDisposition::RepairRequired => "Repair", } } diff --git a/crates/unixnotis-installer/src/app/tests/events.rs b/crates/unixnotis-installer/src/app/tests/events.rs index 1df78107e..ee1dc902e 100644 --- a/crates/unixnotis-installer/src/app/tests/events.rs +++ b/crates/unixnotis-installer/src/app/tests/events.rs @@ -28,18 +28,49 @@ fn ui_message_can_carry_release_status_update() { #[test] fn worker_event_failed_keeps_step_index_and_message() { - let event = WorkerEvent::StepFailed(3, "service start failed".to_string()); + let event = WorkerEvent::StepFailed { + index: 3, + summary: "service start failed".to_string(), + detail: "service start failed: bus unavailable".to_string(), + }; - // Failure events need both fields for progress rendering and final error text + // Failure events keep a short status summary and a full diagnostic chain match event { - WorkerEvent::StepFailed(index, message) => { + WorkerEvent::StepFailed { + index, + summary, + detail, + } => { assert_eq!(index, 3); - assert_eq!(message, "service start failed"); + assert_eq!(summary, "service start failed"); + assert_eq!(detail, "service start failed: bus unavailable"); } _ => panic!("expected failed event"), } } +#[test] +fn worker_event_recovery_required_keeps_detailed_failure_without_finished_event() { + let event = WorkerEvent::RecoveryRequired { + index: 2, + summary: "rollback state is unknown".to_string(), + detail: "rollback state is unknown: journal unreadable".to_string(), + }; + + match event { + WorkerEvent::RecoveryRequired { + index, + summary, + detail, + } => { + assert_eq!(index, 2); + assert_eq!(summary, "rollback state is unknown"); + assert_eq!(detail, "rollback state is unknown: journal unreadable"); + } + _ => panic!("expected recovery-required event"), + } +} + #[test] fn worker_log_line_keeps_original_text() { let event = WorkerEvent::LogLine("Installed service artifact".to_string()); diff --git a/crates/unixnotis-installer/src/app/tests/handlers.rs b/crates/unixnotis-installer/src/app/tests/handlers.rs index 694dbe3bc..3a2e24423 100644 --- a/crates/unixnotis-installer/src/app/tests/handlers.rs +++ b/crates/unixnotis-installer/src/app/tests/handlers.rs @@ -21,14 +21,14 @@ fn vim_keys_move_welcome_menu_like_arrow_keys() { let mut app = App::new(None); // j/k should mirror Down/Up without changing menu bounds - handle_welcome_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_welcome_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.menu_index, 1); - handle_welcome_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_welcome_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.menu_index, 0); // Extra movement at the top should clamp instead of wrapping - handle_welcome_key(&mut app, key(KeyCode::Char('k'))).expect("k should clamp at top"); + handle_welcome_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.menu_index, 0); } @@ -37,7 +37,7 @@ fn welcome_menu_quit_key_exits_without_starting_action() { let _lock = crate::test_support::env::test_env_lock(); let mut app = App::new(None); - let action = handle_welcome_key(&mut app, key(KeyCode::Char('q'))).expect("q should exit"); + let action = handle_welcome_key(&mut app, key(KeyCode::Char('q'))); // Quit should be an explicit exit action, not a silent screen transition assert!(matches!(action, Some(ExitAction::None))); @@ -50,13 +50,13 @@ fn welcome_enter_opens_confirm_or_reset_submenu_for_selected_action() { let mut app = App::new(None); app.menu_index = 1; - handle_welcome_key(&mut app, key(KeyCode::Enter)).expect("install enter"); + handle_welcome_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Install)); app.screen = Screen::Welcome; app.menu_index = 2; app.reset_menu_index = 2; - handle_welcome_key(&mut app, key(KeyCode::Enter)).expect("reset enter"); + handle_welcome_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::ResetMenu); assert_eq!(app.reset_menu_index, 0); } @@ -67,12 +67,12 @@ fn vim_keys_move_reset_menu_like_arrow_keys() { let mut app = App::new(None); // Reset has a fixed three-entry menu, so j/k must stay within 0..=2 - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))).expect("j should clamp"); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); + handle_reset_menu_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.reset_menu_index, 2); - handle_reset_menu_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_reset_menu_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.reset_menu_index, 1); } @@ -82,22 +82,22 @@ fn reset_menu_escape_and_enter_select_expected_destinations() { let mut app = App::new(None); app.screen = Screen::ResetMenu; - handle_reset_menu_key(&mut app, key(KeyCode::Esc)).expect("escape should return"); + handle_reset_menu_key(&mut app, key(KeyCode::Esc)); assert_eq!(app.screen, Screen::Welcome); app.screen = Screen::ResetMenu; app.reset_menu_index = 0; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("defaults enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Reset)); app.screen = Screen::ResetMenu; app.reset_menu_index = 1; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("restore enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::RestoreSelect); app.screen = Screen::ResetMenu; app.reset_menu_index = 2; - handle_reset_menu_key(&mut app, key(KeyCode::Enter)).expect("cancel enter"); + handle_reset_menu_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Welcome); } @@ -107,14 +107,14 @@ fn vim_keys_move_restore_selection_only_when_backups_exist() { let mut app = App::new(None); // Empty restore lists should not underflow or invent a selection - handle_restore_select_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.restore_menu_index, 0); app.restore_backups = vec!["first".into(), "second".into()]; - handle_restore_select_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.restore_menu_index, 1); - handle_restore_select_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_restore_select_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.restore_menu_index, 0); } @@ -124,16 +124,16 @@ fn restore_selection_escape_and_enter_only_confirm_existing_backup() { let mut app = App::new(None); app.screen = Screen::RestoreSelect; - handle_restore_select_key(&mut app, key(KeyCode::Enter)).expect("empty enter"); + handle_restore_select_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::RestoreSelect); app.restore_backups = vec!["first".into(), "second".into()]; app.restore_menu_index = 1; - handle_restore_select_key(&mut app, key(KeyCode::Enter)).expect("backup enter"); + handle_restore_select_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Confirm(ActionMode::Reset)); app.screen = Screen::RestoreSelect; - handle_restore_select_key(&mut app, key(KeyCode::Esc)).expect("escape should return"); + handle_restore_select_key(&mut app, key(KeyCode::Esc)); assert_eq!(app.screen, Screen::ResetMenu); } @@ -144,13 +144,13 @@ fn progress_screen_ignores_keys_while_running_and_returns_after_completion() { app.screen = Screen::Progress(ActionMode::Uninstall); app.progress_state = ProgressState::Running; - let action = handle_progress_key(&mut app, key(KeyCode::Char('q'))).expect("running key"); + let action = handle_progress_key(&mut app, key(KeyCode::Char('q'))); assert!(action.is_none()); assert_eq!(app.screen, Screen::Progress(ActionMode::Uninstall)); app.progress_state = ProgressState::Completed; app.progress_ready_at = None; - handle_progress_key(&mut app, key(KeyCode::Enter)).expect("completed enter"); + handle_progress_key(&mut app, key(KeyCode::Enter)); assert_eq!(app.screen, Screen::Welcome); assert_eq!(app.progress_state, ProgressState::Idle); } @@ -163,15 +163,33 @@ fn progress_screen_quit_and_escape_work_after_action_finishes() { app.progress_state = ProgressState::Failed; app.progress_ready_at = None; - let action = handle_progress_key(&mut app, key(KeyCode::Char('Q'))).expect("quit key"); + let action = handle_progress_key(&mut app, key(KeyCode::Char('Q'))); assert!(matches!(action, Some(ExitAction::None))); app.screen = Screen::Progress(ActionMode::Install); - let action = handle_progress_key(&mut app, key(KeyCode::Esc)).expect("escape key"); + let action = handle_progress_key(&mut app, key(KeyCode::Esc)); assert!(action.is_none()); assert_eq!(app.screen, Screen::Welcome); } +#[test] +fn recovery_required_progress_allows_only_quit() { + let _lock = crate::test_support::env::test_env_lock(); + let mut app = App::new(None); + app.screen = Screen::Progress(ActionMode::Install); + app.progress_state = ProgressState::RecoveryRequired; + app.progress_ready_at = None; + + assert!(handle_progress_key(&mut app, key(KeyCode::Enter)).is_none()); + assert_eq!(app.screen, Screen::Progress(ActionMode::Install)); + assert!(handle_progress_key(&mut app, key(KeyCode::Esc)).is_none()); + assert_eq!(app.screen, Screen::Progress(ActionMode::Install)); + assert!(matches!( + handle_progress_key(&mut app, key(KeyCode::Char('q'))), + Some(ExitAction::None) + )); +} + #[test] fn progress_screen_respects_ready_delay_after_completion() { let _lock = crate::test_support::env::test_env_lock(); @@ -180,7 +198,7 @@ fn progress_screen_respects_ready_delay_after_completion() { app.progress_state = ProgressState::Completed; app.progress_ready_at = Some(Instant::now() + Duration::from_mins(1)); - let action = handle_progress_key(&mut app, key(KeyCode::Enter)).expect("delayed enter"); + let action = handle_progress_key(&mut app, key(KeyCode::Enter)); // The short delay prevents fast key repeats from skipping the completion state assert!(action.is_none()); @@ -196,7 +214,7 @@ fn completed_install_progress_enters_build_accel_prompt() { app.progress_state = ProgressState::Completed; app.progress_ready_at = None; - handle_progress_key(&mut app, key(KeyCode::Enter)).expect("completed install enter"); + handle_progress_key(&mut app, key(KeyCode::Enter)); // Successful install should offer the optional build acceleration prompt before returning assert_eq!(app.screen, Screen::BuildAccel); @@ -217,11 +235,11 @@ fn vim_keys_move_build_accel_menu_like_arrow_keys() { }); // Build acceleration uses dynamic menu length, so j/k must respect that mode - handle_build_accel_key(&mut app, key(KeyCode::Char('j'))).expect("j should be handled"); - handle_build_accel_key(&mut app, key(KeyCode::Char('j'))).expect("j should clamp"); + handle_build_accel_key(&mut app, key(KeyCode::Char('j'))); + handle_build_accel_key(&mut app, key(KeyCode::Char('j'))); assert_eq!(app.build_accel_menu_index, 1); - handle_build_accel_key(&mut app, key(KeyCode::Char('k'))).expect("k should be handled"); + handle_build_accel_key(&mut app, key(KeyCode::Char('k'))); assert_eq!(app.build_accel_menu_index, 0); } @@ -232,11 +250,11 @@ fn build_accel_escape_and_quit_have_distinct_outcomes() { app.screen = Screen::BuildAccel; app.progress_state = ProgressState::Completed; - let quit = handle_build_accel_key(&mut app, key(KeyCode::Char('q'))).expect("quit"); + let quit = handle_build_accel_key(&mut app, key(KeyCode::Char('q'))); assert!(matches!(quit, Some(ExitAction::None))); assert_eq!(app.screen, Screen::BuildAccel); - handle_build_accel_key(&mut app, key(KeyCode::Esc)).expect("escape"); + handle_build_accel_key(&mut app, key(KeyCode::Esc)); // Escape returns to the menu and clears stale progress, unlike q which exits assert_eq!(app.screen, Screen::Welcome); diff --git a/crates/unixnotis-installer/src/app/tests/state.rs b/crates/unixnotis-installer/src/app/tests/state.rs index 10d49c71a..3c16e87ea 100644 --- a/crates/unixnotis-installer/src/app/tests/state.rs +++ b/crates/unixnotis-installer/src/app/tests/state.rs @@ -1,8 +1,13 @@ use std::collections::VecDeque; use std::fs; +use std::os::unix::fs::{symlink, PermissionsExt}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::actions::{check_install_state, BuildAccelConfigStatus, BuildAccelDetection}; +use sha2::{Digest, Sha256}; + +use crate::actions::{ + check_install_state, BuildAccelConfigStatus, BuildAccelDetection, InstallationDisposition, +}; use crate::app::{App, BuildAccelMenuMode, BuildAccelState, MenuItem, ProgressState, Screen}; use crate::checks::{CheckItem, CheckState, Checks}; use crate::detect::Detection; @@ -31,6 +36,46 @@ fn selected_menu_clamps_out_of_range_index_to_last_item() { assert_eq!(app.selected_menu(), MenuItem::Quit); } +#[test] +fn refresh_reloads_environment_checks_instead_of_retaining_stale_state() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("app-refresh-checks"); + let _session = crate::test_support::env::EnvGuard::set("XDG_SESSION_TYPE", "x11"); + let _display = crate::test_support::env::EnvGuard::set("WAYLAND_DISPLAY", ""); + let _runtime = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", root.join("run")); + let _home = crate::test_support::env::EnvGuard::set("HOME", root.join("home")); + let _config = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", root.join("config")); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let mut app = app_with_build_accel(None); + + assert_eq!(app.checks.wayland.state, CheckState::Ok); + app.refresh(); + + assert_eq!(app.checks.wayland.state, CheckState::Fail); + fs::remove_dir_all(root).expect("remove refresh fixture"); +} + +#[test] +fn refresh_backups_replaces_stale_rows_and_resets_selection() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("app-refresh-backups"); + let config_home = root.join("config"); + let backup = config_home.join("unixnotis").join("Backup-2026-08-08"); + fs::create_dir_all(&backup).expect("backup fixture"); + let _config = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", &config_home); + let mut app = app_with_build_accel(None); + app.restore_backups = vec![root.join("stale-backup")]; + app.restore_menu_index = 9; + + app.refresh_backups(); + + assert_eq!(app.restore_backups, [backup]); + assert_eq!(app.restore_menu_index, 0); + fs::remove_dir_all(root).expect("remove backup refresh fixture"); +} + #[test] fn build_accel_menu_mode_returns_only_when_no_prompt_state_exists() { let app = app_with_build_accel(None); @@ -81,25 +126,75 @@ fn action_label_uses_install_wording_when_state_is_unknown() { } #[test] -fn action_label_uses_reinstall_when_expected_artifacts_are_present() { +fn action_label_distinguishes_healthy_install_from_missing_service_artifact() { + let _lock = crate::test_support::env::test_env_lock(); let root = test_root("app-reinstall-label"); let repo_root = root.join("repo"); let bin_dir = root.join("bin"); - let systemd_dir = root.join("systemd"); + let systemd_dir = root.join("config").join("systemd").join("user"); + let _home = crate::test_support::env::EnvGuard::set("HOME", root.join("home")); + let _user = crate::test_support::env::EnvGuard::set("USER", "unixnotis-test"); + let _config_home = + crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", root.join("config")); + let _runit = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_RUNIT_SERVICE_DIR", root.join("runit")); + let _svdir = crate::test_support::env::EnvGuard::set("SVDIR", root.join("runit")); + let _s6_data = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_S6_DATA_DIR", root.join("s6")); + let _s6_live = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_S6RC_LIVE_DIR", root.join("s6-live")); fs::create_dir_all(&repo_root).expect("repo dir"); fs::create_dir_all(&bin_dir).expect("bin dir"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + crate::test_support::fs::write_executable( + &fake_bin.join("systemctl"), + "#!/bin/sh\ncase \"$*\" in\n *is-enabled*) exit 0 ;;\nesac\nprintf '%s\\n' 'LoadState=loaded' 'ActiveState=inactive'\nexit 0\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); - // Minimal workspace metadata keeps the install-state check focused on one - // binary while still using the real metadata parser + // A complete release inventory keeps the install-state check focused on one binary fs::write( - repo_root.join("Cargo.toml"), - r#" -[workspace.metadata.unixnotis.installer] -binaries = ["unixnotis-daemon"] -"#, + repo_root.join("unixnotis-release.json"), + r#"{"version":"test","binaries":["unixnotis-daemon"]}"#, ) - .expect("workspace metadata"); - fs::write(bin_dir.join("unixnotis-daemon"), "#!/bin/sh\n").expect("installed binary"); + .expect("release metadata"); + fs::create_dir_all(repo_root.join("bin")).expect("release source directory"); + fs::write(repo_root.join("bin/unixnotis-daemon"), "release source") + .expect("release source binary"); + let binary_contents = b"#!/bin/sh\n"; + let digest = format!("{:x}", Sha256::digest(binary_contents)); + let size = u64::try_from(binary_contents.len()).expect("test binary size fits u64"); + let build_id = release_build_id("test", "unixnotis-daemon", size, &digest); + let generation = format!("test-{build_id}"); + let release_root = root + .join("lib") + .join("unixnotis") + .join("releases") + .join(&generation); + let release_binary = release_root.join("bin").join("unixnotis-daemon"); + fs::create_dir_all(release_binary.parent().expect("release binary parent")) + .expect("release binary directory"); + fs::write(&release_binary, binary_contents).expect("installed release binary"); + fs::set_permissions(&release_binary, fs::Permissions::from_mode(0o755)) + .expect("installed release binary mode"); + fs::write( + release_root.join("manifest.json"), + format!( + "{{\"schema_version\":1,\"package_version\":\"test\",\"build_id\":\"{build_id}\",\"binaries\":{{\"unixnotis-daemon\":{{\"size\":{size},\"sha256\":\"{digest}\"}}}}}}" + ), + ) + .expect("installed release manifest"); + symlink( + std::path::Path::new("releases").join(generation), + root.join("lib/unixnotis/current"), + ) + .expect("current release link"); + symlink( + "../lib/unixnotis/current/bin/unixnotis-daemon", + bin_dir.join("unixnotis-daemon"), + ) + .expect("installed binary entrypoint"); let service = ServiceManager::systemd_user(systemd_dir); for artifact in service.artifacts(&bin_dir) { @@ -129,10 +224,33 @@ binaries = ["unixnotis-daemon"] // Installed binaries plus a safe service artifact should turn the primary // install action into a reinstall action in the TUI assert_eq!(app.action_label(ActionMode::Install), "Reinstall"); + assert_eq!( + app.installation_disposition(), + InstallationDisposition::InstalledHealthy + ); + + fs::remove_file(paths.service.primary_artifact_path()).expect("remove primary artifact"); + app.install_state = Some(check_install_state(&paths)); + + // Existing verified binaries with an incomplete service install need repair, not a fresh install + assert_eq!(app.action_label(ActionMode::Install), "Repair"); + assert_eq!( + app.installation_disposition(), + InstallationDisposition::RepairRequired + ); let _ = fs::remove_dir_all(root); } +fn release_build_id(package_version: &str, binary_name: &str, size: u64, digest: &str) -> String { + let mut release_digest = Sha256::new(); + release_digest.update(package_version.as_bytes()); + release_digest.update(binary_name.as_bytes()); + release_digest.update(size.to_le_bytes()); + release_digest.update(digest.as_bytes()); + format!("{:x}", release_digest.finalize()) +} + fn app_with_build_accel(detection: Option) -> App { App { checks: passing_checks(), diff --git a/crates/unixnotis-installer/src/app/workflow.rs b/crates/unixnotis-installer/src/app/workflow.rs deleted file mode 100644 index 06c3d2d64..000000000 --- a/crates/unixnotis-installer/src/app/workflow.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Action workflow, worker coordination, and state transitions for the installer - -use anyhow::Result; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::mpsc; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -use crate::actions::{ - build_plan, check_install_state, detect_build_accel, detect_build_accel_without_repo, run_step, - steps_from_plan, write_build_accel_config, ActionContext, BuildAccelOutcome, StepKind, -}; -use crate::app::events::{UiMessage, WorkerEvent}; -use crate::app::{App, ProgressState, Screen}; -use crate::model::{ActionMode, StepStatus}; -use crate::paths::InstallPaths; -use crate::terminal::TerminalGuard; -use crate::ui; - -pub fn start_action( - app: &mut App, - terminal_guard: &mut TerminalGuard, - ui_tx: &mpsc::SyncSender, - mode: ActionMode, -) -> Result<()> { - // Resolve paths once so every step in this action uses the same install target - let paths = InstallPaths::discover_with_service_manager(app.service_manager)?; - // Install state is only needed for install decisions like service start mode - let install_state = if mode == ActionMode::Install { - Some(check_install_state(&paths)) - } else { - None - }; - - let (plan, restore_backup) = match mode { - ActionMode::Reset => match &app.reset_action { - // Default reset uses the normal reset plan - crate::model::ResetAction::ResetDefaults => (build_plan(mode), None), - crate::model::ResetAction::RestoreBackup { path } => { - // Restore runs only the restore step and carries the chosen backup path - (vec![StepKind::RestoreConfig], Some(path.clone())) - } - }, - _ => (build_plan(mode), None), - }; - - // Reset visible progress state before the worker starts sending events - app.steps = steps_from_plan(&plan); - app.logs.clear(); - app.last_error = None; - app.progress_state = ProgressState::Running; - app.progress_ready_at = None; - app.screen = Screen::Progress(mode); - - terminal_guard - .terminal_mut() - .draw(|frame| ui::draw(frame, app))?; - - // Detection is cloned so the worker can run without borrowing UI state - let detection = app.detection.clone(); - let ui_tx = ui_tx.clone(); - thread::spawn(move || { - run_action_worker( - plan, - mode, - detection, - paths, - install_state, - restore_backup, - ui_tx, - ); - }); - - Ok(()) -} - -fn run_action_worker( - plan: Vec, - mode: ActionMode, - detection: crate::detect::Detection, - paths: InstallPaths, - install_state: Option, - restore_backup: Option, - ui_tx: mpsc::SyncSender, -) { - // Run plan steps on the worker thread and stream progress events to the UI - // The flag lives across steps so install can decide later whether reload is needed - let service_reload_required = Arc::new(AtomicBool::new(true)); - for (index, step) in plan.iter().enumerate() { - // Index maps to app.steps in the UI state - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepStarted(index))); - - // Build per-step context; clone install_state to avoid borrow issues - let result = { - let mut ctx = ActionContext { - detection: &detection, - paths: &paths, - install_state: install_state.clone(), - log_tx: ui_tx.clone(), - action_mode: mode, - restore_backup: restore_backup.clone(), - service_reload_required: service_reload_required.clone(), - }; - run_step(*step, &mut ctx) - }; - - match result { - Ok(()) => { - // Successful steps advance the progress list in order - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepCompleted(index))); - } - Err(err) => { - // Stop the worker after the first failed step so later steps cannot compound damage - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepFailed( - index, - err.to_string(), - ))); - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); - return; - } - } - } - - let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); -} - -pub fn apply_worker_event(app: &mut App, event: WorkerEvent) { - match event { - WorkerEvent::StepStarted(index) => { - // Missing indices are ignored because UI state may have reset after worker start - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Running; - } - } - WorkerEvent::StepCompleted(index) => { - // Step completion is best-effort because the worker is decoupled from UI state - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Done; - } - } - WorkerEvent::StepFailed(index, err) => { - // Preserve the error message for the progress screen - if let Some(step) = app.steps.get_mut(index) { - step.status = StepStatus::Failed; - } - app.last_error = Some(err.clone()); - append_log(app, format!("Error: {err}")); - app.progress_state = ProgressState::Failed; - app.progress_ready_at = Some(std::time::Instant::now() + Duration::from_millis(400)); - } - WorkerEvent::LogLine(line) => { - // Worker logs are bounded by append_log - append_log(app, line); - } - WorkerEvent::Finished => { - // Finished should not overwrite a failed progress state - if matches!(app.progress_state, ProgressState::Running) { - app.progress_state = ProgressState::Completed; - app.progress_ready_at = - Some(std::time::Instant::now() + Duration::from_millis(400)); - } - } - } -} - -fn append_log(app: &mut App, line: String) { - // Bound log memory usage by trimming old entries - const MAX_LINES: usize = 200; - - app.logs.push_back(line); - - if app.logs.len() > MAX_LINES { - // VecDeque allows O(1) removal from the front - while app.logs.len() > MAX_LINES { - app.logs.pop_front(); - } - } -} - -pub fn reset_to_menu(app: &mut App) { - // Return every transient menu and progress field to the welcome state - app.screen = Screen::Welcome; - app.last_error = None; - app.logs.clear(); - app.steps.clear(); - app.progress_state = ProgressState::Idle; - app.progress_ready_at = None; - app.build_accel = None; - app.build_accel_menu_index = 0; - app.reset_menu_index = 0; - app.reset_action = crate::model::ResetAction::ResetDefaults; - app.restore_backups.clear(); - app.restore_menu_index = 0; - app.refresh(); -} - -pub fn prepare_build_accel_prompt(app: &mut App) { - // Snapshot detection so the prompt remains stable while the user decides - let detection = match InstallPaths::discover_with_service_manager(app.service_manager) { - Ok(paths) => detect_build_accel(&paths.repo_root), - Err(err) => detect_build_accel_without_repo(err.to_string()), - }; - app.build_accel = Some(crate::app::BuildAccelState { - detection, - outcome: None, - }); - app.build_accel_menu_index = 0; -} - -fn apply_build_accel_setup(app: &mut App) { - // Writes per-repository Cargo config only when explicitly requested - let Some(state) = app.build_accel.as_mut() else { - return; - }; - let paths = match InstallPaths::discover_with_service_manager(app.service_manager) { - Ok(paths) => paths, - Err(err) => { - state.outcome = Some(BuildAccelOutcome::Failed(err.to_string())); - return; - } - }; - let outcome = write_build_accel_config(&paths.repo_root, &state.detection); - state.outcome = Some(outcome); - // Keep selection on the only available action once a result is shown - app.build_accel_menu_index = 0; - // Refresh detection so config state is reflected in the prompt immediately - state.detection = detect_build_accel(&paths.repo_root); -} - -pub fn handle_build_accel_enter(app: &mut App) { - match app.build_accel_menu_mode() { - crate::app::BuildAccelMenuMode::ReturnOnly => { - // Completed prompt returns directly to the main menu - reset_to_menu(app); - } - crate::app::BuildAccelMenuMode::EnableOrSkip => { - // First entry enables acceleration, second entry skips it - if app.build_accel_menu_index == 0 { - apply_build_accel_setup(app); - } else { - reset_to_menu(app); - } - } - crate::app::BuildAccelMenuMode::Reinstall => { - // Reinstall mode keeps return first and setup second - if app.build_accel_menu_index == 0 { - reset_to_menu(app); - } else { - apply_build_accel_setup(app); - } - } - } -} - -#[cfg(test)] -#[path = "tests/workflow.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/app/workflow/build_accel.rs b/crates/unixnotis-installer/src/app/workflow/build_accel.rs new file mode 100644 index 000000000..0a4bf3266 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/build_accel.rs @@ -0,0 +1,71 @@ +//! Build acceleration prompt state and repository-local setup + +use crate::actions::{ + detect_build_accel, detect_build_accel_without_repo, write_build_accel_config, + BuildAccelOutcome, +}; +use crate::app::{App, BuildAccelMenuMode, BuildAccelState}; +use crate::paths::InstallPaths; + +pub fn prepare_build_accel_prompt(app: &mut App) { + // Snapshot detection so the prompt remains stable while the user decides + let detection = match InstallPaths::discover_with_service_manager(app.service_manager) { + Ok(paths) => detect_build_accel(&paths.repo_root), + Err(err) => detect_build_accel_without_repo(err.to_string()), + }; + app.build_accel = Some(BuildAccelState { + detection, + outcome: None, + }); + app.build_accel_menu_index = 0; +} + +fn apply_build_accel_setup(app: &mut App) { + // Writes per-repository Cargo config only when explicitly requested + let Some(state) = app.build_accel.as_mut() else { + return; + }; + let paths = match InstallPaths::discover_with_service_manager(app.service_manager) { + Ok(paths) => paths, + Err(err) => { + state.outcome = Some(BuildAccelOutcome::Failed(err.to_string())); + return; + } + }; + let outcome = write_build_accel_config(&paths.repo_root, &state.detection); + state.outcome = Some(outcome); + // Keep selection on the only available action once a result is shown + app.build_accel_menu_index = 0; + // Refresh detection so config state is reflected in the prompt immediately + state.detection = detect_build_accel(&paths.repo_root); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildAccelEnterAction { + ReturnToMenu, + ApplySetup, +} + +pub const fn build_accel_enter_action( + mode: BuildAccelMenuMode, + selected_index: usize, +) -> BuildAccelEnterAction { + // One pure mapping keeps menu order separate from filesystem side effects + match mode { + BuildAccelMenuMode::EnableOrSkip if selected_index == 0 => { + BuildAccelEnterAction::ApplySetup + } + BuildAccelMenuMode::Reinstall if selected_index != 0 => BuildAccelEnterAction::ApplySetup, + BuildAccelMenuMode::ReturnOnly + | BuildAccelMenuMode::EnableOrSkip + | BuildAccelMenuMode::Reinstall => BuildAccelEnterAction::ReturnToMenu, + } +} + +pub fn handle_build_accel_enter(app: &mut App) { + let action = build_accel_enter_action(app.build_accel_menu_mode(), app.build_accel_menu_index); + match action { + BuildAccelEnterAction::ReturnToMenu => super::reset_to_menu(app), + BuildAccelEnterAction::ApplySetup => apply_build_accel_setup(app), + } +} diff --git a/crates/unixnotis-installer/src/app/workflow/controller.rs b/crates/unixnotis-installer/src/app/workflow/controller.rs new file mode 100644 index 000000000..1f47c1ae1 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/controller.rs @@ -0,0 +1,70 @@ +//! Top-level action setup and worker launch + +use anyhow::Result; +use std::sync::mpsc; +use std::thread; + +use crate::actions::{build_plan, check_install_state, steps_from_plan, InstallerLock, StepKind}; +use crate::app::events::UiMessage; +use crate::app::workflow::worker::{action_requires_install_state, run_action_worker}; +use crate::app::{App, ProgressState, Screen}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; + +pub fn start_action( + app: &mut App, + draw_action: F, + ui_tx: &mpsc::SyncSender, + mode: ActionMode, +) -> Result<()> +where + F: FnOnce(&App) -> Result<()>, +{ + // Resolve paths once so every step in this action uses the same install target + let paths = InstallPaths::discover_with_service_manager(app.service_manager)?; + // The retained descriptor serializes every mutating step across installer processes + let installer_lock = InstallerLock::acquire_for_session()?; + // Install state is only needed for install decisions like service start mode + let install_state = if action_requires_install_state(mode) { + Some(check_install_state(&paths)) + } else { + None + }; + + let (plan, restore_backup) = match mode { + ActionMode::Reset => match &app.reset_action { + // Default reset uses the normal reset plan + crate::model::ResetAction::ResetDefaults => (build_plan(mode), None), + crate::model::ResetAction::RestoreBackup { path } => { + // Restore runs only the restore step and carries the chosen backup path + (vec![StepKind::RestoreConfig], Some(path.clone())) + } + }, + _ => (build_plan(mode), None), + }; + + // Reset visible progress state before the worker starts sending events + app.steps = steps_from_plan(&plan); + app.logs.clear(); + app.last_error = None; + app.progress_state = ProgressState::Running; + app.progress_ready_at = None; + app.screen = Screen::Progress(mode); + + draw_action(app)?; + + let ui_tx = ui_tx.clone(); + thread::spawn(move || { + let _installer_lock = installer_lock; + run_action_worker( + &plan, + mode, + &paths, + install_state.as_ref(), + restore_backup.as_deref(), + &ui_tx, + ); + }); + + Ok(()) +} diff --git a/crates/unixnotis-installer/src/app/workflow/events.rs b/crates/unixnotis-installer/src/app/workflow/events.rs new file mode 100644 index 000000000..6781fa801 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/events.rs @@ -0,0 +1,99 @@ +//! UI-facing workflow state transitions + +use std::time::Duration; + +use crate::app::events::WorkerEvent; +use crate::app::{App, ProgressState, Screen}; +use crate::model::StepStatus; + +pub fn apply_worker_event(app: &mut App, event: WorkerEvent) { + match event { + WorkerEvent::StepStarted(index) => { + // Missing indices are ignored because UI state may have reset after worker start + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Running; + } + } + WorkerEvent::StepCompleted(index) => { + // Step completion is best-effort because the worker is decoupled from UI state + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Done; + } + } + WorkerEvent::StepFailed { + index, + summary, + detail, + } => { + // Preserve the error message for the progress screen + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Failed; + } + app.last_error = Some(summary); + // Keep the compact summary in the status panel and the complete anyhow chain in logs + append_log(app, format!("Error: {detail}")); + app.progress_state = ProgressState::Failed; + app.progress_ready_at = Some(std::time::Instant::now() + Duration::from_millis(400)); + } + WorkerEvent::RecoveryRequired { + index, + summary, + detail, + } => { + // A recovery-required worker is still alive and still owns activation + if let Some(step) = app.steps.get_mut(index) { + step.status = StepStatus::Failed; + } + app.last_error = Some(summary); + append_log(app, format!("Error: {detail}")); + append_log( + app, + "CRITICAL: daemon activation remains inhibited because safe rollback could not be proven." + .to_string(), + ); + app.progress_state = ProgressState::RecoveryRequired; + app.progress_ready_at = None; + } + WorkerEvent::LogLine(line) => { + // Worker logs are bounded by append_log + append_log(app, line); + } + WorkerEvent::Finished => { + // Finished should not overwrite a failed progress state + if matches!(app.progress_state, ProgressState::Running) { + app.progress_state = ProgressState::Completed; + app.progress_ready_at = + Some(std::time::Instant::now() + Duration::from_millis(400)); + } + } + } +} + +fn append_log(app: &mut App, line: String) { + // Bound log memory usage by trimming old entries + const MAX_LINES: usize = 200; + + app.logs.push_back(line); + + // Each call adds one row, so at most one old row needs removal + if app.logs.len() > MAX_LINES { + let _oldest = app.logs.pop_front(); + } +} + +pub fn reset_to_menu(app: &mut App) { + // Return every transient menu and progress field to the welcome state + app.screen = Screen::Welcome; + app.last_error = None; + app.logs.clear(); + app.steps.clear(); + app.progress_state = ProgressState::Idle; + app.progress_ready_at = None; + app.build_accel = None; + app.build_accel_menu_index = 0; + app.reset_menu_index = 0; + app.reset_action = crate::model::ResetAction::ResetDefaults; + app.restore_backups.clear(); + app.restore_menu_index = 0; + app.refresh(); +} diff --git a/crates/unixnotis-installer/src/app/workflow/mod.rs b/crates/unixnotis-installer/src/app/workflow/mod.rs new file mode 100644 index 000000000..0367d8893 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/mod.rs @@ -0,0 +1,13 @@ +//! Installer workflow modules + +mod build_accel; +mod controller; +mod events; +mod recovery; +mod worker; + +pub(super) use build_accel::{handle_build_accel_enter, prepare_build_accel_prompt}; +pub(super) use controller::start_action; +pub(super) use events::{apply_worker_event, reset_to_menu}; +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-installer/src/app/workflow/recovery.rs b/crates/unixnotis-installer/src/app/workflow/recovery.rs new file mode 100644 index 000000000..c4afa27e6 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/recovery.rs @@ -0,0 +1,155 @@ +//! Failure classification and guarded rollback + +use anyhow::Result; +use std::sync::mpsc; +use std::thread; + +use crate::actions::{ + pending_release_exists, restart_previous_service, rollback_failed_activation, + rollback_pending_under_activation_reservation, ActionContext, DaemonActivationReservation, +}; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::workflow::worker::InstallLifecycle; + +pub enum InstallFailureRecovery { + Recovered(anyhow::Error), + ActivationInhibited(anyhow::Error), +} + +pub fn send_worker_failure(ui_tx: &mpsc::SyncSender, index: usize, err: &anyhow::Error) { + let summary = err.to_string(); + let detail = format!("{err:#}"); + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepFailed { + index, + summary, + detail, + })); +} + +pub fn send_recovery_required( + ui_tx: &mpsc::SyncSender, + index: usize, + err: &anyhow::Error, +) { + let summary = err.to_string(); + let detail = format!("{err:#}"); + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::RecoveryRequired { + index, + summary, + detail, + })); +} + +#[expect( + clippy::needless_pass_by_value, + reason = "the owned lifecycle must remain alive while this thread is parked" +)] +pub fn hold_activation_inhibition(lifecycle: InstallLifecycle) -> ! { + debug_assert!( + lifecycle.activation.is_some(), + "catastrophic recovery must retain the activation reservation" + ); + + // Keeping this stack frame alive keeps both the reservation and installer lock alive + loop { + thread::park(); + } +} + +pub fn recover_install_failure( + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, + activation_error: anyhow::Error, +) -> InstallFailureRecovery { + if lifecycle.activation.is_none() { + if lifecycle.release_pending { + return match rollback_failed_activation( + ctx, + &crate::actions::enforce_service_readiness, + activation_error, + ) { + Ok(()) => InstallFailureRecovery::Recovered(anyhow::anyhow!( + "failed install unexpectedly completed generation rollback without an error" + )), + Err(error) => InstallFailureRecovery::Recovered(error), + }; + } + + return InstallFailureRecovery::Recovered(activation_error); + } + + recover_guarded_failure_with_hooks( + ctx, + lifecycle, + activation_error, + pending_release_exists(ctx.paths), + rollback_pending_under_activation_reservation, + |ctx| restart_previous_service(ctx, &crate::actions::enforce_service_readiness), + ) +} + +pub fn recover_guarded_failure_with_hooks( + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, + activation_error: anyhow::Error, + pending: Result, + guarded_rollback: F, + restart_previous: R, +) -> InstallFailureRecovery +where + F: FnOnce(&mut ActionContext, &DaemonActivationReservation) -> Result, + R: FnOnce(&mut ActionContext) -> Result<()>, +{ + let pending = match pending { + Ok(value) => value, + Err(error) => { + return InstallFailureRecovery::ActivationInhibited(activation_error.context( + format!( + "could not determine pending release state; daemon activation remains inhibited: {error:#}" + ), + )); + } + }; + + if !pending { + if lifecycle.release_pending { + return InstallFailureRecovery::ActivationInhibited(activation_error.context( + "release state is inconsistent: worker expected a pending release but the recovery journal is missing; daemon activation remains inhibited", + )); + } + + lifecycle.activation.take(); + return InstallFailureRecovery::Recovered(activation_error); + } + + let rollback_result = { + let Some(reservation) = lifecycle.activation.as_ref() else { + return InstallFailureRecovery::ActivationInhibited( + activation_error + .context("activation reservation disappeared before guarded rollback"), + ); + }; + guarded_rollback(ctx, reservation) + }; + + match rollback_result { + Ok(restart) => { + lifecycle.activation.take(); + + if restart { + if let Err(error) = restart_previous(ctx) { + return InstallFailureRecovery::Recovered(activation_error.context(format!( + "previous generation failed after rollback: {error:#}" + ))); + } + } + + InstallFailureRecovery::Recovered(activation_error) + } + Err(rollback_error) => InstallFailureRecovery::ActivationInhibited(rollback_error.context( + format!( + "guarded rollback failed after the original install error: {activation_error:#}; daemon activation remains inhibited" + ), + )), + } +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs b/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs new file mode 100644 index 000000000..b7a601209 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/build_accel.rs @@ -0,0 +1,73 @@ +use super::super::build_accel::{ + build_accel_enter_action, handle_build_accel_enter, BuildAccelEnterAction, +}; +use crate::actions::{BuildAccelConfigStatus, BuildAccelDetection, BuildAccelOutcome}; +use crate::app::{App, BuildAccelMenuMode, BuildAccelState, Screen}; + +#[test] +fn build_accel_enter_selection_maps_each_menu_mode_to_one_action() { + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::ReturnOnly, 0), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::EnableOrSkip, 0), + BuildAccelEnterAction::ApplySetup + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::EnableOrSkip, 1), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::Reinstall, 0), + BuildAccelEnterAction::ReturnToMenu + ); + assert_eq!( + build_accel_enter_action(BuildAccelMenuMode::Reinstall, 1), + BuildAccelEnterAction::ApplySetup + ); +} + +#[test] +fn build_accel_enable_action_writes_repo_local_setup_and_records_the_outcome() { + let _lock = crate::test_support::env::test_env_lock(); + let root = crate::test_support::fs::unique_temp_path("workflow-build-accel-setup"); + let repo = root.join("repo"); + let home = root.join("home"); + let config_home = home.join(".config"); + std::fs::create_dir_all(&repo).expect("create build acceleration repo"); + std::fs::create_dir_all(&home).expect("create build acceleration home"); + std::fs::write( + repo.join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/unixnotis-daemon\", \"crates/unixnotis-core\"]\n", + ) + .expect("write build acceleration workspace identity"); + let _repo_env = crate::test_support::env::EnvGuard::set("UNIXNOTIS_REPO_ROOT", &repo); + let _home_env = crate::test_support::env::EnvGuard::set("HOME", &home); + let _config_env = crate::test_support::env::EnvGuard::set("XDG_CONFIG_HOME", &config_home); + let _manager_env = + crate::test_support::env::EnvGuard::set("UNIXNOTIS_SERVICE_MANAGER", "systemd"); + let mut app = App::new(None); + app.screen = Screen::BuildAccel; + app.build_accel = Some(BuildAccelState { + detection: BuildAccelDetection { + sccache_installed: true, + mold_installed: false, + config_status: BuildAccelConfigStatus::Missing, + }, + outcome: None, + }); + app.build_accel_menu_index = 0; + + handle_build_accel_enter(&mut app); + + assert!(matches!( + app.build_accel + .as_ref() + .and_then(|state| state.outcome.as_ref()), + Some(BuildAccelOutcome::Written { .. }) + )); + assert!(repo.join(".cargo/config.toml").is_file()); + assert_eq!(app.build_accel_menu_index, 0); + std::fs::remove_dir_all(root).expect("remove build acceleration workflow fixture"); +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/controller.rs b/crates/unixnotis-installer/src/app/workflow/tests/controller.rs new file mode 100644 index 000000000..9c1a6e57f --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/controller.rs @@ -0,0 +1,47 @@ +use std::sync::mpsc; + +use super::super::controller::start_action; +use super::super::worker::action_requires_install_state; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::{App, Screen}; +use crate::model::ActionMode; + +#[test] +fn start_action_prepares_and_draws_the_test_workflow_before_worker_completion() { + let _lock = crate::test_support::env::test_env_lock(); + let runtime = crate::test_support::fs::unique_temp_path("start-action-runtime"); + std::fs::create_dir_all(&runtime).expect("create action runtime directory"); + let _runtime_env = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", &runtime); + let mut app = App::new(None); + let mut draws = 0_u8; + let (tx, rx) = mpsc::sync_channel(8); + + start_action( + &mut app, + |_| { + draws = draws.saturating_add(1); + Ok(()) + }, + &tx, + ActionMode::Test, + ) + .expect("start empty test action"); + + assert_eq!(draws, 1); + assert_eq!(app.screen, Screen::Progress(ActionMode::Test)); + assert_eq!(app.progress_state, crate::app::ProgressState::Running); + assert!(matches!( + rx.recv_timeout(std::time::Duration::from_secs(1)) + .expect("worker completion event"), + UiMessage::Worker(WorkerEvent::Finished) + )); + std::fs::remove_dir_all(runtime).expect("remove action runtime directory"); +} + +#[test] +fn only_install_actions_capture_the_pre_action_install_state() { + assert!(action_requires_install_state(ActionMode::Install)); + assert!(!action_requires_install_state(ActionMode::Test)); + assert!(!action_requires_install_state(ActionMode::Reset)); + assert!(!action_requires_install_state(ActionMode::Uninstall)); +} diff --git a/crates/unixnotis-installer/src/app/tests/workflow.rs b/crates/unixnotis-installer/src/app/workflow/tests/events.rs similarity index 68% rename from crates/unixnotis-installer/src/app/tests/workflow.rs rename to crates/unixnotis-installer/src/app/workflow/tests/events.rs index 26e7bcac2..b23b0e18f 100644 --- a/crates/unixnotis-installer/src/app/tests/workflow.rs +++ b/crates/unixnotis-installer/src/app/workflow/tests/events.rs @@ -1,24 +1,9 @@ use crate::app::events::WorkerEvent; -use crate::app::workflow::{apply_worker_event, reset_to_menu}; -use crate::app::{App, BuildAccelState, ProgressState, Screen}; +use crate::app::{BuildAccelState, ProgressState, Screen}; use crate::model::{ActionStep, ResetAction, StepStatus}; -fn app_with_steps() -> App { - let _lock = crate::test_support::env::test_env_lock(); - let mut app = App::new(None); - app.steps = vec![ - ActionStep { - name: "first", - status: StepStatus::Pending, - }, - ActionStep { - name: "second", - status: StepStatus::Pending, - }, - ]; - app.progress_state = ProgressState::Running; - app -} +use super::super::events::{apply_worker_event, reset_to_menu}; +use super::support::app_with_steps; #[test] fn worker_step_events_update_only_existing_steps() { @@ -38,15 +23,53 @@ fn worker_step_events_update_only_existing_steps() { fn worker_failure_marks_step_logs_error_and_blocks_finished_from_success() { let mut app = app_with_steps(); - apply_worker_event(&mut app, WorkerEvent::StepFailed(1, "boom".to_string())); + apply_worker_event( + &mut app, + WorkerEvent::StepFailed { + index: 1, + summary: "boom".to_string(), + detail: "boom: nested cause".to_string(), + }, + ); apply_worker_event(&mut app, WorkerEvent::Finished); // Finished must not erase the failure state produced by the worker assert_eq!(app.steps[1].status, StepStatus::Failed); assert_eq!(app.progress_state, ProgressState::Failed); assert_eq!(app.last_error.as_deref(), Some("boom")); - assert_eq!(app.logs.back().map(String::as_str), Some("Error: boom")); + assert_eq!( + app.logs.back().map(String::as_str), + Some("Error: boom: nested cause") + ); assert!(app.progress_ready_at.is_some()); + assert!(app + .progress_ready_at + .is_some_and(|deadline| deadline > std::time::Instant::now())); +} + +#[test] +fn recovery_required_event_keeps_the_worker_state_inhibited() { + let mut app = app_with_steps(); + + apply_worker_event( + &mut app, + WorkerEvent::RecoveryRequired { + index: 1, + summary: "rollback failed".to_string(), + detail: "rollback failed: service state unknown".to_string(), + }, + ); + apply_worker_event(&mut app, WorkerEvent::Finished); + + // A catastrophic worker intentionally does not finish, so Finished cannot turn this into success + assert_eq!(app.steps[1].status, StepStatus::Failed); + assert_eq!(app.progress_state, ProgressState::RecoveryRequired); + assert_eq!(app.last_error.as_deref(), Some("rollback failed")); + assert_eq!( + app.logs.back().map(String::as_str), + Some("CRITICAL: daemon activation remains inhibited because safe rollback could not be proven.") + ); + assert!(app.progress_ready_at.is_none()); } #[test] @@ -58,6 +81,9 @@ fn worker_finished_marks_running_action_completed() { // Successful workers delay navigation briefly so users can read completion state assert_eq!(app.progress_state, ProgressState::Completed); assert!(app.progress_ready_at.is_some()); + assert!(app + .progress_ready_at + .is_some_and(|deadline| deadline > std::time::Instant::now())); } #[test] @@ -77,7 +103,7 @@ fn worker_logs_keep_recent_two_hundred_entries() { #[test] fn reset_to_menu_clears_transient_action_state() { let _lock = crate::test_support::env::test_env_lock(); - let mut app = App::new(None); + let mut app = crate::app::App::new(None); app.steps = vec![ActionStep { name: "first", status: StepStatus::Running, diff --git a/crates/unixnotis-installer/src/app/workflow/tests/mod.rs b/crates/unixnotis-installer/src/app/workflow/tests/mod.rs new file mode 100644 index 000000000..060ba57de --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/mod.rs @@ -0,0 +1,6 @@ +mod build_accel; +mod controller; +mod events; +mod recovery; +mod support; +mod worker; diff --git a/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs b/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs new file mode 100644 index 000000000..a447f50b8 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/recovery.rs @@ -0,0 +1,188 @@ +use anyhow::anyhow; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use super::super::recovery::{ + recover_guarded_failure_with_hooks, recover_install_failure, InstallFailureRecovery, +}; +use super::support::{guarded_lifecycle, recovery_context, recovery_paths}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkerFailureAction { + Return, + HoldActivation, +} + +const fn worker_failure_action(recovery: &InstallFailureRecovery) -> WorkerFailureAction { + match recovery { + InstallFailureRecovery::Recovered(_) => WorkerFailureAction::Return, + InstallFailureRecovery::ActivationInhibited(_) => WorkerFailureAction::HoldActivation, + } +} + +#[test] +fn pending_journal_inspection_failure_keeps_activation_inhibited() { + let root = crate::test_support::fs::unique_temp_path("workflow-pending-inspection-failure"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Err(anyhow!("journal unreadable")), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn guarded_rollback_failure_keeps_activation_inhibited() { + let root = crate::test_support::fs::unique_temp_path("workflow-rollback-failure"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(true), + |_ctx, _reservation| Err(anyhow!("rollback failed")), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn successful_guarded_rollback_releases_before_previous_restart() { + let root = crate::test_support::fs::unique_temp_path("workflow-rollback-success"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + let restart_saw_released = Arc::clone(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(true), + |_ctx, _reservation| Ok(true), + move |_ctx| { + assert!(!restart_saw_released.load(Ordering::Acquire)); + Ok(()) + }, + ); + + assert!(matches!(recovery, InstallFailureRecovery::Recovered(_))); + assert!(lifecycle.activation.is_none()); + assert!(!alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn missing_pending_journal_without_memory_mutation_is_an_ordinary_failure() { + let root = crate::test_support::fs::unique_temp_path("workflow-no-pending"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("staging failed"), + Ok(false), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!(recovery, InstallFailureRecovery::Recovered(_))); + assert!(lifecycle.activation.is_none()); + assert!(!alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn missing_pending_journal_with_memory_mutation_is_catastrophic() { + let root = crate::test_support::fs::unique_temp_path("workflow-pending-contradiction"); + let paths = recovery_paths(&root); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + lifecycle.release_pending = true; + + let recovery = recover_guarded_failure_with_hooks( + &mut ctx, + &mut lifecycle, + anyhow!("install failed"), + Ok(false), + |_ctx, _reservation| Ok(false), + |_ctx| Ok(()), + ); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} + +#[test] +fn catastrophic_recovery_selects_hold_action_without_blocking_the_test() { + let recovered = InstallFailureRecovery::Recovered(anyhow!("ordinary failure")); + let inhibited = InstallFailureRecovery::ActivationInhibited(anyhow!("unsafe to release")); + + assert_eq!( + worker_failure_action(&recovered), + WorkerFailureAction::Return + ); + assert_eq!( + worker_failure_action(&inhibited), + WorkerFailureAction::HoldActivation + ); +} + +#[test] +fn worker_recovery_keeps_the_real_guard_when_pending_inspection_fails() { + let root = crate::test_support::fs::unique_temp_path("workflow-real-pending-error"); + let paths = recovery_paths(&root); + let pending_path = paths + .installed_pending_manifest() + .expect("pending manifest path"); + std::fs::create_dir_all(&pending_path).expect("make unreadable pending manifest object"); + let mut ctx = recovery_context(&paths); + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = guarded_lifecycle(&alive); + + let recovery = recover_install_failure(&mut ctx, &mut lifecycle, anyhow!("install failed")); + + assert!(matches!( + recovery, + InstallFailureRecovery::ActivationInhibited(_) + )); + assert!(lifecycle.activation.is_some()); + assert!(alive.load(Ordering::Acquire)); + std::fs::remove_dir_all(root).expect("remove recovery fixture"); +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/support.rs b/crates/unixnotis-installer/src/app/workflow/tests/support.rs new file mode 100644 index 000000000..b5747860d --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/support.rs @@ -0,0 +1,117 @@ +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::sync::Arc; + +use super::super::worker::InstallLifecycle; +use crate::actions::DaemonActivationReservation; +use crate::app::{App, ProgressState}; +use crate::model::{ActionStep, StepStatus}; +use anyhow::{Context, Result}; + +pub(super) fn app_with_steps() -> App { + let _lock = crate::test_support::env::test_env_lock(); + let mut app = App::new(None); + app.steps = vec![ + ActionStep { + name: "first", + status: StepStatus::Pending, + }, + ActionStep { + name: "second", + status: StepStatus::Pending, + }, + ]; + app.progress_state = ProgressState::Running; + app +} + +pub(super) fn recovery_context( + paths: &crate::paths::InstallPaths, +) -> crate::actions::ActionContext<'_> { + let (tx, _rx) = mpsc::sync_channel(8); + crate::actions::ActionContext { + paths, + install_state: None, + log_tx: tx, + action_mode: crate::model::ActionMode::Install, + restore_backup: None, + service_reload_required: Arc::new(AtomicBool::new(false)), + } +} + +pub(super) fn recovery_paths(root: &std::path::Path) -> crate::paths::InstallPaths { + std::fs::create_dir_all(root).expect("create recovery fixture"); + crate::paths::InstallPaths { + repo_root: root.to_path_buf(), + bin_dir: root.join("home").join(".local").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + } +} + +pub(super) fn guarded_lifecycle(alive: &Arc) -> InstallLifecycle { + InstallLifecycle { + activation: Some(crate::actions::DaemonActivationReservation::test_guard( + Arc::clone(alive), + )), + release_pending: false, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the test seam names each lifecycle boundary explicitly" +)] +pub(super) fn run_install_lifecycle_with_hooks< + Stop, + Acquire, + Check, + Binary, + Service, + Prepare, + Start, +>( + lifecycle: &mut InstallLifecycle, + stop: Stop, + acquire: Acquire, + mut check_after_guard: Check, + install_binaries: Binary, + install_service: Service, + prepare_service: Prepare, + start: Start, +) -> Result<()> +where + Stop: FnOnce() -> Result<()>, + Acquire: FnOnce() -> Result, + Check: FnMut(&DaemonActivationReservation) -> Result<()>, + Binary: FnOnce(&DaemonActivationReservation) -> Result<()>, + Service: FnOnce(&DaemonActivationReservation) -> Result<()>, + Prepare: FnOnce(&DaemonActivationReservation) -> Result<()>, + Start: FnOnce() -> Result<()>, +{ + stop()?; + lifecycle.activation = Some(acquire()?); + + let reservation = lifecycle + .activation + .as_ref() + .context("test lifecycle lost activation reservation")?; + check_after_guard(reservation)?; + install_binaries(reservation)?; + install_service(reservation)?; + prepare_service(reservation)?; + check_after_guard(reservation)?; + + // The controlled start is the only point where the names may be released + drop( + lifecycle + .activation + .take() + .context("test lifecycle missing activation handoff")?, + ); + start() +} diff --git a/crates/unixnotis-installer/src/app/workflow/tests/worker.rs b/crates/unixnotis-installer/src/app/workflow/tests/worker.rs new file mode 100644 index 000000000..b0f269950 --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/tests/worker.rs @@ -0,0 +1,99 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::sync::Arc; + +use crate::actions::StepKind; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::model::ActionMode; + +use super::super::worker::{ + release_pending_after_completed_step, run_action_worker, InstallLifecycle, +}; +use super::support::run_install_lifecycle_with_hooks; + +#[test] +fn empty_worker_plan_still_reports_completion() { + let root = crate::test_support::fs::unique_temp_path("empty-worker-plan"); + let paths = crate::paths::InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("home").join(".local").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user( + root.join("home") + .join(".config") + .join("systemd") + .join("user"), + ), + }; + let (tx, rx) = mpsc::sync_channel(4); + + run_action_worker(&[], ActionMode::Install, &paths, None, None, &tx); + + assert!(matches!( + rx.recv_timeout(std::time::Duration::from_secs(1)) + .expect("worker completion event"), + UiMessage::Worker(WorkerEvent::Finished) + )); +} + +#[test] +fn worker_owned_guard_spans_install_steps_and_drops_before_controlled_start() { + let alive = Arc::new(AtomicBool::new(false)); + let mut lifecycle = InstallLifecycle::new(); + + run_install_lifecycle_with_hooks( + &mut lifecycle, + || Ok(()), + || { + Ok(crate::actions::DaemonActivationReservation::test_guard( + Arc::clone(&alive), + )) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + |reservation| { + assert!(alive.load(Ordering::Acquire)); + let _ = reservation; + Ok(()) + }, + || { + assert!(!alive.load(Ordering::Acquire)); + Ok(()) + }, + ) + .expect("complete guarded install lifecycle"); + + assert!(lifecycle.activation.is_none()); +} + +#[test] +fn release_rollback_state_starts_at_binary_activation_and_ends_after_readiness() { + assert!(release_pending_after_completed_step( + false, + StepKind::InstallBinaries + )); + assert!(!release_pending_after_completed_step( + true, + StepKind::EnableService + )); + assert!(release_pending_after_completed_step( + true, + StepKind::InstallService + )); + assert!(!release_pending_after_completed_step( + false, + StepKind::EnsureConfig + )); +} diff --git a/crates/unixnotis-installer/src/app/workflow/worker.rs b/crates/unixnotis-installer/src/app/workflow/worker.rs new file mode 100644 index 000000000..93fd5689f --- /dev/null +++ b/crates/unixnotis-installer/src/app/workflow/worker.rs @@ -0,0 +1,145 @@ +//! Worker execution and guarded installation lifecycle + +use anyhow::{Context, Result}; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::sync::Arc; + +use crate::actions::{ + commit_pending_release, ensure_selected_service_inactive, run_step_with_reservation, + start_service_and_verify, stop_active_daemon, ActionContext, DaemonActivationReservation, + StepKind, +}; +use crate::app::events::{UiMessage, WorkerEvent}; +use crate::app::workflow::recovery::{ + hold_activation_inhibition, recover_install_failure, send_recovery_required, + send_worker_failure, InstallFailureRecovery, +}; +use crate::model::ActionMode; +use crate::paths::InstallPaths; + +pub const fn action_requires_install_state(mode: ActionMode) -> bool { + matches!(mode, ActionMode::Install) +} + +pub struct InstallLifecycle { + // This guard lives across StopDaemon, binary publication, and service preparation + pub(super) activation: Option, + // A committed binary step leaves a reversible pending generation behind + pub(super) release_pending: bool, +} + +impl InstallLifecycle { + pub(super) const fn new() -> Self { + Self { + activation: None, + release_pending: false, + } + } +} + +pub fn run_action_worker( + plan: &[StepKind], + mode: ActionMode, + paths: &InstallPaths, + install_state: Option<&crate::actions::InstallState>, + restore_backup: Option<&std::path::Path>, + ui_tx: &mpsc::SyncSender, +) { + // Run plan steps on the worker thread and stream progress events to the UI + // The flag lives across steps so install can decide later whether reload is needed + let service_reload_required = Arc::new(AtomicBool::new(true)); + let mut lifecycle = InstallLifecycle::new(); + for (index, step) in plan.iter().enumerate() { + // Index maps to app.steps in the UI state + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepStarted(index))); + + // Build per-step context; clone install_state to avoid borrow issues + let mut ctx = ActionContext { + paths, + install_state: install_state.cloned(), + log_tx: ui_tx.clone(), + action_mode: mode, + restore_backup: restore_backup.map(std::path::Path::to_path_buf), + service_reload_required: service_reload_required.clone(), + }; + let result = if mode == ActionMode::Install { + run_install_step(*step, &mut ctx, &mut lifecycle) + } else { + run_step_with_reservation(*step, &mut ctx, None) + }; + + match result { + Ok(()) => { + lifecycle.release_pending = + release_pending_after_completed_step(lifecycle.release_pending, *step); + // Successful steps advance the progress list in order + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::StepCompleted(index))); + } + Err(err) => match recover_install_failure(&mut ctx, &mut lifecycle, err) { + InstallFailureRecovery::Recovered(err) => { + send_worker_failure(ui_tx, index, &err); + // Stop the worker after the first failed step so later steps cannot compound damage + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); + return; + } + InstallFailureRecovery::ActivationInhibited(err) => { + send_recovery_required(ui_tx, index, &err); + // The worker and its installer lock remain alive while this guard is held + hold_activation_inhibition(lifecycle); + } + }, + } + } + + let _ = ui_tx.send(UiMessage::Worker(WorkerEvent::Finished)); +} + +pub fn run_install_step( + step: StepKind, + ctx: &mut ActionContext, + lifecycle: &mut InstallLifecycle, +) -> Result<()> { + match step { + StepKind::StopDaemon => { + stop_active_daemon(ctx)?; + let reservation = DaemonActivationReservation::acquire() + .context("reserve daemon activation after shutdown")?; + ensure_selected_service_inactive(ctx.paths) + .context("recheck selected service after activation reservation")?; + lifecycle.activation = Some(reservation); + Ok(()) + } + StepKind::EnableService => { + { + let reservation = lifecycle + .activation + .as_ref() + .context("service start requires daemon activation reservation")?; + crate::actions::prepare_service_start_under_reservation(ctx, reservation)?; + ensure_selected_service_inactive(ctx.paths) + .context("verify service remains inactive after artifact refresh")?; + } + + // The next operation is the intentional handoff to the new daemon + let reservation = lifecycle + .activation + .take() + .context("missing activation reservation before controlled service start")?; + drop(reservation); + start_service_and_verify(ctx, crate::actions::enforce_service_readiness)?; + commit_pending_release(ctx.paths).context("commit ready binary release generation")?; + Ok(()) + } + _ => run_step_with_reservation(step, ctx, lifecycle.activation.as_ref()), + } +} + +pub(super) const fn release_pending_after_completed_step(current: bool, step: StepKind) -> bool { + match step { + // Binary activation stays reversible until the matching service passes readiness + StepKind::InstallBinaries => true, + StepKind::EnableService => false, + _ => current, + } +} diff --git a/crates/unixnotis-installer/src/checks/gtk.rs b/crates/unixnotis-installer/src/checks/gtk.rs index aa4a18732..0af72d053 100644 --- a/crates/unixnotis-installer/src/checks/gtk.rs +++ b/crates/unixnotis-installer/src/checks/gtk.rs @@ -1,40 +1,36 @@ //! GTK capability checks -use unixnotis_core::{ - gtk_css_features_from_version_string, GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL, -}; +use unixnotis_core::{gtk_css_features_from_version_string, GTK_MIN_VERSION_LABEL}; use super::system::pkg_config_version; use super::{CheckItem, CheckState}; pub(super) fn gtk4_css_features_check(pkg_config: &CheckItem) -> CheckItem { - // Modern CSS support is additive, so older GTK builds should warn instead of fail + // The shipped CSS contract requires the common GTK 4.18 baseline match pkg_config_version("gtk4") { Ok(Some(version)) => match gtk_css_features_from_version_string(&version) { Some(features) if features.custom_properties => CheckItem::ok( - "GTK4 CSS features", - &format!("found {version}; modern css variables and var() are available"), + "GTK4 (4.18+)", + &format!("found {version}; custom properties and var() are available"), ), - Some(_) => CheckItem::warn( - "GTK4 CSS features", - &format!( - "found {version}; legacy theming still works, but modern css variables need {GTK_CSS_CUSTOM_PROPERTIES_MIN_VERSION_LABEL}" - ), + Some(_) => CheckItem::fail( + "GTK4 (4.18+)", + &format!("found {version}; {GTK_MIN_VERSION_LABEL} is required"), ), - None => CheckItem::warn( - "GTK4 CSS features", - &format!("found {version}; css feature level could not be parsed"), + None => CheckItem::fail( + "GTK4 (4.18+)", + &format!("found {version}; GTK version could not be parsed"), ), }, - Ok(None) if pkg_config.state == CheckState::Fail => CheckItem::warn( - "GTK4 CSS features", - "pkg-config missing; cannot probe GTK4 css feature level", + Ok(None) if pkg_config.state == CheckState::Fail => CheckItem::fail( + "GTK4 (4.18+)", + "pkg-config missing; GTK 4.18 or newer is required", ), - Ok(None) => CheckItem::warn( - "GTK4 CSS features", - "pkg-config gtk4 not found; modern css feature support is unknown", + Ok(None) => CheckItem::fail( + "GTK4 (4.18+)", + "pkg-config gtk4 not found; GTK 4.18 or newer is required", ), - Err(err) => CheckItem::warn("GTK4 CSS features", &format!("check failed: {err}")), + Err(err) => CheckItem::fail("GTK4 (4.18+)", &format!("check failed: {err}")), } } diff --git a/crates/unixnotis-installer/src/checks/session.rs b/crates/unixnotis-installer/src/checks/session.rs index 682e3acb5..f9ada7dec 100644 --- a/crates/unixnotis-installer/src/checks/session.rs +++ b/crates/unixnotis-installer/src/checks/session.rs @@ -54,10 +54,10 @@ impl Checks { let gtk4_layer_shell = gtk::gtk4_layer_shell_check(&pkg_config); let busctl = system::busctl_check(); - let dbus_update_env = match &discovered_paths { - Ok(paths) => system::dbus_update_env_check(Some(&paths.service)), - Err(_) => system::dbus_update_env_check(None), - }; + let dbus_update_env = discovered_paths.as_ref().map_or_else( + |_error| system::dbus_update_env_check(None), + |paths| system::dbus_update_env_check(Some(&paths.service)), + ); let (install_paths, path_contains_bin) = match discovered_paths { Ok(paths) => { // Path discovery runs once so every later row reports the same install target @@ -107,6 +107,9 @@ impl Checks { .to_string(), ); } + if self.gtk4_css_features.state == CheckState::Fail { + return Err("GTK 4.18 or newer is required".to_string()); + } } ActionMode::Install => { // Install adds the writable path requirement on top of the runtime checks @@ -125,6 +128,9 @@ impl Checks { .to_string(), ); } + if self.gtk4_css_features.state == CheckState::Fail { + return Err("GTK 4.18 or newer is required".to_string()); + } if self.install_paths.state == CheckState::Fail { return Err("install paths are not writable".to_string()); } diff --git a/crates/unixnotis-installer/src/checks/system.rs b/crates/unixnotis-installer/src/checks/system.rs index dc4b5670c..5e91d85e5 100644 --- a/crates/unixnotis-installer/src/checks/system.rs +++ b/crates/unixnotis-installer/src/checks/system.rs @@ -1,13 +1,14 @@ //! Session and tool availability checks use std::env; -use std::fs::OpenOptions; use std::path::Path; use crate::paths::{InstallPaths, ServiceManagerChoice}; -use crate::service_manager::{CommandSpec, ReadinessIssue, ServiceManager}; +use crate::service_manager::contract::{ServiceManagerAvailability, ServiceProbeState}; +use crate::service_manager::{ReadinessIssue, ServiceManager}; use crate::system_tools; -use unixnotis_core::program_in_path; +use crate::toolchain::resolve_cargo; +use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; use super::CheckItem; @@ -46,33 +47,52 @@ pub(super) fn service_manager_check_from(manager: &ServiceManager) -> CheckItem // Hard readiness errors are shown before running optional availability probes return CheckItem::fail("Service manager", &detail); } - if let Some(spec) = manager.availability_command() { - // Backends with a native availability command still report softer setup warnings - return availability_check_item(manager, &spec, &issues); - } - if let Some(detail) = readiness_warning_detail(manager, &issues) { - // Some experimental backends have no global probe, so warnings become the check result - return CheckItem::warn("Service manager", &detail); + // One semantic interpreter is shared with conflict detection and activation checks + match manager.availability_state() { + Ok(Some(ServiceManagerAvailability::Available)) => { + available_manager_check_item(manager, &issues) + } + Ok(Some(ServiceManagerAvailability::Unavailable)) => CheckItem::fail( + "Service manager", + &format!("{} unavailable", manager.label()), + ), + Ok(Some(ServiceManagerAvailability::Indeterminate)) => CheckItem::fail( + "Service manager", + &format!("{} availability is indeterminate", manager.label()), + ), + Ok(None) => native_service_probe_check_item(manager, &issues), + Err(err) => CheckItem::fail("Service manager", &format!("check failed: {err}")), } - // Some managers have no cheap global probe, so backend readiness is the availability check - CheckItem::ok("Service manager", &format!("{} ready", manager.label())) } -fn availability_check_item( +fn available_manager_check_item(manager: &ServiceManager, issues: &[ReadinessIssue]) -> CheckItem { + readiness_warning_detail(manager, issues).map_or_else( + || CheckItem::ok("Service manager", &format!("{} available", manager.label())), + |detail| CheckItem::warn("Service manager", &detail), + ) +} + +fn native_service_probe_check_item( manager: &ServiceManager, - spec: &CommandSpec, issues: &[ReadinessIssue], ) -> CheckItem { - match spec.to_command().and_then(|mut command| command.status()) { - Ok(status) if status.success() => match readiness_warning_detail(manager, issues) { - // A manager can be available while still needing user setup for autostart - Some(detail) => CheckItem::warn("Service manager", &detail), - None => CheckItem::ok("Service manager", &format!("{} available", manager.label())), - }, - Ok(_) => CheckItem::fail( + // Runit and s6 have no separate manager transport query, so their bounded service probe + // decides whether the selected backend can be inspected without inventing another contract + match manager.active_probe().evaluate_state() { + Ok(ServiceProbeState::Absent | ServiceProbeState::Inactive | ServiceProbeState::Active) => { + readiness_warning_detail(manager, issues).map_or_else( + || CheckItem::ok("Service manager", &format!("{} ready", manager.label())), + |detail| CheckItem::warn("Service manager", &detail), + ) + } + Ok(ServiceProbeState::Unavailable) => CheckItem::fail( "Service manager", &format!("{} unavailable", manager.label()), ), + Ok(ServiceProbeState::Indeterminate) => CheckItem::fail( + "Service manager", + &format!("{} state is indeterminate", manager.label()), + ), Err(err) => CheckItem::fail("Service manager", &format!("check failed: {err}")), } } @@ -114,10 +134,9 @@ pub(super) fn cargo_check(release_archive: bool) -> CheckItem { return CheckItem::ok("cargo", "not required for release archive"); } - if program_in_path("cargo") { - CheckItem::ok("cargo", "available") - } else { - CheckItem::fail("cargo", "not installed") + match resolve_cargo() { + Ok(_) => CheckItem::ok("cargo", "available"), + Err(_) => CheckItem::fail("cargo", "not installed in approved toolchain locations"), } } @@ -218,15 +237,13 @@ fn path_is_writable(path: &Path) -> bool { } let probe_name = format!(".unixnotis-installer-probe-{}", std::process::id()); let probe_path = target_dir.join(probe_name); - let result = OpenOptions::new() - .create_new(true) - .write(true) - .open(&probe_path); - if result.is_err() { - return false; + match write_file_if_missing(&probe_path, b"", 0o600) { + Ok(true) => { + // Cleanup must succeed before the directory is reported as writable + remove_regular_file(&probe_path).is_ok_and(|removed| removed) + } + Ok(false) | Err(_) => false, } - let _ = std::fs::remove_file(&probe_path); - true } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/checks/tests/gtk.rs b/crates/unixnotis-installer/src/checks/tests/gtk.rs index 8ca7ea317..fc14a0b0e 100644 --- a/crates/unixnotis-installer/src/checks/tests/gtk.rs +++ b/crates/unixnotis-installer/src/checks/tests/gtk.rs @@ -9,9 +9,9 @@ use crate::test_support::fs::write_executable; #[test] fn gtk_css_feature_parser_handles_major_and_minor_checks() { - // GTK 4.16 is the first modern CSS feature level needed by the shipped theme path + // GTK 4.18 is the common API baseline needed by the shipped UI assert!( - gtk_css_features_from_version_string("4.16.2") + !gtk_css_features_from_version_string("4.17.2") .expect("version") .custom_properties ); @@ -21,9 +21,9 @@ fn gtk_css_feature_parser_handles_major_and_minor_checks() { .custom_properties ); - // Older GTK4 builds still work with legacy CSS but should not claim var() support + // Older GTK4 builds must not claim support for the common UI contract assert!( - !gtk_css_features_from_version_string("4.14.9") + !gtk_css_features_from_version_string("4.17.9") .expect("version") .custom_properties ); @@ -37,7 +37,7 @@ fn gtk_css_feature_parser_handles_major_and_minor_checks() { } #[test] -fn gtk_css_features_check_warns_for_old_gtk_and_okays_modern_gtk() { +fn gtk_css_features_check_rejects_old_gtk_and_accepts_modern_gtk() { let _lock = crate::test_support::env::test_env_lock(); let root = test_root("gtk-css-features"); let fake_bin = root.join("bin"); @@ -48,15 +48,15 @@ fn gtk_css_features_check_warns_for_old_gtk_and_okays_modern_gtk() { let old = gtk4_css_features_check(&pkg); - assert_eq!(old.state, CheckState::Warn); - assert!(old.detail.contains("legacy theming")); + assert_eq!(old.state, CheckState::Fail); + assert!(old.detail.contains("GTK 4.18+ is required")); write_fake_pkg_config(&fake_bin, "4.22.4", None); let modern = gtk4_css_features_check(&pkg); // Modern GTK should advertise the CSS variable support used by shipped themes assert_eq!(modern.state, CheckState::Ok); - assert!(modern.detail.contains("modern css variables")); + assert!(modern.detail.contains("custom properties")); let _ = fs::remove_dir_all(root); } @@ -73,9 +73,8 @@ fn gtk_checks_distinguish_pkg_config_missing_from_package_missing() { let css = gtk4_css_features_check(&pkg_missing); let layer = gtk4_layer_shell_check(&pkg_missing); - // CSS is optional feature detail, but gtk4-layer-shell is required for the UI - assert_eq!(css.state, CheckState::Warn); - assert!(css.detail.contains("pkg-config missing")); + assert_eq!(css.state, CheckState::Fail); + assert!(css.detail.contains("GTK 4.18 or newer is required")); assert_eq!(layer.state, CheckState::Fail); assert!(layer.detail.contains("pkg-config missing")); let _ = fs::remove_dir_all(root); diff --git a/crates/unixnotis-installer/src/checks/tests/session.rs b/crates/unixnotis-installer/src/checks/tests/session.rs index 9ba639c33..d7ebdff93 100644 --- a/crates/unixnotis-installer/src/checks/tests/session.rs +++ b/crates/unixnotis-installer/src/checks/tests/session.rs @@ -108,6 +108,13 @@ fn ready_for_trial_requires_wayland_cargo_and_layer_shell_only() { Err("cargo is required for trial mode".to_string()) ); + checks = passing_checks(); + checks.gtk4_css_features = item("GTK4 (4.18+)", CheckState::Fail); + assert_eq!( + checks.ready_for(ActionMode::Test), + Err("GTK 4.18 or newer is required".to_string()) + ); + checks = passing_checks(); checks.gtk4_layer_shell = item("gtk4-layer-shell", CheckState::Fail); assert_eq!( @@ -139,6 +146,13 @@ fn ready_for_install_requires_runtime_service_manager_and_writable_paths() { Err("cargo is required for installation".to_string()) ); + checks = passing_checks(); + checks.gtk4_css_features = item("GTK4 (4.18+)", CheckState::Fail); + assert_eq!( + checks.ready_for(ActionMode::Install), + Err("GTK 4.18 or newer is required".to_string()) + ); + checks = passing_checks(); checks.gtk4_layer_shell = item("gtk4-layer-shell", CheckState::Fail); assert_eq!( diff --git a/crates/unixnotis-installer/src/checks/tests/system.rs b/crates/unixnotis-installer/src/checks/tests/system.rs index d71f79234..ae9f20529 100644 --- a/crates/unixnotis-installer/src/checks/tests/system.rs +++ b/crates/unixnotis-installer/src/checks/tests/system.rs @@ -5,12 +5,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::checks::CheckState; use crate::paths::InstallPaths; +use crate::service_manager::contract::ServiceManagerAvailability; use crate::service_manager::{ReadinessIssue, ServiceManager}; use crate::test_support::fs::write_executable; use super::{ - command_success, dbus_update_env_check, install_paths_check, readiness_error_detail, - readiness_messages, readiness_warning_detail, service_manager_check_from, + command_success, dbus_update_env_check, install_paths_check, path_is_writable, + readiness_error_detail, readiness_messages, readiness_warning_detail, + service_manager_check_from, wayland_check, }; fn env_lock() -> std::sync::MutexGuard<'static, ()> { @@ -33,6 +35,20 @@ fn readiness_error_detail_collects_only_blocking_issues() { assert!(!detail.contains("boot setup incomplete")); } +#[test] +fn wayland_check_accepts_exact_wayland_session_without_display_fallback() { + let _lock = env_lock(); + let root = test_root("exact-wayland-session-check"); + let _session = crate::test_support::env::EnvGuard::set("XDG_SESSION_TYPE", "wayland"); + let _display = crate::test_support::env::EnvGuard::set("WAYLAND_DISPLAY", ""); + let _runtime = crate::test_support::env::EnvGuard::set("XDG_RUNTIME_DIR", &root); + + let item = wayland_check(); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "session detected"); +} + #[test] fn readiness_warning_detail_keeps_backend_label() { let manager = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit.d")); @@ -61,6 +77,113 @@ fn readiness_messages_split_warnings_and_errors() { assert_eq!(readiness_messages(&issues, true), ["error one".to_string()]); } +#[test] +fn service_manager_check_uses_canonical_reachable_nonzero_systemd_state() { + let _lock = env_lock(); + let root = test_root("reachable-nonzero-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' degraded\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let availability = manager + .availability_state() + .expect("systemd availability query") + .expect("systemd manager-level probe"); + let item = service_manager_check_from(&manager); + + assert_eq!(availability, ServiceManagerAvailability::Available); + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "systemd --user available"); + fs::remove_dir_all(root).expect("remove systemd availability fixture"); +} + +#[test] +fn service_manager_check_rejects_indeterminate_systemd_availability() { + let _lock = env_lock(); + let root = test_root("indeterminate-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' unexpected-manager-state\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "systemd --user availability is indeterminate"); + fs::remove_dir_all(root).expect("remove indeterminate systemd fixture"); +} + +#[test] +fn service_manager_check_rejects_unavailable_systemd_transport() { + let _lock = env_lock(); + let root = test_root("unavailable-systemd-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool( + &fake_bin.join("systemctl"), + "#!/bin/sh\nprintf '%s\\n' 'Failed to connect to bus: No medium found' >&2\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::systemd_user(root.join("systemd")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "systemd --user unavailable"); + fs::remove_dir_all(root).expect("remove unavailable systemd fixture"); +} + +#[test] +fn service_manager_check_accepts_absent_runit_service_as_ready_backend() { + let _lock = env_lock(); + let root = test_root("absent-runit-service-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool(&fake_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_fake_tool( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf '%s\\n' 'fail: unixnotis-daemon: runsv not running'\nexit 1\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::runit_user(root.join("service")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "runit user services ready"); + fs::remove_dir_all(root).expect("remove absent runit fixture"); +} + +#[test] +fn service_manager_check_rejects_ambiguous_runit_service_state() { + let _lock = env_lock(); + let root = test_root("ambiguous-runit-service-check"); + let fake_bin = root.join("fake-bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_fake_tool(&fake_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_fake_tool( + &fake_bin.join("sv"), + "#!/bin/sh\nprintf '%s\\n' ambiguous\nexit 0\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let manager = ServiceManager::runit_user(root.join("service")); + + let item = service_manager_check_from(&manager); + + assert_eq!(item.state, CheckState::Fail); + assert_eq!(item.detail, "runit user services state is indeterminate"); + fs::remove_dir_all(root).expect("remove ambiguous runit fixture"); +} + #[test] fn service_manager_check_fails_for_s6_missing_live_directory() { let _lock = env_lock(); @@ -203,6 +326,55 @@ fn install_paths_check_fails_when_service_root_is_not_directory() { let _ = fs::remove_dir_all(root); } +#[test] +fn install_paths_check_accepts_writable_binary_and_service_directories() { + let root = test_root("writable-install-paths-check"); + let bin_dir = root.join("bin"); + let service_root = root.join("service-root"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::create_dir_all(&service_root).expect("service root"); + let paths = InstallPaths { + repo_root: root.clone(), + bin_dir, + service: ServiceManager::systemd_user(service_root), + }; + + let item = install_paths_check(&paths); + + assert_eq!(item.state, CheckState::Ok); + assert_eq!(item.detail, "writable"); + fs::remove_dir_all(root).expect("remove writable install paths fixture"); +} + +#[test] +fn path_is_writable_accepts_a_real_directory_and_removes_its_probe() { + let root = test_root("writable-path-check"); + fs::create_dir_all(&root).expect("writable directory"); + + assert!(path_is_writable(&root)); + assert_eq!(fs::read_dir(&root).expect("empty directory").count(), 0); + + let _ = fs::remove_dir_all(root); +} + +#[test] +#[cfg(unix)] +fn path_is_writable_rejects_a_symlinked_directory() { + let root = test_root("linked-writable-path-check"); + let outside = root.join("outside"); + let linked = root.join("linked"); + fs::create_dir_all(&outside).expect("outside directory"); + std::os::unix::fs::symlink(&outside, &linked).expect("linked directory"); + + assert!(!path_is_writable(&linked)); + assert_eq!( + fs::read_dir(&outside).expect("untouched directory").count(), + 0 + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn command_success_distinguishes_success_failure_and_missing_trusted_tools() { let _lock = env_lock(); @@ -231,6 +403,10 @@ fn write_fake_s6_tools(fake_bin: &std::path::Path) { let path = fake_bin.join(tool); write_executable(&path, "#!/bin/sh\nexit 0\n"); } + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\n# Exit 1 means the service is not supervised\nexit 1\n", + ); } fn write_fake_tool(path: &std::path::Path, contents: &str) { @@ -253,6 +429,12 @@ fn write_fake_s6_tools_except(fake_bin: &std::path::Path, missing_tool: &str) { let path = fake_bin.join(tool); write_executable(&path, "#!/bin/sh\nexit 0\n"); } + if missing_tool != "s6-svstat" { + write_executable( + &fake_bin.join("s6-svstat"), + "#!/bin/sh\n# Exit 1 means the service is not supervised\nexit 1\n", + ); + } } fn test_root(name: &str) -> PathBuf { diff --git a/crates/unixnotis-installer/src/cli/model.rs b/crates/unixnotis-installer/src/cli/model.rs index 4f1533860..9dd0c8a89 100644 --- a/crates/unixnotis-installer/src/cli/model.rs +++ b/crates/unixnotis-installer/src/cli/model.rs @@ -15,7 +15,7 @@ pub struct CliArgs { } /// Top-level command-line result -#[derive(Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CliAction { /// Continue into installer startup using the parsed options Run(CliArgs), diff --git a/crates/unixnotis-installer/src/detect.rs b/crates/unixnotis-installer/src/detect.rs index a5b52e14c..eb2782916 100644 --- a/crates/unixnotis-installer/src/detect.rs +++ b/crates/unixnotis-installer/src/detect.rs @@ -3,14 +3,21 @@ use std::fs; use std::io::ErrorKind; use std::path::Path; +use std::time::{Duration, Instant}; +use anyhow::{anyhow, Context, Result}; use rustix::process::geteuid; use serde_json::Value; use crate::system_tools; -#[derive(Clone)] +const MAX_BUSCTL_OUTPUT_BYTES: usize = 64 * 1024; +const DEFAULT_BUSCTL_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] pub struct OwnerInfo { + // Exact transport address is present for fail-closed mutation checks + pub unique_name: Option, pub pid: Option, pub comm: Option, } @@ -31,52 +38,21 @@ pub struct Detection { pub daemons: Vec, } -pub struct KnownDaemon { - pub(crate) name: &'static str, - pub(crate) unit: &'static str, -} - -pub const KNOWN_DAEMONS: &[KnownDaemon] = &[ - KnownDaemon { - name: "unixnotis-daemon", - unit: "unixnotis-daemon.service", - }, - KnownDaemon { - name: "mako", - unit: "mako.service", - }, - KnownDaemon { - name: "dunst", - unit: "dunst.service", - }, - KnownDaemon { - name: "swaync", - unit: "swaync.service", - }, - KnownDaemon { - name: "notify-osd", - unit: "notify-osd.service", - }, - KnownDaemon { - name: "quickshell", - unit: "quickshell.service", - }, - KnownDaemon { - name: "hyprnotify", - unit: "hyprnotify.service", - }, - KnownDaemon { - name: "fnott", - unit: "fnott.service", - }, -]; +pub use unixnotis_core::KNOWN_NOTIFICATION_DAEMONS as KNOWN_DAEMONS; pub fn detect() -> Detection { let owner = detect_owner(); - let daemons = detect_known_daemons(&owner); + let daemons = detect_known_daemons(owner.as_ref()); Detection { owner, daemons } } +pub fn detect_for_mutation() -> Result { + // Destructive workflow gates keep broker errors distinct from an unowned bus name + let owner = read_busctl_owner_strict()?; + let daemons = detect_known_daemons(owner.as_ref()); + Ok(Detection { owner, daemons }) +} + pub fn parse_busctl_status(status: &str) -> Option { // Parses `busctl --user status` output and tolerates the indented key/value format let mut comm = None; @@ -115,7 +91,11 @@ pub fn parse_busctl_status(status: &str) -> Option { return None; } - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } pub fn parse_busctl_json(status: &str) -> Option { @@ -129,7 +109,11 @@ pub fn parse_busctl_json(status: &str) -> Option { return None; } - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } fn walk_busctl_json(value: &Value, comm: &mut Option, pid: &mut Option) { @@ -179,12 +163,16 @@ fn parse_pid_value(value: &Value) -> Option { } fn detect_owner() -> Option { - let OwnerInfo { pid, comm } = read_busctl_owner()?; + let OwnerInfo { pid, comm, .. } = read_busctl_owner()?; // Prefer the executable name derived from argv0; fall back to busctl and /proc data let comm = pid .and_then(read_cmdline_program) .or_else(|| comm.or_else(|| pid.and_then(read_comm))); - Some(OwnerInfo { pid, comm }) + Some(OwnerInfo { + unique_name: None, + pid, + comm, + }) } fn read_busctl_owner() -> Option { @@ -200,10 +188,124 @@ fn read_busctl_owner() -> Option { } } - let status = run_busctl(&["--user", "status", unixnotis_core::NOTIFICATIONS_BUS_NAME])?; + if let Some(status) = run_busctl(&["--user", "status", unixnotis_core::NOTIFICATIONS_BUS_NAME]) + { + if let Some(owner) = parse_busctl_status(&status) { + return Some(owner); + } + } + + // Some busctl versions omit process fields for a well-known name + let reply = run_busctl(&[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ])?; + let unique_name = parse_busctl_string_reply(&reply)?; + if let Some(status) = run_busctl(&["--user", "--json=short", "status", &unique_name]) { + if let Some(owner) = parse_busctl_json(&status) { + return Some(owner); + } + } + let status = run_busctl(&["--user", "status", &unique_name])?; parse_busctl_status(&status) } +fn read_busctl_owner_strict() -> Result> { + let Some(unique_name) = read_busctl_unique_owner_strict()? else { + return Ok(None); + }; + + let mut owner = run_busctl(&["--user", "--json=short", "status", &unique_name]) + .and_then(|status| parse_busctl_json(&status)) + .or_else(|| { + run_busctl(&["--user", "status", &unique_name]) + .and_then(|status| parse_busctl_status(&status)) + }) + .unwrap_or(OwnerInfo { + unique_name: None, + pid: None, + comm: None, + }); + owner.unique_name = Some(unique_name); + owner.comm = owner + .pid + .and_then(read_cmdline_program) + .or_else(|| owner.comm.or_else(|| owner.pid.and_then(read_comm))); + Ok(Some(owner)) +} + +pub fn notification_owner_for_mutation_until(deadline: Instant) -> Result> { + // The final switch only needs the broker address, so it skips slower process discovery + read_busctl_unique_owner_strict_until(deadline) +} + +fn read_busctl_unique_owner_strict() -> Result> { + let deadline = Instant::now() + .checked_add(DEFAULT_BUSCTL_PROBE_TIMEOUT) + .ok_or_else(|| anyhow!("notification owner deadline exceeded the monotonic clock"))?; + read_busctl_unique_owner_strict_until(deadline) +} + +fn read_busctl_unique_owner_strict_until(deadline: Instant) -> Result> { + let has_owner = run_busctl_required_until( + &[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ], + deadline, + )?; + match has_owner.split_whitespace().collect::>().as_slice() { + ["b", "false"] => return Ok(None), + ["b", "true"] => {} + _ => return Err(anyhow!("busctl returned malformed NameHasOwner output")), + } + + let reply = run_busctl_required_until( + &[ + "--user", + "call", + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + "s", + unixnotis_core::NOTIFICATIONS_BUS_NAME, + ], + deadline, + )?; + let unique_name = parse_busctl_string_reply(&reply) + .ok_or_else(|| anyhow!("busctl returned malformed GetNameOwner output"))?; + Ok(Some(unique_name)) +} + +pub fn ensure_owner_is_current(expected_unique_name: &str) -> Result<()> { + let current = read_busctl_unique_owner_strict()?; + anyhow::ensure!( + current.as_deref() == Some(expected_unique_name), + "Notifications owner changed before the stop operation; refusing to act on stale process metadata" + ); + Ok(()) +} + +fn parse_busctl_string_reply(reply: &str) -> Option { + // Method-call string output is formatted as `s "value"` + let (_, quoted) = reply.trim().split_once('"')?; + let (value, _) = quoted.split_once('"')?; + (!value.is_empty()).then(|| value.to_string()) +} + fn run_busctl(args: &[&str]) -> Option { let output = system_tools::command("busctl") .ok()? @@ -216,15 +318,46 @@ fn run_busctl(args: &[&str]) -> Option { Some(String::from_utf8_lossy(&output.stdout).to_string()) } -fn detect_known_daemons(owner: &Option) -> Vec { - let owner_name = owner.as_ref().and_then(|info| info.comm.as_deref()); +fn run_busctl_required_until(args: &[&str], deadline: Instant) -> Result { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return Err(std::io::Error::new( + ErrorKind::TimedOut, + "notification owner probe deadline elapsed", + ) + .into()); + } + let mut command = system_tools::command("busctl").context("locate trusted busctl")?; + command.args(args); + let output = system_tools::output_bounded(&mut command, timeout, MAX_BUSCTL_OUTPUT_BYTES) + .context("query notification owner through busctl")?; + validate_busctl_output(output) +} + +fn validate_busctl_output(output: system_tools::BoundedOutput) -> Result { + if output.stdout_truncated || output.stderr_truncated { + return Err(anyhow!("busctl owner query exceeded the safe output limit")); + } + if !output.status.success() { + return Err(anyhow!( + "busctl owner query failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout).context("busctl owner query output was not UTF-8") +} + +fn detect_known_daemons(owner: Option<&OwnerInfo>) -> Vec { + let owner_name = owner.and_then(|info| info.comm.as_deref()); KNOWN_DAEMONS .iter() .map(|daemon| { - let (systemd_active, systemd_error) = is_unit_active(daemon.unit); + let (systemd_active, systemd_error) = + daemon.systemd_unit.map_or((false, None), is_unit_active); DetectedDaemon { name: daemon.name.to_string(), - unit: daemon.unit.to_string(), + unit: daemon.systemd_unit.unwrap_or_default().to_string(), systemd_active, systemd_error, running_pids: pgrep_exact(daemon.name), diff --git a/crates/unixnotis-installer/src/main.rs b/crates/unixnotis-installer/src/main.rs index 40e1f82ee..c38ec0ae0 100644 --- a/crates/unixnotis-installer/src/main.rs +++ b/crates/unixnotis-installer/src/main.rs @@ -1,21 +1,5 @@ //! `UnixNotis` installer entrypoint with a ratatui-driven flow -#![expect( - clippy::collapsible_else_if, - clippy::items_after_statements, - clippy::match_same_arms, - clippy::missing_const_for_fn, - clippy::needless_continue, - clippy::needless_pass_by_value, - clippy::option_if_let_else, - clippy::redundant_else, - clippy::ref_option, - clippy::similar_names, - clippy::too_many_lines, - clippy::unnecessary_wraps, - reason = "reviewed installer state-machine, backend, and TUI boundaries keep explicit control flow for auditable lifecycle behavior" -)] - mod actions; mod app; mod checks; @@ -24,16 +8,18 @@ mod detect; mod managed_binaries; mod model; mod paths; +mod privilege; mod release; -mod safe_write; mod service_manager; mod system_tools; mod terminal; #[cfg(test)] #[path = "tests/support/mod.rs"] mod test_support; +pub(crate) mod toolchain; mod trial; mod ui; +mod write_target; use anyhow::Result; @@ -44,6 +30,9 @@ use crate::terminal::TerminalGuard; use crate::trial::run_trial; fn main() -> Result<()> { + // Root execution turns user-controlled paths into privileged mutation targets + privilege::reject_root_install(rustix::process::geteuid().as_raw())?; + let cli = match cli::parse_env_args()? { CliAction::Run(args) => args, CliAction::Help => { @@ -62,7 +51,7 @@ fn main() -> Result<()> { match exit_action { Ok(ExitAction::None) => Ok(()), - Ok(ExitAction::RunTrial { repo_root }) => run_trial(repo_root), + Ok(ExitAction::RunTrial { repo_root }) => run_trial(&repo_root), Err(err) => Err(err), } } diff --git a/crates/unixnotis-installer/src/managed_binaries.rs b/crates/unixnotis-installer/src/managed_binaries.rs index 15eda0273..a0ca9dbe9 100644 --- a/crates/unixnotis-installer/src/managed_binaries.rs +++ b/crates/unixnotis-installer/src/managed_binaries.rs @@ -10,6 +10,7 @@ const SUPPORTED_MANAGED_BINARIES: &[&str] = &[ "unixnotis-daemon", "unixnotis-popups", "unixnotis-center", + "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl", ]; diff --git a/crates/unixnotis-installer/src/paths/discovery.rs b/crates/unixnotis-installer/src/paths/discovery.rs index f0d200a9f..1cf2f5222 100644 --- a/crates/unixnotis-installer/src/paths/discovery.rs +++ b/crates/unixnotis-installer/src/paths/discovery.rs @@ -75,6 +75,30 @@ impl InstallPaths { pub fn release_binary_dir(&self) -> PathBuf { self.repo_root.join(RELEASE_BIN_DIR) } + + pub fn installed_release_root(&self) -> Result { + let local_root = self + .bin_dir + .parent() + .ok_or_else(|| anyhow!("binary directory has no local installation root"))?; + Ok(local_root.join("lib").join("unixnotis")) + } + + pub fn installed_releases_dir(&self) -> Result { + Ok(self.installed_release_root()?.join("releases")) + } + + pub fn installed_current_link(&self) -> Result { + Ok(self.installed_release_root()?.join("current")) + } + + pub fn installed_pending_manifest(&self) -> Result { + Ok(self.installed_release_root()?.join("pending-install.json")) + } + + pub fn installed_rollback_root(&self) -> Result { + Ok(self.installed_release_root()?.join("rollback")) + } } fn service_manager_from_selection( diff --git a/crates/unixnotis-installer/src/paths/tests/general.rs b/crates/unixnotis-installer/src/paths/tests/general.rs index 12ed06923..4d32ad6b4 100644 --- a/crates/unixnotis-installer/src/paths/tests/general.rs +++ b/crates/unixnotis-installer/src/paths/tests/general.rs @@ -14,6 +14,40 @@ fn format_with_home_rewrites_prefix() { assert!(rendered.starts_with("$HOME")); } +#[test] +fn installed_release_paths_share_the_binary_directories_local_root() { + let root = crate::test_support::fs::unique_temp_path("installed-release-paths"); + let paths = InstallPaths { + repo_root: root.join("repo"), + bin_dir: root.join("prefix").join("bin"), + service: crate::service_manager::ServiceManager::systemd_user(root.join("units")), + }; + let install_root = root.join("prefix").join("lib").join("unixnotis"); + + assert_eq!( + paths.installed_release_root().expect("release root"), + install_root + ); + assert_eq!( + paths.installed_releases_dir().expect("releases directory"), + install_root.join("releases") + ); + assert_eq!( + paths.installed_current_link().expect("current link"), + install_root.join("current") + ); + assert_eq!( + paths + .installed_pending_manifest() + .expect("pending manifest"), + install_root.join("pending-install.json") + ); + assert_eq!( + paths.installed_rollback_root().expect("rollback root"), + install_root.join("rollback") + ); +} + #[test] fn is_unixnotis_repo_detects_markers() { // Validates that known workspace markers are detected in a Cargo.toml file diff --git a/crates/unixnotis-installer/src/paths/tests/s6_live.rs b/crates/unixnotis-installer/src/paths/tests/s6_live.rs index 32de2566d..70f2af0cf 100644 --- a/crates/unixnotis-installer/src/paths/tests/s6_live.rs +++ b/crates/unixnotis-installer/src/paths/tests/s6_live.rs @@ -24,11 +24,7 @@ fn install_paths_use_existing_local_s6_live_root_when_run_root_is_missing() { assert_eq!(paths.service.artifact_root(), data_root.as_path()); assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", local_live.to_string_lossy().as_ref(), @@ -74,11 +70,7 @@ fn install_paths_use_symlinked_local_s6_live_root() { // s6-rc-update expects the live symlink name, not the resolved live:initial directory assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", linked_live.to_string_lossy().as_ref(), @@ -116,11 +108,7 @@ fn install_paths_use_existing_tmp_s6_live_root_for_standalone_supervision() { // Artix standalone local s6 uses a user-owned live root outside /run assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", standalone_live.to_string_lossy().as_ref(), @@ -164,11 +152,7 @@ fn install_paths_ignore_symlinked_tmp_s6_live_root() { // Auto-detection must not follow a symlinked /tmp live root into another tree assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args(), + paths.service.start_command().args(), &[ "-l", expected_fallback.as_ref(), @@ -242,12 +226,8 @@ fn install_paths_allow_explicit_symlinked_s6_live_root() { let paths = InstallPaths::discover().expect("paths should resolve in repo tests"); assert_eq!( - paths - .service - .start_command() - .expect("s6 start command") - .args()[1], - linked_live.to_string_lossy() + paths.service.start_command().args()[1], + linked_live.as_os_str() ); restore_env("UNIXNOTIS_SERVICE_MANAGER", previous_manager); diff --git a/crates/unixnotis-installer/src/privilege.rs b/crates/unixnotis-installer/src/privilege.rs new file mode 100644 index 000000000..8c0f19bf5 --- /dev/null +++ b/crates/unixnotis-installer/src/privilege.rs @@ -0,0 +1,15 @@ +//! Installer privilege-boundary checks + +use anyhow::{bail, Result}; + +pub fn reject_root_install(euid: u32) -> Result<()> { + if euid == 0 { + bail!("unixnotis-installer is user-level; do not run it as root or through sudo"); + } + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/privilege.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/release.rs b/crates/unixnotis-installer/src/release.rs index d87b2e16d..d33bdc4be 100644 --- a/crates/unixnotis-installer/src/release.rs +++ b/crates/unixnotis-installer/src/release.rs @@ -61,18 +61,30 @@ impl ReleaseStatus { } } - pub fn display_line(&self) -> String { - // Keep the line compact because it sits in the installer status panel - match (self.state, self.latest.as_deref()) { - (ReleaseUpdateState::UpdateAvailable, Some(latest)) => { - format!("{} installed; {latest} available", self.current) + pub fn display_line_for(&self, version: &str, role: &str) -> String { + // The version role prevents installer-build and installed-binary state from being conflated + match self.latest.as_deref() { + Some(latest) + if self.update_state_for(version) == ReleaseUpdateState::UpdateAvailable => + { + format!("{version} {role}; {latest} available") } - (ReleaseUpdateState::UpToDate, Some(latest)) => { - format!("{} installed; latest release is {latest}", self.current) - } - _ => format!("{} installed; update check unavailable", self.current), + Some(latest) => format!("{version} {role}; latest release is {latest}"), + None => format!("{version} {role}; update check unavailable"), } } + + pub fn update_state_for(&self, version: &str) -> ReleaseUpdateState { + self.latest + .as_deref() + .map_or(ReleaseUpdateState::Unknown, |latest| { + if release_tag_is_newer(latest, version) { + ReleaseUpdateState::UpdateAvailable + } else { + ReleaseUpdateState::UpToDate + } + }) + } } fn current_version_tag() -> String { diff --git a/crates/unixnotis-installer/src/safe_write.rs b/crates/unixnotis-installer/src/safe_write.rs deleted file mode 100644 index 9e7d35a5d..000000000 --- a/crates/unixnotis-installer/src/safe_write.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Symlink-aware file writes for user-owned config files - -use rustix::fs::{mkdirat, openat2, renameat, unlinkat, AtFlags, Mode, OFlags, ResolveFlags, CWD}; -use std::fs; -use std::io::{self, Write}; -use std::os::fd::OwnedFd; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub fn write_text_preserving_mode( - path: &Path, - contents: &str, - default_mode: u32, -) -> io::Result<()> { - let mode = existing_mode_or_default(path, default_mode)?; - write_text_with_mode(path, contents, mode) -} - -pub fn write_text_with_mode(path: &Path, contents: &str, mode: u32) -> io::Result<()> { - let (parent_fd, file_name) = open_secure_parent(path)?; - validate_target_at(&parent_fd, &file_name)?; - let (temp_name, mut temp_file) = create_atomic_temp_at(&parent_fd, &file_name, mode)?; - let result = (|| -> io::Result<()> { - temp_file.write_all(contents.as_bytes())?; - temp_file.flush()?; - #[cfg(unix)] - { - // Set the mode before rename so the visible file never appears too permissive - temp_file.set_permissions(fs::Permissions::from_mode(mode & 0o777))?; - } - temp_file.sync_all()?; - Ok(()) - })(); - - if let Err(err) = result { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - return Err(err); - } - drop(temp_file); - - // Re-check immediately before rename so a late symlink swap is not silently followed - validate_target_at(&parent_fd, &file_name).inspect_err(|_err| { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - })?; - Ok(renameat( - &parent_fd, - temp_name.as_str(), - &parent_fd, - file_name.as_str(), - ) - .inspect_err(|_err| { - let _ = unlinkat(&parent_fd, temp_name.as_str(), AtFlags::empty()); - })?) -} - -pub fn reject_unsafe_write_target(path: &Path) -> io::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to write through symlink {}", path.display()), - )); - } - if metadata.is_file() { - return Ok(()); - } - Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to overwrite non-file {}", path.display()), - )) - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err), - } -} - -fn existing_mode_or_default(path: &Path, default_mode: u32) -> io::Result { - let (parent_fd, file_name) = open_secure_parent(path)?; - match openat2( - &parent_fd, - file_name.as_str(), - // O_PATH inspects metadata without opening FIFO or device contents - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => { - let file = fs::File::from(fd); - let metadata = file.metadata()?; - if !metadata.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to overwrite non-file {}", path.display()), - )); - } - Ok(file_mode(&metadata)) - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(default_mode), - Err(err) => Err(err.into()), - } -} - -#[cfg(unix)] -fn file_mode(metadata: &fs::Metadata) -> u32 { - metadata.permissions().mode() & 0o777 -} - -#[cfg(not(unix))] -fn file_mode(_metadata: &fs::Metadata) -> u32 { - 0o644 -} - -fn create_atomic_temp_at( - parent_fd: &OwnedFd, - file_name: &str, - mode: u32, -) -> io::Result<(String, fs::File)> { - for attempt in 0..16 { - let temp_name = atomic_temp_name(file_name, attempt)?; - match openat2( - parent_fd, - temp_name.as_str(), - OFlags::WRONLY | OFlags::CLOEXEC | OFlags::CREATE | OFlags::EXCL, - Mode::from_raw_mode(mode & 0o777), - secure_resolve_flags(), - ) { - Ok(fd) => return Ok((temp_name, fs::File::from(fd))), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - // Another installer run may have picked the same timestamp; retry with a new suffix - continue; - } - Err(err) => return Err(err.into()), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "could not create secure temporary file", - )) -} - -fn atomic_temp_name(file_name: &str, attempt: u8) -> io::Result { - atomic_temp_name_at(file_name, attempt, SystemTime::now()) -} - -fn atomic_temp_name_at(file_name: &str, attempt: u8, now: SystemTime) -> io::Result { - let stamp = now - .duration_since(UNIX_EPOCH) - .map_err(|error| { - io::Error::other(format!( - "system clock is earlier than the Unix epoch: {error}" - )) - })? - .as_nanos(); - Ok(format!( - ".{file_name}.{}.{}.{}.tmp", - std::process::id(), - stamp, - attempt - )) -} - -fn open_secure_parent(path: &Path) -> io::Result<(OwnedFd, String)> { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target file name is invalid"))? - .to_string(); - let parent = path - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target path has no parent"))?; - let mut current_fd = if path.is_absolute() { - openat2( - CWD, - "/", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - ResolveFlags::empty(), - )? - } else { - openat2( - CWD, - ".", - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - ResolveFlags::empty(), - )? - }; - - for component in parent.components() { - match component { - std::path::Component::Prefix(_) - | std::path::Component::RootDir - | std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "secure write path cannot contain parent traversal", - )); - } - std::path::Component::Normal(part) => { - current_fd = open_or_create_child_dir(¤t_fd, part)?; - } - } - } - Ok((current_fd, file_name)) -} - -fn open_or_create_child_dir(parent_fd: &OwnedFd, name: &std::ffi::OsStr) -> io::Result { - match openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => Ok(fd), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - mkdirat(parent_fd, name, Mode::from_raw_mode(0o755))?; - Ok(openat2( - parent_fd, - name, - OFlags::DIRECTORY.union(OFlags::CLOEXEC), - Mode::empty(), - secure_resolve_flags(), - )?) - } - Err(err) => Err(err.into()), - } -} - -fn validate_target_at(parent_fd: &OwnedFd, file_name: &str) -> io::Result<()> { - match openat2( - parent_fd, - file_name, - OFlags::PATH.union(OFlags::CLOEXEC).union(OFlags::NOFOLLOW), - Mode::empty(), - secure_resolve_flags(), - ) { - Ok(fd) => { - let metadata = fs::File::from(fd).metadata()?; - if metadata.is_file() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "refusing to overwrite non-file target", - )) - } - } - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - } -} - -const fn secure_resolve_flags() -> ResolveFlags { - ResolveFlags::BENEATH - .union(ResolveFlags::NO_SYMLINKS) - .union(ResolveFlags::NO_MAGICLINKS) -} - -#[cfg(test)] -#[path = "tests/safe_write.rs"] -mod tests; diff --git a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs index 8341d0e6d..a0702c3a7 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/dinit.rs @@ -1,7 +1,11 @@ use std::fs; use std::path::{Path, PathBuf}; -use super::super::contract::{CommandSpec, ReadinessIssue, ServiceArtifact, ServiceArtifactKind}; +use super::super::contract::{ + CommandSpec, ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceManagerAvailability, + ServiceManagerAvailabilityOutput, ServiceManagerAvailabilityProbe, ServiceProbe, + ServiceProbeOutput, ServiceProbeState, +}; // Dinit service names are file names without the .service suffix used by systemd pub const SERVICE_NAME: &str = "unixnotis-daemon"; @@ -43,15 +47,33 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub fn availability_command() -> Option { - Some( - CommandSpec::new( - "dinitctl --user --quiet list", - "dinitctl", - ["--user", "--quiet", "list"], - ) - .quiet(), +pub fn availability_probe() -> ServiceManagerAvailabilityProbe { + let command = CommandSpec::new( + "dinitctl --user --quiet list", + "dinitctl", + ["--user", "--quiet", "list"], ) + // Transport diagnostics are matched only in the stable C locale + .env("LC_ALL", "C"); + ServiceManagerAvailabilityProbe::new(command, interpret_availability) +} + +fn interpret_availability( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() { + return ServiceManagerAvailability::Available; + } + // This prefix comes from dinit's client-side control-socket connection failure + if output.did_exit() + && output + .stderr() + .trim() + .starts_with("dinit-client: connecting to socket") + { + return ServiceManagerAvailability::Unavailable; + } + ServiceManagerAvailability::Indeterminate } pub const fn is_enabled_command() -> Option { @@ -59,12 +81,42 @@ pub const fn is_enabled_command() -> Option { None } -pub fn is_active_command() -> Option { - Some(CommandSpec::new( - format!("dinitctl --user --quiet is-started {SERVICE_NAME}"), +pub fn active_probe() -> ServiceProbe { + // `status` identifies an unloaded service separately from control-socket failures + let command = CommandSpec::new( + format!("dinitctl --user status {SERVICE_NAME}"), "dinitctl", - ["--user", "--quiet", "is-started", SERVICE_NAME], - )) + ["--user", "status", SERVICE_NAME], + ); + ServiceProbe::new(command, interpret_active_state) +} + +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + if output.status_success() { + return match output + .stdout() + .lines() + .find_map(|line| line.trim().strip_prefix("State: ")) + { + Some("STARTED" | "STARTING" | "STOPPING") => ServiceProbeState::Active, + Some("STOPPED") => ServiceProbeState::Inactive, + Some(state) if state.starts_with("STOPPED (") && state.ends_with(')') => { + ServiceProbeState::Inactive + } + Some(_) | None => ServiceProbeState::Indeterminate, + }; + } + + // dinitctl status uses this exact result when the service has no live record + // Other exit failures may be socket or protocol faults and remain indeterminate + if output.status_code() == Some(1) + && output.stdout().trim().is_empty() + && output.stderr().trim() == "dinitctl: service not loaded." + { + return ServiceProbeState::Absent; + } + + ServiceProbeState::Indeterminate } pub const fn reload_after_artifact_change() -> Option { @@ -72,25 +124,25 @@ pub const fn reload_after_artifact_change() -> Option { None } -pub fn enable_now_command() -> Option { +pub fn enable_now_command() -> CommandSpec { // The boot.d artifact owns persistence; start only handles the live session start_command() } -pub fn start_command() -> Option { - Some(CommandSpec::new( +pub fn start_command() -> CommandSpec { + CommandSpec::new( format!("dinitctl --user start {SERVICE_NAME}"), "dinitctl", ["--user", "start", SERVICE_NAME], - )) + ) } -pub fn disable_now_command() -> Option { - Some(stop_ignoring_unstarted()) +pub fn disable_now_command() -> CommandSpec { + stop_ignoring_unstarted() } -pub fn stop_for_reinstall_command() -> Option { - Some(stop_ignoring_unstarted()) +pub fn stop_for_reinstall_command() -> CommandSpec { + stop_ignoring_unstarted() } pub fn hyprland_startup_commands(import_vars: &[&str]) -> Vec { diff --git a/crates/unixnotis-installer/src/service_manager/backends/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/runit.rs index 7d7f3aab8..0e58c229a 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/runit.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use crate::system_tools; use super::super::contract::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, CommandSpec, ReadinessIssue, ServiceArtifact, - ServiceArtifactKind, ServiceProbe, MANAGED_DIRECTORY_MARKER, + envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, + ReadinessIssue, ServiceArtifact, ServiceArtifactKind, ServiceProbe, ServiceProbeOutput, + ServiceProbeState, MANAGED_DIRECTORY_MARKER, }; // Runit service directories use the service name directly under the supervision root @@ -68,11 +68,6 @@ pub fn install_artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec Option { - // `sv -V` checks the control binary without requiring the service to exist yet - Some(CommandSpec::new("sv -V", "sv", ["-V"]).quiet()) -} - pub const fn is_enabled_command() -> Option { // Enablement is the presence of the service directory under the watched root None @@ -88,15 +83,17 @@ pub fn enabled_by_artifacts(artifact_root: &Path) -> bool { && path_is_missing(&service.join(DOWN_FILE)) } -pub fn active_probe(artifact_root: &Path) -> Option { +pub fn active_probe(artifact_root: &Path) -> ServiceProbe { let service = service_dir_arg(artifact_root); // sv check can succeed for a requested down state, so parse status text instead let command = CommandSpec::new( format!("sv status {service}"), "sv", ["status".to_string(), service], - ); - Some(ServiceProbe::stdout(command, status_output_is_running)) + ) + // Runit diagnostics are stable English strings only under the C locale + .env("LC_ALL", "C"); + ServiceProbe::new(command, interpret_active_state) } pub const fn reload_after_artifact_change() -> Option { @@ -104,44 +101,24 @@ pub const fn reload_after_artifact_change() -> Option { None } -pub fn enable_now_command(artifact_root: &Path) -> Option { +pub fn enable_now_command(artifact_root: &Path) -> CommandSpec { start_command(artifact_root) } -pub fn start_command(artifact_root: &Path) -> Option { - Some(sv_command("start", artifact_root)) +pub fn start_command(artifact_root: &Path) -> CommandSpec { + sv_command("start", artifact_root) } -pub fn disable_now_command(artifact_root: &Path) -> Option { - Some(sv_command("stop", artifact_root)) +pub fn disable_now_command(artifact_root: &Path) -> CommandSpec { + sv_command("stop", artifact_root) } -pub fn stop_for_reinstall_command(artifact_root: &Path) -> Option { - Some(sv_command("stop", artifact_root)) +pub fn stop_for_reinstall_command(artifact_root: &Path) -> CommandSpec { + sv_command("stop", artifact_root) } -pub fn hyprland_startup_commands(artifact_root: &Path, import_vars: &[&str]) -> Vec { - let service = service_dir(artifact_root); - let env_dir = service.join(ENV_DIR); - // Hyprland needs one line, so join shell steps with semicolons instead of newlines - // The envdir checks mirror Rust-side symlink refusal before shell redirection runs - let mut steps = envdir_sync_prelude(&env_dir); - for var in import_vars - .iter() - .copied() - .filter(|name| is_runit_envdir_name(name)) - { - // mktemp writes a fresh file, and mv replaces the env file path without appending - steps.push(render_envdir_shell_update(var)); - } - steps.push(format!( - "sv restart {} || sv start {}", - shell_quote_path(&service), - shell_quote_path(&service) - )); - // Values are read from the live session at runtime, never embedded in config text - let script = steps.join("; "); - vec![format!("sh -lc {}", shell_quote(&script))] +pub fn hyprland_startup_commands(_artifact_root: &Path, _import_vars: &[&str]) -> Vec { + vec!["noticenterctl sync-session-environment --service-manager runit".to_string()] } pub const fn environment_sync_commands() -> Vec { @@ -256,6 +233,28 @@ fn path_is_missing(path: &Path) -> bool { .map_or_else(|err| err.kind() == std::io::ErrorKind::NotFound, |_| false) } -fn status_output_is_running(stdout: &str) -> bool { - stdout.trim_start().starts_with("run:") +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + let stdout = output.stdout().trim(); + if output.status_success() { + return if stdout.starts_with("run:") { + ServiceProbeState::Active + } else if stdout.starts_with("down:") { + ServiceProbeState::Inactive + } else { + ServiceProbeState::Indeterminate + }; + } + + // One-service probes return one only for this service-level failure class + // Match only runit's documented absence diagnostics so timeouts stay blocking + let service_is_absent = output.status_code() == Some(1) + && output.stderr().trim().is_empty() + && (stdout.ends_with(": runsv not running") + || stdout + .ends_with(": unable to change to service directory: No such file or directory")); + if service_is_absent { + ServiceProbeState::Absent + } else { + ServiceProbeState::Indeterminate + } } diff --git a/crates/unixnotis-installer/src/service_manager/backends/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/s6.rs index 4c0c94276..51e51ec4f 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/s6.rs @@ -4,9 +4,10 @@ use std::path::{Path, PathBuf}; use crate::system_tools; use super::super::contract::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, - ServiceArtifactKind, ServiceArtifactRefresh, ServiceProbe, MANAGED_DIRECTORY_MARKER, + envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path, CommandSpec, + ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, + ServiceArtifactRefresh, ServiceProbe, ServiceProbeOutput, ServiceProbeState, + MANAGED_DIRECTORY_MARKER, }; pub const SERVICE_NAME: &str = "unixnotis-daemon"; @@ -71,11 +72,6 @@ pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { ] } -pub const fn availability_command() -> Option { - // s6 readiness needs several tools and paths, so readiness_issues owns validation - None -} - pub const fn is_enabled_command() -> Option { // Enablement is source-backed through the default bundle membership file None @@ -91,7 +87,7 @@ pub fn enabled_by_artifacts(artifact_root: &Path) -> bool { && is_regular_file(&default_bundle_member(artifact_root)) } -pub fn active_probe(live_dir: &Path) -> Option { +pub fn active_probe(live_dir: &Path) -> ServiceProbe { let service = live_service_dir(live_dir).display().to_string(); // s6-svstat -o up is machine-readable and avoids parsing human status text let command = CommandSpec::new( @@ -99,64 +95,42 @@ pub fn active_probe(live_dir: &Path) -> Option { "s6-svstat", ["-o".to_string(), "up".to_string(), service], ); - Some(ServiceProbe::stdout(command, status_output_is_running)) + ServiceProbe::new(command, interpret_active_state) } pub fn refresh_after_artifact_change( artifact_root: &Path, live_dir: &Path, -) -> Option { +) -> ServiceArtifactRefresh { // s6 source changes must be compiled into a database before s6-rc can see them - Some(ServiceArtifactRefresh::S6Database(S6DatabaseRefresh::new( + ServiceArtifactRefresh::S6Database(S6DatabaseRefresh::new( artifact_root.to_path_buf(), live_dir.to_path_buf(), - ))) + )) } -pub fn enable_now_command(live_dir: &Path) -> Option { +pub fn enable_now_command(live_dir: &Path) -> CommandSpec { start_command(live_dir) } -pub fn start_command(live_dir: &Path) -> Option { - Some(s6_rc_change_command(live_dir, "-u")) +pub fn start_command(live_dir: &Path) -> CommandSpec { + s6_rc_change_command(live_dir, "-u") } -pub fn disable_now_command(live_dir: &Path) -> Option { - Some(s6_rc_change_command(live_dir, "-d")) +pub fn disable_now_command(live_dir: &Path) -> CommandSpec { + s6_rc_change_command(live_dir, "-d") } -pub fn stop_for_reinstall_command(live_dir: &Path) -> Option { +pub fn stop_for_reinstall_command(live_dir: &Path) -> CommandSpec { disable_now_command(live_dir) } pub fn hyprland_startup_commands( - artifact_root: &Path, - live_dir: &Path, - import_vars: &[&str], + _artifact_root: &Path, + _live_dir: &Path, + _import_vars: &[&str], ) -> Vec { - let env_dir = service_dir(artifact_root).join(ENV_DIR); - let live_service = live_service_dir(live_dir); - // Hyprland uses one exec-once line, so every shell step must be fail-closed - let mut steps = envdir_sync_prelude(&env_dir); - for var in import_vars - .iter() - .copied() - .filter(|name| is_s6_envdir_name(name)) - { - // Missing session vars intentionally become empty envdir files - steps.push(render_envdir_shell_update(var)); - } - steps.push(format!( - "s6-rc -l {} -u change {} || exit 1", - shell_quote_path(live_dir), - shell_quote(SERVICE_NAME) - )); - steps.push(format!( - "s6-svc -r {} || :", - shell_quote_path(&live_service) - )); - let script = steps.join("; "); - vec![format!("sh -lc {}", shell_quote(&script))] + vec!["noticenterctl sync-session-environment --service-manager s6".to_string()] } pub const fn environment_sync_commands() -> Vec { @@ -326,6 +300,17 @@ fn is_directory_or_symlink_to_directory(path: &Path) -> bool { .is_ok_and(|metadata| metadata.is_dir()) } -fn status_output_is_running(stdout: &str) -> bool { - stdout.trim() == "true" +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + // s6 assigns exit one specifically to an absent s6-supervise process + if output.status_code() == Some(1) { + return ServiceProbeState::Absent; + } + if !output.status_success() { + return ServiceProbeState::Indeterminate; + } + match output.stdout().trim() { + "true" => ServiceProbeState::Active, + "false" => ServiceProbeState::Inactive, + _ => ServiceProbeState::Indeterminate, + } } diff --git a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs index be92ddecd..d3cc51e23 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/systemd.rs @@ -1,11 +1,13 @@ use std::path::{Path, PathBuf}; -use crate::paths::format_with_home; - -use super::super::contract::{CommandSpec, ServiceArtifact}; +use super::super::contract::{ + CommandSpec, ServiceArtifact, ServiceManagerAvailability, ServiceManagerAvailabilityOutput, + ServiceManagerAvailabilityProbe, ServiceProbe, ServiceProbeOutput, ServiceProbeState, +}; // Keep the systemd unit name stable for existing installs and migration cleanup pub const SERVICE_NAME: &str = "unixnotis-daemon.service"; +pub const CONTROL_ACTIVATION_SERVICE: &str = "com.unixnotis.Control.service"; pub const fn artifact_label() -> &'static str { "systemd unit" @@ -21,88 +23,168 @@ pub fn primary_artifact_path(artifact_root: &Path) -> PathBuf { } pub fn artifacts(artifact_root: &Path, bin_dir: &Path) -> Vec { - vec![ServiceArtifact::file( - primary_artifact_path(artifact_root), - render_unit(bin_dir), - )] -} - -pub fn availability_command() -> Option { - Some( - CommandSpec::new( - "systemctl --user --no-pager --plain list-units --type=service", - "systemctl", - [ - "--user", - "--no-pager", - "--plain", - "list-units", - "--type=service", - ], - ) - .quiet(), + vec![ + ServiceArtifact::file(primary_artifact_path(artifact_root), render_unit(bin_dir)), + ServiceArtifact::file( + control_activation_path(bin_dir), + render_control_activation(bin_dir), + ), + ] +} + +pub fn availability_probe() -> ServiceManagerAvailabilityProbe { + // is-system-running gives one bounded manager state instead of an unbounded unit listing + let command = CommandSpec::new( + "systemctl --user is-system-running", + "systemctl", + ["--user", "is-system-running"], ) + // Transport diagnostics are matched only in the stable C locale + .env("LC_ALL", "C"); + ServiceManagerAvailabilityProbe::new(command, interpret_availability) +} + +fn interpret_availability( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + match output.stdout().trim() { + "initializing" | "starting" | "running" | "degraded" | "maintenance" | "stopping" => { + ServiceManagerAvailability::Available + } + "offline" => ServiceManagerAvailability::Unavailable, + _ if !output.status_success() + && output.did_exit() + && output + .stderr() + .trim() + .starts_with("Failed to connect to bus") => + { + ServiceManagerAvailability::Unavailable + } + _ => ServiceManagerAvailability::Indeterminate, + } } -pub fn is_enabled_command() -> Option { - Some(CommandSpec::new( +pub fn is_enabled_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user is-enabled --quiet {SERVICE_NAME}"), "systemctl", ["--user", "is-enabled", "--quiet", SERVICE_NAME], - )) + ) } -pub fn is_active_command() -> Option { - Some(CommandSpec::new( - format!("systemctl --user is-active --quiet {SERVICE_NAME}"), +pub fn active_probe() -> ServiceProbe { + // `show` is systemd's machine-readable state interface + // A generic nonzero `is-active` result cannot prove the manager was reachable + let command = CommandSpec::new( + format!("systemctl --user show LoadState and ActiveState for {SERVICE_NAME}"), "systemctl", - ["--user", "is-active", "--quiet", SERVICE_NAME], - )) + [ + "--user", + "show", + "--property=LoadState", + "--property=ActiveState", + SERVICE_NAME, + ], + ); + ServiceProbe::new(command, interpret_active_state) } -pub fn reload_after_artifact_change() -> Option { - Some(CommandSpec::new( +fn interpret_active_state(output: ServiceProbeOutput<'_>) -> ServiceProbeState { + if !output.status_success() { + return ServiceProbeState::Indeterminate; + } + let mut load_state = None; + let mut active_state = None; + for line in output.stdout().lines() { + match line.split_once('=') { + Some(("LoadState", value)) if load_state.replace(value).is_none() => {} + Some(("ActiveState", value)) if active_state.replace(value).is_none() => {} + Some(("LoadState" | "ActiveState", _)) | None => { + return ServiceProbeState::Indeterminate; + } + Some((_other, _value)) => {} + } + } + let load_is_known = matches!( + load_state, + Some("loaded" | "not-found" | "masked" | "error" | "bad-setting") + ); + if !load_is_known { + return ServiceProbeState::Indeterminate; + } + match (load_state, active_state) { + // A missing unit is different from a stopped unit already known to systemd + (Some("not-found"), Some("inactive")) => ServiceProbeState::Absent, + (_, Some("active" | "activating" | "deactivating" | "reloading" | "refreshing")) => { + ServiceProbeState::Active + } + (_, Some("inactive" | "failed")) => ServiceProbeState::Inactive, + (_, Some(_) | None) => ServiceProbeState::Indeterminate, + } +} + +pub fn reload_after_artifact_change() -> CommandSpec { + CommandSpec::new( "systemctl --user daemon-reload", "systemctl", ["--user", "daemon-reload"], - )) + ) +} + +pub fn clear_runtime_mask_command() -> CommandSpec { + // Explicit installation may clear only temporary state from the current login session + CommandSpec::new( + format!("systemctl --user --runtime unmask {SERVICE_NAME}"), + "systemctl", + ["--user", "--runtime", "unmask", SERVICE_NAME], + ) } -pub fn enable_now_command() -> Option { - Some(CommandSpec::new( +pub fn enable_now_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user enable --now {SERVICE_NAME}"), "systemctl", ["--user", "enable", "--now", SERVICE_NAME], - )) + ) } -pub fn start_command() -> Option { - Some(CommandSpec::new( +pub fn start_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user start {SERVICE_NAME}"), "systemctl", ["--user", "start", SERVICE_NAME], - )) + ) } -pub fn disable_now_command() -> Option { - Some(CommandSpec::new( +pub fn disable_now_command() -> CommandSpec { + CommandSpec::new( format!("systemctl --user disable --now {SERVICE_NAME}"), "systemctl", ["--user", "disable", "--now", SERVICE_NAME], - )) + ) } -pub fn stop_for_reinstall_command() -> Option { +pub fn stop_for_reinstall_command() -> CommandSpec { // Stop only this unit during reinstall so systemd never treats the user session as disposable - Some(CommandSpec::new( + CommandSpec::new( format!("systemctl --user stop {SERVICE_NAME}"), "systemctl", ["--user", "stop", SERVICE_NAME], - )) + ) } pub fn hyprland_startup_commands(import_vars: &[&str]) -> Vec { + let allowed = unixnotis_core::service_manager::variables_for_backend( + unixnotis_core::service_manager::ServiceManagerKind::Systemd, + ); + let import_vars = import_vars + .iter() + .copied() + .filter(|name| allowed.contains(name)) + .collect::>(); vec![ + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS".to_string(), format!( "dbus-update-activation-environment {}", import_vars.join(" ") @@ -119,10 +201,19 @@ pub fn environment_sync_commands( import_vars: &[(&str, String)], dbus_update_available: bool, ) -> Vec { - let mut commands = Vec::new(); + // Remove a value persisted by older installers before importing safe graphical variables + let mut commands = vec![CommandSpec::new( + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS", + "systemctl", + ["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"], + )]; + let allowed = unixnotis_core::service_manager::variables_for_backend( + unixnotis_core::service_manager::ServiceManagerKind::Systemd, + ); let names = import_vars .iter() .map(|(name, _value)| *name) + .filter(|name| allowed.contains(name)) .collect::>(); if dbus_update_available { // D-Bus activation and systemd imports solve different environment paths @@ -147,12 +238,18 @@ fn render_unit(bin_dir: &Path) -> String { "Description=UnixNotis Notification Daemon".to_string(), // Order after the graphical session without pulling that target into the unit graph "After=graphical-session.target".to_string(), + // Stop this user service when its graphical session is stopped + "PartOf=graphical-session.target".to_string(), String::new(), "[Service]".to_string(), - "Type=simple".to_string(), + // Control ownership is published only after notification readiness is verified + "Type=dbus".to_string(), + "BusName=com.unixnotis.Control".to_string(), format!("ExecStart={exec_start}"), "Restart=on-failure".to_string(), "RestartSec=1".to_string(), + "TimeoutStartSec=20".to_string(), + "TimeoutStopSec=10".to_string(), String::new(), "[Install]".to_string(), "WantedBy=default.target".to_string(), @@ -163,11 +260,30 @@ fn render_unit(bin_dir: &Path) -> String { fn format_exec_start(bin_dir: &Path) -> String { let path = bin_dir.join("unixnotis-daemon"); - let rendered = format_with_home(&path); - if let Some(tail) = rendered.strip_prefix("$HOME") { - // systemd expands %h itself, while $HOME is not shell-expanded in ExecStart - format!("%h{tail}") - } else { - path.display().to_string() - } + // The service manager receives one concrete executable with no shell or PATH lookup + path.display().to_string() +} + +fn control_activation_path(bin_dir: &Path) -> PathBuf { + // Home-local binaries live beside the matching home-local data directory + let local_root = bin_dir + .parent() + .expect("installer binary directory must have a parent"); + local_root + .join("share") + .join("dbus-1") + .join("services") + .join(CONTROL_ACTIVATION_SERVICE) +} + +fn render_control_activation(bin_dir: &Path) -> String { + let executable = format_exec_start(bin_dir); + [ + "[D-BUS Service]".to_string(), + "Name=com.unixnotis.Control".to_string(), + format!("Exec={executable}"), + format!("SystemdService={SERVICE_NAME}"), + String::new(), + ] + .join("\n") } diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs index 1802081c1..4d6d3a3c1 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/dinit.rs @@ -53,42 +53,70 @@ fn dinit_backend_renders_boot_dependency_artifacts() { fn dinit_backend_commands_match_expected_behavior() { let manager = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit.d")); - let availability = manager - .availability_command() - .expect("dinit has an availability command"); - assert_eq!(availability.program(), "dinitctl"); - assert_eq!(availability.args(), &["--user", "--quiet", "list"]); - // Enablement is artifact-backed, so no manager command should be required for install state assert!(manager.is_enabled_command().is_none()); - let active = manager - .active_probe() - .expect("dinit has an active-state command"); + let active = manager.active_probe(); assert_eq!( active.command().args(), - &[ - "--user", - "--quiet", - "is-started", - UNIXNOTIS_DAEMON_DINIT_SERVICE - ] + &["--user", "status", UNIXNOTIS_DAEMON_DINIT_SERVICE] + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: STARTED\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + for transition in ["STARTING", "STOPPING"] { + assert_eq!( + active.parser_state( + true, + &format!("Service: unixnotis-daemon\n State: {transition}\n") + ), + crate::service_manager::contract::ServiceProbeState::Active + ); + } + assert_eq!( + active.parser_state( + true, + "Service: unixnotis-daemon\n State: STOPPED (terminated)\n" + ), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: STOPPED\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "Service: unixnotis-daemon\n State: UNKNOWN\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + for malformed_state in ["STOPPED_BUT_UNKNOWN", "STOPPED (unterminated", "UNKNOWN)"] { + assert_eq!( + active.parser_state( + true, + &format!("Service: unixnotis-daemon\n State: {malformed_state}\n") + ), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + } + assert_eq!( + active.parser_state_with_result(Some(1), "", "dinitctl: service not loaded.\n"), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state_with_result(Some(1), "", "dinit-client: connecting to socket failed\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); // First install should not reload a service that dinit has not loaded yet assert!(manager.refresh_after_artifact_change().is_none()); - let enable = manager - .enable_now_command() - .expect("dinit starts after artifacts handle persistence"); + let enable = manager.enable_now_command(); assert_eq!( enable.args(), &["--user", "start", UNIXNOTIS_DAEMON_DINIT_SERVICE] ); - let disable = manager - .disable_now_command() - .expect("dinit can stop during uninstall"); + let disable = manager.disable_now_command(); assert_eq!( disable.args(), &[ @@ -206,14 +234,21 @@ fn dinit_backend_environment_sync_uses_setenv() { ] ); assert_eq!( - commands[0].envs(), - &[ - ("WAYLAND_DISPLAY".to_string(), "wayland-1".to_string()), - ("XDG_RUNTIME_DIR".to_string(), "/run/user/1000".to_string()), + commands[0] + .envs() + .iter() + .map(|(name, value)| ( + name.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + )) + .collect::>(), + vec![ ( "DBUS_SESSION_BUS_ADDRESS".to_string(), "unix:path=/tmp/unixnotis-bus".to_string(), ), + ("WAYLAND_DISPLAY".to_string(), "wayland-1".to_string()), + ("XDG_RUNTIME_DIR".to_string(), "/run/user/1000".to_string()), ] ); } diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs index 9163c0dc4..2fc1da56e 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/runit.rs @@ -69,43 +69,53 @@ fn runit_backend_commands_match_expected_behavior() { let manager = ServiceManager::runit_user(PathBuf::from("/tmp/service")); let service_path = "/tmp/service/unixnotis-daemon"; - let availability = manager - .availability_command() - .expect("runit checks sv availability"); - assert_eq!(availability.program(), "sv"); - assert_eq!(availability.args(), &["-V"]); - // A watched service directory is the enablement source, not an sv query assert!(manager.is_enabled_command().is_none()); assert!(manager.refresh_after_artifact_change().is_none()); // sv check tracks the requested state, so active status must parse sv status output - let active = manager - .active_probe() - .expect("runit can parse current status"); + let active = manager.active_probe(); assert_eq!(active.command().args(), &["status", service_path]); assert_eq!( - active.parser_matches("run: /tmp/service/unixnotis-daemon: (pid 123) 2s"), - Some(true) + active.parser_state(true, "run: /tmp/service/unixnotis-daemon: (pid 123) 2s"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "down: /tmp/service/unixnotis-daemon: 1s"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state_with_result( + Some(1), + "fail: /tmp/service/unixnotis-daemon: runsv not running\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state_with_result( + Some(1), + "fail: /tmp/service/unixnotis-daemon: unable to change to service directory: No such file or directory\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Absent ); assert_eq!( - active.parser_matches("down: /tmp/service/unixnotis-daemon: 1s"), - Some(false) + active.parser_state_with_result( + Some(1), + "timeout: down: /tmp/service/unixnotis-daemon: 30s\n", + "" + ), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); - let enable = manager - .enable_now_command() - .expect("runit starts watched service directories"); + let enable = manager.enable_now_command(); assert_eq!(enable.args(), &["start", service_path]); - let disable = manager - .disable_now_command() - .expect("runit stops watched service directories"); + let disable = manager.disable_now_command(); assert_eq!(disable.args(), &["stop", service_path]); - let stop = manager - .stop_for_reinstall_command() - .expect("runit can stop before reinstall"); + let stop = manager.stop_for_reinstall_command(); assert_eq!(stop.args(), &["stop", service_path]); } @@ -243,22 +253,11 @@ fn runit_backend_hyprland_startup_lines_update_envdir_and_restart() { let commands = manager.hyprland_startup_commands(&vars); assert_eq!(commands.len(), 1); - assert!(commands[0].starts_with("sh -lc ")); - assert!(!commands[0].contains('\n')); - assert!(commands[0].contains("umask 077")); - assert!(commands[0].contains("[ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("mkdir -p \"$envdir\" || exit 1")); - assert!(commands[0].contains("[ -d \"$envdir\" ] && [ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("/tmp/service root/unixnotis-daemon/env")); - assert!(commands[0].contains("mktemp \"$envdir/.WAYLAND_DISPLAY.XXXXXX\"")); - assert!(commands[0].contains("printenv WAYLAND_DISPLAY > \"$tmp\" || : > \"$tmp\"")); - assert!(commands[0].contains("chmod 600 \"$tmp\"")); - assert!(commands[0].contains("mv -f \"$tmp\" \"$envdir/WAYLAND_DISPLAY\"")); - assert!(commands[0].contains("\"$envdir/WAYLAND_DISPLAY\"")); - assert!(!commands[0].contains(".PATH.XXXXXX")); - assert!(!commands[0].contains("$envdir/PATH")); - assert!(commands[0].contains("sv restart")); - assert!(commands[0].contains("|| sv start")); + assert_eq!( + commands[0], + "noticenterctl sync-session-environment --service-manager runit" + ); + assert!(!commands[0].contains("sh -lc")); } #[test] @@ -288,7 +287,7 @@ fn runit_readiness_rejects_chpst_that_exists_only_on_path() { let trusted_bin = root.join("trusted-bin"); fs::create_dir_all(&path_bin).expect("path bin"); fs::create_dir_all(&trusted_bin).expect("trusted bin"); - write_executable(path_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); + write_executable(&path_bin.join("chpst"), "#!/bin/sh\nexit 0\n"); let _path = EnvPathGuard::prepend(&path_bin); let _tools = use_fake_tool_bin(&trusted_bin); @@ -307,8 +306,8 @@ fn test_root(name: &str) -> PathBuf { root } -fn write_executable(path: PathBuf, contents: &str) { - write_test_executable(&path, contents); +fn write_executable(path: &Path, contents: &str) { + write_test_executable(path, contents); } struct EnvPathGuard { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs index 5a878e308..afacca979 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/s6.rs @@ -71,8 +71,6 @@ fn s6_backend_commands_match_expected_behavior() { PathBuf::from("/run/user/s6-rc"), ); - // Readiness checks own tool validation because availability needs several s6 programs - assert!(manager.availability_command().is_none()); assert!(manager.is_enabled_command().is_none()); // Database refresh compiles the user source tree before s6-rc can change the live service let Some(ServiceArtifactRefresh::S6Database(refresh)) = manager.refresh_after_artifact_change() @@ -94,7 +92,7 @@ fn s6_backend_commands_match_expected_behavior() { &["-l", "/run/user/s6-rc", "/tmp/s6-data/rc/compiled-next"] ); assert_eq!( - manager.start_command().expect("s6 start command").args(), + manager.start_command().args(), &[ "-l", "/run/user/s6-rc", @@ -104,10 +102,7 @@ fn s6_backend_commands_match_expected_behavior() { ] ); assert_eq!( - manager - .disable_now_command() - .expect("s6 stop command") - .args(), + manager.disable_now_command().args(), &[ "-l", "/run/user/s6-rc", @@ -124,11 +119,27 @@ fn s6_backend_active_probe_parses_s6_svstat_output() { PathBuf::from("/tmp/s6-data"), PathBuf::from("/run/user/s6-rc"), ); - let active = manager.active_probe().expect("s6 active probe"); + let active = manager.active_probe(); // s6-svstat -o up prints a boolean, so parsing stays exact and cheap - assert_eq!(active.parser_matches("true\n"), Some(true)); - assert_eq!(active.parser_matches("false\n"), Some(false)); + assert_eq!( + active.parser_state(true, "true\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "false\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state_with_result(Some(1), "", ""), + crate::service_manager::contract::ServiceProbeState::Absent + ); + for failure_code in [100, 111] { + assert_eq!( + active.parser_state_with_result(Some(failure_code), "", "system error\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + } } #[test] @@ -194,21 +205,12 @@ fn s6_backend_hyprland_startup_lines_update_envdir_and_start_service() { let commands = manager.hyprland_startup_commands(&vars); - // Hyprland receives one shell line because it does not manage multi-step service hooks assert_eq!(commands.len(), 1); - assert!(commands[0].starts_with("sh -lc ")); - assert!(commands[0].contains("[ ! -L \"$envdir\" ] || exit 1")); - assert!(commands[0].contains("mkdir -p \"$envdir\" || exit 1")); - assert!(commands[0].contains("mktemp \"$envdir/.WAYLAND_DISPLAY.XXXXXX\"")); - assert!(!commands[0].contains(".PATH.XXXXXX")); - assert!(!commands[0].contains("s6-db-reload")); - assert!(!commands[0].contains("s6-rc-compile")); - assert!(commands[0].contains("s6-rc -l ")); - assert!(commands[0].contains("/run/user/s6 rc")); - assert!(commands[0].contains("-u change")); - assert!(commands[0].contains("unixnotis-daemon")); - assert!(commands[0].contains("s6-svc -r ")); - assert!(commands[0].contains("/run/user/s6 rc/servicedirs/unixnotis-daemon")); + assert_eq!( + commands[0], + "noticenterctl sync-session-environment --service-manager s6" + ); + assert!(!commands[0].contains("sh -lc")); } #[test] @@ -308,7 +310,7 @@ fn s6_readiness_rejects_tools_that_exist_only_on_path() { "s6-envdir", "s6-svstat", ] { - write_executable(path_bin.join(tool), "#!/bin/sh\nexit 0\n"); + write_executable(&path_bin.join(tool), "#!/bin/sh\nexit 0\n"); } let _path = EnvPathGuard::prepend(&path_bin); let _tools = use_fake_tool_bin(&trusted_bin); @@ -399,12 +401,21 @@ fn s6_active_probe_rejects_truthy_but_non_exact_output() { PathBuf::from("/tmp/s6-data"), PathBuf::from("/run/user/s6-rc"), ); - let active = manager.active_probe().expect("s6 active probe"); + let active = manager.active_probe(); // s6-svstat -o up emits exact true/false, so loose text must not count as active - assert_eq!(active.parser_matches(" true\n"), Some(true)); - assert_eq!(active.parser_matches("true enough\n"), Some(false)); - assert_eq!(active.parser_matches("1\n"), Some(false)); + assert_eq!( + active.parser_state(true, " true\n"), + crate::service_manager::contract::ServiceProbeState::Active + ); + assert_eq!( + active.parser_state(true, "true enough\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); + assert_eq!( + active.parser_state(true, "1\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate + ); } fn test_root(name: &str) -> PathBuf { @@ -413,8 +424,8 @@ fn test_root(name: &str) -> PathBuf { root } -fn write_executable(path: PathBuf, contents: &str) { - write_test_executable(&path, contents); +fn write_executable(path: &Path, contents: &str) { + write_test_executable(path, contents); } struct EnvPathGuard { diff --git a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs index c493d568a..6fd900dc7 100644 --- a/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs +++ b/crates/unixnotis-installer/src/service_manager/backends/tests/systemd.rs @@ -2,15 +2,14 @@ use std::path::PathBuf; use crate::service_manager::{ServiceArtifactKind, ServiceArtifactRefresh, ServiceManager}; -use super::super::systemd::SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE; +use super::super::systemd::{CONTROL_ACTIVATION_SERVICE, SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE}; #[test] fn systemd_backend_renders_exact_unit_artifact() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); let artifacts = manager.artifacts(std::path::Path::new("/tmp/bin")); - // Systemd is the stable default, so this refactor must keep the unit byte-for-byte stable - assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts.len(), 2); assert_eq!( artifacts[0].path, PathBuf::from("/tmp/systemd/user").join(UNIXNOTIS_DAEMON_SERVICE) @@ -24,39 +23,46 @@ fn systemd_backend_renders_exact_unit_artifact() { "[Unit]\n\ Description=UnixNotis Notification Daemon\n\ After=graphical-session.target\n\ + PartOf=graphical-session.target\n\ \n\ [Service]\n\ - Type=simple\n\ + Type=dbus\n\ + BusName=com.unixnotis.Control\n\ ExecStart=/tmp/bin/unixnotis-daemon\n\ Restart=on-failure\n\ RestartSec=1\n\ + TimeoutStartSec=20\n\ + TimeoutStopSec=10\n\ \n\ [Install]\n\ WantedBy=default.target\n" ); + assert_eq!( + artifacts[1].path, + PathBuf::from("/tmp") + .join("share") + .join("dbus-1") + .join("services") + .join(CONTROL_ACTIVATION_SERVICE) + ); + assert_eq!(artifacts[1].kind, ServiceArtifactKind::File); + assert_eq!( + artifacts[1] + .contents + .as_ref() + .expect("activation artifact should render contents"), + "[D-BUS Service]\n\ + Name=com.unixnotis.Control\n\ + Exec=/tmp/bin/unixnotis-daemon\n\ + SystemdService=unixnotis-daemon.service\n" + ); } #[test] fn systemd_backend_commands_match_existing_behavior() { let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - // Availability should remain a read-only user-manager query - let availability = manager - .availability_command() - .expect("systemd has an availability command"); - assert_eq!(availability.program(), "systemctl"); - assert_eq!( - availability.args(), - &[ - "--user", - "--no-pager", - "--plain", - "list-units", - "--type=service" - ] - ); - - // Enabled and active probes intentionally use quiet status checks for fast install-state reads + // Enabled state uses the native status check while active state uses explicit properties let enabled = manager .is_enabled_command() .expect("systemd has an enabled-state command"); @@ -65,12 +71,28 @@ fn systemd_backend_commands_match_existing_behavior() { &["--user", "is-enabled", "--quiet", UNIXNOTIS_DAEMON_SERVICE] ); - let active = manager - .active_probe() - .expect("systemd has an active-state command"); + let active = manager.active_probe(); assert_eq!( active.command().args(), - &["--user", "is-active", "--quiet", UNIXNOTIS_DAEMON_SERVICE] + &[ + "--user", + "show", + "--property=LoadState", + "--property=ActiveState", + UNIXNOTIS_DAEMON_SERVICE + ] + ); + assert_eq!( + active.parser_state(true, "LoadState=loaded\nActiveState=inactive\n"), + crate::service_manager::contract::ServiceProbeState::Inactive + ); + assert_eq!( + active.parser_state(true, "LoadState=not-found\nActiveState=inactive\n"), + crate::service_manager::contract::ServiceProbeState::Absent + ); + assert_eq!( + active.parser_state(false, "Failed to connect to bus\n"), + crate::service_manager::contract::ServiceProbeState::Indeterminate ); // Unit file changes still require daemon-reload before enable/start @@ -80,29 +102,31 @@ fn systemd_backend_commands_match_existing_behavior() { }; assert_eq!(reload.args(), &["--user", "daemon-reload"]); - let enable = manager - .enable_now_command() - .expect("systemd can enable and start"); + let enable = manager.enable_now_command(); assert_eq!( enable.args(), &["--user", "enable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); - let start = manager.start_command().expect("systemd can start"); + let prepare = manager + .prepare_start_command() + .expect("systemd should clear temporary masks before starting"); + assert_eq!( + prepare.args(), + &["--user", "--runtime", "unmask", UNIXNOTIS_DAEMON_SERVICE] + ); + + let start = manager.start_command(); assert_eq!(start.args(), &["--user", "start", UNIXNOTIS_DAEMON_SERVICE]); - let disable = manager - .disable_now_command() - .expect("systemd can disable and stop"); + let disable = manager.disable_now_command(); assert_eq!( disable.args(), &["--user", "disable", "--now", UNIXNOTIS_DAEMON_SERVICE] ); // Reinstall should stop only UnixNotis and never broaden into user-session targets - let stop = manager - .stop_for_reinstall_command() - .expect("systemd can stop during reinstall"); + let stop = manager.stop_for_reinstall_command(); assert_eq!(stop.args(), &["--user", "stop", UNIXNOTIS_DAEMON_SERVICE]); } @@ -117,6 +141,7 @@ fn hyprland_startup_lines_come_from_selected_backend() { assert_eq!( commands, vec![ + "systemctl --user unset-environment DBUS_SESSION_BUS_ADDRESS".to_string(), "dbus-update-activation-environment WAYLAND_DISPLAY XDG_RUNTIME_DIR".to_string(), "systemctl --user import-environment WAYLAND_DISPLAY XDG_RUNTIME_DIR".to_string(), format!("systemctl --user --no-block restart {UNIXNOTIS_DAEMON_SERVICE}"), @@ -136,43 +161,44 @@ fn environment_sync_commands_come_from_selected_backend() { ), ]; - // D-Bus sync runs first because systemd activation and DBus activation are separate stores + // Legacy bus state is removed before either graphical environment store is updated let with_dbus = manager.environment_sync_commands(&vars, true); - assert_eq!(with_dbus.len(), 2); - assert_eq!(with_dbus[0].program(), "dbus-update-activation-environment"); + assert_eq!(with_dbus.len(), 3); + assert_eq!(with_dbus[0].program(), "systemctl"); assert_eq!( with_dbus[0].args(), - &[ - "WAYLAND_DISPLAY", - "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", - ] + &["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"] ); - assert_eq!(with_dbus[1].program(), "systemctl"); + assert_eq!(with_dbus[1].program(), "dbus-update-activation-environment"); + assert_eq!(with_dbus[1].args(), &["WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]); + assert_eq!(with_dbus[2].program(), "systemctl"); assert_eq!( - with_dbus[1].args(), + with_dbus[2].args(), &[ "--user", "--no-pager", "import-environment", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", ] ); let without_dbus = manager.environment_sync_commands(&vars, false); - assert_eq!(without_dbus.len(), 1); + assert_eq!(without_dbus.len(), 2); assert_eq!(without_dbus[0].program(), "systemctl"); assert_eq!( without_dbus[0].args(), + &["--user", "unset-environment", "DBUS_SESSION_BUS_ADDRESS"] + ); + assert_eq!(without_dbus[1].program(), "systemctl"); + assert_eq!( + without_dbus[1].args(), &[ "--user", "--no-pager", "import-environment", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", - "DBUS_SESSION_BUS_ADDRESS", ] ); } diff --git a/crates/unixnotis-installer/src/service_manager/contract/artifact.rs b/crates/unixnotis-installer/src/service_manager/contract/artifact.rs index 2d18ba6b0..9495dddee 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/artifact.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/artifact.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io; use std::path::{Path, PathBuf}; pub const MANAGED_DIRECTORY_MARKER: &str = ".unixnotis-managed"; @@ -32,6 +33,13 @@ pub struct ServiceArtifact { pub mode: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceArtifactState { + Missing, + Expected, + UnexpectedObject, +} + impl ServiceArtifact { pub(in crate::service_manager) const fn file(path: PathBuf, contents: String) -> Self { // File artifacts are the simplest manager-owned shape, used by systemd and dinit @@ -44,69 +52,69 @@ impl ServiceArtifact { } pub fn is_present_safely(&self) -> bool { - // State checks must match writer/remover ownership rules, not raw path existence - match &self.kind { + // Compatibility callers need a boolean while conflict scans retain inspection errors + self.inspect() + .is_ok_and(|state| state == ServiceArtifactState::Expected) + } + + pub fn exists_at_path_but_not_safely(&self) -> bool { + self.inspect() + .is_ok_and(|state| state == ServiceArtifactState::UnexpectedObject) + } + + pub fn inspect(&self) -> io::Result { + let metadata = match fs::symlink_metadata(&self.path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceArtifactState::Missing) + } + Err(error) => return Err(error), + }; + + let expected = match &self.kind { ServiceArtifactKind::File | ServiceArtifactKind::ExecutableFile => { - // A symlink at a file path is never counted as installed - path_is_regular_file(&self.path) + metadata.file_type().is_file() } ServiceArtifactKind::SharedFile { .. } => { - // Shared files are safe only when the existing bytes match the backend contract - path_is_regular_file(&self.path) - && self - .contents - .as_ref() - .is_some_and(|expected| file_contents_match(&self.path, expected)) + if metadata.file_type().is_file() { + let Some(expected) = self.contents.as_ref() else { + return Ok(ServiceArtifactState::UnexpectedObject); + }; + fs::read_to_string(&self.path)? == *expected + } else { + false + } } - ServiceArtifactKind::Directory => path_is_directory(&self.path), + ServiceArtifactKind::Directory => metadata.file_type().is_dir(), ServiceArtifactKind::ManagedDirectory => { - // Directory backends need the marker before state can call them installer-owned - path_is_directory(&self.path) - && managed_directory_marker_is_valid(&managed_directory_marker(&self.path)) + metadata.file_type().is_dir() && inspect_managed_marker(&self.path)? } - ServiceArtifactKind::Symlink { target } => fs::read_link(&self.path) - // Symlink state is exact because enablement can depend on the stored target - .is_ok_and(|actual| actual == *target), - } - } - - pub fn exists_at_path_but_not_safely(&self) -> bool { - // Unsafe paths are real filesystem entries that do not match the expected artifact shape - // Reporting them separately avoids making symlinks or foreign directories look absent - path_exists_without_following(&self.path) && !self.is_present_safely() + ServiceArtifactKind::Symlink { target } => { + metadata.file_type().is_symlink() && fs::read_link(&self.path)? == *target + } + }; + Ok(if expected { + ServiceArtifactState::Expected + } else { + ServiceArtifactState::UnexpectedObject + }) } } -fn path_is_regular_file(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file()) -} - -fn path_is_directory(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) -} - -fn path_exists_without_following(path: &Path) -> bool { - // symlink_metadata checks the artifact path itself, which is what safety diagnostics need - fs::symlink_metadata(path).is_ok() -} - -fn file_contents_match(path: &Path, expected: &str) -> bool { - // Shared setup files use exact tiny contents, such as s6 bundle type declarations - fs::read_to_string(path).is_ok_and(|contents| contents == expected) +fn inspect_managed_marker(directory: &Path) -> io::Result { + let marker = managed_directory_marker(directory); + let metadata = match fs::symlink_metadata(&marker) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if !metadata.file_type().is_file() { + return Ok(false); + } + Ok(fs::read_to_string(marker)? == MANAGED_DIRECTORY_MARKER_CONTENTS) } pub fn managed_directory_marker(path: &Path) -> PathBuf { // Keep marker placement centralized so writer, remover, and state checks agree path.join(MANAGED_DIRECTORY_MARKER) } - -pub fn managed_directory_marker_is_valid(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - // A marker symlink is not ownership proof because it can point outside the service dir - if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { - return false; - } - fs::read_to_string(path).is_ok_and(|contents| contents == MANAGED_DIRECTORY_MARKER_CONTENTS) -} diff --git a/crates/unixnotis-installer/src/service_manager/contract/availability.rs b/crates/unixnotis-installer/src/service_manager/contract/availability.rs new file mode 100644 index 000000000..bdea5ebaa --- /dev/null +++ b/crates/unixnotis-installer/src/service_manager/contract/availability.rs @@ -0,0 +1,99 @@ +//! Bounded manager-transport availability probes + +use std::io; +use std::time::Duration; + +use super::CommandSpec; + +const DEFAULT_AVAILABILITY_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_AVAILABILITY_STREAM_BYTES: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceManagerAvailability { + // The manager transport accepted a read-only query + Available, + // The command is missing or its manager transport is not reachable + Unavailable, + // The manager query ran but did not prove whether its transport is reachable + Indeterminate, +} + +pub struct ServiceManagerAvailabilityProbe { + command: CommandSpec, + interpret: fn(ServiceManagerAvailabilityOutput<'_>) -> ServiceManagerAvailability, +} + +impl ServiceManagerAvailabilityProbe { + pub(in crate::service_manager) const fn new( + command: CommandSpec, + interpret: fn(ServiceManagerAvailabilityOutput<'_>) -> ServiceManagerAvailability, + ) -> Self { + Self { command, interpret } + } + + pub fn evaluate(&self) -> io::Result { + self.evaluate_with_timeout(DEFAULT_AVAILABILITY_TIMEOUT) + } + + pub(crate) fn evaluate_with_timeout( + &self, + timeout: Duration, + ) -> io::Result { + let mut command = match self.command.to_command() { + Ok(command) => command, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceManagerAvailability::Unavailable); + } + Err(error) => return Err(error), + }; + let output = crate::system_tools::output_bounded( + &mut command, + timeout, + MAX_AVAILABILITY_STREAM_BYTES, + )?; + if output.stdout_truncated || output.stderr_truncated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service-manager availability output exceeded its safe byte limit", + )); + } + let Ok(stdout) = std::str::from_utf8(&output.stdout) else { + return Ok(ServiceManagerAvailability::Indeterminate); + }; + let Ok(stderr) = std::str::from_utf8(&output.stderr) else { + return Ok(ServiceManagerAvailability::Indeterminate); + }; + Ok((self.interpret)(ServiceManagerAvailabilityOutput { + status_success: output.status.success(), + did_exit: output.status.code().is_some(), + stdout, + stderr, + })) + } +} + +#[derive(Clone, Copy)] +pub(in crate::service_manager) struct ServiceManagerAvailabilityOutput<'a> { + status_success: bool, + did_exit: bool, + stdout: &'a str, + stderr: &'a str, +} + +impl<'a> ServiceManagerAvailabilityOutput<'a> { + pub(in crate::service_manager) const fn status_success(self) -> bool { + self.status_success + } + + pub(in crate::service_manager) const fn did_exit(self) -> bool { + self.did_exit + } + + pub(in crate::service_manager) const fn stdout(self) -> &'a str { + self.stdout + } + + pub(in crate::service_manager) const fn stderr(self) -> &'a str { + self.stderr + } +} diff --git a/crates/unixnotis-installer/src/service_manager/contract/command.rs b/crates/unixnotis-installer/src/service_manager/contract/command.rs index 2fe6c8d40..1797c9ea3 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/command.rs @@ -1,18 +1,12 @@ -use std::process::{Command, Stdio}; +use std::process::Command; +use unixnotis_core::CommandSpec as ProcessCommandSpec; #[derive(Clone, Debug, Eq, PartialEq)] pub struct CommandSpec { // Human-readable command shown in logs without exposing inherited environment values label: String, - // Executable name stays separate so tests can assert command construction directly - program: String, - // Arguments are stored as data so no shell parsing is involved - pub(in crate::service_manager::contract) args: Vec, - // Env overrides keep sensitive values out of argv while still giving child tools the session - pub(in crate::service_manager::contract) envs: Vec<(String, String)>, - // Some probes are intentionally quiet to avoid corrupting the TUI - suppress_stdout: bool, - suppress_stderr: bool, + // Shared process spec keeps executable, arguments, and environment structurally separate + command: ProcessCommandSpec, } impl CommandSpec { @@ -27,11 +21,10 @@ impl CommandSpec { { Self { label: label.into(), - program: program.into(), - args: args.into_iter().map(|arg| arg.to_string()).collect(), - envs: Vec::new(), - suppress_stdout: false, - suppress_stderr: false, + command: ProcessCommandSpec::direct( + program.into(), + args.into_iter().map(|arg| arg.to_string()), + ), } } @@ -41,14 +34,7 @@ impl CommandSpec { value: impl Into, ) -> Self { // Values live in the child environment instead of the process argument list - self.envs.push((name.into(), value.into())); - self - } - - pub(in crate::service_manager) const fn quiet(mut self) -> Self { - // Availability probes should not leak command output into the parent process - self.suppress_stdout = true; - self.suppress_stderr = true; + self.command = self.command.with_env(name.into(), value.into()); self } @@ -57,23 +43,30 @@ impl CommandSpec { } pub fn program(&self) -> &str { - &self.program + self.command + .program() + .and_then(|program| program.to_str()) + .expect("installer service commands always use UTF-8 direct programs") + } + + pub fn args(&self) -> &[std::ffi::OsString] { + self.command.args().unwrap_or_default() + } + + pub const fn envs( + &self, + ) -> &std::collections::BTreeMap { + self.command + .env() + .expect("installer service commands are always direct") } pub fn to_command(&self) -> std::io::Result { - let mut command = Command::new(super::command_routing::command_program(&self.program)?); + let program = self.program(); + let mut command = Command::new(super::command_routing::command_program(program)?); // CommandSpec never goes through a shell, which keeps service-manager commands predictable - command.args(&self.args); - for (name, value) in &self.envs { - // Only backend-selected variables are added; inherited process env is left alone - command.env(name, value); - } - if self.suppress_stdout { - command.stdout(Stdio::null()); - } - if self.suppress_stderr { - command.stderr(Stdio::null()); - } + command.args(self.args()); + command.envs(self.envs()); Ok(command) } } diff --git a/crates/unixnotis-installer/src/service_manager/contract/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/mod.rs index 5a6f0e86d..55e53b775 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/mod.rs @@ -1,6 +1,7 @@ //! Shared service-manager artifacts, commands, probes, and refresh plans mod artifact; +mod availability; mod command; // Fake service-manager routing lives under /tests and never enters production binaries #[expect( @@ -18,17 +19,17 @@ mod refresh; mod shell; pub use artifact::{ - managed_directory_marker, managed_directory_marker_is_valid, ServiceArtifact, - ServiceArtifactKind, MANAGED_DIRECTORY_MARKER, MANAGED_DIRECTORY_MARKER_CONTENTS, + ServiceArtifact, ServiceArtifactKind, ServiceArtifactState, MANAGED_DIRECTORY_MARKER, + MANAGED_DIRECTORY_MARKER_CONTENTS, }; +pub(super) use availability::ServiceManagerAvailabilityOutput; +pub use availability::{ServiceManagerAvailability, ServiceManagerAvailabilityProbe}; pub use command::CommandSpec; -pub use probe::ServiceProbe; +pub(super) use probe::ServiceProbeOutput; +pub use probe::{ServiceProbe, ServiceProbeState}; pub use readiness::ReadinessIssue; pub use refresh::{S6DatabaseRefresh, ServiceArtifactRefresh}; -pub(super) use shell::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, shell_quote_path, -}; +pub(super) use shell::{envdir_file_contents, is_safe_env_name, shell_quote, shell_quote_path}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/service_manager/contract/probe.rs b/crates/unixnotis-installer/src/service_manager/contract/probe.rs index 182c3fdeb..899221d73 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/probe.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/probe.rs @@ -1,47 +1,110 @@ +//! Bounded service-manager state probes + use std::io; +use std::time::Duration; use super::command::CommandSpec; +const DEFAULT_SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_SERVICE_PROBE_STREAM_BYTES: usize = 16 * 1024; + #[derive(Clone, Debug)] -pub enum ServiceProbe { - // Exit-only probes fit managers with exact status commands - ExitStatus(CommandSpec), - // Some managers need stdout because exit status means "command worked", not "service runs" - Stdout { - command: CommandSpec, - parser: fn(&str) -> bool, - }, +pub struct ServiceProbe { + pub(in crate::service_manager::contract) command: CommandSpec, + pub(in crate::service_manager::contract) interpret: + fn(ServiceProbeOutput<'_>) -> ServiceProbeState, } -impl ServiceProbe { - pub(in crate::service_manager) const fn exit_status(command: CommandSpec) -> Self { - Self::ExitStatus(command) +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServiceProbeState { + // The manager tool or its manager-level transport is unavailable + Unavailable, + // The manager exists but has no live UnixNotis service record + Absent, + // UnixNotis is known to the manager and stopped + Inactive, + // UnixNotis is running or moving through a live transition + Active, + // The probe could not establish a trustworthy state + Indeterminate, +} + +#[derive(Clone, Copy)] +pub(in crate::service_manager) struct ServiceProbeOutput<'a> { + pub(in crate::service_manager::contract) status_success: bool, + pub(in crate::service_manager::contract) status_code: Option, + pub(in crate::service_manager::contract) stdout: &'a str, + pub(in crate::service_manager::contract) stderr: &'a str, +} + +impl<'a> ServiceProbeOutput<'a> { + pub(in crate::service_manager) const fn status_success(self) -> bool { + self.status_success } - pub(in crate::service_manager) fn stdout( + pub(in crate::service_manager) const fn status_code(self) -> Option { + self.status_code + } + + pub(in crate::service_manager) const fn stdout(self) -> &'a str { + self.stdout + } + + pub(in crate::service_manager) const fn stderr(self) -> &'a str { + self.stderr + } +} + +impl ServiceProbe { + pub(in crate::service_manager) const fn new( command: CommandSpec, - parser: fn(&str) -> bool, + interpret: fn(ServiceProbeOutput<'_>) -> ServiceProbeState, ) -> Self { - Self::Stdout { command, parser } + Self { command, interpret } } - pub fn evaluate(&self) -> io::Result { - match self { - Self::ExitStatus(command) => { - // systemd and dinit status commands already encode active state in exit status - command - .to_command()? - .status() - .map(|status| status.success()) - } - Self::Stdout { command, parser } => { - // runit status needs stdout parsing because `sv check` can pass for down state - let output = command.to_command()?.output()?; - if !output.status.success() { - return Ok(false); - } - Ok(parser(&String::from_utf8_lossy(&output.stdout))) + pub fn evaluate_state(&self) -> io::Result { + self.evaluate_state_with_timeout(DEFAULT_SERVICE_PROBE_TIMEOUT) + } + + pub(crate) fn evaluate_state_with_timeout( + &self, + timeout: Duration, + ) -> io::Result { + let mut command = match self.command.to_command() { + Ok(command) => command, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ServiceProbeState::Unavailable); } + Err(error) => return Err(error), + }; + let output = crate::system_tools::output_bounded( + &mut command, + timeout, + MAX_SERVICE_PROBE_STREAM_BYTES, + )?; + if output.stdout_truncated || output.stderr_truncated { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service-manager probe output exceeded its safe byte limit", + )); } + let Ok(stdout) = std::str::from_utf8(&output.stdout) else { + return Ok(ServiceProbeState::Indeterminate); + }; + let Ok(stderr) = std::str::from_utf8(&output.stderr) else { + return Ok(ServiceProbeState::Indeterminate); + }; + Ok((self.interpret)(ServiceProbeOutput { + status_success: output.status.success(), + // A signal-terminated manager cannot prove a stable service state + status_code: output.status.code(), + stdout, + stderr, + })) + } + + pub(crate) const fn default_timeout() -> Duration { + DEFAULT_SERVICE_PROBE_TIMEOUT } } diff --git a/crates/unixnotis-installer/src/service_manager/contract/shell.rs b/crates/unixnotis-installer/src/service_manager/contract/shell.rs index cac22a6c3..537ae3753 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/shell.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/shell.rs @@ -1,41 +1,11 @@ use std::path::Path; -/// Build the shared envdir setup used by Hyprland bootstrap commands -/// -/// Each returned item is a shell fragment. Backends join them into one -/// `sh -lc` line because Hyprland startup entries are single command strings -pub(in crate::service_manager) fn envdir_sync_prelude(env_dir: &Path) -> Vec { - let envdir = shell_quote_path(env_dir); - - vec![ - "umask 077".to_string(), - format!("envdir={envdir}"), - reject_symlinked_envdir(), - create_envdir(), - verify_real_envdir(), - ] -} - -/// Render one envdir file update for a selected environment variable -/// -/// Missing variables intentionally create empty files. Both chpst and -/// s6-envdir treat empty envdir files as an unset request -pub(in crate::service_manager) fn render_envdir_shell_update(name: &str) -> String { - [ - create_envdir_temp_file(name), - write_envdir_temp_file(name), - chmod_envdir_temp_file(), - replace_envdir_file(name), - ] - .join("; ") -} - /// Convert an env value into envdir file contents /// /// Envdir readers only use the first line and trim trailing blanks. Matching /// that behavior before writing avoids keeping stale shell noise pub(in crate::service_manager) fn envdir_file_contents(value: Option<&str>) -> String { - value.map_or_else(String::new, |value| format!("{}\n", envdir_value(value))) + unixnotis_core::service_manager::envdir_file_contents(value) } /// Return true when a variable name can safely become an envdir file name @@ -45,7 +15,7 @@ pub(in crate::service_manager) fn is_safe_env_name(name: &str) -> bool { return false; }; - // Keep names in ordinary shell-variable form so generated shell stays simple + // Keep names in ordinary environment-variable form so they remain safe file names (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) } @@ -72,48 +42,3 @@ pub(in crate::service_manager) fn shell_quote(raw: &str) -> String { quoted.push('\''); quoted } - -fn reject_symlinked_envdir() -> String { - r#"[ ! -L "$envdir" ] || exit 1"#.to_string() -} - -fn create_envdir() -> String { - r#"mkdir -p "$envdir" || exit 1"#.to_string() -} - -fn verify_real_envdir() -> String { - r#"[ -d "$envdir" ] && [ ! -L "$envdir" ] || exit 1"#.to_string() -} - -fn create_envdir_temp_file(name: &str) -> String { - // mktemp creates a fresh path under the already-verified envdir - // The hidden prefix keeps partial writes out of normal envdir reads - format!("tmp=$(mktemp \"$envdir/.{name}.XXXXXX\") || exit") -} - -fn write_envdir_temp_file(name: &str) -> String { - // printenv failure means the variable is absent, not that sync should fail - // Empty envdir files intentionally unset stale values for chpst and s6-envdir - format!("printenv {name} > \"$tmp\" || : > \"$tmp\"") -} - -fn chmod_envdir_temp_file() -> String { - // Keep session paths private even when the user's umask is permissive - // Cleanup on chmod failure avoids leaving a readable temp file behind - "chmod 600 \"$tmp\" || { rm -f \"$tmp\"; exit 1; }".to_string() -} - -fn replace_envdir_file(name: &str) -> String { - // mv replaces the env file path instead of appending or following shell redirects - // Cleanup on mv failure keeps the envdir from filling with stale temp files - format!("mv -f \"$tmp\" \"$envdir/{name}\" || {{ rm -f \"$tmp\"; exit 1; }}") -} - -fn envdir_value(value: &str) -> String { - value - .split(['\0', '\n']) - .next() - .unwrap_or_default() - .trim_end_matches([' ', '\t']) - .to_string() -} diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs index aba6052e7..c3cc903f9 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/artifact.rs @@ -1,27 +1,30 @@ use std::fs; -use std::os::unix::fs::symlink; +use std::os::unix::fs::{symlink, PermissionsExt}; use std::path::PathBuf; use crate::service_manager::backends::systemd::SERVICE_NAME as UNIXNOTIS_DAEMON_SERVICE; use crate::service_manager::contract::MANAGED_DIRECTORY_MARKER; -use crate::service_manager::{ServiceArtifact, ServiceArtifactKind, ServiceManager}; +use crate::service_manager::{ + ServiceArtifact, ServiceArtifactKind, ServiceArtifactState, ServiceManager, +}; #[test] fn systemd_backend_reports_primary_artifact_path() { - let root = PathBuf::from("/tmp/systemd/user"); + let root = std::env::temp_dir().join("systemd").join("user"); let manager = ServiceManager::systemd_user(root.clone()); assert_eq!(manager.artifact_root(), root); assert_eq!( manager.primary_artifact_path(), - PathBuf::from("/tmp/systemd/user").join(UNIXNOTIS_DAEMON_SERVICE) + root.join(UNIXNOTIS_DAEMON_SERVICE) ); } #[test] fn systemd_backend_uses_file_artifact_not_external_renderer() { - let manager = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd/user")); - let artifacts = manager.artifacts(std::path::Path::new("/tmp/bin")); + let manager = ServiceManager::systemd_user(std::env::temp_dir().join("systemd").join("user")); + let binary_root = std::env::temp_dir().join("bin"); + let artifacts = manager.artifacts(&binary_root); assert_eq!(artifacts[0].kind, ServiceArtifactKind::File); assert!(artifacts[0].contents.is_some()); @@ -41,6 +44,10 @@ fn managed_directory_presence_requires_marker_file() { }; assert!(!artifact.is_present_safely()); + assert_eq!( + artifact.inspect().expect("missing marker state"), + ServiceArtifactState::UnexpectedObject + ); fs::write(service_dir.join(MANAGED_DIRECTORY_MARKER), "unixnotis\n").expect("marker"); @@ -195,6 +202,53 @@ fn managed_directory_marker_rejects_wrong_contents() { let _ = fs::remove_dir_all(root); } +#[test] +fn artifact_inspection_propagates_non_missing_path_errors() { + let root = test_root("artifact-inspection-error"); + fs::create_dir_all(&root).expect("create artifact inspection root"); + let regular_parent = root.join("regular-parent"); + fs::write(®ular_parent, "not a directory").expect("create invalid parent"); + let artifact = ServiceArtifact { + path: regular_parent.join("service"), + kind: ServiceArtifactKind::File, + contents: Some("owned".to_string()), + mode: None, + }; + + assert!( + artifact.inspect().is_err(), + "path lookup failures must not become missing artifacts" + ); + fs::remove_dir_all(root).expect("remove artifact inspection fixture"); +} + +#[test] +fn managed_marker_inspection_propagates_permission_errors() { + // Root bypasses directory mode bits, so this boundary cannot be observed in root CI + if rustix::process::getuid().as_raw() == 0 { + return; + } + let root = test_root("managed-marker-inspection-error"); + let service_dir = root.join("service"); + fs::create_dir_all(&service_dir).expect("create managed service directory"); + fs::set_permissions(&service_dir, fs::Permissions::from_mode(0o000)) + .expect("remove service directory search permission"); + let artifact = ServiceArtifact { + path: service_dir.clone(), + kind: ServiceArtifactKind::ManagedDirectory, + contents: None, + mode: None, + }; + + assert!( + artifact.inspect().is_err(), + "marker lookup failures must not become an absent marker" + ); + fs::set_permissions(&service_dir, fs::Permissions::from_mode(0o700)) + .expect("restore service directory permission"); + fs::remove_dir_all(root).expect("remove marker inspection fixture"); +} + #[test] fn plain_directory_presence_rejects_regular_file() { let root = test_root("artifact-directory-presence"); diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs new file mode 100644 index 000000000..09fbe4782 --- /dev/null +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/availability.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::super::{ + command_routing::use_fake_command_bin, CommandSpec, ServiceManagerAvailability, + ServiceManagerAvailabilityOutput, ServiceManagerAvailabilityProbe, +}; + +fn success_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +fn successful_output_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.status_success() && output.did_exit() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +fn normal_exit_is_available( + output: ServiceManagerAvailabilityOutput<'_>, +) -> ServiceManagerAvailability { + if output.did_exit() { + ServiceManagerAvailability::Available + } else { + ServiceManagerAvailability::Indeterminate + } +} + +struct TempDirGuard { + path: std::path::PathBuf, +} + +impl TempDirGuard { + fn new(label: &str) -> Self { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock moved backwards") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "unixnotis-manager-availability-{label}-{}-{stamp}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create availability test directory"); + Self { path } + } + + fn link_shell(&self, name: &str) { + symlink("/bin/sh", self.path.join(name)).expect("link fake availability tool"); + } +} + +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn successful_transport_query_reports_manager_available() { + let root = TempDirGuard::new("available"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("manager availability", "managerctl", ["-c", "exit 0"]), + success_is_available, + ); + + assert_eq!( + probe.evaluate().expect("availability probe should run"), + ServiceManagerAvailability::Available + ); +} + +#[test] +fn generic_nonzero_result_remains_indeterminate_until_a_backend_recognizes_it() { + let root = TempDirGuard::new("indeterminate-failure"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("manager availability", "managerctl", ["-c", "exit 1"]), + success_is_available, + ); + + assert_eq!( + probe + .evaluate() + .expect("backend interpretation should return a stable state"), + ServiceManagerAvailability::Indeterminate + ); +} + +#[test] +fn signal_terminated_query_remains_indeterminate() { + let root = TempDirGuard::new("signal-terminated"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "signal-terminated manager availability", + "managerctl", + ["-c", "kill -TERM $$"], + ), + normal_exit_is_available, + ); + + assert_eq!( + probe + .evaluate() + .expect("signal termination should remain a stable probe result"), + ServiceManagerAvailability::Indeterminate + ); +} + +#[test] +fn missing_manager_tool_reports_manager_unavailable() { + let root = TempDirGuard::new("tool-unavailable"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "missing manager availability", + "missing-managerctl", + ["status"], + ), + success_is_available, + ); + + assert_eq!( + probe.evaluate().expect("missing manager is a stable state"), + ServiceManagerAvailability::Unavailable + ); +} + +#[test] +fn unsafe_manager_tool_object_remains_an_error() { + let root = TempDirGuard::new("unsafe-tool"); + fs::create_dir(root.path.join("managerctl")).expect("create unsafe manager tool object"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new("unsafe manager availability", "managerctl", ["status"]), + success_is_available, + ); + + probe + .evaluate() + .expect_err("an unsafe manager tool must not look unavailable"); +} + +#[test] +fn availability_timeout_remains_an_error() { + let root = TempDirGuard::new("timeout"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "timed manager availability", + "managerctl", + ["-c", "sleep 30"], + ), + success_is_available, + ); + + let error = probe + .evaluate_with_timeout(Duration::from_millis(25)) + .expect_err("a hung manager must not look unavailable"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); +} + +#[test] +fn moderate_availability_output_remains_within_the_capture_budget() { + let root = TempDirGuard::new("moderate-output"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with moderate output", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 2048 ]; do printf x; i=$((i + 1)); done", + ], + ), + successful_output_is_available, + ); + + assert_eq!( + probe.evaluate().expect("moderate output must stay bounded"), + ServiceManagerAvailability::Available + ); +} + +#[test] +fn oversized_stdout_is_rejected_without_requiring_oversized_stderr() { + let root = TempDirGuard::new("oversized-stdout"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with oversized stdout", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 17000 ]; do printf x; i=$((i + 1)); done", + ], + ), + success_is_available, + ); + + let error = probe + .evaluate() + .expect_err("oversized stdout must not reach the backend parser"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn oversized_stderr_is_rejected_without_requiring_oversized_stdout() { + let root = TempDirGuard::new("oversized-stderr"); + root.link_shell("managerctl"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceManagerAvailabilityProbe::new( + CommandSpec::new( + "manager availability with oversized stderr", + "managerctl", + [ + "-c", + "i=0; while [ \"$i\" -lt 17000 ]; do printf x >&2; i=$((i + 1)); done", + ], + ), + success_is_available, + ); + + let error = probe + .evaluate() + .expect_err("oversized stderr must not reach the backend parser"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs index ca3558ff3..1b07bc09a 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/command.rs @@ -5,16 +5,6 @@ use super::super::command_routing::use_fake_command_bin; use crate::service_manager::CommandSpec; use crate::test_support::fs::write_executable; -impl CommandSpec { - pub(crate) fn args(&self) -> &[String] { - &self.args - } - - pub(crate) fn envs(&self) -> &[(String, String)] { - &self.envs - } -} - struct TempDirGuard { path: std::path::PathBuf, } diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs index 382188a9e..579b9175b 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/command_routing.rs @@ -8,20 +8,38 @@ thread_local! { } pub(super) fn command_program(program: &str) -> std::io::Result { - if let Some(fake_program) = fake_command_program(program) { - return Ok(fake_program.into_os_string()); + if let Some(fake_bin) = configured_fake_command_bin() { + if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "invalid isolated test tool name", + )); + } + let candidate = fake_bin.join(program); + // Fake executable links resolve through the stable test dispatcher + if candidate.is_file() { + return Ok(candidate.into_os_string()); + } + match std::fs::symlink_metadata(&candidate) { + Ok(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{program} is not a regular test tool"), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{program} is unavailable in the isolated test tool directory"), + )); } crate::system_tools::program_path(program).map(PathBuf::into_os_string) } -fn fake_command_program(program: &str) -> Option { - if program.is_empty() || program.contains(std::path::MAIN_SEPARATOR) { - return None; - } - FAKE_COMMAND_BIN.with(|fake_bin| { - let candidate = fake_bin.borrow().as_ref()?.join(program); - candidate.is_file().then_some(candidate) - }) +fn configured_fake_command_bin() -> Option { + FAKE_COMMAND_BIN.with(|fake_bin| fake_bin.borrow().clone()) } pub struct FakeCommandBinGuard { diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs index c9b7e92dc..ffe091c6b 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/mod.rs @@ -1,4 +1,5 @@ mod artifact; +mod availability; mod command; mod probe; mod readiness; diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs index f2a971749..af21d6f19 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/probe.rs @@ -3,21 +3,30 @@ use std::os::unix::fs::symlink; use std::time::{SystemTime, UNIX_EPOCH}; use super::super::command_routing::use_fake_command_bin; -use crate::service_manager::contract::ServiceProbe; +use crate::service_manager::contract::{ServiceProbe, ServiceProbeState}; use crate::service_manager::CommandSpec; impl ServiceProbe { pub(crate) const fn command(&self) -> &CommandSpec { - match self { - Self::ExitStatus(command) | Self::Stdout { command, .. } => command, - } + &self.command } - pub(crate) fn parser_matches(&self, stdout: &str) -> Option { - match self { - Self::ExitStatus(_) => None, - Self::Stdout { parser, .. } => Some(parser(stdout)), - } + pub(crate) fn parser_state(&self, status_success: bool, stdout: &str) -> ServiceProbeState { + self.parser_state_with_result(if status_success { Some(0) } else { Some(1) }, stdout, "") + } + + pub(crate) fn parser_state_with_result( + &self, + status_code: Option, + stdout: &str, + stderr: &str, + ) -> ServiceProbeState { + (self.interpret)(super::super::ServiceProbeOutput { + status_success: status_code == Some(0), + status_code, + stdout, + stderr, + }) } } @@ -52,33 +61,154 @@ impl Drop for TempDirGuard { } #[test] -fn stdout_probe_uses_parser_only_after_successful_command() { +fn explicit_probe_state_is_returned_after_successful_command() { let root = TempDirGuard::new("success"); root.link_shell("probe-tool"); let _tools = use_fake_command_bin(&root.path); - let probe = ServiceProbe::stdout( + let probe = ServiceProbe::new( CommandSpec::new("probe", "probe-tool", ["-c", "printf 'true\\n'; exit 0"]), - |stdout| stdout.trim() == "true", + |output| { + if output.status_success() && output.stdout().trim() == "true" { + ServiceProbeState::Active + } else { + ServiceProbeState::Indeterminate + } + }, ); - let active = probe.evaluate().expect("probe should run"); + let state = probe.evaluate_state().expect("probe should run"); - // Successful stdout probes are allowed to derive active state from command output - assert!(active); + assert_eq!(state, ServiceProbeState::Active); } #[test] -fn stdout_probe_treats_failed_command_as_inactive_even_with_matching_output() { +fn failed_command_is_indeterminate_even_with_active_looking_output() { let root = TempDirGuard::new("failure"); root.link_shell("probe-tool"); let _tools = use_fake_command_bin(&root.path); - let probe = ServiceProbe::stdout( + let probe = ServiceProbe::new( CommandSpec::new("probe", "probe-tool", ["-c", "printf 'true\\n'; exit 1"]), - |stdout| stdout.trim() == "true", + |output| { + if output.status_success() && output.stdout().trim() == "true" { + ServiceProbeState::Active + } else { + ServiceProbeState::Indeterminate + } + }, + ); + + let state = probe.evaluate_state().expect("probe should run"); + + assert_eq!(state, ServiceProbeState::Indeterminate); +} + +#[test] +fn missing_probe_program_is_classified_as_unavailable() { + let root = TempDirGuard::new("unavailable"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("missing manager probe", "missing-managerctl", ["status"]), + |_output| ServiceProbeState::Indeterminate, + ); + + let state = probe + .evaluate_state() + .expect("missing alternate manager should have a stable state"); + + assert_eq!(state, ServiceProbeState::Unavailable); +} + +#[test] +fn unsafe_probe_program_is_an_error_instead_of_unavailable() { + let root = TempDirGuard::new("unsafe-program"); + fs::create_dir(root.path.join("probe-tool")).expect("create unsafe probe tool object"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("unsafe manager probe", "probe-tool", ["status"]), + |_output| ServiceProbeState::Inactive, + ); + + probe + .evaluate_state() + .expect_err("an unsafe program must not be classified as unavailable"); +} + +#[test] +fn oversized_stdout_is_rejected_even_when_stderr_is_empty() { + let root = TempDirGuard::new("oversized-stdout"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "oversized stdout probe", + "probe-tool", + ["-c", "head -c 32768 /dev/zero"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let error = probe + .evaluate_state() + .expect_err("oversized stdout must fail independently of stderr"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn oversized_stderr_is_rejected_even_when_stdout_is_empty() { + let root = TempDirGuard::new("oversized-stderr"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "oversized stderr probe", + "probe-tool", + ["-c", "head -c 32768 /dev/zero >&2"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let error = probe + .evaluate_state() + .expect_err("oversized stderr must fail independently of stdout"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} + +#[test] +fn malformed_stderr_is_indeterminate_before_backend_interpretation() { + let root = TempDirGuard::new("malformed-stderr"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new( + "malformed stderr probe", + "probe-tool", + ["-c", "printf '\\377' >&2"], + ), + |_output| ServiceProbeState::Inactive, + ); + + let state = probe + .evaluate_state() + .expect("malformed manager output has a stable fail-closed state"); + + assert_eq!(state, ServiceProbeState::Indeterminate); +} + +#[test] +fn probe_timeout_is_never_reported_as_inactive() { + let root = TempDirGuard::new("timeout"); + root.link_shell("probe-tool"); + let _tools = use_fake_command_bin(&root.path); + let probe = ServiceProbe::new( + CommandSpec::new("timed probe", "probe-tool", ["-c", "sleep 30"]), + |_output| ServiceProbeState::Inactive, ); - let active = probe.evaluate().expect("probe should run"); + let error = probe + .evaluate_state_with_timeout(std::time::Duration::from_millis(25)) + .expect_err("a timed-out probe must remain indeterminate"); - // Command failure means the manager did not provide trustworthy status output - assert!(!active); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); } diff --git a/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs b/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs index bf5b73341..d0250bdf5 100644 --- a/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs +++ b/crates/unixnotis-installer/src/service_manager/contract/tests/shell.rs @@ -1,41 +1,4 @@ -use std::path::Path; - -use super::super::shell::{ - envdir_file_contents, envdir_sync_prelude, is_safe_env_name, render_envdir_shell_update, - shell_quote, -}; - -#[test] -fn envdir_sync_prelude_renders_readable_guard_steps() { - let steps = envdir_sync_prelude(Path::new("/tmp/service root/env")); - - assert_eq!( - steps, - [ - "umask 077", - "envdir='/tmp/service root/env'", - r#"[ ! -L "$envdir" ] || exit 1"#, - r#"mkdir -p "$envdir" || exit 1"#, - r#"[ -d "$envdir" ] && [ ! -L "$envdir" ] || exit 1"#, - ] - ); -} - -#[test] -fn envdir_shell_update_writes_temp_file_before_replacing_target() { - let update = render_envdir_shell_update("WAYLAND_DISPLAY"); - - // The order matters: create temp, write value, lock permissions, then atomically replace - assert_eq!( - update, - concat!( - r#"tmp=$(mktemp "$envdir/.WAYLAND_DISPLAY.XXXXXX") || exit"#, - r#"; printenv WAYLAND_DISPLAY > "$tmp" || : > "$tmp""#, - r#"; chmod 600 "$tmp" || { rm -f "$tmp"; exit 1; }"#, - r#"; mv -f "$tmp" "$envdir/WAYLAND_DISPLAY" || { rm -f "$tmp"; exit 1; }"# - ) - ); -} +use super::super::shell::{envdir_file_contents, is_safe_env_name, shell_quote}; #[test] fn envdir_file_contents_match_envdir_first_line_semantics() { diff --git a/crates/unixnotis-installer/src/service_manager/mod.rs b/crates/unixnotis-installer/src/service_manager/mod.rs index dc607c6b3..059a3afd7 100644 --- a/crates/unixnotis-installer/src/service_manager/mod.rs +++ b/crates/unixnotis-installer/src/service_manager/mod.rs @@ -9,11 +9,9 @@ mod backends; pub mod contract; mod orchestration; -pub use contract::{ - managed_directory_marker, managed_directory_marker_is_valid, MANAGED_DIRECTORY_MARKER_CONTENTS, -}; +pub use contract::MANAGED_DIRECTORY_MARKER_CONTENTS; pub use contract::{ CommandSpec, ReadinessIssue, S6DatabaseRefresh, ServiceArtifact, ServiceArtifactKind, - ServiceArtifactRefresh, + ServiceArtifactRefresh, ServiceArtifactState, }; pub use orchestration::ServiceManager; diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs b/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs index cab226caf..3effce095 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/artifacts.rs @@ -40,18 +40,19 @@ impl ServiceManager { pub fn refresh_after_artifact_change(&self) -> Option { // s6 returns a compile plan while simpler managers return one reload command match self.kind { - ServiceManagerKind::Systemd => { - systemd::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) - } + ServiceManagerKind::Systemd => Some(ServiceArtifactRefresh::Command( + systemd::reload_after_artifact_change(), + )), ServiceManagerKind::Dinit => { dinit::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) } ServiceManagerKind::Runit => { runit::reload_after_artifact_change().map(ServiceArtifactRefresh::Command) } - ServiceManagerKind::S6 => { - s6::refresh_after_artifact_change(&self.artifact_root, self.live_root()) - } + ServiceManagerKind::S6 => Some(s6::refresh_after_artifact_change( + &self.artifact_root, + self.live_root(), + )), } } diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs index 0caa26da2..30dcb5f0d 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/environment.rs @@ -5,6 +5,11 @@ use super::super::contract::{CommandSpec, ServiceArtifact}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { + pub const fn import_variable_names(&self) -> &'static [&'static str] { + // Backend-specific policy prevents transient shell state from reaching systemd + unixnotis_core::service_manager::variables_for_backend(self.shared_kind()) + } + pub fn hyprland_startup_commands(&self, import_vars: &[&str]) -> Vec { // Startup lines mirror the selected manager instead of assuming systemd match self.kind { diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs index 9091982db..17571587f 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/lifecycle.rs @@ -1,24 +1,40 @@ //! Availability, state probes, and lifecycle command dispatch use super::super::backends::{dinit, runit, s6, systemd}; -use super::super::contract::{CommandSpec, ServiceProbe}; +use super::super::contract::{ + CommandSpec, ServiceManagerAvailability, ServiceManagerAvailabilityProbe, ServiceProbe, +}; use super::model::{ServiceManager, ServiceManagerKind}; impl ServiceManager { - pub fn availability_command(&self) -> Option { - // Availability checks must stay read-only and must not start a service + pub fn prepare_start_command(&self) -> Option { + // Other managers have no temporary mask state to clear before an explicit start match self.kind { - ServiceManagerKind::Systemd => systemd::availability_command(), - ServiceManagerKind::Dinit => dinit::availability_command(), - ServiceManagerKind::Runit => runit::availability_command(), - ServiceManagerKind::S6 => s6::availability_command(), + ServiceManagerKind::Systemd => Some(systemd::clear_runtime_mask_command()), + ServiceManagerKind::Dinit | ServiceManagerKind::Runit | ServiceManagerKind::S6 => None, + } + } + + pub(crate) fn availability_state(&self) -> std::io::Result> { + // None keeps backends without a single manager-level query on their native service probe + self.availability_probe() + .map(|probe| probe.evaluate()) + .transpose() + } + + fn availability_probe(&self) -> Option { + match self.kind { + ServiceManagerKind::Systemd => Some(systemd::availability_probe()), + ServiceManagerKind::Dinit => Some(dinit::availability_probe()), + // sv has no separate manager transport query; status is the authoritative probe + ServiceManagerKind::Runit | ServiceManagerKind::S6 => None, } } pub fn is_enabled_command(&self) -> Option { // Some artifact-backed managers have no separate enabled-state command match self.kind { - ServiceManagerKind::Systemd => systemd::is_enabled_command(), + ServiceManagerKind::Systemd => Some(systemd::is_enabled_command()), ServiceManagerKind::Dinit => dinit::is_enabled_command(), ServiceManagerKind::Runit => runit::is_enabled_command(), ServiceManagerKind::S6 => s6::is_enabled_command(), @@ -35,19 +51,17 @@ impl ServiceManager { } } - pub fn active_probe(&self) -> Option { + pub fn active_probe(&self) -> ServiceProbe { // Probe parsing stays inside each backend because status formats differ match self.kind { - ServiceManagerKind::Systemd => { - systemd::is_active_command().map(ServiceProbe::exit_status) - } - ServiceManagerKind::Dinit => dinit::is_active_command().map(ServiceProbe::exit_status), + ServiceManagerKind::Systemd => systemd::active_probe(), + ServiceManagerKind::Dinit => dinit::active_probe(), ServiceManagerKind::Runit => runit::active_probe(&self.artifact_root), ServiceManagerKind::S6 => s6::active_probe(self.live_root()), } } - pub fn enable_now_command(&self) -> Option { + pub fn enable_now_command(&self) -> CommandSpec { // Enable-and-start is used only when the backend provides one atomic operation match self.kind { ServiceManagerKind::Systemd => systemd::enable_now_command(), @@ -57,7 +71,7 @@ impl ServiceManager { } } - pub fn start_command(&self) -> Option { + pub fn start_command(&self) -> CommandSpec { // Start commands operate on the already installed backend artifact match self.kind { ServiceManagerKind::Systemd => systemd::start_command(), @@ -67,7 +81,7 @@ impl ServiceManager { } } - pub fn disable_now_command(&self) -> Option { + pub fn disable_now_command(&self) -> CommandSpec { // Disable commands stop the service while removing persistent activation match self.kind { ServiceManagerKind::Systemd => systemd::disable_now_command(), @@ -77,7 +91,7 @@ impl ServiceManager { } } - pub fn stop_for_reinstall_command(&self) -> Option { + pub fn stop_for_reinstall_command(&self) -> CommandSpec { // Reinstall stops the old process without discarding persistent enablement match self.kind { ServiceManagerKind::Systemd => systemd::stop_for_reinstall_command(), diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs index d9a05244c..d4346e638 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/model.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/model.rs @@ -68,10 +68,26 @@ impl ServiceManager { } } - pub fn label(&self) -> &'static str { + pub const fn label(&self) -> &'static str { self.kind.label() } + pub const fn shared_kind(&self) -> unixnotis_core::service_manager::ServiceManagerKind { + // Environment policy lives in core so installers and repair commands cannot drift + match self.kind { + ServiceManagerKind::Systemd => { + unixnotis_core::service_manager::ServiceManagerKind::Systemd + } + ServiceManagerKind::Dinit => unixnotis_core::service_manager::ServiceManagerKind::Dinit, + ServiceManagerKind::Runit => unixnotis_core::service_manager::ServiceManagerKind::Runit, + ServiceManagerKind::S6 => unixnotis_core::service_manager::ServiceManagerKind::S6, + } + } + + pub const fn is_systemd(&self) -> bool { + matches!(self.kind, ServiceManagerKind::Systemd) + } + pub const fn service_name(&self) -> &'static str { // The backend owns its native service identifier match self.kind { @@ -82,7 +98,7 @@ impl ServiceManager { } } - pub fn artifact_label(&self) -> &'static str { + pub const fn artifact_label(&self) -> &'static str { // Artifact labels describe the manager-specific file shown in summaries match self.kind { ServiceManagerKind::Systemd => systemd::artifact_label(), @@ -92,7 +108,7 @@ impl ServiceManager { } } - pub fn manager_label(&self) -> &'static str { + pub const fn manager_label(&self) -> &'static str { // Manager labels remain separate from short service identifiers match self.kind { ServiceManagerKind::Systemd => systemd::manager_label(), diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs b/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs index d3a7b7c31..8cf291c90 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/tests/environment.rs @@ -24,3 +24,18 @@ fn artifact_backends_do_not_emit_environment_commands() { assert!(runit.environment_sync_commands(&values, true).is_empty()); assert!(s6.environment_sync_commands(&values, true).is_empty()); } + +#[test] +fn backend_environment_policy_keeps_transient_shell_state_out_of_systemd() { + let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); + let dinit = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")); + + assert!(!systemd + .import_variable_names() + .contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!systemd.import_variable_names().contains(&"PATH")); + assert!(dinit + .import_variable_names() + .contains(&"DBUS_SESSION_BUS_ADDRESS")); + assert!(!dinit.import_variable_names().contains(&"PATH")); +} diff --git a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs index 40e130671..0e38cd869 100644 --- a/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs +++ b/crates/unixnotis-installer/src/service_manager/orchestration/tests/lifecycle.rs @@ -3,30 +3,30 @@ use std::path::PathBuf; use crate::service_manager::ServiceManager; #[test] -fn direct_command_backends_expose_native_availability_probes() { +fn non_systemd_enablement_uses_owned_artifacts() { + let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); + let dinit = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")); + + assert_eq!(systemd.enabled_by_artifacts(), None); + assert_eq!(dinit.enabled_by_artifacts(), Some(false)); +} + +#[test] +fn only_systemd_needs_temporary_start_state_cleanup() { + let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); + assert!( + systemd.prepare_start_command().is_some(), + "systemd should clear a runtime mask before an explicit start" + ); + for manager in [ - ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")), ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")), ServiceManager::runit_user(PathBuf::from("/tmp/runit")), + ServiceManager::s6_user(PathBuf::from("/tmp/s6"), PathBuf::from("/tmp/live")), ] { assert!( - manager.availability_command().is_some(), - "supported manager must expose an availability command" + manager.prepare_start_command().is_none(), + "non-systemd managers must not receive systemd mask cleanup" ); } - - let s6 = ServiceManager::s6_user(PathBuf::from("/tmp/s6"), PathBuf::from("/tmp/live")); - assert!( - s6.availability_command().is_none(), - "s6 validates its command set through readiness checks" - ); -} - -#[test] -fn non_systemd_enablement_uses_owned_artifacts() { - let systemd = ServiceManager::systemd_user(PathBuf::from("/tmp/systemd")); - let dinit = ServiceManager::dinit_user(PathBuf::from("/tmp/dinit")); - - assert_eq!(systemd.enabled_by_artifacts(), None); - assert_eq!(dinit.enabled_by_artifacts(), Some(false)); } diff --git a/crates/unixnotis-installer/src/system_tools/mod.rs b/crates/unixnotis-installer/src/system_tools/mod.rs index 5ee9e6e12..08b789e34 100644 --- a/crates/unixnotis-installer/src/system_tools/mod.rs +++ b/crates/unixnotis-installer/src/system_tools/mod.rs @@ -2,6 +2,7 @@ mod command; mod lookup; +mod process; // Fake executable routing lives under /tests and never enters production binaries #[expect( @@ -15,6 +16,7 @@ mod routing; pub mod routing; pub use command::{command, program_exists, program_path}; +pub use process::{output_bounded, BoundedOutput}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-installer/src/system_tools/process.rs b/crates/unixnotis-installer/src/system_tools/process.rs new file mode 100644 index 000000000..717582333 --- /dev/null +++ b/crates/unixnotis-installer/src/system_tools/process.rs @@ -0,0 +1,163 @@ +//! Bounded execution for trusted installer probes + +use std::io::{self, Read, Write}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use rustix::process::{kill_process_group, Pid, Signal}; +use wait_timeout::ChildExt; + +#[derive(Debug)] +pub struct BoundedOutput { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, + pub stdout_truncated: bool, + pub stderr_truncated: bool, +} + +struct CapturedStream { + bytes: Vec, + truncated: bool, +} + +struct BoundedCapture { + bytes: Vec, + max_bytes: usize, + truncated: bool, +} + +impl Write for BoundedCapture { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let retained = self + .max_bytes + .saturating_sub(self.bytes.len()) + .min(buffer.len()); + self.bytes.extend_from_slice(&buffer[..retained]); + self.truncated |= retained != buffer.len(); + // Report the full write so excess data is drained without being retained + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub fn output_bounded( + command: &mut Command, + timeout: Duration, + max_stream_bytes: usize, +) -> io::Result { + let deadline = probe_deadline(timeout)?; + // A private process group lets timeout cleanup include helper grandchildren + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + let mut child = command.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("probe stdout pipe was not captured"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("probe stderr pipe was not captured"))?; + let stdout_reader = spawn_bounded_reader(stdout, max_stream_bytes); + let stderr_reader = spawn_bounded_reader(stderr, max_stream_bytes); + + let status = wait_for_probe(&mut child, deadline)?; + + let stdout = receive_reader(&stdout_reader, deadline, "stdout")?; + let stderr = receive_reader(&stderr_reader, deadline, "stderr")?; + Ok(BoundedOutput { + status, + stdout: stdout.bytes, + stderr: stderr.bytes, + stdout_truncated: stdout.truncated, + stderr_truncated: stderr.truncated, + }) +} + +fn probe_deadline(timeout: Duration) -> io::Result { + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe deadline elapsed before process start", + )); + } + Instant::now() + .checked_add(timeout) + .ok_or_else(|| io::Error::other("probe deadline exceeded the monotonic clock")) +} + +fn wait_for_probe(child: &mut Child, deadline: Instant) -> io::Result { + match child.wait_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(Some(status)) => { + // A probe may exit after leaving a helper that still owns the output pipes + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + Ok(status) + } + Ok(None) => { + // Group kill prevents a helper child from retaining captured pipes after timeout + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + let _direct_kill = child.kill(); + let _reap = child.wait(); + Err(io::Error::new( + io::ErrorKind::TimedOut, + "probe process exceeded its deadline", + )) + } + Err(error) => { + let process_group = Pid::from_child(child); + let _group_kill = kill_process_group(process_group, Signal::KILL); + let _direct_kill = child.kill(); + let _reap = child.wait(); + Err(error) + } + } +} + +fn spawn_bounded_reader( + mut stream: impl Read + Send + 'static, + max_stream_bytes: usize, +) -> Receiver> { + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let mut capture = BoundedCapture { + bytes: Vec::with_capacity(max_stream_bytes.min(8 * 1024)), + max_bytes: max_stream_bytes, + truncated: false, + }; + let result = io::copy(&mut stream, &mut capture).map(|_copied| CapturedStream { + bytes: capture.bytes, + truncated: capture.truncated, + }); + let _sent = sender.send(result); + }); + receiver +} + +fn receive_reader( + reader: &Receiver>, + deadline: Instant, + stream_name: &str, +) -> io::Result { + reader + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => io::Error::new( + io::ErrorKind::TimedOut, + format!("probe {stream_name} reader exceeded its deadline"), + ), + mpsc::RecvTimeoutError::Disconnected => { + io::Error::other(format!("probe {stream_name} reader stopped unexpectedly")) + } + })? +} diff --git a/crates/unixnotis-installer/src/system_tools/tests/mod.rs b/crates/unixnotis-installer/src/system_tools/tests/mod.rs index 264b05ea0..429d47b1c 100644 --- a/crates/unixnotis-installer/src/system_tools/tests/mod.rs +++ b/crates/unixnotis-installer/src/system_tools/tests/mod.rs @@ -1 +1,2 @@ mod command; +mod process; diff --git a/crates/unixnotis-installer/src/system_tools/tests/process.rs b/crates/unixnotis-installer/src/system_tools/tests/process.rs new file mode 100644 index 000000000..f62fad77f --- /dev/null +++ b/crates/unixnotis-installer/src/system_tools/tests/process.rs @@ -0,0 +1,66 @@ +use std::process::Command; +use std::time::{Duration, Instant}; + +use super::super::output_bounded; + +#[test] +fn bounded_probe_returns_complete_small_output() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf stdout; printf stderr >&2"]); + + let output = output_bounded(&mut command, Duration::from_secs(1), 64) + .expect("bounded probe should finish"); + + assert!(output.status.success()); + assert_eq!(output.stdout, b"stdout"); + assert_eq!(output.stderr, b"stderr"); + assert!(!output.stdout_truncated); + assert!(!output.stderr_truncated); +} + +#[test] +fn bounded_probe_drains_but_does_not_retain_oversized_streams() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "head -c 65536 /dev/zero; head -c 65536 /dev/zero >&2"]); + + let output = output_bounded(&mut command, Duration::from_secs(1), 1_024) + .expect("large bounded probe should finish without a pipe deadlock"); + + assert!(output.status.success()); + assert_eq!(output.stdout.len(), 1_024); + assert_eq!(output.stderr.len(), 1_024); + assert!(output.stdout_truncated); + assert!(output.stderr_truncated); +} + +#[test] +fn bounded_probe_kills_a_hung_process_group_at_the_deadline() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30"]); + let started = Instant::now(); + + let error = output_bounded(&mut command, Duration::from_millis(25), 64) + .expect_err("hung probe must time out"); + + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert!( + started.elapsed() < Duration::from_secs(1), + "probe timeout must kill and reap the process group promptly" + ); +} + +#[test] +fn bounded_probe_reaps_helpers_that_outlive_a_successful_parent() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "sleep 30 & exit 0"]); + let started = Instant::now(); + + let output = output_bounded(&mut command, Duration::from_secs(1), 64) + .expect("completed probe should clean up its inherited pipe owners"); + + assert!(output.status.success()); + assert!( + started.elapsed() < Duration::from_secs(1), + "background helpers must not extend the probe deadline" + ); +} diff --git a/crates/unixnotis-installer/src/tests/detect.rs b/crates/unixnotis-installer/src/tests/detect.rs index fa143ee2a..9cd4455a9 100644 --- a/crates/unixnotis-installer/src/tests/detect.rs +++ b/crates/unixnotis-installer/src/tests/detect.rs @@ -1,10 +1,13 @@ use crate::test_support::fs::write_executable; use std::fs; use std::io::{Error, ErrorKind}; +use std::os::unix::process::ExitStatusExt; use crate::detect::{ - parse_busctl_json, parse_busctl_status, read_cmdline_program, read_comm, systemctl_spawn_error, - KNOWN_DAEMONS, + ensure_owner_is_current, notification_owner_for_mutation_until, parse_busctl_json, + parse_busctl_status, parse_busctl_string_reply, read_busctl_owner_strict, read_cmdline_program, + read_comm, systemctl_spawn_error, validate_busctl_output, KNOWN_DAEMONS, + MAX_BUSCTL_OUTPUT_BYTES, }; #[test] @@ -16,16 +19,13 @@ fn known_daemons_include_quickshell_owner() { .expect("quickshell should be known"); // Unit metadata keeps status output and restore hints consistent - assert_eq!(quickshell.unit, "quickshell.service"); + assert_eq!(quickshell.systemd_unit, None); } #[test] fn known_daemons_include_recent_wayland_notifiers() { // These daemons are common enough to deserve explicit regression coverage - let expected = [ - ("hyprnotify", "hyprnotify.service"), - ("fnott", "fnott.service"), - ]; + let expected = [("hyprnotify", None), ("fnott", Some("fnott.service"))]; for (name, unit) in expected { let daemon = KNOWN_DAEMONS @@ -33,7 +33,26 @@ fn known_daemons_include_recent_wayland_notifiers() { .find(|daemon| daemon.name == name) .expect("daemon should be known"); - assert_eq!(daemon.unit, unit); + assert_eq!(daemon.systemd_unit, unit); + } +} + +#[test] +fn known_daemons_cover_standalone_desktop_and_wayland_owners() { + for name in [ + "xfce4-notifyd", + "lxqt-notificationd", + "mate-notification-daemon", + "notification-daemon", + "wired", + "deadd-notification-center", + "tiramisu", + "runst", + ] { + assert!( + KNOWN_DAEMONS.iter().any(|daemon| daemon.name == name), + "{name} should be recognized" + ); } } @@ -139,6 +158,21 @@ fn parse_busctl_json_ignores_empty_comm_and_keeps_later_valid_value() { assert_eq!(owner.comm.as_deref(), Some("dunst")); } +#[test] +fn parse_busctl_json_keeps_the_first_valid_pid_and_command_identity() { + let output = r#" +{ + "first": { "PID": 111, "Comm": "first-owner" }, + "second": { "PID": 222, "Comm": "second-owner" } +} +"#; + + let owner = parse_busctl_json(output).expect("expected parsed owner info"); + + assert_eq!(owner.pid, Some(111)); + assert_eq!(owner.comm.as_deref(), Some("first-owner")); +} + #[test] fn parse_busctl_json_rejects_zero_and_out_of_range_pid_values() { let zero = parse_busctl_json(r#"{ "PID": 0 }"#); @@ -157,6 +191,151 @@ fn parse_busctl_json_rejects_invalid_pid_string() { assert!(owner.is_none()); } +#[test] +fn parse_busctl_string_reply_reads_unique_owner_name() { + assert_eq!( + parse_busctl_string_reply("s \":1.77\"\n").as_deref(), + Some(":1.77") + ); + assert!(parse_busctl_string_reply("s \"\"").is_none()); + assert!(parse_busctl_string_reply("invalid").is_none()); +} + +#[test] +fn strict_owner_detection_keeps_broker_failure_distinct_from_unowned() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-error"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable(&fake_bin.join("busctl"), "#!/bin/sh\nexit 7\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = read_busctl_owner_strict().expect_err("broker failure must block mutation"); + + assert!( + error.to_string().contains("busctl owner query failed"), + "unexpected strict detection error: {error:#}" + ); + fs::remove_dir_all(root).expect("remove strict owner fixture"); +} + +#[test] +fn strict_owner_detection_accepts_only_explicit_unowned_reply() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-unowned"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\nprintf '%s\\n' 'b false'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert!( + read_busctl_owner_strict() + .expect("explicit unowned reply") + .is_none(), + "explicit false must be the only unowned state" + ); + fs::remove_dir_all(root).expect("remove strict unowned fixture"); +} + +#[test] +fn strict_owner_detection_retains_the_exact_unique_address() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-identity"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.77\"\\n' ;; *'status :1.77'*) printf 'Comm=unixnotis-daemon\\n' ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let owner = read_busctl_owner_strict() + .expect("strict owned query") + .expect("owned notification name"); + + assert_eq!(owner.unique_name.as_deref(), Some(":1.77")); + fs::remove_dir_all(root).expect("remove strict owner fixture"); +} + +#[test] +fn strict_owner_revalidation_rejects_a_different_current_address() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-handoff"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\ncase \"$*\" in *NameHasOwner*) printf 'b true\\n' ;; *GetNameOwner*) printf 's \":1.88\"\\n' ;; *) exit 1 ;; esac\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let error = ensure_owner_is_current(":1.77") + .expect_err("a new transport owner must invalidate inspected process metadata"); + + assert!(error.to_string().contains("owner changed")); + fs::remove_dir_all(root).expect("remove strict owner handoff fixture"); +} + +#[test] +fn strict_bus_output_budget_accepts_exact_limit_and_rejects_each_oversized_stream() { + assert_eq!(MAX_BUSCTL_OUTPUT_BYTES, 65_536); + let output = |stdout: Vec, stderr: Vec, stdout_truncated, stderr_truncated| { + crate::system_tools::BoundedOutput { + status: std::process::ExitStatus::from_raw(0), + stdout, + stderr, + stdout_truncated, + stderr_truncated, + } + }; + + let exact = vec![b'x'; MAX_BUSCTL_OUTPUT_BYTES]; + assert_eq!( + validate_busctl_output(output(exact.clone(), Vec::new(), false, false)) + .expect("exact output limit must remain valid") + .len(), + MAX_BUSCTL_OUTPUT_BYTES + ); + assert!( + validate_busctl_output(output(Vec::new(), exact, false, false)).is_ok(), + "exact stderr limit must remain valid" + ); + assert!(validate_busctl_output(output(Vec::new(), Vec::new(), true, false)).is_err()); + assert!(validate_busctl_output(output(Vec::new(), Vec::new(), false, true)).is_err()); +} + +#[test] +fn strict_owner_probe_kills_a_hung_busctl_at_the_shared_deadline() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("strict-owner-timeout"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake tool directory"); + write_executable(&fake_bin.join("busctl"), "#!/bin/sh\nsleep 30\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let started = std::time::Instant::now(); + let deadline = started + std::time::Duration::from_millis(25); + + let error = notification_owner_for_mutation_until(deadline) + .expect_err("hung owner query must fail at the shared deadline"); + + assert!( + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::TimedOut) + }), + "timeout context must retain the operating-system timeout kind: {error:#}" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "hung busctl and helper processes must be killed promptly" + ); + fs::remove_dir_all(root).expect("remove strict owner timeout fixture"); +} + #[test] fn parse_busctl_json_returns_none_for_invalid_json() { let owner = parse_busctl_json("not json"); @@ -197,6 +376,45 @@ fn read_comm_returns_none_for_missing_process() { assert!(comm.is_none()); } +#[test] +fn read_comm_prefers_a_live_proc_identity_without_invoking_ps() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("read-comm-proc-first"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake tool directory"); + write_executable( + &fake_bin.join("ps"), + "#!/bin/sh\nprintf 'wrong-fallback\\n'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + let expected = fs::read_to_string(format!("/proc/{}/comm", std::process::id())) + .expect("read current process comm") + .trim() + .to_string(); + + assert_eq!( + read_comm(std::process::id()).as_deref(), + Some(expected.as_str()) + ); + fs::remove_dir_all(root).expect("remove proc comm fixture"); +} + +#[test] +fn read_comm_uses_successful_ps_output_when_proc_identity_is_missing() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("read-comm-ps-fallback"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("create fake tool directory"); + write_executable( + &fake_bin.join("ps"), + "#!/bin/sh\nprintf 'fallback-owner\\n'\n", + ); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + assert_eq!(read_comm(u32::MAX).as_deref(), Some("fallback-owner")); + fs::remove_dir_all(root).expect("remove ps comm fixture"); +} + #[test] fn missing_systemctl_does_not_emit_per_daemon_status_errors() { // Non-systemd installs can still use D-Bus and process detection without systemctl @@ -204,6 +422,13 @@ fn missing_systemctl_does_not_emit_per_daemon_status_errors() { assert!(systemctl_spawn_error(&err).is_none()); } +#[test] +fn unexpected_systemctl_spawn_errors_remain_visible() { + let err = Error::from(ErrorKind::PermissionDenied); + + assert!(systemctl_spawn_error(&err).is_some()); +} + #[test] fn detect_uses_bus_owner_systemd_status_and_pgrep_results() { let _lock = crate::test_support::env::test_env_lock(); @@ -301,6 +526,45 @@ fn detect_falls_back_to_text_busctl_status_when_json_status_fails() { let _ = fs::remove_dir_all(root); } +#[test] +fn detect_resolves_unique_owner_when_well_known_status_has_no_process_fields() { + let _lock = crate::test_support::env::test_env_lock(); + let root = test_root("detect-unique-owner-fallback"); + let fake_bin = root.join("bin"); + fs::create_dir_all(&fake_bin).expect("fake bin dir"); + write_executable( + &fake_bin.join("busctl"), + "#!/bin/sh\n\ + if [ \"$2\" = '--json=short' ] && [ \"$4\" = ':1.77' ]; then\n\ + printf '{\"Status\":{\"Comm\":\"fnott\"}}\\n'\n\ + exit 0\n\ + fi\n\ + if [ \"$2\" = '--json=short' ]; then printf '{}\\n'; exit 0; fi\n\ + if [ \"$2\" = 'status' ]; then printf 'Name=org.freedesktop.Notifications\\n'; exit 0; fi\n\ + if [ \"$2\" = 'call' ]; then printf 's \":1.77\"\\n'; exit 0; fi\n\ + exit 1\n", + ); + write_executable(&fake_bin.join("systemctl"), "#!/bin/sh\nexit 3\n"); + write_executable(&fake_bin.join("pgrep"), "#!/bin/sh\nexit 1\n"); + let _fake_tools = crate::system_tools::routing::use_fake_tool_bin(&fake_bin); + + let detection = crate::detect::detect(); + + assert_eq!( + detection + .owner + .as_ref() + .and_then(|owner| owner.comm.as_deref()), + Some("fnott") + ); + assert!(detection + .daemons + .iter() + .any(|daemon| daemon.name == "fnott" && daemon.is_owner)); + + let _ = fs::remove_dir_all(root); +} + fn test_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!("unixnotis-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&root); diff --git a/crates/unixnotis-installer/src/tests/managed_binaries.rs b/crates/unixnotis-installer/src/tests/managed_binaries.rs index 49a2bfaac..ac7039e90 100644 --- a/crates/unixnotis-installer/src/tests/managed_binaries.rs +++ b/crates/unixnotis-installer/src/tests/managed_binaries.rs @@ -22,6 +22,7 @@ fn managed_binary_names_accept_the_complete_runtime_set() { "unixnotis-daemon", "unixnotis-popups", "unixnotis-center", + "unixnotis-svg-renderer", "unixnotis-css-validate", "noticenterctl", ] diff --git a/crates/unixnotis-installer/src/tests/privilege.rs b/crates/unixnotis-installer/src/tests/privilege.rs new file mode 100644 index 000000000..08e47522c --- /dev/null +++ b/crates/unixnotis-installer/src/tests/privilege.rs @@ -0,0 +1,16 @@ +use super::reject_root_install; + +#[test] +fn root_effective_uid_is_rejected_with_user_level_guidance() { + let error = reject_root_install(0).expect_err("root must be rejected"); + + assert_eq!( + error.to_string(), + "unixnotis-installer is user-level; do not run it as root or through sudo" + ); +} + +#[test] +fn normal_user_effective_uid_is_accepted() { + assert!(reject_root_install(1000).is_ok()); +} diff --git a/crates/unixnotis-installer/src/tests/release.rs b/crates/unixnotis-installer/src/tests/release.rs index 3c5057217..4b9814cae 100644 --- a/crates/unixnotis-installer/src/tests/release.rs +++ b/crates/unixnotis-installer/src/tests/release.rs @@ -97,7 +97,10 @@ fn release_status_display_line_reports_available_update() { state: ReleaseUpdateState::UpdateAvailable, }; - assert_eq!(status.display_line(), "v1.0.0 installed; v1.0.1 available"); + assert_eq!( + status.display_line_for(&status.current, "installed"), + "v1.0.0 installed; v1.0.1 available" + ); } #[test] @@ -125,11 +128,37 @@ fn release_status_display_line_reports_up_to_date_release() { }; assert_eq!( - status.display_line(), + status.display_line_for(&status.current, "installed"), "v1.0.0 installed; latest release is v1.0.0" ); } +#[test] +fn release_status_display_line_keeps_installation_role_explicit() { + let status = ReleaseStatus { + current: "v1.2.0".to_string(), + latest: Some("v1.2.0".to_string()), + state: ReleaseUpdateState::UpToDate, + }; + + assert_eq!( + status.display_line_for("v1.2.0", "binaries present"), + "v1.2.0 binaries present; latest release is v1.2.0" + ); + assert_eq!( + status.display_line_for("v1.2.0", "installer"), + "v1.2.0 installer; latest release is v1.2.0" + ); + assert_eq!( + status.update_state_for("v1.1.0"), + ReleaseUpdateState::UpdateAvailable + ); + assert_eq!( + status.update_state_for("v1.2.0"), + ReleaseUpdateState::UpToDate + ); +} + #[test] fn parse_version_tag_accepts_plain_and_prefixed_versions() { assert!(parse_version_tag("1.2.3").is_some()); diff --git a/crates/unixnotis-installer/src/tests/safe_write.rs b/crates/unixnotis-installer/src/tests/safe_write.rs deleted file mode 100644 index 9f9789996..000000000 --- a/crates/unixnotis-installer/src/tests/safe_write.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::fs; -use std::os::unix::fs::{symlink, PermissionsExt}; -use std::os::unix::net::UnixListener; -use std::path::Path; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc; -use std::time::{Duration, UNIX_EPOCH}; - -use rustix::fs::{mkfifoat, open, Mode, OFlags, CWD}; - -use super::{ - atomic_temp_name_at, existing_mode_or_default, validate_target_at, write_text_preserving_mode, - write_text_with_mode, -}; - -fn test_root(label: &str) -> std::path::PathBuf { - static NEXT_ROOT: AtomicUsize = AtomicUsize::new(0); - let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "unixnotis-safe-write-{label}-{}-{sequence}", - std::process::id() - )); - fs::create_dir_all(&root).expect("create test root"); - root -} - -#[test] -fn atomic_temp_name_returns_an_error_when_clock_precedes_unix_epoch() { - let before_epoch = UNIX_EPOCH - .checked_sub(Duration::from_secs(1)) - .expect("construct pre-epoch timestamp"); - - let error = atomic_temp_name_at("config.toml", 0, before_epoch) - .expect_err("pre-epoch clock should return an error"); - - assert_eq!(error.kind(), std::io::ErrorKind::Other); - assert!(error.to_string().contains("earlier than the Unix epoch")); -} - -#[test] -fn secure_write_rejects_symlinked_ancestor_without_touching_target() { - let root = test_root("ancestor-symlink"); - let real_parent = root.join("real"); - let linked_parent = root.join("linked"); - fs::create_dir_all(&real_parent).expect("create real parent"); - symlink(&real_parent, &linked_parent).expect("create parent symlink"); - - let error = write_text_with_mode(&linked_parent.join("config.toml"), "unsafe", 0o644) - .expect_err("reject symlinked ancestor"); - - assert_eq!( - error.raw_os_error(), - Some(rustix::io::Errno::LOOP.raw_os_error()) - ); - assert!(!real_parent.join("config.toml").exists()); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn secure_write_preserves_existing_mode_and_replaces_contents() { - let root = test_root("preserve-mode"); - let target = root.join("config.toml"); - fs::write(&target, "old").expect("write original"); - fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("set mode"); - - write_text_preserving_mode(&target, "new", 0o644).expect("secure replace"); - - assert_eq!(fs::read_to_string(&target).expect("read target"), "new"); - assert_eq!( - fs::metadata(&target) - .expect("target metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - let _ = fs::remove_dir_all(root); -} - -#[test] -fn metadata_validation_rejects_fifo_without_waiting_for_a_writer() { - let root = test_root("fifo-target"); - let target = root.join("config.fifo"); - mkfifoat(CWD, &target, Mode::RUSR | Mode::WUSR).expect("create FIFO target"); - let worker_target = target.clone(); - let (result_tx, result_rx) = mpsc::channel(); - let worker = std::thread::spawn(move || { - let result = write_text_preserving_mode(&worker_target, "new", 0o644); - result_tx.send(result).expect("send FIFO validation result"); - }); - - let result = match result_rx.recv_timeout(Duration::from_secs(2)) { - Ok(result) => result, - Err(error) => { - // A writer releases a regressed read-only FIFO open before the test reports failure - let _writer = open( - &target, - OFlags::WRONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, - Mode::empty(), - ) - .expect("unblock FIFO reader"); - let _ = result_rx.recv_timeout(Duration::from_secs(2)); - worker.join().expect("join unblocked FIFO worker"); - panic!("FIFO validation exceeded its focused deadline: {error}"); - } - }; - worker.join().expect("join FIFO validation worker"); - - assert!(result.expect_err("reject FIFO target").kind() == std::io::ErrorKind::InvalidInput); - fs::remove_dir_all(root).expect("remove FIFO test root"); -} - -#[test] -fn metadata_validation_rejects_socket_device_and_final_symlink_targets() { - let root = test_root("special-targets"); - let socket = root.join("installer.sock"); - let _listener = UnixListener::bind(&socket).expect("bind socket target"); - let sentinel = root.join("sentinel.txt"); - let link = root.join("linked.txt"); - fs::write(&sentinel, "sentinel").expect("write sentinel"); - symlink(&sentinel, &link).expect("create final symlink"); - - for target in [&socket, Path::new("/dev/null"), &link] { - assert!( - write_text_with_mode(target, "new", 0o644).is_err(), - "special target should be rejected: {}", - target.display() - ); - } - assert_eq!( - fs::read_to_string(&sentinel).expect("read sentinel"), - "sentinel" - ); - fs::remove_dir_all(root).expect("remove special target test root"); -} - -#[test] -fn metadata_validation_does_not_treat_other_open_errors_as_missing_files() { - let root = test_root("metadata-open-errors"); - let overlong_name = "x".repeat(300); - - assert!(existing_mode_or_default(&root.join(&overlong_name), 0o644).is_err()); - - let parent_fd = open(&root, OFlags::DIRECTORY | OFlags::CLOEXEC, Mode::empty()) - .expect("open validation parent"); - assert!(validate_target_at(&parent_fd, &overlong_name).is_err()); - - fs::remove_dir_all(root).expect("remove metadata error test root"); -} diff --git a/crates/unixnotis-installer/src/tests/support/fs.rs b/crates/unixnotis-installer/src/tests/support/fs.rs index 9004fde20..23e3323af 100644 --- a/crates/unixnotis-installer/src/tests/support/fs.rs +++ b/crates/unixnotis-installer/src/tests/support/fs.rs @@ -3,7 +3,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::symlink; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; pub(super) static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -12,6 +12,15 @@ const FAKE_TOOL_DISPATCHER: &str = concat!( "/src/tests/support/fixtures/fake-tool" ); +pub fn unique_temp_path(label: &str) -> PathBuf { + // A process-local sequence keeps parallel filesystem tests on separate paths + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "unixnotis-installer-{label}-{}-{sequence}", + std::process::id() + )) +} + pub fn write_executable(path: &Path, contents: &str) { let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); let file_name = path diff --git a/crates/unixnotis-installer/src/tests/support/mod.rs b/crates/unixnotis-installer/src/tests/support/mod.rs index bdecc04cc..d027bc3c4 100644 --- a/crates/unixnotis-installer/src/tests/support/mod.rs +++ b/crates/unixnotis-installer/src/tests/support/mod.rs @@ -1,13 +1,13 @@ //! Shared installer test helpers for environment and filesystem fixtures +use unixnotis_core::CURRENT_CONFIG_VERSION; + pub mod env; pub mod fs; +mod paths; -impl crate::paths::InstallPaths { - pub(crate) fn discover() -> anyhow::Result { - // Test callers use the same automatic manager selection as the normal CLI - Self::discover_with_service_manager(None) - } +pub fn current_config_text(contents: &str) -> String { + format!("config_version = {CURRENT_CONFIG_VERSION}\n{contents}") } #[cfg(test)] diff --git a/crates/unixnotis-installer/src/tests/support/paths.rs b/crates/unixnotis-installer/src/tests/support/paths.rs new file mode 100644 index 000000000..615ee9895 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/support/paths.rs @@ -0,0 +1,6 @@ +impl crate::paths::InstallPaths { + pub(crate) fn discover() -> anyhow::Result { + // Test callers use the same automatic manager selection as the normal CLI + Self::discover_with_service_manager(None) + } +} diff --git a/crates/unixnotis-installer/src/tests/support/tests/env.rs b/crates/unixnotis-installer/src/tests/support/tests/env.rs new file mode 100644 index 000000000..520a32ea0 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/support/tests/env.rs @@ -0,0 +1,16 @@ +use super::super::env::{test_env_lock, EnvGuard}; + +#[test] +fn environment_guard_restores_the_original_value() { + const NAME: &str = "UNIXNOTIS_INSTALLER_ENV_GUARD_TEST"; + let _lock = test_env_lock(); + std::env::set_var(NAME, "before"); + + { + let _guard = EnvGuard::set(NAME, "during"); + assert_eq!(std::env::var_os(NAME).as_deref(), Some("during".as_ref())); + } + + assert_eq!(std::env::var_os(NAME).as_deref(), Some("before".as_ref())); + std::env::remove_var(NAME); +} diff --git a/crates/unixnotis-installer/src/tests/support/tests/mod.rs b/crates/unixnotis-installer/src/tests/support/tests/mod.rs index d0e408f56..d3b0dfd34 100644 --- a/crates/unixnotis-installer/src/tests/support/tests/mod.rs +++ b/crates/unixnotis-installer/src/tests/support/tests/mod.rs @@ -1 +1,2 @@ +mod env; mod fs; diff --git a/crates/unixnotis-installer/src/tests/toolchain.rs b/crates/unixnotis-installer/src/tests/toolchain.rs new file mode 100644 index 000000000..1365c340d --- /dev/null +++ b/crates/unixnotis-installer/src/tests/toolchain.rs @@ -0,0 +1,199 @@ +use std::ffi::OsStr; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::test_support::env::EnvGuard; +use crate::test_support::fs::unique_temp_path; +use unixnotis_core::util::TRUSTED_SYSTEM_TOOL_DIRS; + +use super::{account_home_dir, cargo_command, resolve_cargo}; + +#[test] +fn cargo_resolution_ignores_poisoned_home_and_path_entries() { + let _lock = crate::test_support::env::test_env_lock(); + let root = unique_temp_path("cargo-poisoned-path"); + let poisoned_home = root.join("home"); + let poisoned_home_cargo = poisoned_home.join(".cargo/bin/cargo"); + let poisoned = root.join("poisoned/cargo"); + fs::create_dir_all(poisoned_home_cargo.parent().expect("poisoned HOME parent")) + .expect("poisoned HOME directory"); + fs::create_dir_all(poisoned.parent().expect("poisoned parent")).expect("poisoned directory"); + write_direct_executable(&poisoned_home_cargo, "#!/bin/sh\nexit 41\n"); + write_direct_executable(&poisoned, "#!/bin/sh\nexit 42\n"); + + let _home = EnvGuard::set("HOME", &poisoned_home); + let _path = EnvGuard::set("PATH", poisoned.parent().expect("poisoned parent")); + + let resolved = resolve_cargo().expect("Cargo should resolve from the account home"); + + assert!(resolved.is_absolute()); + assert_eq!( + resolved, + fs::canonicalize(&resolved).expect("resolved path canonicalized") + ); + assert!(!resolved.starts_with(&poisoned_home)); + assert_ne!( + resolved, + fs::canonicalize(poisoned).expect("poisoned path canonicalized") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn cargo_command_removes_compiler_override_environment() { + let _lock = crate::test_support::env::test_env_lock(); + let root = unique_temp_path("cargo-command-environment"); + fs::create_dir_all(&root).expect("create command root"); + let poisoned_path = poisoned_parent(&root); + fs::create_dir_all(&poisoned_path).expect("create poisoned PATH directory"); + let _environment = set_poisoned_cargo_environment(&root, &poisoned_path); + + let cargo = resolve_cargo().expect("trusted Cargo should resolve"); + let command = cargo_command(&cargo).expect("Cargo command should be configured"); + + assert_eq!(command.get_program(), cargo.as_os_str()); + assert_compiler_environment_is_pinned(&command); + assert_path_environment_is_sanitized(&command, &poisoned_path); + assert_account_paths_are_pinned(&command); + + let _ = fs::remove_dir_all(root); +} + +fn set_poisoned_cargo_environment(root: &Path, poisoned_path: &Path) -> Vec { + vec![ + EnvGuard::set("HOME", root.join("attacker-home")), + EnvGuard::set("PATH", poisoned_path), + EnvGuard::set("RUSTC", "/tmp/attacker-rustc"), + EnvGuard::set("RUSTDOC", "/tmp/attacker-rustdoc"), + EnvGuard::set("RUSTC_WRAPPER", "/tmp/attacker-wrapper"), + EnvGuard::set("RUSTC_WORKSPACE_WRAPPER", "/tmp/attacker-workspace-wrapper"), + EnvGuard::set("CARGO_BUILD_RUSTC_WRAPPER", "/tmp/attacker-cargo-wrapper"), + EnvGuard::set( + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "/tmp/attacker-cargo-workspace-wrapper", + ), + EnvGuard::set("RUSTUP_TOOLCHAIN", "/tmp/attacker-toolchain"), + EnvGuard::set("RUSTFLAGS", "--cfg attacker"), + EnvGuard::set("CARGO_ENCODED_RUSTFLAGS", "--cfg\u{1f}attacker"), + EnvGuard::set("CARGO_BUILD_RUSTFLAGS", "--cfg attacker-build"), + EnvGuard::set("CARGO_BUILD_ENCODED_RUSTFLAGS", "--cfg\u{1f}attacker-build"), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", + "/tmp/attacker-linker", + ), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "/tmp/attacker-runner", + ), + EnvGuard::set( + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + "--cfg attacker-target", + ), + ] +} + +fn assert_compiler_environment_is_pinned(command: &Command) { + for variable in [ + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_ENCODED_RUSTFLAGS", + "RUSTUP_TOOLCHAIN", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + ] { + assert!( + command + .get_envs() + .find(|(name, _value)| *name == OsStr::new(variable)) + .is_some_and(|(_name, value)| value.is_none()), + "{variable} must be removed from the Cargo environment" + ); + } + + for variable in ["RUSTC", "RUSTDOC"] { + let value = command_env(command, variable) + .unwrap_or_else(|| panic!("{variable} must be pinned in the Cargo environment")); + let path = Path::new(value); + assert!(path.is_absolute(), "{variable} must be absolute"); + assert!(path.is_file(), "{variable} must name an executable"); + assert!( + fs::canonicalize(path) + .expect("compiler path should canonicalize") + .is_file(), + "{variable} canonical target must be a file" + ); + } +} + +fn assert_path_environment_is_sanitized(command: &Command, poisoned_path: &Path) { + let path_value = + command_env(command, "PATH").expect("Cargo PATH should be explicitly replaced"); + let path_dirs = std::env::split_paths(path_value).collect::>(); + assert!(!path_dirs.iter().any(|path| { + path.as_path() == poisoned_path + || path + .file_name() + .is_some_and(|name| name == OsStr::new("attacker-home")) + })); + let expected_system_dirs = TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .filter_map(|directory| fs::canonicalize(directory).ok()) + .fold(Vec::new(), |mut directories, directory| { + if !directories.contains(&directory) { + directories.push(directory); + } + directories + }); + assert!(path_dirs.ends_with(&expected_system_dirs)); + + let rustc_directory = fs::canonicalize( + Path::new(command_env(command, "RUSTC").expect("RUSTC should be present")) + .parent() + .expect("RUSTC should have a parent directory"), + ) + .expect("RUSTC parent should be canonicalized"); + assert!( + path_dirs.contains(&rustc_directory), + "PATH should contain the validated rustc directory" + ); +} + +fn assert_account_paths_are_pinned(command: &Command) { + let account_home = account_home_dir().expect("effective account home"); + assert_eq!(command_env(command, "HOME"), Some(account_home.as_os_str())); + assert_eq!( + command_env(command, "CARGO_HOME"), + Some(account_home.join(".cargo").as_os_str()) + ); + assert_eq!( + command_env(command, "RUSTUP_HOME"), + Some(account_home.join(".rustup").as_os_str()) + ); +} + +fn command_env<'a>(command: &'a Command, variable: &str) -> Option<&'a OsStr> { + command + .get_envs() + .find(|(name, _value)| *name == OsStr::new(variable)) + .and_then(|(_name, value)| value) +} + +fn write_direct_executable(path: &Path, contents: &str) { + fs::write(path, contents).expect("write executable"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("set executable mode"); +} + +fn poisoned_parent(root: &Path) -> PathBuf { + root.join("attacker-path") +} diff --git a/crates/unixnotis-installer/src/tests/write_target.rs b/crates/unixnotis-installer/src/tests/write_target.rs new file mode 100644 index 000000000..aa2dd2511 --- /dev/null +++ b/crates/unixnotis-installer/src/tests/write_target.rs @@ -0,0 +1,59 @@ +//! Write-target preflight tests + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::reject_unsafe_write_target; + +fn test_root(label: &str) -> PathBuf { + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); + let root = PathBuf::from("target").join(format!( + "unixnotis-write-target-{label}-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create test root"); + root +} + +#[test] +fn regular_and_missing_write_targets_are_accepted() { + let root = test_root("accepted"); + let regular = root.join("config.toml"); + fs::write(®ular, "config").expect("write regular target"); + + reject_unsafe_write_target(®ular).expect("regular file should be accepted"); + reject_unsafe_write_target(&root.join("missing.toml")) + .expect("missing file should be accepted"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn symlink_and_non_file_write_targets_are_rejected() { + let root = test_root("rejected"); + let regular = root.join("config.toml"); + let link = root.join("linked.toml"); + let directory = root.join("directory.toml"); + fs::write(®ular, "config").expect("write regular target"); + symlink(®ular, &link).expect("create target symlink"); + fs::create_dir(&directory).expect("create directory target"); + + assert_eq!( + reject_unsafe_write_target(&link) + .expect_err("target symlink should fail") + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!( + reject_unsafe_write_target(&directory) + .expect_err("directory target should fail") + .kind(), + std::io::ErrorKind::InvalidInput + ); + + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/toolchain.rs b/crates/unixnotis-installer/src/toolchain.rs new file mode 100644 index 000000000..ec2a40421 --- /dev/null +++ b/crates/unixnotis-installer/src/toolchain.rs @@ -0,0 +1,423 @@ +//! Trusted Rust toolchain executable discovery + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(unix)] +use std::ffi::{CStr, OsStr}; +#[cfg(unix)] +use std::io; +#[cfg(unix)] +use std::mem::MaybeUninit; +#[cfg(unix)] +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +#[cfg(unix)] +use std::ptr; + +use anyhow::{anyhow, Result}; + +use unixnotis_core::util::TRUSTED_SYSTEM_TOOL_DIRS; + +#[cfg(unix)] +const PASSWD_BUFFER_START: usize = 1024; +#[cfg(unix)] +const PASSWD_BUFFER_LIMIT: usize = 1024 * 1024; +const CARGO_EXECUTION_ENV_VARS: [&str; 13] = [ + "RUSTC", + "RUSTDOC", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC", + "CARGO_BUILD_RUSTDOC", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_ENCODED_RUSTFLAGS", + "RUSTUP_TOOLCHAIN", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", +]; + +#[derive(Clone, Debug)] +struct ResolvedToolchain { + cargo: PathBuf, + rustc: PathBuf, + rustdoc: PathBuf, + path: OsString, +} + +struct ValidatedExecutable { + launch_path: PathBuf, + canonical_path: PathBuf, +} + +/// Resolve Cargo without consulting the inherited PATH +pub fn resolve_cargo() -> Result { + Ok(resolve_toolchain()?.cargo) +} + +fn resolve_toolchain() -> Result { + let home = account_home_dir()?; + let cargo = resolve_cargo_path(&home)?; + resolve_toolchain_from_cargo(&home, &cargo) +} + +fn resolve_cargo_path(home: &Path) -> Result { + let mut candidates = vec![home.join(".cargo").join("bin").join("cargo")]; + candidates.extend( + TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .map(|directory| Path::new(directory).join("cargo")), + ); + + for candidate in candidates { + let Ok(validated) = validate_executable(&candidate) else { + continue; + }; + + // Rustup proxy paths need one trusted lookup to bind Cargo to the selected toolchain + if validated + .canonical_path + .file_name() + .is_some_and(|name| name == "rustup") + { + if let Ok(cargo) = rustup_which(&validated.canonical_path, home, "cargo") { + return Ok(cargo); + } + continue; + } + + // Return the canonical executable rather than reopening a candidate symlink later + return Ok(validated.canonical_path); + } + + Err(anyhow!( + "cargo was not found in the approved Rust toolchain locations" + )) +} + +/// Build a Cargo command with a stable argv[0] and a sanitized build environment +pub fn cargo_command(path: &Path) -> Result { + let home = account_home_dir()?; + let tools = resolve_toolchain_from_cargo(&home, path)?; + let mut command = Command::new(&tools.cargo); + + #[cfg(unix)] + { + // Rustup uses argv[0] to distinguish Cargo from the rustup frontend + command.arg0("cargo"); + } + + sanitize_command_environment(&mut command, &home, &tools.path); + // Absolute compiler paths prevent Cargo from resolving rustc or rustdoc through PATH + command.env("RUSTC", &tools.rustc); + command.env("RUSTDOC", &tools.rustdoc); + + Ok(command) +} + +fn resolve_toolchain_from_cargo(home: &Path, cargo: &Path) -> Result { + let cargo = validate_executable(cargo)?.canonical_path; + let rustc = resolve_compiler_tool(home, &cargo, "rustc")?; + let rustdoc = resolve_compiler_tool(home, &cargo, "rustdoc")?; + let path = trusted_tool_path(&cargo, &rustc, &rustdoc)?; + + Ok(ResolvedToolchain { + cargo, + rustc, + rustdoc, + path, + }) +} + +fn resolve_compiler_tool(home: &Path, cargo: &Path, tool: &str) -> Result { + if let Some(parent) = cargo.parent() { + let sibling = parent.join(tool); + if let Ok(validated) = validate_executable(&sibling) { + // Keep proxy basenames such as rustc and rustdoc so rustup dispatches correctly + if validated + .canonical_path + .file_name() + .is_some_and(|name| name == "rustup") + { + return Ok(validated.launch_path); + } + return Ok(validated.canonical_path); + } + } + + let rustup = resolve_rustup(home, cargo)?; + rustup_which(&rustup, home, tool) +} + +fn resolve_rustup(home: &Path, cargo: &Path) -> Result { + let mut candidates = Vec::new(); + if let Some(parent) = cargo.parent() { + candidates.push(parent.join("rustup")); + } + candidates.push(home.join(".cargo").join("bin").join("rustup")); + candidates.extend( + TRUSTED_SYSTEM_TOOL_DIRS + .iter() + .map(|directory| Path::new(directory).join("rustup")), + ); + + candidates + .into_iter() + .find_map(|candidate| validate_executable(&candidate).ok()) + .map(|validated| validated.canonical_path) + .ok_or_else(|| anyhow!("rustup was not found in the approved toolchain locations")) +} + +fn rustup_which(rustup: &Path, home: &Path, tool: &str) -> Result { + let mut command = Command::new(rustup); + #[cfg(unix)] + command.arg0("rustup"); + command.args(["which", tool]); + sanitize_command_environment( + &mut command, + home, + &trusted_tool_path_from_directories(std::iter::empty())?, + ); + + let output = command + .output() + .map_err(|error| anyhow!("failed to resolve rustup {tool}: {error}"))?; + if !output.status.success() { + return Err(anyhow!( + "rustup could not resolve {tool}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let path = PathBuf::from( + String::from_utf8(output.stdout) + .map_err(|error| anyhow!("rustup returned a non-UTF-8 {tool} path: {error}"))? + .trim(), + ); + Ok(validate_executable(&path)?.canonical_path) +} + +fn sanitize_command_environment(command: &mut Command, home: &Path, path: &OsString) { + // Cargo and rustup must not read configuration from environment-selected homes + command.env("HOME", home); + command.env("CARGO_HOME", home.join(".cargo")); + // Rustup must use the account-owned toolchain registry selected by this resolver + command.env("RUSTUP_HOME", home.join(".rustup")); + // PATH is rebuilt from only validated toolchain and fixed system directories + command.env("PATH", path); + for variable in CARGO_EXECUTION_ENV_VARS { + // These variables can replace rustc or wrap every compiler invocation + command.env_remove(variable); + } + + #[cfg(unix)] + for (name, _value) in std::env::vars_os() { + // Target-specific linker, runner, and flags variables are dynamically named + if is_target_execution_variable(&name) { + command.env_remove(name); + } + } +} + +fn trusted_tool_path(cargo: &Path, rustc: &Path, rustdoc: &Path) -> Result { + let directories = [cargo, rustc, rustdoc] + .into_iter() + .filter_map(|path| path.parent()) + .map(validate_tool_directory) + .collect::>>()?; + trusted_tool_path_from_directories(directories) +} + +fn trusted_tool_path_from_directories( + directories: impl IntoIterator, +) -> Result { + let mut paths = Vec::new(); + for directory in directories { + if !paths.contains(&directory) { + paths.push(directory); + } + } + for directory in TRUSTED_SYSTEM_TOOL_DIRS { + // A platform may not provide every FHS directory, so absent system paths are skipped + if let Ok(directory) = validate_tool_directory(Path::new(directory)) { + if !paths.contains(&directory) { + paths.push(directory); + } + } + } + std::env::join_paths(paths).map_err(|error| anyhow!("failed to build trusted PATH: {error}")) +} + +fn validate_tool_directory(path: &Path) -> Result { + let canonical_path = fs::canonicalize(path).map_err(|error| { + anyhow!( + "failed to canonicalize tool directory {}: {error}", + path.display() + ) + })?; + let metadata = fs::metadata(&canonical_path)?; + if !metadata.is_dir() { + return Err(anyhow!( + "tool path parent is not a directory: {}", + canonical_path.display() + )); + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // A writable group or other account could replace a tool after resolution + if metadata.permissions().mode() & 0o022 != 0 || metadata.permissions().mode() & 0o111 == 0 + { + return Err(anyhow!( + "tool directory has unsafe permissions: {}", + canonical_path.display() + )); + } + + // Only the current account or root may own a directory used for execution + let uid = metadata.uid(); + let expected_uid = rustix::process::geteuid().as_raw(); + if uid != expected_uid && uid != 0 { + return Err(anyhow!( + "tool directory has an unexpected owner: {}", + canonical_path.display() + )); + } + } + + Ok(canonical_path) +} + +fn validate_executable(path: &Path) -> Result { + if !path.is_absolute() { + return Err(anyhow!("tool path is not absolute: {}", path.display())); + } + let canonical_path = fs::canonicalize(path) + .map_err(|error| anyhow!("failed to canonicalize tool {}: {error}", path.display()))?; + if !is_acceptable_executable(&canonical_path) { + return Err(anyhow!( + "tool is not an acceptable executable: {}", + path.display() + )); + } + Ok(ValidatedExecutable { + launch_path: path.to_path_buf(), + canonical_path, + }) +} + +#[cfg(unix)] +fn is_target_execution_variable(name: &OsStr) -> bool { + let Some(target_setting) = name.as_bytes().strip_prefix(b"CARGO_TARGET_") else { + return false; + }; + + [b"_LINKER".as_slice(), b"_RUNNER", b"_RUSTFLAGS"] + .into_iter() + .any(|suffix| { + target_setting + .strip_suffix(suffix) + .is_some_and(|target| !target.is_empty()) + }) +} + +fn account_home_dir() -> Result { + #[cfg(unix)] + { + let uid = rustix::process::geteuid().as_raw() as libc::uid_t; + let mut buffer = vec![0_u8; PASSWD_BUFFER_START]; + + loop { + let mut passwd = MaybeUninit::::zeroed(); + let mut result = ptr::null_mut(); + // SAFETY: every pointer targets live storage owned by this scope, the buffer is + // writable for its full length, and libc writes the result pointer into result + let status = unsafe { + // getpwuid_r writes the passwd record and its strings into the caller buffer + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &raw mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return Err(anyhow!("effective UID has no passwd entry")); + } + // SAFETY: libc returned success with a non-null result, so passwd was initialized + let passwd = unsafe { passwd.assume_init() }; + if passwd.pw_dir.is_null() { + return Err(anyhow!("effective UID has no home directory")); + } + // SAFETY: pw_dir is a non-null NUL-terminated field owned by the live passwd + // record and remains valid while the backing buffer stays in scope + let home = unsafe { CStr::from_ptr(passwd.pw_dir).to_bytes().to_vec() }; + let home = PathBuf::from(OsString::from_vec(home)); + if !home.is_absolute() { + return Err(anyhow!("account home directory is not absolute")); + } + return Ok(home); + } + + if status != libc::ERANGE { + return Err(io::Error::from_raw_os_error(status).into()); + } + let next_size = buffer + .len() + .checked_mul(2) + .filter(|size| *size <= PASSWD_BUFFER_LIMIT) + .ok_or_else(|| anyhow!("passwd entry exceeds the supported size limit"))?; + buffer.resize(next_size, 0); + } + } + + #[cfg(not(unix))] + { + Err(anyhow!( + "account home lookup is unsupported on this platform" + )) + } +} + +fn is_acceptable_executable(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + // Writable group or other bits would let another account replace the tool + if metadata.permissions().mode() & 0o022 != 0 || metadata.permissions().mode() & 0o111 == 0 + { + return false; + } + + // User toolchains belong to the current account; system tools may belong to root + let uid = metadata.uid(); + let expected_uid = rustix::process::geteuid().as_raw(); + uid == expected_uid || uid == 0 + } + + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +#[path = "tests/toolchain.rs"] +mod tests; diff --git a/crates/unixnotis-installer/src/trial/build.rs b/crates/unixnotis-installer/src/trial/build.rs index b939454a9..9abe5ffdf 100644 --- a/crates/unixnotis-installer/src/trial/build.rs +++ b/crates/unixnotis-installer/src/trial/build.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use crate::toolchain::{cargo_command, resolve_cargo}; + // Trial mode needs every runtime binary that may be spawned by the daemon const TRIAL_PACKAGES: [&str; 4] = [ "unixnotis-daemon", @@ -22,7 +24,8 @@ pub(super) struct TrialBinaries { pub(super) fn build_trial_binaries(repo_root: &Path) -> Result { // Build every runtime binary before launch so stale debug outputs are not reused - let mut command = std::process::Command::new("cargo"); + let cargo = resolve_cargo()?; + let mut command = cargo_command(&cargo)?; command.arg("build"); for package in TRIAL_PACKAGES { // Package arguments stay explicit so adding a runtime binary is visible here diff --git a/crates/unixnotis-installer/src/trial/launch.rs b/crates/unixnotis-installer/src/trial/launch.rs index 9d8d9a971..7aa9d5361 100644 --- a/crates/unixnotis-installer/src/trial/launch.rs +++ b/crates/unixnotis-installer/src/trial/launch.rs @@ -1,6 +1,6 @@ //! Trial process launch and signal-time cleanup shell rendering -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::{anyhow, Result}; @@ -11,11 +11,11 @@ use crate::system_tools; const TRIAL_DAEMON_ARGS: [&str; 4] = ["--trial", "--restore", "auto", "--yes"]; -pub fn run_trial(repo_root: PathBuf) -> Result<()> { +pub fn run_trial(repo_root: &Path) -> Result<()> { println!("Starting UnixNotis trial run."); println!("Press Ctrl+C to stop and restore the previous daemon."); - let binaries = build_trial_binaries(&repo_root)?; + let binaries = build_trial_binaries(repo_root)?; println!("Trial control binary: {}", binaries.control.display()); // A temporary PATH shim is optional; direct control-binary usage remains valid diff --git a/crates/unixnotis-installer/src/trial/paths.rs b/crates/unixnotis-installer/src/trial/paths.rs index 19f9737c3..7178348ca 100644 --- a/crates/unixnotis-installer/src/trial/paths.rs +++ b/crates/unixnotis-installer/src/trial/paths.rs @@ -4,6 +4,8 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use unixnotis_core::filesystem::{remove_regular_file, write_file_if_missing}; + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; @@ -68,17 +70,12 @@ pub(super) fn path_dir_is_writable(dir: &Path) -> bool { .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()) )); - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&probe) - { - Ok(_) => { - // Probe file is trial-only and should not outlive the writability check - let _ = fs::remove_file(probe); - true + match write_file_if_missing(&probe, b"", 0o600) { + Ok(true) => { + // Probe success includes contained cleanup so linked directories cannot be accepted + remove_regular_file(&probe).is_ok_and(|removed| removed) } - Err(_) => false, + Ok(false) | Err(_) => false, } } diff --git a/crates/unixnotis-installer/src/trial/shim.rs b/crates/unixnotis-installer/src/trial/shim.rs index 82665494d..80050e18d 100644 --- a/crates/unixnotis-installer/src/trial/shim.rs +++ b/crates/unixnotis-installer/src/trial/shim.rs @@ -1,12 +1,15 @@ //! Temporary `noticenterctl` PATH shim management for trial mode use std::env; +#[cfg(not(unix))] use std::fs; -#[cfg(unix)] -use std::os::unix::fs as unix_fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Result}; +use unixnotis_core::filesystem::{ + create_directory_all, create_symlink_if_missing, read_symlink, remove_symlink_if_target, + CreateSymlinkOutcome, RemoveSymlinkOutcome, +}; use super::paths::{ canonicalize_best_effort, find_command_on_path_with_index, path_dir_is_writable, path_entries, @@ -39,7 +42,8 @@ pub(super) fn ensure_trial_control_access(ctl_bin: &Path) -> Result Result {} + CreateSymlinkOutcome::Unchanged | CreateSymlinkOutcome::TargetMismatch(_) => { + // A path that appeared after the earlier check is not owned by this trial + println!( + "Trial control command path changed before creation: {}", + shim_path.display() + ); + println!("Use {} directly during trial", ctl_bin.display()); + return Ok(None); + } + } } #[cfg(not(unix))] { @@ -116,8 +131,8 @@ pub(super) fn select_trial_shim_dir( .iter() .position(|entry| path_entries_match(entry, preferred_dir))?; - // Trial auth only trusts ~/.local/bin outside the build tree, so skip every - // other writable PATH directory even if it would be earlier + // A shim is useful only when the trusted local-bin entry can win PATH lookup + // Skip every other writable PATH directory even if it would be earlier if let Some((existing_index, _)) = existing { // If an older command wins PATH resolution before ~/.local/bin, a shim // here would never be observed by the shell @@ -128,7 +143,7 @@ pub(super) fn select_trial_shim_dir( if !preferred_dir.exists() { // Creating ~/.local/bin is safe only after confirming the path can matter - fs::create_dir_all(preferred_dir) + create_directory_all(preferred_dir, 0o755) .map_err(|err| anyhow!("failed to create {}: {}", preferred_dir.display(), err)) .ok()?; } @@ -147,17 +162,6 @@ pub(super) fn trial_control_command_is_compatible(path: &Path, ctl_bin: &Path) - return true; } - // Trial auth also trusts ~/.local/bin/noticenterctl - let local_bin = env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join(".local").join("bin").join("noticenterctl")); - if local_bin - .as_deref() - .is_some_and(|candidate| canonicalize_best_effort(candidate) == canonical) - { - return true; - } - // Trial auth trusts target/debug and target/release siblings under the same target root let Some(profile_dir) = ctl_bin.parent() else { // A control binary without a profile dir cannot prove target-tree ancestry @@ -176,39 +180,30 @@ pub(super) fn trial_control_command_is_compatible(path: &Path, ctl_bin: &Path) - pub(super) fn remove_trial_control_shim(path: &Path, expected_target: &Path) -> Result { #[cfg(unix)] { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(err) => { + let target = match read_symlink(path) { + Ok(Some(target)) => target, + Ok(None) => return Ok(false), + // A replaced regular file is user state, not trial-owned cleanup state + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => return Ok(false), + Err(error) => { return Err(anyhow!( "failed to inspect trial noticenterctl shim at {}: {}", path.display(), - err + error )); } }; - if !metadata.file_type().is_symlink() { - // A replaced regular file is user state, not trial-owned cleanup state - return Ok(false); - } - let target = fs::read_link(path).map_err(|err| { - anyhow!( - "failed to inspect trial noticenterctl shim target at {}: {}", - path.display(), - err - ) - })?; if !trial_shim_target_matches(path, &target, expected_target) { return Ok(false); } - fs::remove_file(path).map_err(|err| { + let outcome = remove_symlink_if_target(path, &target).map_err(|err| { anyhow!( "failed to remove trial noticenterctl shim at {}: {}", path.display(), err ) })?; - Ok(true) + Ok(matches!(outcome, RemoveSymlinkOutcome::Removed)) } #[cfg(not(unix))] diff --git a/crates/unixnotis-installer/src/trial/tests/launch.rs b/crates/unixnotis-installer/src/trial/tests/launch.rs index ffd98ad30..c781ed0f3 100644 --- a/crates/unixnotis-installer/src/trial/tests/launch.rs +++ b/crates/unixnotis-installer/src/trial/tests/launch.rs @@ -6,17 +6,20 @@ use crate::test_support::fs::write_executable; #[test] fn trial_launch_script_guards_cleanup_with_expected_symlink_target() { - let script = trial_launch_script( - "'/tmp/unixnotis-daemon'", - "'/home/user/.local/bin/noticenterctl'", - "'/tmp/target/debug/noticenterctl'", - ); + let root = crate::test_support::fs::unique_temp_path("trial-launch-script"); + let daemon_path = root.join("unixnotis-daemon"); + let shim_path = root.join("home").join(".local/bin/noticenterctl"); + let target_path = root.join("target/debug/noticenterctl"); + let daemon = shell_quote(&daemon_path.to_string_lossy()); + let shim = shell_quote(&shim_path.to_string_lossy()); + let target = shell_quote(&target_path.to_string_lossy()); + let script = trial_launch_script(&daemon, &shim, &target); // Signal-time cleanup must not be a blind rm of whatever is at the shim path - assert!(script.contains("[ -L '/home/user/.local/bin/noticenterctl' ]")); - assert!(script.contains("readlink -- '/home/user/.local/bin/noticenterctl'")); - assert!(script.contains("= '/tmp/target/debug/noticenterctl'")); - assert!(script.contains("rm -f -- '/home/user/.local/bin/noticenterctl'")); + assert!(script.contains(&format!("[ -L {shim} ]"))); + assert!(script.contains(&format!("readlink -- {shim}"))); + assert!(script.contains(&format!("= {target}"))); + assert!(script.contains(&format!("rm -f -- {shim}"))); } #[test] diff --git a/crates/unixnotis-installer/src/trial/tests/shim.rs b/crates/unixnotis-installer/src/trial/tests/shim.rs index aa6054c2f..a780ebc30 100644 --- a/crates/unixnotis-installer/src/trial/tests/shim.rs +++ b/crates/unixnotis-installer/src/trial/tests/shim.rs @@ -1,10 +1,41 @@ use std::fs; use super::shim::{ - remove_trial_control_shim, select_trial_shim_dir, trial_control_command_is_compatible, + ensure_trial_control_access, remove_trial_control_shim, select_trial_shim_dir, + trial_control_command_is_compatible, }; use super::test_support::temp_dir; +#[test] +#[cfg(unix)] +fn ensure_trial_control_access_creates_and_owns_a_private_shim() { + let _lock = crate::test_support::env::test_env_lock(); + let root = temp_dir("ensure-control-access"); + let home = root.join("home"); + let local_bin = home.join(".local").join("bin"); + let target = root.join("target").join("debug").join("noticenterctl"); + fs::create_dir_all(target.parent().expect("target parent")).expect("target parent"); + fs::write(&target, "#!/bin/sh\n").expect("trial control target"); + + let _home = crate::test_support::env::EnvGuard::set("HOME", &home); + let _path = crate::test_support::env::EnvGuard::set("PATH", &local_bin); + + let shim = ensure_trial_control_access(&target) + .expect("trial control access should be checked") + .expect("a visible local-bin path should receive a shim"); + + assert_eq!(shim.path, local_bin.join("noticenterctl")); + assert!(super::paths::path_exists_no_follow(&shim.path)); + + // Drop owns cleanup, so a later trial cannot inherit this run's shim + drop(shim); + assert!(!super::paths::path_exists_no_follow( + &local_bin.join("noticenterctl") + )); + + let _ = fs::remove_dir_all(root); +} + #[test] fn trial_shim_dir_uses_local_bin_when_it_wins_path_resolution() { // This models the clean case where ~/.local/bin is the first command location @@ -71,6 +102,23 @@ fn trial_shim_dir_rejects_local_bin_when_not_on_path() { let _ = fs::remove_dir_all(root); } +#[test] +#[cfg(unix)] +fn trial_shim_dir_rejects_a_symlinked_local_bin() { + let root = temp_dir("linked-local-bin"); + let outside = root.join("outside"); + let local_bin = root.join("local").join("bin"); + fs::create_dir_all(&outside).expect("outside directory"); + fs::create_dir_all(local_bin.parent().expect("local parent")).expect("local parent"); + std::os::unix::fs::symlink(&outside, &local_bin).expect("local bin link"); + + let selected = select_trial_shim_dir(&local_bin, std::slice::from_ref(&local_bin), None); + + assert!(selected.is_none()); + assert!(!outside.join("noticenterctl").exists()); + let _ = fs::remove_dir_all(root); +} + #[test] #[cfg(unix)] fn trial_control_command_accepts_debug_and_release_siblings() { @@ -104,6 +152,63 @@ fn trial_control_command_rejects_unrelated_path() { let _ = fs::remove_dir_all(root); } +#[test] +fn trial_control_command_rejects_arbitrary_local_bin_binary() { + // A writable PATH directory cannot become a trial trust root by pathname + let root = temp_dir("reject-local-bin-forgery"); + let debug = root.join("target").join("debug").join("noticenterctl"); + let forged = root.join(".local").join("bin").join("noticenterctl"); + fs::create_dir_all(debug.parent().expect("debug parent")).expect("debug parent"); + fs::create_dir_all(forged.parent().expect("local-bin parent")).expect("local-bin parent"); + fs::write(&debug, "#!/bin/sh\n").expect("debug ctl"); + fs::write(&forged, "#!/bin/sh\n").expect("forged ctl"); + + assert!(!trial_control_command_is_compatible(&forged, &debug)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +#[cfg(unix)] +fn trial_control_command_accepts_local_bin_symlink_to_trial_binary() { + // PATH convenience remains compatible only when it resolves to the trial binary + let root = temp_dir("accept-trial-local-bin-link"); + let debug = root.join("target").join("debug").join("noticenterctl"); + let shim = root.join(".local").join("bin").join("noticenterctl"); + fs::create_dir_all(debug.parent().expect("debug parent")).expect("debug parent"); + fs::create_dir_all(shim.parent().expect("local-bin parent")).expect("local-bin parent"); + fs::write(&debug, "#!/bin/sh\n").expect("debug ctl"); + std::os::unix::fs::symlink(&debug, &shim).expect("trial shim"); + + assert!(trial_control_command_is_compatible(&shim, &debug)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn trial_control_command_rejects_renamed_component_binaries() { + // Renderer and daemon names must not make unrelated local-bin files trusted + let root = temp_dir("reject-renamed-components"); + let debug = root.join("target").join("debug").join("noticenterctl"); + fs::create_dir_all(debug.parent().expect("debug parent")).expect("debug parent"); + fs::write(&debug, "#!/bin/sh\n").expect("debug ctl"); + + for executable in [ + "noticenterctl", + "unixnotis-center", + "unixnotis-popups", + "unixnotis-daemon", + ] { + let forged = root.join(".local").join("bin").join(executable); + fs::create_dir_all(forged.parent().expect("local-bin parent")).expect("local-bin parent"); + fs::write(&forged, "#!/bin/sh\n").expect("forged component"); + + assert!(!trial_control_command_is_compatible(&forged, &debug)); + } + + let _ = fs::remove_dir_all(root); +} + #[test] #[cfg(unix)] fn remove_trial_control_shim_removes_only_matching_symlink() { @@ -195,3 +300,27 @@ fn remove_trial_control_shim_reports_non_directory_parent() { .contains("failed to inspect trial noticenterctl shim")); let _ = fs::remove_dir_all(root); } + +#[test] +#[cfg(unix)] +fn remove_trial_control_shim_rejects_a_symlinked_parent() { + let root = temp_dir("remove-linked-shim-parent"); + let target = root.join("target").join("noticenterctl"); + let outside = root.join("outside"); + let outside_shim = outside.join("noticenterctl"); + let linked_parent = root.join("linked-bin"); + fs::create_dir_all(target.parent().expect("target parent")).expect("target parent"); + fs::create_dir_all(&outside).expect("outside directory"); + fs::write(&target, "#!/bin/sh\n").expect("target"); + std::os::unix::fs::symlink(&target, &outside_shim).expect("outside trial shim"); + std::os::unix::fs::symlink(&outside, &linked_parent).expect("linked shim parent"); + let shim = linked_parent.join("noticenterctl"); + + remove_trial_control_shim(&shim, &target).expect_err("linked parent should fail"); + + assert_eq!( + fs::read_link(&outside_shim).expect("outside shim remains"), + target + ); + let _ = fs::remove_dir_all(root); +} diff --git a/crates/unixnotis-installer/src/ui/confirm.rs b/crates/unixnotis-installer/src/ui/confirm.rs index beb7eeaef..a568c178f 100644 --- a/crates/unixnotis-installer/src/ui/confirm.rs +++ b/crates/unixnotis-installer/src/ui/confirm.rs @@ -39,7 +39,9 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { "Current owner: ", Style::default().add_modifier(Modifier::BOLD), ), - Span::raw(crate::actions::summarize_owner(&app.detection.owner)), + Span::raw(crate::actions::summarize_owner( + app.detection.owner.as_ref(), + )), ])); // Blocked state is rendered inline so it is visible before execution @@ -120,6 +122,10 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { layout[1], ); + render_confirmation_footer(frame, layout[2]); +} + +fn render_confirmation_footer(frame: &mut Frame<'_>, area: ratatui::layout::Rect) { let footer = Paragraph::new(Text::from(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" = proceed "), @@ -128,5 +134,5 @@ pub(super) fn draw_confirm(frame: &mut Frame<'_>, app: &App, mode: ActionMode) { ]))) .alignment(ratatui::layout::Alignment::Center) .block(Block::default().borders(Borders::TOP)); - frame.render_widget(footer, layout[2]); + frame.render_widget(footer, area); } diff --git a/crates/unixnotis-installer/src/ui/progress.rs b/crates/unixnotis-installer/src/ui/progress.rs index 8fd970fa2..0471adad1 100644 --- a/crates/unixnotis-installer/src/ui/progress.rs +++ b/crates/unixnotis-installer/src/ui/progress.rs @@ -16,14 +16,21 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) ProgressState::Running => ("In progress", Color::Yellow), ProgressState::Completed => ("Completed", Color::Green), ProgressState::Failed => ("Failed", Color::Red), + ProgressState::RecoveryRequired => ("Manual recovery required", Color::Red), ProgressState::Idle => ("Pending", Color::Gray), }; + let status_height = if matches!(app.progress_state, ProgressState::RecoveryRequired) { + 8 + } else { + 6 + }; + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Length(6), + Constraint::Length(status_height), Constraint::Min(8), Constraint::Length(3), ]) @@ -46,13 +53,24 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) .add_modifier(Modifier::BOLD), ))]; if let Some(err) = &app.last_error { - if matches!(app.progress_state, ProgressState::Failed) { + if matches!( + app.progress_state, + ProgressState::Failed | ProgressState::RecoveryRequired + ) { let summary = summarize_error(err); status_lines.push(Line::from(vec![ Span::styled("Error: ", Style::default().fg(Color::Red)), Span::raw(summary), ])); - status_lines.push(Line::from("See logs for full output.")); + if matches!(app.progress_state, ProgressState::RecoveryRequired) { + status_lines.push(Line::from( + "UnixNotis activation remains inhibited while this installer is running.", + )); + status_lines.push(Line::from("Do not start another UnixNotis instance.")); + status_lines.push(Line::from("See logs for the complete failure chain.")); + } else { + status_lines.push(Line::from("See logs for full output.")); + } } } @@ -93,6 +111,7 @@ pub(super) fn draw_progress(frame: &mut Frame<'_>, app: &App, mode: ActionMode) "Enter = back to menu Q = quit" } } + ProgressState::RecoveryRequired => "Q = quit", ProgressState::Idle => "", }; let footer = Paragraph::new(footer_text) diff --git a/crates/unixnotis-installer/src/ui/tests/progress.rs b/crates/unixnotis-installer/src/ui/tests/progress.rs index 7c60cc4ad..7c695d4fc 100644 --- a/crates/unixnotis-installer/src/ui/tests/progress.rs +++ b/crates/unixnotis-installer/src/ui/tests/progress.rs @@ -63,3 +63,18 @@ fn draw_progress_running_state_uses_running_footer_without_error_summary() { assert!(screen.contains("Running...")); assert!(!screen.contains("Error:")); } + +#[test] +fn draw_progress_recovery_required_warns_that_activation_remains_inhibited() { + let mut app = app_for_rendering(Screen::Progress(ActionMode::Install)); + app.progress_state = ProgressState::RecoveryRequired; + app.last_error = Some("rollback state is unknown".to_string()); + + let screen = render_app(&app); + + assert!(screen.contains("Install - Manual recovery required")); + assert!(screen.contains("UnixNotis activation remains inhibited")); + assert!(screen.contains("Do not start another UnixNotis instance")); + assert!(screen.contains("Q = quit")); + assert!(!screen.contains("Enter = back to menu")); +} diff --git a/crates/unixnotis-installer/src/ui/tests/welcome.rs b/crates/unixnotis-installer/src/ui/tests/welcome.rs index 8941950ac..ab81d956a 100644 --- a/crates/unixnotis-installer/src/ui/tests/welcome.rs +++ b/crates/unixnotis-installer/src/ui/tests/welcome.rs @@ -1,3 +1,4 @@ +use crate::actions::InstallationDisposition; use crate::app::Screen; use crate::detect::OwnerInfo; use crate::release::{ReleaseStatus, ReleaseUpdateState}; @@ -7,6 +8,22 @@ use super::test_support::{ app_for_rendering, detected_daemon_with_status, render_app, render_app_buffer, style_for_text, }; +#[test] +fn installed_version_role_distinguishes_verified_and_repair_states() { + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::InstalledHealthy), + "installed" + ); + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::RepairRequired), + "binaries present" + ); + assert_eq!( + super::welcome::installed_version_role(InstallationDisposition::NotInstalled), + "binaries present" + ); +} + #[test] fn draw_welcome_renders_status_and_action_menu() { let app = app_for_rendering(Screen::Welcome); @@ -19,9 +36,10 @@ fn draw_welcome_renders_status_and_action_menu() { assert!(screen.contains("Actions")); assert!(screen.contains("Release")); assert!(screen.contains(&format!( - "Version: v{} installed", + "Version: v{} installer", env!("CARGO_PKG_VERSION") ))); + assert!(screen.contains("Install state: not installed")); assert!(screen.contains("Compatibility")); assert!(screen.contains("[ok]")); assert!(screen.contains("test - ok")); @@ -61,6 +79,7 @@ fn draw_welcome_hides_daemon_section_when_only_probe_errors_exist() { fn draw_welcome_shows_daemon_section_when_runtime_signal_exists() { let mut app = app_for_rendering(Screen::Welcome); app.detection.owner = Some(OwnerInfo { + unique_name: None, pid: Some(4242), comm: Some("dunst".to_string()), }); diff --git a/crates/unixnotis-installer/src/ui/welcome.rs b/crates/unixnotis-installer/src/ui/welcome.rs index a875d0a67..e0bf63b14 100644 --- a/crates/unixnotis-installer/src/ui/welcome.rs +++ b/crates/unixnotis-installer/src/ui/welcome.rs @@ -8,6 +8,7 @@ use super::header::draw_header; use super::widgets::truncate_to_width; use crate::actions::{ daemon_has_displayable_status, daemon_status_is_warning, format_daemon_status, summarize_owner, + InstallationDisposition, }; use crate::app::{App, MenuItem}; use crate::checks::{CheckItem, CheckState}; @@ -66,6 +67,20 @@ pub(super) fn draw_welcome(frame: &mut Frame<'_>, app: &App) { fn render_status(app: &App) -> Text<'static> { // Build a list of Lines that ratatui will render as a single Text block. // This is kept pure so rendering remains deterministic for any given App state. + let disposition = app.installation_disposition(); + let (version, version_role) = app + .install_state + .as_ref() + .and_then(crate::actions::InstallState::installed_version) + .map_or_else( + || (app.release_status.current.clone(), "installer"), + |installed_version| { + ( + format!("v{}", installed_version.trim_start_matches('v')), + installed_version_role(disposition), + ) + }, + ); let mut lines = vec![ // Section heading: release version and update status. Line::from(Span::styled( @@ -75,10 +90,17 @@ fn render_status(app: &App) -> Text<'static> { Line::from(vec![ Span::styled("Version: ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( - app.release_status.display_line(), - release_status_style(app.release_status.state), + app.release_status.display_line_for(&version, version_role), + release_status_style(app.release_status.update_state_for(&version)), ), ]), + Line::from(vec![ + Span::styled( + "Install state: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(disposition.label()), + ]), Line::from(""), // Section heading: core environment checks. Line::from(Span::styled( @@ -103,6 +125,15 @@ fn render_status(app: &App) -> Text<'static> { Text::from(lines) } +pub(super) const fn installed_version_role(disposition: InstallationDisposition) -> &'static str { + // Only a fully verified generation may use the unqualified installed wording + if matches!(disposition, InstallationDisposition::InstalledHealthy) { + "installed" + } else { + "binaries present" + } +} + fn render_daemon_section(app: &App, lines: &mut Vec>) { let visible_daemons = app .detection @@ -125,7 +156,7 @@ fn render_daemon_section(app: &App, lines: &mut Vec>) { lines.push(Line::from(vec![ Span::styled("Owner: ", Style::default().add_modifier(Modifier::BOLD)), Span::styled( - summarize_owner(&app.detection.owner), + summarize_owner(app.detection.owner.as_ref()), daemon_owner_style(app.detection.owner.is_some()), ), ])); diff --git a/crates/unixnotis-installer/src/ui/widgets.rs b/crates/unixnotis-installer/src/ui/widgets.rs index 66233f257..48ade58b1 100644 --- a/crates/unixnotis-installer/src/ui/widgets.rs +++ b/crates/unixnotis-installer/src/ui/widgets.rs @@ -114,6 +114,8 @@ fn take_display_width(text: &str, width: usize) -> String { } pub(super) fn summarize_error(err: &str) -> String { + const MAX_LEN: usize = 72; + // Provide a short user-friendly error line while keeping full details in logs if err.contains("failed to install") { return "failed to install binary (see logs)".to_string(); @@ -128,8 +130,6 @@ pub(super) fn summarize_error(err: &str) -> String { return "repository root not found (see logs)".to_string(); } - const MAX_LEN: usize = 72; - let mut out = String::new(); for ch in err.chars().take(MAX_LEN) { out.push(ch); diff --git a/crates/unixnotis-installer/src/write_target.rs b/crates/unixnotis-installer/src/write_target.rs new file mode 100644 index 000000000..21dab739e --- /dev/null +++ b/crates/unixnotis-installer/src/write_target.rs @@ -0,0 +1,28 @@ +//! Preflight checks for user-owned files edited by the installer + +use std::fs; +use std::io; +use std::path::Path; + +pub fn reject_unsafe_write_target(path: &Path) -> io::Result<()> { + // The final component is classified without following a link into another file + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to write through symlink {}", path.display()), + )), + // Existing regular files may proceed to the descriptor-contained writer + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to overwrite non-file {}", path.display()), + )), + // Missing files are valid because the writer creates their parents safely + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} + +#[cfg(test)] +#[path = "tests/write_target.rs"] +mod tests; diff --git a/crates/unixnotis-installer/tests/cli.rs b/crates/unixnotis-installer/tests/cli.rs deleted file mode 100644 index 3670bf7cc..000000000 --- a/crates/unixnotis-installer/tests/cli.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::process::Command; - - type TestResult = Result<(), Box>; - - #[test] - fn installer_help_prints_usage_from_entrypoint() -> TestResult { - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-installer")) - .arg("--help") - .output()?; - - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout)?; - assert!(stdout.contains("Usage: unixnotis-installer")); - assert!(stdout.contains("--service-manager")); - Ok(()) - } -} diff --git a/crates/unixnotis-popups/Cargo.toml b/crates/unixnotis-popups/Cargo.toml index 016065b9f..2375055d4 100644 --- a/crates/unixnotis-popups/Cargo.toml +++ b/crates/unixnotis-popups/Cargo.toml @@ -15,6 +15,7 @@ glib.workspace = true gtk.workspace = true gtk4-layer-shell.workspace = true image.workspace = true +rustix.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/unixnotis-popups/src/app/command.rs b/crates/unixnotis-popups/src/app/command.rs index 78a9771b8..d19dc141c 100644 --- a/crates/unixnotis-popups/src/app/command.rs +++ b/crates/unixnotis-popups/src/app/command.rs @@ -11,7 +11,10 @@ use glib::MainContext; use gtk::prelude::*; use tracing::{info, warn}; use unixnotis_core::Config; -use unixnotis_ui::css::{self, CssKind}; +use unixnotis_ui::{ + css::{self, CssKind}, + presentation::register_semantic_badges, +}; use crate::{dbus, ui}; @@ -30,6 +33,7 @@ pub struct Args { } pub fn run(args: Args) -> Result<()> { + register_semantic_badges().map_err(anyhow::Error::msg)?; // Load and validate config before GTK starts so startup failures stay clear let (config, config_path, config_source) = load_config(&args).context("load config")?; init_tracing(&config); @@ -54,9 +58,7 @@ pub fn run(args: Args) -> Result<()> { let theme_paths = config .resolve_theme_paths_from(&theme_base) .context("resolve theme paths")?; - config - .ensure_theme_files(&theme_paths) - .context("ensure theme files")?; + // Popup startup never creates or migrates user-editable theme files let app = gtk::Application::new(Some("com.unixnotis.Popups"), Default::default()); // Activation can happen more than once in one process, so runtime setup @@ -72,7 +74,12 @@ pub fn run(args: Args) -> Result<()> { // Bound the queue so a stalled UI cannot grow memory forever let (event_tx, event_rx) = async_channel::bounded(UI_EVENT_QUEUE_CAPACITY); - let command_tx = dbus::start_dbus_runtime(event_tx.clone()); + let dbus_runtime = dbus::start_dbus_runtime(event_tx.clone()); + let command_tx = dbus_runtime.command_sender(); + let shutdown = dbus_runtime.clone(); + app.connect_shutdown(move |_| { + shutdown.request_shutdown(); + }); let reload_gate = Arc::new(ReloadGate::new()); // Timer state keeps only one flush source alive at a time let reload_timer = Arc::new(Mutex::new(None::)); @@ -89,6 +96,9 @@ pub fn run(args: Args) -> Result<()> { command_tx, css_manager, ))); + ui.borrow_mut().set_popup_event_sender(event_tx.clone()); + // Composite readiness now means GTK state exists as well as D-Bus seeding succeeding + dbus_runtime.mark_gtk_ready(); let ui_clone = ui; let reload_gate_loop = Arc::clone(&reload_gate); diff --git a/crates/unixnotis-popups/src/dbus/commands.rs b/crates/unixnotis-popups/src/dbus/commands.rs index 0697655bd..6134a8596 100644 --- a/crates/unixnotis-popups/src/dbus/commands.rs +++ b/crates/unixnotis-popups/src/dbus/commands.rs @@ -2,20 +2,63 @@ use tokio::sync::mpsc; use tracing::warn; -use unixnotis_core::ControlProxy; +use unixnotis_core::{timed_dbus_call, ControlProxy}; use zbus::Result as ZbusResult; use super::types::UiCommand; pub async fn handle_command(proxy: &ControlProxy<'_>, command: UiCommand) -> ZbusResult<()> { match command { - UiCommand::Dismiss(id) => proxy.dismiss(id).await, - UiCommand::InvokeAction { id, action_key } => proxy.invoke_action(id, &action_key).await, + UiCommand::Dismiss(notification) => { + timed_dbus_call(proxy.dismiss_generation(notification.id, notification.generation)) + .await + } + UiCommand::InvokeAction { + notification, + action_key, + confirmed, + } => { + timed_dbus_call(proxy.invoke_action_generation( + notification.id, + notification.generation, + &action_key, + confirmed, + )) + .await + } + UiCommand::Reply { + id, + generation, + text, + outcome, + } => { + let result = timed_dbus_call(proxy.reply_notification(id, generation, &text)).await; + let reply_result = result.as_ref().map_err(ToString::to_string).copied(); + let _ = outcome.send(reply_result); + result + } + UiCommand::Materialized(notification) => { + timed_dbus_call(proxy.mark_popup_materialized(notification.id, notification.generation)) + .await + } + UiCommand::Visible(notification) => { + timed_dbus_call(proxy.mark_popup_visible(notification.id, notification.generation)) + .await + } } } pub fn drain_offline_commands(command_rx: &mut mpsc::Receiver) { - while command_rx.try_recv().is_ok() { + while let Ok(command) = command_rx.try_recv() { + match command { + UiCommand::Reply { outcome, .. } => { + let _ = outcome.send(Err("notification service is unavailable".to_string())); + } + UiCommand::Dismiss(_) + | UiCommand::InvokeAction { .. } + | UiCommand::Materialized(_) + | UiCommand::Visible(_) => {} + } // Popups only reflect live state, so stale button actions are dropped while offline warn!("dropping control command while interface is unavailable"); } diff --git a/crates/unixnotis-popups/src/dbus/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime.rs deleted file mode 100644 index 2b4ba123b..000000000 --- a/crates/unixnotis-popups/src/dbus/runtime.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Popup D-Bus runtime bootstrap and stream loop - -use std::thread; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio::sync::mpsc; -use tracing::{info, warn}; -use unixnotis_core::ControlProxy; -use zbus::Connection; - -use super::backoff::{ - Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, -}; -use super::commands::{drain_offline_commands, handle_command}; -use super::seed::{seed_state_with_retry, PopupSeedSource, SeedError, SeedSnapshot}; -use super::types::{UiCommand, UiEvent}; - -// Bound UI commands to avoid unbounded memory growth under a stuck UI event loop -const UI_COMMAND_QUEUE_CAPACITY: usize = 64; - -struct ControlProxySeedSource<'proxy, 'conn> { - proxy: &'proxy ControlProxy<'conn>, -} - -impl PopupSeedSource for ControlProxySeedSource<'_, '_> { - async fn seed_snapshot(&self) -> Result { - // Both calls run together so startup seed data has the smallest possible skew - // A fully atomic seed would need one daemon method that returns both values - let (state, active) = tokio::join!(self.proxy.get_state(), self.proxy.list_active()); - SeedSnapshot::from_fetch_results(state, active) - } -} - -pub fn start_dbus_runtime(sender: async_channel::Sender) -> mpsc::Sender { - let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); - spawn_runtime_thread(sender, command_rx); - command_tx -} - -fn spawn_runtime_thread( - sender: async_channel::Sender, - command_rx: mpsc::Receiver, -) { - thread::spawn(move || { - // Dedicated runtime keeps async D-Bus work off the GTK main thread - let Some(runtime) = build_runtime() else { - return; - }; - runtime.block_on(run_dbus_loop(sender, command_rx)); - }); -} - -fn build_runtime() -> Option { - tokio::runtime::Builder::new_multi_thread() - // Small worker pool keeps background popups responsive without excess threads - .worker_threads(2) - .enable_all() - .build() - .map_err(|err| { - warn!(?err, "failed to initialize tokio runtime"); - err - }) - .ok() -} - -async fn run_dbus_loop( - sender: async_channel::Sender, - mut command_rx: mpsc::Receiver, -) { - let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); - let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); - let mut connect_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); - let mut subscribe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); - - loop { - let connection = connect_session_bus(&mut connect_backoff, &mut connect_log).await; - let retry_delay = run_connection_once( - &connection, - &sender, - &mut command_rx, - &mut subscribe_backoff, - &mut subscribe_log, - ) - .await; - tokio::time::sleep(retry_delay).await; - } -} - -async fn connect_session_bus( - connect_backoff: &mut Backoff, - connect_log: &mut RetryLog, -) -> Connection { - loop { - match Connection::session().await { - Ok(connection) => { - connect_backoff.reset(); - connect_log.reset(); - return connection; - } - Err(err) => { - connect_log.warn_or_debug(&err, "failed to connect to session bus; retrying"); - tokio::time::sleep(connect_backoff.next_sleep()).await; - } - } - } -} - -async fn run_connection_once( - connection: &Connection, - sender: &async_channel::Sender, - command_rx: &mut mpsc::Receiver, - subscribe_backoff: &mut Backoff, - subscribe_log: &mut RetryLog, -) -> Duration { - let proxy = match ControlProxy::new(connection).await { - Ok(proxy) => proxy, - Err(err) => { - subscribe_log.warn_or_debug(&err, "control interface unavailable, retrying"); - drain_offline_commands(command_rx); - return subscribe_backoff.next_sleep(); - } - }; - subscribe_backoff.reset(); - subscribe_log.reset(); - info!("connected to unixnotis control interface"); - - // Popups stay on the shared notification stream, but the trimmed payload keeps - // each message smaller now that unused flags were removed from NotificationView - let mut added_stream = match proxy.receive_notification_added().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_added"); - return subscribe_backoff.next_sleep(); - } - }; - let mut updated_stream = match proxy.receive_notification_updated().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_updated"); - return subscribe_backoff.next_sleep(); - } - }; - let mut closed_stream = match proxy.receive_notification_closed().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to notification_closed"); - return subscribe_backoff.next_sleep(); - } - }; - let mut popup_gate_stream = match proxy.receive_popup_gate_changed().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to popup_gate_changed"); - return subscribe_backoff.next_sleep(); - } - }; - let mut invalidated_stream = match proxy.receive_snapshot_invalidated().await { - Ok(stream) => stream, - Err(err) => { - subscribe_log.warn_or_debug(&err, "failed to subscribe to snapshot_invalidated"); - return subscribe_backoff.next_sleep(); - } - }; - - // Seed only after subscriptions are active so startup does not miss in-flight changes - seed_state_with_retry(&ControlProxySeedSource { proxy: &proxy }, sender).await; - - loop { - tokio::select! { - command = command_rx.recv() => { - let Some(command) = command else { - break; - }; - if let Err(err) = handle_command(&proxy, command).await { - warn!(?err, "control command failed"); - } - } - signal = added_stream.next() => { - let Some(signal) = signal else { - warn!("notification_added stream ended"); - break; - }; - if let Ok(args) = signal.args() { - push_active_notification_event( - &proxy, - sender, - *args.id(), - *args.show_popup(), - true, - ).await; - } - } - signal = updated_stream.next() => { - let Some(signal) = signal else { - warn!("notification_updated stream ended"); - break; - }; - if let Ok(args) = signal.args() { - push_active_notification_event( - &proxy, - sender, - *args.id(), - *args.show_popup(), - false, - ).await; - } - } - signal = closed_stream.next() => { - let Some(signal) = signal else { - warn!("notification_closed stream ended"); - break; - }; - if let Ok(args) = signal.args() { - let _ = sender - .send(UiEvent::NotificationClosed( - *args.id(), - *args.reason(), - )) - .await; - } - } - signal = popup_gate_stream.next() => { - let Some(signal) = signal else { - warn!("popup_gate_changed stream ended"); - break; - }; - if let Ok(args) = signal.args() { - let _ = sender - .send(UiEvent::PopupGateChanged(args.gate().clone())) - .await; - } - } - signal = invalidated_stream.next() => { - let Some(_signal) = signal else { - warn!("snapshot_invalidated stream ended"); - break; - }; - // A fresh seed clears stale popups after remote clears or daemon restart drift - // Seed reconcile also updates same-id payload changes without trusting missed signals - seed_state_with_retry(&ControlProxySeedSource { proxy: &proxy }, sender).await; - } - } - } - - subscribe_backoff.next_sleep() -} - -async fn push_active_notification_event( - proxy: &ControlProxy<'_>, - sender: &async_channel::Sender, - id: u32, - show_popup: bool, - is_add: bool, -) { - // Full popup payloads now stay on the authorized pull path instead of the shared signal - match proxy.get_active_notification(id).await { - Ok(mut notifications) => { - // Close fanout can win the race, so a missing row is a normal no-op here - let Some(notification) = notifications.pop() else { - return; - }; - let event = if is_add { - UiEvent::NotificationAdded(notification, show_popup) - } else { - UiEvent::NotificationUpdated(notification, show_popup) - }; - let _ = sender.send(event).await; - } - Err(err) => { - warn!(?err, id, "failed to fetch popup notification after signal"); - } - } -} - -#[cfg(test)] -#[path = "tests/runtime.rs"] -mod tests; diff --git a/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs new file mode 100644 index 000000000..d09ccda9c --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/bootstrap.rs @@ -0,0 +1,50 @@ +//! Dedicated Tokio runtime construction and queue wiring + +use std::thread; + +use tokio::sync::{mpsc, watch}; +use tracing::warn; + +use super::connection::run_dbus_loop; +use super::{PopupRuntime, UI_COMMAND_QUEUE_CAPACITY}; +use crate::dbus::{UiCommand, UiEvent}; + +pub(super) fn start_runtime(sender: async_channel::Sender) -> PopupRuntime { + let (command_tx, command_rx) = mpsc::channel(UI_COMMAND_QUEUE_CAPACITY); + let (gtk_ready_tx, gtk_ready_rx) = watch::channel(false); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + spawn_runtime_thread(sender, command_rx, gtk_ready_rx, shutdown_rx); + PopupRuntime { + command_tx, + gtk_ready_tx, + shutdown_tx, + } +} + +fn spawn_runtime_thread( + sender: async_channel::Sender, + command_rx: mpsc::Receiver, + gtk_ready_rx: watch::Receiver, + shutdown_rx: watch::Receiver, +) { + thread::spawn(move || { + // The GTK main thread never blocks on bus calls or retry delays + let Some(runtime) = build_runtime() else { + return; + }; + runtime.block_on(run_dbus_loop(sender, command_rx, gtk_ready_rx, shutdown_rx)); + }); +} + +pub(super) fn build_runtime() -> Option { + tokio::runtime::Builder::new_multi_thread() + // Two workers keep signal delivery moving while one bounded call is pending + .worker_threads(2) + .enable_all() + .build() + .map_err(|error| { + warn!(?error, "failed to initialize popup Tokio runtime"); + error + }) + .ok() +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/connection.rs new file mode 100644 index 000000000..c998f10e2 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/connection.rs @@ -0,0 +1,239 @@ +//! Session-bus recovery and control-owner discovery + +use std::time::Duration; + +use futures_util::StreamExt; +use tokio::sync::{mpsc, watch}; +use tracing::warn; +use unixnotis_core::{ + ensure_control_api_version, log_session_bus_identity, ControlProxy, CONTROL_BUS_NAME, + INTERNAL_DBUS_CALL_TIMEOUT, +}; +use zbus::fdo::DBusProxy; +use zbus::names::BusName; +use zbus::proxy::OwnerChangedStream; +use zbus::Connection; + +use super::generation::{run_owner_generation, GenerationExit, PopupGenerationContext}; +use crate::dbus::backoff::{ + Backoff, RetryLog, BACKOFF_BASE_MS, BACKOFF_MAX_MS, RETRY_WARN_INTERVAL_SECS, +}; +use crate::dbus::commands::drain_offline_commands; +use crate::dbus::{UiCommand, UiEvent}; + +pub(super) async fn run_dbus_loop( + sender: async_channel::Sender, + mut command_rx: mpsc::Receiver, + mut gtk_ready_rx: watch::Receiver, + mut shutdown_rx: watch::Receiver, +) { + // Backoff state survives owner changes but resets after a healthy connection + let mut connect_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); + let mut subscribe_backoff = Backoff::new(BACKOFF_BASE_MS, BACKOFF_MAX_MS); + let mut connect_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + let mut subscribe_log = RetryLog::new(Duration::from_secs(RETRY_WARN_INTERVAL_SECS)); + + loop { + if *shutdown_rx.borrow() { + return; + } + let Some(connection) = + connect_session_bus(&mut connect_backoff, &mut connect_log, &mut shutdown_rx).await + else { + return; + }; + let retry_delay = run_connection_once( + &connection, + &sender, + &mut command_rx, + &mut subscribe_backoff, + &mut subscribe_log, + &mut gtk_ready_rx, + &mut shutdown_rx, + ) + .await; + let Some(retry_delay) = retry_delay else { + return; + }; + tokio::select! { + () = tokio::time::sleep(retry_delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return; } + } + } + } +} + +async fn connect_session_bus( + connect_backoff: &mut Backoff, + connect_log: &mut RetryLog, + shutdown_rx: &mut watch::Receiver, +) -> Option { + loop { + let result = tokio::select! { + result = Connection::session() => result, + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + continue; + } + }; + match result { + Ok(connection) => { + if let Err(error) = log_session_bus_identity(&connection, "popups").await { + connect_log + .warn_or_debug(&error, "session bus identity probe failed; retrying"); + let delay = connect_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + } + } + continue; + } + connect_backoff.reset(); + connect_log.reset(); + return Some(connection); + } + Err(error) => { + connect_log.warn_or_debug(&error, "failed to connect to the session bus; retrying"); + let delay = connect_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return None; } + } + } + } + } + } +} + +async fn run_connection_once( + connection: &Connection, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + subscribe_backoff: &mut Backoff, + subscribe_log: &mut RetryLog, + gtk_ready_rx: &mut watch::Receiver, + shutdown_rx: &mut watch::Receiver, +) -> Option { + // One connection owns one stream set and one command-generation boundary + let proxy = match ControlProxy::new(connection).await { + Ok(proxy) => proxy, + Err(error) => { + subscribe_log.warn_or_debug(&error, "control interface unavailable; retrying"); + drain_offline_commands(command_rx); + return Some(subscribe_backoff.next_sleep()); + } + }; + if let Err(error) = ensure_control_api_version(&proxy).await { + subscribe_log.warn_or_debug(&error, "control API version mismatch; retrying"); + return Some(subscribe_backoff.next_sleep()); + } + let mut owner_changes = match proxy.inner().receive_owner_changed().await { + Ok(stream) => stream, + Err(error) => { + subscribe_log.warn_or_debug(&error, "control owner watch unavailable; retrying"); + return Some(subscribe_backoff.next_sleep()); + } + }; + let dbus = match DBusProxy::new(connection).await { + Ok(proxy) => proxy, + Err(error) => { + subscribe_log.warn_or_debug(&error, "session owner proxy unavailable; retrying"); + return Some(subscribe_backoff.next_sleep()); + } + }; + + loop { + // Wait for an owner before creating generation-scoped subscriptions + let owner = match wait_for_control_owner( + &dbus, + &mut owner_changes, + sender, + command_rx, + shutdown_rx, + ) + .await + { + OwnerWait::Ready(owner) => owner, + OwnerWait::Disconnected => return Some(subscribe_backoff.next_sleep()), + OwnerWait::Shutdown => return None, + }; + let context = PopupGenerationContext::new( + &mut owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + shutdown_rx, + ); + match run_owner_generation(&proxy, &owner, context).await { + GenerationExit::OwnerChanged => {} + GenerationExit::ConnectionLost => return Some(subscribe_backoff.next_sleep()), + GenerationExit::Shutdown => return None, + GenerationExit::Retry => { + let retry_delay = subscribe_backoff.next_sleep(); + tokio::select! { + () = tokio::time::sleep(retry_delay) => {} + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { + return None; + } + } + } + } + } + } +} + +pub(super) enum OwnerWait { + Ready(String), + Disconnected, + Shutdown, +} + +async fn wait_for_control_owner( + dbus: &DBusProxy<'_>, + owner_changes: &mut OwnerChangedStream<'_>, + sender: &async_channel::Sender, + command_rx: &mut mpsc::Receiver, + shutdown_rx: &mut watch::Receiver, +) -> OwnerWait { + let control_name = + BusName::try_from(CONTROL_BUS_NAME).expect("static control bus name must be valid"); + if let Ok(Ok(owner)) = tokio::time::timeout( + INTERNAL_DBUS_CALL_TIMEOUT, + dbus.get_name_owner(control_name), + ) + .await + { + return OwnerWait::Ready(owner.to_string()); + } + + // An unowned name is a quiet state and must not trigger seed or readiness calls + let _ = sender.send(UiEvent::Disconnected).await; + drain_offline_commands(command_rx); + loop { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { return OwnerWait::Shutdown; } + } + command = command_rx.recv() => { + match command { + Some(_) => warn!("dropping popup command while control has no owner"), + None => return OwnerWait::Shutdown, + } + } + update = owner_changes.next() => { + match update { + Some(Some(owner)) => return OwnerWait::Ready(owner.to_string()), + Some(None) => {} + None => return OwnerWait::Disconnected, + } + } + } + } +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs new file mode 100644 index 000000000..2e9dcbc1a --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/delivery.rs @@ -0,0 +1,55 @@ +//! Authenticated notification pulls after lightweight signal delivery + +use tracing::warn; +use unixnotis_core::{timed_dbus_call, ControlProxy, PopupCandidate}; + +use crate::dbus::UiEvent; + +pub(super) async fn push_active_notification_event( + proxy: &ControlProxy<'_>, + sender: &async_channel::Sender, + id: u32, + generation: u64, + is_add: bool, +) { + // Payload and popup policy come from one daemon-side store snapshot + match timed_dbus_call(proxy.get_popup_candidate(id)).await { + Ok(candidates) => { + let Some(event) = popup_event(candidates, generation, is_add) else { + return; + }; + let _ = sender.send(event).await; + } + Err(error) => { + // One failed pull must not tear down an otherwise healthy generation + warn!( + ?error, + id, "failed to fetch popup notification after signal" + ); + } + } +} + +pub(super) fn popup_event( + mut candidates: Vec, + generation: u64, + is_add: bool, +) -> Option { + // A close signal may win this fetch race, making an empty result normal + let candidate = candidates.pop()?; + // A delayed signal must never lend its admission to a replacement payload + if candidate.notification.generation != generation { + return None; + } + if is_add { + Some(UiEvent::NotificationAdded( + candidate.notification, + candidate.admission.should_show(), + )) + } else { + Some(UiEvent::NotificationUpdated( + candidate.notification, + candidate.admission.should_show(), + )) + } +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/generation.rs new file mode 100644 index 000000000..60dbacf3a --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/generation.rs @@ -0,0 +1,301 @@ +//! One control-owner generation from subscription through orderly cleanup + +use futures_util::StreamExt; +use tokio::sync::{mpsc, watch}; +use tracing::{info, warn}; +use unixnotis_core::{ + timed_dbus_call, ControlProxy, NotificationAddedStream, NotificationClosedStream, + NotificationUpdatedStream, PopupGateChangedStream, SnapshotInvalidatedStream, +}; +use zbus::proxy::OwnerChangedStream; + +use super::delivery::push_active_notification_event; +use super::readiness::{wait_for_gtk_runtime, PopupReadinessLease}; +use crate::dbus::backoff::{Backoff, RetryLog}; +use crate::dbus::commands::handle_command; +use crate::dbus::seed::{seed_state, PopupSeedSource, SeedError, SeedSnapshot}; +use crate::dbus::{UiCommand, UiEvent}; + +struct ControlProxySeedSource<'proxy, 'connection> { + // The proxy is borrowed for exactly one owner generation + proxy: &'proxy ControlProxy<'connection>, +} + +impl PopupSeedSource for ControlProxySeedSource<'_, '_> { + async fn seed_snapshot(&self) -> Result { + // GetState proves the owner can serve the expected control interface + let state = match timed_dbus_call(self.proxy.get_state()).await { + Ok(state) => state, + Err(error) => { + return SeedSnapshot::from_fetch_results(Err(error), Ok(Vec::new())); + } + }; + let active = timed_dbus_call(self.proxy.list_popup_candidates()).await; + SeedSnapshot::from_fetch_results(Ok(state), active) + } +} + +pub(super) enum GenerationExit { + OwnerChanged, + ConnectionLost, + Shutdown, + Retry, +} + +pub(super) struct PopupGenerationContext<'context, 'stream> { + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, + shutdown_rx: &'context mut watch::Receiver, +} + +impl<'context, 'stream> PopupGenerationContext<'context, 'stream> { + pub(super) const fn new( + owner_changes: &'context mut OwnerChangedStream<'stream>, + sender: &'context async_channel::Sender, + command_rx: &'context mut mpsc::Receiver, + subscribe_backoff: &'context mut Backoff, + subscribe_log: &'context mut RetryLog, + gtk_ready_rx: &'context mut watch::Receiver, + shutdown_rx: &'context mut watch::Receiver, + ) -> Self { + Self { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + shutdown_rx, + } + } +} + +struct GenerationStreams<'proxy> { + // Each stream belongs to the same verified control owner + added: NotificationAddedStream<'proxy>, + updated: NotificationUpdatedStream<'proxy>, + closed: NotificationClosedStream<'proxy>, + gate: PopupGateChangedStream<'proxy>, + invalidated: SnapshotInvalidatedStream<'proxy>, +} + +struct SubscribeError { + // Keep the signal name next to its original D-Bus error for useful retry logs + signal: &'static str, + source: zbus::Error, +} + +impl GenerationStreams<'_> { + async fn subscribe<'proxy>( + proxy: &'proxy ControlProxy<'_>, + ) -> Result, SubscribeError> { + // Subscribe in a fixed order so partial setup has a deterministic failure point + let added = proxy + .receive_notification_added() + .await + .map_err(|source| SubscribeError { + signal: "notification_added", + source, + })?; + // Updates share the same generation boundary as additions + let updated = proxy + .receive_notification_updated() + .await + .map_err(|source| SubscribeError { + signal: "notification_updated", + source, + })?; + // Close events remove rows only when their generation still matches + let closed = proxy + .receive_notification_closed() + .await + .map_err(|source| SubscribeError { + signal: "notification_closed", + source, + })?; + // Gate changes update popup admission without rebuilding the owner connection + let gate = proxy + .receive_popup_gate_changed() + .await + .map_err(|source| SubscribeError { + signal: "popup_gate_changed", + source, + })?; + // Invalidations request a fresh seed after a missed or coalesced change + let invalidated = proxy + .receive_snapshot_invalidated() + .await + .map_err(|source| SubscribeError { + signal: "snapshot_invalidated", + source, + })?; + Ok(GenerationStreams { + added, + updated, + closed, + gate, + invalidated, + }) + } +} + +pub(super) async fn run_owner_generation( + proxy: &ControlProxy<'_>, + owner: &str, + context: PopupGenerationContext<'_, '_>, +) -> GenerationExit { + let PopupGenerationContext { + owner_changes, + sender, + command_rx, + subscribe_backoff, + subscribe_log, + gtk_ready_rx, + shutdown_rx, + } = context; + // A failed subscription cannot safely share a partial generation + let mut streams = match GenerationStreams::subscribe(proxy).await { + Ok(streams) => streams, + Err(error) => { + subscribe_log.warn_or_debug( + &error.source, + &format!("failed to subscribe to {}", error.signal), + ); + return GenerationExit::Retry; + } + }; + + // Subscription precedes the seed so no change can fall between both phases + // Seed after subscriptions so buffered signals can repair any boundary race + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup readiness handshake or seed failed"); + return GenerationExit::Retry; + } + // Readiness is required before the daemon treats this owner as renderable + if !wait_for_gtk_runtime(gtk_ready_rx).await { + warn!("popup GTK runtime did not become ready"); + return GenerationExit::Retry; + } + // The lease is cleared on every exit path after successful publication + let mut readiness = PopupReadinessLease::new(proxy); + if let Err(error) = readiness.publish().await { + subscribe_log.warn_or_debug(&error, "failed to mark popup renderer ready"); + return GenerationExit::Retry; + } + subscribe_backoff.reset(); + subscribe_log.reset(); + info!(owner, "UnixNotis control service ready"); + + let exit = loop { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_ok() && *shutdown_rx.borrow() { + break GenerationExit::Shutdown; + } + } + command = command_rx.recv() => { + let Some(command) = command else { + break GenerationExit::Shutdown; + }; + if let Err(error) = handle_command(proxy, command).await { + warn!(?error, "popup control command failed"); + } + } + signal = streams.added.next() => { + let Some(signal) = signal else { + warn!("notification_added stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + push_active_notification_event( + proxy, + sender, + *args.id(), + *args.generation(), + true, + ).await; + } + } + signal = streams.updated.next() => { + let Some(signal) = signal else { + warn!("notification_updated stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + push_active_notification_event( + proxy, + sender, + *args.id(), + *args.generation(), + false, + ).await; + } + } + signal = streams.closed.next() => { + let Some(signal) = signal else { + warn!("notification_closed stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + let _ = sender + .send(UiEvent::NotificationClosed( + unixnotis_core::NotificationKey { + id: *args.id(), + generation: *args.generation(), + }, + *args.reason(), + )) + .await; + } + } + signal = streams.gate.next() => { + let Some(signal) = signal else { + warn!("popup_gate_changed stream ended"); + break GenerationExit::OwnerChanged; + }; + if let Ok(args) = signal.args() { + let _ = sender + .send(UiEvent::PopupGateChanged(args.gate().clone())) + .await; + } + } + signal = streams.invalidated.next() => { + let Some(_signal) = signal else { + warn!("snapshot_invalidated stream ended"); + break GenerationExit::OwnerChanged; + }; + // Re-seeding reconciles missed updates without publishing a second readiness lease + if let Err(error) = seed_state(&ControlProxySeedSource { proxy }, sender).await { + subscribe_log.warn_or_debug(&error, "popup snapshot refresh failed"); + break GenerationExit::Retry; + } + } + owner_update = owner_changes.next() => { + match owner_update { + Some(Some(new_owner)) => { + warn!(owner = new_owner.as_str(), "UnixNotis control owner changed"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + Some(None) => { + info!("UnixNotis control service disconnected"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::OwnerChanged; + } + None => { + warn!("control owner stream ended"); + let _ = sender.send(UiEvent::Disconnected).await; + break GenerationExit::ConnectionLost; + } + } + } + } + }; + + readiness.clear().await; + exit +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/mod.rs new file mode 100644 index 000000000..64334bb8b --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/mod.rs @@ -0,0 +1,44 @@ +//! Popup D-Bus runtime public surface + +mod bootstrap; +mod connection; +mod delivery; +mod generation; +mod readiness; + +use tokio::sync::{mpsc, watch}; + +use super::types::{UiCommand, UiEvent}; + +// A bounded queue prevents a stalled D-Bus connection from growing memory without limit +pub(super) const UI_COMMAND_QUEUE_CAPACITY: usize = 64; + +#[derive(Clone)] +pub struct PopupRuntime { + command_tx: mpsc::Sender, + gtk_ready_tx: watch::Sender, + shutdown_tx: watch::Sender, +} + +impl PopupRuntime { + pub fn command_sender(&self) -> mpsc::Sender { + self.command_tx.clone() + } + + pub fn mark_gtk_ready(&self) { + // Readiness is published only after the GTK state owns its complete widget tree + let _ = self.gtk_ready_tx.send(true); + } + + pub fn request_shutdown(&self) { + // Shutdown has its own non-blocking channel and cannot be starved by UI events + let _ = self.shutdown_tx.send(true); + } +} + +pub fn start_dbus_runtime(sender: async_channel::Sender) -> PopupRuntime { + bootstrap::start_runtime(sender) +} + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-popups/src/dbus/runtime/readiness.rs b/crates/unixnotis-popups/src/dbus/runtime/readiness.rs new file mode 100644 index 000000000..658a8ae51 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/readiness.rs @@ -0,0 +1,51 @@ +//! GTK readiness wait and owner-generation readiness lease + +use tokio::sync::watch; +use unixnotis_core::{timed_dbus_call, ControlProxy, INTERNAL_DBUS_CALL_TIMEOUT}; + +pub(super) struct PopupReadinessLease<'proxy, 'connection> { + proxy: &'proxy ControlProxy<'connection>, + published: bool, +} + +impl<'proxy, 'connection> PopupReadinessLease<'proxy, 'connection> { + pub(super) const fn new(proxy: &'proxy ControlProxy<'connection>) -> Self { + Self { + proxy, + published: false, + } + } + + pub(super) async fn publish(&mut self) -> zbus::Result<()> { + timed_dbus_call(self.proxy.mark_popups_ready()).await?; + self.published = true; + Ok(()) + } + + pub(super) async fn clear(&mut self) { + if !self.published { + return; + } + // No-autostart cleanup cannot revive a daemon that is already stopping + let _ = timed_dbus_call(self.proxy.mark_popups_not_ready()).await; + self.published = false; + } +} + +pub(super) async fn wait_for_gtk_runtime(gtk_ready_rx: &mut watch::Receiver) -> bool { + if *gtk_ready_rx.borrow() { + return true; + } + tokio::time::timeout(INTERNAL_DBUS_CALL_TIMEOUT, async { + loop { + if gtk_ready_rx.changed().await.is_err() { + return *gtk_ready_rx.borrow(); + } + if *gtk_ready_rx.borrow() { + return true; + } + } + }) + .await + .unwrap_or(false) +} diff --git a/crates/unixnotis-popups/src/dbus/tests/runtime.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs similarity index 63% rename from crates/unixnotis-popups/src/dbus/tests/runtime.rs rename to crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs index da0a67190..92a41727c 100644 --- a/crates/unixnotis-popups/src/dbus/tests/runtime.rs +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/bootstrap.rs @@ -1,4 +1,5 @@ -use super::{build_runtime, UI_COMMAND_QUEUE_CAPACITY}; +use super::super::bootstrap::build_runtime; +use super::super::UI_COMMAND_QUEUE_CAPACITY; #[test] fn popup_runtime_builds_with_a_bounded_command_queue() { diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs new file mode 100644 index 000000000..fea59aa52 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/connection.rs @@ -0,0 +1,11 @@ +use super::super::connection::OwnerWait; + +#[test] +fn owner_wait_states_keep_shutdown_distinct_from_recovery() { + assert!(matches!( + OwnerWait::Ready(String::from(":1.20")), + OwnerWait::Ready(_) + )); + assert!(matches!(OwnerWait::Disconnected, OwnerWait::Disconnected)); + assert!(matches!(OwnerWait::Shutdown, OwnerWait::Shutdown)); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs new file mode 100644 index 000000000..5dba8d748 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/delivery.rs @@ -0,0 +1,51 @@ +use unixnotis_core::{NotificationImage, NotificationView, PopupAdmissionView, PopupCandidate}; + +use super::super::delivery::popup_event; +use crate::dbus::UiEvent; + +fn candidate(generation: u64, admission: PopupAdmissionView) -> PopupCandidate { + PopupCandidate { + notification: NotificationView { + id: 7, + generation, + app_name: "example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: format!("generation {generation}"), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }, + admission, + } +} + +#[test] +fn old_allowed_signal_cannot_display_new_suppressed_replacement() { + let event = popup_event(vec![candidate(2, PopupAdmissionView::Rule)], 1, true); + + assert!(event.is_none()); +} + +#[test] +fn current_suppressed_replacement_is_delivered_as_hidden_update() { + let event = popup_event(vec![candidate(2, PopupAdmissionView::Dnd)], 2, false); + + assert!(matches!( + event, + Some(UiEvent::NotificationUpdated(notification, false)) + if notification.generation == 2 + )); +} + +#[test] +fn missing_candidate_after_reordered_close_emits_no_event() { + assert!(popup_event(Vec::new(), 3, false).is_none()); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs new file mode 100644 index 000000000..01c65b82f --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/generation.rs @@ -0,0 +1,15 @@ +use super::super::generation::GenerationExit; + +#[test] +fn generation_exit_keeps_owner_change_connection_loss_and_shutdown_distinct() { + assert!(matches!( + GenerationExit::OwnerChanged, + GenerationExit::OwnerChanged + )); + assert!(matches!( + GenerationExit::ConnectionLost, + GenerationExit::ConnectionLost + )); + assert!(matches!(GenerationExit::Shutdown, GenerationExit::Shutdown)); + assert!(matches!(GenerationExit::Retry, GenerationExit::Retry)); +} diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs new file mode 100644 index 000000000..898c495ad --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/mod.rs @@ -0,0 +1,5 @@ +mod bootstrap; +mod connection; +mod delivery; +mod generation; +mod readiness; diff --git a/crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs b/crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs new file mode 100644 index 000000000..0f811ad93 --- /dev/null +++ b/crates/unixnotis-popups/src/dbus/runtime/tests/readiness.rs @@ -0,0 +1,17 @@ +use super::super::readiness::wait_for_gtk_runtime; + +#[tokio::test] +async fn gtk_readiness_wait_completes_after_ui_state_is_published() { + let (ready_tx, mut ready_rx) = tokio::sync::watch::channel(false); + ready_tx.send(true).expect("publish GTK readiness"); + + assert!(wait_for_gtk_runtime(&mut ready_rx).await); +} + +#[tokio::test] +async fn gtk_readiness_wait_rejects_a_closed_unready_startup_channel() { + let (ready_tx, mut ready_rx) = tokio::sync::watch::channel(false); + drop(ready_tx); + + assert!(!wait_for_gtk_runtime(&mut ready_rx).await); +} diff --git a/crates/unixnotis-popups/src/dbus/seed.rs b/crates/unixnotis-popups/src/dbus/seed.rs index aef3bef57..d69a8d860 100644 --- a/crates/unixnotis-popups/src/dbus/seed.rs +++ b/crates/unixnotis-popups/src/dbus/seed.rs @@ -1,20 +1,12 @@ -//! Popup state seeding helpers +//! Popup state seeding for one verified control owner -use std::time::{Duration, Instant}; +use std::fmt; -use tracing::{debug, warn}; use unixnotis_core::{ControlState, NotificationView}; -use super::backoff::{Backoff, RetryLog}; use super::types::UiEvent; -// Seed retries tolerate short startup hiccups without blocking indefinitely -const SEED_RETRY_BASE_MS: u64 = 250; -const SEED_RETRY_MAX_MS: u64 = 2000; -const SEED_RETRY_BUDGET_SECS: u64 = 30; -const SEED_RETRY_LOG_INTERVAL_SECS: u64 = 10; - -// Seed failures are tracked without forcing an immediate reconnect +// Seed failures are returned to the owner state machine #[derive(Debug)] pub struct SeedError { state_error: Option, @@ -22,6 +14,29 @@ pub struct SeedError { send_error: Option, } +impl fmt::Display for SeedError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + // Each available stage preserves the bounded call or channel failure + let failures = [ + self.state_error + .as_deref() + .map(|error| format!("GetState: {error}")), + self.active_error + .as_deref() + .map(|error| format!("ListActive: {error}")), + self.send_error + .as_deref() + .map(|error| format!("UI delivery: {error}")), + ] + .into_iter() + .flatten() + .collect::>(); + write!(formatter, "{}", failures.join("; ")) + } +} + +impl std::error::Error for SeedError {} + #[derive(Debug)] pub struct SeedSnapshot { // State and active rows are sent together so reconnect seeding cannot mix old and new data @@ -50,50 +65,10 @@ pub trait PopupSeedSource { async fn seed_snapshot(&self) -> Result; } -pub async fn seed_state_with_retry(proxy: &S, sender: &async_channel::Sender) -where - S: PopupSeedSource, -{ - // Seed retries stay bounded so startup can recover without hanging forever - let deadline = seed_retry_deadline(Instant::now()); - seed_state_with_retry_until(proxy, sender, deadline).await; -} - -async fn seed_state_with_retry_until( +pub async fn seed_state( proxy: &S, sender: &async_channel::Sender, - deadline: Instant, -) where - S: PopupSeedSource, -{ - // Seed retries stay bounded so startup can recover without hanging forever - let mut backoff = Backoff::new(SEED_RETRY_BASE_MS, SEED_RETRY_MAX_MS); - let mut log = RetryLog::new(Duration::from_secs(SEED_RETRY_LOG_INTERVAL_SECS)); - - loop { - match seed_state(proxy, sender).await { - Ok(()) => return, - Err(err) => { - if Instant::now() >= deadline { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - "failed to seed popup state; giving up until reconnect" - ); - return; - } - log_seed_retry(&mut log, &err, "failed to seed popup state; retrying"); - tokio::time::sleep(backoff.next_sleep()).await; - } - } - } -} - -fn seed_retry_deadline(now: Instant) -> Instant { - now + Duration::from_secs(SEED_RETRY_BUDGET_SECS) -} - -async fn seed_state(proxy: &S, sender: &async_channel::Sender) -> Result<(), SeedError> +) -> Result<(), SeedError> where S: PopupSeedSource, { @@ -121,27 +96,6 @@ async fn send_seed_event( }) } -fn log_seed_retry(log: &mut RetryLog, err: &SeedError, message: &str) -> bool { - log.log_with( - || { - warn!( - state_error = ?err.state_error, - active_error = ?err.active_error, - send_error = ?err.send_error, - "{message}" - ); - }, - || { - debug!( - state_error = ?err.state_error, - active_error = ?err.active_error, - send_error = ?err.send_error, - "{message}" - ); - }, - ) -} - #[cfg(test)] #[path = "tests/seed.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/dbus/tests/commands.rs b/crates/unixnotis-popups/src/dbus/tests/commands.rs index 02ec43ca9..7c1b1a33d 100644 --- a/crates/unixnotis-popups/src/dbus/tests/commands.rs +++ b/crates/unixnotis-popups/src/dbus/tests/commands.rs @@ -1,4 +1,5 @@ use tokio::sync::mpsc; +use unixnotis_core::NotificationKey; use super::drain_offline_commands; use crate::dbus::UiCommand; @@ -6,11 +7,18 @@ use crate::dbus::UiCommand; #[test] fn drain_offline_commands_removes_all_queued_commands() { let (tx, mut rx) = mpsc::channel(4); - tx.try_send(UiCommand::Dismiss(10)) - .expect("dismiss command should queue"); + tx.try_send(UiCommand::Dismiss(NotificationKey { + id: 10, + generation: 12, + })) + .expect("dismiss command should queue"); tx.try_send(UiCommand::InvokeAction { - id: 11, + notification: NotificationKey { + id: 11, + generation: 13, + }, action_key: "default".to_string(), + confirmed: false, }) .expect("action command should queue"); @@ -28,3 +36,22 @@ fn drain_offline_commands_accepts_empty_queue() { assert!(rx.try_recv().is_err()); } + +#[test] +fn drain_offline_commands_reports_reply_delivery_failure() { + let (tx, mut rx) = mpsc::channel(1); + let (outcome, mut result) = tokio::sync::oneshot::channel(); + tx.try_send(UiCommand::Reply { + id: 10, + generation: 12, + text: "Keep this private".to_string(), + outcome, + }) + .expect("reply command should queue"); + + drain_offline_commands(&mut rx); + assert_eq!( + result.try_recv().expect("reply result should be ready"), + Err("notification service is unavailable".to_string()) + ); +} diff --git a/crates/unixnotis-popups/src/dbus/tests/seed.rs b/crates/unixnotis-popups/src/dbus/tests/seed.rs index 9045cd524..3566fa7d2 100644 --- a/crates/unixnotis-popups/src/dbus/tests/seed.rs +++ b/crates/unixnotis-popups/src/dbus/tests/seed.rs @@ -1,12 +1,7 @@ use async_channel::bounded; -use std::time::{Duration, Instant}; use unixnotis_core::ControlState; -use super::{ - log_seed_retry, seed_retry_deadline, seed_state, seed_state_with_retry, - seed_state_with_retry_until, send_seed_event, PopupSeedSource, SeedError, SeedSnapshot, -}; -use crate::dbus::backoff::RetryLog; +use super::{seed_state, send_seed_event, PopupSeedSource, SeedError, SeedSnapshot}; use crate::dbus::UiEvent; struct FakeSeedSource { @@ -112,57 +107,3 @@ async fn seed_state_reports_active_fetch_failure_without_sending() { assert!(err.active_error.is_some()); assert!(rx.try_recv().is_err()); } - -#[tokio::test] -async fn seed_state_with_retry_returns_after_successful_seed() { - let source = FakeSeedSource::available(); - let (tx, rx) = bounded(1); - - seed_state_with_retry(&source, &tx).await; - - let event = rx.try_recv().expect("seed event should be queued"); - assert!(matches!(event, UiEvent::Seed { .. })); -} - -#[tokio::test] -async fn seed_state_with_retry_stops_immediately_after_expired_budget() { - let source = FakeSeedSource::missing_state(); - let (tx, rx) = bounded(1); - let expired = Instant::now() - .checked_sub(Duration::from_millis(1)) - .expect("test instant should support a one-millisecond offset"); - - tokio::time::timeout( - Duration::from_millis(50), - seed_state_with_retry_until(&source, &tx, expired), - ) - .await - .expect("expired retry budget should not sleep"); - - assert!(rx.try_recv().is_err()); -} - -#[test] -fn seed_retry_deadline_adds_fixed_retry_budget() { - let now = Instant::now(); - - let deadline = seed_retry_deadline(now); - - assert_eq!( - deadline.checked_duration_since(now), - Some(Duration::from_secs(30)) - ); -} - -#[test] -fn log_seed_retry_reports_warning_then_debug_status() { - let mut log = RetryLog::new(Duration::from_mins(1)); - let err = SeedError { - state_error: Some("state unavailable".to_string()), - active_error: None, - send_error: None, - }; - - assert!(log_seed_retry(&mut log, &err, "seed retry")); - assert!(!log_seed_retry(&mut log, &err, "seed retry")); -} diff --git a/crates/unixnotis-popups/src/dbus/tests/types.rs b/crates/unixnotis-popups/src/dbus/tests/types.rs index b7a0d8544..e65b55944 100644 --- a/crates/unixnotis-popups/src/dbus/tests/types.rs +++ b/crates/unixnotis-popups/src/dbus/tests/types.rs @@ -1,8 +1,38 @@ use super::{UiCommand, UiEvent}; +use unixnotis_core::NotificationKey; #[test] -fn dismiss_command_preserves_notification_id() { - assert!(matches!(UiCommand::Dismiss(17), UiCommand::Dismiss(17))); +fn dismiss_command_preserves_notification_generation() { + let notification = NotificationKey { + id: 17, + generation: 23, + }; + + assert!(matches!( + UiCommand::Dismiss(notification), + UiCommand::Dismiss(key) if key == notification + )); +} + +#[test] +fn reply_debug_output_redacts_private_message_text() { + let (outcome, _result) = tokio::sync::oneshot::channel(); + let command = UiCommand::Reply { + id: 17, + generation: 23, + text: "private reply content".to_string(), + outcome, + }; + let rendered = format!("{command:?}"); + + assert!( + !rendered.contains("private reply content"), + "reply text must not enter debug output" + ); + assert!( + rendered.contains(""), + "debug output should make redaction explicit" + ); } #[test] diff --git a/crates/unixnotis-popups/src/dbus/types.rs b/crates/unixnotis-popups/src/dbus/types.rs index 287485c57..4ddccb4aa 100644 --- a/crates/unixnotis-popups/src/dbus/types.rs +++ b/crates/unixnotis-popups/src/dbus/types.rs @@ -1,10 +1,14 @@ //! D-Bus-facing popup event and command types -use unixnotis_core::{CloseReason, ControlState, NotificationView, PopupGateState}; +use unixnotis_core::{ + CloseReason, ControlState, NotificationKey, NotificationView, PopupGateState, +}; /// Events delivered to the GTK main loop #[derive(Debug, Clone)] pub enum UiEvent { + // Owner loss clears every popup from the previous daemon generation + Disconnected, Seed { state: ControlState, active: Vec, @@ -12,7 +16,9 @@ pub enum UiEvent { // Add and update reuse the shared lightweight NotificationView payload NotificationAdded(NotificationView, bool), NotificationUpdated(NotificationView, bool), - NotificationClosed(u32, CloseReason), + NotificationClosed(NotificationKey, CloseReason), + // Hiding a banner is local UI state and must not close the daemon record + PopupHidden(NotificationKey), // Popup gate is split out so panel-only state changes do not wake the popup UI PopupGateChanged(PopupGateState), CssReload, @@ -20,10 +26,57 @@ pub enum UiEvent { } /// Commands sent from GTK handlers to the D-Bus runtime -#[derive(Debug, Clone)] pub enum UiCommand { - Dismiss(u32), - InvokeAction { id: u32, action_key: String }, + Dismiss(NotificationKey), + InvokeAction { + notification: NotificationKey, + action_key: String, + confirmed: bool, + }, + Reply { + id: u32, + generation: u64, + text: String, + outcome: tokio::sync::oneshot::Sender>, + }, + Materialized(NotificationKey), + Visible(NotificationKey), +} + +impl std::fmt::Debug for UiCommand { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Dismiss(notification) => formatter + .debug_tuple("Dismiss") + .field(notification) + .finish(), + Self::InvokeAction { + notification, + action_key, + confirmed, + } => formatter + .debug_struct("InvokeAction") + .field("notification", notification) + .field("action_key", action_key) + .field("confirmed", confirmed) + .finish(), + Self::Reply { id, generation, .. } => formatter + .debug_struct("Reply") + .field("id", id) + .field("generation", generation) + // Reply text is private message content and must never enter debug logs + .field("text", &"") + .finish_non_exhaustive(), + Self::Materialized(notification) => formatter + .debug_tuple("Materialized") + .field(notification) + .finish(), + Self::Visible(notification) => formatter + .debug_tuple("Visible") + .field(notification) + .finish(), + } + } } #[cfg(test)] diff --git a/crates/unixnotis-popups/src/ui/config_reload.rs b/crates/unixnotis-popups/src/ui/config_reload.rs index 4350e8098..2b4559632 100644 --- a/crates/unixnotis-popups/src/ui/config_reload.rs +++ b/crates/unixnotis-popups/src/ui/config_reload.rs @@ -20,6 +20,7 @@ const fn config_error_kind(error: &ConfigError) -> &'static str { match error { ConfigError::ReadFailed(_) => "read", ConfigError::ParseFailed(_) => "parse", + ConfigError::TooLarge { .. } => "too-large", ConfigError::MissingHome => "missing-home", } } diff --git a/crates/unixnotis-popups/src/ui/entry/activation.rs b/crates/unixnotis-popups/src/ui/entry/activation.rs new file mode 100644 index 000000000..de3c6520c --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/activation.rs @@ -0,0 +1,45 @@ +//! Whole-card default action activation and interactive-child isolation + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; +use unixnotis_ui::presentation::default_activation::{ + connect_default_activation, mark_interactive as shared_mark_interactive, DefaultActionTarget, +}; + +use super::commands::try_send_command; +use super::presentation::PopupEntryViewModel; +use crate::dbus::UiCommand; + +pub(super) fn mark_interactive>(widget: &W) { + shared_mark_interactive(widget); +} + +pub(super) fn connect_default_action( + root: >k::Box, + notification: NotificationKey, + view: &PopupEntryViewModel, + command_tx: &tokio::sync::mpsc::Sender, +) { + let Some(action_key) = view.default_action_key.clone() else { + return; + }; + let click_tx = command_tx.clone(); + let binding = connect_default_activation(root, move |notification, action_key| { + try_send_command( + &click_tx, + UiCommand::InvokeAction { + notification, + action_key, + confirmed: false, + }, + ); + }); + binding.set_target(Some(DefaultActionTarget { + notification, + action_key, + })); +} + +#[cfg(test)] +#[path = "tests/activation.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/build.rs b/crates/unixnotis-popups/src/ui/entry/build.rs index 193f51d01..822fad2b7 100644 --- a/crates/unixnotis-popups/src/ui/entry/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/build.rs @@ -1,34 +1,53 @@ -//! Popup entry construction and UI action wiring +//! Popup entry lifecycle and high-level card assembly + +use std::cell::Cell; +use std::rc::Rc; -use gtk::pango::{EllipsizeMode, WrapMode}; use gtk::prelude::*; use gtk::Align; -use unixnotis_core::{hooks, NotificationView, Urgency}; +use unixnotis_core::{hooks, NotificationKey, NotificationView}; +use unixnotis_ui::CutCorner; use super::super::window::refresh_popup_input_region; use super::super::UiState; -use super::commands::try_send_command; -use super::labels::{ - clamp_label_text, has_visible_text, update_optional_label, POPUP_ACTION_LABEL_MAX_CHARS, - POPUP_APP_MAX_CHARS, POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, +use super::activation::connect_default_action; +use super::builders::{ + build_action_row, build_close_button, build_inline_reply, build_popup_content, }; +use super::commands::try_send_command; +use super::presentation::PopupEntryViewModel; +use super::PopupVisibilityBinding; use crate::dbus::UiCommand; pub(in crate::ui) struct PopupEntry { // Keep the last payload so seed reconcile can detect real content changes pub(in crate::ui) notification: NotificationView, + // Rows built before an icon-source change must be rebuilt on the next update + pub(in crate::ui) icon_source_generation: u64, // Hidden backlog rows stay lightweight until they enter the visible slice pub(in crate::ui) revealer: Option, pub(in crate::ui) root: Option, + pub(in crate::ui) visibility: Option, + // The display timer hides only this popup row and never closes the daemon record + pub(in crate::ui) hide_timer: Option, + // GLib has already removed a source when its one-shot callback starts + pub(in crate::ui) hide_timer_fired: Option>>, } impl PopupEntry { - pub(in crate::ui) const fn queued(notification: NotificationView) -> Self { + pub(in crate::ui) const fn queued( + notification: NotificationView, + icon_source_generation: u64, + ) -> Self { // Backlog rows start as plain data and only grow GTK nodes when they become visible Self { notification, + icon_source_generation, revealer: None, root: None, + visibility: None, + hide_timer: None, + hide_timer_fired: None, } } @@ -36,10 +55,19 @@ impl PopupEntry { // Both widgets must exist before stack operations can touch this row safely self.revealer.is_some() && self.root.is_some() } + pub(in crate::ui) fn cancel_hide_timer(&mut self) { + let fired = self + .hide_timer_fired + .take() + .is_some_and(|state| state.get()); + if let Some(timer) = self.hide_timer.take() { + if !fired { + timer.remove(); + } + } + } } -const MAX_POPUP_ACTIONS: usize = 3; - impl UiState { pub(in crate::ui) fn build_popup_entry( &mut self, @@ -47,230 +75,153 @@ impl UiState { ) -> PopupEntry { // Build the GTK row first so the revealer always wraps a ready child let root = self.build_popup_root(notification); - let revealer = self.build_popup_revealer(&root); + let (revealer, visibility) = self.build_popup_revealer(&root, notification.key()); PopupEntry { // Store the payload used to build this row so later seeds can compare safely notification: notification.clone(), + icon_source_generation: self.icon_source_generation, revealer: Some(revealer), root: Some(root), + visibility: Some(visibility), + hide_timer: None, + hide_timer_fired: None, } } pub(in crate::ui) fn build_popup_root(&mut self, notification: &NotificationView) -> gtk::Box { - // One vertical box owns the whole popup card layout - let root = gtk::Box::new(gtk::Orientation::Vertical, 6); - root.add_css_class("unixnotis-popup-card"); - // Use the live stack width when a row is built or rebuilt - let popup_width = self - .popup_stack - .width() - .max(self.popup_stack.width_request()) - .max(1); - root.set_size_request(popup_width, -1); - root.set_halign(Align::Fill); - root.set_hexpand(false); - // New roots stay hidden until visibility logic decides otherwise - root.set_visible(false); - if notification.urgency == Urgency::Critical as u8 { - // Critical rows keep the shared urgency class at the root - root.add_css_class(hooks::shared_state::CRITICAL); + let view = PopupEntryViewModel::for_notification(notification); + let root = build_card_root(&view); + let close = build_close_button(); + let rendered = build_popup_content(self, notification, &view); + let content = gtk::Box::new(gtk::Orientation::Vertical, 6); + content.set_hexpand(true); + + // Builder results feed stable state classes used by user themes + set_class_state(&root, hooks::popup_card::HAS_ICON, rendered.has_icon); + set_class_state(&root, hooks::popup_card::NO_ICON, !rendered.has_icon); + set_class_state(&root, hooks::popup_card::HAS_IMAGE, rendered.has_image); + content.append(&rendered.widget); + + if let Some(reply) = build_inline_reply(notification, &view, &self.command_tx) { + content.append(&reply); } - // State classes make popup theming less dependent on child selector tricks - set_class_state( - &root, - hooks::popup_card::HAS_SUMMARY, - has_visible_text(¬ification.summary), - ); - set_class_state( - &root, - hooks::popup_card::HAS_BODY, - has_visible_text(¬ification.body), - ); - set_class_state( - &root, - hooks::popup_card::HAS_ACTIONS, - !notification.actions.is_empty(), - ); - - // Header keeps icon, app name, and close in one stable row - let header = gtk::Box::new(gtk::Orientation::Horizontal, 6); - header.add_css_class("unixnotis-popup-header-row"); - if let Some(icon) = self.build_image_widget(notification) { - // Icon presence is exposed as a state class for theme rules - set_class_state(&root, hooks::popup_card::HAS_ICON, true); - icon.set_valign(Align::Center); - icon.set_halign(Align::Start); - icon.add_css_class("unixnotis-popup-icon"); - header.append(&icon); - } else { - // Missing icons also get a root class so themes can rebalance spacing - set_class_state(&root, hooks::popup_card::NO_ICON, true); - } - // App name stays in the header instead of repeating the full desktop entry name - let app = gtk::Label::new(Some(¬ification.app_name)); - app.set_xalign(0.0); - app.set_single_line_mode(true); - app.set_ellipsize(EllipsizeMode::End); - app.set_max_width_chars(POPUP_APP_MAX_CHARS as i32); - app.set_text(clamp_label_text(¬ification.app_name, POPUP_APP_MAX_CHARS).as_ref()); - app.add_css_class("unixnotis-popup-header"); - - let close = gtk::Button::from_icon_name("window-close-symbolic"); - close.add_css_class("unixnotis-popup-close"); - close.set_halign(Align::End); - - // Close stays on the right edge even when the title text shrinks - header.append(&app); - header.append(&build_popup_header_spacer()); - header.append(&close); - - // Summary stays short and collapses when the payload has no title - let summary = gtk::Label::new(Some(¬ification.summary)); - summary.set_xalign(0.0); - summary.set_wrap(true); - summary.set_wrap_mode(WrapMode::WordChar); - summary.set_ellipsize(EllipsizeMode::End); - summary.set_lines(3); - summary.set_max_width_chars(POPUP_SUMMARY_MAX_CHARS as i32); - summary.add_css_class("unixnotis-popup-summary"); - update_optional_label(&summary, ¬ification.summary, POPUP_SUMMARY_MAX_CHARS); - - // Body follows the same bounded layout rules as the summary - let body = gtk::Label::new(None); - body.set_xalign(0.0); - body.set_wrap(true); - body.set_wrap_mode(WrapMode::WordChar); - body.set_ellipsize(EllipsizeMode::End); - body.set_lines(6); - body.set_max_width_chars(POPUP_BODY_MAX_CHARS as i32); - body.add_css_class("unixnotis-popup-body"); - update_optional_label(&body, ¬ification.body, POPUP_BODY_MAX_CHARS); - - // The root order is stable so CSS can assume header, summary, body, actions - root.append(&header); - root.append(&summary); - root.append(&body); - - // Action buttons are only built when the payload exposes actions - if !notification.actions.is_empty() { - let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); - actions.add_css_class("unixnotis-popup-actions"); - for action in notification.actions.iter().take(MAX_POPUP_ACTIONS) { - // Button labels are clamped before GTK measures them - let button = gtk::Button::with_label( - clamp_label_text(&action.label, POPUP_ACTION_LABEL_MAX_CHARS).as_ref(), - ); - button.add_css_class("unixnotis-popup-action"); - let action_key = action.key.clone(); - let tx = self.command_tx.clone(); - let id = notification.id; - button.connect_clicked(move |_| { - // Click handlers only enqueue the DBus command - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - actions.append(&button); - } - root.append(&actions); - } - - // Close still targets the notification id even when the row is rebuilt - let id = notification.id; - let command_tx_close = self.command_tx.clone(); - close.connect_clicked(move |_| { - try_send_command(&command_tx_close, UiCommand::Dismiss(id)); - }); - - // Default action still fires from the rebuilt card body - let default_action = notification - .actions - .iter() - .find(|action| action.key == "default") - .map(|action| action.key.clone()); - if let Some(action_key) = default_action { - let gesture = gtk::GestureClick::new(); - // Default card actions only belong to plain card clicks - // Real buttons should keep their own handlers without also triggering the card action - gesture.set_button(1); - let root_weak = root.downgrade(); - let tx = self.command_tx.clone(); - gesture.connect_released(move |_, _, x, y| { - let Some(root) = root_weak.upgrade() else { - return; - }; - if picked_widget_blocks_default_action(root.pick(x, y, gtk::PickFlags::DEFAULT)) { - return; - } - // Card clicks mirror the default action button behavior - try_send_command( - &tx, - UiCommand::InvokeAction { - id, - action_key: action_key.clone(), - }, - ); - }); - root.add_controller(gesture); + if let Some(actions) = build_action_row(&self.command_tx, notification.key(), &view) { + content.append(&actions); } + // The close control floats above content and never consumes metadata width + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&content)); + close.set_halign(gtk::Align::End); + close.set_valign(gtk::Align::Start); + close.set_margin_top(2); + close.set_margin_end(2); + overlay.add_overlay(&close); + root.append(&overlay); + + connect_close_action(&close, notification.key(), &self.command_tx); + connect_default_action(&root, notification.key(), &view, &self.command_tx); root } - fn build_popup_revealer(&self, root: >k::Box) -> gtk::Revealer { + fn build_popup_revealer( + &self, + root: >k::Box, + key: NotificationKey, + ) -> (gtk::Revealer, PopupVisibilityBinding) { // Revealers keep entry animations out of the popup list bookkeeping let revealer = gtk::Revealer::new(); revealer.add_css_class("unixnotis-popup-revealer"); - revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); - revealer.set_transition_duration(200); - revealer.set_child(Some(root)); + if self.config.panel.reduced_motion { + // Reduced motion keeps state changes immediate without hiding content + revealer.set_transition_type(gtk::RevealerTransitionType::None); + revealer.set_transition_duration(0); + } else { + // A short fade avoids geometry-heavy card animations + revealer.set_transition_type(gtk::RevealerTransitionType::Crossfade); + revealer.set_transition_duration(200); + } + if self.config.theme.notification_corners.is_active() { + // Explicit diagonal cuts still use the shared clipping primitive + let plate = CutCorner::new(root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } else { + // The default card relies on GTK CSS rounding without a custom snapshot wrapper + revealer.set_child(Some(root)); + } // Visibility is driven centrally so only rows inside max_visible animate in revealer.set_reveal_child(false); let popup_window = self.popup_window.clone(); let popup_stack = self.popup_stack.clone(); let popup_input_region = self.popup_input_region.clone(); - revealer.connect_notify_local(Some("child-revealed"), move |_, _| { - // The first popup can finish revealing after the only earlier refresh ran - // Refresh again here so action rows do not inherit an old empty region - refresh_popup_input_region(&popup_window, &popup_stack, &popup_input_region); + let command_tx = self.command_tx.clone(); + let visibility = PopupVisibilityBinding::new(key); + revealer.connect_notify_local(Some("child-revealed"), { + let reveal_window = popup_window.clone(); + let reveal_command_tx = command_tx.clone(); + let reveal_visibility = visibility.clone(); + move |revealer, _| { + // Refresh after reveal so actions never inherit an earlier empty input region + refresh_popup_input_region(&reveal_window, &popup_stack, &popup_input_region); + reveal_visibility.report_if_visible(revealer, &reveal_window, &reveal_command_tx); + } + }); + revealer.connect_map({ + let visibility = visibility.clone(); + move |revealer| { + // Reduced-motion rows may finish revealing before their surface maps + visibility.report_if_visible(revealer, &popup_window, &command_tx); + } }); - revealer + (revealer, visibility) } } -fn build_popup_header_spacer() -> gtk::Box { - let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 1); - // Spacer width takes up the slack so the trailing button does not drift - // Plain halign on the button is not enough inside a horizontal box - spacer.set_hexpand(popup_header_spacer_expands()); - spacer -} - -pub(super) const fn popup_header_spacer_expands() -> bool { - // Keep the alignment rule easy to test without constructing full GTK rows - true -} - -fn picked_widget_blocks_default_action(mut widget: Option) -> bool { - while let Some(current) = widget { - if widget_type_blocks_default_action(current.type_()) { - return true; - } - widget = current.parent(); +fn build_card_root(view: &PopupEntryViewModel) -> gtk::Box { + let root = gtk::Box::new(gtk::Orientation::Vertical, 6); + root.add_css_class("unixnotis-popup-card"); + root.add_css_class(view.kind.css_class()); + root.add_css_class(view.trust.level.css_class()); + + // The stack owns the outer width and its CSS padding + // Cards fill the remaining allocation without requesting the outer width again + root.set_halign(Align::Fill); + root.set_hexpand(true); + // New roots stay hidden until visibility logic decides otherwise + root.set_visible(false); + + if view.critical { + root.add_css_class(hooks::shared_state::CRITICAL); } - false + set_class_state( + &root, + hooks::popup_card::HAS_SUMMARY, + !view.title.trim().is_empty(), + ); + set_class_state(&root, hooks::popup_card::HAS_BODY, view.body.is_some()); + set_class_state( + &root, + hooks::popup_card::HAS_ACTIONS, + view.trust.reply == super::presentation::ReplyPresentation::Available + || !view.primary_actions.is_empty() + || !view.overflow_actions.is_empty(), + ); + root } -fn widget_type_blocks_default_action(widget_type: gtk::glib::Type) -> bool { - // Button clicks should always stay owned by the button widget subtree - widget_type.is_a(gtk::Button::static_type()) +fn connect_close_action( + close: >k::Button, + notification: unixnotis_core::NotificationKey, + command_tx: &tokio::sync::mpsc::Sender, +) { + let command_tx = command_tx.clone(); + close.connect_clicked(move |_| { + // Dismissal remains independent from application-owned action policy + try_send_command(&command_tx, UiCommand::Dismiss(notification)); + }); } fn set_class_state(root: >k::Box, class_name: &str, enabled: bool) { diff --git a/crates/unixnotis-popups/src/ui/entry/builders/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/common.rs new file mode 100644 index 000000000..2ab555220 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/common.rs @@ -0,0 +1,408 @@ +//! Shared small primitives used by every popup kind + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Instant; + +use gtk::glib; +use gtk::pango::{EllipsizeMode, WrapMode}; +use gtk::prelude::*; +use gtk::Align; +use unixnotis_core::{hooks, NotificationView}; +use unixnotis_ui::presentation::{ + action_activation, build_semantic_badge, ActionActivation, SenderVisualPresentation, +}; + +use super::super::commands::try_send_command; +use super::super::presentation::{PopupEntryViewModel, PopupTrustPresentation, ReplyPresentation}; +use crate::dbus::UiCommand; +use crate::ui::entry::activation::mark_interactive; +use crate::ui::UiState; + +// Clicks inside this window after arming are treated as accidental double-taps +const MIN_CONFIRM_INTERVAL_MS: u64 = 350; +// Armed state expires after this long and the button goes back to normal +const MAX_CONFIRM_TIMEOUT_MS: u64 = 5000; + +pub(super) struct VisualSlot { + pub(super) widget: gtk::Box, +} + +pub(super) fn build_application_identity( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + size: i32, +) -> VisualSlot { + // Application branding and trust are separate presentation decisions + let icon_size = size.clamp(18, 22); + let icon = if view.trust.level.semantic_badge_is_authoritative() { + // A warning state never falls back to caller-provided branding + build_semantic_badge(view.badge, icon_size) + } else { + state + .build_app_icon_widget(notification, icon_size) + .or_else(|| build_semantic_badge(view.badge, icon_size)) + } + .unwrap_or_else(|| gtk::Image::from_icon_name("application-x-executable-symbolic")); + build_identity_slot(icon, view, size, icon_size, true) +} + +pub(super) fn build_conversation_avatar( + notification: &NotificationView, + view: &PopupEntryViewModel, + size: i32, +) -> Option { + // Bounded in-memory pixels are a conversation visual, even when trust is unresolved + if view.visuals.sender != SenderVisualPresentation::ConversationAvatar { + return None; + } + + let icon = UiState::build_conversation_avatar_widget(notification, size)?; + Some(build_identity_slot(icon, view, size, size, false)) +} + +fn build_identity_slot( + icon: gtk::Image, + view: &PopupEntryViewModel, + size: i32, + icon_size: i32, + is_application: bool, +) -> VisualSlot { + icon.set_pixel_size(icon_size); + icon.set_size_request(icon_size, icon_size); + icon.set_valign(Align::Center); + icon.set_halign(Align::Center); + + // The child can expand inside its fixed visual allocation for centering + icon.set_hexpand(true); + icon.set_vexpand(true); + icon.set_accessible_role(gtk::AccessibleRole::Presentation); + if is_application { + icon.add_css_class("unixnotis-popup-icon"); + } + let slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); + slot.set_size_request(size, size); + slot.set_halign(Align::Start); + slot.set_valign(Align::Start); + + // Stop child expansion from making the adjacent message column drift + slot.set_hexpand(false); + slot.set_vexpand(false); + + if is_application { + // A compact header icon must not become a second message card + slot.add_css_class("unixnotis-popup-application-icon-slot"); + slot.add_css_class(view.trust.level.css_class()); + } else { + slot.add_css_class("unixnotis-popup-conversation-avatar-slot"); + } + slot.append(&icon); + VisualSlot { widget: slot } +} + +pub(super) struct IdentityHeader { + pub(super) identity: gtk::Box, + pub(super) trailing: gtk::Box, +} + +pub(super) fn build_identity_header(view: &PopupEntryViewModel) -> IdentityHeader { + let identity = gtk::Box::new(gtk::Orientation::Vertical, 1); + identity.add_css_class("unixnotis-popup-identity-row"); + identity.set_hexpand(true); + identity.set_halign(Align::Fill); + + // The application name and trust chip share one compact header line + let identity_top = gtk::Box::new(gtk::Orientation::Horizontal, 6); + identity_top.set_hexpand(true); + + let app = gtk::Label::new(Some(&view.app_label)); + app.set_xalign(0.0); + app.set_hexpand(true); + app.set_halign(Align::Fill); + app.set_single_line_mode(true); + app.set_ellipsize(EllipsizeMode::End); + app.add_css_class("unixnotis-popup-app-name"); + if let Some(details) = view.trust.details_label.as_deref() { + // Raw paths remain available on demand without entering normal card content + app.set_tooltip_text(Some(details)); + } + identity_top.append(&app); + + if let Some(chip) = build_trust_chip(&view.trust) { + identity_top.append(&chip); + } + identity.append(&identity_top); + + // Attribution context belongs under the application header, not the message body + if let Some(claim) = build_secondary_claim(view) { + identity.append(&claim); + } + + let trailing = gtk::Box::new(gtk::Orientation::Vertical, 2); + trailing.add_css_class("unixnotis-popup-trailing"); + trailing.set_halign(Align::End); + trailing.set_valign(Align::Start); + // Reserve one measured lane for time and urgency beside the overlaid close control + trailing.set_size_request(42, -1); + trailing.set_margin_end(30); + + let time = gtk::Label::new(Some(&view.timestamp_label)); + time.set_single_line_mode(true); + time.set_halign(Align::End); + time.add_css_class("unixnotis-popup-time"); + trailing.append(&time); + + let urgency = build_urgency_badge(view.critical); + urgency.set_halign(Align::End); + trailing.append(&urgency); + IdentityHeader { identity, trailing } +} + +pub(super) fn build_title_label(view: &PopupEntryViewModel) -> Option { + if view.title.trim().is_empty() { + return None; + } + + let title = gtk::Label::new(Some(&view.title)); + title.set_xalign(0.0); + title.set_wrap(true); + title.set_wrap_mode(WrapMode::WordChar); + title.set_ellipsize(EllipsizeMode::End); + title.set_lines(2); + title.add_css_class("unixnotis-popup-summary"); + Some(title) +} + +pub(super) fn build_body_label(view: &PopupEntryViewModel, line_limit: i32) -> Option { + let body_text = view.body.as_deref()?; + let body = gtk::Label::new(Some(body_text)); + body.set_xalign(0.0); + body.set_wrap(true); + body.set_wrap_mode(WrapMode::WordChar); + body.set_ellipsize(EllipsizeMode::End); + body.set_lines(line_limit); + body.add_css_class("unixnotis-popup-body"); + Some(body) +} + +pub(super) fn build_reply_note(view: &PopupEntryViewModel) -> Option { + if view.trust.reply != ReplyPresentation::Unavailable { + return None; + } + + let note = gtk::Label::new(Some("Reply unavailable")); + note.set_xalign(0.0); + note.add_css_class("unixnotis-popup-footer-note"); + Some(note) +} + +pub(super) fn build_secondary_claim(view: &PopupEntryViewModel) -> Option { + let text = view.secondary_claim.as_deref()?; + let label = gtk::Label::new(Some(text)); + label.set_xalign(0.0); + // Provenance context stays one quiet metadata line instead of changing card height + label.set_single_line_mode(true); + label.set_ellipsize(EllipsizeMode::End); + label.add_css_class("unixnotis-popup-secondary-claim"); + Some(label) +} + +pub(in crate::ui::entry) fn build_close_button() -> gtk::Button { + let close = gtk::Button::from_icon_name("window-close-symbolic"); + close.add_css_class("unixnotis-popup-close"); + close.set_halign(Align::End); + close.set_tooltip_text(Some("Dismiss notification")); + mark_interactive(&close); + close +} + +pub(in crate::ui::entry) fn build_action_row( + command_tx: &tokio::sync::mpsc::Sender, + notification: unixnotis_core::NotificationKey, + view: &PopupEntryViewModel, +) -> Option { + if view.primary_actions.is_empty() && view.overflow_actions.is_empty() { + return None; + } + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6); + actions.add_css_class("unixnotis-popup-actions"); + for action in &view.primary_actions { + actions.append(&build_action_button(command_tx, notification, action, None)); + } + if !view.overflow_actions.is_empty() { + actions.append(&build_overflow_menu(command_tx, notification, view)); + } + Some(actions) +} + +fn build_action_button( + command_tx: &tokio::sync::mpsc::Sender, + notification: unixnotis_core::NotificationKey, + action: &super::super::presentation::ActionViewModel, + popover: Option<>k::Popover>, +) -> gtk::Button { + let button = gtk::Button::with_label(&action.label); + button.add_css_class("unixnotis-popup-action"); + mark_interactive(&button); + let action_key = action.key.clone(); + let original_label = action.label.clone(); + let policy = action.policy; + let tx = command_tx.clone(); + let popover = popover.cloned(); + // Single shared state: None = not armed, Some(instant) = armed at that time + // Using Rc> so both the click handler and the timeout callback read and + // write the same cell. The timeout captures `now` at arm time and only resets + // the button if that exact timestamp is still current — this prevents a stale + // timer from the first cycle from wiping the visual state of a newer cycle. + let armed_at = Rc::new(Cell::new(None::)); + button.connect_clicked(move |button| { + let confirmed = match action_activation(policy, armed_at.get().is_some()) { + ActionActivation::Denied => return, + ActionActivation::ArmConfirmation => { + let now = Instant::now(); + armed_at.set(Some(now)); + let confirmation_label = format!("Confirm {original_label}"); + button.set_label(&confirmation_label); + button.set_tooltip_text(Some("Activate again to confirm")); + button.update_property(&[gtk::accessible::Property::Label(&confirmation_label)]); + // Clean up the armed state after a timeout so the button does not stay in + // confirm mode forever + let expire_button = button.clone(); + let expire_label = original_label.clone(); + let expire_armed_at = Rc::clone(&armed_at); + glib::timeout_add_local_once( + std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS), + move || { + if expire_armed_at.get() == Some(now) { + expire_armed_at.set(None); + expire_button.set_label(&expire_label); + expire_button.set_tooltip_text(None); + expire_button.update_property(&[gtk::accessible::Property::Label( + &expire_label, + )]); + } + }, + ); + return; + } + ActionActivation::Invoke { confirmed } => { + // Only check timing when the action was actually confirmed + // Allow-policy actions skip this path entirely + if confirmed { + let elapsed = armed_at.get().map(|t| t.elapsed()); + match elapsed { + // No arm time recorded means something went wrong + // Clean up instead of dispatching + None => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); + return; + } + // Click came too fast after arming + // Probably an accidental double-tap, stay armed so the next click + // can still go through + Some(d) + if d < std::time::Duration::from_millis(MIN_CONFIRM_INTERVAL_MS) => + { + return; + } + // Confirmation took too long + // Reset the button and make the person re-arm + Some(d) if d > std::time::Duration::from_millis(MAX_CONFIRM_TIMEOUT_MS) => { + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label( + &original_label, + )]); + return; + } + // Right amount of time passed, dispatch the action + _ => {} + } + } + confirmed + } + }; + // Reset everything after a successful dispatch + // The next click will start a fresh confirmation cycle instead of invoking again + armed_at.set(None); + button.set_label(&original_label); + button.set_tooltip_text(None); + button.update_property(&[gtk::accessible::Property::Label(&original_label)]); + // Menus close only after an action passes its confirmation policy + if let Some(popover) = &popover { + popover.popdown(); + } + try_send_command( + &tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.clone(), + confirmed, + }, + ); + }); + button +} + +fn build_overflow_menu( + command_tx: &tokio::sync::mpsc::Sender, + notification: unixnotis_core::NotificationKey, + view: &PopupEntryViewModel, +) -> gtk::MenuButton { + let menu = gtk::MenuButton::new(); + menu.set_icon_name("view-more-symbolic"); + menu.set_tooltip_text(Some("More actions")); + menu.add_css_class("unixnotis-popup-action-overflow"); + mark_interactive(&menu); + + let popover = gtk::Popover::new(); + let list = gtk::Box::new(gtk::Orientation::Vertical, 4); + list.add_css_class("unixnotis-popup-action-overflow-list"); + for action in &view.overflow_actions { + list.append(&build_action_button( + command_tx, + notification, + action, + Some(&popover), + )); + } + popover.set_child(Some(&list)); + menu.set_popover(Some(&popover)); + menu +} + +fn build_urgency_badge(is_critical: bool) -> gtk::Label { + let badge = gtk::Label::new(Some("!")); + // The stable node keeps header spacing predictable across urgency changes + badge.add_css_class(hooks::urgency::BADGE); + badge.set_single_line_mode(true); + badge.set_tooltip_text(Some("Critical notification")); + badge.update_property(&[gtk::accessible::Property::Label("Critical notification")]); + badge.set_visible(is_critical); + badge +} + +fn build_trust_chip(trust: &PopupTrustPresentation) -> Option { + let label = trust.short_label.as_deref()?; + let chip = gtk::Label::new(Some(label)); + chip.set_single_line_mode(true); + chip.add_css_class("unixnotis-popup-trust-chip"); + chip.add_css_class(trust.level.css_class()); + if let Some(details) = trust.details_label.as_deref() { + // Detailed evidence remains one hover or keyboard query away + chip.set_tooltip_text(Some(details)); + } + Some(chip) +} + +#[cfg(test)] +#[path = "tests/common.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/communication.rs b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs new file mode 100644 index 000000000..a67c9e1d6 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/communication.rs @@ -0,0 +1,25 @@ +//! Communication popup with quiet application identity and message-first hierarchy + +use unixnotis_core::NotificationView; + +use super::layout::{build_popup_grid, PopupLayout}; +use super::RenderedPopup; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +pub(super) fn build_communication_popup( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, +) -> RenderedPopup { + build_popup_grid( + state, + notification, + view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: true, + }, + ) +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs new file mode 100644 index 000000000..d5759fcb4 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/layout.rs @@ -0,0 +1,105 @@ +//! Shared three-column popup composition + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::common::{ + build_application_identity, build_body_label, build_conversation_avatar, build_identity_header, + build_reply_note, build_title_label, +}; +use super::{append_thumbnail, RenderedPopup}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +const POPUP_APPLICATION_ICON_SIZE: i32 = 24; +const POPUP_CONVERSATION_AVATAR_SIZE: i32 = 46; + +pub(super) struct PopupLayout { + pub(super) css_class: &'static str, + pub(super) body_lines: i32, + pub(super) show_reply_note: bool, +} + +pub(super) fn build_popup_grid( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, + layout: PopupLayout, +) -> RenderedPopup { + let grid = gtk::Grid::new(); + grid.add_css_class(layout.css_class); + grid.add_css_class("unixnotis-popup-content-grid"); + grid.set_column_spacing(8); + grid.set_row_spacing(4); + grid.set_hexpand(true); + grid.set_accessible_role(gtk::AccessibleRole::Group); + let accessible_label = popup_accessible_label(view); + grid.update_property(&[gtk::accessible::Property::Label(&accessible_label)]); + + // The header row owns application identity and trust context independently + let header_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + header_row.add_css_class("unixnotis-popup-header-row"); + header_row.set_hexpand(true); + let application_identity = + build_application_identity(state, notification, view, POPUP_APPLICATION_ICON_SIZE); + let header = build_identity_header(view); + header_row.append(&application_identity.widget); + header_row.append(&header.identity); + header_row.append(&header.trailing); + grid.attach(&header_row, 0, 0, 3, 1); + + // Message content is a separate row so the avatar never sizes the app header + let message = gtk::Box::new(gtk::Orientation::Vertical, 2); + message.add_css_class("unixnotis-popup-message"); + message.set_hexpand(true); + if let Some(title) = build_title_label(view) { + message.append(&title); + } + if let Some(body) = build_body_label(view, layout.body_lines) { + message.append(&body); + } + let has_image = append_thumbnail(notification, view, &message); + if layout.show_reply_note { + if let Some(note) = build_reply_note(view) { + message.append(¬e); + } + } + let message_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + message_row.add_css_class("unixnotis-popup-message-row"); + message_row.set_hexpand(true); + // Conversation pixels belong beside the message, not below its body as a thumbnail + if let Some(conversation_avatar) = + build_conversation_avatar(notification, view, POPUP_CONVERSATION_AVATAR_SIZE) + { + message_row.append(&conversation_avatar.widget); + } + message_row.append(&message); + grid.attach(&message_row, 0, 1, 3, 1); + + RenderedPopup { + widget: grid, + has_icon: true, + has_image, + } +} + +fn popup_accessible_label(view: &PopupEntryViewModel) -> String { + let mut parts = vec![view.app_label.trim()]; + if let Some(trust) = view.trust.short_label.as_deref() { + parts.push(trust.trim()); + } + if let Some(claim) = view.secondary_claim.as_deref() { + parts.push(claim.trim()); + } + if !view.title.trim().is_empty() { + parts.push(view.title.trim()); + } + if let Some(body) = view.body.as_deref().filter(|body| !body.trim().is_empty()) { + parts.push(body.trim()); + } + parts.join(". ") +} + +#[cfg(test)] +#[path = "tests/layout.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs new file mode 100644 index 000000000..ba4f69338 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/mod.rs @@ -0,0 +1,70 @@ +//! Kind-specific GTK popup builders + +mod common; +mod communication; +mod layout; +mod reply; +mod utility; + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::presentation::{PopupEntryViewModel, PopupKind}; +use crate::ui::UiState; + +pub(super) use common::{build_action_row, build_close_button}; +pub(in crate::ui::entry) use reply::build_inline_reply; + +/// Result of building one kind-specific card body +pub(super) struct RenderedPopup { + pub(super) widget: gtk::Grid, + pub(super) has_icon: bool, + pub(super) has_image: bool, +} + +pub(super) fn build_popup_content( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, +) -> RenderedPopup { + // Each layout owns its structure so future changes do not grow one conditional builder + match view.kind { + PopupKind::Communication => { + communication::build_communication_popup(state, notification, view) + } + PopupKind::Utility | PopupKind::Media => { + utility::build_utility_popup(state, notification, view) + } + } +} + +pub(super) fn append_thumbnail( + notification: &NotificationView, + view: &PopupEntryViewModel, + content: >k::Box, +) -> bool { + if !should_append_thumbnail(view) { + return false; + } + let Some(image) = UiState::build_content_image_widget(notification) else { + return false; + }; + if image.paintable().is_none() { + return false; + } + + // Content pixels remain in the dedicated message-media lane + image.set_halign(gtk::Align::Start); + image.add_css_class("unixnotis-popup-content-image"); + content.append(&image); + true +} + +const fn should_append_thumbnail(view: &PopupEntryViewModel) -> bool { + // Only genuine message/media content belongs below the body + matches!(view.thumbnail, super::presentation::ThumbnailKind::Content) +} + +#[cfg(test)] +#[path = "tests/thumbnail.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs new file mode 100644 index 000000000..7f8354bb5 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/lifecycle.rs @@ -0,0 +1,101 @@ +//! Reply submission guards shared by button and keyboard activation + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; + +use crate::dbus::UiCommand; +use crate::ui::entry::commands::try_send_command; + +pub(super) const MAX_REPLY_BYTES: usize = 4 * 1024; +pub(super) const MAX_REPLY_CHARS: i32 = 4 * 1024; + +pub(super) struct ReplySubmission<'widget> { + pub(super) id: u32, + pub(super) generation: u64, + pub(super) entry: &'widget gtk::Entry, + pub(super) revealer: &'widget gtk::Revealer, + pub(super) send: &'widget gtk::Button, + pub(super) error: &'widget gtk::Label, + pub(super) submitted: &'widget Rc>, + pub(super) command_tx: &'widget tokio::sync::mpsc::Sender, +} + +pub(super) fn submit_reply(submission: ReplySubmission<'_>) { + let Some(text) = bounded_reply_text(&submission.entry.text()) else { + return; + }; + // One shared cell closes the near-simultaneous Enter and click race + if submission.submitted.replace(true) { + return; + } + + submission.entry.set_sensitive(false); + submission.send.set_sensitive(false); + submission.error.set_visible(false); + let (outcome, result) = tokio::sync::oneshot::channel(); + try_send_command( + submission.command_tx, + UiCommand::Reply { + id: submission.id, + generation: submission.generation, + text, + outcome, + }, + ); + + let entry = submission.entry.clone(); + let revealer = submission.revealer.clone(); + let send = submission.send.clone(); + let error = submission.error.clone(); + let submitted = Rc::clone(submission.submitted); + gtk::glib::MainContext::default().spawn_local(async move { + let result = result.await; + // Keep both activation paths locked until the daemon returns the final result + submitted.set(false); + entry.set_sensitive(true); + match result { + Ok(Ok(())) => { + // Successful delivery clears local text and returns to the compact card + entry.set_text(""); + send.set_sensitive(false); + error.set_visible(false); + revealer.set_reveal_child(false); + } + Ok(Err(message)) => { + // A transport or daemon rejection keeps the draft available for correction + error.set_text(&message); + error.set_visible(true); + send.set_sensitive(bounded_reply_text(&entry.text()).is_some()); + entry.grab_focus(); + } + Err(_) => { + error.set_text("Notification service did not return a reply result"); + error.set_visible(true); + send.set_sensitive(bounded_reply_text(&entry.text()).is_some()); + entry.grab_focus(); + } + } + }); +} + +pub(super) fn bounded_reply_text(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() && value.len() <= MAX_REPLY_BYTES && !value.contains(['\0', '\r', '\n'])) + .then(|| value.to_string()) +} + +pub(super) fn cancel_reply( + entry: >k::Entry, + revealer: >k::Revealer, + error: >k::Label, + submitted: &Cell, +) { + if submitted.get() { + return; + } + entry.set_text(""); + error.set_visible(false); + revealer.set_reveal_child(false); +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs new file mode 100644 index 000000000..7b0a5a4c6 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/mod.rs @@ -0,0 +1,9 @@ +//! Bounded inline reply editor for verified communication notifications + +mod lifecycle; +mod widget; + +pub(in crate::ui::entry) use widget::build_inline_reply; + +#[cfg(test)] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs new file mode 100644 index 000000000..bf89982a6 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/tests/mod.rs @@ -0,0 +1,107 @@ +use gtk::prelude::*; +use unixnotis_core::{ + Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; + +use super::super::build_inline_reply; +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::PopupEntryViewModel; + +#[gtk::test] +fn reply_button_reveals_editor_without_sending_and_submission_keeps_generation() { + let mut notification = notification(); + notification.inline_reply = InlineReply { + available: true, + label: "Reply".to_string(), + ..InlineReply::default() + }; + notification.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let widget = + build_inline_reply(¬ification, &view, &command_tx).expect("verified reply editor"); + let reveal = widget + .first_child() + .and_downcast::() + .expect("reply button"); + let revealer = widget + .last_child() + .and_downcast::() + .expect("reply revealer"); + + reveal.emit_clicked(); + assert!(revealer.reveals_child()); + assert!(command_rx.try_recv().is_err()); + + let form = revealer + .child() + .and_downcast::() + .expect("reply form"); + let input_row = form + .first_child() + .and_downcast::() + .expect("reply input row"); + let entry = input_row + .first_child() + .and_downcast::() + .expect("reply entry"); + entry.set_text("On my way"); + entry.emit_activate(); + + let UiCommand::Reply { + id, + generation, + text, + .. + } = command_rx.try_recv().expect("reply command") + else { + panic!("expected reply command"); + }; + assert_eq!(id, notification.id); + assert_eq!(generation, notification.generation); + assert_eq!(text, "On my way"); +} + +#[gtk::test] +fn unverified_notification_never_builds_a_reply_editor() { + let mut notification = notification(); + notification.inline_reply.available = true; + notification.inline_reply_policy = unixnotis_core::InlineReplyPolicy::Deny; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + + assert!(build_inline_reply(¬ification, &view, &command_tx).is_none()); +} + +fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs new file mode 100644 index 000000000..ca9bea3b9 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/reply/widget.rs @@ -0,0 +1,225 @@ +//! GTK construction and input wiring for one popup reply editor + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationView; + +use super::lifecycle::{ + bounded_reply_text, cancel_reply, submit_reply, ReplySubmission, MAX_REPLY_CHARS, +}; +use crate::dbus::UiCommand; +use crate::ui::entry::activation::mark_interactive; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; + +pub(in crate::ui::entry) fn build_inline_reply( + notification: &NotificationView, + view: &PopupEntryViewModel, + command_tx: &tokio::sync::mpsc::Sender, +) -> Option { + if view.kind != PopupKind::Communication || view.trust.reply != ReplyPresentation::Available { + return None; + } + + let root = gtk::Box::new(gtk::Orientation::Vertical, 4); + root.add_css_class("unixnotis-popup-inline-reply"); + mark_interactive(&root); + let reveal = gtk::Button::with_label(reply_label(notification)); + reveal.add_css_class("unixnotis-popup-action"); + root.append(&reveal); + + let revealer = gtk::Revealer::new(); + revealer.set_reveal_child(false); + revealer.set_transition_type(gtk::RevealerTransitionType::SlideDown); + revealer.set_transition_duration(200); + let form = gtk::Box::new(gtk::Orientation::Vertical, 4); + let input_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let entry = gtk::Entry::new(); + entry.set_hexpand(true); + entry.set_max_length(MAX_REPLY_CHARS); + entry.set_placeholder_text(Some(reply_placeholder(notification))); + entry.add_css_class("unixnotis-popup-reply-entry"); + let send = gtk::Button::with_label(reply_submit_label(notification)); + send.set_sensitive(false); + send.add_css_class("unixnotis-popup-action"); + let cancel = gtk::Button::with_label("Cancel"); + cancel.add_css_class("unixnotis-popup-action"); + let error = gtk::Label::new(None); + error.set_xalign(0.0); + error.set_wrap(true); + error.set_visible(false); + error.add_css_class("unixnotis-popup-reply-error"); + + input_row.append(&entry); + input_row.append(&send); + input_row.append(&cancel); + form.append(&input_row); + form.append(&error); + revealer.set_child(Some(&form)); + root.append(&revealer); + + let submitted = Rc::new(Cell::new(false)); + connect_reveal(&reveal, &revealer, &entry, &submitted); + connect_validation(&entry, &send, &error, &submitted); + connect_submission( + notification, + &entry, + &revealer, + &send, + &error, + &submitted, + command_tx, + ); + connect_cancel(&entry, &revealer, &cancel, &error, &submitted); + + Some(root) +} + +fn connect_reveal( + button: >k::Button, + revealer: >k::Revealer, + entry: >k::Entry, + submitted: &Rc>, +) { + let revealer = revealer.clone(); + let entry = entry.clone(); + let submitted = Rc::clone(submitted); + button.connect_clicked(move |_| { + // Opening the editor is local-only and never sends an application signal + if submitted.get() { + return; + } + revealer.set_reveal_child(true); + entry.grab_focus(); + }); +} + +fn connect_validation( + entry: >k::Entry, + send: >k::Button, + error: >k::Label, + submitted: &Rc>, +) { + let send = send.clone(); + let error = error.clone(); + let submitted = Rc::clone(submitted); + entry.connect_changed(move |entry| { + error.set_visible(false); + let valid = bounded_reply_text(&entry.text()).is_some(); + send.set_sensitive(valid && !submitted.get()); + entry.set_tooltip_text( + (!valid && !entry.text().trim().is_empty()) + .then_some("Reply text must be one line and no larger than 4 KiB"), + ); + }); +} + +fn connect_submission( + notification: &NotificationView, + entry: >k::Entry, + revealer: >k::Revealer, + send: >k::Button, + error: >k::Label, + submitted: &Rc>, + command_tx: &tokio::sync::mpsc::Sender, +) { + let click_entry = entry.clone(); + let click_revealer = revealer.clone(); + let click_send = send.clone(); + let click_error = error.clone(); + let click_submitted = Rc::clone(submitted); + let click_tx = command_tx.clone(); + let id = notification.id; + let generation = notification.generation; + send.connect_clicked(move |_| { + submit_reply(ReplySubmission { + id, + generation, + entry: &click_entry, + revealer: &click_revealer, + send: &click_send, + error: &click_error, + submitted: &click_submitted, + command_tx: &click_tx, + }); + }); + + let activate_revealer = revealer.clone(); + let activate_send = send.clone(); + let activate_error = error.clone(); + let activate_submitted = Rc::clone(submitted); + let activate_tx = command_tx.clone(); + entry.connect_activate(move |entry| { + submit_reply(ReplySubmission { + id, + generation, + entry, + revealer: &activate_revealer, + send: &activate_send, + error: &activate_error, + submitted: &activate_submitted, + command_tx: &activate_tx, + }); + }); +} + +fn connect_cancel( + entry: >k::Entry, + revealer: >k::Revealer, + cancel: >k::Button, + error: >k::Label, + submitted: &Rc>, +) { + let cancel_entry = entry.clone(); + let cancel_revealer = revealer.clone(); + let cancel_error = error.clone(); + let cancel_submitted = Rc::clone(submitted); + cancel.connect_clicked(move |_| { + cancel_reply( + &cancel_entry, + &cancel_revealer, + &cancel_error, + &cancel_submitted, + ); + }); + + let key_revealer = revealer.clone(); + let key_error = error.clone(); + let key_submitted = Rc::clone(submitted); + let controller = gtk::EventControllerKey::new(); + controller.connect_key_pressed(move |controller, key, _, _| { + if key != gtk::gdk::Key::Escape { + return gtk::glib::Propagation::Proceed; + } + if let Some(entry) = controller.widget().and_downcast::() { + cancel_reply(&entry, &key_revealer, &key_error, &key_submitted); + } + gtk::glib::Propagation::Stop + }); + entry.add_controller(controller); +} + +fn reply_label(notification: &NotificationView) -> &str { + if notification.inline_reply.label.trim().is_empty() { + "Reply" + } else { + ¬ification.inline_reply.label + } +} + +fn reply_placeholder(notification: &NotificationView) -> &str { + if notification.inline_reply.placeholder.trim().is_empty() { + "Write a reply" + } else { + ¬ification.inline_reply.placeholder + } +} + +fn reply_submit_label(notification: &NotificationView) -> &str { + if notification.inline_reply.submit_label.trim().is_empty() { + "Send" + } else { + ¬ification.inline_reply.submit_label + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs new file mode 100644 index 000000000..52e654234 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/common.rs @@ -0,0 +1,520 @@ +use super::{ + build_action_row, build_application_identity, build_body_label, build_close_button, + build_conversation_avatar, build_identity_header, build_reply_note, build_secondary_claim, + build_title_label, build_urgency_badge, +}; +use gtk::prelude::*; +use unixnotis_core::{ + Action, AttributionReason, ImageData, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; + +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::{PopupEntryViewModel, ReplyPresentation}; +use crate::ui::UiState; +use unixnotis_core::{Config, ThemePaths}; +use unixnotis_ui::css::CssManager; + +#[gtk::test] +fn popup_critical_badge_uses_shared_hook_and_visibility() { + let critical = build_urgency_badge(true); + let normal = build_urgency_badge(false); + + assert!(critical.has_css_class(unixnotis_core::hooks::urgency::BADGE)); + assert_eq!(critical.text().as_str(), "!"); + assert_eq!( + critical.tooltip_text().as_deref(), + Some("Critical notification") + ); + assert!(critical.get_visible()); + assert!(!normal.get_visible()); +} + +#[gtk::test] +fn title_and_body_builders_keep_text_classes_and_line_limits() { + let mut view = view_model(); + + let title = build_title_label(&view).expect("visible title"); + let body = build_body_label(&view, 5).expect("visible body"); + + assert_eq!(title.text().as_str(), "Primary title"); + assert!(title.has_css_class("unixnotis-popup-summary")); + assert_eq!(title.lines(), 2); + assert_eq!(body.text().as_str(), "Supporting body"); + assert!(body.has_css_class("unixnotis-popup-body")); + assert_eq!(body.lines(), 5); + + view.title.clear(); + view.body = None; + assert!(build_title_label(&view).is_none()); + assert!(build_body_label(&view, 3).is_none()); +} + +#[gtk::test] +fn secondary_claim_stays_on_one_compact_metadata_line() { + let mut view = view_model(); + view.secondary_claim = Some("Claimed app: Example Chat".to_string()); + + let claim = build_secondary_claim(&view).expect("secondary claim"); + + assert_eq!(claim.text().as_str(), "Claimed app: Example Chat"); + assert!(claim.is_single_line_mode()); + assert_eq!(claim.ellipsize(), gtk::pango::EllipsizeMode::End); + assert!(!claim.wraps()); +} + +#[gtk::test] +fn reply_note_exists_only_when_the_policy_explanation_is_needed() { + let mut view = view_model(); + assert!(build_reply_note(&view).is_none()); + + view.trust.reply = ReplyPresentation::Unavailable; + let note = build_reply_note(&view).expect("reply unavailable note"); + + assert_eq!(note.text().as_str(), "Reply unavailable"); + assert!(note.has_css_class("unixnotis-popup-footer-note")); +} + +#[gtk::test] +fn close_button_and_identity_header_keep_their_interaction_contracts() { + let close = build_close_button(); + let mut view = view_model(); + view.secondary_claim = Some("App identity could not be verified".to_string()); + let header = build_identity_header(&view); + + assert!(close.has_css_class("unixnotis-popup-close")); + assert_eq!( + close.tooltip_text().as_deref(), + Some("Dismiss notification") + ); + assert!(header.identity.hexpands()); + assert_eq!(header.trailing.width_request(), 42); + assert_eq!(header.trailing.height_request(), -1); + assert_eq!(header.trailing.margin_end(), 30); + assert_eq!(header.trailing.orientation(), gtk::Orientation::Vertical); + assert!(header + .trailing + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-time"))); + assert!(header + .trailing + .last_child() + .is_some_and(|child| { child.has_css_class(unixnotis_core::hooks::urgency::BADGE) })); + assert!(header + .identity + .last_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-secondary-claim"))); +} + +#[gtk::test] +fn action_row_dispatches_the_prepared_action_identity() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let view = view_model_with_action(); + let notification = notification(); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("action button"); + + button.emit_clicked(); + + match command_rx.try_recv().expect("queued action command") { + UiCommand::InvokeAction { + notification, + action_key, + confirmed, + } => { + assert_eq!(notification.id, 41); + assert_eq!(notification.generation, 3); + assert_eq!(action_key, "open"); + assert!(!confirmed, "allowed actions should not claim confirmation"); + } + command => panic!("unexpected command: {command:?}"), + } +} + +#[gtk::test] +fn confirmable_popup_action_requires_two_clicks_before_dispatch() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let mut notification = notification(); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions.push(Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("confirmable action button"); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "first click must not invoke a confirmable action" + ); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 41 + && notification.generation == 3 + && action_key == "archive" + )); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!( + command_rx.try_recv().is_err(), + "third click must re-arm rather than dispatching" + ); +} + +#[gtk::test] +fn confirmable_popup_action_stale_timer_does_not_disarm_newer_cycle() { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4); + let mut notification = notification(); + notification.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + notification.actions.push(Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let button = row + .first_child() + .and_downcast::() + .expect("confirmable action button"); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + std::thread::sleep(std::time::Duration::from_millis(400)); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + button.emit_clicked(); + assert!(matches!( + command_rx.try_recv(), + Ok(UiCommand::InvokeAction { + notification, + action_key, + confirmed: true, + }) if notification.id == 41 && notification.generation == 3 && action_key == "archive" + )); + + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer A (from first arm at t=0) fires at t=5000. We are at t=400 now. + // Sleep 4600ms -> t=5000. Process timer A. It should NOT clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(4600)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); + + // Timer B (from second arm at t=400) fires at t=5400. We are at t=5000. + // Sleep 400ms -> t=5400. Process timer B. It SHOULD clear cycle B. + std::thread::sleep(std::time::Duration::from_millis(400)); + while context.pending() { + context.iteration(false); + } + assert_eq!(button.label().as_deref(), Some("Archive")); + assert!(command_rx.try_recv().is_err()); + + // Next click re-arms rather than invokes. + button.emit_clicked(); + assert_eq!(button.label().as_deref(), Some("Confirm Archive")); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn extra_safe_action_builds_a_compact_overflow_menu() { + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut notification = notification(); + notification.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "folder".to_string(), + label: "Open folder".to_string(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + Action { + key: "mute".to_string(), + label: "Mute".to_string(), + }, + ]; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let row = build_action_row(&command_tx, notification.key(), &view).expect("action row"); + let menu = row + .last_child() + .and_downcast::() + .expect("overflow menu"); + + assert_eq!(menu.icon_name().as_deref(), Some("view-more-symbolic")); + assert!(menu.popover().is_some()); +} + +#[gtk::test] +fn empty_action_model_does_not_build_an_action_row() { + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + assert!(build_action_row(&command_tx, notification().key(), &view_model()).is_none()); +} + +#[gtk::test] +fn application_identity_scales_the_symbolic_glyph_inside_its_fixed_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupAvatarSizing") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register avatar sizing application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-avatar-sizing"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.attribution = NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_application_identity(&mut state, ¬ification, &view, 36); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar should contain one image"); + + assert_eq!(avatar.widget.width_request(), 36); + assert_eq!(avatar.widget.height_request(), 36); + assert_eq!(icon.pixel_size(), 22); + assert!(icon.hexpands()); + assert!(icon.vexpands()); +} + +#[gtk::test] +fn conversation_avatar_renders_from_bounded_message_pixels() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register conversation avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let _state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.inline_reply.available = true; + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_conversation_avatar(¬ification, &view, 36) + .expect("conversation avatar should be available"); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar should contain one image"); + + assert_eq!(icon.pixel_size(), 36); + assert!(icon.has_css_class("unixnotis-popup-conversation-avatar")); +} + +#[gtk::test] +fn conversation_avatar_aspect_ratios_keep_the_fixed_lead_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConversationAspectRatios") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register aspect-ratio application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conversation-aspect-ratios"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let _state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + + for (width, height, rowstride, data) in [ + (1, 1, 4, vec![1, 2, 3, 255]), + (1, 3, 4, [1, 2, 3, 255].repeat(3)), + (3, 1, 12, [1, 2, 3, 255].repeat(3)), + ] { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = ImageData { + width, + height, + rowstride, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data, + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let avatar = build_conversation_avatar(¬ification, &view, 36) + .expect("valid bounded avatar should build"); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("avatar slot should contain the image"); + + assert_eq!(avatar.widget.width_request(), 36); + assert_eq!(avatar.widget.height_request(), 36); + assert_eq!(icon.pixel_size(), 36); + } +} + +#[gtk::test] +fn decorative_application_visual_does_not_replace_the_identity_badge() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupDecorativeVisual") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register decorative visual application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-decorative-visual"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 1, 1, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let avatar = build_application_identity(&mut state, ¬ification, &view, 36); + let icon = avatar + .widget + .first_child() + .and_downcast::() + .expect("identity slot should contain an image"); + + assert!(!icon.has_css_class("unixnotis-popup-application-visual")); + assert!(!icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert_eq!(icon.pixel_size(), 22); +} + +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel::for_notification_at(¬ification(), 1_000) +} + +fn view_model_with_action() -> PopupEntryViewModel { + let mut notification = notification(); + notification.actions.push(Action { + key: "open".to_string(), + label: "Open".to_string(), + }); + PopupEntryViewModel::for_notification_at(¬ification, 1_000) +} + +fn notification() -> NotificationView { + NotificationView { + id: 41, + generation: 3, + app_name: "Example".to_string(), + attribution: NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), + summary: "Primary title".to_string(), + body: "Supporting body".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn theme_paths(root: &std::path::Path) -> ThemePaths { + ThemePaths { + base_dir: root.to_path_buf(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs new file mode 100644 index 000000000..a57cad256 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/layout.rs @@ -0,0 +1,187 @@ +use super::{build_popup_grid, popup_accessible_label, PopupLayout}; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{Config, NotificationImage, NotificationView, ThemePaths}; +use unixnotis_ui::css::CssManager; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; + +#[test] +fn popup_accessible_name_keeps_identity_and_message_context() { + let mut view = view_model(); + + assert_eq!( + popup_accessible_label(&view), + "Command-line notification. App label: Builder. Build finished" + ); + + view.title.clear(); + assert_eq!( + popup_accessible_label(&view), + "Command-line notification. App label: Builder" + ); +} + +#[test] +fn conflict_accessible_name_includes_trust_claim_and_body() { + let mut view = view_model(); + view.app_label = "Unknown application".to_string(); + view.secondary_claim = Some("Claimed app: Example Chat".to_string()); + view.badge = BadgePresentation::SuspiciousApplication; + view.body = Some("Hey, did this go through?".to_string()); + view.trust.level = TrustLevel::Conflict; + view.trust.short_label = Some("Suspicious".to_string()); + + assert_eq!( + popup_accessible_label(&view), + "Unknown application. Suspicious. Claimed app: Example Chat. Build finished. \ + Hey, did this go through?" + ); +} + +#[gtk::test] +fn popup_separates_application_identity_from_conversation_avatar() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupAvatarGrid") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register avatar grid application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-avatar-grid"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let notification = conversation_notification(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: true, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("header row"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("header row should contain the application identity"); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row"); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation avatar"); + let application_icon = application_identity + .first_child() + .and_downcast::() + .expect("application identity slot should contain one image"); + let conversation_icon = conversation_avatar + .first_child() + .and_downcast::() + .expect("conversation slot should contain one image"); + + assert!(!application_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(conversation_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert!(message_row.last_child().is_some()); + assert!(header_row.has_css_class("unixnotis-popup-header-row")); + assert!(message_row.has_css_class("unixnotis-popup-message-row")); +} +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel { + kind: PopupKind::Communication, + app_label: "Command-line notification".to_string(), + secondary_claim: Some("App label: Builder".to_string()), + badge: BadgePresentation::CommandLine, + timestamp_label: "now".to_string(), + title: "Build finished".to_string(), + body: None, + thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, + default_action_key: None, + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Relay, + short_label: None, + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + } +} + +fn conversation_notification() -> NotificationView { + let mut notification = NotificationView { + id: 7, + generation: 1, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::verified( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "verified test fixture", + "verified:example-chat".to_string(), + ), + summary: "PV2 Rivera in Tel Aviv 2026".to_string(), + body: "10 eps I heard ts tuff asf".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + }; + notification +} + +fn theme_paths(root: &std::path::Path) -> ThemePaths { + ThemePaths { + base_dir: root.to_path_buf(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + } +} + +#[path = "visual_matrix.rs"] +mod visual_matrix; diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs new file mode 100644 index 000000000..7707431bb --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/thumbnail.rs @@ -0,0 +1,140 @@ +use super::{append_thumbnail, should_append_thumbnail}; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use gtk::prelude::*; +use unixnotis_core::{NotificationImage, NotificationView}; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; + +#[test] +fn application_provided_visual_cannot_enter_message_thumbnail_lane() { + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ApplicationProvidedIcon; + + assert!(!should_append_thumbnail(&view)); +} + +#[test] +fn genuine_content_image_enters_message_thumbnail_lane() { + let mut view = view_model(); + view.thumbnail = ThumbnailKind::Content; + view.visuals.content_image = true; + + assert!(should_append_thumbnail(&view)); +} + +#[gtk::test] +fn append_thumbnail_rejects_application_visual_without_adding_widget() { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + notification.image.sender_visual = pixel(); + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ApplicationProvidedIcon; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(!append_thumbnail(¬ification, &view, &content)); + assert!(content.first_child().is_none()); +} + +#[gtk::test] +fn append_thumbnail_adds_only_genuine_content_image() { + let mut notification = notification(); + notification.image.content_image = pixel(); + let mut view = view_model(); + view.thumbnail = ThumbnailKind::Content; + view.visuals.content_image = true; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(append_thumbnail(¬ification, &view, &content)); + let image = content + .first_child() + .and_downcast::() + .expect("content lane should contain one image"); + assert!(image.has_css_class("unixnotis-popup-content-image")); +} + +#[gtk::test] +fn append_thumbnail_rejects_conversation_avatar_without_content_image() { + let notification = notification_with_conversation_pixels(); + let mut view = view_model(); + view.visuals.sender = SenderVisualPresentation::ConversationAvatar; + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); + + assert!(!append_thumbnail(¬ification, &view, &content)); + assert!(content.first_child().is_none()); +} + +fn view_model() -> PopupEntryViewModel { + PopupEntryViewModel { + kind: PopupKind::Communication, + app_label: "Example Chat".to_string(), + secondary_claim: None, + badge: BadgePresentation::UnknownApplication, + timestamp_label: "now".to_string(), + title: "Conversation".to_string(), + body: Some("Message".to_string()), + thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, + default_action_key: None, + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Unresolved, + short_label: Some("Unverified".to_string()), + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + } +} + +fn notification() -> NotificationView { + NotificationView { + id: 1, + generation: 1, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "claim:example-chat".to_string(), + ), + summary: "Conversation".to_string(), + body: "Message".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn notification_with_conversation_pixels() -> NotificationView { + let mut notification = notification(); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ConversationAvatar; + notification.image.sender_visual = pixel(); + notification +} + +fn pixel() -> unixnotis_core::ImageData { + unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs b/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs new file mode 100644 index 000000000..eb6c0e4ba --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/tests/visual_matrix.rs @@ -0,0 +1,559 @@ +//! Generic popup visual-role matrix + +use super::{build_popup_grid, conversation_notification, theme_paths, PopupLayout}; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{ + AttributionReason, Config, IdentityAssurance, InteractionPolicies, NotificationAttribution, +}; +use unixnotis_ui::css::CssManager; + +#[gtk::test] +fn unresolved_conversation_avatar_stays_in_the_message_lead_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupUnresolvedConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register unresolved conversation avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-unresolved-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example-chat".to_string(), + ); + // A claimed desktop id may brand the header without changing the unverified state + notification.image.claimed_desktop_id = "folder".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + assert_eq!( + view.trust.level, + unixnotis_ui::presentation::TrustLevel::Unresolved + ); + assert_eq!( + view.visuals.sender, + unixnotis_ui::presentation::SenderVisualPresentation::ConversationAvatar + ); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: true, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("header row should exist"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("header row should contain the application identity"); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation avatar"); + let application_icon = application_identity + .first_child() + .and_downcast::() + .expect("identity slot should contain one image"); + let conversation_icon = conversation_avatar + .first_child() + .and_downcast::() + .expect("conversation slot should contain one image"); + + assert!(!application_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(application_icon.paintable().is_some()); + assert!(conversation_icon.has_css_class("unixnotis-popup-conversation-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert!(!conversation_avatar.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(!rendered.has_image); + assert!(header_row.has_css_class("unixnotis-popup-header-row")); + assert!(message_row.has_css_class("unixnotis-popup-message-row")); +} + +#[gtk::test] +fn trust_state_does_not_change_conversation_avatar_geometry() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupTrustGeometry") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register trust geometry application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-trust-geometry"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + + for (name, attribution) in trust_attributions() { + let mut notification = conversation_notification(); + notification.attribution = attribution; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .unwrap_or_else(|| panic!("missing application header for {name}")); + let application_identity = header_row + .first_child() + .and_downcast::() + .unwrap_or_else(|| panic!("missing application identity for {name}")); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .unwrap_or_else(|| panic!("missing message row for {name}")); + let conversation_avatar = message_row + .first_child() + .and_downcast::() + .unwrap_or_else(|| panic!("missing conversation avatar for {name}")); + assert!(application_identity.has_css_class("unixnotis-popup-application-icon-slot")); + assert!(!application_identity.has_css_class("unixnotis-identity-avatar")); + assert!(conversation_avatar.has_css_class("unixnotis-popup-conversation-avatar-slot")); + assert!(!conversation_avatar.has_css_class("unixnotis-identity-avatar")); + assert_eq!(conversation_avatar.width_request(), 46); + assert_eq!(conversation_avatar.height_request(), 46); + assert!(!conversation_avatar.compute_expand(gtk::Orientation::Horizontal)); + assert!(!conversation_avatar.compute_expand(gtk::Orientation::Vertical)); + assert!(message_row.last_child().is_some()); + } +} + +#[gtk::test] +fn fixed_visual_slots_do_not_consume_short_message_width() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupFixedVisualSlots") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register fixed visual slot application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-fixed-visual-slots"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.summary = "A".to_string(); + notification.body = "B".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("header row should exist"); + let application_slot = header_row + .first_child() + .and_downcast::() + .expect("header should contain the application visual slot"); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let conversation_slot = message_row + .first_child() + .and_downcast::() + .expect("message row should contain the conversation visual slot"); + let message_column = message_row + .last_child() + .and_downcast::() + .expect("message row should contain the message column"); + + assert_eq!(application_slot.width_request(), 24); + assert_eq!(conversation_slot.width_request(), 46); + assert!(!application_slot.compute_expand(gtk::Orientation::Horizontal)); + assert!(!conversation_slot.compute_expand(gtk::Orientation::Horizontal)); + assert!(message_column.compute_expand(gtk::Orientation::Horizontal)); +} + +#[gtk::test] +fn conflict_popup_keeps_warning_badge_ahead_of_claimed_branding() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupConflictBranding") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register conflict branding application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-conflict-branding"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.attribution = unixnotis_core::NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "identity conflict", + "conflict:example-chat".to_string(), + ); + notification.image.claimed_theme_icon = "folder".to_string(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let header_row = rendered + .widget + .child_at(0, 0) + .and_downcast::() + .expect("conflict header row"); + let application_identity = header_row + .first_child() + .and_downcast::() + .expect("conflict header identity slot"); + let icon = application_identity + .first_child() + .and_downcast::() + .expect("conflict header icon"); + + assert_eq!( + icon.icon_name().as_deref(), + Some("unixnotis-shield-warning-symbolic") + ); + assert_eq!( + view.trust.level, + unixnotis_ui::presentation::TrustLevel::Conflict + ); +} + +#[gtk::test] +fn conversation_avatar_and_content_image_use_separate_popup_lanes() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupVisualLanes") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register visual lane application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-visual-lanes"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.content_image = unixnotis_core::ImageData { + width: 2, + height: 2, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: [9, 8, 7, 255].repeat(4), + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should exist"); + let avatar = message_row + .first_child() + .and_downcast::() + .expect("conversation avatar should stay beside the message"); + assert!(avatar + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar"))); + + let message = message_row + .last_child() + .and_downcast::() + .expect("message column should exist"); + let content_image = message + .last_child() + .and_downcast::() + .expect("content media should remain below the message"); + assert!(content_image.has_css_class("unixnotis-popup-content-image")); + assert!(rendered.has_image); +} + +#[gtk::test] +fn invalid_conversation_pixels_remove_the_popup_avatar_cell() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupInvalidConversationAvatar") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register invalid avatar application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-invalid-conversation-avatar"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.sender_visual = unixnotis_core::ImageData { + width: 0, + height: 0, + data: vec![1, 2, 3, 255], + ..unixnotis_core::ImageData::default() + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-communication-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("message row should remain when avatar decoding fails"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); + assert!(!rendered.has_image); +} + +#[gtk::test] +fn ordinary_notifications_do_not_gain_a_second_avatar_row() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupOrdinaryNotification") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register ordinary notification application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-ordinary-notification"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.category.clear(); + notification.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + notification.image.sender_visual = unixnotis_core::ImageData::default(); + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-utility-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("ordinary notifications still have one message row"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); +} + +#[gtk::test] +fn content_only_popup_keeps_media_below_message_without_avatar_slot() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupContentOnly") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register content-only application"); + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-content-only"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + let mut state = UiState::new(&app, config, root.join("config.toml"), command_tx, css); + let mut notification = conversation_notification(); + notification.image.sender_visual_role = unixnotis_core::NotificationVisualRole::None; + notification.image.sender_visual = unixnotis_core::ImageData::default(); + notification.image.content_image = unixnotis_core::ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![8, 9, 10, 255], + }; + let view = PopupEntryViewModel::for_notification_at(¬ification, 1_000); + + let rendered = build_popup_grid( + &mut state, + ¬ification, + &view, + PopupLayout { + css_class: "unixnotis-popup-media-content", + body_lines: 5, + show_reply_note: false, + }, + ); + + assert!(rendered.widget.child_at(0, 0).is_some()); + let message_row = rendered + .widget + .child_at(0, 1) + .and_downcast::() + .expect("content-only notifications still have one message row"); + assert!(!message_row + .first_child() + .is_some_and(|child| child.has_css_class("unixnotis-popup-conversation-avatar-slot"))); + let message = message_row + .last_child() + .and_downcast::() + .expect("message column should exist"); + let content = message + .last_child() + .and_downcast::() + .expect("content image should remain in the message column"); + assert!(content.has_css_class("unixnotis-popup-content-image")); + assert!(rendered.has_image); +} + +fn trust_attributions() -> [(&'static str, NotificationAttribution); 7] { + [ + ( + "authenticated", + NotificationAttribution::verified( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + AttributionReason::ExactSystemExecutable, + "authenticated fixture", + "verified:example-chat".to_string(), + ), + ), + ( + "system-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "system fixture", + "associated:system:example-chat".to_string(), + ), + ), + ( + "user-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "user fixture", + "associated:user:example-chat".to_string(), + ), + ), + ( + "portal-associated", + NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "example-chat", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal fixture", + "associated:portal:example-chat".to_string(), + ), + ), + ( + "unresolved", + NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::MissingSenderEvidence, + "unresolved fixture", + "unknown:example-chat".to_string(), + ), + ), + ( + "conflict", + NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "conflict fixture", + "conflict:example-chat".to_string(), + ), + ), + ( + "relay", + NotificationAttribution::relay( + "Example Chat", + "relay fixture", + "relay:example-chat".to_string(), + ), + ), + ] +} diff --git a/crates/unixnotis-popups/src/ui/entry/builders/utility.rs b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs new file mode 100644 index 000000000..f05a6fd23 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/builders/utility.rs @@ -0,0 +1,25 @@ +//! Compact utility popup for device, transfer, clipboard, and generic events + +use unixnotis_core::NotificationView; + +use super::layout::{build_popup_grid, PopupLayout}; +use super::RenderedPopup; +use crate::ui::entry::presentation::PopupEntryViewModel; +use crate::ui::UiState; + +pub(super) fn build_utility_popup( + state: &mut UiState, + notification: &NotificationView, + view: &PopupEntryViewModel, +) -> RenderedPopup { + build_popup_grid( + state, + notification, + view, + PopupLayout { + css_class: "unixnotis-popup-utility-content", + body_lines: 4, + show_reply_note: false, + }, + ) +} diff --git a/crates/unixnotis-popups/src/ui/entry/commands.rs b/crates/unixnotis-popups/src/ui/entry/commands.rs index bb117a90f..4f89f6de2 100644 --- a/crates/unixnotis-popups/src/ui/entry/commands.rs +++ b/crates/unixnotis-popups/src/ui/entry/commands.rs @@ -8,7 +8,7 @@ use tracing::debug; use crate::dbus::UiCommand; -pub(super) fn try_send_command(tx: &Sender, command: UiCommand) { +pub(in crate::ui) fn try_send_command(tx: &Sender, command: UiCommand) { // GTK click handlers must stay non-blocking even when the runtime queue is saturated match tx.try_send(command) { // Fast path for normal queue availability diff --git a/crates/unixnotis-popups/src/ui/entry/labels.rs b/crates/unixnotis-popups/src/ui/entry/labels.rs deleted file mode 100644 index 45d934371..000000000 --- a/crates/unixnotis-popups/src/ui/entry/labels.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Popup text sizing and empty-row handling -//! -//! Keeps label rules in one place so summary and body rows stay consistent - -use std::borrow::Cow; - -use gtk::prelude::*; - -// Header/app title stays single-line and clipped at this length -pub(super) const POPUP_APP_MAX_CHARS: usize = 40; -// Summary is visually dominant but still bounded to avoid tall cards -pub(super) const POPUP_SUMMARY_MAX_CHARS: usize = 120; -// Body keeps enough context while preventing oversized popup growth -pub(super) const POPUP_BODY_MAX_CHARS: usize = 320; -// Action labels stay short so button row width remains predictable -pub(super) const POPUP_ACTION_LABEL_MAX_CHARS: usize = 14; - -pub(super) struct OptionalLabelState<'a> { - // Empty rows should disappear instead of leaving stray spacing behind - pub(super) visible: bool, - // Reuse borrowed text when possible so empty checks stay cheap - pub(super) text: Cow<'a, str>, -} - -pub(super) fn update_optional_label(label: >k::Label, text: &str, max_chars: usize) { - // Build the layout decision first so empty-text handling stays identical - // for both summary and body rows - let state = optional_label_state(text, max_chars); - // Hidden labels collapse their space in the popup box - set_label_visible_if_changed(label, state.visible); - // Text assignment happens after the visibility decision so empty rows stay blank - set_label_text_if_changed(label, state.text.as_ref()); -} - -pub(super) fn optional_label_state(text: &str, max_chars: usize) -> OptionalLabelState<'_> { - if !has_visible_text(text) { - // Empty text rows stay hidden so the card does not keep dead spacing - return OptionalLabelState { - visible: false, - text: Cow::Borrowed(""), - }; - } - let text = clamp_label_text(text, max_chars); - OptionalLabelState { - // Clamped-empty text should collapse the row the same way raw empty text does - visible: has_visible_text(text.as_ref()), - // Clamp before the label sees the text so layout work stays bounded - text, - } -} - -pub(super) fn has_visible_text(text: &str) -> bool { - // Visibility depends on real content, not just raw string length - // Space-only strings count as empty for popup layout purposes - text.chars().any(|ch| !ch.is_whitespace()) -} - -pub(super) fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { - if max_chars == 0 { - // Zero means the caller wants an intentionally blank label - return Cow::Borrowed(""); - } - // char_indices preserves UTF-8 boundaries during truncation - for (chars, (idx, _)) in text.char_indices().enumerate() { - if chars == max_chars { - // Keep one glyph slot for the ellipsis instead of splitting the codepoint - let mut clamped = String::with_capacity(idx + 3); - clamped.push_str(&text[..idx]); - clamped.push('…'); - return Cow::Owned(clamped); - } - } - // Borrow the original text when no clamp is needed - Cow::Borrowed(text) -} - -fn set_label_visible_if_changed(label: >k::Label, visible: bool) { - // Popup rows are refreshed often while the data stays the same - // Skip the setter when the row is already in the right state - if label.is_visible() != visible { - label.set_visible(visible); - } -} - -fn set_label_text_if_changed(label: >k::Label, text: &str) { - // Reapplying identical text still makes GTK walk the update path - // Compare first so stable popup rows stay quiet - if label.text().as_str() != text { - label.set_text(text); - } -} - -#[cfg(test)] -#[path = "tests/labels.rs"] -mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/mod.rs b/crates/unixnotis-popups/src/ui/entry/mod.rs index ccf1b8a76..85f4ebab8 100644 --- a/crates/unixnotis-popups/src/ui/entry/mod.rs +++ b/crates/unixnotis-popups/src/ui/entry/mod.rs @@ -1,7 +1,12 @@ //! Popup row construction and bounded label handling +mod activation; mod build; +mod builders; mod commands; -mod labels; +mod presentation; +mod visibility; pub(in crate::ui) use build::PopupEntry; +pub(in crate::ui) use commands::try_send_command; +pub(in crate::ui) use visibility::PopupVisibilityBinding; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs new file mode 100644 index 000000000..0d2c73b59 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/kind.rs @@ -0,0 +1,3 @@ +//! Popup naming for the shared notification content hierarchy + +pub(in crate::ui::entry) use unixnotis_ui::presentation::NotificationKind as PopupKind; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs new file mode 100644 index 000000000..3b88e0fbd --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/mod.rs @@ -0,0 +1,13 @@ +//! Popup-only presentation model derived from daemon-owned notification evidence + +mod kind; +mod trust; +mod view_model; + +pub(in crate::ui::entry) use kind::PopupKind; +pub(in crate::ui::entry) use trust::{PopupTrustPresentation, ReplyPresentation}; +pub(in crate::ui::entry) use view_model::{ActionViewModel, PopupEntryViewModel, ThumbnailKind}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs new file mode 100644 index 000000000..b3cc301e7 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/kind.rs @@ -0,0 +1,77 @@ +use unixnotis_core::{Action, AttributionReason, NotificationAttribution}; + +use super::super::PopupKind; +use super::support::notification; + +#[test] +fn standard_communication_category_classes_select_the_communication_layout() { + for category in [ + "call.incoming", + "email.arrived", + "im.received", + "presence.online", + ] { + let mut view = notification(); + view.category = category.to_string(); + assert_eq!( + PopupKind::for_notification(&view), + PopupKind::Communication, + "{category} should use the communication layout" + ); + } +} + +#[test] +fn utility_categories_and_missing_categories_select_the_compact_layout() { + for category in ["", "device.added", "network.connected", "transfer.complete"] { + let mut view = notification(); + view.category = category.to_string(); + assert_eq!( + PopupKind::for_notification(&view), + PopupKind::Utility, + "{category:?} should use the utility layout" + ); + } +} + +#[test] +fn suspicious_provenance_preserves_the_communication_category() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", + "conflict:example-chat".to_string(), + ); + + assert_eq!(PopupKind::for_notification(&view), PopupKind::Communication); +} + +#[test] +fn either_reply_contract_selects_the_communication_layout() { + let mut metadata_reply = notification(); + metadata_reply.inline_reply.available = true; + assert_eq!( + PopupKind::for_notification(&metadata_reply), + PopupKind::Communication + ); + + let mut action_reply = notification(); + action_reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + assert_eq!( + PopupKind::for_notification(&action_reply), + PopupKind::Communication + ); +} + +#[test] +fn each_popup_kind_keeps_its_intended_action_budget() { + assert_eq!(PopupKind::Communication.action_limit(), 2); + assert_eq!(PopupKind::Utility.action_limit(), 2); + assert_eq!(PopupKind::Media.action_limit(), 2); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs new file mode 100644 index 000000000..aad083d3a --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/mod.rs @@ -0,0 +1,4 @@ +mod kind; +mod support; +mod trust; +mod view_model; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs new file mode 100644 index 000000000..a42dc33bb --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/support.rs @@ -0,0 +1,33 @@ +use unixnotis_core::{ + AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + NotificationView, +}; + +pub(super) fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), + summary: "Primary title".to_string(), + body: "Supporting body".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs new file mode 100644 index 000000000..56c69bd7e --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/trust.rs @@ -0,0 +1,114 @@ +use unixnotis_core::{Action, AttributionReason, InlineReplyPolicy, NotificationAttribution}; +use unixnotis_ui::presentation::TrustLevel; + +use super::super::{PopupTrustPresentation, ReplyPresentation}; +use super::support::notification; + +#[test] +fn protected_desktop_association_stays_verified_and_visually_quiet() { + let mut view = notification(); + view.inline_reply.available = true; + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Verified); + assert!(trust.short_label.is_none()); + assert_eq!(trust.reply, ReplyPresentation::Available); +} + +#[test] +fn trusted_relay_uses_human_source_text_and_keeps_raw_path_in_details() { + let mut view = notification(); + view.attribution = NotificationAttribution::relay( + "Screenshot", + "Sent via /usr/bin/notify-send", + "relay:screenshot".to_string(), + ); + view.inline_reply_policy = InlineReplyPolicy::Deny; + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Relay); + assert!(trust.short_label.is_none()); + assert_eq!( + trust.details_label.as_deref(), + Some("Sent via /usr/bin/notify-send") + ); + assert_eq!(trust.reply, ReplyPresentation::Hidden); +} + +#[test] +fn conflicting_claim_is_suspicious_and_cannot_enable_reply() { + let mut view = notification(); + view.attribution = NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", + "conflict:example-chat".to_string(), + ); + view.inline_reply.available = true; + view.inline_reply_policy = InlineReplyPolicy::Deny; + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::Conflict); + assert_eq!(trust.short_label.as_deref(), Some("Suspicious")); + assert_eq!(trust.reply, ReplyPresentation::Unavailable); +} + +#[test] +fn user_writable_desktop_association_remains_unverified() { + let mut view = notification(); + view.attribution = NotificationAttribution::recognized( + "Local app", + "Local app", + "org.example.Local", + "local-app", + AttributionReason::ExactUserExecutable, + "user desktop association", + "user-desktop:org.example.Local".to_string(), + ); + + let trust = PopupTrustPresentation::for_notification(&view); + + assert_eq!(trust.level, TrustLevel::UserAssociated); + assert_eq!(trust.short_label.as_deref(), Some("Local app")); + assert_eq!(trust.reply, ReplyPresentation::Hidden); +} + +#[test] +fn verified_identity_still_needs_both_a_reply_request_and_policy_permission() { + let mut denied = notification(); + denied.inline_reply.available = true; + denied.inline_reply_policy = InlineReplyPolicy::Deny; + let denied_trust = PopupTrustPresentation::for_notification(&denied); + assert_eq!(denied_trust.reply, ReplyPresentation::Unavailable); + + let no_request = notification(); + let no_request_trust = PopupTrustPresentation::for_notification(&no_request); + assert_eq!(no_request_trust.reply, ReplyPresentation::Hidden); +} + +#[test] +fn only_the_exact_inline_reply_action_key_requests_reply_ui() { + let mut other_action = notification(); + other_action.actions.push(Action { + key: "reply-later".to_string(), + label: "Reply later".to_string(), + }); + let other_trust = PopupTrustPresentation::for_notification(&other_action); + assert_eq!(other_trust.reply, ReplyPresentation::Hidden); + + let mut inline_reply = notification(); + inline_reply.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + let inline_trust = PopupTrustPresentation::for_notification(&inline_reply); + assert_eq!(inline_trust.reply, ReplyPresentation::Unavailable); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs new file mode 100644 index 000000000..bdca9ead2 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/tests/view_model.rs @@ -0,0 +1,279 @@ +use unixnotis_core::{Action, AttributionReason, ImageData, NotificationAttribution}; + +use super::super::{PopupEntryViewModel, PopupKind, ThumbnailKind}; +use super::support::notification; + +#[test] +fn view_model_formats_relative_time_without_losing_original_age() { + let mut view = notification(); + view.received_at_unix_seconds = 1_000; + + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_030).timestamp_label, + "now" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_120).timestamp_label, + "2m" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 8_200).timestamp_label, + "2h" + ); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "2d" + ); + + view.received_at_unix_seconds = 0; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "now" + ); + view.received_at_unix_seconds = 200_000; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 173_800).timestamp_label, + "now" + ); +} + +#[test] +fn utility_layout_moves_extra_safe_actions_into_overflow() { + let mut view = notification(); + view.actions = vec![ + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "folder".to_string(), + label: "Open folder".to_string(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + Action { + key: "mute".to_string(), + label: "Mute".to_string(), + }, + ]; + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.kind, PopupKind::Utility); + assert_eq!(model.default_action_key.as_deref(), Some("default")); + assert_eq!(model.primary_actions.len(), 2); + assert_eq!(model.primary_actions[0].key, "default"); + assert_eq!(model.primary_actions[1].key, "folder"); + assert_eq!(model.overflow_actions.len(), 2); + assert_eq!(model.overflow_actions[0].key, "archive"); + assert_eq!(model.overflow_actions[1].key, "mute"); +} + +#[test] +fn blank_default_action_is_clickable_without_becoming_a_visible_control() { + let mut view = notification(); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.default_action_key.as_deref(), Some("default")); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); +} + +#[test] +fn weak_attribution_hides_every_application_directed_action() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::NoDesktopCandidate, + "source /tmp/fake", + "unknown:example-chat".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); +} + +#[test] +fn user_associated_attribution_hides_application_directed_actions() { + let mut view = notification(); + view.attribution = NotificationAttribution::recognized( + "User application", + "User application", + "org.example.UserApplication", + "user-application", + AttributionReason::ExactUserExecutable, + "user desktop association", + "user-desktop:org.example.UserApplication".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); +} + +#[test] +fn communication_avatar_is_not_suppressed_as_decoration() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.image.content_image = ImageData { + width: 64, + height: 64, + data: vec![0; 64 * 64 * 4], + ..ImageData::default() + }; + + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn thumbnail_requires_real_image_data() { + let mut view = notification(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::None + ); + + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn app_icon_name_never_suppresses_real_content_image_data() { + let mut icon_match = notification(); + icon_match.attribution.badge_icon = "example".to_string(); + icon_match.image.badge_icon = "example".to_string(); + icon_match.image.content_image = ImageData { + width: 160, + height: 90, + data: vec![0; 160 * 90 * 4], + ..ImageData::default() + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&icon_match, 1_000).thumbnail, + ThumbnailKind::Content + ); + + let mut path_match = notification(); + path_match.attribution.badge_icon = "example".to_string(); + path_match.image.content_image = ImageData::default(); + assert_eq!( + PopupEntryViewModel::for_notification_at(&path_match, 1_000).thumbnail, + ThumbnailKind::None + ); + + let mut no_match = path_match; + no_match.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&no_match, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn invalid_image_dimensions_do_not_create_thumbnail_content() { + let mut view = notification(); + for (width, height) in [(0, 0), (64, 64), (96, 72), (128, 128), (129, 129)] { + view.image.content_image = ImageData { + width, + height, + data: if width > 0 && height > 0 { + vec![0; 4] + } else { + Vec::new() + }, + ..ImageData::default() + }; + let expected = if width > 0 && height > 0 { + ThumbnailKind::Content + } else { + ThumbnailKind::None + }; + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + expected + ); + } +} + +#[test] +fn square_content_is_rendered_as_notification_content() { + let mut view = notification(); + view.image.content_image = ImageData { + width: 64, + height: 64, + data: vec![0; 64 * 64 * 4], + ..ImageData::default() + }; + + assert_eq!( + PopupEntryViewModel::for_notification_at(&view, 1_000).thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn conflicting_claim_keeps_communication_layout_and_drops_actions() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + AttributionReason::ExecutableMismatch, + "source /tmp/fake", + "conflict:example-chat".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + assert_eq!(model.kind, PopupKind::Communication); + assert_eq!( + model.secondary_claim.as_deref(), + Some("Claimed app: Example Chat") + ); + assert!(model.primary_actions.is_empty()); + assert!(model.overflow_actions.is_empty()); +} diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs new file mode 100644 index 000000000..73a12339a --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/trust.rs @@ -0,0 +1,5 @@ +//! Popup naming for shared trust and reply presentation + +pub(in crate::ui::entry) use unixnotis_ui::presentation::{ + ReplyPresentation, TrustPresentation as PopupTrustPresentation, +}; diff --git a/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs new file mode 100644 index 000000000..0dbecc725 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/presentation/view_model.rs @@ -0,0 +1,69 @@ +//! Popup adapter over the shared non-GTK notification presentation + +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::NotificationView; +use unixnotis_ui::presentation::{NotificationPresentation, VisualPresentation}; + +use super::{PopupKind, PopupTrustPresentation}; + +pub(in crate::ui::entry) use unixnotis_ui::presentation::{ + ActionView as ActionViewModel, ThumbnailKind, +}; + +/// Popup field names retained as a thin adapter for the kind-specific GTK builders +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::entry) struct PopupEntryViewModel { + pub(in crate::ui::entry) kind: PopupKind, + pub(in crate::ui::entry) app_label: String, + pub(in crate::ui::entry) secondary_claim: Option, + pub(in crate::ui::entry) badge: unixnotis_ui::presentation::BadgePresentation, + pub(in crate::ui::entry) timestamp_label: String, + pub(in crate::ui::entry) title: String, + pub(in crate::ui::entry) body: Option, + pub(in crate::ui::entry) thumbnail: ThumbnailKind, + // Carry the shared visual decision so GTK builders do not derive it again + pub(in crate::ui::entry) visuals: VisualPresentation, + pub(in crate::ui::entry) default_action_key: Option, + pub(in crate::ui::entry) primary_actions: Vec, + pub(in crate::ui::entry) overflow_actions: Vec, + pub(in crate::ui::entry) trust: PopupTrustPresentation, + pub(in crate::ui::entry) critical: bool, +} + +impl PopupEntryViewModel { + pub(in crate::ui::entry) fn for_notification(notification: &NotificationView) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + Self::for_notification_at(notification, now) + } + + pub(in crate::ui::entry) fn for_notification_at( + notification: &NotificationView, + now: i64, + ) -> Self { + Self::from_shared(NotificationPresentation::from_view_at(notification, now)) + } + + fn from_shared(shared: NotificationPresentation) -> Self { + Self { + kind: shared.kind, + app_label: shared.identity.primary_label, + secondary_claim: shared.identity.secondary_claim, + badge: shared.identity.badge, + timestamp_label: shared.timestamp, + title: shared.title, + body: shared.body, + thumbnail: shared.media.thumbnail, + visuals: shared.visuals, + default_action_key: shared.actions.default_key, + primary_actions: shared.actions.primary, + overflow_actions: shared.actions.overflow, + trust: shared.trust, + critical: shared.critical, + } + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/tests/activation.rs b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs new file mode 100644 index 000000000..cfb89c20a --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/tests/activation.rs @@ -0,0 +1,212 @@ +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +use super::{connect_default_action, mark_interactive}; +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::{PopupEntryViewModel, PopupKind, ReplyPresentation}; +use unixnotis_ui::presentation::default_activation::{ + is_default_activation_key, picked_widget_blocks_default_action, +}; +use unixnotis_ui::presentation::{ + BadgePresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, TrustPresentation, + VisualPresentation, +}; + +const KEY: NotificationKey = NotificationKey { + id: 41, + generation: 3, +}; + +#[gtk::test] +fn clicking_overflow_menu_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let menu = gtk::MenuButton::new(); + mark_interactive(&menu); + root.append(&menu); + assert_pick_does_not_dispatch(&root, &menu); +} + +#[gtk::test] +fn clicking_reply_entry_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let reply = gtk::Box::new(gtk::Orientation::Vertical, 0); + mark_interactive(&reply); + let entry = gtk::Entry::new(); + reply.append(&entry); + root.append(&reply); + assert_pick_does_not_dispatch(&root, &entry); +} + +#[gtk::test] +fn clicking_reply_button_does_not_invoke_default() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let button = gtk::Button::with_label("Reply"); + mark_interactive(&button); + root.append(&button); + assert_pick_does_not_dispatch(&root, &button); +} + +#[gtk::test] +fn clicking_plain_card_content_invokes_default_once() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let label = gtk::Label::new(Some("Message")); + root.append(&label); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(2); + + dispatch_default_action_for_test( + root.upcast_ref(), + Some(label.upcast()), + KEY, + "default", + &command_tx, + ); + + assert_default_command(&mut command_rx); + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn default_action_card_is_focusable_and_keyboard_activatable() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(2); + let view = PopupEntryViewModel { + kind: PopupKind::Utility, + app_label: "Example".to_string(), + secondary_claim: None, + badge: BadgePresentation::AuthenticatedApplication, + timestamp_label: "now".to_string(), + title: "Update complete".to_string(), + body: None, + thumbnail: ThumbnailKind::None, + visuals: VisualPresentation { + sender: SenderVisualPresentation::None, + content_image: false, + }, + default_action_key: Some("default".to_string()), + primary_actions: Vec::new(), + overflow_actions: Vec::new(), + trust: TrustPresentation { + level: TrustLevel::Verified, + short_label: None, + details_label: None, + reply: ReplyPresentation::Hidden, + }, + critical: false, + }; + + connect_default_action(&root, KEY, &view, &command_tx); + + assert!(root.is_focusable()); + assert_eq!(root.accessible_role(), gtk::AccessibleRole::Button); + assert!(is_default_activation_key(gtk::gdk::Key::Return)); + assert!(is_default_activation_key(gtk::gdk::Key::KP_Enter)); + assert!(is_default_activation_key(gtk::gdk::Key::space)); + assert!(!is_default_activation_key(gtk::gdk::Key::Escape)); +} + +#[gtk::test] +fn keyboard_default_action_requires_card_focus_and_enter_or_space() { + for key in [ + gtk::gdk::Key::Return, + gtk::gdk::Key::KP_Enter, + gtk::gdk::Key::space, + ] { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + assert_eq!( + handle_default_action_for_test(true, key, KEY, "default", &command_tx), + gtk::glib::Propagation::Stop + ); + assert_default_command(&mut command_rx); + } + + for (focused, key) in [ + (false, gtk::gdk::Key::Return), + (true, gtk::gdk::Key::Escape), + (false, gtk::gdk::Key::Escape), + ] { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + assert_eq!( + handle_default_action_for_test(focused, key, KEY, "default", &command_tx), + gtk::glib::Propagation::Proceed + ); + assert!(command_rx.try_recv().is_err()); + } +} + +fn assert_pick_does_not_dispatch>(root: >k::Box, picked: &W) { + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + dispatch_default_action_for_test( + root.upcast_ref(), + Some(picked.clone().upcast()), + KEY, + "default", + &command_tx, + ); + assert!(command_rx.try_recv().is_err()); +} + +fn handle_default_action_for_test( + root_has_focus: bool, + key: gtk::gdk::Key, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) -> gtk::glib::Propagation { + if !root_has_focus || !is_default_activation_key(key) { + return gtk::glib::Propagation::Proceed; + } + invoke_default_action_for_test(notification, action_key, command_tx); + gtk::glib::Propagation::Stop +} + +fn dispatch_default_action_for_test( + root: >k::Widget, + picked: Option, + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + if picked_widget_blocks_default_action(root, picked) { + return; + } + invoke_default_action_for_test(notification, action_key, command_tx); +} + +fn invoke_default_action_for_test( + notification: NotificationKey, + action_key: &str, + command_tx: &tokio::sync::mpsc::Sender, +) { + try_send_command_for_test( + command_tx, + UiCommand::InvokeAction { + notification, + action_key: action_key.to_string(), + confirmed: false, + }, + ); +} + +fn try_send_command_for_test( + command_tx: &tokio::sync::mpsc::Sender, + command: UiCommand, +) { + command_tx + .try_send(command) + .expect("test command channel has capacity"); +} + +fn assert_default_command(command_rx: &mut tokio::sync::mpsc::Receiver) { + match command_rx.try_recv().expect("default action command") { + UiCommand::InvokeAction { + notification, + action_key, + confirmed, + } => { + assert_eq!(notification, KEY); + assert_eq!(action_key, "default"); + assert!(!confirmed, "card activation should not claim confirmation"); + } + command => panic!("unexpected command: {command:?}"), + } +} diff --git a/crates/unixnotis-popups/src/ui/entry/tests/build.rs b/crates/unixnotis-popups/src/ui/entry/tests/build.rs index 418b2cff3..02e2e6d8c 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/build.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/build.rs @@ -1,20 +1,104 @@ -use super::{popup_header_spacer_expands, widget_type_blocks_default_action}; -use gtk::glib::prelude::StaticType; +use super::connect_close_action; +use crate::ui::entry::activation::connect_default_action; +use gtk::prelude::*; +use unixnotis_core::{ + Action, AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, + NotificationImage, NotificationView, +}; -#[test] -fn popup_header_spacer_expands_to_hold_close_alignment() { - // The spacer owns unused header width so the close button stays aligned - assert!(popup_header_spacer_expands()); +use crate::dbus::UiCommand; +use crate::ui::entry::presentation::PopupEntryViewModel; + +#[gtk::test] +fn close_button_dispatches_only_the_notification_dismissal() { + let close = gtk::Button::new(); + let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); + let notification = notification(); + connect_close_action(&close, notification.key(), &command_tx); + + close.emit_clicked(); + + match command_rx.try_recv().expect("queued dismiss command") { + UiCommand::Dismiss(key) => assert_eq!(key, notification.key()), + command => panic!("unexpected command: {command:?}"), + } } #[gtk::test] -fn default_card_action_is_blocked_for_button_widgets() { - // Button clicks must remain owned by the button action - assert!(widget_type_blocks_default_action(gtk::Button::static_type())); +fn exact_default_action_adds_card_click_handling() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.key(), &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 2); + assert!(root.is_focusable()); } #[gtk::test] -fn default_card_action_is_allowed_for_plain_content_widgets() { - // Plain card content may use the notification default action - assert!(!widget_type_blocks_default_action(gtk::Label::static_type())); +fn blank_default_action_still_adds_card_click_handling() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.key(), &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 2); + assert!(root.is_focusable()); +} + +#[gtk::test] +fn nondefault_action_does_not_make_the_whole_card_clickable() { + let root = gtk::Box::new(gtk::Orientation::Vertical, 0); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let mut view = notification(); + view.actions.push(Action { + key: "details".to_string(), + label: "Details".to_string(), + }); + let model = PopupEntryViewModel::for_notification_at(&view, 1_000); + + connect_default_action(&root, view.key(), &model, &command_tx); + + assert_eq!(root.observe_controllers().n_items(), 0); +} + +pub(super) fn notification() -> NotificationView { + NotificationView { + id: 31, + generation: 1, + app_name: "Example".to_string(), + attribution: NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), + summary: "Example".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs index d4beda4ea..cdf7b9535 100644 --- a/crates/unixnotis-popups/src/ui/entry/tests/commands.rs +++ b/crates/unixnotis-popups/src/ui/entry/tests/commands.rs @@ -1,13 +1,21 @@ use super::try_send_command; use crate::dbus::UiCommand; +use unixnotis_core::NotificationKey; #[test] fn available_command_queue_receives_dismiss_without_delay() { let (tx, mut rx) = tokio::sync::mpsc::channel(1); - try_send_command(&tx, UiCommand::Dismiss(42)); + let notification = NotificationKey { + id: 42, + generation: 5, + }; + try_send_command(&tx, UiCommand::Dismiss(notification)); - assert!(matches!(rx.try_recv(), Ok(UiCommand::Dismiss(42)))); + assert!(matches!( + rx.try_recv(), + Ok(UiCommand::Dismiss(key)) if key == notification + )); } #[test] @@ -19,8 +27,12 @@ fn closed_command_queue_drops_action_without_panicking() { try_send_command( &tx, UiCommand::InvokeAction { - id: 7, + notification: NotificationKey { + id: 7, + generation: 9, + }, action_key: "open".to_string(), + confirmed: false, }, ); } diff --git a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs b/crates/unixnotis-popups/src/ui/entry/tests/labels.rs deleted file mode 100644 index 554747738..000000000 --- a/crates/unixnotis-popups/src/ui/entry/tests/labels.rs +++ /dev/null @@ -1,40 +0,0 @@ -use super::{ - clamp_label_text, optional_label_state, POPUP_BODY_MAX_CHARS, POPUP_SUMMARY_MAX_CHARS, -}; - -#[test] -fn summary_row_hides_when_text_is_empty() { - let state = optional_label_state("", POPUP_SUMMARY_MAX_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn body_row_hides_when_text_is_only_whitespace() { - let state = optional_label_state("\n\t ", POPUP_BODY_MAX_CHARS); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn zero_length_limit_hides_nonempty_text() { - let state = optional_label_state("hello", 0); - - assert!(!state.visible); - assert!(state.text.is_empty()); -} - -#[test] -fn visible_text_preserves_surrounding_whitespace() { - let state = optional_label_state(" hello ", POPUP_SUMMARY_MAX_CHARS); - - assert!(state.visible); - assert_eq!(state.text.as_ref(), " hello "); -} - -#[test] -fn clamp_preserves_utf8_boundaries_and_adds_ellipsis() { - assert_eq!(clamp_label_text("éclair", 2).as_ref(), "éc…"); -} diff --git a/crates/unixnotis-popups/src/ui/entry/visibility.rs b/crates/unixnotis-popups/src/ui/entry/visibility.rs new file mode 100644 index 000000000..cca2aad59 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/entry/visibility.rs @@ -0,0 +1,49 @@ +//! Generation-bound popup visibility reporting + +use std::cell::Cell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +use super::try_send_command; +use crate::dbus::UiCommand; + +#[derive(Clone)] +pub(in crate::ui) struct PopupVisibilityBinding { + key: Rc>, + reported: Rc>>, +} + +impl PopupVisibilityBinding { + pub(in crate::ui) fn new(key: NotificationKey) -> Self { + Self { + key: Rc::new(Cell::new(key)), + reported: Rc::new(Cell::new(None)), + } + } + + pub(in crate::ui) fn bind_generation(&self, key: NotificationKey) { + if self.key.get() == key { + return; + } + // A same-ID replacement needs its own visibility acknowledgement + self.key.set(key); + self.reported.set(None); + } + + pub(in crate::ui) fn report_if_visible( + &self, + revealer: >k::Revealer, + window: >k::ApplicationWindow, + command_tx: &tokio::sync::mpsc::Sender, + ) { + let key = self.key.get(); + if self.reported.get() == Some(key) || !window.is_mapped() || !revealer.is_child_revealed() + { + return; + } + self.reported.set(Some(key)); + try_send_command(command_tx, UiCommand::Visible(key)); + } +} diff --git a/crates/unixnotis-popups/src/ui/icon_state.rs b/crates/unixnotis-popups/src/ui/icon_state.rs deleted file mode 100644 index d1a8a6be3..000000000 --- a/crates/unixnotis-popups/src/ui/icon_state.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Icon decode, cache, and widget construction for popups -//! -//! Keeps icon decoding, caching, and texture reuse isolated from UI state handling - -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -use gtk::glib::object::Cast; -use gtk::prelude::*; -use gtk::{gdk, glib}; -use tracing::debug; -use unixnotis_core::NotificationView; - -use super::icons::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, - IconDecodePool, IconDecodeResult, -}; -use super::state::IconCacheEntry; -use super::UiState; - -const ICON_CACHE_MAX_ENTRIES: usize = 256; -// Skip caching decoded textures above this size to avoid holding large buffers -const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1024 * 1024; -// Popup icon size is fixed so rows stay visually consistent across icon sources -const POPUP_ICON_SIZE: i32 = 20; -// Missing icons are retried soon so package and theme installs heal without a process restart -const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); - -impl UiState { - pub(super) fn build_image_widget( - &mut self, - notification: &NotificationView, - ) -> Option { - self.refresh_icon_sources_if_needed(); - let image = ¬ification.image; - if let Some(texture) = image_data_texture(image) { - let widget = gtk::Image::from_paintable(Some(&texture)); - set_popup_icon_size(&widget, POPUP_ICON_SIZE); - return Some(widget); - } - - if !image.image_path.is_empty() { - let path = image.image_path.as_str(); - return self.resolve_icon_widget(path, POPUP_ICON_SIZE); - } - - let cache_key = format!("{}|{}", notification.app_name, notification.image.icon_name); - if let Some(cached) = self.icon_cache.get(&cache_key) { - if let Some(icon_name) = &cached.resolved { - return self.resolve_icon_widget(icon_name, POPUP_ICON_SIZE); - } - if negative_cache_is_fresh(cached.cached_at, Instant::now()) { - return None; - } - // Expired misses fall through to a real desktop and icon-theme lookup - self.icon_cache.remove(&cache_key); - self.icon_cache_order.retain(|key| key != &cache_key); - } - - let candidates = collect_icon_candidates(notification); - // Keep the first successful resolve to avoid duplicate theme lookups and widget creation - let mut resolved: Option<(String, gtk::Image)> = None; - - for candidate in &candidates { - if let Some(icon_names) = self.desktop_icons.icons_for(candidate) { - for icon_name in icon_names { - if let Some(widget) = - self.resolve_icon_widget(icon_name.as_str(), POPUP_ICON_SIZE) - { - resolved = Some((icon_name, widget)); - break; - } - } - if resolved.is_some() { - break; - } - } - } - - if resolved.is_none() { - for candidate in candidates { - if let Some(widget) = self.resolve_icon_widget(&candidate, POPUP_ICON_SIZE) { - resolved = Some((candidate, widget)); - break; - } - } - } - - if let Some((icon_name, widget)) = resolved { - self.cache_icon(cache_key, Some(icon_name)); - Some(widget) - } else { - self.cache_icon(cache_key, None); - None - } - } - - fn cache_icon(&mut self, cache_key: String, resolved: Option) { - let cached = IconCacheEntry { - resolved, - cached_at: Instant::now(), - }; - // Bound the icon cache to avoid unbounded growth in long-running sessions - match self.icon_cache.entry(cache_key) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - entry.insert(cached); - return; - } - std::collections::hash_map::Entry::Vacant(entry) => { - let key = entry.key().clone(); - entry.insert(cached); - self.icon_cache_order.push_back(key); - } - } - while self.icon_cache_order.len() > ICON_CACHE_MAX_ENTRIES { - if let Some(evicted) = self.icon_cache_order.pop_front() { - self.icon_cache.remove(&evicted); - } - } - } - - pub(super) fn invalidate_icon_sources(&mut self) { - // Positive names remain useful while misses must be retried against the rebuilt index - self.desktop_icons.rebuild(); - self.icon_cache.retain(|_, entry| entry.resolved.is_some()); - self.icon_cache_order - .retain(|key| self.icon_cache.contains_key(key)); - self.icon_sources_dirty.set(false); - } - - fn refresh_icon_sources_if_needed(&mut self) { - if self.icon_sources_dirty.replace(false) { - self.invalidate_icon_sources(); - } - } - - fn resolve_icon_widget(&self, name: &str, size: i32) -> Option { - if let Some(file_path) = file_path_from_hint(name) { - // Decoded file:// paths allow loading icon files with escaped characters - if file_path.is_file() { - // Reuse a cached texture when available to avoid repeated decode work - if let Some(texture) = self.icon_texture_cache.borrow_mut().get(&file_path, size) { - let widget = gtk::Image::new(); - widget.set_paintable(Some(&texture)); - set_popup_icon_size(&widget, size); - return Some(widget); - } - return Some(self.spawn_file_icon(file_path, size)); - } - } - let widget = resolve_icon_image(name, size)?; - set_popup_icon_size(&widget, size); - Some(widget) - } - - fn spawn_file_icon(&self, path: PathBuf, size: i32) -> gtk::Image { - let widget = gtk::Image::new(); - set_popup_icon_size(&widget, size); - let (tx, rx) = async_channel::bounded::(1); - let widget_clone = widget.clone(); - let cache = self.icon_texture_cache.clone(); - let path_clone = path.clone(); - let target_size = size.max(1); - // Apply the texture on the main loop to avoid GTK thread violations - glib::MainContext::default().spawn_local(async move { - if let Ok(result) = rx.recv().await { - match result { - Ok(icon) => { - let bytes = glib::Bytes::from(&icon.bytes); - let texture = gdk::MemoryTexture::new( - icon.width, - icon.height, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - icon.stride as usize, - ) - .upcast::(); - widget_clone.set_paintable(Some(&texture)); - set_popup_icon_size(&widget_clone, target_size); - // Cache only modestly sized textures to limit resident memory - if icon.bytes.len() <= ICON_TEXTURE_CACHE_MAX_BYTES { - cache.borrow_mut().insert(path_clone, target_size, texture); - } - } - Err(err) => { - debug!(?err, "popup icon decode failed"); - } - } - } - }); - - // Decode on a background worker pool to avoid spawning unbounded threads - IconDecodePool::global().submit(path, target_size, tx); - - widget - } -} - -fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { - now.saturating_duration_since(cached_at) < NEGATIVE_ICON_CACHE_TTL -} - -#[cfg(test)] -#[path = "tests/icon_state.rs"] -mod tests; - -fn set_popup_icon_size(widget: >k::Image, size: i32) { - let size = size.max(1); - // Enforce a fixed icon footprint so file-backed and themed icons align - widget.set_pixel_size(size); - widget.set_size_request(size, size); -} diff --git a/crates/unixnotis-popups/src/ui/icons/cache.rs b/crates/unixnotis-popups/src/ui/icons/cache.rs index 86103331b..a50d9ce1a 100644 --- a/crates/unixnotis-popups/src/ui/icons/cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/cache.rs @@ -186,6 +186,12 @@ impl TextureCache { self.enforce_limit(); } + pub(crate) fn clear(&mut self) { + // Source changes can replace file contents at the same path + self.entries.clear(); + self.order.clear(); + } + fn bump(&mut self, key: &IconRequestKey) { // Move the key to the back to reflect recent use if let Some(pos) = self.order.iter().position(|entry| entry == key) { diff --git a/crates/unixnotis-popups/src/ui/icons/content.rs b/crates/unixnotis-popups/src/ui/icons/content.rs new file mode 100644 index 000000000..46f45e52b --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/content.rs @@ -0,0 +1,101 @@ +//! Caller-supplied notification content image decoding + +use gtk::gdk; +use gtk::glib::object::Cast; +use unixnotis_core::{ImageData, NotificationImage}; + +pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option { + // Content images stay separate from the authenticated application badge + if image.content_image.data.is_empty() { + return None; + } + + image_data_texture_for_data(&image.content_image) +} + +pub(in crate::ui) fn image_data_texture_for_data(data: &ImageData) -> Option { + // GTK memory textures need positive dimensions and eight-bit channels + if data.bits_per_sample != 8 || data.rowstride < 0 || data.width <= 0 || data.height <= 0 { + return None; + } + + let width = usize::try_from(data.width).ok()?; + let height = usize::try_from(data.height).ok()?; + let width_i32 = i32::try_from(width).ok()?; + let height_i32 = i32::try_from(height).ok()?; + + let (bytes, stride) = match data.channels { + 4 => { + // Row padding is valid, but every visible pixel must fit in each row + let min_stride = width.checked_mul(4)?; + let stride = if data.rowstride > 0 { + usize::try_from(data.rowstride).ok()? + } else { + min_stride + }; + if stride < min_stride || data.data.len() < stride.checked_mul(height)? { + return None; + } + (gtk::glib::Bytes::from(&data.data), stride) + } + 3 => { + // GTK has no matching packed RGB format here, so add an opaque alpha channel + let (expanded, stride) = expand_rgb_to_rgba(data)?; + (gtk::glib::Bytes::from(&expanded), stride) + } + _ => return None, + }; + + Some( + gdk::MemoryTexture::new( + width_i32, + height_i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + stride, + ) + .upcast::(), + ) +} + +fn expand_rgb_to_rgba(data: &ImageData) -> Option<(Vec, usize)> { + // Every multiplication is checked before allocating or slicing image storage + let width = usize::try_from(data.width).ok()?; + let height = usize::try_from(data.height).ok()?; + if width == 0 || height == 0 { + return None; + } + + let min_source_stride = width.checked_mul(3)?; + let source_stride = if data.rowstride > 0 { + usize::try_from(data.rowstride).ok()? + } else { + min_source_stride + }; + if source_stride < min_source_stride || data.data.len() < source_stride.checked_mul(height)? { + return None; + } + + let target_stride = width.checked_mul(4)?; + let mut rgba = vec![0; target_stride.checked_mul(height)?]; + for row in 0..height { + // Source padding is skipped while target rows remain tightly packed + let source_start = row.checked_mul(source_stride)?; + let target_start = row.checked_mul(target_stride)?; + let source = &data.data[source_start..source_start + min_source_stride]; + let target = &mut rgba[target_start..target_start + target_stride]; + for column in 0..width { + let source_pixel = column * 3; + let target_pixel = column * 4; + target[target_pixel..target_pixel + 3] + .copy_from_slice(&source[source_pixel..source_pixel + 3]); + target[target_pixel + 3] = u8::MAX; + } + } + + Some((rgba, target_stride)) +} + +#[cfg(test)] +#[path = "tests/content.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/icons/decode.rs b/crates/unixnotis-popups/src/ui/icons/decode.rs index 2818c9d63..fe249adb4 100644 --- a/crates/unixnotis-popups/src/ui/icons/decode.rs +++ b/crates/unixnotis-popups/src/ui/icons/decode.rs @@ -2,11 +2,13 @@ //! //! Keeps image decoding and size limits away from GTK widget code -use std::fs; +use std::fs::File; +use std::io::{self, Read}; use std::path::Path; use image::imageops::FilterType; use image::{ImageReader, Limits}; +use rustix::fs::{open, Mode, OFlags}; #[derive(Clone)] pub struct RasterIcon { @@ -24,40 +26,36 @@ const MAX_ICON_SOURCE_DIMENSION: u32 = 2048; const MAX_ICON_DECODE_ALLOC_BYTES: u64 = 16 * 1024 * 1024; pub fn decode_icon_file(path: &Path, target_size: i32) -> Result { - // Decode on a worker thread; keep I/O and CPU-bound work off the GTK main loop - let metadata = fs::metadata(path).map_err(|err| err.to_string())?; - if !metadata.is_file() { - // Directories and special files are rejected before image parsing starts - return Err("icon path is not a regular file".to_string()); - } - if metadata.len() > MAX_ICON_BYTES { - // Oversized files are rejected early to cap decode memory use - return Err(format!("icon file too large ({} bytes)", metadata.len())); - } + // Single descriptor-backed read captures the complete source before decode. + // O_NOFOLLOW rejects last-component symlinks; O_NONBLOCK avoids blocking on + // FIFOs or device files. This closes the TOCTOU window where a regular file + // could be swapped for a FIFO between metadata and decode calls. + let bytes = read_icon_file_bounded(path)?; - let (width, height) = image::image_dimensions(path).map_err(|err| err.to_string())?; - if width > MAX_ICON_SOURCE_DIMENSION || height > MAX_ICON_SOURCE_DIMENSION { - // Header checks reject very large rasters before a full pixel decode happens - return Err(format!( - "icon dimensions exceed popup decode limit ({width}x{height})" - )); - } + // Probe format from content, not extension, so disguised files are caught + let _format = + image::guess_format(&bytes).map_err(|err| format!("icon format probe failed: {err}"))?; let mut limits = Limits::default(); limits.max_image_width = Some(MAX_ICON_SOURCE_DIMENSION); limits.max_image_height = Some(MAX_ICON_SOURCE_DIMENSION); limits.max_alloc = Some(MAX_ICON_DECODE_ALLOC_BYTES); - let mut reader = ImageReader::open(path).map_err(|err| err.to_string())?; - if reader.format().is_none() { - // Extension-free temp paths still need content sniffing before decode - reader = reader - .with_guessed_format() - .map_err(|err| err.to_string())?; - } + let mut reader = ImageReader::new(io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| err.to_string())?; reader.limits(limits); let mut image = reader.decode().map_err(|err| err.to_string())?; + let width = image.width(); + let height = image.height(); + if width > MAX_ICON_SOURCE_DIMENSION || height > MAX_ICON_SOURCE_DIMENSION { + // Header checks reject very large rasters before a full pixel decode happens + return Err(format!( + "icon dimensions exceed popup decode limit ({width}x{height})" + )); + } + let target = target_size.max(1) as u32; // Normalize to the popup icon target so file-backed icons match themed icon sizing image = image.resize(target, target, FilterType::Lanczos3); @@ -82,6 +80,44 @@ pub fn decode_icon_file(path: &Path, target_size: i32) -> Result Result, String> { + // Open with NOFOLLOW to reject last-component symlinks and NONBLOCK to + // avoid hanging on FIFOs or device files + let descriptor = open( + path, + OFlags::CLOEXEC + .union(OFlags::NOFOLLOW) + .union(OFlags::NONBLOCK), + Mode::empty(), + ) + .map_err(|err| err.to_string())?; + let file = File::from(descriptor); + + // Metadata and content come from the same descriptor even if the path changes later + let metadata = file.metadata().map_err(|err| err.to_string())?; + if !metadata.is_file() { + return Err("icon path is not a regular file".to_string()); + } + if metadata.len() > MAX_ICON_BYTES { + return Err(format!("icon file too large ({} bytes)", metadata.len())); + } + + let capacity = usize::try_from(metadata.len()).unwrap_or(0); + let max_capacity = usize::try_from(MAX_ICON_BYTES).unwrap_or(usize::MAX); + let mut bytes = Vec::with_capacity(capacity.min(max_capacity)); + + // One extra byte detects a regular file that grew after the metadata snapshot + file.take(MAX_ICON_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|err| err.to_string())?; + let observed = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if observed > MAX_ICON_BYTES { + return Err(format!("icon file too large ({observed} bytes)")); + } + + Ok(bytes) +} + #[cfg(test)] #[path = "tests/decode.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/icons/mod.rs b/crates/unixnotis-popups/src/ui/icons/mod.rs index 4e8af8b49..ed6d21752 100644 --- a/crates/unixnotis-popups/src/ui/icons/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/mod.rs @@ -1,11 +1,14 @@ //! Popup icon lookup, decoding, and cache ownership mod cache; +mod content; mod decode; mod resolver; +mod state; +mod theme_cache; pub(super) use cache::{IconDecodePool, IconDecodeResult, TextureCache}; +pub(super) use content::{image_data_texture, image_data_texture_for_data}; pub(super) use decode::{decode_icon_file, RasterIcon}; -pub(super) use resolver::{ - collect_icon_candidates, file_path_from_hint, image_data_texture, resolve_icon_image, -}; +pub(super) use resolver::{collect_icon_candidates, file_path_from_hint}; +pub(super) use theme_cache::ThemeIconCache; diff --git a/crates/unixnotis-popups/src/ui/icons/resolver.rs b/crates/unixnotis-popups/src/ui/icons/resolver.rs index fd43c09b9..c9b03fe13 100644 --- a/crates/unixnotis-popups/src/ui/icons/resolver.rs +++ b/crates/unixnotis-popups/src/ui/icons/resolver.rs @@ -2,14 +2,12 @@ //! //! Separates icon lookup and image decoding from UI state management. -use std::collections::HashSet; use std::path::{Path, PathBuf}; use gio::prelude::FileExt; use gtk::gdk; -use gtk::gdk::prelude::*; -use gtk::{gdk::Texture, IconLookupFlags, IconPaintable, TextDirection}; -use unixnotis_core::{NotificationImage, NotificationView}; +use gtk::{IconLookupFlags, IconPaintable, TextDirection}; +use unixnotis_core::{AttributionStatus, NotificationView}; pub(in crate::ui) fn file_path_from_hint(path: &str) -> Option { // Accept raw absolute paths and file:// URIs, decoding percent escapes when present. @@ -29,7 +27,11 @@ pub(in crate::ui) fn file_path_from_hint(path: &str) -> Option { } // Resolve themed icon names while filtering out the missing-icon placeholder. -fn resolve_icon_paintable(name: &str, size: i32) -> Option { +pub(in crate::ui) fn resolve_icon_paintable_with_scale( + name: &str, + size: i32, + scale: i32, +) -> Option { if name.is_empty() { return None; } @@ -39,7 +41,7 @@ fn resolve_icon_paintable(name: &str, size: i32) -> Option { name, &[], size, - 1, + scale.max(1), TextDirection::Ltr, IconLookupFlags::empty(), ); @@ -53,149 +55,83 @@ fn resolve_icon_paintable(name: &str, size: i32) -> Option { Some(paintable) } -pub(in crate::ui) fn resolve_icon_image(name: &str, size: i32) -> Option { - // File-path icons are resolved asynchronously in the UI layer to avoid blocking the GTK thread. - let paintable = resolve_icon_paintable(name, size)?; - let widget = gtk::Image::from_paintable(Some(&paintable)); - widget.set_pixel_size(size); - Some(widget) -} - pub(in crate::ui) fn collect_icon_candidates(notification: &NotificationView) -> Vec { - let mut candidates = Vec::new(); - if !notification.image.icon_name.is_empty() { - candidates.push(notification.image.icon_name.clone()); - if let Some(stripped) = notification.image.icon_name.strip_suffix(".desktop") { - candidates.push(stripped.to_string()); - } - candidates.push(notification.image.icon_name.to_lowercase()); - } - if !notification.app_name.is_empty() { - candidates.push(notification.app_name.clone()); - let lower = notification.app_name.to_lowercase(); - let dashed = lower.replace(' ', "-"); - candidates.push(lower); - candidates.push(dashed); + // Candidate lists stay small, so ordered linear deduplication avoids a hash allocation + let mut candidates = Vec::with_capacity(12); + if notification.attribution.status == AttributionStatus::Unresolved { + push_claimed_icon_candidates(&mut candidates, notification); + push_attributed_icon_candidates(&mut candidates, notification); + } else { + push_attributed_icon_candidates(&mut candidates, notification); + push_claimed_icon_candidates(&mut candidates, notification); } - - let mut seen = HashSet::new(); candidates - .into_iter() - .filter(|candidate| !candidate.is_empty() && seen.insert(candidate.clone())) - .collect() } -fn is_missing_icon(path: &Path) -> bool { - // Filter the theme placeholder to avoid rendering a missing-icon glyph. - let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { - return false; - }; - stem.starts_with("image-missing") -} - -pub(in crate::ui) fn image_data_texture(image: &NotificationImage) -> Option { - if !image.has_image_data { - return None; - } - let data = &image.image_data; - if data.bits_per_sample != 8 { - return None; - } - // Negative rowstride is invalid for pixel buffers. - if data.rowstride < 0 { - return None; - } - - // Reject non-positive dimensions before creating the texture. - if data.width <= 0 || data.height <= 0 { - return None; +fn push_attributed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { + let badge_icon = notification.attribution.badge_icon.as_str(); + if !badge_icon.is_empty() { + push_candidate(candidates, badge_icon); + if let Some(stripped) = badge_icon.strip_suffix(".desktop") { + push_candidate(candidates, stripped); + } + let lowercase = badge_icon.to_lowercase(); + push_candidate(candidates, &lowercase); } - let width = data.width as usize; - let height = data.height as usize; - let width_i32 = i32::try_from(width).ok()?; - let height_i32 = i32::try_from(height).ok()?; - let (bytes, stride) = match data.channels { - 4 => { - // Rowstride is bytes per row; hint payloads may include padding. - let min_stride = width.checked_mul(4)?; - let stride = if data.rowstride > 0 { - data.rowstride as usize - } else { - min_stride - }; - // Validate rowstride and buffer length before building the texture. - if stride < min_stride { - return None; - } - let required = stride.checked_mul(height)?; - if data.data.len() < required { - return None; - } - (gtk::glib::Bytes::from(&data.data), stride) - } - 3 => { - let (expanded, stride) = expand_rgb_to_rgba(data)?; - (gtk::glib::Bytes::from(&expanded), stride) + let desktop_id = notification.attribution.desktop_id.as_str(); + if !desktop_id.is_empty() { + push_candidate(candidates, desktop_id); + if let Some(stripped) = desktop_id.strip_suffix(".desktop") { + push_candidate(candidates, stripped); } - _ => return None, - }; - Some( - gdk::MemoryTexture::new( - width_i32, - height_i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - stride, - ) - .upcast::(), - ) + let lowercase = desktop_id.to_lowercase(); + push_candidate(candidates, &lowercase); + } } -fn expand_rgb_to_rgba(data: &unixnotis_core::ImageData) -> Option<(Vec, usize)> { - // Expand RGB to RGBA while honoring per-row padding in the source buffer. - let width = usize::try_from(data.width).ok()?; - let height = usize::try_from(data.height).ok()?; - if width == 0 || height == 0 { - return None; +fn push_claimed_icon_candidates(candidates: &mut Vec, notification: &NotificationView) { + // Claimed names only select presentation candidates; they never prove identity + let claimed_desktop_id = notification.image.claimed_desktop_id.as_str(); + if is_safe_theme_name(claimed_desktop_id) { + push_candidate(candidates, claimed_desktop_id); + if let Some(stripped) = claimed_desktop_id.strip_suffix(".desktop") { + push_candidate(candidates, stripped); + } + let lowercase = claimed_desktop_id.to_lowercase(); + push_candidate(candidates, &lowercase); } - // Source stride handles optional per-row padding for RGB input. - let min_src_stride = width.checked_mul(3)?; - let src_stride = if data.rowstride > 0 { - data.rowstride as usize - } else { - min_src_stride - }; - if src_stride < min_src_stride { - return None; - } - let required = src_stride.checked_mul(height)?; - if data.data.len() < required { - return None; + let claimed_theme_icon = notification.image.claimed_theme_icon.as_str(); + if is_safe_theme_name(claimed_theme_icon) { + push_candidate(candidates, claimed_theme_icon); + let lowercase = claimed_theme_icon.to_lowercase(); + push_candidate(candidates, &lowercase); } +} - // Destination uses tightly packed RGBA rows. - let dst_stride = width.checked_mul(4)?; - let mut rgba = vec![0u8; dst_stride.checked_mul(height)?]; - - // Copy RGB per pixel and append opaque alpha. - for y in 0..height { - let src_row_start = y * src_stride; - let dst_row_start = y * dst_stride; - let src_row = &data.data[src_row_start..src_row_start + min_src_stride]; - let dst_row = &mut rgba[dst_row_start..dst_row_start + dst_stride]; - for x in 0..width { - let src = x * 3; - let dst = x * 4; - dst_row[dst] = src_row[src]; - dst_row[dst + 1] = src_row[src + 1]; - dst_row[dst + 2] = src_row[src + 2]; - dst_row[dst + 3] = 255; - } +fn push_candidate(candidates: &mut Vec, candidate: &str) { + if candidate.is_empty() || candidates.iter().any(|existing| existing == candidate) { + return; } + candidates.push(candidate.to_owned()); +} + +fn is_safe_theme_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.starts_with('.') + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') + }) +} - Some((rgba, dst_stride)) +fn is_missing_icon(path: &Path) -> bool { + // Filter the theme placeholder to avoid rendering a missing-icon glyph. + let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else { + return false; + }; + stem.starts_with("image-missing") } #[cfg(test)] diff --git a/crates/unixnotis-popups/src/ui/icons/state.rs b/crates/unixnotis-popups/src/ui/icons/state.rs new file mode 100644 index 000000000..118273794 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/state.rs @@ -0,0 +1,276 @@ +//! Icon decode, cache, and widget construction for popups +//! +//! Keeps icon decoding, caching, and texture reuse isolated from UI state handling + +use std::path::PathBuf; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk::glib::object::Cast; +use gtk::prelude::*; +use gtk::{gdk, glib}; +use tracing::debug; +use unixnotis_core::NotificationView; + +use super::super::state::{IconCacheEntry, IconResolutionKey}; +use super::super::UiState; +use super::{ + collect_icon_candidates, file_path_from_hint, image_data_texture, image_data_texture_for_data, + IconDecodePool, IconDecodeResult, TextureCache, ThemeIconCache, +}; + +const ICON_CACHE_MAX_ENTRIES: usize = 256; +// Skip caching decoded textures above this size to avoid holding large buffers +const ICON_TEXTURE_CACHE_MAX_BYTES: usize = 1_048_576; +// Content stays visibly separate from the daemon-associated application badge +const POPUP_CONTENT_THUMBNAIL_SIZE: i32 = 64; +// Missing icons are retried soon so package and theme installs heal without a process restart +const NEGATIVE_ICON_CACHE_TTL: Duration = Duration::from_secs(15); + +impl UiState { + pub(in crate::ui) fn build_conversation_avatar_widget( + notification: &NotificationView, + size: i32, + ) -> Option { + // Conversation art is safe to render here because the daemon sent pixels, not a path + if !matches!( + notification.image.sender_visual_role, + unixnotis_core::NotificationVisualRole::ConversationAvatar + ) { + return None; + } + + let texture = image_data_texture_for_data(¬ification.image.sender_visual)?; + let widget = gtk::Image::from_paintable(Some(&texture)); + set_popup_icon_size(&widget, size); + widget.add_css_class("unixnotis-popup-conversation-avatar"); + Some(widget) + } + + pub(in crate::ui) fn build_content_image_widget( + notification: &NotificationView, + ) -> Option { + if let Some(texture) = image_data_texture(¬ification.image) { + let widget = gtk::Image::from_paintable(Some(&texture)); + set_popup_icon_size(&widget, POPUP_CONTENT_THUMBNAIL_SIZE); + return Some(widget); + } + + None + } + + pub(in crate::ui) fn build_app_icon_widget( + &mut self, + notification: &NotificationView, + size: i32, + ) -> Option { + self.refresh_icon_sources_if_needed(); + // Authenticated inputs and bounded presentation hints share lookup, not trust authority + let cache_key = IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, + }; + if let Some(cached) = self.icon_cache.get(&cache_key) { + if let Some(icon_name) = cached.resolved.as_deref() { + return resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + icon_name, + size, + ); + } + if negative_cache_is_fresh(cached.cached_at, Instant::now()) { + return None; + } + // Expired misses fall through to a real desktop and icon-theme lookup + self.icon_cache.remove(&cache_key); + self.icon_cache_order.retain(|key| key != &cache_key); + } + + let candidates = collect_icon_candidates(notification); + // Keep the first successful resolve to avoid duplicate theme lookups and widget creation + let mut resolved: Option<(String, gtk::Image)> = None; + + for candidate in &candidates { + if let Some(icon_names) = self.desktop_icons.icons_for(candidate) { + for icon_name in icon_names { + if let Some(widget) = resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + icon_name.as_str(), + size, + ) { + resolved = Some((icon_name, widget)); + break; + } + } + if resolved.is_some() { + break; + } + } + } + + if resolved.is_none() { + for candidate in candidates { + if let Some(widget) = resolve_icon_widget( + &mut self.theme_icon_cache, + &self.icon_texture_cache, + &candidate, + size, + ) { + resolved = Some((candidate, widget)); + break; + } + } + } + + if let Some((icon_name, widget)) = resolved { + self.cache_icon(cache_key, Some(icon_name)); + Some(widget) + } else { + self.cache_icon(cache_key, None); + None + } + } + + pub(in crate::ui) fn cache_icon( + &mut self, + cache_key: IconResolutionKey, + resolved: Option, + ) { + let cached = IconCacheEntry { + resolved, + cached_at: Instant::now(), + }; + // Bound the icon cache to avoid unbounded growth in long-running sessions + match self.icon_cache.entry(cache_key) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + entry.insert(cached); + return; + } + std::collections::hash_map::Entry::Vacant(entry) => { + let key = entry.key().clone(); + entry.insert(cached); + self.icon_cache_order.push_back(key); + } + } + let excess_entries = self + .icon_cache_order + .len() + .saturating_sub(ICON_CACHE_MAX_ENTRIES); + for _ in 0..excess_entries { + if let Some(evicted) = self.icon_cache_order.pop_front() { + self.icon_cache.remove(&evicted); + } + } + } + + pub(in crate::ui) fn invalidate_icon_sources(&mut self) { + // Rebuild both lookup layers so changed desktop entries are resolved again + self.icon_source_generation = self.icon_source_generation.wrapping_add(1); + self.desktop_icons.rebuild(); + self.icon_cache.clear(); + self.icon_cache_order.clear(); + self.theme_icon_cache.clear(); + self.icon_texture_cache.borrow_mut().clear(); + self.icon_sources_dirty.set(false); + } + + pub(in crate::ui) fn refresh_icon_sources_if_needed(&mut self) { + if self.icon_sources_dirty.replace(false) { + self.invalidate_icon_sources(); + } + } +} + +fn resolve_icon_widget( + theme_icon_cache: &mut ThemeIconCache, + icon_texture_cache: &Rc>, + name: &str, + size: i32, +) -> Option { + if let Some(file_path) = file_path_from_hint(name) { + // Decoded file:// paths allow loading icon files with escaped characters + if file_path.is_file() { + // Reuse a cached texture when available to avoid repeated decode work + if let Some(texture) = icon_texture_cache.borrow_mut().get(&file_path, size) { + let widget = gtk::Image::new(); + widget.set_paintable(Some(&texture)); + set_popup_icon_size(&widget, size); + return Some(widget); + } + return Some(spawn_file_icon(icon_texture_cache, file_path, size)); + } + } + // Keep the existing lookup scale so caching does not change rendered icon selection + let paintable = theme_icon_cache.get_or_resolve(name, size, 1)?; + let widget = gtk::Image::from_paintable(Some(&paintable)); + set_popup_icon_size(&widget, size); + Some(widget) +} + +fn spawn_file_icon( + icon_texture_cache: &Rc>, + path: PathBuf, + size: i32, +) -> gtk::Image { + let widget = gtk::Image::new(); + set_popup_icon_size(&widget, size); + let (tx, rx) = async_channel::bounded::(1); + let widget_clone = widget.clone(); + let cache = Rc::clone(icon_texture_cache); + let path_clone = path.clone(); + let target_size = size.max(1); + // Apply the texture on the main loop to avoid GTK thread violations + glib::MainContext::default().spawn_local(async move { + if let Ok(result) = rx.recv().await { + match result { + Ok(icon) => { + let bytes = glib::Bytes::from(&icon.bytes); + let texture = gdk::MemoryTexture::new( + icon.width, + icon.height, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + icon.stride as usize, + ) + .upcast::(); + widget_clone.set_paintable(Some(&texture)); + set_popup_icon_size(&widget_clone, target_size); + // Cache only modestly sized textures to limit resident memory + if icon.bytes.len() <= ICON_TEXTURE_CACHE_MAX_BYTES { + cache.borrow_mut().insert(path_clone, target_size, texture); + } + } + Err(err) => { + debug!(?err, "popup icon decode failed"); + } + } + } + }); + + // Decode on a background worker pool to avoid spawning unbounded threads + IconDecodePool::global().submit(path, target_size, tx); + + widget +} + +fn negative_cache_is_fresh(cached_at: Instant, now: Instant) -> bool { + now.saturating_duration_since(cached_at) < NEGATIVE_ICON_CACHE_TTL +} + +#[cfg(test)] +#[path = "tests/state.rs"] +mod tests; + +fn set_popup_icon_size(widget: >k::Image, size: i32) { + let size = size.max(1); + // Enforce a fixed icon footprint so file-backed and themed icons align + widget.set_pixel_size(size); + widget.set_size_request(size, size); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/cache.rs b/crates/unixnotis-popups/src/ui/icons/tests/cache.rs index 7dabcc0ba..b57b2e3cb 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/cache.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/cache.rs @@ -88,3 +88,19 @@ fn texture_cache_keeps_sizes_separate() { assert!(cache.get(&path, 20).is_some()); assert!(cache.get(&path, 32).is_some()); } + +#[gtk::test] +fn texture_cache_clear_discards_all_path_and_size_entries() { + let mut cache = TextureCache::new(4); + let path = PathBuf::from("icon-test.png"); + let bytes = glib::Bytes::from_owned(vec![255; 4]); + let texture = gdk::MemoryTexture::new(1, 1, gdk::MemoryFormat::R8g8b8a8, &bytes, 4) + .upcast::(); + + cache.insert(path.clone(), 20, texture.clone()); + cache.insert(path.clone(), 32, texture); + cache.clear(); + + assert!(cache.get(&path, 20).is_none()); + assert!(cache.get(&path, 32).is_none()); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/content.rs b/crates/unixnotis-popups/src/ui/icons/tests/content.rs new file mode 100644 index 000000000..685ba4106 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/content.rs @@ -0,0 +1,41 @@ +use super::*; + +fn image_data(channels: i32, rowstride: i32, data: Vec) -> NotificationImage { + NotificationImage { + claimed_desktop_id: String::new(), + content_image: ImageData { + width: 2, + height: 1, + rowstride, + has_alpha: channels == 4, + bits_per_sample: 8, + channels, + data, + }, + ..NotificationImage::default() + } +} + +#[test] +fn rgb_content_image_expands_to_opaque_rgba() { + let image = image_data(3, 8, vec![1, 2, 3, 4, 5, 6, 90, 91]); + + let (bytes, stride) = expand_rgb_to_rgba(&image.content_image).expect("valid RGB data"); + + assert_eq!(stride, 8); + assert_eq!(bytes, vec![1, 2, 3, 255, 4, 5, 6, 255]); +} + +#[gtk::test] +fn valid_rgba_content_image_creates_a_texture() { + let image = image_data(4, 8, vec![1, 2, 3, 4, 5, 6, 7, 8]); + + assert!(image_data_texture(&image).is_some()); +} + +#[gtk::test] +fn undersized_content_buffer_is_rejected() { + let image = image_data(4, 8, vec![1, 2, 3, 4]); + + assert!(image_data_texture(&image).is_none()); +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/decode.rs b/crates/unixnotis-popups/src/ui/icons/tests/decode.rs index 83019b58e..eae1d9d0f 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/decode.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/decode.rs @@ -32,7 +32,12 @@ fn decode_icon_file_rejects_large_dimensions_before_full_decode() { let Err(err) = decode_icon_file(&path, 20) else { panic!("oversized image should fail") }; - assert!(err.contains("decode limit")); + assert!( + err.contains("decode limit") + || err.contains("dimensions exceed") + || err.contains("exceeds limit"), + "unexpected error: {err}" + ); let _ = fs::remove_file(&path); } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs index bc0158d2b..42d371019 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/candidates.rs @@ -2,7 +2,7 @@ use super::super::collect_icon_candidates; use super::support::notification; #[test] -fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { +fn collect_icon_candidates_uses_only_daemon_associated_badge_variants() { let candidates = collect_icon_candidates(¬ification("UnixNotis Center", "org.demo.App.desktop")); @@ -12,9 +12,24 @@ fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { "org.demo.App.desktop", "org.demo.App", "org.demo.app.desktop", - "UnixNotis Center", - "unixnotis center", - "unixnotis-center", + ] + ); +} + +#[test] +fn collect_icon_candidates_includes_a_distinct_desktop_id() { + let mut input = notification("UnixNotis Center", "trusted-badge"); + input.attribution.desktop_id = "org.demo.App.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates, + vec![ + "trusted-badge", + "org.demo.App.desktop", + "org.demo.App", + "org.demo.app.desktop", ] ); } @@ -23,5 +38,89 @@ fn collect_icon_candidates_prefers_icon_name_variants_then_app_name_variants() { fn collect_icon_candidates_dedupes_empty_and_repeated_values() { let candidates = collect_icon_candidates(¬ification("App", "app")); - assert_eq!(candidates, vec!["app", "App"]); + assert_eq!(candidates, vec!["app"]); +} + +#[test] +fn collect_icon_candidates_does_not_treat_content_icon_as_application_badge() { + let mut notification = notification("authenticated-app", "trusted-badge"); + notification.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates.iter().any(|value| value == "trusted-badge")); + assert!(!candidates + .iter() + .any(|value| value == "caller-controlled-content")); +} + +#[test] +fn collect_icon_candidates_keeps_a_bounded_unresolved_theme_hint_decorative() { + let mut notification = notification("Trusted Brand", "dialog-warning-symbolic"); + notification.attribution.status = unixnotis_core::AttributionStatus::Unresolved; + notification.attribution.desktop_id.clear(); + notification.attribution.claimed_name = "Trusted Brand".to_string(); + notification.image.claimed_theme_icon = "trusted-brand".to_string(); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|value| value == "dialog-warning-symbolic")); + assert!(candidates.iter().any(|value| value == "trusted-brand")); + assert!(candidates.iter().all(|value| !value.contains('/'))); +} + +#[test] +fn claimed_desktop_id_is_a_presentation_only_icon_candidate() { + let mut notification = notification("Example Chat", ""); + notification.image.claimed_desktop_id = "example-chat.desktop".to_string(); + + let candidates = collect_icon_candidates(¬ification); + + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat.desktop")); + assert!(candidates + .iter() + .any(|candidate| candidate == "example-chat")); + assert!(candidates.iter().all(|candidate| !candidate.contains('/'))); +} + +#[test] +fn unresolved_claimed_branding_precedes_the_generic_daemon_badge() { + let mut input = notification("Example Application", "application-x-executable-symbolic"); + input.image.claimed_desktop_id = "org.example.App.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.App.desktop") + ); +} + +#[test] +fn associated_branding_still_precedes_presentation_claims() { + let mut input = notification("Example Application", "org.example.associated"); + input.attribution = unixnotis_core::NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Associated", + "org.example.associated", + unixnotis_core::IdentityAssurance::SystemAssociated, + unixnotis_core::InteractionPolicies::NATIVE_COMPATIBILITY, + unixnotis_core::AttributionReason::ExactSystemExecutable, + "associated fixture", + "associated:system-app:org.example.Associated".to_string(), + ); + input.image.claimed_desktop_id = "org.example.Claimed.desktop".to_string(); + + let candidates = collect_icon_candidates(&input); + + assert_eq!( + candidates.first().map(String::as_str), + Some("org.example.associated") + ); } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs deleted file mode 100644 index d7a840b4a..000000000 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/image_data.rs +++ /dev/null @@ -1,101 +0,0 @@ -use unixnotis_core::NotificationImage; - -use super::super::{expand_rgb_to_rgba, image_data_texture}; -use super::support::image_data; - -#[test] -fn expand_rgb_to_rgba_appends_alpha() { - let data = image_data(2, 1, 0, 3, vec![10, 20, 30, 40, 50, 60]); - - let (expanded, stride) = expand_rgb_to_rgba(&data).expect("rgb expansion"); - - assert_eq!(stride, 8); - assert_eq!(expanded, vec![10, 20, 30, 255, 40, 50, 60, 255]); -} - -#[test] -fn expand_rgb_to_rgba_honors_row_padding() { - let data = image_data( - 2, - 2, - 8, - 3, - vec![1, 2, 3, 4, 5, 6, 0, 0, 7, 8, 9, 10, 11, 12, 0, 0], - ); - - let (expanded, stride) = expand_rgb_to_rgba(&data).expect("rgb expansion"); - - assert_eq!(stride, 8); - assert_eq!( - expanded, - vec![1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255] - ); -} - -#[test] -fn expand_rgb_to_rgba_rejects_empty_dimensions_short_rows_and_short_buffers() { - assert!(expand_rgb_to_rgba(&image_data(0, 1, 0, 3, vec![1, 2, 3])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(1, 0, 0, 3, vec![1, 2, 3])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(2, 1, 5, 3, vec![1, 2, 3, 4, 5])).is_none()); - assert!(expand_rgb_to_rgba(&image_data(2, 2, 0, 3, vec![1, 2, 3, 4, 5, 6])).is_none()); -} - -#[gtk::test] -fn image_data_texture_accepts_valid_rgba_and_rgb_payloads() { - let rgba = NotificationImage { - has_image_data: true, - image_data: image_data(1, 1, 0, 4, vec![1, 2, 3, 4]), - ..NotificationImage::default() - }; - let rgb = NotificationImage { - has_image_data: true, - image_data: image_data(1, 1, 0, 3, vec![1, 2, 3]), - ..NotificationImage::default() - }; - - assert!(image_data_texture(&rgba).is_some()); - assert!(image_data_texture(&rgb).is_some()); -} - -#[gtk::test] -fn image_data_texture_rejects_missing_flag_bad_bits_dimensions_and_channels() { - let mut image = NotificationImage { - has_image_data: false, - image_data: image_data(1, 1, 0, 4, vec![1, 2, 3, 4]), - ..NotificationImage::default() - }; - assert!(image_data_texture(&image).is_none()); - - image.has_image_data = true; - image.image_data.bits_per_sample = 16; - assert!(image_data_texture(&image).is_none()); - - image.image_data.bits_per_sample = 8; - image.image_data.width = 0; - assert!(image_data_texture(&image).is_none()); - - image.image_data.width = 1; - image.image_data.height = -1; - assert!(image_data_texture(&image).is_none()); - - image.image_data.height = 1; - image.image_data.channels = 2; - assert!(image_data_texture(&image).is_none()); -} - -#[gtk::test] -fn image_data_texture_rejects_bad_stride_and_short_buffers() { - let mut image = NotificationImage { - has_image_data: true, - image_data: image_data(2, 1, 7, 4, vec![0; 8]), - ..NotificationImage::default() - }; - assert!(image_data_texture(&image).is_none()); - - image.image_data.rowstride = -1; - assert!(image_data_texture(&image).is_none()); - - image.image_data.rowstride = 0; - image.image_data.data = vec![0; 7]; - assert!(image_data_texture(&image).is_none()); -} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs index 3ef072eef..99ccc5f57 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/mod.rs @@ -1,7 +1,6 @@ //! Test index for popup icon resolution mod candidates; -mod image_data; mod path_hints; mod support; mod theme_lookup; diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs index b43dc2436..3aa6e3f84 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/support.rs @@ -1,35 +1,29 @@ -use unixnotis_core::{ImageData, NotificationImage, NotificationView}; - -pub(super) fn image_data( - width: i32, - height: i32, - rowstride: i32, - channels: i32, - data: Vec, -) -> ImageData { - ImageData { - width, - height, - rowstride, - has_alpha: channels == 4, - bits_per_sample: 8, - channels, - data, - } -} +use unixnotis_core::{NotificationImage, NotificationView}; pub(super) fn notification(app_name: &str, icon_name: &str) -> NotificationView { NotificationView { id: 1, + generation: 1, app_name: app_name.to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: app_name.to_string(), + badge_icon: icon_name.to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, summary: String::new(), body: String::new(), actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, urgency: 1, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage { - icon_name: icon_name.to_string(), + badge_icon: icon_name.to_string(), ..NotificationImage::default() }, + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs b/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs index 4c93f3a87..2b5eddbfa 100644 --- a/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs +++ b/crates/unixnotis-popups/src/ui/icons/tests/resolver/theme_lookup.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use gtk::gdk; use gtk::prelude::FileExt; -use super::super::{is_missing_icon, resolve_icon_image, resolve_icon_paintable}; +use super::super::{is_missing_icon, resolve_icon_paintable_with_scale}; fn available_theme_icon() -> Option<&'static str> { // The GTK test runtime initializes the display on its dedicated thread @@ -30,8 +30,7 @@ fn is_missing_icon_detects_theme_placeholder_stems_only() { #[test] fn resolve_icon_helpers_reject_empty_icon_names() { - assert!(resolve_icon_paintable("", 24).is_none()); - assert!(resolve_icon_image("", 24).is_none()); + assert!(resolve_icon_paintable_with_scale("", 24, 1).is_none()); } #[gtk::test] @@ -41,7 +40,8 @@ fn resolve_icon_image_uses_theme_icon_and_sets_requested_size() { return; }; - let paintable = resolve_icon_paintable(icon_name, 24).expect("theme icon paintable"); + let paintable = + resolve_icon_paintable_with_scale(icon_name, 24, 1).expect("theme icon paintable"); assert!(!is_missing_icon( &paintable .file() @@ -49,7 +49,8 @@ fn resolve_icon_image_uses_theme_icon_and_sets_requested_size() { .unwrap_or_else(|| PathBuf::from(icon_name)) )); - let image = resolve_icon_image(icon_name, 24).expect("theme icon image"); + let image = gtk::Image::from_paintable(Some(&paintable)); + image.set_pixel_size(24); assert_eq!(image.pixel_size(), 24); } diff --git a/crates/unixnotis-popups/src/ui/icons/tests/state.rs b/crates/unixnotis-popups/src/ui/icons/tests/state.rs new file mode 100644 index 000000000..141aeb57f --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/state.rs @@ -0,0 +1,230 @@ +use super::*; +use image::{ImageBuffer, ImageFormat, Rgba}; +use std::cell::RefCell; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::{SystemTime, UNIX_EPOCH}; +use unixnotis_core::{Config, ThemePaths}; +use unixnotis_ui::css::CssManager; + +use crate::ui::state::UiState; + +#[test] +fn negative_icon_cache_expires_at_the_ttl_boundary() { + let now = Instant::now(); + + let fresh = now + .checked_sub(Duration::from_secs(14)) + .expect("fresh timestamp should remain representable"); + let expired = now + .checked_sub(NEGATIVE_ICON_CACHE_TTL) + .expect("expired timestamp should remain representable"); + + assert!(negative_cache_is_fresh(fresh, now)); + assert!(!negative_cache_is_fresh(expired, now)); +} + +#[test] +fn negative_icon_cache_handles_future_timestamp_without_panicking() { + let now = Instant::now(); + + assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); +} + +#[gtk::test] +fn expired_negative_cache_replaces_its_old_order_marker_once() { + let mut state = popup_state("org.unixnotis.PopupExpiredIconCache"); + let notification = icon_notification("dialog-information"); + let cache_key = icon_cache_key(¬ification); + + state.icon_cache.insert( + cache_key.clone(), + IconCacheEntry { + resolved: None, + cached_at: Instant::now() + .checked_sub(NEGATIVE_ICON_CACHE_TTL) + .expect("test timestamp should remain representable"), + }, + ); + state.icon_cache_order.push_back(cache_key.clone()); + + assert!(state.build_app_icon_widget(¬ification, 20).is_some()); + assert_eq!( + state + .icon_cache_order + .iter() + .filter(|key| *key == &cache_key) + .count(), + 1 + ); +} + +#[gtk::test] +fn icon_cache_evicts_only_after_the_configured_limit_is_exceeded() { + let mut state = popup_state("org.unixnotis.PopupIconCacheLimit"); + + for index in 0..ICON_CACHE_MAX_ENTRIES { + state.cache_icon( + test_cache_key(&format!("icon-{index}")), + Some("folder".to_string()), + ); + } + assert_eq!(state.icon_cache.len(), ICON_CACHE_MAX_ENTRIES); + assert!(state.icon_cache.contains_key(&test_cache_key("icon-0"))); + + state.cache_icon( + test_cache_key(&format!("icon-{ICON_CACHE_MAX_ENTRIES}")), + Some("folder".to_string()), + ); + assert_eq!(state.icon_cache.len(), ICON_CACHE_MAX_ENTRIES); + assert!(!state.icon_cache.contains_key(&test_cache_key("icon-0"))); +} + +#[gtk::test] +fn source_invalidation_discards_successful_resolved_icon_names() { + let mut state = popup_state("org.unixnotis.PopupIconSourceCacheClear"); + let notification = icon_notification("old-icon"); + let cache_key = icon_cache_key(¬ification); + + state.cache_icon(cache_key.clone(), Some("old-icon".to_string())); + state.icon_cache_order.push_back(cache_key.clone()); + state.icon_sources_dirty.set(true); + + state.invalidate_icon_sources(); + + assert!(!state.icon_cache.contains_key(&cache_key)); + assert!(state.icon_cache_order.is_empty()); +} + +#[test] +fn icon_resolution_key_includes_all_candidate_inputs() { + let mut first = icon_notification("badge"); + first.attribution.desktop_id = "org.example.First.desktop".to_string(); + first.image.claimed_theme_icon = "first-theme".to_string(); + let mut second = first.clone(); + second.attribution.desktop_id = "org.example.Second.desktop".to_string(); + second.image.claimed_theme_icon = "second-theme".to_string(); + let mut third = first.clone(); + third.image.claimed_desktop_id = "org.example.Third.desktop".to_string(); + let mut fourth = first.clone(); + fourth.attribution.status = unixnotis_core::AttributionStatus::Recognized; + + assert_ne!(icon_cache_key(&first), icon_cache_key(&second)); + assert_ne!(icon_cache_key(&first), icon_cache_key(&third)); + assert_ne!(icon_cache_key(&first), icon_cache_key(&fourth)); +} + +#[gtk::test] +fn file_icon_rows_keep_the_requested_size_and_cache_small_decodes() { + let path = test_image_path("spawn-file-icon"); + let image = ImageBuffer::, Vec>::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + image + .save_with_format(&path, ImageFormat::Png) + .expect("save icon fixture"); + + let texture_cache = Rc::new(RefCell::new(TextureCache::new_for_popups())); + let mut theme_cache = ThemeIconCache::new_for_popups(); + let widget = resolve_icon_widget( + &mut theme_cache, + &texture_cache, + path.to_str().expect("fixture path is utf8"), + 20, + ) + .expect("regular file icon should create a widget"); + assert_eq!(widget.pixel_size(), 20); + + for _ in 0..100 { + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + if texture_cache.borrow_mut().get(&path, 20).is_some() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(texture_cache.borrow_mut().get(&path, 20).is_some()); + let _ = fs::remove_file(path); +} + +fn popup_state(application_id: &str) -> UiState { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register icon state test application"); + + let config = Config::default(); + let root = std::env::temp_dir().join("unixnotis-popup-icon-state"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4); + let theme_paths = ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + }; + let css = CssManager::new_popup(theme_paths, config.theme.clone()); + + UiState::new(&app, config, root.join("config.toml"), command_tx, css) +} + +fn icon_notification(icon_name: &str) -> unixnotis_core::NotificationView { + unixnotis_core::NotificationView { + id: 1, + generation: 1, + app_name: "Icon test".to_string(), + attribution: unixnotis_core::NotificationAttribution { + badge_icon: icon_name.to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + summary: "Icon test".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: unixnotis_core::NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} + +fn icon_cache_key(notification: &unixnotis_core::NotificationView) -> IconResolutionKey { + IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, + } +} + +fn test_cache_key(name: &str) -> IconResolutionKey { + IconResolutionKey { + app_name: name.to_string(), + badge_icon: String::new(), + desktop_id: String::new(), + claimed_theme_icon: String::new(), + claimed_desktop_id: String::new(), + claimed_candidates_first: true, + } +} + +fn test_image_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!( + "unixnotis-popups-{name}-{}-{nonce}.png", + std::process::id() + )) +} diff --git a/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs new file mode 100644 index 000000000..9a05d6eae --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/tests/theme_cache.rs @@ -0,0 +1,159 @@ +use super::super::ThemeIconCache; +use super::{ThemeIconCacheMap, ThemeIconKey}; + +fn entry_count(cache: &ThemeIconCacheMap) -> usize { + cache + .entries + .values() + .map(std::collections::HashMap::len) + .sum() +} + +fn contains(cache: &ThemeIconCacheMap, name: &str, size: i32, scale: i32) -> bool { + let size_key = super::ThemeIconSizeKey::new(size.max(1), scale.max(1)); + cache + .entries + .get(&size_key) + .is_some_and(|bucket| bucket.contains_key(name)) +} + +#[test] +fn successful_theme_icon_lookup_is_reused_without_re_resolving() { + let mut cache = ThemeIconCacheMap::new(128); + let mut resolves = 0; + + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| { + resolves += 1; + Some(7_u8) + }), + Some(7) + ); + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| { + resolves += 1; + Some(8_u8) + }), + Some(7) + ); + + assert_eq!(resolves, 1); + assert_eq!(entry_count(&cache), 1); +} + +#[test] +fn a_miss_is_not_cached_and_can_be_retried_successfully() { + let mut cache = ThemeIconCacheMap::new(128); + let mut resolves = 0; + + assert_eq!( + cache.get_or_resolve_with("eventual-icon", 24, 1, |_, _, _| { + resolves += 1; + None:: + }), + None + ); + assert!(!contains(&cache, "eventual-icon", 24, 1)); + + assert_eq!( + cache.get_or_resolve_with("eventual-icon", 24, 1, |_, _, _| { + resolves += 1; + Some(9_u8) + }), + Some(9) + ); + + assert_eq!(resolves, 2); + assert_eq!(entry_count(&cache), 1); +} + +#[test] +fn scale_variants_have_independent_successful_entries() { + let mut cache = ThemeIconCacheMap::new(128); + + assert_eq!( + cache.get_or_resolve_with("folder", 24, 1, |_, _, scale| Some(scale as u8)), + Some(1) + ); + assert_eq!( + cache.get_or_resolve_with("folder", 24, 2, |_, _, scale| Some(scale as u8)), + Some(2) + ); + + assert!(contains(&cache, "folder", 24, 1)); + assert!(contains(&cache, "folder", 24, 2)); + assert_eq!(entry_count(&cache), 2); +} + +#[test] +fn invalidation_discards_successful_paintables() { + let mut cache = ThemeIconCacheMap::new(128); + cache.get_or_resolve_with("folder", 24, 1, |_, _, _| Some(1_u8)); + assert_eq!(entry_count(&cache), 1); + + cache.clear(); + + assert_eq!(entry_count(&cache), 0); + assert!(!contains(&cache, "folder", 24, 1)); +} + +#[test] +fn lru_promotion_keeps_the_recent_success_when_the_limit_is_reached() { + let mut cache = ThemeIconCacheMap::new(2); + + cache.get_or_resolve_with("first", 24, 1, |_, _, _| Some(1_u8)); + cache.get_or_resolve_with("second", 24, 1, |_, _, _| Some(2_u8)); + + // A successful hit moves the first entry behind the second entry + assert_eq!( + cache.get_or_resolve_with("first", 24, 1, |_, _, _| Some(10_u8)), + Some(1) + ); + cache.get_or_resolve_with("third", 24, 1, |_, _, _| Some(3_u8)); + + assert!(contains(&cache, "first", 24, 1)); + assert!(!contains(&cache, "second", 24, 1)); + assert!(contains(&cache, "third", 24, 1)); + assert_eq!(entry_count(&cache), 2); +} + +#[test] +fn failed_lookups_do_not_consume_lru_capacity() { + let mut cache = ThemeIconCacheMap::new(1); + + cache.get_or_resolve_with("missing", 24, 1, |_, _, _| None::); + cache.get_or_resolve_with("present", 24, 1, |_, _, _| Some(1_u8)); + + assert!(!contains(&cache, "missing", 24, 1)); + assert!(contains(&cache, "present", 24, 1)); + assert_eq!(entry_count(&cache), 1); +} + +#[test] +fn theme_icon_keys_match_name_size_and_scale_together() { + let key = ThemeIconKey::new("folder", 24, 1); + + assert!(key.matches("folder", 24, 1)); + assert!(!key.matches("dialog-information", 24, 1)); + assert!(!key.matches("folder", 32, 1)); + assert!(!key.matches("folder", 24, 2)); +} + +#[gtk::test] +fn production_theme_cache_clear_removes_successful_entries() { + let mut cache = ThemeIconCache::new_for_popups(); + let Some(_) = cache.get_or_resolve("folder", 24, 1) else { + return; + }; + + assert_eq!(entry_count(&cache.entries), 1); + cache.clear(); + assert_eq!(entry_count(&cache.entries), 0); +} + +#[gtk::test] +fn production_cache_rejects_empty_theme_names_without_creating_an_entry() { + let mut cache = ThemeIconCache::new_for_popups(); + + assert!(cache.get_or_resolve("", 24, 1).is_none()); +} diff --git a/crates/unixnotis-popups/src/ui/icons/theme_cache.rs b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs new file mode 100644 index 000000000..eae8a46a1 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/icons/theme_cache.rs @@ -0,0 +1,159 @@ +//! Main-thread cache for themed popup icons + +use std::collections::{HashMap, VecDeque}; + +use gtk::IconPaintable; + +use super::resolver::resolve_icon_paintable_with_scale; + +const THEME_ICON_CACHE_MAX_ENTRIES: usize = 128; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ThemeIconSizeKey { + size: i32, + scale: i32, +} + +impl ThemeIconSizeKey { + const fn new(size: i32, scale: i32) -> Self { + Self { size, scale } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) struct ThemeIconKey { + name: String, + size: i32, + scale: i32, +} + +impl ThemeIconKey { + fn new(name: &str, size: i32, scale: i32) -> Self { + Self { + name: name.to_owned(), + size, + scale, + } + } + + const fn size_key(&self) -> ThemeIconSizeKey { + ThemeIconSizeKey::new(self.size, self.scale) + } + + fn matches(&self, name: &str, size: i32, scale: i32) -> bool { + self.size == size && self.scale == scale && self.name == name + } +} + +/// Cache storage is generic so miss and eviction behavior can be tested without +/// depending on the host icon theme +#[derive(Debug)] +struct ThemeIconCacheMap { + entries: HashMap>, + order: VecDeque, + max_entries: usize, +} + +impl ThemeIconCacheMap { + fn new(max_entries: usize) -> Self { + Self { + entries: HashMap::new(), + order: VecDeque::new(), + max_entries, + } + } + + fn get_or_resolve_with(&mut self, name: &str, size: i32, scale: i32, resolve: F) -> Option + where + F: FnOnce(&str, i32, i32) -> Option, + { + let size = size.max(1); + let scale = scale.max(1); + let size_key = ThemeIconSizeKey::new(size, scale); + + // Borrow the caller's name on a hit so no lookup key is allocated + if let Some(value) = self + .entries + .get(&size_key) + .and_then(|bucket| bucket.get(name)) + { + let value = value.clone(); + self.bump(name, size, scale); + return Some(value); + } + + // A miss is deliberately not inserted; the outer negative cache owns retry timing + let value = resolve(name, size, scale)?; + self.entries + .entry(size_key) + .or_default() + .insert(name.to_owned(), value.clone()); + self.order.push_back(ThemeIconKey::new(name, size, scale)); + self.enforce_limit(); + Some(value) + } + + fn clear(&mut self) { + self.entries.clear(); + self.order.clear(); + } + + fn bump(&mut self, name: &str, size: i32, scale: i32) { + if let Some(position) = self + .order + .iter() + .position(|entry| entry.matches(name, size, scale)) + { + let key = self.order.remove(position).expect("position was checked"); + self.order.push_back(key); + } + } + + fn enforce_limit(&mut self) { + while self.order.len() > self.max_entries { + let Some(evicted) = self.order.pop_front() else { + break; + }; + let size_key = evicted.size_key(); + let Some(bucket) = self.entries.get_mut(&size_key) else { + continue; + }; + bucket.remove(&evicted.name); + if bucket.is_empty() { + self.entries.remove(&size_key); + } + } + } +} + +/// GTK objects stay on the GTK thread while repeated successful lookups are avoided +#[derive(Debug)] +pub(in crate::ui) struct ThemeIconCache { + entries: ThemeIconCacheMap, +} + +impl ThemeIconCache { + pub(in crate::ui) fn new_for_popups() -> Self { + Self { + entries: ThemeIconCacheMap::new(THEME_ICON_CACHE_MAX_ENTRIES), + } + } + + pub(in crate::ui) fn get_or_resolve( + &mut self, + name: &str, + size: i32, + scale: i32, + ) -> Option { + self.entries + .get_or_resolve_with(name, size, scale, resolve_icon_paintable_with_scale) + } + + pub(in crate::ui) fn clear(&mut self) { + self.entries.clear(); + } +} + +#[cfg(test)] +#[path = "tests/theme_cache.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/mod.rs b/crates/unixnotis-popups/src/ui/mod.rs index a0db07dbf..c45291502 100644 --- a/crates/unixnotis-popups/src/ui/mod.rs +++ b/crates/unixnotis-popups/src/ui/mod.rs @@ -3,7 +3,6 @@ mod config_reload; pub mod css_reload; mod entry; -mod icon_state; mod icons; mod popups; mod state; diff --git a/crates/unixnotis-popups/src/ui/popups/mod.rs b/crates/unixnotis-popups/src/ui/popups/mod.rs index d5dcbcf04..81bed33ee 100644 --- a/crates/unixnotis-popups/src/ui/popups/mod.rs +++ b/crates/unixnotis-popups/src/ui/popups/mod.rs @@ -2,4 +2,5 @@ mod mutation; mod reconcile; +mod timeout; mod visibility; diff --git a/crates/unixnotis-popups/src/ui/popups/mutation.rs b/crates/unixnotis-popups/src/ui/popups/mutation.rs index 9026e091c..3c0e5b649 100644 --- a/crates/unixnotis-popups/src/ui/popups/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/mutation.rs @@ -2,11 +2,13 @@ use gtk::prelude::*; use tracing::debug; -use unixnotis_core::NotificationView; +use unixnotis_core::{NotificationKey, NotificationView}; +use unixnotis_ui::CutCorner; -use super::super::entry::PopupEntry; +use super::super::entry::{try_send_command, PopupEntry}; use super::super::window::refresh_popup_input_region; use super::super::UiState; +use crate::dbus::UiCommand; pub(super) struct ReconcilePlan { // Local rows missing from the daemon snapshot @@ -17,6 +19,21 @@ pub(super) struct ReconcilePlan { pub(super) desired_order: std::collections::VecDeque, } +pub(super) fn incoming_generation_is_stale(existing: Option, incoming: u64) -> bool { + existing.is_some_and(|generation| generation > incoming) +} + +pub(super) fn generation_matches(existing: Option, expected: u64) -> bool { + existing.is_some_and(|generation| generation == expected) +} + +pub(super) fn popup_payload_is_unchanged( + existing: &NotificationView, + incoming: &NotificationView, +) -> bool { + existing == incoming +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(super) struct VisiblePopupUpdate { // True when stack order, materialization, or reveal state changed @@ -35,14 +52,29 @@ impl UiState { refresh_visibility: bool, ) { let id = notification.id; - // Duplicate ids point at an upstream state bug - if self.popups.contains_key(&id) { - debug!(id, "popup insert skipped because id already exists"); + let key = notification.key(); + if self.hidden_popups.contains(&key) { + // A local display timeout suppresses duplicate banner updates for this generation return; } + if let Some(existing) = self.popups.get(&id) { + // A later generation always dominates an old or duplicated add event + if existing.notification.generation >= notification.generation { + debug!(id, "stale popup insert skipped"); + return; + } + self.update_popup_internal(notification, true, refresh_visibility); + return; + } + + // A replacement generation starts a fresh popup display lifecycle + self.hidden_popups.retain(|hidden| hidden.id != id); // Hidden overflow rows stay as plain data until they can actually be shown - self.popups.insert(id, PopupEntry::queued(notification)); + self.popups.insert( + id, + PopupEntry::queued(notification, self.icon_source_generation), + ); self.popup_order.push_front(id); if refresh_visibility { self.update_popup_visibility(false); @@ -62,12 +94,70 @@ impl UiState { refresh_visibility: bool, ) -> bool { let id = notification.id; + let existing_generation = self + .popups + .get(&id) + .map(|entry| entry.notification.generation); + let new_generation = existing_generation != Some(notification.generation); + if incoming_generation_is_stale(existing_generation, notification.generation) { + // Reordered older updates cannot roll a popup back + debug!( + id, + generation = notification.generation, + "stale popup update skipped" + ); + return false; + } + let key = notification.key(); + self.hidden_popups + .retain(|hidden| hidden.id != id || hidden.generation >= key.generation); + if self.hidden_popups.contains(&key) { + // Keep the payload current without reviving a banner the user already saw + if let Some(entry) = self.popups.get_mut(&id) { + entry.notification = notification; + } + return false; + } if !show_popup { - // Hidden updates act like a close for this popup id + // A newer suppressed generation removes any older visible payload for this ID self.remove_popup_internal(id, refresh_visibility); return false; } + if self.popups.get(&id).is_some_and(|entry| { + popup_can_skip_rebuild( + &entry.notification, + ¬ification, + entry.icon_source_generation, + self.icon_source_generation, + self.icon_sources_dirty.get(), + ) + }) { + // Duplicate payloads do not rebuild a GTK row, but they still repair + // the daemon acknowledgement if an earlier command was lost + let is_materialized = self + .popups + .get(&id) + .is_some_and(PopupEntry::is_materialized); + if is_materialized { + try_send_command( + &self.command_tx, + UiCommand::Materialized(notification.key()), + ); + } + if refresh_visibility { + // A queued duplicate may now enter the visible slice + self.update_popup_visibility(false); + } + debug!( + id, + generation = notification.generation, + materialized = is_materialized, + "unchanged popup update skipped with acknowledgement repair" + ); + return false; + } + if !self.popups.contains_key(&id) { // Same helper handles late updates for ids that were not present locally self.add_popup_internal(notification, refresh_visibility); @@ -87,22 +177,45 @@ impl UiState { if let Some(entry) = self.popups.get_mut(&id) { // Cached payload stays in sync with the rebuilt or queued row entry.notification = notification; + if !entry.is_materialized() { + entry.icon_source_generation = self.icon_source_generation; + } } if refresh_visibility { self.update_popup_visibility(rebuilt_visible_row); } + if rebuilt_visible_row && new_generation { + // A replacement generation starts a fresh local banner timeout + self.schedule_popup_hide(key); + } debug!(id, "popup updated"); rebuilt_visible_row } - pub(in crate::ui) fn remove_popup(&mut self, id: u32) { - // Runtime close path keeps one place for remove semantics - self.remove_popup_internal(id, true); + pub(in crate::ui) fn remove_popup_if_generation(&mut self, key: NotificationKey) { + let existing_generation = self + .popups + .get(&key.id) + .map(|entry| entry.notification.generation); + // A hidden banner may already be absent from the widget map + // Remove its exact-generation marker when the daemon closes it + let hidden_marker_removed = self.hidden_popups.remove(&key); + if generation_matches(existing_generation, key.generation) { + self.remove_popup_internal(key.id, true); + } else if hidden_marker_removed { + debug!( + id = key.id, + generation = key.generation, + "cleared hidden popup marker after close" + ); + } } pub(super) fn remove_popup_internal(&mut self, id: u32, refresh_visibility: bool) { if let Some(entry) = self.popups.remove(&id) { + let mut entry = entry; + entry.cancel_hide_timer(); if let Some(revealer) = entry.revealer { // Visible rows animate out before leaving the stack revealer.set_reveal_child(false); @@ -126,6 +239,19 @@ impl UiState { debug!(id, total = self.popup_order.len(), "popup removed"); } + pub(in crate::ui) fn hide_popup_if_generation(&mut self, key: NotificationKey) { + let matches = self + .popups + .get(&key.id) + .is_some_and(|entry| entry.notification.key() == key); + if !matches { + return; + } + // Keep this marker until the generation closes or is replaced + self.hidden_popups.insert(key); + self.remove_popup_internal(key.id, true); + } + fn rebuild_materialized_popup(&mut self, notification: &NotificationView) -> bool { let id = notification.id; let Some(revealer) = self @@ -138,6 +264,10 @@ impl UiState { let Some(old_root) = self.popups.get(&id).and_then(|entry| entry.root.clone()) else { return false; }; + let visibility = self + .popups + .get(&id) + .and_then(|entry| entry.visibility.clone()); // Reuse the current revealer so one id still has one stack row let new_root = self.build_popup_root(notification); @@ -148,10 +278,33 @@ impl UiState { if old_root.has_css_class("unixnotis-popup-visible") { new_root.add_css_class("unixnotis-popup-visible"); } - revealer.set_child(Some(&new_root)); + if self.config.theme.notification_corners.is_active() { + if let Some(plate) = revealer.child().and_downcast::() { + // Preserve the reveal animation while swapping only the clipped card contents + plate.set_child(Some(&new_root)); + plate.set_corners(self.config.theme.notification_corners); + } else { + // Enabling experimental cuts replaces the ordinary card wrapper on rebuild + let plate = CutCorner::new(&new_root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } + } else { + // Disabling experimental cuts restores the native rounded card shape + revealer.set_child(Some(&new_root)); + } if let Some(entry) = self.popups.get_mut(&id) { entry.root = Some(new_root); + entry.icon_source_generation = self.icon_source_generation; + } + try_send_command( + &self.command_tx, + UiCommand::Materialized(notification.key()), + ); + if let Some(visibility) = visibility { + // Replacements reuse one revealer but never reuse its generation identity + visibility.bind_generation(notification.key()); + visibility.report_if_visible(&revealer, &self.popup_window, &self.command_tx); } rebuilt_visible_row } @@ -169,6 +322,8 @@ impl UiState { // Swap in the fresh GTK nodes while keeping the cached payload untouched entry.revealer = built.revealer; entry.root = built.root; + entry.visibility = built.visibility; + entry.icon_source_generation = built.icon_source_generation; } pub(super) fn dematerialize_popup(&mut self, id: u32) { @@ -178,11 +333,14 @@ impl UiState { }; let Some(root) = entry.root.take() else { entry.revealer = None; + entry.visibility = None; return; }; let Some(revealer) = entry.revealer.take() else { + entry.visibility = None; return; }; + entry.visibility = None; // Hidden overflow rows should not retain GTK trees or CSS state root.remove_css_class("unixnotis-popup-visible"); root.set_visible(false); @@ -193,6 +351,18 @@ impl UiState { } } +pub(super) fn popup_can_skip_rebuild( + existing: &NotificationView, + incoming: &NotificationView, + entry_icon_source_generation: u64, + icon_source_generation: u64, + icon_sources_dirty: bool, +) -> bool { + popup_payload_is_unchanged(existing, incoming) + && entry_icon_source_generation == icon_source_generation + && !icon_sources_dirty +} + #[cfg(test)] #[path = "tests/mutation.rs"] mod tests; diff --git a/crates/unixnotis-popups/src/ui/popups/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/reconcile.rs index d089a49ba..3370f5c1c 100644 --- a/crates/unixnotis-popups/src/ui/popups/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/reconcile.rs @@ -2,22 +2,47 @@ use std::borrow::Borrow; use std::collections::{HashMap, HashSet, VecDeque}; use tracing::debug; -use unixnotis_core::{popup_allowed_by_state, ControlState, NotificationView}; +use unixnotis_core::{popup_allowed_by_state, ControlState, NotificationView, PopupDeliveryStage}; use super::super::UiState; use super::mutation::ReconcilePlan; impl UiState { pub(in super::super) fn reconcile_seed(&mut self, active: Vec) { + // Refresh source indexes before deciding which materialized rows need rebuilding + self.refresh_icon_sources_if_needed(); // Seed is a full snapshot, so desired popups come only from this list - let desired = desired_seed_popups(active, &self.control_state); + let desired = desired_seed_popups(active, &self.control_state) + .into_iter() + .filter(|notification| !self.hidden_popups.contains(¬ification.key())) + .collect::>(); // Compare only the portable notification payload so seed logic stays deterministic let local = self .popups .iter() .map(|(id, entry)| (*id, &entry.notification)) .collect(); - let plan = build_reconcile_plan(&local, &self.popup_order, &desired); + let refresh_icons = desired.iter().any(|notification| { + self.popups.get(¬ification.id).is_some_and(|entry| { + entry.is_materialized() + && entry.icon_source_generation != self.icon_source_generation + }) + }); + let plan = build_reconcile_plan_with_icon_refresh( + &local, + &self.popup_order, + &desired, + refresh_icons, + ); + + // Queued rows have no GTK tree to rebuild, so advance them to the current source generation + for notification in &desired { + if let Some(entry) = self.popups.get_mut(¬ification.id) { + if !entry.is_materialized() { + entry.icon_source_generation = self.icon_source_generation; + } + } + } // Remove old ids first so inserts and updates work on the final set for id in plan.stale_ids { @@ -56,10 +81,11 @@ impl UiState { } } -pub(super) fn build_reconcile_plan( +fn build_reconcile_plan_with_icon_refresh( local: &HashMap, local_order: &VecDeque, desired: &[NotificationView], + refresh_icons: bool, ) -> ReconcilePlan where T: Borrow, @@ -85,7 +111,7 @@ where .iter() .filter(|notification| match local.get(¬ification.id) { // Identical rows can stay as they are while visibility fixes order later - Some(existing) => existing.borrow() != *notification, + Some(existing) => refresh_icons || existing.borrow() != *notification, // Missing rows must be inserted from seed None => true, }) @@ -108,7 +134,11 @@ pub(super) fn desired_seed_popups( // This keeps reconnect snapshots and live signals on the same visibility rules active .into_iter() - .filter(|notification| popup_allowed_by_state(notification.urgency, state)) + .filter(|notification| { + popup_allowed_by_state(notification.urgency, state) + && notification.popup_decision.delivery_stage.rank() + < PopupDeliveryStage::Visible.rank() + }) .collect() } diff --git a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs index fd8f63238..a920c60c1 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/mutation.rs @@ -1,4 +1,8 @@ -use super::VisiblePopupUpdate; +use super::{ + generation_matches, incoming_generation_is_stale, popup_can_skip_rebuild, + popup_payload_is_unchanged, VisiblePopupUpdate, +}; +use unixnotis_core::{Action, NotificationImage, NotificationView}; #[test] fn visible_update_starts_without_stack_changes() { @@ -6,3 +10,96 @@ fn visible_update_starts_without_stack_changes() { assert!(!update.stack_changed); } + +#[test] +fn newer_popup_generation_rejects_reordered_older_update() { + assert!(incoming_generation_is_stale(Some(8), 7)); + assert!(!incoming_generation_is_stale(Some(8), 8)); + assert!(!incoming_generation_is_stale(Some(7), 8)); + assert!(!incoming_generation_is_stale(None, 8)); +} + +#[test] +fn popup_close_matches_only_the_exact_generation() { + assert!(generation_matches(Some(8), 8)); + assert!(!generation_matches(Some(8), 7)); + assert!(!generation_matches(None, 8)); +} + +#[test] +fn identical_same_generation_payloads_do_not_need_a_row_rebuild() { + let notification = NotificationView { + id: 7, + generation: 3, + app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: vec![Action { + key: "open".to_string(), + label: "Open".to_string(), + }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + assert!(popup_payload_is_unchanged( + ¬ification, + ¬ification.clone() + )); + + let mut changed = notification.clone(); + changed.summary = "Changed".to_string(); + assert!(!popup_payload_is_unchanged(¬ification, &changed)); +} + +#[test] +fn identical_payloads_require_rebuild_when_icon_sources_are_stale() { + let notification = NotificationView { + id: 9, + generation: 4, + app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + assert!(!popup_can_skip_rebuild( + ¬ification, + ¬ification, + 2, + 3, + false, + )); + assert!(!popup_can_skip_rebuild( + ¬ification, + ¬ification, + 3, + 3, + true, + )); + assert!(popup_can_skip_rebuild( + ¬ification, + ¬ification, + 3, + 3, + false, + )); +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs index 21f29b349..754e0535f 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/reconcile.rs @@ -1,24 +1,47 @@ use std::collections::{HashMap, VecDeque}; -use super::{build_reconcile_plan, desired_seed_popups}; -use unixnotis_core::{Action, ControlState, NotificationImage, NotificationView, Urgency}; +use super::{build_reconcile_plan_with_icon_refresh, desired_seed_popups}; +use crate::ui::UiState; +use gtk::prelude::*; +use unixnotis_core::{ + Action, Config, ControlState, NotificationImage, NotificationView, ThemePaths, Urgency, +}; +use unixnotis_ui::css::CssManager; fn make_view(id: u32, urgency: Urgency, summary: &str) -> NotificationView { NotificationView { id, + generation: u64::from(id), app_name: "Test".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), summary: summary.to_string(), body: "body".to_string(), actions: vec![Action { key: "default".to_string(), label: "Open".to_string(), }], + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, urgency: urgency as u8, + category: String::new(), is_transient: false, + received_at_unix_seconds: 0, image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, } } +#[test] +fn visible_generations_are_not_recreated_from_a_reconnect_seed() { + let mut notification = make_view(10, Urgency::Normal, "already shown"); + notification.popup_decision.delivery_stage = unixnotis_core::PopupDeliveryStage::Visible; + + let desired = desired_seed_popups(vec![notification], &ControlState::default()); + + assert!(desired.is_empty()); +} + #[test] fn desired_seed_clears_all_popups_when_inhibited() { let state = ControlState { @@ -58,7 +81,7 @@ fn reconcile_plan_removes_missing_rows_and_updates_changed_payloads() { let local_order = VecDeque::from([7, 5]); let desired = vec![make_view(5, Urgency::Normal, "new")]; - let plan = build_reconcile_plan(&local, &local_order, &desired); + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, false); assert_eq!(plan.stale_ids, vec![7]); assert_eq!(plan.updates.len(), 1); @@ -73,9 +96,158 @@ fn reconcile_plan_preserves_unchanged_rows_without_rebuild() { let local_order = VecDeque::from([1]); let desired = vec![make_view(1, Urgency::Normal, "keep")]; - let plan = build_reconcile_plan(&local, &local_order, &desired); + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, false); assert!(plan.stale_ids.is_empty()); assert!(plan.updates.is_empty()); assert_eq!(plan.desired_order, VecDeque::from([1])); } + +#[test] +fn reconcile_plan_refreshes_unchanged_rows_when_icon_sources_changed() { + let mut local = HashMap::new(); + local.insert(1, make_view(1, Urgency::Normal, "keep")); + let local_order = VecDeque::from([1]); + let desired = vec![make_view(1, Urgency::Normal, "keep")]; + + let plan = build_reconcile_plan_with_icon_refresh(&local, &local_order, &desired, true); + + assert_eq!(plan.updates.len(), 1); + assert_eq!(plan.updates[0].id, 1); +} + +#[gtk::test] +fn reconcile_seed_rebuilds_an_unchanged_row_after_icon_source_invalidation() { + let mut state = popup_state("org.unixnotis.PopupReconcileIconSources"); + let notification = make_view(30, Urgency::Normal, "unchanged"); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("seed fixture should materialize a visible row"); + + state.icon_source_generation = 1; + state.reconcile_seed(vec![notification]); + + let new_root = state + .popups + .get(&30) + .and_then(|entry| entry.root.clone()) + .expect("reconciled row should remain materialized"); + assert_ne!(old_root, new_root); +} + +#[gtk::test] +fn reconcile_seed_refreshes_unchanged_rows_when_sources_are_dirty() { + let mut state = popup_state("org.unixnotis.PopupReconcileDirtySources"); + let notification = make_view(31, Urgency::Normal, "unchanged"); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("seed fixture should materialize a visible row"); + + state.icon_sources_dirty.set(true); + state.reconcile_seed(vec![notification]); + + let new_root = state + .popups + .get(&31) + .and_then(|entry| entry.root.clone()) + .expect("reconciled row should remain materialized"); + assert_ne!(old_root, new_root); +} + +#[gtk::test] +fn reconcile_seed_refreshes_visible_rows_once_and_advances_queued_rows() { + let mut state = popup_state("org.unixnotis.PopupReconcileQueuedIconSources"); + let visible = make_view(40, Urgency::Normal, "visible"); + let queued = make_view(41, Urgency::Normal, "queued"); + + state.add_popup(visible); + state.add_popup(queued); + + let visible_id = state + .popups + .iter() + .find_map(|(id, entry)| entry.is_materialized().then_some(*id)) + .expect("one row should be materialized"); + let queued_id = state + .popups + .iter() + .find_map(|(id, entry)| (!entry.is_materialized()).then_some(*id)) + .expect("one row should remain queued"); + let old_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("visible row should have a root"); + let seed = state + .popup_order + .iter() + .map(|id| { + state + .popups + .get(id) + .expect("seed row should exist") + .notification + .clone() + }) + .collect::>(); + + state.icon_sources_dirty.set(true); + state.reconcile_seed(seed.clone()); + + let refreshed_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("refreshed row should have a root"); + assert_ne!(old_root, refreshed_root); + assert_eq!( + state + .popups + .get(&queued_id) + .expect("queued row should remain") + .icon_source_generation, + state.icon_source_generation + ); + + state.reconcile_seed(seed); + + let second_root = state + .popups + .get(&visible_id) + .and_then(|entry| entry.root.clone()) + .expect("visible row should remain materialized"); + assert_eq!(refreshed_root, second_root); +} + +fn popup_state(application_id: &str) -> UiState { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register reconcile test application"); + + let mut config = Config::default(); + config.popups.max_visible = 1; + let root = std::env::temp_dir().join("unixnotis-popup-reconcile"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(16); + let theme_paths = ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + }; + let css = CssManager::new_popup(theme_paths, config.theme.clone()); + + UiState::new(&app, config, root.join("config.toml"), command_tx, css) +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs b/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs new file mode 100644 index 000000000..ca394b238 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/popups/tests/timeout.rs @@ -0,0 +1,62 @@ +use std::time::Duration; + +use super::super::timeout::popup_display_timeout; +use unixnotis_core::{Config, NotificationImage, NotificationView, Urgency}; + +fn notification(timeout_ms: u64, urgency: Urgency) -> NotificationView { + NotificationView { + id: 1, + generation: 1, + app_name: "TestApp".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "summary".to_string(), + body: "body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: urgency as u8, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: timeout_ms, + } +} + +#[test] +fn normal_popup_uses_the_configured_display_timeout() { + let config = Config::default(); + + assert_eq!( + popup_display_timeout(¬ification( + config.popups.default_timeout_ms, + Urgency::Normal + )), + Some(Duration::from_millis(config.popups.default_timeout_ms)) + ); +} + +#[test] +fn critical_popup_without_a_critical_timeout_stays_visible() { + assert_eq!( + popup_display_timeout(¬ification(0, Urgency::Critical)), + None + ); +} + +#[test] +fn critical_popup_uses_its_own_configured_timeout_when_present() { + assert_eq!( + popup_display_timeout(¬ification(2_500, Urgency::Critical)), + Some(Duration::from_millis(2_500)) + ); +} + +#[test] +fn zero_display_timeout_disables_local_hiding() { + assert_eq!( + popup_display_timeout(¬ification(0, Urgency::Normal)), + None + ); +} diff --git a/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs b/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs index 41d57380c..77a61eebc 100644 --- a/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/tests/visibility.rs @@ -1,4 +1,9 @@ -use super::{visible_popup_restack_ids, visible_popup_target}; +use gtk::prelude::*; + +use super::{ + needs_input_region_refresh, set_window_visible_if_changed, visible_popup_restack_ids, + visible_popup_target, +}; #[test] fn visible_target_stays_within_popup_and_runtime_limits() { @@ -7,6 +12,30 @@ fn visible_target_stays_within_popup_and_runtime_limits() { assert_eq!(visible_popup_target(0, 3), 0); } +#[test] +fn input_region_refreshes_when_any_popup_state_changed() { + assert!(!needs_input_region_refresh(false, false, false)); + assert!(needs_input_region_refresh(true, false, false)); + assert!(needs_input_region_refresh(false, true, false)); + assert!(needs_input_region_refresh(false, false, true)); +} + +#[gtk::test] +fn window_visibility_updates_only_when_state_changes() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupVisibilityCache") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register visibility test application"); + let window = gtk::ApplicationWindow::new(&app); + + assert!(!set_window_visible_if_changed(&window, false)); + assert!(set_window_visible_if_changed(&window, true)); + assert!(!set_window_visible_if_changed(&window, true)); + assert!(set_window_visible_if_changed(&window, false)); +} + #[test] fn stable_visible_order_requires_no_restack() { assert!(visible_popup_restack_ids(&[9, 8, 7], &[9, 8, 7]).is_empty()); diff --git a/crates/unixnotis-popups/src/ui/popups/timeout.rs b/crates/unixnotis-popups/src/ui/popups/timeout.rs new file mode 100644 index 000000000..d49cb94fc --- /dev/null +++ b/crates/unixnotis-popups/src/ui/popups/timeout.rs @@ -0,0 +1,57 @@ +//! Local popup display timers + +use std::time::Duration; +use std::{cell::Cell, rc::Rc}; + +use unixnotis_core::{NotificationKey, NotificationView}; + +use crate::dbus::UiEvent; + +use super::super::UiState; + +pub(super) fn popup_display_timeout(notification: &NotificationView) -> Option { + // The daemon has already resolved protocol, urgency, rule, and resident policy + let timeout_ms = notification.popup_hide_after_ms; + + // Zero disables local hiding while the active daemon record remains available + (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)) +} + +impl UiState { + pub(super) fn schedule_popup_hide(&mut self, key: NotificationKey) { + let Some(sender) = self.popup_event_tx.clone() else { + // Unit tests construct state without an application event channel + return; + }; + let Some(notification) = self + .popups + .get(&key.id) + .filter(|entry| entry.notification.key() == key) + .map(|entry| entry.notification.clone()) + else { + return; + }; + let Some(timeout) = popup_display_timeout(¬ification) else { + return; + }; + let Some(entry) = self.popups.get_mut(&key.id) else { + return; + }; + entry.cancel_hide_timer(); + let fired = Rc::new(Cell::new(false)); + let callback_fired = Rc::clone(&fired); + entry.hide_timer = Some(glib::timeout_add_local_once(timeout, move || { + // This event only removes the popup process's banner + callback_fired.set(true); + // Wait asynchronously if the shared UI queue is briefly full + glib::MainContext::default().spawn_local(async move { + let _ = sender.send(UiEvent::PopupHidden(key)).await; + }); + })); + entry.hide_timer_fired = Some(fired); + } +} + +#[cfg(test)] +#[path = "tests/timeout.rs"] +mod tests; diff --git a/crates/unixnotis-popups/src/ui/popups/visibility.rs b/crates/unixnotis-popups/src/ui/popups/visibility.rs index 2ac32765b..41409589c 100644 --- a/crates/unixnotis-popups/src/ui/popups/visibility.rs +++ b/crates/unixnotis-popups/src/ui/popups/visibility.rs @@ -5,6 +5,10 @@ use super::super::UiState; use super::mutation::VisiblePopupUpdate; use gtk::prelude::*; use tracing::{debug, warn}; +use unixnotis_ui::CutCorner; + +use crate::dbus::UiCommand; +use crate::ui::entry::try_send_command; impl UiState { pub(in super::super) fn update_popup_visibility(&mut self, force_region_refresh: bool) { @@ -14,9 +18,13 @@ impl UiState { // Max-visible of zero disables popups entirely if max_visible == 0 { let update = self.apply_visible_popups(Vec::new()); - self.popup_window.set_visible(false); + let window_changed = set_window_visible_if_changed(&self.popup_window, false); // Keep input region empty when popups are disabled - if force_region_refresh || update.stack_changed { + if needs_input_region_refresh( + force_region_refresh, + update.stack_changed, + window_changed, + ) { refresh_popup_input_region( &self.popup_window, &self.popup_stack, @@ -37,9 +45,9 @@ impl UiState { let update = self.apply_visible_popups(desired_visible); // Window visibility follows the rows GTK actually represents, not just the // logical popup order that was requested upstream - self.popup_window - .set_visible(!self.visible_popups.is_empty()); - if force_region_refresh || update.stack_changed { + let window_changed = + set_window_visible_if_changed(&self.popup_window, !self.visible_popups.is_empty()); + if needs_input_region_refresh(force_region_refresh, update.stack_changed, window_changed) { refresh_popup_input_region( &self.popup_window, &self.popup_stack, @@ -54,28 +62,43 @@ impl UiState { } pub(in super::super) fn refresh_after_config_reload(&mut self) { - // Only built rows have GTK roots that need a width refresh - let resized_roots = self + // Only built rows have GTK wrappers that may need a corner refresh + let materialized_roots = self .popups .values() .filter(|entry| entry.root.is_some()) .count(); - // Prefer the live width when GTK has already measured the stack - let popup_width = self - .popup_stack - .width() - .max(self.popup_stack.width_request()) - .max(1); for entry in self.popups.values() { let Some(root) = entry.root.as_ref() else { continue; }; - root.set_size_request(popup_width, -1); + let Some(revealer) = entry.revealer.as_ref() else { + continue; + }; + let plate = revealer.child().and_downcast::(); + match (self.config.theme.notification_corners.is_active(), plate) { + (true, Some(plate)) => { + // Active cut geometry updates without rebuilding the full card + plate.set_corners(self.config.theme.notification_corners); + } + (true, None) => { + // Detach the ordinary card before moving it under the opt-in clipper + revealer.set_child(gtk::Widget::NONE); + let plate = CutCorner::new(root, self.config.theme.notification_corners); + revealer.set_child(Some(&plate)); + } + (false, Some(plate)) => { + // Return the card to the revealer before dropping the disabled clipper + plate.set_child(gtk::Widget::NONE); + revealer.set_child(Some(root)); + } + (false, None) => {} + } } // Re-run visibility so max_visible changes take effect right away self.update_popup_visibility(true); debug!( - resized_roots, + materialized_roots, visible_target = visible_popup_target(self.popups.len(), self.config.popups.max_visible), total = self.popups.len(), @@ -90,6 +113,7 @@ impl UiState { let restack_ids = visible_popup_restack_ids(&previous_visible, &desired_visible); let mut update = VisiblePopupUpdate::default(); let mut applied_visible = Vec::with_capacity(desired_visible.len()); + let mut newly_materialized = Vec::new(); for id in &previous_visible { if desired_visible_set.contains(id) { // Rows that stay visible keep their current widgets @@ -102,6 +126,10 @@ impl UiState { // Attach or move only the rows that actually changed order let mut previous_revealer: Option = None; for id in &desired_visible { + let was_materialized = self + .popups + .get(id) + .is_some_and(super::super::entry::PopupEntry::is_materialized); self.materialize_popup(*id); let Some(entry) = self.popups.get(id) else { warn!(id, "popup marked visible but entry is missing"); @@ -126,6 +154,14 @@ impl UiState { } previous_revealer = Some(revealer.clone()); + if !was_materialized { + // Materialization is complete only after the row joins the live stack + try_send_command( + &self.command_tx, + UiCommand::Materialized(entry.notification.key()), + ); + newly_materialized.push(entry.notification.key()); + } applied_visible.push(*id); } @@ -152,10 +188,29 @@ impl UiState { } self.visible_popups = applied_visible; + for key in newly_materialized { + self.schedule_popup_hide(key); + } update } } +fn set_window_visible_if_changed(window: >k::ApplicationWindow, visible: bool) -> bool { + if window.is_visible() == visible { + return false; + } + window.set_visible(visible); + true +} + +pub(super) const fn needs_input_region_refresh( + force_region_refresh: bool, + stack_changed: bool, + window_changed: bool, +) -> bool { + force_region_refresh || stack_changed || window_changed +} + pub(super) fn visible_popup_target(total_popups: usize, max_visible: usize) -> usize { // Visible slice can never exceed the number of known popups total_popups.min(max_visible) diff --git a/crates/unixnotis-popups/src/ui/state/constructor.rs b/crates/unixnotis-popups/src/ui/state/constructor.rs index f026bc33b..9f8f8babf 100644 --- a/crates/unixnotis-popups/src/ui/state/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/constructor.rs @@ -11,7 +11,7 @@ use unixnotis_ui::icons::DesktopIconIndex; use crate::dbus::UiCommand; -use super::super::icons::TextureCache; +use super::super::icons::{TextureCache, ThemeIconCache}; use super::super::window::build_popup_window; use super::model::UiState; @@ -42,21 +42,33 @@ impl UiState { config_path, css, command_tx, + popup_event_tx: None, popup_window, popup_stack, popup_input_region, popups: HashMap::new(), popup_order: VecDeque::new(), + hidden_popups: std::collections::HashSet::new(), visible_popups: Vec::new(), // Startup remains permissive until the daemon seed arrives control_state: ControlState::default(), desktop_icons: DesktopIconIndex::new(), icon_sources_dirty, + icon_source_generation: 0, _app_info_monitor: app_info_monitor, _icon_theme: icon_theme, icon_cache: HashMap::new(), icon_cache_order: VecDeque::new(), icon_texture_cache: Rc::new(RefCell::new(TextureCache::new_for_popups())), + theme_icon_cache: ThemeIconCache::new_for_popups(), } } + + pub(crate) fn set_popup_event_sender( + &mut self, + sender: async_channel::Sender, + ) { + // The production event loop owns this sender; tests can leave it unset + self.popup_event_tx = Some(sender); + } } diff --git a/crates/unixnotis-popups/src/ui/state/events.rs b/crates/unixnotis-popups/src/ui/state/events.rs index f603593e0..c23308f2d 100644 --- a/crates/unixnotis-popups/src/ui/state/events.rs +++ b/crates/unixnotis-popups/src/ui/state/events.rs @@ -12,6 +12,12 @@ use super::model::UiState; impl UiState { pub fn handle_event(&mut self, event: UiEvent) { match event { + UiEvent::Disconnected => { + debug!("UnixNotis control service disconnected"); + self.control_state = ControlState::default(); + self.hidden_popups.clear(); + self.reconcile_seed(Vec::new()); + } UiEvent::Seed { state, active } => { // Seed is daemon truth, so filtering uses the newest gate state self.control_state = state; @@ -35,9 +41,17 @@ impl UiState { ); self.update_popup(notification, show_popup); } - UiEvent::NotificationClosed(id, _reason) => { - debug!(id, "popup closed"); - self.remove_popup(id); + UiEvent::NotificationClosed(key, _reason) => { + debug!(id = key.id, generation = key.generation, "popup closed"); + self.remove_popup_if_generation(key); + } + UiEvent::PopupHidden(key) => { + debug!( + id = key.id, + generation = key.generation, + "popup banner hidden" + ); + self.hide_popup_if_generation(key); } UiEvent::PopupGateChanged(gate) => { // Gate updates change only policy fields and preserve unrelated daemon state diff --git a/crates/unixnotis-popups/src/ui/state/mod.rs b/crates/unixnotis-popups/src/ui/state/mod.rs index dd079ab95..cacc33fc1 100644 --- a/crates/unixnotis-popups/src/ui/state/mod.rs +++ b/crates/unixnotis-popups/src/ui/state/mod.rs @@ -4,8 +4,8 @@ mod constructor; mod events; mod model; -pub(super) use model::IconCacheEntry; pub use model::UiState; +pub(super) use model::{IconCacheEntry, IconResolutionKey}; #[cfg(test)] mod tests; diff --git a/crates/unixnotis-popups/src/ui/state/model.rs b/crates/unixnotis-popups/src/ui/state/model.rs index baa10160a..cfd08a419 100644 --- a/crates/unixnotis-popups/src/ui/state/model.rs +++ b/crates/unixnotis-popups/src/ui/state/model.rs @@ -1,7 +1,7 @@ //! Popup UI state owned by the GTK main thread use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::rc::Rc; use std::time::Instant; @@ -10,10 +10,10 @@ use unixnotis_core::{Config, ControlState}; use unixnotis_ui::css::CssManager; use unixnotis_ui::icons::DesktopIconIndex; -use crate::dbus::UiCommand; +use crate::dbus::{UiCommand, UiEvent}; use super::super::entry::PopupEntry; -use super::super::icons::TextureCache; +use super::super::icons::{TextureCache, ThemeIconCache}; use super::super::window::PopupInputRegionState; /// Popup-only GTK state for notification toasts @@ -22,12 +22,16 @@ pub struct UiState { pub(in crate::ui) config_path: std::path::PathBuf, pub(in crate::ui) css: CssManager, pub(in crate::ui) command_tx: Sender, + // Popup-only events let local banner timers avoid mutating daemon state + pub(in crate::ui) popup_event_tx: Option>, pub(in crate::ui) popup_window: gtk::ApplicationWindow, pub(in crate::ui) popup_stack: gtk::Box, // Shared popup input shaping state for config and runtime updates pub(in crate::ui) popup_input_region: PopupInputRegionState, pub(in crate::ui) popups: HashMap, pub(in crate::ui) popup_order: VecDeque, + // A hidden banner stays hidden for its exact generation until it is replaced + pub(in crate::ui) hidden_popups: HashSet, // Only visible ids need repeated GTK updates during backlog churn pub(in crate::ui) visible_popups: Vec, // Latest daemon gate state used to keep visible popups in policy @@ -36,14 +40,31 @@ pub struct UiState { pub(in crate::ui) desktop_icons: DesktopIconIndex, // Monitors mark lookup state dirty without rebuilding inside callbacks pub(in crate::ui) icon_sources_dirty: Rc>, + // Each source invalidation advances the generation used by duplicate-update checks + pub(in crate::ui) icon_source_generation: u64, pub(in crate::ui) _app_info_monitor: gtk::gio::AppInfoMonitor, pub(in crate::ui) _icon_theme: Option, // Cache resolved icon names per app to reduce repeated theme lookups - pub(in crate::ui) icon_cache: HashMap, + pub(in crate::ui) icon_cache: HashMap, // FIFO order used to cap icon cache growth - pub(in crate::ui) icon_cache_order: VecDeque, + pub(in crate::ui) icon_cache_order: VecDeque, // Small LRU for decoded textures to avoid repeated PNG decode work pub(in crate::ui) icon_texture_cache: Rc>, + // Themed paintables stay on the GTK thread and are reused by repeated rows + pub(in crate::ui) theme_icon_cache: ThemeIconCache, +} + +/// Inputs that affect desktop and theme icon candidate resolution +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(in crate::ui) struct IconResolutionKey { + pub(in crate::ui) app_name: String, + pub(in crate::ui) badge_icon: String, + pub(in crate::ui) desktop_id: String, + pub(in crate::ui) claimed_theme_icon: String, + pub(in crate::ui) claimed_desktop_id: String, + // Unresolved identities search claimed presentation before daemon branding + // A trust transition must not reuse a result chosen under the opposite order + pub(in crate::ui) claimed_candidates_first: bool, } pub(in crate::ui) struct IconCacheEntry { diff --git a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs index 561fc2131..b468cdc67 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/constructor.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/constructor.rs @@ -1,21 +1,112 @@ -use std::path::PathBuf; - use gtk::prelude::*; -use unixnotis_core::{Config, ThemePaths}; -use unixnotis_ui::css::CssManager; +use unixnotis_core::{hooks, Config, CutCorners, NotificationImage, NotificationView, Urgency}; +use unixnotis_ui::{css::CssManager, CutCorner}; use super::super::UiState; +use super::support::theme_paths; -fn theme_paths(root: &str) -> ThemePaths { - let root = PathBuf::from(root); - ThemePaths { - base_dir: root.clone(), - base_css: root.join("base.css"), - popup_css: root.join("popup.css"), - panel_css: root.join("panel.css"), - widgets_css: root.join("widgets.css"), - media_css: root.join("media.css"), - } +#[gtk::test] +fn popup_entry_uses_the_configured_cut_corner_primitive() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupCornerTest") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup corner test application"); + let mut config = Config::default(); + config.theme.notification_corners = CutCorners { + top_left: 20, + bottom_right: 14, + ..CutCorners::default() + }; + let corners = config.theme.notification_corners; + let config_root = std::env::temp_dir().join("unixnotis-popup-corners"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 1, + generation: 1, + app_name: "Demo".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + let entry = state.build_popup_entry(¬ification); + let plate = entry + .revealer + .and_then(|revealer| revealer.child()) + .and_downcast::() + .expect("popup revealer should contain the cut-corner primitive"); + let root = entry.root.expect("popup entry should keep its styled root"); + + assert_eq!(plate.corners(), corners); + assert_eq!(plate.child().as_ref(), Some(root.upcast_ref())); +} + +#[gtk::test] +fn default_popup_entry_uses_the_native_rounded_card_without_a_clipper() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupRoundedCardTest") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup rounded-card test application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-rounded-card"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 1, + generation: 1, + app_name: "Demo".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: "Summary".to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + let entry = state.build_popup_entry(¬ification); + let root = entry.root.expect("popup entry should keep its styled root"); + let child = entry + .revealer + .and_then(|revealer| revealer.child()) + .expect("popup revealer should contain its card"); + + assert_eq!(child, root.upcast::()); } #[gtk::test] @@ -27,12 +118,10 @@ fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { app.register(None::<>k::gio::Cancellable>) .expect("register popup test application"); let config = Config::default(); - let config_path = PathBuf::from("/tmp/unixnotis-popup-state/config.toml"); + let config_root = std::env::temp_dir().join("unixnotis-popup-state"); + let config_path = config_root.join("config.toml"); let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); - let css = CssManager::new_popup( - theme_paths("/tmp/unixnotis-popup-state"), - config.theme.clone(), - ); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); let state = UiState::new(&app, config, config_path.clone(), command_tx, css); @@ -40,4 +129,319 @@ fn constructor_keeps_config_path_and_starts_with_empty_runtime_collections() { assert!(state.popups.is_empty()); assert!(state.popup_order.is_empty()); assert!(state.visible_popups.is_empty()); + assert!( + state.popup_window.is_resizable(), + "the layer window must accept content-driven height changes" + ); + assert_eq!( + state.popup_window.default_size().1, + -1, + "popup height must use the current stack's natural request" + ); +} + +#[gtk::test] +fn critical_popup_probe_builds_the_root_class_and_badge() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupCriticalProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup critical probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-critical-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 2, + generation: 2, + app_name: "Critical probe".to_string(), + attribution: unixnotis_core::NotificationAttribution { + display_name: "Critical probe".to_string(), + ..unixnotis_core::NotificationAttribution::default() + }, + summary: "Critical popup".to_string(), + body: "The composed critical state must be visible".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Critical as u8, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class(hooks::shared_state::CRITICAL)); + assert!(root.has_css_class(hooks::popup_card::HAS_SUMMARY)); + assert!(root.has_css_class(hooks::popup_card::HAS_BODY)); + assert!(!root.has_css_class(hooks::popup_card::HAS_ACTIONS)); + assert_ne!( + root.has_css_class(hooks::popup_card::HAS_ICON), + root.has_css_class(hooks::popup_card::NO_ICON) + ); + assert_eq!(root.width_request(), -1); + assert_eq!(root.height_request(), -1); + assert!(root.hexpands()); + assert!(visible_descendant_has_class( + root.upcast_ref(), + hooks::urgency::BADGE + )); +} + +#[gtk::test] +fn unknown_attribution_uses_a_short_chip_without_showing_raw_provenance() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupUnverifiedProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup unverified probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-unverified-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 3, + generation: 3, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::unresolved( + "Example Chat", + unixnotis_core::AttributionReason::MissingSenderEvidence, + "sender evidence unavailable", + "unknown:example-chat".to_string(), + ), + summary: "John Doe".to_string(), + body: "Are you free later?".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("unresolved")); + assert!(root.has_css_class("utility")); + assert!(visible_descendant_has_text(root.upcast_ref(), "Unverified")); + assert!(!visible_descendant_has_text( + root.upcast_ref(), + "sender evidence unavailable" + )); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "App identity could not be verified" + )); +} + +#[gtk::test] +fn conflicting_attribution_keeps_message_layout_and_uses_suspicious_chip() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupSuspiciousProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup suspicious probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-suspicious-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let notification = NotificationView { + id: 4, + generation: 4, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::conflict( + "Example Chat", + "org.example.Chat", + unixnotis_core::AttributionReason::ExecutableMismatch, + "application claim mismatch; source /tmp/fake", + "conflict:example-chat".to_string(), + ), + summary: "John Doe".to_string(), + body: "Are you free later?".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("communication")); + assert!(root.has_css_class("conflict")); + assert!(visible_descendant_has_text(root.upcast_ref(), "Suspicious")); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "Claimed app: Example Chat" + )); + assert!(!visible_descendant_has_text( + root.upcast_ref(), + "application claim mismatch; source /tmp/fake" + )); +} + +#[gtk::test] +fn notify_send_claim_uses_one_command_line_avatar_without_app_branding() { + let app = gtk::Application::builder() + .application_id("org.unixnotis.PopupRelayProbe") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup relay probe application"); + let config = Config::default(); + let config_root = std::env::temp_dir().join("unixnotis-popup-relay-probe"); + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(1); + let css = CssManager::new_popup(theme_paths(&config_root), config.theme.clone()); + let mut state = UiState::new( + &app, + config, + config_root.join("config.toml"), + command_tx, + css, + ); + let mut notification = NotificationView { + id: 5, + generation: 5, + app_name: "Example Chat".to_string(), + attribution: unixnotis_core::NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ), + summary: "John Doe".to_string(), + body: String::new(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: Urgency::Normal as u8, + category: "im.received".to_string(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + }; + notification.image.badge_icon = "example-chat".to_string(); + + let root = state.build_popup_root(¬ification); + + assert!(root.has_css_class("relay")); + assert!(root.has_css_class("communication")); + assert!(!root.has_css_class("conflict")); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "Command-line notification" + )); + assert!(visible_descendant_has_text( + root.upcast_ref(), + "App label: Example Chat" + )); + assert_eq!( + visible_descendant_class_count(root.upcast_ref(), "unixnotis-popup-application-icon-slot",), + 1 + ); + assert!(!visible_descendant_has_class( + root.upcast_ref(), + "unixnotis-popup-content-image" + )); + let close = descendant_with_class(root.upcast_ref(), "unixnotis-popup-close") + .expect("overlay close control"); + assert!(close + .parent() + .is_some_and(|parent| parent.is::())); +} + +fn visible_descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.get_visible() && current.has_css_class(class_name) { + return true; + } + if visible_descendant_has_class(¤t, class_name) { + return true; + } + child = current.next_sibling(); + } + false +} + +fn visible_descendant_has_text(widget: >k::Widget, expected: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current + .downcast_ref::() + .is_some_and(|label| label.get_visible() && label.text() == expected) + { + return true; + } + if visible_descendant_has_text(¤t, expected) { + return true; + } + child = current.next_sibling(); + } + false +} + +fn visible_descendant_class_count(widget: >k::Widget, class_name: &str) -> usize { + let mut count = 0; + let mut child = widget.first_child(); + while let Some(current) = child { + if current.get_visible() && current.has_css_class(class_name) { + count += 1; + } + count += visible_descendant_class_count(¤t, class_name); + child = current.next_sibling(); + } + count +} + +fn descendant_with_class(widget: >k::Widget, class_name: &str) -> Option { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.has_css_class(class_name) { + return Some(current); + } + if let Some(found) = descendant_with_class(¤t, class_name) { + return Some(found); + } + child = current.next_sibling(); + } + None } diff --git a/crates/unixnotis-popups/src/ui/state/tests/events.rs b/crates/unixnotis-popups/src/ui/state/tests/events.rs index 899b78243..2cc8b2213 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/events.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/events.rs @@ -6,6 +6,7 @@ use super::super::events::apply_popup_gate; fn popup_gate_update_changes_policy_without_replacing_runtime_counts() { let mut state = ControlState { dnd_enabled: false, + dnd_expires_at: 0, inhibited: false, history_count: 42, inhibitor_count: 3, diff --git a/crates/unixnotis-popups/src/ui/state/tests/mod.rs b/crates/unixnotis-popups/src/ui/state/tests/mod.rs index f7dd6dc6e..6feaff8dc 100644 --- a/crates/unixnotis-popups/src/ui/state/tests/mod.rs +++ b/crates/unixnotis-popups/src/ui/state/tests/mod.rs @@ -1,3 +1,5 @@ mod constructor; mod events; mod model; +mod mutation; +mod support; diff --git a/crates/unixnotis-popups/src/ui/state/tests/mutation.rs b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs new file mode 100644 index 000000000..2c969f6c6 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/state/tests/mutation.rs @@ -0,0 +1,609 @@ +use gtk::prelude::*; +use unixnotis_core::{ + hooks, CloseReason, Config, ImageData, NotificationImage, NotificationKey, NotificationView, +}; +use unixnotis_ui::css::CssManager; + +use super::super::{IconCacheEntry, IconResolutionKey, UiState}; +use super::support::theme_paths; +use crate::dbus::UiEvent; + +#[gtk::test] +fn popup_events_preserve_newest_generation_and_exact_close_identity() { + let mut state = popup_state("org.unixnotis.PopupMutationEvents"); + let original = notification(7, 1, "original"); + + state.handle_event(UiEvent::NotificationAdded(original, true)); + assert_eq!( + state + .popups + .get(&7) + .expect("original popup should be visible") + .notification + .summary, + "original" + ); + + let duplicate = notification(7, 1, "duplicate"); + // Equal generations cannot replace the payload already accepted by the UI + state.handle_event(UiEvent::NotificationAdded(duplicate, true)); + assert_eq!( + state + .popups + .get(&7) + .expect("equal generation should preserve the popup") + .notification + .summary, + "original" + ); + + let replacement = notification(7, 2, "replacement"); + state.handle_event(UiEvent::NotificationUpdated(replacement, true)); + assert_eq!( + state + .popups + .get(&7) + .expect("newer generation should replace the popup") + .notification + .summary, + "replacement" + ); + + // A delayed close for generation one must leave generation two visible + state.handle_event(UiEvent::NotificationClosed( + NotificationKey { + id: 7, + generation: 1, + }, + CloseReason::Expired, + )); + assert!(state.popups.contains_key(&7)); + + // A newer suppressed decision removes the older visible generation + state.handle_event(UiEvent::NotificationUpdated( + notification(7, 3, "suppressed"), + false, + )); + assert!(!state.popups.contains_key(&7)); + + // The next admitted generation may create the popup again + state.handle_event(UiEvent::NotificationUpdated( + notification(7, 4, "restored"), + true, + )); + assert!(state.popups.contains_key(&7)); + + state.handle_event(UiEvent::NotificationClosed( + NotificationKey { + id: 7, + generation: 4, + }, + CloseReason::Expired, + )); + assert!(!state.popups.contains_key(&7)); +} + +#[gtk::test] +fn identical_generation_updates_keep_the_existing_widget() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupDuplicateUpdate", 1); + let original = notification(11, 1, "unchanged"); + + state.add_popup(original.clone()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); + let original_root = state + .popups + .get(&original.id) + .and_then(|entry| entry.root.clone()) + .expect("original popup root"); + + let original_key = original.key(); + state.update_popup(original, true); + + let current_root = state + .popups + .get(&11) + .and_then(|entry| entry.root.clone()) + .expect("unchanged popup root"); + assert_eq!(current_root, original_root); + match command_rx + .try_recv() + .expect("duplicate update should repair materialization acknowledgement") + { + crate::dbus::UiCommand::Materialized(key) => assert_eq!(key, original_key), + command => panic!("unexpected duplicate-update command: {command:?}"), + } + assert!(command_rx.try_recv().is_err()); +} + +#[gtk::test] +fn queued_duplicate_update_can_materialize_without_rebuilding_payload() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupQueuedDuplicate", 0); + let original = notification(12, 1, "queued"); + + state.add_popup(original.clone()); + assert!(state + .popups + .get(&original.id) + .is_some_and(|entry| !entry.is_materialized())); + assert!(command_rx.try_recv().is_err()); + + // A later visibility change makes the existing queued payload eligible + state.config.popups.max_visible = 1; + state.update_popup(original.clone(), true); + + assert!(state + .popups + .get(&original.id) + .is_some_and(super::super::super::entry::PopupEntry::is_materialized)); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); +} + +#[gtk::test] +fn popup_visibility_tracks_the_materialized_visible_slice() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupVisibilityState", 1); + state.update_popup_visibility(false); + assert!(!state.popup_window.is_visible()); + + let notification = notification(13, 1, "visible"); + state.add_popup(notification.clone()); + assert_materialized_and_visible_commands(&mut command_rx, notification.key()); + assert!(state.popup_window.is_visible()); + + state.remove_popup_if_generation(notification.key()); + assert!(!state.popup_window.is_visible()); +} + +#[gtk::test] +fn popup_image_builders_distinguish_content_badges_and_missing_sources() { + let mut state = popup_state("org.unixnotis.PopupMutationImages"); + let mut content = notification(8, 1, "content"); + content.category = "image.photo".to_string(); + content.image = NotificationImage { + content_image: ImageData { + width: 2, + height: 1, + rowstride: 8, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255; 8], + }, + ..NotificationImage::default() + }; + // Image categories retain real content even when the thumbnail is compact + assert!(UiState::build_content_image_widget(&content).is_some()); + let content_root = state.build_popup_root(&content); + assert!(content_root.has_css_class(hooks::popup_card::HAS_IMAGE)); + assert!(descendant_has_class( + content_root.upcast_ref(), + "unixnotis-popup-content-image" + )); + + let mut missing_content = notification(9, 1, "missing"); + missing_content.attribution.badge_icon.clear(); + missing_content.attribution.desktop_id.clear(); + // Empty content and badge sources must not create placeholder image widgets + assert!(UiState::build_content_image_widget(&missing_content).is_none()); + assert!(state.build_app_icon_widget(&missing_content, 20).is_none()); + let missing_root = state.build_popup_root(&missing_content); + assert!(!missing_root.has_css_class(hooks::popup_card::HAS_IMAGE)); + assert!(!descendant_has_class( + missing_root.upcast_ref(), + "unixnotis-popup-content-image" + )); + + // A daemon-selected badge remains independent from caller image content + missing_content.attribution.badge_icon = "dialog-information".to_string(); + let badge = state + .build_app_icon_widget(&missing_content, 20) + .expect("known themed badge"); + assert!(badge.paintable().is_some()); + + let mut decorative = notification(10, 1, "decorative"); + decorative.image.sender_visual_role = + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon; + decorative.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + has_alpha: true, + bits_per_sample: 8, + channels: 4, + data: vec![255, 32, 32, 255], + }; + let decorative_root = state.build_popup_root(&decorative); + // Application identity art never enters the message-content lane below the body + assert!(!descendant_has_class( + decorative_root.upcast_ref(), + "unixnotis-popup-sender-visual" + )); + assert!(!descendant_has_class( + decorative_root.upcast_ref(), + "unixnotis-popup-content-image" + )); + assert!(!decorative_root.has_css_class(hooks::popup_card::HAS_IMAGE)); +} + +#[gtk::test] +fn icon_source_invalidation_clears_the_dirty_marker() { + let mut state = popup_state("org.unixnotis.PopupIconInvalidation"); + state.icon_sources_dirty.set(true); + + state.invalidate_icon_sources(); + + assert!(!state.icon_sources_dirty.get()); +} + +#[gtk::test] +fn identical_update_rebuilds_a_row_after_icon_source_invalidation() { + let (mut state, _command_rx) = + popup_state_with_commands("org.unixnotis.PopupIconSourceGeneration", 1); + let mut notification = notification(32, 1, "icon source changed"); + notification.attribution = unixnotis_core::NotificationAttribution::verified( + "Example", + "", + "", + "", + unixnotis_core::AttributionReason::ExactSystemExecutable, + "", + "test:icon-source-generation".to_string(), + ); + + // Keep the miss independent of the host icon theme by providing no lookup candidates + assert!(state.build_app_icon_widget(¬ification, 20).is_none()); + let cache_key = IconResolutionKey { + app_name: notification.app_name.clone(), + badge_icon: notification.attribution.badge_icon.clone(), + desktop_id: notification.attribution.desktop_id.clone(), + claimed_theme_icon: notification.image.claimed_theme_icon.clone(), + claimed_desktop_id: notification.image.claimed_desktop_id.clone(), + claimed_candidates_first: notification.attribution.status + == unixnotis_core::AttributionStatus::Unresolved, + }; + assert!(state + .icon_cache + .get(&cache_key) + .is_some_and(|entry| entry.resolved.is_none())); + + state.add_popup(notification.clone()); + let old_root = state + .popups + .get(¬ification.id) + .and_then(|entry| entry.root.clone()) + .expect("initial popup root"); + + // Simulate the icon monitor seeing the newly installed themed icon + state.icon_cache.insert( + cache_key.clone(), + IconCacheEntry { + resolved: Some("dialog-information".to_string()), + cached_at: std::time::Instant::now(), + }, + ); + state.icon_sources_dirty.set(true); + state.update_popup(notification.clone(), true); + + let entry = state + .popups + .get(¬ification.id) + .expect("popup should remain active"); + assert_ne!(entry.root.as_ref(), Some(&old_root)); + assert_eq!(entry.icon_source_generation, 1); + assert!(state + .icon_cache + .get(&cache_key) + .is_some_and(|cached| { cached.resolved.is_none() })); +} + +#[gtk::test] +fn popup_widget_tree_keeps_one_identity_grid_and_overlay_close_control() { + let mut state = popup_state("org.unixnotis.PopupWidgetTree"); + let mut relayed = notification(12, 1, "Build finished"); + relayed.attribution = unixnotis_core::NotificationAttribution::relay( + "Builder", + "Sent through /usr/bin/notify-send", + "relay:notify-send:builder".to_string(), + ); + let root = state.build_popup_root(&relayed); + let overlay = root + .first_child() + .and_downcast::() + .expect("popup root should contain one overlay"); + let content = overlay + .child() + .and_downcast::() + .expect("overlay should own the measured popup content"); + let grid = content + .first_child() + .and_downcast::() + .expect("popup content should start with the identity grid"); + + assert!(grid.has_css_class("unixnotis-popup-content-grid")); + assert_eq!(grid.column_spacing(), 8); + assert_eq!(grid.row_spacing(), 4); + assert_eq!( + grid.property::("accessible-role"), + gtk::AccessibleRole::Group + ); + assert_eq!( + descendant_class_count(root.upcast_ref(), "unixnotis-popup-application-icon-slot",), + 1, + "one compact icon must own application identity" + ); + assert!(descendant_has_text( + root.upcast_ref(), + "Command-line notification" + )); + assert!(descendant_has_text(root.upcast_ref(), "App label: Builder")); + assert!(!descendant_has_class( + content.upcast_ref(), + "unixnotis-popup-close" + )); + assert!(descendant_has_class( + overlay.upcast_ref(), + "unixnotis-popup-close" + )); +} + +#[gtk::test] +fn popup_display_timeout_hides_only_the_local_banner_generation() { + let (mut state, mut command_rx) = popup_state_with_commands("org.unixnotis.PopupLocalHide", 1); + state.config.popups.default_timeout_ms = 1; + let (event_tx, event_rx) = async_channel::bounded(2); + state.set_popup_event_sender(event_tx); + let mut first = notification(31, 1, "active action"); + first.popup_hide_after_ms = 1; + + state.handle_event(UiEvent::NotificationAdded(first.clone(), true)); + assert!(state.popups.contains_key(&first.id)); + assert_materialized_and_visible_commands(&mut command_rx, first.key()); + + std::thread::sleep(std::time::Duration::from_millis(15)); + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + let hidden = event_rx + .try_recv() + .expect("display timeout should emit a local hide event"); + assert!(matches!(hidden, UiEvent::PopupHidden(key) if key == first.key())); + state.handle_event(hidden); + + assert!(!state.popups.contains_key(&first.id)); + assert!(state.hidden_popups.contains(&first.key())); + + // An update for the same live generation must not resurrect its banner + state.handle_event(UiEvent::NotificationUpdated(first.clone(), true)); + assert!(!state.popups.contains_key(&first.id)); + assert!( + command_rx.try_recv().is_err(), + "hidden generations must not send a fresh materialization acknowledgement" + ); + + // Closing the active record also releases the local hidden-banner marker + state.handle_event(UiEvent::NotificationClosed( + first.key(), + unixnotis_core::CloseReason::DismissedByUser, + )); + assert!(!state.hidden_popups.contains(&first.key())); + + // A replacement generation is a new notification lifecycle + let replacement = notification(31, 2, "replacement action"); + state.handle_event(UiEvent::NotificationUpdated(replacement.clone(), true)); + assert!(state.popups.contains_key(&replacement.id)); + assert_eq!( + state + .popups + .get(&replacement.id) + .expect("replacement popup") + .notification + .generation, + replacement.generation + ); +} + +#[gtk::test] +fn visible_popup_materialization_and_rebuild_replace_the_exact_widget_generation() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupMaterialization", 1); + let original = notification(21, 1, "original"); + + state.add_popup(original.clone()); + let original_entry = state + .popups + .get(&original.id) + .expect("visible popup should be stored"); + assert!(original_entry.is_materialized()); + assert_eq!(state.visible_popups, vec![original.id]); + let original_root = original_entry + .root + .clone() + .expect("visible popup should have a root"); + assert!(original_root.is_visible()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); + + let replacement = notification(21, 2, "replacement"); + state.update_popup(replacement.clone(), true); + let replacement_root = state + .popups + .get(&replacement.id) + .and_then(|entry| entry.root.clone()) + .expect("replacement popup should have a root"); + assert_ne!(original_root, replacement_root); + assert!(descendant_has_text( + replacement_root.upcast_ref(), + "replacement" + )); + assert_materialized_and_visible_commands(&mut command_rx, replacement.key()); +} + +#[gtk::test] +fn visible_popup_callbacks_report_each_generation_only_once() { + let (mut state, mut command_rx) = + popup_state_with_commands("org.unixnotis.PopupVisibleOnce", 1); + let original = notification(24, 1, "original"); + state.add_popup(original.clone()); + assert_materialized_and_visible_commands(&mut command_rx, original.key()); + + let entry = state + .popups + .get(&original.id) + .expect("visible popup should be stored"); + let revealer = entry + .revealer + .as_ref() + .expect("visible popup should have a revealer"); + let visibility = entry + .visibility + .as_ref() + .expect("visible popup should retain its visibility binding"); + visibility.report_if_visible(revealer, &state.popup_window, &state.command_tx); + visibility.report_if_visible(revealer, &state.popup_window, &state.command_tx); + assert!( + command_rx.try_recv().is_err(), + "duplicate map and reveal callbacks must not send another acknowledgement" + ); + + let replacement = notification(24, 2, "replacement"); + state.update_popup(replacement.clone(), true); + assert_materialized_and_visible_commands(&mut command_rx, replacement.key()); + assert!( + command_rx.try_recv().is_err(), + "one replacement generation should produce one visibility acknowledgement" + ); +} + +#[gtk::test] +fn mapped_window_does_not_acknowledge_an_unrevealed_popup_row() { + let (mut state, mut command_rx) = popup_state_with_commands("org.unixnotis.PopupHiddenRow", 1); + let visible = notification(25, 1, "visible"); + state.add_popup(visible.clone()); + assert_materialized_and_visible_commands(&mut command_rx, visible.key()); + assert!(state.popup_window.is_mapped()); + + let hidden_revealer = gtk::Revealer::new(); + hidden_revealer.set_child(Some(>k::Label::new(Some("hidden")))); + hidden_revealer.set_reveal_child(false); + let hidden_key = NotificationKey { + id: 26, + generation: 1, + }; + let visibility = crate::ui::entry::PopupVisibilityBinding::new(hidden_key); + + visibility.report_if_visible(&hidden_revealer, &state.popup_window, &state.command_tx); + + assert!( + command_rx.try_recv().is_err(), + "a mapped window cannot make an unrevealed row visible" + ); +} + +fn assert_materialized_and_visible_commands( + command_rx: &mut tokio::sync::mpsc::Receiver, + expected: NotificationKey, +) { + match command_rx + .try_recv() + .expect("materialization acknowledgement") + { + crate::dbus::UiCommand::Materialized(notification) => { + assert_eq!(notification, expected); + } + command => panic!("unexpected command: {command:?}"), + } + match command_rx.try_recv().expect("visibility acknowledgement") { + crate::dbus::UiCommand::Visible(notification) => { + assert_eq!(notification, expected); + } + command => panic!("unexpected command: {command:?}"), + } +} + +fn descendant_has_class(widget: >k::Widget, class_name: &str) -> bool { + let mut child = widget.first_child(); + while let Some(current) = child { + if current.has_css_class(class_name) || descendant_has_class(¤t, class_name) { + return true; + } + child = current.next_sibling(); + } + false +} + +fn descendant_class_count(widget: >k::Widget, class_name: &str) -> usize { + let own = usize::from(widget.has_css_class(class_name)); + let mut count = own; + let mut child = widget.first_child(); + while let Some(current) = child { + count += descendant_class_count(¤t, class_name); + child = current.next_sibling(); + } + count +} + +fn descendant_has_text(widget: >k::Widget, expected: &str) -> bool { + if widget + .downcast_ref::() + .is_some_and(|label| label.text().as_str() == expected) + { + return true; + } + let mut child = widget.first_child(); + while let Some(current) = child { + if descendant_has_text(¤t, expected) { + return true; + } + child = current.next_sibling(); + } + false +} + +fn popup_state(application_id: &str) -> UiState { + popup_state_with_commands(application_id, 0).0 +} + +fn popup_state_with_commands( + application_id: &str, + max_visible: usize, +) -> (UiState, tokio::sync::mpsc::Receiver) { + let app = gtk::Application::builder() + .application_id(application_id) + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>) + .expect("register popup mutation application"); + let mut config = Config::default(); + config.popups.max_visible = max_visible; + let root = std::env::temp_dir().join("unixnotis-popup-mutation"); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(4); + let css = CssManager::new_popup(theme_paths(&root), config.theme.clone()); + + ( + UiState::new(&app, config, root.join("config.toml"), command_tx, css), + command_rx, + ) +} + +fn notification(id: u32, generation: u64, summary: &str) -> NotificationView { + NotificationView { + id, + generation, + app_name: "Example".to_string(), + attribution: unixnotis_core::NotificationAttribution::default(), + summary: summary.to_string(), + body: "Body".to_string(), + actions: Vec::new(), + inline_reply: unixnotis_core::InlineReply::default(), + inline_reply_policy: unixnotis_core::InlineReplyPolicy::Deny, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 0, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} diff --git a/crates/unixnotis-popups/src/ui/state/tests/support.rs b/crates/unixnotis-popups/src/ui/state/tests/support.rs new file mode 100644 index 000000000..2d31f17b2 --- /dev/null +++ b/crates/unixnotis-popups/src/ui/state/tests/support.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +use unixnotis_core::ThemePaths; + +pub(super) fn theme_paths(root: &Path) -> ThemePaths { + let root = root.to_path_buf(); + ThemePaths { + base_dir: root.clone(), + base_css: root.join("base.css"), + popup_css: root.join("popup.css"), + panel_css: root.join("panel.css"), + widgets_css: root.join("widgets.css"), + media_css: root.join("media.css"), + } +} diff --git a/crates/unixnotis-popups/src/ui/tests/config_reload.rs b/crates/unixnotis-popups/src/ui/tests/config_reload.rs index 23fbfe758..ef64be532 100644 --- a/crates/unixnotis-popups/src/ui/tests/config_reload.rs +++ b/crates/unixnotis-popups/src/ui/tests/config_reload.rs @@ -38,6 +38,16 @@ fn rejected_config_logs_never_include_private_parser_text() { assert!(!rendered.contains("private-popup-parser-sentinel")); } +#[test] +fn oversized_config_uses_a_stable_rejection_kind() { + let error = ConfigError::TooLarge { + size: 2_000_000, + max: 1_048_576, + }; + + assert_eq!(config_error_kind(&error), "too-large"); +} + #[test] fn theme_resolution_failure_logs_only_the_stable_stage() { let output = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/unixnotis-popups/src/ui/tests/icon_state.rs b/crates/unixnotis-popups/src/ui/tests/icon_state.rs deleted file mode 100644 index 877fb2188..000000000 --- a/crates/unixnotis-popups/src/ui/tests/icon_state.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::*; - -#[test] -fn negative_icon_cache_expires_at_the_ttl_boundary() { - let now = Instant::now(); - - let fresh = now - .checked_sub(Duration::from_secs(14)) - .expect("fresh timestamp should remain representable"); - let expired = now - .checked_sub(NEGATIVE_ICON_CACHE_TTL) - .expect("expired timestamp should remain representable"); - - assert!(negative_cache_is_fresh(fresh, now)); - assert!(!negative_cache_is_fresh(expired, now)); -} - -#[test] -fn negative_icon_cache_handles_future_timestamp_without_panicking() { - let now = Instant::now(); - - assert!(negative_cache_is_fresh(now + Duration::from_secs(1), now)); -} diff --git a/crates/unixnotis-popups/src/ui/window/build.rs b/crates/unixnotis-popups/src/ui/window/build.rs index a4eca7074..93fc3c0ed 100644 --- a/crates/unixnotis-popups/src/ui/window/build.rs +++ b/crates/unixnotis-popups/src/ui/window/build.rs @@ -8,6 +8,7 @@ use unixnotis_core::Config; use super::anchor::apply_anchor; use super::input_region::{refresh_popup_input_region, PopupInputRegionState}; use super::monitor::{default_monitor, find_monitor}; +use super::width_constraint::PopupWidthConstraint; // Keep popup width proportional on compact displays to avoid oversized cards. const POPUP_WIDTH_MONITOR_RATIO_CAP: f32 = 0.28; @@ -21,7 +22,8 @@ pub(in crate::ui) fn build_popup_window( // Window lifecycle hooks are centralized here to keep popup setup deterministic let window = gtk::ApplicationWindow::new(app); window.set_decorated(false); - window.set_resizable(false); + // Layer-shell has no user resize chrome, but GTK must accept content-driven height changes + window.set_resizable(true); window.set_title(Some("UnixNotis Popups")); window.add_css_class("unixnotis-popup-window"); @@ -33,9 +35,15 @@ pub(in crate::ui) fn build_popup_window( // Stack owns popup layout and reveal order for visible entries let stack = gtk::Box::new(gtk::Orientation::Vertical, config.popups.spacing); stack.add_css_class("unixnotis-popup-stack"); - window.set_child(Some(&stack)); + let width_constraint = PopupWidthConstraint::new(&stack, config.popups.width); + window.set_child(Some(&width_constraint)); window.set_visible(false); + window.connect_default_width_notify(move |window| { + // Config and monitor changes update the height-for-width measurement hint + width_constraint.set_width_hint(window.default_width()); + }); + // Shared input-region state is reused by config reloads and runtime visibility updates let input_region = PopupInputRegionState::new(config.popups.allow_click_through); apply_popup_config(&window, &stack, config, &input_region); @@ -132,13 +140,12 @@ pub(in crate::ui) fn apply_popup_config( } // Width follows config but is capped by monitor geometry on smaller displays. let popup_width = resolve_popup_width(config, monitor.as_ref()); - // Width is fixed by config while height remains content-driven - window.set_default_size(popup_width, 1); + // A negative height asks GTK to use the stack's natural height + window.set_default_size(popup_width, -1); window.set_size_request(popup_width, -1); - // Stack width follows popup width exactly so children cannot request wider geometry. - // This keeps popup geometry pinned to config even with hostile payload text - stack.set_size_request(popup_width, -1); - stack.set_hexpand(false); + // The window and width constraint own horizontal geometry + stack.set_size_request(-1, -1); + stack.set_hexpand(true); stack.set_spacing(config.popups.spacing); apply_anchor(window, config.popups.anchor, config.popups.margin); diff --git a/crates/unixnotis-popups/src/ui/window/mod.rs b/crates/unixnotis-popups/src/ui/window/mod.rs index 3dda3ae0b..110fd33e9 100644 --- a/crates/unixnotis-popups/src/ui/window/mod.rs +++ b/crates/unixnotis-popups/src/ui/window/mod.rs @@ -4,6 +4,7 @@ mod anchor; mod build; mod input_region; mod monitor; +mod width_constraint; pub(super) use build::{apply_popup_config, build_popup_window}; pub(super) use input_region::{refresh_popup_input_region, PopupInputRegionState}; diff --git a/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs b/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs new file mode 100644 index 000000000..9c62042fe --- /dev/null +++ b/crates/unixnotis-popups/src/ui/window/tests/width_constraint.rs @@ -0,0 +1,45 @@ +use gtk::prelude::*; + +use super::PopupWidthConstraint; + +#[gtk::test] +fn unconstrained_vertical_measurement_uses_the_known_surface_width() { + let label = gtk::Label::new(Some( + "A wrapping popup body must measure against the fixed layer width", + )); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + let constraint = PopupWidthConstraint::new(&label, 240); + + assert_eq!( + constraint.measure(gtk::Orientation::Vertical, -1), + label.measure(gtk::Orientation::Vertical, 240) + ); + assert_eq!( + constraint.request_mode(), + gtk::SizeRequestMode::HeightForWidth + ); + assert_eq!( + constraint.measure(gtk::Orientation::Horizontal, 1), + (240, 240, -1, -1) + ); +} + +#[gtk::test] +fn updated_surface_width_changes_the_vertical_measurement_contract() { + let label = gtk::Label::new(Some( + "A longer wrapping body needs more lines when the popup becomes narrow", + )); + label.set_wrap(true); + label.set_wrap_mode(gtk::pango::WrapMode::WordChar); + let constraint = PopupWidthConstraint::new(&label, 280); + let wide = constraint.measure(gtk::Orientation::Vertical, -1); + + constraint.set_width_hint(120); + let narrow = constraint.measure(gtk::Orientation::Vertical, -1); + + assert!( + narrow.0 > wide.0, + "a narrower fixed surface must report a taller minimum" + ); +} diff --git a/crates/unixnotis-popups/src/ui/window/width_constraint.rs b/crates/unixnotis-popups/src/ui/window/width_constraint.rs new file mode 100644 index 000000000..6356f2f0b --- /dev/null +++ b/crates/unixnotis-popups/src/ui/window/width_constraint.rs @@ -0,0 +1,124 @@ +//! Fixed-width measurement bridge for height-for-width popup content + +use std::cell::{Cell, RefCell}; + +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; + +mod imp { + use super::{glib, Cell, RefCell}; + use gtk::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default)] + pub struct PopupWidthConstraint { + pub(super) child: RefCell>, + pub(super) width_hint: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for PopupWidthConstraint { + const NAME: &'static str = "UnixNotisPopupWidthConstraint"; + type Type = super::PopupWidthConstraint; + type ParentType = gtk::Widget; + + fn class_init(class: &mut Self::Class) { + class.set_css_name("unixnotis-popup-width-constraint"); + } + } + + impl ObjectImpl for PopupWidthConstraint { + fn dispose(&self) { + if let Some(child) = self.child.borrow_mut().take() { + // Custom parenting must be released before GTK finalizes the wrapper + child.unparent(); + } + } + } + + impl WidgetImpl for PopupWidthConstraint { + fn request_mode(&self) -> gtk::SizeRequestMode { + gtk::SizeRequestMode::HeightForWidth + } + + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + let width_hint = self.width_hint.get().max(1); + if orientation == gtk::Orientation::Horizontal { + // The layer surface owns width, so content never expands or contracts it + return (width_hint, width_hint, -1, -1); + } + + let Some(child) = self.child.borrow().as_ref().cloned() else { + return (0, 0, -1, -1); + }; + + // GTK asks for an unconstrained vertical minimum before layer-shell + // supplies the fixed surface width. Reuse that known width so wrapping + // text reports the same height in both passes + let child_for_size = if for_size < 0 { width_hint } else { for_size }; + child.measure(orientation, child_for_size) + } + + fn size_allocate(&self, width: i32, height: i32, baseline: i32) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + // The wrapper has no visual box of its own, so the child receives all space + child.allocate(width, height, baseline, None); + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + if child.is_visible() { + // Custom parenting requires explicit snapshot delegation + self.obj().snapshot_child(&child, snapshot); + } + } + } +} + +glib::wrapper! { + pub struct PopupWidthConstraint(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl PopupWidthConstraint { + pub(super) fn new(child: &impl IsA, width_hint: i32) -> Self { + let constraint: Self = glib::Object::new(); + constraint.set_child(Some(child)); + constraint.set_width_hint(width_hint); + constraint + } + + pub(super) fn set_width_hint(&self, width_hint: i32) { + let width_hint = width_hint.max(1); + if self.imp().width_hint.replace(width_hint) != width_hint { + // A config or monitor change can alter line wrapping and total height + self.queue_resize(); + } + } + + fn set_child(&self, child: Option<&impl IsA>) { + let imp = self.imp(); + let next = child.map(|child| child.clone().upcast::()); + if imp.child.borrow().as_ref() == next.as_ref() { + return; + } + if let Some(current) = imp.child.borrow_mut().take() { + current.unparent(); + } + if let Some(next) = next { + next.set_parent(self); + imp.child.replace(Some(next)); + } + self.queue_resize(); + } +} + +#[cfg(test)] +#[path = "tests/width_constraint.rs"] +mod tests; diff --git a/crates/unixnotis-ui/Cargo.toml b/crates/unixnotis-ui/Cargo.toml index 1c5d9e8ed..e10cb2008 100644 --- a/crates/unixnotis-ui/Cargo.toml +++ b/crates/unixnotis-ui/Cargo.toml @@ -13,7 +13,9 @@ serde.workspace = true serde_json.workspace = true url.workspace = true +[build-dependencies] +glib-build-tools.workspace = true + [[bin]] name = "unixnotis-css-validate" path = "src/bin/css_validate.rs" -test = false diff --git a/crates/unixnotis-ui/build.rs b/crates/unixnotis-ui/build.rs new file mode 100644 index 000000000..af2e0fc89 --- /dev/null +++ b/crates/unixnotis-ui/build.rs @@ -0,0 +1,8 @@ +fn main() { + // Compile security badges once so every UI client renders the same controlled symbols + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "unixnotis-ui.gresource", + ); +} diff --git a/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg new file mode 100644 index 000000000..c52f2a05d --- /dev/null +++ b/crates/unixnotis-ui/resources/icons/unixnotis-app-unknown-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-ui/resources/icons/unixnotis-shield-warning-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-shield-warning-symbolic.svg new file mode 100644 index 000000000..43433398f --- /dev/null +++ b/crates/unixnotis-ui/resources/icons/unixnotis-shield-warning-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-ui/resources/icons/unixnotis-system-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-system-symbolic.svg new file mode 100644 index 000000000..5ccbd65d1 --- /dev/null +++ b/crates/unixnotis-ui/resources/icons/unixnotis-system-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-ui/resources/icons/unixnotis-terminal-symbolic.svg b/crates/unixnotis-ui/resources/icons/unixnotis-terminal-symbolic.svg new file mode 100644 index 000000000..12132a1b3 --- /dev/null +++ b/crates/unixnotis-ui/resources/icons/unixnotis-terminal-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/unixnotis-ui/resources/resources.gresource.xml b/crates/unixnotis-ui/resources/resources.gresource.xml new file mode 100644 index 000000000..76d3a0def --- /dev/null +++ b/crates/unixnotis-ui/resources/resources.gresource.xml @@ -0,0 +1,9 @@ + + + + icons/unixnotis-app-unknown-symbolic.svg + icons/unixnotis-shield-warning-symbolic.svg + icons/unixnotis-terminal-symbolic.svg + icons/unixnotis-system-symbolic.svg + + diff --git a/crates/unixnotis-ui/src/bin/css_validate.rs b/crates/unixnotis-ui/src/bin/css_validate.rs index 464a0b3d9..315191dce 100644 --- a/crates/unixnotis-ui/src/bin/css_validate.rs +++ b/crates/unixnotis-ui/src/bin/css_validate.rs @@ -47,7 +47,23 @@ fn main() -> ExitCode { fn run_path_protocol(path: &Path) -> ExitCode { // Initialization stays inside this helper so ordinary CLI commands do not load GTK - let report = match gtk::init() { + let report = path_report(path); + + // One JSON document keeps the parent-side protocol simple and deterministic + match serde_json::to_string(&report) { + Ok(encoded) => { + println!("{encoded}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("failed to encode CSS validation report: {error}"); + ExitCode::from(2) + } + } +} + +fn path_report(path: &Path) -> ValidatorReport { + match gtk::init() { Ok(()) => { let (diagnostics, truncated) = parse_path(path); ValidatorReport { @@ -63,18 +79,6 @@ fn run_path_protocol(path: &Path) -> ExitCode { truncated: false, diagnostics: Vec::new(), }, - }; - - // One JSON document keeps the parent-side protocol simple and deterministic - match serde_json::to_string(&report) { - Ok(encoded) => { - println!("{encoded}"); - ExitCode::SUCCESS - } - Err(error) => { - eprintln!("failed to encode CSS validation report: {error}"); - ExitCode::from(2) - } } } @@ -90,6 +94,17 @@ fn run_stdin_protocol() -> ExitCode { return ExitCode::SUCCESS; } + let parse_errors = parse_css_text(&css); + + // Success remains silent for easy use from build scripts + if parse_errors == 0 { + return ExitCode::SUCCESS; + } + eprintln!("gtk css validation found {parse_errors} parse error(s)"); + ExitCode::from(1) +} + +fn parse_css_text(css: &str) -> usize { // Parse errors are counted without retaining unbounded GTK messages let provider = CssProvider::new(); let parse_errors = Rc::new(Cell::new(0usize)); @@ -104,17 +119,8 @@ fn run_stdin_protocol() -> ExitCode { error ); }); - provider.load_from_data(&css); - - // Success remains silent for easy use from build scripts - if parse_errors.get() == 0 { - return ExitCode::SUCCESS; - } - eprintln!( - "gtk css validation found {} parse error(s)", - parse_errors.get() - ); - ExitCode::from(1) + provider.load_from_string(css); + parse_errors.get() } fn parse_path(path: &Path) -> (Vec, bool) { @@ -152,3 +158,7 @@ fn parse_path(path: &Path) -> (Vec, bool) { let parsed = diagnostics.borrow().clone(); (parsed, truncated.get()) } + +#[cfg(test)] +#[path = "tests/css_validate.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/bin/tests/css_validate.rs b/crates/unixnotis-ui/src/bin/tests/css_validate.rs new file mode 100644 index 000000000..f436f5294 --- /dev/null +++ b/crates/unixnotis-ui/src/bin/tests/css_validate.rs @@ -0,0 +1,115 @@ +use std::error::Error; +use std::fmt::Write as _; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::{parse_css_text, path_report}; + +type TestResult = Result<(), Box>; + +static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); + +#[gtk::test] +fn css_validator_accepts_parseable_css_and_rejects_invalid_css() -> TestResult { + assert_eq!(parse_css_text(".panel { color: #ffffff; }"), 0); + assert!(parse_css_text(".panel { color: ;") > 0); + Ok(()) +} + +#[gtk::test] +fn path_protocol_accepts_percent_encoded_asset_urls() -> TestResult { + let root = temp_root("encoded-imports"); + let assets = root.join("assets"); + std::fs::create_dir_all(&assets)?; + let cases = [ + ("icon%20one.svg", "icon one.svg"), + ("icon%23one.svg", "icon#one.svg"), + ("icon%25one.svg", "icon%one.svg"), + ("icon%29one.svg", "icon)one.svg"), + ("icon%22one.svg", "icon\"one.svg"), + ]; + let mut stylesheet = String::new(); + for (index, (encoded_name, decoded_name)) in cases.into_iter().enumerate() { + std::fs::write( + assets.join(decoded_name), + "", + )?; + writeln!( + stylesheet, + ".encoded-{index}-plain {{ background-image: url(assets/{encoded_name}); }}" + )?; + writeln!( + stylesheet, + ".encoded-{index}-quoted {{ background-image: url(\"assets/{encoded_name}\"); }}" + )?; + } + let stylesheet_path = root.join("base.css"); + std::fs::write(&stylesheet_path, stylesheet)?; + + let report = path_report(&stylesheet_path); + + if report.available { + assert!(report.diagnostics.is_empty()); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[gtk::test] +fn path_protocol_accepts_css_escaped_url_and_import_names() -> TestResult { + let root = temp_root("escaped-reference-tokens"); + let assets = root.join("assets"); + std::fs::create_dir_all(&assets)?; + std::fs::write( + assets.join("icon.svg"), + "", + )?; + std::fs::write(root.join("colors.css"), ".imported { color: red; }")?; + let stylesheet = root.join("base.css"); + std::fs::write( + &stylesheet, + concat!( + "@im\\70ort \"colors.css\";\n", + ".short { background-image: u\\72l(\"assets/icon.svg\"); }\n", + ".six { background-image: U\\000052L(assets/icon.svg); }\n", + ), + )?; + + let report = path_report(&stylesheet); + + if report.available { + assert!(report.diagnostics.is_empty()); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[gtk::test] +fn path_protocol_returns_bounded_structured_diagnostics() -> TestResult { + let root = temp_root("diagnostic-cap"); + std::fs::create_dir_all(&root)?; + let stylesheet = root.join("many-errors.css"); + let mut css = String::new(); + for index in 0..12 { + writeln!(css, ".broken-{index} {{ color: ; }}")?; + } + std::fs::write(&stylesheet, css)?; + + let report = path_report(&stylesheet); + + if report.available { + assert!(!report.diagnostics.is_empty()); + assert!(report.diagnostics.len() <= 4); + assert!(report.truncated); + assert_eq!(report.diagnostics[0].line, 1); + } + std::fs::remove_dir_all(root)?; + Ok(()) +} + +fn temp_root(name: &str) -> std::path::PathBuf { + let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "unixnotis-css-validator-{name}-{}-{serial}", + std::process::id() + )) +} diff --git a/crates/unixnotis-ui/src/css/loader/merge.rs b/crates/unixnotis-ui/src/css/loader/merge.rs index 9fdd59ca2..960ee246a 100644 --- a/crates/unixnotis-ui/src/css/loader/merge.rs +++ b/crates/unixnotis-ui/src/css/loader/merge.rs @@ -14,5 +14,5 @@ pub(super) fn merge_css_with_overrides(contents: &str, fallback: &str, overrides } #[cfg(test)] -#[path = "../tests/loader/merge.rs"] +#[path = "tests/merge.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/loader/mod.rs b/crates/unixnotis-ui/src/css/loader/mod.rs index efbb10b86..6b502d849 100644 --- a/crates/unixnotis-ui/src/css/loader/mod.rs +++ b/crates/unixnotis-ui/src/css/loader/mod.rs @@ -10,5 +10,5 @@ pub(super) use model::{CssFileLoadResult, CssFileLoadSource}; pub(super) use provider::load_provider_with_overrides; #[cfg(test)] -#[path = "../tests/loader/provider.rs"] +#[path = "tests/provider.rs"] mod provider_tests; diff --git a/crates/unixnotis-ui/src/css/loader/model.rs b/crates/unixnotis-ui/src/css/loader/model.rs index 487da7820..b17ea323d 100644 --- a/crates/unixnotis-ui/src/css/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/model.rs @@ -3,7 +3,7 @@ /// Source used for the CSS bytes passed to GTK #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(in crate::css) enum CssFileLoadSource { - /// Non-empty custom CSS was read from disk + /// Non-empty configured CSS was read from disk Custom, /// An intentionally empty file used embedded defaults EmptyFallback, @@ -42,5 +42,5 @@ impl CssFileLoadResult { } #[cfg(test)] -#[path = "../tests/loader/model.rs"] +#[path = "tests/model.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/loader/provider.rs b/crates/unixnotis-ui/src/css/loader/provider.rs index b6711d1c6..646bb8e8b 100644 --- a/crates/unixnotis-ui/src/css/loader/provider.rs +++ b/crates/unixnotis-ui/src/css/loader/provider.rs @@ -1,9 +1,10 @@ //! CSS provider loading with explicit fallback outcomes -use std::fs; +use std::io; use std::path::Path; use tracing::warn; +use unixnotis_core::{filesystem::read_regular_file_bounded, MAX_CSS_FILE_BYTES}; use super::merge::merge_css_with_overrides; use super::model::CssFileLoadResult; @@ -18,7 +19,7 @@ pub fn load_provider_with_overrides( overrides: &str, inject_base_tokens: bool, ) -> CssFileLoadResult { - match fs::read_to_string(path) { + match read_runtime_css(path) { Ok(contents) => { let contents = if inject_base_tokens { ensure_base_tokens(&contents, path) @@ -64,3 +65,9 @@ pub fn load_provider_with_overrides( } } } + +/// Read one configured stylesheet through the shared no-follow regular-file boundary +fn read_runtime_css(path: &Path) -> io::Result { + let bytes = read_regular_file_bounded(path, MAX_CSS_FILE_BYTES)?; + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} diff --git a/crates/unixnotis-ui/src/css/tests/loader/merge.rs b/crates/unixnotis-ui/src/css/loader/tests/merge.rs similarity index 97% rename from crates/unixnotis-ui/src/css/tests/loader/merge.rs rename to crates/unixnotis-ui/src/css/loader/tests/merge.rs index 0e654e0ef..9db8cdb52 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/merge.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/merge.rs @@ -1,3 +1,5 @@ +//! CSS source merge tests + use super::*; #[test] diff --git a/crates/unixnotis-ui/src/css/tests/loader/model.rs b/crates/unixnotis-ui/src/css/loader/tests/model.rs similarity index 93% rename from crates/unixnotis-ui/src/css/tests/loader/model.rs rename to crates/unixnotis-ui/src/css/loader/tests/model.rs index db6b98598..a6273227e 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/model.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/model.rs @@ -1,3 +1,5 @@ +//! CSS loader result-model tests + use super::*; #[test] diff --git a/crates/unixnotis-ui/src/css/tests/loader/paths.rs b/crates/unixnotis-ui/src/css/loader/tests/paths.rs similarity index 95% rename from crates/unixnotis-ui/src/css/tests/loader/paths.rs rename to crates/unixnotis-ui/src/css/loader/tests/paths.rs index c4d67591e..c3634b895 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/paths.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/paths.rs @@ -1,3 +1,5 @@ +//! CSS loader path tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/loader/tests/provider.rs b/crates/unixnotis-ui/src/css/loader/tests/provider.rs new file mode 100644 index 000000000..f3b69f789 --- /dev/null +++ b/crates/unixnotis-ui/src/css/loader/tests/provider.rs @@ -0,0 +1,157 @@ +//! CSS provider loading tests + +use std::cell::RefCell; +use std::fs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use unixnotis_core::MAX_CSS_FILE_BYTES; + +use super::*; + +fn unique_css_test_dir(label: &str) -> PathBuf { + static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); + + // A per-test directory avoids cross-test races while keeping dependencies small + let unique = NEXT_DIR.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "unixnotis-ui-css-loader-{pid}-{label}-{unique}", + pid = std::process::id(), + )); + fs::create_dir_all(&path).expect("create css test directory"); + path +} + +#[test] +fn load_provider_with_overrides_loads_merged_and_rebased_css_into_sink() { + let root = unique_css_test_dir("load-provider"); + let css_dir = root.join("themes"); + let css_path = css_dir.join("widgets.css"); + let loaded = RefCell::new(Vec::::new()); + + fs::create_dir_all(&css_dir).expect("create css fixture directory"); + fs::write( + &css_path, + ".card { background-image: url(icons/card.png); color: green; }", + ) + .expect("write css fixture"); + + load_provider_with_overrides( + |data| { + // Tests inspect the exact bytes sent to GTK without needing a display server + loaded.borrow_mut().push(data.to_string()); + }, + &css_path, + ".card { color: red; }", + ".card { color: blue; }", + false, + ); + + let loaded = loaded.borrow(); + assert_eq!(loaded.len(), 1); + // Edited user CSS keeps overrides first, then rebases asset refs before GTK sees the data + assert!(loaded[0].starts_with(".card { color: blue; }\n.card")); + assert!(loaded[0].contains("file://")); + assert!(loaded[0].contains("/themes/icons/card.png")); + + fs::remove_dir_all(root).expect("remove css test directory"); +} + +#[test] +fn empty_css_uses_the_embedded_fallback() { + let root = unique_css_test_dir("empty"); + let path = root.join("popup.css"); + fs::write(&path, "\n \t").expect("write empty stylesheet"); + let loaded = RefCell::new(Vec::new()); + + let result = load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback { color: red; }", + "", + false, + ); + + assert_eq!(result.source, CssFileLoadSource::EmptyFallback); + assert_eq!(loaded.borrow().as_slice(), [".fallback { color: red; }"]); + fs::remove_dir_all(root).expect("remove css test directory"); +} + +#[test] +fn unsafe_or_invalid_css_files_use_the_embedded_fallback() { + let root = unique_css_test_dir("unsafe"); + let fallback = ".fallback { color: red; }"; + let cases = [ + "missing", + "invalid-utf8", + "directory", + "symlink", + "oversized", + ]; + + for case in cases { + let path = root.join(case); + match case { + "missing" => {} + "invalid-utf8" => fs::write(&path, [0xff, 0xfe]).expect("write invalid CSS"), + "directory" => fs::create_dir(&path).expect("create CSS directory"), + "symlink" => { + let target = root.join("symlink-target.css"); + fs::write(&target, ".target { color: blue; }").expect("write symlink target"); + std::os::unix::fs::symlink(&target, &path).expect("create CSS symlink"); + } + "oversized" => { + let file = fs::File::create(&path).expect("create oversized CSS"); + file.set_len(MAX_CSS_FILE_BYTES + 1) + .expect("make CSS file oversized"); + } + _ => unreachable!("all cases are covered above"), + } + + let loaded = RefCell::new(Vec::new()); + let result = load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + fallback, + "", + false, + ); + + assert_eq!( + result.source, + CssFileLoadSource::ReadFailureFallback, + "{case}" + ); + assert_eq!(loaded.borrow().as_slice(), [fallback], "{case}"); + } + + fs::remove_dir_all(root).expect("remove css test directory"); +} + +#[test] +fn configured_css_reload_replaces_the_previous_contents() { + let root = unique_css_test_dir("reload"); + let path = root.join("popup.css"); + let loaded = RefCell::new(Vec::new()); + + fs::write(&path, ".popup { color: red; }").expect("write first stylesheet"); + load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback {}", + "", + false, + ); + fs::write(&path, ".popup { color: green; }").expect("write second stylesheet"); + load_provider_with_overrides( + |data| loaded.borrow_mut().push(data.to_string()), + &path, + ".fallback {}", + "", + false, + ); + + let loaded = loaded.borrow(); + assert!(loaded[0].contains("red")); + assert!(loaded[1].contains("green")); + fs::remove_dir_all(root).expect("remove css test directory"); +} diff --git a/crates/unixnotis-ui/src/css/tests/loader/rebase.rs b/crates/unixnotis-ui/src/css/loader/tests/rebase.rs similarity index 99% rename from crates/unixnotis-ui/src/css/tests/loader/rebase.rs rename to crates/unixnotis-ui/src/css/loader/tests/rebase.rs index 78209485a..c47b9f936 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/rebase.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/rebase.rs @@ -1,3 +1,5 @@ +//! CSS URL rebasing tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/tests/loader/tokens.rs b/crates/unixnotis-ui/src/css/loader/tests/tokens.rs similarity index 98% rename from crates/unixnotis-ui/src/css/tests/loader/tokens.rs rename to crates/unixnotis-ui/src/css/loader/tests/tokens.rs index 20c53f48f..62783917f 100644 --- a/crates/unixnotis-ui/src/css/tests/loader/tokens.rs +++ b/crates/unixnotis-ui/src/css/loader/tests/tokens.rs @@ -1,3 +1,5 @@ +//! CSS tokenization tests + use std::path::Path; use super::*; diff --git a/crates/unixnotis-ui/src/css/loader/tokens.rs b/crates/unixnotis-ui/src/css/loader/tokens.rs index ef858bbb1..aed888002 100644 --- a/crates/unixnotis-ui/src/css/loader/tokens.rs +++ b/crates/unixnotis-ui/src/css/loader/tokens.rs @@ -25,5 +25,5 @@ pub(super) fn ensure_base_tokens(contents: &str, path: &Path) -> String { } #[cfg(test)] -#[path = "../tests/loader/tokens.rs"] +#[path = "tests/tokens.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/loader/urls.rs b/crates/unixnotis-ui/src/css/loader/urls.rs index be3fe7da8..d9e5e70ce 100644 --- a/crates/unixnotis-ui/src/css/loader/urls.rs +++ b/crates/unixnotis-ui/src/css/loader/urls.rs @@ -94,8 +94,8 @@ fn normalize_lexical_path(path: &Path) -> PathBuf { } #[cfg(test)] -#[path = "../tests/loader/paths.rs"] +#[path = "tests/paths.rs"] mod path_tests; #[cfg(test)] -#[path = "../tests/loader/rebase.rs"] +#[path = "tests/rebase.rs"] mod rebase_tests; diff --git a/crates/unixnotis-ui/src/css/manager/layers.rs b/crates/unixnotis-ui/src/css/manager/layers.rs index 0212d99e5..066556091 100644 --- a/crates/unixnotis-ui/src/css/manager/layers.rs +++ b/crates/unixnotis-ui/src/css/manager/layers.rs @@ -14,6 +14,8 @@ pub enum CssProviderLayer { Widgets, /// MPRIS media card layer Media, + /// Internal reduced-motion policy loaded above editable theme layers + MotionPolicy, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/crates/unixnotis-ui/src/css/manager/provider.rs b/crates/unixnotis-ui/src/css/manager/provider.rs index 8e8cf0978..185b48eb9 100644 --- a/crates/unixnotis-ui/src/css/manager/provider.rs +++ b/crates/unixnotis-ui/src/css/manager/provider.rs @@ -10,10 +10,14 @@ pub(super) trait CssProviderBackend: Clone { impl CssProviderBackend for CssProvider { fn load_css_data(&self, data: &str) { - self.load_from_data(data); + self.load_from_string(data); } fn add_to_display(&self, display: &gdk::Display, priority: u32) { gtk::style_context_add_provider_for_display(display, self, priority); } } + +#[cfg(test)] +#[path = "tests/provider.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/css/manager/stack/display.rs b/crates/unixnotis-ui/src/css/manager/stack/display.rs index a146d68dd..a04f7e4cd 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/display.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/display.rs @@ -70,6 +70,13 @@ where priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 3, }); } + if self.motion_policy.is_some() { + // Reduced motion is an accessibility contract rather than a theme suggestion + registrations.push(CssProviderRegistration { + layer: CssProviderLayer::MotionPolicy, + priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 4, + }); + } registrations } @@ -81,6 +88,7 @@ where CssProviderLayer::Popup => self.popup.as_ref(), CssProviderLayer::Widgets => self.widgets.as_ref(), CssProviderLayer::Media => self.media.as_ref(), + CssProviderLayer::MotionPolicy => self.motion_policy.as_ref(), } } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/model.rs b/crates/unixnotis-ui/src/css/manager/stack/model.rs index f69f3665e..826527ffa 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/model.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/model.rs @@ -57,6 +57,8 @@ where pub(super) panel: Option

, pub(super) widgets: Option

, pub(super) media: Option

, + // Runtime accessibility policy must override every editable panel theme layer + pub(super) motion_policy: Option

, pub(super) popup: Option

, } @@ -71,6 +73,7 @@ impl CssManagerInner { panel: Some(CssProvider::new()), widgets: Some(CssProvider::new()), media: Some(CssProvider::new()), + motion_policy: Some(CssProvider::new()), popup: None, } } @@ -85,6 +88,7 @@ impl CssManagerInner { panel: None, widgets: None, media: None, + motion_policy: None, popup: Some(CssProvider::new()), } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/reload.rs index c8b21c077..f68dd18bc 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/reload.rs @@ -2,7 +2,7 @@ use unixnotis_core::{ ThemeConfig, ThemePaths, DEFAULT_MEDIA_CSS, DEFAULT_PANEL_CSS, DEFAULT_POPUP_CSS, - DEFAULT_WIDGETS_CSS, INTERNAL_STRUCTURE_CSS, + DEFAULT_WIDGETS_CSS, INTERNAL_STRUCTURE_CSS, MOTION_POLICY_CSS, }; use super::super::super::loader::{ @@ -122,6 +122,11 @@ where )); } + if let Some(motion_policy) = self.motion_policy.as_ref() { + // This fixed policy is intentionally loaded after every editable panel layer + motion_policy.load_css_data(MOTION_POLICY_CSS); + } + // Callers receive every layer outcome instead of a lossy success count CssReloadReport { layers: loaded } } diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs index 0212af9d4..2a8c339c8 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/display.rs @@ -53,6 +53,7 @@ fn panel_manager_registers_base_panel_widgets_and_media_priorities() { panel: Some(RecordingProvider::new("panel", Rc::clone(&calls))), widgets: Some(RecordingProvider::new("widgets", Rc::clone(&calls))), media: Some(RecordingProvider::new("media", Rc::clone(&calls))), + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&calls))), popup: None, }; @@ -82,6 +83,10 @@ fn panel_manager_registers_base_panel_widgets_and_media_priorities() { layer: CssProviderLayer::Media, priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 3, }, + CssProviderRegistration { + layer: CssProviderLayer::MotionPolicy, + priority: gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 4, + }, ] ); } @@ -97,6 +102,7 @@ fn popup_manager_registers_base_and_popup_at_popup_priority() { panel: None, widgets: None, media: None, + motion_policy: None, popup: Some(RecordingProvider::new("popup", Rc::clone(&calls))), }; @@ -128,7 +134,7 @@ fn public_panel_manager_reports_every_registered_provider() { ThemeConfig::default(), ); - assert_eq!(manager.apply_to_display(), 5); + assert_eq!(manager.apply_to_display(), 6); } #[test] @@ -142,6 +148,7 @@ fn provider_lookup_returns_only_layers_owned_by_the_manager() { panel: Some(RecordingProvider::new("panel", Rc::clone(&calls))), widgets: None, media: None, + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&calls))), popup: None, }; @@ -166,4 +173,10 @@ fn provider_lookup_returns_only_layers_owned_by_the_manager() { assert!(manager .provider_for_layer(CssProviderLayer::Popup) .is_none()); + assert_eq!( + manager + .provider_for_layer(CssProviderLayer::MotionPolicy) + .map(|provider| provider.label), + Some("motion") + ); } diff --git a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs index 7709e9a5f..a40942801 100644 --- a/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs +++ b/crates/unixnotis-ui/src/css/manager/stack/tests/reload.rs @@ -3,6 +3,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc; +use std::time::Duration; use gtk::gdk; use unixnotis_core::{ThemeConfig, ThemePaths}; @@ -11,6 +13,7 @@ use super::super::model::{CssManager, CssManagerInner}; use crate::css::manager::layers::CssProviderLayer; use crate::css::manager::provider::CssProviderBackend; use crate::css::manager::report::CssLayerSource; +use crate::css::{start_css_watcher, CssKind}; #[derive(Clone)] struct RecordingProvider { @@ -79,18 +82,82 @@ fn panel_manager( paths: ThemePaths, loaded: Rc>>, ) -> CssManagerInner { + let theme_config = ThemeConfig::default(); CssManagerInner { theme_paths: paths, - theme_config: ThemeConfig::default(), + theme_config, internal_structure: RecordingProvider::new("internal", Rc::clone(&loaded)), base: RecordingProvider::new("base", Rc::clone(&loaded)), panel: Some(RecordingProvider::new("panel", Rc::clone(&loaded))), widgets: Some(RecordingProvider::new("widgets", Rc::clone(&loaded))), media: Some(RecordingProvider::new("media", Rc::clone(&loaded))), + motion_policy: Some(RecordingProvider::new("motion", Rc::clone(&loaded))), popup: None, } } +fn popup_manager( + paths: ThemePaths, + loaded: Rc>>, +) -> CssManagerInner { + CssManagerInner { + theme_paths: paths, + theme_config: ThemeConfig::default(), + internal_structure: RecordingProvider::new("internal", Rc::clone(&loaded)), + base: RecordingProvider::new("base", Rc::clone(&loaded)), + panel: None, + widgets: None, + media: None, + motion_policy: None, + popup: Some(RecordingProvider::new("popup", loaded)), + } +} + +#[test] +fn configured_css_loads_without_a_theme_manifest() { + let root = unique_theme_root("without-manifest"); + let paths = theme_paths(&root); + let loaded = Rc::new(RefCell::new(Vec::new())); + write_theme(&paths, "magenta"); + let manager = panel_manager(paths, Rc::clone(&loaded)); + + let report = manager.reload(".fallback { color: red; }"); + + assert!(report + .layers + .iter() + .all(|layer| layer.source == CssLayerSource::Custom)); + assert!(loaded + .borrow() + .iter() + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) + .all(|(_label, css)| css.contains("magenta"))); + fs::remove_dir_all(root).expect("remove css manager test root"); +} + +#[test] +fn incompatible_theme_manifest_does_not_block_configured_css() { + let root = unique_theme_root("incompatible-theme"); + let paths = theme_paths(&root); + let loaded = Rc::new(RefCell::new(Vec::new())); + write_theme(&paths, "magenta"); + fs::write(paths.manifest_path(), "api_version = 1\nname = \"Old\"\n").expect("old manifest"); + let manager = panel_manager(paths, Rc::clone(&loaded)); + + let report = manager.reload(".fallback { color: red; }"); + + assert!(report + .layers + .iter() + .all(|layer| layer.source == CssLayerSource::Custom)); + assert!(loaded + .borrow() + .iter() + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) + .all(|(_, css)| css.contains("magenta"))); + fs::remove_dir_all(root).expect("remove incompatible theme test root"); +} + #[test] fn panel_reload_loads_base_panel_widgets_and_media_layers() { let root = unique_theme_root("reload-panel"); @@ -118,16 +185,20 @@ fn panel_reload_loads_base_panel_widgets_and_media_layers() { let labels = loaded.iter().map(|(label, _)| *label).collect::>(); assert_eq!( labels, - vec!["internal", "base", "panel", "widgets", "media"] + vec!["internal", "base", "panel", "widgets", "media", "motion"] ); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| css.contains("green"))); assert!(loaded .iter() .find(|(label, _)| *label == "internal") .is_some_and(|(_, css)| css.contains(".unixnotis-reload-notice"))); + assert!(loaded + .iter() + .find(|(label, _)| *label == "motion") + .is_some_and(|(_, css)| css.contains(".unixnotis-reduced-motion"))); fs::remove_dir_all(root).expect("remove css manager test root"); } @@ -143,18 +214,19 @@ fn update_theme_changes_the_paths_used_by_the_next_reload() { write_theme(&new_paths, "blue"); let mut manager = panel_manager(old_paths, Rc::clone(&loaded)); - manager.update_theme(new_paths, ThemeConfig::default()); + let theme = ThemeConfig::default(); + manager.update_theme(new_paths, theme); let report = manager.reload(".fallback { color: red; }"); assert_eq!(report.layers.len(), 4); let loaded = loaded.borrow(); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| css.contains("blue"))); assert!(loaded .iter() - .filter(|(label, _)| *label != "internal") + .filter(|(label, _)| matches!(*label, "base" | "panel" | "widgets" | "media")) .all(|(_, css)| !css.contains("red"))); fs::remove_dir_all(old_root).expect("remove old css manager test root"); @@ -169,9 +241,10 @@ fn public_manager_reload_and_theme_update_report_the_applied_stack() { let new_paths = theme_paths(&new_root); write_theme(&old_paths, "red"); write_theme(&new_paths, "blue"); - let mut manager = CssManager::new_panel(old_paths, ThemeConfig::default()); + let theme = ThemeConfig::default(); + let mut manager = CssManager::new_panel(old_paths, theme.clone()); - manager.update_theme(new_paths.clone(), ThemeConfig::default()); + manager.update_theme(new_paths.clone(), theme); let report = manager.reload(".fallback { color: red; }"); assert_eq!(report.layers.len(), 4); @@ -210,3 +283,47 @@ fn reload_report_distinguishes_empty_and_unreadable_theme_files() { fs::remove_dir_all(root).expect("remove css fallback test root"); } + +#[test] +fn popup_css_watcher_reloads_file_changes_without_a_manifest() { + let root = unique_theme_root("watcher"); + let paths = theme_paths(&root); + // The first load must use the configured file even though no theme manifest exists + fs::write(&paths.popup_css, ".popup { color: red; }").expect("write first popup CSS"); + let loaded = Rc::new(RefCell::new(Vec::new())); + let manager = popup_manager(paths.clone(), Rc::clone(&loaded)); + let initial = manager.reload(".fallback {}"); + assert_eq!( + initial + .layers + .iter() + .find(|layer| layer.layer == CssProviderLayer::Popup) + .expect("popup layer") + .source, + CssLayerSource::Custom + ); + assert!(loaded + .borrow() + .iter() + .any(|(label, css)| *label == "popup" && css.contains("red"))); + + let (reload_tx, reload_rx) = mpsc::channel(); + start_css_watcher(&paths, CssKind::Popup, move || { + let _ = reload_tx.send(()); + }) + .expect("start popup CSS watcher"); + // Atomic replacement mirrors a normal editor save and exercises the directory watcher + let replacement = root.join("popup.css.tmp"); + fs::write(&replacement, ".popup { color: green; }").expect("write replacement popup CSS"); + fs::rename(&replacement, &paths.popup_css).expect("atomically replace popup CSS"); + reload_rx + .recv_timeout(Duration::from_secs(3)) + .expect("watcher should report the popup replacement"); + + manager.reload(".fallback {}"); + assert!(loaded + .borrow() + .iter() + .any(|(label, css)| *label == "popup" && css.contains("green"))); + fs::remove_dir_all(root).expect("remove css manager test directory"); +} diff --git a/crates/unixnotis-ui/src/css/manager/tests/provider.rs b/crates/unixnotis-ui/src/css/manager/tests/provider.rs new file mode 100644 index 000000000..22299a8a8 --- /dev/null +++ b/crates/unixnotis-ui/src/css/manager/tests/provider.rs @@ -0,0 +1,18 @@ +use std::cell::Cell; +use std::rc::Rc; + +use super::{CssProvider, CssProviderBackend}; + +#[gtk::test] +fn gtk_provider_backend_loads_css_and_reports_invalid_input() { + let provider = CssProvider::new(); + let parse_errors = Rc::new(Cell::new(0)); + let observed_errors = parse_errors.clone(); + provider.connect_parsing_error(move |_, _, _| { + observed_errors.set(observed_errors.get() + 1); + }); + + provider.load_css_data(".broken { color: ;"); + + assert!(parse_errors.get() > 0); +} diff --git a/crates/unixnotis-ui/src/css/manager/tests/report.rs b/crates/unixnotis-ui/src/css/manager/tests/report.rs index ea0b538b7..459fa6897 100644 --- a/crates/unixnotis-ui/src/css/manager/tests/report.rs +++ b/crates/unixnotis-ui/src/css/manager/tests/report.rs @@ -6,6 +6,12 @@ use super::*; fn read_failures_excludes_custom_and_intentional_empty_fallbacks() { let report = CssReloadReport { layers: vec![ + CssLayerReload { + layer: CssProviderLayer::Popup, + path: PathBuf::from("popup.css"), + source: CssLayerSource::Custom, + error: None, + }, CssLayerReload { layer: CssProviderLayer::Base, path: PathBuf::from("base.css"), diff --git a/crates/unixnotis-ui/src/css/overrides.rs b/crates/unixnotis-ui/src/css/overrides.rs index ffa547d71..81cfc5fe5 100644 --- a/crates/unixnotis-ui/src/css/overrides.rs +++ b/crates/unixnotis-ui/src/css/overrides.rs @@ -1,21 +1,15 @@ //! Theme-driven CSS overrides used by the UI CSS manager -use gtk::{major_version, minor_version}; use unixnotis_core::{ build_legacy_theme_color_overrides, build_modern_theme_custom_properties, - gtk_css_features_for_version, theme_card_style_values, GtkCssFeatures, ThemeConfig, + theme_card_style_values, ThemeConfig, }; pub fn build_base_overrides(theme: &ThemeConfig) -> String { - // Runtime gating keeps older GTK builds on the legacy-safe token path - build_base_overrides_for_runtime(theme, current_gtk_css_features()) -} - -fn build_base_overrides_for_runtime(theme: &ThemeConfig, features: GtkCssFeatures) -> String { - // Legacy colors stay first so older GTK still has the same theme path + // Legacy color aliases remain first so every generated token has a stable source let mut overrides = build_legacy_theme_color_overrides(theme); - // Modern tokens are additive and only show up on runtimes that can parse them - overrides.push_str(&build_modern_theme_custom_properties(theme, features)); + // GTK 4.18 is the supported baseline for the custom-property theme contract + overrides.push_str(&build_modern_theme_custom_properties(theme)); overrides } @@ -66,11 +60,6 @@ pub fn build_popup_overrides(theme: &ThemeConfig) -> String { ) } -fn current_gtk_css_features() -> GtkCssFeatures { - // Runtime GTK version decides whether custom properties can be emitted safely - gtk_css_features_for_version(major_version(), minor_version()) -} - #[cfg(test)] #[path = "tests/overrides.rs"] mod tests; diff --git a/crates/unixnotis-ui/src/css/tests/loader/provider.rs b/crates/unixnotis-ui/src/css/tests/loader/provider.rs deleted file mode 100644 index 1470819f6..000000000 --- a/crates/unixnotis-ui/src/css/tests/loader/provider.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::cell::RefCell; -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use super::*; - -fn unique_css_test_dir(label: &str) -> PathBuf { - static NEXT_DIR: AtomicUsize = AtomicUsize::new(0); - - // A per-test directory avoids cross-test races while keeping dependencies small - let unique = NEXT_DIR.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "unixnotis-ui-css-loader-{pid}-{label}-{unique}", - pid = std::process::id(), - )); - fs::create_dir_all(&path).expect("create css test directory"); - path -} - -#[test] -fn load_provider_with_overrides_loads_merged_and_rebased_css_into_sink() { - let root = unique_css_test_dir("load-provider"); - let css_dir = root.join("themes"); - let css_path = css_dir.join("widgets.css"); - let loaded = RefCell::new(Vec::::new()); - - fs::create_dir_all(&css_dir).expect("create css fixture directory"); - fs::write( - &css_path, - ".card { background-image: url(icons/card.png); color: green; }", - ) - .expect("write css fixture"); - - load_provider_with_overrides( - |data| { - // Tests inspect the exact bytes sent to GTK without needing a display server - loaded.borrow_mut().push(data.to_string()); - }, - &css_path, - ".card { color: red; }", - ".card { color: blue; }", - false, - ); - - let loaded = loaded.borrow(); - assert_eq!(loaded.len(), 1); - // Edited user CSS keeps overrides first, then rebases asset refs before GTK sees the data - assert!(loaded[0].starts_with(".card { color: blue; }\n.card")); - assert!(loaded[0].contains("file://")); - assert!(loaded[0].contains("/themes/icons/card.png")); - - fs::remove_dir_all(root).expect("remove css test directory"); -} diff --git a/crates/unixnotis-ui/src/css/tests/overrides.rs b/crates/unixnotis-ui/src/css/tests/overrides.rs index 3d29dc56b..ed633996b 100644 --- a/crates/unixnotis-ui/src/css/tests/overrides.rs +++ b/crates/unixnotis-ui/src/css/tests/overrides.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; use std::{env, fs}; use super::{build_panel_overrides, build_popup_overrides, build_widgets_overrides}; -use unixnotis_core::{gtk_css_features_for_version, ThemeConfig}; +use unixnotis_core::ThemeConfig; #[test] fn base_overrides_clamp_alpha_values() { @@ -17,8 +17,7 @@ fn base_overrides_clamp_alpha_values() { ..ThemeConfig::default() }; - let overrides = - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 15)); + let overrides = super::build_base_overrides(&theme); let surface = format!( "alpha(@unixnotis-surface-base, {})", 1.0_f32.clamp(0.0, 1.0) @@ -46,16 +45,15 @@ fn base_overrides_can_emit_modern_custom_properties() { ..ThemeConfig::default() }; - let overrides = - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 16)); + let overrides = super::build_base_overrides(&theme); assert!(overrides.contains(":root {")); assert!(overrides.contains("--unixnotis-border-width: 3px;")); assert!(overrides.contains("--unixnotis-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-card-alpha: 0.52;")); assert!(overrides.contains("--unixnotis-panel-header-radius: 18px;")); - assert!(overrides.contains("--unixnotis-notification-card-radius: 20px;")); + assert!(overrides.contains("--unixnotis-notification-card-radius: 18px;")); assert!(overrides.contains("--unixnotis-stat-card-radius: 18px;")); - assert!(overrides.contains("--unixnotis-panel-card-padding-y: 10px;")); + assert!(overrides.contains("--unixnotis-panel-card-padding-y: 9px;")); assert!(overrides.contains("--unixnotis-popup-reveal-duration: 200ms;")); assert!(overrides.contains("--unixnotis-accent-color: @unixnotis-accent;")); assert!(overrides.contains("@define-color unixnotis-surface alpha(@unixnotis-surface-base,")); @@ -108,23 +106,8 @@ fn popup_overrides_use_theme_values() { } #[test] -fn generated_override_css_loads_without_parse_errors_for_legacy_runtime() { - // Old GTK should still accept the generated fallback path cleanly - let theme = ThemeConfig::default(); - let css = format!( - "{}\n{}\n{}\n{}", - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 15)), - build_panel_overrides(&theme), - build_widgets_overrides(&theme), - build_popup_overrides(&theme), - ); - - assert_css_validates_in_gtk(&css); -} - -#[test] -fn generated_override_css_loads_without_parse_errors_for_modern_runtime() { - // New GTK should also accept the additive custom property path cleanly +fn generated_override_css_loads_without_parse_errors() { + // The supported GTK baseline parses the complete generated token set let theme = ThemeConfig { border_width: 2, card_radius: 18, @@ -133,7 +116,7 @@ fn generated_override_css_loads_without_parse_errors_for_modern_runtime() { }; let css = format!( "{}\n{}\n{}\n{}", - super::build_base_overrides_for_runtime(&theme, gtk_css_features_for_version(4, 16)), + super::build_base_overrides(&theme), build_panel_overrides(&theme), build_widgets_overrides(&theme), build_popup_overrides(&theme), diff --git a/crates/unixnotis-ui/src/cut_corner/geometry.rs b/crates/unixnotis-ui/src/cut_corner/geometry.rs new file mode 100644 index 000000000..20cea46eb --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/geometry.rs @@ -0,0 +1,62 @@ +//! Bounded polygon construction and hit testing + +use gtk::gsk; +use unixnotis_core::CutCorners; + +#[derive(Clone, Copy)] +struct NormalizedCorners { + top_left: f32, + top_right: f32, + bottom_right: f32, + bottom_left: f32, +} + +impl NormalizedCorners { + fn new(width: f32, height: f32, corners: CutCorners) -> Self { + // Half-edge limits stop neighboring diagonal cuts from crossing + let limit = (width.max(0.0) / 2.0).min(height.max(0.0) / 2.0); + Self { + top_left: f32::from(corners.top_left).min(limit), + top_right: f32::from(corners.top_right).min(limit), + bottom_right: f32::from(corners.bottom_right).min(limit), + bottom_left: f32::from(corners.bottom_left).min(limit), + } + } +} + +pub(super) fn build_path(width: f32, height: f32, corners: CutCorners) -> gsk::Path { + let width = width.max(0.0); + let height = height.max(0.0); + let corners = NormalizedCorners::new(width, height, corners); + let path = gsk::PathBuilder::new(); + + // Clockwise points form one convex plate with a diagonal at every active corner + path.move_to(corners.top_left, 0.0); + path.line_to(width - corners.top_right, 0.0); + path.line_to(width, corners.top_right); + path.line_to(width, height - corners.bottom_right); + path.line_to(width - corners.bottom_right, height); + path.line_to(corners.bottom_left, height); + path.line_to(0.0, height - corners.bottom_left); + path.line_to(0.0, corners.top_left); + path.close(); + path.to_path() +} + +pub(super) fn contains_point(width: f64, height: f64, corners: CutCorners, x: f64, y: f64) -> bool { + if x < 0.0 || y < 0.0 || x >= width || y >= height { + // GTK hit testing excludes the far allocation edge + return false; + } + + // Pointer coordinates stay in GTK's native f64 space to avoid lossy input conversion + let limit = (width.max(0.0) / 2.0).min(height.max(0.0) / 2.0); + let top_left = f64::from(corners.top_left).min(limit); + let top_right = f64::from(corners.top_right).min(limit); + let bottom_right = f64::from(corners.bottom_right).min(limit); + let bottom_left = f64::from(corners.bottom_left).min(limit); + x + y >= top_left + && (width - x) + y >= top_right + && (width - x) + (height - y) >= bottom_right + && x + (height - y) >= bottom_left +} diff --git a/crates/unixnotis-ui/src/cut_corner/mod.rs b/crates/unixnotis-ui/src/cut_corner/mod.rs new file mode 100644 index 000000000..76dc4b45a --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/mod.rs @@ -0,0 +1,10 @@ +//! Reusable child clipping for true diagonal card corners + +mod geometry; +mod widget; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; + +pub use widget::CutCorner; diff --git a/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs new file mode 100644 index 000000000..2afb35487 --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/tests/geometry.rs @@ -0,0 +1,106 @@ +use unixnotis_core::CutCorners; + +use gtk::{graphene, gsk}; + +use super::super::geometry::{build_path, contains_point}; + +#[test] +fn hit_testing_rejects_clipped_pixels_and_accepts_the_plate() { + let corners = CutCorners { + top_left: 20, + top_right: 20, + bottom_right: 20, + bottom_left: 20, + }; + + assert!(!contains_point(100.0, 80.0, corners, 1.0, 1.0)); + assert!(!contains_point(100.0, 80.0, corners, 99.0, 1.0)); + assert!(contains_point(100.0, 80.0, corners, 50.0, 40.0)); + assert!(contains_point(100.0, 80.0, corners, 20.0, 0.0)); +} + +#[test] +fn oversized_corner_values_are_bounded_to_non_crossing_edges() { + let corners = CutCorners { + top_left: u16::MAX, + top_right: u16::MAX, + bottom_right: u16::MAX, + bottom_left: u16::MAX, + }; + + assert!(!contains_point(100.0, 40.0, corners, 1.0, 1.0)); + assert!(contains_point(100.0, 40.0, corners, 50.0, 20.0)); +} + +#[test] +fn hit_testing_rejects_every_point_outside_the_allocation() { + let corners = CutCorners::default(); + + assert!(!contains_point(100.0, 40.0, corners, -1.0, 20.0)); + assert!(!contains_point(100.0, 40.0, corners, 50.0, -1.0)); + assert!(!contains_point(100.0, 40.0, corners, 100.0, 20.0)); + assert!(!contains_point(100.0, 40.0, corners, 50.0, 40.0)); +} + +#[test] +fn hit_testing_includes_near_edges_and_cuts_each_corner_independently() { + let corners = CutCorners { + top_left: 8, + top_right: 12, + bottom_right: 16, + bottom_left: 20, + }; + + // Each pair straddles one diagonal so all four corner equations stay covered + for (outside, inside) in [ + ((2.0, 2.0), (4.0, 4.0)), + ((96.0, 2.0), (94.0, 6.0)), + ((94.0, 46.0), (90.0, 40.0)), + ((4.0, 46.0), (12.0, 38.0)), + ] { + assert!(!contains_point(100.0, 48.0, corners, outside.0, outside.1)); + assert!(contains_point(100.0, 48.0, corners, inside.0, inside.1)); + } + + assert!(contains_point(100.0, 48.0, CutCorners::default(), 0.0, 0.0)); + assert!(contains_point( + 100.0, + 48.0, + CutCorners::default(), + 99.999, + 47.999 + )); +} + +#[test] +fn rendered_path_and_pointer_shape_match_across_the_plate() { + let width = 37.0; + let height = 29.0; + let corners = CutCorners { + top_left: 5, + top_right: 9, + bottom_right: 12, + bottom_left: 7, + }; + let path = build_path(width, height, corners); + + // A dense grid catches drift between the visible polygon and pointer hit testing + for y in 0_u16..29 { + for x in 0_u16..37 { + // Unequal fractions avoid sampling directly on a diagonal boundary + let x = f32::from(x) + 0.33; + let y = f32::from(y) + 0.21; + assert_eq!( + path.in_fill(&graphene::Point::new(x, y), gsk::FillRule::Winding), + contains_point( + f64::from(width), + f64::from(height), + corners, + f64::from(x), + f64::from(y) + ), + "path and hit test differ at ({x}, {y})" + ); + } + } +} diff --git a/crates/unixnotis-ui/src/cut_corner/tests/mod.rs b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs new file mode 100644 index 000000000..6b5fb298d --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/tests/mod.rs @@ -0,0 +1,4 @@ +//! Cut-corner geometry regression coverage + +mod geometry; +mod widget; diff --git a/crates/unixnotis-ui/src/cut_corner/tests/widget.rs b/crates/unixnotis-ui/src/cut_corner/tests/widget.rs new file mode 100644 index 000000000..b96429bf5 --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/tests/widget.rs @@ -0,0 +1,55 @@ +use gtk::prelude::*; +use unixnotis_core::CutCorners; + +use crate::CutCorner; + +#[gtk::test] +fn cut_corner_wraps_one_child_and_retains_configured_geometry() { + let child = gtk::Label::new(Some("plate")); + let corners = CutCorners { + top_left: 12, + top_right: 8, + bottom_right: 4, + bottom_left: 2, + }; + + let wrapper = CutCorner::new(&child, corners); + + assert_eq!(wrapper.child().as_ref(), Some(child.upcast_ref())); + assert_eq!(wrapper.corners(), corners); + assert!(wrapper.has_css_class("unixnotis-cut-corner")); +} + +#[gtk::test] +fn cut_corner_class_sets_layout_hit_testing_and_cleanup_contracts() { + let child = gtk::Label::new(Some("plate")); + let wrapper = CutCorner::new( + &child, + CutCorners { + top_left: 20, + ..CutCorners::default() + }, + ); + let window = gtk::Window::new(); + window.set_default_size(100, 60); + window.set_child(Some(&wrapper)); + window.present(); + let context = gtk::glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + + assert_eq!(wrapper.css_name(), "unixnotis-cut-corner"); + assert!(wrapper.layout_manager().is_some()); + assert!(!wrapper.contains(1.0, 1.0)); + assert!(wrapper.contains( + f64::from(wrapper.width()) / 2.0, + f64::from(wrapper.height()) / 2.0 + )); + + window.set_child(gtk::Widget::NONE); + window.close(); + drop(window); + drop(wrapper); + assert!(child.parent().is_none()); +} diff --git a/crates/unixnotis-ui/src/cut_corner/widget.rs b/crates/unixnotis-ui/src/cut_corner/widget.rs new file mode 100644 index 000000000..c4b09e06f --- /dev/null +++ b/crates/unixnotis-ui/src/cut_corner/widget.rs @@ -0,0 +1,146 @@ +//! GTK widget that clips one child to an angled polygon + +use std::cell::{Cell, RefCell}; + +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use unixnotis_core::{css::hooks, CutCorners}; + +use super::geometry::{build_path, contains_point}; + +mod imp { + use super::{build_path, contains_point, glib, render_dimension, Cell, CutCorners, RefCell}; + use gtk::prelude::*; + use gtk::subclass::prelude::*; + + #[derive(Default)] + pub struct CutCorner { + pub(super) child: RefCell>, + pub(super) corners: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CutCorner { + const NAME: &'static str = "UnixNotisCutCorner"; + type Type = super::CutCorner; + type ParentType = gtk::Widget; + + fn class_init(class: &mut Self::Class) { + // BinLayout delegates measurement and allocation to the single child + class.set_layout_manager_type::(); + class.set_css_name("unixnotis-cut-corner"); + } + } + + impl ObjectImpl for CutCorner { + fn dispose(&self) { + if let Some(child) = self.child.borrow_mut().take() { + // Custom child parenting must be undone before the wrapper is finalized + child.unparent(); + } + } + } + + impl WidgetImpl for CutCorner { + fn contains(&self, x: f64, y: f64) -> bool { + let widget = self.obj(); + contains_point( + f64::from(widget.width()), + f64::from(widget.height()), + self.corners.get(), + x, + y, + ) + } + + fn snapshot(&self, snapshot: >k::Snapshot) { + let Some(child) = self.child.borrow().as_ref().cloned() else { + return; + }; + if !child.is_visible() { + return; + } + + let corners = self.corners.get(); + if !corners.is_active() { + // The default path avoids creating a render node when no cut is requested + self.obj().snapshot_child(&child, snapshot); + return; + } + + let path = build_path( + render_dimension(self.obj().width()), + render_dimension(self.obj().height()), + corners, + ); + // GTK records the child until pop and discards pixels outside this polygon + snapshot.push_fill(&path, gtk::gsk::FillRule::Winding); + self.obj().snapshot_child(&child, snapshot); + snapshot.pop(); + } + } +} + +#[expect( + clippy::cast_precision_loss, + reason = "GTK logical dimensions are bounded far below f32's exact integer range" +)] +const fn render_dimension(value: i32) -> f32 { + value as f32 +} + +glib::wrapper! { + /// Single-child container that clips rendering and pointer hits to diagonal corners + pub struct CutCorner(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl CutCorner { + /// Build an angled wrapper around one existing widget + #[must_use] + pub fn new(child: &impl IsA, corners: CutCorners) -> Self { + let wrapper: Self = glib::Object::new(); + wrapper.add_css_class(hooks::cut_corner::ROOT); + wrapper.set_corners(corners); + wrapper.set_child(Some(child)); + wrapper + } + + /// Replace the wrapped widget without rebuilding the clipping primitive + pub fn set_child(&self, child: Option<&impl IsA>) { + let imp = self.imp(); + let next = child.map(|child| child.clone().upcast::()); + if imp.child.borrow().as_ref() == next.as_ref() { + return; + } + if let Some(current) = imp.child.borrow_mut().take() { + current.unparent(); + } + if let Some(next) = next { + next.set_parent(self); + imp.child.replace(Some(next)); + } + self.queue_resize(); + } + + /// Return the current wrapped widget + #[must_use] + pub fn child(&self) -> Option { + self.imp().child.borrow().clone() + } + + /// Apply new corner geometry and invalidate the rendered plate + pub fn set_corners(&self, corners: CutCorners) { + if self.imp().corners.replace(corners) != corners { + self.queue_draw(); + } + } + + /// Return the active corner geometry + #[must_use] + pub fn corners(&self) -> CutCorners { + self.imp().corners.get() + } +} diff --git a/crates/unixnotis-ui/src/icons/desktop_index.rs b/crates/unixnotis-ui/src/icons/desktop_index.rs index cbd5cc26c..66da713aa 100644 --- a/crates/unixnotis-ui/src/icons/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/desktop_index.rs @@ -11,6 +11,7 @@ pub struct DesktopIconIndex { names: HashMap>, wm_classes: HashMap>, ids: HashMap>, + executables: HashMap>, } impl DesktopIconIndex { @@ -26,6 +27,7 @@ impl DesktopIconIndex { self.names.clear(); self.wm_classes.clear(); self.ids.clear(); + self.executables.clear(); for app_info in gio::AppInfo::all() { let Ok(desktop) = app_info.downcast::() else { continue; @@ -48,6 +50,12 @@ impl DesktopIconIndex { if let Some(id) = desktop.id() { self.add_id(id.as_str(), &icon_name); } + // D-Bus-activated desktop entries may validly omit Exec + if desktop.commandline().is_some() { + if let Some(executable) = executable_basename(&desktop.executable()) { + self.add_executable(&executable, &icon_name); + } + } } } @@ -67,6 +75,9 @@ impl DesktopIconIndex { if let Some(values) = self.names.get(&normalized) { out.extend(values.iter().cloned()); } + if let Some(values) = self.executables.get(&normalized) { + out.extend(values.iter().cloned()); + } if out.is_empty() { return None; } @@ -92,6 +103,20 @@ impl DesktopIconIndex { add_icon_to_map(&mut self.ids, stripped, icon); } } + + fn add_executable(&mut self, key: &str, icon: &str) { + // Executable basenames come from authenticated daemon metadata + add_icon_to_map(&mut self.executables, key, icon); + } +} + +fn executable_basename(program: &std::path::Path) -> Option { + // GIO follows desktop Exec syntax and exposes only the executable component + program + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_string) } fn add_icon_to_map(map: &mut HashMap>, key: &str, icon: &str) { diff --git a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs index bcd6c0227..f9882d03e 100644 --- a/crates/unixnotis-ui/src/icons/tests/desktop_index.rs +++ b/crates/unixnotis-ui/src/icons/tests/desktop_index.rs @@ -1,5 +1,31 @@ use super::*; +#[test] +fn executable_basename_handles_paths_and_program_names() { + assert_eq!( + executable_basename(std::path::Path::new("/opt/Demo App/bin/demo-app")), + Some("demo-app".to_string()) + ); + assert_eq!( + executable_basename(std::path::Path::new("firefox")), + Some("firefox".to_string()) + ); + assert_eq!(executable_basename(std::path::Path::new("")), None); +} + +#[test] +fn desktop_index_resolves_associated_executable_to_application_icon() { + let mut index = DesktopIconIndex::default(); + + index.add_executable("demo-app", "org.example.Demo"); + index.add_executable("DEMO-APP", "org.example.Demo"); + + assert_eq!( + index.icons_for("demo-app"), + Some(vec!["org.example.Demo".to_string()]) + ); +} + #[test] fn desktop_index_normalizes_ids_and_removes_duplicate_icons() { let mut index = DesktopIconIndex::default(); diff --git a/crates/unixnotis-ui/src/lib.rs b/crates/unixnotis-ui/src/lib.rs index 5a5d72587..4593a6e2c 100644 --- a/crates/unixnotis-ui/src/lib.rs +++ b/crates/unixnotis-ui/src/lib.rs @@ -9,4 +9,8 @@ //! ``` pub mod css; +mod cut_corner; pub mod icons; +pub mod presentation; + +pub use cut_corner::CutCorner; diff --git a/crates/unixnotis-ui/src/presentation/badges.rs b/crates/unixnotis-ui/src/presentation/badges.rs new file mode 100644 index 000000000..f7b74048e --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/badges.rs @@ -0,0 +1,66 @@ +//! Controlled security badges shared by every GTK notification client + +use std::sync::OnceLock; + +use gtk::prelude::*; + +use super::BadgePresentation; + +const RESOURCE_ROOT: &str = "/com/unixnotis/Ui/icons"; + +/// Registers bundled badge resources once for the current process +/// +/// # Errors +/// +/// Returns the original registration error when GTK cannot load the compiled resource +pub fn register_semantic_badges() -> Result<(), String> { + static REGISTRATION: OnceLock> = OnceLock::new(); + // One cached result keeps repeated GTK startup and test initialization deterministic + REGISTRATION + .get_or_init(|| { + gtk::gio::resources_register_include!("unixnotis-ui.gresource") + .map_err(|error| format!("register bundled UI resources: {error}")) + }) + .clone() +} + +/// Builds a daemon-controlled badge when authenticated application art is not allowed +#[must_use] +pub fn build_semantic_badge(badge: BadgePresentation, size: i32) -> Option { + let image = gtk::Image::new(); + apply_semantic_badge(&image, badge, size).then_some(image) +} + +/// Applies one daemon-controlled symbolic icon to an existing reusable image widget +#[must_use] +pub fn apply_semantic_badge(image: >k::Image, badge: BadgePresentation, size: i32) -> bool { + if register_semantic_badges().is_err() { + return false; + } + let Some(display) = gtk::gdk::Display::default() else { + return false; + }; + let icon_theme = gtk::IconTheme::for_display(&display); + // Named symbolic icons use GTK's recoloring path instead of raw resource paintables + icon_theme.add_resource_path(RESOURCE_ROOT); + let icon_name = match badge { + // Verified applications retain the authenticated desktop badge + BadgePresentation::AuthenticatedApplication | BadgePresentation::RecognizedApplication => { + return false + } + BadgePresentation::UnknownApplication => "unixnotis-app-unknown-symbolic", + BadgePresentation::SuspiciousApplication => "unixnotis-shield-warning-symbolic", + BadgePresentation::CommandLine => "unixnotis-terminal-symbolic", + BadgePresentation::System => "unixnotis-system-symbolic", + }; + let size = size.max(1); + image.set_paintable(None::<>k::gdk::Paintable>); + image.set_icon_name(Some(icon_name)); + image.set_pixel_size(size); + image.set_size_request(size, size); + true +} + +#[cfg(test)] +#[path = "tests/badges.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/build.rs b/crates/unixnotis-ui/src/presentation/build.rs new file mode 100644 index 000000000..5196ed369 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/build.rs @@ -0,0 +1,370 @@ +//! Derivation of one shared notification presentation snapshot + +use std::time::{SystemTime, UNIX_EPOCH}; + +use unixnotis_core::{ + Action, ApplicationActionPolicy, AttributionStatus, IdentityAssurance, InlineReplyPolicy, + NotificationView, PopupAdmissionView, Urgency, +}; + +use super::text::{ + clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, + BODY_LABEL_MAX_CHARS, SUMMARY_LABEL_MAX_CHARS, +}; +use super::types::{ + ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, + NotificationKind, ReplyPresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, + TrustPresentation, VisualPresentation, +}; + +/// Complete non-GTK notification presentation shared by popup and panel adapters +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NotificationPresentation { + pub kind: NotificationKind, + pub trust: TrustPresentation, + pub identity: IdentityPresentation, + pub title: String, + pub body: Option, + pub timestamp: String, + pub popup_status: Option, + pub media: MediaPresentation, + /// Sender and content visual roles shared by every GTK adapter + pub visuals: VisualPresentation, + pub actions: ActionPresentation, + pub critical: bool, +} + +impl NotificationPresentation { + #[must_use] + pub fn from_view(notification: &NotificationView) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) + }); + Self::from_view_at(notification, now) + } + + #[must_use] + pub fn from_view_at(notification: &NotificationView, now: i64) -> Self { + let trust = trust_presentation(notification); + let kind = notification_kind(notification); + let identity = identity_presentation(notification, trust.level); + + Self { + kind, + trust, + identity, + title: clamp_label_text(¬ification.summary, SUMMARY_LABEL_MAX_CHARS).into_owned(), + body: has_visible_text(¬ification.body) + .then(|| clamp_label_text(¬ification.body, BODY_LABEL_MAX_CHARS).into_owned()), + timestamp: relative_time_label(notification.received_at_unix_seconds, now), + popup_status: popup_status(notification), + media: MediaPresentation { + thumbnail: thumbnail_kind(notification), + }, + visuals: visual_presentation(notification), + actions: visible_actions(notification, kind), + critical: notification.urgency == Urgency::Critical as u8, + } + } +} + +fn popup_status(notification: &NotificationView) -> Option { + let decision = ¬ification.popup_decision; + if decision.decided_at_unix_ms <= 0 { + return None; + } + match decision.admission_at_commit { + PopupAdmissionView::Rule => { + return Some("Not shown — matched a notification rule".to_string()); + } + PopupAdmissionView::Dnd => { + return Some("Not shown — Do Not Disturb was enabled".to_string()); + } + PopupAdmissionView::Inhibitor => { + return Some("Not shown — notifications were inhibited".to_string()); + } + PopupAdmissionView::RendererDisabled => { + return Some("Not shown — popups are disabled".to_string()); + } + PopupAdmissionView::RendererUnavailable => { + if decision.delivery_stage != unixnotis_core::PopupDeliveryStage::Visible { + return Some("Not shown — popup renderer was unavailable".to_string()); + } + } + PopupAdmissionView::Show => {} + } + + matches!( + decision.delivery_stage, + unixnotis_core::PopupDeliveryStage::FanoutFailed + ) + .then(|| "Not shown — live notification delivery failed".to_string()) +} + +pub(super) fn trust_presentation(notification: &NotificationView) -> TrustPresentation { + let level = trust_level(notification); + let short_label = match level { + // Verified and relay primary labels already communicate their source clearly + TrustLevel::Verified | TrustLevel::Relay => None, + TrustLevel::SystemAssociated => Some("System associated".to_string()), + TrustLevel::PortalAssociated => Some("Portal mediated".to_string()), + TrustLevel::UserAssociated => Some("Local app".to_string()), + TrustLevel::Unresolved => Some("Unverified".to_string()), + TrustLevel::Conflict => Some("Suspicious".to_string()), + }; + let details_label = nonempty_text(¬ification.attribution.diagnostic_detail); + let has_reply_action = notification + .actions + .iter() + .any(|action| action.key == "inline-reply"); + let has_reply_request = notification.inline_reply.available || has_reply_action; + let reply = if has_reply_action + && notification.inline_reply.available + && notification.inline_reply_policy == InlineReplyPolicy::Allow + && level == TrustLevel::Verified + { + ReplyPresentation::Available + } else if has_reply_request { + ReplyPresentation::Unavailable + } else { + ReplyPresentation::Hidden + }; + + TrustPresentation { + level, + short_label, + details_label, + reply, + } +} + +const fn trust_level(notification: &NotificationView) -> TrustLevel { + match notification.attribution.assurance { + IdentityAssurance::Authenticated => TrustLevel::Verified, + IdentityAssurance::SystemAssociated => TrustLevel::SystemAssociated, + IdentityAssurance::PortalAssociated => TrustLevel::PortalAssociated, + IdentityAssurance::UserAssociated => TrustLevel::UserAssociated, + IdentityAssurance::Unresolved => TrustLevel::Unresolved, + IdentityAssurance::Conflict => TrustLevel::Conflict, + IdentityAssurance::Relay => TrustLevel::Relay, + } +} + +fn identity_presentation( + notification: &NotificationView, + level: TrustLevel, +) -> IdentityPresentation { + let display_name = + clamp_label_text(¬ification.attribution.display_name, APP_LABEL_MAX_CHARS); + let claimed_name = + clamp_label_text(¬ification.attribution.claimed_name, APP_LABEL_MAX_CHARS); + let (primary_label, secondary_claim) = match notification.attribution.status { + AttributionStatus::Verified | AttributionStatus::Recognized => ( + display_name.into_owned(), + differing_claim(¬ification.attribution.display_name, &claimed_name), + ), + AttributionStatus::Relay => ( + "Command-line notification".to_string(), + visible_claim(&claimed_name).map(|claim| format!("App label: {claim}")), + ), + AttributionStatus::Conflict => ( + "Unknown application".to_string(), + visible_claim(&claimed_name).map(|claim| format!("Claimed app: {claim}")), + ), + AttributionStatus::Unresolved => { + // A claim can help people recognize a message, but never supplies trusted branding + let claim = visible_claim(&claimed_name); + ( + claim.unwrap_or("Unknown application").to_string(), + claim.map(|_| "App identity could not be verified".to_string()), + ) + } + }; + let badge = match level { + TrustLevel::Verified => BadgePresentation::AuthenticatedApplication, + TrustLevel::SystemAssociated + | TrustLevel::PortalAssociated + | TrustLevel::UserAssociated => BadgePresentation::RecognizedApplication, + TrustLevel::Unresolved => BadgePresentation::UnknownApplication, + TrustLevel::Conflict => BadgePresentation::SuspiciousApplication, + TrustLevel::Relay => BadgePresentation::CommandLine, + }; + IdentityPresentation { + primary_label, + secondary_claim, + badge, + } +} + +fn differing_claim(display_name: &str, claimed_name: &str) -> Option { + let claim = visible_claim(claimed_name)?; + (!claim.eq_ignore_ascii_case(display_name.trim())).then(|| format!("App label: {claim}")) +} + +fn visible_claim(claim: &str) -> Option<&str> { + let claim = claim.trim(); + (!claim.is_empty() && claim != "Unknown application").then_some(claim) +} + +pub(super) fn notification_kind(notification: &NotificationView) -> NotificationKind { + let category_class = notification + .category + .split('.') + .next() + .unwrap_or_default() + .trim(); + if communication_category_class(category_class) + || notification.inline_reply.available + || notification + .actions + .iter() + .any(|action| action.key == "inline-reply") + { + NotificationKind::Communication + } else if media_category_class(category_class) { + NotificationKind::Media + } else { + NotificationKind::Utility + } +} + +fn media_category_class(category_class: &str) -> bool { + ["image", "media", "photo", "video", "audio"] + .iter() + .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) +} + +fn communication_category_class(category_class: &str) -> bool { + [ + "call", + "email", + "im", + "presence", + "chat", + "message", + "social", + "voicemail", + ] + .iter() + .any(|candidate| category_class.eq_ignore_ascii_case(candidate)) +} + +fn visible_actions(notification: &NotificationView, kind: NotificationKind) -> ActionPresentation { + let default_policy = notification.attribution.default_activation_policy(); + let button_policy = notification.attribution.action_button_policy(); + // Only unconditional default activation becomes a whole-card action + let advertised_default = notification + .actions + .iter() + .find(|action| action.key == "default"); + let default_key = (default_policy == ApplicationActionPolicy::Allow) + .then(|| advertised_default.map(|action| action.key.clone())) + .flatten(); + let mut actions = notification + .actions + .iter() + .filter(|action| { + action.key != "inline-reply" + && !action.key.trim().is_empty() + && !action.label.trim().is_empty() + }) + .filter_map(|action| { + let policy = if action.key == "default" { + default_policy + } else { + button_policy + }; + // Allowed defaults keep a labeled button while blank labels use card activation + (policy != ApplicationActionPolicy::Deny + && !(action.key == "default" + && policy == ApplicationActionPolicy::Allow + && action.label.trim().is_empty())) + .then(|| action_view(action, policy)) + }) + .collect::>(); + if default_policy == ApplicationActionPolicy::Confirm + && advertised_default.is_some_and(|action| action.label.trim().is_empty()) + { + // Confirmable blank defaults need an explicit control instead of hidden card activation + actions.push(ActionView { + key: "default".to_string(), + label: "Open notification".to_string(), + policy: ApplicationActionPolicy::Confirm, + }); + } + let overflow = actions.split_off(actions.len().min(kind.action_limit())); + ActionPresentation { + default_key, + primary: actions, + overflow, + } +} + +fn action_view(action: &Action, policy: ApplicationActionPolicy) -> ActionView { + ActionView { + key: action.key.clone(), + label: clamp_label_text(&action.label, ACTION_LABEL_MAX_CHARS).into_owned(), + policy, + } +} + +fn thumbnail_kind(notification: &NotificationView) -> ThumbnailKind { + let has_content = !notification.image.content_image.data.is_empty(); + let category_is_media = ["image", "media", "photo"].iter().any(|category| { + notification + .category + .split('.') + .next() + .unwrap_or_default() + .eq_ignore_ascii_case(category) + }); + if category_is_media || has_content { + return ThumbnailKind::Content; + } + ThumbnailKind::None +} + +const fn visual_presentation(notification: &NotificationView) -> VisualPresentation { + // The daemon has already materialized safe pixels; clients only select a slot + let sender = if notification.image.sender_visual.data.is_empty() { + SenderVisualPresentation::None + } else { + match notification.image.sender_visual_role { + unixnotis_core::NotificationVisualRole::ConversationAvatar => { + // Bounded sender pixels are conversation presentation, not application identity + SenderVisualPresentation::ConversationAvatar + } + unixnotis_core::NotificationVisualRole::ApplicationProvidedIcon => { + SenderVisualPresentation::ApplicationProvidedIcon + } + unixnotis_core::NotificationVisualRole::None + | unixnotis_core::NotificationVisualRole::ContentImage => { + SenderVisualPresentation::None + } + } + }; + VisualPresentation { + sender, + content_image: !notification.image.content_image.data.is_empty(), + } +} + +fn relative_time_label(received_at: i64, now: i64) -> String { + if received_at <= 0 { + return "now".to_string(); + } + let age = now.saturating_sub(received_at).max(0); + match age { + 0..=59 => "now".to_string(), + 60..=3_599 => format!("{}m", age / 60), + 3_600..=86_399 => format!("{}h", age / 3_600), + _ => format!("{}d", age / 86_400), + } +} + +fn nonempty_text(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} diff --git a/crates/unixnotis-ui/src/presentation/default_activation.rs b/crates/unixnotis-ui/src/presentation/default_activation.rs new file mode 100644 index 000000000..7fba60a96 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/default_activation.rs @@ -0,0 +1,153 @@ +//! Shared whole-card default activation for popup and panel rows + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +pub const INTERACTIVE_CLASS: &str = "unixnotis-popup-interactive"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultActionTarget { + pub notification: NotificationKey, + pub action_key: String, +} + +#[derive(Clone)] +pub struct DefaultActionBinding { + target: Rc>>, + root: gtk::glib::WeakRef, +} + +impl DefaultActionBinding { + pub fn set_target(&self, target: Option) { + let enabled = target.is_some(); + *self.target.borrow_mut() = target; + + let Some(root) = self.root.upgrade() else { + return; + }; + + // Recycled rows are only keyboard controls while an active generation is bound + root.set_focusable(enabled); + root.set_accessible_role(if enabled { + gtk::AccessibleRole::Button + } else { + gtk::AccessibleRole::Generic + }); + root.update_property(&[gtk::accessible::Property::Label(if enabled { + "Open notification" + } else { + "" + })]); + if enabled { + root.add_css_class("unixnotis-default-action"); + } else { + root.remove_css_class("unixnotis-default-action"); + } + } +} + +pub fn mark_interactive>(widget: &W) { + // Composite controls use the marker even when their leaf widget changes + widget.add_css_class(INTERACTIVE_CLASS); +} + +pub fn connect_default_activation(widget: &W, dispatch: F) -> DefaultActionBinding +where + W: IsA, + F: Fn(NotificationKey, String) + 'static, +{ + let root = widget.clone().upcast::(); + + let target: Rc>> = Rc::new(RefCell::new(None)); + let dispatch = Rc::new(dispatch); + + let gesture = gtk::GestureClick::new(); + gesture.set_button(1); + let click_root = root.downgrade(); + let click_target = Rc::clone(&target); + let click_dispatch = Rc::clone(&dispatch); + gesture.connect_released(move |_, _, x, y| { + let Some(root) = click_root.upgrade() else { + return; + }; + let Some(current) = click_target.borrow().clone() else { + return; + }; + if picked_widget_blocks_default_action(&root, root.pick(x, y, gtk::PickFlags::DEFAULT)) { + return; + } + click_dispatch(current.notification, current.action_key); + }); + root.add_controller(gesture); + + let key_controller = gtk::EventControllerKey::new(); + let key_root = root.downgrade(); + let key_target = Rc::clone(&target); + let key_dispatch = Rc::clone(&dispatch); + key_controller.connect_key_pressed(move |_, key, _, _| { + let Some(root) = key_root.upgrade() else { + return gtk::glib::Propagation::Proceed; + }; + let current = key_target.borrow().clone(); + if keyboard_activation_is_ready(root.has_focus(), key, current.is_some()) { + if let Some(current) = current { + key_dispatch(current.notification, current.action_key); + return gtk::glib::Propagation::Stop; + } + } + gtk::glib::Propagation::Proceed + }); + root.add_controller(key_controller); + + let binding = DefaultActionBinding { + target, + root: root.downgrade(), + }; + binding.set_target(None); + binding +} + +#[must_use] +pub fn picked_widget_blocks_default_action( + root: >k::Widget, + mut picked: Option, +) -> bool { + while let Some(current) = picked { + if current == *root { + return false; + } + if current.has_css_class(INTERACTIVE_CLASS) + || current.is_focusable() + || current.is::() + || current.is::() + || current.is::() + { + return true; + } + picked = current.parent(); + } + false +} + +#[must_use] +pub const fn is_default_activation_key(key: gtk::gdk::Key) -> bool { + matches!( + key, + gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space + ) +} + +pub(super) const fn keyboard_activation_is_ready( + root_has_focus: bool, + key: gtk::gdk::Key, + has_target: bool, +) -> bool { + root_has_focus && has_target && is_default_activation_key(key) +} + +#[cfg(test)] +#[path = "tests/default_activation.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/interaction.rs b/crates/unixnotis-ui/src/presentation/interaction.rs new file mode 100644 index 000000000..a83421025 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/interaction.rs @@ -0,0 +1,25 @@ +//! Shared confirmation state for application-owned controls + +use unixnotis_core::ApplicationActionPolicy; + +/// Result of one user activation attempt +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionActivation { + Denied, + ArmConfirmation, + Invoke { confirmed: bool }, +} + +/// Convert policy and local confirmation state into one safe UI action +#[must_use] +pub const fn action_activation( + policy: ApplicationActionPolicy, + confirmation_armed: bool, +) -> ActionActivation { + match (policy, confirmation_armed) { + (ApplicationActionPolicy::Allow, _) => ActionActivation::Invoke { confirmed: false }, + (ApplicationActionPolicy::Confirm, false) => ActionActivation::ArmConfirmation, + (ApplicationActionPolicy::Confirm, true) => ActionActivation::Invoke { confirmed: true }, + (ApplicationActionPolicy::Deny, _) => ActionActivation::Denied, + } +} diff --git a/crates/unixnotis-ui/src/presentation/mod.rs b/crates/unixnotis-ui/src/presentation/mod.rs new file mode 100644 index 000000000..1deb13423 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/mod.rs @@ -0,0 +1,25 @@ +//! Shared notification presentation decisions for popup and panel clients + +mod badges; +mod build; +pub mod default_activation; +mod interaction; +mod text; +mod types; + +pub use badges::{apply_semantic_badge, build_semantic_badge, register_semantic_badges}; +pub use build::NotificationPresentation; +pub use interaction::{action_activation, ActionActivation}; +pub use text::{ + clamp_label_text, has_visible_text, ACTION_LABEL_MAX_CHARS, APP_LABEL_MAX_CHARS, + BODY_LABEL_MAX_CHARS, SUMMARY_LABEL_MAX_CHARS, +}; +pub use types::{ + ActionPresentation, ActionView, BadgePresentation, IdentityPresentation, MediaPresentation, + NotificationKind, ReplyPresentation, SenderVisualPresentation, ThumbnailKind, TrustLevel, + TrustPresentation, VisualPresentation, +}; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/unixnotis-ui/src/presentation/tests/badges.rs b/crates/unixnotis-ui/src/presentation/tests/badges.rs new file mode 100644 index 000000000..80001432f --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/badges.rs @@ -0,0 +1,27 @@ +use super::super::{build_semantic_badge, BadgePresentation}; + +#[gtk::test] +fn uncertain_identity_badges_load_from_controlled_resources() { + for badge in [ + BadgePresentation::UnknownApplication, + BadgePresentation::SuspiciousApplication, + BadgePresentation::CommandLine, + BadgePresentation::System, + ] { + let image = build_semantic_badge(badge, 20).expect("semantic badge should exist"); + + let icon_name = image.icon_name().expect("named badge icon"); + assert!(icon_name.starts_with("unixnotis-")); + let display = gtk::gdk::Display::default().expect("GTK display"); + assert!( + gtk::IconTheme::for_display(&display).has_icon(&icon_name), + "badge should resolve through the named symbolic icon theme" + ); + assert_eq!(image.pixel_size(), 20); + } +} + +#[gtk::test] +fn authenticated_identity_keeps_the_application_badge() { + assert!(build_semantic_badge(BadgePresentation::AuthenticatedApplication, 20).is_none()); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/default_activation.rs b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs new file mode 100644 index 000000000..69b8bd513 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/default_activation.rs @@ -0,0 +1,136 @@ +use super::super::default_activation::{ + connect_default_activation, is_default_activation_key, keyboard_activation_is_ready, + mark_interactive, picked_widget_blocks_default_action, DefaultActionTarget, +}; +use gtk::prelude::*; +use unixnotis_core::NotificationKey; + +#[test] +fn activation_keys_match_pointer_equivalents() { + assert!(is_default_activation_key(gtk::gdk::Key::Return)); + assert!(is_default_activation_key(gtk::gdk::Key::KP_Enter)); + assert!(is_default_activation_key(gtk::gdk::Key::space)); + assert!(!is_default_activation_key(gtk::gdk::Key::Escape)); +} + +#[gtk::test] +fn binding_replaces_and_clears_the_current_generation() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let binding = connect_default_activation(&card, |_, _| {}); + let first = DefaultActionTarget { + notification: NotificationKey { + id: 7, + generation: 1, + }, + action_key: "default".to_string(), + }; + binding.set_target(Some(first.clone())); + assert_eq!(binding.target.borrow().as_ref(), Some(&first)); + let replacement = DefaultActionTarget { + notification: NotificationKey { + id: 8, + generation: 2, + }, + action_key: "open".to_string(), + }; + binding.set_target(Some(replacement.clone())); + assert_eq!(binding.target.borrow().as_ref(), Some(&replacement)); + binding.set_target(None); + assert!(binding.target.borrow().is_none()); + assert!(!card.is_focusable()); + assert!(!card.has_css_class("unixnotis-default-action")); +} + +#[gtk::test] +fn activation_callbacks_do_not_keep_destroyed_cards_alive() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let weak = card.downgrade(); + let binding = connect_default_activation(&card, |_, _| {}); + + binding.set_target(Some(DefaultActionTarget { + notification: NotificationKey { + id: 9, + generation: 1, + }, + action_key: "default".to_string(), + })); + drop(binding); + drop(card); + + while gtk::glib::MainContext::default().pending() { + gtk::glib::MainContext::default().iteration(false); + } + + assert!(weak.upgrade().is_none()); +} + +#[gtk::test] +fn interactive_descendants_block_card_activation() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let marked = gtk::Box::new(gtk::Orientation::Vertical, 0); + marked.set_focusable(false); + mark_interactive(&marked); + card.append(&marked); + + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(marked.upcast()) + )); +} + +#[gtk::test] +fn focusable_or_marked_descendants_block_but_plain_content_does_not() { + let card = gtk::Box::new(gtk::Orientation::Vertical, 0); + let focusable = gtk::Box::new(gtk::Orientation::Vertical, 0); + focusable.set_focusable(true); + let plain = gtk::Label::new(Some("Message")); + let menu = gtk::MenuButton::new(); + menu.set_focusable(false); + let entry = gtk::Entry::new(); + entry.set_focusable(false); + card.append(&focusable); + card.append(&plain); + card.append(&menu); + card.append(&entry); + + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(focusable.upcast()) + )); + assert!(!picked_widget_blocks_default_action( + card.upcast_ref(), + Some(plain.upcast()) + )); + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(menu.upcast()) + )); + assert!(picked_widget_blocks_default_action( + card.upcast_ref(), + Some(entry.upcast()) + )); +} + +#[test] +fn keyboard_activation_requires_focus_key_and_target() { + assert!(keyboard_activation_is_ready( + true, + gtk::gdk::Key::Return, + true + )); + assert!(!keyboard_activation_is_ready( + false, + gtk::gdk::Key::Return, + true + )); + assert!(!keyboard_activation_is_ready( + true, + gtk::gdk::Key::Escape, + true + )); + assert!(!keyboard_activation_is_ready( + true, + gtk::gdk::Key::Return, + false + )); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/interaction.rs b/crates/unixnotis-ui/src/presentation/tests/interaction.rs new file mode 100644 index 000000000..6c72abf7f --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/interaction.rs @@ -0,0 +1,39 @@ +//! Application action confirmation regressions + +use unixnotis_core::ApplicationActionPolicy; + +use super::super::{action_activation, ActionActivation}; + +#[test] +fn allowed_action_invokes_without_confirmation() { + assert_eq!( + action_activation(ApplicationActionPolicy::Allow, false), + ActionActivation::Invoke { confirmed: false }, + "allowed actions should invoke without adding confirmation state" + ); +} + +#[test] +fn confirm_action_requires_two_activation_attempts() { + assert_eq!( + action_activation(ApplicationActionPolicy::Confirm, false), + ActionActivation::ArmConfirmation, + "the first activation should only arm confirmation" + ); + assert_eq!( + action_activation(ApplicationActionPolicy::Confirm, true), + ActionActivation::Invoke { confirmed: true }, + "the second activation should carry explicit confirmation" + ); +} + +#[test] +fn denied_action_never_invokes_even_when_armed() { + for armed in [false, true] { + assert_eq!( + action_activation(ApplicationActionPolicy::Deny, armed), + ActionActivation::Denied, + "denied actions must not inherit stale confirmation state" + ); + } +} diff --git a/crates/unixnotis-ui/src/presentation/tests/mod.rs b/crates/unixnotis-ui/src/presentation/tests/mod.rs new file mode 100644 index 000000000..ef371bb62 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/mod.rs @@ -0,0 +1,7 @@ +//! Shared notification presentation regression tests + +mod interaction; +mod presentation; +mod support; +mod text; +mod visual_contract; diff --git a/crates/unixnotis-ui/src/presentation/tests/presentation.rs b/crates/unixnotis-ui/src/presentation/tests/presentation.rs new file mode 100644 index 000000000..6605e406d --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/presentation.rs @@ -0,0 +1,782 @@ +use unixnotis_core::{ + Action, AttributionReason, AttributionStatus, IdentityAssurance, ImageData, InlineReplyPolicy, + InteractionPolicies, NotificationAttribution, NotificationVisualRole, Urgency, +}; + +use super::super::{ + BadgePresentation, NotificationKind, NotificationPresentation, ReplyPresentation, + SenderVisualPresentation, ThumbnailKind, TrustLevel, +}; +use super::support::notification; + +#[test] +fn shared_model_keeps_verified_communication_content_and_actions_consistent() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + ]; + view.image.content_image = ImageData { + width: 64, + height: 64, + data: vec![0; 64 * 64 * 4], + ..ImageData::default() + }; + + let presentation = NotificationPresentation::from_view_at(&view, 1_120); + + assert_eq!(presentation.kind, NotificationKind::Communication); + assert_eq!(presentation.trust.level, TrustLevel::Verified); + assert_eq!(presentation.trust.reply, ReplyPresentation::Available); + assert_eq!( + presentation.identity.badge, + BadgePresentation::AuthenticatedApplication + ); + assert_eq!(presentation.media.thumbnail, ThumbnailKind::Content); + assert_eq!(presentation.actions.primary.len(), 1); + assert_eq!(presentation.actions.primary[0].key, "default"); + assert!(presentation.actions.overflow.is_empty()); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert_eq!(presentation.timestamp, "2m"); +} + +#[test] +fn shared_visual_roles_are_consistent_for_popup_and_panel_clients() { + let mut view = notification(); + view.image.sender_visual_role = NotificationVisualRole::ConversationAvatar; + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + let avatar = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + avatar.visuals.sender, + SenderVisualPresentation::ConversationAvatar + ); + assert!(!avatar.visuals.content_image); + + view.image.sender_visual_role = NotificationVisualRole::ApplicationProvidedIcon; + let decorative = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + decorative.visuals.sender, + SenderVisualPresentation::ApplicationProvidedIcon + ); + + view.image.sender_visual_role = NotificationVisualRole::ContentImage; + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + let content = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(content.visuals.sender, SenderVisualPresentation::None); + assert!(content.visuals.content_image); +} + +#[test] +fn empty_sender_pixels_cannot_select_a_sender_visual_role() { + for role in [ + NotificationVisualRole::ConversationAvatar, + NotificationVisualRole::ApplicationProvidedIcon, + ] { + let mut view = notification(); + view.image.sender_visual_role = role; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!( + presentation.visuals.sender, + SenderVisualPresentation::None, + "role={role:?}" + ); + } +} + +#[test] +fn native_association_keeps_card_activation_and_confirms_only_extra_buttons() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Chat", + "Example Chat", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + view.inline_reply.available = true; + view.inline_reply_policy = InlineReplyPolicy::Deny; + view.actions = vec![ + Action { + key: "default".to_string(), + label: "Open conversation".to_string(), + }, + Action { + key: "archive".to_string(), + label: "Archive".to_string(), + }, + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::SystemAssociated); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert_eq!(presentation.actions.primary.len(), 2); + assert_eq!(presentation.actions.primary[0].key, "default"); + assert_eq!(presentation.actions.primary[1].key, "archive"); + assert_eq!( + presentation.actions.primary[0].policy, + unixnotis_core::ApplicationActionPolicy::Allow + ); + assert_eq!( + presentation.actions.primary[1].policy, + unixnotis_core::ApplicationActionPolicy::Confirm + ); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); +} + +#[test] +fn portal_association_exposes_confirmable_default_as_one_explicit_control() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Portal App", + "Example Portal App", + "org.example.PortalApp", + "org.example.PortalApp", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal app id without confinement provenance", + "associated:portal-app:org.example.PortalApp".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: String::new(), + }); + + let blank = NotificationPresentation::from_view_at(&view, 1_000); + assert!(blank.actions.default_key.is_none()); + assert_eq!(blank.actions.primary.len(), 1); + assert_eq!(blank.actions.primary[0].key, "default"); + assert_eq!(blank.actions.primary[0].label, "Open notification"); + assert_eq!( + blank.actions.primary[0].policy, + unixnotis_core::ApplicationActionPolicy::Confirm + ); + + view.actions[0].label = "Open portal item".to_string(); + let labeled = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(labeled.actions.primary.len(), 1); + assert_eq!(labeled.actions.primary[0].label, "Open portal item"); + + view.actions.clear(); + let missing = NotificationPresentation::from_view_at(&view, 1_000); + assert!(missing.actions.primary.is_empty()); +} + +#[test] +fn blank_default_action_keeps_card_activation_without_rendering_a_button() { + let mut view = notification(); + view.actions = vec![Action { + key: "default".to_string(), + label: " ".to_string(), + }]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + +#[test] +fn shared_model_downgrades_conflicts_and_denies_application_interaction() { + let mut view = notification(); + view.attribution = NotificationAttribution::conflict( + "Known application", + "org.example.Known", + AttributionReason::ExecutableMismatch, + "sender executable differs", + "conflict:known".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.kind, NotificationKind::Utility); + assert_eq!(presentation.trust.level, TrustLevel::Conflict); + assert_eq!( + presentation.identity.badge, + BadgePresentation::SuspiciousApplication + ); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("Claimed app: Known application") + ); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + +#[test] +fn trusted_relay_claim_never_becomes_the_primary_application_identity() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + view.image.badge_icon = "example-chat".to_string(); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.kind, NotificationKind::Communication); + assert_eq!(presentation.trust.level, TrustLevel::Relay); + assert!(presentation.trust.short_label.is_none()); + assert_eq!( + presentation.identity.primary_label, + "Command-line notification" + ); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("App label: Example Chat") + ); + assert_eq!(presentation.identity.badge, BadgePresentation::CommandLine); + assert_eq!(presentation.media.thumbnail, ThumbnailKind::None); +} + +#[test] +fn unknown_claim_is_primary_but_remains_unverified() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Local helper", + AttributionReason::NoDesktopCandidate, + "Source: /tmp/local-helper", + "unknown:local-helper".to_string(), + ); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); + assert_eq!(presentation.identity.primary_label, "Local helper"); + assert_eq!( + presentation.identity.secondary_claim.as_deref(), + Some("App identity could not be verified") + ); + assert_ne!(presentation.trust.short_label.as_deref(), Some("Local app")); + assert_eq!( + presentation.trust.short_label.as_deref(), + Some("Unverified") + ); +} + +#[test] +fn local_process_ownership_does_not_equal_local_application_identity() { + let mut unresolved = notification(); + unresolved.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application association unavailable", + "unknown:example".to_string(), + ); + unresolved.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + + let unresolved_presentation = NotificationPresentation::from_view_at(&unresolved, 1_000); + assert_eq!(unresolved_presentation.trust.level, TrustLevel::Unresolved); + assert_eq!( + unresolved_presentation.trust.short_label.as_deref(), + Some("Unverified") + ); + + let mut associated = notification(); + associated.attribution = NotificationAttribution::associated( + "Example Application", + "Example Application", + "org.example.Application", + "example-application", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "generic user association fixture", + "associated:user:example".to_string(), + ); + + let associated_presentation = NotificationPresentation::from_view_at(&associated, 1_000); + assert_eq!( + associated_presentation.trust.level, + TrustLevel::UserAssociated + ); + assert_eq!( + associated_presentation.trust.short_label.as_deref(), + Some("Local app") + ); +} + +#[test] +fn associated_identity_discloses_a_different_caller_label_only() { + let mut view = notification(); + view.attribution = NotificationAttribution::associated( + "Example Chat", + "Caller alias", + "org.example.Chat", + "org.example.Chat", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "protected executable association", + "associated:system-app:org.example.Chat".to_string(), + ); + + let differing = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!( + differing.identity.secondary_claim.as_deref(), + Some("App label: Caller alias"), + "a differing protocol label must remain visible as untrusted metadata" + ); + + view.attribution.claimed_name = "example chat".to_string(); + let matching = NotificationPresentation::from_view_at(&view, 1_000); + assert!( + matching.identity.secondary_claim.is_none(), + "case-only canonical label differences should not duplicate identity text" + ); +} + +#[test] +fn unresolved_claim_has_no_application_actions_or_reply() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Chat", + AttributionReason::NoDesktopCandidate, + "sender has no positive application association", + "unresolved:random-script:example-chat".to_string(), + ); + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); + assert!(presentation.actions.default_key.is_none()); + assert!(presentation.actions.primary.is_empty()); + assert!(presentation.actions.overflow.is_empty()); +} + +#[test] +fn owner_bound_unresolved_sender_exposes_only_the_advertised_default_action() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + view.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + view.inline_reply.available = true; + view.actions = vec![ + Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }, + Action { + key: "default".to_string(), + label: "Open".to_string(), + }, + Action { + key: "delete".to_string(), + label: "Delete".to_string(), + }, + ]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.iter().any(|action| { + action.key == "default" + && action.label == "Open" + && action.policy == unixnotis_core::ApplicationActionPolicy::Allow + })); + assert!(!presentation + .actions + .primary + .iter() + .any(|action| action.key == "delete")); + assert_eq!(presentation.trust.reply, ReplyPresentation::Unavailable); +} + +#[test] +fn owner_bound_blank_default_uses_card_activation_without_a_redundant_button() { + let mut view = notification(); + view.attribution = NotificationAttribution::unresolved( + "Example Application", + AttributionReason::MissingSenderEvidence, + "application identity unavailable", + "unknown:example".to_string(), + ); + view.attribution.interactions = InteractionPolicies::OWNER_BOUND_DEFAULT; + view.actions = vec![Action { + key: "default".to_string(), + label: String::new(), + }]; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.actions.default_key.as_deref(), Some("default")); + assert!(presentation.actions.primary.is_empty()); +} + +#[test] +fn communication_layout_is_preserved_for_unverified_sender() { + let mut view = notification(); + view.category = "im.received".to_string(); + view.attribution = NotificationAttribution::unresolved( + "Local chat", + AttributionReason::MissingSenderEvidence, + "sender evidence unavailable", + "unknown:local-chat".to_string(), + ); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!( + presentation.kind, + NotificationKind::Communication, + "attribution uncertainty must not erase message semantics" + ); + assert_eq!(presentation.trust.level, TrustLevel::Unresolved); +} + +#[test] +fn reply_metadata_and_action_each_select_communication_layout() { + let mut metadata = notification(); + metadata.inline_reply.available = true; + assert_eq!( + NotificationPresentation::from_view_at(&metadata, 1_000).kind, + NotificationKind::Communication, + "reply metadata should preserve message hierarchy without a category" + ); + + let mut action = notification(); + action.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + assert_eq!( + NotificationPresentation::from_view_at(&action, 1_000).kind, + NotificationKind::Communication, + "an explicit reply action should preserve message hierarchy" + ); +} + +#[test] +fn media_category_selects_media_layout_without_image_content() { + let mut view = notification(); + view.category = "media.player".to_string(); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000).kind, + NotificationKind::Media, + "media semantics must not depend on an optional thumbnail" + ); +} + +#[test] +fn untrusted_non_media_notification_cannot_render_content_art() { + let mut view = notification(); + view.attribution = NotificationAttribution::relay( + "Example Chat", + "Sent via /usr/bin/notify-send", + "relay:notify-send:example-chat".to_string(), + ); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::None + ); + + view.category = "image.received".to_string(); + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content + ); +} + +#[test] +fn popup_status_uses_the_committed_reason_instead_of_current_state() { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: unixnotis_core::PopupAdmissionView::RendererDisabled, + renderer_process_running_at_commit: true, + renderer_ready_at_commit: true, + renderer_health_revision_at_commit: 0, + max_visible_at_commit: 0, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::Suppressed, + popup_hide_after_ms: 0, + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + Some("Not shown — popups are disabled") + ); +} + +#[test] +fn popup_status_distinguishes_renderer_recovery_and_delivery_failure() { + for (stage, admission, expected) in [ + ( + unixnotis_core::PopupDeliveryStage::Visible, + unixnotis_core::PopupAdmissionView::RendererUnavailable, + None, + ), + ( + unixnotis_core::PopupDeliveryStage::RendererFetched, + unixnotis_core::PopupAdmissionView::RendererUnavailable, + Some("Not shown — popup renderer was unavailable"), + ), + ( + unixnotis_core::PopupDeliveryStage::FanoutFailed, + unixnotis_core::PopupAdmissionView::Show, + Some("Not shown — live notification delivery failed"), + ), + ( + unixnotis_core::PopupDeliveryStage::Visible, + unixnotis_core::PopupAdmissionView::Show, + None, + ), + ] { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: admission, + decided_at_unix_ms: 1_000, + delivery_stage: stage, + ..unixnotis_core::PopupDecisionRecord::default() + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + expected, + "stage={stage:?}, admission={admission:?}" + ); + } +} + +#[test] +fn suppression_reason_survives_a_later_fanout_failure() { + for (admission, expected) in [ + ( + unixnotis_core::PopupAdmissionView::Dnd, + "Not shown — Do Not Disturb was enabled", + ), + ( + unixnotis_core::PopupAdmissionView::Rule, + "Not shown — matched a notification rule", + ), + ( + unixnotis_core::PopupAdmissionView::Inhibitor, + "Not shown — notifications were inhibited", + ), + ] { + let mut view = notification(); + view.popup_decision = unixnotis_core::PopupDecisionRecord { + admission_at_commit: admission, + decided_at_unix_ms: 1_000, + delivery_stage: unixnotis_core::PopupDeliveryStage::FanoutFailed, + ..unixnotis_core::PopupDecisionRecord::default() + }; + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .popup_status + .as_deref(), + Some(expected), + "the arrival decision must outrank later delivery state" + ); + } +} + +#[test] +fn empty_and_generic_claims_never_create_secondary_identity_copy() { + for claim in ["", "Unknown application"] { + let mut view = notification(); + view.attribution = NotificationAttribution::relay( + claim, + "Sent via /usr/bin/notify-send", + format!("relay:notify-send:{claim}"), + ); + + assert!( + NotificationPresentation::from_view_at(&view, 1_000) + .identity + .secondary_claim + .is_none(), + "claim={claim:?}" + ); + } +} + +#[test] +fn media_category_or_pixel_data_can_select_content() { + for (has_image_data, category) in [(false, "image.received"), (true, "")] { + let mut view = notification(); + if has_image_data { + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + channels: 4, + bits_per_sample: 8, + data: vec![1, 2, 3, 4], + ..ImageData::default() + }; + } + view.category = category.to_string(); + + assert_eq!( + NotificationPresentation::from_view_at(&view, 1_000) + .media + .thumbnail, + ThumbnailKind::Content, + "has_image_data={has_image_data}, category={category:?}" + ); + } +} + +#[test] +fn shared_model_keeps_user_association_unverified_and_noninteractive() { + let mut view = notification(); + view.attribution = NotificationAttribution::recognized( + "Local application", + "Local application", + "org.example.Local", + "org.example.Local", + AttributionReason::ExactUserExecutable, + "user-local desktop association", + "user:local".to_string(), + ); + view.actions.push(Action { + key: "default".to_string(), + label: "Open".to_string(), + }); + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + + assert_eq!(presentation.trust.level, TrustLevel::UserAssociated); + assert_eq!( + presentation.identity.badge, + BadgePresentation::RecognizedApplication + ); + assert!(presentation.actions.primary.is_empty()); +} + +#[test] +fn shared_model_requires_every_reply_authorization_condition() { + let cases = [ + (false, false, InlineReplyPolicy::Deny, false), + (true, false, InlineReplyPolicy::Allow, false), + (false, true, InlineReplyPolicy::Allow, false), + (true, true, InlineReplyPolicy::Deny, false), + (true, true, InlineReplyPolicy::Allow, true), + ]; + + for (has_action, metadata_available, policy, expected_available) in cases { + let mut view = notification(); + view.inline_reply.available = metadata_available; + view.inline_reply_policy = policy; + if has_action { + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + } + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + let expected = if expected_available { + ReplyPresentation::Available + } else if has_action || metadata_available { + ReplyPresentation::Unavailable + } else { + ReplyPresentation::Hidden + }; + + assert_eq!( + presentation.trust.reply, expected, + "has_action={has_action}, metadata_available={metadata_available}, policy={policy:?}" + ); + } +} + +#[test] +fn shared_model_requires_verified_identity_and_exact_critical_urgency() { + let mut view = notification(); + view.inline_reply.available = true; + view.actions.push(Action { + key: "inline-reply".to_string(), + label: "Reply".to_string(), + }); + + view.attribution.status = AttributionStatus::Recognized; + view.attribution.assurance = IdentityAssurance::SystemAssociated; + view.attribution.interactions = InteractionPolicies::NATIVE_COMPATIBILITY; + view.inline_reply_policy = InlineReplyPolicy::Deny; + let unverified = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(unverified.trust.reply, ReplyPresentation::Unavailable); + assert!(!unverified.critical); + + view.attribution.status = AttributionStatus::Verified; + view.attribution.assurance = IdentityAssurance::Authenticated; + view.attribution.interactions = InteractionPolicies::AUTHENTICATED; + view.inline_reply_policy = InlineReplyPolicy::Allow; + view.urgency = Urgency::Critical as u8; + let critical = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(critical.trust.reply, ReplyPresentation::Available); + assert!(critical.critical); + + view.urgency = (Urgency::Critical as u8).saturating_add(1); + assert!(!NotificationPresentation::from_view_at(&view, 1_000).critical); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/support.rs b/crates/unixnotis-ui/src/presentation/tests/support.rs new file mode 100644 index 000000000..1bb054308 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/support.rs @@ -0,0 +1,33 @@ +use unixnotis_core::{ + AttributionReason, InlineReply, InlineReplyPolicy, NotificationAttribution, NotificationImage, + NotificationView, +}; + +pub(super) fn notification() -> NotificationView { + NotificationView { + id: 7, + generation: 11, + app_name: "Example".to_string(), + attribution: NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "exact system executable", + "system-app:org.example.App".to_string(), + ), + summary: "New message".to_string(), + body: "Are you coming?".to_string(), + actions: Vec::new(), + inline_reply: InlineReply::default(), + inline_reply_policy: InlineReplyPolicy::Allow, + urgency: 1, + category: String::new(), + is_transient: false, + received_at_unix_seconds: 1_000, + image: NotificationImage::default(), + popup_decision: unixnotis_core::PopupDecisionRecord::default(), + popup_hide_after_ms: 0, + } +} diff --git a/crates/unixnotis-ui/src/presentation/tests/text.rs b/crates/unixnotis-ui/src/presentation/tests/text.rs new file mode 100644 index 000000000..fa1bdaeae --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/text.rs @@ -0,0 +1,18 @@ +use super::super::{clamp_label_text, has_visible_text}; + +#[test] +fn blank_text_has_no_visible_notification_content() { + assert!(!has_visible_text("")); + assert!(!has_visible_text("\n\t ")); +} + +#[test] +fn nonempty_text_remains_visible_with_surrounding_whitespace() { + assert!(has_visible_text(" hello ")); +} + +#[test] +fn shared_clamp_preserves_utf8_and_zero_limit_semantics() { + assert!(clamp_label_text("hello", 0).is_empty()); + assert_eq!(clamp_label_text("éclair", 2).as_ref(), "éc…"); +} diff --git a/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs new file mode 100644 index 000000000..27a0b2df7 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/tests/visual_contract.rs @@ -0,0 +1,232 @@ +use unixnotis_core::{ + AttributionReason, IdentityAssurance, ImageData, InteractionPolicies, NotificationAttribution, + NotificationVisualRole, +}; + +use super::super::{ + NotificationKind, NotificationPresentation, SenderVisualPresentation, TrustLevel, +}; +use super::support::notification; + +#[test] +fn shared_notification_visual_contract_covers_client_surface_matrix() { + let cases = [ + ( + "utility", + NotificationKind::Utility, + NotificationVisualRole::None, + ), + ( + "communication-avatar", + NotificationKind::Communication, + NotificationVisualRole::ConversationAvatar, + ), + ( + "media-content", + NotificationKind::Media, + NotificationVisualRole::ContentImage, + ), + ( + "utility-application-visual", + NotificationKind::Utility, + NotificationVisualRole::ApplicationProvidedIcon, + ), + ]; + + for (name, expected_kind, role) in cases { + let mut view = notification(); + view.category = match expected_kind { + NotificationKind::Utility => String::new(), + NotificationKind::Communication => "message.received".to_string(), + NotificationKind::Media => "media.player".to_string(), + }; + view.image.sender_visual_role = role; + if role == NotificationVisualRole::ConversationAvatar { + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + } + if role == NotificationVisualRole::ApplicationProvidedIcon { + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![9, 8, 7, 255], + ..ImageData::default() + }; + } + if role == NotificationVisualRole::ContentImage { + view.image.content_image = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![4, 5, 6, 255], + ..ImageData::default() + }; + } + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + assert_eq!(presentation.kind, expected_kind, "case={name}"); + assert_eq!( + presentation.visuals.sender, + match role { + NotificationVisualRole::ConversationAvatar => { + SenderVisualPresentation::ConversationAvatar + } + NotificationVisualRole::None | NotificationVisualRole::ContentImage => { + SenderVisualPresentation::None + } + NotificationVisualRole::ApplicationProvidedIcon => { + SenderVisualPresentation::ApplicationProvidedIcon + } + }, + "case={name}" + ); + assert_eq!( + presentation.visuals.content_image, + role == NotificationVisualRole::ContentImage, + "case={name}" + ); + } +} + +#[test] +fn conversation_pixels_keep_avatar_role_across_trust_states() { + for (name, attribution) in conversation_attribution_cases() { + let mut view = notification(); + view.attribution = attribution; + view.image.sender_visual_role = NotificationVisualRole::ConversationAvatar; + view.image.sender_visual = ImageData { + width: 1, + height: 1, + rowstride: 4, + bits_per_sample: 8, + channels: 4, + data: vec![1, 2, 3, 255], + ..ImageData::default() + }; + + let presentation = NotificationPresentation::from_view_at(&view, 1_000); + // Trust is carried by the separate trust presentation, never by the image role + assert_eq!( + presentation.visuals.sender, + SenderVisualPresentation::ConversationAvatar, + "case={name}" + ); + } +} + +#[test] +fn trust_state_only_controls_semantic_badge_precedence() { + let semantic_first = [TrustLevel::Conflict, TrustLevel::Relay]; + let branding_first = [ + TrustLevel::Verified, + TrustLevel::SystemAssociated, + TrustLevel::PortalAssociated, + TrustLevel::UserAssociated, + TrustLevel::Unresolved, + ]; + + for level in semantic_first { + assert!(level.semantic_badge_is_authoritative()); + } + for level in branding_first { + assert!(!level.semantic_badge_is_authoritative()); + } +} + +fn conversation_attribution_cases() -> [(&'static str, NotificationAttribution); 7] { + [ + ( + "authenticated", + NotificationAttribution::verified( + "Example", + "Example", + "org.example.App", + "example-app", + AttributionReason::ExactSystemExecutable, + "authenticated test fixture", + "verified:example".to_string(), + ), + ), + ( + "system-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::SystemAssociated, + InteractionPolicies::NATIVE_COMPATIBILITY, + AttributionReason::ExactSystemExecutable, + "system association", + "associated:system:example".to_string(), + ), + ), + ( + "user-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::UserAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::ExactUserExecutable, + "user association", + "associated:user:example".to_string(), + ), + ), + ( + "portal-associated", + NotificationAttribution::associated( + "Example", + "Example", + "org.example.App", + "example-app", + IdentityAssurance::PortalAssociated, + InteractionPolicies::CONFIRM_ACTIONS, + AttributionReason::PortalAppIdAssociation, + "portal association", + "associated:portal:example".to_string(), + ), + ), + ( + "unresolved", + NotificationAttribution::unresolved( + "Example", + AttributionReason::MissingSenderEvidence, + "no sender evidence", + "unknown:example".to_string(), + ), + ), + ( + "conflict", + NotificationAttribution::conflict( + "Example", + "org.example.App", + AttributionReason::ExecutableMismatch, + "sender executable differs", + "conflict:example".to_string(), + ), + ), + ( + "relay", + NotificationAttribution::relay( + "Example", + "forwarded notification", + "relay:example".to_string(), + ), + ), + ] +} diff --git a/crates/unixnotis-ui/src/presentation/text.rs b/crates/unixnotis-ui/src/presentation/text.rs new file mode 100644 index 000000000..9e88fedd5 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/text.rs @@ -0,0 +1,30 @@ +//! Shared text limits that keep both notification surfaces bounded + +use std::borrow::Cow; + +pub const APP_LABEL_MAX_CHARS: usize = 64; +pub const SUMMARY_LABEL_MAX_CHARS: usize = 120; +pub const BODY_LABEL_MAX_CHARS: usize = 320; +pub const ACTION_LABEL_MAX_CHARS: usize = 20; + +#[must_use] +pub fn has_visible_text(text: &str) -> bool { + text.chars().any(|character| !character.is_whitespace()) +} + +#[must_use] +pub fn clamp_label_text(text: &str, max_chars: usize) -> Cow<'_, str> { + if max_chars == 0 { + return Cow::Borrowed(""); + } + // Character boundaries retain valid UTF-8 for untrusted notification strings + for (characters, (index, _)) in text.char_indices().enumerate() { + if characters == max_chars { + let mut clamped = String::with_capacity(index + 3); + clamped.push_str(&text[..index]); + clamped.push('…'); + return Cow::Owned(clamped); + } + } + Cow::Borrowed(text) +} diff --git a/crates/unixnotis-ui/src/presentation/types.rs b/crates/unixnotis-ui/src/presentation/types.rs new file mode 100644 index 000000000..1d5b19777 --- /dev/null +++ b/crates/unixnotis-ui/src/presentation/types.rs @@ -0,0 +1,151 @@ +//! Plain presentation types shared without GTK widget ownership + +/// Stable content hierarchy selected from protocol and trust evidence +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationKind { + Communication, + Utility, + Media, +} + +impl NotificationKind { + #[must_use] + pub fn for_notification(notification: &unixnotis_core::NotificationView) -> Self { + super::build::notification_kind(notification) + } + + #[must_use] + pub const fn action_limit(self) -> usize { + // Two visible actions preserve room for content; remaining actions use overflow + match self { + Self::Communication | Self::Utility | Self::Media => 2, + } + } + + #[must_use] + pub const fn css_class(self) -> &'static str { + match self { + Self::Communication => "communication", + Self::Utility => "utility", + Self::Media => "media", + } + } +} + +/// Human-scale trust state shown consistently by every notification client +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustLevel { + Verified, + SystemAssociated, + PortalAssociated, + UserAssociated, + Unresolved, + Conflict, + Relay, +} + +impl TrustLevel { + #[must_use] + pub const fn css_class(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::SystemAssociated | Self::PortalAssociated | Self::UserAssociated => "recognized", + Self::Unresolved => "unresolved", + Self::Conflict => "conflict", + Self::Relay => "relay", + } + } + + /// Returns whether the semantic trust badge must remain the leading icon + #[must_use] + pub const fn semantic_badge_is_authoritative(self) -> bool { + matches!(self, Self::Conflict | Self::Relay) + } +} + +/// Controlled badge source selected from daemon-owned identity evidence +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BadgePresentation { + AuthenticatedApplication, + RecognizedApplication, + UnknownApplication, + SuspiciousApplication, + CommandLine, + System, +} + +/// Inline reply state kept separate from application-owned actions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyPresentation { + Hidden, + Available, + Unavailable, +} + +/// Safe visible trust text plus optional diagnostic detail +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustPresentation { + pub level: TrustLevel, + pub short_label: Option, + pub details_label: Option, + pub reply: ReplyPresentation, +} + +impl TrustPresentation { + #[must_use] + pub fn for_notification(notification: &unixnotis_core::NotificationView) -> Self { + super::build::trust_presentation(notification) + } +} + +/// Identity content owned by a group or notification header +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityPresentation { + pub primary_label: String, + pub secondary_claim: Option, + pub badge: BadgePresentation, +} + +/// One daemon-approved application action +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActionView { + pub key: String, + pub label: String, + pub policy: unixnotis_core::ApplicationActionPolicy, +} + +/// Compact actions split without silently dropping safe overflow +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ActionPresentation { + pub default_key: Option, + pub primary: Vec, + pub overflow: Vec, +} + +/// Whether a notification contains genuine bounded content media +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThumbnailKind { + None, + Content, +} + +/// Shared media decision independent from GTK decoding +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MediaPresentation { + pub thumbnail: ThumbnailKind, +} + +/// Sender visual role selected once for every client surface +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SenderVisualPresentation { + None, + ConversationAvatar, + ApplicationProvidedIcon, +} + +/// Safe visual roles shared by popup and panel adapters +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VisualPresentation { + pub sender: SenderVisualPresentation, + pub content_image: bool, +} diff --git a/crates/unixnotis-ui/tests/css_validate.rs b/crates/unixnotis-ui/tests/css_validate.rs deleted file mode 100644 index 336b69a25..000000000 --- a/crates/unixnotis-ui/tests/css_validate.rs +++ /dev/null @@ -1,207 +0,0 @@ -#[cfg(test)] -mod tests { - use std::error::Error; - use std::fmt::Write as _; - use std::io::{Error as IoError, ErrorKind, Write as _}; - use std::process::{Command, Output, Stdio}; - use std::sync::atomic::{AtomicUsize, Ordering}; - - type TestResult = Result<(), Box>; - - static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0); - - #[test] - fn css_validate_accepts_parseable_css() -> TestResult { - let output = run_validator(".panel { color: #ffffff; }")?; - - // Valid CSS should not emit parser diagnostics or fail the helper process - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr).trim().is_empty(), - "unexpected stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - Ok(()) - } - - #[test] - fn path_protocol_accepts_percent_encoded_asset_urls_in_quoted_and_unquoted_forms() -> TestResult - { - let root = temp_root("encoded-imports"); - let assets = root.join("assets"); - std::fs::create_dir_all(&assets)?; - let cases = [ - ("icon%20one.svg", "icon one.svg"), - ("icon%23one.svg", "icon#one.svg"), - ("icon%25one.svg", "icon%one.svg"), - ("icon%29one.svg", "icon)one.svg"), - ("icon%22one.svg", "icon\"one.svg"), - ]; - let mut stylesheet = String::new(); - for (index, (encoded_name, decoded_name)) in cases.into_iter().enumerate() { - std::fs::write( - assets.join(decoded_name), - "", - )?; - writeln!( - stylesheet, - ".encoded-{index}-plain {{ background-image: url(assets/{encoded_name}); }}" - )?; - writeln!( - stylesheet, - ".encoded-{index}-quoted {{ background-image: url(\"assets/{encoded_name}\"); }}" - )?; - } - let stylesheet_path = root.join("base.css"); - std::fs::write(&stylesheet_path, stylesheet)?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet_path) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - assert_eq!(report["diagnostics"], serde_json::json!([]), "{report}"); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn path_protocol_accepts_css_escaped_url_and_import_token_names() -> TestResult { - let root = temp_root("escaped-reference-tokens"); - let assets = root.join("assets"); - std::fs::create_dir_all(&assets)?; - std::fs::write( - assets.join("icon.svg"), - "", - )?; - std::fs::write(root.join("colors.css"), ".imported { color: red; }")?; - let stylesheet = root.join("base.css"); - std::fs::write( - &stylesheet, - concat!( - "@im\\70ort \"colors.css\";\n", - ".short { background-image: u\\72l(\"assets/icon.svg\"); }\n", - ".six { background-image: U\\000052L(assets/icon.svg); }\n", - ), - )?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - assert_eq!(report["diagnostics"], serde_json::json!([]), "{report}"); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn css_validate_rejects_invalid_css_with_diagnostic() -> TestResult { - let output = run_validator(".panel { color: ;")?; - let stderr = String::from_utf8_lossy(&output.stderr); - - // A real parser error must fail so generated CSS tests catch broken output - assert!(!output.status.success()); - assert!(stderr.contains("gtk css parse error"), "{stderr}"); - assert!(stderr.contains("gtk css validation found"), "{stderr}"); - - Ok(()) - } - - #[test] - fn path_protocol_returns_structured_parser_diagnostics() -> TestResult { - let root = temp_root("path-protocol"); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root)?; - let stylesheet = root.join("broken.css"); - std::fs::write(&stylesheet, ".panel { color: ;")?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - let diagnostics = report["diagnostics"] - .as_array() - .ok_or("diagnostics must be an array")?; - assert!(!diagnostics.is_empty()); - assert_eq!(diagnostics[0]["line"], 1); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - #[test] - fn path_protocol_caps_large_diagnostic_sets() -> TestResult { - let root = temp_root("diagnostic-cap"); - std::fs::create_dir_all(&root)?; - let stylesheet = root.join("many-errors.css"); - let mut css = String::new(); - for index in 0..12 { - writeln!(css, ".broken-{index} {{ color: ; }}")?; - } - std::fs::write(&stylesheet, css)?; - - let output = Command::new(env!("CARGO_BIN_EXE_unixnotis-css-validate")) - .arg("--json-path") - .arg(&stylesheet) - .stdin(Stdio::null()) - .output()?; - let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; - - assert!(output.status.success()); - if report["available"] == true { - let diagnostics = report["diagnostics"] - .as_array() - .ok_or("diagnostics must be an array")?; - assert!(diagnostics.len() <= 4); - assert_eq!(report["truncated"], true); - } - std::fs::remove_dir_all(root)?; - Ok(()) - } - - fn run_validator(css: &str) -> Result { - let binary = env!("CARGO_BIN_EXE_unixnotis-css-validate"); - let mut child = Command::new(binary) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn()?; - - // The validator contract is stdin, stderr diagnostics, and exit status - let Some(mut stdin) = child.stdin.take() else { - return Err(IoError::new( - ErrorKind::BrokenPipe, - "css validator stdin unavailable", - )); - }; - stdin.write_all(css.as_bytes())?; - drop(stdin); - - child.wait_with_output() - } - - fn temp_root(name: &str) -> std::path::PathBuf { - let serial = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "unixnotis-css-validator-{name}-{}-{serial}", - std::process::id() - )) - } -} diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b719a142b..51e8dd92a 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -46,7 +46,7 @@ assert_workspace_version() { local actual # cargo pkgid reads Cargo metadata and avoids hand-parsing Cargo.toml - pkgid="$(cargo pkgid -p unixnotis-installer)" + pkgid="$(cargo pkgid --locked -p unixnotis-installer)" actual="${pkgid##*#}" if [[ "$actual" != "$expected" ]]; then @@ -57,7 +57,7 @@ assert_workspace_version() { build_release_binaries() { local binaries=("$@") - local args=(build --release --bin unixnotis-installer) + local args=(build --locked --release --bin unixnotis-installer) for binary in "${binaries[@]}"; do # Managed values are executable targets and do not have to match Cargo package names @@ -154,7 +154,7 @@ write_manifest() { } managed_binaries() { - cargo metadata --no-deps --format-version 1 | + cargo metadata --locked --no-deps --format-version 1 | python3 -c ' import json import sys diff --git a/tests/check-release-hardening.sh b/tests/check-release-hardening.sh new file mode 100755 index 000000000..f15626159 --- /dev/null +++ b/tests/check-release-hardening.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd -- "${script_dir}/.." && pwd -P)" +workflow="${repo_root}/.github/workflows/release.yml" +packager="${repo_root}/scripts/package-release.sh" + +assert_contains() { + local path="${1}" + local expected="${2}" + + # Fixed-string matching keeps workflow syntax out of regular-expression parsing + if ! grep -Fq -- "$expected" "$path"; then + printf 'missing release hardening in %s: %s\n' "$path" "$expected" >&2 + return 1 + fi +} + +assert_excludes() { + local path="${1}" + local rejected="${2}" + + # Mutable installers and live Cargo tools must not enter the release builder + if grep -Fq -- "$rejected" "$path"; then + printf 'mutable release input remains in %s: %s\n' "$path" "$rejected" >&2 + return 1 + fi +} + +assert_count() { + local path="${1}" + local expected_count="${2}" + local needle="${3}" + local actual_count + + actual_count="$(grep -Fc -- "$needle" "$path")" + if [[ "$actual_count" != "$expected_count" ]]; then + printf 'expected %s occurrences in %s, found %s: %s\n' \ + "$expected_count" "$path" "$actual_count" "$needle" >&2 + return 1 + fi +} + +check_bootstrap_index_pins() { + local path="${1}" + + # Bootstrap still uses HTTP only until the CA bundle exists, so each index is hash-pinned + assert_contains "$path" 'verify_snapshot_index() {' + assert_contains "$path" '98b25b5cd185c59d34aa6e4c3e9b5b8f01bbe9d104fe2dcfbcd30dc0a14a59ed' + assert_contains "$path" 'bd8aee7ca2a980563032065681fd39b1e284e511841399f3730eac279a1bd2f7' + assert_contains "$path" 'ea95c17e3b9d86d71e58a90831fdfc562f59a9cf6fa5f3d1e52e537a6fbe8e41' +} + +# The base image and package repository both resolve to immutable inputs +assert_contains "$workflow" 'container: debian:trixie-slim@sha256:' +assert_contains "$workflow" "snapshot.debian.org/archive/debian/\${DEBIAN_SNAPSHOT}" +assert_contains "$workflow" "snapshot.debian.org/archive/debian-security/\${DEBIAN_SNAPSHOT}" + +# Rustup is downloaded from a versioned archive and checked before execution +assert_contains "$workflow" 'RUSTUP_INIT_VERSION: 1.28.2' +assert_contains "$workflow" 'RUSTUP_INIT_SHA256: 20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c' +assert_contains "$workflow" '| sha256sum --check --strict' +assert_excludes "$workflow" 'https://sh.rustup.rs' +assert_excludes "$workflow" 'cargo install' + +# A release input must select the same tag and commit that triggered the workflow +# Literal workflow expressions must stay unexpanded in these fixed-string checks +# shellcheck disable=SC2016 +assert_contains "$workflow" 'if [[ "$GITHUB_REF" != "refs/tags/${RELEASE_TAG}" ]]; then' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'tag_commit="$(git rev-parse "${tag_ref}^{commit}")"' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'if [[ "$tag_commit" != "$GITHUB_SHA" || "$checked_out_commit" != "$GITHUB_SHA" ]]; then' + +# Release builds cannot update the dependency lockfile +assert_contains "$packager" 'local args=(build --locked --release' +assert_contains "$packager" 'cargo pkgid --locked' +assert_contains "$packager" 'cargo metadata --locked --no-deps' + +# Archives and checksum manifests receive both portable signatures and provenance +assert_contains "$workflow" 'sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22' +assert_contains "$workflow" 'cosign-release: v3.1.2' +assert_contains "$workflow" 'cosign sign-blob' +assert_contains "$workflow" 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' +assert_contains "$workflow" 'dist/*.sigstore.json' + +# Build steps cannot mint identities; only the dependent signing job receives OIDC +assert_contains "$workflow" 'needs: package' +assert_contains "$workflow" 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' +# shellcheck disable=SC2016 +assert_contains "$workflow" 'name: unixnotis-${{ inputs.tag }}-unsigned' +assert_count "$workflow" 1 'id-token: write' +assert_count "$workflow" 1 'attestations: write' +assert_count "$workflow" 1 'artifact-metadata: write' + +check_bootstrap_index_pins "${repo_root}/.github/workflows/ci.yml" +check_bootstrap_index_pins "${repo_root}/.github/workflows/mutation.yml" +check_bootstrap_index_pins "$workflow" diff --git a/tests/check-test-placement.sh b/tests/check-test-placement.sh index c4e9e116b..3836f063d 100755 --- a/tests/check-test-placement.sh +++ b/tests/check-test-placement.sh @@ -53,6 +53,61 @@ while IFS= read -r -d '' file; do fi done < <(find crates -type f -path '*/src/*.rs' ! -path '*/tests/*' -print0) +# Nested modules keep their tests beside their own source directory +# Parent-level test paths make ownership unclear and leave stale folders after moves +while IFS=: read -r file line _match; do + violations+="${file}:${line}: test module must use its source directory's /tests tree"$'\n' +done < <( + rg --line-number --no-heading '#\[path[[:space:]]*=[[:space:]]*"(\.\./)+tests/' \ + crates -g '*.rs' || true +) + +# Every test source below /src needs an incoming Rust module declaration +# This catches test files that look complete but Cargo never compiles +declare -A wired_test_files=() +while IFS= read -r -d '' source_file; do + source_directory="$(dirname -- "$source_file")" + source_name="$(basename -- "$source_file")" + module_directory="$source_directory/${source_name%.rs}" + case "$source_name" in + lib.rs | main.rs | mod.rs) + module_directory="$source_directory" + ;; + esac + + while IFS= read -r module_path; do + wired_test_files["$(realpath -m -- "$source_directory/$module_path")"]=1 + done < <( + sed -nE 's/^[[:space:]]*#\[path[[:space:]]*=[[:space:]]*"([^"]+)"\][[:space:]]*$/\1/p' \ + "$source_file" + ) + + while IFS= read -r module_name; do + for candidate in \ + "$module_directory/$module_name.rs" \ + "$module_directory/$module_name/mod.rs"; do + if [[ -f "$candidate" ]]; then + wired_test_files["$(realpath -m -- "$candidate")"]=1 + fi + done + done < <( + sed -nE \ + 's/^[[:space:]]*(pub(\([^)]*\))?[[:space:]]+)?mod[[:space:]]+(r#)?([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*;.*$/\4/p' \ + "$source_file" + ) +done < <(find crates -type f -path '*/src/*.rs' -print0) + +while IFS= read -r -d '' test_file; do + canonical_test_file="$(realpath -m -- "$test_file")" + if [[ -z "${wired_test_files[$canonical_test_file]+present}" ]]; then + violations+="${test_file}: test source is not wired into the Rust module graph"$'\n' + fi +done < <( + find crates -type f \ + \( -path '*/src/tests/*.rs' -o -path '*/src/*/tests/*.rs' \) \ + -print0 +) + # A support module is test code too, even when it contains no #[test] function itself while IFS= read -r path; do violations+="${path}: test support must live under a /tests directory"$'\n' diff --git a/tests/package-release.sh b/tests/package-release.sh index a5be39a7f..c89e71e12 100755 --- a/tests/package-release.sh +++ b/tests/package-release.sh @@ -7,6 +7,11 @@ repo_root="$(cd -- "${script_dir}/.." && pwd -P)" cd -- "$repo_root" source scripts/package-release.sh +if ! managed_binaries | grep -Fxq 'unixnotis-svg-renderer'; then + printf 'installer metadata omitted the SVG renderer\n' >&2 + exit 1 +fi + test_root="$(mktemp -d)" trap 'rm -rf -- "${test_root}"' EXIT cd -- "$test_root" @@ -16,16 +21,17 @@ mkdir -p target/release printf 'installer\n' > target/release/unixnotis-installer printf 'daemon\n' > target/release/unixnotis-daemon printf 'center\n' > target/release/unixnotis-center +printf 'svg-renderer\n' > target/release/unixnotis-svg-renderer chmod 0755 target/release/* export SOURCE_DATE_EPOCH=1700000000 -assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center +assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center unixnotis-svg-renderer archive="dist/unixnotis-v9.8.7-x86_64-unknown-linux-gnu.tar.zst" first_digest="$(sha256sum "$archive" | cut -d ' ' -f 1)" # Input timestamps must not influence the published archive touch target/release/* -assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center +assemble_archive v9.8.7 9.8.7 x86_64-unknown-linux-gnu unixnotis-daemon unixnotis-center unixnotis-svg-renderer second_digest="$(sha256sum "$archive" | cut -d ' ' -f 1)" if [[ "$first_digest" != "$second_digest" ]]; then @@ -55,10 +61,10 @@ cargo_args="${test_root}/cargo-args" cargo() { printf '%s\n' "$@" > "$cargo_args" } -build_release_binaries unixnotis-daemon unixnotis-css-validate +build_release_binaries unixnotis-daemon unixnotis-svg-renderer unixnotis-css-validate unset -f cargo -expected_args=$'build\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-css-validate' +expected_args=$'build\n--locked\n--release\n--bin\nunixnotis-installer\n--bin\nunixnotis-daemon\n--bin\nunixnotis-svg-renderer\n--bin\nunixnotis-css-validate' actual_args="$(cat -- "$cargo_args")" if [[ "$actual_args" != "$expected_args" ]]; then printf 'release build did not select exact binary targets\n' >&2