From 73168ac149c039d61a80d103e0f444500376c548 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:07:30 +0300 Subject: [PATCH 01/22] Add versioned release bundle manifest --- .github/workflows/ci.yml | 20 +- .github/workflows/release.yml | 2 + docs/development.md | 22 +- docs/release-process.md | 34 ++- docs/testing-strategy.md | 6 + scripts/build-release-bundle.sh | 5 + scripts/publish-release-assets.sh | 18 ++ scripts/release_bundle_manifest.py | 311 ++++++++++++++++++++++++ scripts/test-release-bundle.sh | 68 ++++-- scripts/test_release_bundle_manifest.py | 244 +++++++++++++++++++ 10 files changed, 697 insertions(+), 33 deletions(-) create mode 100644 scripts/release_bundle_manifest.py create mode 100644 scripts/test_release_bundle_manifest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859f57a..7105a16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Validate release promotion contract run: python3 scripts/test_release_promotion.py + - name: Validate release bundle manifest contract + run: python3 scripts/test_release_bundle_manifest.py + bundle-smoke-test: runs-on: ubuntu-latest needs: verify @@ -65,13 +68,24 @@ jobs: run: sudo apt-get update && sudo apt-get install -y musl-tools zenity - name: Build lg-buddy release binary + env: + LG_BUDDY_BUILD_COMMIT: ${{ github.sha }} + LG_BUDDY_RELEASE_VERSION: 0.0.0-ci.smoke run: cargo build --release -p lg-buddy --target x86_64-unknown-linux-musl - name: Create release bundle - run: ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version ci-smoke --output-dir dist + run: ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version 0.0.0-ci.smoke --output-dir dist - name: Smoke test release bundle - run: ./scripts/test-release-bundle.sh --skip-pip-install --archive dist/lg-buddy-ci-smoke-x86_64-unknown-linux-musl.tar.gz + run: | + ./scripts/test-release-bundle.sh \ + --skip-pip-install \ + --archive dist/lg-buddy-0.0.0-ci.smoke-x86_64-unknown-linux-musl.tar.gz \ + --expected-tag v0.0.0-ci.smoke \ + --expected-version 0.0.0-ci.smoke \ + --expected-channel prerelease \ + --expected-target x86_64-unknown-linux-musl \ + --expected-commit "${{ github.sha }}" - name: Generate checksums run: | @@ -86,4 +100,4 @@ jobs: - name: Dry-run publish release assets env: GH_RELEASE_DRY_RUN: "1" - run: ./scripts/publish-release-assets.sh --dist-dir dist --tag v0.0.0-ci-smoke + run: ./scripts/publish-release-assets.sh --dist-dir dist --tag v0.0.0-ci.smoke --commit "${{ github.sha }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ca1012..588dd6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,8 +142,10 @@ jobs: ./scripts/test-release-bundle.sh \ --skip-pip-install \ --archive "dist/lg-buddy-${{ needs.validate.outputs.version }}-x86_64-unknown-linux-musl.tar.gz" \ + --expected-tag "${{ needs.validate.outputs.tag }}" \ --expected-version "${{ needs.validate.outputs.version }}" \ --expected-channel "${{ needs.validate.outputs.channel }}" \ + --expected-target x86_64-unknown-linux-musl \ --expected-commit "${{ needs.validate.outputs.head_sha }}" - name: Generate checksums diff --git a/docs/development.md b/docs/development.md index 41bdf56..6aa754d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -111,10 +111,14 @@ For gamepad subsystem internals and adapter contribution guidance, see Build a release bundle locally with: ```bash +LG_BUDDY_RELEASE_VERSION=0.0.0-dev \ +LG_BUDDY_BUILD_COMMIT="$(git rev-parse HEAD)" \ +cargo build --release -p lg-buddy --target x86_64-unknown-linux-gnu ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-gnu --version 0.0.0-dev ``` -That script expects the matching release binary to already exist under: +The builder requires a full release commit and expects the matching release +binary to exist under: ```text ./target//release/lg-buddy @@ -126,10 +130,17 @@ Smoke test a generated release bundle with: ./scripts/test-release-bundle.sh --archive ./dist/lg-buddy-0.0.0-dev-x86_64-unknown-linux-gnu.tar.gz ``` -The smoke test unpacks the archive, verifies expected files, and installs into a -temporary root. It checks both TV platforms, native credential preservation -across upgrades, lifecycle and NetworkManager hook topology, and uninstall -cleanup without mutating the host installation. +The smoke test validates `release-manifest.json` against the archive name and +bundled binary before running installer code. It then installs into a temporary +root and checks both TV platforms, native credential preservation across +upgrades, lifecycle and NetworkManager hook topology, and uninstall cleanup +without mutating the host installation. + +Run the focused manifest contract tests with: + +```bash +python3 scripts/test_release_bundle_manifest.py +``` Dry-run the GitHub release publish step with: @@ -167,6 +178,7 @@ the branch contract and recovery process, see | `configure.sh` | Interactive configuration tool | | `install.sh` | Installer for an existing binary | | `uninstall.sh` | Uninstaller | +| `scripts/release_bundle_manifest.py` | Release-bundle identity manifest creator and validator | | `scripts/build-release-bundle.sh` | Release bundle builder | | `scripts/test-release-bundle.sh` | Release bundle smoke test | | `scripts/publish-release-assets.sh` | GitHub release publish helper | diff --git a/docs/release-process.md b/docs/release-process.md index 00137b4..8210921 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -65,15 +65,37 @@ The workflow: 1. Revalidates the live PR, refs, required checks, Cargo version, and tag state. 2. Builds a static `x86_64-unknown-linux-musl` binary with exact version and commit identity. -3. Packages and installs the release bundle in an isolated smoke-test root. -4. Verifies the built and installed binary's exact version, channel, and commit. -5. Generates and verifies `sha256sums.txt`. -6. Publishes the tag and GitHub release without replacing conflicting assets. -7. Downloads the published assets and verifies their checksums independently. -8. Fast-forwards the selected branch only after publication succeeds. +3. Generates a versioned identity manifest and packages the release bundle. +4. Validates the manifest and installs the bundle in an isolated smoke-test root. +5. Verifies the built and installed binary's exact version, channel, and commit. +6. Generates and verifies `sha256sums.txt`. +7. Publishes the tag and GitHub release without replacing conflicting assets. +8. Downloads the published assets and verifies their checksums independently. +9. Fast-forwards the selected branch only after publication succeeds. `install.sh` is only an installer. It does not build the runtime. +## Release bundle identity + +Every release archive contains `release-manifest.json` at the bundle root. The +schema-versioned JSON records the exact release tag, semantic version, release +channel, Rust target triple, and full lowercase commit SHA. Version, tag, and +channel must agree: stable SemVer maps to `stable`, prerelease SemVer maps to +`prerelease`, and the tag is exactly `v`. + +Schema 1 marks all five identity fields as critical. Validators reject an +unsupported schema, duplicate JSON fields, missing identity fields, and unknown +critical fields. Unknown non-critical fields may be ignored for compatible +schema evolution. Official manifests use a deterministic field order and JSON +rendering. + +The bundle builder derives version, channel, and commit from `lg-buddy +--version`; it cannot package a binary whose identity disagrees with its tag. +Smoke validation checks the manifest before executing installer code and then +compares it with both the bundled and installed binary. Publishing validates +the manifest directly from each archive without extracting or executing archive +content. + ## Nix source selection Nix configurations may select `main`, `prerelease`, or `dev` as the upstream diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index c1913b7..402acee 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -300,6 +300,12 @@ that a missing TV platform remains `bscpylgtv`, explicit platform values survive reconfiguration, and `lg_webos` routes to the stored-credential-only native path and reports a missing credential without initiating background pairing. +The focused release-manifest suite covers deterministic serialization, schema +and critical-field handling, duplicate and missing fields, canonical identity +formats, archive layout, and binary version/channel/commit mismatches. The +bundle smoke test then exercises the same validator against the generated and +installed release binary. + ## Current Practical Gaps The most important remaining gaps are: diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index ffdd21b..d752b50 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -61,6 +61,11 @@ install -m 755 "$REPO_ROOT/bin/LG_Buddy_Common" "$BUNDLE_DIR/bin/LG_Buddy_Common install -m 644 "$REPO_ROOT/LG_Buddy_Brightness.desktop" "$BUNDLE_DIR/LG_Buddy_Brightness.desktop" install -m 644 "$REPO_ROOT/README.md" "$BUNDLE_DIR/README.md" install -m 644 "$REPO_ROOT/LICENSE" "$BUNDLE_DIR/LICENSE" +python3 "$SCRIPT_DIR/release_bundle_manifest.py" create \ + --output "$BUNDLE_DIR/release-manifest.json" \ + --release-tag "v$VERSION" \ + --target "$TARGET" \ + --binary "$BUNDLE_DIR/lg-buddy" install -m 644 "$REPO_ROOT/systemd/LG_Buddy.service" "$BUNDLE_DIR/systemd/LG_Buddy.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_lifecycle.service" "$BUNDLE_DIR/systemd/LG_Buddy_lifecycle.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_screen.service" "$BUNDLE_DIR/systemd/LG_Buddy_screen.service" diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 4eb8630..3cfedea 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -2,6 +2,8 @@ set -euo pipefail +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + usage() { echo "Usage: $0 [--dist-dir ] [--tag ] [--commit ]" exit 1 @@ -71,11 +73,27 @@ VERSION="${TAG#v}" TITLE="LG Buddy ${VERSION}" NOTES="Prebuilt LG Buddy release bundle for Linux. Extract the archive and run ./install.sh from inside the bundle." RELEASE_FLAGS=() +EXPECTED_CHANNEL="stable" if [[ "$VERSION" == *-* ]]; then RELEASE_FLAGS+=(--prerelease) + EXPECTED_CHANNEL="prerelease" fi +for archive in "${ARCHIVES[@]}"; do + manifest_expectations=( + --expected-release-tag "$TAG" + --expected-version "$VERSION" + --expected-channel "$EXPECTED_CHANNEL" + ) + if [ -n "$EXPECTED_COMMIT" ]; then + manifest_expectations+=(--expected-commit "$EXPECTED_COMMIT") + fi + python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --archive "$archive" \ + "${manifest_expectations[@]}" +done + if [ "$DRY_RUN" = "1" ]; then echo "Dry run: would publish tag $TAG" printf 'Archive: %s\n' "${ARCHIVES[@]}" diff --git a/scripts/release_bundle_manifest.py b/scripts/release_bundle_manifest.py new file mode 100644 index 0000000..b1e208c --- /dev/null +++ b/scripts/release_bundle_manifest.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 + +"""Create and validate the identity manifest embedded in LG Buddy release bundles.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import tarfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + +from release_promotion import PromotionError, SemVer + + +MANIFEST_NAME = "release-manifest.json" +SCHEMA_VERSION = 1 +IDENTITY_FIELDS = ("release_tag", "version", "channel", "target", "commit") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +TARGET_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$") +MAX_MANIFEST_BYTES = 64 * 1024 + + +class ManifestError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ReleaseIdentity: + release_tag: str + version: str + channel: str + target: str + commit: str + + +def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ManifestError(f"duplicate manifest field: {key}") + result[key] = value + return result + + +def parse_manifest(content: bytes) -> dict[str, Any]: + if len(content) > MAX_MANIFEST_BYTES: + raise ManifestError("release manifest exceeds the 64 KiB size limit") + try: + text = content.decode("utf-8") + except UnicodeDecodeError as error: + raise ManifestError("release manifest is not valid UTF-8") from error + try: + value = json.loads(text, object_pairs_hook=reject_duplicate_keys) + except ManifestError: + raise + except json.JSONDecodeError as error: + raise ManifestError( + f"release manifest is not valid JSON: {error.msg}" + ) from error + if not isinstance(value, dict): + raise ManifestError("release manifest root must be a JSON object") + return value + + +def validate_manifest(value: dict[str, Any]) -> ReleaseIdentity: + schema_version = value.get("schema_version") + if type(schema_version) is not int or schema_version != SCHEMA_VERSION: + raise ManifestError( + f"unsupported release manifest schema_version: {schema_version!r}" + ) + + critical = value.get("critical") + if not isinstance(critical, list) or any( + not isinstance(field, str) for field in critical + ): + raise ManifestError("release manifest critical must be an array of field names") + if len(critical) != len(set(critical)): + raise ManifestError("release manifest critical contains a duplicate field name") + + unknown_critical = sorted(set(critical) - set(IDENTITY_FIELDS)) + if unknown_critical: + raise ManifestError( + f"unknown critical release manifest field: {unknown_critical[0]}" + ) + missing_critical = sorted(set(IDENTITY_FIELDS) - set(critical)) + if missing_critical: + raise ManifestError( + f"required identity field is not marked critical: {missing_critical[0]}" + ) + + fields: dict[str, str] = {} + for field in IDENTITY_FIELDS: + field_value = value.get(field) + if not isinstance(field_value, str) or not field_value: + raise ManifestError(f"missing or invalid release manifest field: {field}") + fields[field] = field_value + + version_text = fields["version"] + try: + version = SemVer.parse(version_text) + except PromotionError as error: + raise ManifestError(str(error)) from error + if version.build: + raise ManifestError("release manifest version must not contain build metadata") + + if fields["release_tag"] != f"v{version_text}": + raise ManifestError( + "release manifest tag must be exactly v followed by the manifest version" + ) + + expected_channel = "prerelease" if version.prerelease else "stable" + if fields["channel"] != expected_channel: + raise ManifestError( + f"release manifest channel must be {expected_channel} for version {version_text}" + ) + + if TARGET_RE.fullmatch(fields["target"]) is None: + raise ManifestError(f"invalid release manifest target: {fields['target']}") + if COMMIT_RE.fullmatch(fields["commit"]) is None: + raise ManifestError( + "release manifest commit must be a full lowercase 40-character SHA" + ) + + return ReleaseIdentity(**fields) + + +def render_manifest(identity: ReleaseIdentity) -> bytes: + value = { + "schema_version": SCHEMA_VERSION, + "critical": list(IDENTITY_FIELDS), + "release_tag": identity.release_tag, + "version": identity.version, + "channel": identity.channel, + "target": identity.target, + "commit": identity.commit, + } + validate_manifest(value) + return (json.dumps(value, indent=2, ensure_ascii=True) + "\n").encode("utf-8") + + +def parse_binary_identity( + output: str, *, target: str, release_tag: str +) -> ReleaseIdentity: + lines = output.splitlines() + if len(lines) != 4: + raise ManifestError("lg-buddy --version must emit exactly four identity lines") + + prefixes = ("lg-buddy ", "version: ", "channel: ", "commit: ") + if any(not line.startswith(prefix) for line, prefix in zip(lines, prefixes)): + raise ManifestError("lg-buddy --version output has an unexpected format") + + headline_version = lines[0][len(prefixes[0]) :] + version = lines[1][len(prefixes[1]) :] + if headline_version != version: + raise ManifestError("lg-buddy --version headline and version field disagree") + + identity = ReleaseIdentity( + release_tag=release_tag, + version=version, + channel=lines[2][len(prefixes[2]) :], + target=target, + commit=lines[3][len(prefixes[3]) :], + ) + validate_manifest(parse_manifest(render_manifest(identity))) + return identity + + +def binary_identity(binary: Path, *, target: str, release_tag: str) -> ReleaseIdentity: + result = subprocess.run( + [binary, "--version"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise ManifestError(f"cannot read bundled binary identity: {detail}") + return parse_binary_identity(result.stdout, target=target, release_tag=release_tag) + + +def manifest_from_archive(archive: Path) -> tuple[dict[str, Any], str]: + try: + with tarfile.open(archive, mode="r:gz") as bundle: + matches = [] + for member in bundle.getmembers(): + path = PurePosixPath(member.name) + if ( + member.isfile() + and len(path.parts) == 2 + and path.parts[1] == MANIFEST_NAME + ): + matches.append(member) + if len(matches) != 1: + raise ManifestError( + f"release archive must contain exactly one top-level {MANIFEST_NAME}" + ) + member = matches[0] + if member.size > MAX_MANIFEST_BYTES: + raise ManifestError("release manifest exceeds the 64 KiB size limit") + extracted = bundle.extractfile(member) + if extracted is None: + raise ManifestError(f"cannot read {member.name} from release archive") + return parse_manifest(extracted.read()), PurePosixPath(member.name).parts[0] + except ManifestError: + raise + except (OSError, tarfile.TarError) as error: + raise ManifestError(f"cannot read release archive: {error}") from error + + +def validate_archive(archive: Path) -> ReleaseIdentity: + value, bundle_root = manifest_from_archive(archive) + identity = validate_manifest(value) + expected_bundle_name = f"lg-buddy-{identity.version}-{identity.target}" + if bundle_root != expected_bundle_name: + raise ManifestError( + f"release manifest identity expects bundle root {expected_bundle_name}, found {bundle_root}" + ) + expected_archive_name = f"{expected_bundle_name}.tar.gz" + if archive.name != expected_archive_name: + raise ManifestError( + f"release manifest identity expects archive name {expected_archive_name}, found {archive.name}" + ) + return identity + + +def validate_expected(identity: ReleaseIdentity, args: argparse.Namespace) -> None: + for field in IDENTITY_FIELDS: + expected = getattr(args, f"expected_{field}", None) + if expected is not None and getattr(identity, field) != expected: + raise ManifestError( + f"release manifest {field} is {getattr(identity, field)}, expected {expected}" + ) + + +def validate_binary_matches(identity: ReleaseIdentity, binary: Path) -> None: + observed = binary_identity( + binary, + target=identity.target, + release_tag=identity.release_tag, + ) + for field in ("version", "channel", "commit"): + if getattr(observed, field) != getattr(identity, field): + raise ManifestError( + f"bundled binary {field} is {getattr(observed, field)}, " + f"manifest records {getattr(identity, field)}" + ) + + +def add_expected_arguments(parser: argparse.ArgumentParser) -> None: + for field in IDENTITY_FIELDS: + parser.add_argument(f"--expected-{field.replace('_', '-')}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + create = subparsers.add_parser("create") + create.add_argument("--output", type=Path, required=True) + create.add_argument("--release-tag", required=True) + create.add_argument("--target", required=True) + create.add_argument("--binary", type=Path, required=True) + + validate = subparsers.add_parser("validate") + source = validate.add_mutually_exclusive_group(required=True) + source.add_argument("--manifest", type=Path) + source.add_argument("--archive", type=Path) + validate.add_argument("--binary", type=Path) + add_expected_arguments(validate) + + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.command == "create": + identity = binary_identity( + args.binary, + target=args.target, + release_tag=args.release_tag, + ) + args.output.write_bytes(render_manifest(identity)) + print(f"Created {args.output}") + return 0 + + if args.archive is not None: + identity = validate_archive(args.archive) + else: + identity = validate_manifest(parse_manifest(args.manifest.read_bytes())) + validate_expected(identity, args) + if args.binary is not None: + validate_binary_matches(identity, args.binary) + except (ManifestError, OSError) as error: + raise SystemExit( + f"release bundle manifest validation failed: {error}" + ) from error + + print( + f"Validated {identity.release_tag} for {identity.target} at {identity.commit}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index 95b7f43..7aaf18e 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -3,21 +3,10 @@ set -euo pipefail usage() { - echo "Usage: $0 --archive [--work-dir ] [--skip-pip-install] [--expected-version --expected-channel --expected-commit ]" + echo "Usage: $0 --archive [--work-dir ] [--skip-pip-install] [--expected-tag --expected-version --expected-channel --expected-target --expected-commit ]" exit 1 } -assert_version_identity() { - local binary="$1" - local output="" - - output="$("$binary" --version)" - printf '%s\n' "$output" | grep -F -x -q "lg-buddy $EXPECTED_VERSION" - printf '%s\n' "$output" | grep -F -x -q "version: $EXPECTED_VERSION" - printf '%s\n' "$output" | grep -F -x -q "channel: $EXPECTED_CHANNEL" - printf '%s\n' "$output" | grep -F -x -q "commit: $EXPECTED_COMMIT" -} - assert_file() { local path="$1" @@ -174,11 +163,14 @@ validate_archive_paths() { done < <(tar -tzf "$archive") } +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" ARCHIVE="" WORK_DIR="" SKIP_PIP_INSTALL=0 +EXPECTED_TAG="" EXPECTED_VERSION="" EXPECTED_CHANNEL="" +EXPECTED_TARGET="" EXPECTED_COMMIT="" while [ "$#" -gt 0 ]; do @@ -195,6 +187,10 @@ while [ "$#" -gt 0 ]; do SKIP_PIP_INSTALL=1 shift ;; + --expected-tag) + EXPECTED_TAG="${2:-}" + shift 2 + ;; --expected-version) EXPECTED_VERSION="${2:-}" shift 2 @@ -203,6 +199,10 @@ while [ "$#" -gt 0 ]; do EXPECTED_CHANNEL="${2:-}" shift 2 ;; + --expected-target) + EXPECTED_TARGET="${2:-}" + shift 2 + ;; --expected-commit) EXPECTED_COMMIT="${2:-}" shift 2 @@ -215,7 +215,14 @@ done [ -n "$ARCHIVE" ] || usage [ -z "$EXPECTED_VERSION$EXPECTED_CHANNEL$EXPECTED_COMMIT" ] || { - [ -n "$EXPECTED_VERSION" ] && [ -n "$EXPECTED_CHANNEL" ] && [ -n "$EXPECTED_COMMIT" ] || usage + [ -n "$EXPECTED_VERSION" ] && \ + [ -n "$EXPECTED_CHANNEL" ] && \ + [ -n "$EXPECTED_COMMIT" ] || usage +} +[ -z "$EXPECTED_TAG$EXPECTED_TARGET" ] || { + [ -n "$EXPECTED_TAG" ] && \ + [ -n "$EXPECTED_TARGET" ] && \ + [ -n "$EXPECTED_VERSION" ] || usage } [ -f "$ARCHIVE" ] || { echo "Archive not found: $ARCHIVE" @@ -243,6 +250,25 @@ XDG_CONFIG_HOME="$HOME_DIR/.config" mkdir -p "$EXTRACT_DIR" "$INSTALL_ROOT" "$HOME_DIR" +MANIFEST_EXPECTATIONS=() +if [ -n "$EXPECTED_VERSION" ]; then + MANIFEST_EXPECTATIONS=( + --expected-version "$EXPECTED_VERSION" + --expected-channel "$EXPECTED_CHANNEL" + --expected-commit "$EXPECTED_COMMIT" + ) +fi +if [ -n "$EXPECTED_TAG" ]; then + MANIFEST_EXPECTATIONS+=( + --expected-release-tag "$EXPECTED_TAG" + --expected-target "$EXPECTED_TARGET" + ) +fi + +# Validate archive identity without extracting or executing archive content. +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --archive "$ARCHIVE" \ + "${MANIFEST_EXPECTATIONS[@]}" validate_archive_paths "$ARCHIVE" tar -C "$EXTRACT_DIR" -xzf "$ARCHIVE" BUNDLE_DIR="$(find "$EXTRACT_DIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" @@ -260,6 +286,7 @@ assert_executable "$BUNDLE_DIR/bin/LG_Buddy_Common" assert_file "$BUNDLE_DIR/LG_Buddy_Brightness.desktop" assert_file "$BUNDLE_DIR/README.md" assert_file "$BUNDLE_DIR/LICENSE" +assert_file "$BUNDLE_DIR/release-manifest.json" assert_file "$BUNDLE_DIR/docs/architecture-overview.md" assert_file "$BUNDLE_DIR/docs/runtime-event-handler-map.md" assert_file "$BUNDLE_DIR/docs/user-guide.md" @@ -271,6 +298,11 @@ assert_file "$BUNDLE_DIR/systemd/LG_Buddy_screen.service" assert_file "$BUNDLE_DIR/systemd/LG_Buddy_update_check.service" assert_file "$BUNDLE_DIR/systemd/LG_Buddy_update_check.timer" +# The identity query is the first command executed from the extracted bundle. +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --manifest "$BUNDLE_DIR/release-manifest.json" \ + --binary "$BUNDLE_DIR/lg-buddy" \ + "${MANIFEST_EXPECTATIONS[@]}" assert_cli_surface "$BUNDLE_DIR/lg-buddy" VERSION_OUTPUT="$("$BUNDLE_DIR/lg-buddy" --version)" @@ -278,9 +310,6 @@ printf '%s\n' "$VERSION_OUTPUT" | grep -q "^lg-buddy " printf '%s\n' "$VERSION_OUTPUT" | grep -q "^version: " printf '%s\n' "$VERSION_OUTPUT" | grep -q "^channel: " printf '%s\n' "$VERSION_OUTPUT" | grep -q "^commit: " -if [ -n "$EXPECTED_VERSION" ]; then - assert_version_identity "$BUNDLE_DIR/lg-buddy" -fi export HOME="$HOME_DIR" export XDG_CONFIG_HOME="$XDG_CONFIG_HOME" @@ -362,9 +391,10 @@ printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^lg-buddy " printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^version: " printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^channel: " printf '%s\n' "$INSTALLED_VERSION_OUTPUT" | grep -q "^commit: " -if [ -n "$EXPECTED_VERSION" ]; then - assert_version_identity "$INSTALLED_BINARY" -fi +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --manifest "$BUNDLE_DIR/release-manifest.json" \ + --binary "$INSTALLED_BINARY" \ + "${MANIFEST_EXPECTATIONS[@]}" # Existing profiles without the platform key remain on bscpylgtv. Materialize # that choice through settings, then use a controlled raw-config fixture to diff --git a/scripts/test_release_bundle_manifest.py b/scripts/test_release_bundle_manifest.py new file mode 100644 index 0000000..b475ae1 --- /dev/null +++ b/scripts/test_release_bundle_manifest.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import io +import tarfile +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path + +from release_bundle_manifest import ( + IDENTITY_FIELDS, + MANIFEST_NAME, + ManifestError, + ReleaseIdentity, + parse_binary_identity, + parse_manifest, + render_manifest, + validate_archive, + validate_binary_matches, + validate_expected, + validate_manifest, +) + + +COMMIT = "0123456789abcdef0123456789abcdef01234567" +IDENTITY = ReleaseIdentity( + release_tag="v1.4.0-beta.1", + version="1.4.0-beta.1", + channel="prerelease", + target="x86_64-unknown-linux-musl", + commit=COMMIT, +) + + +def manifest_value(**overrides: object) -> dict[str, object]: + value: dict[str, object] = { + "schema_version": 1, + "critical": list(IDENTITY_FIELDS), + "release_tag": IDENTITY.release_tag, + "version": IDENTITY.version, + "channel": IDENTITY.channel, + "target": IDENTITY.target, + "commit": IDENTITY.commit, + } + value.update(overrides) + return value + + +class ManifestTests(unittest.TestCase): + def test_render_is_deterministic_and_round_trips(self) -> None: + first = render_manifest(IDENTITY) + second = render_manifest(IDENTITY) + + self.assertEqual(first, second) + self.assertEqual(validate_manifest(parse_manifest(first)), IDENTITY) + self.assertTrue(first.endswith(b"\n")) + + def test_duplicate_json_field_is_rejected(self) -> None: + content = render_manifest(IDENTITY).replace( + b' "version": "1.4.0-beta.1",', + b' "version": "1.4.0-beta.1",\n "version": "1.4.0-beta.2",', + ) + + with self.assertRaisesRegex(ManifestError, "duplicate manifest field: version"): + parse_manifest(content) + + def test_missing_identity_field_is_rejected(self) -> None: + value = manifest_value() + del value["target"] + + with self.assertRaisesRegex(ManifestError, "missing or invalid.*target"): + validate_manifest(value) + + def test_unknown_critical_field_is_rejected(self) -> None: + value = manifest_value(critical=[*IDENTITY_FIELDS, "signature"]) + value["signature"] = "future" + + with self.assertRaisesRegex(ManifestError, "unknown critical.*signature"): + validate_manifest(value) + + def test_unknown_noncritical_field_is_ignored(self) -> None: + value = manifest_value(annotation="future") + + self.assertEqual(validate_manifest(value), IDENTITY) + + def test_schema_version_must_be_supported_integer(self) -> None: + for schema_version in (True, "1", 2): + with ( + self.subTest(schema_version=schema_version), + self.assertRaisesRegex(ManifestError, "unsupported.*schema_version"), + ): + validate_manifest(manifest_value(schema_version=schema_version)) + + def test_tag_must_match_version(self) -> None: + with self.assertRaisesRegex(ManifestError, "tag must be exactly"): + validate_manifest(manifest_value(release_tag="v1.4.0-beta.2")) + + def test_channel_must_match_semver_stage(self) -> None: + with self.assertRaisesRegex(ManifestError, "channel must be prerelease"): + validate_manifest(manifest_value(channel="stable")) + + stable = manifest_value( + release_tag="v1.4.0", version="1.4.0", channel="prerelease" + ) + with self.assertRaisesRegex(ManifestError, "channel must be stable"): + validate_manifest(stable) + + def test_version_must_be_canonical_release_semver(self) -> None: + for version in ("1.4", "v1.4.0", "1.4.0+local"): + value = manifest_value(release_tag=f"v{version}", version=version) + with self.subTest(version=version), self.assertRaises(ManifestError): + validate_manifest(value) + + def test_target_and_commit_have_canonical_formats(self) -> None: + with self.assertRaisesRegex(ManifestError, "invalid release manifest target"): + validate_manifest(manifest_value(target="../../host")) + with self.assertRaisesRegex(ManifestError, "full lowercase 40-character SHA"): + validate_manifest(manifest_value(commit="ABC123")) + + def test_external_identity_mismatches_are_rejected(self) -> None: + for field in IDENTITY_FIELDS: + expected = {f"expected_{name}": None for name in IDENTITY_FIELDS} + expected[f"expected_{field}"] = "different" + with ( + self.subTest(field=field), + self.assertRaisesRegex(ManifestError, f"release manifest {field}"), + ): + validate_expected(IDENTITY, Namespace(**expected)) + + def test_binary_identity_requires_exact_output(self) -> None: + output = ( + "lg-buddy 1.4.0-beta.1\n" + "version: 1.4.0-beta.1\n" + "channel: prerelease\n" + f"commit: {COMMIT}\n" + ) + + self.assertEqual( + parse_binary_identity( + output, + target=IDENTITY.target, + release_tag=IDENTITY.release_tag, + ), + IDENTITY, + ) + + with self.assertRaisesRegex(ManifestError, "exactly four"): + parse_binary_identity( + f"{output}extra\n", + target=IDENTITY.target, + release_tag=IDENTITY.release_tag, + ) + + +class ArchiveTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.path = Path(self.temporary_directory.name) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def archive( + self, manifest: bytes | None = None, *, duplicate: bool = False + ) -> Path: + bundle_name = f"lg-buddy-{IDENTITY.version}-{IDENTITY.target}" + archive = self.path / f"{bundle_name}.tar.gz" + with tarfile.open(archive, mode="w:gz") as bundle: + if manifest is not None: + for _ in range(2 if duplicate else 1): + info = tarfile.TarInfo(f"{bundle_name}/{MANIFEST_NAME}") + info.size = len(manifest) + bundle.addfile(info, io.BytesIO(manifest)) + return archive + + def test_archive_identity_round_trips(self) -> None: + self.assertEqual( + validate_archive(self.archive(render_manifest(IDENTITY))), IDENTITY + ) + + def test_missing_and_duplicate_archive_manifests_are_rejected(self) -> None: + with self.assertRaisesRegex(ManifestError, "exactly one"): + validate_archive(self.archive()) + with self.assertRaisesRegex(ManifestError, "exactly one"): + validate_archive(self.archive(render_manifest(IDENTITY), duplicate=True)) + + def test_archive_name_must_match_manifest_identity(self) -> None: + archive = self.archive(render_manifest(IDENTITY)) + renamed = archive.with_name("renamed.tar.gz") + archive.rename(renamed) + + with self.assertRaisesRegex(ManifestError, "expects archive name"): + validate_archive(renamed) + + def test_malformed_archive_manifest_is_rejected(self) -> None: + with self.assertRaisesRegex(ManifestError, "not valid JSON"): + validate_archive(self.archive(b"not json\n")) + + +class BinaryComparisonTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.binary = Path(self.temporary_directory.name) / "lg-buddy" + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def write_binary(self, *, version: str, channel: str, commit: str) -> None: + self.binary.write_text( + "#!/bin/sh\n" + "cat <<'EOF'\n" + f"lg-buddy {version}\n" + f"version: {version}\n" + f"channel: {channel}\n" + f"commit: {commit}\n" + "EOF\n", + encoding="utf-8", + ) + self.binary.chmod(0o755) + + def test_binary_identity_must_match_manifest(self) -> None: + self.write_binary( + version=IDENTITY.version, + channel=IDENTITY.channel, + commit=IDENTITY.commit, + ) + validate_binary_matches(IDENTITY, self.binary) + + mismatches = ( + ("1.4.0-beta.2", IDENTITY.channel, IDENTITY.commit, "version"), + (IDENTITY.version, "stable", IDENTITY.commit, "channel"), + (IDENTITY.version, IDENTITY.channel, "f" * 40, "commit"), + ) + for version, channel, commit, field in mismatches: + with self.subTest(field=field): + self.write_binary(version=version, channel=channel, commit=commit) + with self.assertRaises(ManifestError): + validate_binary_matches(IDENTITY, self.binary) + + +if __name__ == "__main__": + unittest.main() From f2921273d2d55480cc24127d4aed2a9a6ac74dbc Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:10:11 +0300 Subject: [PATCH 02/22] feat: add host upgrade compatibility preflight --- crates/lg-buddy/src/lib.rs | 1 + crates/lg-buddy/src/upgrade_preflight.rs | 1394 ++++++++++++++++++++++ docs/architecture-overview.md | 32 + docs/release-process.md | 14 + docs/testing-strategy.md | 14 + 5 files changed, 1455 insertions(+) create mode 100644 crates/lg-buddy/src/upgrade_preflight.rs diff --git a/crates/lg-buddy/src/lib.rs b/crates/lg-buddy/src/lib.rs index 996c30a..2e41e06 100644 --- a/crates/lg-buddy/src/lib.rs +++ b/crates/lg-buddy/src/lib.rs @@ -19,6 +19,7 @@ pub mod sources; pub mod state; pub mod tv; pub mod updates; +pub mod upgrade_preflight; pub mod version; pub mod web_os; pub mod wol; diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs new file mode 100644 index 0000000..817a898 --- /dev/null +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -0,0 +1,1394 @@ +use std::collections::BTreeSet; +use std::env; +use std::ffi::CString; +use std::fmt; +use std::fs; +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const SYSTEM_FILES: &[(&str, bool)] = &[ + ("/usr/bin/lg-buddy", true), + ("/etc/systemd/system/LG_Buddy.service", false), + ("/etc/systemd/system/LG_Buddy.service.d/config.conf", false), + ("/etc/systemd/system/LG_Buddy_lifecycle.service", false), + ( + "/etc/systemd/system/LG_Buddy_lifecycle.service.d/config.conf", + false, + ), + ("/etc/tmpfiles.d/lg_buddy.conf", false), + ( + "/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_lifecycle", + true, + ), + ("/usr/share/applications/LG_Buddy_Brightness.desktop", false), +]; + +const SYSTEM_MUTABLE_DIRECTORIES: &[&str] = &[ + "/usr/bin", + "/usr/bin/LG_Buddy_PIP", + "/usr/lib/lg-buddy", + "/etc/systemd/system", + "/etc/systemd/system/LG_Buddy.service.d", + "/etc/systemd/system/LG_Buddy_lifecycle.service.d", + "/etc/tmpfiles.d", + "/etc/NetworkManager/dispatcher.d/pre-down.d", + "/usr/share/applications", +]; + +const LEGACY_SYSTEM_PATHS: &[&str] = &[ + "/usr/bin/LG_Buddy_Startup", + "/usr/bin/LG_Buddy_Shutdown", + "/usr/bin/LG_Buddy_Screen_On", + "/usr/bin/LG_Buddy_Screen_Off", + "/usr/bin/LG_Buddy_Screen_Monitor", + "/usr/bin/LG_Buddy_sleep_pre", + "/usr/bin/LG_Buddy_Brightness", + "/usr/lib/lg-buddy/common.sh", + "/usr/lib/systemd/system-sleep/LG_Buddy_sleep_hook", + "/etc/systemd/system/LG_Buddy_wake.service", + "/etc/systemd/system/LG_Buddy_sleep.service", + "/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_sleep", +]; + +const USER_SYSTEMD_FILES: &[(&str, bool)] = &[ + ("LG_Buddy_screen.service", false), + ("LG_Buddy_screen.service.d/config.conf", false), + ("LG_Buddy_update_check.service", false), + ("LG_Buddy_update_check.service.d/config.conf", false), + ("LG_Buddy_update_check.timer", false), +]; + +const USER_MUTABLE_DIRECTORIES: &[&str] = &[ + ".config/systemd/user", + ".config/systemd/user/LG_Buddy_screen.service.d", + ".config/systemd/user/LG_Buddy_update_check.service.d", +]; + +const CANDIDATE_FILES: &[(&str, bool)] = &[ + ("release-manifest.json", false), + ("install.sh", true), + ("lg-buddy", true), + ("LG_Buddy_Brightness.desktop", false), + ("systemd/LG_Buddy.service", false), + ("systemd/LG_Buddy_lifecycle.service", false), + ("systemd/LG_Buddy_screen.service", false), + ("systemd/LG_Buddy_update_check.service", false), + ("systemd/LG_Buddy_update_check.timer", false), + ("systemd/lg_buddy.conf", false), +]; + +const MAX_CONFIG_TREE_ENTRIES: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + File, + Directory, + Symlink, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathFacts { + pub kind: PathKind, + pub owner_uid: u32, + pub mode: u32, + pub link_count: u64, + pub read_only_filesystem: bool, +} + +pub trait FilesystemFacts { + fn path_facts(&self, path: &Path) -> io::Result; + fn read_to_string(&self, path: &Path) -> io::Result; + fn read_directory(&self, path: &Path) -> io::Result>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct OsFilesystemFacts; + +impl FilesystemFacts for OsFilesystemFacts { + fn path_facts(&self, path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + let kind = if file_type.is_symlink() { + PathKind::Symlink + } else if file_type.is_file() { + PathKind::File + } else if file_type.is_dir() { + PathKind::Directory + } else { + PathKind::Other + }; + + Ok(PathFacts { + kind, + owner_uid: metadata.uid(), + mode: metadata.permissions().mode(), + link_count: metadata.nlink(), + read_only_filesystem: if matches!(kind, PathKind::File | PathKind::Directory) { + filesystem_is_read_only(path)? + } else { + false + }, + }) + } + + fn read_to_string(&self, path: &Path) -> io::Result { + fs::read_to_string(path) + } + + fn read_directory(&self, path: &Path) -> io::Result> { + let mut entries: Vec<_> = fs::read_dir(path)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + entries.sort(); + Ok(entries) + } +} + +fn filesystem_is_read_only(path: &Path) -> io::Result { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))?; + let mut stat = std::mem::MaybeUninit::::uninit(); + let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + let stat = unsafe { stat.assume_init() }; + Ok(stat.f_flag & libc::ST_RDONLY as libc::c_ulong != 0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilityFact { + Available, + Unavailable(String), +} + +impl CapabilityFact { + pub fn unavailable(reason: impl Into) -> Self { + Self::Unavailable(reason.into()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceManagerFacts { + pub system: CapabilityFact, + pub user: CapabilityFact, +} + +impl ServiceManagerFacts { + pub fn available() -> Self { + Self { + system: CapabilityFact::Available, + user: CapabilityFact::Available, + } + } + + pub fn observe() -> Self { + Self { + system: observe_systemd(false), + user: observe_systemd(true), + } + } +} + +fn observe_systemd(user: bool) -> CapabilityFact { + let mut command = Command::new("systemctl"); + if user { + command.arg("--user"); + } + let output = match command.arg("is-system-running").output() { + Ok(output) => output, + Err(err) => { + return CapabilityFact::unavailable(format!("could not run systemctl: {err}")); + } + }; + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if matches!(state.as_str(), "running" | "degraded") { + CapabilityFact::Available + } else if state.is_empty() { + CapabilityFact::unavailable(format!( + "systemctl did not report a usable manager state ({})", + String::from_utf8_lossy(&output.stderr).trim() + )) + } else { + CapabilityFact::unavailable(format!("systemd manager state is {state}")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledLayout { + pub system_root: PathBuf, + pub user_home: PathBuf, +} + +impl InstalledLayout { + pub fn new(system_root: impl Into, user_home: impl Into) -> Self { + Self { + system_root: system_root.into(), + user_home: user_home.into(), + } + } + + pub fn system_path(&self, path: &str) -> PathBuf { + debug_assert!(path.starts_with('/')); + if self.system_root == Path::new("/") { + PathBuf::from(path) + } else { + self.system_root.join(path.trim_start_matches('/')) + } + } + + pub fn installed_executable(&self) -> PathBuf { + self.system_path("/usr/bin/lg-buddy") + } + + pub fn config_pointer(&self) -> PathBuf { + self.system_path("/usr/lib/lg-buddy/config-path") + } + + fn user_systemd_path(&self, path: &str) -> PathBuf { + self.user_home.join(".config/systemd/user").join(path) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostPreflightFacts { + pub layout: InstalledLayout, + pub running_executable: PathBuf, + pub effective_uid: u32, + pub system_owner_uid: u32, + pub user_owner_uid: u32, + pub service_managers: ServiceManagerFacts, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompatibilityFailure { + pub check: &'static str, + pub path: Option, + pub detail: String, + pub remedy: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CompatibilityReport { + failures: Vec, +} + +impl CompatibilityReport { + pub fn compatible(&self) -> bool { + self.failures.is_empty() + } + + pub fn failures(&self) -> &[CompatibilityFailure] { + &self.failures + } + + pub fn render(&self) -> String { + if self.compatible() { + return "upgrade preflight: compatible\n".to_string(); + } + + let mut output = String::from("upgrade preflight: refused\n"); + for failure in &self.failures { + output.push_str("- "); + output.push_str(failure.check); + if let Some(path) = &failure.path { + output.push_str(" ("); + output.push_str(&path.display().to_string()); + output.push(')'); + } + output.push_str(": "); + output.push_str(&failure.detail); + output.push_str(" Remedy: "); + output.push_str(&failure.remedy); + output.push('\n'); + } + output + } + + fn refuse( + &mut self, + check: &'static str, + path: Option, + detail: impl Into, + remedy: impl Into, + ) { + self.failures.push(CompatibilityFailure { + check, + path, + detail: detail.into(), + remedy: remedy.into(), + }); + } + + fn extend(&mut self, other: Self) { + self.failures.extend(other.failures); + } +} + +impl fmt::Display for CompatibilityReport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.render()) + } +} + +pub fn current_host_preflight() -> CompatibilityReport { + let facts = match observe_current_process() { + Ok(facts) => facts, + Err(report) => return report, + }; + evaluate_initial_preflight(&OsFilesystemFacts, &facts) +} + +pub fn candidate_host_preflight(candidate_root: &Path) -> CompatibilityReport { + let facts = match observe_current_process() { + Ok(facts) => facts, + Err(report) => return report, + }; + evaluate_candidate_host_preflight(&OsFilesystemFacts, &facts, candidate_root) +} + +fn observe_current_process() -> Result { + let mut report = CompatibilityReport::default(); + let running_executable = match env::current_exe() { + Ok(path) => path, + Err(err) => { + report.refuse( + "running-executable", + None, + format!("could not resolve the running executable: {err}"), + "run the installed LG Buddy executable directly", + ); + return Err(report); + } + }; + let user_home = match env::var_os("HOME") { + Some(home) if !home.is_empty() => PathBuf::from(home), + _ => { + report.refuse( + "user-home", + None, + "HOME is not available", + "run the updater from the installed user's normal session", + ); + return Err(report); + } + }; + let effective_uid = unsafe { libc::geteuid() }; + Ok(HostPreflightFacts { + layout: InstalledLayout::new("/", user_home), + running_executable, + effective_uid, + system_owner_uid: 0, + user_owner_uid: effective_uid, + service_managers: ServiceManagerFacts::observe(), + }) +} + +pub fn evaluate_initial_preflight( + filesystem: &impl FilesystemFacts, + facts: &HostPreflightFacts, +) -> CompatibilityReport { + evaluate_installed_state( + filesystem, + facts, + &facts.layout.installed_executable(), + "running-executable", + "run the release-bundle installation at /usr/bin/lg-buddy, or use the host's native package manager", + ) +} + +fn evaluate_installed_state( + filesystem: &impl FilesystemFacts, + facts: &HostPreflightFacts, + expected_running_executable: &Path, + executable_check: &'static str, + executable_remedy: &'static str, +) -> CompatibilityReport { + let mut checker = Checker::new(filesystem); + + if facts.effective_uid == 0 { + checker.report.refuse( + "invoking-user", + None, + "the updater is running as root", + "run LG Buddy as the installed user; it will request sudo only for the mutation step", + ); + } + check_normalized_absolute( + &mut checker.report, + "system-root", + &facts.layout.system_root, + ); + check_normalized_absolute(&mut checker.report, "user-home", &facts.layout.user_home); + + if facts.running_executable != expected_running_executable { + checker.report.refuse( + executable_check, + Some(facts.running_executable.clone()), + format!( + "running executable is not the expected runtime at {}", + expected_running_executable.display() + ), + executable_remedy, + ); + } + + for (path, executable) in SYSTEM_FILES { + checker.check_file( + &facts.layout.system_path(path), + facts.system_owner_uid, + *executable, + true, + "installed-layout", + ); + } + for path in SYSTEM_MUTABLE_DIRECTORIES { + checker.check_directory( + &facts.layout.system_path(path), + facts.system_owner_uid, + true, + "mutable-installation", + ); + } + for (path, executable) in USER_SYSTEMD_FILES { + checker.check_file( + &facts.layout.user_systemd_path(path), + facts.user_owner_uid, + *executable, + true, + "user-integration", + ); + } + for path in USER_MUTABLE_DIRECTORIES { + checker.check_directory( + &facts.layout.user_home.join(path), + facts.user_owner_uid, + true, + "mutable-user-integration", + ); + } + + for path in LEGACY_SYSTEM_PATHS { + checker.check_absent(&facts.layout.system_path(path)); + } + + let config_path = + checker.read_config_pointer(&facts.layout.config_pointer(), facts.system_owner_uid); + if let Some(config_path) = config_path { + let config_marker = systemd_config_override_line(&config_path); + for path in [ + facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service.d/config.conf"), + facts + .layout + .system_path("/etc/systemd/system/LG_Buddy_lifecycle.service.d/config.conf"), + facts + .layout + .user_systemd_path("LG_Buddy_screen.service.d/config.conf"), + facts + .layout + .user_systemd_path("LG_Buddy_update_check.service.d/config.conf"), + ] { + checker.check_file_has_line(&path, &config_marker); + } + checker.check_config_tree(&config_path, facts.user_owner_uid); + } + + checker.check_capability( + "system-service-manager", + &facts.service_managers.system, + "make the system systemd manager available before upgrading", + ); + checker.check_capability( + "user-service-manager", + &facts.service_managers.user, + "run the upgrade from a user session with a reachable systemd user manager", + ); + + checker.report +} + +pub fn evaluate_candidate_preflight( + filesystem: &impl FilesystemFacts, + candidate_root: &Path, + user_owner_uid: u32, +) -> CompatibilityReport { + let mut checker = Checker::new(filesystem); + if !check_normalized_absolute(&mut checker.report, "candidate-root", candidate_root) { + return checker.report; + } + + checker.check_directory(candidate_root, user_owner_uid, true, "candidate-layout"); + checker.check_directory( + &candidate_root.join("systemd"), + user_owner_uid, + false, + "candidate-layout", + ); + for (path, executable) in CANDIDATE_FILES { + checker.check_file( + &candidate_root.join(path), + user_owner_uid, + *executable, + false, + "candidate-layout", + ); + } + checker.report +} + +pub fn evaluate_candidate_host_preflight( + filesystem: &impl FilesystemFacts, + facts: &HostPreflightFacts, + candidate_root: &Path, +) -> CompatibilityReport { + let expected_candidate_executable = candidate_root.join("lg-buddy"); + let mut report = evaluate_installed_state( + filesystem, + facts, + &expected_candidate_executable, + "candidate-executable", + "run the preflight with the verified candidate binary from this bundle", + ); + report.extend(evaluate_candidate_preflight( + filesystem, + candidate_root, + facts.user_owner_uid, + )); + report +} + +fn check_normalized_absolute( + report: &mut CompatibilityReport, + check: &'static str, + path: &Path, +) -> bool { + let has_dot_component = path + .as_os_str() + .as_bytes() + .split(|byte| *byte == b'/') + .any(|component| matches!(component, b"." | b"..")); + if !path.is_absolute() || has_dot_component { + report.refuse( + check, + Some(path.to_path_buf()), + "path is not normalized and absolute", + "use an absolute path without '.' or '..' components", + ); + false + } else { + true + } +} + +fn systemd_config_override_line(config_path: &Path) -> String { + let escaped = config_path + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("Environment=\"LG_BUDDY_CONFIG={escaped}\"") +} + +struct Checker<'a, F> { + filesystem: &'a F, + report: CompatibilityReport, + checked_ancestors: BTreeSet, +} + +impl<'a, F: FilesystemFacts> Checker<'a, F> { + fn new(filesystem: &'a F) -> Self { + Self { + filesystem, + report: CompatibilityReport::default(), + checked_ancestors: BTreeSet::new(), + } + } + + fn check_file( + &mut self, + path: &Path, + owner_uid: u32, + executable: bool, + must_be_mutable: bool, + check: &'static str, + ) { + self.check_ancestors(path); + let facts = match self.path_facts(path, check) { + Some(facts) => facts, + None => return, + }; + if facts.kind != PathKind::File { + self.report.refuse( + check, + Some(path.to_path_buf()), + format!("expected a regular file, found {:?}", facts.kind), + "restore this path from a current release-bundle installation", + ); + return; + } + self.check_owner(path, &facts, owner_uid, check); + if must_be_mutable && facts.read_only_filesystem { + self.report.refuse( + check, + Some(path.to_path_buf()), + "file is on a read-only filesystem", + "use the host's native package manager or make this installation file mutable", + ); + } + if must_be_mutable && facts.link_count != 1 { + self.report.refuse( + check, + Some(path.to_path_buf()), + format!( + "file has {} hard links, expected exactly one", + facts.link_count + ), + "replace the path with an independent regular file before upgrading", + ); + } + if must_be_mutable && facts.mode & 0o200 == 0 { + self.report.refuse( + check, + Some(path.to_path_buf()), + "file is not writable by its owner", + "restore owner-write permission before upgrading", + ); + } + if executable && facts.mode & 0o111 == 0 { + self.report.refuse( + check, + Some(path.to_path_buf()), + "file is not executable", + "restore the executable mode from a current release bundle", + ); + } + } + + fn check_directory( + &mut self, + path: &Path, + owner_uid: u32, + must_be_mutable: bool, + check: &'static str, + ) { + self.check_ancestors(path); + let facts = match self.path_facts(path, check) { + Some(facts) => facts, + None => return, + }; + if facts.kind != PathKind::Directory { + self.report.refuse( + check, + Some(path.to_path_buf()), + format!("expected a directory, found {:?}", facts.kind), + "restore this directory as part of a current release-bundle installation", + ); + return; + } + self.check_owner(path, &facts, owner_uid, check); + if must_be_mutable && facts.read_only_filesystem { + self.report.refuse( + check, + Some(path.to_path_buf()), + "directory is on a read-only filesystem", + "use the host's native package manager or make the installed release-bundle paths mutable", + ); + } + if must_be_mutable && facts.mode & 0o200 == 0 { + self.report.refuse( + check, + Some(path.to_path_buf()), + "directory is not writable by its owner", + "restore owner-write permission before upgrading", + ); + } + } + + fn check_file_has_line(&mut self, path: &Path, expected: &str) { + if self + .report + .failures + .iter() + .any(|failure| failure.path.as_deref() == Some(path)) + { + return; + } + match self.filesystem.read_to_string(path) { + Ok(contents) if contents.lines().any(|line| line.trim() == expected) => {} + Ok(_) => self.report.refuse( + "integration-config", + Some(path.to_path_buf()), + format!("integration does not reference {expected}"), + "restore the integration override for the discovered config path", + ), + Err(err) => self.report.refuse( + "integration-config", + Some(path.to_path_buf()), + format!("could not read the integration override: {err}"), + "restore a readable integration override", + ), + } + } + + fn check_absent(&mut self, path: &Path) { + match self.filesystem.path_facts(path) { + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => self.report.refuse( + "legacy-layout", + Some(path.to_path_buf()), + format!("could not determine whether a legacy path exists: {err}"), + "inspect and remove the legacy integration before upgrading", + ), + Ok(_) => self.report.refuse( + "legacy-layout", + Some(path.to_path_buf()), + "legacy installation state is present", + "reinstall the current release bundle cleanly; the updater does not migrate legacy layouts", + ), + } + } + + fn read_config_pointer(&mut self, path: &Path, owner_uid: u32) -> Option { + self.check_file(path, owner_uid, false, true, "config-discovery"); + if self + .report + .failures + .iter() + .any(|failure| failure.path.as_deref() == Some(path)) + { + return None; + } + let contents = match self.filesystem.read_to_string(path) { + Ok(contents) => contents, + Err(err) => { + self.report.refuse( + "config-discovery", + Some(path.to_path_buf()), + format!("could not read the installed config pointer: {err}"), + "restore the config pointer from a current release-bundle installation", + ); + return None; + } + }; + let lines: Vec<_> = contents + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if lines.len() != 1 { + self.report.refuse( + "config-discovery", + Some(path.to_path_buf()), + "config pointer must contain exactly one non-empty path", + "rewrite the pointer with the absolute path to config.env", + ); + return None; + } + let config_path = PathBuf::from(lines[0]); + if !check_normalized_absolute(&mut self.report, "config-discovery", &config_path) { + return None; + } + Some(config_path) + } + + fn check_config_tree(&mut self, config_path: &Path, owner_uid: u32) { + self.check_file(config_path, owner_uid, false, false, "config-state"); + let Some(config_directory) = config_path.parent() else { + self.report.refuse( + "config-state", + Some(config_path.to_path_buf()), + "config path has no parent directory", + "place config.env in a user-owned configuration directory", + ); + return; + }; + self.check_directory(config_directory, owner_uid, true, "mutable-config-state"); + if self + .report + .failures + .iter() + .any(|failure| failure.path.as_deref() == Some(config_directory)) + { + return; + } + + let mut pending = vec![config_directory.to_path_buf()]; + let mut seen = 0; + while let Some(directory) = pending.pop() { + let entries = match self.filesystem.read_directory(&directory) { + Ok(entries) => entries, + Err(err) => { + self.report.refuse( + "config-state", + Some(directory), + format!("could not inspect the config directory: {err}"), + "make the LG Buddy config directory readable by the installed user", + ); + return; + } + }; + for entry in entries { + seen += 1; + if seen > MAX_CONFIG_TREE_ENTRIES { + self.report.refuse( + "config-state", + Some(config_directory.to_path_buf()), + format!("config tree exceeds {MAX_CONFIG_TREE_ENTRIES} entries"), + "remove unrelated files from the LG Buddy config directory", + ); + return; + } + let facts = match self.path_facts(&entry, "config-state") { + Some(facts) => facts, + None => continue, + }; + self.check_owner(&entry, &facts, owner_uid, "config-state"); + match facts.kind { + PathKind::Directory => pending.push(entry), + PathKind::File => {} + PathKind::Symlink | PathKind::Other => self.report.refuse( + "config-state", + Some(entry), + format!("config tree contains an unsafe {:?} entry", facts.kind), + "replace the entry with a user-owned regular file or directory", + ), + } + } + } + } + + fn check_capability( + &mut self, + check: &'static str, + capability: &CapabilityFact, + remedy: &'static str, + ) { + if let CapabilityFact::Unavailable(reason) = capability { + self.report + .refuse(check, None, reason.clone(), remedy.to_string()); + } + } + + fn check_ancestors(&mut self, path: &Path) { + for ancestor in path.ancestors().skip(1) { + if !self.checked_ancestors.insert(ancestor.to_path_buf()) { + continue; + } + match self.filesystem.path_facts(ancestor) { + Ok(facts) if facts.kind == PathKind::Directory => {} + Ok(facts) => self.report.refuse( + "path-containment", + Some(ancestor.to_path_buf()), + format!("path ancestor is {:?}, not a real directory", facts.kind), + "replace symlinked or special ancestors with ordinary directories", + ), + Err(err) => self.report.refuse( + "path-containment", + Some(ancestor.to_path_buf()), + format!("could not inspect path ancestor: {err}"), + "restore the complete installation path", + ), + } + } + } + + fn path_facts(&mut self, path: &Path, check: &'static str) -> Option { + match self.filesystem.path_facts(path) { + Ok(facts) => Some(facts), + Err(err) => { + self.report.refuse( + check, + Some(path.to_path_buf()), + if err.kind() == io::ErrorKind::NotFound { + "required path is missing".to_string() + } else { + format!("could not inspect required path: {err}") + }, + "restore this path from a current release-bundle installation", + ); + None + } + } + } + + fn check_owner( + &mut self, + path: &Path, + facts: &PathFacts, + expected_uid: u32, + check: &'static str, + ) { + if facts.owner_uid != expected_uid { + self.report.refuse( + check, + Some(path.to_path_buf()), + format!( + "path is owned by uid {}, expected uid {expected_uid}", + facts.owner_uid + ), + "restore the expected ownership before upgrading", + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicU64, Ordering}; + + #[test] + fn supported_release_bundle_layout_passes_initial_and_candidate_preflights() { + let fixture = InstalledFixture::new("supported"); + + let initial = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); + let candidate = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + ); + + assert!(initial.compatible(), "{}", initial.render()); + assert!(candidate.compatible(), "{}", candidate.render()); + } + + #[test] + fn initial_preflight_refuses_a_symlinked_installed_runtime() { + let fixture = InstalledFixture::new("symlink-runtime"); + let runtime = fixture.facts.layout.installed_executable(); + fs::remove_file(&runtime).unwrap(); + symlink(fixture.candidate_root.join("lg-buddy"), &runtime).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "installed-layout", &runtime, "Symlink"); + } + + #[test] + fn initial_preflight_refuses_a_symlinked_installed_virtualenv() { + let fixture = InstalledFixture::new("symlink-virtualenv"); + let virtualenv = fixture.facts.layout.system_path("/usr/bin/LG_Buddy_PIP"); + let target = fixture.root.join("external-virtualenv"); + fs::remove_dir(&virtualenv).unwrap(); + fs::create_dir(&target).unwrap(); + symlink(&target, &virtualenv).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "mutable-installation", &virtualenv, "Symlink"); + } + + #[test] + fn initial_preflight_refuses_an_incomplete_integration() { + let fixture = InstalledFixture::new("missing-integration"); + let service = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy_lifecycle.service"); + fs::remove_file(&service).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "installed-layout", &service, "missing"); + } + + #[test] + fn initial_preflight_refuses_conflicting_ownership() { + let fixture = InstalledFixture::new("wrong-owner"); + let runtime = fixture.facts.layout.installed_executable(); + let filesystem = OverriddenFilesystem { + path: runtime.clone(), + owner_uid: Some(fixture.facts.system_owner_uid + 1), + read_only: None, + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure(&report, "installed-layout", &runtime, "owned by uid"); + } + + #[test] + fn initial_preflight_refuses_read_only_installation_paths() { + let fixture = InstalledFixture::new("read-only"); + let directory = fixture.facts.layout.system_path("/usr/bin"); + let filesystem = OverriddenFilesystem { + path: directory.clone(), + owner_uid: None, + read_only: Some(true), + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure( + &report, + "mutable-installation", + &directory, + "read-only filesystem", + ); + } + + #[test] + fn initial_preflight_refuses_a_read_only_installed_file() { + let fixture = InstalledFixture::new("read-only-file"); + let runtime = fixture.facts.layout.installed_executable(); + let filesystem = OverriddenFilesystem { + path: runtime.clone(), + owner_uid: None, + read_only: Some(true), + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure( + &report, + "installed-layout", + &runtime, + "read-only filesystem", + ); + } + + #[test] + fn initial_preflight_refuses_a_hard_linked_mutation_target() { + let fixture = InstalledFixture::new("hard-linked-service"); + let service = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service"); + fs::hard_link(&service, fixture.root.join("shared-service-file")).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "installed-layout", &service, "2 hard links"); + } + + #[test] + fn initial_preflight_refuses_a_non_writable_mutation_target() { + let fixture = InstalledFixture::new("non-writable-user-service"); + let service = fixture + .facts + .layout + .user_systemd_path("LG_Buddy_screen.service"); + let mut permissions = fs::metadata(&service).unwrap().permissions(); + permissions.set_mode(0o444); + fs::set_permissions(&service, permissions).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "user-integration", &service, "not writable"); + } + + #[test] + fn initial_preflight_refuses_unavailable_service_manager() { + let mut fixture = InstalledFixture::new("no-user-manager"); + fixture.facts.service_managers.user = + CapabilityFact::unavailable("user systemd manager is offline"); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + let failure = report + .failures() + .iter() + .find(|failure| failure.check == "user-service-manager") + .expect("user manager refusal"); + assert!(failure.detail.contains("offline")); + assert!(failure.remedy.contains("user session")); + } + + #[test] + fn initial_preflight_refuses_integration_pointing_at_another_config() { + let fixture = InstalledFixture::new("stale-config-override"); + let override_path = fixture + .facts + .layout + .user_systemd_path("LG_Buddy_screen.service.d/config.conf"); + fs::write( + &override_path, + "[Service]\nEnvironment=\"LG_BUDDY_CONFIG=/tmp/other/config.env\"\n", + ) + .unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure( + &report, + "integration-config", + &override_path, + "does not reference", + ); + } + + #[test] + fn initial_preflight_refuses_legacy_state_instead_of_migrating_it() { + let fixture = InstalledFixture::new("legacy-state"); + let legacy = fixture + .facts + .layout + .system_path("/usr/bin/LG_Buddy_Startup"); + write_file(&legacy, false); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "legacy-layout", &legacy, "legacy"); + } + + #[test] + fn initial_preflight_refuses_symlinks_in_config_state() { + let fixture = InstalledFixture::new("config-symlink"); + let link = fixture.config_directory.join("linked-token.json"); + symlink(fixture.config_directory.join("config.env"), &link).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "config-state", &link, "unsafe Symlink"); + } + + #[test] + fn candidate_preflight_refuses_missing_or_non_executable_inputs() { + let fixture = InstalledFixture::new("bad-candidate"); + let manifest = fixture.candidate_root.join("release-manifest.json"); + let installer = fixture.candidate_root.join("install.sh"); + fs::remove_file(&manifest).unwrap(); + set_executable(&installer, false); + + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure(&report, "candidate-layout", &manifest, "missing"); + assert_failure(&report, "candidate-layout", &installer, "not executable"); + } + + #[test] + fn candidate_preflight_refuses_a_missing_upgrade_asset() { + let fixture = InstalledFixture::new("missing-candidate-service"); + let service = fixture + .candidate_root + .join("systemd/LG_Buddy_screen.service"); + fs::remove_file(&service).unwrap(); + + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure(&report, "candidate-layout", &service, "missing"); + } + + #[test] + fn candidate_host_preflight_must_run_from_the_verified_bundle() { + let fixture = InstalledFixture::new("wrong-candidate-runtime"); + + let report = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &fixture.facts, + &fixture.candidate_root, + ); + + let failure = report + .failures() + .iter() + .find(|failure| failure.check == "candidate-executable") + .expect("candidate executable refusal"); + assert!(failure.detail.contains("candidate/lg-buddy")); + } + + #[test] + fn candidate_host_preflight_rechecks_installed_state() { + let fixture = InstalledFixture::new("candidate-rechecks-installed-state"); + let installed_service = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service"); + fs::remove_file(&installed_service).unwrap(); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); + + let report = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + ); + + assert_failure(&report, "installed-layout", &installed_service, "missing"); + } + + #[test] + fn candidate_preflight_refuses_unnormalized_relative_root() { + let report = + evaluate_candidate_preflight(&OsFilesystemFacts, Path::new("bundle/../next"), 1); + + let failure = report.failures().first().expect("candidate root refusal"); + assert_eq!(failure.check, "candidate-root"); + assert!(failure.detail.contains("normalized and absolute")); + } + + #[test] + fn initial_preflight_refuses_root_invocation() { + let mut fixture = InstalledFixture::new("root-invocation"); + fixture.facts.effective_uid = 0; + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert!(report + .failures() + .iter() + .any(|failure| failure.check == "invoking-user")); + } + + fn assert_failure(report: &CompatibilityReport, check: &str, path: &Path, detail: &str) { + let failure = report + .failures() + .iter() + .find(|failure| failure.check == check && failure.path.as_deref() == Some(path)) + .unwrap_or_else(|| panic!("missing {check} failure for {}:\n{report}", path.display())); + assert!( + failure.detail.contains(detail), + "expected detail {detail:?}, got {:?}", + failure.detail + ); + assert!(!failure.remedy.is_empty()); + } + + struct OverriddenFilesystem { + path: PathBuf, + owner_uid: Option, + read_only: Option, + } + + impl FilesystemFacts for OverriddenFilesystem { + fn path_facts(&self, path: &Path) -> io::Result { + let mut facts = OsFilesystemFacts.path_facts(path)?; + if path == self.path { + if let Some(owner_uid) = self.owner_uid { + facts.owner_uid = owner_uid; + } + if let Some(read_only) = self.read_only { + facts.read_only_filesystem = read_only; + } + } + Ok(facts) + } + + fn read_to_string(&self, path: &Path) -> io::Result { + OsFilesystemFacts.read_to_string(path) + } + + fn read_directory(&self, path: &Path) -> io::Result> { + OsFilesystemFacts.read_directory(path) + } + } + + struct InstalledFixture { + root: PathBuf, + facts: HostPreflightFacts, + candidate_root: PathBuf, + config_directory: PathBuf, + } + + impl InstalledFixture { + fn new(label: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let root = env::temp_dir().join(format!( + "lg-buddy-upgrade-preflight-{label}-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + let system_root = root.join("root"); + let user_home = root.join("home/user"); + let layout = InstalledLayout::new(&system_root, &user_home); + let config_directory = user_home.join(".config/lg-buddy"); + let config_path = config_directory.join("config.env"); + + for path in SYSTEM_MUTABLE_DIRECTORIES { + fs::create_dir_all(layout.system_path(path)).unwrap(); + } + for (path, executable) in SYSTEM_FILES { + write_file(&layout.system_path(path), *executable); + } + for path in USER_MUTABLE_DIRECTORIES { + fs::create_dir_all(user_home.join(path)).unwrap(); + } + for (path, executable) in USER_SYSTEMD_FILES { + write_file(&layout.user_systemd_path(path), *executable); + } + fs::create_dir_all(config_directory.join("tvs/primary")).unwrap(); + fs::write(&config_path, "updates_channel=stable\n").unwrap(); + fs::write( + config_directory.join("tvs/primary/access-token.json"), + "{}\n", + ) + .unwrap(); + write_file(&layout.config_pointer(), false); + fs::write( + layout.config_pointer(), + format!("{}\n", config_path.display()), + ) + .unwrap(); + let config_override = format!( + "[Service]\nEnvironment=\"LG_BUDDY_CONFIG={}\"\n", + config_path.display() + ); + for path in [ + layout.system_path("/etc/systemd/system/LG_Buddy.service.d/config.conf"), + layout.system_path("/etc/systemd/system/LG_Buddy_lifecycle.service.d/config.conf"), + layout.user_systemd_path("LG_Buddy_screen.service.d/config.conf"), + layout.user_systemd_path("LG_Buddy_update_check.service.d/config.conf"), + ] { + fs::write(path, &config_override).unwrap(); + } + + let candidate_root = root.join("candidate"); + fs::create_dir_all(&candidate_root).unwrap(); + for (path, executable) in CANDIDATE_FILES { + write_file(&candidate_root.join(path), *executable); + } + + let owner_uid = unsafe { libc::geteuid() }; + let facts = HostPreflightFacts { + running_executable: layout.installed_executable(), + layout, + effective_uid: if owner_uid == 0 { 1000 } else { owner_uid }, + system_owner_uid: owner_uid, + user_owner_uid: owner_uid, + service_managers: ServiceManagerFacts::available(), + }; + + Self { + root, + facts, + candidate_root, + config_directory, + } + } + } + + impl Drop for InstalledFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn write_file(path: &Path, executable: bool) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"fixture\n").unwrap(); + set_executable(path, executable); + } + + fn set_executable(path: &Path, executable: bool) { + let mut permissions = fs::metadata(path).unwrap().permissions(); + let mode = if executable { 0o755 } else { 0o644 }; + permissions.set_mode(mode); + fs::set_permissions(path, permissions).unwrap(); + } +} diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f6f6ae7..03d1076 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -263,6 +263,12 @@ The intended split is: - runtime directory resolution - system/session state separation - ownership marker management +- `upgrade_preflight.rs` + - observes whether the current release-bundle installation can be replaced + safely + - returns structured, actionable refusals without downloading or mutating + anything + - provides separate installed-runtime and verified-candidate entrypoints - `tv.rs` - TV transport abstraction - profile-bound `bscpylgtvcommand` adapter @@ -428,6 +434,32 @@ operation; mute toggle reads the current state before writing its inverse. This keeps CLI parsing separate from operational behavior. +## Host Upgrade Safety Boundary + +`upgrade_preflight.rs` checks observable host and installation state. It does +not infer upgrade support from the distribution name, a build flag, an install +receipt, or where the binary originally came from. + +The initial preflight expects the running binary to be the mutable +`/usr/bin/lg-buddy` installation. It checks the conventional release-bundle +filesystem topology, ordinary file and directory types, ownership, writable +mounts, config-pointer discovery, user and system integrations, systemd manager +availability, and the absence of legacy layouts that would require migration. +The installed Python virtualenv root is part of that topology. Symlinks, +multiply linked mutation targets, read-only files, and special files in owned +installation or config state are refused. + +After a bundle has been verified, its candidate binary can run the second +preflight. That pass rechecks the installed state, proves it is executing the +candidate from the supplied bundle root, and checks the candidate manifest, +installer, runtime, desktop entry, and systemd assets before any privileged +mutation. Interactive-only configuration assets are not upgrade requirements. + +These checks are a conservative, evolving safety boundary, not an exhaustive +host-support declaration or a promise that no later privileged operation can +fail. New observable checks can be added as real installations expose unsafe +conditions; callers only consume the structured compatibility result. + ## Core Control Flows ### `screen off` diff --git a/docs/release-process.md b/docs/release-process.md index 8210921..8ce621e 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -96,6 +96,20 @@ compares it with both the bundled and installed binary. Publishing validates the manifest directly from each archive without extracting or executing archive content. +## Upgrade compatibility preflight + +Release-bundle replacement is guarded by observed capability rather than a +distribution allowlist or provenance receipt. The initial runtime preflight +checks the installed mutable FHS topology, config discovery, ownership, path +types, integration state, and system/user service-manager availability before +an updater performs release or privilege-related effects. A verified +candidate's binary performs a second pass for its own installer requirements +before privileged mutation. + +This is intentionally a conservative and evolving refusal boundary. It does +not migrate legacy layouts, declare broad host support, or guarantee that a +later privileged operation cannot fail. + ## Nix source selection Nix configurations may select `main`, `prerelease`, or `dev` as the upstream diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 402acee..c5a4082 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -306,6 +306,20 @@ formats, archive layout, and binary version/channel/commit mismatches. The bundle smoke test then exercises the same validator against the generated and installed release binary. +The upgrade-preflight module uses injected process, service-manager, +filesystem, and ownership facts around a real temporary-root installation +fixture. Its focused suite covers a passing mutable FHS layout plus symlinked, +incomplete, wrongly owned, read-only, hard-linked, legacy, +malformed-candidate, and unavailable-service-manager refusals. Run it with: + +```bash +cargo test -p lg-buddy upgrade_preflight::tests --lib +``` + +The initial and candidate checks are deliberately non-mutating. Orchestration +tests for their consumers must separately prove that a refusal prevents release +client, confirmation, sudo, and installer effects. + ## Current Practical Gaps The most important remaining gaps are: From 3a9d001e71268d81ab6d9e95505f21fb285d1332 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:50:20 +0300 Subject: [PATCH 03/22] fix: harden upgrade preflight safety checks --- crates/lg-buddy/src/upgrade_preflight.rs | 405 +++++++++++++++++++++-- docs/architecture-overview.md | 3 +- docs/release-process.md | 8 +- docs/testing-strategy.md | 5 +- 4 files changed, 391 insertions(+), 30 deletions(-) diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs index 817a898..e91525a 100644 --- a/crates/lg-buddy/src/upgrade_preflight.rs +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -49,7 +49,9 @@ const LEGACY_SYSTEM_PATHS: &[&str] = &[ "/usr/lib/lg-buddy/common.sh", "/usr/lib/systemd/system-sleep/LG_Buddy_sleep_hook", "/etc/systemd/system/LG_Buddy_wake.service", + "/etc/systemd/system/LG_Buddy_wake.service.d", "/etc/systemd/system/LG_Buddy_sleep.service", + "/etc/systemd/system/LG_Buddy_sleep.service.d", "/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_sleep", ]; @@ -97,6 +99,7 @@ pub struct PathFacts { pub mode: u32, pub link_count: u64, pub read_only_filesystem: bool, + pub mount_point: bool, } pub trait FilesystemFacts { @@ -132,6 +135,11 @@ impl FilesystemFacts for OsFilesystemFacts { } else { false }, + mount_point: if matches!(kind, PathKind::File | PathKind::Directory) { + path_is_mount_point(path)? + } else { + false + }, }) } @@ -160,6 +168,39 @@ fn filesystem_is_read_only(path: &Path) -> io::Result { Ok(stat.f_flag & libc::ST_RDONLY as libc::c_ulong != 0) } +fn path_is_mount_point(path: &Path) -> io::Result { + let mountinfo = fs::read("/proc/self/mountinfo")?; + let path = path.as_os_str().as_bytes(); + Ok(mountinfo.split(|byte| *byte == b'\n').any(|line| { + line.split(|byte| *byte == b' ') + .nth(4) + .is_some_and(|field| decode_mountinfo_field(field) == path) + })) +} + +fn decode_mountinfo_field(field: &[u8]) -> Vec { + let mut decoded = Vec::with_capacity(field.len()); + let mut index = 0; + while index < field.len() { + if field[index] == b'\\' + && index + 3 < field.len() + && field[index + 1..=index + 3] + .iter() + .all(|byte| matches!(byte, b'0'..=b'7')) + { + let value = (field[index + 1] - b'0') * 64 + + (field[index + 2] - b'0') * 8 + + (field[index + 3] - b'0'); + decoded.push(value); + index += 4; + } else { + decoded.push(field[index]); + index += 1; + } + } + decoded +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum CapabilityFact { Available, @@ -443,6 +484,7 @@ fn evaluate_installed_state( facts.system_owner_uid, *executable, true, + Some(&facts.layout.system_root), "installed-layout", ); } @@ -451,6 +493,7 @@ fn evaluate_installed_state( &facts.layout.system_path(path), facts.system_owner_uid, true, + Some(&facts.layout.system_root), "mutable-installation", ); } @@ -460,6 +503,7 @@ fn evaluate_installed_state( facts.user_owner_uid, *executable, true, + None, "user-integration", ); } @@ -468,6 +512,7 @@ fn evaluate_installed_state( &facts.layout.user_home.join(path), facts.user_owner_uid, true, + None, "mutable-user-integration", ); } @@ -476,8 +521,11 @@ fn evaluate_installed_state( checker.check_absent(&facts.layout.system_path(path)); } - let config_path = - checker.read_config_pointer(&facts.layout.config_pointer(), facts.system_owner_uid); + let config_path = checker.read_config_pointer( + &facts.layout.config_pointer(), + facts.system_owner_uid, + &facts.layout.system_root, + ); if let Some(config_path) = config_path { let config_marker = systemd_config_override_line(&config_path); for path in [ @@ -494,7 +542,7 @@ fn evaluate_installed_state( .layout .user_systemd_path("LG_Buddy_update_check.service.d/config.conf"), ] { - checker.check_file_has_line(&path, &config_marker); + checker.check_integration_override(&path, &config_marker); } checker.check_config_tree(&config_path, facts.user_owner_uid); } @@ -523,11 +571,18 @@ pub fn evaluate_candidate_preflight( return checker.report; } - checker.check_directory(candidate_root, user_owner_uid, true, "candidate-layout"); + checker.check_directory( + candidate_root, + user_owner_uid, + true, + None, + "candidate-layout", + ); checker.check_directory( &candidate_root.join("systemd"), user_owner_uid, false, + None, "candidate-layout", ); for (path, executable) in CANDIDATE_FILES { @@ -536,9 +591,20 @@ pub fn evaluate_candidate_preflight( user_owner_uid, *executable, false, + None, "candidate-layout", ); } + checker.check_owner_permissions( + &candidate_root.join("lg-buddy"), + 0o100, + "candidate runtime is not executable by its owner", + ); + checker.check_owner_permissions( + &candidate_root.join("install.sh"), + 0o500, + "candidate installer is not readable and executable by its owner", + ); checker.report } @@ -598,7 +664,7 @@ fn systemd_config_override_line(config_path: &Path) -> String { struct Checker<'a, F> { filesystem: &'a F, report: CompatibilityReport, - checked_ancestors: BTreeSet, + checked_ancestors: BTreeSet<(PathBuf, Option)>, } impl<'a, F: FilesystemFacts> Checker<'a, F> { @@ -616,9 +682,10 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { owner_uid: u32, executable: bool, must_be_mutable: bool, + trusted_system_root: Option<&Path>, check: &'static str, ) { - self.check_ancestors(path); + self.check_ancestors(path, trusted_system_root, owner_uid); let facts = match self.path_facts(path, check) { Some(facts) => facts, None => return, @@ -633,6 +700,9 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { return; } self.check_owner(path, &facts, owner_uid, check); + if trusted_system_root.is_some() { + self.check_not_writable_by_others(path, &facts); + } if must_be_mutable && facts.read_only_filesystem { self.report.refuse( check, @@ -652,6 +722,14 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "replace the path with an independent regular file before upgrading", ); } + if must_be_mutable && facts.mount_point { + self.report.refuse( + check, + Some(path.to_path_buf()), + "file is a mount point", + "replace the mounted path with an ordinary installation file before upgrading", + ); + } if must_be_mutable && facts.mode & 0o200 == 0 { self.report.refuse( check, @@ -675,9 +753,10 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { path: &Path, owner_uid: u32, must_be_mutable: bool, + trusted_system_root: Option<&Path>, check: &'static str, ) { - self.check_ancestors(path); + self.check_ancestors(path, trusted_system_root, owner_uid); let facts = match self.path_facts(path, check) { Some(facts) => facts, None => return, @@ -692,6 +771,9 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { return; } self.check_owner(path, &facts, owner_uid, check); + if trusted_system_root.is_some() { + self.check_not_writable_by_others(path, &facts); + } if must_be_mutable && facts.read_only_filesystem { self.report.refuse( check, @@ -700,6 +782,14 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "use the host's native package manager or make the installed release-bundle paths mutable", ); } + if must_be_mutable && facts.mount_point { + self.report.refuse( + check, + Some(path.to_path_buf()), + "directory is a mount point", + "replace the mounted path with an ordinary installation directory before upgrading", + ); + } if must_be_mutable && facts.mode & 0o200 == 0 { self.report.refuse( check, @@ -710,7 +800,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } - fn check_file_has_line(&mut self, path: &Path, expected: &str) { + fn check_integration_override(&mut self, path: &Path, expected: &str) { if self .report .failures @@ -720,13 +810,22 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { return; } match self.filesystem.read_to_string(path) { - Ok(contents) if contents.lines().any(|line| line.trim() == expected) => {} - Ok(_) => self.report.refuse( - "integration-config", - Some(path.to_path_buf()), - format!("integration does not reference {expected}"), - "restore the integration override for the discovered config path", - ), + Ok(contents) => { + let directives: Vec<_> = contents + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#') && !line.starts_with(';')) + .filter(|line| line.contains("LG_BUDDY_CONFIG")) + .collect(); + if directives.len() != 1 || directives[0] != expected { + self.report.refuse( + "integration-config", + Some(path.to_path_buf()), + format!("integration does not reference exactly {expected}"), + "restore the sole integration override for the discovered config path", + ); + } + } Err(err) => self.report.refuse( "integration-config", Some(path.to_path_buf()), @@ -734,6 +833,30 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "restore a readable integration override", ), } + + let Some(directory) = path.parent() else { + return; + }; + match self.filesystem.read_directory(directory) { + Ok(entries) => { + for entry in entries { + if entry != path { + self.report.refuse( + "integration-config", + Some(entry), + "integration override directory contains an unexpected entry", + "remove unexpected drop-ins before upgrading", + ); + } + } + } + Err(err) => self.report.refuse( + "integration-config", + Some(directory.to_path_buf()), + format!("could not inspect the integration override directory: {err}"), + "make the integration override directory readable before upgrading", + ), + } } fn check_absent(&mut self, path: &Path) { @@ -754,8 +877,20 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } - fn read_config_pointer(&mut self, path: &Path, owner_uid: u32) -> Option { - self.check_file(path, owner_uid, false, true, "config-discovery"); + fn read_config_pointer( + &mut self, + path: &Path, + owner_uid: u32, + system_root: &Path, + ) -> Option { + self.check_file( + path, + owner_uid, + false, + true, + Some(system_root), + "config-discovery", + ); if self .report .failures @@ -797,7 +932,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } fn check_config_tree(&mut self, config_path: &Path, owner_uid: u32) { - self.check_file(config_path, owner_uid, false, false, "config-state"); + self.check_file(config_path, owner_uid, false, false, None, "config-state"); let Some(config_directory) = config_path.parent() else { self.report.refuse( "config-state", @@ -807,7 +942,13 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { ); return; }; - self.check_directory(config_directory, owner_uid, true, "mutable-config-state"); + self.check_directory( + config_directory, + owner_uid, + true, + None, + "mutable-config-state", + ); if self .report .failures @@ -874,13 +1015,58 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } - fn check_ancestors(&mut self, path: &Path) { + fn check_owner_permissions(&mut self, path: &Path, required: u32, detail: &'static str) { + if self + .report + .failures + .iter() + .any(|failure| failure.path.as_deref() == Some(path)) + { + return; + } + let Some(facts) = self.path_facts(path, "candidate-layout") else { + return; + }; + if facts.mode & required != required { + self.report.refuse( + "candidate-layout", + Some(path.to_path_buf()), + detail, + "restore the candidate file mode from a verified release bundle", + ); + } + } + + fn check_not_writable_by_others(&mut self, path: &Path, facts: &PathFacts) { + if facts.mode & 0o022 != 0 { + self.report.refuse( + "path-containment", + Some(path.to_path_buf()), + "system path is writable by its group or by other users", + "remove group and other write permission from the system installation path", + ); + } + } + + fn check_ancestors(&mut self, path: &Path, trusted_system_root: Option<&Path>, owner_uid: u32) { for ancestor in path.ancestors().skip(1) { - if !self.checked_ancestors.insert(ancestor.to_path_buf()) { + let trusted_owner = match trusted_system_root { + Some(root) if ancestor.starts_with(root) => Some(owner_uid), + _ => None, + }; + if !self + .checked_ancestors + .insert((ancestor.to_path_buf(), trusted_owner)) + { continue; } match self.filesystem.path_facts(ancestor) { - Ok(facts) if facts.kind == PathKind::Directory => {} + Ok(facts) if facts.kind == PathKind::Directory => { + if let Some(expected_uid) = trusted_owner { + self.check_owner(ancestor, &facts, expected_uid, "path-containment"); + self.check_not_writable_by_others(ancestor, &facts); + } + } Ok(facts) => self.report.refuse( "path-containment", Some(ancestor.to_path_buf()), @@ -960,6 +1146,17 @@ mod tests { assert!(candidate.compatible(), "{}", candidate.render()); } + #[test] + fn os_filesystem_identifies_mount_points() { + let root = OsFilesystemFacts.path_facts(Path::new("/")).unwrap(); + + assert!(root.mount_point); + assert_eq!( + decode_mountinfo_field(br"/tmp/lg\040buddy\134config"), + br"/tmp/lg buddy\config" + ); + } + #[test] fn initial_preflight_refuses_a_symlinked_installed_runtime() { let fixture = InstalledFixture::new("symlink-runtime"); @@ -1007,7 +1204,9 @@ mod tests { let filesystem = OverriddenFilesystem { path: runtime.clone(), owner_uid: Some(fixture.facts.system_owner_uid + 1), + mode: None, read_only: None, + mount_point: None, }; let report = evaluate_initial_preflight(&filesystem, &fixture.facts); @@ -1015,6 +1214,45 @@ mod tests { assert_failure(&report, "installed-layout", &runtime, "owned by uid"); } + #[test] + fn initial_preflight_refuses_an_untrusted_system_ancestor() { + let fixture = InstalledFixture::new("wrong-ancestor-owner"); + let ancestor = fixture.facts.layout.system_path("/etc/systemd"); + let filesystem = OverriddenFilesystem { + path: ancestor.clone(), + owner_uid: Some(fixture.facts.system_owner_uid + 1), + mode: None, + read_only: None, + mount_point: None, + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure(&report, "path-containment", &ancestor, "owned by uid"); + } + + #[test] + fn initial_preflight_refuses_a_system_path_writable_by_other_users() { + let fixture = InstalledFixture::new("world-writable-system-path"); + let directory = fixture.facts.layout.system_path("/etc/systemd/system"); + let filesystem = OverriddenFilesystem { + path: directory.clone(), + owner_uid: None, + mode: Some(0o777), + read_only: None, + mount_point: None, + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure( + &report, + "path-containment", + &directory, + "writable by its group or by other users", + ); + } + #[test] fn initial_preflight_refuses_read_only_installation_paths() { let fixture = InstalledFixture::new("read-only"); @@ -1022,7 +1260,9 @@ mod tests { let filesystem = OverriddenFilesystem { path: directory.clone(), owner_uid: None, + mode: None, read_only: Some(true), + mount_point: None, }; let report = evaluate_initial_preflight(&filesystem, &fixture.facts); @@ -1042,7 +1282,9 @@ mod tests { let filesystem = OverriddenFilesystem { path: runtime.clone(), owner_uid: None, + mode: None, read_only: Some(true), + mount_point: None, }; let report = evaluate_initial_preflight(&filesystem, &fixture.facts); @@ -1055,6 +1297,26 @@ mod tests { ); } + #[test] + fn initial_preflight_refuses_a_mounted_mutation_target() { + let fixture = InstalledFixture::new("mounted-service"); + let service = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service"); + let filesystem = OverriddenFilesystem { + path: service.clone(), + owner_uid: None, + mode: None, + read_only: None, + mount_point: Some(true), + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure(&report, "installed-layout", &service, "mount point"); + } + #[test] fn initial_preflight_refuses_a_hard_linked_mutation_target() { let fixture = InstalledFixture::new("hard-linked-service"); @@ -1125,6 +1387,41 @@ mod tests { ); } + #[test] + fn initial_preflight_refuses_conflicting_config_assignments() { + let fixture = InstalledFixture::new("duplicate-config-override"); + let override_path = fixture + .facts + .layout + .user_systemd_path("LG_Buddy_screen.service.d/config.conf"); + let mut contents = fs::read_to_string(&override_path).unwrap(); + contents.push_str("Environment=\"LG_BUDDY_CONFIG=/tmp/other/config.env\"\n"); + fs::write(&override_path, contents).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure( + &report, + "integration-config", + &override_path, + "does not reference exactly", + ); + } + + #[test] + fn initial_preflight_refuses_an_unexpected_systemd_drop_in() { + let fixture = InstalledFixture::new("unexpected-systemd-drop-in"); + let drop_in = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service.d/99-local.conf"); + write_file(&drop_in, false); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "integration-config", &drop_in, "unexpected entry"); + } + #[test] fn initial_preflight_refuses_legacy_state_instead_of_migrating_it() { let fixture = InstalledFixture::new("legacy-state"); @@ -1139,6 +1436,23 @@ mod tests { assert_failure(&report, "legacy-layout", &legacy, "legacy"); } + #[test] + fn initial_preflight_refuses_a_legacy_override_directory() { + let fixture = InstalledFixture::new("legacy-override-directory"); + let legacy = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy_wake.service.d"); + let external = fixture.root.join("external-legacy-override"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("config.conf"), "external\n").unwrap(); + symlink(&external, &legacy).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure(&report, "legacy-layout", &legacy, "legacy"); + } + #[test] fn initial_preflight_refuses_symlinks_in_config_state() { let fixture = InstalledFixture::new("config-symlink"); @@ -1168,6 +1482,28 @@ mod tests { assert_failure(&report, "candidate-layout", &installer, "not executable"); } + #[test] + fn candidate_preflight_requires_owner_usable_executable_modes() { + for (label, mode) in [("unreadable-installer", 0o100), ("other-executable", 0o401)] { + let fixture = InstalledFixture::new(label); + let installer = fixture.candidate_root.join("install.sh"); + set_mode(&installer, mode); + + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure( + &report, + "candidate-layout", + &installer, + "not readable and executable by its owner", + ); + } + } + #[test] fn candidate_preflight_refuses_a_missing_upgrade_asset() { let fixture = InstalledFixture::new("missing-candidate-service"); @@ -1263,7 +1599,9 @@ mod tests { struct OverriddenFilesystem { path: PathBuf, owner_uid: Option, + mode: Option, read_only: Option, + mount_point: Option, } impl FilesystemFacts for OverriddenFilesystem { @@ -1273,9 +1611,15 @@ mod tests { if let Some(owner_uid) = self.owner_uid { facts.owner_uid = owner_uid; } + if let Some(mode) = self.mode { + facts.mode = mode; + } if let Some(read_only) = self.read_only { facts.read_only_filesystem = read_only; } + if let Some(mount_point) = self.mount_point { + facts.mount_point = mount_point; + } } Ok(facts) } @@ -1316,6 +1660,7 @@ mod tests { for (path, executable) in SYSTEM_FILES { write_file(&layout.system_path(path), *executable); } + set_directory_tree_mode(&system_root, 0o755); for path in USER_MUTABLE_DIRECTORIES { fs::create_dir_all(user_home.join(path)).unwrap(); } @@ -1385,9 +1730,23 @@ mod tests { set_executable(path, executable); } + fn set_directory_tree_mode(path: &Path, mode: u32) { + for entry in fs::read_dir(path).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + set_directory_tree_mode(&entry.path(), mode); + } + } + set_mode(path, mode); + } + fn set_executable(path: &Path, executable: bool) { - let mut permissions = fs::metadata(path).unwrap().permissions(); let mode = if executable { 0o755 } else { 0o644 }; + set_mode(path, mode); + } + + fn set_mode(path: &Path, mode: u32) { + let mut permissions = fs::metadata(path).unwrap().permissions(); permissions.set_mode(mode); fs::set_permissions(path, permissions).unwrap(); } diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 03d1076..bf6e3fa 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -446,7 +446,8 @@ filesystem topology, ordinary file and directory types, ownership, writable mounts, config-pointer discovery, user and system integrations, systemd manager availability, and the absence of legacy layouts that would require migration. The installed Python virtualenv root is part of that topology. Symlinks, -multiply linked mutation targets, read-only files, and special files in owned +mounted or multiply linked mutation targets, untrusted writable system paths, +unexpected systemd drop-ins, read-only files, and special files in owned installation or config state are refused. After a bundle has been verified, its candidate binary can run the second diff --git a/docs/release-process.md b/docs/release-process.md index 8ce621e..cd4c894 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -101,10 +101,10 @@ content. Release-bundle replacement is guarded by observed capability rather than a distribution allowlist or provenance receipt. The initial runtime preflight checks the installed mutable FHS topology, config discovery, ownership, path -types, integration state, and system/user service-manager availability before -an updater performs release or privilege-related effects. A verified -candidate's binary performs a second pass for its own installer requirements -before privileged mutation. +types and mount boundaries, trusted system containment, integration override +state, and system/user service-manager availability before an updater performs +release or privilege-related effects. A verified candidate's binary performs a +second pass for its own installer requirements before privileged mutation. This is intentionally a conservative and evolving refusal boundary. It does not migrate legacy layouts, declare broad host support, or guarantee that a diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index c5a4082..ae6069e 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -309,8 +309,9 @@ installed release binary. The upgrade-preflight module uses injected process, service-manager, filesystem, and ownership facts around a real temporary-root installation fixture. Its focused suite covers a passing mutable FHS layout plus symlinked, -incomplete, wrongly owned, read-only, hard-linked, legacy, -malformed-candidate, and unavailable-service-manager refusals. Run it with: +mounted, incompletely or wrongly owned, untrusted-writable, read-only, +hard-linked, legacy, conflicting-drop-in, malformed-candidate, and +unavailable-service-manager refusals. Run it with: ```bash cargo test -p lg-buddy upgrade_preflight::tests --lib From 01a732b9e9378e270426ac47740278b8020d6f0e Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:12:31 +0300 Subject: [PATCH 04/22] fix: enforce upgrade preflight safety invariants --- .github/workflows/ci.yml | 4 +- crates/lg-buddy/src/upgrade_preflight.rs | 975 ++++++++++++++++------- docs/architecture-overview.md | 21 +- docs/release-process.md | 3 + docs/testing-strategy.md | 5 +- scripts/build-release-bundle.sh | 1 + scripts/test-release-bundle.sh | 13 + 7 files changed, 745 insertions(+), 277 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7105a16..1285c97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,9 @@ jobs: run: cargo build --release -p lg-buddy --target x86_64-unknown-linux-musl - name: Create release bundle - run: ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version 0.0.0-ci.smoke --output-dir dist + run: | + umask 0002 + ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version 0.0.0-ci.smoke --output-dir dist - name: Smoke test release bundle run: | diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs index e91525a..8f03d7a 100644 --- a/crates/lg-buddy/src/upgrade_preflight.rs +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -1,41 +1,113 @@ use std::collections::BTreeSet; use std::env; -use std::ffi::CString; +use std::ffi::{CString, OsString}; use std::fmt; use std::fs; use std::io; -use std::os::unix::ffi::OsStrExt; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::process::Command; -const SYSTEM_FILES: &[(&str, bool)] = &[ - ("/usr/bin/lg-buddy", true), - ("/etc/systemd/system/LG_Buddy.service", false), - ("/etc/systemd/system/LG_Buddy.service.d/config.conf", false), - ("/etc/systemd/system/LG_Buddy_lifecycle.service", false), - ( +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InstallerPathPolicy { + ReplaceFile, + ReplaceExecutable, + MutateDirectory, + RecursiveClear, + ExactDropInDirectory { expected_entry: &'static str }, + ReadableInput, + ExecutableInput, + InputDirectory, +} + +impl InstallerPathPolicy { + fn expects_file(self) -> bool { + matches!( + self, + Self::ReplaceFile + | Self::ReplaceExecutable + | Self::ReadableInput + | Self::ExecutableInput + ) + } + + fn expects_directory(self) -> bool { + matches!( + self, + Self::MutateDirectory + | Self::RecursiveClear + | Self::ExactDropInDirectory { .. } + | Self::InputDirectory + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InstallerPathRequirement { + path: &'static str, + policy: InstallerPathPolicy, +} + +const fn requirement(path: &'static str, policy: InstallerPathPolicy) -> InstallerPathRequirement { + InstallerPathRequirement { path, policy } +} + +const SYSTEM_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[ + requirement("/usr/bin/lg-buddy", InstallerPathPolicy::ReplaceExecutable), + requirement( + "/etc/systemd/system/LG_Buddy.service", + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "/etc/systemd/system/LG_Buddy.service.d/config.conf", + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "/etc/systemd/system/LG_Buddy_lifecycle.service", + InstallerPathPolicy::ReplaceFile, + ), + requirement( "/etc/systemd/system/LG_Buddy_lifecycle.service.d/config.conf", - false, + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "/etc/tmpfiles.d/lg_buddy.conf", + InstallerPathPolicy::ReplaceFile, ), - ("/etc/tmpfiles.d/lg_buddy.conf", false), - ( + requirement( "/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_lifecycle", - true, + InstallerPathPolicy::ReplaceExecutable, + ), + requirement( + "/usr/share/applications/LG_Buddy_Brightness.desktop", + InstallerPathPolicy::ReplaceFile, + ), + requirement("/usr/bin", InstallerPathPolicy::MutateDirectory), + requirement("/usr/bin/LG_Buddy_PIP", InstallerPathPolicy::RecursiveClear), + requirement("/usr/lib/lg-buddy", InstallerPathPolicy::MutateDirectory), + requirement("/etc/systemd/system", InstallerPathPolicy::MutateDirectory), + requirement( + "/etc/systemd/system/LG_Buddy.service.d", + InstallerPathPolicy::ExactDropInDirectory { + expected_entry: "config.conf", + }, + ), + requirement( + "/etc/systemd/system/LG_Buddy_lifecycle.service.d", + InstallerPathPolicy::ExactDropInDirectory { + expected_entry: "config.conf", + }, + ), + requirement("/etc/tmpfiles.d", InstallerPathPolicy::MutateDirectory), + requirement( + "/etc/NetworkManager/dispatcher.d/pre-down.d", + InstallerPathPolicy::MutateDirectory, + ), + requirement( + "/usr/share/applications", + InstallerPathPolicy::MutateDirectory, ), - ("/usr/share/applications/LG_Buddy_Brightness.desktop", false), -]; - -const SYSTEM_MUTABLE_DIRECTORIES: &[&str] = &[ - "/usr/bin", - "/usr/bin/LG_Buddy_PIP", - "/usr/lib/lg-buddy", - "/etc/systemd/system", - "/etc/systemd/system/LG_Buddy.service.d", - "/etc/systemd/system/LG_Buddy_lifecycle.service.d", - "/etc/tmpfiles.d", - "/etc/NetworkManager/dispatcher.d/pre-down.d", - "/usr/share/applications", ]; const LEGACY_SYSTEM_PATHS: &[&str] = &[ @@ -55,31 +127,72 @@ const LEGACY_SYSTEM_PATHS: &[&str] = &[ "/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_sleep", ]; -const USER_SYSTEMD_FILES: &[(&str, bool)] = &[ - ("LG_Buddy_screen.service", false), - ("LG_Buddy_screen.service.d/config.conf", false), - ("LG_Buddy_update_check.service", false), - ("LG_Buddy_update_check.service.d/config.conf", false), - ("LG_Buddy_update_check.timer", false), -]; - -const USER_MUTABLE_DIRECTORIES: &[&str] = &[ - ".config/systemd/user", - ".config/systemd/user/LG_Buddy_screen.service.d", - ".config/systemd/user/LG_Buddy_update_check.service.d", +const USER_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[ + requirement("LG_Buddy_screen.service", InstallerPathPolicy::ReplaceFile), + requirement( + "LG_Buddy_screen.service.d/config.conf", + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "LG_Buddy_update_check.service", + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "LG_Buddy_update_check.service.d/config.conf", + InstallerPathPolicy::ReplaceFile, + ), + requirement( + "LG_Buddy_update_check.timer", + InstallerPathPolicy::ReplaceFile, + ), + requirement("", InstallerPathPolicy::MutateDirectory), + requirement( + "LG_Buddy_screen.service.d", + InstallerPathPolicy::ExactDropInDirectory { + expected_entry: "config.conf", + }, + ), + requirement( + "LG_Buddy_update_check.service.d", + InstallerPathPolicy::ExactDropInDirectory { + expected_entry: "config.conf", + }, + ), ]; -const CANDIDATE_FILES: &[(&str, bool)] = &[ - ("release-manifest.json", false), - ("install.sh", true), - ("lg-buddy", true), - ("LG_Buddy_Brightness.desktop", false), - ("systemd/LG_Buddy.service", false), - ("systemd/LG_Buddy_lifecycle.service", false), - ("systemd/LG_Buddy_screen.service", false), - ("systemd/LG_Buddy_update_check.service", false), - ("systemd/LG_Buddy_update_check.timer", false), - ("systemd/lg_buddy.conf", false), +// These are the inputs consumed by the non-interactive `install.sh --upgrade` +// contract. Configuration and pairing scripts are deliberately not upgrade inputs. +const CANDIDATE_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[ + requirement("", InstallerPathPolicy::InputDirectory), + requirement("systemd", InstallerPathPolicy::InputDirectory), + requirement("release-manifest.json", InstallerPathPolicy::ReadableInput), + requirement("install.sh", InstallerPathPolicy::ExecutableInput), + requirement("lg-buddy", InstallerPathPolicy::ExecutableInput), + requirement( + "LG_Buddy_Brightness.desktop", + InstallerPathPolicy::ReadableInput, + ), + requirement( + "systemd/LG_Buddy.service", + InstallerPathPolicy::ReadableInput, + ), + requirement( + "systemd/LG_Buddy_lifecycle.service", + InstallerPathPolicy::ReadableInput, + ), + requirement( + "systemd/LG_Buddy_screen.service", + InstallerPathPolicy::ReadableInput, + ), + requirement( + "systemd/LG_Buddy_update_check.service", + InstallerPathPolicy::ReadableInput, + ), + requirement( + "systemd/LG_Buddy_update_check.timer", + InstallerPathPolicy::ReadableInput, + ), + requirement("systemd/lg_buddy.conf", InstallerPathPolicy::ReadableInput), ]; const MAX_CONFIG_TREE_ENTRIES: usize = 256; @@ -106,6 +219,7 @@ pub trait FilesystemFacts { fn path_facts(&self, path: &Path) -> io::Result; fn read_to_string(&self, path: &Path) -> io::Result; fn read_directory(&self, path: &Path) -> io::Result>; + fn mount_points(&self) -> io::Result>; } #[derive(Debug, Clone, Copy, Default)] @@ -136,7 +250,7 @@ impl FilesystemFacts for OsFilesystemFacts { false }, mount_point: if matches!(kind, PathKind::File | PathKind::Directory) { - path_is_mount_point(path)? + mounted_paths()?.iter().any(|mounted| mounted == path) } else { false }, @@ -154,6 +268,10 @@ impl FilesystemFacts for OsFilesystemFacts { entries.sort(); Ok(entries) } + + fn mount_points(&self) -> io::Result> { + mounted_paths() + } } fn filesystem_is_read_only(path: &Path) -> io::Result { @@ -168,14 +286,13 @@ fn filesystem_is_read_only(path: &Path) -> io::Result { Ok(stat.f_flag & libc::ST_RDONLY as libc::c_ulong != 0) } -fn path_is_mount_point(path: &Path) -> io::Result { +fn mounted_paths() -> io::Result> { let mountinfo = fs::read("/proc/self/mountinfo")?; - let path = path.as_os_str().as_bytes(); - Ok(mountinfo.split(|byte| *byte == b'\n').any(|line| { - line.split(|byte| *byte == b' ') - .nth(4) - .is_some_and(|field| decode_mountinfo_field(field) == path) - })) + Ok(mountinfo + .split(|byte| *byte == b'\n') + .filter_map(|line| line.split(|byte| *byte == b' ').nth(4)) + .map(|field| PathBuf::from(OsString::from_vec(decode_mountinfo_field(field)))) + .collect()) } fn decode_mountinfo_field(field: &[u8]) -> Vec { @@ -450,6 +567,8 @@ fn evaluate_installed_state( executable_remedy: &'static str, ) -> CompatibilityReport { let mut checker = Checker::new(filesystem); + let system_trust = TrustedRoot::strict(&facts.layout.system_root, facts.system_owner_uid); + let user_trust = TrustedRoot::owned(&facts.layout.user_home, facts.user_owner_uid); if facts.effective_uid == 0 { checker.report.refuse( @@ -478,42 +597,34 @@ fn evaluate_installed_state( ); } - for (path, executable) in SYSTEM_FILES { - checker.check_file( - &facts.layout.system_path(path), - facts.system_owner_uid, - *executable, - true, - Some(&facts.layout.system_root), - "installed-layout", - ); - } - for path in SYSTEM_MUTABLE_DIRECTORIES { - checker.check_directory( - &facts.layout.system_path(path), + for requirement in SYSTEM_PATH_REQUIREMENTS { + let check = match requirement.policy { + InstallerPathPolicy::MutateDirectory | InstallerPathPolicy::RecursiveClear => { + "mutable-installation" + } + InstallerPathPolicy::ExactDropInDirectory { .. } => "integration-config", + _ => "installed-layout", + }; + checker.check_requirement( + &facts.layout.system_path(requirement.path), facts.system_owner_uid, - true, - Some(&facts.layout.system_root), - "mutable-installation", - ); - } - for (path, executable) in USER_SYSTEMD_FILES { - checker.check_file( - &facts.layout.user_systemd_path(path), - facts.user_owner_uid, - *executable, - true, - None, - "user-integration", + Some(system_trust), + requirement.policy, + check, ); } - for path in USER_MUTABLE_DIRECTORIES { - checker.check_directory( - &facts.layout.user_home.join(path), + for requirement in USER_PATH_REQUIREMENTS { + let check = match requirement.policy { + InstallerPathPolicy::MutateDirectory => "mutable-user-integration", + InstallerPathPolicy::ExactDropInDirectory { .. } => "integration-config", + _ => "user-integration", + }; + checker.check_requirement( + &facts.layout.user_systemd_path(requirement.path), facts.user_owner_uid, - true, - None, - "mutable-user-integration", + Some(user_trust), + requirement.policy, + check, ); } @@ -571,40 +682,16 @@ pub fn evaluate_candidate_preflight( return checker.report; } - checker.check_directory( - candidate_root, - user_owner_uid, - true, - None, - "candidate-layout", - ); - checker.check_directory( - &candidate_root.join("systemd"), - user_owner_uid, - false, - None, - "candidate-layout", - ); - for (path, executable) in CANDIDATE_FILES { - checker.check_file( - &candidate_root.join(path), + let candidate_trust = TrustedRoot::strict(candidate_root, user_owner_uid); + for requirement in CANDIDATE_PATH_REQUIREMENTS { + checker.check_requirement( + &candidate_root.join(requirement.path), user_owner_uid, - *executable, - false, - None, + Some(candidate_trust), + requirement.policy, "candidate-layout", ); } - checker.check_owner_permissions( - &candidate_root.join("lg-buddy"), - 0o100, - "candidate runtime is not executable by its owner", - ); - checker.check_owner_permissions( - &candidate_root.join("install.sh"), - 0o500, - "candidate installer is not readable and executable by its owner", - ); checker.report } @@ -661,10 +748,35 @@ fn systemd_config_override_line(config_path: &Path) -> String { format!("Environment=\"LG_BUDDY_CONFIG={escaped}\"") } +#[derive(Debug, Clone, Copy)] +struct TrustedRoot<'a> { + path: &'a Path, + owner_uid: u32, + reject_other_writes: bool, +} + +impl<'a> TrustedRoot<'a> { + fn strict(path: &'a Path, owner_uid: u32) -> Self { + Self { + path, + owner_uid, + reject_other_writes: true, + } + } + + fn owned(path: &'a Path, owner_uid: u32) -> Self { + Self { + path, + owner_uid, + reject_other_writes: false, + } + } +} + struct Checker<'a, F> { filesystem: &'a F, report: CompatibilityReport, - checked_ancestors: BTreeSet<(PathBuf, Option)>, + checked_ancestors: BTreeSet<(PathBuf, Option<(u32, bool)>)>, } impl<'a, F: FilesystemFacts> Checker<'a, F> { @@ -676,21 +788,20 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } - fn check_file( + fn check_requirement( &mut self, path: &Path, owner_uid: u32, - executable: bool, - must_be_mutable: bool, - trusted_system_root: Option<&Path>, + trusted_root: Option>, + policy: InstallerPathPolicy, check: &'static str, ) { - self.check_ancestors(path, trusted_system_root, owner_uid); + self.check_ancestors(path, trusted_root); let facts = match self.path_facts(path, check) { Some(facts) => facts, None => return, }; - if facts.kind != PathKind::File { + if policy.expects_file() && facts.kind != PathKind::File { self.report.refuse( check, Some(path.to_path_buf()), @@ -699,11 +810,69 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { ); return; } + if policy.expects_directory() && facts.kind != PathKind::Directory { + self.report.refuse( + check, + Some(path.to_path_buf()), + format!("expected a directory, found {:?}", facts.kind), + "restore this directory as part of a current release-bundle installation", + ); + return; + } self.check_owner(path, &facts, owner_uid, check); - if trusted_system_root.is_some() { + if trusted_root.is_some_and(|root| root.reject_other_writes) { self.check_not_writable_by_others(path, &facts); } - if must_be_mutable && facts.read_only_filesystem { + + match policy { + InstallerPathPolicy::ReplaceFile => self.check_replace_file(path, &facts, check), + InstallerPathPolicy::ReplaceExecutable => { + self.check_replace_file(path, &facts, check); + self.check_permissions( + path, + &facts, + 0o111, + check, + "installed executable has no execute permission", + ); + } + InstallerPathPolicy::MutateDirectory => { + self.check_mutable_directory(path, &facts, check, 0o300); + } + InstallerPathPolicy::RecursiveClear => { + self.check_mutable_directory(path, &facts, check, 0o300); + self.check_recursive_clear_mounts(path, check); + } + InstallerPathPolicy::ExactDropInDirectory { expected_entry } => { + self.check_mutable_directory(path, &facts, check, 0o700); + self.check_exact_directory(path, expected_entry, check); + } + InstallerPathPolicy::ReadableInput => self.check_permissions( + path, + &facts, + 0o400, + check, + "input is not readable by its owner", + ), + InstallerPathPolicy::ExecutableInput => self.check_permissions( + path, + &facts, + 0o500, + check, + "input is not readable and executable by its owner", + ), + InstallerPathPolicy::InputDirectory => self.check_permissions( + path, + &facts, + 0o500, + check, + "input directory is not readable and searchable by its owner", + ), + } + } + + fn check_replace_file(&mut self, path: &Path, facts: &PathFacts, check: &'static str) { + if facts.read_only_filesystem { self.report.refuse( check, Some(path.to_path_buf()), @@ -711,7 +880,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "use the host's native package manager or make this installation file mutable", ); } - if must_be_mutable && facts.link_count != 1 { + if facts.link_count != 1 { self.report.refuse( check, Some(path.to_path_buf()), @@ -722,7 +891,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "replace the path with an independent regular file before upgrading", ); } - if must_be_mutable && facts.mount_point { + if facts.mount_point { self.report.refuse( check, Some(path.to_path_buf()), @@ -730,51 +899,23 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "replace the mounted path with an ordinary installation file before upgrading", ); } - if must_be_mutable && facts.mode & 0o200 == 0 { - self.report.refuse( - check, - Some(path.to_path_buf()), - "file is not writable by its owner", - "restore owner-write permission before upgrading", - ); - } - if executable && facts.mode & 0o111 == 0 { - self.report.refuse( - check, - Some(path.to_path_buf()), - "file is not executable", - "restore the executable mode from a current release bundle", - ); - } + self.check_permissions( + path, + facts, + 0o200, + check, + "file is not writable by its owner", + ); } - fn check_directory( + fn check_mutable_directory( &mut self, path: &Path, - owner_uid: u32, - must_be_mutable: bool, - trusted_system_root: Option<&Path>, + facts: &PathFacts, check: &'static str, + required_permissions: u32, ) { - self.check_ancestors(path, trusted_system_root, owner_uid); - let facts = match self.path_facts(path, check) { - Some(facts) => facts, - None => return, - }; - if facts.kind != PathKind::Directory { - self.report.refuse( - check, - Some(path.to_path_buf()), - format!("expected a directory, found {:?}", facts.kind), - "restore this directory as part of a current release-bundle installation", - ); - return; - } - self.check_owner(path, &facts, owner_uid, check); - if trusted_system_root.is_some() { - self.check_not_writable_by_others(path, &facts); - } - if must_be_mutable && facts.read_only_filesystem { + if facts.read_only_filesystem { self.report.refuse( check, Some(path.to_path_buf()), @@ -782,7 +923,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "use the host's native package manager or make the installed release-bundle paths mutable", ); } - if must_be_mutable && facts.mount_point { + if facts.mount_point { self.report.refuse( check, Some(path.to_path_buf()), @@ -790,12 +931,88 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "replace the mounted path with an ordinary installation directory before upgrading", ); } - if must_be_mutable && facts.mode & 0o200 == 0 { + self.check_permissions( + path, + facts, + required_permissions, + check, + if required_permissions & 0o400 != 0 { + "directory is not readable, writable, and searchable by its owner" + } else { + "directory is not writable and searchable by its owner" + }, + ); + } + + fn check_recursive_clear_mounts(&mut self, path: &Path, check: &'static str) { + match self.filesystem.mount_points() { + Ok(mount_points) => { + for mount_point in mount_points { + if mount_point != path && mount_point.starts_with(path) { + self.report.refuse( + check, + Some(mount_point), + "recursively cleared directory contains a nested mount point", + "unmount nested filesystems from the managed virtualenv before upgrading", + ); + } + } + } + Err(err) => self.report.refuse( + check, + Some(path.to_path_buf()), + format!("could not inspect nested mount points: {err}"), + "make mount information available before upgrading", + ), + } + } + + fn check_exact_directory(&mut self, path: &Path, expected_entry: &str, check: &'static str) { + let expected_path = path.join(expected_entry); + match self.filesystem.read_directory(path) { + Ok(entries) => { + if !entries.iter().any(|entry| entry == &expected_path) { + self.report.refuse( + check, + Some(expected_path.clone()), + "required drop-in entry is missing", + "restore the exact drop-in directory from a current release bundle", + ); + } + for entry in entries { + if entry != expected_path { + self.report.refuse( + check, + Some(entry), + "drop-in directory contains an unexpected entry", + "remove unexpected drop-ins before upgrading", + ); + } + } + } + Err(err) => self.report.refuse( + check, + Some(path.to_path_buf()), + format!("could not inspect drop-in directory: {err}"), + "make the drop-in directory readable before upgrading", + ), + } + } + + fn check_permissions( + &mut self, + path: &Path, + facts: &PathFacts, + required: u32, + check: &'static str, + detail: &'static str, + ) { + if facts.mode & required != required { self.report.refuse( check, Some(path.to_path_buf()), - "directory is not writable by its owner", - "restore owner-write permission before upgrading", + detail, + "restore the path permissions from a current release bundle", ); } } @@ -833,30 +1050,6 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "restore a readable integration override", ), } - - let Some(directory) = path.parent() else { - return; - }; - match self.filesystem.read_directory(directory) { - Ok(entries) => { - for entry in entries { - if entry != path { - self.report.refuse( - "integration-config", - Some(entry), - "integration override directory contains an unexpected entry", - "remove unexpected drop-ins before upgrading", - ); - } - } - } - Err(err) => self.report.refuse( - "integration-config", - Some(directory.to_path_buf()), - format!("could not inspect the integration override directory: {err}"), - "make the integration override directory readable before upgrading", - ), - } } fn check_absent(&mut self, path: &Path) { @@ -883,12 +1076,11 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { owner_uid: u32, system_root: &Path, ) -> Option { - self.check_file( + self.check_requirement( path, owner_uid, - false, - true, - Some(system_root), + Some(TrustedRoot::strict(system_root, owner_uid)), + InstallerPathPolicy::ReplaceFile, "config-discovery", ); if self @@ -932,7 +1124,6 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } fn check_config_tree(&mut self, config_path: &Path, owner_uid: u32) { - self.check_file(config_path, owner_uid, false, false, None, "config-state"); let Some(config_directory) = config_path.parent() else { self.report.refuse( "config-state", @@ -942,12 +1133,20 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { ); return; }; - self.check_directory( + let config_trust = TrustedRoot::owned(config_directory, owner_uid); + self.check_requirement( config_directory, owner_uid, - true, - None, - "mutable-config-state", + Some(config_trust), + InstallerPathPolicy::InputDirectory, + "config-state", + ); + self.check_requirement( + config_path, + owner_uid, + Some(config_trust), + InstallerPathPolicy::ReadableInput, + "config-state", ); if self .report @@ -990,8 +1189,23 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { }; self.check_owner(&entry, &facts, owner_uid, "config-state"); match facts.kind { - PathKind::Directory => pending.push(entry), - PathKind::File => {} + PathKind::Directory => { + self.check_permissions( + &entry, + &facts, + 0o500, + "config-state", + "config directory is not readable and searchable by its owner", + ); + pending.push(entry); + } + PathKind::File => self.check_permissions( + &entry, + &facts, + 0o400, + "config-state", + "config file is not readable by its owner", + ), PathKind::Symlink | PathKind::Other => self.report.refuse( "config-state", Some(entry), @@ -1015,56 +1229,35 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } - fn check_owner_permissions(&mut self, path: &Path, required: u32, detail: &'static str) { - if self - .report - .failures - .iter() - .any(|failure| failure.path.as_deref() == Some(path)) - { - return; - } - let Some(facts) = self.path_facts(path, "candidate-layout") else { - return; - }; - if facts.mode & required != required { - self.report.refuse( - "candidate-layout", - Some(path.to_path_buf()), - detail, - "restore the candidate file mode from a verified release bundle", - ); - } - } - fn check_not_writable_by_others(&mut self, path: &Path, facts: &PathFacts) { if facts.mode & 0o022 != 0 { self.report.refuse( "path-containment", Some(path.to_path_buf()), - "system path is writable by its group or by other users", - "remove group and other write permission from the system installation path", + "trusted path is writable by its group or by other users", + "remove group and other write permission from the trusted path", ); } } - fn check_ancestors(&mut self, path: &Path, trusted_system_root: Option<&Path>, owner_uid: u32) { + fn check_ancestors(&mut self, path: &Path, trusted_root: Option>) { for ancestor in path.ancestors().skip(1) { - let trusted_owner = match trusted_system_root { - Some(root) if ancestor.starts_with(root) => Some(owner_uid), - _ => None, - }; + let trusted_properties = trusted_root + .filter(|root| ancestor.starts_with(root.path)) + .map(|root| (root.owner_uid, root.reject_other_writes)); if !self .checked_ancestors - .insert((ancestor.to_path_buf(), trusted_owner)) + .insert((ancestor.to_path_buf(), trusted_properties)) { continue; } match self.filesystem.path_facts(ancestor) { Ok(facts) if facts.kind == PathKind::Directory => { - if let Some(expected_uid) = trusted_owner { + if let Some((expected_uid, reject_other_writes)) = trusted_properties { self.check_owner(ancestor, &facts, expected_uid, "path-containment"); - self.check_not_writable_by_others(ancestor, &facts); + if reject_other_writes { + self.check_not_writable_by_others(ancestor, &facts); + } } } Ok(facts) => self.report.refuse( @@ -1157,6 +1350,157 @@ mod tests { ); } + #[test] + fn installer_path_policy_permission_matrix_is_enforced() { + let cases = [ + ( + "replace-file", + InstallerPathPolicy::ReplaceFile, + 0o400, + "not writable by its owner", + ), + ( + "replace-executable", + InstallerPathPolicy::ReplaceExecutable, + 0o600, + "no execute permission", + ), + ( + "mutate-directory", + InstallerPathPolicy::MutateDirectory, + 0o500, + "not writable and searchable", + ), + ( + "recursive-clear", + InstallerPathPolicy::RecursiveClear, + 0o500, + "not writable and searchable", + ), + ( + "exact-drop-in", + InstallerPathPolicy::ExactDropInDirectory { + expected_entry: "config.conf", + }, + 0o500, + "not readable, writable, and searchable", + ), + ( + "readable-input", + InstallerPathPolicy::ReadableInput, + 0o200, + "not readable by its owner", + ), + ( + "executable-input", + InstallerPathPolicy::ExecutableInput, + 0o400, + "not readable and executable", + ), + ( + "input-directory", + InstallerPathPolicy::InputDirectory, + 0o400, + "not readable and searchable", + ), + ]; + + for (label, policy, mode, expected_detail) in cases { + let fixture = InstalledFixture::new(label); + let (path, trusted_root, owner_uid) = match policy { + InstallerPathPolicy::ReplaceFile => ( + fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service"), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), + InstallerPathPolicy::ReplaceExecutable => ( + fixture.facts.layout.installed_executable(), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), + InstallerPathPolicy::MutateDirectory => ( + fixture.facts.layout.system_path("/usr/bin"), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), + InstallerPathPolicy::RecursiveClear => ( + fixture.facts.layout.system_path("/usr/bin/LG_Buddy_PIP"), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), + InstallerPathPolicy::ExactDropInDirectory { .. } => ( + fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service.d"), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), + InstallerPathPolicy::ReadableInput => ( + fixture.candidate_root.join("release-manifest.json"), + fixture.candidate_root.clone(), + fixture.facts.user_owner_uid, + ), + InstallerPathPolicy::ExecutableInput => ( + fixture.candidate_root.join("install.sh"), + fixture.candidate_root.clone(), + fixture.facts.user_owner_uid, + ), + InstallerPathPolicy::InputDirectory => ( + fixture.candidate_root.join("systemd"), + fixture.candidate_root.clone(), + fixture.facts.user_owner_uid, + ), + }; + let filesystem = OverriddenFilesystem { + path: path.clone(), + owner_uid: None, + mode: Some(mode), + read_only: None, + mount_point: None, + }; + let mut checker = Checker::new(&filesystem); + checker.check_requirement( + &path, + owner_uid, + Some(TrustedRoot::strict(&trusted_root, owner_uid)), + policy, + "policy-contract", + ); + + assert_failure(&checker.report, "policy-contract", &path, expected_detail); + } + } + + #[test] + fn candidate_input_policy_refuses_group_or_other_write_access() { + for (label, path, mode) in [ + ("writable-manifest", "release-manifest.json", 0o660), + ("writable-installer", "install.sh", 0o770), + ("writable-input-directory", "systemd", 0o770), + ] { + let fixture = InstalledFixture::new(label); + let input = fixture.candidate_root.join(path); + set_mode(&input, mode); + + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure( + &report, + "path-containment", + &input, + "writable by its group or by other users", + ); + } + } + #[test] fn initial_preflight_refuses_a_symlinked_installed_runtime() { let fixture = InstalledFixture::new("symlink-runtime"); @@ -1183,6 +1527,32 @@ mod tests { assert_failure(&report, "mutable-installation", &virtualenv, "Symlink"); } + #[test] + fn initial_preflight_refuses_a_nested_mount_in_the_recursively_cleared_virtualenv() { + let fixture = InstalledFixture::new("nested-virtualenv-mount"); + let nested_mount = fixture + .facts + .layout + .system_path("/usr/bin/LG_Buddy_PIP/lib/python/site-packages"); + fs::create_dir_all(&nested_mount).unwrap(); + let filesystem = OverriddenFilesystem { + path: nested_mount.clone(), + owner_uid: None, + mode: None, + read_only: None, + mount_point: Some(true), + }; + + let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + + assert_failure( + &report, + "mutable-installation", + &nested_mount, + "nested mount point", + ); + } + #[test] fn initial_preflight_refuses_an_incomplete_integration() { let fixture = InstalledFixture::new("missing-integration"); @@ -1422,6 +1792,25 @@ mod tests { assert_failure(&report, "integration-config", &drop_in, "unexpected entry"); } + #[test] + fn initial_preflight_refuses_a_missing_exact_systemd_drop_in() { + let fixture = InstalledFixture::new("missing-systemd-drop-in"); + let drop_in = fixture + .facts + .layout + .system_path("/etc/systemd/system/LG_Buddy.service.d/config.conf"); + fs::remove_file(&drop_in).unwrap(); + + let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + + assert_failure( + &report, + "integration-config", + &drop_in, + "required drop-in entry is missing", + ); + } + #[test] fn initial_preflight_refuses_legacy_state_instead_of_migrating_it() { let fixture = InstalledFixture::new("legacy-state"); @@ -1479,7 +1868,12 @@ mod tests { ); assert_failure(&report, "candidate-layout", &manifest, "missing"); - assert_failure(&report, "candidate-layout", &installer, "not executable"); + assert_failure( + &report, + "candidate-layout", + &installer, + "not readable and executable", + ); } #[test] @@ -1505,20 +1899,28 @@ mod tests { } #[test] - fn candidate_preflight_refuses_a_missing_upgrade_asset() { - let fixture = InstalledFixture::new("missing-candidate-service"); - let service = fixture - .candidate_root - .join("systemd/LG_Buddy_screen.service"); - fs::remove_file(&service).unwrap(); + fn candidate_preflight_refuses_each_missing_upgrade_input() { + for (index, requirement) in CANDIDATE_PATH_REQUIREMENTS + .iter() + .filter(|requirement| !requirement.path.is_empty()) + .enumerate() + { + let fixture = InstalledFixture::new(&format!("missing-candidate-input-{index}")); + let input = fixture.candidate_root.join(requirement.path); + if requirement.policy.expects_directory() { + fs::remove_dir_all(&input).unwrap(); + } else { + fs::remove_file(&input).unwrap(); + } - let report = evaluate_candidate_preflight( - &OsFilesystemFacts, - &fixture.candidate_root, - fixture.facts.user_owner_uid, - ); + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); - assert_failure(&report, "candidate-layout", &service, "missing"); + assert_failure(&report, "candidate-layout", &input, "missing"); + } } #[test] @@ -1631,6 +2033,18 @@ mod tests { fn read_directory(&self, path: &Path) -> io::Result> { OsFilesystemFacts.read_directory(path) } + + fn mount_points(&self) -> io::Result> { + let mut mount_points = OsFilesystemFacts.mount_points()?; + match self.mount_point { + Some(true) if !mount_points.contains(&self.path) => { + mount_points.push(self.path.clone()); + } + Some(false) => mount_points.retain(|path| path != &self.path), + _ => {} + } + Ok(mount_points) + } } struct InstalledFixture { @@ -1654,19 +2068,9 @@ mod tests { let config_directory = user_home.join(".config/lg-buddy"); let config_path = config_directory.join("config.env"); - for path in SYSTEM_MUTABLE_DIRECTORIES { - fs::create_dir_all(layout.system_path(path)).unwrap(); - } - for (path, executable) in SYSTEM_FILES { - write_file(&layout.system_path(path), *executable); - } + create_system_requirements(&layout); set_directory_tree_mode(&system_root, 0o755); - for path in USER_MUTABLE_DIRECTORIES { - fs::create_dir_all(user_home.join(path)).unwrap(); - } - for (path, executable) in USER_SYSTEMD_FILES { - write_file(&layout.user_systemd_path(path), *executable); - } + create_relative_requirements(&layout.user_systemd_path(""), USER_PATH_REQUIREMENTS); fs::create_dir_all(config_directory.join("tvs/primary")).unwrap(); fs::write(&config_path, "updates_channel=stable\n").unwrap(); fs::write( @@ -1692,12 +2096,11 @@ mod tests { ] { fs::write(path, &config_override).unwrap(); } + set_directory_tree_mode(&user_home, 0o755); let candidate_root = root.join("candidate"); - fs::create_dir_all(&candidate_root).unwrap(); - for (path, executable) in CANDIDATE_FILES { - write_file(&candidate_root.join(path), *executable); - } + create_relative_requirements(&candidate_root, CANDIDATE_PATH_REQUIREMENTS); + set_directory_tree_mode(&candidate_root, 0o755); let owner_uid = unsafe { libc::geteuid() }; let facts = HostPreflightFacts { @@ -1724,6 +2127,42 @@ mod tests { } } + fn create_system_requirements(layout: &InstalledLayout) { + for requirement in SYSTEM_PATH_REQUIREMENTS + .iter() + .filter(|requirement| requirement.policy.expects_directory()) + { + fs::create_dir_all(layout.system_path(requirement.path)).unwrap(); + } + for requirement in SYSTEM_PATH_REQUIREMENTS + .iter() + .filter(|requirement| requirement.policy.expects_file()) + { + write_file( + &layout.system_path(requirement.path), + matches!(requirement.policy, InstallerPathPolicy::ReplaceExecutable), + ); + } + } + + fn create_relative_requirements(base: &Path, requirements: &[InstallerPathRequirement]) { + for requirement in requirements + .iter() + .filter(|requirement| requirement.policy.expects_directory()) + { + fs::create_dir_all(base.join(requirement.path)).unwrap(); + } + for requirement in requirements + .iter() + .filter(|requirement| requirement.policy.expects_file()) + { + write_file( + &base.join(requirement.path), + matches!(requirement.policy, InstallerPathPolicy::ExecutableInput), + ); + } + } + fn write_file(path: &Path, executable: bool) { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, b"fixture\n").unwrap(); diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index bf6e3fa..7e6cd12 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -443,18 +443,25 @@ receipt, or where the binary originally came from. The initial preflight expects the running binary to be the mutable `/usr/bin/lg-buddy` installation. It checks the conventional release-bundle filesystem topology, ordinary file and directory types, ownership, writable -mounts, config-pointer discovery, user and system integrations, systemd manager -availability, and the absence of legacy layouts that would require migration. -The installed Python virtualenv root is part of that topology. Symlinks, -mounted or multiply linked mutation targets, untrusted writable system paths, -unexpected systemd drop-ins, read-only files, and special files in owned -installation or config state are refused. +mounts, config-pointer discovery, readable configuration state, user and system +integrations, systemd manager availability, and the absence of legacy layouts +that would require migration. Each path is tied to the future upgrade +operation that consumes it: file replacement, executable replacement, +directory mutation, recursive virtualenv repair, or exact drop-in replacement. +Those policies carry their ownership, permission, link, mount, and containment +invariants. Symlinks, mounted or multiply linked replacement targets, nested +mounts below a recursively cleared virtualenv, untrusted writable system paths, +unexpected systemd drop-ins, read-only mutation targets, and special files in +owned config state are refused. After a bundle has been verified, its candidate binary can run the second preflight. That pass rechecks the installed state, proves it is executing the candidate from the supplied bundle root, and checks the candidate manifest, installer, runtime, desktop entry, and systemd assets before any privileged -mutation. Interactive-only configuration assets are not upgrade requirements. +mutation. Candidate inputs must be owner-usable and not writable by another +user. Configuration and pairing scripts are deliberately excluded because the +non-interactive upgrade mode preserves existing configuration and credentials +without invoking them. These checks are a conservative, evolving safety boundary, not an exhaustive host-support declaration or a promise that no later privileged operation can diff --git a/docs/release-process.md b/docs/release-process.md index cd4c894..752bd5b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -105,6 +105,9 @@ types and mount boundaries, trusted system containment, integration override state, and system/user service-manager availability before an updater performs release or privilege-related effects. A verified candidate's binary performs a second pass for its own installer requirements before privileged mutation. +The checker assigns each target an installer-operation policy so replacement, +directory mutation, recursive repair, exact drop-in, and candidate-input +requirements cannot silently lose their operation-specific safeguards. This is intentionally a conservative and evolving refusal boundary. It does not migrate legacy layouts, declare broad host support, or guarantee that a diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index ae6069e..520a6db 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -311,7 +311,10 @@ filesystem, and ownership facts around a real temporary-root installation fixture. Its focused suite covers a passing mutable FHS layout plus symlinked, mounted, incompletely or wrongly owned, untrusted-writable, read-only, hard-linked, legacy, conflicting-drop-in, malformed-candidate, and -unavailable-service-manager refusals. Run it with: +unavailable-service-manager refusals. Table-driven cases exercise every path +policy's permission contract and every declared candidate input; recursive +repair coverage also refuses nested mount points below the virtualenv root. Run +it with: ```bash cargo test -p lg-buddy upgrade_preflight::tests --lib diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index d752b50..558cdee 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -66,6 +66,7 @@ python3 "$SCRIPT_DIR/release_bundle_manifest.py" create \ --release-tag "v$VERSION" \ --target "$TARGET" \ --binary "$BUNDLE_DIR/lg-buddy" +chmod 644 "$BUNDLE_DIR/release-manifest.json" install -m 644 "$REPO_ROOT/systemd/LG_Buddy.service" "$BUNDLE_DIR/systemd/LG_Buddy.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_lifecycle.service" "$BUNDLE_DIR/systemd/LG_Buddy_lifecycle.service" install -m 644 "$REPO_ROOT/systemd/LG_Buddy_screen.service" "$BUNDLE_DIR/systemd/LG_Buddy_screen.service" diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index 7aaf18e..fd851e0 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -16,6 +16,18 @@ assert_file() { fi } +assert_mode() { + local path="$1" + local expected="$2" + local actual="" + + actual="$(stat -c '%a' "$path")" + if [ "$actual" != "$expected" ]; then + echo "Expected mode $expected for $path, got $actual" + exit 1 + fi +} + assert_executable() { local path="$1" @@ -287,6 +299,7 @@ assert_file "$BUNDLE_DIR/LG_Buddy_Brightness.desktop" assert_file "$BUNDLE_DIR/README.md" assert_file "$BUNDLE_DIR/LICENSE" assert_file "$BUNDLE_DIR/release-manifest.json" +assert_mode "$BUNDLE_DIR/release-manifest.json" 644 assert_file "$BUNDLE_DIR/docs/architecture-overview.md" assert_file "$BUNDLE_DIR/docs/runtime-event-handler-map.md" assert_file "$BUNDLE_DIR/docs/user-guide.md" From 514d6832ba61e207df5bc8507bbdd2c13b85c5d3 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:08:45 +0300 Subject: [PATCH 05/22] fix: secure candidate bundle containment --- crates/lg-buddy/src/upgrade_preflight.rs | 157 +++++++++++++++++++++-- docs/architecture-overview.md | 4 +- docs/release-process.md | 3 +- docs/testing-strategy.md | 7 +- 4 files changed, 156 insertions(+), 15 deletions(-) diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs index 8f03d7a..54513ea 100644 --- a/crates/lg-buddy/src/upgrade_preflight.rs +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -682,7 +682,7 @@ pub fn evaluate_candidate_preflight( return checker.report; } - let candidate_trust = TrustedRoot::strict(candidate_root, user_owner_uid); + let candidate_trust = TrustedRoot::candidate(candidate_root, user_owner_uid); for requirement in CANDIDATE_PATH_REQUIREMENTS { checker.check_requirement( &candidate_root.join(requirement.path), @@ -753,6 +753,7 @@ struct TrustedRoot<'a> { path: &'a Path, owner_uid: u32, reject_other_writes: bool, + protect_external_ancestors: bool, } impl<'a> TrustedRoot<'a> { @@ -761,6 +762,16 @@ impl<'a> TrustedRoot<'a> { path, owner_uid, reject_other_writes: true, + protect_external_ancestors: false, + } + } + + fn candidate(path: &'a Path, owner_uid: u32) -> Self { + Self { + path, + owner_uid, + reject_other_writes: true, + protect_external_ancestors: true, } } @@ -769,14 +780,26 @@ impl<'a> TrustedRoot<'a> { path, owner_uid, reject_other_writes: false, + protect_external_ancestors: false, } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum AncestorPolicy { + Trusted { + owner_uid: u32, + reject_other_writes: bool, + }, + CandidateExternal { + user_owner_uid: u32, + }, +} + struct Checker<'a, F> { filesystem: &'a F, report: CompatibilityReport, - checked_ancestors: BTreeSet<(PathBuf, Option<(u32, bool)>)>, + checked_ancestors: BTreeSet<(PathBuf, Option)>, } impl<'a, F: FilesystemFacts> Checker<'a, F> { @@ -1240,26 +1263,72 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } + fn check_candidate_external_ancestor( + &mut self, + path: &Path, + facts: &PathFacts, + user_owner_uid: u32, + ) { + if facts.owner_uid != 0 && facts.owner_uid != user_owner_uid { + self.report.refuse( + "path-containment", + Some(path.to_path_buf()), + format!( + "candidate path ancestor is owned by uid {}, expected root or uid {user_owner_uid}", + facts.owner_uid + ), + "move the verified bundle below a root- or user-owned directory", + ); + return; + } + if facts.mode & 0o022 != 0 && facts.mode & 0o1000 == 0 { + self.report.refuse( + "path-containment", + Some(path.to_path_buf()), + "candidate path ancestor is writable by its group or by other users without sticky-directory protection", + "move the verified bundle into a private directory or below a sticky shared directory such as /tmp", + ); + } + } + fn check_ancestors(&mut self, path: &Path, trusted_root: Option>) { for ancestor in path.ancestors().skip(1) { - let trusted_properties = trusted_root - .filter(|root| ancestor.starts_with(root.path)) - .map(|root| (root.owner_uid, root.reject_other_writes)); + let ancestor_policy = trusted_root.and_then(|root| { + if ancestor.starts_with(root.path) { + Some(AncestorPolicy::Trusted { + owner_uid: root.owner_uid, + reject_other_writes: root.reject_other_writes, + }) + } else if root.protect_external_ancestors { + Some(AncestorPolicy::CandidateExternal { + user_owner_uid: root.owner_uid, + }) + } else { + None + } + }); if !self .checked_ancestors - .insert((ancestor.to_path_buf(), trusted_properties)) + .insert((ancestor.to_path_buf(), ancestor_policy)) { continue; } match self.filesystem.path_facts(ancestor) { - Ok(facts) if facts.kind == PathKind::Directory => { - if let Some((expected_uid, reject_other_writes)) = trusted_properties { - self.check_owner(ancestor, &facts, expected_uid, "path-containment"); + Ok(facts) if facts.kind == PathKind::Directory => match ancestor_policy { + Some(AncestorPolicy::Trusted { + owner_uid, + reject_other_writes, + }) => { + self.check_owner(ancestor, &facts, owner_uid, "path-containment"); if reject_other_writes { self.check_not_writable_by_others(ancestor, &facts); } } - } + Some(AncestorPolicy::CandidateExternal { user_owner_uid }) => { + self.check_candidate_external_ancestor(ancestor, &facts, user_owner_uid) + } + None => {} + }, Ok(facts) => self.report.refuse( "path-containment", Some(ancestor.to_path_buf()), @@ -1501,6 +1570,73 @@ mod tests { } } + #[test] + fn candidate_preflight_refuses_a_non_sticky_writable_external_ancestor() { + let fixture = InstalledFixture::new("writable-candidate-ancestor"); + let ancestor = fixture.candidate_root.parent().unwrap().to_path_buf(); + set_mode(&ancestor, 0o777); + + let report = evaluate_candidate_preflight( + &OsFilesystemFacts, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure( + &report, + "path-containment", + &ancestor, + "without sticky-directory protection", + ); + } + + #[test] + fn candidate_preflight_refuses_an_external_ancestor_owned_by_another_user() { + let fixture = InstalledFixture::new("untrusted-candidate-ancestor-owner"); + let ancestor = fixture.candidate_root.parent().unwrap().to_path_buf(); + let filesystem = OverriddenFilesystem { + path: ancestor.clone(), + owner_uid: Some(fixture.facts.user_owner_uid + 1), + mode: None, + read_only: None, + mount_point: None, + }; + + let report = evaluate_candidate_preflight( + &filesystem, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert_failure( + &report, + "path-containment", + &ancestor, + "expected root or uid", + ); + } + + #[test] + fn candidate_preflight_accepts_a_root_owned_sticky_external_ancestor() { + let fixture = InstalledFixture::new("sticky-candidate-ancestor"); + let ancestor = fixture.candidate_root.parent().unwrap().to_path_buf(); + let filesystem = OverriddenFilesystem { + path: ancestor, + owner_uid: Some(0), + mode: Some(0o1777), + read_only: None, + mount_point: None, + }; + + let report = evaluate_candidate_preflight( + &filesystem, + &fixture.candidate_root, + fixture.facts.user_owner_uid, + ); + + assert!(report.compatible(), "{}", report.render()); + } + #[test] fn initial_preflight_refuses_a_symlinked_installed_runtime() { let fixture = InstalledFixture::new("symlink-runtime"); @@ -2101,6 +2237,7 @@ mod tests { let candidate_root = root.join("candidate"); create_relative_requirements(&candidate_root, CANDIDATE_PATH_REQUIREMENTS); set_directory_tree_mode(&candidate_root, 0o755); + set_mode(&root, 0o755); let owner_uid = unsafe { libc::geteuid() }; let facts = HostPreflightFacts { diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 7e6cd12..ac41d89 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -459,7 +459,9 @@ preflight. That pass rechecks the installed state, proves it is executing the candidate from the supplied bundle root, and checks the candidate manifest, installer, runtime, desktop entry, and systemd assets before any privileged mutation. Candidate inputs must be owner-usable and not writable by another -user. Configuration and pairing scripts are deliberately excluded because the +user. The external ancestor chain must remain root- or user-owned and cannot be +shared-writable unless sticky-directory semantics protect its trusted child. +Configuration and pairing scripts are deliberately excluded because the non-interactive upgrade mode preserves existing configuration and credentials without invoking them. diff --git a/docs/release-process.md b/docs/release-process.md index 752bd5b..580a147 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -104,7 +104,8 @@ checks the installed mutable FHS topology, config discovery, ownership, path types and mount boundaries, trusted system containment, integration override state, and system/user service-manager availability before an updater performs release or privilege-related effects. A verified candidate's binary performs a -second pass for its own installer requirements before privileged mutation. +second pass for its own installer requirements and trusted external ancestor +chain before privileged mutation. The checker assigns each target an installer-operation policy so replacement, directory mutation, recursive repair, exact drop-in, and candidate-input requirements cannot silently lose their operation-specific safeguards. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 520a6db..9545f36 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -312,9 +312,10 @@ fixture. Its focused suite covers a passing mutable FHS layout plus symlinked, mounted, incompletely or wrongly owned, untrusted-writable, read-only, hard-linked, legacy, conflicting-drop-in, malformed-candidate, and unavailable-service-manager refusals. Table-driven cases exercise every path -policy's permission contract and every declared candidate input; recursive -repair coverage also refuses nested mount points below the virtualenv root. Run -it with: +policy's permission contract and every declared candidate input. Candidate +containment cases reject untrusted and non-sticky shared-writable ancestors +while preserving root-owned sticky temporary directories; recursive repair +coverage also refuses nested mount points below the virtualenv root. Run it with: ```bash cargo test -p lg-buddy upgrade_preflight::tests --lib From 5dfbb085ebf2aad51b408c43617a7074c97bd3a3 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:54:27 +0300 Subject: [PATCH 06/22] feat: add release bundle upgrade mode --- README.md | 13 +- crates/lg-buddy/src/events.rs | 3 +- crates/lg-buddy/src/lib.rs | 108 ++++++++ crates/lg-buddy/src/upgrade_preflight.rs | 215 ++++++++++++++-- docs/architecture-overview.md | 20 +- docs/development.md | 6 +- docs/release-process.md | 21 ++ docs/testing-strategy.md | 14 +- install.sh | 310 ++++++++++++++++------- scripts/test-release-bundle.sh | 275 ++++++++++++++++++-- 10 files changed, 838 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index f29e0ae..d8dad21 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ selection and troubleshooting. Protocol and event details are documented in the ## Before You Install The native `lg_webos` control path does not require Python. Native-only packages -can omit the Python client, `venv`, and `pip`. The current `install.sh` flow +can omit the Python client, `venv`, and `pip`. The current fresh-install flow still provisions `bscpylgtv` as a compatibility fallback and installs the -brightness dialog, so release-bundle installation checks for Python 3 with -`venv` and `pip`, plus `zenity`. `swayidle` is required only when using that -desktop backend. +brightness dialog, so release-bundle installation checks for Python 3 with a +`venv` that provisions `pip`, plus `zenity`. `swayidle` is required only when +using that desktop backend. ### Debian, Ubuntu, and Pop!_OS @@ -95,6 +95,11 @@ during setup. With the default `bscpylgtv` platform, the prompt may instead appear on first use; see the [bscpylgtv first-use guide](https://github.com/chros73/bscpylgtv/blob/master/docs/guides/first_use.md). +To update an existing compatible release-bundle installation from an already +verified and extracted newer bundle, run `./install.sh --upgrade`. Upgrade mode +preserves configuration and credentials and does not repeat setup or pairing; +incompatible and legacy layouts are refused rather than migrated. + The shell installer targets conventional Linux installations with mutable system locations. First-class NixOS packaging is tracked in [issue #24](https://github.com/Staphylococcus/LG_Buddy/issues/24). diff --git a/crates/lg-buddy/src/events.rs b/crates/lg-buddy/src/events.rs index 41c0de1..49536ca 100644 --- a/crates/lg-buddy/src/events.rs +++ b/crates/lg-buddy/src/events.rs @@ -109,7 +109,8 @@ impl RuntimeEventKind { | Command::DetectBackend | Command::Dev(_) | Command::Settings(_) - | Command::Updates(_) => None, + | Command::Updates(_) + | Command::UpgradePreflight { .. } => None, } } } diff --git a/crates/lg-buddy/src/lib.rs b/crates/lg-buddy/src/lib.rs index 2e41e06..9817965 100644 --- a/crates/lg-buddy/src/lib.rs +++ b/crates/lg-buddy/src/lib.rs @@ -47,8 +47,10 @@ use crate::tv::{ VolumeLevelParseError, }; use crate::updates::{run_updates_command, UpdatesCommand, UpdatesError, UpdatesParseError}; +use crate::upgrade_preflight::CompatibilityReport; use std::fmt; use std::io::{self, Write}; +use std::path::PathBuf; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { @@ -69,6 +71,10 @@ pub enum Command { Dev(DevCommand), Settings(SettingsCommand), Updates(UpdatesCommand), + UpgradePreflight { + candidate_root: PathBuf, + repair_python: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -205,6 +211,7 @@ pub enum ParseError { Dev(DevParseError), Settings(SettingsParseError), Updates(UpdatesParseError), + MissingUpgradePreflightRoot, UnexpectedArguments { command: Command, arguments: Vec, @@ -255,6 +262,9 @@ impl fmt::Display for ParseError { Self::Dev(err) => write!(f, "{err}"), Self::Settings(err) => write!(f, "{err}"), Self::Updates(err) => write!(f, "{err}"), + Self::MissingUpgradePreflightRoot => { + write!(f, "missing candidate root for `upgrade-preflight`") + } Self::UnexpectedArguments { command, arguments } => { write!( f, @@ -280,6 +290,7 @@ pub enum RunError { Dev(DevError), Settings(SettingsError), Updates(UpdatesError), + UpgradePreflight(CompatibilityReport), NotificationAfterPrimary { primary: Box, notification: NotificationError, @@ -300,6 +311,7 @@ impl fmt::Display for RunError { Self::Dev(err) => write!(f, "{err}"), Self::Settings(err) => write!(f, "{err}"), Self::Updates(err) => write!(f, "{err}"), + Self::UpgradePreflight(report) => write!(f, "{report}"), Self::NotificationAfterPrimary { primary, notification, @@ -325,6 +337,7 @@ impl std::error::Error for RunError { Self::Dev(err) => Some(err), Self::Settings(err) => Some(err), Self::Updates(err) => Some(err), + Self::UpgradePreflight(_) => None, Self::NotificationAfterPrimary { primary, .. } => Some(primary.as_ref()), } } @@ -413,6 +426,7 @@ impl Command { Self::Dev(command) => command.as_str(), Self::Settings(_) => "settings", Self::Updates(_) => "updates", + Self::UpgradePreflight { .. } => "upgrade-preflight", } } @@ -435,6 +449,7 @@ impl Command { Self::Dev(_) => "TODO: implemented via temporary dev command handler", Self::Settings(_) => "TODO: implemented via command handler", Self::Updates(_) => "TODO: implemented via command handler", + Self::UpgradePreflight { .. } => "TODO: implemented via command handler", } } } @@ -701,6 +716,33 @@ where } "settings" => return parse_settings_command(args), "updates" => return parse_updates_command(args), + "upgrade-preflight" => { + let candidate_root = PathBuf::from( + args.next() + .ok_or(ParseError::MissingUpgradePreflightRoot)? + .as_ref(), + ); + let mut repair_python = false; + let mut unexpected = Vec::new(); + for argument in args { + if argument.as_ref() == "--repair-python" && !repair_python { + repair_python = true; + } else { + unexpected.push(argument.as_ref().to_string()); + } + } + let command = Command::UpgradePreflight { + candidate_root, + repair_python, + }; + if !unexpected.is_empty() { + return Err(ParseError::UnexpectedArguments { + command, + arguments: unexpected, + }); + } + return Ok(ParseOutcome::Command(command)); + } "dev" => { return DevCommand::parse(args) .map(|command| ParseOutcome::Command(Command::Dev(command))) @@ -758,6 +800,19 @@ pub fn run_command(command: Command, writer: &mut W) -> Result<(), Run Command::Updates(command) => { run_updates_command(command, writer).map_err(RunError::Updates) } + Command::UpgradePreflight { + candidate_root, + repair_python, + } => { + let report = + crate::upgrade_preflight::candidate_host_preflight(&candidate_root, repair_python); + if report.compatible() { + write!(writer, "{report}")?; + Ok(()) + } else { + Err(RunError::UpgradePreflight(report)) + } + } } } @@ -1176,6 +1231,7 @@ mod tests { use crate::{notifications::NotificationError, RunError}; use std::error::Error; use std::io; + use std::path::PathBuf; #[test] fn no_args_prints_help() { @@ -1552,6 +1608,24 @@ mod tests { UpdatesCommand::BackgroundCheck ))) ); + assert_eq!( + parse_args(["upgrade-preflight", "/tmp/lg-buddy-candidate"]), + Ok(ParseOutcome::Command(Command::UpgradePreflight { + candidate_root: PathBuf::from("/tmp/lg-buddy-candidate"), + repair_python: false, + })) + ); + assert_eq!( + parse_args([ + "upgrade-preflight", + "/tmp/lg-buddy-candidate", + "--repair-python" + ]), + Ok(ParseOutcome::Command(Command::UpgradePreflight { + candidate_root: PathBuf::from("/tmp/lg-buddy-candidate"), + repair_python: true, + })) + ); } #[test] @@ -1827,6 +1901,39 @@ mod tests { ); } + #[test] + fn invalid_upgrade_preflight_command_is_rejected() { + assert_eq!( + parse_args(["upgrade-preflight"]), + Err(ParseError::MissingUpgradePreflightRoot) + ); + assert_eq!( + parse_args(["upgrade-preflight", "/tmp/candidate", "extra"]), + Err(ParseError::UnexpectedArguments { + command: Command::UpgradePreflight { + candidate_root: PathBuf::from("/tmp/candidate"), + repair_python: false, + }, + arguments: vec!["extra".to_string()], + }) + ); + assert_eq!( + parse_args([ + "upgrade-preflight", + "/tmp/candidate", + "--repair-python", + "--repair-python" + ]), + Err(ParseError::UnexpectedArguments { + command: Command::UpgradePreflight { + candidate_root: PathBuf::from("/tmp/candidate"), + repair_python: true, + }, + arguments: vec!["--repair-python".to_string()], + }) + ); + } + #[test] fn global_usage_only_mentions_public_commands() { let help = usage("lg-buddy"); @@ -1868,6 +1975,7 @@ mod tests { "screen-on", "detect-backend", "updates background-check", + "upgrade-preflight", "webos-auth-probe", "webos-read-probe", ] { diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs index 54513ea..48fc304 100644 --- a/crates/lg-buddy/src/upgrade_preflight.rs +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -17,6 +17,7 @@ enum InstallerPathPolicy { RecursiveClear, ExactDropInDirectory { expected_entry: &'static str }, ReadableInput, + SystemReadableInput, ExecutableInput, InputDirectory, } @@ -28,6 +29,7 @@ impl InstallerPathPolicy { Self::ReplaceFile | Self::ReplaceExecutable | Self::ReadableInput + | Self::SystemReadableInput | Self::ExecutableInput ) } @@ -84,8 +86,6 @@ const SYSTEM_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[ InstallerPathPolicy::ReplaceFile, ), requirement("/usr/bin", InstallerPathPolicy::MutateDirectory), - requirement("/usr/bin/LG_Buddy_PIP", InstallerPathPolicy::RecursiveClear), - requirement("/usr/lib/lg-buddy", InstallerPathPolicy::MutateDirectory), requirement("/etc/systemd/system", InstallerPathPolicy::MutateDirectory), requirement( "/etc/systemd/system/LG_Buddy.service.d", @@ -110,6 +110,11 @@ const SYSTEM_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[ ), ]; +const PYTHON_REPAIR_PATH_REQUIREMENTS: &[InstallerPathRequirement] = &[requirement( + "/usr/bin/LG_Buddy_PIP", + InstallerPathPolicy::RecursiveClear, +)]; + const LEGACY_SYSTEM_PATHS: &[&str] = &[ "/usr/bin/LG_Buddy_Startup", "/usr/bin/LG_Buddy_Shutdown", @@ -410,6 +415,10 @@ impl InstalledLayout { fn user_systemd_path(&self, path: &str) -> PathBuf { self.user_home.join(".config/systemd/user").join(path) } + + fn user_desktop_entry(&self) -> PathBuf { + self.user_home.join("Desktop/LG_Buddy_Brightness.desktop") + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -501,12 +510,12 @@ pub fn current_host_preflight() -> CompatibilityReport { evaluate_initial_preflight(&OsFilesystemFacts, &facts) } -pub fn candidate_host_preflight(candidate_root: &Path) -> CompatibilityReport { +pub fn candidate_host_preflight(candidate_root: &Path, repair_python: bool) -> CompatibilityReport { let facts = match observe_current_process() { Ok(facts) => facts, Err(report) => return report, }; - evaluate_candidate_host_preflight(&OsFilesystemFacts, &facts, candidate_root) + evaluate_candidate_host_preflight(&OsFilesystemFacts, &facts, candidate_root, repair_python) } fn observe_current_process() -> Result { @@ -536,13 +545,24 @@ fn observe_current_process() -> Result } }; let effective_uid = unsafe { libc::geteuid() }; + let install_root = env::var_os("LG_BUDDY_INSTALL_ROOT") + .filter(|root| !root.is_empty()) + .map(PathBuf::from); + let sandboxed_install = install_root.is_some(); + let system_root = install_root.unwrap_or_else(|| PathBuf::from("/")); + let service_managers = + if sandboxed_install && env::var("LG_BUDDY_SKIP_SYSTEMD_ACTIONS").as_deref() == Ok("1") { + ServiceManagerFacts::available() + } else { + ServiceManagerFacts::observe() + }; Ok(HostPreflightFacts { - layout: InstalledLayout::new("/", user_home), + layout: InstalledLayout::new(system_root, user_home), running_executable, effective_uid, - system_owner_uid: 0, + system_owner_uid: if sandboxed_install { effective_uid } else { 0 }, user_owner_uid: effective_uid, - service_managers: ServiceManagerFacts::observe(), + service_managers, }) } @@ -556,6 +576,7 @@ pub fn evaluate_initial_preflight( &facts.layout.installed_executable(), "running-executable", "run the release-bundle installation at /usr/bin/lg-buddy, or use the host's native package manager", + false, ) } @@ -565,6 +586,7 @@ fn evaluate_installed_state( expected_running_executable: &Path, executable_check: &'static str, executable_remedy: &'static str, + repair_python: bool, ) -> CompatibilityReport { let mut checker = Checker::new(filesystem); let system_trust = TrustedRoot::strict(&facts.layout.system_root, facts.system_owner_uid); @@ -613,6 +635,17 @@ fn evaluate_installed_state( check, ); } + if repair_python { + for requirement in PYTHON_REPAIR_PATH_REQUIREMENTS { + checker.check_requirement( + &facts.layout.system_path(requirement.path), + facts.system_owner_uid, + Some(system_trust), + requirement.policy, + "python-environment-repair", + ); + } + } for requirement in USER_PATH_REQUIREMENTS { let check = match requirement.policy { InstallerPathPolicy::MutateDirectory => "mutable-user-integration", @@ -627,6 +660,13 @@ fn evaluate_installed_state( check, ); } + checker.check_optional_requirement( + &facts.layout.user_desktop_entry(), + facts.user_owner_uid, + Some(user_trust), + InstallerPathPolicy::ReplaceFile, + "user-desktop", + ); for path in LEGACY_SYSTEM_PATHS { checker.check_absent(&facts.layout.system_path(path)); @@ -699,6 +739,7 @@ pub fn evaluate_candidate_host_preflight( filesystem: &impl FilesystemFacts, facts: &HostPreflightFacts, candidate_root: &Path, + repair_python: bool, ) -> CompatibilityReport { let expected_candidate_executable = candidate_root.join("lg-buddy"); let mut report = evaluate_installed_state( @@ -707,6 +748,7 @@ pub fn evaluate_candidate_host_preflight( &expected_candidate_executable, "candidate-executable", "run the preflight with the verified candidate binary from this bundle", + repair_python, ); report.extend(evaluate_candidate_preflight( filesystem, @@ -820,9 +862,27 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { check: &'static str, ) { self.check_ancestors(path, trusted_root); - let facts = match self.path_facts(path, check) { - Some(facts) => facts, - None => return, + let facts = match self.filesystem.path_facts(path) { + Ok(facts) => facts, + Err(err) + if policy == InstallerPathPolicy::RecursiveClear + && err.kind() == io::ErrorKind::NotFound => + { + return; + } + Err(err) => { + self.report.refuse( + check, + Some(path.to_path_buf()), + if err.kind() == io::ErrorKind::NotFound { + "required path is missing".to_string() + } else { + format!("could not inspect required path: {err}") + }, + "restore this path from a current release-bundle installation", + ); + return; + } }; if policy.expects_file() && facts.kind != PathKind::File { self.report.refuse( @@ -877,6 +937,13 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { check, "input is not readable by its owner", ), + InstallerPathPolicy::SystemReadableInput => self.check_permissions( + path, + &facts, + 0o404, + check, + "system input is not readable by its owner and the invoking user", + ), InstallerPathPolicy::ExecutableInput => self.check_permissions( path, &facts, @@ -894,6 +961,20 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { } } + fn check_optional_requirement( + &mut self, + path: &Path, + owner_uid: u32, + trusted_root: Option>, + policy: InstallerPathPolicy, + check: &'static str, + ) { + match self.filesystem.path_facts(path) { + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + _ => self.check_requirement(path, owner_uid, trusted_root, policy, check), + } + } + fn check_replace_file(&mut self, path: &Path, facts: &PathFacts, check: &'static str) { if facts.read_only_filesystem { self.report.refuse( @@ -1103,7 +1184,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { path, owner_uid, Some(TrustedRoot::strict(system_root, owner_uid)), - InstallerPathPolicy::ReplaceFile, + InstallerPathPolicy::SystemReadableInput, "config-discovery", ); if self @@ -1402,12 +1483,81 @@ mod tests { &OsFilesystemFacts, &candidate_facts, &fixture.candidate_root, + false, ); assert!(initial.compatible(), "{}", initial.render()); assert!(candidate.compatible(), "{}", candidate.render()); } + #[test] + fn python_environment_safety_is_required_only_when_repair_is_requested() { + let fixture = InstalledFixture::new("conditional-python-repair"); + let virtualenv = fixture.facts.layout.system_path("/usr/bin/LG_Buddy_PIP"); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); + + let missing = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + true, + ); + assert!(missing.compatible(), "{}", missing.render()); + + fs::create_dir_all(&virtualenv).unwrap(); + let repair = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + true, + ); + assert!(repair.compatible(), "{}", repair.render()); + + fs::remove_dir(&virtualenv).unwrap(); + symlink(&fixture.config_directory, &virtualenv).unwrap(); + let preserving = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + false, + ); + let repair = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + true, + ); + + assert!(preserving.compatible(), "{}", preserving.render()); + assert_failure(&repair, "python-environment-repair", &virtualenv, "Symlink"); + } + + #[test] + fn existing_user_desktop_launcher_must_be_safely_replaceable() { + let fixture = InstalledFixture::new("user-desktop-launcher"); + let launcher = fixture.facts.layout.user_desktop_entry(); + fs::create_dir_all(launcher.parent().unwrap()).unwrap(); + write_file(&launcher, false); + set_mode(&launcher, 0o400); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); + + let report = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + false, + ); + + assert_failure( + &report, + "user-desktop", + &launcher, + "not writable by its owner", + ); + } + #[test] fn os_filesystem_identifies_mount_points() { let root = OsFilesystemFacts.path_facts(Path::new("/")).unwrap(); @@ -1460,6 +1610,12 @@ mod tests { 0o200, "not readable by its owner", ), + ( + "system-readable-input", + InstallerPathPolicy::SystemReadableInput, + 0o400, + "invoking user", + ), ( "executable-input", InstallerPathPolicy::ExecutableInput, @@ -1513,6 +1669,11 @@ mod tests { fixture.candidate_root.clone(), fixture.facts.user_owner_uid, ), + InstallerPathPolicy::SystemReadableInput => ( + fixture.facts.layout.config_pointer(), + fixture.facts.layout.system_root.clone(), + fixture.facts.system_owner_uid, + ), InstallerPathPolicy::ExecutableInput => ( fixture.candidate_root.join("install.sh"), fixture.candidate_root.clone(), @@ -1524,6 +1685,9 @@ mod tests { fixture.facts.user_owner_uid, ), }; + if policy == InstallerPathPolicy::RecursiveClear { + fs::create_dir_all(&path).unwrap(); + } let filesystem = OverriddenFilesystem { path: path.clone(), owner_uid: None, @@ -1650,27 +1814,37 @@ mod tests { } #[test] - fn initial_preflight_refuses_a_symlinked_installed_virtualenv() { + fn python_repair_preflight_refuses_a_symlinked_installed_virtualenv() { let fixture = InstalledFixture::new("symlink-virtualenv"); let virtualenv = fixture.facts.layout.system_path("/usr/bin/LG_Buddy_PIP"); let target = fixture.root.join("external-virtualenv"); + fs::create_dir(&virtualenv).unwrap(); fs::remove_dir(&virtualenv).unwrap(); fs::create_dir(&target).unwrap(); symlink(&target, &virtualenv).unwrap(); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); - let report = evaluate_initial_preflight(&OsFilesystemFacts, &fixture.facts); + let report = evaluate_candidate_host_preflight( + &OsFilesystemFacts, + &candidate_facts, + &fixture.candidate_root, + true, + ); - assert_failure(&report, "mutable-installation", &virtualenv, "Symlink"); + assert_failure(&report, "python-environment-repair", &virtualenv, "Symlink"); } #[test] - fn initial_preflight_refuses_a_nested_mount_in_the_recursively_cleared_virtualenv() { + fn python_repair_preflight_refuses_a_nested_virtualenv_mount() { let fixture = InstalledFixture::new("nested-virtualenv-mount"); let nested_mount = fixture .facts .layout .system_path("/usr/bin/LG_Buddy_PIP/lib/python/site-packages"); fs::create_dir_all(&nested_mount).unwrap(); + let mut candidate_facts = fixture.facts.clone(); + candidate_facts.running_executable = fixture.candidate_root.join("lg-buddy"); let filesystem = OverriddenFilesystem { path: nested_mount.clone(), owner_uid: None, @@ -1679,11 +1853,16 @@ mod tests { mount_point: Some(true), }; - let report = evaluate_initial_preflight(&filesystem, &fixture.facts); + let report = evaluate_candidate_host_preflight( + &filesystem, + &candidate_facts, + &fixture.candidate_root, + true, + ); assert_failure( &report, - "mutable-installation", + "python-environment-repair", &nested_mount, "nested mount point", ); @@ -2067,6 +2246,7 @@ mod tests { &OsFilesystemFacts, &fixture.facts, &fixture.candidate_root, + false, ); let failure = report @@ -2092,6 +2272,7 @@ mod tests { &OsFilesystemFacts, &candidate_facts, &fixture.candidate_root, + false, ); assert_failure(&report, "installed-layout", &installed_service, "missing"); diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index ac41d89..f682cdc 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -445,14 +445,13 @@ The initial preflight expects the running binary to be the mutable filesystem topology, ordinary file and directory types, ownership, writable mounts, config-pointer discovery, readable configuration state, user and system integrations, systemd manager availability, and the absence of legacy layouts -that would require migration. Each path is tied to the future upgrade +that would require migration. Each path is tied to the upgrade operation that consumes it: file replacement, executable replacement, -directory mutation, recursive virtualenv repair, or exact drop-in replacement. -Those policies carry their ownership, permission, link, mount, and containment -invariants. Symlinks, mounted or multiply linked replacement targets, nested -mounts below a recursively cleared virtualenv, untrusted writable system paths, -unexpected systemd drop-ins, read-only mutation targets, and special files in -owned config state are refused. +directory mutation, read-only input, or exact drop-in replacement. Those +policies carry their ownership, permission, link, mount, and containment +invariants. Symlinks, mounted or multiply linked replacement targets, +untrusted writable system paths, unexpected systemd drop-ins, read-only +mutation targets, and special files in owned config state are refused. After a bundle has been verified, its candidate binary can run the second preflight. That pass rechecks the installed state, proves it is executing the @@ -465,6 +464,13 @@ Configuration and pairing scripts are deliberately excluded because the non-interactive upgrade mode preserves existing configuration and credentials without invoking them. +The installer then reads the existing platform choice and checks the legacy +Python environment without mutating either. Native installations and healthy +compatibility environments preserve that directory unchanged. Only an +unhealthy compatibility environment triggers a second candidate preflight for +recursive repair; that conditional pass also refuses unsafe virtualenv roots +and nested mounts before the directory is cleared. + These checks are a conservative, evolving safety boundary, not an exhaustive host-support declaration or a promise that no later privileged operation can fail. New observable checks can be added as real installations expose unsafe diff --git a/docs/development.md b/docs/development.md index 6aa754d..db45cf2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -132,9 +132,9 @@ Smoke test a generated release bundle with: The smoke test validates `release-manifest.json` against the archive name and bundled binary before running installer code. It then installs into a temporary -root and checks both TV platforms, native credential preservation across -upgrades, lifecycle and NetworkManager hook topology, and uninstall cleanup -without mutating the host installation. +root and exercises upgrade refusal, preservation, Python repair, owned-file +replacement, service ordering, installed identity, lifecycle topology, and +uninstall cleanup without mutating the host installation. Run the focused manifest contract tests with: diff --git a/docs/release-process.md b/docs/release-process.md index 580a147..0fb8108 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -110,6 +110,16 @@ The checker assigns each target an installer-operation policy so replacement, directory mutation, recursive repair, exact drop-in, and candidate-input requirements cannot silently lose their operation-specific safeguards. +The extracted candidate exposes this second pass through the hidden +`upgrade-preflight` installer entrypoint. `install.sh --upgrade` invokes it +before sudo or installation writes, loads the existing config pointer and +settings without rewriting them, and never runs configuration, discovery, or +pairing. Native and healthy compatibility installations preserve their Python +environment; an unhealthy compatibility environment must pass the additional +recursive-repair checks before it is rebuilt. After replacing owned runtime and +integration files, the installer reloads system integrations before user +integrations and verifies that the installed binary matches the candidate. + This is intentionally a conservative and evolving refusal boundary. It does not migrate legacy layouts, declare broad host support, or guarantee that a later privileged operation cannot fail. @@ -134,6 +144,17 @@ End users can extract the release archive and run: That path uses the bundled `lg-buddy` binary and does not require a Rust toolchain. +To update an existing compatible release-bundle installation from an already +verified and extracted newer bundle, run as the installed user: + +```bash +./install.sh --upgrade +``` + +An incompatible or legacy layout is refused rather than migrated. If a failure +occurs after installation writes begin, correct the reported cause and rerun the +same verified bundle with `--upgrade`. + ## Installing a locally built binary If you build `lg-buddy` yourself, install it by passing the binary path explicitly: diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 9545f36..0850d2b 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -295,10 +295,11 @@ These should not dominate the Rust test suite, but they still matter because ins The release-bundle smoke test covers the current installed lifecycle topology: the logind lifecycle service remains installed, the NetworkManager pre-down hook -remains installed, and legacy systemd sleep hooks are absent. It also verifies -that a missing TV platform remains `bscpylgtv`, explicit platform values survive -reconfiguration, and `lg_webos` routes to the stored-credential-only native path -and reports a missing credential without initiating background pairing. +remains installed, and legacy systemd sleep hooks are absent. Its upgrade phase +proves refusal before sudo, skips configuration, preserves config and native +credentials byte-for-byte, conditionally preserves or repairs the Python +environment, replaces the owned bundle assets, checks service action order, and +verifies the installed runtime against the candidate bytes and identity. The focused release-manifest suite covers deterministic serialization, schema and critical-field handling, duplicate and missing fields, canonical identity @@ -314,8 +315,9 @@ hard-linked, legacy, conflicting-drop-in, malformed-candidate, and unavailable-service-manager refusals. Table-driven cases exercise every path policy's permission contract and every declared candidate input. Candidate containment cases reject untrusted and non-sticky shared-writable ancestors -while preserving root-owned sticky temporary directories; recursive repair -coverage also refuses nested mount points below the virtualenv root. Run it with: +while preserving root-owned sticky temporary directories. Virtualenv mutation +checks are conditional on an actual compatibility-environment repair and refuse +unsafe roots or nested mount points before clearing. Run it with: ```bash cargo test -p lg-buddy upgrade_preflight::tests --lib diff --git a/install.sh b/install.sh index 9afd850..f97a10f 100755 --- a/install.sh +++ b/install.sh @@ -12,13 +12,20 @@ SKIP_SYSTEMD_ACTIONS="${LG_BUDDY_SKIP_SYSTEMD_ACTIONS:-0}" SKIP_PIP_INSTALL="${LG_BUDDY_SKIP_PIP_INSTALL:-0}" DEFAULT_RUNTIME_BINARY="$SCRIPT_DIR/lg-buddy" RUNTIME_BINARY="$DEFAULT_RUNTIME_BINARY" +RUNTIME_BINARY_OVERRIDDEN=0 +UPGRADE_MODE=0 +MUTATION_STARTED=0 +UPGRADE_COMPLETED=0 usage() { cat </dev/null 2>&1; then + if python3 -m venv "$tmp_venv_dir" >/dev/null 2>&1 && + "$tmp_venv_dir/bin/pip" --version >/dev/null 2>&1; then rm -rf "$tmp_venv_dir" return 0 fi @@ -138,10 +167,6 @@ check_python3_venv() { return 1 } -check_dep "python3-venv" "python3-venv" "check_python3_venv" -check_dep "python3-pip" "python3-pip" "/usr/bin/python3 -m pip --version" -check_dep "zenity" "zenity" "command -v zenity" - write_config_override() { local override_file="$1" local config_path="$2" @@ -214,28 +239,15 @@ resolve_runtime_binary() { echo "Using lg-buddy runtime binary: $RUNTIME_BINARY" } -cleanup() { - if [ -n "$SYSTEM_CONFIG_OVERRIDE_TMP" ]; then - rm -f "$SYSTEM_CONFIG_OVERRIDE_TMP" - fi - - if [ -n "$CONFIG_POINTER_TMP" ]; then - rm -f "$CONFIG_POINTER_TMP" - fi - - if [ -n "$NM_HOOK_TMP" ]; then - rm -f "$NM_HOOK_TMP" +install_missing_prerequisites() { + if [ ${#MISSING_PKGS[@]} -eq 0 ]; then + echo "All prerequisites satisfied." + return fi -} - -trap cleanup EXIT - -if [ ${#MISSING_PKGS[@]} -gt 0 ]; then echo "" echo "Missing: ${MISSING_PKGS[*]}" - # Detect package manager if command -v apt &>/dev/null; then PM="apt" INSTALL_CMD=(apt install -y) @@ -268,14 +280,120 @@ if [ ${#MISSING_PKGS[@]} -gt 0 ]; then echo "Please install the missing packages manually and re-run install.sh." exit 1 fi -else - echo "All prerequisites satisfied." -fi +} + +check_fresh_install_prerequisites() { + echo "" + echo "Checking prerequisites..." + MISSING_PKGS=() + check_dep "python3-venv" "python3-venv" "check_python3_venv" + check_dep "zenity" "zenity" "command -v zenity" + install_missing_prerequisites +} + +require_python_repair_prerequisites() { + echo "Checking Python compatibility-platform repair prerequisites..." + MISSING_PKGS=() + check_dep "python3-venv" "python3-venv" "check_python3_venv" + if [ ${#MISSING_PKGS[@]} -gt 0 ]; then + echo "Upgrade requires Python environment repair, but these prerequisites are missing: ${MISSING_PKGS[*]}" + echo "Install them manually and rerun the upgrade. No installation files were changed." + exit 1 + fi +} + +python_environment_healthy() { + local python_version="" + local site_packages="" + + python_version="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" || return 1 + site_packages="$VENV_DIR/lib/python$python_version/site-packages" + + [ -f "$VENV_DIR/pyvenv.cfg" ] && + [ -x "$VENV_DIR/bin/python" ] && + [ -x "$VENV_DIR/bin/pip" ] && + [ -x "$VENV_DIR/bin/bscpylgtvcommand" ] && + { [ -d "$site_packages/bscpylgtv" ] || [ -f "$site_packages/bscpylgtv.py" ]; } +} + +load_upgrade_configuration() { + CONFIG_FILE="$(sed -n '/[^[:space:]]/{p;q;}' "$CONFIG_POINTER_PATH")" + [ -n "$CONFIG_FILE" ] || { + echo "Installed config pointer is empty: $CONFIG_POINTER_PATH" + exit 1 + } + + TV_PLATFORM="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get tv.platform)" + SCREEN_IDLE_BLANK="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get screen.idle_blank)" + SCREEN_MONITOR_CONFIGURED_BACKEND="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get screen.backend)" + SYSTEM_SLEEP_WAKE_POLICY="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get system.sleep_wake_policy)" + UPDATE_AUTO_CHECK="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get updates.auto_check)" + UPDATE_CHANNEL="$(LG_BUDDY_CONFIG="$CONFIG_FILE" "$RUNTIME_BINARY" settings get updates.channel)" + CANDIDATE_VERSION_OUTPUT="$("$RUNTIME_BINARY" --version)" + + echo "Using existing configuration file at $CONFIG_FILE" + echo "Preserving update channel: $UPDATE_CHANNEL" +} + +prepare_installation_files() { + if [ "$UPGRADE_MODE" -eq 0 ]; then + CONFIG_POINTER_TMP="$(mktemp)" + write_config_pointer "$CONFIG_POINTER_TMP" "$CONFIG_FILE" + fi + SYSTEM_CONFIG_OVERRIDE_TMP="$(mktemp)" + write_config_override "$SYSTEM_CONFIG_OVERRIDE_TMP" "$CONFIG_FILE" + NM_HOOK_TMP="$(mktemp)" + write_nm_pre_down_hook "$NM_HOOK_TMP" +} + +cleanup() { + local status=$? + + if [ -n "$SYSTEM_CONFIG_OVERRIDE_TMP" ]; then + rm -f "$SYSTEM_CONFIG_OVERRIDE_TMP" + fi + + if [ -n "$CONFIG_POINTER_TMP" ]; then + rm -f "$CONFIG_POINTER_TMP" + fi + + if [ -n "$NM_HOOK_TMP" ]; then + rm -f "$NM_HOOK_TMP" + fi + + if [ "$status" -ne 0 ] && [ "$UPGRADE_MODE" -eq 1 ] && [ "$MUTATION_STARTED" -eq 1 ] && [ "$UPGRADE_COMPLETED" -eq 0 ]; then + echo "LG Buddy upgrade did not complete after installation changes began." >&2 + echo "The installation may be partial; rerun this verified bundle with --upgrade after correcting the reported failure." >&2 + fi + + trap - EXIT + exit "$status" +} + +trap cleanup EXIT -# 2. RESOLVE RUST RUNTIME resolve_runtime_binary +REPAIR_PYTHON_ENVIRONMENT=0 + +if [ "$UPGRADE_MODE" -eq 1 ]; then + echo "" + echo "Running candidate upgrade preflight..." + "$RUNTIME_BINARY" upgrade-preflight "$SCRIPT_DIR" + load_upgrade_configuration + + if [ "$TV_PLATFORM" = "lg_webos" ]; then + echo "Native TV platform selected; preserving the existing Python environment unchanged." + elif python_environment_healthy; then + echo "Python compatibility environment is healthy; preserving it unchanged." + else + REPAIR_PYTHON_ENVIRONMENT=1 + "$RUNTIME_BINARY" upgrade-preflight "$SCRIPT_DIR" --repair-python + require_python_repair_prerequisites + fi +else + check_fresh_install_prerequisites -# 3. CONFIGURE SCRIPTS +# CONFIGURE FRESH INSTALLATION echo "" echo "Running configuration script..." # Make sure configure.sh is executable @@ -363,77 +481,77 @@ else ;; esac fi +fi -# 4. CREATE VIRTUAL ENVIRONMENT -echo "Creating Python virtual environment at $VENV_DIR..." -# Recreate the helper venv so OS Python minor-version upgrades do not leave -# bscpylgtv installed under an interpreter-specific site-packages directory -# that the new `/usr/bin/python3` no longer reads. -run_privileged python3 -m venv --clear "$VENV_DIR" -echo "Done." +prepare_installation_files -# 5. INSTALL BSCPYLGTV -if [ "$SKIP_PIP_INSTALL" = "1" ]; then - echo "Skipping bscpylgtv installation because LG_BUDDY_SKIP_PIP_INSTALL=1." -else - echo "Installing bscpylgtv into the virtual environment..." - run_privileged "$VENV_DIR/bin/pip" install bscpylgtv +# 4. CREATE VIRTUAL ENVIRONMENT +if [ "$UPGRADE_MODE" -eq 0 ] || [ "$REPAIR_PYTHON_ENVIRONMENT" -eq 1 ]; then + MUTATION_STARTED=1 + echo "Creating Python virtual environment at $VENV_DIR..." + # Recreate the helper venv so OS Python minor-version upgrades do not leave + # bscpylgtv installed under an interpreter-specific site-packages directory + # that the new `/usr/bin/python3` no longer reads. + run_privileged python3 -m venv --clear "$VENV_DIR" echo "Done." + + if [ "$SKIP_PIP_INSTALL" = "1" ]; then + echo "Skipping bscpylgtv installation because LG_BUDDY_SKIP_PIP_INSTALL=1." + else + echo "Installing bscpylgtv into the virtual environment..." + run_privileged "$VENV_DIR/bin/pip" install bscpylgtv + echo "Done." + fi fi # 6. INSTALL RUST RUNTIME AND SUPPORT FILES +MUTATION_STARTED=1 echo "Installing Rust runtime and support files..." run_privileged install -m 755 "$RUNTIME_BINARY" "$RUNTIME_INSTALL_PATH" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Startup" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Shutdown" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_On" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_Off" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_Monitor" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_sleep_pre" -run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Brightness" -run_privileged rm -f "$COMMON_HELPER_PATH" -run_privileged rm -f "$CONFIG_POINTER_PATH" -run_privileged rmdir "$SYSTEM_LIB_DIR" 2>/dev/null || true -run_privileged install -d "$SYSTEM_LIB_DIR" -CONFIG_POINTER_TMP="$(mktemp)" -write_config_pointer "$CONFIG_POINTER_TMP" "$CONFIG_FILE" -run_privileged install -m 644 "$CONFIG_POINTER_TMP" "$CONFIG_POINTER_PATH" -rm -f "$CONFIG_POINTER_TMP" -CONFIG_POINTER_TMP="" +if [ "$UPGRADE_MODE" -eq 0 ]; then + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Startup" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Shutdown" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_On" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_Off" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Screen_Monitor" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_sleep_pre" + run_privileged rm -f "${SYSTEM_BIN_DIR}/LG_Buddy_Brightness" + run_privileged rm -f "$COMMON_HELPER_PATH" + run_privileged rm -f "$CONFIG_POINTER_PATH" + run_privileged rmdir "$SYSTEM_LIB_DIR" 2>/dev/null || true +fi +if [ "$UPGRADE_MODE" -eq 0 ]; then + run_privileged install -d "$SYSTEM_LIB_DIR" + run_privileged install -m 644 "$CONFIG_POINTER_TMP" "$CONFIG_POINTER_PATH" +fi echo "Installing brightness control desktop entry..." -run_privileged mkdir -p "$APPLICATIONS_DIR" -run_privileged cp "$SCRIPT_DIR/LG_Buddy_Brightness.desktop" "$DESKTOP_ENTRY_PATH" -cp "$SCRIPT_DIR/LG_Buddy_Brightness.desktop" ~/Desktop/ 2>/dev/null || true +run_privileged install -d "$APPLICATIONS_DIR" +run_privileged install -m 644 "$SCRIPT_DIR/LG_Buddy_Brightness.desktop" "$DESKTOP_ENTRY_PATH" +if [ "$UPGRADE_MODE" -eq 0 ]; then + cp "$SCRIPT_DIR/LG_Buddy_Brightness.desktop" ~/Desktop/ 2>/dev/null || true +elif [ -f "$HOME/Desktop/LG_Buddy_Brightness.desktop" ]; then + cp "$SCRIPT_DIR/LG_Buddy_Brightness.desktop" "$HOME/Desktop/LG_Buddy_Brightness.desktop" +fi echo "Done." # 7. SETUP SYSTEMD SERVICES echo "Copying and enabling systemd services..." run_privileged install -d "$SYSTEMD_SYSTEM_DIR" run_privileged install -d "$TMPFILES_CONF_DIR" -run_privileged cp "$SCRIPT_DIR/systemd/LG_Buddy.service" "$SYSTEMD_SERVICE_PATH" -run_privileged cp "$SCRIPT_DIR/systemd/lg_buddy.conf" "$TMPFILES_CONF_PATH" +run_privileged install -m 644 "$SCRIPT_DIR/systemd/LG_Buddy.service" "$SYSTEMD_SERVICE_PATH" +run_privileged install -m 644 "$SCRIPT_DIR/systemd/lg_buddy.conf" "$TMPFILES_CONF_PATH" run_privileged install -d "$SYSTEMD_SERVICE_OVERRIDE_DIR" -SYSTEM_CONFIG_OVERRIDE_TMP="$(mktemp)" -write_config_override "$SYSTEM_CONFIG_OVERRIDE_TMP" "$CONFIG_FILE" run_privileged install -m 644 "$SYSTEM_CONFIG_OVERRIDE_TMP" "${SYSTEMD_SERVICE_OVERRIDE_DIR}/config.conf" -rm -f "$SYSTEM_CONFIG_OVERRIDE_TMP" -SYSTEM_CONFIG_OVERRIDE_TMP="" -cleanup_legacy_sleep_wake_handlers +if [ "$UPGRADE_MODE" -eq 0 ]; then + cleanup_legacy_sleep_wake_handlers +fi -run_privileged cp "$SCRIPT_DIR/systemd/LG_Buddy_lifecycle.service" "$SYSTEMD_LIFECYCLE_SERVICE_PATH" +run_privileged install -m 644 "$SCRIPT_DIR/systemd/LG_Buddy_lifecycle.service" "$SYSTEMD_LIFECYCLE_SERVICE_PATH" run_privileged install -d "$SYSTEMD_LIFECYCLE_OVERRIDE_DIR" -SYSTEM_CONFIG_OVERRIDE_TMP="$(mktemp)" -write_config_override "$SYSTEM_CONFIG_OVERRIDE_TMP" "$CONFIG_FILE" run_privileged install -m 644 "$SYSTEM_CONFIG_OVERRIDE_TMP" "${SYSTEMD_LIFECYCLE_OVERRIDE_DIR}/config.conf" -rm -f "$SYSTEM_CONFIG_OVERRIDE_TMP" -SYSTEM_CONFIG_OVERRIDE_TMP="" run_privileged install -d "$NM_PRE_DOWN_DIR" -NM_HOOK_TMP="$(mktemp)" -write_nm_pre_down_hook "$NM_HOOK_TMP" run_privileged install -m 755 "$NM_HOOK_TMP" "$NM_LIFECYCLE_HOOK_PATH" -rm -f "$NM_HOOK_TMP" -NM_HOOK_TMP="" if [ "$SKIP_SYSTEMD_ACTIONS" = "1" ]; then echo "Skipping systemd tmpfiles and enable actions because LG_BUDDY_SKIP_SYSTEMD_ACTIONS=1." @@ -449,16 +567,16 @@ echo "Done." # 8. INSTALL USER SERVICES echo "Installing background update check user timer..." mkdir -p "$USER_SYSTEMD_DIR" -cp "$SCRIPT_DIR/systemd/LG_Buddy_update_check.service" "$USER_UPDATE_CHECK_SERVICE_PATH" -cp "$SCRIPT_DIR/systemd/LG_Buddy_update_check.timer" "$USER_UPDATE_CHECK_TIMER_PATH" +install -m 644 "$SCRIPT_DIR/systemd/LG_Buddy_update_check.service" "$USER_UPDATE_CHECK_SERVICE_PATH" +install -m 644 "$SCRIPT_DIR/systemd/LG_Buddy_update_check.timer" "$USER_UPDATE_CHECK_TIMER_PATH" mkdir -p "$USER_UPDATE_CHECK_OVERRIDE_DIR" -write_config_override "${USER_UPDATE_CHECK_OVERRIDE_DIR}/config.conf" "$CONFIG_FILE" +install -m 644 "$SYSTEM_CONFIG_OVERRIDE_TMP" "${USER_UPDATE_CHECK_OVERRIDE_DIR}/config.conf" echo "Done." echo "Installing screen monitor user service..." -cp "$SCRIPT_DIR/systemd/LG_Buddy_screen.service" "$USER_SCREEN_SERVICE_PATH" +install -m 644 "$SCRIPT_DIR/systemd/LG_Buddy_screen.service" "$USER_SCREEN_SERVICE_PATH" mkdir -p "$USER_SCREEN_OVERRIDE_DIR" -write_config_override "${USER_SCREEN_OVERRIDE_DIR}/config.conf" "$CONFIG_FILE" +install -m 644 "$SYSTEM_CONFIG_OVERRIDE_TMP" "${USER_SCREEN_OVERRIDE_DIR}/config.conf" if [ "$SKIP_SYSTEMD_ACTIONS" != "1" ]; then systemctl --user daemon-reload fi @@ -499,7 +617,19 @@ else echo "System sleep/wake TV control disabled by config. Lifecycle integration is installed and will no-op until re-enabled." fi -echo "Installation complete!" -echo "The user-session service has been installed." -echo "Please restart your computer for all changes to take full effect." -echo "NOTE: On first use, you may need to accept a prompt on your TV to allow this application to connect." +if [ "$UPGRADE_MODE" -eq 1 ]; then + INSTALLED_VERSION_OUTPUT="$("$RUNTIME_INSTALL_PATH" --version)" + if ! cmp -s "$RUNTIME_BINARY" "$RUNTIME_INSTALL_PATH" || [ "$INSTALLED_VERSION_OUTPUT" != "$CANDIDATE_VERSION_OUTPUT" ]; then + echo "Installed binary identity does not match the verified candidate." >&2 + echo "Rerun this verified bundle with --upgrade to repair the partial installation." >&2 + exit 1 + fi + UPGRADE_COMPLETED=1 + echo "Upgrade complete!" + echo "$INSTALLED_VERSION_OUTPUT" +else + echo "Installation complete!" + echo "The user-session service has been installed." + echo "Please restart your computer for all changes to take full effect." + echo "NOTE: On first use, you may need to accept a prompt on your TV to allow this application to connect." +fi diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index fd851e0..22857c6 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -1,6 +1,7 @@ #!/bin/bash set -euo pipefail +umask 0022 usage() { echo "Usage: $0 --archive [--work-dir ] [--skip-pip-install] [--expected-tag --expected-version --expected-channel --expected-target --expected-commit ]" @@ -104,7 +105,7 @@ assert_cli_surface() { fi printf '%s\n' "$removed_channel_output" | grep -F -q 'unexpected arguments for `updates check`: --channel stable' - for hidden in startup shutdown screen-off screen-on "updates background-check"; do + for hidden in startup shutdown screen-off screen-on "updates background-check" upgrade-preflight; do if printf '%s\n' "$help_output" | grep -F -q "$hidden"; then echo "Hidden entrypoint appeared in public help: $hidden" exit 1 @@ -123,6 +124,7 @@ assert_cli_surface() { assert_hidden_compatibility_alias "$binary" shutdown shutdown assert_hidden_compatibility_alias "$binary" screen-off screen-off assert_hidden_compatibility_alias "$binary" screen-on screen-on + assert_hidden_compatibility_alias "$binary" upgrade-preflight upgrade-preflight "$BUNDLE_DIR" } assert_lifecycle_topology_installed() { @@ -355,6 +357,7 @@ STALE_VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/lib/python-old/site-packag INSTALLED_POINTER="$INSTALL_ROOT/usr/lib/lg-buddy/config-path" SYSTEM_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy.service" LIFECYCLE_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_lifecycle.service" +TMPFILES_CONFIG="$INSTALL_ROOT/etc/tmpfiles.d/lg_buddy.conf" LEGACY_SLEEP_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_sleep.service" LEGACY_WAKE_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_wake.service" SYSTEM_SLEEP_HOOK="$INSTALL_ROOT/usr/lib/systemd/system-sleep/LG_Buddy_sleep_hook" @@ -363,6 +366,7 @@ USER_UPDATE_CHECK_SERVICE="$HOME/.config/systemd/user/LG_Buddy_update_check.serv USER_UPDATE_CHECK_TIMER="$HOME/.config/systemd/user/LG_Buddy_update_check.timer" USER_UPDATE_CHECK_OVERRIDE="$HOME/.config/systemd/user/LG_Buddy_update_check.service.d/config.conf" DESKTOP_ENTRY="$INSTALL_ROOT/usr/share/applications/LG_Buddy_Brightness.desktop" +USER_DESKTOP_ENTRY="$HOME/Desktop/LG_Buddy_Brightness.desktop" NM_SLEEP_HOOK="$INSTALL_ROOT/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_sleep" NM_LIFECYCLE_HOOK="$INSTALL_ROOT/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_lifecycle" @@ -507,41 +511,274 @@ done cp "$VALID_PLATFORM_CONFIG" "$CONFIG_FILE" rm -f "$VALID_PLATFORM_CONFIG" "$INVALID_PLATFORM_CONFIG" "$INVALID_PLATFORM_OUTPUT" -# A real in-place install must preserve an opted-in platform and its -# profile-scoped native credential. Do this before the existing uninstall and -# fresh-install coverage below, without removing the user configuration. +# A real upgrade must refuse before sudo when preflight fails, then preserve an +# opted-in native installation while replacing every owned candidate asset. NATIVE_ACCESS_TOKEN_FILE="$(dirname "$CONFIG_FILE")/tvs/primary/access-token.json" NATIVE_PROFILE_DIR="$(dirname "$NATIVE_ACCESS_TOKEN_FILE")" NATIVE_PROFILES_DIR="$(dirname "$NATIVE_PROFILE_DIR")" NATIVE_ACCESS_TOKEN_SNAPSHOT="$WORK_DIR/native-access-token.snapshot" +CONFIG_SNAPSHOT="$WORK_DIR/config.snapshot" +CONFIG_POINTER_SNAPSHOT="$WORK_DIR/config-pointer.snapshot" +NATIVE_VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/native-upgrade-marker" NATIVE_ACCESS_TOKEN_CONTENT='{"access_token":"release-smoke-native-token"}' mkdir -p "$NATIVE_PROFILE_DIR" printf '%s\n' "$NATIVE_ACCESS_TOKEN_CONTENT" >"$NATIVE_ACCESS_TOKEN_FILE" chmod 600 "$NATIVE_ACCESS_TOKEN_FILE" cp "$NATIVE_ACCESS_TOKEN_FILE" "$NATIVE_ACCESS_TOKEN_SNAPSHOT" +cp "$CONFIG_FILE" "$CONFIG_SNAPSHOT" +cp "$INSTALLED_POINTER" "$CONFIG_POINTER_SNAPSHOT" +touch "$NATIVE_VENV_MARKER" "$INSTALLED_BINARY" settings get tv.platform | grep -q '^lg_webos$' +printf '#!/bin/sh\nprintf "stale installed runtime\\n"\n' >"$INSTALLED_BINARY" +chmod 755 "$INSTALLED_BINARY" +for stale_target in \ + "$SYSTEM_SERVICE" \ + "$LIFECYCLE_SERVICE" \ + "$TMPFILES_CONFIG" \ + "$DESKTOP_ENTRY" \ + "$USER_SCREEN_SERVICE" \ + "$USER_UPDATE_CHECK_SERVICE" \ + "$USER_UPDATE_CHECK_TIMER" \ + "$NM_LIFECYCLE_HOOK" +do + printf 'stale installed integration\n' >"$stale_target" +done + +INSTALLER_STUB_DIR="$WORK_DIR/installer-stubs" +SUDO_MARKER="$WORK_DIR/sudo-invoked" +SUDO_SPY="$INSTALLER_STUB_DIR/sudo-spy" +REFUSAL_OUTPUT="$WORK_DIR/upgrade-refusal.output" +mkdir -p "$INSTALLER_STUB_DIR" +cat >"$SUDO_SPY" <<'EOF' +#!/bin/sh +: >"${LG_BUDDY_SUDO_MARKER:?}" +exit 97 +EOF +chmod 755 "$SUDO_SPY" +mv "$SYSTEM_SERVICE" "$SYSTEM_SERVICE.preflight-refusal" +if ( + export LG_BUDDY_SUDO_CMD="$SUDO_SPY" + export LG_BUDDY_SUDO_MARKER="$SUDO_MARKER" + cd "$BUNDLE_DIR" + ./install.sh --upgrade >"$REFUSAL_OUTPUT" 2>&1 +); then + echo "Upgrade unexpectedly passed with a missing installed service." + exit 1 +fi +mv "$SYSTEM_SERVICE.preflight-refusal" "$SYSTEM_SERVICE" +grep -F -q 'upgrade preflight: refused' "$REFUSAL_OUTPUT" +[ ! -e "$SUDO_MARKER" ] || { + echo "Upgrade requested sudo after a candidate preflight refusal." + exit 1 +} +grep -F -q 'stale installed integration' "$DESKTOP_ENTRY" +grep -F -q 'stale installed runtime' "$INSTALLED_BINARY" +cmp -s "$CONFIG_SNAPSHOT" "$CONFIG_FILE" || { + echo "Refused upgrade changed the user configuration." + exit 1 +} + +CONFIGURE_SCRIPT_SNAPSHOT="$WORK_DIR/configure.sh.snapshot" +CONFIGURE_MARKER="$WORK_DIR/configure-invoked" +SERVICE_ACTION_LOG="$WORK_DIR/upgrade-service-actions.log" +PARTIAL_SERVICE_ACTION_LOG="$WORK_DIR/partial-upgrade-service-actions.log" +EXPECTED_SERVICE_ACTION_LOG="$WORK_DIR/expected-upgrade-service-actions.log" +UPGRADE_OUTPUT="$WORK_DIR/upgrade.output" +PARTIAL_UPGRADE_OUTPUT="$WORK_DIR/partial-upgrade.output" +cp -p "$BUNDLE_DIR/configure.sh" "$CONFIGURE_SCRIPT_SNAPSHOT" +cat >"$BUNDLE_DIR/configure.sh" <<'EOF' +#!/bin/sh +: >"${LG_BUDDY_CONFIGURE_MARKER:?}" +exit 91 +EOF +chmod 755 "$BUNDLE_DIR/configure.sh" +cat >"$INSTALLER_STUB_DIR/systemctl" <<'EOF' +#!/bin/sh +set -eu +printf 'systemctl %s\n' "$*" >>"${LG_BUDDY_SERVICE_ACTION_LOG:?}" +case "$*" in + is-system-running|"--user is-system-running") + printf 'running\n' + ;; + daemon-reload) + if [ "${LG_BUDDY_FAIL_SYSTEM_RELOAD:-0}" = "1" ]; then + exit 73 + fi + ;; +esac +EOF +cat >"$INSTALLER_STUB_DIR/systemd-tmpfiles" <<'EOF' +#!/bin/sh +set -eu +printf 'tmpfiles %s\n' "$*" >>"${LG_BUDDY_SERVICE_ACTION_LOG:?}" +EOF +chmod 755 "$INSTALLER_STUB_DIR/systemctl" "$INSTALLER_STUB_DIR/systemd-tmpfiles" + +PARTIAL_UPGRADE_STATUS=0 +if ( + export PATH="$INSTALLER_STUB_DIR:$PATH" + export LG_BUDDY_CONFIGURE_MARKER="$CONFIGURE_MARKER" + export LG_BUDDY_SERVICE_ACTION_LOG="$PARTIAL_SERVICE_ACTION_LOG" + export LG_BUDDY_FAIL_SYSTEM_RELOAD="1" + export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="0" + cd "$BUNDLE_DIR" + ./install.sh --upgrade >"$PARTIAL_UPGRADE_OUTPUT" 2>&1 +); then + echo "Upgrade unexpectedly succeeded after a simulated post-mutation failure." + exit 1 +else + PARTIAL_UPGRADE_STATUS=$? +fi +[ "$PARTIAL_UPGRADE_STATUS" -eq 73 ] || { + cat "$PARTIAL_UPGRADE_OUTPUT" + echo "Partial upgrade returned status $PARTIAL_UPGRADE_STATUS instead of 73." + exit 1 +} +grep -F -q 'upgrade did not complete after installation changes began' "$PARTIAL_UPGRADE_OUTPUT" +grep -F -q 'installation may be partial' "$PARTIAL_UPGRADE_OUTPUT" +grep -F -q 'rerun this verified bundle with --upgrade' "$PARTIAL_UPGRADE_OUTPUT" +[ ! -e "$CONFIGURE_MARKER" ] || { + echo "Partial upgrade invoked configure.sh." + exit 1 +} +[ -e "$NATIVE_VENV_MARKER" ] || { + echo "Partial native upgrade recreated the Python virtual environment." + exit 1 +} +cmp -s "$CONFIG_SNAPSHOT" "$CONFIG_FILE" +cmp -s "$CONFIG_POINTER_SNAPSHOT" "$INSTALLED_POINTER" +cmp -s "$NATIVE_ACCESS_TOKEN_SNAPSHOT" "$NATIVE_ACCESS_TOKEN_FILE" + +mkdir -p "$(dirname "$USER_DESKTOP_ENTRY")" +printf 'stale user desktop launcher\n' >"$USER_DESKTOP_ENTRY" + +UPGRADE_STATUS=0 +if ( + export PATH="$INSTALLER_STUB_DIR:$PATH" + export LG_BUDDY_CONFIGURE_MARKER="$CONFIGURE_MARKER" + export LG_BUDDY_SERVICE_ACTION_LOG="$SERVICE_ACTION_LOG" + export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="0" + cd "$BUNDLE_DIR" + ./install.sh --upgrade >"$UPGRADE_OUTPUT" 2>&1 +); then + : +else + UPGRADE_STATUS=$? +fi +cp -p "$CONFIGURE_SCRIPT_SNAPSHOT" "$BUNDLE_DIR/configure.sh" +if [ "$UPGRADE_STATUS" -ne 0 ]; then + cat "$UPGRADE_OUTPUT" + echo "Native release-bundle upgrade failed with status $UPGRADE_STATUS." + exit 1 +fi + +[ ! -e "$CONFIGURE_MARKER" ] || { + echo "Upgrade invoked configure.sh." + exit 1 +} +grep -F -q 'Upgrade complete!' "$UPGRADE_OUTPUT" +cat >"$EXPECTED_SERVICE_ACTION_LOG" </dev/null 2>&1; then + mkdir -p "$VENV_SITE_PACKAGES/bscpylgtv" + printf '__version__ = "smoke"\n' >"$VENV_SITE_PACKAGES/bscpylgtv/__init__.py" +fi +if [ ! -x "$INSTALLED_BSCPYLGTV" ]; then + printf '#!/bin/sh\nexit 0\n' >"$INSTALLED_BSCPYLGTV" + chmod 755 "$INSTALLED_BSCPYLGTV" +fi +rm -f "$USER_DESKTOP_ENTRY" +HEALTHY_VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/healthy-upgrade-marker" +touch "$HEALTHY_VENV_MARKER" ( - unset LG_BUDDY_TV_IP - unset LG_BUDDY_TV_MAC - unset LG_BUDDY_INPUT - unset LG_BUDDY_SCREEN_BACKEND - unset LG_BUDDY_SCREEN_IDLE_TIMEOUT - unset LG_BUDDY_SCREEN_RESTORE_POLICY - unset LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY - unset LG_BUDDY_DISABLE_SLEEP_WAKE + export LG_BUDDY_SKIP_PIP_INSTALL="1" cd "$BUNDLE_DIR" - ./install.sh + ./install.sh --upgrade +) +[ -e "$HEALTHY_VENV_MARKER" ] || { + echo "Healthy compatibility environment was recreated during upgrade." + exit 1 +} +[ ! -e "$USER_DESKTOP_ENTRY" ] || { + echo "Upgrade recreated a user-removed Desktop launcher." + exit 1 +} + +rm -f "$INSTALLED_BSCPYLGTV" +REPAIR_VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/repair-upgrade-marker" +touch "$REPAIR_VENV_MARKER" +( + export LG_BUDDY_SKIP_PIP_INSTALL="1" + cd "$BUNDLE_DIR" + ./install.sh --upgrade ) +[ ! -e "$REPAIR_VENV_MARKER" ] || { + echo "Unhealthy compatibility environment was not repaired." + exit 1 +} +assert_executable "$INSTALLED_VENV_PIP" + +rm -rf "$INSTALL_ROOT/usr/bin/LG_Buddy_PIP" +( + export LG_BUDDY_SKIP_PIP_INSTALL="1" + cd "$BUNDLE_DIR" + ./install.sh --upgrade +) +assert_executable "$INSTALLED_VENV_PIP" assert_file "$CONFIG_FILE" -grep -q '^tvs_primary_platform=lg_webos$' "$CONFIG_FILE" -"$INSTALLED_BINARY" settings get tv.platform | grep -q '^lg_webos$' assert_file "$NATIVE_ACCESS_TOKEN_FILE" -cmp -s "$NATIVE_ACCESS_TOKEN_SNAPSHOT" "$NATIVE_ACCESS_TOKEN_FILE" || { - echo "In-place install changed the stored native access token." - exit 1 -} rm -f "$NATIVE_ACCESS_TOKEN_SNAPSHOT" export LG_BUDDY_REMOVE_CONFIG="1" From 24757e1b8694d6897efcee91974eca575655a7cc Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:07:14 +0300 Subject: [PATCH 07/22] feat: securely acquire release bundles --- Cargo.lock | 110 +- crates/lg-buddy/Cargo.toml | 4 + crates/lg-buddy/build.rs | 57 + crates/lg-buddy/src/lib.rs | 1 + crates/lg-buddy/src/release_bundle.rs | 3593 +++++++++++++++++++++++++ crates/lg-buddy/src/updates.rs | 361 ++- crates/lg-buddy/src/version.rs | 25 +- docs/architecture-overview.md | 23 + docs/testing-strategy.md | 19 + 9 files changed, 4150 insertions(+), 43 deletions(-) create mode 100644 crates/lg-buddy/build.rs create mode 100644 crates/lg-buddy/src/release_bundle.rs diff --git a/Cargo.lock b/Cargo.lock index 4f6c5f0..96c7198 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -157,7 +166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -235,6 +244,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -278,6 +296,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -393,15 +421,25 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -455,6 +493,16 @@ dependencies = [ "nix", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -574,6 +622,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -839,14 +897,18 @@ dependencies = [ "dbus", "dbus-crossroads", "evdev", + "flate2", "libc", "rustls", "semver", "serde", "serde_json", + "sha2", + "tar", "tokio", "tungstenite", "ureq", + "url", "wayland-client", "wayland-protocols", ] @@ -1283,8 +1345,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -1407,6 +1480,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -1600,6 +1684,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1803,6 +1893,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/lg-buddy/Cargo.toml b/crates/lg-buddy/Cargo.toml index 71a9bc2..c18bccf 100644 --- a/crates/lg-buddy/Cargo.toml +++ b/crates/lg-buddy/Cargo.toml @@ -8,13 +8,17 @@ publish = false dbus = { version = "0.9.11", features = ["vendored"] } dbus-crossroads = "0.5.3" evdev = "0.13.2" +flate2 = "1" libc = "0.2" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" +tar = "0.4" tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } ureq = "2" +url = "2" wayland-client = { version = "0.31.15", default-features = false } wayland-protocols = { version = "0.32.13", default-features = false, features = ["client", "staging"] } diff --git a/crates/lg-buddy/build.rs b/crates/lg-buddy/build.rs new file mode 100644 index 0000000..0e65315 --- /dev/null +++ b/crates/lg-buddy/build.rs @@ -0,0 +1,57 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +const PREFIX: &str = "LG_BUDDY_RELEASE_IDENTITY_V1\0"; +const SUFFIX: &str = "\0LG_BUDDY_RELEASE_IDENTITY_END\0"; + +fn main() { + println!("cargo:rerun-if-env-changed=LG_BUDDY_RELEASE_VERSION"); + println!("cargo:rerun-if-env-changed=LG_BUDDY_BUILD_COMMIT"); + + let package_version = env::var("CARGO_PKG_VERSION").expect("Cargo package version"); + let release_version = env::var("LG_BUDDY_RELEASE_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()); + let version = release_version.as_deref().unwrap_or(&package_version); + let channel = match release_version.as_deref() { + None => "dev", + Some(value) if value.contains('-') => "prerelease", + Some(_) => "stable", + }; + let commit = env::var("LG_BUDDY_BUILD_COMMIT") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let target = env::var("TARGET").expect("Cargo target triple"); + for (name, value) in [ + ("version", version), + ("channel", channel), + ("target", target.as_str()), + ("commit", commit.as_str()), + ] { + assert!( + value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')), + "{name} contains unsupported release-identity characters" + ); + } + + let manifest = format!( + "{{\"schema_version\":1,\"critical\":[\"release_tag\",\"version\",\"channel\",\"target\",\"commit\"],\"release_tag\":\"v{version}\",\"version\":\"{version}\",\"channel\":\"{channel}\",\"target\":\"{target}\",\"commit\":\"{commit}\"}}" + ); + let record = format!("{PREFIX}{manifest}{SUFFIX}").into_bytes(); + let bytes = record + .iter() + .map(u8::to_string) + .collect::>() + .join(","); + let generated = format!( + "#[used]\n#[link_section = \".lg_buddy.identity\"]\nstatic LG_BUDDY_EMBEDDED_RELEASE_IDENTITY: [u8; {}] = [{bytes}];\n", + record.len() + ); + let output = + PathBuf::from(env::var_os("OUT_DIR").expect("Cargo OUT_DIR")).join("release_identity.rs"); + fs::write(output, generated).expect("write embedded release identity"); +} diff --git a/crates/lg-buddy/src/lib.rs b/crates/lg-buddy/src/lib.rs index 9817965..6bf045c 100644 --- a/crates/lg-buddy/src/lib.rs +++ b/crates/lg-buddy/src/lib.rs @@ -9,6 +9,7 @@ pub mod lifecycle; pub mod notifications; pub mod platform_access_token; pub mod policy; +pub mod release_bundle; pub mod runtime_phase; pub mod screen; pub mod session; diff --git a/crates/lg-buddy/src/release_bundle.rs b/crates/lg-buddy/src/release_bundle.rs new file mode 100644 index 0000000..10f90e9 --- /dev/null +++ b/crates/lg-buddy/src/release_bundle.rs @@ -0,0 +1,3593 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use flate2::read::MultiGzDecoder; +use semver::Version; +use serde::de::{self, IgnoredAny, MapAccess, Visitor}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tar::{Archive, EntryType}; +use url::Url; + +use crate::updates::{ReleaseAsset, ReleaseInfo, UpdateChannel}; + +const REPOSITORY_OWNER: &str = "Staphylococcus"; +const REPOSITORY_NAME: &str = "LG_Buddy"; +const GITHUB_API_ROOT: &str = "https://api.github.com"; +const GITHUB_RELEASE_ASSET_HOST: &str = "release-assets.githubusercontent.com"; +const GITHUB_API_VERSION: &str = "2026-03-10"; +const GITHUB_JSON_ACCEPT: &str = "application/vnd.github+json"; +const GITHUB_ASSET_ACCEPT: &str = "application/octet-stream"; +const GITHUB_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const GITHUB_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const RELEASE_TARGET: &str = "x86_64-unknown-linux-musl"; +const CHECKSUM_ASSET_NAME: &str = "sha256sums.txt"; +const MANIFEST_NAME: &str = "release-manifest.json"; +const ACQUISITION_DIR_NAME: &str = "release-bundles"; +const LOCK_FILE_NAME: &str = ".acquisition.lock"; +const MAX_TAG_DEPTH: usize = 4; +const MAX_API_BYTES: u64 = 256 * 1024; +const MAX_API_ERROR_BYTES: u64 = 16 * 1024; +const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_CHECKSUM_BYTES: u64 = 64 * 1024; +const MAX_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_ARCHIVE_ENTRIES: usize = 4_096; +const MAX_ARCHIVE_FILES: usize = 2_048; +const MAX_ARCHIVE_PATH_BYTES: usize = 4_096; +const MAX_ARCHIVE_METADATA_BYTES: u64 = 64 * 1024; +const MAX_ARCHIVE_FILE_BYTES: u64 = 128 * 1024 * 1024; +const MAX_ARCHIVE_EXPANDED_BYTES: u64 = 256 * 1024 * 1024; +const MAX_ARCHIVE_TRAILING_BYTES: u64 = 1024 * 1024; + +static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseIdentity { + release_tag: String, + version: Version, + channel: UpdateChannel, + target: String, + commit: String, +} + +impl ReleaseIdentity { + pub fn release_tag(&self) -> &str { + &self.release_tag + } + + pub fn version(&self) -> &Version { + &self.version + } + + pub fn channel(&self) -> UpdateChannel { + self.channel + } + + pub fn target(&self) -> &str { + &self.target + } + + pub fn commit(&self) -> &str { + &self.commit + } +} + +#[derive(Debug)] +pub struct VerifiedReleaseBundle { + root: PathBuf, + identity: ReleaseIdentity, + _staging: StagingDirectory, +} + +impl VerifiedReleaseBundle { + pub fn root(&self) -> &Path { + &self.root + } + + pub fn identity(&self) -> &ReleaseIdentity { + &self.identity + } +} + +#[derive(Debug)] +pub enum BundleAcquisitionError { + CachePathUnavailable, + UnsafeStaging(String), + ConcurrentAcquisition, + ReleaseMetadata(String), + TagResolution(String), + Http { + url: String, + message: String, + }, + HttpStatus { + url: String, + status: u16, + body: String, + }, + ResponseTooLarge { + label: String, + max_bytes: u64, + }, + InterruptedAsset { + name: String, + expected: u64, + actual: u64, + }, + Digest(String), + Checksum(String), + Archive(String), + Manifest(String), + Binary(String), + Io(io::Error), +} + +impl fmt::Display for BundleAcquisitionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CachePathUnavailable => write!( + f, + "could not resolve release-bundle staging from XDG_CACHE_HOME or HOME" + ), + Self::UnsafeStaging(message) => { + write!(f, "release-bundle staging is unsafe: {message}") + } + Self::ConcurrentAcquisition => { + write!(f, "another release-bundle acquisition is already running") + } + Self::ReleaseMetadata(message) => write!(f, "invalid release metadata: {message}"), + Self::TagResolution(message) => write!(f, "could not resolve release tag: {message}"), + Self::Http { url, message } => write!(f, "request to `{url}` failed: {message}"), + Self::HttpStatus { url, status, body } => { + if body.trim().is_empty() { + write!(f, "request to `{url}` returned HTTP status {status}") + } else { + write!( + f, + "request to `{url}` returned HTTP status {status}: {}", + body.trim() + ) + } + } + Self::ResponseTooLarge { label, max_bytes } => { + write!(f, "{label} exceeded the {max_bytes}-byte limit") + } + Self::InterruptedAsset { + name, + expected, + actual, + } => write!( + f, + "release asset `{name}` ended after {actual} bytes; metadata declares {expected}" + ), + Self::Digest(message) => write!(f, "release asset digest check failed: {message}"), + Self::Checksum(message) => write!(f, "published checksum check failed: {message}"), + Self::Archive(message) => write!(f, "release archive check failed: {message}"), + Self::Manifest(message) => write!(f, "release manifest check failed: {message}"), + Self::Binary(message) => write!(f, "bundled binary identity check failed: {message}"), + Self::Io(err) => write!(f, "release-bundle acquisition failed: {err}"), + } + } +} + +impl Error for BundleAcquisitionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for BundleAcquisitionError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +pub fn acquire_release_bundle( + release: &ReleaseInfo, +) -> Result { + let cache_root = acquisition_cache_root_from_env()?; + let source = UreqGitHubSource::new(); + acquire_release_bundle_with(release, &cache_root, &source, &EmbeddedBinaryIdentityReader) +} + +fn acquisition_cache_root_from_env() -> Result { + let base = std::env::var_os("XDG_CACHE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".cache")) + }) + .ok_or(BundleAcquisitionError::CachePathUnavailable)?; + Ok(base.join("lg-buddy").join(ACQUISITION_DIR_NAME)) +} + +fn acquire_release_bundle_with( + release: &ReleaseInfo, + cache_root: &Path, + source: &S, + binary_reader: &B, +) -> Result { + validate_release_identity(release)?; + let staging = StagingDirectory::create(cache_root)?; + let fresh_release = source.fetch_release_by_tag(release)?; + validate_release_identity(&fresh_release)?; + if fresh_release.version() != release.version() + || fresh_release.channel() != release.channel() + || fresh_release.tag_name() != release.tag_name() + { + return Err(BundleAcquisitionError::ReleaseMetadata( + "fresh release-by-tag metadata disagrees with the selected release".to_string(), + )); + } + let selected = select_release_assets(&fresh_release)?; + let commit = source.resolve_tag_commit(fresh_release.tag_name())?; + validate_commit_sha(&commit).map_err(BundleAcquisitionError::TagResolution)?; + + let checksum_path = staging.path.join(CHECKSUM_ASSET_NAME); + let archive_path = staging.path.join(selected.archive.name()); + + let checksum_download = + source.download_asset(selected.checksum, &checksum_path, MAX_CHECKSUM_BYTES)?; + pin_download_file(&checksum_path, &checksum_download.file)?; + verify_download(selected.checksum, &checksum_download.facts)?; + + let archive_download = + source.download_asset(selected.archive, &archive_path, MAX_ARCHIVE_BYTES)?; + pin_download_file(&archive_path, &archive_download.file)?; + verify_download(selected.archive, &archive_download.facts)?; + + let published_digest = + published_archive_digest(&checksum_download.file, selected.archive.name())?; + let github_archive_digest = required_github_digest(selected.archive)?; + if published_digest != github_archive_digest { + return Err(BundleAcquisitionError::Checksum(format!( + "archive digest `{published_digest}` does not agree with GitHub digest `{github_archive_digest}`" + ))); + } + + let expected = ReleaseIdentity { + release_tag: fresh_release.tag_name().to_string(), + version: fresh_release.version().clone(), + channel: fresh_release.channel(), + target: RELEASE_TARGET.to_string(), + commit, + }; + let plan = inspect_archive(&archive_download.file, selected.archive.name(), &expected)?; + let root = extract_archive(&archive_download.file, &staging.path, &plan)?; + let observed = binary_reader.read_identity( + &root.join("lg-buddy"), + RELEASE_TARGET, + fresh_release.tag_name(), + )?; + if observed != expected { + return Err(BundleAcquisitionError::Binary(format!( + "identity {:?} does not match verified release identity {:?}", + observed, expected + ))); + } + + Ok(VerifiedReleaseBundle { + root, + identity: expected, + _staging: staging, + }) +} + +fn validate_release_identity(release: &ReleaseInfo) -> Result<(), BundleAcquisitionError> { + let expected_tag = format!("v{}", release.version()); + if release.tag_name() != expected_tag { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "tag `{}` must be `{expected_tag}`", + release.tag_name() + ))); + } + if !release.version().build.is_empty() { + return Err(BundleAcquisitionError::ReleaseMetadata( + "release version must not contain build metadata".to_string(), + )); + } + let expected_channel = if release.version().pre.is_empty() { + UpdateChannel::Stable + } else { + UpdateChannel::Prerelease + }; + if release.channel() != expected_channel { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "version {} belongs to the {} channel, not {}", + release.version(), + expected_channel.as_str(), + release.channel().as_str() + ))); + } + Ok(()) +} + +struct SelectedAssets<'a> { + archive: &'a ReleaseAsset, + checksum: &'a ReleaseAsset, +} + +fn select_release_assets( + release: &ReleaseInfo, +) -> Result, BundleAcquisitionError> { + let archive_name = format!("lg-buddy-{}-{RELEASE_TARGET}.tar.gz", release.version()); + let archives: Vec<_> = release + .assets() + .iter() + .filter(|asset| asset.name() == archive_name) + .collect(); + let checksums: Vec<_> = release + .assets() + .iter() + .filter(|asset| asset.name() == CHECKSUM_ASSET_NAME) + .collect(); + + let archive = exactly_one_asset(&archive_name, archives)?; + let checksum = exactly_one_asset(CHECKSUM_ASSET_NAME, checksums)?; + if archive.id() == checksum.id() { + return Err(BundleAcquisitionError::ReleaseMetadata( + "archive and checksum assets share the same GitHub asset ID".to_string(), + )); + } + validate_asset_metadata(release, archive)?; + validate_asset_metadata(release, checksum)?; + if archive.size() > MAX_ARCHIVE_BYTES { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("release asset `{}`", archive.name()), + max_bytes: MAX_ARCHIVE_BYTES, + }); + } + if checksum.size() > MAX_CHECKSUM_BYTES { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("release asset `{}`", checksum.name()), + max_bytes: MAX_CHECKSUM_BYTES, + }); + } + + Ok(SelectedAssets { archive, checksum }) +} + +fn exactly_one_asset<'a>( + name: &str, + assets: Vec<&'a ReleaseAsset>, +) -> Result<&'a ReleaseAsset, BundleAcquisitionError> { + match assets.as_slice() { + [asset] => Ok(*asset), + [] => Err(BundleAcquisitionError::ReleaseMetadata(format!( + "release must contain exactly one `{name}` asset; found none" + ))), + _ => Err(BundleAcquisitionError::ReleaseMetadata(format!( + "release must contain exactly one `{name}` asset; found {}", + assets.len() + ))), + } +} + +fn validate_asset_metadata( + release: &ReleaseInfo, + asset: &ReleaseAsset, +) -> Result<(), BundleAcquisitionError> { + if asset.state() != "uploaded" { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "asset `{}` is in GitHub state `{}` instead of `uploaded`", + asset.name(), + asset.state() + ))); + } + if asset.size() == 0 { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "asset `{}` has an empty size", + asset.name() + ))); + } + required_github_digest(asset)?; + + let expected_api_url = format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/assets/{}", + asset.id() + ); + if asset.api_url() != expected_api_url { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "asset `{}` API URL does not belong to the LG Buddy repository", + asset.name() + ))); + } + + let expected_download_url = expected_browser_download_url(release.tag_name(), asset.name())?; + if asset.download_url() != expected_download_url { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "asset `{}` download URL does not belong to the selected LG Buddy release", + asset.name() + ))); + } + Ok(()) +} + +fn expected_browser_download_url(tag: &str, name: &str) -> Result { + let mut url = Url::parse("https://github.com/").map_err(|err| { + BundleAcquisitionError::ReleaseMetadata(format!("cannot construct GitHub URL: {err}")) + })?; + url.path_segments_mut() + .map_err(|_| { + BundleAcquisitionError::ReleaseMetadata( + "cannot construct GitHub release URL".to_string(), + ) + })? + .extend([ + REPOSITORY_OWNER, + REPOSITORY_NAME, + "releases", + "download", + tag, + name, + ]); + Ok(url.to_string()) +} + +fn required_github_digest(asset: &ReleaseAsset) -> Result { + let digest = asset.digest().ok_or_else(|| { + BundleAcquisitionError::Digest(format!("asset `{}` has no GitHub digest", asset.name())) + })?; + let value = digest.strip_prefix("sha256:").ok_or_else(|| { + BundleAcquisitionError::Digest(format!( + "asset `{}` uses unsupported digest `{digest}`", + asset.name() + )) + })?; + validate_sha256(value).map_err(BundleAcquisitionError::Digest)?; + Ok(value.to_string()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DownloadFacts { + bytes: u64, + sha256: String, +} + +#[derive(Debug)] +struct DownloadedAsset { + file: File, + facts: DownloadFacts, +} + +fn verify_download( + asset: &ReleaseAsset, + download: &DownloadFacts, +) -> Result<(), BundleAcquisitionError> { + if download.bytes != asset.size() { + return Err(BundleAcquisitionError::InterruptedAsset { + name: asset.name().to_string(), + expected: asset.size(), + actual: download.bytes, + }); + } + let expected = required_github_digest(asset)?; + if download.sha256 != expected { + return Err(BundleAcquisitionError::Digest(format!( + "asset `{}` computed `{}`, expected `{expected}`", + asset.name(), + download.sha256 + ))); + } + Ok(()) +} + +fn published_archive_digest( + checksum_file: &File, + archive_name: &str, +) -> Result { + let mut checksum_file = rewind_clone(checksum_file)?; + let mut bytes = Vec::new(); + std::io::Read::by_ref(&mut checksum_file) + .take(MAX_CHECKSUM_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_CHECKSUM_BYTES { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: CHECKSUM_ASSET_NAME.to_string(), + max_bytes: MAX_CHECKSUM_BYTES, + }); + } + let text = std::str::from_utf8(&bytes).map_err(|err| { + BundleAcquisitionError::Checksum(format!("checksum file is not valid UTF-8: {err}")) + })?; + let mut matches = Vec::new(); + for (index, line) in text.lines().enumerate() { + if line.is_empty() { + continue; + } + if line.len() < 67 { + return Err(BundleAcquisitionError::Checksum(format!( + "line {} is malformed", + index + 1 + ))); + } + let digest = line.get(..64).ok_or_else(|| { + BundleAcquisitionError::Checksum(format!( + "line {} splits a UTF-8 character in the digest field", + index + 1 + )) + })?; + validate_sha256(digest).map_err(BundleAcquisitionError::Checksum)?; + let separator = line.get(64..66).ok_or_else(|| { + BundleAcquisitionError::Checksum(format!( + "line {} splits a UTF-8 character at the checksum separator", + index + 1 + )) + })?; + if separator != " " && separator != " *" { + return Err(BundleAcquisitionError::Checksum(format!( + "line {} has an unsupported checksum separator", + index + 1 + ))); + } + let name = line.get(66..).ok_or_else(|| { + BundleAcquisitionError::Checksum(format!( + "line {} splits a UTF-8 character before the filename", + index + 1 + )) + })?; + let normalized_name = name.strip_prefix("./").unwrap_or(name); + if normalized_name == archive_name { + matches.push(digest.to_string()); + } + } + match matches.as_slice() { + [digest] => Ok(digest.clone()), + [] => Err(BundleAcquisitionError::Checksum(format!( + "no entry names `{archive_name}`" + ))), + _ => Err(BundleAcquisitionError::Checksum(format!( + "multiple entries name `{archive_name}`" + ))), + } +} + +fn validate_sha256(value: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "`{value}` is not a lowercase hexadecimal SHA-256 digest" + )); + } + Ok(()) +} + +trait GitHubSource { + fn fetch_release_by_tag( + &self, + selected: &ReleaseInfo, + ) -> Result; + + fn resolve_tag_commit(&self, tag: &str) -> Result; + + fn download_asset( + &self, + asset: &ReleaseAsset, + destination: &Path, + max_bytes: u64, + ) -> Result; +} + +struct UreqGitHubSource { + api_agent: ureq::Agent, +} + +impl UreqGitHubSource { + fn new() -> Self { + Self { + api_agent: github_agent(GITHUB_CONNECT_TIMEOUT, GITHUB_REQUEST_TIMEOUT), + } + } + + fn get_json Deserialize<'de>>( + &self, + url: &str, + ) -> Result { + let response = match github_request(&self.api_agent, url, GITHUB_JSON_ACCEPT).call() { + Ok(response) => response, + Err(ureq::Error::Status(status, response)) => { + let body = read_response_text(response, url, MAX_API_ERROR_BYTES)?; + return Err(BundleAcquisitionError::HttpStatus { + url: url.to_string(), + status, + body, + }); + } + Err(ureq::Error::Transport(err)) => { + return Err(BundleAcquisitionError::Http { + url: url.to_string(), + message: err.to_string(), + }); + } + }; + if response.status() != 200 { + return Err(BundleAcquisitionError::HttpStatus { + url: url.to_string(), + status: response.status(), + body: String::new(), + }); + } + let body = read_response_bytes(response, url, MAX_API_BYTES)?; + serde_json::from_slice(&body).map_err(|err| { + BundleAcquisitionError::TagResolution(format!( + "GitHub response from `{url}` was malformed: {err}" + )) + }) + } + + fn asset_response( + &self, + asset: &ReleaseAsset, + ) -> Result<(ureq::Response, String), BundleAcquisitionError> { + let deadline = Instant::now() + GITHUB_REQUEST_TIMEOUT; + let url = format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/assets/{}", + asset.id() + ); + let budget = remaining_request_budget(deadline)?; + let agent = github_agent(budget.connect, budget.request); + match github_request(&agent, &url, GITHUB_ASSET_ACCEPT) + .set("Accept-Encoding", "identity") + .call() + { + Ok(response) if response.status() == 200 => Ok((response, url)), + Ok(response) if response.status() == 302 => { + self.follow_asset_redirect(&url, response, deadline) + } + Ok(response) => Err(BundleAcquisitionError::HttpStatus { + url, + status: response.status(), + body: String::new(), + }), + Err(ureq::Error::Status(302, response)) => { + self.follow_asset_redirect(&url, response, deadline) + } + Err(ureq::Error::Status(status, response)) => { + let body = read_response_text(response, &url, MAX_API_ERROR_BYTES)?; + Err(BundleAcquisitionError::HttpStatus { url, status, body }) + } + Err(ureq::Error::Transport(err)) => Err(BundleAcquisitionError::Http { + url, + message: err.to_string(), + }), + } + } + + fn follow_asset_redirect( + &self, + original_url: &str, + response: ureq::Response, + deadline: Instant, + ) -> Result<(ureq::Response, String), BundleAcquisitionError> { + let location = response + .header("Location") + .ok_or_else(|| BundleAcquisitionError::Http { + url: original_url.to_string(), + message: "GitHub asset redirect omitted Location".to_string(), + })?; + validate_asset_redirect(location)?; + let redirected_url = location.to_string(); + let safe_redirected_url = redact_url(&redirected_url); + let budget = remaining_request_budget(deadline)?; + let agent = github_agent(budget.connect, budget.request); + match agent + .get(&redirected_url) + .set("Accept", GITHUB_ASSET_ACCEPT) + .set("Accept-Encoding", "identity") + .set( + "User-Agent", + concat!("lg-buddy/", env!("CARGO_PKG_VERSION")), + ) + .call() + { + Ok(response) if response.status() == 200 => Ok((response, safe_redirected_url)), + Ok(response) => Err(BundleAcquisitionError::HttpStatus { + url: safe_redirected_url, + status: response.status(), + body: String::new(), + }), + Err(ureq::Error::Status(status, _)) => Err(BundleAcquisitionError::HttpStatus { + url: safe_redirected_url, + status, + body: String::new(), + }), + Err(ureq::Error::Transport(_)) => Err(BundleAcquisitionError::Http { + url: safe_redirected_url, + message: "redirected asset request failed".to_string(), + }), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RequestBudget { + connect: Duration, + request: Duration, +} + +fn remaining_request_budget(deadline: Instant) -> Result { + let request = deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| BundleAcquisitionError::Http { + url: "GitHub release asset".to_string(), + message: "request deadline expired".to_string(), + })?; + Ok(request_budget(request)) +} + +fn request_budget(request: Duration) -> RequestBudget { + RequestBudget { + connect: request.min(GITHUB_CONNECT_TIMEOUT), + request, + } +} + +fn github_agent(connect_timeout: Duration, request_timeout: Duration) -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(connect_timeout) + .timeout(request_timeout) + .https_only(true) + .try_proxy_from_env(false) + .redirects(0) + .redirect_auth_headers(ureq::RedirectAuthHeaders::Never) + .build() +} + +fn github_request(agent: &ureq::Agent, url: &str, accept: &str) -> ureq::Request { + agent + .get(url) + .set("Accept", accept) + .set( + "User-Agent", + concat!("lg-buddy/", env!("CARGO_PKG_VERSION")), + ) + .set("X-GitHub-Api-Version", GITHUB_API_VERSION) +} + +fn redact_url(value: &str) -> String { + match Url::parse(value) { + Ok(mut url) => { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + } + Err(_) => "GitHub release asset redirect".to_string(), + } +} + +impl GitHubSource for UreqGitHubSource { + fn fetch_release_by_tag( + &self, + selected: &ReleaseInfo, + ) -> Result { + let tag = selected.tag_name(); + if !valid_github_tag(tag) { + return Err(BundleAcquisitionError::ReleaseMetadata(format!( + "tag `{tag}` cannot be used in a GitHub release request" + ))); + } + let url = format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tags/{tag}" + ); + let release: FreshGitHubRelease = self.get_json(&url)?; + if release.draft || release.tag_name != tag { + return Err(BundleAcquisitionError::ReleaseMetadata( + "fresh GitHub release metadata is draft or has the wrong tag".to_string(), + )); + } + let version = Version::parse( + release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name), + ) + .map_err(|err| { + BundleAcquisitionError::ReleaseMetadata(format!( + "fresh GitHub release tag is not a version: {err}" + )) + })?; + let channel = if release.prerelease { + UpdateChannel::Prerelease + } else { + UpdateChannel::Stable + }; + Ok(ReleaseInfo::from_github( + version, + channel, + release.html_url, + release.tag_name, + release + .assets + .into_iter() + .map(|asset| { + ReleaseAsset::from_github( + asset.id, + asset.name, + asset.state, + asset.size, + asset.digest, + asset.url, + asset.browser_download_url, + ) + }) + .collect(), + )) + } + + fn resolve_tag_commit(&self, tag: &str) -> Result { + if !valid_github_tag(tag) { + return Err(BundleAcquisitionError::TagResolution(format!( + "tag `{tag}` cannot be used in a GitHub reference request" + ))); + } + let reference_url = format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/git/ref/tags/{tag}" + ); + let reference: GitReferenceResponse = self.get_json(&reference_url)?; + peel_tag_object(reference.object, |sha| { + let tag_url = format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/git/tags/{sha}" + ); + Ok(self.get_json::(&tag_url)?.object) + }) + } + + fn download_asset( + &self, + asset: &ReleaseAsset, + destination: &Path, + max_bytes: u64, + ) -> Result { + let (response, response_url) = self.asset_response(asset)?; + if let Some(length) = response.header("Content-Length") { + let length = length + .parse::() + .map_err(|_| BundleAcquisitionError::Http { + url: response_url.clone(), + message: "asset response has an invalid Content-Length".to_string(), + })?; + if length > max_bytes { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("release asset `{}`", asset.name()), + max_bytes, + }); + } + if length != asset.size() { + return Err(BundleAcquisitionError::InterruptedAsset { + name: asset.name().to_string(), + expected: asset.size(), + actual: length, + }); + } + } + stream_response_to_file(response, &response_url, destination, max_bytes) + } +} + +fn valid_github_tag(tag: &str) -> bool { + !tag.is_empty() + && tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +#[derive(Debug, Deserialize)] +struct FreshGitHubRelease { + tag_name: String, + html_url: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct FreshGitHubAsset { + id: u64, + name: String, + state: String, + size: u64, + digest: Option, + url: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct GitReferenceResponse { + object: GitObject, +} + +#[derive(Debug, Deserialize)] +struct GitTagResponse { + object: GitObject, +} + +#[derive(Debug, Deserialize)] +struct GitObject { + #[serde(rename = "type")] + kind: String, + sha: String, +} + +fn peel_tag_object( + mut object: GitObject, + mut fetch_tag: F, +) -> Result +where + F: FnMut(&str) -> Result, +{ + let mut visited = HashSet::new(); + for depth in 0..=MAX_TAG_DEPTH { + validate_commit_sha(&object.sha).map_err(BundleAcquisitionError::TagResolution)?; + match object.kind.as_str() { + "commit" => return Ok(object.sha), + "tag" if depth < MAX_TAG_DEPTH => { + if !visited.insert(object.sha.clone()) { + return Err(BundleAcquisitionError::TagResolution( + "annotated tag chain contains a cycle".to_string(), + )); + } + object = fetch_tag(&object.sha)?; + } + "tag" => { + return Err(BundleAcquisitionError::TagResolution(format!( + "annotated tag chain exceeds depth {MAX_TAG_DEPTH}" + ))); + } + other => { + return Err(BundleAcquisitionError::TagResolution(format!( + "tag resolves to unsupported Git object type `{other}`" + ))); + } + } + } + Err(BundleAcquisitionError::TagResolution( + "tag resolution did not reach a commit".to_string(), + )) +} + +fn validate_commit_sha(sha: &str) -> Result<(), String> { + if sha.len() != 40 + || !sha + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("`{sha}` is not a full lowercase commit SHA")); + } + Ok(()) +} + +fn validate_asset_redirect(location: &str) -> Result<(), BundleAcquisitionError> { + let url = Url::parse(location).map_err(|err| BundleAcquisitionError::Http { + url: "GitHub release asset redirect".to_string(), + message: format!("GitHub asset redirect is not a valid URL: {err}"), + })?; + if url.scheme() != "https" + || url.host_str() != Some(GITHUB_RELEASE_ASSET_HOST) + || url.port_or_known_default() != Some(443) + || url.username() != "" + || url.password().is_some() + || url.fragment().is_some() + || !url.path().starts_with("/github-production-release-asset/") + { + return Err(BundleAcquisitionError::Http { + url: redact_url(location), + message: "GitHub asset redirect target is not allowed".to_string(), + }); + } + Ok(()) +} + +fn read_response_text( + response: ureq::Response, + url: &str, + max_bytes: u64, +) -> Result { + let bytes = read_response_bytes(response, url, max_bytes)?; + String::from_utf8(bytes).map_err(|err| BundleAcquisitionError::Http { + url: url.to_string(), + message: format!("response is not valid UTF-8: {err}"), + }) +} + +fn read_response_bytes( + response: ureq::Response, + url: &str, + max_bytes: u64, +) -> Result, BundleAcquisitionError> { + let mut bytes = Vec::new(); + response + .into_reader() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|err| BundleAcquisitionError::Http { + url: url.to_string(), + message: err.to_string(), + })?; + if bytes.len() as u64 > max_bytes { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("response from `{url}`"), + max_bytes, + }); + } + Ok(bytes) +} + +fn stream_response_to_file( + response: ureq::Response, + url: &str, + destination: &Path, + max_bytes: u64, +) -> Result { + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(destination)?; + let mut reader = response.into_reader(); + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|err| BundleAcquisitionError::Http { + url: url.to_string(), + message: err.to_string(), + })?; + if read == 0 { + break; + } + total = total.saturating_add(read as u64); + if total > max_bytes { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("response from `{url}`"), + max_bytes, + }); + } + hasher.update(&buffer[..read]); + file.write_all(&buffer[..read])?; + } + file.flush()?; + file.sync_all()?; + Ok(DownloadedAsset { + file, + facts: DownloadFacts { + bytes: total, + sha256: format!("{:x}", hasher.finalize()), + }, + }) +} + +fn pin_download_file(path: &Path, file: &File) -> Result<(), BundleAcquisitionError> { + let descriptor = file.metadata()?; + let named = fs::symlink_metadata(path)?; + let expected_uid = unsafe { libc::geteuid() }; + if !descriptor.file_type().is_file() + || !named.file_type().is_file() + || descriptor.uid() != expected_uid + || descriptor.nlink() != 1 + || descriptor.mode() & 0o077 != 0 + || descriptor.dev() != named.dev() + || descriptor.ino() != named.ino() + { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "download `{}` is not the expected private regular file", + path.display() + ))); + } + fs::remove_file(path)?; + if file.metadata()?.nlink() != 0 { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "download `{}` remained linked after pinning", + path.display() + ))); + } + Ok(()) +} + +fn rewind_clone(file: &File) -> Result { + let mut clone = file.try_clone()?; + clone.seek(SeekFrom::Start(0))?; + Ok(clone) +} + +#[derive(Debug)] +struct StagingDirectory { + path: PathBuf, + _lock: File, + device: u64, + inode: u64, +} + +impl StagingDirectory { + fn create(cache_root: &Path) -> Result { + ensure_private_directory(cache_root)?; + let lock = open_lock_file(&cache_root.join(LOCK_FILE_NAME))?; + let lock_result = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if lock_result != 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + return Err(BundleAcquisitionError::ConcurrentAcquisition); + } + return Err(BundleAcquisitionError::Io(err)); + } + + for _ in 0..100 { + let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = cache_root.join(format!("staging-{}-{sequence}", process::id())); + match fs::create_dir(&path) { + Ok(()) => { + if let Err(err) = fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + { + let _ = fs::remove_dir(&path); + return Err(BundleAcquisitionError::Io(err)); + } + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(err) => { + let _ = fs::remove_dir(&path); + return Err(BundleAcquisitionError::Io(err)); + } + }; + if !metadata.file_type().is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + { + let _ = fs::remove_dir(&path); + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "new staging directory `{}` is not private", + path.display() + ))); + } + return Ok(Self { + path, + _lock: lock, + device: metadata.dev(), + inode: metadata.ino(), + }); + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(BundleAcquisitionError::Io(err)), + } + } + Err(BundleAcquisitionError::UnsafeStaging( + "could not create a unique staging directory".to_string(), + )) + } +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + let expected_uid = unsafe { libc::geteuid() }; + if fs::symlink_metadata(&self.path).is_ok_and(|metadata| { + metadata.file_type().is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == expected_uid + && metadata.mode() & 0o077 == 0 + && metadata.dev() == self.device + && metadata.ino() == self.inode + }) { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn ensure_private_directory(path: &Path) -> Result<(), BundleAcquisitionError> { + if !path.is_absolute() + || path.components().any(|component| { + !matches!( + component, + std::path::Component::RootDir | std::path::Component::Normal(_) + ) + }) + { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "`{}` is not an absolute normalized path", + path.display() + ))); + } + + let mut current = PathBuf::from("/"); + for component in path.components().skip(1) { + let std::path::Component::Normal(name) = component else { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "`{}` contains an unsupported path component", + path.display() + ))); + }; + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) => validate_staging_ancestor(¤t, &metadata)?, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + fs::create_dir(¤t)?; + fs::set_permissions(¤t, fs::Permissions::from_mode(0o700))?; + validate_staging_ancestor(¤t, &fs::symlink_metadata(¤t)?)?; + } + Err(err) => return Err(BundleAcquisitionError::Io(err)), + } + } + + let metadata = fs::symlink_metadata(path)?; + let expected_uid = unsafe { libc::geteuid() }; + if metadata.uid() != expected_uid { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "`{}` is owned by UID {}, expected {}", + path.display(), + metadata.uid(), + expected_uid + ))); + } + if metadata.mode() & 0o077 != 0 { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "`{}` is accessible to group or other users", + path.display() + ))); + } + Ok(()) +} + +fn validate_staging_ancestor( + path: &Path, + metadata: &fs::Metadata, +) -> Result<(), BundleAcquisitionError> { + if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "`{}` is not a real directory", + path.display() + ))); + } + let expected_uid = unsafe { libc::geteuid() }; + if metadata.uid() != 0 && metadata.uid() != expected_uid { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "ancestor `{}` is owned by untrusted UID {}", + path.display(), + metadata.uid() + ))); + } + let writable_by_others = metadata.mode() & 0o022 != 0; + let sticky = metadata.mode() & libc::S_ISVTX != 0; + if writable_by_others && !sticky { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "ancestor `{}` is writable by other users without sticky protection", + path.display() + ))); + } + Ok(()) +} + +fn open_lock_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path)?; + let metadata = file.metadata()?; + let expected_uid = unsafe { libc::geteuid() }; + if !metadata.file_type().is_file() + || metadata.uid() != expected_uid + || metadata.nlink() != 1 + || metadata.mode() & 0o077 != 0 + { + return Err(BundleAcquisitionError::UnsafeStaging(format!( + "lock file `{}` is not a private, singly linked regular file", + path.display() + ))); + } + Ok(file) +} + +#[derive(Debug)] +struct ArchivePlan { + root_name: String, + entries: HashMap, + manifest: ReleaseIdentity, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ArchiveEntryFact { + entry_type: EntryType, + size: u64, + mode: u32, +} + +fn inspect_archive( + archive_file: &File, + archive_name: &str, + expected: &ReleaseIdentity, +) -> Result { + let expected_root = format!("lg-buddy-{}-{RELEASE_TARGET}", expected.version()); + let expected_archive = format!("{expected_root}.tar.gz"); + if archive_name != expected_archive { + return Err(BundleAcquisitionError::Archive(format!( + "archive name `{archive_name}` must be `{expected_archive}`" + ))); + } + let archive_metadata = archive_file.metadata()?; + if !archive_metadata.file_type().is_file() || archive_metadata.nlink() > 1 { + return Err(BundleAcquisitionError::Archive( + "staged archive is not a singly linked regular file".to_string(), + )); + } + if archive_metadata.len() > MAX_ARCHIVE_BYTES { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: format!("release archive `{archive_name}`"), + max_bytes: MAX_ARCHIVE_BYTES, + }); + } + preflight_raw_archive(archive_file)?; + let file = rewind_clone(archive_file)?; + let mut archive = Archive::new(MultiGzDecoder::new(file)); + let mut validated_entries = HashMap::new(); + let mut expanded_bytes = 0_u64; + let mut regular_files = 0_usize; + let mut manifest = None; + let entries = archive.entries().map_err(archive_error)?; + for (index, entry) in entries.enumerate() { + if index >= MAX_ARCHIVE_ENTRIES { + return Err(BundleAcquisitionError::Archive(format!( + "archive contains more than {MAX_ARCHIVE_ENTRIES} entries" + ))); + } + let mut entry = entry.map_err(archive_error)?; + let entry_type = entry.header().entry_type(); + let path = normalize_archive_path(entry.path_bytes().as_ref(), entry_type)?; + if validated_entries.contains_key(&path) { + return Err(BundleAcquisitionError::Archive(format!( + "archive contains duplicate path `{path}`" + ))); + } + validate_archive_layout(&path, &expected_root, entry_type)?; + if entry.link_name_bytes().is_some() { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` contains link metadata" + ))); + } + let mode = entry.header().mode().map_err(archive_error)?; + if mode & 0o7022 != 0 { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` has unsafe mode {mode:o}" + ))); + } + let size = entry.size(); + if entry_type == EntryType::Directory && size != 0 { + return Err(BundleAcquisitionError::Archive(format!( + "directory `{path}` has a nonzero payload" + ))); + } + if entry_type == EntryType::Regular { + regular_files += 1; + if regular_files > MAX_ARCHIVE_FILES { + return Err(BundleAcquisitionError::Archive(format!( + "archive contains more than {MAX_ARCHIVE_FILES} regular files" + ))); + } + } + validated_entries.insert( + path.clone(), + ArchiveEntryFact { + entry_type, + size, + mode, + }, + ); + if size > MAX_ARCHIVE_FILE_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` exceeds the {MAX_ARCHIVE_FILE_BYTES}-byte file limit" + ))); + } + expanded_bytes = expanded_bytes.checked_add(size).ok_or_else(|| { + BundleAcquisitionError::Archive("expanded archive size overflowed".to_string()) + })?; + if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "expanded archive exceeds the {MAX_ARCHIVE_EXPANDED_BYTES}-byte limit" + ))); + } + + if path == format!("{expected_root}/{MANIFEST_NAME}") { + if size > MAX_MANIFEST_BYTES { + return Err(BundleAcquisitionError::Manifest(format!( + "manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit" + ))); + } + let mut contents = Vec::new(); + entry + .by_ref() + .take(MAX_MANIFEST_BYTES + 1) + .read_to_end(&mut contents) + .map_err(|err| BundleAcquisitionError::Archive(err.to_string()))?; + if contents.len() as u64 != size { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` ended before its declared size" + ))); + } + manifest = Some(parse_manifest(&contents)?); + } else if entry_type == EntryType::Regular { + let copied = io::copy(&mut entry, &mut io::sink()) + .map_err(|err| BundleAcquisitionError::Archive(err.to_string()))?; + if copied != size { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` ended after {copied} bytes; header declares {size}" + ))); + } + } + } + + reject_archive_trailing_data(&mut archive.into_inner())?; + + validate_required_layout(&validated_entries, &expected_root)?; + let manifest = manifest.ok_or_else(|| { + BundleAcquisitionError::Manifest(format!("archive does not contain `{MANIFEST_NAME}`")) + })?; + if &manifest != expected { + return Err(BundleAcquisitionError::Manifest(format!( + "identity {:?} does not match selected release identity {:?}", + manifest, expected + ))); + } + Ok(ArchivePlan { + root_name: expected_root, + entries: validated_entries, + manifest, + }) +} + +fn preflight_raw_archive(archive_file: &File) -> Result<(), BundleAcquisitionError> { + let file = rewind_clone(archive_file)?; + let mut archive = Archive::new(MultiGzDecoder::new(file)); + let mut payload_bytes = 0_u64; + let entries = archive.entries().map_err(archive_error)?.raw(true); + for (index, entry) in entries.enumerate() { + if index >= MAX_ARCHIVE_ENTRIES { + return Err(BundleAcquisitionError::Archive(format!( + "archive contains more than {MAX_ARCHIVE_ENTRIES} raw entries" + ))); + } + let mut entry = entry.map_err(archive_error)?; + let entry_type = entry.header().entry_type(); + let size = entry.size(); + if is_archive_metadata_entry(entry_type) && size > MAX_ARCHIVE_METADATA_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "archive metadata entry exceeds the {MAX_ARCHIVE_METADATA_BYTES}-byte limit" + ))); + } + if !is_archive_metadata_entry(entry_type) && size > MAX_ARCHIVE_FILE_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "raw archive entry exceeds the {MAX_ARCHIVE_FILE_BYTES}-byte file limit" + ))); + } + payload_bytes = payload_bytes.checked_add(size).ok_or_else(|| { + BundleAcquisitionError::Archive("raw archive payload size overflowed".to_string()) + })?; + if payload_bytes > MAX_ARCHIVE_EXPANDED_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "raw archive payload exceeds the {MAX_ARCHIVE_EXPANDED_BYTES}-byte limit" + ))); + } + let copied = io::copy(&mut entry, &mut io::sink()) + .map_err(|err| BundleAcquisitionError::Archive(err.to_string()))?; + if copied != size { + return Err(BundleAcquisitionError::Archive(format!( + "raw archive entry ended after {copied} bytes; header declares {size}" + ))); + } + } + Ok(()) +} + +fn is_archive_metadata_entry(entry_type: EntryType) -> bool { + entry_type.is_gnu_longname() + || entry_type.is_gnu_longlink() + || entry_type.is_pax_local_extensions() + || entry_type.is_pax_global_extensions() +} + +fn normalize_archive_path( + bytes: &[u8], + entry_type: EntryType, +) -> Result { + if bytes.len() > MAX_ARCHIVE_PATH_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "archive path exceeds the {MAX_ARCHIVE_PATH_BYTES}-byte limit" + ))); + } + if bytes.contains(&0) { + return Err(BundleAcquisitionError::Archive( + "archive path contains a NUL byte".to_string(), + )); + } + let text = std::str::from_utf8(bytes).map_err(|_| { + BundleAcquisitionError::Archive("archive path is not valid UTF-8".to_string()) + })?; + if text.ends_with('/') && entry_type != EntryType::Directory { + return Err(BundleAcquisitionError::Archive(format!( + "non-directory archive path `{text}` has a trailing slash" + ))); + } + let text = text.strip_suffix('/').unwrap_or(text); + if text.is_empty() + || text.starts_with('/') + || text.contains('\\') + || text + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(BundleAcquisitionError::Archive(format!( + "archive path `{text}` is not a normalized relative path" + ))); + } + Ok(text.to_string()) +} + +fn validate_archive_layout( + path: &str, + root: &str, + entry_type: EntryType, +) -> Result<(), BundleAcquisitionError> { + if entry_type != EntryType::Regular && entry_type != EntryType::Directory { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` has unsupported type {:?}", + entry_type + ))); + } + if path != root && !path.starts_with(&format!("{root}/")) { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` is outside expected root `{root}`" + ))); + } + let relative = path + .strip_prefix(root) + .unwrap_or(path) + .trim_start_matches('/'); + if relative.is_empty() { + if entry_type != EntryType::Directory { + return Err(BundleAcquisitionError::Archive( + "bundle root must be a directory".to_string(), + )); + } + return Ok(()); + } + + let allowed_file = required_relative_files().contains(&relative) + || (relative.starts_with("docs/") && entry_type == EntryType::Regular); + let allowed_directory = entry_type == EntryType::Directory + && (matches!(relative, "bin" | "docs" | "systemd") || relative.starts_with("docs/")); + if !allowed_file && !allowed_directory { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` is not part of the release-bundle layout" + ))); + } + if allowed_file && entry_type != EntryType::Regular { + return Err(BundleAcquisitionError::Archive(format!( + "required file `{path}` is not a regular file" + ))); + } + Ok(()) +} + +fn required_relative_files() -> &'static [&'static str] { + &[ + "lg-buddy", + "install.sh", + "configure.sh", + "uninstall.sh", + "bin/LG_Buddy_Common", + "LG_Buddy_Brightness.desktop", + "README.md", + "LICENSE", + MANIFEST_NAME, + "systemd/LG_Buddy.service", + "systemd/LG_Buddy_lifecycle.service", + "systemd/LG_Buddy_screen.service", + "systemd/LG_Buddy_update_check.service", + "systemd/LG_Buddy_update_check.timer", + "systemd/lg_buddy.conf", + ] +} + +fn validate_required_layout( + entries: &HashMap, + root: &str, +) -> Result<(), BundleAcquisitionError> { + for relative in required_relative_files() { + let path = format!("{root}/{relative}"); + if !entries.contains_key(&path) { + return Err(BundleAcquisitionError::Archive(format!( + "archive is missing required file `{path}`" + ))); + } + } + for relative in ["", "bin", "docs", "systemd"] { + let path = if relative.is_empty() { + root.to_string() + } else { + format!("{root}/{relative}") + }; + if !entries.contains_key(&path) { + return Err(BundleAcquisitionError::Archive(format!( + "archive is missing required directory `{path}`" + ))); + } + } + Ok(()) +} + +fn extract_archive( + archive_file: &File, + staging_root: &Path, + plan: &ArchivePlan, +) -> Result { + let file = rewind_clone(archive_file)?; + let mut archive = Archive::new(MultiGzDecoder::new(file)); + let mut seen = HashSet::new(); + for entry in archive.entries().map_err(archive_error)? { + let mut entry = entry.map_err(archive_error)?; + let entry_type = entry.header().entry_type(); + let path = normalize_archive_path(entry.path_bytes().as_ref(), entry_type)?; + if !seen.insert(path.clone()) { + return Err(BundleAcquisitionError::Archive(format!( + "archive changed after validation: duplicate path `{path}`" + ))); + } + let mode = entry.header().mode().map_err(archive_error)?; + let observed = ArchiveEntryFact { + entry_type, + size: entry.size(), + mode, + }; + if plan.entries.get(&path) != Some(&observed) || entry.link_name_bytes().is_some() { + return Err(BundleAcquisitionError::Archive(format!( + "archive changed after validation at `{path}`" + ))); + } + let destination = staging_root.join(&path); + if entry_type == EntryType::Directory { + fs::create_dir_all(&destination)?; + fs::set_permissions(&destination, fs::Permissions::from_mode(0o700))?; + continue; + } + let parent = destination.parent().ok_or_else(|| { + BundleAcquisitionError::Archive(format!("entry `{path}` has no parent")) + })?; + fs::create_dir_all(parent)?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + let relative = path + .strip_prefix(&format!("{}/", plan.root_name)) + .unwrap_or(&path); + let mode = if executable_relative_files().contains(&relative) { + 0o700 + } else { + 0o600 + }; + let mut destination_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&destination)?; + let expected_size = entry.size(); + let copied = io::copy(&mut entry, &mut destination_file) + .map_err(|err| BundleAcquisitionError::Archive(err.to_string()))?; + if copied != expected_size { + return Err(BundleAcquisitionError::Archive(format!( + "entry `{path}` extracted {copied} bytes; header declares {expected_size}" + ))); + } + destination_file.flush()?; + } + if seen.len() != plan.entries.len() { + return Err(BundleAcquisitionError::Archive( + "archive changed after validation: entries are missing".to_string(), + )); + } + reject_archive_trailing_data(&mut archive.into_inner())?; + let root = staging_root.join(&plan.root_name); + if plan.manifest.release_tag.is_empty() { + return Err(BundleAcquisitionError::Manifest( + "validated manifest identity was unexpectedly empty".to_string(), + )); + } + Ok(root) +} + +fn reject_archive_trailing_data(reader: &mut R) -> Result<(), BundleAcquisitionError> { + let mut total = 0_u64; + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|err| BundleAcquisitionError::Archive(err.to_string()))?; + if read == 0 { + return Ok(()); + } + total = total.saturating_add(read as u64); + if total > MAX_ARCHIVE_TRAILING_BYTES { + return Err(BundleAcquisitionError::Archive(format!( + "archive has more than {MAX_ARCHIVE_TRAILING_BYTES} bytes after its end marker" + ))); + } + if buffer[..read].iter().any(|byte| *byte != 0) { + return Err(BundleAcquisitionError::Archive( + "archive contains data after its end marker".to_string(), + )); + } + } +} + +fn executable_relative_files() -> &'static [&'static str] { + &[ + "lg-buddy", + "install.sh", + "configure.sh", + "uninstall.sh", + "bin/LG_Buddy_Common", + ] +} + +fn archive_error(err: impl fmt::Display) -> BundleAcquisitionError { + BundleAcquisitionError::Archive(err.to_string()) +} + +#[derive(Debug)] +struct RawManifest { + schema_version: Option, + critical: Option>, + release_tag: Option, + version: Option, + channel: Option, + target: Option, + commit: Option, +} + +impl<'de> Deserialize<'de> for RawManifest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct ManifestVisitor; + + impl<'de> Visitor<'de> for ManifestVisitor { + type Value = RawManifest; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a release manifest object") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut seen = HashSet::new(); + let mut manifest = RawManifest { + schema_version: None, + critical: None, + release_tag: None, + version: None, + channel: None, + target: None, + commit: None, + }; + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(de::Error::custom(format!( + "duplicate manifest field `{key}`" + ))); + } + match key.as_str() { + "schema_version" => manifest.schema_version = Some(map.next_value()?), + "critical" => manifest.critical = Some(map.next_value()?), + "release_tag" => manifest.release_tag = Some(map.next_value()?), + "version" => manifest.version = Some(map.next_value()?), + "channel" => manifest.channel = Some(map.next_value()?), + "target" => manifest.target = Some(map.next_value()?), + "commit" => manifest.commit = Some(map.next_value()?), + _ => { + map.next_value::()?; + } + } + } + Ok(manifest) + } + } + + deserializer.deserialize_map(ManifestVisitor) + } +} + +fn parse_manifest(contents: &[u8]) -> Result { + if contents.len() as u64 > MAX_MANIFEST_BYTES { + return Err(BundleAcquisitionError::Manifest(format!( + "manifest exceeds the {MAX_MANIFEST_BYTES}-byte limit" + ))); + } + let manifest: RawManifest = serde_json::from_slice(contents) + .map_err(|err| BundleAcquisitionError::Manifest(err.to_string()))?; + if manifest.schema_version != Some(1) { + return Err(BundleAcquisitionError::Manifest(format!( + "unsupported schema version {:?}", + manifest.schema_version + ))); + } + let critical = manifest.critical.ok_or_else(|| { + BundleAcquisitionError::Manifest("manifest has no critical field list".to_string()) + })?; + let expected_critical = ["release_tag", "version", "channel", "target", "commit"]; + let mut seen = HashSet::new(); + for field in &critical { + if !seen.insert(field.as_str()) { + return Err(BundleAcquisitionError::Manifest(format!( + "critical field `{field}` is duplicated" + ))); + } + if !expected_critical.contains(&field.as_str()) { + return Err(BundleAcquisitionError::Manifest(format!( + "unknown critical field `{field}`" + ))); + } + } + for field in expected_critical { + if !seen.contains(field) { + return Err(BundleAcquisitionError::Manifest(format!( + "identity field `{field}` is not marked critical" + ))); + } + } + + let release_tag = required_manifest_string(manifest.release_tag, "release_tag")?; + let version_text = required_manifest_string(manifest.version, "version")?; + let version = Version::parse(&version_text).map_err(|err| { + BundleAcquisitionError::Manifest(format!("invalid version `{version_text}`: {err}")) + })?; + if !version.build.is_empty() { + return Err(BundleAcquisitionError::Manifest( + "version must not contain build metadata".to_string(), + )); + } + if release_tag != format!("v{version}") { + return Err(BundleAcquisitionError::Manifest( + "release_tag must be exactly v followed by version".to_string(), + )); + } + let channel_text = required_manifest_string(manifest.channel, "channel")?; + let channel = match channel_text.as_str() { + "stable" if version.pre.is_empty() => UpdateChannel::Stable, + "prerelease" if !version.pre.is_empty() => UpdateChannel::Prerelease, + "stable" | "prerelease" => { + return Err(BundleAcquisitionError::Manifest(format!( + "channel `{channel_text}` disagrees with version `{version}`" + ))); + } + _ => { + return Err(BundleAcquisitionError::Manifest(format!( + "unsupported channel `{channel_text}`" + ))); + } + }; + let target = required_manifest_string(manifest.target, "target")?; + if !valid_target(&target) { + return Err(BundleAcquisitionError::Manifest(format!( + "invalid target `{target}`" + ))); + } + let commit = required_manifest_string(manifest.commit, "commit")?; + validate_commit_sha(&commit).map_err(BundleAcquisitionError::Manifest)?; + Ok(ReleaseIdentity { + release_tag, + version, + channel, + target, + commit, + }) +} + +fn required_manifest_string( + value: Option, + field: &str, +) -> Result { + value.filter(|value| !value.is_empty()).ok_or_else(|| { + BundleAcquisitionError::Manifest(format!("missing or invalid field `{field}`")) + }) +} + +fn valid_target(target: &str) -> bool { + !target.is_empty() + && target.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || (index > 0 && matches!(byte, b'_' | b'.' | b'-')) + }) +} + +trait BinaryIdentityReader { + fn read_identity( + &self, + binary: &Path, + target: &str, + release_tag: &str, + ) -> Result; +} + +struct EmbeddedBinaryIdentityReader; + +impl BinaryIdentityReader for EmbeddedBinaryIdentityReader { + fn read_identity( + &self, + binary: &Path, + target: &str, + release_tag: &str, + ) -> Result { + read_embedded_binary_identity(binary, target, release_tag) + } +} + +const EMBEDDED_IDENTITY_PREFIX: &[u8] = b"LG_BUDDY_RELEASE_IDENTITY_V1\0"; +const EMBEDDED_IDENTITY_SUFFIX: &[u8] = b"\0LG_BUDDY_RELEASE_IDENTITY_END\0"; +const EMBEDDED_IDENTITY_SECTION: &[u8] = b".lg_buddy.identity"; + +fn read_embedded_binary_identity( + binary: &Path, + target: &str, + release_tag: &str, +) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(binary) + .map_err(|err| BundleAcquisitionError::Binary(err.to_string()))?; + let metadata = file + .metadata() + .map_err(|err| BundleAcquisitionError::Binary(err.to_string()))?; + if !metadata.file_type().is_file() + || metadata.nlink() != 1 + || metadata.mode() & 0o022 != 0 + || metadata.len() > MAX_ARCHIVE_FILE_BYTES + { + return Err(BundleAcquisitionError::Binary( + "bundled binary is not a safe regular file".to_string(), + )); + } + let mut bytes = Vec::new(); + file.take(MAX_ARCHIVE_FILE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|err| BundleAcquisitionError::Binary(err.to_string()))?; + if bytes.len() as u64 > MAX_ARCHIVE_FILE_BYTES { + return Err(BundleAcquisitionError::Binary( + "bundled binary exceeds the file-size limit".to_string(), + )); + } + if bytes.get(..4) != Some(b"\x7fELF") + || bytes.get(4) != Some(&2) + || bytes.get(5) != Some(&1) + || bytes.get(18..20) != Some(62_u16.to_le_bytes().as_slice()) + { + return Err(BundleAcquisitionError::Binary( + "bundled binary is not an x86-64 little-endian ELF file".to_string(), + )); + } + + let record = embedded_identity_section(&bytes)?; + if !record.starts_with(EMBEDDED_IDENTITY_PREFIX) || !record.ends_with(EMBEDDED_IDENTITY_SUFFIX) + { + return Err(BundleAcquisitionError::Binary( + "embedded identity section has an invalid envelope".to_string(), + )); + } + let payload = + &record[EMBEDDED_IDENTITY_PREFIX.len()..record.len() - EMBEDDED_IDENTITY_SUFFIX.len()]; + let identity = + parse_manifest(payload).map_err(|err| BundleAcquisitionError::Binary(err.to_string()))?; + if identity.target() != target || identity.release_tag() != release_tag { + return Err(BundleAcquisitionError::Binary(format!( + "embedded identity target/tag do not match `{target}` and `{release_tag}`" + ))); + } + Ok(identity) +} + +fn embedded_identity_section(bytes: &[u8]) -> Result<&[u8], BundleAcquisitionError> { + let section_offset = elf_u64(bytes, 40)?; + let section_entry_size = elf_u16(bytes, 58)? as u64; + let section_count = elf_u16(bytes, 60)? as u64; + let names_index = elf_u16(bytes, 62)? as u64; + if section_entry_size < 64 + || section_count == 0 + || section_count > 4_096 + || names_index >= section_count + { + return Err(BundleAcquisitionError::Binary( + "binary has an invalid ELF section table".to_string(), + )); + } + section_offset + .checked_add( + section_entry_size + .checked_mul(section_count) + .ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section table size overflowed".to_string()) + })?, + ) + .filter(|end| *end <= bytes.len() as u64) + .ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section table extends beyond the file".to_string()) + })?; + + let (_, names_type, names_offset, names_size) = + elf_section_header(bytes, section_offset, section_entry_size, names_index)?; + if names_type != 3 { + return Err(BundleAcquisitionError::Binary( + "ELF section-name table has the wrong type".to_string(), + )); + } + let names = checked_elf_slice(bytes, names_offset, names_size, "section-name table")?; + let mut identity = None; + for index in 0..section_count { + let (name_offset, section_type, offset, size) = + elf_section_header(bytes, section_offset, section_entry_size, index)?; + let name_offset = name_offset as usize; + let Some(name_tail) = names.get(name_offset..) else { + return Err(BundleAcquisitionError::Binary( + "ELF section name points outside the name table".to_string(), + )); + }; + let name_end = name_tail + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section name is not terminated".to_string()) + })?; + if &name_tail[..name_end] != EMBEDDED_IDENTITY_SECTION { + continue; + } + if section_type != 1 || size > MAX_MANIFEST_BYTES + 256 { + return Err(BundleAcquisitionError::Binary( + "embedded identity ELF section has an invalid type or size".to_string(), + )); + } + let record = checked_elf_slice(bytes, offset, size, "embedded identity section")?; + if identity.replace(record).is_some() { + return Err(BundleAcquisitionError::Binary( + "binary has multiple embedded identity sections".to_string(), + )); + } + } + identity.ok_or_else(|| { + BundleAcquisitionError::Binary("binary has no embedded identity section".to_string()) + }) +} + +fn elf_section_header( + bytes: &[u8], + table_offset: u64, + entry_size: u64, + index: u64, +) -> Result<(u32, u32, u64, u64), BundleAcquisitionError> { + let offset = table_offset + .checked_add(entry_size.checked_mul(index).ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section offset overflowed".to_string()) + })?) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section offset is invalid".to_string()) + })?; + Ok(( + elf_u32(bytes, offset)?, + elf_u32(bytes, offset + 4)?, + elf_u64(bytes, offset + 24)?, + elf_u64(bytes, offset + 32)?, + )) +} + +fn checked_elf_slice<'a>( + bytes: &'a [u8], + offset: u64, + size: u64, + label: &str, +) -> Result<&'a [u8], BundleAcquisitionError> { + let start = usize::try_from(offset) + .map_err(|_| BundleAcquisitionError::Binary(format!("ELF {label} offset is invalid")))?; + let end = offset + .checked_add(size) + .and_then(|value| usize::try_from(value).ok()) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| { + BundleAcquisitionError::Binary(format!("ELF {label} extends beyond the file")) + })?; + bytes + .get(start..end) + .ok_or_else(|| BundleAcquisitionError::Binary(format!("ELF {label} range is invalid"))) +} + +fn elf_u16(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 2) + .and_then(|value| value.try_into().ok()) + .map(u16::from_le_bytes) + .ok_or_else(|| BundleAcquisitionError::Binary("ELF header is truncated".to_string())) +} + +fn elf_u32(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 4) + .and_then(|value| value.try_into().ok()) + .map(u32::from_le_bytes) + .ok_or_else(|| { + BundleAcquisitionError::Binary("ELF section header is truncated".to_string()) + }) +} + +fn elf_u64(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 8) + .and_then(|value| value.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or_else(|| BundleAcquisitionError::Binary("ELF header is truncated".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::collections::HashMap; + use std::fs; + use std::io::Cursor; + use std::os::unix::fs::OpenOptionsExt; + use std::path::{Path, PathBuf}; + use std::process; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use tar::{Builder, Header}; + + static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); + + fn temp_dir(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "lg-buddy-release-bundle-{label}-{}-{}", + process::id(), + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("create test directory"); + path + } + + fn stable_identity() -> ReleaseIdentity { + ReleaseIdentity { + release_tag: "v1.4.0".to_string(), + version: Version::parse("1.4.0").expect("version"), + channel: UpdateChannel::Stable, + target: "x86_64-unknown-linux-musl".to_string(), + commit: "0123456789abcdef0123456789abcdef01234567".to_string(), + } + } + + fn manifest_json(identity: &ReleaseIdentity) -> Vec { + format!( + "{{\"schema_version\":1,\"critical\":[\"release_tag\",\"version\",\"channel\",\"target\",\"commit\"],\"release_tag\":\"{}\",\"version\":\"{}\",\"channel\":\"{}\",\"target\":\"{}\",\"commit\":\"{}\"}}\n", + identity.release_tag, + identity.version, + identity.channel.as_str(), + identity.target, + identity.commit + ) + .into_bytes() + } + + fn sha256(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) + } + + fn release_asset(id: u64, name: &str, bytes: &[u8]) -> ReleaseAsset { + let tag = "v1.4.0"; + ReleaseAsset::from_github( + id, + name.to_string(), + "uploaded".to_string(), + bytes.len() as u64, + Some(format!("sha256:{}", sha256(bytes))), + format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/assets/{id}" + ), + expected_browser_download_url(tag, name).expect("download URL"), + ) + } + + fn release_info(assets: Vec) -> ReleaseInfo { + ReleaseInfo::from_github( + Version::parse("1.4.0").expect("version"), + UpdateChannel::Stable, + "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0".to_string(), + "v1.4.0".to_string(), + assets, + ) + } + + #[derive(Clone)] + enum FixtureEntry { + Directory(String, u32), + File(String, Vec, u32), + Symlink(String, String), + Special(String, u8), + RawPath(String), + } + + fn valid_fixture_entries(identity: &ReleaseIdentity) -> Vec { + let root = format!("lg-buddy-{}-{RELEASE_TARGET}", identity.version); + let mut entries = vec![ + FixtureEntry::Directory(root.clone(), 0o755), + FixtureEntry::Directory(format!("{root}/bin"), 0o755), + FixtureEntry::Directory(format!("{root}/docs"), 0o755), + FixtureEntry::Directory(format!("{root}/systemd"), 0o755), + ]; + for relative in required_relative_files() { + let contents = if *relative == MANIFEST_NAME { + manifest_json(identity) + } else { + format!("fixture for {relative}\n").into_bytes() + }; + let mode = if executable_relative_files().contains(relative) { + 0o755 + } else { + 0o644 + }; + entries.push(FixtureEntry::File( + format!("{root}/{relative}"), + contents, + mode, + )); + } + entries.push(FixtureEntry::File( + format!("{root}/docs/testing.md"), + b"documentation\n".to_vec(), + 0o644, + )); + entries.push(FixtureEntry::File( + format!("{root}/docs/{}.md", "long-name-".repeat(20)), + b"long-path documentation\n".to_vec(), + 0o644, + )); + entries + } + + fn write_fixture_archive( + directory: &Path, + identity: &ReleaseIdentity, + entries: &[FixtureEntry], + ) -> PathBuf { + let archive_name = format!("lg-buddy-{}-{RELEASE_TARGET}.tar.gz", identity.version); + let path = directory.join(archive_name); + let file = File::create(&path).expect("create archive"); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + for fixture in entries { + match fixture { + FixtureEntry::Directory(path, mode) => { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Directory); + header.set_mode(*mode); + header.set_size(0); + header.set_cksum(); + builder + .append_data(&mut header, format!("{path}/"), io::empty()) + .expect("append directory"); + } + FixtureEntry::File(path, contents, mode) => { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_mode(*mode); + header.set_size(contents.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, path, Cursor::new(contents)) + .expect("append file"); + } + FixtureEntry::Symlink(path, target) => { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Symlink); + header.set_mode(0o777); + header.set_size(0); + header.set_link_name(target).expect("link target"); + header.set_cksum(); + builder + .append_data(&mut header, path, io::empty()) + .expect("append symlink"); + } + FixtureEntry::Special(path, kind) => { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::new(*kind)); + header.set_mode(0o600); + header.set_size(0); + header.set_cksum(); + builder + .append_data(&mut header, path, io::empty()) + .expect("append special entry"); + } + FixtureEntry::RawPath(path) => { + assert!(path.len() <= 100); + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_mode(0o600); + header.set_size(0); + header.as_mut_bytes()[..100].fill(0); + header.as_mut_bytes()[..path.len()].copy_from_slice(path.as_bytes()); + header.set_cksum(); + builder + .append(&header, io::empty()) + .expect("append raw-path entry"); + } + } + } + let encoder = builder.into_inner().expect("finish tar"); + encoder.finish().expect("finish gzip"); + path + } + + fn write_raw_extension_archive( + directory: &Path, + identity: &ReleaseIdentity, + entry_type: EntryType, + payload_size: u64, + ) -> PathBuf { + let archive_name = format!("lg-buddy-{}-{RELEASE_TARGET}.tar.gz", identity.version); + let path = directory.join(archive_name); + let mut encoder = GzEncoder::new( + File::create(&path).expect("create raw extension archive"), + Compression::default(), + ); + let mut header = Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_mode(0o600); + header.set_size(payload_size); + header.set_cksum(); + encoder + .write_all(header.as_bytes()) + .expect("write extension header"); + io::copy(&mut io::repeat(b'x').take(payload_size), &mut encoder) + .expect("write extension payload"); + let padding = (512 - payload_size % 512) % 512; + io::copy(&mut io::repeat(0).take(padding), &mut encoder).expect("write extension padding"); + encoder + .write_all(&[0_u8; 1024]) + .expect("write tar end markers"); + encoder.finish().expect("finish raw extension gzip"); + path + } + + fn read_file(path: &Path) -> Vec { + fs::read(path).expect("read fixture") + } + + fn write_embedded_identity_binary(directory: &Path, identity: &ReleaseIdentity) -> PathBuf { + let path = directory.join("lg-buddy"); + let mut bytes = vec![0_u8; 64]; + bytes[..4].copy_from_slice(b"\x7fELF"); + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + bytes[16..18].copy_from_slice(&2_u16.to_le_bytes()); + bytes[18..20].copy_from_slice(&62_u16.to_le_bytes()); + bytes[20..24].copy_from_slice(&1_u32.to_le_bytes()); + bytes[52..54].copy_from_slice(&64_u16.to_le_bytes()); + bytes[58..60].copy_from_slice(&64_u16.to_le_bytes()); + bytes[60..62].copy_from_slice(&3_u16.to_le_bytes()); + bytes[62..64].copy_from_slice(&1_u16.to_le_bytes()); + + let names = b"\0.shstrtab\0.lg_buddy.identity\0"; + let names_offset = bytes.len() as u64; + bytes.extend_from_slice(names); + let identity_offset = bytes.len() as u64; + let mut record = Vec::new(); + record.extend_from_slice(EMBEDDED_IDENTITY_PREFIX); + record.extend_from_slice(&manifest_json(identity)); + record.extend_from_slice(EMBEDDED_IDENTITY_SUFFIX); + bytes.extend_from_slice(&record); + while !bytes.len().is_multiple_of(8) { + bytes.push(0); + } + let section_offset = bytes.len() as u64; + bytes[40..48].copy_from_slice(§ion_offset.to_le_bytes()); + bytes.resize(bytes.len() + 3 * 64, 0); + let names_header = section_offset as usize + 64; + bytes[names_header..names_header + 4].copy_from_slice(&1_u32.to_le_bytes()); + bytes[names_header + 4..names_header + 8].copy_from_slice(&3_u32.to_le_bytes()); + bytes[names_header + 24..names_header + 32].copy_from_slice(&names_offset.to_le_bytes()); + bytes[names_header + 32..names_header + 40] + .copy_from_slice(&(names.len() as u64).to_le_bytes()); + let identity_header = section_offset as usize + 2 * 64; + bytes[identity_header..identity_header + 4].copy_from_slice(&11_u32.to_le_bytes()); + bytes[identity_header + 4..identity_header + 8].copy_from_slice(&1_u32.to_le_bytes()); + bytes[identity_header + 24..identity_header + 32] + .copy_from_slice(&identity_offset.to_le_bytes()); + bytes[identity_header + 32..identity_header + 40] + .copy_from_slice(&(record.len() as u64).to_le_bytes()); + fs::write(&path, bytes).expect("write embedded identity fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("make fixture executable"); + path + } + + struct FakeSource { + fresh_release: ReleaseInfo, + payloads: HashMap>, + commit: String, + } + + impl GitHubSource for FakeSource { + fn fetch_release_by_tag( + &self, + _selected: &ReleaseInfo, + ) -> Result { + Ok(self.fresh_release.clone()) + } + + fn resolve_tag_commit(&self, _tag: &str) -> Result { + Ok(self.commit.clone()) + } + + fn download_asset( + &self, + asset: &ReleaseAsset, + destination: &Path, + max_bytes: u64, + ) -> Result { + let bytes = + self.payloads + .get(&asset.id()) + .ok_or_else(|| BundleAcquisitionError::Http { + url: asset.api_url().to_string(), + message: "missing fake payload".to_string(), + })?; + if bytes.len() as u64 > max_bytes { + return Err(BundleAcquisitionError::ResponseTooLarge { + label: asset.name().to_string(), + max_bytes, + }); + } + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(destination)?; + file.write_all(bytes)?; + file.flush()?; + Ok(DownloadedAsset { + file, + facts: DownloadFacts { + bytes: bytes.len() as u64, + sha256: sha256(bytes), + }, + }) + } + } + + struct FakeBinaryIdentityReader { + identity: ReleaseIdentity, + calls: AtomicUsize, + } + + struct UncalledSource; + + impl GitHubSource for UncalledSource { + fn fetch_release_by_tag( + &self, + _selected: &ReleaseInfo, + ) -> Result { + panic!("concurrent acquisition reached release metadata") + } + + fn resolve_tag_commit(&self, _tag: &str) -> Result { + panic!("concurrent acquisition reached tag resolution") + } + + fn download_asset( + &self, + _asset: &ReleaseAsset, + _destination: &Path, + _max_bytes: u64, + ) -> Result { + panic!("concurrent acquisition reached asset download") + } + } + + impl BinaryIdentityReader for FakeBinaryIdentityReader { + fn read_identity( + &self, + _binary: &Path, + _target: &str, + _release_tag: &str, + ) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); + Ok(self.identity.clone()) + } + } + + #[test] + fn asset_redirect_policy_accepts_only_the_release_asset_host_and_path() { + validate_asset_redirect( + "https://release-assets.githubusercontent.com/github-production-release-asset/1/file?sig=x", + ) + .expect("allowed redirect"); + for rejected in [ + "http://release-assets.githubusercontent.com/github-production-release-asset/1/file", + "https://example.com/github-production-release-asset/1/file", + "https://release-assets.githubusercontent.com/unexpected/1/file", + "https://user@release-assets.githubusercontent.com/github-production-release-asset/1/file", + "https://release-assets.githubusercontent.com:444/github-production-release-asset/1/file", + "https://release-assets.githubusercontent.com/github-production-release-asset/1/file#fragment", + ] { + assert!(validate_asset_redirect(rejected).is_err(), "{rejected}"); + } + let error = validate_asset_redirect( + "https://user:secret@evil.example/github-production-release-asset/1/file?token=signed", + ) + .expect_err("userinfo redirect must fail") + .to_string(); + assert!(!error.contains("user")); + assert!(!error.contains("secret")); + assert!(!error.contains("signed")); + } + + #[test] + fn request_budget_never_allows_connect_to_outlive_the_remaining_deadline() { + let full = request_budget(GITHUB_REQUEST_TIMEOUT); + assert_eq!(full.connect, GITHUB_CONNECT_TIMEOUT); + assert_eq!(full.request, GITHUB_REQUEST_TIMEOUT); + + let redirect_remaining = Duration::from_millis(25); + let redirect = request_budget(redirect_remaining); + assert_eq!(redirect.connect, redirect_remaining); + assert_eq!(redirect.request, redirect_remaining); + } + + #[test] + fn sha256_requires_canonical_lowercase_hex() { + assert!(validate_sha256(&"a".repeat(64)).is_ok()); + assert!(validate_sha256(&"A".repeat(64)).is_err()); + assert!(validate_sha256(&"a".repeat(63)).is_err()); + assert!(validate_sha256(&"z".repeat(64)).is_err()); + } + + #[test] + fn checksum_requires_exactly_one_archive_entry() { + let dir = temp_dir("checksum"); + let path = dir.join("sha256sums.txt"); + let digest = "a".repeat(64); + fs::write(&path, format!("{digest} bundle.tar.gz\n")).expect("write checksum"); + let file = File::open(&path).expect("open checksum"); + assert_eq!( + published_archive_digest(&file, "bundle.tar.gz").expect("digest"), + digest + ); + fs::write(&path, format!("{digest} ./bundle.tar.gz\n")) + .expect("write workflow-style checksum"); + let file = File::open(&path).expect("open workflow checksum"); + assert_eq!( + published_archive_digest(&file, "bundle.tar.gz").expect("workflow digest"), + digest + ); + fs::write( + &path, + format!("{digest} bundle.tar.gz\n{digest} *bundle.tar.gz\n"), + ) + .expect("write duplicate checksum"); + let file = File::open(&path).expect("open duplicate checksum"); + assert!(matches!( + published_archive_digest(&file, "bundle.tar.gz"), + Err(BundleAcquisitionError::Checksum(_)) + )); + for malformed in [ + format!("{} bundle.tar.gz\n", "a".repeat(64)), + format!("{} other.tar.gz\n", "a".repeat(64)), + format!("{}é bundle.tar.gz\n", "a".repeat(63)), + "not-a-checksum\n".to_string(), + ] { + fs::write(&path, malformed).expect("write malformed checksum"); + let file = File::open(&path).expect("open malformed checksum"); + assert!(published_archive_digest(&file, "bundle.tar.gz").is_err()); + } + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn pinned_download_descriptor_ignores_later_path_replacement() { + let dir = temp_dir("pinned-download"); + let path = dir.join("asset"); + let mut original = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .expect("create original"); + original.write_all(b"verified").expect("write original"); + pin_download_file(&path, &original).expect("pin original"); + fs::write(&path, b"replacement").expect("create replacement"); + + let mut pinned = rewind_clone(&original).expect("rewind pinned file"); + let mut contents = Vec::new(); + pinned.read_to_end(&mut contents).expect("read pinned file"); + assert_eq!(contents, b"verified"); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn manifest_rejects_duplicate_fields_and_identity_mismatches() { + let identity = stable_identity(); + let valid = format!( + "{{\"schema_version\":1,\"critical\":[\"release_tag\",\"version\",\"channel\",\"target\",\"commit\"],\"release_tag\":\"{}\",\"version\":\"{}\",\"channel\":\"{}\",\"target\":\"{}\",\"commit\":\"{}\"}}", + identity.release_tag, + identity.version, + identity.channel.as_str(), + identity.target, + identity.commit + ); + assert_eq!( + parse_manifest(valid.as_bytes()).expect("manifest"), + identity + ); + let duplicate = valid.replacen( + "\"schema_version\":1", + "\"schema_version\":1,\"schema_version\":1", + 1, + ); + assert!(parse_manifest(duplicate.as_bytes()).is_err()); + let wrong_channel = valid.replace("\"channel\":\"stable\"", "\"channel\":\"prerelease\""); + assert!(parse_manifest(wrong_channel.as_bytes()).is_err()); + for invalid in [ + valid.replace("\"release_tag\":\"v1.4.0\"", "\"release_tag\":\"v1.4.1\""), + valid.replace("\"version\":\"1.4.0\"", "\"version\":\"1.4.0+build\""), + valid.replace( + "\"target\":\"x86_64-unknown-linux-musl\"", + "\"target\":\"/unsafe\"", + ), + valid.replace("0123456789abcdef0123456789abcdef01234567", "not-a-commit"), + valid.replace("\"commit\"]", "\"unknown\"]"), + valid.replace("\"commit\"]", "\"target\"]"), + ] { + assert!(parse_manifest(invalid.as_bytes()).is_err(), "{invalid}"); + } + } + + #[test] + fn embedded_binary_identity_reader_requires_exact_elf_identity() { + let dir = temp_dir("embedded-binary-identity"); + let identity = stable_identity(); + let binary = write_embedded_identity_binary(&dir, &identity); + assert_eq!( + read_embedded_binary_identity(&binary, RELEASE_TARGET, "v1.4.0") + .expect("embedded identity"), + identity + ); + assert!(read_embedded_binary_identity(&binary, RELEASE_TARGET, "v1.4.1").is_err()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn current_test_binary_exposes_exactly_one_identity_elf_section() { + let binary = std::env::current_exe().expect("current test executable"); + let bytes = fs::read(binary).expect("read current test executable"); + let record = embedded_identity_section(&bytes).expect("embedded identity section"); + assert!(record.starts_with(EMBEDDED_IDENTITY_PREFIX)); + assert!(record.ends_with(EMBEDDED_IDENTITY_SUFFIX)); + let payload = + &record[EMBEDDED_IDENTITY_PREFIX.len()..record.len() - EMBEDDED_IDENTITY_SUFFIX.len()]; + let manifest: serde_json::Value = + serde_json::from_slice(payload).expect("embedded identity JSON"); + assert!(manifest["target"] + .as_str() + .is_some_and(|target| !target.is_empty())); + } + + #[test] + fn embedded_binary_identity_rejects_wrong_target_and_non_elf_files() { + let dir = temp_dir("invalid-embedded-binary"); + let wrong_target = ReleaseIdentity { + target: "x86_64-unknown-linux-gnu".to_string(), + ..stable_identity() + }; + let binary = write_embedded_identity_binary(&dir, &wrong_target); + assert!(read_embedded_binary_identity(&binary, RELEASE_TARGET, "v1.4.0").is_err()); + + fs::write(&binary, b"not an ELF executable").expect("replace with script-shaped file"); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).expect("fixture mode"); + assert!(read_embedded_binary_identity(&binary, RELEASE_TARGET, "v1.4.0").is_err()); + + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn asset_selection_requires_one_uploaded_bounded_asset_of_each_exact_name() { + let archive_name = format!("lg-buddy-1.4.0-{RELEASE_TARGET}.tar.gz"); + let archive = release_asset(1, &archive_name, b"archive"); + let checksum = release_asset(2, CHECKSUM_ASSET_NAME, b"checksum"); + let release = release_info(vec![archive.clone(), checksum.clone()]); + let selected = select_release_assets(&release).expect("select assets"); + assert_eq!(selected.archive.id(), 1); + assert_eq!(selected.checksum.id(), 2); + + assert!(select_release_assets(&release_info(vec![checksum.clone()])).is_err()); + assert!(select_release_assets(&release_info(vec![ + archive.clone(), + archive.clone(), + checksum.clone(), + ])) + .is_err()); + let wrong_target = release_asset( + 3, + "lg-buddy-1.4.0-aarch64-unknown-linux-musl.tar.gz", + b"archive", + ); + assert!( + select_release_assets(&release_info(vec![wrong_target, checksum.clone()])).is_err() + ); + + let pending = ReleaseAsset::from_github( + 1, + archive_name.clone(), + "new".to_string(), + 7, + archive.digest().map(str::to_string), + archive.api_url().to_string(), + archive.download_url().to_string(), + ); + assert!(select_release_assets(&release_info(vec![pending, checksum.clone()])).is_err()); + let oversized = ReleaseAsset::from_github( + 1, + archive_name, + "uploaded".to_string(), + MAX_ARCHIVE_BYTES + 1, + archive.digest().map(str::to_string), + archive.api_url().to_string(), + archive.download_url().to_string(), + ); + assert!(matches!( + select_release_assets(&release_info(vec![oversized, checksum])), + Err(BundleAcquisitionError::ResponseTooLarge { .. }) + )); + } + + #[test] + fn asset_selection_rejects_missing_digest_and_repository_url_substitution() { + let archive_name = format!("lg-buddy-1.4.0-{RELEASE_TARGET}.tar.gz"); + let archive = release_asset(1, &archive_name, b"archive"); + let checksum = release_asset(2, CHECKSUM_ASSET_NAME, b"checksum"); + let missing_digest = ReleaseAsset::from_github( + archive.id(), + archive.name().to_string(), + "uploaded".to_string(), + archive.size(), + None, + archive.api_url().to_string(), + archive.download_url().to_string(), + ); + assert!(matches!( + select_release_assets(&release_info(vec![missing_digest, checksum.clone()])), + Err(BundleAcquisitionError::Digest(_)) + )); + let foreign_url = ReleaseAsset::from_github( + archive.id(), + archive.name().to_string(), + "uploaded".to_string(), + archive.size(), + archive.digest().map(str::to_string), + "https://api.github.com/repos/other/project/releases/assets/1".to_string(), + archive.download_url().to_string(), + ); + assert!(matches!( + select_release_assets(&release_info(vec![foreign_url, checksum])), + Err(BundleAcquisitionError::ReleaseMetadata(_)) + )); + } + + #[test] + fn tag_peeling_accepts_lightweight_and_bounded_annotated_tags() { + let commit = "0123456789abcdef0123456789abcdef01234567"; + assert_eq!( + peel_tag_object( + GitObject { + kind: "commit".to_string(), + sha: commit.to_string(), + }, + |_| panic!("lightweight tag must not fetch a tag object") + ) + .expect("lightweight tag"), + commit + ); + + let annotated = "1111111111111111111111111111111111111111"; + assert_eq!( + peel_tag_object( + GitObject { + kind: "tag".to_string(), + sha: annotated.to_string(), + }, + |sha| { + assert_eq!(sha, annotated); + Ok(GitObject { + kind: "commit".to_string(), + sha: commit.to_string(), + }) + } + ) + .expect("annotated tag"), + commit + ); + } + + #[test] + fn tag_peeling_rejects_cycles_depth_invalid_shas_and_non_commits() { + let tag_sha = "1111111111111111111111111111111111111111"; + assert!(peel_tag_object( + GitObject { + kind: "tag".to_string(), + sha: tag_sha.to_string(), + }, + |_| Ok(GitObject { + kind: "tag".to_string(), + sha: tag_sha.to_string(), + }) + ) + .is_err()); + + let mut depth = 1_u64; + assert!(peel_tag_object( + GitObject { + kind: "tag".to_string(), + sha: format!("{depth:040x}"), + }, + |_| { + depth += 1; + Ok(GitObject { + kind: "tag".to_string(), + sha: format!("{depth:040x}"), + }) + } + ) + .is_err()); + assert!(peel_tag_object( + GitObject { + kind: "commit".to_string(), + sha: "short".to_string(), + }, + |_| unreachable!() + ) + .is_err()); + assert!(peel_tag_object( + GitObject { + kind: "tree".to_string(), + sha: tag_sha.to_string(), + }, + |_| unreachable!() + ) + .is_err()); + } + + #[test] + fn archive_path_normalization_rejects_aliases_and_escape_shapes() { + assert_eq!( + normalize_archive_path(b"root/file", EntryType::Regular).expect("normal path"), + "root/file" + ); + assert_eq!( + normalize_archive_path(b"root/dir/", EntryType::Directory).expect("directory path"), + "root/dir" + ); + for rejected in [ + b"/root/file".as_slice(), + b"root/../file", + b"root/./file", + b"root//file", + b"root\\file", + b"", + ] { + assert!( + normalize_archive_path(rejected, EntryType::Regular).is_err(), + "{rejected:?}" + ); + } + assert!(normalize_archive_path(b"root/file/", EntryType::Regular).is_err()); + assert!(normalize_archive_path(b"root/\0file", EntryType::Regular).is_err()); + } + + #[test] + fn archive_layout_rejects_every_link_device_and_special_entry_type() { + let root = "lg-buddy-1.4.0-x86_64-unknown-linux-musl"; + for kind in [b'1', b'2', b'3', b'4', b'6', b'7', b'S', b'L', b'K'] { + assert!(validate_archive_layout( + &format!("{root}/docs/entry"), + root, + EntryType::new(kind), + ) + .is_err()); + } + } + + #[test] + fn archive_trailing_data_check_is_strict_and_bounded() { + reject_archive_trailing_data(&mut Cursor::new(vec![0_u8; 1024])) + .expect("bounded zero padding"); + assert!(reject_archive_trailing_data(&mut Cursor::new(b"\0unexpected")).is_err()); + assert!(reject_archive_trailing_data(&mut Cursor::new(vec![ + 0_u8; + (MAX_ARCHIVE_TRAILING_BYTES + 1) + as usize + ])) + .is_err()); + } + + #[test] + fn valid_archive_is_fully_inspected_then_extracted_with_private_modes() { + let dir = temp_dir("valid-archive"); + let identity = stable_identity(); + let archive = write_fixture_archive(&dir, &identity, &valid_fixture_entries(&identity)); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("name"); + let archive_file = File::open(&archive).expect("open archive"); + let plan = inspect_archive(&archive_file, name, &identity).expect("inspect archive"); + let extraction = dir.join("extraction"); + fs::create_dir(&extraction).expect("create extraction root"); + let root = extract_archive(&archive_file, &extraction, &plan).expect("extract archive"); + assert_eq!( + fs::symlink_metadata(root.join("lg-buddy")) + .expect("binary metadata") + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + fs::symlink_metadata(root.join("release-manifest.json")) + .expect("manifest metadata") + .mode() + & 0o777, + 0o600 + ); + assert!(root + .join("docs") + .join(format!("{}.md", "long-name-".repeat(20))) + .is_file()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn archive_rejects_duplicate_link_unsafe_mode_missing_and_unexpected_entries() { + let identity = stable_identity(); + let root = format!("lg-buddy-{}-{RELEASE_TARGET}", identity.version); + let cases = [ + { + let mut entries = valid_fixture_entries(&identity); + entries.push(entries[4].clone()); + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + entries.push(FixtureEntry::Symlink( + format!("{root}/docs/link"), + "../../outside".to_string(), + )); + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + entries.push(FixtureEntry::Special(format!("{root}/docs/fifo"), b'6')); + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + entries.push(FixtureEntry::RawPath("../outside".to_string())); + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + if let FixtureEntry::File(_, _, mode) = &mut entries[4] { + *mode = 0o666; + } + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + entries.retain(|entry| { + !matches!(entry, FixtureEntry::File(path, _, _) if path.ends_with("/README.md")) + }); + entries + }, + { + let mut entries = valid_fixture_entries(&identity); + entries.push(FixtureEntry::File( + "other-root/file".to_string(), + b"unexpected".to_vec(), + 0o644, + )); + entries + }, + ]; + for (index, entries) in cases.into_iter().enumerate() { + let dir = temp_dir(&format!("invalid-archive-{index}")); + let archive = write_fixture_archive(&dir, &identity, &entries); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("name"); + let archive_file = File::open(&archive).expect("open archive"); + assert!( + inspect_archive(&archive_file, name, &identity).is_err(), + "case {index}" + ); + fs::remove_dir_all(dir).expect("remove test directory"); + } + } + + #[test] + fn archive_rejects_manifest_identity_mismatch_and_truncation() { + let identity = stable_identity(); + let mut entries = valid_fixture_entries(&identity); + for entry in &mut entries { + if let FixtureEntry::File(path, contents, _) = entry { + if path.ends_with(MANIFEST_NAME) { + *contents = manifest_json(&ReleaseIdentity { + commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + ..identity.clone() + }); + } + } + } + let dir = temp_dir("manifest-mismatch"); + let archive = write_fixture_archive(&dir, &identity, &entries); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("name"); + let archive_file = File::open(&archive).expect("open archive"); + assert!(matches!( + inspect_archive(&archive_file, name, &identity), + Err(BundleAcquisitionError::Manifest(_)) + )); + + let archive = write_fixture_archive(&dir, &identity, &valid_fixture_entries(&identity)); + let length = fs::metadata(&archive).expect("archive metadata").len(); + OpenOptions::new() + .write(true) + .open(&archive) + .expect("open archive") + .set_len(length / 2) + .expect("truncate archive"); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("name"); + let archive_file = File::open(&archive).expect("open truncated archive"); + assert!(inspect_archive(&archive_file, name, &identity).is_err()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn archive_rejects_a_trailing_gzip_member() { + let identity = stable_identity(); + let dir = temp_dir("trailing-gzip-member"); + let archive = write_fixture_archive(&dir, &identity, &valid_fixture_entries(&identity)); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(b"unexpected trailing member") + .expect("gzip payload"); + let trailing = encoder.finish().expect("finish trailing gzip"); + OpenOptions::new() + .append(true) + .open(&archive) + .expect("open archive for append") + .write_all(&trailing) + .expect("append trailing gzip member"); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("name"); + let archive_file = File::open(&archive).expect("open archive"); + assert!(inspect_archive(&archive_file, name, &identity).is_err()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn archive_inspection_enforces_the_compressed_size_limit() { + let identity = stable_identity(); + let dir = temp_dir("oversized-compressed-archive"); + let name = format!("lg-buddy-{}-{RELEASE_TARGET}.tar.gz", identity.version()); + let path = dir.join(&name); + File::create(&path) + .expect("create sparse archive") + .set_len(MAX_ARCHIVE_BYTES + 1) + .expect("size sparse archive"); + let file = File::open(&path).expect("open sparse archive"); + assert!(matches!( + inspect_archive(&file, &name, &identity), + Err(BundleAcquisitionError::ResponseTooLarge { .. }) + )); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn raw_archive_preflight_rejects_oversized_hidden_extension_payloads() { + let identity = stable_identity(); + for (label, entry_type) in [ + ("gnu-long-name", EntryType::new(b'L')), + ("pax-local", EntryType::new(b'x')), + ] { + let dir = temp_dir(label); + let archive = write_raw_extension_archive( + &dir, + &identity, + entry_type, + MAX_ARCHIVE_METADATA_BYTES + 1, + ); + let file = File::open(&archive).expect("open extension archive"); + let name = archive + .file_name() + .and_then(|name| name.to_str()) + .expect("archive name"); + assert!(matches!( + inspect_archive(&file, name, &identity), + Err(BundleAcquisitionError::Archive(message)) + if message.contains("archive metadata entry exceeds") + )); + fs::remove_dir_all(dir).expect("remove test directory"); + } + } + + #[test] + fn archive_inspection_rejects_an_oversized_declared_entry_before_reading_it() { + let identity = stable_identity(); + let dir = temp_dir("oversized-archive-entry"); + let name = format!("lg-buddy-{}-{RELEASE_TARGET}.tar.gz", identity.version()); + let path = dir.join(&name); + let mut header = Header::new_gnu(); + header + .set_path(format!( + "lg-buddy-{}-{RELEASE_TARGET}/docs/oversized", + identity.version() + )) + .expect("entry path"); + header.set_entry_type(EntryType::Regular); + header.set_mode(0o644); + header.set_size(MAX_ARCHIVE_FILE_BYTES + 1); + header.set_cksum(); + let mut encoder = GzEncoder::new( + File::create(&path).expect("create archive"), + Compression::default(), + ); + encoder + .write_all(header.as_bytes()) + .expect("write tar header"); + encoder + .write_all(&[0_u8; 1024]) + .expect("write tar end markers"); + encoder.finish().expect("finish gzip"); + + let file = File::open(&path).expect("open archive"); + assert!(inspect_archive(&file, &name, &identity).is_err()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn staging_lock_excludes_concurrent_attempts_and_cleanup_is_guard_owned() { + let dir = temp_dir("staging-lock"); + let cache_root = dir.join("cache"); + let first = StagingDirectory::create(&cache_root).expect("first staging"); + let first_path = first.path.clone(); + assert!(matches!( + StagingDirectory::create(&cache_root), + Err(BundleAcquisitionError::ConcurrentAcquisition) + )); + assert!(first_path.is_dir()); + drop(first); + assert!(!first_path.exists()); + assert!(cache_root.join(LOCK_FILE_NAME).is_file()); + let next = StagingDirectory::create(&cache_root).expect("next staging"); + drop(next); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn staging_cleanup_does_not_remove_a_replacement_directory() { + let dir = temp_dir("staging-replacement"); + let cache_root = dir.join("cache"); + let staging = StagingDirectory::create(&cache_root).expect("create staging"); + let original = staging.path.clone(); + let moved = cache_root.join("moved-original"); + fs::rename(&original, &moved).expect("move original staging"); + fs::create_dir(&original).expect("create replacement staging"); + fs::set_permissions(&original, fs::Permissions::from_mode(0o700)) + .expect("secure replacement"); + drop(staging); + assert!(original.is_dir()); + assert!(moved.is_dir()); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn concurrent_acquisition_fails_before_any_source_operation() { + let dir = temp_dir("concurrent-before-source"); + let cache_root = dir.join("cache"); + let active = StagingDirectory::create(&cache_root).expect("active staging"); + let active_path = active.path.clone(); + let binary_reader = FakeBinaryIdentityReader { + identity: stable_identity(), + calls: AtomicUsize::new(0), + }; + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &UncalledSource, + &binary_reader, + ), + Err(BundleAcquisitionError::ConcurrentAcquisition) + )); + assert!(active_path.is_dir()); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); + drop(active); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn staging_rejects_a_hard_linked_lock_file() { + let dir = temp_dir("hard-linked-lock"); + let cache_root = dir.join("cache"); + let first = StagingDirectory::create(&cache_root).expect("create staging"); + drop(first); + fs::hard_link( + cache_root.join(LOCK_FILE_NAME), + cache_root.join("lock-alias"), + ) + .expect("hard link lock"); + assert!(matches!( + StagingDirectory::create(&cache_root), + Err(BundleAcquisitionError::UnsafeStaging(_)) + )); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn staging_rejects_relative_symlinked_and_shared_writable_roots() { + assert!(matches!( + StagingDirectory::create(Path::new("relative/cache")), + Err(BundleAcquisitionError::UnsafeStaging(_)) + )); + let dir = temp_dir("unsafe-staging"); + let target = dir.join("target"); + fs::create_dir(&target).expect("create target"); + let link = dir.join("link"); + std::os::unix::fs::symlink(&target, &link).expect("create symlink"); + assert!(matches!( + StagingDirectory::create(&link.join("cache")), + Err(BundleAcquisitionError::UnsafeStaging(_)) + )); + let shared = dir.join("shared"); + fs::create_dir(&shared).expect("create shared"); + fs::set_permissions(&shared, fs::Permissions::from_mode(0o777)).expect("set shared mode"); + assert!(matches!( + StagingDirectory::create(&shared.join("cache")), + Err(BundleAcquisitionError::UnsafeStaging(_)) + )); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + fn acquisition_uses_fresh_metadata_and_returns_only_a_verified_owned_stage() { + let identity = stable_identity(); + let fixture_dir = temp_dir("complete-acquisition"); + let archive_path = + write_fixture_archive(&fixture_dir, &identity, &valid_fixture_entries(&identity)); + let archive_name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .expect("archive name") + .to_string(); + let archive_bytes = read_file(&archive_path); + let checksum_bytes = format!("{} {archive_name}\n", sha256(&archive_bytes)).into_bytes(); + let archive_asset = release_asset(1, &archive_name, &archive_bytes); + let checksum_asset = release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes); + let fresh_release = release_info(vec![archive_asset, checksum_asset]); + let source = FakeSource { + fresh_release, + payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), + commit: identity.commit.clone(), + }; + let binary_reader = FakeBinaryIdentityReader { + identity: identity.clone(), + calls: AtomicUsize::new(0), + }; + let cache_root = fixture_dir.join("cache"); + let verified = acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &source, + &binary_reader, + ) + .expect("verified acquisition"); + assert_eq!(verified.identity(), &identity); + assert!(verified.root().join("install.sh").is_file()); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 1); + let staging_root = verified + .root() + .parent() + .expect("candidate root parent") + .to_path_buf(); + assert!(staging_root.is_dir()); + drop(verified); + assert!(!staging_root.exists()); + assert!(cache_root.join(LOCK_FILE_NAME).is_file()); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + fn acquisition_rejects_fresh_release_identity_disagreement_before_download() { + let fixture_dir = temp_dir("fresh-release-disagreement"); + let source = FakeSource { + fresh_release: ReleaseInfo::from_github( + Version::parse("1.4.1").expect("version"), + UpdateChannel::Stable, + "https://github.test/releases/tag/v1.4.1".to_string(), + "v1.4.1".to_string(), + Vec::new(), + ), + payloads: HashMap::new(), + commit: stable_identity().commit, + }; + let binary_reader = FakeBinaryIdentityReader { + identity: stable_identity(), + calls: AtomicUsize::new(0), + }; + let cache_root = fixture_dir.join("cache"); + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &source, + &binary_reader, + ), + Err(BundleAcquisitionError::ReleaseMetadata(_)) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); + assert!(fs::read_dir(&cache_root) + .expect("read cache root") + .all(|entry| entry.expect("cache entry").file_name() == LOCK_FILE_NAME)); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + fn integrity_failure_cleans_staging_without_running_the_binary() { + let identity = stable_identity(); + let fixture_dir = temp_dir("failed-acquisition"); + let archive_path = + write_fixture_archive(&fixture_dir, &identity, &valid_fixture_entries(&identity)); + let archive_name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .expect("archive name") + .to_string(); + let archive_bytes = read_file(&archive_path); + let checksum_bytes = format!("{} {archive_name}\n", "a".repeat(64)).into_bytes(); + let source = FakeSource { + fresh_release: release_info(vec![ + release_asset(1, &archive_name, &archive_bytes), + release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes), + ]), + payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), + commit: identity.commit.clone(), + }; + let binary_reader = FakeBinaryIdentityReader { + identity, + calls: AtomicUsize::new(0), + }; + let cache_root = fixture_dir.join("cache"); + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &source, + &binary_reader, + ), + Err(BundleAcquisitionError::Checksum(_)) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); + let entries: Vec<_> = fs::read_dir(&cache_root) + .expect("read cache root") + .map(|entry| entry.expect("cache entry").file_name()) + .collect(); + assert_eq!(entries, vec![LOCK_FILE_NAME]); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + fn interrupted_asset_is_rejected_and_staging_is_removed() { + let identity = stable_identity(); + let fixture_dir = temp_dir("interrupted-acquisition"); + let archive_name = format!("lg-buddy-1.4.0-{RELEASE_TARGET}.tar.gz"); + let declared_archive = b"complete archive"; + let checksum_bytes = format!("{} {archive_name}\n", sha256(declared_archive)).into_bytes(); + let source = FakeSource { + fresh_release: release_info(vec![ + release_asset(1, &archive_name, declared_archive), + release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes), + ]), + payloads: HashMap::from([(1, b"short".to_vec()), (2, checksum_bytes)]), + commit: identity.commit.clone(), + }; + let binary_reader = FakeBinaryIdentityReader { + identity, + calls: AtomicUsize::new(0), + }; + let cache_root = fixture_dir.join("cache"); + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &source, + &binary_reader, + ), + Err(BundleAcquisitionError::InterruptedAsset { .. }) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); + assert!(fs::read_dir(&cache_root) + .expect("read cache root") + .all(|entry| entry.expect("cache entry").file_name() == LOCK_FILE_NAME)); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + fn same_size_digest_mismatch_is_rejected_before_archive_or_binary_work() { + let identity = stable_identity(); + let fixture_dir = temp_dir("digest-mismatch"); + let archive_name = format!("lg-buddy-1.4.0-{RELEASE_TARGET}.tar.gz"); + let declared_archive = b"expected"; + let checksum_bytes = format!("{} {archive_name}\n", sha256(declared_archive)).into_bytes(); + let source = FakeSource { + fresh_release: release_info(vec![ + release_asset(1, &archive_name, declared_archive), + release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes), + ]), + payloads: HashMap::from([(1, b"tampered".to_vec()), (2, checksum_bytes)]), + commit: identity.commit.clone(), + }; + let binary_reader = FakeBinaryIdentityReader { + identity, + calls: AtomicUsize::new(0), + }; + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &fixture_dir.join("cache"), + &source, + &binary_reader, + ), + Err(BundleAcquisitionError::Digest(_)) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + fn binary_identity_mismatch_removes_the_fully_extracted_stage() { + let identity = stable_identity(); + let fixture_dir = temp_dir("binary-mismatch"); + let archive_path = + write_fixture_archive(&fixture_dir, &identity, &valid_fixture_entries(&identity)); + let archive_name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .expect("archive name") + .to_string(); + let archive_bytes = read_file(&archive_path); + let checksum_bytes = format!("{} {archive_name}\n", sha256(&archive_bytes)).into_bytes(); + let source = FakeSource { + fresh_release: release_info(vec![ + release_asset(1, &archive_name, &archive_bytes), + release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes), + ]), + payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), + commit: identity.commit.clone(), + }; + let binary_reader = FakeBinaryIdentityReader { + identity: ReleaseIdentity { + commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + ..identity + }, + calls: AtomicUsize::new(0), + }; + let cache_root = fixture_dir.join("cache"); + assert!(matches!( + acquire_release_bundle_with( + &release_info(Vec::new()), + &cache_root, + &source, + &binary_reader, + ), + Err(BundleAcquisitionError::Binary(_)) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 1); + assert!(fs::read_dir(&cache_root) + .expect("read cache root") + .all(|entry| entry.expect("cache entry").file_name() == LOCK_FILE_NAME)); + fs::remove_dir_all(fixture_dir).expect("remove test directory"); + } + + #[test] + #[ignore = "contacts GitHub and checks the historical pre-manifest beta bundle"] + fn live_historical_beta_reaches_archive_validation_and_is_rejected() { + let selected = ReleaseInfo::from_github( + Version::parse("1.4.0-beta.1").expect("version"), + UpdateChannel::Prerelease, + "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0-beta.1".to_string(), + "v1.4.0-beta.1".to_string(), + Vec::new(), + ); + let dir = temp_dir("live-historical-beta"); + let error = acquire_release_bundle_with( + &selected, + &dir.join("cache"), + &UreqGitHubSource::new(), + &EmbeddedBinaryIdentityReader, + ) + .expect_err("historical beta predates the manifest contract"); + assert!(matches!(error, BundleAcquisitionError::Archive(_))); + fs::remove_dir_all(dir).expect("remove test directory"); + } + + #[test] + #[ignore = "set LG_BUDDY_TEST_RELEASE_TAG to a release built with the current bundle contract"] + fn live_published_release_acquires_into_a_verified_stage() { + let tag = std::env::var("LG_BUDDY_TEST_RELEASE_TAG") + .expect("set LG_BUDDY_TEST_RELEASE_TAG to an eligible published release tag"); + let version = Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).expect("version tag"); + let channel = if version.pre.is_empty() { + UpdateChannel::Stable + } else { + UpdateChannel::Prerelease + }; + let selected = ReleaseInfo::from_github( + version, + channel, + format!("https://github.com/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tag/{tag}"), + tag, + Vec::new(), + ); + let dir = temp_dir("live-published-release"); + let verified = acquire_release_bundle_with( + &selected, + &dir.join("cache"), + &UreqGitHubSource::new(), + &EmbeddedBinaryIdentityReader, + ) + .expect("acquire published release"); + assert_eq!(verified.identity().version(), selected.version()); + assert_eq!(verified.identity().channel(), selected.channel()); + assert_eq!(verified.identity().target(), RELEASE_TARGET); + assert!(verified.root().join("install.sh").is_file()); + drop(verified); + fs::remove_dir_all(dir).expect("remove test directory"); + } +} diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 838e6c4..2bf9f57 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -1,7 +1,7 @@ use std::error::Error; use std::fmt; use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -22,6 +22,8 @@ const GITHUB_API_VERSION: &str = "2026-03-10"; const GITHUB_ACCEPT: &str = "application/vnd.github+json"; const GITHUB_CONNECT_TIMEOUT_SECONDS: u64 = 5; const GITHUB_REQUEST_TIMEOUT_SECONDS: u64 = 20; +const MAX_GITHUB_RESPONSE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_GITHUB_ERROR_BYTES: u64 = 16 * 1024; const PRERELEASE_PAGE_SIZE: u8 = 20; const CACHE_DIR_NAME: &str = "lg-buddy"; const UPDATE_CHECK_CACHE_FILE_NAME: &str = "update-check.json"; @@ -328,6 +330,10 @@ pub enum UpdatesError { endpoint: &'static str, source: serde_json::Error, }, + ResponseTooLarge { + url: String, + max_bytes: u64, + }, InvalidLocalVersion { version: String, source: semver::Error, @@ -401,6 +407,10 @@ impl fmt::Display for UpdatesError { "could not parse GitHub releases API `{endpoint}` response: {source}" ) } + Self::ResponseTooLarge { url, max_bytes } => write!( + f, + "GitHub releases API `{url}` exceeded the {max_bytes}-byte response limit" + ), Self::InvalidLocalVersion { version, source } => { write!(f, "invalid local LG Buddy version `{version}`: {source}") } @@ -469,6 +479,7 @@ impl Error for UpdatesError { Self::Io(err) => Some(err), Self::Http { .. } | Self::ApiStatus { .. } + | Self::ResponseTooLarge { .. } | Self::NoMatchingRelease { .. } | Self::NotModifiedWithoutCache { .. } | Self::SettingsInvariant(_) => None, @@ -493,9 +504,88 @@ pub struct ReleaseInfo { version: Version, channel: UpdateChannel, url: String, + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReleaseAsset { + id: u64, + name: String, + state: String, + size: u64, + digest: Option, + api_url: String, + download_url: String, +} + +impl ReleaseAsset { + pub fn id(&self) -> u64 { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn size(&self) -> u64 { + self.size + } + + pub fn state(&self) -> &str { + &self.state + } + + pub fn digest(&self) -> Option<&str> { + self.digest.as_deref() + } + + pub fn api_url(&self) -> &str { + &self.api_url + } + + pub fn download_url(&self) -> &str { + &self.download_url + } + + pub(crate) fn from_github( + id: u64, + name: String, + state: String, + size: u64, + digest: Option, + api_url: String, + download_url: String, + ) -> Self { + Self { + id, + name, + state, + size, + digest, + api_url, + download_url, + } + } } impl ReleaseInfo { + pub(crate) fn from_github( + version: Version, + channel: UpdateChannel, + url: String, + tag_name: String, + assets: Vec, + ) -> Self { + Self { + version, + channel, + url, + tag_name, + assets, + } + } + pub fn version(&self) -> &Version { &self.version } @@ -508,19 +598,37 @@ impl ReleaseInfo { &self.url } + pub fn tag_name(&self) -> &str { + &self.tag_name + } + + pub fn assets(&self) -> &[ReleaseAsset] { + &self.assets + } + fn to_cached(&self) -> CachedReleaseInfo { CachedReleaseInfo { version: self.version.to_string(), channel: self.channel, url: self.url.clone(), + tag_name: Some(self.tag_name.clone()), + assets: self.assets.clone(), } } fn from_cached(cached: &CachedReleaseInfo) -> Option { - Version::parse(&cached.version).ok().map(|version| Self { - version, - channel: cached.channel, - url: cached.url.clone(), + Version::parse(&cached.version).ok().map(|version| { + let tag_name = cached + .tag_name + .clone() + .unwrap_or_else(|| format!("v{version}")); + Self { + version, + channel: cached.channel, + url: cached.url.clone(), + tag_name, + assets: cached.assets.clone(), + } }) } } @@ -687,6 +795,10 @@ struct CachedReleaseInfo { version: String, channel: UpdateChannel, url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tag_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + assets: Vec, } impl CachedReleaseInfo { @@ -788,6 +900,10 @@ impl Default for UreqGitHubReleasesClient { agent: ureq::AgentBuilder::new() .timeout_connect(Duration::from_secs(GITHUB_CONNECT_TIMEOUT_SECONDS)) .timeout(Duration::from_secs(GITHUB_REQUEST_TIMEOUT_SECONDS)) + .https_only(true) + .try_proxy_from_env(false) + .redirects(0) + .redirect_auth_headers(ureq::RedirectAuthHeaders::Never) .build(), } } @@ -815,23 +931,20 @@ impl GitHubReleasesClient for UreqGitHubReleasesClient { let result = request.call(); match result { - Ok(response) => { - if response.status() == 304 { - return Ok(GitHubReleaseResponse::NotModified); - } - + Ok(response) if response.status() == 200 => { let etag = response.header("ETag").map(str::to_string); - response - .into_string() + read_ureq_response_body(response, &url, MAX_GITHUB_RESPONSE_BYTES) .map(|body| GitHubReleaseResponse::Ok { body, etag }) - .map_err(|err| UpdatesError::Http { - url, - message: err.to_string(), - }) + } + Ok(response) if response.status() == 304 => Ok(GitHubReleaseResponse::NotModified), + Ok(response) => { + let status = response.status(); + let body = read_ureq_response_body(response, &url, MAX_GITHUB_ERROR_BYTES)?; + Err(UpdatesError::ApiStatus { url, status, body }) } Err(ureq::Error::Status(304, _)) => Ok(GitHubReleaseResponse::NotModified), Err(ureq::Error::Status(status, response)) => { - let body = response.into_string().unwrap_or_default(); + let body = read_ureq_response_body(response, &url, MAX_GITHUB_ERROR_BYTES)?; Err(UpdatesError::ApiStatus { url, status, body }) } Err(ureq::Error::Transport(err)) => Err(UpdatesError::Http { @@ -842,6 +955,33 @@ impl GitHubReleasesClient for UreqGitHubReleasesClient { } } +fn read_ureq_response_body( + response: ureq::Response, + url: &str, + max_bytes: u64, +) -> Result { + let mut bytes = Vec::new(); + response + .into_reader() + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|err| UpdatesError::Http { + url: url.to_string(), + message: err.to_string(), + })?; + if bytes.len() as u64 > max_bytes { + return Err(UpdatesError::ResponseTooLarge { + url: url.to_string(), + max_bytes, + }); + } + + String::from_utf8(bytes).map_err(|err| UpdatesError::Http { + url: url.to_string(), + message: format!("response was not valid UTF-8: {err}"), + }) +} + trait UpdateCacheStore { fn load(&self) -> Result; fn save(&self, cache: &UpdateCheckCache) -> Result<(), UpdatesError>; @@ -969,6 +1109,19 @@ struct GitHubRelease { html_url: String, draft: bool, prerelease: bool, + #[serde(default)] + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GitHubReleaseAsset { + id: u64, + name: String, + state: String, + size: u64, + digest: Option, + url: String, + browser_download_url: String, } pub fn run_updates_command( @@ -1253,10 +1406,28 @@ fn release_info_from_api_release( UpdateChannel::Stable }; - parse_release_version(&release.tag_name).map(|version| ReleaseInfo { - version, - channel: release_channel, - url: release.html_url, + parse_release_version(&release.tag_name).map(|version| { + ReleaseInfo::from_github( + version, + release_channel, + release.html_url, + release.tag_name, + release + .assets + .into_iter() + .map(|asset| { + ReleaseAsset::from_github( + asset.id, + asset.name, + asset.state, + asset.size, + asset.digest, + asset.url, + asset.browser_download_url, + ) + }) + .collect(), + ) }) } @@ -1278,12 +1449,13 @@ mod tests { evaluate_update_notification_policy, parse_release_version, resolve_update_cache_path, run_updates_command_with, run_updates_command_with_update_settings, CachedReleaseInfo, CachedUpdateCheck, CachedUpdateNotification, DefaultUpdateCacheStore, EnvUpdateSettings, - FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, ReleaseEndpoint, - ReleaseInfo, StaticUpdateSettings, UpdateCachePathError, UpdateCachePathSources, - UpdateCacheStore, UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, - UpdateNotificationPolicyInput, UpdateNotificationReason, UpdateNotificationSkipReason, - UpdateSettings, UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, - UreqGitHubReleasesClient, PRERELEASE_PAGE_SIZE, + FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, ReleaseAsset, + ReleaseEndpoint, ReleaseInfo, StaticUpdateSettings, UpdateCachePathError, + UpdateCachePathSources, UpdateCacheStore, UpdateChannel, UpdateCheckCache, + UpdateNotificationDecision, UpdateNotificationPolicyInput, UpdateNotificationReason, + UpdateNotificationSkipReason, UpdateSettings, UpdatesCommand, UpdatesDeferredFailure, + UpdatesError, UpdatesRunContext, UreqGitHubReleasesClient, MAX_GITHUB_RESPONSE_BYTES, + PRERELEASE_PAGE_SIZE, }; use crate::session_notifications::{ UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationOutcome, @@ -1539,10 +1711,13 @@ mod tests { } fn release_info(version: &str, channel: UpdateChannel, url: &str) -> ReleaseInfo { + let version = Version::parse(version).expect("test version should parse"); ReleaseInfo { - version: Version::parse(version).expect("test version should parse"), + tag_name: format!("v{version}"), + version, channel, url: url.to_string(), + assets: Vec::new(), } } @@ -1567,6 +1742,8 @@ mod tests { version: version.to_string(), channel, url: url.to_string(), + tag_name: None, + assets: Vec::new(), }, last_notification: None, } @@ -1584,6 +1761,8 @@ mod tests { version: version.to_string(), channel, url: url.to_string(), + tag_name: None, + assets: Vec::new(), }, } } @@ -1836,17 +2015,25 @@ mod tests { let store = FileUpdateCacheStore::new(path); let mut cache = UpdateCheckCache::default(); - cache.set_entry( + let mut stable_entry = cached_entry_with_notification( + Some("\"stable-etag\""), + "1.1.0", UpdateChannel::Stable, - cached_entry_with_notification( - Some("\"stable-etag\""), - "1.1.0", - UpdateChannel::Stable, - "https://github.test/releases/tag/v1.1.0", - TEST_NOW, - TEST_NOW + 1, - ), + "https://github.test/releases/tag/v1.1.0", + TEST_NOW, + TEST_NOW + 1, ); + stable_entry.latest.tag_name = Some("v1.1.0".to_string()); + stable_entry.latest.assets = vec![ReleaseAsset::from_github( + 42, + "lg-buddy-1.1.0-x86_64-unknown-linux-musl.tar.gz".to_string(), + "uploaded".to_string(), + 1234, + Some(format!("sha256:{}", "a".repeat(64))), + "https://api.github.test/releases/assets/42".to_string(), + "https://github.test/releases/download/v1.1.0/bundle.tar.gz".to_string(), + )]; + cache.set_entry(UpdateChannel::Stable, stable_entry); cache.set_entry( UpdateChannel::Prerelease, cached_entry( @@ -2012,6 +2199,106 @@ mod tests { server.join().expect("server thread should finish"); } + #[test] + fn ureq_client_refuses_release_discovery_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); + let address = listener.local_addr().expect("read local test address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept client connection"); + let mut request = [0; 2048]; + let _ = stream.read(&mut request).expect("read request"); + stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/untrusted\r\nContent-Length: 0\r\n\r\n", + ) + .expect("write redirect"); + }); + let base_url = Box::leak(format!("http://{address}/releases").into_boxed_str()); + let client = UreqGitHubReleasesClient { + base_url, + agent: ureq::AgentBuilder::new() + .timeout(Duration::from_secs(5)) + .try_proxy_from_env(false) + .redirects(0) + .redirect_auth_headers(ureq::RedirectAuthHeaders::Never) + .build(), + }; + + assert!(matches!( + client.get(ReleaseEndpoint::LatestStable, "lg-buddy/1.3.0", None), + Err(UpdatesError::ApiStatus { status: 302, .. }) + )); + server.join().expect("server thread should finish"); + } + + #[test] + fn release_discovery_preserves_complete_asset_metadata() { + let body = r#"{ + "tag_name":"v1.4.0", + "html_url":"https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0", + "draft":false, + "prerelease":false, + "assets":[{ + "id":123, + "name":"lg-buddy-1.4.0-x86_64-unknown-linux-musl.tar.gz", + "state":"uploaded", + "size":456, + "digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "url":"https://api.github.com/repos/Staphylococcus/LG_Buddy/releases/assets/123", + "browser_download_url":"https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0/lg-buddy-1.4.0-x86_64-unknown-linux-musl.tar.gz" + }] + }"#; + let client = MockGitHubReleasesClient::new(vec![Ok(body.to_string())]); + let result = check_updates( + UpdateChannel::Stable, + version_info("1.3.0", ReleaseChannel::Stable), + &client, + ) + .expect("update check"); + + assert_eq!(result.latest.tag_name(), "v1.4.0"); + assert_eq!(result.latest.assets().len(), 1); + let asset = &result.latest.assets()[0]; + assert_eq!(asset.id(), 123); + assert_eq!(asset.state(), "uploaded"); + assert_eq!(asset.size(), 456); + assert_eq!( + asset.digest(), + Some("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + } + + #[test] + fn ureq_client_rejects_oversized_release_metadata() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); + let address = listener.local_addr().expect("read local test address"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept client connection"); + let mut request = [0; 2048]; + let _ = stream.read(&mut request).expect("read request"); + let body = vec![b' '; MAX_GITHUB_RESPONSE_BYTES as usize + 1]; + stream + .write_all( + format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes(), + ) + .expect("write response header"); + stream.write_all(&body).expect("write response body"); + }); + let base_url = Box::leak(format!("http://{address}/releases").into_boxed_str()); + let client = UreqGitHubReleasesClient { + base_url, + agent: ureq::AgentBuilder::new() + .timeout(Duration::from_secs(5)) + .build(), + }; + + assert!(matches!( + client.get(ReleaseEndpoint::LatestStable, "lg-buddy/1.3.0", None), + Err(UpdatesError::ResponseTooLarge { .. }) + )); + server.join().expect("server thread should finish"); + } + #[test] fn stable_not_modified_uses_cached_release_metadata() { let client = diff --git a/crates/lg-buddy/src/version.rs b/crates/lg-buddy/src/version.rs index 86dd49b..2e95a35 100644 --- a/crates/lg-buddy/src/version.rs +++ b/crates/lg-buddy/src/version.rs @@ -1,3 +1,5 @@ +include!(concat!(env!("OUT_DIR"), "/release_identity.rs")); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct VersionInfo { version: &'static str, @@ -82,6 +84,7 @@ impl ReleaseChannel { } pub fn version_text() -> String { + std::hint::black_box(&LG_BUDDY_EMBEDDED_RELEASE_IDENTITY); VersionInfo::current().render() } @@ -102,7 +105,7 @@ fn is_prerelease_version(version: &str) -> bool { #[cfg(test)] mod tests { - use super::{version_text, ReleaseChannel, VersionInfo}; + use super::{version_text, ReleaseChannel, VersionInfo, LG_BUDDY_EMBEDDED_RELEASE_IDENTITY}; #[test] fn dev_version_output_reports_package_version_channel_and_unknown_commit() { @@ -176,4 +179,24 @@ mod tests { assert!(output.contains("channel: ")); assert!(output.contains("commit: ")); } + + #[test] + fn embedded_identity_matches_the_current_build_metadata() { + const PREFIX: &[u8] = b"LG_BUDDY_RELEASE_IDENTITY_V1\0"; + const SUFFIX: &[u8] = b"\0LG_BUDDY_RELEASE_IDENTITY_END\0"; + let record = &LG_BUDDY_EMBEDDED_RELEASE_IDENTITY; + assert!(record.starts_with(PREFIX)); + assert!(record.ends_with(SUFFIX)); + let payload = &record[PREFIX.len()..record.len() - SUFFIX.len()]; + let manifest: serde_json::Value = + serde_json::from_slice(payload).expect("embedded identity JSON"); + let current = VersionInfo::current(); + assert_eq!(manifest["version"], current.version()); + assert_eq!(manifest["release_tag"], format!("v{}", current.version())); + assert_eq!(manifest["channel"], current.channel().as_str()); + assert_eq!(manifest["commit"], current.commit().unwrap_or("unknown")); + assert!(manifest["target"] + .as_str() + .is_some_and(|value| !value.is_empty())); + } } diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f682cdc..fbde77a 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -434,6 +434,29 @@ operation; mute toggle reads the current state before writing its inverse. This keeps CLI parsing separate from operational behavior. +## Release Bundle Acquisition Boundary + +`release_bundle.rs` turns a selected GitHub release into an owned, verified +candidate without invoking its installer. Acquisition refreshes the selected +tag directly from the fixed LG Buddy repository instead of trusting cached +asset metadata, resolves the tag to a bounded immutable commit, and requires +exactly one Linux-musl archive and one checksum asset. + +Asset downloads use fixed GitHub API URLs, bounded bodies and deadlines, and an +explicit one-hop HTTPS release-asset redirect policy. Both assets must match +GitHub's SHA-256 digest and declared size; the archive digest must also match +the single corresponding entry in `sha256sums.txt`. + +The process holds a nonblocking filesystem lock while staging under a private +user-cache directory. It scans the complete archive before extraction, +rejecting path aliases, traversal, links, special files, duplicate entries, +unsafe modes, excessive sizes, and unexpected layout. The manifest must agree +with the release, target, and resolved commit. The extracted ELF is never run: +its build-generated, linker-retained identity record is parsed as data and must +independently agree on version, channel, target, tag, and commit. The returned +guard owns the verified candidate and removes its staging tree when dropped; +no executable, installer, sudo, or configuration action runs in this boundary. + ## Host Upgrade Safety Boundary `upgrade_preflight.rs` checks observable host and installation state. It does diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 0850d2b..eff1805 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -307,6 +307,25 @@ formats, archive layout, and binary version/channel/commit mismatches. The bundle smoke test then exercises the same validator against the generated and installed release binary. +The Rust release-bundle acquisition suite covers exact asset selection, fresh +release metadata, bounded responses and downloads, GitHub and published digest +agreement, lightweight and annotated tags, restrictive staging and locking, +hostile archive types and paths, manifest identity, non-executing embedded +binary identity, and cleanup on success or failure. Run it with: + +```bash +cargo test -p lg-buddy release_bundle::tests --lib +``` + +After a release built with the current bundle contract is published, exercise +the real GitHub transport and artifact with: + +```bash +LG_BUDDY_TEST_RELEASE_TAG=vX.Y.Z cargo test -p lg-buddy \ + release_bundle::tests::live_published_release_acquires_into_a_verified_stage \ + --lib -- --ignored --exact +``` + The upgrade-preflight module uses injected process, service-manager, filesystem, and ownership facts around a real temporary-root installation fixture. Its focused suite covers a passing mutable FHS layout plus symlinked, From 23bb2086f56c2e29dd26d2122988929eab9256a2 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:56:03 +0300 Subject: [PATCH 08/22] test: replay release bundle responses offline --- Cargo.lock | 11 - crates/lg-buddy/Cargo.toml | 2 +- crates/lg-buddy/src/release_bundle.rs | 349 +++++++++++++------------- docs/testing-strategy.md | 12 +- 4 files changed, 185 insertions(+), 189 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96c7198..0f36006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1488,7 +1488,6 @@ checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", - "xattr", ] [[package]] @@ -1893,16 +1892,6 @@ dependencies = [ "tap", ] -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/lg-buddy/Cargo.toml b/crates/lg-buddy/Cargo.toml index c18bccf..82f5095 100644 --- a/crates/lg-buddy/Cargo.toml +++ b/crates/lg-buddy/Cargo.toml @@ -15,7 +15,7 @@ semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" -tar = "0.4" +tar = { version = "0.4", default-features = false } tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } ureq = "2" url = "2" diff --git a/crates/lg-buddy/src/release_bundle.rs b/crates/lg-buddy/src/release_bundle.rs index 10f90e9..bc03121 100644 --- a/crates/lg-buddy/src/release_bundle.rs +++ b/crates/lg-buddy/src/release_bundle.rs @@ -407,7 +407,7 @@ fn validate_asset_metadata( ))); } - let expected_download_url = expected_browser_download_url(release.tag_name(), asset.name())?; + let expected_download_url = expected_browser_download_url(release.tag_name(), asset.name()); if asset.download_url() != expected_download_url { return Err(BundleAcquisitionError::ReleaseMetadata(format!( "asset `{}` download URL does not belong to the selected LG Buddy release", @@ -417,25 +417,10 @@ fn validate_asset_metadata( Ok(()) } -fn expected_browser_download_url(tag: &str, name: &str) -> Result { - let mut url = Url::parse("https://github.com/").map_err(|err| { - BundleAcquisitionError::ReleaseMetadata(format!("cannot construct GitHub URL: {err}")) - })?; - url.path_segments_mut() - .map_err(|_| { - BundleAcquisitionError::ReleaseMetadata( - "cannot construct GitHub release URL".to_string(), - ) - })? - .extend([ - REPOSITORY_OWNER, - REPOSITORY_NAME, - "releases", - "download", - tag, - name, - ]); - Ok(url.to_string()) +fn expected_browser_download_url(tag: &str, name: &str) -> String { + format!( + "https://github.com/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/download/{tag}/{name}" + ) } fn required_github_digest(asset: &ReleaseAsset) -> Result { @@ -790,48 +775,7 @@ impl GitHubSource for UreqGitHubSource { "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tags/{tag}" ); let release: FreshGitHubRelease = self.get_json(&url)?; - if release.draft || release.tag_name != tag { - return Err(BundleAcquisitionError::ReleaseMetadata( - "fresh GitHub release metadata is draft or has the wrong tag".to_string(), - )); - } - let version = Version::parse( - release - .tag_name - .strip_prefix('v') - .unwrap_or(&release.tag_name), - ) - .map_err(|err| { - BundleAcquisitionError::ReleaseMetadata(format!( - "fresh GitHub release tag is not a version: {err}" - )) - })?; - let channel = if release.prerelease { - UpdateChannel::Prerelease - } else { - UpdateChannel::Stable - }; - Ok(ReleaseInfo::from_github( - version, - channel, - release.html_url, - release.tag_name, - release - .assets - .into_iter() - .map(|asset| { - ReleaseAsset::from_github( - asset.id, - asset.name, - asset.state, - asset.size, - asset.digest, - asset.url, - asset.browser_download_url, - ) - }) - .collect(), - )) + release_info_from_github_response(release, tag) } fn resolve_tag_commit(&self, tag: &str) -> Result { @@ -911,6 +855,54 @@ struct FreshGitHubAsset { browser_download_url: String, } +fn release_info_from_github_response( + release: FreshGitHubRelease, + expected_tag: &str, +) -> Result { + if release.draft || release.tag_name != expected_tag { + return Err(BundleAcquisitionError::ReleaseMetadata( + "fresh GitHub release metadata is draft or has the wrong tag".to_string(), + )); + } + let version = Version::parse( + release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name), + ) + .map_err(|err| { + BundleAcquisitionError::ReleaseMetadata(format!( + "fresh GitHub release tag is not a version: {err}" + )) + })?; + let channel = if release.prerelease { + UpdateChannel::Prerelease + } else { + UpdateChannel::Stable + }; + Ok(ReleaseInfo::from_github( + version, + channel, + release.html_url, + release.tag_name, + release + .assets + .into_iter() + .map(|asset| { + ReleaseAsset::from_github( + asset.id, + asset.name, + asset.state, + asset.size, + asset.digest, + asset.url, + asset.browser_download_url, + ) + }) + .collect(), + )) +} + #[derive(Debug, Deserialize)] struct GitReferenceResponse { object: GitObject, @@ -1183,7 +1175,6 @@ impl Drop for StagingDirectory { let expected_uid = unsafe { libc::geteuid() }; if fs::symlink_metadata(&self.path).is_ok_and(|metadata| { metadata.file_type().is_dir() - && !metadata.file_type().is_symlink() && metadata.uid() == expected_uid && metadata.mode() & 0o077 == 0 && metadata.dev() == self.device @@ -1211,13 +1202,7 @@ fn ensure_private_directory(path: &Path) -> Result<(), BundleAcquisitionError> { let mut current = PathBuf::from("/"); for component in path.components().skip(1) { - let std::path::Component::Normal(name) = component else { - return Err(BundleAcquisitionError::UnsafeStaging(format!( - "`{}` contains an unsupported path component", - path.display() - ))); - }; - current.push(name); + current.push(component.as_os_str()); match fs::symlink_metadata(¤t) { Ok(metadata) => validate_staging_ancestor(¤t, &metadata)?, Err(err) if err.kind() == io::ErrorKind::NotFound => { @@ -1252,7 +1237,7 @@ fn validate_staging_ancestor( path: &Path, metadata: &fs::Metadata, ) -> Result<(), BundleAcquisitionError> { - if !metadata.file_type().is_dir() || metadata.file_type().is_symlink() { + if !metadata.file_type().is_dir() { return Err(BundleAcquisitionError::UnsafeStaging(format!( "`{}` is not a real directory", path.display() @@ -1304,7 +1289,6 @@ fn open_lock_file(path: &Path) -> Result { struct ArchivePlan { root_name: String, entries: HashMap, - manifest: ReleaseIdentity, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1452,7 +1436,6 @@ fn inspect_archive( Ok(ArchivePlan { root_name: expected_root, entries: validated_entries, - manifest, }) } @@ -1705,13 +1688,7 @@ fn extract_archive( )); } reject_archive_trailing_data(&mut archive.into_inner())?; - let root = staging_root.join(&plan.root_name); - if plan.manifest.release_tag.is_empty() { - return Err(BundleAcquisitionError::Manifest( - "validated manifest identity was unexpectedly empty".to_string(), - )); - } - Ok(root) + Ok(staging_root.join(&plan.root_name)) } fn reject_archive_trailing_data(reader: &mut R) -> Result<(), BundleAcquisitionError> { @@ -2212,7 +2189,7 @@ mod tests { format!( "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/assets/{id}" ), - expected_browser_download_url(tag, name).expect("download URL"), + expected_browser_download_url(tag, name), ) } @@ -2226,6 +2203,43 @@ mod tests { ) } + fn github_response_release_info( + identity: &ReleaseIdentity, + assets: &[(u64, &str, &[u8])], + ) -> ReleaseInfo { + let response = serde_json::json!({ + "tag_name": identity.release_tag, + "html_url": format!( + "https://github.com/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tag/{}", + identity.release_tag + ), + "draft": false, + "prerelease": identity.channel == UpdateChannel::Prerelease, + "assets": assets + .iter() + .map(|(id, name, bytes)| serde_json::json!({ + "id": id, + "name": name, + "state": "uploaded", + "size": bytes.len(), + "digest": format!("sha256:{}", sha256(bytes)), + "url": format!( + "{GITHUB_API_ROOT}/repos/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/assets/{id}" + ), + "browser_download_url": expected_browser_download_url( + &identity.release_tag, + name + ), + })) + .collect::>(), + }); + release_info_from_github_response( + serde_json::from_value(response).expect("GitHub response fixture"), + &identity.release_tag, + ) + .expect("valid GitHub response fixture") + } + #[derive(Clone)] enum FixtureEntry { Directory(String, u32), @@ -2246,6 +2260,8 @@ mod tests { for relative in required_relative_files() { let contents = if *relative == MANIFEST_NAME { manifest_json(identity) + } else if *relative == "lg-buddy" { + embedded_identity_binary(identity) } else { format!("fixture for {relative}\n").into_bytes() }; @@ -2383,6 +2399,14 @@ mod tests { fn write_embedded_identity_binary(directory: &Path, identity: &ReleaseIdentity) -> PathBuf { let path = directory.join("lg-buddy"); + fs::write(&path, embedded_identity_binary(identity)) + .expect("write embedded identity fixture"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("make fixture executable"); + path + } + + fn embedded_identity_binary(identity: &ReleaseIdentity) -> Vec { let mut bytes = vec![0_u8; 64]; bytes[..4].copy_from_slice(b"\x7fELF"); bytes[4] = 2; @@ -2424,10 +2448,7 @@ mod tests { .copy_from_slice(&identity_offset.to_le_bytes()); bytes[identity_header + 32..identity_header + 40] .copy_from_slice(&(record.len() as u64).to_le_bytes()); - fs::write(&path, bytes).expect("write embedded identity fixture"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) - .expect("make fixture executable"); - path + bytes } struct FakeSource { @@ -2490,6 +2511,15 @@ mod tests { calls: AtomicUsize, } + impl FakeBinaryIdentityReader { + fn new(identity: ReleaseIdentity) -> Self { + Self { + identity, + calls: AtomicUsize::new(0), + } + } + } + struct UncalledSource; impl GitHubSource for UncalledSource { @@ -3233,10 +3263,7 @@ mod tests { let cache_root = dir.join("cache"); let active = StagingDirectory::create(&cache_root).expect("active staging"); let active_path = active.path.clone(); - let binary_reader = FakeBinaryIdentityReader { - identity: stable_identity(), - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(stable_identity()); assert!(matches!( acquire_release_bundle_with( &release_info(Vec::new()), @@ -3296,7 +3323,7 @@ mod tests { } #[test] - fn acquisition_uses_fresh_metadata_and_returns_only_a_verified_owned_stage() { + fn github_response_replay_returns_only_a_verified_owned_stage() { let identity = stable_identity(); let fixture_dir = temp_dir("complete-acquisition"); let archive_path = @@ -3308,29 +3335,28 @@ mod tests { .to_string(); let archive_bytes = read_file(&archive_path); let checksum_bytes = format!("{} {archive_name}\n", sha256(&archive_bytes)).into_bytes(); - let archive_asset = release_asset(1, &archive_name, &archive_bytes); - let checksum_asset = release_asset(2, CHECKSUM_ASSET_NAME, &checksum_bytes); - let fresh_release = release_info(vec![archive_asset, checksum_asset]); + let fresh_release = github_response_release_info( + &identity, + &[ + (1, archive_name.as_str(), archive_bytes.as_slice()), + (2, CHECKSUM_ASSET_NAME, checksum_bytes.as_slice()), + ], + ); let source = FakeSource { fresh_release, payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), commit: identity.commit.clone(), }; - let binary_reader = FakeBinaryIdentityReader { - identity: identity.clone(), - calls: AtomicUsize::new(0), - }; let cache_root = fixture_dir.join("cache"); let verified = acquire_release_bundle_with( &release_info(Vec::new()), &cache_root, &source, - &binary_reader, + &EmbeddedBinaryIdentityReader, ) .expect("verified acquisition"); assert_eq!(verified.identity(), &identity); assert!(verified.root().join("install.sh").is_file()); - assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 1); let staging_root = verified .root() .parent() @@ -3357,10 +3383,7 @@ mod tests { payloads: HashMap::new(), commit: stable_identity().commit, }; - let binary_reader = FakeBinaryIdentityReader { - identity: stable_identity(), - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(stable_identity()); let cache_root = fixture_dir.join("cache"); assert!(matches!( acquire_release_bundle_with( @@ -3399,10 +3422,7 @@ mod tests { payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), commit: identity.commit.clone(), }; - let binary_reader = FakeBinaryIdentityReader { - identity, - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(identity); let cache_root = fixture_dir.join("cache"); assert!(matches!( acquire_release_bundle_with( @@ -3437,10 +3457,7 @@ mod tests { payloads: HashMap::from([(1, b"short".to_vec()), (2, checksum_bytes)]), commit: identity.commit.clone(), }; - let binary_reader = FakeBinaryIdentityReader { - identity, - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(identity); let cache_root = fixture_dir.join("cache"); assert!(matches!( acquire_release_bundle_with( @@ -3473,10 +3490,7 @@ mod tests { payloads: HashMap::from([(1, b"tampered".to_vec()), (2, checksum_bytes)]), commit: identity.commit.clone(), }; - let binary_reader = FakeBinaryIdentityReader { - identity, - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(identity); assert!(matches!( acquire_release_bundle_with( &release_info(Vec::new()), @@ -3511,13 +3525,10 @@ mod tests { payloads: HashMap::from([(1, archive_bytes), (2, checksum_bytes)]), commit: identity.commit.clone(), }; - let binary_reader = FakeBinaryIdentityReader { - identity: ReleaseIdentity { - commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - ..identity - }, - calls: AtomicUsize::new(0), - }; + let binary_reader = FakeBinaryIdentityReader::new(ReleaseIdentity { + commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + ..identity + }); let cache_root = fixture_dir.join("cache"); assert!(matches!( acquire_release_bundle_with( @@ -3536,58 +3547,58 @@ mod tests { } #[test] - #[ignore = "contacts GitHub and checks the historical pre-manifest beta bundle"] - fn live_historical_beta_reaches_archive_validation_and_is_rejected() { - let selected = ReleaseInfo::from_github( - Version::parse("1.4.0-beta.1").expect("version"), - UpdateChannel::Prerelease, - "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0-beta.1".to_string(), - "v1.4.0-beta.1".to_string(), - Vec::new(), + fn observed_historical_beta_response_is_rejected_at_the_manifest_boundary() { + let identity = ReleaseIdentity { + release_tag: "v1.4.0-beta.1".to_string(), + version: Version::parse("1.4.0-beta.1").expect("version"), + channel: UpdateChannel::Prerelease, + target: RELEASE_TARGET.to_string(), + commit: "12326a4acdfc0dccb532e389e8d4ae5edeb78c20".to_string(), + }; + let dir = temp_dir("observed-historical-beta"); + let mut entries = valid_fixture_entries(&identity); + entries.retain(|entry| { + !matches!(entry, FixtureEntry::File(path, _, _) if path.ends_with(MANIFEST_NAME)) + }); + let archive_path = write_fixture_archive(&dir, &identity, &entries); + let archive_name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .expect("archive name") + .to_string(); + let archive_bytes = read_file(&archive_path); + let checksum_bytes = format!("{} ./{archive_name}\n", sha256(&archive_bytes)).into_bytes(); + let fresh_release = github_response_release_info( + &identity, + &[ + (536_289_792, archive_name.as_str(), archive_bytes.as_slice()), + (536_289_793, CHECKSUM_ASSET_NAME, checksum_bytes.as_slice()), + ], ); - let dir = temp_dir("live-historical-beta"); - let error = acquire_release_bundle_with( - &selected, - &dir.join("cache"), - &UreqGitHubSource::new(), - &EmbeddedBinaryIdentityReader, - ) - .expect_err("historical beta predates the manifest contract"); - assert!(matches!(error, BundleAcquisitionError::Archive(_))); - fs::remove_dir_all(dir).expect("remove test directory"); - } - - #[test] - #[ignore = "set LG_BUDDY_TEST_RELEASE_TAG to a release built with the current bundle contract"] - fn live_published_release_acquires_into_a_verified_stage() { - let tag = std::env::var("LG_BUDDY_TEST_RELEASE_TAG") - .expect("set LG_BUDDY_TEST_RELEASE_TAG to an eligible published release tag"); - let version = Version::parse(tag.strip_prefix('v').unwrap_or(&tag)).expect("version tag"); - let channel = if version.pre.is_empty() { - UpdateChannel::Stable - } else { - UpdateChannel::Prerelease + let source = FakeSource { + fresh_release, + payloads: HashMap::from([(536_289_792, archive_bytes), (536_289_793, checksum_bytes)]), + commit: identity.commit.clone(), }; let selected = ReleaseInfo::from_github( - version, - channel, - format!("https://github.com/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tag/{tag}"), - tag, + identity.version.clone(), + identity.channel, + format!( + "https://github.com/{REPOSITORY_OWNER}/{REPOSITORY_NAME}/releases/tag/{}", + identity.release_tag + ), + identity.release_tag.clone(), Vec::new(), ); - let dir = temp_dir("live-published-release"); - let verified = acquire_release_bundle_with( - &selected, - &dir.join("cache"), - &UreqGitHubSource::new(), - &EmbeddedBinaryIdentityReader, - ) - .expect("acquire published release"); - assert_eq!(verified.identity().version(), selected.version()); - assert_eq!(verified.identity().channel(), selected.channel()); - assert_eq!(verified.identity().target(), RELEASE_TARGET); - assert!(verified.root().join("install.sh").is_file()); - drop(verified); + let binary_reader = FakeBinaryIdentityReader::new(identity); + let error = + acquire_release_bundle_with(&selected, &dir.join("cache"), &source, &binary_reader) + .expect_err("historical beta predates the manifest contract"); + assert!(matches!( + error, + BundleAcquisitionError::Archive(message) if message.contains(MANIFEST_NAME) + )); + assert_eq!(binary_reader.calls.load(Ordering::Relaxed), 0); fs::remove_dir_all(dir).expect("remove test directory"); } } diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index eff1805..a61f63d 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -317,14 +317,10 @@ binary identity, and cleanup on success or failure. Run it with: cargo test -p lg-buddy release_bundle::tests --lib ``` -After a release built with the current bundle contract is published, exercise -the real GitHub transport and artifact with: - -```bash -LG_BUDDY_TEST_RELEASE_TAG=vX.Y.Z cargo test -p lg-buddy \ - release_bundle::tests::live_published_release_acquires_into_a_verified_stage \ - --lib -- --ignored --exact -``` +The normal suite replays GitHub release-response shapes through both a valid +current-contract bundle and the observed historical `v1.4.0-beta.1` metadata. +The historical payload is reduced to a deterministic pre-manifest archive and +must still be rejected at the manifest boundary without contacting GitHub. The upgrade-preflight module uses injected process, service-manager, filesystem, and ownership facts around a real temporary-root installation From ba3c447c76ff33a2dbb0e063937ef5d60747688e Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:01:56 +0300 Subject: [PATCH 09/22] test: satisfy current clippy byte slice lint --- crates/lg-buddy/src/release_bundle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/lg-buddy/src/release_bundle.rs b/crates/lg-buddy/src/release_bundle.rs index bc03121..226782c 100644 --- a/crates/lg-buddy/src/release_bundle.rs +++ b/crates/lg-buddy/src/release_bundle.rs @@ -2947,7 +2947,7 @@ mod tests { #[test] fn archive_layout_rejects_every_link_device_and_special_entry_type() { let root = "lg-buddy-1.4.0-x86_64-unknown-linux-musl"; - for kind in [b'1', b'2', b'3', b'4', b'6', b'7', b'S', b'L', b'K'] { + for kind in *b"123467SLK" { assert!(validate_archive_layout( &format!("{root}/docs/entry"), root, From 4f28dc7eb191bf4bee82549cd56a8be196fbf96f Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:48:49 +0300 Subject: [PATCH 10/22] feat: add assisted update installation --- README.md | 9 +- crates/lg-buddy/src/lib.rs | 87 +- crates/lg-buddy/src/release_bundle.rs | 115 ++- crates/lg-buddy/src/session_notifications.rs | 8 +- crates/lg-buddy/src/update_install.rs | 877 ++++++++++++++++++ crates/lg-buddy/src/updates.rs | 116 ++- crates/lg-buddy/tests/features/help.feature | 1 + .../lg-buddy/tests/features/updates.feature | 19 +- docs/architecture-overview.md | 16 +- docs/runtime-event-handler-map.md | 1 + docs/testing-strategy.md | 3 + docs/user-guide.md | 12 +- scripts/test-release-bundle.sh | 16 + 13 files changed, 1222 insertions(+), 58 deletions(-) create mode 100644 crates/lg-buddy/src/update_install.rs diff --git a/README.md b/README.md index d8dad21..0e76f95 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,11 @@ during setup. With the default `bscpylgtv` platform, the prompt may instead appear on first use; see the [bscpylgtv first-use guide](https://github.com/chros73/bscpylgtv/blob/master/docs/guides/first_use.md). -To update an existing compatible release-bundle installation from an already -verified and extracted newer bundle, run `./install.sh --upgrade`. Upgrade mode -preserves configuration and credentials and does not repeat setup or pairing; +To check, verify, and install the next release from your saved update channel, +run `lg-buddy updates install` as your regular user. It checks host +compatibility, shows the exact target identity, asks for explicit confirmation, +and then runs the verified bundle's upgrade installer. Upgrade mode preserves +configuration and credentials and does not repeat setup or pairing; incompatible and legacy layouts are refused rather than migrated. The shell installer targets conventional Linux installations with mutable @@ -123,6 +125,7 @@ lg-buddy volume mute lg-buddy settings list lg-buddy settings describe screen.backend lg-buddy updates check +lg-buddy updates install lg-buddy --version ``` diff --git a/crates/lg-buddy/src/lib.rs b/crates/lg-buddy/src/lib.rs index 6bf045c..9d89128 100644 --- a/crates/lg-buddy/src/lib.rs +++ b/crates/lg-buddy/src/lib.rs @@ -19,6 +19,7 @@ pub mod settings; pub mod sources; pub mod state; pub mod tv; +pub mod update_install; pub mod updates; pub mod upgrade_preflight; pub mod version; @@ -47,6 +48,7 @@ use crate::tv::{ OledBrightness, OledBrightnessParseError, TvClientBuildError, VolumeLevel, VolumeLevelParseError, }; +use crate::update_install::{run_update_install, UpdateInstallError}; use crate::updates::{run_updates_command, UpdatesCommand, UpdatesError, UpdatesParseError}; use crate::upgrade_preflight::CompatibilityReport; use std::fmt; @@ -140,12 +142,14 @@ impl SettingsHelpTopic { pub enum UpdatesHelpTopic { Root, Check, + Install, } impl UpdatesHelpTopic { fn from_subcommand(subcommand: &str) -> Option { match subcommand { "check" => Some(Self::Check), + "install" => Some(Self::Install), _ => None, } } @@ -291,6 +295,7 @@ pub enum RunError { Dev(DevError), Settings(SettingsError), Updates(UpdatesError), + UpdateInstall(UpdateInstallError), UpgradePreflight(CompatibilityReport), NotificationAfterPrimary { primary: Box, @@ -312,6 +317,7 @@ impl fmt::Display for RunError { Self::Dev(err) => write!(f, "{err}"), Self::Settings(err) => write!(f, "{err}"), Self::Updates(err) => write!(f, "{err}"), + Self::UpdateInstall(err) => write!(f, "{err}"), Self::UpgradePreflight(report) => write!(f, "{report}"), Self::NotificationAfterPrimary { primary, @@ -338,6 +344,7 @@ impl std::error::Error for RunError { Self::Dev(err) => Some(err), Self::Settings(err) => Some(err), Self::Updates(err) => Some(err), + Self::UpdateInstall(err) => Some(err), Self::UpgradePreflight(_) => None, Self::NotificationAfterPrimary { primary, .. } => Some(primary.as_ref()), } @@ -483,7 +490,7 @@ Commands: screen off Blank the configured TV output if active screen on Restore the TV output after an LG Buddy screen blank settings Inspect and edit structured LG Buddy settings - updates Check for LG Buddy releases + updates Check for and install LG Buddy releases help [COMMAND...] Show global or scoped command help @@ -496,6 +503,7 @@ Settings: Updates: updates check [--notify] + updates install " ) } @@ -637,14 +645,16 @@ pub fn updates_usage(program: &str, topic: UpdatesHelpTopic) -> String { match topic { UpdatesHelpTopic::Root => format!( "\ -LG Buddy update checks +LG Buddy updates Usage: {program} updates check [--notify] + {program} updates install {program} updates --help Commands: check Check GitHub releases for an available update + install Interactively verify and install an available update " ), UpdatesHelpTopic::Check => format!( @@ -656,6 +666,17 @@ Usage: Options: --notify Request a desktop notification when an update is available +" + ), + UpdatesHelpTopic::Install => format!( + "\ +LG Buddy update installation + +Usage: + {program} updates install + +Installs the next release from the saved updates.channel after host checks and +explicit confirmation. Channel and version arguments are not accepted. " ), } @@ -798,6 +819,9 @@ pub fn run_command(command: Command, writer: &mut W) -> Result<(), Run Command::Settings(command) => { run_settings_command(command, writer).map_err(RunError::Settings) } + Command::Updates(UpdatesCommand::Install) => { + run_update_install(writer).map_err(RunError::UpdateInstall) + } Command::Updates(command) => { run_updates_command(command, writer).map_err(RunError::Updates) } @@ -909,10 +933,10 @@ where )) }), [subcommand, arguments @ ..] => { - if UpdatesHelpTopic::from_subcommand(subcommand).is_some() { + if let Some(topic) = UpdatesHelpTopic::from_subcommand(subcommand) { Err(ParseError::Updates( UpdatesParseError::UnexpectedArguments { - subcommand: "check", + subcommand: updates_help_subcommand(topic), arguments: arguments.to_vec(), }, )) @@ -927,6 +951,14 @@ where } } +fn updates_help_subcommand(topic: UpdatesHelpTopic) -> &'static str { + match topic { + UpdatesHelpTopic::Root => "updates", + UpdatesHelpTopic::Check => "check", + UpdatesHelpTopic::Install => "install", + } +} + fn settings_help_subcommand(topic: SettingsHelpTopic) -> &'static str { match topic { SettingsHelpTopic::Root => "settings", @@ -992,14 +1024,13 @@ where ))); } - if UpdatesHelpTopic::from_subcommand(subcommand).is_some() - && arguments[1..] + if let Some(topic) = UpdatesHelpTopic::from_subcommand(subcommand) { + if arguments[1..] .iter() .any(|argument| matches!(argument.as_str(), "-h" | "--help")) - { - return Ok(ParseOutcome::Help(HelpTopic::Updates( - UpdatesHelpTopic::Check, - ))); + { + return Ok(ParseOutcome::Help(HelpTopic::Updates(topic))); + } } UpdatesCommand::parse(arguments) @@ -1364,6 +1395,12 @@ mod tests { UpdatesHelpTopic::Check ))) ); + assert_eq!( + parse_args(["help", "updates", "install"]), + Ok(ParseOutcome::Help(HelpTopic::Updates( + UpdatesHelpTopic::Install + ))) + ); assert_eq!( parse_args(["updates", "--help"]), Ok(ParseOutcome::Help(HelpTopic::Updates( @@ -1376,6 +1413,12 @@ mod tests { UpdatesHelpTopic::Check ))) ); + assert_eq!( + parse_args(["updates", "install", "--help"]), + Ok(ParseOutcome::Help(HelpTopic::Updates( + UpdatesHelpTopic::Install + ))) + ); } #[test] @@ -1603,6 +1646,12 @@ mod tests { UpdatesCommand::Check { notify: true } ))) ); + assert_eq!( + parse_args(["updates", "install"]), + Ok(ParseOutcome::Command(Command::Updates( + UpdatesCommand::Install + ))) + ); assert_eq!( parse_args(["updates", "background-check"]), Ok(ParseOutcome::Command(Command::Updates( @@ -1887,6 +1936,18 @@ mod tests { } )) ); + let error = parse_args(["updates", "install", "stable"]).unwrap_err(); + assert_eq!( + error, + ParseError::Updates(UpdatesParseError::UnexpectedArguments { + subcommand: "install", + arguments: vec!["stable".to_string()] + }) + ); + assert_eq!( + error.help_topic(), + HelpTopic::Updates(UpdatesHelpTopic::Install) + ); assert_eq!( parse_args(["updates", "check", "--notify", "--notify"]), Err(ParseError::Updates(UpdatesParseError::DuplicateNotify)) @@ -2050,6 +2111,7 @@ mod tests { fn updates_usage_is_scoped_and_hides_the_timer_entrypoint() { let root = updates_usage("lg-buddy", UpdatesHelpTopic::Root); assert!(root.contains("lg-buddy updates check [--notify]")); + assert!(root.contains("lg-buddy updates install")); assert!(!root.contains("--channel")); assert!(!root.contains("background-check")); @@ -2057,6 +2119,11 @@ mod tests { assert!(!check.contains("--channel")); assert!(check.contains("--notify")); assert!(!check.contains("background-check")); + + let install = updates_usage("lg-buddy", UpdatesHelpTopic::Install); + assert!(install.contains("lg-buddy updates install")); + assert!(install.contains("saved updates.channel")); + assert!(!install.contains("--channel")); } #[test] diff --git a/crates/lg-buddy/src/release_bundle.rs b/crates/lg-buddy/src/release_bundle.rs index 226782c..c186c10 100644 --- a/crates/lg-buddy/src/release_bundle.rs +++ b/crates/lg-buddy/src/release_bundle.rs @@ -60,6 +60,22 @@ pub struct ReleaseIdentity { } impl ReleaseIdentity { + pub(crate) fn from_parts( + release_tag: impl Into, + version: Version, + channel: UpdateChannel, + target: impl Into, + commit: impl Into, + ) -> Self { + Self { + release_tag: release_tag.into(), + version, + channel, + target: target.into(), + commit: commit.into(), + } + } + pub fn release_tag(&self) -> &str { &self.release_tag } @@ -202,6 +218,27 @@ pub fn acquire_release_bundle( acquire_release_bundle_with(release, &cache_root, &source, &EmbeddedBinaryIdentityReader) } +pub(crate) fn resolve_release_identity( + release: &ReleaseInfo, +) -> Result { + let source = UreqGitHubSource::new(); + resolve_release_with(release, &source).map(|resolved| resolved.identity) +} + +pub(crate) fn verify_release_binary_identity( + binary: &Path, + expected: &ReleaseIdentity, +) -> Result { + let observed = + read_embedded_binary_identity(binary, expected.target(), expected.release_tag())?; + if observed != *expected { + return Err(BundleAcquisitionError::Binary(format!( + "identity {observed:?} does not match verified release identity {expected:?}" + ))); + } + Ok(observed) +} + fn acquisition_cache_root_from_env() -> Result { let base = std::env::var_os("XDG_CACHE_HOME") .filter(|value| !value.is_empty()) @@ -221,21 +258,11 @@ fn acquire_release_bundle_with( source: &S, binary_reader: &B, ) -> Result { - validate_release_identity(release)?; let staging = StagingDirectory::create(cache_root)?; - let fresh_release = source.fetch_release_by_tag(release)?; - validate_release_identity(&fresh_release)?; - if fresh_release.version() != release.version() - || fresh_release.channel() != release.channel() - || fresh_release.tag_name() != release.tag_name() - { - return Err(BundleAcquisitionError::ReleaseMetadata( - "fresh release-by-tag metadata disagrees with the selected release".to_string(), - )); - } + let resolved = resolve_release_with(release, source)?; + let fresh_release = resolved.release; + let expected = resolved.identity; let selected = select_release_assets(&fresh_release)?; - let commit = source.resolve_tag_commit(fresh_release.tag_name())?; - validate_commit_sha(&commit).map_err(BundleAcquisitionError::TagResolution)?; let checksum_path = staging.path.join(CHECKSUM_ASSET_NAME); let archive_path = staging.path.join(selected.archive.name()); @@ -259,13 +286,6 @@ fn acquire_release_bundle_with( ))); } - let expected = ReleaseIdentity { - release_tag: fresh_release.tag_name().to_string(), - version: fresh_release.version().clone(), - channel: fresh_release.channel(), - target: RELEASE_TARGET.to_string(), - commit, - }; let plan = inspect_archive(&archive_download.file, selected.archive.name(), &expected)?; let root = extract_archive(&archive_download.file, &staging.path, &plan)?; let observed = binary_reader.read_identity( @@ -287,6 +307,42 @@ fn acquire_release_bundle_with( }) } +struct ResolvedRelease { + release: ReleaseInfo, + identity: ReleaseIdentity, +} + +fn resolve_release_with( + release: &ReleaseInfo, + source: &S, +) -> Result { + validate_release_identity(release)?; + let fresh_release = source.fetch_release_by_tag(release)?; + validate_release_identity(&fresh_release)?; + if fresh_release.version() != release.version() + || fresh_release.channel() != release.channel() + || fresh_release.tag_name() != release.tag_name() + { + return Err(BundleAcquisitionError::ReleaseMetadata( + "fresh release-by-tag metadata disagrees with the selected release".to_string(), + )); + } + select_release_assets(&fresh_release)?; + let commit = source.resolve_tag_commit(fresh_release.tag_name())?; + validate_commit_sha(&commit).map_err(BundleAcquisitionError::TagResolution)?; + let identity = ReleaseIdentity::from_parts( + fresh_release.tag_name(), + fresh_release.version().clone(), + fresh_release.channel(), + RELEASE_TARGET, + commit, + ); + Ok(ResolvedRelease { + release: fresh_release, + identity, + }) +} + fn validate_release_identity(release: &ReleaseInfo) -> Result<(), BundleAcquisitionError> { let expected_tag = format!("v{}", release.version()); if release.tag_name() != expected_tag { @@ -2594,6 +2650,25 @@ mod tests { assert_eq!(redirect.request, redirect_remaining); } + #[test] + fn release_identity_resolution_validates_metadata_without_downloading_assets() { + let identity = stable_identity(); + let archive_name = format!("lg-buddy-1.4.0-{RELEASE_TARGET}.tar.gz"); + let source = FakeSource { + fresh_release: release_info(vec![ + release_asset(1, &archive_name, b"archive"), + release_asset(2, CHECKSUM_ASSET_NAME, b"checksum"), + ]), + payloads: HashMap::new(), + commit: identity.commit.clone(), + }; + + let resolved = resolve_release_with(&release_info(Vec::new()), &source) + .expect("metadata-only resolution should not download assets"); + + assert_eq!(resolved.identity, identity); + } + #[test] fn sha256_requires_canonical_lowercase_hex() { assert!(validate_sha256(&"a".repeat(64)).is_ok()); diff --git a/crates/lg-buddy/src/session_notifications.rs b/crates/lg-buddy/src/session_notifications.rs index ada07e6..cf64008 100644 --- a/crates/lg-buddy/src/session_notifications.rs +++ b/crates/lg-buddy/src/session_notifications.rs @@ -154,7 +154,7 @@ impl UpdateNotificationRequest { let mut notification = Notification::new( "LG Buddy update available", format!( - "LG Buddy {} ({}) is available.\nCurrent: {} ({})\n{}", + "LG Buddy {} ({}) is available.\nCurrent: {} ({})\nInstall: lg-buddy updates install\n{}", self.latest_version, self.latest_channel.as_str(), self.current_version, @@ -1389,6 +1389,12 @@ mod tests { assert_eq!(notifications[0].actions[0].label, "Never Notify Again"); assert_eq!(notifications[0].actions[1].key, VIEW_RELEASE_ACTION_KEY); assert_eq!(notifications[0].actions[1].label, "View Release"); + assert!(notifications[0] + .body + .contains("Install: lg-buddy updates install")); + assert!(notifications[0] + .body + .contains("https://github.test/releases/tag/v1.1.1")); assert_eq!(dispatcher.pending_len(), 1); } diff --git a/crates/lg-buddy/src/update_install.rs b/crates/lg-buddy/src/update_install.rs new file mode 100644 index 0000000..d011b0e --- /dev/null +++ b/crates/lg-buddy/src/update_install.rs @@ -0,0 +1,877 @@ +use std::error::Error; +use std::fmt; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use semver::Version; + +use crate::release_bundle::{ + acquire_release_bundle, resolve_release_identity, verify_release_binary_identity, + BundleAcquisitionError, ReleaseIdentity, VerifiedReleaseBundle, +}; +use crate::updates::{discover_install_candidate, ReleaseInfo, UpdatesError}; +use crate::upgrade_preflight::{current_host_preflight, CompatibilityReport}; +use crate::version::VersionInfo; + +#[derive(Debug)] +pub enum UpdateInstallError { + Updates(UpdatesError), + Bundle(BundleAcquisitionError), + InvalidCurrentVersion { + version: String, + source: semver::Error, + }, + InitialPreflight(CompatibilityReport), + DowngradeRefused { + current: Version, + candidate: Version, + }, + Output(io::Error), + ConfirmationRequiresTerminal, + ConfirmationIo(io::Error), + TargetChanged { + confirmed: Box, + acquired: Box, + }, + CandidatePreflightLaunch(io::Error), + CandidatePreflightFailed(Option), + InstallerLaunch(io::Error), + InstallerFailed(Option), + InstalledIdentity(BundleAcquisitionError), + InstalledIdentityMismatch { + expected: Box, + observed: Box, + }, +} + +impl fmt::Display for UpdateInstallError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Updates(error) => write!(formatter, "update discovery failed: {error}"), + Self::Bundle(error) => write!(formatter, "release bundle verification failed: {error}"), + Self::InvalidCurrentVersion { version, source } => { + write!(formatter, "installed version `{version}` is invalid: {source}") + } + Self::InitialPreflight(report) => formatter.write_str(&report.render()), + Self::DowngradeRefused { current, candidate } => write!( + formatter, + "refusing to downgrade LG Buddy from {current} to {candidate}" + ), + Self::Output(error) => write!(formatter, "could not write upgrade output: {error}"), + Self::ConfirmationRequiresTerminal => write!( + formatter, + "upgrade confirmation requires an interactive terminal" + ), + Self::ConfirmationIo(error) => { + write!(formatter, "could not read upgrade confirmation: {error}") + } + Self::TargetChanged { + confirmed, + acquired, + } => write!( + formatter, + "verified release identity changed after confirmation (confirmed {confirmed:?}, acquired {acquired:?})" + ), + Self::CandidatePreflightLaunch(error) => { + write!(formatter, "could not run candidate upgrade preflight: {error}") + } + Self::CandidatePreflightFailed(code) => write!( + formatter, + "candidate upgrade preflight refused the host{}", + render_exit_code(*code) + ), + Self::InstallerLaunch(error) => { + write!(formatter, "could not start the verified upgrade installer: {error}") + } + Self::InstallerFailed(code) => write!( + formatter, + "verified upgrade installer failed{}", + render_exit_code(*code) + ), + Self::InstalledIdentity(error) => { + write!(formatter, "installed release identity verification failed: {error}") + } + Self::InstalledIdentityMismatch { expected, observed } => write!( + formatter, + "installed release identity {observed:?} does not match verified release identity {expected:?}" + ), + } + } +} + +impl Error for UpdateInstallError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Updates(error) => Some(error), + Self::Bundle(error) => Some(error), + Self::InvalidCurrentVersion { source, .. } => Some(source), + Self::Output(error) + | Self::ConfirmationIo(error) + | Self::CandidatePreflightLaunch(error) + | Self::InstallerLaunch(error) => Some(error), + Self::InstalledIdentity(error) => Some(error), + Self::InitialPreflight(_) + | Self::DowngradeRefused { .. } + | Self::ConfirmationRequiresTerminal + | Self::TargetChanged { .. } + | Self::CandidatePreflightFailed(_) + | Self::InstallerFailed(_) + | Self::InstalledIdentityMismatch { .. } => None, + } + } +} + +fn render_exit_code(code: Option) -> String { + code.map(|code| format!(" with exit status {code}")) + .unwrap_or_else(|| " after termination by signal".to_string()) +} + +trait BundleView { + fn identity(&self) -> &ReleaseIdentity; +} + +impl BundleView for VerifiedReleaseBundle { + fn identity(&self) -> &ReleaseIdentity { + self.identity() + } +} + +trait UpdateInstallRuntime { + type Bundle: BundleView; + + fn current_version(&mut self) -> VersionInfo; + fn initial_preflight(&mut self) -> Result<(), UpdateInstallError>; + fn discover_candidate( + &mut self, + current: VersionInfo, + ) -> Result; + fn resolve_target( + &mut self, + release: &ReleaseInfo, + ) -> Result; + fn confirm(&mut self) -> Result; + fn acquire(&mut self, release: &ReleaseInfo) -> Result; + fn candidate_preflight(&mut self, bundle: &Self::Bundle) -> Result<(), UpdateInstallError>; + fn run_installer(&mut self, bundle: &Self::Bundle) -> Result<(), UpdateInstallError>; + fn installed_identity( + &mut self, + expected: &ReleaseIdentity, + ) -> Result; +} + +struct SystemUpdateInstallRuntime; + +impl UpdateInstallRuntime for SystemUpdateInstallRuntime { + type Bundle = VerifiedReleaseBundle; + + fn current_version(&mut self) -> VersionInfo { + VersionInfo::current() + } + + fn initial_preflight(&mut self) -> Result<(), UpdateInstallError> { + let report = current_host_preflight(); + if report.compatible() { + Ok(()) + } else { + Err(UpdateInstallError::InitialPreflight(report)) + } + } + + fn discover_candidate( + &mut self, + current: VersionInfo, + ) -> Result { + discover_install_candidate(current).map_err(UpdateInstallError::Updates) + } + + fn resolve_target( + &mut self, + release: &ReleaseInfo, + ) -> Result { + resolve_release_identity(release).map_err(UpdateInstallError::Bundle) + } + + fn confirm(&mut self) -> Result { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(UpdateInstallError::ConfirmationRequiresTerminal); + } + let mut answer = String::new(); + io::stdin() + .lock() + .read_line(&mut answer) + .map_err(UpdateInstallError::ConfirmationIo)?; + Ok(confirmation_is_yes(&answer)) + } + + fn acquire(&mut self, release: &ReleaseInfo) -> Result { + acquire_release_bundle(release).map_err(UpdateInstallError::Bundle) + } + + fn candidate_preflight(&mut self, bundle: &Self::Bundle) -> Result<(), UpdateInstallError> { + let status = candidate_preflight_command(bundle.root()) + .status() + .map_err(UpdateInstallError::CandidatePreflightLaunch)?; + if status.success() { + Ok(()) + } else { + Err(UpdateInstallError::CandidatePreflightFailed(status.code())) + } + } + + fn run_installer(&mut self, bundle: &Self::Bundle) -> Result<(), UpdateInstallError> { + let status = installer_command(bundle.root()) + .status() + .map_err(UpdateInstallError::InstallerLaunch)?; + if status.success() { + Ok(()) + } else { + Err(UpdateInstallError::InstallerFailed(status.code())) + } + } + + fn installed_identity( + &mut self, + expected: &ReleaseIdentity, + ) -> Result { + verify_release_binary_identity(&installed_binary_path(), expected) + .map_err(UpdateInstallError::InstalledIdentity) + } +} + +fn candidate_preflight_command(candidate_root: &Path) -> Command { + let mut command = Command::new(candidate_root.join("lg-buddy")); + command.arg("upgrade-preflight").arg(candidate_root); + command +} + +fn installer_command(candidate_root: &Path) -> Command { + let mut command = Command::new(candidate_root.join("install.sh")); + command.arg("--upgrade"); + command +} + +fn confirmation_is_yes(answer: &str) -> bool { + answer.trim() == "yes" +} + +fn installed_binary_path() -> PathBuf { + let install_root = std::env::var_os("LG_BUDDY_INSTALL_ROOT") + .filter(|root| !root.is_empty()) + .map(PathBuf::from); + installed_binary_path_for_root(install_root) +} + +fn installed_binary_path_for_root(install_root: Option) -> PathBuf { + install_root + .unwrap_or_else(|| PathBuf::from("/")) + .join("usr/bin/lg-buddy") +} + +pub fn run_update_install(writer: &mut W) -> Result<(), UpdateInstallError> { + run_update_install_with(writer, &mut SystemUpdateInstallRuntime) +} + +fn run_update_install_with( + writer: &mut W, + runtime: &mut R, +) -> Result<(), UpdateInstallError> { + let current = runtime.current_version(); + runtime.initial_preflight()?; + let current_version = Version::parse(current.version()).map_err(|source| { + UpdateInstallError::InvalidCurrentVersion { + version: current.version().to_string(), + source, + } + })?; + let release = runtime.discover_candidate(current)?; + + match release.version().cmp(¤t_version) { + std::cmp::Ordering::Equal => { + writeln!( + writer, + "LG Buddy {} ({}) is already up to date.", + current.version(), + current.channel().as_str() + ) + .map_err(UpdateInstallError::Output)?; + return Ok(()); + } + std::cmp::Ordering::Less => { + return Err(UpdateInstallError::DowngradeRefused { + current: current_version, + candidate: release.version().clone(), + }); + } + std::cmp::Ordering::Greater => {} + } + + let confirmed_target = runtime.resolve_target(&release)?; + writeln!( + writer, + "Current: {} ({}, commit {})", + current.version(), + current.channel().as_str(), + current.commit().unwrap_or("unknown") + ) + .map_err(UpdateInstallError::Output)?; + writeln!( + writer, + "Target: {} ({}, commit {})", + confirmed_target.version(), + confirmed_target.channel().as_str(), + confirmed_target.commit() + ) + .map_err(UpdateInstallError::Output)?; + writeln!(writer, "Release: {}", release.url()).map_err(UpdateInstallError::Output)?; + write!(writer, "Type `yes` to install this update: ").map_err(UpdateInstallError::Output)?; + writer.flush().map_err(UpdateInstallError::Output)?; + + if !runtime.confirm()? { + writeln!(writer, "Upgrade cancelled.").map_err(UpdateInstallError::Output)?; + return Ok(()); + } + + let bundle = runtime.acquire(&release)?; + if bundle.identity() != &confirmed_target { + return Err(UpdateInstallError::TargetChanged { + confirmed: Box::new(confirmed_target), + acquired: Box::new(bundle.identity().clone()), + }); + } + runtime.candidate_preflight(&bundle)?; + runtime.run_installer(&bundle)?; + let installed = runtime.installed_identity(bundle.identity())?; + if &installed != bundle.identity() { + return Err(UpdateInstallError::InstalledIdentityMismatch { + expected: Box::new(bundle.identity().clone()), + observed: Box::new(installed), + }); + } + writeln!( + writer, + "Installed: {} ({}, commit {})", + installed.version(), + installed.channel().as_str(), + installed.commit() + ) + .map_err(UpdateInstallError::Output)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + candidate_preflight_command, confirmation_is_yes, installed_binary_path_for_root, + installer_command, run_update_install_with, BundleView, UpdateInstallError, + UpdateInstallRuntime, + }; + use crate::release_bundle::{BundleAcquisitionError, ReleaseIdentity}; + use crate::updates::{ReleaseInfo, UpdateChannel}; + use crate::version::{ReleaseChannel, VersionInfo}; + use semver::Version; + use std::cell::RefCell; + use std::ffi::OsStr; + use std::path::{Path, PathBuf}; + use std::rc::Rc; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Failure { + Initial, + Resolve, + Confirmation, + Acquire, + CandidatePreflight, + Installer, + InstalledIdentity, + } + + struct FakeBundle { + identity: ReleaseIdentity, + events: Rc>>, + } + + impl BundleView for FakeBundle { + fn identity(&self) -> &ReleaseIdentity { + &self.identity + } + } + + impl Drop for FakeBundle { + fn drop(&mut self) { + self.events.borrow_mut().push("drop_bundle"); + } + } + + struct FakeRuntime { + events: Rc>>, + current_version: &'static str, + candidate_version: &'static str, + confirmed: bool, + failure: Option, + resolved_identity: ReleaseIdentity, + acquired_identity: ReleaseIdentity, + installed_identity: ReleaseIdentity, + } + + impl FakeRuntime { + fn new(candidate_version: &'static str) -> Self { + let events = Rc::new(RefCell::new(Vec::new())); + let identity = identity(candidate_version, "target-commit"); + Self { + events, + current_version: "1.4.0", + candidate_version, + confirmed: true, + failure: None, + resolved_identity: identity.clone(), + acquired_identity: identity.clone(), + installed_identity: identity, + } + } + + fn event_names(&self) -> Vec<&'static str> { + self.events.borrow().clone() + } + } + + impl UpdateInstallRuntime for FakeRuntime { + type Bundle = FakeBundle; + + fn current_version(&mut self) -> VersionInfo { + self.events.borrow_mut().push("current"); + VersionInfo::for_testing( + self.current_version, + ReleaseChannel::Stable, + Some("current-commit"), + ) + } + + fn initial_preflight(&mut self) -> Result<(), UpdateInstallError> { + self.events.borrow_mut().push("initial_preflight"); + if self.failure == Some(Failure::Initial) { + Err(UpdateInstallError::ConfirmationRequiresTerminal) + } else { + Ok(()) + } + } + + fn discover_candidate( + &mut self, + _current: VersionInfo, + ) -> Result { + self.events.borrow_mut().push("discover"); + Ok(ReleaseInfo::from_github( + Version::parse(self.candidate_version).unwrap(), + UpdateChannel::Stable, + format!("https://example.test/releases/v{}", self.candidate_version), + format!("v{}", self.candidate_version), + Vec::new(), + )) + } + + fn resolve_target( + &mut self, + _release: &ReleaseInfo, + ) -> Result { + self.events.borrow_mut().push("resolve"); + if self.failure == Some(Failure::Resolve) { + Err(bundle_error()) + } else { + Ok(self.resolved_identity.clone()) + } + } + + fn confirm(&mut self) -> Result { + self.events.borrow_mut().push("confirm"); + if self.failure == Some(Failure::Confirmation) { + Err(UpdateInstallError::ConfirmationRequiresTerminal) + } else { + Ok(self.confirmed) + } + } + + fn acquire(&mut self, _release: &ReleaseInfo) -> Result { + self.events.borrow_mut().push("acquire"); + if self.failure == Some(Failure::Acquire) { + Err(UpdateInstallError::Bundle( + BundleAcquisitionError::ConcurrentAcquisition, + )) + } else { + Ok(FakeBundle { + identity: self.acquired_identity.clone(), + events: Rc::clone(&self.events), + }) + } + } + + fn candidate_preflight( + &mut self, + _bundle: &Self::Bundle, + ) -> Result<(), UpdateInstallError> { + self.events.borrow_mut().push("candidate_preflight"); + if self.failure == Some(Failure::CandidatePreflight) { + Err(UpdateInstallError::CandidatePreflightFailed(Some(1))) + } else { + Ok(()) + } + } + + fn run_installer(&mut self, _bundle: &Self::Bundle) -> Result<(), UpdateInstallError> { + self.events.borrow_mut().push("installer"); + if self.failure == Some(Failure::Installer) { + Err(UpdateInstallError::InstallerFailed(Some(1))) + } else { + Ok(()) + } + } + + fn installed_identity( + &mut self, + _expected: &ReleaseIdentity, + ) -> Result { + self.events.borrow_mut().push("installed_identity"); + if self.failure == Some(Failure::InstalledIdentity) { + Err(UpdateInstallError::InstalledIdentity( + BundleAcquisitionError::Binary("mismatch".to_string()), + )) + } else { + Ok(self.installed_identity.clone()) + } + } + } + + fn identity(version: &str, commit: &str) -> ReleaseIdentity { + ReleaseIdentity::from_parts( + format!("v{version}"), + Version::parse(version).unwrap(), + UpdateChannel::Stable, + "x86_64-unknown-linux-musl", + commit, + ) + } + + fn bundle_error() -> UpdateInstallError { + UpdateInstallError::Bundle(BundleAcquisitionError::ReleaseMetadata( + "test failure".to_string(), + )) + } + + #[test] + fn candidate_and_installer_processes_use_exact_argv_without_a_shell() { + let root = Path::new("/tmp/release bundle; touch escaped"); + let candidate = candidate_preflight_command(root); + assert_eq!( + candidate.get_program(), + OsStr::new("/tmp/release bundle; touch escaped/lg-buddy") + ); + assert_eq!( + candidate.get_args().collect::>(), + [OsStr::new("upgrade-preflight"), root.as_os_str()] + ); + + let installer = installer_command(root); + assert_eq!( + installer.get_program(), + OsStr::new("/tmp/release bundle; touch escaped/install.sh") + ); + assert_eq!( + installer.get_args().collect::>(), + [OsStr::new("--upgrade")] + ); + } + + #[test] + fn confirmation_accepts_only_an_explicit_lowercase_yes() { + assert!(confirmation_is_yes("yes\n")); + for answer in ["", "y", "YES", "yes please", "no"] { + assert!(!confirmation_is_yes(answer), "accepted `{answer}`"); + } + } + + #[test] + fn installed_binary_path_is_absolute_without_an_install_root_override() { + assert_eq!( + installed_binary_path_for_root(None), + Path::new("/usr/bin/lg-buddy") + ); + assert_eq!( + installed_binary_path_for_root(Some(PathBuf::from("/tmp/install-root"))), + Path::new("/tmp/install-root/usr/bin/lg-buddy") + ); + } + + #[test] + fn initial_refusal_stops_before_discovery() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::Initial); + + assert!(run_update_install_with(&mut Vec::new(), &mut runtime).is_err()); + assert_eq!(runtime.event_names(), ["current", "initial_preflight"]); + } + + #[test] + fn invalid_current_version_stops_before_discovery() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.current_version = "invalid"; + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!( + error, + UpdateInstallError::InvalidCurrentVersion { .. } + )); + assert_eq!(runtime.event_names(), ["current", "initial_preflight"]); + } + + #[test] + fn equal_version_stops_before_resolution_and_confirmation() { + let mut runtime = FakeRuntime::new("1.4.0"); + let mut output = Vec::new(); + + run_update_install_with(&mut output, &mut runtime).unwrap(); + + assert_eq!( + runtime.event_names(), + ["current", "initial_preflight", "discover"] + ); + assert!(String::from_utf8(output) + .unwrap() + .contains("already up to date")); + } + + #[test] + fn downgrade_stops_before_resolution_and_confirmation() { + let mut runtime = FakeRuntime::new("1.3.0"); + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!(error, UpdateInstallError::DowngradeRefused { .. })); + assert_eq!( + runtime.event_names(), + ["current", "initial_preflight", "discover"] + ); + } + + #[test] + fn declined_confirmation_does_not_acquire_or_mutate() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.confirmed = false; + + run_update_install_with(&mut Vec::new(), &mut runtime).unwrap(); + + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm" + ] + ); + } + + #[test] + fn unavailable_terminal_stops_before_acquisition() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::Confirmation); + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!( + error, + UpdateInstallError::ConfirmationRequiresTerminal + )); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm" + ] + ); + } + + #[test] + fn acquisition_failure_does_not_run_candidate_or_installer() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::Acquire); + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!( + error, + UpdateInstallError::Bundle(BundleAcquisitionError::ConcurrentAcquisition) + )); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire" + ] + ); + } + + #[test] + fn changed_identity_stops_before_candidate_preflight() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.acquired_identity = identity("1.5.0", "different-commit"); + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!(error, UpdateInstallError::TargetChanged { .. })); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "drop_bundle" + ] + ); + } + + #[test] + fn candidate_refusal_stops_before_installer() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::CandidatePreflight); + + assert!(run_update_install_with(&mut Vec::new(), &mut runtime).is_err()); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "candidate_preflight", + "drop_bundle" + ] + ); + } + + #[test] + fn installer_failure_skips_post_install_verification() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::Installer); + + assert!(run_update_install_with(&mut Vec::new(), &mut runtime).is_err()); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "candidate_preflight", + "installer", + "drop_bundle" + ] + ); + } + + #[test] + fn installed_identity_failure_is_reported_before_bundle_cleanup() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::InstalledIdentity); + + assert!(run_update_install_with(&mut Vec::new(), &mut runtime).is_err()); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "candidate_preflight", + "installer", + "installed_identity", + "drop_bundle" + ] + ); + } + + #[test] + fn installed_identity_mismatch_is_rejected_before_bundle_cleanup() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.installed_identity = identity("1.5.0", "wrong-commit"); + + let error = run_update_install_with(&mut Vec::new(), &mut runtime).unwrap_err(); + + assert!(matches!( + error, + UpdateInstallError::InstalledIdentityMismatch { .. } + )); + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "candidate_preflight", + "installer", + "installed_identity", + "drop_bundle" + ] + ); + } + + #[test] + fn successful_upgrade_preserves_order_and_reports_identities() { + let mut runtime = FakeRuntime::new("1.5.0"); + let mut output = Vec::new(); + + run_update_install_with(&mut output, &mut runtime).unwrap(); + + assert_eq!( + runtime.event_names(), + [ + "current", + "initial_preflight", + "discover", + "resolve", + "confirm", + "acquire", + "candidate_preflight", + "installer", + "installed_identity", + "drop_bundle" + ] + ); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Current: 1.4.0 (stable, commit current-commit)")); + assert!(output.contains("Target: 1.5.0 (stable, commit target-commit)")); + assert!(output.contains("Installed: 1.5.0 (stable, commit target-commit)")); + } + + #[test] + fn resolver_failure_stops_before_confirmation() { + let mut runtime = FakeRuntime::new("1.5.0"); + runtime.failure = Some(Failure::Resolve); + + assert!(run_update_install_with(&mut Vec::new(), &mut runtime).is_err()); + assert_eq!( + runtime.event_names(), + ["current", "initial_preflight", "discover", "resolve"] + ); + } +} diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 2bf9f57..7d57432 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -31,6 +31,7 @@ const UPDATE_CHECK_CACHE_FILE_NAME: &str = "update-check.json"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum UpdatesCommand { Check { notify: bool }, + Install, BackgroundCheck, } @@ -47,6 +48,7 @@ impl UpdatesCommand { match subcommand.as_ref() { "check" => parse_check_args(args), + "install" => parse_no_args("install", UpdatesCommand::Install, args), "background-check" => parse_background_check_args(args), other => Err(UpdatesParseError::UnknownSubcommand(other.to_string())), } @@ -55,6 +57,7 @@ impl UpdatesCommand { pub fn as_str(&self) -> &'static str { match self { Self::Check { .. } => "check", + Self::Install => "install", Self::BackgroundCheck => "background-check", } } @@ -62,11 +65,35 @@ impl UpdatesCommand { fn notify(&self) -> bool { match self { Self::Check { notify } => *notify, + Self::Install => false, Self::BackgroundCheck => true, } } } +fn parse_no_args( + subcommand: &'static str, + command: UpdatesCommand, + args: I, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let arguments = args + .into_iter() + .map(|arg| arg.as_ref().to_string()) + .collect::>(); + if arguments.is_empty() { + Ok(command) + } else { + Err(UpdatesParseError::UnexpectedArguments { + subcommand, + arguments, + }) + } +} + fn parse_background_check_args(args: I) -> Result where I: IntoIterator, @@ -133,7 +160,7 @@ impl fmt::Display for UpdatesParseError { match self { Self::MissingSubcommand => write!( f, - "missing updates command; expected `updates check [--notify]`" + "missing updates command; expected `updates check [--notify]` or `updates install`" ), Self::UnknownSubcommand(subcommand) => { write!(f, "unknown updates command `{subcommand}`") @@ -352,6 +379,7 @@ pub enum UpdatesError { CacheEncode(serde_json::Error), Settings(SettingsError), SettingsInvariant(String), + CommandInvariant(String), DeferredFailures(Vec), Notification(UpdateNotificationError), Io(io::Error), @@ -441,6 +469,7 @@ impl fmt::Display for UpdatesError { Self::SettingsInvariant(message) => { write!(f, "invalid update settings metadata: {message}") } + Self::CommandInvariant(message) => write!(f, "invalid update command: {message}"), Self::DeferredFailures(failures) => { write!(f, "update check completed with deferred failure")?; if failures.len() != 1 { @@ -482,7 +511,8 @@ impl Error for UpdatesError { | Self::ResponseTooLarge { .. } | Self::NoMatchingRelease { .. } | Self::NotModifiedWithoutCache { .. } - | Self::SettingsInvariant(_) => None, + | Self::SettingsInvariant(_) + | Self::CommandInvariant(_) => None, } } } @@ -829,14 +859,18 @@ impl UpdateCheckResult { "up to date" }; - format!( + let mut output = format!( "status: {status}\ncurrent: {} ({})\nlatest: {} ({})\nurl: {}\n", self.current_version, self.current_channel.as_str(), self.latest.version(), self.latest.channel().as_str(), self.latest.url() - ) + ); + if self.update_available() { + output.push_str("install: lg-buddy updates install\n"); + } + output } fn notification_request(&self) -> Result { @@ -1128,6 +1162,11 @@ pub fn run_updates_command( command: UpdatesCommand, writer: &mut W, ) -> Result<(), UpdatesError> { + if matches!(command, UpdatesCommand::Install) { + return Err(UpdatesError::CommandInvariant( + "updates install must use the install orchestrator".to_string(), + )); + } let client = UreqGitHubReleasesClient::default(); let version = VersionInfo::current(); let notification_handoff = SessionBusUpdateNotificationHandoff; @@ -1145,6 +1184,23 @@ pub fn run_updates_command( run_updates_command_with_update_settings(command, writer, context) } +pub(crate) fn discover_install_candidate( + current: VersionInfo, +) -> Result { + let client = UreqGitHubReleasesClient::default(); + let settings = EnvUpdateSettings::from_env()?; + discover_install_candidate_with(current, &client, &settings) +} + +fn discover_install_candidate_with( + current: VersionInfo, + client: &C, + settings: &U, +) -> Result { + let channel = settings.channel()?; + check_updates(channel, current, client).map(|result| result.latest) +} + #[cfg(test)] fn run_updates_command_with< W: io::Write, @@ -1265,7 +1321,6 @@ fn run_updates_command_with_update_settings< Ok(()) } -#[cfg(test)] fn check_updates( channel: UpdateChannel, current: VersionInfo, @@ -1446,16 +1501,16 @@ fn current_unix_seconds() -> u64 { mod tests { use super::{ atomic_write_file, check_updates, check_updates_with_cache, - evaluate_update_notification_policy, parse_release_version, resolve_update_cache_path, - run_updates_command_with, run_updates_command_with_update_settings, CachedReleaseInfo, - CachedUpdateCheck, CachedUpdateNotification, DefaultUpdateCacheStore, EnvUpdateSettings, - FileUpdateCacheStore, GitHubReleaseResponse, GitHubReleasesClient, ReleaseAsset, - ReleaseEndpoint, ReleaseInfo, StaticUpdateSettings, UpdateCachePathError, - UpdateCachePathSources, UpdateCacheStore, UpdateChannel, UpdateCheckCache, - UpdateNotificationDecision, UpdateNotificationPolicyInput, UpdateNotificationReason, - UpdateNotificationSkipReason, UpdateSettings, UpdatesCommand, UpdatesDeferredFailure, - UpdatesError, UpdatesRunContext, UreqGitHubReleasesClient, MAX_GITHUB_RESPONSE_BYTES, - PRERELEASE_PAGE_SIZE, + discover_install_candidate_with, evaluate_update_notification_policy, + parse_release_version, resolve_update_cache_path, run_updates_command_with, + run_updates_command_with_update_settings, CachedReleaseInfo, CachedUpdateCheck, + CachedUpdateNotification, DefaultUpdateCacheStore, EnvUpdateSettings, FileUpdateCacheStore, + GitHubReleaseResponse, GitHubReleasesClient, ReleaseAsset, ReleaseEndpoint, ReleaseInfo, + StaticUpdateSettings, UpdateCachePathError, UpdateCachePathSources, UpdateCacheStore, + UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, UpdateNotificationPolicyInput, + UpdateNotificationReason, UpdateNotificationSkipReason, UpdateSettings, UpdatesCommand, + UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, UreqGitHubReleasesClient, + MAX_GITHUB_RESPONSE_BYTES, PRERELEASE_PAGE_SIZE, }; use crate::session_notifications::{ UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationOutcome, @@ -2327,7 +2382,7 @@ mod tests { assert!(result.update_available()); assert_eq!( result.render(), - "status: update available\ncurrent: 1.1.0 (stable)\nlatest: 1.1.1 (stable)\nurl: https://github.test/releases/tag/v1.1.1\n" + "status: update available\ncurrent: 1.1.0 (stable)\nlatest: 1.1.1 (stable)\nurl: https://github.test/releases/tag/v1.1.1\ninstall: lg-buddy updates install\n" ); assert_eq!( client.requests_with_etags(), @@ -2983,6 +3038,33 @@ mod tests { ); } + #[test] + fn install_discovery_uses_saved_channel_and_ignores_automatic_check_gate() { + let client = MockGitHubReleasesClient::new(vec![Ok(format!( + "[{},{}]", + stable_release("v1.1.0"), + prerelease("v1.2.0-beta.1") + ))]); + let update_settings = StaticUpdateSettings::disabled(UpdateChannel::Prerelease); + + let release = discover_install_candidate_with( + version_info("1.1.0", ReleaseChannel::Stable), + &client, + &update_settings, + ) + .expect("install discovery should use the saved prerelease channel"); + + assert_eq!(release.version(), &Version::parse("1.2.0-beta.1").unwrap()); + assert_eq!(release.channel(), UpdateChannel::Prerelease); + assert_eq!( + client.requests(), + vec![( + "https://api.example.test/releases?per_page=20".to_string(), + "lg-buddy/1.1.0".to_string() + )] + ); + } + #[test] fn saved_channel_drives_manual_checks_for_every_binary_identity() { for saved_channel in [UpdateChannel::Stable, UpdateChannel::Prerelease] { @@ -3491,7 +3573,7 @@ mod tests { assert!(result.update_available()); assert_eq!( result.render(), - "status: update available\ncurrent: 1.2.0-beta.1 (prerelease)\nlatest: 1.2.0 (stable)\nurl: https://github.test/releases/tag/v1.2.0\n" + "status: update available\ncurrent: 1.2.0-beta.1 (prerelease)\nlatest: 1.2.0 (stable)\nurl: https://github.test/releases/tag/v1.2.0\ninstall: lg-buddy updates install\n" ); } diff --git a/crates/lg-buddy/tests/features/help.feature b/crates/lg-buddy/tests/features/help.feature index b25f89e..56e9fae 100644 --- a/crates/lg-buddy/tests/features/help.feature +++ b/crates/lg-buddy/tests/features/help.feature @@ -22,6 +22,7 @@ Feature: Public CLI help And stdout contains "settings set " And stdout contains "settings unset " And stdout contains "updates check [--notify]" + And stdout contains "updates install" And stdout contains "help [COMMAND...]" And stdout contains "--help, -h" And stdout contains "--version, -V" diff --git a/crates/lg-buddy/tests/features/updates.feature b/crates/lg-buddy/tests/features/updates.feature index 2c617cd..443620e 100644 --- a/crates/lg-buddy/tests/features/updates.feature +++ b/crates/lg-buddy/tests/features/updates.feature @@ -1,10 +1,11 @@ Feature: Updates CLI - LG Buddy should expose manual update checks without advertising its timer entrypoint. + LG Buddy should expose manual checks and assisted installation without advertising its timer entrypoint. Scenario: Updates help describes the public check command When I run the command "updates --help" Then the command succeeds And stdout contains "updates check [--notify]" + And stdout contains "updates install" And stdout does not contain "--channel" And stdout does not contain "background-check" @@ -27,10 +28,26 @@ Feature: Updates CLI And stderr contains "updates check [--notify]" And stderr does not contain "background-check" + Scenario: Updates install has scoped help and accepts no target arguments + When I run the command "updates install --help" + Then the command succeeds + And stdout contains "updates install" + And stdout contains "saved updates.channel" + And stdout does not contain "--channel" + When I run the command "help updates install" + Then the command succeeds + And stdout contains "updates install" + When I run the command "updates install 1.5.0" + Then the command fails + And the command exits with status 2 + And stderr contains "unexpected arguments for `updates install`: 1.5.0" + And stderr contains "updates install" + Scenario: Global help hides the timer entrypoint When I run the command "--help" Then the command succeeds And stdout contains "updates check [--notify]" + And stdout contains "updates install" And stdout does not contain "updates background-check" Scenario: The hidden background check entrypoint remains operational diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index fbde77a..e0113ea 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -388,6 +388,7 @@ The intended public user-action surface is: - `settings set ` - `settings unset ` - `updates check [--notify]` +- `updates install` The binary also retains package-owned and compatibility entrypoints during the public-surface migration: @@ -410,11 +411,16 @@ the runtime command handlers in `commands.rs` and `session/runner.rs`. modules and delegates platform ingestion to `sources/`. The on-demand `updates check` command reads the saved `updates.channel` policy and consumes the GitHub Releases API without entering the screen, lifecycle, or scheduling -paths. `updates background-check` is the timer-owned wrapper: it exits before -GitHub/cache work when `updates.auto_check` is disabled and otherwise delegates -to the same settings-driven check path with notification intent enabled. When -notification is -requested and an update is available, the one-shot CLI process hands the +paths. `updates install` adds the user-confirmed upgrade orchestration: initial +host preflight, fresh settings-driven discovery, target identity resolution, +explicit terminal confirmation, verified bundle acquisition, candidate +preflight, direct `install.sh --upgrade` execution, and installed identity +verification. The verified bundle and acquisition lock remain owned until the +installer and final verification finish. `updates background-check` is the +timer-owned wrapper: it exits before GitHub/cache work when +`updates.auto_check` is disabled and otherwise delegates to the same +settings-driven check path with notification intent enabled. When notification +is requested and an update is available, the one-shot CLI process hands the resolved update facts to the LG Buddy-owned user-session D-Bus surface. The running session process then owns desktop notification dispatch, notification ids, the `View Release` action, and the notification opt-out action. The diff --git a/docs/runtime-event-handler-map.md b/docs/runtime-event-handler-map.md index 073e453..58cfd04 100644 --- a/docs/runtime-event-handler-map.md +++ b/docs/runtime-event-handler-map.md @@ -44,6 +44,7 @@ the shared native-session path. | manual screen blank | `lg-buddy screen off` | `commands` -> `screen` | Blank or power off the TV if LG Buddy owns the configured input. | | manual screen restore | `lg-buddy screen on` | `commands` -> `screen` | Restore the screen when marker and restore-policy rules allow it. | | manual update check | `lg-buddy updates check` | `updates` -> saved channel policy -> GitHub releases API | Check for an available release independently of the automatic update-check setting. | +| user-confirmed update install | `lg-buddy updates install` | `update_install` -> host preflight -> release verification -> candidate preflight -> `install.sh --upgrade` -> installed identity verification | Install only a newer release from the saved channel after explicit terminal confirmation. | | user update-check timer | `lg-buddy updates background-check` | `updates` -> saved channel policy -> GitHub releases API -> session notification handoff | Check for updates when automatic checks are enabled and notify once per release. | Compatibility command surfaces still exist for direct/manual invocation: diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index a61f63d..21acc11 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -83,6 +83,9 @@ This is the place for integration tests and contract tests. - logind lifecycle and NetworkManager gate behavior against a private system-bus harness - desktop and auxiliary gamepad activity resetting one LG Buddy-owned deadline +- update-install orchestration ordering against an injected runtime, including + refusal, decline, concurrent acquisition, candidate preflight, installer, + identity mismatch, cleanup, and success paths ### How to test it diff --git a/docs/user-guide.md b/docs/user-guide.md index 180495a..c44f3db 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -231,6 +231,7 @@ lg-buddy updates check lg-buddy updates check --notify lg-buddy settings set updates.channel prerelease lg-buddy updates check +lg-buddy updates install ``` The saved `updates.channel` setting controls every check, regardless of the @@ -238,6 +239,14 @@ installed binary's own release channel. `stable` checks stable releases only; `prerelease` considers both stable and prerelease releases and selects the highest semantic version. +`updates install` is an assisted, foreground upgrade. It checks whether the +current host and installation are safely upgradeable before discovery, shows +the current and target version/channel/commit, and requires you to type `yes` +in a terminal before downloading the release bundle. It then verifies the +bundle, reruns preflight from the candidate, invokes `install.sh --upgrade`, +and verifies the installed release identity. It does not accept channel or +version arguments, downgrade, migrate legacy installations, or run unattended. + `--notify` sends a desktop notification through the running user service. When supported by the desktop, the notification includes actions to open the release or disable future automatic notifications. LG Buddy does not repeatedly notify @@ -251,7 +260,8 @@ lg-buddy settings set updates.auto_check enabled lg-buddy settings set updates.channel prerelease ``` -Disabling automatic checks does not disable manual `updates check` commands. +Disabling automatic checks does not disable manual `updates check` or +`updates install` commands. Both use the saved `updates.channel` setting. ## Technical References diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index 22857c6..3a81e4f 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -66,6 +66,8 @@ assert_cli_surface() { local no_args_output="" local removed_channel_output="" local removed_channel_status=0 + local install_argument_output="" + local install_argument_status=0 help_output="$("$binary" --help)" no_args_output="$("$binary")" @@ -88,6 +90,7 @@ assert_cli_surface() { printf '%s\n' "$help_output" | grep -q "settings list" printf '%s\n' "$help_output" | grep -q "settings set " printf '%s\n' "$help_output" | grep -F -q "updates check [--notify]" + printf '%s\n' "$help_output" | grep -F -q "updates install" if printf '%s\n' "$help_output" | grep -F -q -- "--channel"; then echo "Removed updates --channel option appeared in public help: $binary" exit 1 @@ -105,6 +108,19 @@ assert_cli_surface() { fi printf '%s\n' "$removed_channel_output" | grep -F -q 'unexpected arguments for `updates check`: --channel stable' + "$binary" updates install --help | grep -F -q "updates install" + if install_argument_output="$("$binary" updates install 1.5.0 2>&1)"; then + echo "Updates install unexpectedly accepted a version argument: $binary" + exit 1 + else + install_argument_status=$? + fi + if [ "$install_argument_status" -ne 2 ]; then + echo "Updates install argument rejection returned status $install_argument_status instead of 2: $binary" + exit 1 + fi + printf '%s\n' "$install_argument_output" | grep -F -q 'unexpected arguments for `updates install`: 1.5.0' + for hidden in startup shutdown screen-off screen-on "updates background-check" upgrade-preflight; do if printf '%s\n' "$help_output" | grep -F -q "$hidden"; then echo "Hidden entrypoint appeared in public help: $hidden" From b15c49fab1116eb74a3f0d9784f4c6a8f13918c0 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:04:25 +0300 Subject: [PATCH 11/22] chore(release): prepare 1.4.0-beta.2 --- Cargo.lock | 2 +- crates/lg-buddy/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f36006..b6d4d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,7 +890,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lg-buddy" -version = "1.3.0" +version = "1.4.0-beta.2" dependencies = [ "base64", "cucumber", diff --git a/crates/lg-buddy/Cargo.toml b/crates/lg-buddy/Cargo.toml index 82f5095..ee57008 100644 --- a/crates/lg-buddy/Cargo.toml +++ b/crates/lg-buddy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lg-buddy" -version = "1.3.0" +version = "1.4.0-beta.2" edition = "2021" publish = false From a68162657286c71d9ea004c6c867fc47890ded28 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:35:14 +0300 Subject: [PATCH 12/22] ci: publish releases after promotion merges --- .github/workflows/promotion-check.yml | 2 +- .github/workflows/release.yml | 176 ++++++++++---------------- CONTRIBUTING.md | 2 +- docs/development.md | 4 +- docs/release-process.md | 59 ++++----- scripts/release_promotion.py | 174 +++++++++++++++++++------ scripts/test_release_promotion.py | 114 ++++++++++++++++- 7 files changed, 342 insertions(+), 189 deletions(-) diff --git a/.github/workflows/promotion-check.yml b/.github/workflows/promotion-check.yml index 20c4d8f..1909e47 100644 --- a/.github/workflows/promotion-check.yml +++ b/.github/workflows/promotion-check.yml @@ -32,7 +32,7 @@ jobs: exit 1 } [ "$HEAD_REF" = "dev" ] || { - echo "Only the exact dev branch may target $BASE_REF." + echo "Only the dev branch may target $BASE_REF." exit 1 } case "$BASE_REF" in diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 588dd6f..42e72b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,82 +1,37 @@ name: Release promotion on: - pull_request_target: + push: branches: - main - prerelease - types: - - labeled permissions: actions: read - checks: read contents: read - pull-requests: read concurrency: group: release-promotion cancel-in-progress: false jobs: - authorize: - if: github.event.label.name == 'release:promote' - runs-on: ubuntu-latest - outputs: - base_ref: ${{ steps.authorize.outputs.base_ref }} - base_sha: ${{ steps.authorize.outputs.base_sha }} - head_sha: ${{ steps.authorize.outputs.head_sha }} - pr_number: ${{ steps.authorize.outputs.pr_number }} - - steps: - - name: Authorize promotion request - id: authorize - env: - ACTOR: ${{ github.actor }} - BASE_REF: ${{ github.event.pull_request.base.ref }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - GH_TOKEN: ${{ github.token }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - IS_DRAFT: ${{ github.event.pull_request.draft }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - [ "$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .permission)" = "admin" ] || { - echo "Only a repository administrator may request release promotion." - exit 1 - } - [ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] - [ "$HEAD_REF" = "dev" ] - [ "$IS_DRAFT" = "false" ] - case "$BASE_REF" in - main|prerelease) ;; - *) exit 1 ;; - esac - gh pr checks "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --required - { - echo "base_ref=$BASE_REF" - echo "base_sha=$BASE_SHA" - echo "head_sha=$HEAD_SHA" - echo "pr_number=$PR_NUMBER" - } >> "$GITHUB_OUTPUT" - validate: runs-on: ubuntu-latest - needs: authorize outputs: + base_sha: ${{ steps.contract.outputs.base_sha }} channel: ${{ steps.contract.outputs.channel }} head_sha: ${{ steps.contract.outputs.head_sha }} - main_sha: ${{ steps.contract.outputs.main_sha }} prerelease_sha: ${{ steps.contract.outputs.prerelease_sha }} + publish: ${{ steps.contract.outputs.publish }} + source_sha: ${{ steps.contract.outputs.source_sha }} tag: ${{ steps.contract.outputs.tag }} version: ${{ steps.contract.outputs.version }} steps: - - name: Check out trusted promotion validator + - name: Check out merged release commit uses: actions/checkout@v4 with: - ref: ${{ needs.authorize.outputs.base_sha }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -85,7 +40,7 @@ jobs: with: python-version: "3.x" - - name: Fetch channel refs and release tags + - name: Fetch release refs and tags run: | git fetch --force origin \ +refs/heads/main:refs/remotes/origin/main \ @@ -93,23 +48,27 @@ jobs: +refs/heads/dev:refs/remotes/origin/dev \ +refs/tags/*:refs/tags/* - - name: Validate promotion contract + - name: Validate merged promotion id: contract + env: + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + TARGET: ${{ github.ref_name }} run: | python3 scripts/release_promotion.py \ - --target "${{ needs.authorize.outputs.base_ref }}" \ - --head-ref "${{ needs.authorize.outputs.head_sha }}" \ - --base-sha "${{ needs.authorize.outputs.base_sha }}" \ + --merged \ + --target "$TARGET" \ + --head-ref "$HEAD_SHA" \ + --base-sha "$BASE_SHA" \ --github-output "$GITHUB_OUTPUT" build-release-bundle: + if: needs.validate.outputs.publish == 'true' runs-on: ubuntu-latest - needs: - - authorize - - validate + needs: validate steps: - - name: Check out exact reviewed dev commit + - name: Check out merged release commit uses: actions/checkout@v4 with: ref: ${{ needs.validate.outputs.head_sha }} @@ -168,19 +127,19 @@ jobs: if-no-files-found: error retention-days: 7 - finalize: + publish: + if: needs.validate.outputs.publish == 'true' runs-on: ubuntu-latest environment: release-promotion needs: - - authorize - validate - build-release-bundle steps: - - name: Check out trusted finalizer + - name: Check out merged release commit uses: actions/checkout@v4 with: - ref: ${{ needs.authorize.outputs.base_sha }} + ref: ${{ needs.validate.outputs.head_sha }} fetch-depth: 0 persist-credentials: false @@ -189,8 +148,7 @@ jobs: with: python-version: "3.x" - - name: Fetch and revalidate current refs - id: recheck + - name: Fetch and revalidate release refs run: | git fetch --force origin \ +refs/heads/main:refs/remotes/origin/main \ @@ -198,16 +156,10 @@ jobs: +refs/heads/dev:refs/remotes/origin/dev \ +refs/tags/*:refs/tags/* python3 scripts/release_promotion.py \ - --target "${{ needs.authorize.outputs.base_ref }}" \ - --head-ref "${{ needs.authorize.outputs.head_sha }}" \ - --base-sha "${{ needs.authorize.outputs.base_sha }}" \ - --github-output "$GITHUB_OUTPUT" - [ "$(git rev-parse refs/remotes/origin/dev)" = "${{ needs.validate.outputs.head_sha }}" ] - - - name: Revalidate required PR checks - env: - GH_TOKEN: ${{ github.token }} - run: gh pr checks "${{ needs.authorize.outputs.pr_number }}" --repo "$GITHUB_REPOSITORY" --required + --merged \ + --target "${{ github.ref_name }}" \ + --head-ref "${{ needs.validate.outputs.head_sha }}" \ + --base-sha "${{ needs.validate.outputs.base_sha }}" - name: Download verified release bundle uses: actions/download-artifact@v4 @@ -218,8 +170,8 @@ jobs: - name: Verify downloaded checksums run: cd dist && sha256sum -c sha256sums.txt - - name: Create repository-scoped promotion token - id: promotion-token + - name: Create repository-scoped release token + id: release-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: app-id: ${{ vars.RELEASE_PROMOTION_APP_ID }} @@ -228,37 +180,14 @@ jobs: repositories: LG_Buddy permission-contents: write - - name: Publish release and advance channel refs + - name: Align release streams env: - BASE_REF: ${{ needs.authorize.outputs.base_ref }} - EXPECTED_MAIN_SHA: ${{ needs.validate.outputs.main_sha }} - EXPECTED_PRERELEASE_SHA: ${{ needs.validate.outputs.prerelease_sha }} - GH_TOKEN: ${{ steps.promotion-token.outputs.token }} + GH_TOKEN: ${{ steps.release-token.outputs.token }} HEAD_SHA: ${{ needs.validate.outputs.head_sha }} - RELEASE_TAG: ${{ needs.validate.outputs.tag }} + PRERELEASE_SHA: ${{ needs.validate.outputs.prerelease_sha }} + SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} + TARGET: ${{ github.ref_name }} run: | - remote_tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq .object.sha 2>/dev/null || true)" - if [ -z "$remote_tag_sha" ]; then - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$RELEASE_TAG" \ - -f sha="$HEAD_SHA" >/dev/null - elif [ "$remote_tag_sha" != "$HEAD_SHA" ]; then - echo "Tag $RELEASE_TAG points to $remote_tag_sha, expected $HEAD_SHA." - exit 1 - fi - - if ! git rev-parse --verify "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then - git tag "$RELEASE_TAG" "$HEAD_SHA" - fi - ./scripts/publish-release-assets.sh \ - --dist-dir dist \ - --tag "$RELEASE_TAG" \ - --commit "$HEAD_SHA" - - verify_dir="$(mktemp -d)" - gh release download "$RELEASE_TAG" --dir "$verify_dir" - (cd "$verify_dir" && sha256sum -c sha256sums.txt) - update_ref() { local ref_name="$1" local expected_sha="$2" @@ -277,11 +206,36 @@ jobs: -F force=false >/dev/null } - if [ "$BASE_REF" = "main" ]; then - update_ref prerelease "$EXPECTED_PRERELEASE_SHA" - update_ref main "$EXPECTED_MAIN_SHA" + if [ "$TARGET" = "main" ]; then + update_ref prerelease "$PRERELEASE_SHA" + fi + update_ref dev "$SOURCE_SHA" + + - name: Publish release + env: + GH_TOKEN: ${{ steps.release-token.outputs.token }} + HEAD_SHA: ${{ needs.validate.outputs.head_sha }} + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + run: | + if remote_tag_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq .object.sha 2>/dev/null)"; then + [ "$remote_tag_sha" = "$HEAD_SHA" ] || { + echo "Tag $RELEASE_TAG points to $remote_tag_sha, expected $HEAD_SHA." + exit 1 + } else - update_ref prerelease "$EXPECTED_PRERELEASE_SHA" + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" \ + -f sha="$HEAD_SHA" >/dev/null fi - [ "$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$BASE_REF" --jq .object.sha)" = "$HEAD_SHA" ] + if ! git rev-parse --verify "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + git tag "$RELEASE_TAG" "$HEAD_SHA" + fi + ./scripts/publish-release-assets.sh \ + --dist-dir dist \ + --tag "$RELEASE_TAG" \ + --commit "$HEAD_SHA" + + verify_dir="$(mktemp -d)" + gh release download "$RELEASE_TAG" --dir "$verify_dir" + (cd "$verify_dir" && sha256sum -c sha256sums.txt) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4172edb..c32da4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ Keep one concern per pull request. Base ordinary contribution PRs on `dev` and target `dev`. The persistent `main` and `prerelease` branches accept only release promotion PRs whose head is -the exact same-repository `dev` branch; see the +the same-repository `dev` branch; see the [release process](docs/release-process.md). Good PRs usually fit one of these shapes: diff --git a/docs/development.md b/docs/development.md index db45cf2..cf2b332 100644 --- a/docs/development.md +++ b/docs/development.md @@ -182,10 +182,10 @@ the branch contract and recovery process, see | `scripts/build-release-bundle.sh` | Release bundle builder | | `scripts/test-release-bundle.sh` | Release bundle smoke test | | `scripts/publish-release-assets.sh` | GitHub release publish helper | -| `scripts/release_promotion.py` | Promotion version, ancestry, and tag validator | +| `scripts/release_promotion.py` | Promotion version, branch, and tag validator | | `.github/workflows/ci.yml` | CI validation workflow | | `.github/workflows/promotion-check.yml` | Trusted promotion PR contract check | -| `.github/workflows/release.yml` | Approved promotion build, publication, and ref finalizer | +| `.github/workflows/release.yml` | Post-merge promotion build and publication workflow | | `bin/LG_Buddy_Common` | Shared shell config helper used by setup scripts | | `systemd/` | Installed unit files and tmpfiles config, including the logind lifecycle service | | `docs/architecture-overview.md` | Runtime architecture | diff --git a/docs/release-process.md b/docs/release-process.md index 0fb8108..52ee228 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -8,17 +8,21 @@ LG Buddy uses three persistent branches as source channels: - `dev` is the ordinary integration branch and may contain unreleased work. The intended ancestry is `main <= prerelease <= dev`. Ordinary changes merge -into `dev`. An official release requires a promotion PR whose head is the exact +into `dev`. An official release requires a promotion PR whose head is the same-repository `dev` branch and whose base is `main` or `prerelease`. ## Promotion contract -The promotion PR is the review and approval surface. The release-channel -ruleset blocks GitHub's merge, squash, and rebase buttons from updating the -branch because those methods would create a commit different from the reviewed -`dev` commit; only the promotion App may perform the final fast-forward. -Repository-wide automatic head-branch deletion stays disabled so GitHub cannot -remove the persistent `dev` branch when a promotion PR becomes merged. +The promotion PR is the review and approval surface. Required checks gate its +merge, and merging the PR is the release authorization. The merged target-branch +commit is the release commit; there is no separate approval label or publish +action. + +The release App is not a substitute for merging. After the merged commit has +passed the release build and smoke test, the App performs protected tag and +release writes and keeps the persistent streams aligned. A prerelease merge +advances `dev` to the merged prerelease commit. A stable merge advances both +`prerelease` and `dev` to the merged stable commit. Required promotion checks prove that: @@ -27,9 +31,8 @@ Required promotion checks prove that: - a stable target has a stable SemVer and a prerelease target has a prerelease SemVer - the version advances both existing release-channel heads -- the persistent branches have not diverged or moved since review -- the derived `v` tag is absent or already points to the same - commit during an idempotent retry +- the persistent branches have not moved or diverged before merge +- the derived `v` tag is absent before merge - normal CI and the release-bundle smoke test pass The tag, binary, archive, and GitHub release all use the Cargo package version. @@ -40,38 +43,32 @@ There is no separate version input. 1. Prepare the exact release version in `Cargo.toml` and `Cargo.lock` on `dev`. 2. Open a PR from `dev` to `prerelease` or `main`. 3. Wait for `verify`, `bundle-smoke-test`, and `validate-promotion` to pass. -4. After review, a repository administrator applies the `release:promote` - label. -5. The serialized release workflow rebuilds and smoke-tests the exact reviewed - commit without write credentials. -6. A separate finalization job obtains a short-lived token from the dedicated - repository-only GitHub App, publishes the immutable tag and release, verifies - the published checksums, and only then fast-forwards the channel branch. - -Stable finalization advances `prerelease` before `main`, preserving -`main <= prerelease` even if the second ref update needs to be retried. -Prerelease finalization advances only `prerelease`; `dev` already points to the -reviewed commit in both cases. - -Do not push version tags manually. Protected `v*` tags and release-channel -branches permit bypass only to the dedicated promotion App. Failed finalization -can be rerun safely, but an existing tag or asset is accepted only when it is -byte-for-byte consistent with the same reviewed commit. +4. Merge the promotion PR. This is the release authorization. +5. The resulting push starts the serialized release workflow, which builds and + smoke-tests the merged release commit without write credentials. +6. The final job obtains a short-lived token from the dedicated repository-only + GitHub App, aligns the remaining release streams, publishes the immutable tag + and release, and verifies the published checksums. + +Do not push version tags manually. Protected `v*` tags and stream-alignment +writes permit bypass only to the dedicated release App. A failed post-merge +release run can be rerun safely, but an existing tag or asset is accepted only +when it is byte-for-byte consistent with the merged release commit. ## What the release workflow validates The workflow: -1. Revalidates the live PR, refs, required checks, Cargo version, and tag state. +1. Validates the merged target-branch commit, Cargo version, and tag state. 2. Builds a static `x86_64-unknown-linux-musl` binary with exact version and commit identity. 3. Generates a versioned identity manifest and packages the release bundle. 4. Validates the manifest and installs the bundle in an isolated smoke-test root. 5. Verifies the built and installed binary's exact version, channel, and commit. 6. Generates and verifies `sha256sums.txt`. -7. Publishes the tag and GitHub release without replacing conflicting assets. -8. Downloads the published assets and verifies their checksums independently. -9. Fast-forwards the selected branch only after publication succeeds. +7. Keeps `main`, `prerelease`, and `dev` aligned for the next promotion. +8. Publishes the tag and GitHub release without replacing conflicting assets. +9. Downloads the published assets and verifies their checksums independently. `install.sh` is only an installer. It does not build the runtime. diff --git a/scripts/release_promotion.py b/scripts/release_promotion.py index 6a7dbe6..a7b3bfc 100644 --- a/scripts/release_promotion.py +++ b/scripts/release_promotion.py @@ -154,33 +154,14 @@ def require_ancestor(repository: Path, ancestor: str, descendant: str) -> None: raise PromotionError(result.stderr.strip() or "git merge-base failed") -def validate_promotion( +def validate_release_identity( repository: Path, *, target: str, - head_ref: str, - base_sha: str, - main_ref: str, - prerelease_ref: str, - dev_ref: str, + head_sha: str, + current_main_ref: str, + current_prerelease_ref: str, ) -> dict[str, object]: - if target not in {"main", "prerelease"}: - raise PromotionError(f"unsupported promotion target: {target}") - - head_sha = resolve(repository, head_ref) - dev_sha = resolve(repository, dev_ref) - main_sha = resolve(repository, main_ref) - prerelease_sha = resolve(repository, prerelease_ref) - target_sha = main_sha if target == "main" else prerelease_sha - - if head_sha != dev_sha: - raise PromotionError(f"reviewed head {head_sha} is not the current dev commit {dev_sha}") - if target_sha != resolve(repository, base_sha): - raise PromotionError(f"promotion target moved from reviewed base {base_sha} to {target_sha}") - - require_ancestor(repository, main_sha, prerelease_sha) - require_ancestor(repository, prerelease_sha, head_sha) - version_text = package_version_at_ref(repository, head_sha) lock_version = lock_version_at_ref(repository, head_sha) if lock_version != version_text: @@ -196,12 +177,13 @@ def validate_promotion( if target == "prerelease" and not version.prerelease: raise PromotionError(f"prerelease requires a prerelease version, found {version_text}") - current_main_text = package_version_at_ref(repository, main_sha) - current_prerelease_text = package_version_at_ref(repository, prerelease_sha) - for channel, current_text in ( - ("main", current_main_text), - ("prerelease", current_prerelease_text), + for channel, current_ref in ( + ("main", current_main_ref), + ("prerelease", current_prerelease_ref), ): + if resolve(repository, current_ref) == head_sha: + continue + current_text = package_version_at_ref(repository, current_ref) if version <= SemVer.parse(current_text): raise PromotionError( f"candidate {version_text} must advance {channel} from {current_text}" @@ -226,11 +208,116 @@ def validate_promotion( "version": version_text, "tag": tag, "channel": "stable" if target == "main" else "prerelease", + "retry": retry, + } + + +def validate_promotion( + repository: Path, + *, + target: str, + head_ref: str, + base_sha: str, + main_ref: str, + prerelease_ref: str, + dev_ref: str, +) -> dict[str, object]: + if target not in {"main", "prerelease"}: + raise PromotionError(f"unsupported promotion target: {target}") + + head_sha = resolve(repository, head_ref) + dev_sha = resolve(repository, dev_ref) + main_sha = resolve(repository, main_ref) + prerelease_sha = resolve(repository, prerelease_ref) + target_sha = main_sha if target == "main" else prerelease_sha + + if head_sha != dev_sha: + raise PromotionError(f"reviewed head {head_sha} is not the current dev commit {dev_sha}") + if target_sha != resolve(repository, base_sha): + raise PromotionError(f"promotion target moved from reviewed base {base_sha} to {target_sha}") + + require_ancestor(repository, main_sha, prerelease_sha) + require_ancestor(repository, prerelease_sha, head_sha) + + identity = validate_release_identity( + repository, + target=target, + head_sha=head_sha, + current_main_ref=main_sha, + current_prerelease_ref=prerelease_sha, + ) + if identity["retry"]: + raise PromotionError(f"tag {identity['tag']} already exists before promotion merge") + + return { + **identity, + "publish": True, "head_sha": head_sha, + "source_sha": dev_sha, "base_sha": target_sha, "main_sha": main_sha, "prerelease_sha": prerelease_sha, - "retry": retry, + } + + +def validate_merged_promotion( + repository: Path, + *, + target: str, + head_ref: str, + base_sha: str, + main_ref: str, + prerelease_ref: str, +) -> dict[str, object]: + if target not in {"main", "prerelease"}: + raise PromotionError(f"unsupported promotion target: {target}") + + head_sha = resolve(repository, head_ref) + previous_sha = resolve(repository, base_sha) + main_sha = resolve(repository, main_ref) + prerelease_sha = resolve(repository, prerelease_ref) + target_sha = main_sha if target == "main" else prerelease_sha + if target_sha != head_sha: + raise PromotionError(f"merged {target} moved from release commit {head_sha} to {target_sha}") + require_ancestor(repository, previous_sha, head_sha) + + if target == "prerelease" and main_sha == head_sha: + version = package_version_at_ref(repository, head_sha) + return { + "publish": False, + "version": version, + "tag": f"v{version}", + "channel": "stable", + "retry": False, + "head_sha": head_sha, + "source_sha": "", + "base_sha": previous_sha, + "main_sha": main_sha, + "prerelease_sha": prerelease_sha, + } + + parents = git(repository, "rev-list", "--parents", "-n", "1", head_sha).split() + if len(parents) != 3: + raise PromotionError("release promotion must produce a two-parent merge commit") + source_sha = parents[2] + + identity = validate_release_identity( + repository, + target=target, + head_sha=head_sha, + current_main_ref=previous_sha if target == "main" else main_sha, + current_prerelease_ref=( + previous_sha if target == "prerelease" else prerelease_sha + ), + ) + return { + **identity, + "publish": True, + "head_sha": head_sha, + "source_sha": source_sha, + "base_sha": previous_sha, + "main_sha": main_sha, + "prerelease_sha": prerelease_sha, } @@ -251,21 +338,32 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--prerelease-ref", default="refs/remotes/origin/prerelease") parser.add_argument("--dev-ref", default="refs/remotes/origin/dev") parser.add_argument("--github-output", type=Path) + parser.add_argument("--merged", action="store_true") return parser.parse_args() def main() -> int: args = parse_args() try: - result = validate_promotion( - args.repository.resolve(), - target=args.target, - head_ref=args.head_ref, - base_sha=args.base_sha, - main_ref=args.main_ref, - prerelease_ref=args.prerelease_ref, - dev_ref=args.dev_ref, - ) + if args.merged: + result = validate_merged_promotion( + args.repository.resolve(), + target=args.target, + head_ref=args.head_ref, + base_sha=args.base_sha, + main_ref=args.main_ref, + prerelease_ref=args.prerelease_ref, + ) + else: + result = validate_promotion( + args.repository.resolve(), + target=args.target, + head_ref=args.head_ref, + base_sha=args.base_sha, + main_ref=args.main_ref, + prerelease_ref=args.prerelease_ref, + dev_ref=args.dev_ref, + ) except PromotionError as error: raise SystemExit(f"release promotion validation failed: {error}") from error diff --git a/scripts/test_release_promotion.py b/scripts/test_release_promotion.py index af7cb99..6e03d66 100644 --- a/scripts/test_release_promotion.py +++ b/scripts/test_release_promotion.py @@ -7,7 +7,12 @@ import unittest from pathlib import Path -from release_promotion import PromotionError, SemVer, validate_promotion +from release_promotion import ( + PromotionError, + SemVer, + validate_merged_promotion, + validate_promotion, +) class RepositoryFixture: @@ -70,6 +75,25 @@ def validate(self, target: str, head_sha: str) -> dict[str, object]: dev_ref="dev", ) + def merge_promotion(self, target: str) -> tuple[str, str, str]: + source_sha = self.git("rev-parse", "dev") + base_sha = self.git("rev-parse", target) + self.git("switch", target) + self.git("merge", "--no-ff", "dev", "-m", f"promote dev to {target}") + return base_sha, source_sha, self.git("rev-parse", "HEAD") + + def validate_merged( + self, target: str, head_sha: str, base_sha: str + ) -> dict[str, object]: + return validate_merged_promotion( + self.path, + target=target, + head_ref=head_sha, + base_sha=base_sha, + main_ref="main", + prerelease_ref="prerelease", + ) + class SemVerTests(unittest.TestCase): def test_semver_precedence(self) -> None: @@ -160,13 +184,12 @@ def test_diverged_prerelease_is_rejected(self) -> None: with self.assertRaisesRegex(PromotionError, "is not an ancestor"): self.repository.validate("prerelease", head) - def test_matching_existing_tag_is_an_idempotent_retry(self) -> None: + def test_existing_candidate_tag_is_rejected_before_merge(self) -> None: head = self.repository.candidate("1.4.0-beta.1") self.repository.git("tag", "v1.4.0-beta.1", head) - result = self.repository.validate("prerelease", head) - - self.assertTrue(result["retry"]) + with self.assertRaisesRegex(PromotionError, "already exists before promotion merge"): + self.repository.validate("prerelease", head) def test_conflicting_existing_tag_is_rejected(self) -> None: head = self.repository.candidate("1.4.0-beta.1") @@ -175,6 +198,87 @@ def test_conflicting_existing_tag_is_rejected(self) -> None: with self.assertRaisesRegex(PromotionError, "already points"): self.repository.validate("prerelease", head) + def test_merged_prerelease_is_publishable(self) -> None: + self.repository.candidate("1.4.0-beta.1") + base, source, merged = self.repository.merge_promotion("prerelease") + + result = self.repository.validate_merged("prerelease", merged, base) + + self.assertTrue(result["publish"]) + self.assertEqual(result["head_sha"], merged) + self.assertEqual(result["source_sha"], source) + self.assertEqual(result["tag"], "v1.4.0-beta.1") + self.assertEqual(result["channel"], "prerelease") + + def test_merged_stable_release_after_prerelease_is_publishable(self) -> None: + prerelease = self.repository.candidate("1.4.0-beta.1") + self.repository.git("branch", "-f", "prerelease", prerelease) + self.repository.write_version("1.4.0") + self.repository.git("add", ".") + self.repository.git("commit", "-m", "prepare stable") + base, source, merged = self.repository.merge_promotion("main") + + result = self.repository.validate_merged("main", merged, base) + + self.assertTrue(result["publish"]) + self.assertEqual(result["source_sha"], source) + self.assertEqual(result["tag"], "v1.4.0") + self.assertEqual(result["channel"], "stable") + + def test_stable_alignment_push_to_prerelease_does_not_publish_again(self) -> None: + prerelease = self.repository.candidate("1.4.0-beta.1") + self.repository.git("branch", "-f", "prerelease", prerelease) + self.repository.write_version("1.4.0") + self.repository.git("add", ".") + self.repository.git("commit", "-m", "prepare stable") + _, _, merged = self.repository.merge_promotion("main") + self.repository.git("branch", "-f", "prerelease", merged) + + result = self.repository.validate_merged("prerelease", merged, prerelease) + + self.assertFalse(result["publish"]) + self.assertEqual(result["head_sha"], merged) + self.assertEqual(result["base_sha"], prerelease) + self.assertEqual(result["channel"], "stable") + + def test_release_branch_fast_forward_without_a_merge_is_rejected(self) -> None: + head = self.repository.candidate("1.4.0-beta.1") + self.repository.git("branch", "-f", "prerelease", head) + + with self.assertRaisesRegex(PromotionError, "two-parent merge commit"): + self.repository.validate_merged("prerelease", head, self.repository.stable_sha) + + def test_matching_merged_release_tag_is_an_idempotent_retry(self) -> None: + self.repository.candidate("1.4.0-beta.1") + base, _, merged = self.repository.merge_promotion("prerelease") + self.repository.git("tag", "v1.4.0-beta.1", merged) + + result = self.repository.validate_merged("prerelease", merged, base) + + self.assertTrue(result["retry"]) + + def test_merged_release_lockfile_mismatch_is_rejected(self) -> None: + self.repository.candidate("1.4.0-beta.1", "1.3.0") + base, _, merged = self.repository.merge_promotion("prerelease") + + with self.assertRaisesRegex(PromotionError, "does not match Cargo.lock"): + self.repository.validate_merged("prerelease", merged, base) + + def test_merged_release_channel_mismatch_is_rejected(self) -> None: + self.repository.candidate("1.4.0") + base, _, merged = self.repository.merge_promotion("prerelease") + + with self.assertRaisesRegex(PromotionError, "requires a prerelease version"): + self.repository.validate_merged("prerelease", merged, base) + + def test_conflicting_merged_release_tag_is_rejected(self) -> None: + self.repository.candidate("1.4.0-beta.1") + base, _, merged = self.repository.merge_promotion("prerelease") + self.repository.git("tag", "v1.4.0-beta.1", self.repository.stable_sha) + + with self.assertRaisesRegex(PromotionError, "already points"): + self.repository.validate_merged("prerelease", merged, base) + if __name__ == "__main__": unittest.main() From f938e8664d585773a7d93dbb7b409b54be380e8d Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:41:59 +0300 Subject: [PATCH 13/22] test: gate releases on cross-version upgrades --- .github/workflows/ci.yml | 65 ++- .github/workflows/promotion-check.yml | 21 + .github/workflows/release.yml | 94 +++- README.md | 4 + crates/lg-buddy/src/updates.rs | 38 ++ .../github/releases-v1.4.0-beta.2.json | 42 ++ docs/development.md | 25 +- docs/release-process.md | 37 +- docs/testing-strategy.md | 17 + docs/user-guide.md | 4 + scripts/record_github_release_responses.py | 237 ++++++++++ scripts/test-cross-version-upgrade.sh | 445 ++++++++++++++++++ scripts/test-production-upgrade-canary.sh | 212 +++++++++ .../test_record_github_release_responses.py | 123 +++++ 14 files changed, 1348 insertions(+), 16 deletions(-) create mode 100644 crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json create mode 100755 scripts/record_github_release_responses.py create mode 100755 scripts/test-cross-version-upgrade.sh create mode 100755 scripts/test-production-upgrade-canary.sh create mode 100755 scripts/test_record_github_release_responses.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1285c97..9892f64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: push: permissions: + checks: read contents: read jobs: @@ -38,7 +39,7 @@ jobs: run: cargo clippy -p lg-buddy --all-targets --all-features -- -D warnings - name: Validate shell scripts - run: bash -n install.sh uninstall.sh configure.sh bin/LG_Buddy_Common scripts/build-release-bundle.sh scripts/test-release-bundle.sh scripts/publish-release-assets.sh + run: bash -n install.sh uninstall.sh configure.sh bin/LG_Buddy_Common scripts/build-release-bundle.sh scripts/test-release-bundle.sh scripts/test-cross-version-upgrade.sh scripts/test-production-upgrade-canary.sh scripts/publish-release-assets.sh - name: Validate release promotion contract run: python3 scripts/test_release_promotion.py @@ -46,9 +47,33 @@ jobs: - name: Validate release bundle manifest contract run: python3 scripts/test_release_bundle_manifest.py + - name: Validate GitHub response recorder + run: python3 scripts/test_record_github_release_responses.py + + - name: Require successful production prerelease canary for stable promotion + if: github.event_name == 'pull_request' && github.base_ref == 'main' + env: + GH_TOKEN: ${{ github.token }} + run: | + prerelease_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/prerelease" --jq .object.sha)" + [ "$prerelease_sha" != "${{ github.event.pull_request.base.sha }}" ] || { + echo "Stable promotion requires a distinct prerelease candidate." + exit 1 + } + matching_checks="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/commits/$prerelease_sha/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "production-prerelease-canary" and .status == "completed" and .conclusion == "success" and .app.slug == "github-actions")] | length')" + [ "$matching_checks" -gt 0 ] || { + echo "No successful production-prerelease canary exists for $prerelease_sha." + exit 1 + } + bundle-smoke-test: runs-on: ubuntu-latest needs: verify + env: + SMOKE_VERSION: 1.4.0-beta.2.ci steps: - name: Check out repository @@ -65,30 +90,54 @@ jobs: python-version: "3.x" - name: Install smoke-test prerequisites - run: sudo apt-get update && sudo apt-get install -y musl-tools zenity + run: sudo apt-get update && sudo apt-get install -y musl-tools strace zenity - name: Build lg-buddy release binary env: LG_BUDDY_BUILD_COMMIT: ${{ github.sha }} - LG_BUDDY_RELEASE_VERSION: 0.0.0-ci.smoke + LG_BUDDY_RELEASE_VERSION: ${{ env.SMOKE_VERSION }} run: cargo build --release -p lg-buddy --target x86_64-unknown-linux-musl - name: Create release bundle run: | umask 0002 - ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version 0.0.0-ci.smoke --output-dir dist + ./scripts/build-release-bundle.sh --target x86_64-unknown-linux-musl --version "$SMOKE_VERSION" --output-dir dist - name: Smoke test release bundle run: | ./scripts/test-release-bundle.sh \ --skip-pip-install \ - --archive dist/lg-buddy-0.0.0-ci.smoke-x86_64-unknown-linux-musl.tar.gz \ - --expected-tag v0.0.0-ci.smoke \ - --expected-version 0.0.0-ci.smoke \ + --archive "dist/lg-buddy-${SMOKE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + --expected-tag "v${SMOKE_VERSION}" \ + --expected-version "$SMOKE_VERSION" \ --expected-channel prerelease \ --expected-target x86_64-unknown-linux-musl \ --expected-commit "${{ github.sha }}" + - name: Download pinned cross-version baseline + run: | + curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz + + - name: Smoke test cross-version upgrade + run: | + ./scripts/test-cross-version-upgrade.sh \ + --previous-archive "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + --previous-sha256 883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32 \ + --previous-tag v1.4.0-beta.2 \ + --previous-version 1.4.0-beta.2 \ + --previous-channel prerelease \ + --previous-target x86_64-unknown-linux-musl \ + --previous-commit 77c8f46c66b9e385f3d90c15dee33d775639bbeb \ + --candidate-archive "dist/lg-buddy-${SMOKE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + --candidate-tag "v${SMOKE_VERSION}" \ + --candidate-version "$SMOKE_VERSION" \ + --candidate-channel prerelease \ + --candidate-target x86_64-unknown-linux-musl \ + --candidate-commit "${{ github.sha }}" + - name: Generate checksums run: | cd dist @@ -102,4 +151,4 @@ jobs: - name: Dry-run publish release assets env: GH_RELEASE_DRY_RUN: "1" - run: ./scripts/publish-release-assets.sh --dist-dir dist --tag v0.0.0-ci.smoke --commit "${{ github.sha }}" + run: ./scripts/publish-release-assets.sh --dist-dir dist --tag "v${SMOKE_VERSION}" --commit "${{ github.sha }}" diff --git a/.github/workflows/promotion-check.yml b/.github/workflows/promotion-check.yml index 1909e47..9583cc9 100644 --- a/.github/workflows/promotion-check.yml +++ b/.github/workflows/promotion-check.yml @@ -12,6 +12,7 @@ on: - ready_for_review permissions: + checks: read contents: read pull-requests: read @@ -95,3 +96,23 @@ jobs: [ "$CHANNEL" = "stable" ] || expected_prerelease="true" [ "$(printf '%s' "$release_state" | jq -r .isPrerelease)" = "$expected_prerelease" ] fi + + - name: Require production prerelease upgrade canary + if: github.event.pull_request.base.ref == 'main' + env: + GH_TOKEN: ${{ github.token }} + MAIN_SHA: ${{ steps.contract.outputs.main_sha }} + PRERELEASE_SHA: ${{ steps.contract.outputs.prerelease_sha }} + run: | + [ "$PRERELEASE_SHA" != "$MAIN_SHA" ] || { + echo "Stable promotion requires a distinct prerelease candidate." + exit 1 + } + matching_checks="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/commits/$PRERELEASE_SHA/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "production-prerelease-canary" and .status == "completed" and .conclusion == "success" and .app.slug == "github-actions")] | length')" + [ "$matching_checks" -gt 0 ] || { + echo "No successful production-prerelease canary exists for $PRERELEASE_SHA." + exit 1 + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42e72b9..c4f12f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: python-version: "3.x" - name: Install release prerequisites - run: sudo apt-get update && sudo apt-get install -y musl-tools zenity + run: sudo apt-get update && sudo apt-get install -y musl-tools strace zenity - name: Build lg-buddy env: @@ -107,6 +107,30 @@ jobs: --expected-target x86_64-unknown-linux-musl \ --expected-commit "${{ needs.validate.outputs.head_sha }}" + - name: Download pinned cross-version baseline + run: | + curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz + + - name: Smoke test cross-version upgrade + run: | + ./scripts/test-cross-version-upgrade.sh \ + --previous-archive "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + --previous-sha256 883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32 \ + --previous-tag v1.4.0-beta.2 \ + --previous-version 1.4.0-beta.2 \ + --previous-channel prerelease \ + --previous-target x86_64-unknown-linux-musl \ + --previous-commit 77c8f46c66b9e385f3d90c15dee33d775639bbeb \ + --candidate-archive "dist/lg-buddy-${{ needs.validate.outputs.version }}-x86_64-unknown-linux-musl.tar.gz" \ + --candidate-tag "${{ needs.validate.outputs.tag }}" \ + --candidate-version "${{ needs.validate.outputs.version }}" \ + --candidate-channel "${{ needs.validate.outputs.channel }}" \ + --candidate-target x86_64-unknown-linux-musl \ + --candidate-commit "${{ needs.validate.outputs.head_sha }}" + - name: Generate checksums run: | cd dist @@ -239,3 +263,71 @@ jobs: verify_dir="$(mktemp -d)" gh release download "$RELEASE_TAG" --dir "$verify_dir" (cd "$verify_dir" && sha256sum -c sha256sums.txt) + + production-prerelease-canary: + name: production-prerelease-canary + if: needs.validate.outputs.publish == 'true' && needs.validate.outputs.channel == 'prerelease' + runs-on: ubuntu-latest + needs: + - validate + - publish + + steps: + - name: Check out published prerelease + uses: actions/checkout@v4 + with: + ref: ${{ needs.validate.outputs.head_sha }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install canary prerequisites + run: sudo apt-get update && sudo apt-get install -y python3-venv util-linux zenity + + - name: Download pinned updater-capable baseline + run: | + curl --fail --silent --show-error --location \ + --proto '=https' --tlsv1.2 \ + --output "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz + + - name: Exercise production GitHub upgrade path + run: | + ./scripts/test-production-upgrade-canary.sh \ + --work-dir "$RUNNER_TEMP/production-canary" \ + --previous-archive "$RUNNER_TEMP/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" \ + --previous-sha256 883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32 \ + --previous-tag v1.4.0-beta.2 \ + --previous-version 1.4.0-beta.2 \ + --previous-channel prerelease \ + --previous-target x86_64-unknown-linux-musl \ + --previous-commit 77c8f46c66b9e385f3d90c15dee33d775639bbeb \ + --expected-tag "${{ needs.validate.outputs.tag }}" \ + --expected-version "${{ needs.validate.outputs.version }}" \ + --expected-channel prerelease \ + --expected-target x86_64-unknown-linux-musl \ + --expected-commit "${{ needs.validate.outputs.head_sha }}" + + - name: Record sanitized production GitHub responses + if: always() + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 scripts/record_github_release_responses.py \ + --repository "$GITHUB_REPOSITORY" \ + --tag "${{ needs.validate.outputs.tag }}" \ + --output "$RUNNER_TEMP/production-canary/github-responses.json" + + - name: Upload canary evidence for the offline mock + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-upgrade-canary-${{ needs.validate.outputs.tag }} + path: | + ${{ runner.temp }}/production-canary/production-upgrade-canary.output + ${{ runner.temp }}/production-canary/github-responses.json + if-no-files-found: warn + retention-days: 30 diff --git a/README.md b/README.md index 0e76f95..48f4c22 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ and then runs the verified bundle's upgrade installer. Upgrade mode preserves configuration and credentials and does not repeat setup or pairing; incompatible and legacy layouts are refused rather than migrated. +`v1.4.0-beta.2` is the first release with `updates install`; older versions +need one normal manual release-bundle installation before assisted upgrades are +available. + The shell installer targets conventional Linux installations with mutable system locations. First-class NixOS packaging is tracked in [issue #24](https://github.com/Staphylococcus/LG_Buddy/issues/24). diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 7d57432..46ad76d 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -2323,6 +2323,44 @@ mod tests { ); } + #[test] + fn observed_beta_2_response_is_replayed_as_the_upgrade_baseline() { + // Reduced to the fields consumed by GitHubRelease from the production + // response recorded in https://github.com/Staphylococcus/LG_Buddy/issues/99. + let body = include_str!("../testdata/github/releases-v1.4.0-beta.2.json"); + let client = MockGitHubReleasesClient::new(vec![Ok(body.to_string())]); + let release = discover_install_candidate_with( + version_info("1.4.0-beta.1", ReleaseChannel::Prerelease), + &client, + &StaticUpdateSettings::enabled(UpdateChannel::Prerelease), + ) + .expect("observed prerelease response should select beta.2"); + + assert_eq!(release.version(), &Version::parse("1.4.0-beta.2").unwrap()); + assert_eq!(release.channel(), UpdateChannel::Prerelease); + assert_eq!(release.tag_name(), "v1.4.0-beta.2"); + assert_eq!(release.assets().len(), 2); + let archive = &release.assets()[0]; + assert_eq!(archive.id(), 539980839); + assert_eq!(archive.size(), 3_051_471); + assert_eq!( + archive.digest(), + Some("sha256:883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32") + ); + assert_eq!( + archive.api_url(), + "https://api.github.com/repos/Staphylococcus/LG_Buddy/releases/assets/539980839" + ); + assert_eq!( + archive.download_url(), + "https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" + ); + let checksums = &release.assets()[1]; + assert_eq!(checksums.id(), 539980872); + assert_eq!(checksums.name(), "sha256sums.txt"); + assert_eq!(checksums.size(), 123); + } + #[test] fn ureq_client_rejects_oversized_release_metadata() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test server"); diff --git a/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json b/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json new file mode 100644 index 0000000..2ad8194 --- /dev/null +++ b/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json @@ -0,0 +1,42 @@ +[ + { + "tag_name": "v1.4.0-beta.2", + "html_url": "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0-beta.2", + "draft": false, + "prerelease": true, + "assets": [ + { + "id": 539980839, + "name": "lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz", + "state": "uploaded", + "size": 3051471, + "digest": "sha256:883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32", + "url": "https://api.github.com/repos/Staphylococcus/LG_Buddy/releases/assets/539980839", + "browser_download_url": "https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz" + }, + { + "id": 539980872, + "name": "sha256sums.txt", + "state": "uploaded", + "size": 123, + "digest": "sha256:581c539163fc8477b464d05c598fdcb19f654c83b124c9986665b1a531093dd1", + "url": "https://api.github.com/repos/Staphylococcus/LG_Buddy/releases/assets/539980872", + "browser_download_url": "https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/sha256sums.txt" + } + ] + }, + { + "tag_name": "v1.4.0-beta.1", + "html_url": "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0-beta.1", + "draft": false, + "prerelease": true, + "assets": [] + }, + { + "tag_name": "v1.3.0", + "html_url": "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.3.0", + "draft": false, + "prerelease": false, + "assets": [] + } +] diff --git a/docs/development.md b/docs/development.md index cf2b332..e7f181c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -84,8 +84,9 @@ Useful checks during development: cargo test -p lg-buddy --lib cargo test -p lg-buddy --test cucumber cargo clippy -p lg-buddy --all-targets --all-features -- -D warnings -bash -n install.sh uninstall.sh configure.sh bin/LG_Buddy_Common scripts/build-release-bundle.sh scripts/test-release-bundle.sh scripts/publish-release-assets.sh +bash -n install.sh uninstall.sh configure.sh bin/LG_Buddy_Common scripts/build-release-bundle.sh scripts/test-release-bundle.sh scripts/test-cross-version-upgrade.sh scripts/test-production-upgrade-canary.sh scripts/publish-release-assets.sh python3 scripts/test_release_promotion.py +python3 scripts/test_record_github_release_responses.py ``` Optional hardware smoke for gamepad activity: @@ -136,6 +137,25 @@ root and exercises upgrade refusal, preservation, Python repair, owned-file replacement, service ordering, installed identity, lifecycle topology, and uninstall cleanup without mutating the host installation. +Run the cross-version smoke with explicit previous and candidate archives: + +```bash +./scripts/test-cross-version-upgrade.sh \ + --previous-archive /path/to/previous.tar.gz \ + --previous-sha256 \ + --previous-tag --previous-version \ + --previous-channel --previous-target \ + --previous-commit \ + --candidate-archive /path/to/candidate.tar.gz \ + --candidate-tag --candidate-version \ + --candidate-channel --candidate-target \ + --candidate-commit +``` + +The production upgrade canary is CI-only because it requires the candidate to +already be published. It records sanitized GitHub responses as a workflow +artifact so the observed production shapes can be replayed offline. + Run the focused manifest contract tests with: ```bash @@ -181,6 +201,9 @@ the branch contract and recovery process, see | `scripts/release_bundle_manifest.py` | Release-bundle identity manifest creator and validator | | `scripts/build-release-bundle.sh` | Release bundle builder | | `scripts/test-release-bundle.sh` | Release bundle smoke test | +| `scripts/test-cross-version-upgrade.sh` | Pinned previous-to-candidate archive upgrade smoke test | +| `scripts/test-production-upgrade-canary.sh` | Post-publication production GitHub upgrade canary | +| `scripts/record_github_release_responses.py` | Sanitized production response recorder for offline mocks | | `scripts/publish-release-assets.sh` | GitHub release publish helper | | `scripts/release_promotion.py` | Promotion version, branch, and tag validator | | `.github/workflows/ci.yml` | CI validation workflow | diff --git a/docs/release-process.md b/docs/release-process.md index 52ee228..413ab69 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -33,7 +33,10 @@ Required promotion checks prove that: - the version advances both existing release-channel heads - the persistent branches have not moved or diverged before merge - the derived `v` tag is absent before merge -- normal CI and the release-bundle smoke test pass +- normal CI and the release-bundle smoke test pass; the bundle smoke includes a + pinned cross-version upgrade from `v1.4.0-beta.2` +- a stable promotion has a successful production upgrade canary on the exact + prerelease commit The tag, binary, archive, and GitHub release all use the Cargo package version. There is no separate version input. @@ -45,10 +48,15 @@ There is no separate version input. 3. Wait for `verify`, `bundle-smoke-test`, and `validate-promotion` to pass. 4. Merge the promotion PR. This is the release authorization. 5. The resulting push starts the serialized release workflow, which builds and - smoke-tests the merged release commit without write credentials. + smoke-tests the merged release commit without write credentials, including + an archive-driven upgrade from the pinned public baseline. 6. The final job obtains a short-lived token from the dedicated repository-only GitHub App, aligns the remaining release streams, publishes the immutable tag and release, and verifies the published checksums. +7. A published prerelease then runs `v1.4.0-beta.2` through the real production + `lg-buddy updates install` path. The canary records sanitized GitHub response + evidence for the deterministic mock. Stable promotion remains blocked until + this exact prerelease commit has a successful canary. Do not push version tags manually. Protected `v*` tags and stream-alignment writes permit bypass only to the dedicated release App. A failed post-merge @@ -65,10 +73,14 @@ The workflow: 3. Generates a versioned identity manifest and packages the release bundle. 4. Validates the manifest and installs the bundle in an isolated smoke-test root. 5. Verifies the built and installed binary's exact version, channel, and commit. -6. Generates and verifies `sha256sums.txt`. -7. Keeps `main`, `prerelease`, and `dev` aligned for the next promotion. -8. Publishes the tag and GitHub release without replacing conflicting assets. -9. Downloads the published assets and verifies their checksums independently. +6. Upgrades a pinned real previous archive to the candidate and verifies + preserved user state plus replaced owned integration files. +7. Generates and verifies `sha256sums.txt`. +8. Keeps `main`, `prerelease`, and `dev` aligned for the next promotion. +9. Publishes the tag and GitHub release without replacing conflicting assets. +10. Downloads the published assets and verifies their checksums independently. +11. For prereleases, exercises production GitHub discovery, acquisition, + confirmation, installation, and final identity from the pinned baseline. `install.sh` is only an installer. It does not build the runtime. @@ -121,6 +133,19 @@ This is intentionally a conservative and evolving refusal boundary. It does not migrate legacy layouts, declare broad host support, or guarantee that a later privileged operation cannot fail. +## Cross-version upgrade baseline + +`v1.4.0-beta.2` is the first public updater-capable release and is the pinned +previous archive for the initial cross-version contract. Its +`x86_64-unknown-linux-musl` archive SHA-256 is +`883e6cb869cbe60988a195acac2e15864d904797edfefbb7d90052eff9a17d32`. +CI verifies that digest and the full release identity before extracting or +executing the baseline. + +Versions before `v1.4.0-beta.2` do not contain `updates install`. They require +one normal manual installation of an updater-capable release before assisted +upgrades become available. Arbitrary historical upgrade support is not implied. + ## Nix source selection Nix configurations may select `main`, `prerelease`, or `dev` as the upstream diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 21acc11..8b185e6 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -345,6 +345,23 @@ The initial and candidate checks are deliberately non-mutating. Orchestration tests for their consumers must separately prove that a refusal prevents release client, confirmation, sudo, and installer effects. +The cross-version bundle smoke test adds the real release boundary that a +same-bundle reinstall cannot cover. It verifies the pinned public +`v1.4.0-beta.2` digest and identity before extraction, installs it into an +isolated root and home, populates non-default settings and native credentials, +and upgrades to an explicit candidate archive. It checks initial and candidate +refusals before network, sudo, or mutation, then verifies preserved user state, +candidate-owned file replacement, service action order, and final identity. + +After a prerelease is public, `production-prerelease-canary` installs the same +baseline and drives its real `updates install` command through a PTY against +GitHub. The canary records a sanitized release list, release-by-tag response, +tag ref, and asset redirects as a workflow artifact. Signed redirect queries +and URL userinfo are never retained. The observed beta.2 release-list fields +also live in `crates/lg-buddy/testdata/github/` and are replayed by the normal +offline Rust suite. A successful canary on the exact prerelease commit is a +stable-promotion prerequisite. + ## Current Practical Gaps The most important remaining gaps are: diff --git a/docs/user-guide.md b/docs/user-guide.md index c44f3db..a075ec6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -247,6 +247,10 @@ bundle, reruns preflight from the candidate, invokes `install.sh --upgrade`, and verifies the installed release identity. It does not accept channel or version arguments, downgrade, migrate legacy installations, or run unattended. +`v1.4.0-beta.2` is the first release that contains `updates install`. Older +installations require one normal manual installation of an updater-capable +release before this assisted path is available. + `--notify` sends a desktop notification through the running user service. When supported by the desktop, the notification includes actions to open the release or disable future automatic notifications. LG Buddy does not repeatedly notify diff --git a/scripts/record_github_release_responses.py b/scripts/record_github_release_responses.py new file mode 100755 index 0000000..daf80c8 --- /dev/null +++ b/scripts/record_github_release_responses.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 + +"""Record sanitized GitHub release responses for deterministic update mocks.""" + +from __future__ import annotations + +import argparse +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +API_VERSION = "2026-03-10" + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + +def sanitize_url(value: str) -> str: + parsed = urllib.parse.urlsplit(value) + host = parsed.hostname or "" + if ":" in host: + host = f"[{host}]" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return urllib.parse.urlunsplit((parsed.scheme, host, parsed.path, "", "")) + + +def sanitize_location(value: str) -> dict[str, Any]: + parsed = urllib.parse.urlsplit(value) + return { + "scheme": parsed.scheme, + "host": parsed.hostname, + "port": parsed.port, + "path": parsed.path, + "query_present": bool(parsed.query), + "fragment_present": bool(parsed.fragment), + } + + +def project_asset(asset: dict[str, Any]) -> dict[str, Any]: + projected = { + field: asset.get(field) + for field in ( + "id", + "name", + "state", + "size", + "digest", + "url", + "browser_download_url", + ) + } + for field in ("url", "browser_download_url"): + if isinstance(projected[field], str): + projected[field] = sanitize_url(projected[field]) + return projected + + +def project_release(release: dict[str, Any]) -> dict[str, Any]: + return { + "tag_name": release.get("tag_name"), + "html_url": sanitize_url(release["html_url"]), + "draft": release.get("draft"), + "prerelease": release.get("prerelease"), + "assets": [project_asset(asset) for asset in release.get("assets", [])], + } + + +def project_git_object(response: dict[str, Any]) -> dict[str, Any]: + object_data = response.get("object", {}) + return { + "object": { + "type": object_data.get("type"), + "sha": object_data.get("sha"), + } + } + + +class GitHubRecorder: + def __init__(self, *, api_root: str, token: str | None) -> None: + self.api_root = api_root.rstrip("/") + self.token = token + self.opener = urllib.request.build_opener(NoRedirect) + + def request(self, url: str, *, accept: str) -> tuple[int, Any, dict[str, str]]: + headers = { + "Accept": accept, + "User-Agent": "lg-buddy-release-response-recorder", + "X-GitHub-Api-Version": API_VERSION, + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + request = urllib.request.Request(url, headers=headers) + try: + response = self.opener.open(request, timeout=30) + except urllib.error.HTTPError as error: + response = error + body = response.read() + response_headers = {key.lower(): value for key, value in response.headers.items()} + parsed: Any = None + if body: + content_type = response_headers.get("content-type", "") + if "json" in content_type: + parsed = json.loads(body) + else: + parsed = {"bytes": len(body)} + return response.status, parsed, response_headers + + def api_json(self, path: str) -> tuple[Any, dict[str, Any]]: + status, body, headers = self.request( + f"{self.api_root}/{path.lstrip('/')}", + accept="application/vnd.github+json", + ) + if status != 200: + raise RuntimeError(f"GitHub API request for {path} returned {status}") + return body, { + "status": status, + "etag": headers.get("etag"), + "content_type": headers.get("content-type"), + } + + def asset_redirect(self, asset: dict[str, Any]) -> dict[str, Any]: + status, _, headers = self.request( + str(asset["url"]), + accept="application/octet-stream", + ) + location = headers.get("location") + return { + "asset": project_asset(asset), + "response": { + "status": status, + "content_type": headers.get("content-type"), + "content_length": headers.get("content-length"), + "location": sanitize_location(location) if location else None, + }, + } + + +def record_responses( + *, recorder: GitHubRecorder, repository: str, tag: str, target: str +) -> dict[str, Any]: + encoded_repository = "/".join( + urllib.parse.quote(part, safe="") for part in repository.split("/") + ) + encoded_tag = urllib.parse.quote(tag, safe="") + releases, releases_response = recorder.api_json( + f"repos/{encoded_repository}/releases?per_page=20" + ) + release, release_response = recorder.api_json( + f"repos/{encoded_repository}/releases/tags/{encoded_tag}" + ) + tag_ref, tag_ref_response = recorder.api_json( + f"repos/{encoded_repository}/git/ref/tags/{encoded_tag}" + ) + + version = tag.removeprefix("v") + expected_assets = { + f"lg-buddy-{version}-{target}.tar.gz", + "sha256sums.txt", + } + selected_assets = [ + asset for asset in release.get("assets", []) if asset.get("name") in expected_assets + ] + if {asset.get("name") for asset in selected_assets} != expected_assets: + raise RuntimeError(f"release {tag} does not expose the expected upgrade assets") + + annotated_tag = None + if tag_ref.get("object", {}).get("type") == "tag": + tag_sha = urllib.parse.quote(str(tag_ref["object"]["sha"]), safe="") + tag_object, tag_object_response = recorder.api_json( + f"repos/{encoded_repository}/git/tags/{tag_sha}" + ) + annotated_tag = { + "response": tag_object_response, + "body": project_git_object(tag_object), + } + + return { + "schema_version": 1, + "repository": repository, + "tag": tag, + "release_list": { + "response": releases_response, + "body": [project_release(item) for item in releases], + }, + "release_by_tag": { + "response": release_response, + "body": project_release(release), + }, + "tag_ref": { + "response": tag_ref_response, + "body": project_git_object(tag_ref), + }, + "annotated_tag": annotated_tag, + "asset_redirects": [ + recorder.asset_redirect(asset) for asset in selected_assets + ], + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--target", default="x86_64-unknown-linux-musl") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--api-root", default="https://api.github.com") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + observation = record_responses( + recorder=GitHubRecorder(api_root=args.api_root, token=token), + repository=args.repository, + tag=args.tag, + target=args.target, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(observation, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Recorded sanitized GitHub responses in {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-cross-version-upgrade.sh b/scripts/test-cross-version-upgrade.sh new file mode 100755 index 0000000..c619179 --- /dev/null +++ b/scripts/test-cross-version-upgrade.sh @@ -0,0 +1,445 @@ +#!/bin/bash + +set -euo pipefail +umask 0022 + +usage() { + cat < --previous-sha256 \ + --previous-tag --previous-version \ + --previous-channel --previous-target \ + --previous-commit \ + --candidate-archive --candidate-tag \ + --candidate-version --candidate-channel \ + --candidate-target --candidate-commit \ + [--work-dir ] +EOF + exit 1 +} + +fail() { + echo "$1" >&2 + exit 1 +} + +assert_file() { + [ -f "$1" ] || fail "Expected file not found: $1" +} + +assert_executable() { + [ -x "$1" ] || fail "Expected executable not found: $1" +} + +validate_archive_paths() { + local archive="$1" + local entry="" + + while IFS= read -r entry; do + case "$entry" in + /*) fail "Archive contains an absolute path: $entry" ;; + esac + if printf '%s\n' "$entry" | grep -Eq '(^|/)\.\.(/|$)'; then + fail "Archive contains a parent-directory traversal path: $entry" + fi + done < <(tar -tzf "$archive") +} + +extract_bundle() { + local archive="$1" + local destination="$2" + local roots=() + + mkdir -p "$destination" + tar --no-same-owner -C "$destination" -xzf "$archive" + mapfile -t roots < <(find "$destination" -mindepth 1 -maxdepth 1 -type d -print) + [ "${#roots[@]}" -eq 1 ] || fail "Release archive must contain exactly one top-level directory: $archive" + printf '%s\n' "${roots[0]}" +} + +tree_digest() { + local install_root="$1" + local user_home="$2" + + tar \ + --sort=name \ + --mtime='@0' \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + -C "$(dirname "$install_root")" \ + -cf - "$(basename "$install_root")" \ + -C "$(dirname "$user_home")" \ + "$(basename "$user_home")" | + sha256sum | awk '{print $1}' +} + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +PREVIOUS_ARCHIVE="" +PREVIOUS_SHA256="" +PREVIOUS_TAG="" +PREVIOUS_VERSION="" +PREVIOUS_CHANNEL="" +PREVIOUS_TARGET="" +PREVIOUS_COMMIT="" +CANDIDATE_ARCHIVE="" +CANDIDATE_TAG="" +CANDIDATE_VERSION="" +CANDIDATE_CHANNEL="" +CANDIDATE_TARGET="" +CANDIDATE_COMMIT="" +WORK_DIR="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --previous-archive) PREVIOUS_ARCHIVE="${2:-}"; shift 2 ;; + --previous-sha256) PREVIOUS_SHA256="${2:-}"; shift 2 ;; + --previous-tag) PREVIOUS_TAG="${2:-}"; shift 2 ;; + --previous-version) PREVIOUS_VERSION="${2:-}"; shift 2 ;; + --previous-channel) PREVIOUS_CHANNEL="${2:-}"; shift 2 ;; + --previous-target) PREVIOUS_TARGET="${2:-}"; shift 2 ;; + --previous-commit) PREVIOUS_COMMIT="${2:-}"; shift 2 ;; + --candidate-archive) CANDIDATE_ARCHIVE="${2:-}"; shift 2 ;; + --candidate-tag) CANDIDATE_TAG="${2:-}"; shift 2 ;; + --candidate-version) CANDIDATE_VERSION="${2:-}"; shift 2 ;; + --candidate-channel) CANDIDATE_CHANNEL="${2:-}"; shift 2 ;; + --candidate-target) CANDIDATE_TARGET="${2:-}"; shift 2 ;; + --candidate-commit) CANDIDATE_COMMIT="${2:-}"; shift 2 ;; + --work-dir) WORK_DIR="${2:-}"; shift 2 ;; + *) usage ;; + esac +done + +for required in \ + PREVIOUS_ARCHIVE PREVIOUS_SHA256 PREVIOUS_TAG PREVIOUS_VERSION \ + PREVIOUS_CHANNEL PREVIOUS_TARGET PREVIOUS_COMMIT CANDIDATE_ARCHIVE \ + CANDIDATE_TAG CANDIDATE_VERSION CANDIDATE_CHANNEL CANDIDATE_TARGET \ + CANDIDATE_COMMIT +do + [ -n "${!required}" ] || usage +done + +assert_file "$PREVIOUS_ARCHIVE" +assert_file "$CANDIDATE_ARCHIVE" +printf '%s\n' "$PREVIOUS_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "Previous archive SHA-256 must be 64 lowercase hexadecimal characters." +command -v strace >/dev/null || fail "strace is required for the no-network refusal control." + +ACTUAL_PREVIOUS_SHA256="$(sha256sum "$PREVIOUS_ARCHIVE" | awk '{print $1}')" +[ "$ACTUAL_PREVIOUS_SHA256" = "$PREVIOUS_SHA256" ] || fail "Previous archive digest is $ACTUAL_PREVIOUS_SHA256, expected $PREVIOUS_SHA256." + +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --archive "$PREVIOUS_ARCHIVE" \ + --expected-release-tag "$PREVIOUS_TAG" \ + --expected-version "$PREVIOUS_VERSION" \ + --expected-channel "$PREVIOUS_CHANNEL" \ + --expected-target "$PREVIOUS_TARGET" \ + --expected-commit "$PREVIOUS_COMMIT" +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --archive "$CANDIDATE_ARCHIVE" \ + --expected-release-tag "$CANDIDATE_TAG" \ + --expected-version "$CANDIDATE_VERSION" \ + --expected-channel "$CANDIDATE_CHANNEL" \ + --expected-target "$CANDIDATE_TARGET" \ + --expected-commit "$CANDIDATE_COMMIT" + +PYTHONPATH="$SCRIPT_DIR" python3 - "$PREVIOUS_VERSION" "$CANDIDATE_VERSION" <<'PY' +import sys +from release_promotion import SemVer + +previous = SemVer.parse(sys.argv[1]) +candidate = SemVer.parse(sys.argv[2]) +if candidate <= previous: + raise SystemExit( + f"candidate version {sys.argv[2]} must advance previous version {sys.argv[1]}" + ) +PY + +validate_archive_paths "$PREVIOUS_ARCHIVE" +validate_archive_paths "$CANDIDATE_ARCHIVE" + +CLEANUP_WORK_DIR=0 +if [ -z "$WORK_DIR" ]; then + WORK_DIR="$(mktemp -d)" + CLEANUP_WORK_DIR=1 +else + mkdir -p "$WORK_DIR" +fi + +cleanup() { + if [ "$CLEANUP_WORK_DIR" -eq 1 ]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT + +PREVIOUS_BUNDLE="$(extract_bundle "$PREVIOUS_ARCHIVE" "$WORK_DIR/previous")" +CANDIDATE_BUNDLE="$(extract_bundle "$CANDIDATE_ARCHIVE" "$WORK_DIR/candidate")" +assert_executable "$PREVIOUS_BUNDLE/install.sh" +assert_executable "$PREVIOUS_BUNDLE/lg-buddy" +assert_executable "$CANDIDATE_BUNDLE/install.sh" +assert_executable "$CANDIDATE_BUNDLE/lg-buddy" +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --manifest "$PREVIOUS_BUNDLE/release-manifest.json" \ + --binary "$PREVIOUS_BUNDLE/lg-buddy" \ + --expected-release-tag "$PREVIOUS_TAG" \ + --expected-version "$PREVIOUS_VERSION" \ + --expected-channel "$PREVIOUS_CHANNEL" \ + --expected-target "$PREVIOUS_TARGET" \ + --expected-commit "$PREVIOUS_COMMIT" +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --manifest "$CANDIDATE_BUNDLE/release-manifest.json" \ + --binary "$CANDIDATE_BUNDLE/lg-buddy" \ + --expected-release-tag "$CANDIDATE_TAG" \ + --expected-version "$CANDIDATE_VERSION" \ + --expected-channel "$CANDIDATE_CHANNEL" \ + --expected-target "$CANDIDATE_TARGET" \ + --expected-commit "$CANDIDATE_COMMIT" + +INSTALL_ROOT="$WORK_DIR/root" +HOME_DIR="$WORK_DIR/home" +XDG_CONFIG_HOME="$HOME_DIR/.config" +mkdir -p "$INSTALL_ROOT" "$HOME_DIR/Desktop" + +export HOME="$HOME_DIR" +export XDG_CONFIG_HOME +export LG_BUDDY_INSTALL_ROOT="$INSTALL_ROOT" +export LG_BUDDY_SUDO_CMD="none" +export LG_BUDDY_NONINTERACTIVE="1" +export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="1" +export LG_BUDDY_SKIP_PIP_INSTALL="1" +export LG_BUDDY_TV_IP="192.168.50.20" +export LG_BUDDY_TV_MAC="02:00:00:00:00:20" +export LG_BUDDY_INPUT="HDMI_3" +export LG_BUDDY_SCREEN_BACKEND="auto" +export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" +export PIP_DISABLE_PIP_VERSION_CHECK="1" +export PIP_NO_PYTHON_VERSION_WARNING="1" + +( + cd "$PREVIOUS_BUNDLE" + ./install.sh +) + +CONFIG_FILE="$XDG_CONFIG_HOME/lg-buddy/config.env" +INSTALLED_BINARY="$INSTALL_ROOT/usr/bin/lg-buddy" +INSTALLED_POINTER="$INSTALL_ROOT/usr/lib/lg-buddy/config-path" +SYSTEM_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy.service" +LIFECYCLE_SERVICE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_lifecycle.service" +TMPFILES_CONFIG="$INSTALL_ROOT/etc/tmpfiles.d/lg_buddy.conf" +SYSTEM_SERVICE_OVERRIDE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy.service.d/config.conf" +LIFECYCLE_SERVICE_OVERRIDE="$INSTALL_ROOT/etc/systemd/system/LG_Buddy_lifecycle.service.d/config.conf" +NM_LIFECYCLE_HOOK="$INSTALL_ROOT/etc/NetworkManager/dispatcher.d/pre-down.d/LG_Buddy_lifecycle" +SYSTEM_DESKTOP_ENTRY="$INSTALL_ROOT/usr/share/applications/LG_Buddy_Brightness.desktop" +USER_DESKTOP_ENTRY="$HOME_DIR/Desktop/LG_Buddy_Brightness.desktop" +USER_SCREEN_SERVICE="$HOME_DIR/.config/systemd/user/LG_Buddy_screen.service" +USER_SCREEN_OVERRIDE="$HOME_DIR/.config/systemd/user/LG_Buddy_screen.service.d/config.conf" +USER_UPDATE_SERVICE="$HOME_DIR/.config/systemd/user/LG_Buddy_update_check.service" +USER_UPDATE_TIMER="$HOME_DIR/.config/systemd/user/LG_Buddy_update_check.timer" +USER_UPDATE_OVERRIDE="$HOME_DIR/.config/systemd/user/LG_Buddy_update_check.service.d/config.conf" +NATIVE_TOKEN_FILE="$XDG_CONFIG_HOME/lg-buddy/tvs/primary/access-token.json" +VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/cross-version-native-marker" + +for installed_path in \ + "$CONFIG_FILE" "$INSTALLED_BINARY" "$INSTALLED_POINTER" "$SYSTEM_SERVICE" \ + "$LIFECYCLE_SERVICE" "$TMPFILES_CONFIG" "$NM_LIFECYCLE_HOOK" \ + "$SYSTEM_DESKTOP_ENTRY" "$USER_DESKTOP_ENTRY" "$USER_SCREEN_SERVICE" \ + "$USER_UPDATE_SERVICE" "$USER_UPDATE_TIMER" +do + assert_file "$installed_path" +done + +export LG_BUDDY_CONFIG="$CONFIG_FILE" +"$INSTALLED_BINARY" settings set screen.backend gnome +"$INSTALLED_BINARY" settings set screen.idle_timeout 900 +"$INSTALLED_BINARY" settings set screen.restore_policy aggressive +"$INSTALLED_BINARY" settings set screen.idle_blank disabled +"$INSTALLED_BINARY" settings set system.sleep_wake_policy disabled +"$INSTALLED_BINARY" settings set tv.ip 192.168.50.21 +"$INSTALLED_BINARY" settings set tv.mac 02:00:00:00:00:21 +"$INSTALLED_BINARY" settings set tv.input HDMI_4 +"$INSTALLED_BINARY" settings set updates.auto_check disabled +"$INSTALLED_BINARY" settings set updates.channel prerelease +sed -i 's/^tvs_primary_platform=bscpylgtv$/tvs_primary_platform=lg_webos/' "$CONFIG_FILE" +grep -q '^tvs_primary_platform=lg_webos$' "$CONFIG_FILE" + +mkdir -p "$(dirname "$NATIVE_TOKEN_FILE")" +printf '%s\n' '{"access_token":"cross-version-native-token"}' >"$NATIVE_TOKEN_FILE" +chmod 600 "$NATIVE_TOKEN_FILE" +touch "$VENV_MARKER" + +CONFIG_SNAPSHOT="$WORK_DIR/config.snapshot" +POINTER_SNAPSHOT="$WORK_DIR/config-pointer.snapshot" +TOKEN_SNAPSHOT="$WORK_DIR/access-token.snapshot" +cp "$CONFIG_FILE" "$CONFIG_SNAPSHOT" +cp "$INSTALLED_POINTER" "$POINTER_SNAPSHOT" +cp "$NATIVE_TOKEN_FILE" "$TOKEN_SNAPSHOT" + +BASELINE_PREFLIGHT_OUTPUT="$WORK_DIR/compatible-baseline-preflight.output" +"$PREVIOUS_BUNDLE/lg-buddy" upgrade-preflight "$PREVIOUS_BUNDLE" >"$BASELINE_PREFLIGHT_OUTPUT" +grep -F -x -q 'upgrade preflight: compatible' "$BASELINE_PREFLIGHT_OUTPUT" + +CANDIDATE_PREFLIGHT_OUTPUT="$WORK_DIR/compatible-candidate-preflight.output" +"$CANDIDATE_BUNDLE/lg-buddy" upgrade-preflight "$CANDIDATE_BUNDLE" >"$CANDIDATE_PREFLIGHT_OUTPUT" +grep -F -x -q 'upgrade preflight: compatible' "$CANDIDATE_PREFLIGHT_OUTPUT" + +INSTALLER_STUB_DIR="$WORK_DIR/installer-stubs" +SUDO_MARKER="$WORK_DIR/sudo-invoked" +SUDO_SPY="$INSTALLER_STUB_DIR/sudo-spy" +REFUSAL_OUTPUT="$WORK_DIR/initial-preflight-refusal.output" +NETWORK_TRACE="$WORK_DIR/initial-preflight-network.trace" +mkdir -p "$INSTALLER_STUB_DIR" +cat >"$SUDO_SPY" <<'EOF' +#!/bin/sh +: >"${LG_BUDDY_SUDO_MARKER:?}" +exit 97 +EOF +chmod 755 "$SUDO_SPY" + +mv "$SYSTEM_SERVICE" "$SYSTEM_SERVICE.incompatible" +REFUSAL_TREE_BEFORE="$(tree_digest "$INSTALL_ROOT" "$HOME_DIR")" +REFUSAL_STATUS=0 +if ( + LG_BUDDY_SUDO_CMD="$SUDO_SPY" \ + LG_BUDDY_SUDO_MARKER="$SUDO_MARKER" \ + strace -f -qq -e trace=network -o "$NETWORK_TRACE" \ + "$INSTALLED_BINARY" updates install >"$REFUSAL_OUTPUT" 2>&1 +); then + fail "Incompatible previous installation unexpectedly passed the initial preflight." +else + REFUSAL_STATUS=$? +fi +[ "$REFUSAL_STATUS" -eq 1 ] || fail "Initial preflight refusal returned status $REFUSAL_STATUS instead of 1." +grep -F -q 'upgrade preflight: refused' "$REFUSAL_OUTPUT" +grep -F -q "$SYSTEM_SERVICE" "$REFUSAL_OUTPUT" +[ ! -e "$SUDO_MARKER" ] || fail "Initial preflight refusal invoked sudo." +if grep -E -q 'AF_INET|AF_INET6' "$NETWORK_TRACE"; then + fail "Initial preflight refusal attempted network access." +fi +REFUSAL_TREE_AFTER="$(tree_digest "$INSTALL_ROOT" "$HOME_DIR")" +[ "$REFUSAL_TREE_BEFORE" = "$REFUSAL_TREE_AFTER" ] || fail "Initial preflight refusal mutated the installation or user state." +mv "$SYSTEM_SERVICE.incompatible" "$SYSTEM_SERVICE" + +CANDIDATE_REFUSAL_OUTPUT="$WORK_DIR/candidate-preflight-refusal.output" +CANDIDATE_NETWORK_TRACE="$WORK_DIR/candidate-preflight-network.trace" +CANDIDATE_SERVICE="$CANDIDATE_BUNDLE/systemd/LG_Buddy.service" +mv "$CANDIDATE_SERVICE" "$CANDIDATE_SERVICE.incompatible" +CANDIDATE_REFUSAL_TREE_BEFORE="$(tree_digest "$INSTALL_ROOT" "$HOME_DIR")" +if ( + cd "$CANDIDATE_BUNDLE" + LG_BUDDY_SUDO_CMD="$SUDO_SPY" \ + LG_BUDDY_SUDO_MARKER="$SUDO_MARKER" \ + strace -f -qq -e trace=network -o "$CANDIDATE_NETWORK_TRACE" \ + ./install.sh --upgrade >"$CANDIDATE_REFUSAL_OUTPUT" 2>&1 +); then + fail "Malformed candidate unexpectedly passed its preflight." +fi +grep -F -q 'upgrade preflight: refused' "$CANDIDATE_REFUSAL_OUTPUT" +grep -F -q "$CANDIDATE_SERVICE" "$CANDIDATE_REFUSAL_OUTPUT" +[ ! -e "$SUDO_MARKER" ] || fail "Candidate preflight refusal invoked sudo." +if grep -E -q 'AF_INET|AF_INET6' "$CANDIDATE_NETWORK_TRACE"; then + fail "Candidate preflight refusal attempted network access." +fi +CANDIDATE_REFUSAL_TREE_AFTER="$(tree_digest "$INSTALL_ROOT" "$HOME_DIR")" +[ "$CANDIDATE_REFUSAL_TREE_BEFORE" = "$CANDIDATE_REFUSAL_TREE_AFTER" ] || fail "Candidate preflight refusal mutated the installation or user state." +mv "$CANDIDATE_SERVICE.incompatible" "$CANDIDATE_SERVICE" + +for stale_target in \ + "$INSTALLED_BINARY" "$SYSTEM_SERVICE" "$LIFECYCLE_SERVICE" \ + "$TMPFILES_CONFIG" "$NM_LIFECYCLE_HOOK" "$SYSTEM_DESKTOP_ENTRY" \ + "$USER_DESKTOP_ENTRY" "$USER_SCREEN_SERVICE" "$USER_UPDATE_SERVICE" \ + "$USER_UPDATE_TIMER" +do + printf 'stale previous-version asset\n' >"$stale_target" +done +chmod 755 "$INSTALLED_BINARY" "$NM_LIFECYCLE_HOOK" + +SERVICE_ACTION_LOG="$WORK_DIR/service-actions.log" +EXPECTED_SERVICE_ACTION_LOG="$WORK_DIR/expected-service-actions.log" +UPGRADE_OUTPUT="$WORK_DIR/cross-version-upgrade.output" +cat >"$INSTALLER_STUB_DIR/systemctl" <<'EOF' +#!/bin/sh +set -eu +printf 'systemctl %s\n' "$*" >>"${LG_BUDDY_SERVICE_ACTION_LOG:?}" +case "$*" in + is-system-running|"--user is-system-running") printf 'running\n' ;; +esac +EOF +cat >"$INSTALLER_STUB_DIR/systemd-tmpfiles" <<'EOF' +#!/bin/sh +set -eu +printf 'tmpfiles %s\n' "$*" >>"${LG_BUDDY_SERVICE_ACTION_LOG:?}" +EOF +chmod 755 "$INSTALLER_STUB_DIR/systemctl" "$INSTALLER_STUB_DIR/systemd-tmpfiles" + +( + export PATH="$INSTALLER_STUB_DIR:$PATH" + export LG_BUDDY_SERVICE_ACTION_LOG="$SERVICE_ACTION_LOG" + export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="0" + cd "$CANDIDATE_BUNDLE" + ./install.sh --upgrade >"$UPGRADE_OUTPUT" 2>&1 +) + +grep -F -q 'Upgrade complete!' "$UPGRADE_OUTPUT" +cat >"$EXPECTED_SERVICE_ACTION_LOG" < $CANDIDATE_VERSION" diff --git a/scripts/test-production-upgrade-canary.sh b/scripts/test-production-upgrade-canary.sh new file mode 100755 index 0000000..97d6055 --- /dev/null +++ b/scripts/test-production-upgrade-canary.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +set -euo pipefail +umask 0022 + +usage() { + cat < --previous-sha256 \ + --previous-tag --previous-version \ + --previous-channel --previous-target \ + --previous-commit \ + --expected-tag --expected-version \ + --expected-channel --expected-target \ + --expected-commit [--work-dir ] +EOF + exit 1 +} + +fail() { + echo "$1" >&2 + exit 1 +} + +validate_archive_paths() { + local archive="$1" + local entry="" + + while IFS= read -r entry; do + case "$entry" in + /*) fail "Archive contains an absolute path: $entry" ;; + esac + if printf '%s\n' "$entry" | grep -Eq '(^|/)\.\.(/|$)'; then + fail "Archive contains a parent-directory traversal path: $entry" + fi + done < <(tar -tzf "$archive") +} + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +PREVIOUS_ARCHIVE="" +PREVIOUS_SHA256="" +PREVIOUS_TAG="" +PREVIOUS_VERSION="" +PREVIOUS_CHANNEL="" +PREVIOUS_TARGET="" +PREVIOUS_COMMIT="" +EXPECTED_TAG="" +EXPECTED_VERSION="" +EXPECTED_CHANNEL="" +EXPECTED_TARGET="" +EXPECTED_COMMIT="" +WORK_DIR="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --previous-archive) PREVIOUS_ARCHIVE="${2:-}"; shift 2 ;; + --previous-sha256) PREVIOUS_SHA256="${2:-}"; shift 2 ;; + --previous-tag) PREVIOUS_TAG="${2:-}"; shift 2 ;; + --previous-version) PREVIOUS_VERSION="${2:-}"; shift 2 ;; + --previous-channel) PREVIOUS_CHANNEL="${2:-}"; shift 2 ;; + --previous-target) PREVIOUS_TARGET="${2:-}"; shift 2 ;; + --previous-commit) PREVIOUS_COMMIT="${2:-}"; shift 2 ;; + --expected-tag) EXPECTED_TAG="${2:-}"; shift 2 ;; + --expected-version) EXPECTED_VERSION="${2:-}"; shift 2 ;; + --expected-channel) EXPECTED_CHANNEL="${2:-}"; shift 2 ;; + --expected-target) EXPECTED_TARGET="${2:-}"; shift 2 ;; + --expected-commit) EXPECTED_COMMIT="${2:-}"; shift 2 ;; + --work-dir) WORK_DIR="${2:-}"; shift 2 ;; + *) usage ;; + esac +done + +for required in \ + PREVIOUS_ARCHIVE PREVIOUS_SHA256 PREVIOUS_TAG PREVIOUS_VERSION \ + PREVIOUS_CHANNEL PREVIOUS_TARGET PREVIOUS_COMMIT EXPECTED_TAG \ + EXPECTED_VERSION EXPECTED_CHANNEL EXPECTED_TARGET EXPECTED_COMMIT +do + [ -n "${!required}" ] || usage +done + +[ -f "$PREVIOUS_ARCHIVE" ] || fail "Previous archive not found: $PREVIOUS_ARCHIVE" +[ "$EXPECTED_TARGET" = "$PREVIOUS_TARGET" ] || fail "Production canary target $EXPECTED_TARGET does not match baseline target $PREVIOUS_TARGET." +printf '%s\n' "$PREVIOUS_SHA256" | grep -Eq '^[0-9a-f]{64}$' || fail "Previous archive SHA-256 must be 64 lowercase hexadecimal characters." +command -v script >/dev/null || fail "The util-linux script command is required for the confirmation PTY." + +ACTUAL_PREVIOUS_SHA256="$(sha256sum "$PREVIOUS_ARCHIVE" | awk '{print $1}')" +[ "$ACTUAL_PREVIOUS_SHA256" = "$PREVIOUS_SHA256" ] || fail "Previous archive digest is $ACTUAL_PREVIOUS_SHA256, expected $PREVIOUS_SHA256." +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --archive "$PREVIOUS_ARCHIVE" \ + --expected-release-tag "$PREVIOUS_TAG" \ + --expected-version "$PREVIOUS_VERSION" \ + --expected-channel "$PREVIOUS_CHANNEL" \ + --expected-target "$PREVIOUS_TARGET" \ + --expected-commit "$PREVIOUS_COMMIT" +PYTHONPATH="$SCRIPT_DIR" python3 - "$PREVIOUS_VERSION" "$EXPECTED_VERSION" <<'PY' +import sys +from release_promotion import SemVer + +if SemVer.parse(sys.argv[2]) <= SemVer.parse(sys.argv[1]): + raise SystemExit( + f"expected candidate {sys.argv[2]} must advance baseline {sys.argv[1]}" + ) +PY +validate_archive_paths "$PREVIOUS_ARCHIVE" + +CLEANUP_WORK_DIR=0 +if [ -z "$WORK_DIR" ]; then + WORK_DIR="$(mktemp -d)" + CLEANUP_WORK_DIR=1 +else + mkdir -p "$WORK_DIR" +fi + +cleanup() { + if [ "$CLEANUP_WORK_DIR" -eq 1 ]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT + +EXTRACT_DIR="$WORK_DIR/previous" +mkdir -p "$EXTRACT_DIR" +tar --no-same-owner -C "$EXTRACT_DIR" -xzf "$PREVIOUS_ARCHIVE" +mapfile -t PREVIOUS_ROOTS < <(find "$EXTRACT_DIR" -mindepth 1 -maxdepth 1 -type d -print) +[ "${#PREVIOUS_ROOTS[@]}" -eq 1 ] || fail "Previous archive must contain exactly one top-level directory." +PREVIOUS_BUNDLE="${PREVIOUS_ROOTS[0]}" +[ -x "$PREVIOUS_BUNDLE/install.sh" ] || fail "Previous installer is not executable." +[ -x "$PREVIOUS_BUNDLE/lg-buddy" ] || fail "Previous binary is not executable." +python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ + --manifest "$PREVIOUS_BUNDLE/release-manifest.json" \ + --binary "$PREVIOUS_BUNDLE/lg-buddy" \ + --expected-release-tag "$PREVIOUS_TAG" \ + --expected-version "$PREVIOUS_VERSION" \ + --expected-channel "$PREVIOUS_CHANNEL" \ + --expected-target "$PREVIOUS_TARGET" \ + --expected-commit "$PREVIOUS_COMMIT" + +INSTALL_ROOT="$WORK_DIR/root" +HOME_DIR="$WORK_DIR/home" +XDG_CONFIG_HOME="$HOME_DIR/.config" +XDG_CACHE_HOME="$HOME_DIR/.cache" +mkdir -p "$INSTALL_ROOT" "$HOME_DIR/Desktop" "$XDG_CACHE_HOME" + +export HOME="$HOME_DIR" +export XDG_CONFIG_HOME +export XDG_CACHE_HOME +export LG_BUDDY_INSTALL_ROOT="$INSTALL_ROOT" +export LG_BUDDY_SUDO_CMD="none" +export LG_BUDDY_NONINTERACTIVE="1" +export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="1" +export LG_BUDDY_SKIP_PIP_INSTALL="1" +export LG_BUDDY_TV_IP="192.168.60.20" +export LG_BUDDY_TV_MAC="02:00:00:00:00:60" +export LG_BUDDY_INPUT="HDMI_3" +export LG_BUDDY_SCREEN_BACKEND="auto" +export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" +export PIP_DISABLE_PIP_VERSION_CHECK="1" +export PIP_NO_PYTHON_VERSION_WARNING="1" + +( + cd "$PREVIOUS_BUNDLE" + ./install.sh +) + +CONFIG_FILE="$XDG_CONFIG_HOME/lg-buddy/config.env" +INSTALLED_BINARY="$INSTALL_ROOT/usr/bin/lg-buddy" +INSTALLED_POINTER="$INSTALL_ROOT/usr/lib/lg-buddy/config-path" +NATIVE_TOKEN_FILE="$XDG_CONFIG_HOME/lg-buddy/tvs/primary/access-token.json" +VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/production-canary-native-marker" +[ -x "$INSTALLED_BINARY" ] || fail "Baseline binary was not installed." +[ -f "$CONFIG_FILE" ] || fail "Baseline config was not installed." +[ -f "$INSTALLED_POINTER" ] || fail "Baseline config pointer was not installed." + +export LG_BUDDY_CONFIG="$CONFIG_FILE" +"$INSTALLED_BINARY" settings set screen.backend gnome +"$INSTALLED_BINARY" settings set screen.idle_blank disabled +"$INSTALLED_BINARY" settings set updates.auto_check disabled +"$INSTALLED_BINARY" settings set updates.channel prerelease +sed -i 's/^tvs_primary_platform=bscpylgtv$/tvs_primary_platform=lg_webos/' "$CONFIG_FILE" +grep -q '^tvs_primary_platform=lg_webos$' "$CONFIG_FILE" +mkdir -p "$(dirname "$NATIVE_TOKEN_FILE")" +printf '%s\n' '{"access_token":"production-canary-native-token"}' >"$NATIVE_TOKEN_FILE" +chmod 600 "$NATIVE_TOKEN_FILE" +touch "$VENV_MARKER" + +CONFIG_SNAPSHOT="$WORK_DIR/config.snapshot" +POINTER_SNAPSHOT="$WORK_DIR/config-pointer.snapshot" +TOKEN_SNAPSHOT="$WORK_DIR/access-token.snapshot" +cp "$CONFIG_FILE" "$CONFIG_SNAPSHOT" +cp "$INSTALLED_POINTER" "$POINTER_SNAPSHOT" +cp "$NATIVE_TOKEN_FILE" "$TOKEN_SNAPSHOT" + +CANARY_OUTPUT="$WORK_DIR/production-upgrade-canary.output" +CANARY_COMMAND="$(printf '%q' "$INSTALLED_BINARY") updates install" +printf 'yes\n' | script -qefc "$CANARY_COMMAND" /dev/null >"$CANARY_OUTPUT" + +grep -F -q "Current: $PREVIOUS_VERSION ($PREVIOUS_CHANNEL, commit $PREVIOUS_COMMIT)" "$CANARY_OUTPUT" +grep -F -q "Target: $EXPECTED_VERSION ($EXPECTED_CHANNEL, commit $EXPECTED_COMMIT)" "$CANARY_OUTPUT" +grep -F -q "Release: https://github.com/Staphylococcus/LG_Buddy/releases/tag/$EXPECTED_TAG" "$CANARY_OUTPUT" +grep -F -q "Installed: $EXPECTED_VERSION ($EXPECTED_CHANNEL, commit $EXPECTED_COMMIT)" "$CANARY_OUTPUT" + +EXPECTED_VERSION_OUTPUT="$(printf 'lg-buddy %s\nversion: %s\nchannel: %s\ncommit: %s' \ + "$EXPECTED_VERSION" "$EXPECTED_VERSION" "$EXPECTED_CHANNEL" "$EXPECTED_COMMIT")" +ACTUAL_VERSION_OUTPUT="$("$INSTALLED_BINARY" --version)" +[ "$ACTUAL_VERSION_OUTPUT" = "$EXPECTED_VERSION_OUTPUT" ] || fail "Installed identity does not match the published canary target." +cmp -s "$CONFIG_SNAPSHOT" "$CONFIG_FILE" || fail "Production upgrade changed the user configuration." +cmp -s "$POINTER_SNAPSHOT" "$INSTALLED_POINTER" || fail "Production upgrade changed the config pointer." +cmp -s "$TOKEN_SNAPSHOT" "$NATIVE_TOKEN_FILE" || fail "Production upgrade changed the native credential." +[ -e "$VENV_MARKER" ] || fail "Production native upgrade recreated the Python environment." +"$INSTALLED_BINARY" settings get updates.channel | grep -q '^prerelease$' + +echo "Production GitHub upgrade canary passed: $PREVIOUS_VERSION -> $EXPECTED_VERSION" diff --git a/scripts/test_record_github_release_responses.py b/scripts/test_record_github_release_responses.py new file mode 100755 index 0000000..c4b7a34 --- /dev/null +++ b/scripts/test_record_github_release_responses.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import unittest + +from record_github_release_responses import ( + API_VERSION, + GitHubRecorder, + project_git_object, + project_release, + sanitize_location, +) + + +class StubResponse: + status = 200 + headers = {"Content-Type": "application/json"} + + @staticmethod + def read() -> bytes: + return b"{}" + + +class RecordingOpener: + def __init__(self) -> None: + self.request = None + + def open(self, request, *, timeout): # type: ignore[no-untyped-def] + self.request = request + self.timeout = timeout + return StubResponse() + + +class ResponseRecordingTests(unittest.TestCase): + def test_request_uses_the_production_github_api_version(self) -> None: + recorder = GitHubRecorder(api_root="https://api.github.test", token=None) + opener = RecordingOpener() + recorder.opener = opener + + recorder.request( + "https://api.github.test/releases", + accept="application/vnd.github+json", + ) + + self.assertEqual(API_VERSION, "2026-03-10") + self.assertEqual( + opener.request.get_header("X-github-api-version"), + API_VERSION, + ) + + def test_location_record_drops_userinfo_query_and_fragment(self) -> None: + recorded = sanitize_location( + "https://user:secret@example.test:8443/releases/asset?token=secret#fragment" + ) + + self.assertEqual( + recorded, + { + "scheme": "https", + "host": "example.test", + "port": 8443, + "path": "/releases/asset", + "query_present": True, + "fragment_present": True, + }, + ) + self.assertNotIn("secret", repr(recorded)) + + def test_release_projection_keeps_only_update_client_fields(self) -> None: + projected = project_release( + { + "tag_name": "v1.4.0-beta.2", + "html_url": "https://user:secret@github.test/releases/v1.4.0-beta.2?token=secret", + "draft": False, + "prerelease": True, + "author": {"login": "ignored"}, + "assets": [ + { + "id": 42, + "name": "sha256sums.txt", + "state": "uploaded", + "size": 123, + "digest": "sha256:abc", + "url": "https://user:secret@api.github.test/assets/42?token=secret", + "browser_download_url": "https://github.test/assets/42#secret", + "uploader": {"login": "ignored"}, + } + ], + } + ) + + self.assertNotIn("author", projected) + self.assertNotIn("uploader", projected["assets"][0]) + self.assertEqual(projected["assets"][0]["id"], 42) + self.assertNotIn("secret", repr(projected)) + + def test_git_object_projection_keeps_only_tag_peeling_fields(self) -> None: + projected = project_git_object( + { + "ref": "refs/tags/v1.4.0-beta.2", + "node_id": "ignored", + "object": { + "type": "commit", + "sha": "77c8f46c66b9e385f3d90c15dee33d775639bbeb", + "url": "https://api.github.test/commits/secret", + }, + } + ) + + self.assertEqual( + projected, + { + "object": { + "type": "commit", + "sha": "77c8f46c66b9e385f3d90c15dee33d775639bbeb", + } + }, + ) + + +if __name__ == "__main__": + unittest.main() From 0c9f4f7d5164c86a782f06ecdd8bb3d1cd659c21 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 01:25:52 +0300 Subject: [PATCH 14/22] Harden immutable release publication --- .github/workflows/ci.yml | 3 + docs/development.md | 6 + docs/release-process.md | 21 +- scripts/publish-release-assets.sh | 137 +++++++-- scripts/test_publish_release_assets.py | 367 +++++++++++++++++++++++++ scripts/test_release_promotion.py | 24 +- 6 files changed, 536 insertions(+), 22 deletions(-) create mode 100644 scripts/test_publish_release_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9892f64..6d44397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Validate release promotion contract run: python3 scripts/test_release_promotion.py + - name: Validate release publication contract + run: python3 scripts/test_publish_release_assets.py + - name: Validate release bundle manifest contract run: python3 scripts/test_release_bundle_manifest.py diff --git a/docs/development.md b/docs/development.md index e7f181c..376dd9c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -162,6 +162,12 @@ Run the focused manifest contract tests with: python3 scripts/test_release_bundle_manifest.py ``` +Run the mock-backed draft publication and retry contract tests with: + +```bash +python3 scripts/test_publish_release_assets.py +``` + Dry-run the GitHub release publish step with: ```bash diff --git a/docs/release-process.md b/docs/release-process.md index 413ab69..7b6752e 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -51,8 +51,9 @@ There is no separate version input. smoke-tests the merged release commit without write credentials, including an archive-driven upgrade from the pinned public baseline. 6. The final job obtains a short-lived token from the dedicated repository-only - GitHub App, aligns the remaining release streams, publishes the immutable tag - and release, and verifies the published checksums. + GitHub App, aligns the remaining release streams, creates or resumes a draft, + verifies its complete asset set, and publishes it. Repository release + immutability then locks the tag and assets. 7. A published prerelease then runs `v1.4.0-beta.2` through the real production `lg-buddy updates install` path. The canary records sanitized GitHub response evidence for the deterministic mock. Stable promotion remains blocked until @@ -60,8 +61,17 @@ There is no separate version input. Do not push version tags manually. Protected `v*` tags and stream-alignment writes permit bypass only to the dedicated release App. A failed post-merge -release run can be rerun safely, but an existing tag or asset is accepted only -when it is byte-for-byte consistent with the merged release commit. +release run can be rerun safely: an incomplete draft remains private and is +resumed only when its tag, classification, and existing assets match the merged +release commit. A published release is accepted only when its expected asset set +is complete and byte-for-byte identical. + +Repository release immutability must remain enabled. GitHub applies it only when +a draft is published, so the publisher uploads and verifies every expected asset +before making the release visible. Each stored asset must report the expected +name, uploaded state, byte size, and server-computed SHA-256 digest. Unexpected +draft assets block publication; published releases are verification-only and are +never repaired in place. ## What the release workflow validates @@ -77,7 +87,8 @@ The workflow: preserved user state plus replaced owned integration files. 7. Generates and verifies `sha256sums.txt`. 8. Keeps `main`, `prerelease`, and `dev` aligned for the next promotion. -9. Publishes the tag and GitHub release without replacing conflicting assets. +9. Stages the exact release assets privately, then publishes them together under + the repository's immutable-release policy. 10. Downloads the published assets and verifies their checksums independently. 11. For prereleases, exercises production GitHub discovery, acquisition, confirmation, installation, and final identity from the pinned baseline. diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 3cfedea..e4fed8f 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -2,7 +2,7 @@ set -euo pipefail -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" usage() { echo "Usage: $0 [--dist-dir ] [--tag ] [--commit ]" @@ -121,33 +121,138 @@ if [ "${#RELEASE_FLAGS[@]}" -gt 0 ]; then EXPECTED_PRERELEASE="true" fi +RELEASE_IS_DRAFT="true" if gh release view "$TAG" >/dev/null 2>&1; then RELEASE_STATE="$(gh release view "$TAG" --json isDraft,isPrerelease)" - [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isDraft)" = "false" ] || { - echo "Existing release $TAG is still a draft." - exit 1 - } + RELEASE_IS_DRAFT="$(printf '%s' "$RELEASE_STATE" | jq -r .isDraft)" + case "$RELEASE_IS_DRAFT" in + true|false) ;; + *) + echo "Existing release $TAG returned an invalid draft state." + exit 1 + ;; + esac [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isPrerelease)" = "$EXPECTED_PRERELEASE" ] || { echo "Existing release $TAG has the wrong prerelease classification." exit 1 } else - gh release create "$TAG" --verify-tag --title "$TITLE" --notes "$NOTES" "${RELEASE_FLAGS[@]}" + gh release create "$TAG" --draft --verify-tag --title "$TITLE" --notes "$NOTES" "${RELEASE_FLAGS[@]}" fi +EXPECTED_ASSETS=("${ARCHIVES[@]}" "$CHECKSUM_FILE") +EXPECTED_ASSET_NAMES=() + +asset_name_is_expected() { + local candidate="$1" + local expected="" + + for expected in "${EXPECTED_ASSET_NAMES[@]}"; do + [ "$candidate" != "$expected" ] || return 0 + done + return 1 +} + +for asset in "${EXPECTED_ASSETS[@]}"; do + asset_name="$(basename "$asset")" + ! asset_name_is_expected "$asset_name" || { + echo "Release assets must have unique file names: $asset_name" + exit 1 + } + EXPECTED_ASSET_NAMES+=("$asset_name") +done + +RELEASE_ASSET_NAMES="$(gh release view "$TAG" --json assets --jq '.assets[].name')" +while IFS= read -r asset_name; do + [ -z "$asset_name" ] || asset_name_is_expected "$asset_name" || { + echo "Release $TAG contains unexpected asset: $asset_name" + exit 1 + } +done <<< "$RELEASE_ASSET_NAMES" -for asset in "${ARCHIVES[@]}" "$CHECKSUM_FILE"; do +for asset in "${EXPECTED_ASSETS[@]}"; do asset_name="$(basename "$asset")" - if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -F -x -q "$asset_name"; then - compare_dir="$(mktemp -d)" - gh release download "$TAG" --pattern "$asset_name" --dir "$compare_dir" - if ! cmp -s "$asset" "$compare_dir/$asset_name"; then - echo "Existing release asset differs from the candidate: $asset_name" - rm -rf "$compare_dir" + if ! grep -F -x -q -- "$asset_name" <<< "$RELEASE_ASSET_NAMES"; then + [ "$RELEASE_IS_DRAFT" = "true" ] || { + echo "Published release $TAG is missing required asset: $asset_name" exit 1 - fi - rm -rf "$compare_dir" - else + } gh release upload "$TAG" "$asset" fi done + +RELEASE_ASSETS="$(gh release view "$TAG" --json assets)" +REMOTE_ASSET_COUNT="$(printf '%s' "$RELEASE_ASSETS" | jq '.assets | length')" +[ "$REMOTE_ASSET_COUNT" -eq "${#EXPECTED_ASSET_NAMES[@]}" ] || { + echo "Release $TAG contains $REMOTE_ASSET_COUNT assets, expected ${#EXPECTED_ASSET_NAMES[@]}." + exit 1 +} + +for asset in "${EXPECTED_ASSETS[@]}"; do + asset_name="$(basename "$asset")" + MATCHING_ASSET_COUNT="$( + printf '%s' "$RELEASE_ASSETS" | + jq --arg name "$asset_name" '[.assets[] | select(.name == $name)] | length' + )" + [ "$MATCHING_ASSET_COUNT" -eq 1 ] || { + echo "Release $TAG must contain exactly one asset named $asset_name." + exit 1 + } + + REMOTE_ASSET_STATE="$( + printf '%s' "$RELEASE_ASSETS" | + jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .state' + )" + [ "$REMOTE_ASSET_STATE" = "uploaded" ] || { + echo "Release asset $asset_name is not fully uploaded." + exit 1 + } + + LOCAL_ASSET_SIZE="$(wc -c < "$asset" | tr -d '[:space:]')" + REMOTE_ASSET_SIZE="$( + printf '%s' "$RELEASE_ASSETS" | + jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .size' + )" + [ "$REMOTE_ASSET_SIZE" = "$LOCAL_ASSET_SIZE" ] || { + echo "Release asset $asset_name has size $REMOTE_ASSET_SIZE, expected $LOCAL_ASSET_SIZE." + exit 1 + } + + LOCAL_ASSET_DIGEST="sha256:$(sha256sum "$asset" | cut -d ' ' -f 1)" + REMOTE_ASSET_DIGEST="$( + printf '%s' "$RELEASE_ASSETS" | + jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .digest' + )" + [ "$REMOTE_ASSET_DIGEST" = "$LOCAL_ASSET_DIGEST" ] || { + echo "Release asset $asset_name has digest $REMOTE_ASSET_DIGEST, expected $LOCAL_ASSET_DIGEST." + exit 1 + } +done + +if [ "$RELEASE_IS_DRAFT" = "true" ]; then + RELEASE_STATE="$(gh release view "$TAG" --json isDraft,isPrerelease)" + [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isDraft)" = "true" ] || { + echo "Release $TAG was published before asset verification completed." + exit 1 + } + [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isPrerelease)" = "$EXPECTED_PRERELEASE" ] || { + echo "Release $TAG changed prerelease classification before publication." + exit 1 + } + + gh release edit "$TAG" \ + --draft=false \ + --prerelease="$EXPECTED_PRERELEASE" \ + --title "$TITLE" \ + --notes "$NOTES" + + RELEASE_STATE="$(gh release view "$TAG" --json isDraft,isPrerelease)" + [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isDraft)" = "false" ] || { + echo "Release $TAG remained a draft after publication." + exit 1 + } + [ "$(printf '%s' "$RELEASE_STATE" | jq -r .isPrerelease)" = "$EXPECTED_PRERELEASE" ] || { + echo "Published release $TAG has the wrong prerelease classification." + exit 1 + } +fi diff --git a/scripts/test_publish_release_assets.py b/scripts/test_publish_release_assets.py new file mode 100644 index 0000000..fa35629 --- /dev/null +++ b/scripts/test_publish_release_assets.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import hashlib +import io +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + +from release_bundle_manifest import MANIFEST_NAME, ReleaseIdentity, render_manifest + + +VERSION = "9.8.7-beta.1" +TAG = f"v{VERSION}" +TARGET = "x86_64-unknown-linux-musl" + + +FAKE_GH = r"""#!/usr/bin/env python3 +import hashlib +import json +import os +import shutil +import sys +from pathlib import Path + +state_path = Path(os.environ["FAKE_GH_STATE"]) +asset_dir = Path(os.environ["FAKE_GH_ASSETS"]) +log_path = Path(os.environ["FAKE_GH_LOG"]) +args = sys.argv[1:] + +with log_path.open("a", encoding="utf-8") as log: + log.write(json.dumps(args) + "\n") + +state = json.loads(state_path.read_text(encoding="utf-8")) + +def save(): + state_path.write_text(json.dumps(state), encoding="utf-8") + +if len(args) < 3 or args[0] != "release": + sys.exit("unsupported fake gh invocation") + +command = args[1] +tag = args[2] +rest = args[3:] + +if command == "view": + if not state["exists"]: + sys.exit(1) + if "--json" not in rest: + sys.exit(0) + fields = rest[rest.index("--json") + 1] + if fields == "assets": + if "--jq" in rest: + print("\n".join(sorted(state["assets"]))) + else: + assets = [] + for name in sorted(state["assets"]): + content = (asset_dir / name).read_bytes() + assets.append({ + "name": name, + "state": "uploaded", + "size": len(content), + "digest": f"sha256:{hashlib.sha256(content).hexdigest()}", + }) + print(json.dumps({"assets": assets})) + else: + print(json.dumps({ + "isDraft": state["draft"], + "isPrerelease": state["prerelease"], + })) + sys.exit(0) + +if command == "create": + if state["exists"]: + sys.exit("release already exists") + state.update({ + "exists": True, + "draft": "--draft" in rest, + "prerelease": "--prerelease" in rest, + "assets": [], + }) + save() + sys.exit(0) + +if not state["exists"]: + sys.exit("release does not exist") + +if command == "upload": + source = Path(rest[0]) + if os.environ.get("FAKE_GH_FAIL_UPLOAD") == source.name: + sys.exit("injected upload failure") + asset_dir.mkdir(parents=True, exist_ok=True) + if os.environ.get("FAKE_GH_CORRUPT_UPLOAD") == source.name: + content = bytearray(source.read_bytes()) + content[0] ^= 1 + (asset_dir / source.name).write_bytes(content) + else: + shutil.copyfile(source, asset_dir / source.name) + if source.name not in state["assets"]: + state["assets"].append(source.name) + save() + sys.exit(0) + +if command == "download": + pattern = rest[rest.index("--pattern") + 1] + destination = Path(rest[rest.index("--dir") + 1]) + destination.mkdir(parents=True, exist_ok=True) + shutil.copyfile(asset_dir / pattern, destination / pattern) + sys.exit(0) + +if command == "edit": + for argument in rest: + if argument.startswith("--draft="): + state["draft"] = argument.split("=", 1)[1] == "true" + elif argument.startswith("--prerelease="): + state["prerelease"] = argument.split("=", 1)[1] == "true" + save() + sys.exit(0) + +sys.exit("unsupported fake gh release command") +""" + + +class PublishReleaseAssetsTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.root = Path(self.temporary_directory.name) + self.repository = self.root / "repository" + self.repository.mkdir() + self.dist = self.repository / "dist" + self.dist.mkdir() + self.fake_bin = self.root / "bin" + self.fake_bin.mkdir() + self.remote_assets = self.root / "remote-assets" + self.state_path = self.root / "state.json" + self.log_path = self.root / "gh-calls.jsonl" + + self.git("init", "--quiet") + self.git("config", "user.name", "Release test") + self.git("config", "user.email", "release-test@example.invalid") + (self.repository / "marker").write_text("release\n", encoding="utf-8") + self.git("add", "marker") + self.git("commit", "--quiet", "-m", "release") + self.commit = self.git("rev-parse", "HEAD").stdout.strip() + self.git("tag", TAG) + + identity = ReleaseIdentity( + release_tag=TAG, + version=VERSION, + channel="prerelease", + target=TARGET, + commit=self.commit, + ) + bundle_name = f"lg-buddy-{VERSION}-{TARGET}" + self.archive = self.dist / f"{bundle_name}.tar.gz" + manifest = render_manifest(identity) + with tarfile.open(self.archive, mode="w:gz") as bundle: + info = tarfile.TarInfo(f"{bundle_name}/{MANIFEST_NAME}") + info.size = len(manifest) + bundle.addfile(info, io.BytesIO(manifest)) + + digest = hashlib.sha256(self.archive.read_bytes()).hexdigest() + self.checksums = self.dist / "sha256sums.txt" + self.checksums.write_text( + f"{digest} {self.archive.name}\n", encoding="utf-8" + ) + + fake_gh = self.fake_bin / "gh" + fake_gh.write_text(FAKE_GH, encoding="utf-8") + fake_gh.chmod(0o755) + self.write_state() + + self.environment = os.environ.copy() + self.environment.update( + { + "PATH": f"{self.fake_bin}:{self.environment['PATH']}", + "FAKE_GH_STATE": str(self.state_path), + "FAKE_GH_ASSETS": str(self.remote_assets), + "FAKE_GH_LOG": str(self.log_path), + } + ) + self.publisher = Path(__file__).with_name("publish-release-assets.sh") + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def git(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=self.repository, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + + def write_state( + self, + *, + exists: bool = False, + draft: bool = True, + prerelease: bool = True, + assets: dict[str, bytes] | None = None, + ) -> None: + asset_values = assets or {} + self.remote_assets.mkdir(parents=True, exist_ok=True) + for name, content in asset_values.items(): + (self.remote_assets / name).write_bytes(content) + self.state_path.write_text( + json.dumps( + { + "exists": exists, + "draft": draft, + "prerelease": prerelease, + "assets": list(asset_values), + } + ), + encoding="utf-8", + ) + + def run_publisher( + self, + *, + fail_upload: str | None = None, + corrupt_upload: str | None = None, + ) -> subprocess.CompletedProcess[str]: + environment = self.environment.copy() + if fail_upload is not None: + environment["FAKE_GH_FAIL_UPLOAD"] = fail_upload + if corrupt_upload is not None: + environment["FAKE_GH_CORRUPT_UPLOAD"] = corrupt_upload + return subprocess.run( + [ + "bash", + self.publisher, + "--dist-dir", + self.dist, + "--tag", + TAG, + "--commit", + self.commit, + ], + cwd=self.repository, + env=environment, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + def state(self) -> dict[str, object]: + return json.loads(self.state_path.read_text(encoding="utf-8")) + + def calls(self) -> list[list[str]]: + if not self.log_path.exists(): + return [] + return [ + json.loads(line) + for line in self.log_path.read_text(encoding="utf-8").splitlines() + ] + + def mutations(self, command: str) -> list[list[str]]: + return [call for call in self.calls() if call[:2] == ["release", command]] + + def complete_assets(self) -> dict[str, bytes]: + return { + self.archive.name: self.archive.read_bytes(), + self.checksums.name: self.checksums.read_bytes(), + } + + def test_new_release_is_published_only_after_assets_are_uploaded(self) -> None: + result = self.run_publisher() + + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(set(self.state()["assets"]), set(self.complete_assets())) + self.assertFalse(self.state()["draft"]) + create = self.mutations("create") + uploads = self.mutations("upload") + edit = self.mutations("edit") + self.assertEqual(len(create), 1) + self.assertIn("--draft", create[0]) + self.assertEqual(len(uploads), 2) + self.assertEqual(len(edit), 1) + self.assertIn("--draft=false", edit[0]) + self.assertLess(self.calls().index(create[0]), self.calls().index(uploads[0])) + self.assertLess(self.calls().index(uploads[-1]), self.calls().index(edit[0])) + + def test_retry_resumes_a_partially_uploaded_draft(self) -> None: + first = self.run_publisher(fail_upload=self.checksums.name) + self.assertNotEqual(first.returncode, 0) + self.assertTrue(self.state()["draft"]) + + second = self.run_publisher() + + self.assertEqual(second.returncode, 0, second.stdout) + self.assertFalse(self.state()["draft"]) + self.assertEqual(len(self.mutations("create")), 1) + archive_uploads = [ + call for call in self.mutations("upload") if call[-1] == str(self.archive) + ] + self.assertEqual(len(archive_uploads), 1) + self.assertEqual(len(self.mutations("edit")), 1) + + def test_unexpected_draft_asset_blocks_publication_without_mutation(self) -> None: + self.write_state(exists=True, assets={"unexpected.txt": b"unexpected"}) + + result = self.run_publisher() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("contains unexpected asset", result.stdout) + self.assertTrue(self.state()["draft"]) + self.assertEqual(self.mutations("upload"), []) + self.assertEqual(self.mutations("edit"), []) + + def test_published_release_missing_asset_is_not_modified(self) -> None: + self.write_state( + exists=True, + draft=False, + assets={self.archive.name: self.archive.read_bytes()}, + ) + + result = self.run_publisher() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("is missing required asset", result.stdout) + self.assertEqual(self.mutations("upload"), []) + self.assertEqual(self.mutations("edit"), []) + + def test_mismatched_draft_asset_blocks_publication(self) -> None: + self.write_state( + exists=True, + assets={self.archive.name: b"different archive"}, + ) + + result = self.run_publisher() + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"Release asset {self.archive.name} has size", result.stdout) + self.assertTrue(self.state()["draft"]) + self.assertEqual(self.mutations("edit"), []) + + def test_successful_but_corrupted_upload_blocks_publication(self) -> None: + result = self.run_publisher(corrupt_upload=self.checksums.name) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"Release asset {self.checksums.name} has digest", result.stdout) + self.assertTrue(self.state()["draft"]) + self.assertEqual(self.mutations("edit"), []) + + def test_complete_published_release_is_verification_only(self) -> None: + self.write_state(exists=True, draft=False, assets=self.complete_assets()) + + result = self.run_publisher() + + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.mutations("create"), []) + self.assertEqual(self.mutations("upload"), []) + self.assertEqual(self.mutations("edit"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_promotion.py b/scripts/test_release_promotion.py index 6e03d66..39d1c92 100644 --- a/scripts/test_release_promotion.py +++ b/scripts/test_release_promotion.py @@ -60,7 +60,7 @@ def candidate(self, version: str, lock_version: str | None = None) -> str: self.git("switch", "dev") self.write_version(version, lock_version) self.git("add", ".") - self.git("commit", "-m", f"prepare {version}") + self.git("commit", "--allow-empty", "-m", f"prepare {version}") return self.git("rev-parse", "HEAD") def validate(self, target: str, head_sha: str) -> dict[str, object]: @@ -161,6 +161,28 @@ def test_lockfile_mismatch_is_rejected(self) -> None: with self.assertRaisesRegex(PromotionError, "does not match Cargo.lock"): self.repository.validate("prerelease", head) + def test_stable_version_must_strictly_advance_main(self) -> None: + for version in ("1.3.0", "1.2.9"): + with self.subTest(version=version): + head = self.repository.candidate(version) + with self.assertRaisesRegex( + PromotionError, f"candidate {version} must advance main from 1.3.0" + ): + self.repository.validate("main", head) + + def test_prerelease_version_must_strictly_advance_prerelease(self) -> None: + current = self.repository.candidate("1.4.0-beta.2") + self.repository.git("branch", "-f", "prerelease", current) + + for version in ("1.4.0-beta.2", "1.4.0-beta.1"): + with self.subTest(version=version): + head = self.repository.candidate(version) + with self.assertRaisesRegex( + PromotionError, + f"candidate {version} must advance prerelease from 1.4.0-beta.2", + ): + self.repository.validate("prerelease", head) + def test_stale_target_is_rejected(self) -> None: head = self.repository.candidate("1.4.0-beta.1") with self.assertRaisesRegex(PromotionError, "promotion target moved"): From 628a01f254e064cae2c9646be68c2462258deb12 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:33:44 +0300 Subject: [PATCH 15/22] Simplify release discovery to newest publication --- crates/lg-buddy/src/updates.rs | 121 +++++++----------- .../github/releases-v1.4.0-beta.2.json | 14 -- docs/release-process.md | 12 +- docs/testing-strategy.md | 12 +- docs/user-guide.md | 5 +- scripts/record_github_release_responses.py | 17 ++- scripts/test-production-upgrade-canary.sh | 14 ++ .../test_record_github_release_responses.py | 93 ++++++++++++++ 8 files changed, 187 insertions(+), 101 deletions(-) diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 46ad76d..5eef597 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -24,7 +24,6 @@ const GITHUB_CONNECT_TIMEOUT_SECONDS: u64 = 5; const GITHUB_REQUEST_TIMEOUT_SECONDS: u64 = 20; const MAX_GITHUB_RESPONSE_BYTES: u64 = 2 * 1024 * 1024; const MAX_GITHUB_ERROR_BYTES: u64 = 16 * 1024; -const PRERELEASE_PAGE_SIZE: u8 = 20; const CACHE_DIR_NAME: &str = "lg-buddy"; const UPDATE_CHECK_CACHE_FILE_NAME: &str = "update-check.json"; @@ -903,21 +902,21 @@ enum GitHubReleaseResponse { #[derive(Debug, Clone, Copy)] enum ReleaseEndpoint { LatestStable, - ReleasesList { per_page: u8 }, + LatestPublished, } impl ReleaseEndpoint { fn url(self, base: &str) -> String { match self { Self::LatestStable => format!("{base}/latest"), - Self::ReleasesList { per_page } => format!("{base}?per_page={per_page}"), + Self::LatestPublished => format!("{base}?per_page=1"), } } fn label(self) -> &'static str { match self { Self::LatestStable => "latest", - Self::ReleasesList { .. } => "releases", + Self::LatestPublished => "releases", } } } @@ -1379,9 +1378,7 @@ fn fetch_latest_release( }) } UpdateChannel::Prerelease => { - let endpoint = ReleaseEndpoint::ReleasesList { - per_page: PRERELEASE_PAGE_SIZE, - }; + let endpoint = ReleaseEndpoint::LatestPublished; let response = client.get(endpoint, &user_agent, cached_etag)?; latest_from_response(channel, response, cache, now_unix_seconds, |body| { @@ -1393,8 +1390,8 @@ fn fetch_latest_release( releases .into_iter() - .filter_map(|release| release_info_from_api_release(release, channel)) - .max_by(|left, right| left.version.cmp(&right.version)) + .next() + .and_then(|release| release_info_from_api_release(release, channel)) .ok_or(UpdatesError::NoMatchingRelease { channel }) }) } @@ -1510,7 +1507,7 @@ mod tests { UpdateChannel, UpdateCheckCache, UpdateNotificationDecision, UpdateNotificationPolicyInput, UpdateNotificationReason, UpdateNotificationSkipReason, UpdateSettings, UpdatesCommand, UpdatesDeferredFailure, UpdatesError, UpdatesRunContext, UreqGitHubReleasesClient, - MAX_GITHUB_RESPONSE_BYTES, PRERELEASE_PAGE_SIZE, + MAX_GITHUB_RESPONSE_BYTES, }; use crate::session_notifications::{ UpdateNotificationError, UpdateNotificationHandoff, UpdateNotificationOutcome, @@ -1725,18 +1722,14 @@ mod tests { fn channel_response(channel: UpdateChannel) -> String { match channel { UpdateChannel::Stable => stable_release("v1.1.1"), - UpdateChannel::Prerelease => format!( - "[{},{}]", - stable_release("v1.1.1"), - prerelease("v1.2.0-beta.1") - ), + UpdateChannel::Prerelease => format!("[{}]", prerelease("v1.2.0-beta.1")), } } fn channel_endpoint(channel: UpdateChannel) -> &'static str { match channel { UpdateChannel::Stable => "https://api.example.test/releases/latest", - UpdateChannel::Prerelease => "https://api.example.test/releases?per_page=20", + UpdateChannel::Prerelease => "https://api.example.test/releases?per_page=1", } } @@ -2223,7 +2216,7 @@ mod tests { let length = stream.read(&mut buffer).expect("read request"); let request = String::from_utf8_lossy(&buffer[..length]); - assert!(request.starts_with("GET /releases?per_page=20 ")); + assert!(request.starts_with("GET /releases?per_page=1 ")); assert!(request.contains("If-None-Match: \"cached-etag\"")); stream @@ -2242,9 +2235,7 @@ mod tests { let response = client .get( - ReleaseEndpoint::ReleasesList { - per_page: PRERELEASE_PAGE_SIZE, - }, + ReleaseEndpoint::LatestPublished, "lg-buddy/1.1.0-alpha.0", Some("\"cached-etag\""), ) @@ -2467,7 +2458,7 @@ mod tests { assert_eq!( client.requests_with_etags(), vec![( - "https://api.example.test/releases?per_page=20".to_string(), + "https://api.example.test/releases?per_page=1".to_string(), "lg-buddy/1.2.0-beta.1".to_string(), Some("\"prerelease-etag\"".to_string()) )] @@ -2660,11 +2651,8 @@ mod tests { #[test] fn background_update_check_uses_configured_prerelease_channel() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{}]", - stable_release("v1.1.0"), - prerelease("v1.2.0-beta.1") - ))]); + let client = + MockGitHubReleasesClient::new(vec![Ok(format!("[{}]", prerelease("v1.2.0-beta.1")))]); let notifier = RecordingNotifier::default(); let cache_store = MemoryUpdateCacheStore::default(); let update_settings = StaticUpdateSettings::enabled(UpdateChannel::Prerelease); @@ -2689,7 +2677,7 @@ mod tests { assert_eq!( client.requests(), vec![( - "https://api.example.test/releases?per_page=20".to_string(), + "https://api.example.test/releases?per_page=1".to_string(), "lg-buddy/1.1.0".to_string() )] ); @@ -3042,11 +3030,8 @@ mod tests { #[test] fn manual_update_check_uses_saved_prerelease_channel_when_auto_check_is_disabled() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{}]", - stable_release("v1.1.0"), - prerelease("v1.2.0-beta.1") - ))]); + let client = + MockGitHubReleasesClient::new(vec![Ok(format!("[{}]", prerelease("v1.2.0-beta.1")))]); let notifier = RecordingNotifier::default(); let cache_store = MemoryUpdateCacheStore::default(); let update_settings = StaticUpdateSettings::disabled(UpdateChannel::Prerelease); @@ -3070,7 +3055,7 @@ mod tests { assert_eq!( client.requests(), vec![( - "https://api.example.test/releases?per_page=20".to_string(), + "https://api.example.test/releases?per_page=1".to_string(), "lg-buddy/1.1.0".to_string() )] ); @@ -3078,11 +3063,8 @@ mod tests { #[test] fn install_discovery_uses_saved_channel_and_ignores_automatic_check_gate() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{}]", - stable_release("v1.1.0"), - prerelease("v1.2.0-beta.1") - ))]); + let client = + MockGitHubReleasesClient::new(vec![Ok(format!("[{}]", prerelease("v1.2.0-beta.1")))]); let update_settings = StaticUpdateSettings::disabled(UpdateChannel::Prerelease); let release = discover_install_candidate_with( @@ -3097,7 +3079,7 @@ mod tests { assert_eq!( client.requests(), vec![( - "https://api.example.test/releases?per_page=20".to_string(), + "https://api.example.test/releases?per_page=1".to_string(), "lg-buddy/1.1.0".to_string() )] ); @@ -3592,14 +3574,9 @@ mod tests { } #[test] - fn prerelease_channel_includes_stable_releases_and_picks_highest_semver() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{},{},{}]", - draft_prerelease("v1.3.0-beta.1"), - stable_release("v1.2.0"), - prerelease("release-0.6"), - prerelease("v1.2.0-beta.2") - ))]); + fn prerelease_channel_accepts_the_newest_published_stable_release() { + let client = + MockGitHubReleasesClient::new(vec![Ok(format!("[{}]", stable_release("v1.2.0")))]); let result = check_updates( UpdateChannel::Prerelease, @@ -3616,15 +3593,9 @@ mod tests { } #[test] - fn prerelease_channel_uses_semver_ordering_across_release_stages() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{},{},{},{}]", - stable_release("v1.2.0"), - prerelease("v1.2.0-rc.1"), - prerelease("v1.3.0-alpha.1"), - prerelease("v1.2.0-beta.1"), - prerelease("v1.2.0-alpha.1") - ))]); + fn prerelease_channel_accepts_the_newest_published_prerelease() { + let client = + MockGitHubReleasesClient::new(vec![Ok(format!("[{}]", prerelease("v1.3.0-alpha.1")))]); let result = check_updates( UpdateChannel::Prerelease, @@ -3726,25 +3697,27 @@ mod tests { #[test] fn missing_release_candidate_for_prerelease_channel_is_reported() { - let client = MockGitHubReleasesClient::new(vec![Ok(format!( - "[{},{}]", - prerelease("release-0.6"), - draft_prerelease("v1.2.0-beta.1") - ))]); - - let err = check_updates( - UpdateChannel::Prerelease, - version_info("1.1.0-beta.1", ReleaseChannel::Prerelease), - &client, - ) - .expect_err("missing prerelease candidate should fail update check"); + for response in [ + "[]".to_string(), + format!("[{}]", prerelease("release-0.6")), + format!("[{}]", draft_prerelease("v1.2.0-beta.1")), + ] { + let client = MockGitHubReleasesClient::new(vec![Ok(response)]); + + let err = check_updates( + UpdateChannel::Prerelease, + version_info("1.1.0-beta.1", ReleaseChannel::Prerelease), + &client, + ) + .expect_err("missing prerelease candidate should fail update check"); - assert!(matches!( - err, - UpdatesError::NoMatchingRelease { - channel: UpdateChannel::Prerelease - } - )); + assert!(matches!( + err, + UpdatesError::NoMatchingRelease { + channel: UpdateChannel::Prerelease + } + )); + } } #[test] diff --git a/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json b/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json index 2ad8194..3913540 100644 --- a/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json +++ b/crates/lg-buddy/testdata/github/releases-v1.4.0-beta.2.json @@ -24,19 +24,5 @@ "browser_download_url": "https://github.com/Staphylococcus/LG_Buddy/releases/download/v1.4.0-beta.2/sha256sums.txt" } ] - }, - { - "tag_name": "v1.4.0-beta.1", - "html_url": "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.4.0-beta.1", - "draft": false, - "prerelease": true, - "assets": [] - }, - { - "tag_name": "v1.3.0", - "html_url": "https://github.com/Staphylococcus/LG_Buddy/releases/tag/v1.3.0", - "draft": false, - "prerelease": false, - "assets": [] } ] diff --git a/docs/release-process.md b/docs/release-process.md index 7b6752e..8cf48b0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -38,6 +38,11 @@ Required promotion checks prove that: - a stable promotion has a successful production upgrade canary on the exact prerelease commit +Requiring every candidate to advance both release-channel heads keeps release +publication globally monotonic. The newest published release is therefore also +the highest semantic version and prerelease-channel clients do not scan release +history to determine ordering. + The tag, binary, archive, and GitHub release all use the Cargo package version. There is no separate version input. @@ -55,9 +60,10 @@ There is no separate version input. verifies its complete asset set, and publishes it. Repository release immutability then locks the tag and assets. 7. A published prerelease then runs `v1.4.0-beta.2` through the real production - `lg-buddy updates install` path. The canary records sanitized GitHub response - evidence for the deterministic mock. Stable promotion remains blocked until - this exact prerelease commit has a successful canary. + `lg-buddy updates install` path. The newly installed candidate performs a + cold-cache production update check, and the canary records sanitized GitHub + response evidence for the deterministic mock. Stable promotion remains + blocked until this exact prerelease commit has a successful canary. Do not push version tags manually. Protected `v*` tags and stream-alignment writes permit bypass only to the dedicated release App. A failed post-merge diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 8b185e6..92e9d05 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -355,11 +355,13 @@ candidate-owned file replacement, service action order, and final identity. After a prerelease is public, `production-prerelease-canary` installs the same baseline and drives its real `updates install` command through a PTY against -GitHub. The canary records a sanitized release list, release-by-tag response, -tag ref, and asset redirects as a workflow artifact. Signed redirect queries -and URL userinfo are never retained. The observed beta.2 release-list fields -also live in `crates/lg-buddy/testdata/github/` and are replayed by the normal -offline Rust suite. A successful canary on the exact prerelease commit is a +GitHub. It then clears the update cache and proves that the newly installed +candidate sees itself as GitHub's newest published release. The canary records +that sanitized newest-release response, the release-by-tag response, tag ref, +and asset redirects as a workflow artifact. Signed redirect queries and URL +userinfo are never retained. The observed beta.2 newest-release fields also +live in `crates/lg-buddy/testdata/github/` and are replayed by the normal offline +Rust suite. A successful canary on the exact prerelease commit is a stable-promotion prerequisite. ## Current Practical Gaps diff --git a/docs/user-guide.md b/docs/user-guide.md index a075ec6..8d8f4fd 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -236,8 +236,9 @@ lg-buddy updates install The saved `updates.channel` setting controls every check, regardless of the installed binary's own release channel. `stable` checks stable releases only; -`prerelease` considers both stable and prerelease releases and selects the -highest semantic version. +`prerelease` accepts GitHub's newest published stable or prerelease. Release +promotion requires every version to advance both release-channel heads, so the +newest published release is also the highest semantic version. `updates install` is an assisted, foreground upgrade. It checks whether the current host and installation are safely upgradeable before discovery, shows diff --git a/scripts/record_github_release_responses.py b/scripts/record_github_release_responses.py index daf80c8..39e1816 100755 --- a/scripts/record_github_release_responses.py +++ b/scripts/record_github_release_responses.py @@ -103,7 +103,9 @@ def request(self, url: str, *, accept: str) -> tuple[int, Any, dict[str, str]]: except urllib.error.HTTPError as error: response = error body = response.read() - response_headers = {key.lower(): value for key, value in response.headers.items()} + response_headers = { + key.lower(): value for key, value in response.headers.items() + } parsed: Any = None if body: content_type = response_headers.get("content-type", "") @@ -151,8 +153,15 @@ def record_responses( ) encoded_tag = urllib.parse.quote(tag, safe="") releases, releases_response = recorder.api_json( - f"repos/{encoded_repository}/releases?per_page=20" + f"repos/{encoded_repository}/releases?per_page=1" + ) + latest_release = ( + releases[0] if isinstance(releases, list) and len(releases) == 1 else None ) + if not isinstance(latest_release, dict) or latest_release.get("tag_name") != tag: + raise RuntimeError( + f"newly published release {tag} is not GitHub's newest release" + ) release, release_response = recorder.api_json( f"repos/{encoded_repository}/releases/tags/{encoded_tag}" ) @@ -166,7 +175,9 @@ def record_responses( "sha256sums.txt", } selected_assets = [ - asset for asset in release.get("assets", []) if asset.get("name") in expected_assets + asset + for asset in release.get("assets", []) + if asset.get("name") in expected_assets ] if {asset.get("name") for asset in selected_assets} != expected_assets: raise RuntimeError(f"release {tag} does not expose the expected upgrade assets") diff --git a/scripts/test-production-upgrade-canary.sh b/scripts/test-production-upgrade-canary.sh index 97d6055..1c0f582 100755 --- a/scripts/test-production-upgrade-canary.sh +++ b/scripts/test-production-upgrade-canary.sh @@ -209,4 +209,18 @@ cmp -s "$TOKEN_SNAPSHOT" "$NATIVE_TOKEN_FILE" || fail "Production upgrade change [ -e "$VENV_MARKER" ] || fail "Production native upgrade recreated the Python environment." "$INSTALLED_BINARY" settings get updates.channel | grep -q '^prerelease$' +CANDIDATE_CHECK_OUTPUT="$WORK_DIR/candidate-update-check.output" +UPDATE_CACHE_FILE="$XDG_CACHE_HOME/lg-buddy/update-check.json" +rm -f "$UPDATE_CACHE_FILE" +"$INSTALLED_BINARY" updates check >"$CANDIDATE_CHECK_OUTPUT" +grep -F -q "status: up to date" "$CANDIDATE_CHECK_OUTPUT" +grep -F -q "current: $EXPECTED_VERSION ($EXPECTED_CHANNEL)" "$CANDIDATE_CHECK_OUTPUT" +grep -F -q "latest: $EXPECTED_VERSION ($EXPECTED_CHANNEL)" "$CANDIDATE_CHECK_OUTPUT" +grep -F -q "url: https://github.com/Staphylococcus/LG_Buddy/releases/tag/$EXPECTED_TAG" "$CANDIDATE_CHECK_OUTPUT" +{ + echo + echo "Published candidate update check:" + cat "$CANDIDATE_CHECK_OUTPUT" +} >>"$CANARY_OUTPUT" + echo "Production GitHub upgrade canary passed: $PREVIOUS_VERSION -> $EXPECTED_VERSION" diff --git a/scripts/test_record_github_release_responses.py b/scripts/test_record_github_release_responses.py index c4b7a34..7592c24 100755 --- a/scripts/test_record_github_release_responses.py +++ b/scripts/test_record_github_release_responses.py @@ -9,6 +9,7 @@ GitHubRecorder, project_git_object, project_release, + record_responses, sanitize_location, ) @@ -32,6 +33,57 @@ def open(self, request, *, timeout): # type: ignore[no-untyped-def] return StubResponse() +class FixtureRecorder: + def __init__(self, latest_tag: str = "v1.4.0-beta.2") -> None: + self.latest_tag = latest_tag + self.paths: list[str] = [] + self.release = { + "tag_name": "v1.4.0-beta.2", + "html_url": "https://github.test/releases/tag/v1.4.0-beta.2", + "draft": False, + "prerelease": True, + "assets": [ + { + "id": 1, + "name": "lg-buddy-1.4.0-beta.2-x86_64-unknown-linux-musl.tar.gz", + "state": "uploaded", + "size": 100, + "digest": "sha256:archive", + "url": "https://api.github.test/assets/1", + "browser_download_url": "https://github.test/assets/1", + }, + { + "id": 2, + "name": "sha256sums.txt", + "state": "uploaded", + "size": 10, + "digest": "sha256:checksums", + "url": "https://api.github.test/assets/2", + "browser_download_url": "https://github.test/assets/2", + }, + ], + } + + def api_json(self, path: str): # type: ignore[no-untyped-def] + self.paths.append(path) + if path.endswith("releases?per_page=1"): + return [{**self.release, "tag_name": self.latest_tag}], {"status": 200} + if "/releases/tags/" in path: + return self.release, {"status": 200} + if "/git/ref/tags/" in path: + return { + "object": { + "type": "commit", + "sha": "a" * 40, + } + }, {"status": 200} + raise AssertionError(f"unexpected API path: {path}") + + @staticmethod + def asset_redirect(asset): # type: ignore[no-untyped-def] + return {"asset": asset, "response": {"status": 302}} + + class ResponseRecordingTests(unittest.TestCase): def test_request_uses_the_production_github_api_version(self) -> None: recorder = GitHubRecorder(api_root="https://api.github.test", token=None) @@ -118,6 +170,47 @@ def test_git_object_projection_keeps_only_tag_peeling_fields(self) -> None: }, ) + def test_recorder_captures_only_githubs_newest_release(self) -> None: + recorder = FixtureRecorder() + + recorded = record_responses( + recorder=recorder, + repository="Staphylococcus/LG_Buddy", + tag="v1.4.0-beta.2", + target="x86_64-unknown-linux-musl", + ) + + self.assertEqual( + recorder.paths[0], + "repos/Staphylococcus/LG_Buddy/releases?per_page=1", + ) + self.assertEqual(len(recorded["release_list"]["body"]), 1) + self.assertEqual( + recorded["release_list"]["body"][0]["tag_name"], + "v1.4.0-beta.2", + ) + + def test_recorder_refuses_when_candidate_is_not_githubs_newest_release( + self, + ) -> None: + recorder = FixtureRecorder(latest_tag="v1.4.0-beta.1") + + with self.assertRaisesRegex( + RuntimeError, + "newly published release v1.4.0-beta.2 is not GitHub's newest release", + ): + record_responses( + recorder=recorder, + repository="Staphylococcus/LG_Buddy", + tag="v1.4.0-beta.2", + target="x86_64-unknown-linux-musl", + ) + + self.assertEqual( + recorder.paths, + ["repos/Staphylococcus/LG_Buddy/releases?per_page=1"], + ) + if __name__ == "__main__": unittest.main() From c9ccbeb5b8e8a85d340e1dced94f7fe8ba729ce8 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:53:59 +0300 Subject: [PATCH 16/22] chore(release): prepare 1.4.0-beta.3 --- Cargo.lock | 2 +- crates/lg-buddy/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b6d4d3e..12728fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,7 +890,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lg-buddy" -version = "1.4.0-beta.2" +version = "1.4.0-beta.3" dependencies = [ "base64", "cucumber", diff --git a/crates/lg-buddy/Cargo.toml b/crates/lg-buddy/Cargo.toml index ee57008..d348977 100644 --- a/crates/lg-buddy/Cargo.toml +++ b/crates/lg-buddy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lg-buddy" -version = "1.4.0-beta.2" +version = "1.4.0-beta.3" edition = "2021" publish = false From 125a81c6b6b019f68e21fa5e56541868ad9d7082 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:35:12 +0300 Subject: [PATCH 17/22] chore(ci): update actions to Node 24 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/promotion-check.yml | 4 ++-- .github/workflows/release.yml | 24 ++++++++++++------------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d44397..faa2e84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -22,7 +22,7 @@ jobs: components: clippy - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" @@ -80,7 +80,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -88,7 +88,7 @@ jobs: targets: x86_64-unknown-linux-musl - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/promotion-check.yml b/.github/workflows/promotion-check.yml index 9583cc9..0fdd083 100644 --- a/.github/workflows/promotion-check.yml +++ b/.github/workflows/promotion-check.yml @@ -49,14 +49,14 @@ jobs: } - name: Check out trusted promotion validator - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4f12f4..5d16ce4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,14 +29,14 @@ jobs: steps: - name: Check out merged release commit - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" @@ -69,7 +69,7 @@ jobs: steps: - name: Check out merged release commit - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.validate.outputs.head_sha }} persist-credentials: false @@ -80,7 +80,7 @@ jobs: targets: x86_64-unknown-linux-musl - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" @@ -142,7 +142,7 @@ jobs: sha256sum -c sha256sums.txt - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: lg-buddy-release-${{ needs.validate.outputs.tag }} path: | @@ -161,14 +161,14 @@ jobs: steps: - name: Check out merged release commit - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.validate.outputs.head_sha }} fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" @@ -186,7 +186,7 @@ jobs: --base-sha "${{ needs.validate.outputs.base_sha }}" - name: Download verified release bundle - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: lg-buddy-release-${{ needs.validate.outputs.tag }} path: dist @@ -198,7 +198,7 @@ jobs: id: release-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: - app-id: ${{ vars.RELEASE_PROMOTION_APP_ID }} + client-id: ${{ vars.RELEASE_PROMOTION_APP_CLIENT_ID }} private-key: ${{ secrets.RELEASE_PROMOTION_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: LG_Buddy @@ -274,13 +274,13 @@ jobs: steps: - name: Check out published prerelease - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.validate.outputs.head_sha }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" @@ -323,7 +323,7 @@ jobs: - name: Upload canary evidence for the offline mock if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: production-upgrade-canary-${{ needs.validate.outputs.tag }} path: | From 38d132bb70fc9218430161a1488d56677481d4c8 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:44:51 +0300 Subject: [PATCH 18/22] feat(screen): prefer native Wayland in auto mode --- README.md | 18 +- configure.sh | 23 +- crates/lg-buddy/src/backend.rs | 324 +++++++++++++++--- crates/lg-buddy/src/session/runner.rs | 197 ++++++----- crates/lg-buddy/src/settings.rs | 28 +- crates/lg-buddy/src/settings/formatter.rs | 42 +-- crates/lg-buddy/src/settings/screen.rs | 144 ++++++-- .../lg-buddy/src/sources/desktop/swayidle.rs | 2 +- .../lg-buddy/src/sources/desktop/wayland.rs | 41 ++- .../lg-buddy/tests/cucumber_support/world.rs | 9 +- .../tests/features/detect_backend.feature | 3 +- .../tests/features/monitor_gnome.feature | 9 + .../tests/features/monitor_swayidle.feature | 1 + .../lg-buddy/tests/features/settings.feature | 33 +- crates/lg-buddy/tests/support/mod.rs | 6 + docs/architecture-overview.md | 18 +- docs/development.md | 2 +- docs/runtime-event-handler-map.md | 15 +- docs/session-backend-model.md | 11 +- docs/testing-strategy.md | 13 +- docs/user-guide.md | 25 +- install.sh | 28 +- scripts/test-cross-version-upgrade.sh | 6 +- scripts/test-production-upgrade-canary.sh | 4 +- scripts/test-release-bundle.sh | 33 +- 25 files changed, 746 insertions(+), 289 deletions(-) diff --git a/README.md b/README.md index 48f4c22..fbb3bf2 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,13 @@ binary, so normal installation does not require a Rust toolchain. | Brightness, volume, settings, and update commands | ✅ | ✅ | ✅ | ✅ | | Brightness desktop dialog | ✅ | ✅ | ✅ | ✅ | -The default `auto` backend uses GNOME when compatible, then falls back to -`swayidle` when installed. Native Wayland is currently opt-in: +The default `auto` backend prefers a complete GNOME session, then native +Wayland when the compositor provides `ext_idle_notifier_v1` version 2 or newer +and at least one seat. It uses `swayidle` only as a deprecated compatibility +fallback when native monitoring is unavailable. Inspect the decision with: ```bash -lg-buddy settings set screen.backend wayland +lg-buddy settings describe screen.backend ``` See the [user guide](docs/user-guide.md#automatic-screen-blanking) for backend @@ -44,14 +46,14 @@ The native `lg_webos` control path does not require Python. Native-only packages can omit the Python client, `venv`, and `pip`. The current fresh-install flow still provisions `bscpylgtv` as a compatibility fallback and installs the brightness dialog, so release-bundle installation checks for Python 3 with a -`venv` that provisions `pip`, plus `zenity`. `swayidle` is required only when -using that desktop backend. +`venv` that provisions `pip`, plus `zenity`. `swayidle` is needed only by an +existing explicit selection or as the deprecated compatibility fallback. ### Debian, Ubuntu, and Pop!_OS ```bash sudo apt install python3-venv python3-pip zenity -# Optional swayidle backend: +# Deprecated compatibility fallback only: sudo apt install swayidle ``` @@ -59,7 +61,7 @@ sudo apt install swayidle ```bash sudo dnf install python3 python3-pip python3-virtualenv zenity -# Optional swayidle backend: +# Deprecated compatibility fallback only: sudo dnf install swayidle ``` @@ -67,7 +69,7 @@ sudo dnf install swayidle ```bash sudo pacman -S python python-pip python-virtualenv zenity -# Optional swayidle backend: +# Deprecated compatibility fallback only: sudo pacman -S swayidle ``` diff --git a/configure.sh b/configure.sh index f8a4269..d5f27a9 100755 --- a/configure.sh +++ b/configure.sh @@ -161,7 +161,7 @@ if [ "${LG_BUDDY_NONINTERACTIVE:-0}" = "1" ]; then exit 1 } validate_backend "$screen_backend" || { - echo "LG_BUDDY_SCREEN_BACKEND must be one of auto, gnome, wayland, or swayidle." + echo "LG_BUDDY_SCREEN_BACKEND must be auto, gnome, wayland, or the deprecated compatibility value swayidle." exit 1 } validate_screen_idle_blank "$screen_idle_blank" || { @@ -310,7 +310,11 @@ else echo " 1) auto" echo " 2) gnome" echo " 3) wayland" - echo " 4) swayidle" + backend_choice_range="1-3" + if [ "$existing_config_loaded" -eq 1 ] && [ "$current_screen_backend" = "swayidle" ]; then + echo " 4) swayidle (deprecated compatibility backend; preserve existing selection)" + backend_choice_range="1-4" + fi case "$current_screen_backend" in auto) default_backend_choice="1" ;; @@ -321,13 +325,19 @@ else esac while true; do - BACKEND_CHOICE="$(prompt_with_default "Enter number (1-4)" "$default_backend_choice")" + BACKEND_CHOICE="$(prompt_with_default "Enter number ($backend_choice_range)" "$default_backend_choice")" case "$BACKEND_CHOICE" in 1) screen_backend="auto"; break ;; 2) screen_backend="gnome"; break ;; 3) screen_backend="wayland"; break ;; - 4) screen_backend="swayidle"; break ;; - *) echo " Please enter a number between 1 and 4." ;; + 4) + if [ "$backend_choice_range" = "1-4" ]; then + screen_backend="swayidle" + break + fi + echo " Please enter a number between 1 and 3." + ;; + *) echo " Please enter a number in $backend_choice_range." ;; esac done @@ -383,6 +393,9 @@ echo " System Sleep/Wake: $system_sleep_wake_policy" echo " Update Checks: $update_auto_check" echo " Update Channel: $update_channel" echo " Config File: $CONFIG_FILE" +if [ "$screen_backend" = "swayidle" ]; then + echo " Warning: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0; use auto or wayland." +fi echo "" if [ "${LG_BUDDY_NONINTERACTIVE:-0}" != "1" ]; then diff --git a/crates/lg-buddy/src/backend.rs b/crates/lg-buddy/src/backend.rs index 367e11f..05ed10b 100644 --- a/crates/lg-buddy/src/backend.rs +++ b/crates/lg-buddy/src/backend.rs @@ -1,3 +1,4 @@ +use std::cell::{Cell, RefCell}; use std::env; use std::error::Error; use std::fmt; @@ -10,7 +11,12 @@ use crate::sources::desktop::gnome::{ GNOME_IDLE_MONITOR_NAME, GNOME_REQUIRED_SERVICES_REASON, GNOME_SCREEN_SAVER_NAME, GNOME_SHELL_NAME, }; -use crate::sources::desktop::wayland::probe_wayland_capabilities; +use crate::sources::desktop::wayland::{ + connect_wayland, probe_wayland_capabilities_on, WaylandProviderCapabilities, +}; + +pub const SWAYIDLE_DEPRECATION_NOTICE: &str = + "swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0; use auto or wayland"; const GNOME_SHELL_WAIT_TIMEOUT: Duration = Duration::from_secs(2); @@ -34,7 +40,11 @@ impl Error for BackendSelectionError {} #[derive(Debug, Clone, PartialEq, Eq)] pub enum BackendDetectionError { - NoSupportedBackend, + NoSupportedBackend { + gnome_reason: String, + wayland_reason: String, + swayidle_reason: String, + }, UnavailableBackend { backend: ScreenBackend, reason: String, @@ -48,12 +58,14 @@ pub enum BackendDetectionError { impl fmt::Display for BackendDetectionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::NoSupportedBackend => { - write!( - f, - "no supported backend detected; install swayidle or run under a compatible GNOME session" - ) - } + Self::NoSupportedBackend { + gnome_reason, + wayland_reason, + swayidle_reason, + } => write!( + f, + "no supported backend detected; GNOME unavailable: {gnome_reason}; native Wayland unavailable: {wayland_reason}; deprecated swayidle compatibility unavailable: {swayidle_reason}" + ), Self::UnavailableBackend { backend, reason } => { write!(f, "backend `{}` is unavailable: {reason}", backend.as_str()) } @@ -68,18 +80,57 @@ impl fmt::Display for BackendDetectionError { impl Error for BackendDetectionError {} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendResolution { + backend: ScreenBackend, + fallback_reason: Option, +} + +impl BackendResolution { + pub fn backend(&self) -> ScreenBackend { + self.backend + } + + pub fn fallback_reason(&self) -> Option<&str> { + self.fallback_reason.as_deref() + } + + pub(crate) fn selected(backend: ScreenBackend, fallback_reason: Option) -> Self { + Self { + backend, + fallback_reason, + } + } +} + pub trait BackendProbe { fn has_command(&self, command: &str) -> bool; fn gnome_shell_available(&self) -> bool; fn gnome_screen_saver_available(&self) -> bool; fn gnome_idle_monitor_available(&self) -> bool; - fn wayland_capabilities(&self) -> Result<(), String> { + fn wayland_capabilities(&self) -> Result { Err("native Wayland capability probing is unavailable".to_string()) } + fn swayidle_fallback_available(&self) -> Result<(), String> { + if self.has_command("swayidle") { + Ok(()) + } else { + Err("swayidle command not found".to_string()) + } + } } -#[derive(Debug, Default, Clone, Copy)] -pub struct SystemBackendProbe; +#[derive(Default)] +pub struct SystemBackendProbe { + wayland_connection: RefCell>, + inherited_wayland_socket_consumed: Cell, +} + +impl SystemBackendProbe { + pub fn take_wayland_connection(&mut self) -> Option { + self.wayland_connection.get_mut().take() + } +} impl BackendProbe for SystemBackendProbe { fn has_command(&self, command: &str) -> bool { @@ -115,10 +166,36 @@ impl BackendProbe for SystemBackendProbe { bus.name_has_owner(GNOME_IDLE_MONITOR_NAME).unwrap_or(false) } - fn wayland_capabilities(&self) -> Result<(), String> { - probe_wayland_capabilities() - .map(|_| ()) - .map_err(|err| err.to_string()) + fn wayland_capabilities(&self) -> Result { + let connection = match self.wayland_connection.borrow().as_ref() { + Some(connection) => connection.clone(), + None => { + let inherited_socket_without_display = env::var_os("WAYLAND_SOCKET").is_some() + && env::var_os("WAYLAND_DISPLAY").is_none(); + let result = connect_wayland().map_err(|err| err.to_string()); + if inherited_socket_without_display && env::var_os("WAYLAND_SOCKET").is_none() { + self.inherited_wayland_socket_consumed.set(true); + } + result? + } + }; + let capabilities = + probe_wayland_capabilities_on(connection.clone()).map_err(|err| err.to_string())?; + *self.wayland_connection.borrow_mut() = Some(connection); + Ok(capabilities) + } + + fn swayidle_fallback_available(&self) -> Result<(), String> { + if !self.has_command("swayidle") { + return Err("swayidle command not found".to_string()); + } + if self.inherited_wayland_socket_consumed.get() { + return Err( + "native probing consumed the session's one-shot WAYLAND_SOCKET, so swayidle cannot reconnect; configure swayidle explicitly to bypass native probing" + .to_string(), + ); + } + Ok(()) } } @@ -148,42 +225,68 @@ pub fn configured_backend_from_sources( pub fn detect_backend_from_system( configured: ScreenBackend, ) -> Result { - detect_backend_with_probe(&SystemBackendProbe, configured) + resolve_backend_from_system(configured).map(|resolution| resolution.backend()) +} + +pub fn resolve_backend_from_system( + configured: ScreenBackend, +) -> Result { + resolve_backend_with_probe(&SystemBackendProbe::default(), configured) } pub fn detect_backend_with_probe( probe: &impl BackendProbe, configured: ScreenBackend, ) -> Result { + resolve_backend_with_probe(probe, configured).map(|resolution| resolution.backend()) +} + +pub fn resolve_backend_with_probe( + probe: &impl BackendProbe, + configured: ScreenBackend, +) -> Result { match configured { ScreenBackend::Auto => { - if probe.gnome_shell_available() { - if probe.gnome_screen_saver_available() && probe.gnome_idle_monitor_available() { - return Ok(ScreenBackend::Gnome); - } - - if probe.has_command("swayidle") { - return Ok(ScreenBackend::Swayidle); - } - - return Err(BackendDetectionError::UnavailableBackend { - backend: ScreenBackend::Gnome, - reason: GNOME_REQUIRED_SERVICES_REASON.to_string(), - }); + let gnome_shell_available = probe.gnome_shell_available(); + if gnome_shell_available + && probe.gnome_screen_saver_available() + && probe.gnome_idle_monitor_available() + { + return Ok(BackendResolution::selected(ScreenBackend::Gnome, None)); } - if probe.has_command("swayidle") { - return Ok(ScreenBackend::Swayidle); + let gnome_reason = if gnome_shell_available { + GNOME_REQUIRED_SERVICES_REASON.to_string() + } else { + "GNOME Shell is not available".to_string() + }; + + match probe.wayland_capabilities() { + Ok(_) => Ok(BackendResolution::selected( + ScreenBackend::Wayland, + Some(format!("GNOME unavailable: {gnome_reason}")), + )), + Err(wayland_reason) => match probe.swayidle_fallback_available() { + Ok(()) => Ok(BackendResolution::selected( + ScreenBackend::Swayidle, + Some(format!( + "GNOME unavailable: {gnome_reason}; native Wayland unavailable: {wayland_reason}" + )), + )), + Err(swayidle_reason) => Err(BackendDetectionError::NoSupportedBackend { + gnome_reason, + wayland_reason, + swayidle_reason, + }), + }, } - - Err(BackendDetectionError::NoSupportedBackend) } ScreenBackend::Gnome => { if probe.gnome_shell_available() && probe.gnome_screen_saver_available() && probe.gnome_idle_monitor_available() { - Ok(ScreenBackend::Gnome) + Ok(BackendResolution::selected(ScreenBackend::Gnome, None)) } else { Err(BackendDetectionError::UnavailableBackend { backend: ScreenBackend::Gnome, @@ -193,14 +296,14 @@ pub fn detect_backend_with_probe( } ScreenBackend::Wayland => probe .wayland_capabilities() - .map(|()| ScreenBackend::Wayland) + .map(|_| BackendResolution::selected(ScreenBackend::Wayland, None)) .map_err(|reason| BackendDetectionError::UnavailableBackend { backend: ScreenBackend::Wayland, reason, }), ScreenBackend::Swayidle => { if probe.has_command("swayidle") { - Ok(ScreenBackend::Swayidle) + Ok(BackendResolution::selected(ScreenBackend::Swayidle, None)) } else { Err(BackendDetectionError::MissingRequiredCommand { backend: ScreenBackend::Swayidle, @@ -226,10 +329,11 @@ fn command_in_path(command: &str) -> bool { #[cfg(test)] mod tests { use super::{ - configured_backend_from_sources, detect_backend_with_probe, BackendDetectionError, - BackendProbe, BackendSelectionError, + configured_backend_from_sources, detect_backend_with_probe, resolve_backend_with_probe, + BackendDetectionError, BackendProbe, BackendSelectionError, }; use crate::config::ScreenBackend; + use crate::sources::desktop::wayland::WaylandProviderCapabilities; #[derive(Debug, Clone, Copy)] struct FakeProbe { @@ -237,6 +341,21 @@ mod tests { gnome_screen_saver_available: bool, gnome_idle_monitor_available: bool, has_swayidle: bool, + swayidle_fallback_reason: Option<&'static str>, + wayland_capabilities: Result, + } + + impl Default for FakeProbe { + fn default() -> Self { + Self { + gnome_shell_available: false, + gnome_screen_saver_available: false, + gnome_idle_monitor_available: false, + has_swayidle: false, + swayidle_fallback_reason: None, + wayland_capabilities: Err("no Wayland compositor is available"), + } + } } impl BackendProbe for FakeProbe { @@ -258,9 +377,23 @@ mod tests { fn gnome_idle_monitor_available(&self) -> bool { self.gnome_idle_monitor_available } + + fn wayland_capabilities(&self) -> Result { + self.wayland_capabilities.map_err(str::to_string) + } + + fn swayidle_fallback_available(&self) -> Result<(), String> { + if !self.has_swayidle { + return Err("swayidle command not found".to_string()); + } + match self.swayidle_fallback_reason { + Some(reason) => Err(reason.to_string()), + None => Ok(()), + } + } } - struct WaylandProbe(Result<(), &'static str>); + struct WaylandProbe(Result); impl BackendProbe for WaylandProbe { fn has_command(&self, _command: &str) -> bool { @@ -279,11 +412,18 @@ mod tests { false } - fn wayland_capabilities(&self) -> Result<(), String> { + fn wayland_capabilities(&self) -> Result { self.0.map_err(str::to_string) } } + fn native_wayland_capabilities() -> WaylandProviderCapabilities { + WaylandProviderCapabilities { + idle_notifier_version: 2, + seat_count: 1, + } + } + #[test] fn env_override_wins_over_config_backend() { let backend = configured_backend_from_sources(Some("swayidle"), Some(ScreenBackend::Gnome)) @@ -334,6 +474,7 @@ mod tests { gnome_screen_saver_available: true, gnome_idle_monitor_available: true, has_swayidle: true, + ..FakeProbe::default() }; let backend = @@ -342,6 +483,45 @@ mod tests { assert_eq!(backend, ScreenBackend::Gnome); } + #[test] + fn auto_selects_native_wayland_before_swayidle() { + let probe = FakeProbe { + has_swayidle: true, + wayland_capabilities: Ok(native_wayland_capabilities()), + ..FakeProbe::default() + }; + + let resolution = resolve_backend_with_probe(&probe, ScreenBackend::Auto) + .expect("detect native Wayland backend"); + + assert_eq!(resolution.backend(), ScreenBackend::Wayland); + assert_eq!( + resolution.fallback_reason(), + Some("GNOME unavailable: GNOME Shell is not available") + ); + } + + #[test] + fn auto_selects_native_wayland_when_gnome_is_incomplete() { + let probe = FakeProbe { + gnome_shell_available: true, + gnome_screen_saver_available: true, + gnome_idle_monitor_available: false, + has_swayidle: true, + wayland_capabilities: Ok(native_wayland_capabilities()), + ..FakeProbe::default() + }; + + let resolution = resolve_backend_with_probe(&probe, ScreenBackend::Auto) + .expect("fall back from incomplete GNOME to native Wayland"); + + assert_eq!(resolution.backend(), ScreenBackend::Wayland); + assert!(resolution + .fallback_reason() + .unwrap() + .contains("org.gnome.Mutter.IdleMonitor")); + } + #[test] fn auto_falls_back_to_swayidle() { let probe = FakeProbe { @@ -349,6 +529,7 @@ mod tests { gnome_screen_saver_available: false, gnome_idle_monitor_available: false, has_swayidle: true, + ..FakeProbe::default() }; let backend = detect_backend_with_probe(&probe, ScreenBackend::Auto) @@ -364,12 +545,20 @@ mod tests { gnome_screen_saver_available: false, gnome_idle_monitor_available: false, has_swayidle: false, + ..FakeProbe::default() }; let err = detect_backend_with_probe(&probe, ScreenBackend::Auto) .expect_err("missing backend should fail"); - assert_eq!(err, BackendDetectionError::NoSupportedBackend); + assert_eq!( + err, + BackendDetectionError::NoSupportedBackend { + gnome_reason: "GNOME Shell is not available".to_string(), + wayland_reason: "no Wayland compositor is available".to_string(), + swayidle_reason: "swayidle command not found".to_string(), + } + ); } #[test] @@ -379,6 +568,7 @@ mod tests { gnome_screen_saver_available: false, gnome_idle_monitor_available: false, has_swayidle: true, + ..FakeProbe::default() }; let err = detect_backend_with_probe(&probe, ScreenBackend::Gnome) @@ -402,6 +592,7 @@ mod tests { gnome_screen_saver_available: true, gnome_idle_monitor_available: false, has_swayidle: false, + ..FakeProbe::default() }; let err = detect_backend_with_probe(&probe, ScreenBackend::Auto) @@ -409,15 +600,43 @@ mod tests { assert_eq!( err, - BackendDetectionError::UnavailableBackend { - backend: ScreenBackend::Gnome, - reason: + BackendDetectionError::NoSupportedBackend { + gnome_reason: "GNOME Shell, org.gnome.ScreenSaver, and org.gnome.Mutter.IdleMonitor are required" .to_string(), + wayland_reason: "no Wayland compositor is available".to_string(), + swayidle_reason: "swayidle command not found".to_string(), } ); } + #[test] + fn auto_refuses_an_unsafe_swayidle_fallback_without_disabling_explicit_swayidle() { + let probe = FakeProbe { + has_swayidle: true, + swayidle_fallback_reason: Some( + "native probing consumed the session's one-shot WAYLAND_SOCKET", + ), + ..FakeProbe::default() + }; + + let err = detect_backend_with_probe(&probe, ScreenBackend::Auto) + .expect_err("unsafe automatic fallback should fail"); + assert_eq!( + err, + BackendDetectionError::NoSupportedBackend { + gnome_reason: "GNOME Shell is not available".to_string(), + wayland_reason: "no Wayland compositor is available".to_string(), + swayidle_reason: "native probing consumed the session's one-shot WAYLAND_SOCKET" + .to_string(), + } + ); + + let explicit = detect_backend_with_probe(&probe, ScreenBackend::Swayidle) + .expect("explicit swayidle should bypass native fallback safety"); + assert_eq!(explicit, ScreenBackend::Swayidle); + } + #[test] fn auto_falls_back_to_swayidle_when_gnome_idle_monitor_is_missing() { let probe = FakeProbe { @@ -425,12 +644,16 @@ mod tests { gnome_screen_saver_available: true, gnome_idle_monitor_available: false, has_swayidle: true, + ..FakeProbe::default() }; - let backend = detect_backend_with_probe(&probe, ScreenBackend::Auto) + let resolution = resolve_backend_with_probe(&probe, ScreenBackend::Auto) .expect("fallback to swayidle when GNOME is incomplete"); - assert_eq!(backend, ScreenBackend::Swayidle); + assert_eq!(resolution.backend(), ScreenBackend::Swayidle); + let reason = resolution.fallback_reason().unwrap(); + assert!(reason.contains("org.gnome.Mutter.IdleMonitor")); + assert!(reason.contains("native Wayland unavailable: no Wayland compositor is available")); } #[test] @@ -440,6 +663,7 @@ mod tests { gnome_screen_saver_available: true, gnome_idle_monitor_available: false, has_swayidle: true, + ..FakeProbe::default() }; let err = detect_backend_with_probe(&probe, ScreenBackend::Gnome) @@ -463,6 +687,7 @@ mod tests { gnome_screen_saver_available: true, gnome_idle_monitor_available: true, has_swayidle: false, + ..FakeProbe::default() }; let err = detect_backend_with_probe(&probe, ScreenBackend::Swayidle) @@ -500,8 +725,11 @@ mod tests { #[test] fn forced_wayland_is_selected_when_the_native_protocol_surface_is_available() { - let backend = detect_backend_with_probe(&WaylandProbe(Ok(())), ScreenBackend::Wayland) - .expect("forced Wayland should be available"); + let backend = detect_backend_with_probe( + &WaylandProbe(Ok(native_wayland_capabilities())), + ScreenBackend::Wayland, + ) + .expect("forced Wayland should be available"); assert_eq!(backend, ScreenBackend::Wayland); } diff --git a/crates/lg-buddy/src/session/runner.rs b/crates/lg-buddy/src/session/runner.rs index e39e276..7865370 100644 --- a/crates/lg-buddy/src/session/runner.rs +++ b/crates/lg-buddy/src/session/runner.rs @@ -14,8 +14,8 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use crate::backend::{ - configured_backend_from_env_or_config, detect_backend_from_system, BackendDetectionError, - BackendSelectionError, + configured_backend_from_env_or_config, resolve_backend_with_probe, BackendDetectionError, + BackendResolution, BackendSelectionError, SystemBackendProbe, SWAYIDLE_DEPRECATION_NOTICE, }; use crate::commands::{run_sleep_pre_for_event, run_system_resume}; use crate::config::{ @@ -41,7 +41,7 @@ use crate::sources::desktop::gnome::{ screen_saver_owner_changed, GnomeBackend, SystemGnomeProbe, GNOME_SCREEN_SAVER_INTERFACE, GNOME_SCREEN_SAVER_PATH, GNOME_SHELL_NAME, }; -use crate::sources::desktop::wayland::{connect_wayland, run_wayland_activity_monitor}; +use crate::sources::desktop::wayland::run_wayland_activity_monitor; use crate::sources::linux::logind::{ acquire_sleep_delay_inhibitor, add_logind_signal_match, map_prepare_for_sleep_signal, }; @@ -509,21 +509,13 @@ fn lifecycle_policy_enabled_from_config(config_path: &Path) -> Result( +fn prepare_monitor_backend( + probe: &mut SystemBackendProbe, configured: ScreenBackend, - detect: F, -) -> Result -where - F: FnOnce(ScreenBackend) -> Result, -{ - if configured == ScreenBackend::Wayland { - // The provider validates the protocol and seats while opening its - // production connection. A separate probe would consume an inherited - // WAYLAND_SOCKET before the provider can use it. - Ok(ScreenBackend::Wayland) - } else { - detect(configured) - } +) -> Result<(BackendResolution, Option), BackendDetectionError> { + let resolution = resolve_backend_with_probe(probe, configured)?; + let connection = probe.take_wayland_connection(); + Ok((resolution, connection)) } fn run_monitor_with_executor( @@ -531,37 +523,17 @@ fn run_monitor_with_executor( executor: E, ) -> Result<(), SessionRunnerError> { let screen_idle_blank_enabled = screen_idle_blank_enabled_from_config()?; - let configured = if screen_idle_blank_enabled { - Some( - configured_backend_from_env_or_config() - .map_err(SessionRunnerError::BackendSelection)?, - ) - } else { - None - }; - let mut wayland_connection = if configured == Some(ScreenBackend::Wayland) { - // wayland-client consumes an inherited WAYLAND_SOCKET by mutating the - // process environment. Do that while monitor startup is single-threaded. - Some(connect_wayland().map_err(|err| SessionRunnerError::Failed { - backend: ScreenBackend::Wayland, - message: err.to_string(), - })?) - } else { - None - }; - - let _session_service = match spawn_session_notification_service() { - Ok(service) => Some(service), - Err(err) => { - writeln!( - writer, - "LG Buddy Monitor: session notification service unavailable: {err}" - )?; - None - } - }; - if !screen_idle_blank_enabled { + let _session_service = match spawn_session_notification_service() { + Ok(service) => Some(service), + Err(err) => { + writeln!( + writer, + "LG Buddy Monitor: session notification service unavailable: {err}" + )?; + None + } + }; writeln!( writer, "LG Buddy Monitor: screen idle blanking is disabled by config." @@ -569,47 +541,91 @@ fn run_monitor_with_executor( return run_passive_session_agent(writer); } + let initial_configured = + configured_backend_from_env_or_config().map_err(SessionRunnerError::BackendSelection)?; let mut executor = Some(executor); - let mut initial_configured = configured; let started = Instant::now(); let test_timeout = resolve_gnome_monitor_test_timeout(); + let mut probe = SystemBackendProbe::default(); + let initial_resolution = prepare_monitor_backend(&mut probe, initial_configured); + let mut initial_attempt = Some((initial_configured, initial_resolution)); - loop { - if test_timeout_reached(started, test_timeout) { - return Ok(()); + // Native probing must consume an inherited WAYLAND_SOCKET before this + // thread starts. The same probe is retained for every later retry so the + // automatic fallback policy cannot forget that one-shot socket state. + let _session_service = match spawn_session_notification_service() { + Ok(service) => Some(service), + Err(err) => { + writeln!( + writer, + "LG Buddy Monitor: session notification service unavailable: {err}" + )?; + None } + }; - let configured = match initial_configured.take() { - Some(configured) => configured, - None => configured_backend_from_env_or_config() - .map_err(SessionRunnerError::BackendSelection)?, + loop { + let (configured, resolution) = match initial_attempt.take() { + Some(attempt) => attempt, + None => { + if test_timeout_reached(started, test_timeout) { + return Ok(()); + } + let configured = configured_backend_from_env_or_config() + .map_err(SessionRunnerError::BackendSelection)?; + let resolution = prepare_monitor_backend(&mut probe, configured); + (configured, resolution) + } }; - match select_monitor_backend(configured, detect_backend_from_system) { - Ok(ScreenBackend::Gnome) => { - let mut dispatcher = - SessionEventDispatcher::new(executor.take().expect("executor available")); - return run_gnome_monitor(writer, &mut dispatcher); - } - Ok(ScreenBackend::Wayland) => { - let mut dispatcher = - SessionEventDispatcher::new(executor.take().expect("executor available")); - let connection = wayland_connection.take().ok_or_else(|| { - SessionRunnerError::Failed { - backend: ScreenBackend::Wayland, - message: "native Wayland was selected after threaded monitor startup; restart the monitor to acquire its connection safely" - .to_string(), + match resolution { + Ok((resolution, mut wayland_connection)) => { + if configured == ScreenBackend::Auto { + writeln!( + writer, + "LG Buddy Monitor: auto resolved to {}.", + resolution.backend().as_str() + )?; + if let Some(reason) = resolution.fallback_reason() { + writeln!(writer, "LG Buddy Monitor: fallback reason: {reason}")?; } - })?; - return run_wayland_monitor(writer, &mut dispatcher, connection); - } - Ok(ScreenBackend::Swayidle) => return run_swayidle_monitor(writer), - Ok(ScreenBackend::Auto) => { - return Err(SessionRunnerError::Failed { - backend: ScreenBackend::Auto, - message: "auto backend should be resolved before starting the runner" - .to_string(), - }); + } + if resolution.backend() == ScreenBackend::Swayidle { + writeln!( + writer, + "LG Buddy Monitor: warning: {SWAYIDLE_DEPRECATION_NOTICE}." + )?; + } + + match resolution.backend() { + ScreenBackend::Gnome => { + let mut dispatcher = SessionEventDispatcher::new( + executor.take().expect("executor available"), + ); + return run_gnome_monitor(writer, &mut dispatcher); + } + ScreenBackend::Wayland => { + let mut dispatcher = SessionEventDispatcher::new( + executor.take().expect("executor available"), + ); + let connection = wayland_connection.take().ok_or_else(|| { + SessionRunnerError::Failed { + backend: ScreenBackend::Wayland, + message: "native Wayland was selected without retaining its verified connection" + .to_string(), + } + })?; + return run_wayland_monitor(writer, &mut dispatcher, connection); + } + ScreenBackend::Swayidle => return run_swayidle_monitor(writer), + ScreenBackend::Auto => { + return Err(SessionRunnerError::Failed { + backend: ScreenBackend::Auto, + message: "auto backend should be resolved before starting the runner" + .to_string(), + }); + } + } } Err(err) => { writeln!( @@ -1586,7 +1602,7 @@ mod tests { gamepad_device_event_refresh_requested, gamepad_refresh_due, handle_inactivity_observation, handle_inactivity_timeout, normalize_idle_timeout_secs, poll_gnome_idle_monitor_once, run_lifecycle_monitor_with_bus, run_native_session_monitor, schedule_gamepad_refresh, - select_monitor_backend, shell_quote, GamepadDeviceEventMonitor, GamepadDeviceEventRefresh, + shell_quote, GamepadDeviceEventMonitor, GamepadDeviceEventRefresh, GamepadDiagnosticEmitter, LatestInactivityObservation, RunnerMessage, SessionActionExecutor, SessionEventDispatcher, TimedInactivityObservation, TrustedScreenSaverSignals, GAMEPAD_ACTIVITY_REFRESH_RETRY_INTERVAL, @@ -1620,27 +1636,6 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } - #[test] - fn explicit_wayland_monitor_selection_does_not_probe_a_second_connection() { - let backend = select_monitor_backend(ScreenBackend::Wayland, |_| { - panic!("explicit Wayland monitor selection must not run capability detection") - }) - .expect("select explicit Wayland backend"); - - assert_eq!(backend, ScreenBackend::Wayland); - } - - #[test] - fn other_monitor_backend_selection_still_uses_detection() { - let backend = select_monitor_backend(ScreenBackend::Auto, |configured| { - assert_eq!(configured, ScreenBackend::Auto); - Ok(ScreenBackend::Swayidle) - }) - .expect("resolve automatic backend"); - - assert_eq!(backend, ScreenBackend::Swayidle); - } - #[derive(Debug, Default)] struct FakeActionExecutor { screen_off_calls: usize, diff --git a/crates/lg-buddy/src/settings.rs b/crates/lg-buddy/src/settings.rs index 64b7349..d94e441 100644 --- a/crates/lg-buddy/src/settings.rs +++ b/crates/lg-buddy/src/settings.rs @@ -1,7 +1,5 @@ use std::io; -use crate::config::ScreenBackend; - mod command; mod formatter; mod model; @@ -134,8 +132,11 @@ impl SettingsCommandRunner { } } - fn with_screen_backend_resolution(mut self, resolution: Option) -> Self { - self.screen_backend = screen::BackendPresentation::Resolved(resolution); + fn with_screen_backend_presentation( + mut self, + presentation: screen::BackendPresentation, + ) -> Self { + self.screen_backend = presentation; self } @@ -155,7 +156,7 @@ impl SettingsCommandRunner { self.formatter.write_describe_with_backend( writer, &[setting], - self.screen_backend, + &self.screen_backend, ) } None => { @@ -163,7 +164,7 @@ impl SettingsCommandRunner { self.formatter.write_describe_with_backend( writer, &settings, - self.screen_backend, + &self.screen_backend, ) } }, @@ -206,13 +207,14 @@ pub fn run_settings_command( writer: &mut W, ) -> Result<(), SettingsError> { let store = SettingsStore::load_from_env()?; + let configured_backend = store + .effective_by_name("screen.backend") + .ok() + .and_then(|setting| setting.value()) + .map(|value| value.to_string()); + let presentation = screen::presentation_for_command(&command, configured_backend.as_deref()); let runner = SettingsCommandRunner::new(store); - let runner = match screen::presentation_for_command(&command) { - screen::BackendPresentation::Raw => runner, - screen::BackendPresentation::Resolved(resolution) => { - runner.with_screen_backend_resolution(resolution) - } - }; + let runner = runner.with_screen_backend_presentation(presentation); runner.run(command, writer) } @@ -491,7 +493,7 @@ screen.backend default: auto mutability: read-write supported operations: get, describe, set, unset - allowed values: auto, gnome, wayland, swayidle + allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend) apply: restart-user-screen-service description: Screen backend selection for user-session blanking and restore behavior. diff --git a/crates/lg-buddy/src/settings/formatter.rs b/crates/lg-buddy/src/settings/formatter.rs index e3637db..10e85d6 100644 --- a/crates/lg-buddy/src/settings/formatter.rs +++ b/crates/lg-buddy/src/settings/formatter.rs @@ -43,14 +43,14 @@ impl SettingsFormatter { writer: &mut W, settings: &[EffectiveSetting], ) -> Result<(), SettingsError> { - self.write_describe_with_backend(writer, settings, screen::BackendPresentation::Raw) + self.write_describe_with_backend(writer, settings, &screen::BackendPresentation::Raw) } pub(super) fn write_describe_with_backend( &self, writer: &mut W, settings: &[EffectiveSetting], - screen_backend: screen::BackendPresentation, + screen_backend: &screen::BackendPresentation, ) -> Result<(), SettingsError> { for (index, setting) in settings.iter().enumerate() { if index > 0 { @@ -124,19 +124,26 @@ impl SettingsFormatter { &self, writer: &mut W, setting: &EffectiveSetting, - screen_backend: screen::BackendPresentation, + screen_backend: &screen::BackendPresentation, ) -> Result<(), SettingsError> { let definition = setting.definition(); writeln!(writer, "{}", setting.key_name()).map_err(output_error)?; writeln!(writer, " storage key: {}", setting.storage_key()).map_err(output_error)?; writeln!(writer, " type: {}", definition.value_type().as_str()).map_err(output_error)?; - writeln!( - writer, - " current: {}", - format_described_value(setting, screen_backend) - ) - .map_err(output_error)?; + writeln!(writer, " current: {}", format_described_value(setting)).map_err(output_error)?; + if setting.key_name() == "screen.backend" { + let configured = format_effective_value(setting); + if let Some((resolved, fallback_reason)) = + screen::resolution_details(&configured, screen_backend) + { + writeln!(writer, " resolved backend: {resolved}").map_err(output_error)?; + writeln!(writer, " fallback reason: {fallback_reason}").map_err(output_error)?; + } + if let Some(notice) = screen::deprecation_notice(&configured) { + writeln!(writer, " deprecation: {notice}.").map_err(output_error)?; + } + } writeln!(writer, " source: {}", setting.source().as_str()).map_err(output_error)?; writeln!(writer, " default: {}", definition.default_value_label()) .map_err(output_error)?; @@ -154,7 +161,7 @@ impl SettingsFormatter { writeln!( writer, " allowed values: {}", - format_described_enum_values(setting, enum_type.values(), screen_backend) + format_described_enum_values(setting, enum_type.values()) ) .map_err(output_error)?; if !enum_type.aliases().is_empty() { @@ -200,30 +207,23 @@ pub(super) fn format_effective_value(setting: &EffectiveSetting) -> String { .unwrap_or_else(|| "".to_string()) } -fn format_described_value( - setting: &EffectiveSetting, - screen_backend: screen::BackendPresentation, -) -> String { +fn format_described_value(setting: &EffectiveSetting) -> String { let value = format_effective_value(setting); if setting.key_name() == "screen.backend" { - screen::format_backend_choice(&value, screen_backend) + screen::format_backend_choice(&value) } else { value } } -fn format_described_enum_values( - setting: &EffectiveSetting, - values: &[&str], - screen_backend: screen::BackendPresentation, -) -> String { +fn format_described_enum_values(setting: &EffectiveSetting, values: &[&str]) -> String { if setting.key_name() != "screen.backend" { return values.join(", "); } values .iter() - .map(|value| screen::format_backend_choice(value, screen_backend)) + .map(|value| screen::format_backend_choice(value)) .collect::>() .join(", ") } diff --git a/crates/lg-buddy/src/settings/screen.rs b/crates/lg-buddy/src/settings/screen.rs index 09d2cd9..b852633 100644 --- a/crates/lg-buddy/src/settings/screen.rs +++ b/crates/lg-buddy/src/settings/screen.rs @@ -1,4 +1,4 @@ -use crate::backend::detect_backend_from_system; +use crate::backend::{resolve_backend_from_system, BackendResolution, SWAYIDLE_DEPRECATION_NOTICE}; use crate::config::{ScreenBackend, DEFAULT_IDLE_TIMEOUT, MAX_IDLE_TIMEOUT}; use super::{ @@ -77,42 +77,71 @@ pub(super) const RESTORE_POLICY: SettingDefinition = SettingDefinition { description: "Screen restore policy after LG Buddy blanks the configured screen.", }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum BackendPresentation { Raw, - Resolved(Option), + Resolved(Result), } -pub(super) fn presentation_for_command(command: &SettingsCommand) -> BackendPresentation { +pub(super) fn presentation_for_command( + command: &SettingsCommand, + configured: Option<&str>, +) -> BackendPresentation { let describes_backend = match command { SettingsCommand::Describe(None) => true, SettingsCommand::Describe(Some(key)) => key == "screen.backend", _ => false, }; - if describes_backend { - BackendPresentation::Resolved(detect_backend_from_system(ScreenBackend::Auto).ok()) - } else { - BackendPresentation::Raw + let Some(configured) = configured.and_then(|value| value.parse::().ok()) else { + return BackendPresentation::Raw; + }; + if !describes_backend { + return BackendPresentation::Raw; } + + BackendPresentation::Resolved( + resolve_backend_from_system(configured).map_err(|err| err.to_string()), + ) } -pub(super) fn format_backend_choice(value: &str, presentation: BackendPresentation) -> String { - if value != ScreenBackend::Auto.as_str() { - return value.to_string(); +pub(super) fn format_backend_choice(value: &str) -> String { + if value == ScreenBackend::Swayidle.as_str() { + format!("{value} (deprecated compatibility backend)") + } else { + value.to_string() } +} +pub(super) fn resolution_details( + configured: &str, + presentation: &BackendPresentation, +) -> Option<(String, String)> { match presentation { - BackendPresentation::Raw => value.to_string(), - BackendPresentation::Resolved(Some(backend)) => { - format!("{value} ({})", backend.as_str()) - } - BackendPresentation::Resolved(None) => { - format!("{value} (no backend currently available)") + BackendPresentation::Raw => None, + BackendPresentation::Resolved(Ok(resolution)) => Some(( + resolution.backend().as_str().to_string(), + resolution + .fallback_reason() + .unwrap_or_else(|| { + if configured == ScreenBackend::Auto.as_str() { + "none; preferred backend is available" + } else { + "none; explicit selection does not fall back" + } + }) + .to_string(), + )), + BackendPresentation::Resolved(Err(reason)) => { + Some(("unavailable".to_string(), reason.clone())) } } } +pub(super) fn deprecation_notice(configured: &str) -> Option<&'static str> { + (configured == ScreenBackend::Swayidle.as_str()).then_some(SWAYIDLE_DEPRECATION_NOTICE) +} + pub(super) fn apply_service_restart( service_controller: &C, ) -> Result { @@ -175,10 +204,14 @@ mod tests { } #[test] - fn settings_runner_describe_annotates_auto_backend_without_changing_get() { + fn settings_runner_describe_distinguishes_auto_resolution_without_changing_get() { let store = ConfigEnvReader::parse("/tmp/config.env", "screen_backend=auto\n").into_store(); - let runner = SettingsCommandRunner::new(store) - .with_screen_backend_resolution(Some(ScreenBackend::Gnome)); + let runner = SettingsCommandRunner::new(store).with_screen_backend_presentation( + BackendPresentation::Resolved(Ok(BackendResolution::selected( + ScreenBackend::Gnome, + None, + ))), + ); let mut output = Vec::new(); runner @@ -189,8 +222,12 @@ mod tests { .unwrap(); let output = String::from_utf8(output).unwrap(); - assert!(output.contains(" current: auto (gnome)\n")); - assert!(output.contains(" allowed values: auto (gnome), gnome, wayland, swayidle\n")); + assert!(output.contains(" current: auto\n")); + assert!(output.contains(" resolved backend: gnome\n")); + assert!(output.contains(" fallback reason: none; preferred backend is available\n")); + assert!(output.contains( + " allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend)\n" + )); let mut raw_output = Vec::new(); runner @@ -205,7 +242,11 @@ mod tests { #[test] fn settings_runner_describe_reports_when_auto_has_no_available_backend() { let store = ConfigEnvReader::parse("/tmp/config.env", "screen_backend=auto\n").into_store(); - let runner = SettingsCommandRunner::new(store).with_screen_backend_resolution(None); + let runner = SettingsCommandRunner::new(store).with_screen_backend_presentation( + BackendPresentation::Resolved( + Err("native Wayland protocol is unavailable".to_string()), + ), + ); let mut output = Vec::new(); runner @@ -216,12 +257,65 @@ mod tests { .unwrap(); let output = String::from_utf8(output).unwrap(); - assert!(output.contains(" current: auto (no backend currently available)\n")); + assert!(output.contains(" current: auto\n")); + assert!(output.contains(" resolved backend: unavailable\n")); + assert!(output.contains(" fallback reason: native Wayland protocol is unavailable\n")); assert!(output.contains( - " allowed values: auto (no backend currently available), gnome, wayland, swayidle\n" + " allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend)\n" )); } + #[test] + fn settings_runner_marks_explicit_swayidle_as_deprecated() { + let store = + ConfigEnvReader::parse("/tmp/config.env", "screen_backend=swayidle\n").into_store(); + let runner = SettingsCommandRunner::new(store).with_screen_backend_presentation( + BackendPresentation::Resolved(Ok(BackendResolution::selected( + ScreenBackend::Swayidle, + None, + ))), + ); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Describe(Some("screen.backend".to_string())), + &mut output, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(" current: swayidle (deprecated compatibility backend)\n")); + assert!(output.contains(" resolved backend: swayidle\n")); + assert!(output.contains(" fallback reason: none; explicit selection does not fall back\n")); + assert!(output.contains(" deprecation: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0; use auto or wayland.\n")); + } + + #[test] + fn settings_runner_reports_an_explicit_wayland_capability_limit() { + let store = + ConfigEnvReader::parse("/tmp/config.env", "screen_backend=wayland\n").into_store(); + let runner = SettingsCommandRunner::new(store).with_screen_backend_presentation( + BackendPresentation::Resolved(Err( + "backend `wayland` is unavailable: the compositor advertises ext_idle_notifier_v1 version 1; version 2 or newer is required" + .to_string(), + )), + ); + let mut output = Vec::new(); + + runner + .run( + SettingsCommand::Describe(Some("screen.backend".to_string())), + &mut output, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(" current: wayland\n")); + assert!(output.contains(" resolved backend: unavailable\n")); + assert!(output.contains("ext_idle_notifier_v1 version 1; version 2 or newer is required\n")); + } + #[test] fn settings_runner_sets_value_and_restarts_active_screen_service() { let path = unique_test_path("set"); diff --git a/crates/lg-buddy/src/sources/desktop/swayidle.rs b/crates/lg-buddy/src/sources/desktop/swayidle.rs index e56dc4e..ac5c10c 100644 --- a/crates/lg-buddy/src/sources/desktop/swayidle.rs +++ b/crates/lg-buddy/src/sources/desktop/swayidle.rs @@ -73,7 +73,7 @@ pub struct SystemSwayidleProbe; impl SwayidleProbe for SystemSwayidleProbe { fn swayidle_available(&self) -> bool { - let probe = SystemBackendProbe; + let probe = SystemBackendProbe::default(); probe.has_command("swayidle") } diff --git a/crates/lg-buddy/src/sources/desktop/wayland.rs b/crates/lg-buddy/src/sources/desktop/wayland.rs index af32659..13f5b9c 100644 --- a/crates/lg-buddy/src/sources/desktop/wayland.rs +++ b/crates/lg-buddy/src/sources/desktop/wayland.rs @@ -302,6 +302,32 @@ where } } +#[derive(Default)] +struct WaylandCapabilityProbeState { + registry_facts: RegistryFacts, +} + +impl Dispatch for WaylandCapabilityProbeState { + fn event( + state: &mut Self, + _: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + wl_registry::Event::Global { + name, + interface, + version, + } => state.registry_facts.add(name, interface.as_str(), version), + wl_registry::Event::GlobalRemove { name } => state.registry_facts.remove(name), + _ => {} + } + } +} + impl Dispatch for WaylandProviderState where F: FnMut(Instant) -> bool + 'static, @@ -390,11 +416,18 @@ pub(crate) fn connect_wayland() -> Result { Connection::connect_to_env().map_err(|err| WaylandProviderError::Connection(err.to_string())) } -pub(crate) fn probe_wayland_capabilities( +pub(crate) fn probe_wayland_capabilities_on( + connection: Connection, ) -> Result { - let connection = connect_wayland()?; - let (_, _, capabilities) = initialize_provider(connection, |_| true)?; - Ok(capabilities) + let display = connection.display(); + let mut event_queue = connection.new_event_queue(); + let queue_handle = event_queue.handle(); + let _registry = display.get_registry(&queue_handle, ()); + let mut state = WaylandCapabilityProbeState::default(); + event_queue + .roundtrip(&mut state) + .map_err(|err| WaylandProviderError::Dispatch(err.to_string()))?; + state.registry_facts.capabilities() } pub(crate) fn run_wayland_activity_monitor( diff --git a/crates/lg-buddy/tests/cucumber_support/world.rs b/crates/lg-buddy/tests/cucumber_support/world.rs index cc51771..2d2d18d 100644 --- a/crates/lg-buddy/tests/cucumber_support/world.rs +++ b/crates/lg-buddy/tests/cucumber_support/world.rs @@ -1,6 +1,7 @@ use crate::support::{ - ExecutableScript, MockBscpylgtv, MockNmOnline, MockSessionBusIdleMonitor, MockSwayidle, - MockSystemLogind, RuntimeStateLayout, TestConfigFile, TestEnv, + prime_isolated_path_dependencies, ExecutableScript, MockBscpylgtv, MockNmOnline, + MockSessionBusIdleMonitor, MockSwayidle, MockSystemLogind, RuntimeStateLayout, TestConfigFile, + TestEnv, }; use crate::web_os::{MockWebOsTv, MockWebOsTvSnapshot, MockWebOsVersion, VALID_WEBOS_ACCESS_TOKEN}; use cucumber::World; @@ -387,7 +388,11 @@ exit 1\n", } pub fn isolate_path(&mut self) { + prime_isolated_path_dependencies(); self.ensure_env().set("PATH", ""); + self.ensure_env().remove("WAYLAND_DISPLAY"); + self.ensure_env().remove("WAYLAND_SOCKET"); + self.ensure_env().remove("XDG_RUNTIME_DIR"); } pub fn set_backend_override(&mut self, backend: &str) { diff --git a/crates/lg-buddy/tests/features/detect_backend.feature b/crates/lg-buddy/tests/features/detect_backend.feature index 136c0d2..1b50742 100644 --- a/crates/lg-buddy/tests/features/detect_backend.feature +++ b/crates/lg-buddy/tests/features/detect_backend.feature @@ -10,7 +10,7 @@ Feature: Detect backend Then the command succeeds And stdout is "gnome" - Scenario: swayidle is selected when GNOME is unavailable + Scenario: swayidle is selected when GNOME and native Wayland are unavailable Given a temporary LG Buddy config using input HDMI_2 And the executable PATH is isolated And swayidle is installed @@ -35,3 +35,4 @@ Feature: Detect backend When I run the command "detect-backend" Then the command fails And stderr contains "org.gnome.Mutter.IdleMonitor" + And stderr contains "native Wayland unavailable" diff --git a/crates/lg-buddy/tests/features/monitor_gnome.feature b/crates/lg-buddy/tests/features/monitor_gnome.feature index 6474ed8..196dd81 100644 --- a/crates/lg-buddy/tests/features/monitor_gnome.feature +++ b/crates/lg-buddy/tests/features/monitor_gnome.feature @@ -17,6 +17,15 @@ Feature: GNOME monitor And the TV client did not receive "turn_screen_off" And the TV client did not receive "turn_screen_on" + Scenario: unavailable idle backends do not suppress the session notification service + Given a temporary LG Buddy config using input HDMI_2 + And the executable PATH is isolated + And GNOME monitor stays open for 0.1 seconds + When I run the command "monitor" + Then the command succeeds + And stdout contains "session notification service unavailable" + And stdout contains "screen idle backend unavailable" + Scenario: GNOME ScreenSaver idle does not bypass the LG Buddy timeout Given a temporary LG Buddy config using input HDMI_2 And the idle timeout is 2 seconds diff --git a/crates/lg-buddy/tests/features/monitor_swayidle.feature b/crates/lg-buddy/tests/features/monitor_swayidle.feature index f49ec45..8fe2450 100644 --- a/crates/lg-buddy/tests/features/monitor_swayidle.feature +++ b/crates/lg-buddy/tests/features/monitor_swayidle.feature @@ -12,6 +12,7 @@ Feature: swayidle monitor And swayidle will emit an idle timeout When I run the command "monitor" Then the command succeeds + And stdout contains "swayidle is a deprecated compatibility backend" And the TV client received "get_input" And the TV client received "turn_screen_off" And the session marker exists diff --git a/crates/lg-buddy/tests/features/settings.feature b/crates/lg-buddy/tests/features/settings.feature index 6232b40..57c50ff 100644 --- a/crates/lg-buddy/tests/features/settings.feature +++ b/crates/lg-buddy/tests/features/settings.feature @@ -45,34 +45,51 @@ Feature: Settings CLI And stdout contains "settings" And stdout does not contain "detect-backend" - Scenario: settings describe annotates auto with the resolved GNOME backend + Scenario: settings describe distinguishes auto from the resolved GNOME backend Given a temporary LG Buddy config using input HDMI_2 And GNOME Shell is available And the executable PATH is isolated When I run the command "settings describe screen.backend" Then the command succeeds - And stdout contains "current: auto (gnome)" - And stdout contains "allowed values: auto (gnome), gnome, wayland, swayidle" + And stdout contains "current: auto" + And stdout contains "resolved backend: gnome" + And stdout contains "fallback reason: none; preferred backend is available" + And stdout contains "allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend)" When I run the command "settings get screen.backend" Then the command succeeds And stdout is "auto" - Scenario: settings describe annotates auto with the swayidle fallback + Scenario: settings describe explains the swayidle compatibility fallback Given a temporary LG Buddy config using input HDMI_2 And the executable PATH is isolated And swayidle is installed When I run the command "settings describe screen.backend" Then the command succeeds - And stdout contains "current: auto (swayidle)" - And stdout contains "allowed values: auto (swayidle), gnome, wayland, swayidle" + And stdout contains "current: auto" + And stdout contains "resolved backend: swayidle" + And stdout contains "native Wayland unavailable" + And stdout contains "allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend)" Scenario: settings describe remains available without a detected backend Given a temporary LG Buddy config using input HDMI_2 And the executable PATH is isolated When I run the command "settings describe screen.backend" Then the command succeeds - And stdout contains "current: auto (no backend currently available)" - And stdout contains "allowed values: auto (no backend currently available), gnome, wayland, swayidle" + And stdout contains "current: auto" + And stdout contains "resolved backend: unavailable" + And stdout contains "native Wayland unavailable" + And stdout contains "allowed values: auto, gnome, wayland, swayidle (deprecated compatibility backend)" + + Scenario: settings describe marks an explicit swayidle selection as deprecated + Given a temporary LG Buddy config using input HDMI_2 + And the executable PATH is isolated + And systemd apply actions are skipped + When I run the command "settings set screen.backend swayidle" + Then the command succeeds + When I run the command "settings describe screen.backend" + Then the command succeeds + And stdout contains "current: swayidle (deprecated compatibility backend)" + And stdout contains "planned for removal in LG Buddy 2.0.0" Scenario: settings describe shows required TV operations Given a temporary LG Buddy config using input HDMI_2 diff --git a/crates/lg-buddy/tests/support/mod.rs b/crates/lg-buddy/tests/support/mod.rs index 4d624cd..7d78738 100644 --- a/crates/lg-buddy/tests/support/mod.rs +++ b/crates/lg-buddy/tests/support/mod.rs @@ -1574,6 +1574,12 @@ fn env_lock() -> &'static Mutex<()> { ENV_LOCK.get_or_init(|| Mutex::new(())) } +#[allow(dead_code)] +pub fn prime_isolated_path_dependencies() { + let _ = python3_path(); + let _ = dbus_daemon_path(); +} + fn python3_path() -> PathBuf { static PYTHON3_PATH: OnceLock = OnceLock::new(); diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index e0113ea..75cadc8 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -284,7 +284,7 @@ The intended split is: - native Wake-on-LAN packet generation and UDP send - `backend.rs` - backend selection and detection - - `auto`, `gnome`, explicit `wayland`, and `swayidle` support + - `auto`, `gnome`, native `wayland`, and deprecated `swayidle` compatibility - `session.rs` - backend-neutral session event model - capability surface for desktop backends @@ -623,10 +623,10 @@ Selection order: Detection behavior: - `auto` prefers GNOME when the current session satisfies the full GNOME contract and the session bus is reachable -- otherwise falls back to `swayidle` if installed -- explicit `wayland` validates `ext_idle_notifier_v1` version 2 or newer plus - at least one advertised seat and does not fall back -- `auto` does not select native Wayland yet +- native `wayland` validates `ext_idle_notifier_v1` version 2 or newer plus at + least one advertised seat; explicit selection does not fall back +- `auto` prefers complete GNOME, then compatible native Wayland, then the + deprecated `swayidle` compatibility backend when installed - other forced backends validate their required services or commands ## TV Integration Boundary @@ -840,9 +840,11 @@ asymmetric: handled by the NetworkManager pre-down gate plus logind lifecycle service instead -`swayidle` remains the external-tool compatibility backend while native -Wayland is explicit opt-in. Automatic native selection and later deprecation of -the delegated path are separate work. +`swayidle` remains an explicit and automatic compatibility fallback during the +1.x migration window, but emits a deprecation notice and is not offered by +fresh interactive configuration. Removal is planned for 2.0.0 after native +Wayland remains field-validated across supported compositors and unsupported +sessions have precise diagnostics. ## Configuration and Override Surface diff --git a/docs/development.md b/docs/development.md index 376dd9c..084dd8b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,7 +18,7 @@ testing release bundles also requires: Backend-specific tools used in development and local testing: -- `swayidle` for the `swayidle` monitor backend +- `swayidle` only when testing the deprecated compatibility backend - readable `/dev/input/event*` devices for local gamepad activity testing - readable `/dev/hidraw*` devices when testing the Logitech G923 raw HID fallback diff --git a/docs/runtime-event-handler-map.md b/docs/runtime-event-handler-map.md index 58cfd04..dde6cf3 100644 --- a/docs/runtime-event-handler-map.md +++ b/docs/runtime-event-handler-map.md @@ -97,8 +97,8 @@ Examples: ### Native Inactivity Path -The native inactivity path is used by GNOME and the explicit native Wayland -backend. Both feed activity facts into the same inactivity model instead of +The native inactivity path is used by GNOME and native Wayland, including +Wayland selected by `auto`. Both feed activity facts into the same inactivity model instead of delegating blank/restore commands to an external tool. ```text @@ -153,8 +153,9 @@ swayidle timeout/resume currently starts `swayidle` with direct `screen off` and `screen on` commands. Those richer hook events are not consumed by the monitor runner. -This path exists for current non-GNOME Wayland support. It is delegated, but it -is not a separate screen-policy quirks mode: `swayidle` re-enters LG Buddy +This deprecated path remains for existing explicit selections and as an +automatic compatibility fallback on unsupported native sessions. It is +delegated, but it is not a separate screen-policy quirks mode: `swayidle` re-enters LG Buddy through the same CLI/API command surface as manual `screen off` and `screen on`. Retiring it means replacing delegated timeout/resume execution with native idle/activity facts that feed the same inactivity engine used by the current @@ -283,9 +284,9 @@ The current architecture has the Linux lifecycle sources, screen policy, lifecycle policy, runtime phase guard, and source adapter namespace in place. Remaining work should stay scoped: -1. Keep native Wayland idle replacement separate from the logind lifecycle path. -2. Keep `swayidle` available while native Wayland remains explicit opt-in; - automatic promotion and deprecation are separate work. +1. Keep native Wayland monitoring separate from the logind lifecycle path. +2. Keep `swayidle` working without rewriting existing configuration throughout + the documented 1.x compatibility window. 3. Preserve the one-lifecycle-owner invariant in installer, release-bundle, and uninstall tests. 4. Treat future platform lifecycle providers, such as a possible macOS provider, diff --git a/docs/session-backend-model.md b/docs/session-backend-model.md index 3cfffd5..c1aa182 100644 --- a/docs/session-backend-model.md +++ b/docs/session-backend-model.md @@ -168,8 +168,8 @@ facts; it does not acquire gamepad responsibility. ### Native Wayland -The explicit `wayland` backend requires `ext_idle_notifier_v1` version 2 or -newer and at least one advertised `wl_seat`. It monitors every seat, including +The native `wayland` backend requires `ext_idle_notifier_v1` version 2 or newer +and at least one advertised `wl_seat`. It monitors every seat, including seats that currently advertise no input capabilities, using zero-timeout idle notifications. `resumed` maps to desktop activity; `idled` remains observational, so only LG Buddy's inactivity deadline can trigger blanking. @@ -177,8 +177,8 @@ observational, so only LG Buddy's inactivity deadline can trigger blanking. Seats are added and removed dynamically. Connection or dispatch loss, removal of the bound notifier, or removal of the last seat is fatal to the provider and causes the user service to retry. Explicit selection reports capability errors -without falling back. `auto` does not select this backend yet, and `swayidle` -remains available. +without falling back. `auto` selects native Wayland after the complete GNOME +contract and before the deprecated `swayidle` compatibility backend. ### `swayidle` @@ -195,6 +195,9 @@ Current mapping: Notes: +- `swayidle` is deprecated, remains accepted for existing explicit selections, + and is planned for removal in 2.0.0 after the native provider remains + field-validated across supported compositors and the 1.x migration window. - `swayidle` does not provide a clear equivalent of GNOME's `WakeRequested`. - `swayidle` does not provide a Mutter-style early activity surface. - LG Buddy owns the configured timeout value for this backend. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 92e9d05..53df140 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -242,12 +242,13 @@ Examples: - gamepad activity integration with the LG Buddy inactivity deadline - screen runtime-phase eligibility over the private logind system-bus seam -Native Wayland changes also require manual opt-in checks on Plasma/KWin and at -least one other target compositor. Verify that explicit `wayland` detection and -monitor startup succeed, unsupported capability or connection cases fail with -a precise diagnostic, and `auto` retains its existing GNOME-then-`swayidle` -selection. Release-facing changes must keep the static x86_64 musl build and -release-bundle smoke test green. +Native Wayland changes also require manual checks on Plasma/KWin and at least +one other target compositor. Verify that explicit and automatic `wayland` +detection and monitor startup succeed, unsupported capability or connection +cases report a precise fallback reason, and `auto` retains the +GNOME-then-native-Wayland-then-`swayidle` order. Release-facing changes must +keep the static x86_64 musl build and release-bundle smoke test green, including +preservation and deprecation reporting for an existing `swayidle` config. ### Gamepad activity diff --git a/docs/user-guide.md b/docs/user-guide.md index 8d8f4fd..4ba1df6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -83,7 +83,7 @@ Current settings are: | `tv.mac` | TV MAC address used for Wake-on-LAN. | | `tv.input` | Input LG Buddy manages, such as `HDMI_2`. | | `tv.platform` | TV control implementation: `bscpylgtv` or experimental `lg_webos`. | -| `screen.backend` | Desktop idle backend: `auto`, `gnome`, `wayland`, or `swayidle`. | +| `screen.backend` | Desktop idle backend: `auto`, `gnome`, `wayland`, or deprecated compatibility value `swayidle`. | | `screen.idle_blank` | Enable or disable automatic idle blanking. | | `screen.idle_timeout` | Seconds of inactivity before blanking; defaults to 300. | | `screen.restore_policy` | `conservative` or `aggressive` restore behavior. | @@ -129,10 +129,10 @@ The restore policies are: | Backend | When to use it | | --- | --- | -| `auto` | Default. Uses GNOME when the session is compatible, otherwise `swayidle` when installed. It does not select native Wayland yet. | +| `auto` | Default. Prefers compatible GNOME, then compatible native Wayland, then the deprecated `swayidle` fallback when installed. | | `gnome` | A GNOME Shell session with the required GNOME idle services. | -| `wayland` | A compatible recent Wayland compositor. This backend is currently opt-in. | -| `swayidle` | A Wayland session with `swayidle` installed. | +| `wayland` | Force native monitoring on a compositor that advertises `ext_idle_notifier_v1` version 2 or newer and at least one `wl_seat`. | +| `swayidle` | Deprecated compatibility backend for existing installations and older compositors. Fresh interactive configuration does not offer it. | Select a backend persistently: @@ -147,8 +147,9 @@ lg-buddy settings unset screen.backend ``` An explicitly selected backend reports a compatibility error rather than -silently switching to another backend. If native Wayland is unavailable, use -`auto` or `swayidle` instead. +silently switching to another backend. `auto` reports why it moved past GNOME +or native Wayland. Existing explicit `swayidle` selections remain valid and are +never silently rewritten, but emit a deprecation notice. Check the selected backend and user service: @@ -158,8 +159,16 @@ systemctl --user status LG_Buddy_screen.service journalctl --user -u LG_Buddy_screen.service --since today ``` -For `auto`, `settings describe` also shows the backend currently detected, such -as `auto (gnome)` or `auto (swayidle)`. +For `auto`, `settings describe` prints the configured selection, resolved +backend, and fallback reason separately. Unsupported native sessions report +the compositor connection or protocol limitation before using `swayidle` or +reporting that no backend is available. + +The `swayidle` compatibility window lasts through the 1.x release line, with +removal planned for 2.0.0. Removal requires native Wayland monitoring to remain +field-validated on supported non-GNOME compositors, precise unsupported-session +diagnostics, and a released migration window in which existing configurations +continue to run without being rewritten. ### Gamepad Activity diff --git a/install.sh b/install.sh index f97a10f..244a2cb 100755 --- a/install.sh +++ b/install.sh @@ -453,8 +453,10 @@ else fi ;; swayidle) - if command -v swayidle &>/dev/null; then - echo " [OK] swayidle (configured backend)" + echo " [WARNING] swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0" + echo " Select auto or wayland when the compositor supports ext_idle_notifier_v1 version 2 or newer." + if command -v swayidle >/dev/null 2>&1; then + echo " [OK] swayidle (configured compatibility backend)" SCREEN_MONITOR_AVAILABLE=1 SCREEN_MONITOR_RUNTIME_BACKEND="swayidle" else @@ -463,19 +465,23 @@ else fi ;; *) - if command -v swayidle &>/dev/null; then - echo " [OK] swayidle (wlroots/COSMIC backend)" - SCREEN_MONITOR_AVAILABLE=1 - else - echo " [OPTIONAL] swayidle (required for wlroots/COSMIC backend)" - fi - - SCREEN_MONITOR_RUNTIME_BACKEND="$("$RUNTIME_BINARY" detect-backend 2>/dev/null || true)" - if [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ]; then + SCREEN_MONITOR_DIAGNOSTICS="$("$RUNTIME_BINARY" settings describe screen.backend 2>/dev/null || true)" + SCREEN_MONITOR_RUNTIME_BACKEND="$(printf '%s\n' "$SCREEN_MONITOR_DIAGNOSTICS" | sed -n 's/^ resolved backend: //p' | tail -n1)" + SCREEN_MONITOR_FALLBACK_REASON="$(printf '%s\n' "$SCREEN_MONITOR_DIAGNOSTICS" | sed -n 's/^ fallback reason: //p' | tail -n1)" + if [ -n "$SCREEN_MONITOR_RUNTIME_BACKEND" ] && [ "$SCREEN_MONITOR_RUNTIME_BACKEND" != "unavailable" ]; then SCREEN_MONITOR_AVAILABLE=1 echo " [OK] current session backend: $SCREEN_MONITOR_RUNTIME_BACKEND" + if [ -n "$SCREEN_MONITOR_FALLBACK_REASON" ] && [ "$SCREEN_MONITOR_FALLBACK_REASON" != "none; preferred backend is available" ]; then + echo " [INFO] fallback reason: $SCREEN_MONITOR_FALLBACK_REASON" + fi + if [ "$SCREEN_MONITOR_RUNTIME_BACKEND" = "swayidle" ]; then + echo " [WARNING] using deprecated swayidle compatibility fallback; planned for removal in LG Buddy 2.0.0" + fi else echo " [INFO] no supported backend detected in the current session" + if [ -n "$SCREEN_MONITOR_FALLBACK_REASON" ]; then + echo " $SCREEN_MONITOR_FALLBACK_REASON" + fi echo " The user-session service will retry until a supported backend is available." fi ;; diff --git a/scripts/test-cross-version-upgrade.sh b/scripts/test-cross-version-upgrade.sh index c619179..7d0bb7b 100755 --- a/scripts/test-cross-version-upgrade.sh +++ b/scripts/test-cross-version-upgrade.sh @@ -249,7 +249,7 @@ do done export LG_BUDDY_CONFIG="$CONFIG_FILE" -"$INSTALLED_BINARY" settings set screen.backend gnome +"$INSTALLED_BINARY" settings set screen.backend swayidle "$INSTALLED_BINARY" settings set screen.idle_timeout 900 "$INSTALLED_BINARY" settings set screen.restore_policy aggressive "$INSTALLED_BINARY" settings set screen.idle_blank disabled @@ -421,7 +421,7 @@ do grep -F -q "LG_BUDDY_CONFIG=$CONFIG_FILE" "$override" done -grep -q '^screen_backend=gnome$' "$CONFIG_FILE" +grep -q '^screen_backend=swayidle$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" grep -q '^screen_idle_blank=disabled$' "$CONFIG_FILE" @@ -432,6 +432,8 @@ grep -q '^tvs_primary_input=HDMI_4$' "$CONFIG_FILE" grep -q '^tvs_primary_platform=lg_webos$' "$CONFIG_FILE" grep -q '^updates_auto_check=disabled$' "$CONFIG_FILE" grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" +"$INSTALLED_BINARY" settings describe screen.backend \ + | grep -F -q 'deprecation: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0' python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ --manifest "$CANDIDATE_BUNDLE/release-manifest.json" \ diff --git a/scripts/test-production-upgrade-canary.sh b/scripts/test-production-upgrade-canary.sh index 1c0f582..809c818 100755 --- a/scripts/test-production-upgrade-canary.sh +++ b/scripts/test-production-upgrade-canary.sh @@ -172,7 +172,7 @@ VENV_MARKER="$INSTALL_ROOT/usr/bin/LG_Buddy_PIP/production-canary-native-marker" [ -f "$INSTALLED_POINTER" ] || fail "Baseline config pointer was not installed." export LG_BUDDY_CONFIG="$CONFIG_FILE" -"$INSTALLED_BINARY" settings set screen.backend gnome +"$INSTALLED_BINARY" settings set screen.backend swayidle "$INSTALLED_BINARY" settings set screen.idle_blank disabled "$INSTALLED_BINARY" settings set updates.auto_check disabled "$INSTALLED_BINARY" settings set updates.channel prerelease @@ -208,6 +208,8 @@ cmp -s "$POINTER_SNAPSHOT" "$INSTALLED_POINTER" || fail "Production upgrade chan cmp -s "$TOKEN_SNAPSHOT" "$NATIVE_TOKEN_FILE" || fail "Production upgrade changed the native credential." [ -e "$VENV_MARKER" ] || fail "Production native upgrade recreated the Python environment." "$INSTALLED_BINARY" settings get updates.channel | grep -q '^prerelease$' +"$INSTALLED_BINARY" settings describe screen.backend \ + | grep -F -q 'deprecation: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0' CANDIDATE_CHECK_OUTPUT="$WORK_DIR/candidate-update-check.output" UPDATE_CACHE_FILE="$XDG_CACHE_HOME/lg-buddy/update-check.json" diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index 3a81e4f..c0d0eb0 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -342,6 +342,26 @@ printf '%s\n' "$VERSION_OUTPUT" | grep -q "^version: " printf '%s\n' "$VERSION_OUTPUT" | grep -q "^channel: " printf '%s\n' "$VERSION_OUTPUT" | grep -q "^commit: " +FRESH_CONFIG_HOME="$WORK_DIR/fresh-config-home" +FRESH_CONFIG_OUTPUT="$WORK_DIR/fresh-config.output" +mkdir -p "$FRESH_CONFIG_HOME" +( + unset LG_BUDDY_NONINTERACTIVE LG_BUDDY_SCREEN_BACKEND LG_BUDDY_CONFIG + export HOME="$FRESH_CONFIG_HOME" + export XDG_CONFIG_HOME="$FRESH_CONFIG_HOME/.config" + export LG_BUDDY_RUNTIME_BINARY="$BUNDLE_DIR/lg-buddy" + export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="1" + printf '%s\n' \ + '192.0.2.10' 'aa:bb:cc:dd:ee:ff' '2' '1' 'Y' '1' '300' '1' 'Y' \ + | "$BUNDLE_DIR/configure.sh" >"$FRESH_CONFIG_OUTPUT" 2>&1 +) +grep -F -q ' 3) wayland' "$FRESH_CONFIG_OUTPUT" +if grep -F -q 'swayidle' "$FRESH_CONFIG_OUTPUT"; then + echo "Fresh interactive configuration presented swayidle." + exit 1 +fi +grep -q '^screen_backend=auto$' "$FRESH_CONFIG_HOME/.config/lg-buddy/config.env" + export HOME="$HOME_DIR" export XDG_CONFIG_HOME="$XDG_CONFIG_HOME" export LG_BUDDY_INSTALL_ROOT="$INSTALL_ROOT" @@ -445,7 +465,7 @@ printf '%s\n' "$NATIVE_PLATFORM_OUTPUT" | grep -F -q 'No stored native TV creden "$INSTALLED_BINARY" settings set tv.platform bscpylgtv grep -q '^tvs_primary_platform=bscpylgtv$' "$CONFIG_FILE" -"$INSTALLED_BINARY" settings set screen.backend gnome +"$INSTALLED_BINARY" settings set screen.backend swayidle "$INSTALLED_BINARY" settings set screen.idle_timeout 900 "$INSTALLED_BINARY" settings set screen.idle_timeout 90000 grep -q '^screen_idle_timeout=86400$' "$CONFIG_FILE" @@ -460,7 +480,7 @@ grep -q '^screen_idle_timeout=86400$' "$CONFIG_FILE" "$INSTALLED_BINARY" settings set updates.channel prerelease BACKGROUND_UPDATE_OUTPUT="$("$INSTALLED_BINARY" updates background-check)" printf '%s\n' "$BACKGROUND_UPDATE_OUTPUT" | grep -F -q 'background: skipped (automatic update checks disabled)' -grep -q '^screen_backend=gnome$' "$CONFIG_FILE" +grep -q '^screen_backend=swayidle$' "$CONFIG_FILE" grep -q '^screen_idle_blank=disabled$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" @@ -475,6 +495,7 @@ grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" # semantics as the Rust config parser, then persist the sanitized choice. sed -i 's/^tvs_primary_platform=bscpylgtv$/ tvs_primary_platform = bscpylgtv # legacy/' "$CONFIG_FILE" printf '%s\n' 'tvs_primary_platform = lg_webos # experimental' >>"$CONFIG_FILE" +LEGACY_CONFIGURE_OUTPUT="$WORK_DIR/legacy-configure.output" ( unset LG_BUDDY_SCREEN_BACKEND @@ -485,14 +506,16 @@ printf '%s\n' 'tvs_primary_platform = lg_webos # experimental' >>"$CONFIG_FILE" export LG_BUDDY_TV_MAC="11:22:33:44:55:66" export LG_BUDDY_INPUT="HDMI_3" cd "$BUNDLE_DIR" - ./configure.sh + ./configure.sh >"$LEGACY_CONFIGURE_OUTPUT" 2>&1 ) +grep -F -q 'Warning: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0' "$LEGACY_CONFIGURE_OUTPUT" + grep -q '^tvs_primary_ip=192.168.1.11$' "$CONFIG_FILE" grep -q '^tvs_primary_mac=11:22:33:44:55:66$' "$CONFIG_FILE" grep -q '^tvs_primary_input=HDMI_3$' "$CONFIG_FILE" grep -q '^tvs_primary_platform=lg_webos$' "$CONFIG_FILE" -grep -q '^screen_backend=gnome$' "$CONFIG_FILE" +grep -q '^screen_backend=swayidle$' "$CONFIG_FILE" grep -q '^screen_idle_blank=disabled$' "$CONFIG_FILE" grep -q '^screen_idle_timeout=900$' "$CONFIG_FILE" grep -q '^screen_restore_policy=aggressive$' "$CONFIG_FILE" @@ -717,6 +740,8 @@ cmp -s "$CONFIG_SNAPSHOT" "$CONFIG_FILE" || { echo "Upgrade changed the user configuration." exit 1 } +"$INSTALLED_BINARY" settings describe screen.backend \ + | grep -F -q 'deprecation: swayidle is a deprecated compatibility backend planned for removal in LG Buddy 2.0.0' cmp -s "$CONFIG_POINTER_SNAPSHOT" "$INSTALLED_POINTER" || { echo "Upgrade changed the installed config pointer." exit 1 From a0f832cd0a08926e5e31d90c41abdf0eaaa89ecb Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:32:41 +0300 Subject: [PATCH 19/22] feat(tv): default fresh profiles to native webOS --- README.md | 20 ++++--- configure.sh | 16 ++--- .../lg-buddy/tests/cucumber_support/steps.rs | 6 +- .../lg-buddy/tests/cucumber_support/world.rs | 7 ++- crates/lg-buddy/tests/features/webos.feature | 12 +++- docs/architecture-overview.md | 9 ++- docs/defaults-and-configuration.md | 16 ++--- docs/release-process.md | 4 ++ docs/testing-strategy.md | 6 +- docs/user-guide.md | 34 +++++++++-- scripts/test-cross-version-upgrade.sh | 1 + scripts/test-production-upgrade-canary.sh | 1 + scripts/test-release-bundle.sh | 59 ++++++++++++++++--- 13 files changed, 140 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index fbb3bf2..9af3b57 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,13 @@ selection and troubleshooting. Protocol and event details are documented in the ## Before You Install -The native `lg_webos` control path does not require Python. Native-only packages -can omit the Python client, `venv`, and `pip`. The current fresh-install flow -still provisions `bscpylgtv` as a compatibility fallback and installs the -brightness dialog, so release-bundle installation checks for Python 3 with a -`venv` that provisions `pip`, plus `zenity`. `swayidle` is needed only by an -existing explicit selection or as the deprecated compatibility fallback. +Fresh installation selects the native `lg_webos` control path, which does not +require Python. Native-only packages can omit the Python client, `venv`, and +`pip`. The release-bundle installer still provisions `bscpylgtv` as an explicit +compatibility fallback and installs the brightness dialog, so it checks for +Python 3 with a `venv` that provisions `pip`, plus `zenity`. `swayidle` is needed +only by an existing explicit selection or as the deprecated compatibility +fallback. ### Debian, Ubuntu, and Pop!_OS @@ -92,9 +93,10 @@ Do not run the installer with `sudo`; it requests elevated access when needed. The installer asks for the TV's IP address, MAC address, HDMI input, control platform, and desktop idle preferences, then installs the required services. -If you select the native `lg_webos` control platform, accept the pairing prompt -during setup. With the default `bscpylgtv` platform, the prompt may instead -appear on first use; see the +Fresh setup defaults to the native `lg_webos` platform and verifies pairing +before saving the configuration, so accept the prompt on the TV. You can +instead select the explicit `bscpylgtv` compatibility fallback; its prompt may +appear on first use. See the [bscpylgtv first-use guide](https://github.com/chros73/bscpylgtv/blob/master/docs/guides/first_use.md). To check, verify, and install the next release from your saved update channel, diff --git a/configure.sh b/configure.sh index d5f27a9..499a7c1 100755 --- a/configure.sh +++ b/configure.sh @@ -93,7 +93,9 @@ normalize_restore_policy() { current_tv_ip="" current_tv_mac="" current_input="HDMI_1" -current_tv_platform="$LG_BUDDY_DEFAULT_TV_PLATFORM" +# A missing platform in an existing profile remains the bscpylgtv compatibility +# default. Only a genuinely fresh profile starts from the native platform. +current_tv_platform="lg_webos" current_screen_backend="$LG_BUDDY_DEFAULT_SCREEN_BACKEND" current_screen_idle_blank="$LG_BUDDY_DEFAULT_SCREEN_IDLE_BLANK" current_screen_idle_timeout="$LG_BUDDY_DEFAULT_IDLE_TIMEOUT" @@ -272,20 +274,20 @@ else done echo "Choose the TV control platform:" - echo " 1) bscpylgtv (Python compatibility platform)" - echo " 2) lg_webos (native LG Buddy platform)" + echo " 1) lg_webos (native LG Buddy platform; recommended)" + echo " 2) bscpylgtv (Python compatibility fallback)" case "$current_tv_platform" in - bscpylgtv) default_platform_choice="1" ;; - lg_webos) default_platform_choice="2" ;; + lg_webos) default_platform_choice="1" ;; + bscpylgtv) default_platform_choice="2" ;; *) default_platform_choice="1" ;; esac while true; do PLATFORM_CHOICE="$(prompt_with_default "Enter number (1-2)" "$default_platform_choice")" case "$PLATFORM_CHOICE" in - 1) tv_platform="bscpylgtv"; break ;; - 2) tv_platform="lg_webos"; break ;; + 1) tv_platform="lg_webos"; break ;; + 2) tv_platform="bscpylgtv"; break ;; *) echo " Please enter 1 or 2." ;; esac done diff --git a/crates/lg-buddy/tests/cucumber_support/steps.rs b/crates/lg-buddy/tests/cucumber_support/steps.rs index c4a1256..a2f539d 100644 --- a/crates/lg-buddy/tests/cucumber_support/steps.rs +++ b/crates/lg-buddy/tests/cucumber_support/steps.rs @@ -354,9 +354,9 @@ fn run_command(world: &mut LgBuddyWorld, command: String) { world.run_named_command(&command); } -#[when("I choose native webOS during initial configuration")] -fn run_native_initial_configuration(world: &mut LgBuddyWorld) { - world.run_native_initial_configuration(); +#[when("I accept the default TV platform during initial configuration")] +fn run_default_initial_configuration(world: &mut LgBuddyWorld) { + world.run_default_initial_configuration(); } #[then("the command succeeds")] diff --git a/crates/lg-buddy/tests/cucumber_support/world.rs b/crates/lg-buddy/tests/cucumber_support/world.rs index 2d2d18d..cacbdbf 100644 --- a/crates/lg-buddy/tests/cucumber_support/world.rs +++ b/crates/lg-buddy/tests/cucumber_support/world.rs @@ -609,13 +609,13 @@ exit 1\n", }); } - pub fn run_native_initial_configuration(&mut self) { + pub fn run_default_initial_configuration(&mut self) { self.ensure_env().set("LG_BUDDY_NONINTERACTIVE", "1"); self.ensure_env().set("LG_BUDDY_TV_IP", "127.0.0.1"); self.ensure_env() .set("LG_BUDDY_TV_MAC", "22:33:44:55:66:77"); self.ensure_env().set("LG_BUDDY_INPUT", "HDMI_2"); - self.ensure_env().set("LG_BUDDY_TV_PLATFORM", "lg_webos"); + self.ensure_env().remove("LG_BUDDY_TV_PLATFORM"); self.ensure_env() .set("LG_BUDDY_RUNTIME_BINARY", env!("CARGO_BIN_EXE_lg-buddy")); self.ensure_env().set("LG_BUDDY_SKIP_SYSTEMD_ACTIONS", "1"); @@ -624,7 +624,8 @@ exit 1\n", .join("../..") .join("configure.sh"); let started = std::time::Instant::now(); - let output = ProcessCommand::new(configure) + let output = ProcessCommand::new("bash") + .arg(configure) .output() .expect("run initial configuration"); let duration = started.elapsed(); diff --git a/crates/lg-buddy/tests/features/webos.feature b/crates/lg-buddy/tests/features/webos.feature index ad39cc2..4f38bc6 100644 --- a/crates/lg-buddy/tests/features/webos.feature +++ b/crates/lg-buddy/tests/features/webos.feature @@ -3,10 +3,10 @@ Feature: Native webOS TV platform pairing when the user is setting up or actively controlling the TV without delaying shutdown, suspend, or network teardown when no stored credential is available. - Scenario: Initial configuration selects and pairs the native platform + Scenario: Fresh configuration defaults to and pairs the native platform Given an empty temporary LG Buddy config path And a native webOS26 TV on firmware 43.21.60 on input HDMI_2 with brightness 90 - When I choose native webOS during initial configuration + When I accept the default TV platform during initial configuration Then the command succeeds And stdout contains "TV Platform: lg_webos" And stdout contains "pairing required; accept the prompt on the TV" @@ -15,6 +15,12 @@ Feature: Native webOS TV platform And the native TV connection count is 1 And the native TV registration tokens are "none" And the native TV pairing prompt count is 1 + When I run the command "brightness get" + Then the command succeeds + And stdout is "90" + And the native TV connection count is 2 + And the native TV registration tokens are "none,webos-test-access-token" + And the native TV pairing prompt count is 1 Scenario: Opting in pairs the TV and the stored token authenticates later commands Given a temporary LG Buddy config using input HDMI_2 @@ -90,7 +96,7 @@ Feature: Native webOS TV platform And the native TV registration tokens are "webos-test-access-token" And the native TV pairing prompt count is 0 - Scenario: Unsetting native platform restores the default without preflight + Scenario: Unsetting native platform restores the missing-value compatibility default Given a temporary LG Buddy config using input HDMI_2 And the existing config selects TV platform "lg_webos" When I run the command "settings unset tv.platform" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 75cadc8..ce21640 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -679,9 +679,12 @@ remain inside the adapter. Wake-on-LAN keeps the configured network identity at ### TV Implementations -`tv.platform` selects the production TV implementation. `bscpylgtvcommand` -remains the compatibility default, including when an existing profile has no -platform value. `lg_webos` explicitly selects the native Rust implementation. +`tv.platform` selects the production TV implementation. Fresh profiles select +the native Rust `lg_webos` implementation and verify pairing before the profile +is saved. Existing profiles retain their explicit choice; a missing platform +value continues to resolve to `bscpylgtv` and is materialized as that +compatibility choice when configuration is rewritten. `bscpylgtvcommand` +remains available as an explicit fallback. The Rust runtime talks to it through `BscpylgtvCommandClient`, which: diff --git a/docs/defaults-and-configuration.md b/docs/defaults-and-configuration.md index 095f888..bc6e190 100644 --- a/docs/defaults-and-configuration.md +++ b/docs/defaults-and-configuration.md @@ -77,7 +77,7 @@ Good shapes: tvs_primary_ip=192.168.1.100 tvs_primary_mac=aa:bb:cc:dd:ee:ff tvs_primary_input=HDMI_2 -tvs_primary_platform=bscpylgtv +tvs_primary_platform=lg_webos screen_restore_policy=conservative screen_idle_blank=enabled system_sleep_wake_policy=enabled @@ -109,12 +109,14 @@ choice. `tvs_primary_platform` selects the control platform for the active TV profile: -- `bscpylgtv` remains the default, including for existing profiles where the - key is absent -- `lg_webos` is the experimental native Rust platform and can be selected - during initial configuration or with `lg-buddy settings set tv.platform lg_webos` -- initial native selection pairs before it is saved; ordinary TV operations - can also pair or repair credentials when needed +- fresh profiles select `lg_webos` and pair before the configuration is saved +- existing profiles keep their explicit platform; a missing platform continues + to resolve to `bscpylgtv` and is materialized as such when configuration is + rewritten +- `bscpylgtv` remains an accepted explicit compatibility fallback +- existing users can opt into native control with + `lg-buddy settings set tv.platform lg_webos`; ordinary foreground TV + operations can also pair or repair credentials when needed - shutdown, suspend, resume, startup, and network-teardown handling use stored credentials only and skip promptly when no credential is available - this is the only platform selector; there is no separate backend, adapter, diff --git a/docs/release-process.md b/docs/release-process.md index 8cf48b0..3e4fe43 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -65,6 +65,10 @@ There is no separate version input. response evidence for the deterministic mock. Stable promotion remains blocked until this exact prerelease commit has a successful canary. +Replace the publisher's generic release description with concise, +release-specific notes for user-visible default or compatibility changes before +announcing the release. + Do not push version tags manually. Protected `v*` tags and stream-alignment writes permit bypass only to the dedicated release App. A failed post-merge release run can be rerun safely: an incomplete draft remains private and is diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 53df140..2178a5d 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -148,8 +148,10 @@ It is useful when we want to express scenarios like: - when the user returns after LG Buddy blanked the TV, LG Buddy restores the screen - when aggressive restore policy is enabled, wake/activity can restore even without a marker - when GNOME is available, backend detection resolves to `gnome` -- when the user chooses `lg_webos` during initial configuration, pairing stores - the credential before setup completes +- when fresh configuration accepts the default `lg_webos` platform, pairing + stores the credential before setup completes +- when an existing profile has no platform value, configuration preserves and + materializes the `bscpylgtv` compatibility fallback - when native credentials are missing or stale, ordinary TV commands pair or repair them as part of the operation - when native credentials are missing, shutdown and suspend-related commands diff --git a/docs/user-guide.md b/docs/user-guide.md index 4ba1df6..d72ec4e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -82,7 +82,7 @@ Current settings are: | `tv.ip` | TV network address. | | `tv.mac` | TV MAC address used for Wake-on-LAN. | | `tv.input` | Input LG Buddy manages, such as `HDMI_2`. | -| `tv.platform` | TV control implementation: `bscpylgtv` or experimental `lg_webos`. | +| `tv.platform` | TV control implementation: native `lg_webos` or the `bscpylgtv` compatibility fallback. | | `screen.backend` | Desktop idle backend: `auto`, `gnome`, `wayland`, or deprecated compatibility value `swayidle`. | | `screen.idle_blank` | Enable or disable automatic idle blanking. | | `screen.idle_timeout` | Seconds of inactivity before blanking; defaults to 300. | @@ -185,10 +185,16 @@ input paths and hardware troubleshooting. `tv.platform` selects the TV control implementation: -- `bscpylgtv`: the compatibility default. -- `lg_webos`: the experimental native Rust webOS implementation. +- `lg_webos`: the native Rust implementation and fresh-profile default. +- `bscpylgtv`: the explicit Python compatibility fallback. -Select the native implementation with: +Fresh configuration verifies native pairing before it saves the profile. +Existing profiles retain their selected platform. If an older profile has no +platform key, it continues to resolve to `bscpylgtv`; rewriting that profile +through the configurator materializes the compatibility choice instead of +silently moving it to native control. + +Move an existing profile to the native implementation with: ```bash lg-buddy settings set tv.platform lg_webos @@ -200,12 +206,28 @@ commands can pair or repair credentials when necessary; unattended startup, shutdown, suspend, and resume handling use an existing credential and do not open a pairing prompt. -Return to the default with: +Select and persist the compatibility fallback with: ```bash -lg-buddy settings unset tv.platform +lg-buddy settings set tv.platform bscpylgtv ``` +`settings unset tv.platform` removes the explicit choice and therefore resolves +to `bscpylgtv` for legacy compatibility; it does not apply the fresh-profile +default. + +For support and troubleshooting, inspect the effective platform, its source, +and accepted values with: + +```bash +lg-buddy settings describe tv.platform +``` + +If native pairing or its power-state verification fails, setup leaves the +profile unsaved. Confirm that the TV is reachable and accept its pairing prompt, +then rerun configuration or `settings set tv.platform lg_webos`. Select +`bscpylgtv` explicitly if native control is not usable on that TV. + ## System Sleep And Wake Default installs power off the TV before system sleep and restore it after diff --git a/scripts/test-cross-version-upgrade.sh b/scripts/test-cross-version-upgrade.sh index 7d0bb7b..203a5c1 100755 --- a/scripts/test-cross-version-upgrade.sh +++ b/scripts/test-cross-version-upgrade.sh @@ -210,6 +210,7 @@ export LG_BUDDY_SKIP_PIP_INSTALL="1" export LG_BUDDY_TV_IP="192.168.50.20" export LG_BUDDY_TV_MAC="02:00:00:00:00:20" export LG_BUDDY_INPUT="HDMI_3" +export LG_BUDDY_TV_PLATFORM="bscpylgtv" export LG_BUDDY_SCREEN_BACKEND="auto" export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" export PIP_DISABLE_PIP_VERSION_CHECK="1" diff --git a/scripts/test-production-upgrade-canary.sh b/scripts/test-production-upgrade-canary.sh index 809c818..79ac5d9 100755 --- a/scripts/test-production-upgrade-canary.sh +++ b/scripts/test-production-upgrade-canary.sh @@ -152,6 +152,7 @@ export LG_BUDDY_SKIP_PIP_INSTALL="1" export LG_BUDDY_TV_IP="192.168.60.20" export LG_BUDDY_TV_MAC="02:00:00:00:00:60" export LG_BUDDY_INPUT="HDMI_3" +export LG_BUDDY_TV_PLATFORM="bscpylgtv" export LG_BUDDY_SCREEN_BACKEND="auto" export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" export PIP_DISABLE_PIP_VERSION_CHECK="1" diff --git a/scripts/test-release-bundle.sh b/scripts/test-release-bundle.sh index c0d0eb0..a48d567 100755 --- a/scripts/test-release-bundle.sh +++ b/scripts/test-release-bundle.sh @@ -344,17 +344,51 @@ printf '%s\n' "$VERSION_OUTPUT" | grep -q "^commit: " FRESH_CONFIG_HOME="$WORK_DIR/fresh-config-home" FRESH_CONFIG_OUTPUT="$WORK_DIR/fresh-config.output" +FRESH_NATIVE_RUNTIME="$WORK_DIR/fresh-native-runtime" +FRESH_NATIVE_PAIRING_MARKER="$WORK_DIR/fresh-native-pairing" +FRESH_NATIVE_TOKEN="$FRESH_CONFIG_HOME/.config/lg-buddy/tvs/primary/access-token.json" +cat >"$FRESH_NATIVE_RUNTIME" <<'EOF' +#!/bin/sh +set -eu + +[ "$#" -eq 4 ] && + [ "$1" = "settings" ] && + [ "$2" = "set" ] && + [ "$3" = "tv.platform" ] && + [ "$4" = "lg_webos" ] || exit 2 + +config_path="${LG_BUDDY_CONFIG:?}" +token_dir="$(dirname "$config_path")/tvs/primary" +sed -i 's/^tvs_primary_platform=bscpylgtv$/tvs_primary_platform=lg_webos/' "$config_path" +mkdir -p "$token_dir" +chmod 700 "$(dirname "$token_dir")" "$token_dir" +printf '{\n "access_token": "release-smoke-native-token"\n}\n' >"$token_dir/access-token.json" +chmod 600 "$token_dir/access-token.json" +: >"${LG_BUDDY_NATIVE_PAIRING_MARKER:?}" +echo "LG Buddy native webOS preflight: pairing required; accept the prompt on the TV." +echo "LG Buddy native webOS preflight: stored access token at $token_dir/access-token.json" +echo "LG Buddy native webOS preflight succeeded: power_state=Active" +EOF +chmod 755 "$FRESH_NATIVE_RUNTIME" mkdir -p "$FRESH_CONFIG_HOME" ( - unset LG_BUDDY_NONINTERACTIVE LG_BUDDY_SCREEN_BACKEND LG_BUDDY_CONFIG + unset LG_BUDDY_NONINTERACTIVE LG_BUDDY_TV_PLATFORM LG_BUDDY_SCREEN_BACKEND LG_BUDDY_CONFIG export HOME="$FRESH_CONFIG_HOME" export XDG_CONFIG_HOME="$FRESH_CONFIG_HOME/.config" - export LG_BUDDY_RUNTIME_BINARY="$BUNDLE_DIR/lg-buddy" + export LG_BUDDY_RUNTIME_BINARY="$FRESH_NATIVE_RUNTIME" + export LG_BUDDY_NATIVE_PAIRING_MARKER="$FRESH_NATIVE_PAIRING_MARKER" export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="1" printf '%s\n' \ - '192.0.2.10' 'aa:bb:cc:dd:ee:ff' '2' '1' 'Y' '1' '300' '1' 'Y' \ + '192.0.2.10' 'aa:bb:cc:dd:ee:ff' '2' '' 'Y' '1' '300' '1' 'Y' \ | "$BUNDLE_DIR/configure.sh" >"$FRESH_CONFIG_OUTPUT" 2>&1 ) +grep -F -q 'TV Platform: lg_webos' "$FRESH_CONFIG_OUTPUT" +grep -F -q 'pairing required; accept the prompt on the TV' "$FRESH_CONFIG_OUTPUT" +grep -q '^tvs_primary_platform=lg_webos$' "$FRESH_CONFIG_HOME/.config/lg-buddy/config.env" +assert_file "$FRESH_NATIVE_PAIRING_MARKER" +assert_file "$FRESH_NATIVE_TOKEN" +assert_mode "$FRESH_NATIVE_TOKEN" 600 +python3 -c 'import json, sys; assert json.load(open(sys.argv[1], encoding="utf-8")) == {"access_token": "release-smoke-native-token"}' "$FRESH_NATIVE_TOKEN" grep -F -q ' 3) wayland' "$FRESH_CONFIG_OUTPUT" if grep -F -q 'swayidle' "$FRESH_CONFIG_OUTPUT"; then echo "Fresh interactive configuration presented swayidle." @@ -371,6 +405,7 @@ export LG_BUDDY_SKIP_SYSTEMD_ACTIONS="1" export LG_BUDDY_TV_IP="192.168.1.10" export LG_BUDDY_TV_MAC="aa:bb:cc:dd:ee:ff" export LG_BUDDY_INPUT="HDMI_2" +export LG_BUDDY_TV_PLATFORM="bscpylgtv" export LG_BUDDY_SCREEN_BACKEND="auto" export LG_BUDDY_SYSTEM_SLEEP_WAKE_POLICY="enabled" export PIP_DISABLE_PIP_VERSION_CHECK="1" @@ -449,12 +484,19 @@ python3 "$SCRIPT_DIR/release_bundle_manifest.py" validate \ --binary "$INSTALLED_BINARY" \ "${MANIFEST_EXPECTATIONS[@]}" -# Existing profiles without the platform key remain on bscpylgtv. Materialize -# that choice through settings, then use a controlled raw-config fixture to -# prove an unpaired native shutdown skips immediately without contacting a TV. +# Existing profiles without the platform key remain on bscpylgtv. Rewriting +# one through configure.sh materializes that choice instead of applying the +# fresh-profile default. Then use a controlled raw-config fixture to prove an +# unpaired native shutdown skips immediately without contacting a TV. sed -i '/^tvs_primary_platform=/d' "$CONFIG_FILE" "$INSTALLED_BINARY" settings get tv.platform | grep -q '^bscpylgtv$' -"$INSTALLED_BINARY" settings set tv.platform bscpylgtv +LEGACY_MISSING_CONFIGURE_OUTPUT="$WORK_DIR/legacy-missing-configure.output" +( + unset LG_BUDDY_TV_PLATFORM + cd "$BUNDLE_DIR" + ./configure.sh >"$LEGACY_MISSING_CONFIGURE_OUTPUT" 2>&1 +) +grep -F -q 'TV Platform: bscpylgtv' "$LEGACY_MISSING_CONFIGURE_OUTPUT" grep -q '^tvs_primary_platform=bscpylgtv$' "$CONFIG_FILE" sed -i 's/^tvs_primary_platform=bscpylgtv$/tvs_primary_platform=lg_webos/' "$CONFIG_FILE" @@ -494,10 +536,11 @@ grep -q '^updates_channel=prerelease$' "$CONFIG_FILE" # Configure should read inline-commented platform values with the same value # semantics as the Rust config parser, then persist the sanitized choice. sed -i 's/^tvs_primary_platform=bscpylgtv$/ tvs_primary_platform = bscpylgtv # legacy/' "$CONFIG_FILE" -printf '%s\n' 'tvs_primary_platform = lg_webos # experimental' >>"$CONFIG_FILE" +printf '%s\n' 'tvs_primary_platform = lg_webos # native' >>"$CONFIG_FILE" LEGACY_CONFIGURE_OUTPUT="$WORK_DIR/legacy-configure.output" ( + unset LG_BUDDY_TV_PLATFORM unset LG_BUDDY_SCREEN_BACKEND unset LG_BUDDY_SCREEN_IDLE_TIMEOUT unset LG_BUDDY_SCREEN_RESTORE_POLICY From db010c182df0910f250d434a046a2c69db2835f7 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:05:24 +0300 Subject: [PATCH 20/22] fix(lifecycle): retry NM-owned suspend failures --- crates/lg-buddy/src/lifecycle.rs | 5 +- .../src/sources/linux/network_manager.rs | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/lg-buddy/src/lifecycle.rs b/crates/lg-buddy/src/lifecycle.rs index 4a06eed..7d67576 100644 --- a/crates/lg-buddy/src/lifecycle.rs +++ b/crates/lg-buddy/src/lifecycle.rs @@ -1168,7 +1168,10 @@ fn handle_network_teardown_rail_disposition( disposition: SuspendRailDisposition, outcome: &mut PolicyOutcome, ) -> Result<(), RunError> { - if disposition != SuspendRailDisposition::JoinedInProgress { + if !matches!( + disposition, + SuspendRailDisposition::JoinedInProgress | SuspendRailDisposition::RetryableFailure + ) { return Ok(()); } diff --git a/crates/lg-buddy/src/sources/linux/network_manager.rs b/crates/lg-buddy/src/sources/linux/network_manager.rs index ebec3e4..231c6fa 100644 --- a/crates/lg-buddy/src/sources/linux/network_manager.rs +++ b/crates/lg-buddy/src/sources/linux/network_manager.rs @@ -323,6 +323,75 @@ mod tests { assert!(output.contains("Turning off for sleep")); } + #[test] + fn pre_down_retries_when_network_manager_owned_cycle_reports_retryable_failure() { + let temp_dir = TestDir::new("nm-pre-down-owned-retryable"); + let marker = ScreenOwnershipMarker::new(temp_dir.path().to_path_buf()); + let attempt_state = SystemSleepAttemptState::new(temp_dir.path().to_path_buf()); + let mock = MockBscpylgtv::new("nm-pre-down-owned-retryable-tv"); + mock.set_input("HDMI_2"); + mock.queue_error("power_off", 1, "offline"); + let client = client_for_mock(&mock); + let sleeper = RecordingSleeper::default(); + let mut bus = FakeBus::preparing_for_sleep(true); + let mut output = Vec::new(); + + handle_pre_down_with( + &mut output, + &sample_config(SystemSleepWakePolicy::Enabled), + &marker, + &attempt_state, + &client, + &sleeper, + &mut bus, + ) + .expect("pre-down should retry its own retryable rail failure"); + + assert_eq!( + attempt_state.read_outcome().expect("read cycle outcome"), + Some(SystemSleepCycleOutcome::Completed) + ); + assert!(marker.exists()); + assert!(!attempt_state.exists()); + assert_call_commands(&mock, &["get_input", "power_off", "get_input", "power_off"]); + assert!(rendered(&output).contains("retrying before network teardown")); + } + + #[test] + fn pre_down_stops_after_network_manager_owned_follow_up_failure() { + let temp_dir = TestDir::new("nm-pre-down-owned-follow-up-failure"); + let marker = ScreenOwnershipMarker::new(temp_dir.path().to_path_buf()); + let attempt_state = SystemSleepAttemptState::new(temp_dir.path().to_path_buf()); + let mock = MockBscpylgtv::new("nm-pre-down-owned-follow-up-failure-tv"); + mock.set_input("HDMI_2"); + mock.queue_error("power_off", 1, "offline"); + mock.queue_error("power_off", 1, "still offline"); + let client = client_for_mock(&mock); + let sleeper = RecordingSleeper::default(); + let mut bus = FakeBus::preparing_for_sleep(true); + let mut output = Vec::new(); + + handle_pre_down_with( + &mut output, + &sample_config(SystemSleepWakePolicy::Enabled), + &marker, + &attempt_state, + &client, + &sleeper, + &mut bus, + ) + .expect("pre-down should release teardown after its follow-up fails"); + + assert_eq!( + attempt_state.read_outcome().expect("read cycle outcome"), + Some(SystemSleepCycleOutcome::RetryableTransportFailure) + ); + assert!(!marker.exists()); + assert!(!attempt_state.exists()); + assert_call_commands(&mock, &["get_input", "power_off", "get_input", "power_off"]); + assert!(rendered(&output).contains("retrying before network teardown")); + } + #[test] fn pre_down_repeated_sleep_hooks_are_idempotent() { let temp_dir = TestDir::new("nm-pre-down-idempotent"); From a3294721e8369e4ca9a7f3efcf3653c1e409d46f Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:17:29 +0300 Subject: [PATCH 21/22] fix(updates): support real host upgrade layouts --- crates/lg-buddy/src/updates.rs | 138 ++++++++++++++++++++++- crates/lg-buddy/src/upgrade_preflight.rs | 70 ++++++++++-- 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/crates/lg-buddy/src/updates.rs b/crates/lg-buddy/src/updates.rs index 5eef597..c4c2347 100644 --- a/crates/lg-buddy/src/updates.rs +++ b/crates/lg-buddy/src/updates.rs @@ -2,6 +2,8 @@ use std::error::Error; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::process; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -1084,18 +1086,19 @@ impl UpdateCacheStore for FileUpdateCacheStore { fn atomic_write_file(path: &Path, contents: &[u8]) -> io::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent)?; + ensure_cache_parent(parent)?; } } let mut last_error = None; for attempt in 0..100 { let temp_path = atomic_temp_path(path, attempt); - let mut file = match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + + let mut file = match options.open(&temp_path) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { last_error = Some(err); @@ -1128,6 +1131,49 @@ fn atomic_write_file(path: &Path, contents: &[u8]) -> io::Result<()> { })) } +#[cfg(unix)] +fn ensure_cache_parent(parent: &Path) -> io::Result<()> { + let mut current = PathBuf::new(); + for component in parent.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!( + "cache path component `{}` is not a directory", + current.display() + ), + )) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + match fs::DirBuilder::new().mode(0o700).create(¤t) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {} + Err(err) => return Err(err), + } + if !fs::symlink_metadata(¤t)?.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + format!( + "cache path component `{}` is not a directory", + current.display() + ), + )); + } + } + Err(err) => return Err(err), + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_cache_parent(parent: &Path) -> io::Result<()> { + fs::create_dir_all(parent) +} + fn atomic_temp_path(path: &Path, attempt: u8) -> PathBuf { let file_name = path .file_name() @@ -1520,6 +1566,8 @@ mod tests { use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::process; use std::sync::{ @@ -1970,6 +2018,27 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + #[cfg(unix)] + struct UmaskGuard { + previous: libc::mode_t, + } + + #[cfg(unix)] + impl UmaskGuard { + fn set(mask: libc::mode_t) -> Self { + Self { + previous: unsafe { libc::umask(mask) }, + } + } + } + + #[cfg(unix)] + impl Drop for UmaskGuard { + fn drop(&mut self) { + unsafe { libc::umask(self.previous) }; + } + } + #[test] fn cache_path_resolver_prefers_xdg_cache_home() { let xdg_cache_home = PathBuf::from("/tmp/xdg-cache"); @@ -2122,6 +2191,63 @@ mod tests { fs::remove_dir_all(dir).expect("remove test temp dir"); } + #[cfg(unix)] + #[test] + fn file_cache_creates_private_path_and_file_under_group_writable_umask() { + const CHILD_ENV: &str = "LG_BUDDY_TEST_CACHE_PERMISSIONS_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new( + std::env::current_exe().expect("resolve current test executable"), + ) + .arg("file_cache_creates_private_path_and_file_under_group_writable_umask") + .arg("--nocapture") + .env(CHILD_ENV, "1") + .status() + .expect("run isolated cache-permissions regression"); + assert!(status.success(), "isolated cache-permissions test failed"); + return; + } + + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = unique_temp_dir("cache-permissions"); + let home = dir.join("home"); + fs::create_dir(&home).expect("create test home"); + fs::set_permissions(&home, fs::Permissions::from_mode(0o750)) + .expect("set test home permissions"); + let path = home + .join(".cache") + .join("lg-buddy") + .join("update-check.json"); + + let _umask = UmaskGuard::set(0o002); + FileUpdateCacheStore::new(path.clone()) + .save(&UpdateCheckCache::default()) + .expect("save cache"); + + for directory in [home.join(".cache"), home.join(".cache").join("lg-buddy")] { + assert_eq!( + fs::symlink_metadata(directory) + .expect("cache directory metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + assert_eq!( + fs::symlink_metadata(path) + .expect("cache file metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + fs::remove_dir_all(dir).expect("remove test temp dir"); + } + #[test] fn cache_without_notification_state_loads_with_absent_notification() { let cache: UpdateCheckCache = serde_json::from_str( diff --git a/crates/lg-buddy/src/upgrade_preflight.rs b/crates/lg-buddy/src/upgrade_preflight.rs index 48fc304..298bb2d 100644 --- a/crates/lg-buddy/src/upgrade_preflight.rs +++ b/crates/lg-buddy/src/upgrade_preflight.rs @@ -920,14 +920,14 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { ); } InstallerPathPolicy::MutateDirectory => { - self.check_mutable_directory(path, &facts, check, 0o300); + self.check_mutable_directory(path, &facts, owner_uid, check, 0o300); } InstallerPathPolicy::RecursiveClear => { - self.check_mutable_directory(path, &facts, check, 0o300); + self.check_mutable_directory(path, &facts, owner_uid, check, 0o300); self.check_recursive_clear_mounts(path, check); } InstallerPathPolicy::ExactDropInDirectory { expected_entry } => { - self.check_mutable_directory(path, &facts, check, 0o700); + self.check_mutable_directory(path, &facts, owner_uid, check, 0o700); self.check_exact_directory(path, expected_entry, check); } InstallerPathPolicy::ReadableInput => self.check_permissions( @@ -1016,6 +1016,7 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { &mut self, path: &Path, facts: &PathFacts, + owner_uid: u32, check: &'static str, required_permissions: u32, ) { @@ -1035,15 +1036,21 @@ impl<'a, F: FilesystemFacts> Checker<'a, F> { "replace the mounted path with an ordinary installation directory before upgrading", ); } + let required_permissions = if owner_uid == 0 && facts.owner_uid == 0 { + required_permissions & !0o200 + } else { + required_permissions + }; self.check_permissions( path, facts, required_permissions, check, - if required_permissions & 0o400 != 0 { - "directory is not readable, writable, and searchable by its owner" - } else { - "directory is not writable and searchable by its owner" + match required_permissions { + 0o100 => "directory is not searchable by its owner", + 0o500 => "directory is not readable and searchable by its owner", + 0o700 => "directory is not readable, writable, and searchable by its owner", + _ => "directory is not writable and searchable by its owner", }, ); } @@ -1938,6 +1945,55 @@ mod tests { ); } + #[test] + fn root_owned_mutation_directory_may_rely_on_privileged_write_access() { + let fixture = InstalledFixture::new("root-owned-read-only-mode"); + let directory = fixture.facts.layout.system_path("/usr/bin"); + + let root_owned = OverriddenFilesystem { + path: directory.clone(), + owner_uid: Some(0), + mode: Some(0o555), + read_only: None, + mount_point: None, + }; + let mut root_checker = Checker::new(&root_owned); + root_checker.check_requirement( + &directory, + 0, + None, + InstallerPathPolicy::MutateDirectory, + "policy-contract", + ); + assert!( + root_checker.report.compatible(), + "{}", + root_checker.report.render() + ); + + let user_owned = OverriddenFilesystem { + path: directory.clone(), + owner_uid: Some(fixture.facts.user_owner_uid), + mode: Some(0o555), + read_only: None, + mount_point: None, + }; + let mut user_checker = Checker::new(&user_owned); + user_checker.check_requirement( + &directory, + fixture.facts.user_owner_uid, + None, + InstallerPathPolicy::MutateDirectory, + "policy-contract", + ); + assert_failure( + &user_checker.report, + "policy-contract", + &directory, + "not writable and searchable", + ); + } + #[test] fn initial_preflight_refuses_read_only_installation_paths() { let fixture = InstalledFixture::new("read-only"); From 326689961d449a622f1c59ce5494d3e21b7aea17 Mon Sep 17 00:00:00 2001 From: Vas Zayarskiy <7261268+Staphylococcus@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:27:26 +0300 Subject: [PATCH 22/22] chore(release): prepare v1.4.0 --- Cargo.lock | 2 +- crates/lg-buddy/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12728fa..33e0706 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,7 +890,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lg-buddy" -version = "1.4.0-beta.3" +version = "1.4.0" dependencies = [ "base64", "cucumber", diff --git a/crates/lg-buddy/Cargo.toml b/crates/lg-buddy/Cargo.toml index d348977..9106241 100644 --- a/crates/lg-buddy/Cargo.toml +++ b/crates/lg-buddy/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lg-buddy" -version = "1.4.0-beta.3" +version = "1.4.0" edition = "2021" publish = false