diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 5c534c9..47316fc 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -1,165 +1,5 @@ #!/usr/bin/env bash -# Install already-built binaries. Run from any directory with sudo. +# Apply a prepared bundle, roll back, or recover an interrupted installation. set -euo pipefail - -session_entry_matches() { - # App-icon metadata can change between managed releases. All other fields, - # especially Exec/TryExec, must still match before replacing an entry. - cmp -s -- <(sed '/^Icon=/d' "$1") <(sed '/^Icon=/d' "$2") -} - -managed_file_matches() { - local relative=$1 destination=$2 - cmp -s -- "$repo_dir/packaging/$relative" "$destination" || - cmp -s -- "$install_root/current/installer-files/$relative" "$destination" -} - repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -release_id=${1:?usage: install-session.sh RELEASE_ID [--replace-portal-config]} -replace_portal_config=false -if [[ ${2:-} == --replace-portal-config && $# == 2 ]]; then - replace_portal_config=true -elif (($# > 1)); then - echo 'usage: install-session.sh RELEASE_ID [--replace-portal-config]' >&2 - exit 2 -fi -[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || exit 2 -[[ $EUID == 0 ]] || { echo 'Run this installer with sudo.' >&2; exit 1; } - -install_root=/usr/local/lib/ferese -release_dir=$install_root/releases/$release_id -session_entry=/usr/share/wayland-sessions/ferese.desktop -[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } - -# Refuse to overwrite unrelated installations. -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do - link=/usr/local/bin/$name - if [[ -e $link || -L $link ]]; then - [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { - echo "Unmanaged path exists: $link" >&2; exit 1; - } - fi -done -for link in current previous; do - if [[ -e $install_root/$link || -L $install_root/$link ]]; then - [[ -L $install_root/$link && $(readlink -- "$install_root/$link") == releases/* ]] || { - echo "Unmanaged path exists: $install_root/$link" >&2; exit 1; - } - fi -done -if [[ -e $session_entry ]]; then - session_entry_matches "$repo_dir/packaging/ferese.desktop" "$session_entry" || { - echo "Different session entry exists: $session_entry" >&2; exit 1; - } -fi -for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do - [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } -done -"$repo_dir/target/release/ferese-polkit-agent" --check -for name in ferese.png ferese.svg; do - [[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; } -done -# Preserve administrator-supplied policies. New installs use the distro's -# authentication and account stacks, never a permissive fallback. -pam_policy=generic -if [[ -f /etc/pam.d/system-auth ]]; then - pam_policy=fedora -elif [[ -f /etc/pam.d/common-auth && -f /etc/pam.d/common-account ]]; then - pam_policy=debian -elif [[ ! -f /etc/pam.d/login && ! -f /etc/pam.d/ferese-lock ]]; then - echo 'No supported PAM stack found; install /etc/pam.d/ferese-lock first.' >&2 - exit 1 -fi - -# These files are owned by Ferese; preserve unrelated administrator configuration. -for entry in \ - 'ferese.portal:portals/ferese.portal' \ - 'ferese-portals.conf:ferese-portals.conf'; do - source=${entry%%:*} - destination=/usr/share/xdg-desktop-portal/${entry#*:} - if [[ -e $destination ]] && ! managed_file_matches "portal/$source" "$destination" && ! $replace_portal_config; then - echo "Unmanaged portal configuration exists: $destination" >&2 - echo 'Use --replace-portal-config to back up and replace Ferese portal configuration.' >&2 - exit 1 - fi -done -service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service -if [[ -e $service ]] && ! managed_file_matches "portal/$(basename "$service")" "$service"; then - # Upgrade the exact activation file shipped before systemd supervision. - if ! cmp -s <(printf '%s\n' '[D-BUS Service]' \ - 'Name=org.freedesktop.impl.portal.desktop.ferese' \ - 'Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese') "$service"; then - echo "Unmanaged portal service exists: $service" >&2; exit 1 - fi -fi - -portal_unit=/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service -if [[ -e $portal_unit ]] && ! managed_file_matches "systemd/xdg-desktop-portal-ferese.service" "$portal_unit"; then - echo "Unmanaged portal unit exists: $portal_unit" >&2; exit 1 -fi - -session_target=/usr/local/lib/systemd/user/ferese-session.target -if [[ -e $session_target ]] && ! managed_file_matches "systemd/ferese-session.target" "$session_target"; then - echo "Unmanaged session target exists: $session_target" >&2; exit 1 -fi - -desktop-file-validate "$repo_dir/packaging/ferese.desktop" -desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" - -install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions -for relative in portals/ferese.portal ferese-portals.conf; do - destination=/usr/share/xdg-desktop-portal/$relative - if [[ -e $destination ]]; then - install -D -m 0644 -- "$destination" "$release_dir/portal-config.previous/$relative" - fi -done -for relative in \ - portal/ferese.portal \ - portal/ferese-portals.conf \ - portal/org.freedesktop.impl.portal.desktop.ferese.service \ - systemd/xdg-desktop-portal-ferese.service \ - systemd/ferese-session.target; do - install -D -m 0644 -- "$repo_dir/packaging/$relative" "$release_dir/installer-files/$relative" -done -if [[ -e $session_entry ]]; then - install -m 0644 -- "$session_entry" "$release_dir/session.previous.desktop" -fi -for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do - install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" -done -install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" -install -m 0755 -- "$repo_dir/packaging/ferese-session-shell" "$release_dir/ferese-session-shell" -install -D -m 0644 -- "$repo_dir/packaging/systemd/ferese-session.target" "$session_target" -install -D -m 0644 -- "$repo_dir/packaging/systemd/xdg-desktop-portal-ferese.service" "$portal_unit" -install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot" -install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl" -install -D -m 0644 -- "$repo_dir/assets/fonts/Comfortaa-LICENSE.txt" "$release_dir/licenses/Comfortaa-LICENSE.txt" -install -D -m 0644 -- "$repo_dir/assets/fonts/Cantarell-LICENSE.txt" "$release_dir/licenses/Cantarell-LICENSE.txt" -for license in "$repo_dir"/assets/themes/licenses/*.txt; do - install -D -m 0644 -- "$license" "$release_dir/licenses/theme-$(basename -- "$license")" -done -install -d -m 0755 -- "$release_dir/wallpapers" -install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png" -install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg" - -if [[ ! -e /etc/pam.d/ferese-lock ]]; then - install -D -m 0644 -- "$repo_dir/packaging/pam.d/ferese-lock.$pam_policy" /etc/pam.d/ferese-lock -fi - -if [[ -L $install_root/current ]]; then - ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous" -fi -ln -s -- "releases/$release_id" "$install_root/current-$release_id" -mv -Tf -- "$install_root/current-$release_id" "$install_root/current" -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-polkit-agent ferese-screenshot xdg-desktop-portal-ferese ferese-record; do - ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" -done -install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" -install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop -install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg -install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg -install -D -m 0644 -- "$repo_dir/packaging/portal/ferese.portal" /usr/share/xdg-desktop-portal/portals/ferese.portal -install -D -m 0644 -- "$repo_dir/packaging/portal/ferese-portals.conf" /usr/share/xdg-desktop-portal/ferese-portals.conf -install -D -m 0644 -- "$repo_dir/packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service" "$service" -if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi -echo "Installed Ferese $release_id. Select Ferese at your login screen, or run /usr/local/bin/ferese-session from a local TTY." +exec python3 "$repo_dir/scripts/installer/install.py" "$@" diff --git a/scripts/install.sh b/scripts/install.sh index 710c0a4..7093918 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,64 +1,48 @@ #!/usr/bin/env bash -# Build as the invoking user; elevate only the versioned system installation. +# Build and prepare a complete release as the user; elevate only installation. set -euo pipefail usage() { cat <<'EOF' Usage: scripts/install.sh [options] -Build and install Ferese's compositor, shell, CLI, and login-screen session. -Run from any directory as your normal user. Existing user config is preserved. +Build, bundle and install Ferese. Run as your normal user. - --release-id ID Name the installed release (default: UTC timestamp + PID) - --skip-build Install existing target/release binaries - --replace-portal-config Back up and replace existing Ferese portal configuration - --offline Build using only cached Cargo dependencies - --resize-metrics Build with resize-barrier measurements (reported on exit) - --dry-run Print commands without building or installing - -h, --help Show this help + --release-id ID Release name (default: UTC timestamp + PID) + --bundle PATH Install an existing verified bundle; do not build + --bundle-only PATH Build a bundle at PATH without installing + --replace-portal-config Back up and replace modified Ferese portal config + --offline Build using cached Cargo dependencies + --resize-metrics Build with resize-barrier measurements + --dry-run Show commands without building or installing + -h, --help Show this help -The previous release remains available for rollback. Administrator -authentication is requested only after a successful build. +Recovery and rollback: scripts/install-session.sh --help +Repeat user setup: python3 scripts/installer/install.py user-setup EOF } -check_user_portal_override() { - local override=$1 user_home=$2 - [[ -f $override ]] || return 0 - if ! cmp -s -- <(printf '%s\n' '[Service]' 'ExecStart=' \ - "ExecStart=$user_home/.local/libexec/ferese/xdg-desktop-portal-ferese") "$override"; then - echo "Custom portal override takes precedence over the installation: $override" >&2 - echo 'Update its ExecStart to /usr/local/lib/ferese/current/xdg-desktop-portal-ferese before installing.' >&2 - return 1 - fi -} - -backup_user_portal_override() { - local override=$1 user_home=$2 release=$3 - [[ -f $override ]] || return 0 - check_user_portal_override "$override" "$user_home" || return 1 - local backup="$override.before-$release" - [[ ! -e $backup ]] || { echo "Portal override backup already exists: $backup" >&2; return 1; } - mv -- "$override" "$backup" - echo "Backed up old development portal override: $backup" -} - fail() { echo "ferese installer: $*" >&2; exit 1; } repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +helper=$repo_dir/scripts/installer/install.py release_id="$(date -u +%Y%m%d-%H%M%S)-$$" -skip_build=false +release_set=false +bundle_path= +bundle_only= replace_portal_config=false offline=false resize_metrics=false dry_run=false while (($#)); do case $1 in - --release-id) - (($# >= 2)) || fail '--release-id requires a value' - release_id=$2 - shift 2 - ;; - --skip-build) skip_build=true; shift ;; + --release-id|--bundle|--bundle-only) + (($# >= 2)) && [[ -n $2 && $2 != --* ]] || fail "$1 requires a value" + case $1 in + --release-id) release_id=$2; release_set=true ;; + --bundle) bundle_path=$2 ;; + --bundle-only) bundle_only=$2 ;; + esac + shift 2 ;; --replace-portal-config) replace_portal_config=true; shift ;; --offline) offline=true; shift ;; --resize-metrics) resize_metrics=true; shift ;; @@ -68,56 +52,77 @@ while (($#)); do esac done [[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail 'invalid release ID' -if $skip_build && $resize_metrics; then - fail '--resize-metrics requires a build; omit --skip-build' -fi - [[ $(uname -s) == Linux ]] || fail 'Ferese requires Linux' -for tool in desktop-file-validate dbus-run-session; do - command -v "$tool" >/dev/null || fail "required command not found: $tool" -done -desktop-file-validate "$repo_dir/packaging/ferese.desktop" -user_portal_override="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/xdg-desktop-portal-ferese.service.d/override.conf" -if ((EUID != 0)); then - check_user_portal_override "$user_portal_override" "$HOME" || fail 'portal service override needs attention' +command -v python3 >/dev/null || fail 'install Python 3 first' +if [[ -n $bundle_path ]]; then + [[ -z $bundle_only ]] && ! $offline && ! $resize_metrics && ! $release_set || + fail '--bundle cannot be combined with build options' +else + ((EUID != 0)) || fail 'build as your normal user, without sudo' +fi +if [[ -n $bundle_only ]] && $replace_portal_config; then + fail '--replace-portal-config requires installation' fi -build=(cargo build --manifest-path "$repo_dir/Cargo.toml" - --target-dir "$repo_dir/target" --release --locked - -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese) +build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked) +mapfile -t packages < <(python3 "$helper" packages) +((${#packages[@]})) || fail 'could not read package inventory' +for package in "${packages[@]}"; do build+=(-p "$package"); done if $offline; then build+=(--offline); fi -if $resize_metrics; then build+=(--features ferese/resize-metrics); fi -if ! $skip_build; then - ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' - command -v cargo >/dev/null || fail 'install the Rust toolchain and Cargo first' +features=default +if $resize_metrics; then + build+=(--features ferese/resize-metrics) + features=ferese/resize-metrics fi -installer=(bash "$repo_dir/scripts/install-session.sh" "$release_id") +if [[ -z $bundle_path ]]; then + bundle_path=${bundle_only:-$repo_dir/target/bundles/$release_id} + prepare=(python3 "$helper" bundle --output "$bundle_path" --release-id "$release_id" --features "$features") +fi +installer=(/usr/bin/bash "$repo_dir/scripts/install-session.sh" install "$bundle_path") if $replace_portal_config; then installer+=(--replace-portal-config); fi -if ((EUID != 0)); then +if [[ -z $bundle_only ]] && ((EUID != 0)); then if command -v sudo >/dev/null; then installer=(sudo "${installer[@]}") elif command -v pkexec >/dev/null; then - installer=(pkexec /usr/bin/bash "$repo_dir/scripts/install-session.sh" "$release_id") + installer=(pkexec "${installer[@]}") else fail 'system installation requires sudo or pkexec' fi fi if $dry_run; then - if ! $skip_build; then printf 'Build: '; printf '%q ' "${build[@]}"; printf '\n'; fi - printf 'Install: '; printf '%q ' "${installer[@]}"; printf '\n' + if [[ ${prepare+x} ]]; then + printf 'Build: '; printf '%q ' "${build[@]}"; printf '\n' + printf 'Bundle: '; printf '%q ' "${prepare[@]}"; printf '\n' + fi + if [[ -z $bundle_only ]]; then printf 'Install: '; printf '%q ' "${installer[@]}"; printf '\n'; fi exit 0 fi -cd -- "$repo_dir" -if ! $skip_build; then "${build[@]}"; fi -for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do - [[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" -done +if [[ -z $bundle_only ]] && ((EUID != 0)); then + python3 "$helper" user-setup --check +fi +if [[ ${prepare+x} ]]; then + for tool in cargo desktop-file-validate dbus-run-session; do + command -v "$tool" >/dev/null || fail "required command not found: $tool" + done + desktop-file-validate "$repo_dir/packaging/ferese.desktop" + desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" + (cd -- "$repo_dir"; "${build[@]}") + "$repo_dir/target/release/ferese-polkit-agent" --check + "${prepare[@]}" +fi +[[ -z $bundle_only ]] || exit 0 + +python3 "$helper" verify "$bundle_path" "${installer[@]}" -if ((EUID != 0)) && [[ -f $user_portal_override ]]; then - backup_user_portal_override "$user_portal_override" "$HOME" "$release_id" || fail 'could not update portal override' - systemctl --user daemon-reload || fail 'could not reload the user portal service configuration' +if ((EUID != 0)); then + if ! python3 "$helper" user-setup; then + echo 'System installation succeeded; user setup is incomplete.' >&2 + echo "Retry as your normal user: python3 '$helper' user-setup" >&2 + exit 1 + fi +else + echo "System installation succeeded. As your normal user, run: python3 '$helper' user-setup" fi -echo 'Installation complete. Log out and select Ferese at your login screen, or run ferese-session from a local TTY.' diff --git a/scripts/installer/install.py b/scripts/installer/install.py new file mode 100644 index 0000000..cb0c880 --- /dev/null +++ b/scripts/installer/install.py @@ -0,0 +1,568 @@ +#!/usr/bin/env python3 +"""Build release bundles and apply recoverable, serialized system installations.""" +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import uuid + +REPO = Path(__file__).resolve().parents[2] +INVENTORY = json.loads(Path(__file__).with_name('inventory.json').read_text()) +PREFIX = '/usr/local/lib/ferese' +ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*\Z') + + +def fail(message): + raise ValueError(message) + + +def release_id(value): + if not ID.fullmatch(value): + fail(f'Invalid release ID: {value}') + + return value + + +def digest(path): + with path.open('rb') as stream: + checksum = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b''): + checksum.update(block) + + return checksum.hexdigest() + + +def sync_dir(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def atomic_bytes(path, data, mode=0o644): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + os.fchmod(stream.fileno(), mode) + stream.flush() + os.fsync(stream.fileno()) + + os.replace(temporary, path) + sync_dir(path.parent) + finally: + if os.path.lexists(temporary): + os.unlink(temporary) + + +def atomic_copy(path, source, mode): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as output: + source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(source_fd, 'rb') as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + fail(f'Expected regular file: {source}') + shutil.copyfileobj(stream, output) + + os.fchmod(output.fileno(), mode) + output.flush() + os.fsync(output.fileno()) + + os.replace(temporary, path) + sync_dir(path.parent) + finally: + if os.path.lexists(temporary): + os.unlink(temporary) + + +def sync_tree(path): + # Persist new directory entries as well as file contents before publication. + for directory, _, _ in os.walk(path, topdown=False): + sync_dir(Path(directory)) + + +def atomic_json(path, data, mode=0o600): + atomic_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + '\n').encode(), mode) + + +def record(rule, name=None): + return {key: (value.format(name=name) if key == 'target' else value) + for key, value in rule.items() if key != 'source'} + + +def validate_bundle(bundle): + if bundle.is_symlink() or not bundle.is_dir(): + fail(f'Bundle must be a directory, not a symlink: {bundle}') + + for path in bundle.rglob('*'): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + fail(f'Unexpected bundle entry: {path}') + + manifest = json.loads((bundle / 'manifest.json').read_text()) + if manifest.get('format') != 1: + fail('Unsupported bundle format') + + release_id(manifest['release']) + expected = {rule['target']: record(rule) for rule in INVENTORY['files'] if '*' not in rule['source']} + seen = set() + for item in manifest['files']: + target = item['target'] + allowed = expected.get(target) + if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target): + allowed = {'target': target, 'mode': '0644'} + + if allowed is None or {k: v for k, v in item.items() if k != 'sha256'} != allowed or target in seen: + fail(f'Invalid inventory entry: {target}') + + path = bundle / target + if not path.is_file() or digest(path) != item['sha256']: + fail(f'Checksum mismatch: {target}') + if stat.S_IMODE(path.stat().st_mode) != int(item['mode'], 8): + fail(f'Incorrect file mode: {target}') + + seen.add(target) + + actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()} + if not expected.keys() <= seen or actual != seen | {'manifest.json'}: + fail('Bundle is incomplete or contains unlisted files') + + return manifest + + +def bundle(args): + release_id(args.release_id) + source = args.source_root.resolve() + output = args.output.absolute() + if output.exists() or output.is_symlink(): + fail(f'Bundle already exists: {output}') + + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.bundle-', dir=output.parent) as temporary: + staging = Path(temporary) / 'release' + staging.mkdir() + files = [] + for rule in INVENTORY['files']: + sources = sorted(source.glob(rule['source'])) + if not sources: + fail(f'Missing bundle input: {rule["source"]}') + + for origin in sources: + if origin.is_symlink() or not origin.is_file(): + fail(f'Bundle input must be a regular file: {origin}') + if rule['mode'] == '0755' and not os.access(origin, os.X_OK): + fail(f'Bundle input is not executable: {origin}') + + item = record(rule, origin.name) + destination = staging / item['target'] + atomic_copy(destination, origin, int(item['mode'], 8)) + item['sha256'] = digest(destination) + files.append(item) + + def git(*command): + result = subprocess.run(['git', '-C', str(source), *command], capture_output=True, text=True) + return result.stdout.strip() if result.returncode == 0 else None + + status = git('status', '--porcelain') + manifest = dict(format=1, release=args.release_id, commit=git('rev-parse', 'HEAD'), + dirty=None if status is None else bool(status), features=args.features, + created_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), files=files) + atomic_json(staging / 'manifest.json', manifest, 0o644) + validate_bundle(staging) + sync_tree(staging) + os.rename(staging, output) + sync_dir(output.parent) + + print(f'Prepared bundle: {output}') + + +class Installation: + def __init__(self, root): + self.root = root.resolve() + self.base = self.path(PREFIX) + self.transaction = self.base / '.transaction' + + def path(self, absolute): + if not absolute.startswith('/') or '..' in Path(absolute).parts: + fail(f'Invalid installation path: {absolute}') + + path = self.root / absolute.lstrip('/') + for parent in path.parents: + if parent == self.root: + break + if parent.is_symlink(): + fail(f'Refusing symlinked parent: {parent}') + + return path + + @contextlib.contextmanager + def lock(self): + self.base.mkdir(parents=True, exist_ok=True) + lock_path = self.path(PREFIX + '/.install.lock') + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + fail('Another Ferese installation is in progress') + + yield + finally: + os.close(fd) + + def pointer(self, name): + path = self.path(PREFIX + '/' + name) + if not os.path.lexists(path): + return None + if not path.is_symlink(): + fail(f'Unmanaged release pointer: {path}') + + target = os.readlink(path) + if not target.startswith('releases/') or not ID.fullmatch(target[len('releases/'):]): + fail(f'Invalid release pointer: {path}') + + return target[len('releases/'):] + + def state(self, absolute): + path = self.path(absolute) + if path.is_symlink(): + return {'kind': 'link', 'target': os.readlink(path)} + if not path.exists(): + return {'kind': 'absent'} + if not path.is_file(): + fail(f'Unmanaged non-file path: {path}') + + return {'kind': 'file', 'sha256': digest(path), 'mode': stat.S_IMODE(path.stat().st_mode)} + + def manifest(self, release): + directory = self.path(PREFIX + '/releases/' + release_id(release)) + if not (directory / 'manifest.json').is_file(): + fail(f'Release {release} predates verified bundles; select a bundled release for rollback') + + return validate_bundle(directory) + + def current_publication(self, current): + if not current: + return {} + + directory = self.path(PREFIX + '/releases/' + current) + if (directory / 'manifest.json').exists(): + return self.publication(self.manifest(current)) + + # The previous installer retained exact originals for these integration files. + # Use those ownership receipts for the first upgrade, never the live file itself. + if directory.is_symlink() or not (directory / 'installer-files').is_dir(): + fail(f'Release has no installation manifest: {current}') + + entries = {} + for rule in INVENTORY['files']: + if rule.get('link'): + target = rule['target'] + entries['/usr/local/bin/' + target] = ( + {'kind': 'link', 'target': PREFIX + '/current/' + target}, None, 'managed') + if 'destination' in rule: + saved = directory / 'installer-files' / rule['source'].removeprefix('packaging/') + if saved.is_file() and not saved.is_symlink(): + entries[rule['destination']] = ( + {'kind': 'file', 'sha256': digest(saved), 'mode': int(rule['mode'], 8)}, + None, rule['policy']) + + return entries + + def publication(self, manifest): + entries = {} + for item in manifest['files']: + if item.get('link'): + destination = '/usr/local/bin/' + item['target'] + entries[destination] = ({'kind': 'link', 'target': PREFIX + '/current/' + item['target']}, None, 'managed') + if 'destination' in item: + source = PREFIX + '/releases/' + manifest['release'] + '/' + item['target'] + entries[item['destination']] = ({'kind': 'file', 'sha256': item['sha256'], + 'mode': int(item['mode'], 8)}, source, item['policy']) + + return entries + + def plan(self, manifest, replace_portal=False, remove=False): + current = self.pointer('current') + self.pointer('previous') + previous = self.current_publication(current) + desired = {} if remove else self.publication(manifest) + operations = [] + for destination in sorted(previous.keys() | desired.keys()): + old = self.state(destination) + new, source, policy = desired.get(destination, ({'kind': 'absent'}, None, 'managed')) + owned = previous.get(destination, (None,))[0] + if old != {'kind': 'absent'} and old != owned and old != new: + if not (replace_portal and policy == 'portal' and old['kind'] == 'file'): + fail(f'Unmanaged or modified file: {destination}') + + if old != new: + operations.append(dict(path=destination, old=old, new=new, source=source)) + + pam_path = '/etc/pam.d/ferese-lock' + if not remove and not os.path.lexists(self.path(pam_path)): + if self.path('/etc/pam.d/system-auth').is_file(): + policy = 'fedora' + elif all(self.path('/etc/pam.d/' + name).is_file() for name in ('common-auth', 'common-account')): + policy = 'debian' + elif self.path('/etc/pam.d/login').is_file(): + policy = 'generic' + else: + fail('No supported PAM stack; install /etc/pam.d/ferese-lock first') + + item = next(item for item in manifest['files'] if item['target'] == 'pam/' + policy) + operations.append(dict(path=pam_path, old={'kind': 'absent'}, + new={'kind': 'file', 'sha256': item['sha256'], 'mode': 0o644}, + source=PREFIX + '/releases/' + manifest['release'] + '/' + item['target'])) + + for name, target in [('previous', self.pointer('previous') if current == manifest['release'] and not remove else current), + ('current', None if remove else manifest['release'])]: + old = self.state(PREFIX + '/' + name) + new = {'kind': 'link', 'target': 'releases/' + target} if target else {'kind': 'absent'} + if old != new: + operations.append(dict(path=PREFIX + '/' + name, old=old, new=new, source=None)) + + return operations + + def write_state(self, absolute, state, source=None): + path = self.path(absolute) + path.parent.mkdir(parents=True, exist_ok=True) + if state['kind'] == 'absent': + path.unlink(missing_ok=True) + sync_dir(path.parent) + elif state['kind'] == 'file': + if digest(source) != state['sha256']: + fail(f'Recovery source checksum mismatch: {source}') + + atomic_copy(path, source, state['mode']) + else: + temporary = path.parent / ('.ferese-link-' + uuid.uuid4().hex) + try: + temporary.symlink_to(state['target']) + os.replace(temporary, path) + sync_dir(path.parent) + finally: + temporary.unlink(missing_ok=True) + + def archive(self, journal): + history = self.path(PREFIX + '/transactions') + history.mkdir(mode=0o700, exist_ok=True) + os.rename(self.transaction, history / journal['id']) + sync_dir(history) + sync_dir(self.base) + + def recover(self): + if not self.transaction.exists(): + return + + if self.transaction.is_symlink(): + fail('Refusing symlinked recovery transaction') + + journal = json.loads((self.transaction / 'journal.json').read_text()) + if journal['phase'] != 'committed': + # Check all paths before restoring anything; preserve later administrator edits. + for operation in journal['operations']: + actual = self.state(operation['path']) + if actual not in (operation['old'], operation['new']): + fail(f'Recovery conflict at {operation["path"]}; transaction retained at {self.transaction}') + + for index, operation in reversed(list(enumerate(journal['operations']))): + self.write_state(operation['path'], operation['old'], self.transaction / 'backup' / str(index)) + + journal['phase'] = 'rolled-back' + atomic_json(self.transaction / 'journal.json', journal) + print('Recovered interrupted installation; restored previous files') + + self.archive(journal) + + def apply(self, operations): + if not operations: + print('Already installed; no system changes needed') + return + + journal = dict(id=time.strftime('%Y%m%dT%H%M%S', time.gmtime()) + '-' + uuid.uuid4().hex, + phase='applying', operations=operations) + with tempfile.TemporaryDirectory(prefix='.prepare-', dir=self.base) as temporary: + staging = Path(temporary) / 'transaction' + staging.mkdir(mode=0o700) + for index, operation in enumerate(operations): + if self.state(operation['path']) != operation['old']: + fail(f'File changed during preparation: {operation["path"]}') + if operation['old']['kind'] == 'file': + backup = staging / 'backup' / str(index) + atomic_copy(backup, self.path(operation['path']), 0o600) + if digest(backup) != operation['old']['sha256']: + fail(f'File changed while backing up: {operation["path"]}') + + atomic_json(staging / 'journal.json', journal) + sync_tree(staging) + os.rename(staging, self.transaction) + sync_dir(self.base) + + try: + for operation in operations: + if self.state(operation['path']) != operation['old']: + fail(f'File changed during installation: {operation["path"]}') + + source = self.path(operation['source']) if operation['source'] else None + self.write_state(operation['path'], operation['new'], source) + + journal['phase'] = 'committed' + atomic_json(self.transaction / 'journal.json', journal) + except BaseException: + self.recover() + raise + + self.archive(journal) + + def stage(self, origin, manifest): + releases = self.path(PREFIX + '/releases') + if releases.is_symlink(): + fail(f'Refusing symlinked release directory: {releases}') + + releases.mkdir(parents=True, exist_ok=True) + destination = releases / manifest['release'] + if os.path.lexists(destination): + if self.manifest(manifest['release']) != manifest: + fail(f'Release ID already belongs to a different bundle: {manifest["release"]}') + + return + + with tempfile.TemporaryDirectory(prefix='.stage-', dir=releases) as temporary: + staging = Path(temporary) / 'release' + staging.mkdir() + for item in manifest['files']: + atomic_copy(staging / item['target'], origin / item['target'], int(item['mode'], 8)) + + atomic_json(staging / 'manifest.json', manifest, 0o644) + validate_bundle(staging) + sync_tree(staging) + os.rename(staging, destination) + sync_dir(releases) + + +def system_command(args): + if args.root.resolve() == Path('/') and os.geteuid() != 0 and not args.dry_run: + fail('System installation requires sudo or pkexec') + + installation = Installation(args.root) + if args.dry_run: + if installation.transaction.exists(): + fail('An interrupted transaction requires recovery before planning') + + if args.command == 'recover': + print('No interrupted transaction') + return + if args.command == 'install': + manifest = validate_bundle(args.bundle) + else: + selected = (installation.pointer('current') if args.command == 'uninstall' else + args.release or installation.pointer('previous')) + manifest = installation.manifest(selected or fail('No release selected')) + operations = installation.plan(manifest, getattr(args, 'replace_portal_config', False), args.command == 'uninstall') + for operation in operations: + print(f'{operation["path"]}: {operation["old"]["kind"]} -> {operation["new"]["kind"]}') + return + + with installation.lock(): + installation.recover() + if args.command == 'recover': + return + if args.command == 'install': + manifest = validate_bundle(args.bundle) + # Preflight before retaining a release or changing integration files. + operations = installation.plan(manifest, args.replace_portal_config) + installation.stage(args.bundle, manifest) + elif args.command == 'rollback': + selected = args.release or installation.pointer('previous') or fail('No previous release') + manifest = installation.manifest(selected) + operations = installation.plan(manifest) + else: + current = installation.pointer('current') or fail('No installed release') + manifest = installation.manifest(current) + operations = installation.plan(manifest, remove=True) + + installation.apply(operations) + print(f'System {args.command} complete: {manifest["release"]}') + + +def user_setup(args): + if os.geteuid() == 0: + fail('Run user-setup as your normal user') + + config = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) + override = config / 'systemd/user/xdg-desktop-portal-ferese.service.d/override.conf' + expected = f'[Service]\nExecStart=\nExecStart={Path.home()}/.local/libexec/ferese/xdg-desktop-portal-ferese\n' + if os.path.lexists(override): + if override.is_symlink() or override.read_text() != expected: + fail(f'Custom portal override preserved: {override}; update its ExecStart to {PREFIX}/current/xdg-desktop-portal-ferese') + if not args.check: + backup = override.with_name('override.conf.before-' + uuid.uuid4().hex) + override.rename(backup) + print(f'Backed up development portal override: {backup}') + + if not args.check: + subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True) + print('User setup complete. Log out and select Ferese to use the installed release.') + + +def parser(): + result = argparse.ArgumentParser(description=__doc__) + commands = result.add_subparsers(dest='command', required=True) + build = commands.add_parser('bundle') + build.add_argument('--source-root', type=Path, default=REPO) + build.add_argument('--output', type=Path, required=True) + build.add_argument('--release-id', required=True) + build.add_argument('--features', default='default') + commands.add_parser('packages') + verify = commands.add_parser('verify') + verify.add_argument('bundle', type=Path) + user = commands.add_parser('user-setup') + user.add_argument('--check', action='store_true') + for name in ('install', 'rollback', 'recover', 'uninstall'): + command = commands.add_parser(name) + command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)') + command.add_argument('--dry-run', action='store_true') + if name == 'install': + command.add_argument('bundle', type=Path) + command.add_argument('--replace-portal-config', action='store_true') + elif name == 'rollback': + command.add_argument('release', nargs='?') + + return result + + +def main(): + args = parser().parse_args() + if args.command == 'bundle': + bundle(args) + elif args.command == 'verify': + manifest = validate_bundle(args.bundle) + print(f'Verified bundle: {manifest["release"]}') + elif args.command == 'packages': + print('\n'.join(INVENTORY['packages'])) + elif args.command == 'user-setup': + user_setup(args) + else: + system_command(args) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, KeyError, TypeError, subprocess.CalledProcessError) as error: + print(f'ferese installer: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/installer/inventory.json b/scripts/installer/inventory.json new file mode 100644 index 0000000..f4d13cc --- /dev/null +++ b/scripts/installer/inventory.json @@ -0,0 +1,187 @@ +{ + "packages": [ + "ferese", + "ferese-shell", + "ferese-settings", + "feresectl", + "ferese-lock", + "ferese-polkit-agent", + "xdg-desktop-portal-ferese" + ], + "files": [ + { + "source": "target/release/ferese", + "target": "ferese", + "mode": "0755", + "link": true + }, + { + "source": "target/release/ferese-shell", + "target": "ferese-shell", + "mode": "0755", + "link": true + }, + { + "source": "target/release/ferese-settings", + "target": "ferese-settings", + "mode": "0755", + "link": true + }, + { + "source": "target/release/feresectl", + "target": "feresectl", + "mode": "0755", + "link": true + }, + { + "source": "target/release/ferese-lock", + "target": "ferese-lock", + "mode": "0755", + "link": true + }, + { + "source": "target/release/ferese-polkit-agent", + "target": "ferese-polkit-agent", + "mode": "0755", + "link": true + }, + { + "source": "target/release/xdg-desktop-portal-ferese", + "target": "xdg-desktop-portal-ferese", + "mode": "0755", + "link": true + }, + { + "source": "target/release/ferese-record", + "target": "ferese-record", + "mode": "0755", + "link": true + }, + { + "source": "packaging/ferese-session", + "target": "ferese-session", + "mode": "0755", + "link": true + }, + { + "source": "packaging/ferese-session-shell", + "target": "ferese-session-shell", + "mode": "0755", + "link": false + }, + { + "source": "packaging/ferese-screenshot", + "target": "ferese-screenshot", + "mode": "0755", + "link": true + }, + { + "source": "packaging/config.kdl", + "target": "config.example.kdl", + "mode": "0644" + }, + { + "source": "assets/fonts/Comfortaa-LICENSE.txt", + "target": "licenses/Comfortaa-LICENSE.txt", + "mode": "0644" + }, + { + "source": "assets/fonts/Cantarell-LICENSE.txt", + "target": "licenses/Cantarell-LICENSE.txt", + "mode": "0644" + }, + { + "source": "assets/themes/licenses/*.txt", + "target": "licenses/theme-{name}", + "mode": "0644" + }, + { + "source": "assets/wallpapers/ferese.png", + "target": "wallpapers/ferese.png", + "mode": "0644" + }, + { + "source": "assets/wallpapers/ferese.svg", + "target": "wallpapers/ferese.svg", + "mode": "0644" + }, + { + "source": "packaging/ferese.desktop", + "target": "integration/usr/share/wayland-sessions/ferese.desktop", + "mode": "0644", + "destination": "/usr/share/wayland-sessions/ferese.desktop", + "policy": "managed" + }, + { + "source": "packaging/dev.ferese.Settings.desktop", + "target": "integration/usr/local/share/applications/dev.ferese.Settings.desktop", + "mode": "0644", + "destination": "/usr/local/share/applications/dev.ferese.Settings.desktop", + "policy": "managed" + }, + { + "source": "packaging/icons/ferese.svg", + "target": "integration/usr/local/share/icons/hicolor/scalable/apps/ferese.svg", + "mode": "0644", + "destination": "/usr/local/share/icons/hicolor/scalable/apps/ferese.svg", + "policy": "managed" + }, + { + "source": "packaging/icons/ferese.svg", + "target": "integration/usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg", + "mode": "0644", + "destination": "/usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg", + "policy": "managed" + }, + { + "source": "packaging/systemd/ferese-session.target", + "target": "integration/usr/local/lib/systemd/user/ferese-session.target", + "mode": "0644", + "destination": "/usr/local/lib/systemd/user/ferese-session.target", + "policy": "managed" + }, + { + "source": "packaging/systemd/xdg-desktop-portal-ferese.service", + "target": "integration/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service", + "mode": "0644", + "destination": "/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service", + "policy": "managed" + }, + { + "source": "packaging/portal/ferese.portal", + "target": "integration/usr/share/xdg-desktop-portal/portals/ferese.portal", + "mode": "0644", + "destination": "/usr/share/xdg-desktop-portal/portals/ferese.portal", + "policy": "portal" + }, + { + "source": "packaging/portal/ferese-portals.conf", + "target": "integration/usr/share/xdg-desktop-portal/ferese-portals.conf", + "mode": "0644", + "destination": "/usr/share/xdg-desktop-portal/ferese-portals.conf", + "policy": "portal" + }, + { + "source": "packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service", + "target": "integration/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service", + "mode": "0644", + "destination": "/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service", + "policy": "managed" + }, + { + "source": "packaging/pam.d/ferese-lock.fedora", + "target": "pam/fedora", + "mode": "0644" + }, + { + "source": "packaging/pam.d/ferese-lock.debian", + "target": "pam/debian", + "mode": "0644" + }, + { + "source": "packaging/pam.d/ferese-lock.generic", + "target": "pam/generic", + "mode": "0644" + } + ] +} diff --git a/scripts/tests/test_portal_install.py b/scripts/tests/test_portal_install.py index 30b808b..526d4ee 100644 --- a/scripts/tests/test_portal_install.py +++ b/scripts/tests/test_portal_install.py @@ -1,161 +1,362 @@ -"""Exercise portal upgrade preflight against temporary installation files.""" +"""Run complete installer commands against disposable destination filesystems.""" +import fcntl +import importlib.util +import json +import os from pathlib import Path +import shutil import subprocess +import sys import tempfile import unittest REPO = Path(__file__).resolve().parents[2] -LEGACY = ('[D-BUS Service]\n' - 'Name=org.freedesktop.impl.portal.desktop.ferese\n' - 'Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese\n') +HELPER = REPO / 'scripts/installer/install.py' +spec = importlib.util.spec_from_file_location('ferese_install', HELPER) +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) +PREFIX = installer.PREFIX.lstrip('/') +PORTAL = 'usr/share/xdg-desktop-portal/ferese-portals.conf' +UNIT = 'usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service' +DESKTOP = 'usr/share/wayland-sessions/ferese.desktop' -class PortalInstallTest(unittest.TestCase): - def preflight(self, service=None, unit=None): - script = (REPO / 'scripts/install-session.sh').read_text() - start = script.index('service=/usr/local/share/dbus-1/services/') - helpers = script[:script.index('repo_dir=')] - script = helpers + script[start:script.index('session_target=', start)] - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - if service is not None: - (root / 'activation.service').write_text(service) - if unit is not None: - (root / 'portal.service').write_text(unit) - script = script.replace( - 'service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service', - 'service="$TEST_ROOT/activation.service"') - script = script.replace('$(basename "$service")', - 'org.freedesktop.impl.portal.desktop.ferese.service') - script = script.replace( - 'portal_unit=/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service', - 'portal_unit="$TEST_ROOT/portal.service"') - return subprocess.run(['bash', '-eu', '-c', script], - env={'repo_dir': str(REPO), 'TEST_ROOT': directory, 'install_root': directory}, - capture_output=True, text=True) +class InstallerTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.shared = tempfile.TemporaryDirectory() + cls.source = Path(cls.shared.name) / 'source' + for rule in installer.INVENTORY['files']: + if rule['source'].startswith('target/release/'): + paths = [REPO / rule['source']] + else: + paths = sorted(REPO.glob(rule['source'])) - def configuration_preflight(self, portal=None, preferences=None, previous=None, replace=False): - script = (REPO / 'scripts/install-session.sh').read_text() - helpers = script[:script.index('repo_dir=')] - start = script.index('for entry in ') - check = script[start:script.index('service=/usr/local/share/dbus-1/services/', start)] - check = check.replace('/usr/share/xdg-desktop-portal/', '$TEST_ROOT/config/') - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for relative, content in [('portals/ferese.portal', portal), ('ferese-portals.conf', preferences)]: - if content is not None: - destination = root / 'config' / relative - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(content) - if previous: - for relative, content in previous.items(): - destination = root / 'current/installer-files/portal' / relative - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(content) - return subprocess.run(['bash', '-eu', '-c', helpers + check], - env={'repo_dir': str(REPO), 'TEST_ROOT': directory, 'install_root': directory, - 'replace_portal_config': str(replace).lower()}, - capture_output=True, text=True) + for path in paths: + target = cls.source / path.relative_to(REPO) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b'#!/bin/sh\nexit 0\n' if rule['source'].startswith('target/release/') else path.read_bytes()) + target.chmod(int(rule['mode'], 8)) - def user_override(self, content, backup=False): - script = (REPO / 'scripts/install.sh').read_text() - helpers = script[script.index('check_user_portal_override()'):script.index('fail() {')] - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - override = root / 'override.conf' - if content is not None: - override.write_text(content.replace('USER_HOME', directory)) - command = 'backup_user_portal_override' if backup else 'check_user_portal_override' - result = subprocess.run(['bash', '-eu', '-c', helpers + - f'\n{command} "$TEST_ROOT/override.conf" "$TEST_ROOT" test-release'], - env={'TEST_ROOT': directory}, capture_output=True, text=True) - saved = root / 'override.conf.before-test-release' - return result, override.exists(), saved.read_text() if saved.exists() else None + cls.bundles = [] + for name in ('first', 'second'): + for relative in ('portal/ferese-portals.conf', 'systemd/xdg-desktop-portal-ferese.service', 'ferese.desktop'): + with (cls.source / 'packaging' / relative).open('a') as stream: + stream.write(f'\n# {name}\n') - def test_user_development_override_is_backed_up(self): - content = '[Service]\nExecStart=\nExecStart=USER_HOME/.local/libexec/ferese/xdg-desktop-portal-ferese\n' - result, exists, saved = self.user_override(content, backup=True) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(exists) - self.assertIn('/.local/libexec/ferese/xdg-desktop-portal-ferese', saved) + output = Path(cls.shared.name) / name + subprocess.run([sys.executable, str(HELPER), 'bundle', '--source-root', str(cls.source), + '--output', str(output), '--release-id', name], check=True, capture_output=True) + cls.bundles.append(output) - def test_custom_user_override_is_preserved_and_reported(self): - result, exists, saved = self.user_override('[Service]\nExecStart=/custom/portal\n', backup=True) - self.assertNotEqual(result.returncode, 0) - self.assertTrue(exists) - self.assertIsNone(saved) - self.assertIn('takes precedence', result.stderr) + @classmethod + def tearDownClass(cls): + cls.shared.cleanup() - def test_missing_user_override_needs_no_backup(self): - result, exists, saved = self.user_override(None, backup=True) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(exists) - self.assertIsNone(saved) + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / 'root' + pam = self.root / 'etc/pam.d/login' + pam.parent.mkdir(parents=True) + pam.write_text('test PAM stack\n') + self.base = self.root / PREFIX - def test_existing_development_install_requires_explicit_replacement(self): - portal = ('[portal]\nDBusName=org.freedesktop.impl.portal.desktop.ferese\n' - 'Interfaces=org.freedesktop.impl.portal.ScreenCast;\nUseIn=Ferese;\n') - preferences = '[preferred]\ndefault=gtk;\norg.freedesktop.impl.portal.ScreenCast=ferese;\n' - result = self.configuration_preflight(portal, preferences) - self.assertNotEqual(result.returncode, 0) - self.assertIn('--replace-portal-config', result.stderr) - result = self.configuration_preflight(portal, preferences, replace=True) - self.assertEqual(result.returncode, 0, result.stderr) + def run_command(self, *args, success=True, fault=None, fault_after=4): + command = [sys.executable, str(HELPER)] + if fault: + # Inject failure at the filesystem boundary, without shipping production test hooks. + code = f'''import importlib.util, os, sys +spec = importlib.util.spec_from_file_location('installer', {str(HELPER)!r}) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +original = m.Installation.write_state +count = 0 +def write(self, *args, **kwargs): + global count + original(self, *args, **kwargs) + count += 1 + if count == {fault_after}: + {fault} +m.Installation.write_state = write +m.main() +''' + command = [sys.executable, '-c', code] - def test_previous_portal_configuration_is_backed_up(self): - script = (REPO / 'scripts/install-session.sh').read_text() - start = script.index('for relative in portals/ferese.portal') - backup = script[start:script.index('for relative in ', start + 1)] - backup = backup.replace('/usr/share/xdg-desktop-portal/', '$TEST_ROOT/config/') - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for relative in ('portals/ferese.portal', 'ferese-portals.conf'): - source = root / 'config' / relative - source.parent.mkdir(parents=True, exist_ok=True) - source.write_text('existing custom content\n') - result = subprocess.run(['bash', '-eu', '-c', backup], - env={'TEST_ROOT': directory, 'release_dir': str(root / 'release')}, - capture_output=True, text=True) + result = subprocess.run([*command, *map(str, args), '--root', str(self.root)], capture_output=True, text=True) + if success: self.assertEqual(result.returncode, 0, result.stderr) - for relative in ('portals/ferese.portal', 'ferese-portals.conf'): - self.assertEqual((root / 'release/portal-config.previous' / relative).read_text(), - 'existing custom content\n') - self.assertEqual((root / 'config' / relative).read_text(), 'existing custom content\n') + else: + self.assertNotEqual(result.returncode, 0, result.stdout) - def test_upgrades_recorded_previous_release(self): - old_portal = '[portal]\nInterfaces=org.freedesktop.impl.portal.Settings;\n' - old_preferences = '[preferred]\norg.freedesktop.impl.portal.Settings=ferese;\n' - result = self.configuration_preflight(old_portal, old_preferences, - {'ferese.portal': old_portal, 'ferese-portals.conf': old_preferences}) - self.assertEqual(result.returncode, 0, result.stderr) - result = self.configuration_preflight(old_portal + 'UseIn=Custom;\n', old_preferences, - {'ferese.portal': old_portal, 'ferese-portals.conf': old_preferences}) - self.assertNotEqual(result.returncode, 0) - self.assertIn('Unmanaged portal configuration', result.stderr) + return result - def test_preserves_custom_configuration(self): - portal = (REPO / 'packaging/portal/ferese.portal').read_text() - preferences = '[preferred]\ndefault=custom;\n' - result = self.configuration_preflight(portal, preferences) - self.assertNotEqual(result.returncode, 0) - self.assertIn('Unmanaged portal configuration', result.stderr) + def install(self, index=0, **kwargs): + return self.run_command('install', self.bundles[index], **kwargs) - def test_accepts_fresh_install_and_previous_activation_file(self): - self.assertEqual(self.preflight().returncode, 0) - self.assertEqual(self.preflight(LEGACY).returncode, 0) + def snapshot(self): + result = {} + for path in self.root.rglob('*'): + if path.is_relative_to(self.base / 'transactions') or path.is_relative_to(self.base / 'releases'): + continue + if path.is_symlink(): + result[str(path.relative_to(self.root))] = os.readlink(path) + elif path.is_file() and path.name != '.install.lock': + result[str(path.relative_to(self.root))] = path.read_bytes() - def test_accepts_current_managed_files(self): - service = (REPO / 'packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service').read_text() - unit = (REPO / 'packaging/systemd/xdg-desktop-portal-ferese.service').read_text() - self.assertEqual(self.preflight(service, unit).returncode, 0) + return result - def test_preserves_modified_activation_and_systemd_files(self): - result = self.preflight(LEGACY.replace('/usr/local/lib/ferese/current/', '/custom/')) - self.assertNotEqual(result.returncode, 0) - self.assertIn('Unmanaged portal service', result.stderr) - result = self.preflight(LEGACY, '[Service]\nExecStart=/custom/portal\n') - self.assertNotEqual(result.returncode, 0) - self.assertIn('Unmanaged portal unit', result.stderr) + def test_fresh_upgrade_and_rollback_restore_all_integration(self): + self.install() + first = [(self.root / path).read_bytes() for path in (PORTAL, UNIT, DESKTOP)] + self.assertEqual(os.readlink(self.base / 'current'), 'releases/first') + self.assertEqual(os.readlink(self.root / 'usr/local/bin/ferese'), '/usr/local/lib/ferese/current/ferese') + self.install(1) + self.assertEqual(os.readlink(self.base / 'previous'), 'releases/first') + for path, old in zip((PORTAL, UNIT, DESKTOP), first): + self.assertNotEqual((self.root / path).read_bytes(), old) + + self.run_command('rollback') + self.assertEqual(os.readlink(self.base / 'current'), 'releases/first') + self.assertEqual(os.readlink(self.base / 'previous'), 'releases/second') + self.assertEqual([(self.root / path).read_bytes() for path in (PORTAL, UNIT, DESKTOP)], first) + + def test_upgrade_uses_previous_installer_ownership_receipts(self): + self.install() + release = self.base / 'releases/first' + (release / 'manifest.json').unlink() + for rule in installer.INVENTORY['files']: + if 'destination' in rule and ('portal/' in rule['source'] or 'systemd/' in rule['source']): + receipt = release / 'installer-files' / rule['source'].removeprefix('packaging/') + receipt.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(self.root / rule['destination'].lstrip('/'), receipt) + + # The old installer saved no desktop receipt: only identical incoming files are safe. + (self.root / DESKTOP).write_bytes((self.bundles[1] / ('integration/' + DESKTOP)).read_bytes()) + self.install(1) + result = self.run_command('rollback', success=False) + self.assertIn('predates verified bundles', result.stderr) + self.assertEqual(os.readlink(self.base / 'current'), 'releases/second') + + def test_reinstall_is_idempotent_and_keeps_previous(self): + self.install() + self.install(1) + before = self.snapshot() + self.install(1) + self.assertEqual(before, self.snapshot()) + self.assertEqual(len(list((self.base / 'transactions').iterdir())), 2) + + def test_modified_configuration_is_preserved_on_upgrade_and_rollback(self): + self.install() + self.install(1) + (self.root / UNIT).write_text('administrator override\n') + before = self.snapshot() + self.run_command('rollback', success=False) + self.assertEqual(before, self.snapshot()) + self.install(success=False) + self.assertEqual(before, self.snapshot()) + + def test_explicit_portal_replacement_keeps_backup(self): + self.install() + (self.root / PORTAL).write_text('administrator preferences\n') + self.install(1, success=False) + self.run_command('install', self.bundles[1], '--replace-portal-config') + backups = list((self.base / 'transactions').glob('*/backup/*')) + self.assertTrue(any(path.read_text() == 'administrator preferences\n' for path in backups)) + + def test_normal_failure_restores_previous_files_and_can_retry(self): + self.install() + before = self.snapshot() + self.install(1, success=False, fault="raise OSError('injected write failure')") + self.assertEqual(before, self.snapshot()) + self.assertFalse((self.base / '.transaction').exists()) + self.install(1) + + def test_killed_upgrade_recovers_before_next_install(self): + self.install() + self.install(1, success=False, fault='os._exit(91)') + self.assertTrue((self.base / '.transaction/journal.json').is_file()) + result = self.install(1) + self.assertIn('Recovered interrupted', result.stdout) + self.assertFalse((self.base / '.transaction').exists()) + self.assertEqual(os.readlink(self.base / 'current'), 'releases/second') + + def test_failure_after_switching_current_restores_previous_release(self): + self.install() + before = self.snapshot() + self.install(1, success=False, fault="raise OSError('failure after current switch')", fault_after=5) + self.assertEqual(before, self.snapshot()) + + def test_killed_install_after_current_switch_is_recovered(self): + self.install() + before = self.snapshot() + self.install(1, success=False, fault='os._exit(91)', fault_after=5) + self.assertEqual(os.readlink(self.base / 'current'), 'releases/second') + self.run_command('recover') + self.assertEqual(before, self.snapshot()) + + def test_killed_fresh_install_recovers_absent_files(self): + before = self.snapshot() + self.install(success=False, fault='os._exit(91)') + self.run_command('recover') + self.assertEqual(before, self.snapshot()) + + def test_recovery_preserves_edits_made_after_interruption(self): + self.install() + self.install(1, success=False, fault='os._exit(91)') + (self.root / UNIT).write_text('edited after crash\n') + result = self.run_command('recover', success=False) + self.assertIn('Recovery conflict', result.stderr) + self.assertEqual((self.root / UNIT).read_text(), 'edited after crash\n') + self.assertTrue((self.base / '.transaction').exists()) + + def test_concurrent_install_is_rejected(self): + self.base.mkdir(parents=True) + with (self.base / '.install.lock').open('w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + result = self.install(success=False) + + self.assertIn('in progress', result.stderr) + self.assertFalse((self.base / 'current').exists()) + + def test_dry_run_does_not_write(self): + before = self.snapshot() + self.run_command('install', self.bundles[0], '--dry-run') + self.assertEqual(before, self.snapshot()) + self.assertFalse(self.base.exists()) + + def test_custom_pam_is_preserved(self): + pam = self.root / 'etc/pam.d/ferese-lock' + pam.write_text('administrator authentication\n') + self.install() + self.install(1) + self.run_command('rollback') + self.assertEqual(pam.read_text(), 'administrator authentication\n') + + def test_missing_pam_stack_fails_before_publication(self): + (self.root / 'etc/pam.d/login').unlink() + self.install(success=False) + self.assertFalse((self.base / 'current').exists()) + self.assertFalse((self.root / UNIT).exists()) + + def test_symlinked_destination_parent_is_rejected(self): + outside = Path(self.temporary.name) / 'outside' + outside.mkdir() + (self.root / 'usr').symlink_to(outside) + self.install(success=False) + self.assertEqual(list(outside.iterdir()), []) + + def test_uninstall_preserves_releases_pam_and_unrelated_files(self): + self.install() + unrelated = self.root / 'usr/local/bin/unrelated' + unrelated.write_text('keep\n') + self.run_command('uninstall') + self.assertFalse(os.path.lexists(self.base / 'current')) + self.assertFalse(os.path.lexists(self.root / 'usr/local/bin/ferese')) + self.assertFalse((self.root / PORTAL).exists()) + self.assertTrue((self.base / 'releases/first/ferese').is_file()) + self.assertTrue((self.root / 'etc/pam.d/ferese-lock').is_file()) + self.assertEqual(unrelated.read_text(), 'keep\n') + + def test_corrupt_incomplete_unlisted_or_symlink_bundle_is_rejected(self): + for mutation in ('checksum', 'missing', 'extra', 'symlink', 'mode', 'traversal'): + with self.subTest(mutation=mutation): + copy = Path(self.temporary.name) / mutation + shutil.copytree(self.bundles[0], copy) + binary = copy / 'ferese' + if mutation == 'checksum': + binary.write_text('corrupt') + elif mutation == 'missing': + binary.unlink() + elif mutation == 'extra': + (copy / 'unexpected').write_text('extra') + elif mutation == 'symlink': + binary.unlink() + binary.symlink_to('/etc/passwd') + elif mutation == 'mode': + binary.chmod(0o777) + else: + manifest = json.loads((copy / 'manifest.json').read_text()) + manifest['files'][0]['target'] = '../../etc/passwd' + (copy / 'manifest.json').write_text(json.dumps(manifest)) + + self.run_command('install', copy, success=False) + self.assertFalse((self.base / 'current').exists()) + + def test_release_id_collision_does_not_change_current(self): + self.install() + changed = Path(self.temporary.name) / 'changed' + shutil.copytree(self.bundles[0], changed) + manifest = json.loads((changed / 'manifest.json').read_text()) + manifest['features'] = 'different-build' + (changed / 'manifest.json').write_text(json.dumps(manifest)) + before = self.snapshot() + self.run_command('install', changed, success=False) + self.assertEqual(before, self.snapshot()) + + +class FrontendTest(unittest.TestCase): + def test_elevation_preserves_arguments_for_sudo_and_pkexec(self): + import shlex + for elevation in ('sudo', 'pkexec'): + with self.subTest(elevation=elevation), tempfile.TemporaryDirectory() as directory: + bin_dir = Path(directory) + for tool in ('python3', 'dirname', 'uname', 'date'): + (bin_dir / tool).symlink_to(shutil.which(tool)) + elevate = bin_dir / elevation + elevate.write_text('#!/bin/sh\nexit 99\n') + elevate.chmod(0o755) + result = subprocess.run(['/bin/bash', str(REPO / 'scripts/install.sh'), '--bundle', + '/tmp/bundle with spaces', '--replace-portal-config', '--dry-run'], + env=dict(os.environ, PATH=str(bin_dir)), capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + command = shlex.split(result.stdout.removeprefix('Install: ')) + # Building is prohibited as root, but installing an existing bundle is allowed. + expected = ([] if os.geteuid() == 0 else [elevation]) + [ + '/usr/bin/bash', str(REPO / 'scripts/install-session.sh'), 'install', + '/tmp/bundle with spaces', '--replace-portal-config'] + self.assertEqual(command, expected) + + def test_incompatible_or_missing_options_are_rejected(self): + for options in (['--bundle'], ['--release-id'], ['--bundle-only'], + ['--bundle', '/tmp/bundle', '--offline'], + ['--bundle', '/tmp/bundle', '--resize-metrics'], + ['--bundle', '/tmp/bundle', '--release-id', 'another'], + ['--skip-build']): + with self.subTest(options=options): + result = subprocess.run(['/bin/bash', str(REPO / 'scripts/install.sh'), *options], + capture_output=True) + self.assertNotEqual(result.returncode, 0) + + +class UserSetupTest(unittest.TestCase): + def test_repeatable_migration_and_reload_after_failure(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + override = home / '.config/systemd/user/xdg-desktop-portal-ferese.service.d/override.conf' + override.parent.mkdir(parents=True) + override.write_text(f'[Service]\nExecStart=\nExecStart={home}/.local/libexec/ferese/xdg-desktop-portal-ferese\n') + bin_dir = home / 'bin' + bin_dir.mkdir() + systemctl = bin_dir / 'systemctl' + systemctl.write_text('#!/bin/sh\nexit 1\n') + systemctl.chmod(0o755) + env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir)) + # User setup intentionally refuses root; emulate the user identity in root-run CI. + command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup'] + result = subprocess.run(command, env=env, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(override.exists()) + self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1) + systemctl.write_text('#!/bin/sh\nexit 0\n') + for _ in range(2): + result = subprocess.run(command, env=env, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1) + override.write_text('custom configuration\n') + result = subprocess.run(command, env=env, capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(override.read_text(), 'custom configuration\n') if __name__ == '__main__':