diff --git a/AGENTS.md b/AGENTS.md index d7ec941..a2dca4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,17 @@ Development guidelines for solstone-linux, a standalone Linux desktop observer. solstone-linux is a companion app that runs alongside the main [solstone](https://solstone.app) journal. It is one of the owner's observers — it experiences screen and audio along with the owner on a Linux desktop using PipeWire and GStreamer, stores segments locally, and syncs them to your solstone journal. It runs as a systemd user service on GNOME Wayland sessions. -This is **not** part of the solstone monorepo. It is a standalone package with its own release lifecycle, installed via pipx alongside system-provided PyGObject/GStreamer bindings. +This is **not** part of the solstone monorepo. It is a standalone Rust package with its own native release lifecycle. The retained Python implementation is non-shipping legacy code. ## Source Layout ``` -src/solstone_linux/ +crates/solstone-linux/src/ Shipping Rust observer, CLI, service, sync, and capture code +packaging/ Native package Containerfile and install notes +scripts/build-release.sh Operator-run native package build +scripts/install.sh Portable archive installer + +src/solstone_linux/ Non-shipping legacy Python implementation __init__.py Package version cli.py CLI entry point (run, setup, settings, install-service, status) solstone-linux.service.in Systemd unit template (rendered by install-service) @@ -39,7 +44,7 @@ src/solstone_linux/ sni.py StatusNotifierItem D-Bus interface for tray icons tray.py In-process D-Bus SNI tray icon, menu, and tooltip -tests/ pytest test suite +tests/ Legacy Python pytest suite contrib/ Reference icons for development fallback ``` @@ -62,48 +67,43 @@ The `observe/status` heartbeat carries top-level diagnostics-only health-beacon ## Commands ```bash -make install # Create venv, install package + dev tools (pytest, ruff) via uv -make test # Run all tests -make test-only TEST=tests/test_config.py # Run specific test -make format # Auto-format with ruff -make ci # Python + Rust lint, format, dependency, and test checks -make install-service # Smart install-or-upgrade: guards against cross-repo contamination; runs CI in upgrade mode +make bootstrap # Install rustup if needed, then establish pinned tools +make install # Establish pinned Rust/tools and install the observer +make format # Format Rust source +make test # Run locked Rust tests +make ci # Host evidence: Rust format, lint, tests, offline policy +make audit # Refresh RustSec data, then check advisories +make update-deps # Sole unlocked Cargo dependency-update path +make install-service # Install the native systemd user service make service-restart # systemctl restart wrapper make service-status # systemctl status wrapper make service-logs # systemctl log tail wrapper -make uninstall-service # Disable + remove unit + pipx uninstall +make uninstall-service # Remove the native systemd user service make clean # Remove build artifacts and caches make versions # Show installed package versions + +make legacy-python-install # Set up retained non-shipping Python code +make legacy-python-test # Run the legacy Python tests +make legacy-python-ci # Run the legacy Python gate ``` ## Rust rebuild -The root Cargo workspace is workspace-only: `crates/solstone-linux/` contains the portable observer logic and Linux video-capture backends, plus a stub CLI. Run `make rust-fmt-check`, `make rust-lint`, `make rust-test`, and `make rust-deny` individually, or use `make ci` as the combined Python and Rust gate. Python remains the shipped pipx observer until an explicit cutover; Rust crates are not installed or released with it. -For the unexercised operator-run Rust packaging rail and its blocking first-release validation, see `RELEASING.md`. +The root Cargo workspace is workspace-only: `crates/solstone-linux/` contains the shipping observer, native CLI, service lifecycle, and Linux video-capture backends. `rust-toolchain.toml` is the compiler authority. Use the canonical Make targets above; Python targets are retained only for non-shipping legacy maintenance. For the operator-run native packaging rail and its blocking release validation, see `RELEASING.md`. ## Releasing -solstone-linux ships to PyPI via `scripts/release.sh`. The operator runs the -release from a clean checkout; there is no CI publish path. +solstone-linux ships as portable, Debian, and RPM artifacts through the +operator-run native release rail. There is no automated publish path. ```bash -make release-test # upload to TestPyPI (requires TESTPYPI_TOKEN) -make release # upload to PyPI (requires PYPI_TOKEN) +make release # build native Debian and RPM release artifacts ``` -The script refuses to run on a dirty tree, builds an sdist + a -`py3-none-any` wheel with `uv build`, runs `uvx twine check`, uploads, -tags the commit `vX.Y.Z`, pushes the tag, and creates a matching GitHub -Release with the artifacts attached and the CHANGELOG block as release -notes. - -Before releasing, bump the version in BOTH `pyproject.toml` (`[project].version`) -and `src/solstone_linux/__init__.py` (`__version__`) — they must match — and add -a `## [X.Y.Z] - YYYY-MM-DD` block to `CHANGELOG.md`. - -Set `RELEASE_DRY_RUN=1` to walk the full flow without uploading, tagging, -pushing, or publishing a GitHub Release; the build and `twine check` still -run for real. +The build refuses a dirty tree and does not upload, tag, or publish. Follow +`RELEASING.md` for artifact inspection, the blocking FLAC soak, and handoff. +`scripts/release.sh` and its `legacy-python-release*` Make targets are retained +only for the non-shipping Python implementation. ## Development Principles diff --git a/Cargo.toml b/Cargo.toml index 55b4b39..a3daa72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,17 @@ resolver = "3" version = "1.0.0" edition = "2024" license = "AGPL-3.0-only" -rust-version = "1.92" +rust-version = "1.97.1" + +[workspace.lints.rust] +unsafe_code = "deny" +future_incompatible = { level = "deny", priority = -1 } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" [workspace.dependencies] clap = { version = "4.5", features = ["derive"] } diff --git a/INSTALL.md b/INSTALL.md index 341dd41..40d3f0f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,172 +1,81 @@ # installing solstone-linux -these instructions are for a coding agent and human working together. solstone-linux is a standalone observer that experiences your screen and audio along with you on linux desktops using PipeWire and GStreamer, and uploads to your solstone journal. +these instructions are for a coding agent and human working together. solstone-linux is a standalone observer that experiences your screen and audio along with you on Linux desktops using PipeWire and GStreamer, and syncs to your solstone journal. solstone must already be installed and running. if it isn't, start there: https://solstone.app/install -> **most users install solstone-linux from PyPI in three commands** on the machine that will host the observer: `pipx install --system-site-packages solstone-linux`, `solstone-linux install-service`, then `solstone-linux setup` (which registers against your journal over the local `http://localhost:5015` link — no URL to type). if the observer machine reaches your solstone host directly instead, run `solstone-linux setup --server-url `. the instructions below are for developers building from source or troubleshooting the install. -> -> the `--system-site-packages` flag is **required**: it lets pipx's virtualenv reuse your distro's system PyGObject, pycairo, and GStreamer bindings (the `python3-gi` / `python3-cairo` packages installed below) instead of rebuilding PyGObject from source. a plain `pipx install solstone-linux` rebuilds PyGObject in an isolated venv, which needs the full GObject-Introspection build toolchain (`libgirepository-2.0-dev`) — and that dev package isn't even available on every supported distro (Debian 12 stable doesn't ship it). `--system-site-packages` is the path that works on every distro and skips the compile entirely. - -## before you begin - -if `sol` is not in PATH, check `~/.local/bin/sol`. - -check if solstone-linux is already installed and running: +## system dependencies +**Fedora:** ``` -systemctl --user status solstone-linux -journal observer list +sudo dnf install pulseaudio-libs gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer pipewire-pulseaudio xdg-desktop-portal xdg-utils ``` -if it's already active and connected, you're done. - -## what to sort out together - -- **system dependencies.** the observer needs PyGObject, GStreamer, and PipeWire bindings from system packages. installing these requires sudo. -- **stream name.** this identifies this observer's stream. the machine's hostname is the typical choice. - -### journal reached directly (not over the local link) - -By default the observer registers over the local `http://localhost:5015` link, so the journal and observer are colocated. If you reach your journal directly over HTTPS instead: - -- clone anywhere; the `$(sol root)/observers` path in step 2 only applies when sol is installed locally. -- run `solstone-linux setup --server-url ` to point at that journal and auto-register the observer over HTTP, persisting the returned key. No manual key handoff is needed if the journal's observer-registration endpoint is reachable. -- otherwise, the install sequence below is the same. - -## install sequence - -this is the developer/from-source path; most installs should use the `pipx install --system-site-packages solstone-linux` + `solstone-linux install-service` + `solstone-linux setup` flow described in the callout above. - -1. install system dependencies for your distro, including `pipx`. if you need sudo, walk your human through it. - - **fedora:** - ``` - sudo dnf install python3-gobject python3-cairo gtk4 gstreamer1-plugins-base pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx gcc python3-devel pkgconf-pkg-config cairo-devel cairo-gobject-devel - ``` - - **debian / ubuntu:** - ``` - sudo apt install python3-gi python3-cairo gir1.2-gtk-4.0 gstreamer1.0-pipewire gstreamer1.0-tools libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx gcc python3-dev pkg-config libcairo2-dev - ``` - - **arch:** - ``` - sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire gst-plugins-good libpulse alsa-lib xdg-desktop-portal python-pipx uv python-cairo - ``` - - **opensuse:** - ``` - sudo zypper install python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-4_0 \ - gtk4-tools gstreamer-plugins-base gstreamer-plugin-pipewire \ - pipewire-pulseaudio pulseaudio-utils alsa-devel \ - xdg-desktop-portal python3-pipx - ``` - note: package names diverge from Fedora — `typelib-1_0-Gtk-4_0` (not `gtk4`), `gstreamer-plugin-pipewire` (singular), and `alsa-devel` (not `alsa-lib-devel`). - - with the recommended `pipx install --system-site-packages solstone-linux`, the system `python3-gi` and `python3-cairo` packages above satisfy PyGObject and pycairo, so **neither builds from source**. the `cairo` headers + `gcc` + Python dev headers in the Fedora/Debian lines are kept as a fallback for any other pure-Python dependency that lacks a prebuilt wheel on your platform — and they're what an *isolated*-venv install (a plain `pipx install` without `--system-site-packages`) needs to compile pycairo from source. (an isolated-venv install also needs `libgirepository-2.0-dev`; see Troubleshooting.) - - `uv` / `pipx`: Fedora packages both (`sudo dnf install uv pipx`); Debian/Ubuntu package `pipx` but not `uv`. the PyPI install flow only needs `pipx` — `uv` is optional and used by the from-source dev workflow in the Makefile. - -2. cloning into `$(sol root)/observers` is only a developer convenience for keeping observer checkouts colocated with a local solstone clone. for a journal you reach directly, clone anywhere — the observer runs independently of your journal at runtime: - ``` - cd "$(sol root)/observers" - git clone https://github.com/solpbc/solstone-linux.git - cd solstone-linux - make install-service - ``` - `make install-service` is a smart install-or-upgrade: detects fresh-install vs upgrade via a marker file, runs CI in upgrade mode, guards against cross-repo contamination. - -3. run setup: - ``` - solstone-linux setup - ``` - this registers the observer against your journal over the local `http://localhost:5015` link — no URL to type. pass `--server-url ` for a journal you reach directly. - -4. verify the service is running: - ``` - systemctl --user status solstone-linux - ``` - -## updating from PyPI - +**Debian / Ubuntu:** ``` -pipx upgrade solstone-linux -systemctl --user restart solstone-linux +sudo apt install libpulse0 libgstreamer1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-pipewire gstreamer1.0-x pipewire-pulse xdg-desktop-portal xdg-utils ``` -the systemd unit template and icons only refresh when you re-run `solstone-linux install-service`, so after upgrading across a release that changed the unit, re-run `solstone-linux install-service`. - -## updating after a code change +**Arch:** +``` +sudo pacman -S libpulse gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire pipewire-pulse xdg-desktop-portal xdg-utils +``` +**openSUSE:** ``` -git pull && make install-service +sudo zypper install libpulse0 gstreamer gstreamer-plugins-base gstreamer-plugins-good gstreamer-plugin-pipewire pipewire-pulseaudio xdg-desktop-portal xdg-utils ``` -## notes +## install a release -- Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger. -- the tray icon uses the StatusNotifierItem (SNI) D-Bus protocol. it works on KDE natively and GNOME with the AppIndicator extension. if no SNI host is available, the observer runs normally without a tray icon. +use the Debian or RPM package published for your distribution when available. For the portable archive: -## appendix: GNOME tray support +```bash +scripts/install.sh solstone-linux--linux-x86_64.tar.gz +solstone-linux install-service +solstone-linux setup +``` -the system tray icon appears automatically when the observer starts in a graphical session. on KDE Plasma this works out of the box. on GNOME, the AppIndicator extension is required. +the archive includes `INSTALL-NOTES`, which is the canonical cross-distribution runtime dependency list. Native packages install the same observer binary and icon set. The service command writes the systemd user unit and desktop autostart entry, enables the unit, and starts sol. -GNOME removed native system tray support. the AppIndicator extension restores it via the same StatusNotifierItem protocol KDE uses. without it, the observer runs fine but has no tray icon. +`setup` registers the observer through the local `http://localhost:5015` journal link by default. For a journal reached directly, use `solstone-linux setup --server-url `. -**ubuntu:** already installed and enabled by default — skip this step. +## build from source -**fedora:** -``` -sudo dnf install gnome-shell-extension-appindicator -``` -then log out and back in, or restart GNOME Shell (Alt+F2, type `r`, enter). enable the extension in GNOME Extensions app if not auto-enabled. +install rustup, a C toolchain, CMake, pkg-config, GLib/GStreamer development headers, and PulseAudio development headers. Then: -**arch:** -``` -sudo pacman -S gnome-shell-extension-appindicator +```bash +git clone https://github.com/solpbc/solstone-linux.git +cd solstone-linux +make bootstrap +make ci +make install-service +solstone-linux setup ``` -**other distros (openSUSE, etc.):** +`rust-toolchain.toml` selects the exact compiler, components, and target. `make install` explicitly establishes them and cargo-deny before installing the observer. -if your distro doesn't ship an AppIndicator extension package, install it from extensions.gnome.org via the CLI: +## update from source +```bash +git pull +make ci +make install-service ``` -curl -LO https://extensions.gnome.org/extension-data/appindicatorsupportrgcjonas.gmail.com.v64.shell-extension.zip -gnome-extensions install appindicatorsupportrgcjonas.gmail.com.v64.shell-extension.zip -gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com -``` - -then restart GNOME Shell — on Wayland, log out and back in; on X11, press Alt+F2 and type `r`. v64 supports GNOME Shell 45–50; check https://extensions.gnome.org/extension/615/appindicator-support/ for a newer build if you're on a later shell. -to check if it's working: `gnome-extensions list | grep appindicator` should show it. if the tray icon still doesn't appear, verify it's enabled: `gnome-extensions enable appindicatorsupport@rgcjonas.gmail.com` +## verify -## Troubleshooting +```bash +systemctl --user status solstone-linux +solstone-linux status +``` -Common install-time errors and their fixes: +## desktop notes -- **`pkg-config: command not found` or `cairo` pkg-config failure** - - fedora: `sudo dnf install pkgconf-pkg-config cairo-devel` - - debian/ubuntu: `sudo apt install pkg-config libcairo2-dev` - - arch: `sudo pacman -S pkgconf cairo` - - opensuse: `sudo zypper install pkgconf-pkg-config cairo-devel` +Activity detection uses screen-lock and power-save signals to notice when you step away. GNOME provides both signals; KDE Wayland provides screen lock; X11 can also provide DPMS power save. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries do not trigger. -- **`girepository-2.0` missing or `pygobject` build failure** — only hit when installing into an *isolated* venv (a plain `pipx install` **without** `--system-site-packages`), which rebuilds PyGObject from PyPI from source. the recommended `pipx install --system-site-packages solstone-linux` uses your system `python3-gi` and skips this build entirely. - - **first, retry with `--system-site-packages`.** `pipx install --system-site-packages solstone-linux` needs no build toolchain. this is the fix on Debian 12 (stable) and other distros that don't package the `-2.0` dev headers at all. - - if you must build from source: PyPI's PyGObject (3.50+, Sept 2024 onward) needs the girepository-**2.0** dev headers, not the old 1.0 package: - - fedora: `sudo dnf install gobject-introspection-devel` - - debian/ubuntu: `sudo apt install libgirepository-2.0-dev` (Debian 13 / Ubuntu 24.04+; older releases that ship PyGObject < 3.50 use `libgirepository1.0-dev`) - - arch: `sudo pacman -S gobject-introspection` - - opensuse: `sudo zypper install gobject-introspection-devel` +The tray uses the StatusNotifierItem D-Bus protocol. KDE supports it directly. GNOME requires an AppIndicator extension; without an SNI host, the observer continues normally without a tray icon. -- **`Python.h: No such file or directory`** - - fedora: `sudo dnf install python3-devel` - - debian/ubuntu: `sudo apt install python3-dev` - - arch: already bundled in `python` package - - opensuse: `sudo zypper install python3-devel` +## retained Python implementation -- **`pipx: command not found`** - - fedora: `sudo dnf install pipx` - - debian/ubuntu: `sudo apt install pipx` - - arch: `sudo pacman -S python-pipx` - - opensuse: `sudo zypper install python3-pipx` +The Python source, tests, PyPI metadata, and `scripts/release.sh` remain for maintenance and historical parity, but they are non-shipping. Their developer commands are explicitly named `make legacy-python-*`; they require uv and the former system PyGObject environment. Canonical install, test, CI, service, and release commands are Rust-native. diff --git a/Makefile b/Makefile index 12b3a92..374f361 100644 --- a/Makefile +++ b/Makefile @@ -1,183 +1,127 @@ # solstone-linux Makefile # Standalone Linux desktop observer for solstone -.PHONY: install test test-only format ci shellcheck rust-fmt-check rust-lint rust-test rust-deny clean clean-install versions all bootstrap install-service service-restart service-status service-logs uninstall-service release release-test +.PHONY: all bootstrap install format test ci audit update-deps shellcheck install-service uninstall-service service-restart service-status service-logs versions clean clean-install release legacy-python-bootstrap legacy-python-install legacy-python-format legacy-python-test legacy-python-test-only legacy-python-ci legacy-python-release legacy-python-release-test check-toolchain-env establish-toolchain rust-preflight check-cargo-deny -# Default target -all: install +APP := solstone-linux +UNIT := solstone-linux.service +CARGO ?= cargo +RUSTUP ?= rustup +RUST_VERSION := $(shell sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml 2>/dev/null) +AMBIENT_RUSTUP_TOOLCHAIN := $(RUSTUP_TOOLCHAIN) +export RUSTUP_TOOLCHAIN := $(RUST_VERSION) +RUST_TARGET := x86_64-unknown-linux-gnu +CARGO_LOCKED := --locked +CARGO_DENY_VERSION := 0.20.2 +CARGO_DEB_VERSION := 3.7.0 +CARGO_GENERATE_RPM_VERSION := 0.21.0 +SHELLCHECK_SCRIPTS := scripts/build-release.sh scripts/install.sh -# Virtual environment directory VENV := .venv VENV_BIN := $(VENV)/bin PYTHON := $(VENV_BIN)/python -CARGO ?= $(shell command -v cargo 2>/dev/null || echo $(HOME)/.cargo/bin/cargo) -CARGO_DENY ?= $(shell command -v cargo-deny 2>/dev/null || { [ -x $(HOME)/.cargo/bin/cargo-deny ] && echo $(HOME)/.cargo/bin/cargo-deny; }) -CARGO_BIN_DIR := $(patsubst %/,%,$(dir $(CARGO))) -SHELLCHECK_SCRIPTS := scripts/build-release.sh scripts/install.sh - -# Require uv +PYTEST := $(VENV_BIN)/pytest +RUFF := $(VENV_BIN)/ruff UV := $(shell command -v uv 2>/dev/null) -ifneq ($(filter bootstrap,$(MAKECMDGOALS)),bootstrap) -ifndef UV -$(error uv is not installed. Run: make bootstrap) -endif -endif - -APP := solstone-linux -UNIT := solstone-linux.service PIPX_FLAGS := --system-site-packages VENV_FLAGS := --system-site-packages -# Marker file to track installation -.installed: pyproject.toml - @echo "Installing package with uv (including dev tools)..." - @[ -f $(VENV)/pyvenv.cfg ] || $(UV) venv $(VENV_FLAGS) --python /usr/bin/python3 $(VENV) - $(UV) sync --group dev --no-install-package pygobject --no-install-package pycairo - @touch .installed - -# Install package in editable mode with isolated venv -install: .installed +all: install -bootstrap: - @if command -v uv >/dev/null 2>&1; then \ - echo "uv already installed"; \ - else \ - echo "installing uv..."; \ - curl -LsSf https://astral.sh/uv/install.sh | sh; \ +check-toolchain-env: + @test -n "$(RUST_VERSION)" || { echo "error: rust-toolchain.toml is missing or has no channel" >&2; exit 1; } + @if [ -n "$(AMBIENT_RUSTUP_TOOLCHAIN)" ] && [ "$(AMBIENT_RUSTUP_TOOLCHAIN)" != "$(RUST_VERSION)" ]; then \ + echo "error: Rust toolchain mismatch: expected $(RUST_VERSION), RUSTUP_TOOLCHAIN is '$(AMBIENT_RUSTUP_TOOLCHAIN)'" >&2; \ + echo "repair: unset RUSTUP_TOOLCHAIN" >&2; \ + echo "repair: rustup toolchain install $(RUST_VERSION) --component rustfmt --component clippy" >&2; \ + exit 1; \ fi - @if ! command -v pipx >/dev/null 2>&1; then \ - echo "pipx missing — install instructions:"; \ - echo " fedora: sudo dnf install pipx"; \ - echo " debian: sudo apt install pipx"; \ - echo " arch: sudo pacman -S python-pipx"; \ - echo " opensuse: sudo zypper install python3-pipx"; \ + +establish-toolchain: check-toolchain-env + @command -v $(RUSTUP) >/dev/null 2>&1 || { echo "error: rustup not found; run 'make bootstrap'" >&2; exit 1; } + $(RUSTUP) toolchain install $(RUST_VERSION) --profile minimal --component rustfmt --component clippy --target $(RUST_TARGET) + +rust-preflight: check-toolchain-env + @actual=$$($(CARGO) --version >/dev/null 2>&1 && rustc --version --verbose | sed -n 's/^release: //p'); \ + if [ "$$actual" != "$(RUST_VERSION)" ]; then \ + echo "error: Rust toolchain mismatch: expected $(RUST_VERSION), actual $${actual:-unavailable}" >&2; \ + echo "repair: rustup toolchain install $(RUST_VERSION) --component rustfmt --component clippy" >&2; \ exit 1; \ fi - @python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)' || { \ - echo "python >=3.10 required"; exit 1; \ - } - @if [ -f .installed ]; then \ - $(VENV_BIN)/solstone-linux doctor; \ - else \ - echo "now run: make install-service"; \ + +check-cargo-deny: + @actual=$$(cargo deny --version 2>/dev/null || true); \ + if [ -z "$$actual" ]; then \ + echo "error: cargo-deny not found; expected 'cargo-deny $(CARGO_DENY_VERSION)'; run 'make install'" >&2; exit 1; \ + elif [ "$$actual" != "cargo-deny $(CARGO_DENY_VERSION)" ]; then \ + echo "error: cargo-deny version mismatch: expected 'cargo-deny $(CARGO_DENY_VERSION)', got '$$actual'; run 'make install'" >&2; exit 1; \ fi -install-service: .installed - @$(VENV_BIN)/solstone-linux doctor - @command -v pipx >/dev/null || { echo "pipx not found — install with: sudo dnf install pipx (or apt/brew equivalent)"; exit 1; } - @$(PYTHON) -m solstone_linux.install_guard preinstall "$(CURDIR)"; rc=$$?; \ - if [ $$rc -eq 2 ]; then exit 1; \ - elif [ $$rc -eq 10 ]; then $(MAKE) ci; \ - fi - # Editable installs (pipx install -e .) are deliberately avoided: pipx treats editable installs differently and system-site-packages behavior is unreliable with them. - pipx install --force $(PIPX_FLAGS) . - $(PYTHON) -m solstone_linux.install_guard write "$(CURDIR)" - $(APP) install-service - systemctl --user status $(UNIT) --no-pager -l | head -n 20 || true +install: establish-toolchain rust-preflight + @actual=$$(cargo deny --version 2>/dev/null || true); \ + if [ "$$actual" != "cargo-deny $(CARGO_DENY_VERSION)" ]; then \ + $(CARGO) install cargo-deny --version $(CARGO_DENY_VERSION) $(CARGO_LOCKED) || { echo "error: cargo-deny $(CARGO_DENY_VERSION) tool not established; cargo install failed" >&2; exit 1; }; \ + fi + @actual=$$(cargo deny --version 2>/dev/null || true); \ + [ "$$actual" = "cargo-deny $(CARGO_DENY_VERSION)" ] || { echo "error: cargo-deny $(CARGO_DENY_VERSION) tool not established; got '$$actual'" >&2; exit 1; } + $(CARGO) install --path crates/solstone-linux $(CARGO_LOCKED) -service-restart: - systemctl --user restart $(UNIT) +bootstrap: + @if ! command -v rustup >/dev/null 2>&1; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path; fi + @$(MAKE) install -service-status: - systemctl --user --no-pager status $(UNIT) +format: rust-preflight + $(CARGO) fmt -service-logs: - journalctl --user -u $(UNIT) -n 100 --no-pager -f +test: rust-preflight + $(CARGO) test $(CARGO_LOCKED) -p solstone-linux -uninstall-service: .installed - @$(PYTHON) -m solstone_linux.install_guard preuninstall "$(CURDIR)"; rc=$$?; \ - if [ $$rc -eq 2 ]; then exit 1; \ - elif [ $$rc -eq 0 ]; then exit 0; \ - fi - -systemctl --user stop $(UNIT) - -systemctl --user disable $(UNIT) - -rm -f $(HOME)/.config/systemd/user/$(UNIT) - -systemctl --user daemon-reload - -pipx uninstall $(APP) - $(PYTHON) -m solstone_linux.install_guard remove - -# Venv tool shortcuts -PYTEST := $(VENV_BIN)/pytest -RUFF := $(VENV_BIN)/ruff +shellcheck: + shellcheck $(SHELLCHECK_SCRIPTS) -# Run all tests -test: .installed - @echo "Running tests..." - $(PYTEST) tests/ -q +ci: rust-preflight check-cargo-deny + @echo "Evidence class: host evidence (format, lint, tests, and offline dependency policy)." + @echo "This gate does not run target-package validation or the release FLAC soak." + $(CARGO) fmt --check + $(CARGO) clippy $(CARGO_LOCKED) --all-targets -- -D warnings + $(CARGO) test $(CARGO_LOCKED) -p solstone-linux + $(MAKE) shellcheck + cargo deny $(CARGO_LOCKED) --offline check licenses bans sources -# Run specific test file or pattern -test-only: .installed - @if [ -z "$(TEST)" ]; then \ - echo "Usage: make test-only TEST="; \ - echo "Example: make test-only TEST=tests/test_config.py"; \ - echo "Example: make test-only TEST=\"-k test_function_name\""; \ - exit 1; \ - fi - $(PYTEST) $(TEST) +audit: rust-preflight check-cargo-deny + @echo "Evidence class: refreshed advisory evidence." + cargo deny fetch db + cargo deny $(CARGO_LOCKED) check advisories -# Auto-format and fix code, then report remaining issues -format: .installed - @echo "Formatting and fixing code with ruff..." - @$(RUFF) format . - @$(RUFF) check --fix . - @echo "" - @echo "Checking for remaining issues..." - @$(RUFF) check . || { echo ""; echo "Issues above need manual fixes."; exit 1; } - @echo "" - @echo "All clean!" +update-deps: rust-preflight + $(CARGO) update -rust-fmt-check rust-lint rust-test rust-deny: export PATH := $(CARGO_BIN_DIR):$(PATH) +install-service: install + $(APP) install-service -rust-fmt-check: - $(CARGO) fmt --check +uninstall-service: rust-preflight + $(CARGO) run $(CARGO_LOCKED) -p solstone-linux -- uninstall-service -rust-lint: - $(CARGO) clippy --all-targets -- -D warnings +service-restart: + systemctl --user restart $(UNIT) -rust-test: - $(CARGO) test +service-status: + systemctl --user --no-pager status $(UNIT) -rust-deny: - @if [ -n "$(CARGO_DENY)" ] && [ -x "$(CARGO_DENY)" ]; then \ - echo "Running cargo deny check with $(CARGO_DENY)..."; \ - "$(CARGO_DENY)" check; \ - else \ - echo "NOTICE: cargo-deny not found on PATH or at $(HOME)/.cargo/bin/cargo-deny; skipping cargo deny check"; \ - fi +service-logs: + journalctl --user -u $(UNIT) -n 100 --no-pager -f -shellcheck: - shellcheck $(SHELLCHECK_SCRIPTS) +versions: rust-preflight check-cargo-deny + rustc --version --verbose + $(CARGO) --version + cargo deny --version + @command -v $(APP) >/dev/null 2>&1 && $(APP) --version || true + +release: rust-preflight + @echo "Evidence class: target-package drift evidence. This does not run the release FLAC soak." + @bash scripts/build-release.sh deb + @bash scripts/build-release.sh rpm -# Run CI checks (what CI would run) -ci: .installed - @echo "Running CI checks..." - @echo "=== Checking formatting ===" - @$(RUFF) format --check . || { echo "Run 'make format' to fix formatting"; exit 1; } - @echo "" - @echo "=== Running ruff ===" - @$(RUFF) check . || { echo "Run 'make format' to auto-fix"; exit 1; } - @echo "" - @echo "=== Running tests ===" - @$(MAKE) test - @echo "" - @echo "=== Checking release scripts ===" - @$(MAKE) shellcheck - @echo "" - @echo "=== Checking Rust formatting ===" - @$(MAKE) rust-fmt-check - @echo "" - @echo "=== Running Rust clippy ===" - @$(MAKE) rust-lint - @echo "" - @echo "=== Running Rust tests ===" - @$(MAKE) rust-test - @echo "" - @echo "=== Checking Rust dependencies ===" - @$(MAKE) rust-deny - @echo "" - @echo "All CI checks passed!" - -# Clean build artifacts and cache files clean: @echo "Cleaning build artifacts and cache files..." rm -rf build/ dist/ *.egg-info/ @@ -186,22 +130,41 @@ clean: find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete find . -type f -name "*.pyo" -delete - rm -f .installed + rm -f .legacy-python-installed rm -rf $(VENV) -# Clean everything and reinstall clean-install: clean install -# Show installed package versions -versions: .installed - @echo "=== Python version ===" - $(PYTHON) --version - @echo "" - @echo "=== Installed packages ===" - @$(UV) pip list | grep -E "^(pytest|ruff|requests|numpy|soundfile|soundcard|dbus-fast|PyGObject)" || true +.legacy-python-installed: pyproject.toml + @command -v uv >/dev/null 2>&1 || { echo "error: uv is required for legacy Python targets" >&2; exit 1; } + @[ -f $(VENV)/pyvenv.cfg ] || $(UV) venv $(VENV_FLAGS) --python /usr/bin/python3 $(VENV) + $(UV) sync --group dev --no-install-package pygobject --no-install-package pycairo + @touch .legacy-python-installed + +legacy-python-install: .legacy-python-installed + +legacy-python-format: .legacy-python-installed + $(RUFF) format . + $(RUFF) check --fix . + +legacy-python-test: .legacy-python-installed + $(PYTEST) tests/ -q + +legacy-python-test-only: .legacy-python-installed + @test -n "$(TEST)" || { echo "Usage: make legacy-python-test-only TEST=" >&2; exit 1; } + $(PYTEST) $(TEST) + +legacy-python-ci: .legacy-python-installed + $(RUFF) format --check . + $(RUFF) check . + $(PYTEST) tests/ -q + +legacy-python-bootstrap: + @if command -v uv >/dev/null 2>&1; then echo "uv already installed"; else curl -LsSf https://astral.sh/uv/install.sh | sh; fi + @$(MAKE) legacy-python-install -release: ## Publish solstone-linux to PyPI (production) +legacy-python-release: @bash scripts/release.sh -release-test: ## Publish solstone-linux to TestPyPI +legacy-python-release-test: @bash scripts/release.sh --test diff --git a/README.md b/README.md index 2bcde78..1b4bb2a 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,41 @@ Standalone Linux desktop observer for [solstone](https://solpbc.org). Experience **Note:** Activity detection uses screen-lock and power-save signals to notice when you step away. Coverage varies by desktop: GNOME provides both signals; KDE (Wayland) provides screen lock only; any X11 session also provides DPMS power save; other Wayland desktops provide screen lock where the compositor exposes it. Where neither signal is available, solstone-linux still experiences your screen and audio, but activity-based segment boundaries won't trigger. -## System Dependencies +## System dependencies - **Fedora:** - ``` - sudo dnf install python3-gobject python3-cairo gtk4 gstreamer1-plugins-base pipewire-gstreamer alsa-lib-devel pulseaudio-utils pipewire-pulseaudio xdg-desktop-portal pipx gcc python3-devel pkgconf-pkg-config cairo-devel cairo-gobject-devel - ``` +**Fedora:** +``` +sudo dnf install pulseaudio-libs gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer pipewire-pulseaudio xdg-desktop-portal xdg-utils +``` - **Debian / Ubuntu:** - ``` - sudo apt install python3-gi python3-cairo gir1.2-gtk-4.0 gstreamer1.0-pipewire gstreamer1.0-tools libasound2-dev pulseaudio-utils pipewire-pulse xdg-desktop-portal pipx gcc python3-dev pkg-config libcairo2-dev - ``` +**Debian / Ubuntu:** +``` +sudo apt install libpulse0 libgstreamer1.0-0 gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-pipewire gstreamer1.0-x pipewire-pulse xdg-desktop-portal xdg-utils +``` - **Arch:** - ``` - sudo pacman -S python-gobject gtk4 gstreamer gst-plugin-pipewire gst-plugins-good libpulse alsa-lib xdg-desktop-portal python-pipx uv python-cairo - ``` +**Arch:** +``` +sudo pacman -S libpulse gstreamer gst-plugins-base gst-plugins-good gst-plugin-pipewire pipewire-pulse xdg-desktop-portal xdg-utils +``` - **openSUSE:** - ``` - sudo zypper install python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-4_0 gtk4-tools gstreamer-plugins-base gstreamer-plugin-pipewire pipewire-pulseaudio pulseaudio-utils alsa-devel xdg-desktop-portal python3-pipx - ``` +**openSUSE:** +``` +sudo zypper install libpulse0 gstreamer gstreamer-plugins-base gstreamer-plugins-good gstreamer-plugin-pipewire pipewire-pulseaudio xdg-desktop-portal xdg-utils +``` ## Install solstone (the journal) must already be installed and running on the host this observer reports to. If it isn't, start with the [journal install](https://solstone.app/install). -On the machine that will host the observer: +Install a native Debian/RPM package from the release, or install its portable archive: ```bash -pipx install --system-site-packages solstone-linux +scripts/install.sh solstone-linux--linux-x86_64.tar.gz solstone-linux install-service solstone-linux setup ``` -The `--system-site-packages` flag is required: it lets pipx reuse your distro's system PyGObject/pycairo/GStreamer bindings (the `python3-gi` / `python3-cairo` packages from System Dependencies above) instead of rebuilding PyGObject from source — which needs the GObject-Introspection build toolchain and isn't packaged on every distro. See `INSTALL.md` if a plain `pipx install` failed with a `girepository-2.0` build error. +The archive includes `packaging/INSTALL-NOTES`, the canonical runtime-dependency list. See `INSTALL.md` for package installation, tray notes, and troubleshooting. `setup` registers the observer against your journal over the local `http://localhost:5015` link, so there's no URL to type. If this machine reaches your solstone host directly instead, run `solstone-linux setup --server-url `. (Legacy fallback: mint a key on the journal host with `journal observer create ` and paste it during setup.) @@ -51,7 +51,7 @@ make install-service solstone-linux setup ``` -See `INSTALL.md` for distro packages, tray notes, and troubleshooting details. +The Python implementation and its `legacy-python-*` developer targets remain in the repository for reference and parity testing, but are non-shipping. ## Setup diff --git a/RELEASING.md b/RELEASING.md index cd05883..e96bbe1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ -# Rust release rail +# Native Rust release rail -The Rust release rail is operator-run and separate from the Python/PyPI rail. +The shipping release rail is operator-run. The retained Python/PyPI rail is non-shipping. It produces portable, Debian, and RPM artifacts; it does not publish, tag, or create a hosted release. Releases remain an operator-run process. @@ -14,6 +14,11 @@ needed to compile the program. Only x86_64 is supported. The build and install scripts refuse every other architecture rather than placing an x86_64 binary under a misleading name. +The compiler authority is `rust-toolchain.toml` (`1.97.1`). Native package +tools are pinned to cargo-deb `3.7.0` and cargo-generate-rpm `0.21.0`; their +exact single-line version banners are asserted in the build container before +packaging starts. + ## 2. Version source and output names The Rust version comes from `[workspace.package].version` and the member's @@ -97,8 +102,9 @@ The script reports when `$HOME/.local/bin` is not on PATH. A different prefix requires explicit `--prefix PATH`; the script never silently invokes sudo. Unknown distribution families stop without making changes. -The Rust `install-service` command is not implemented yet. It is an optional -future step, and its current failure must not invalidate a binary install. +Run `solstone-linux install-service` after installing the binary. The native +command writes the user unit and desktop autostart entry, reloads systemd, and +enables and starts the observer service. ## 7. Runtime dependencies @@ -122,7 +128,7 @@ separate engineering-tone release-note template here. - x86_64 only - glibc 2.35 baseline - no packaged unit file or desktop file -- Rust `install-service` remains a stub +- native service files are installed at runtime rather than packaged - release panics unwind; reconsider abort only at the Rust cutover ## 10. Failure recovery @@ -131,3 +137,22 @@ Container builds and local installs do not publish, tag, or push. Fix the reported problem, remove only the affected files under `dist/rust/`, and rerun the relevant `deb` or `rpm` command. Never relabel an artifact built for a different architecture or version. + +## 11. Evidence classes + +| Evidence class | What it proves | What it does not prove | +|---|---|---| +| Host evidence | Source formatting, lint, tests, and offline dependency policy | Target-distribution packaging or runtime behavior | +| Target-package drift evidence | Container compiler/tool pins and distro-native package construction | Installed-artifact behavior or the release soak | +| Shipped-artifact proof | Artifact contents, linkage, installation, and the manual FLAC soak | Behavior outside the tested artifact and environment | + +`make ci` names itself as host evidence. Container package gates name the +target-package class. Neither may claim the blocking FLAC soak ran; only the +operator completing section 5 has shipped-artifact proof. + +## 12. Dependency policy + +`make ci` runs cargo-deny offline for licenses, bans, and sources. It does not +fetch or inspect advisories. `make audit` first refreshes the RustSec database +and stops nonzero if refresh fails, then performs the locked advisory check. +This prevents stale cached data from being presented as freshly audited. diff --git a/crates/solstone-linux/Cargo.toml b/crates/solstone-linux/Cargo.toml index da48387..286eab3 100644 --- a/crates/solstone-linux/Cargo.toml +++ b/crates/solstone-linux/Cargo.toml @@ -8,6 +8,9 @@ description = "Linux desktop observer that experiences screen and audio along wi homepage = "https://solstone.app" repository = "https://github.com/solpbc/solstone-linux" +[lints] +workspace = true + [dependencies] clap.workspace = true tracing.workspace = true diff --git a/crates/solstone-linux/src/cli.rs b/crates/solstone-linux/src/cli.rs index b0d451e..76decbe 100644 --- a/crates/solstone-linux/src/cli.rs +++ b/crates/solstone-linux/src/cli.rs @@ -166,13 +166,18 @@ fn apply_session_environment(environment: &HashMap) { "DBUS_SESSION_BUS_ADDRESS", ] { if let Some(value) = environment.get(name) { - // SAFETY: cmd_run performs session recovery during single-threaded startup, - // before the observer stub starts any worker threads. - unsafe { env::set_var(name, value) }; + set_session_environment_variable(name, value); } } } +#[allow(unsafe_code)] +fn set_session_environment_variable(name: &str, value: &str) { + // SAFETY: cmd_run performs session recovery during single-threaded startup, + // before the observer starts any worker threads. + unsafe { env::set_var(name, value) }; +} + fn hostname() -> io::Result { Ok(fs::read_to_string("/proc/sys/kernel/hostname")? .trim() @@ -698,6 +703,26 @@ mod tests { ); assert!(!Args::try_parse_from(["solstone-linux"]).unwrap().verbose); } + // AC: the safe wrapper assigns the exact value and leaves the process environment as found. + #[test] + #[allow(unsafe_code)] + fn session_environment_wrapper_assigns_and_restores() { + const NAME: &str = "SOLSTONE_LINUX_TEST_SAFE_ENVIRONMENT_WRAPPER"; + const VALUE: &str = "known-wrapper-value"; + // Compile-time proof: this coercion fails if the wrapper becomes an unsafe function. + let wrapper: fn(&str, &str) = set_session_environment_variable; + let previous = env::var_os(NAME); + + wrapper(NAME, VALUE); + assert_eq!(env::var(NAME).as_deref(), Ok(VALUE)); + + match previous { + Some(value) => wrapper(NAME, &value.to_string_lossy()), + // SAFETY: this test restores its uniquely named variable after the assertion, + // and no other test or runtime path reads or writes that variable. + None => unsafe { env::remove_var(NAME) }, + } + } // AC: bare invocation is run parity. #[test] fn bare_is_run() { diff --git a/crates/solstone-linux/src/lib.rs b/crates/solstone-linux/src/lib.rs index d0b3432..f9390bd 100644 --- a/crates/solstone-linux/src/lib.rs +++ b/crates/solstone-linux/src/lib.rs @@ -40,3 +40,5 @@ pub mod video; mod release_rail_tests; #[cfg(test)] mod test_support; +#[cfg(test)] +mod toolchain_policy_tests; diff --git a/crates/solstone-linux/src/release_rail_tests.rs b/crates/solstone-linux/src/release_rail_tests.rs index 7585f8f..dec3559 100644 --- a/crates/solstone-linux/src/release_rail_tests.rs +++ b/crates/solstone-linux/src/release_rail_tests.rs @@ -16,11 +16,11 @@ fn manifest_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } -fn workspace_root() -> PathBuf { +pub(crate) fn workspace_root() -> PathBuf { manifest_dir().join("../..").canonicalize().unwrap() } -fn read_toml(path: &Path) -> Value { +pub(crate) fn read_toml(path: &Path) -> Value { toml::from_str(&fs::read_to_string(path).unwrap()).unwrap() } @@ -297,7 +297,7 @@ fn installer_command(archive: &Path, os_release: &Path) -> Command { command } -fn command_path(name: &str) -> PathBuf { +pub(crate) fn command_path(name: &str) -> PathBuf { let output = Command::new("sh") .args(["-c", &format!("command -v {name}")]) .output() diff --git a/crates/solstone-linux/src/toolchain_policy_tests.rs b/crates/solstone-linux/src/toolchain_policy_tests.rs new file mode 100644 index 0000000..b64de70 --- /dev/null +++ b/crates/solstone-linux/src/toolchain_policy_tests.rs @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use crate::release_rail_tests::{command_path, read_toml, workspace_root}; +use std::{ + collections::HashMap, + fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + process::{Command, Output}, +}; + +const LOCKED_SUBCOMMANDS: &[&str] = &[ + "build", "check", "clippy", "test", "install", "metadata", "deb", "run", +]; + +#[derive(Default)] +struct ScanCounts { + inspected: usize, + nested_container: usize, + make_wrapper: usize, +} + +fn toolchain() -> toml::Value { + read_toml(&workspace_root().join("rust-toolchain.toml")) +} + +fn pin() -> String { + toolchain()["toolchain"]["channel"] + .as_str() + .unwrap() + .to_owned() +} + +fn make_variables(makefile: &str) -> HashMap { + makefile + .lines() + .filter_map(|line| { + let (name, value) = line.split_once(":=").or_else(|| line.split_once("?="))?; + let name = name.trim(); + (!name.is_empty() && name.chars().all(|c| c.is_ascii_uppercase() || c == '_')) + .then(|| (name.to_owned(), value.trim().to_owned())) + }) + .collect() +} + +fn expand_make(mut line: String, variables: &HashMap) -> String { + for _ in 0..variables.len().max(1) { + let before = line.clone(); + for (name, value) in variables { + line = line.replace(&format!("$({name})"), value); + } + if line == before { + break; + } + } + line +} + +fn logical_lines(text: &str) -> Vec { + let mut result = Vec::new(); + let mut current = String::new(); + for raw in text.lines() { + let trimmed = raw.trim_end(); + current.push_str(trimmed.strip_suffix('\\').unwrap_or(trimmed)); + if trimmed.ends_with('\\') { + current.push(' '); + } else { + result.push(std::mem::take(&mut current)); + } + } + if !current.is_empty() { + result.push(current); + } + result +} + +fn cargo_command(fragment: &str) -> Option<(&str, bool)> { + let words = fragment.split_whitespace().collect::>(); + let cargo = words.iter().position(|word| { + word.trim_matches(|c: char| matches!(c, '@' | '"' | '\'')) == "cargo" + || word.contains("$(cargo") + || word.contains("$(CARGO)") + })?; + let tail = &words[cargo + 1..]; + let first = tail + .iter() + .find(|word| !word.starts_with('+') && !word.starts_with('-'))?; + let subcommand = first.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-'); + Some(( + subcommand, + fragment.split_whitespace().any(|word| { + word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-') == "--locked" + }), + )) +} + +fn scan_policy( + makefile: &str, + containerfile: &str, + scripts: &[String], +) -> Result { + let variables = make_variables(makefile); + let mut counts = ScanCounts::default(); + let mut sources = vec![ + ("Makefile", makefile.to_owned()), + ("Containerfile", containerfile.to_owned()), + ]; + sources.extend(scripts.iter().cloned().map(|text| ("script", text))); + + for (source, text) in sources { + for logical in logical_lines(&text) { + let was_wrapper = logical.contains("$(CARGO)"); + if was_wrapper + && logical.contains("$(CARGO_LOCKED)") + && !variables.contains_key("CARGO_LOCKED") + { + return Err("unresolvable Make lock indirection".into()); + } + let expanded = expand_make(logical.clone(), &variables); + if expanded.contains("$(CARGO)") || expanded.contains("$(CARGO_LOCKED)") { + return Err(format!("unresolvable Cargo indirection in {source}")); + } + for fragment in expanded.split("&&") { + let Some((subcommand, locked)) = cargo_command(fragment) else { + continue; + }; + counts.inspected += 1; + if was_wrapper { + counts.make_wrapper += 1; + } + if source == "Containerfile" && logical.contains("&&") { + counts.nested_container += 1; + } + let version_query = fragment.contains("--version"); + let resolving = !version_query + && (LOCKED_SUBCOMMANDS.contains(&subcommand) + || (subcommand == "deny" && fragment.contains(" check "))); + let exempt = matches!(subcommand, "fmt" | "generate-rpm" | "clean" | "update") + || (subcommand == "deny" && (fragment.contains(" fetch ") || version_query)) + || version_query + || subcommand == "--version"; + if resolving && !locked { + return Err(format!( + "resolving Cargo invocation lacks --locked: {fragment}" + )); + } + if !resolving && !exempt { + return Err(format!("unclassified Cargo invocation: {fragment}")); + } + } + } + } + if counts.inspected == 0 || counts.nested_container == 0 || counts.make_wrapper == 0 { + return Err("policy scan did not inspect required command classes".into()); + } + Ok(counts) +} + +fn policy_sources() -> (String, String, Vec) { + let root = workspace_root(); + let makefile = fs::read_to_string(root.join("Makefile")).unwrap(); + let containerfile = fs::read_to_string(root.join("packaging/Containerfile")).unwrap(); + let mut paths = fs::read_dir(root.join("scripts")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "sh")) + .collect::>(); + paths.sort(); + let scripts = paths + .into_iter() + .map(|path| fs::read_to_string(path).unwrap()) + .collect(); + (makefile, containerfile, scripts) +} + +fn make_with_fake_cargo(version: Option<&str>) -> Output { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let cargo = bin.join("cargo"); + let body = version.map_or_else( + || "#!/bin/sh\nexit 127\n".to_owned(), + |version| format!("#!/bin/sh\nif [ \"$1 $2\" = \"deny --version\" ]; then echo '{version}'; exit 0; fi\nexit 97\n"), + ); + fs::write(&cargo, body).unwrap(); + fs::set_permissions(&cargo, fs::Permissions::from_mode(0o755)).unwrap(); + Command::new(command_path("make")) + .arg("--no-print-directory") + .arg("check-cargo-deny") + .current_dir(workspace_root()) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .output() + .unwrap() +} + +fn combined_output(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +// AC: the exact committed Rust toolchain declaration is mandatory and complete. +#[test] +fn toolchain_file_is_required() { + let config = toolchain(); + let selected = &config["toolchain"]; + assert!( + selected["channel"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + assert_eq!(selected["profile"].as_str(), Some("minimal")); + assert_eq!( + selected["components"].as_array().unwrap(), + &[ + toml::Value::String("rustfmt".into()), + toml::Value::String("clippy".into()) + ] + ); + assert_eq!( + selected["targets"].as_array().unwrap(), + &[toml::Value::String("x86_64-unknown-linux-gnu".into())] + ); +} + +// AC: every compiler declaration is derived from the toolchain-file authority. +#[test] +fn compiler_declarations_match_toolchain_authority() { + let root = workspace_root(); + let expected = pin(); + let manifest = read_toml(&root.join("Cargo.toml")); + assert_eq!( + manifest["workspace"]["package"]["rust-version"].as_str(), + Some(expected.as_str()) + ); + let container = fs::read_to_string(root.join("packaging/Containerfile")).unwrap(); + let declarations = container + .lines() + .filter_map(|line| line.strip_prefix("ARG RUST_VERSION=")) + .collect::>(); + assert_eq!(declarations, vec![expected.as_str(), expected.as_str()]); +} + +// AC: dependency-resolving commands stay locked across Make, containers, and scripts. +#[test] +fn locked_policy_covers_nested_container_commands() { + let (makefile, container, scripts) = policy_sources(); + let counts = scan_policy(&makefile, &container, &scripts).unwrap(); + assert!(counts.inspected > 0); + assert!(counts.nested_container > 0); +} + +// AC: Make command wrappers and wrapper-carried lock flags cannot evade policy. +#[test] +fn locked_policy_resolves_make_wrappers() { + let (makefile, container, scripts) = policy_sources(); + let counts = scan_policy(&makefile, &container, &scripts).unwrap(); + assert!(counts.make_wrapper > 0); +} + +// AC: every workspace member inherits the workspace lint floor. +#[test] +fn workspace_members_inherit_workspace_lints() { + let root = workspace_root(); + let workspace = read_toml(&root.join("Cargo.toml")); + assert_eq!( + workspace["workspace"]["lints"]["rust"]["unsafe_code"].as_str(), + Some("deny") + ); + for member in workspace["workspace"]["members"].as_array().unwrap() { + let manifest = read_toml(&root.join(member.as_str().unwrap()).join("Cargo.toml")); + assert_eq!(manifest["lints"]["workspace"].as_bool(), Some(true)); + } +} + +// AC: an absent cargo-deny executable is a named hard failure, never a skip. +#[test] +fn cargo_deny_missing_fails_loudly() { + let output = make_with_fake_cargo(None); + assert!(!output.status.success()); + assert!(combined_output(&output).contains("cargo-deny not found")); +} + +// AC: a cargo-deny version skew is a named hard failure. +#[test] +fn cargo_deny_version_skew_fails_loudly() { + let output = make_with_fake_cargo(Some("cargo-deny 0.20.1")); + assert!(!output.status.success()); + assert!(combined_output(&output).contains("cargo-deny version mismatch")); +} + +// AC: dependency policy cannot regain a success-producing missing-tool branch. +#[test] +fn cargo_deny_cannot_skip_dependency_policy() { + let makefile = fs::read_to_string(workspace_root().join("Makefile")).unwrap(); + assert!(!makefile.contains("skipping cargo deny")); + assert!(makefile.contains("cargo deny $(CARGO_LOCKED) --offline check licenses bans sources")); +} + +// AC: ambient toolchain skew is rejected before any Cargo gate work can run. +#[test] +fn ambient_toolchain_override_cannot_escape_preflight() { + let output = Command::new(command_path("make")) + .args(["--no-print-directory", "ci"]) + .current_dir(workspace_root()) + .env("RUSTUP_TOOLCHAIN", "stable") + .output() + .unwrap(); + assert!(!output.status.success()); + let text = combined_output(&output); + assert!(text.contains(&format!("expected {}", pin()))); + assert!(text.contains("unset RUSTUP_TOOLCHAIN")); + assert!(!text.contains("cargo clippy")); +} + +// AC: unsafe Rust remains confined to the reviewed startup environment seam. +#[test] +fn unsafe_code_is_confined_to_session_environment_wrapper() { + let source = workspace_root().join("crates/solstone-linux/src"); + let mut unsafe_blocks = Vec::::new(); + let mut allowances = Vec::::new(); + for entry in fs::read_dir(&source).unwrap() { + let path = entry.unwrap().path(); + if path.extension().is_none_or(|extension| extension != "rs") + || path.file_name().unwrap() == "toolchain_policy_tests.rs" + { + continue; + } + let text = fs::read_to_string(&path).unwrap(); + unsafe_blocks.extend(std::iter::repeat_n( + path.clone(), + text.matches("unsafe {").count(), + )); + allowances.extend(std::iter::repeat_n( + path.clone(), + text.matches("#[allow(unsafe_code)]").count(), + )); + } + let cli = source.join("cli.rs"); + assert_eq!(unsafe_blocks, vec![cli.clone(), cli.clone()]); + assert_eq!(allowances, vec![cli.clone(), cli]); +} + +// AC: package-tool mirrors equal their Makefile authorities without test literals. +#[test] +fn package_tool_versions_match_authority() { + let root = workspace_root(); + let makefile = fs::read_to_string(root.join("Makefile")).unwrap(); + let variables = make_variables(&makefile); + let container = fs::read_to_string(root.join("packaging/Containerfile")).unwrap(); + for (make_name, container_name) in [ + ("CARGO_DEB_VERSION", "CARGO_DEB_VERSION"), + ("CARGO_GENERATE_RPM_VERSION", "CARGO_GENERATE_RPM_VERSION"), + ] { + let value = &variables[make_name]; + assert!( + container + .lines() + .any(|line| line == format!("ARG {container_name}={value}")) + ); + } + assert!(!variables["CARGO_DENY_VERSION"].is_empty()); + assert!(makefile.contains("cargo-deny $(CARGO_DENY_VERSION)")); +} + +// AC: dependency policy retains explicit wildcard and unknown-source denial. +#[test] +fn dependency_policy_denies_wildcards_and_unknown_sources() { + let deny = read_toml(&workspace_root().join("deny.toml")); + assert_eq!(deny["bans"]["wildcards"].as_str(), Some("deny")); + assert_eq!(deny["sources"]["unknown-registry"].as_str(), Some("deny")); + assert_eq!(deny["sources"]["unknown-git"].as_str(), Some("deny")); + for ignored in deny["advisories"]["ignore"].as_array().unwrap() { + let reason = ignored["reason"].as_str().unwrap(); + assert!(reason.contains("Owner: sol pbc engineering.")); + assert!(reason.contains("Build-only via wayland-scanner; it parses crate-bundled protocol XML, not runtime or untrusted input.")); + assert!(reason.contains("Remove when wayland-scanner accepts quick-xml >=0.41.")); + } +} diff --git a/deny.toml b/deny.toml index c3483d0..3df9399 100644 --- a/deny.toml +++ b/deny.toml @@ -3,8 +3,8 @@ yanked = "deny" ignore = [ # Build-only via wayland-scanner; it parses crate-bundled protocol XML, not # runtime or untrusted input. Remove when wayland-scanner accepts quick-xml >=0.41. - "RUSTSEC-2026-0194", - "RUSTSEC-2026-0195", + { id = "RUSTSEC-2026-0194", reason = "Owner: sol pbc engineering. Build-only via wayland-scanner; it parses crate-bundled protocol XML, not runtime or untrusted input. Remove when wayland-scanner accepts quick-xml >=0.41." }, + { id = "RUSTSEC-2026-0195", reason = "Owner: sol pbc engineering. Build-only via wayland-scanner; it parses crate-bundled protocol XML, not runtime or untrusted input. Remove when wayland-scanner accepts quick-xml >=0.41." }, ] [licenses] @@ -25,7 +25,7 @@ confidence-threshold = 0.8 [bans] multiple-versions = "warn" -wildcards = "allow" +wildcards = "deny" [sources] unknown-registry = "deny" diff --git a/packaging/Containerfile b/packaging/Containerfile index a5a5df3..e85a198 100644 --- a/packaging/Containerfile +++ b/packaging/Containerfile @@ -4,7 +4,7 @@ FROM ubuntu:22.04 AS baseline ARG DEBIAN_FRONTEND=noninteractive -ARG RUST_VERSION=1.92.0 +ARG RUST_VERSION=1.97.1 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ @@ -29,10 +29,12 @@ WORKDIR /src # deliberately panics if those canonical source files cannot be read. COPY . . +RUN test "$(rustc --version --verbose | sed -n 's/^release: //p')" = "${RUST_VERSION}" + # Cargo currently serializes package name immediately before version. Keep the # non-empty guard below so any JSON ordering change fails the build. RUN cargo build --locked --release -p solstone-linux \ - && VERSION=$(cargo metadata --format-version 1 --no-deps \ + && VERSION=$(cargo metadata --locked --format-version 1 --no-deps \ | sed -n 's/.*"name":"solstone-linux","version":"\([^"]*\)".*/\1/p') \ && test -n "$VERSION" \ && ROOT="solstone-linux-${VERSION}-linux-x86_64" \ @@ -46,7 +48,9 @@ RUN cargo build --locked --release -p solstone-linux \ FROM baseline AS deb-build -RUN cargo install cargo-deb --version 3.7.0 --locked \ +ARG CARGO_DEB_VERSION=3.7.0 +RUN cargo install cargo-deb --version "${CARGO_DEB_VERSION}" --locked \ + && test "$(cargo deb --version)" = "cargo-deb ${CARGO_DEB_VERSION}" \ && cargo deb --locked -p solstone-linux \ && VERSION=$(cat /release/VERSION) \ && DEB="target/debian/solstone-linux_${VERSION}-1_amd64.deb" \ @@ -59,7 +63,8 @@ COPY --from=deb-build /release/ / FROM fedora:42 AS rpm-build -ARG RUST_VERSION=1.92.0 +ARG RUST_VERSION=1.97.1 +ARG CARGO_GENERATE_RPM_VERSION=0.21.0 RUN dnf install -y ca-certificates curl gcc glibc-devel rpm-build \ && dnf clean all \ && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ @@ -69,7 +74,10 @@ WORKDIR /src COPY --from=baseline /src /src COPY --from=baseline /release /release -RUN cargo install cargo-generate-rpm --version 0.21.0 --locked \ +RUN test "$(rustc --version --verbose | sed -n 's/^release: //p')" = "${RUST_VERSION}" + +RUN cargo install cargo-generate-rpm --version "${CARGO_GENERATE_RPM_VERSION}" --locked \ + && test "$(cargo generate-rpm --version)" = "cargo-generate-rpm ${CARGO_GENERATE_RPM_VERSION}" \ && cargo generate-rpm -p crates/solstone-linux \ && VERSION=$(cat /release/VERSION) \ && RPM="target/generate-rpm/solstone-linux-${VERSION}-1.x86_64.rpm" \ diff --git a/packaging/INSTALL-NOTES b/packaging/INSTALL-NOTES index 9924994..aa77775 100644 --- a/packaging/INSTALL-NOTES +++ b/packaging/INSTALL-NOTES @@ -28,5 +28,5 @@ Typical package families: - Arch: libpulse, gstreamer, gst-plugins-base, gst-plugins-good, gst-plugin-pipewire, pipewire-pulse, xdg-desktop-portal, xdg-utils -The Rust install-service subcommand is not implemented in this release. Run -the observer in the foreground for validation; service setup remains optional. +Run `solstone-linux install-service` to write and enable the systemd user unit +and desktop autostart entry after installing the observer binary. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d377c9c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] +targets = ["x86_64-unknown-linux-gnu"] +profile = "minimal"