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
+1180 -379

No files matched your search

+2 -162
View File
@@ -1,165 +1,5 @@
#!/usr/bin/env bash #!/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 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) repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
release_id=${1:?usage: install-session.sh RELEASE_ID [--replace-portal-config]} exec python3 "$repo_dir/scripts/installer/install.py" "$@"
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."
+75 -70
View File
@@ -1,64 +1,48 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: scripts/install.sh [options] Usage: scripts/install.sh [options]
Build and install Ferese's compositor, shell, CLI, and login-screen session. Build, bundle and install Ferese. Run as your normal user.
Run from any directory as your normal user. Existing user config is preserved.
--release-id ID Name the installed release (default: UTC timestamp + PID) --release-id ID Release name (default: UTC timestamp + PID)
--skip-build Install existing target/release binaries --bundle PATH Install an existing verified bundle; do not build
--replace-portal-config Back up and replace existing Ferese portal configuration --bundle-only PATH Build a bundle at PATH without installing
--offline Build using only cached Cargo dependencies --replace-portal-config Back up and replace modified Ferese portal config
--resize-metrics Build with resize-barrier measurements (reported on exit) --offline Build using cached Cargo dependencies
--dry-run Print commands without building or installing --resize-metrics Build with resize-barrier measurements
--dry-run Show commands without building or installing
-h, --help Show this help -h, --help Show this help
The previous release remains available for rollback. Administrator Recovery and rollback: scripts/install-session.sh --help
authentication is requested only after a successful build. Repeat user setup: python3 scripts/installer/install.py user-setup
EOF 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; } fail() { echo "ferese installer: $*" >&2; exit 1; }
repo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) 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)-$$" release_id="$(date -u +%Y%m%d-%H%M%S)-$$"
skip_build=false release_set=false
bundle_path=
bundle_only=
replace_portal_config=false replace_portal_config=false
offline=false offline=false
resize_metrics=false resize_metrics=false
dry_run=false dry_run=false
while (($#)); do while (($#)); do
case $1 in case $1 in
--release-id) --release-id|--bundle|--bundle-only)
(($# >= 2)) || fail '--release-id requires a value' (($# >= 2)) && [[ -n $2 && $2 != --* ]] || fail "$1 requires a value"
release_id=$2 case $1 in
shift 2 --release-id) release_id=$2; release_set=true ;;
;; --bundle) bundle_path=$2 ;;
--skip-build) skip_build=true; shift ;; --bundle-only) bundle_only=$2 ;;
esac
shift 2 ;;
--replace-portal-config) replace_portal_config=true; shift ;; --replace-portal-config) replace_portal_config=true; shift ;;
--offline) offline=true; shift ;; --offline) offline=true; shift ;;
--resize-metrics) resize_metrics=true; shift ;; --resize-metrics) resize_metrics=true; shift ;;
@@ -68,56 +52,77 @@ while (($#)); do
esac esac
done done
[[ $release_id =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || fail 'invalid release ID' [[ $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' [[ $(uname -s) == Linux ]] || fail 'Ferese requires Linux'
for tool in desktop-file-validate dbus-run-session; do command -v python3 >/dev/null || fail 'install Python 3 first'
command -v "$tool" >/dev/null || fail "required command not found: $tool" if [[ -n $bundle_path ]]; then
done [[ -z $bundle_only ]] && ! $offline && ! $resize_metrics && ! $release_set ||
desktop-file-validate "$repo_dir/packaging/ferese.desktop" fail '--bundle cannot be combined with build options'
user_portal_override="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/xdg-desktop-portal-ferese.service.d/override.conf" else
if ((EUID != 0)); then ((EUID != 0)) || fail 'build as your normal user, without sudo'
check_user_portal_override "$user_portal_override" "$HOME" || fail 'portal service override needs attention' fi
if [[ -n $bundle_only ]] && $replace_portal_config; then
fail '--replace-portal-config requires installation'
fi fi
build=(cargo build --manifest-path "$repo_dir/Cargo.toml" build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked)
--target-dir "$repo_dir/target" --release --locked mapfile -t packages < <(python3 "$helper" packages)
-p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p ferese-polkit-agent -p xdg-desktop-portal-ferese) ((${#packages[@]})) || fail 'could not read package inventory'
for package in "${packages[@]}"; do build+=(-p "$package"); done
if $offline; then build+=(--offline); fi if $offline; then build+=(--offline); fi
if $resize_metrics; then build+=(--features ferese/resize-metrics); fi features=default
if ! $skip_build; then if $resize_metrics; then
((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' build+=(--features ferese/resize-metrics)
command -v cargo >/dev/null || fail 'install the Rust toolchain and Cargo first' features=ferese/resize-metrics
fi 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 $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 if command -v sudo >/dev/null; then
installer=(sudo "${installer[@]}") installer=(sudo "${installer[@]}")
elif command -v pkexec >/dev/null; then elif command -v pkexec >/dev/null; then
installer=(pkexec /usr/bin/bash "$repo_dir/scripts/install-session.sh" "$release_id") installer=(pkexec "${installer[@]}")
else else
fail 'system installation requires sudo or pkexec' fail 'system installation requires sudo or pkexec'
fi fi
fi fi
if $dry_run; then if $dry_run; then
if ! $skip_build; then printf 'Build: '; printf '%q ' "${build[@]}"; printf '\n'; fi if [[ ${prepare+x} ]]; then
printf 'Install: '; printf '%q ' "${installer[@]}"; printf '\n' 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 exit 0
fi fi
cd -- "$repo_dir" if [[ -z $bundle_only ]] && ((EUID != 0)); then
if ! $skip_build; then "${build[@]}"; fi python3 "$helper" user-setup --check
for name in ferese ferese-shell ferese-settings feresectl ferese-lock ferese-polkit-agent xdg-desktop-portal-ferese ferese-record; do fi
[[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" 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 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[@]}" "${installer[@]}"
if ((EUID != 0)) && [[ -f $user_portal_override ]]; then if ((EUID != 0)); then
backup_user_portal_override "$user_portal_override" "$HOME" "$release_id" || fail 'could not update portal override' if ! python3 "$helper" user-setup; then
systemctl --user daemon-reload || fail 'could not reload the user portal service configuration' 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 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"
}
]
}
+348 -147
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 from pathlib import Path
import shutil
import subprocess import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
REPO = Path(__file__).resolve().parents[2] REPO = Path(__file__).resolve().parents[2]
LEGACY = ('[D-BUS Service]\n' HELPER = REPO / 'scripts/installer/install.py'
'Name=org.freedesktop.impl.portal.desktop.ferese\n' spec = importlib.util.spec_from_file_location('ferese_install', HELPER)
'Exec=/usr/local/lib/ferese/current/xdg-desktop-portal-ferese\n') 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): class InstallerTest(unittest.TestCase):
def preflight(self, service=None, unit=None): @classmethod
script = (REPO / 'scripts/install-session.sh').read_text() def setUpClass(cls):
start = script.index('service=/usr/local/share/dbus-1/services/') cls.shared = tempfile.TemporaryDirectory()
helpers = script[:script.index('repo_dir=')] cls.source = Path(cls.shared.name) / 'source'
script = helpers + script[start:script.index('session_target=', start)] for rule in installer.INVENTORY['files']:
if rule['source'].startswith('target/release/'):
paths = [REPO / rule['source']]
else:
paths = sorted(REPO.glob(rule['source']))
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))
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')
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)
@classmethod
def tearDownClass(cls):
cls.shared.cleanup()
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 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]
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)
else:
self.assertNotEqual(result.returncode, 0, result.stdout)
return result
def install(self, index=0, **kwargs):
return self.run_command('install', self.bundles[index], **kwargs)
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()
return result
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: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) home = Path(directory)
if service is not None: override = home / '.config/systemd/user/xdg-desktop-portal-ferese.service.d/override.conf'
(root / 'activation.service').write_text(service) override.parent.mkdir(parents=True)
if unit is not None: override.write_text(f'[Service]\nExecStart=\nExecStart={home}/.local/libexec/ferese/xdg-desktop-portal-ferese\n')
(root / 'portal.service').write_text(unit) bin_dir = home / 'bin'
script = script.replace( bin_dir.mkdir()
'service=/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service', systemctl = bin_dir / 'systemctl'
'service="$TEST_ROOT/activation.service"') systemctl.write_text('#!/bin/sh\nexit 1\n')
script = script.replace('$(basename "$service")', systemctl.chmod(0o755)
'org.freedesktop.impl.portal.desktop.ferese.service') env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir))
script = script.replace( # User setup intentionally refuses root; emulate the user identity in root-run CI.
'portal_unit=/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service', command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup']
'portal_unit="$TEST_ROOT/portal.service"') result = subprocess.run(command, env=env, capture_output=True)
return subprocess.run(['bash', '-eu', '-c', script], self.assertNotEqual(result.returncode, 0)
env={'repo_dir': str(REPO), 'TEST_ROOT': directory, 'install_root': directory}, self.assertFalse(override.exists())
capture_output=True, text=True) self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1)
systemctl.write_text('#!/bin/sh\nexit 0\n')
def configuration_preflight(self, portal=None, preferences=None, previous=None, replace=False): for _ in range(2):
script = (REPO / 'scripts/install-session.sh').read_text() result = subprocess.run(command, env=env, capture_output=True)
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)
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
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.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(exists) self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1)
self.assertIn('/.local/libexec/ferese/xdg-desktop-portal-ferese', saved) override.write_text('custom configuration\n')
result = subprocess.run(command, env=env, capture_output=True)
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.assertNotEqual(result.returncode, 0)
self.assertTrue(exists) self.assertEqual(override.read_text(), 'custom configuration\n')
self.assertIsNone(saved)
self.assertIn('takes precedence', result.stderr)
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 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 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)
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')
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)
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 test_accepts_fresh_install_and_previous_activation_file(self):
self.assertEqual(self.preflight().returncode, 0)
self.assertEqual(self.preflight(LEGACY).returncode, 0)
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)
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)
if __name__ == '__main__': if __name__ == '__main__':