Install verified release bundles with recoverable activation
This commit is contained in:
1 parent
17243464f6
commit
f770147c6d
5 files changed
+1180
-379
No files matched your search
+2
-162
@@ -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" "$@"
|
||||
+75
-70
@@ -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
|
||||
--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"
|
||||
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.'
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)]
|
||||
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']))
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
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.assertFalse(exists)
|
||||
self.assertIn('/.local/libexec/ferese/xdg-desktop-portal-ferese', saved)
|
||||
|
||||
def test_custom_user_override_is_preserved_and_reported(self):
|
||||
result, exists, saved = self.user_override('[Service]\nExecStart=/custom/portal\n', backup=True)
|
||||
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.assertTrue(exists)
|
||||
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)
|
||||
self.assertEqual(override.read_text(), 'custom configuration\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in new issue
Block a user