#!/bin/sh # test-provision-linux.sh — rootless integration test for the Linux # kiosk provisioning pipeline (LibreWolf AND Firefox). # # Runs provision-kiosk.sh and deprovision-kiosk.sh against a sandbox: # all privileged destinations (/opt, /etc, /usr/local/bin, systemd) are # rewritten to a temp directory, and privileged commands (useradd, # systemctl, chown, id) are replaced with shims. No root required, no # real system mutation — this is what CI runs on every push. # # Scenarios: # 1. --librewolf native full provision + deprovision cycle # 2. --firefox native /etc/firefox/policies path (distro Firefox) # 3. --firefox flatpak kiosk-home policy path + sandbox XPI copy # # What it verifies per scenario: # provision: kiosk user creation, /opt staging, XPI build (valid zip # with manifest.json), launcher install, kiosk.env browser # + flavor, Native Messaging manifests (all five Gecko # locations, valid JSON, correct path), policies.json # deep-merge (the browser's own keys survive, ours added, # extension force-installed with the right install_url), # unit generation + enable. # deprovision: unit removed, policies.json equal to the pre-provision # baseline byte-for-byte, manifests + sandbox XPI copies # removed, staged files removed. # # After the scenarios, the launcher dispatch is exercised directly: all # four env permutations (firefox/flatpak, firefox/native, # librewolf/flatpak, defaults) run against browser shims and the exec'd # command line is asserted. # # Usage: sh scripts/test-provision-linux.sh (KEEP_SANDBOX=1 to inspect) # Exit: 0 pass, 1 fail # # POSIX sh — no bashisms. set -u REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) SANDBOX=$(mktemp -d) PASS=0 FAIL=0 KIOSK_USER="vestibule-kiosk" cleanup() { if [ "${KEEP_SANDBOX:-0}" = "1" ]; then say "sandbox kept at: ${SANDBOX}" else rm -rf "${SANDBOX}" fi } trap cleanup EXIT INT TERM say() { printf '%s\n' "$1"; } pass() { PASS=$((PASS+1)); say " [pass] $1"; } fail() { FAIL=$((FAIL+1)); say " [FAIL] $1"; } check() { # check desc="$1"; shift if "$@" >/dev/null 2>&1; then pass "${desc}"; else fail "${desc}"; fi } # ─── Sandbox layout ─────────────────────────────────────────────────── # # repo/ copy of the real repo (scripts/, config/, extension/) # root/opt fake /opt/vestibule # root/etc fake /etc/vestibule + /etc/systemd/system # root/etc/firefox fake /etc/firefox/policies (distro Firefox) # root/usrlocal fake /usr/local/bin # root/usr/lib/firefox fake distro Firefox install # librewolf/ fake native LibreWolf install (wrapper in PATH) # home/ fake kiosk user home # bin/ PATH shims (privileged + browser + usher) mkdir -p "${SANDBOX}/repo" "${SANDBOX}/root/opt" "${SANDBOX}/root/etc/systemd/system" \ "${SANDBOX}/root/etc/firefox/policies" "${SANDBOX}/root/usrlocal" \ "${SANDBOX}/root/usr/lib/firefox" "${SANDBOX}/bin" \ "${SANDBOX}/librewolf/browser" "${SANDBOX}/librewolf/distribution" \ "${SANDBOX}/home" cp -r "${REPO_ROOT}/scripts" "${REPO_ROOT}/config" "${REPO_ROOT}/extension" "${SANDBOX}/repo/" mkdir -p "${SANDBOX}/repo/helper/target/release" printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/repo/helper/target/release/usher" chmod +x "${SANDBOX}/repo/helper/target/release/usher" # Fake native LibreWolf: wrapper in PATH + real install dir with markers. printf '#!/bin/sh\nexec "%s/librewolf/librewolf" "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/librewolf" chmod +x "${SANDBOX}/bin/librewolf" # Fake LibreWolf binary: logs its argv (launcher dispatch assertions). printf '#!/bin/sh\nprintf "%%s\\n" "librewolf $*" >> "%s/browser.log"\n' "${SANDBOX}" \ > "${SANDBOX}/librewolf/librewolf" chmod +x "${SANDBOX}/librewolf/librewolf" touch "${SANDBOX}/librewolf/application.ini" # Fake Firefox in PATH for the launcher dispatch assertions. Provision # detection never consults it: the canonical /usr/lib/firefox path wins. printf '#!/bin/sh\nprintf "%%s\\n" "firefox $*" >> "%s/browser.log"\n' "${SANDBOX}" \ > "${SANDBOX}/bin/firefox" chmod +x "${SANDBOX}/bin/firefox" # Pre-seed LibreWolf's own shipped policies — the merge must preserve # them and the deprovision must return this baseline byte-for-byte. cat > "${SANDBOX}/librewolf/distribution/policies.json" <<'EOF' { "policies": { "DisableAppUpdate": true, "LibreWolfOwnSetting": "must-survive" } } EOF cp "${SANDBOX}/librewolf/distribution/policies.json" "${SANDBOX}/original-lw-policies.json" # Fake distro Firefox at /usr/lib/firefox (canonical Debian/Arch layout; # Fedora uses /usr/lib64 — same code path). No PATH wrapper: detection # must find it via the canonical path. printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/root/usr/lib/firefox/firefox" chmod +x "${SANDBOX}/root/usr/lib/firefox/firefox" touch "${SANDBOX}/root/usr/lib/firefox/application.ini" # Pre-seed Firefox's own policies in /etc/firefox/policies. cat > "${SANDBOX}/root/etc/firefox/policies/policies.json" <<'EOF' { "policies": { "DisableTelemetry": true, "FirefoxOwnSetting": "must-survive" } } EOF cp "${SANDBOX}/root/etc/firefox/policies/policies.json" "${SANDBOX}/original-ff-policies.json" # ─── PATH shims ─────────────────────────────────────────────────────── printf '#!/bin/sh\n# id shim: "id -u" -> 0 (root); "id -u NAME" -> uid or fail if absent\nif [ "$1" = "-u" ] && [ $# -eq 1 ]; then echo 0; exit 0; fi\nname="$2"\nif grep -q "^${name}:" "%s/passwd" 2>/dev/null; then echo 1500; exit 0; fi\nexit 1\n' \ "${SANDBOX}" > "${SANDBOX}/bin/id" printf '#!/bin/sh\necho useradd "$@" >> "%s/priv.log"\nmkdir -p "%s/home/vestibule-kiosk"\nprintf "vestibule-kiosk:x:1500:1500::%s/home/vestibule-kiosk:/bin/sh\\n" >> "%s/passwd"\n' \ "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/useradd" printf '#!/bin/sh\nif [ "$1" = "passwd" ] && [ "$2" = "vestibule-kiosk" ]; then grep "^vestibule-kiosk:" "%s/passwd"; fi\nexit 0\n' \ "${SANDBOX}" > "${SANDBOX}/bin/getent" printf '#!/bin/sh\necho systemctl "$@" >> "%s/priv.log"\nexit 0\n' "${SANDBOX}" > "${SANDBOX}/bin/systemctl" printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/bin/chown" printf '#!/bin/sh\necho userdel "$@" >> "%s/priv.log"\ngrep -v "^vestibule-kiosk:" "%s/passwd" > "%s/passwd.tmp" && mv "%s/passwd.tmp" "%s/passwd"\nexit 0\n' \ "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/userdel" # cage shim: log the invocation, then exec the client after "--". The # harness pins VESTIBULE_CAGE_ARGS to a single flag (-d) in every # launcher test, so exactly two arguments precede the client. printf '#!/bin/sh\nprintf "%%s\\n" "cage $*" >> "%s/browser.log"\nshift 2\nexec "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/cage" # dbus-run-session shim: pass straight through to the compositor. printf '#!/bin/sh\nshift\nexec "$@"\n' > "${SANDBOX}/bin/dbus-run-session" # flatpak shim: system installs "exist" (info --system succeeds), user # installs do not, and "run" is a logged browser launch. printf '#!/bin/sh\nif [ "$1" = "info" ]; then [ "$2" = "--system" ] && exit 0; exit 1; fi\nif [ "$1" = "run" ]; then printf "%%s\\n" "flatpak $*" >> "%s/browser.log"; exit 0; fi\nexit 1\n' \ "${SANDBOX}" > "${SANDBOX}/bin/flatpak" for f in "${SANDBOX}/bin/"*; do chmod +x "$f"; done # ─── Rewrite privileged paths in the scripts under test ─────────────── rewrite() { sed -e "s|/run/systemd/system|${SANDBOX}/run-systemd|g" \ -e "s|/opt/vestibule|${SANDBOX}/root/opt/vestibule|g" \ -e "s|/etc/vestibule|${SANDBOX}/root/etc/vestibule|g" \ -e "s|/etc/systemd/system|${SANDBOX}/root/etc/systemd/system|g" \ -e "s|/etc/firefox|${SANDBOX}/root/etc/firefox|g" \ -e "s|/usr/lib/firefox|${SANDBOX}/root/usr/lib/firefox|g" \ -e "s|/usr/local/bin|${SANDBOX}/root/usrlocal|g" \ -e "s|/usr/lib/librewolf|${SANDBOX}/librewolf|g" \ "$1" > "$2" } mkdir -p "${SANDBOX}/run-systemd" rewrite "${REPO_ROOT}/scripts/provision-kiosk.sh" "${SANDBOX}/repo/scripts/provision-kiosk.sh" rewrite "${REPO_ROOT}/scripts/deprovision-kiosk.sh" "${SANDBOX}/repo/scripts/deprovision-kiosk.sh" rewrite "${REPO_ROOT}/scripts/vestibule-kiosk-launch" "${SANDBOX}/repo/scripts/vestibule-kiosk-launch" export PATH="${SANDBOX}/bin:${PATH}" NM="${SANDBOX}/home/vestibule-kiosk" UNIT="${SANDBOX}/root/etc/systemd/system/vestibule-kiosk.service" run_provision() { sh "${SANDBOX}/repo/scripts/provision-kiosk.sh" "$@" \ --kiosk-user "${KIOSK_USER}" --tty 2 --yes \ > "${SANDBOX}/provision.out" 2>&1 } run_deprovision() { sh "${SANDBOX}/repo/scripts/deprovision-kiosk.sh" \ --kiosk-user "${KIOSK_USER}" \ --remove-user --remove-opt --remove-usher --yes \ > "${SANDBOX}/deprovision.out" 2>&1 } report_on_fail() { if [ "$1" -ne 0 ]; then sed -n '1,60p' "${SANDBOX}/provision.out" 2>/dev/null sed -n '1,60p' "${SANDBOX}/deprovision.out" 2>/dev/null fi } assert_common_provision() { # Everything browser-independent: staging, launcher, unit, NM. check "usher installed" test -x "${SANDBOX}/root/usrlocal/usher" check "usher staged under /opt" test -x "${SANDBOX}/root/opt/vestibule/bin/usher" check "launcher installed" test -x "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch" check "XPI built" test -f "${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi" if python3 -c " import zipfile, json z = zipfile.ZipFile('${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi') m = json.loads(z.read('manifest.json')) assert m['version'] == '1.2.2', m['version'] "; then pass "XPI valid zip with manifest.json v1.2.2"; else fail "XPI valid zip with manifest.json v1.2.2"; fi check "unit generated" test -f "${UNIT}" check "unit User substituted" grep -q '^User=vestibule-kiosk$' "${UNIT}" check "unit TTYPath substituted" grep -q '^TTYPath=/dev/tty2$' "${UNIT}" check "unit conflicts getty" grep -q 'Conflicts=getty@tty2.service' "${UNIT}" check "unit execs launcher" grep -q 'ExecStart=/usr/local/bin/vestibule-kiosk-launch' "${UNIT}" check "systemctl daemon-reload" grep -q "daemon-reload" "${SANDBOX}/priv.log" check "systemctl enable" grep -q "enable vestibule-kiosk.service" "${SANDBOX}/priv.log" # Native Messaging manifests: all five Gecko locations, valid JSON. for loc in ".librewolf" ".mozilla" \ ".var/app/io.gitlab.librewolf-community/.librewolf" \ ".var/app/org.mozilla.firefox/.mozilla" \ "snap/firefox/common/.mozilla"; do f="${NM}/${loc}/native-messaging-hosts/com.vestibule.usher.json" check "NM manifest ${loc}" test -f "${f}" if [ -f "${f}" ] && python3 -c " import json m = json.load(open('${f}')) assert m['path'] == '${SANDBOX}/root/usrlocal/usher', m['path'] assert m['allowed_extensions'] == ['vestibule@vestibule.kiosk'] assert m['type'] == 'stdio' "; then pass "NM manifest ${loc} valid"; else fail "NM manifest ${loc} valid"; fi done } assert_common_deprovision() { check "unit removed" test ! -f "${UNIT}" check "launcher removed" test ! -e "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch" check "kiosk.env removed" test ! -e "${SANDBOX}/root/etc/vestibule" check "/opt/vestibule removed" test ! -e "${SANDBOX}/root/opt/vestibule" check "usher removed" test ! -e "${SANDBOX}/root/usrlocal/usher" check "userdel called" grep -q "userdel" "${SANDBOX}/priv.log" check "NM manifests removed" test ! -e "${NM}/.librewolf/native-messaging-hosts/com.vestibule.usher.json" check "NM manifests removed (firefox flatpak)" test ! -e "${NM}/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/com.vestibule.usher.json" check "NM manifests removed (firefox snap)" test ! -e "${NM}/snap/firefox/common/.mozilla/native-messaging-hosts/com.vestibule.usher.json" check "sandbox XPI copy removed" test ! -e "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi" } # ══════════════════════════════════════════════════════════════════════ # Scenario 1: LibreWolf, native # ══════════════════════════════════════════════════════════════════════ say "" say "==> scenario 1: provision --librewolf native" if run_provision --librewolf native --home-url https://checkin.example.org; then pass "provision-kiosk.sh exited 0" else fail "provision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_provision check "kiosk.env written" test -f "${SANDBOX}/root/etc/vestibule/kiosk.env" check "kiosk.env home URL" grep -q 'VESTIBULE_HOME_URL="https://checkin.example.org"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "kiosk.env browser librewolf" grep -q 'VESTIBULE_BROWSER="librewolf"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "kiosk.env flavor native" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env" POL="${SANDBOX}/librewolf/distribution/policies.json" if python3 -c " import json p = json.load(open('${POL}'))['policies'] assert p['LibreWolfOwnSetting'] == 'must-survive', 'pre-existing key lost' assert p['DisablePrivateBrowsing'] is True, 'canonical key missing' assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing' es = p['ExtensionSettings'] assert es['*']['installation_mode'] == 'blocked' assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed' assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url'] "; then pass "policies.json deep-merge correct (librewolf)"; else fail "policies.json deep-merge correct (librewolf)"; fi check "policies backup created" test -f "${POL}.vestibule-bak" say "" say "==> scenario 1: deprovision" if run_deprovision; then pass "deprovision-kiosk.sh exited 0" else fail "deprovision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_deprovision if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then pass "librewolf policies.json equals the pre-provision baseline (byte-for-byte)" else fail "librewolf policies.json equals the pre-provision baseline (byte-for-byte)" fi # ══════════════════════════════════════════════════════════════════════ # Scenario 2: Firefox, native (distro package -> /etc/firefox/policies) # ══════════════════════════════════════════════════════════════════════ say "" say "==> scenario 2: provision --firefox native" if run_provision --firefox native --home-url https://portal.example.org; then pass "provision-kiosk.sh exited 0" else fail "provision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_provision FFPOL="${SANDBOX}/root/etc/firefox/policies/policies.json" check "kiosk.env browser firefox" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "kiosk.env flavor native (ff)" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "firefox policies file deployed" test -f "${FFPOL}" if python3 -c " import json p = json.load(open('${FFPOL}'))['policies'] assert p['FirefoxOwnSetting'] == 'must-survive', 'pre-existing Firefox key lost' assert p['DisablePrivateBrowsing'] is True, 'canonical key missing' assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing' es = p['ExtensionSettings'] assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed' assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url'] "; then pass "firefox /etc/firefox policies deep-merge correct"; else fail "firefox /etc/firefox policies deep-merge correct"; fi check "firefox policies backup created" test -f "${FFPOL}.vestibule-bak" # Firefox provisioning must not touch the LibreWolf install. if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then pass "librewolf policies untouched by firefox provision" else fail "librewolf policies untouched by firefox provision" fi say "" say "==> scenario 2: deprovision" if run_deprovision; then pass "deprovision-kiosk.sh exited 0" else fail "deprovision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_deprovision if cmp -s "${FFPOL}" "${SANDBOX}/original-ff-policies.json"; then pass "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)" else fail "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)" fi # ══════════════════════════════════════════════════════════════════════ # Scenario 3: Firefox, Flatpak (kiosk-home policy path + XPI copy) # ══════════════════════════════════════════════════════════════════════ say "" say "==> scenario 3: provision --firefox flatpak" if run_provision --firefox flatpak --home-url https://lobby.example.org; then pass "provision-kiosk.sh exited 0" else fail "provision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_provision FFHOME_POL="${NM}/.var/app/org.mozilla.firefox/.mozilla/policies/policies.json" check "kiosk.env browser firefox (fp)" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "kiosk.env flavor flatpak (fp)" grep -q 'VESTIBULE_BROWSER_FLAVOR="flatpak"' "${SANDBOX}/root/etc/vestibule/kiosk.env" check "XPI copied to flatpak home" test -f "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi" check "flatpak policies file deployed" test -f "${FFHOME_POL}" if python3 -c " import json p = json.load(open('${FFHOME_POL}'))['policies'] es = p['ExtensionSettings'] assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed' # install_url must point INSIDE the sandbox-visible home, not /opt. assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${NM}/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url'] "; then pass "flatpak install_url points at sandbox-visible XPI"; else fail "flatpak install_url points at sandbox-visible XPI"; fi say "" say "==> scenario 3: deprovision" if run_deprovision; then pass "deprovision-kiosk.sh exited 0" else fail "deprovision-kiosk.sh exited nonzero" report_on_fail 1 fi assert_common_deprovision check "flatpak-home policies removed" test ! -e "${FFHOME_POL}" # ════════════════════════════════════════════════════════════════════ # Launcher dispatch: all four env permutations run against browser # shims; the exec'd command line is asserted, not just parsed. # ════════════════════════════════════════════════════════════════════ say "" say "==> launcher dispatch" LAUNCHER="${SANDBOX}/repo/scripts/vestibule-kiosk-launch" KIOSK_ENV="${SANDBOX}/root/etc/vestibule/kiosk.env" URL="https://launch.example.org" write_kiosk_env() { mkdir -p "${SANDBOX}/root/etc/vestibule" { printf 'VESTIBULE_HOME_URL="%s"\n' "${URL}" printf 'VESTIBULE_CAGE_ARGS="-d"\n' [ -n "${1:-}" ] && printf 'VESTIBULE_BROWSER="%s"\n' "$1" [ -n "${2:-}" ] && printf 'VESTIBULE_BROWSER_FLAVOR="%s"\n' "$2" } > "${KIOSK_ENV}" } launcher_check() { # $1 = description, $2 = expected command line (fixed string) desc="$1"; expect="$2" rm -f "${SANDBOX}/browser.log" sh "${LAUNCHER}" > "${SANDBOX}/launcher.out" 2>&1 if grep -qF "${expect}" "${SANDBOX}/browser.log" 2>/dev/null; then pass "launcher dispatches ${desc}" else fail "launcher dispatches ${desc} (log: $(cat "${SANDBOX}/browser.log" 2>/dev/null || echo empty))" fi } write_kiosk_env firefox flatpak launcher_check "firefox/flatpak -> flatpak run" \ "flatpak run org.mozilla.firefox --kiosk -P vestibule-profile -no-remote ${URL}" write_kiosk_env firefox native launcher_check "firefox/native -> firefox" \ "firefox --kiosk -P vestibule-profile -no-remote ${URL}" write_kiosk_env librewolf flatpak launcher_check "librewolf/flatpak -> flatpak run" \ "flatpak run io.gitlab.librewolf-community --kiosk -P vestibule-profile -no-remote ${URL}" write_kiosk_env "" "" launcher_check "defaults -> librewolf native" \ "librewolf --kiosk -P vestibule-profile -no-remote ${URL}" # ─── Summary ────────────────────────────────────────────────────────── say "" if [ "${FAIL}" -eq 0 ]; then say "OK — ${PASS} assertions passed: kiosk provision/deprovision works for LibreWolf native, Firefox native, and Firefox Flatpak." exit 0 else say "FAIL: ${FAIL} failed of $((PASS+FAIL))" exit 1 fi