Install verified release bundles with recoverable activation

This commit is contained in:
Samuel Onoja committed 2026-10-02 10:13:54 +01:00
1 parent 17243464f6
commit f770147c6d
5 files changed
+1172 -371

No files matched your search

+2 -162
View File
@@ -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" "$@"
+77 -72
View File
@@ -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.'
+568
View File
@@ -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)
+187
View File
@@ -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"
}
]
}
+338 -137
View File
@@ -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__':