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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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