Merge branch 'quattro' into unified-input-method-setup
This commit is contained in:
commit
101bb547bb
245 files changed
+19949
-375
No files matched your search
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# PC and Intel Mac quirks that must leave Apple Silicon alone, run on both sides
|
||||
# of the detector.
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls.log"
|
||||
conf="$test_tmp/modprobe.d/hid_apple.conf"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${APPLE_SILICON:-0} == "1" ]]
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/uname" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "-m" ]]; then
|
||||
echo "${UNAME_M:-x86_64}"
|
||||
else
|
||||
exec /usr/bin/uname "$@"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'sudo %s\n' "$*" >>"$TEST_LOG"
|
||||
"$@"
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
run_fkeys() {
|
||||
APPLE_SILICON="$1" OMARCHY_HID_APPLE_CONF="$conf" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \
|
||||
bash -eE -o pipefail -c 'source "$1"' _ "$ROOT/install/hardware/fix-fkeys.sh"
|
||||
}
|
||||
|
||||
run_fkeys 0
|
||||
[[ $(<"$conf") == "options hid_apple fnmode=2" ]] || fail "a PC keeps F-keys first on Apple-like keyboards" "$(cat "$conf")"
|
||||
pass "a PC keeps F-keys first on Apple-like keyboards"
|
||||
|
||||
printf 'options hid_apple fnmode=0\n' >"$conf"
|
||||
run_fkeys 0
|
||||
[[ $(<"$conf") == "options hid_apple fnmode=0" ]] || fail "an existing hid_apple.conf is left alone"
|
||||
pass "an existing hid_apple.conf is left alone"
|
||||
|
||||
rm -rf "$test_tmp/modprobe.d"
|
||||
: >"$calls"
|
||||
run_fkeys 1
|
||||
[[ ! -e $conf && ! -s $calls ]] || fail "Apple Silicon keeps its own keyboard mode" "$(cat "$calls")"
|
||||
pass "Apple Silicon keeps its own keyboard mode"
|
||||
|
||||
# The Windows guest is x86_64, so every other CPU refuses it before it touches
|
||||
# anything, Apple Silicon or not.
|
||||
for machine in aarch64 armv7l; do
|
||||
output=$(UNAME_M=$machine APPLE_SILICON=0 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" install 2>&1) &&
|
||||
fail "Windows VM install fails on $machine"
|
||||
[[ $output == *"needs an x86_64 machine"* ]] || fail "Windows VM says why it refuses on $machine" "$output"
|
||||
done
|
||||
pass "Windows VM refuses every CPU but x86_64"
|
||||
|
||||
# The firmware boot entry and hibernation are refused or skipped on Apple
|
||||
# Silicon before they touch anything.
|
||||
output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-setup-direct-boot" 2>&1) &&
|
||||
fail "direct boot fails on Apple Silicon"
|
||||
[[ $output == *"not supported on Apple Silicon"* ]] || fail "direct boot says why it refuses" "$output"
|
||||
output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-hibernation-setup" --force 2>&1) ||
|
||||
fail "hibernation setup skips Apple Silicon without failing" "$output"
|
||||
[[ $output == "Skipping hibernation setup (not supported on Apple Silicon)" ]] ||
|
||||
fail "hibernation setup stops before its own checks on Apple Silicon" "$output"
|
||||
pass "direct boot and hibernation step aside on Apple Silicon"
|
||||
|
||||
# On x86_64 the Windows VM goes on to its own commands, as before.
|
||||
output=$(UNAME_M=x86_64 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" help 2>&1) || true
|
||||
[[ $output != *"x86_64 machine"* && $output == *"install"* ]] || fail "Windows VM runs as before on x86_64" "$output"
|
||||
pass "Windows VM runs as before on x86_64"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
|
||||
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8'))
|
||||
const when = id => (items.find(entry => entry.id === id) || {}).when
|
||||
assertEqual(when('install.windows'), 'omarchy-hw-x86', 'install.windows is offered on x86_64 only')
|
||||
assertEqual(when('setup.direct-boot'), '! omarchy-hw-aarch64-apple', 'setup.direct-boot is hidden on Apple Silicon only')
|
||||
JS
|
||||
pass "the menu offers Windows on x86_64 only and hides Direct Boot on Apple Silicon only"
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin"
|
||||
|
||||
cat >"$work/bin/wpctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'wpctl %s\n' "$*" >>"$CALLS"
|
||||
SH
|
||||
cat >"$work/bin/pactl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'pactl %s\n' "$*" >>"$CALLS"
|
||||
if [[ $* == "list source-outputs" ]]; then
|
||||
[[ ${LC_ALL:-} == C ]] || echo "pactl read outside the C locale" >>"$CALLS"
|
||||
cat "$OUTPUTS"
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$work/bin/wpctl" "$work/bin/pactl"
|
||||
|
||||
moved() {
|
||||
grep '^pactl move-source-output ' "$CALLS" | awk '{ print $3 }' | tr '\n' ' '
|
||||
}
|
||||
|
||||
export CALLS="$work/calls" OUTPUTS="$work/outputs"
|
||||
|
||||
# A filter chain's capture half (joined to its source by node.link-group)
|
||||
# records its device all the time. Choosing an input moves apps, never it.
|
||||
cat >"$OUTPUTS" <<'OUT'
|
||||
Source Output #67
|
||||
Driver: PipeWire
|
||||
Properties:
|
||||
node.name = "filter_capture.mic"
|
||||
node.group = "filter-chain-100-18"
|
||||
node.link-group = "filter-chain-100-18"
|
||||
media.name = "Microphone filter"
|
||||
Source Output #90
|
||||
Driver: PipeWire
|
||||
Properties:
|
||||
application.name = "Firefox"
|
||||
node.name = "Firefox"
|
||||
Source Output #91
|
||||
Properties:
|
||||
node.name = "native-recorder"
|
||||
media.name = "node.link-group = in a title"
|
||||
OUT
|
||||
: >"$CALLS"
|
||||
PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 113 virtual_mic
|
||||
grep -qx 'wpctl set-default 113' "$CALLS" || fail 'input set-default selects the node'
|
||||
grep -qx 'pactl set-default-source virtual_mic' "$CALLS" || fail 'input set-default names the default source'
|
||||
! grep -q 'outside the C locale' "$CALLS" || fail 'input set-default reads the recordings in the C locale'
|
||||
[[ $(moved) == "90 91 " ]] || fail 'input set-default moves apps but not a filter'"'"'s own capture' "$(moved)"
|
||||
pass 'input set-default moves apps but not a filter'"'"'s own capture'
|
||||
|
||||
# Without filters every recording moves, as before, whatever its properties.
|
||||
cat >"$OUTPUTS" <<'OUT'
|
||||
Source Output #12
|
||||
Properties:
|
||||
application.name = "OBS"
|
||||
Source Output #13
|
||||
Properties:
|
||||
node.name = "native-recorder"
|
||||
Source Output #14
|
||||
Properties:
|
||||
node.name = "filter_capture.mic-copy"
|
||||
node.group = "pipewire.dummy"
|
||||
OUT
|
||||
: >"$CALLS"
|
||||
PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 43 alsa_input.pci-0000_00_1f.3.analog-stereo
|
||||
[[ $(moved) == "12 13 14 " ]] || fail 'input set-default still moves every other recording' "$(moved)"
|
||||
pass 'input set-default still moves every other recording'
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir -p "$work/bin"
|
||||
|
||||
cat >"$work/bin/pactl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${LC_ALL:-unset}" "$*" >>"$CALLS"
|
||||
case $* in
|
||||
"list sources") cat "$SOURCES" ;;
|
||||
"list sinks") cat "$SINKS" ;;
|
||||
esac
|
||||
SH
|
||||
cat >"$work/bin/omarchy-audio-tuning" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$work/bin/pactl" "$work/bin/omarchy-audio-tuning"
|
||||
export CALLS="$work/calls" SOURCES="$work/sources" SINKS="$work/sinks"
|
||||
|
||||
# A headset jack input with nothing plugged in, a virtual source (no ports)
|
||||
# and a filter's source (one port of unknown availability).
|
||||
cat >"$SOURCES" <<'OUT'
|
||||
Source #1074
|
||||
Name: alsa_input.pci-0000_00_1f.3.analog-stereo
|
||||
Ports:
|
||||
[In] analog-input-headset-mic: Headset Microphone (type: Headset, priority: 300, availability group: Headset Mic, not available)
|
||||
Active Port: [In] analog-input-headset-mic
|
||||
Formats:
|
||||
pcm
|
||||
Source #126
|
||||
Name: easyeffects_source
|
||||
Formats:
|
||||
pcm
|
||||
Source #1102
|
||||
Name: effect_output.mic-filter
|
||||
Ports:
|
||||
[In] Mic: Microphone (type: Mic, priority: 100, availability unknown)
|
||||
Active Port: [In] Mic
|
||||
OUT
|
||||
cat >"$SINKS" <<'OUT'
|
||||
Sink #57
|
||||
Name: alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||
Ports:
|
||||
[Out] analog-output-headphones: Headphones (type: Headphones, priority: 300, availability group: Headphone, not available)
|
||||
Active Port: [Out] analog-output-headphones
|
||||
OUT
|
||||
|
||||
: >"$CALLS"
|
||||
expected=$'alsa_input.pci-0000_00_1f.3.analog-stereo\t0\neasyeffects_source\t1\neffect_output.mic-filter\t1'
|
||||
actual=$(PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability" sources)
|
||||
[[ $actual == "$expected" ]] || fail 'source availability reports an empty jack input unavailable' "$actual"
|
||||
grep -qx 'C list sources' "$CALLS" || fail 'source availability reads pactl in the C locale' "$(cat "$CALLS")"
|
||||
pass 'source availability reports an empty jack input unavailable'
|
||||
|
||||
: >"$CALLS"
|
||||
actual=$(LC_ALL=de_DE.UTF-8 PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability")
|
||||
[[ $actual == $'alsa_output.pci-0000_00_1f.3.analog-stereo\t0' ]] || fail 'sink availability reports an empty headphone jack unavailable' "$actual"
|
||||
grep -qx 'C list sinks' "$CALLS" || fail 'sink availability reads pactl in the C locale whatever the caller'"'"'s' "$(cat "$CALLS")"
|
||||
pass 'sink availability reports an empty headphone jack unavailable, in the C locale'
|
||||
@@ -57,4 +57,72 @@ assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'au
|
||||
assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams')
|
||||
assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS')
|
||||
assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player')
|
||||
|
||||
// A virtual source is untyped in Quickshell (no audio); PulseAudio's source
|
||||
// listing is what names it a source.
|
||||
const availability = { virtual_mic: true, 'alsa_input.usb': false }
|
||||
assert(audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, availability), 'audio finds an untyped source PulseAudio lists')
|
||||
assert(audio.isUntypedSource({ name: 'unplugged_virtual', isSink: false, isStream: false }, { unplugged_virtual: false }), 'an untyped source PulseAudio lists as unavailable is still a source')
|
||||
assert(!audio.isUntypedSource({ name: 'v4l2_input.camera', isSink: false, isStream: false }, availability), 'audio skips an untyped node PulseAudio does not list')
|
||||
assert(!audio.isUntypedSource({ name: 'virtual_mic', audio: {}, isSink: false, isStream: false }, availability), 'a typed source is not untyped')
|
||||
assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: true }, availability), 'a stream is never an untyped source')
|
||||
assert(!audio.isUntypedSource({ name: '', isSink: false, isStream: false }, { '': true }), 'a nameless node is never an untyped source')
|
||||
assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, {}), 'nothing is untyped before the listing')
|
||||
|
||||
const nodes = requireFromRoot('shell/Commons/AudioNodesModel.js')
|
||||
|
||||
for (const name of ['quickshell', 'quickshell-peak-monitor'])
|
||||
assert(nodes.isShellLevelMeter(name), 'audio knows the shell meter ' + name)
|
||||
for (const name of ['Firefox', 'quickshell-other', '', undefined])
|
||||
assert(!nodes.isShellLevelMeter(name), 'audio counts ' + name + ' as a recording')
|
||||
|
||||
// Platform hints: none by default, whole-name patterns, and "replaced" only
|
||||
// while its replacement exists.
|
||||
const none = nodes.parsePlatformAudio('')
|
||||
assertDeepEqual([none.hidden.length, none.replaced.length], [0, 0], 'audio has no platform hints without the file')
|
||||
for (const text of ['not json', '[]', 'null', '{"hidden": "x"}', '{"replaced": [1, {"node": "a"}, {"node": "(", "by": "b"}]}']) {
|
||||
const parsed = nodes.parsePlatformAudio(text)
|
||||
assertEqual(parsed.hidden.length + parsed.replaced.length, 0, 'audio ignores platform hints ' + text)
|
||||
}
|
||||
const hints = nodes.parsePlatformAudio(JSON.stringify({
|
||||
hidden: ['dsp_capture\\.[a-z]+', 'raw_speakers', '(', 7],
|
||||
replaced: [{ node: 'dsp_mic\\.[0-9]+', by: 'stereo_mic' }]
|
||||
}))
|
||||
assertEqual(hints.hidden.length, 2, 'audio skips invalid hidden patterns')
|
||||
const names = ['dsp_capture.mic', 'dsp_mic.1', 'stereo_mic', 'raw_speakers']
|
||||
assert(nodes.platformHidesNode('dsp_capture.mic', hints, names), 'platform hints hide a matching node')
|
||||
assert(nodes.platformHidesNode('raw_speakers', hints, []), 'platform hints hide a literal name')
|
||||
assert(!nodes.platformHidesNode('raw_speakers.monitor', hints, []), 'platform patterns match whole names only')
|
||||
assert(!nodes.platformHidesNode('xdsp_capture.mic', hints, []), 'platform patterns are anchored at the start')
|
||||
assert(nodes.platformHidesNode('dsp_mic.1', hints, names), 'platform hints hide a replaced node while its replacement exists')
|
||||
assert(!nodes.platformHidesNode('dsp_mic.1', hints, ['dsp_mic.1']), 'platform hints keep a replaced node without its replacement')
|
||||
assert(!nodes.platformHidesNode('stereo_mic', hints, names), 'platform hints keep the replacement')
|
||||
for (const name of ['Firefox', 'alsa_input.pci-0000_00_1f.3.analog-stereo', '', undefined])
|
||||
assert(!nodes.platformHidesNode(name, hints, names), 'platform hints keep ' + name)
|
||||
assert(!nodes.platformHidesNode('dsp_capture.mic', none, names), 'no platform hints hide nothing')
|
||||
|
||||
// An untyped source's level is read from wpctl.
|
||||
assertDeepEqual(nodes.parseWpctlVolume('Volume: 0.50\n'), { volume: 0.5, muted: false }, 'audio reads a wpctl volume')
|
||||
assertDeepEqual(nodes.parseWpctlVolume('Volume: 1.00 [MUTED]'), { volume: 1, muted: true }, 'audio reads a muted wpctl volume')
|
||||
for (const text of ['', 'Translate ID error: 404', 'Volume: loud', 'Volume: 0.50 [muted]'])
|
||||
assertEqual(nodes.parseWpctlVolume(text), null, 'audio rejects wpctl output ' + JSON.stringify(text))
|
||||
|
||||
// Source wiring (no Quickshell here): the hints come from the fixed platform
|
||||
// root, and the audio code names no platform or hardware of its own.
|
||||
const fs = require('fs')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
const audioNodes = read('shell/Commons/AudioNodes.qml')
|
||||
assert(/path: "\/usr\/share\/omarchy-platform\/audio\.json"/.test(audioNodes) && !/Quickshell\.env|OMARCHY_/.test(audioNodes),
|
||||
'audio reads the platform hints from the fixed platform root')
|
||||
assert(/onLoadFailed: missing = true/.test(audioNodes), 'a removed hints file hides nothing')
|
||||
const sources = ['shell/plugins/panels/audio/Panel.qml', 'shell/plugins/panels/audio/Model.js', 'shell/Commons/AudioNodes.qml',
|
||||
'shell/Commons/AudioNodesModel.js', 'shell/Commons/UntypedInput.qml', 'shell/plugins/bar/widgets/Microphone.qml',
|
||||
'bin/omarchy-audio-input-set-default', 'bin/omarchy-audio-sink-availability']
|
||||
for (const file of sources)
|
||||
assert(!/apple|asahi|macbook|j[0-9]{3}|omarchy-hw-|platform-sound/i.test(read(file)), file + ' names no platform')
|
||||
|
||||
const panel = read('shell/plugins/panels/audio/Panel.qml')
|
||||
assert(/inputPeakNode: inputViaWpctl \? null : source/.test(panel) && /inputLevelShown: !!inputPeakNode/.test(panel) &&
|
||||
/visible: root\.inputLevelShown[^}]*inputPeakMonitor\.peak/.test(panel),
|
||||
'the input level bar is hidden when the input is driven through wpctl')
|
||||
JS
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
||||
const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8')
|
||||
const styleSource = fs.readFileSync(root + '/shell/Commons/Style.qml', 'utf8')
|
||||
const shellJson = fs.readFileSync(root + '/config/omarchy/shell.json', 'utf8')
|
||||
|
||||
// A platform package's cutout description, as it would ship it.
|
||||
const cutouts = bar.parseCutouts(JSON.stringify({ panels: [
|
||||
{ connector: 'eDP', width: 3024, height: 1964, top: 64 },
|
||||
{ connector: 'eDP', width: 2560, height: 1664, top: 56 }
|
||||
] }))
|
||||
assertEqual(cutouts.length, 2, 'a well-formed description yields its panels')
|
||||
|
||||
// Malformed descriptions and entries are dropped, never guessed at.
|
||||
for (const text of ['', 'not json', '[]', '{}', '{"panels": {}}'])
|
||||
assertDeepEqual(bar.parseCutouts(text), [], `an unusable description (${JSON.stringify(text)}) yields no cutouts`)
|
||||
assertDeepEqual(bar.parseCutouts(JSON.stringify({ panels: [
|
||||
{ width: 3024, height: 1964, top: 64 },
|
||||
{ connector: '', width: 3024, height: 1964, top: 64 },
|
||||
{ connector: 'eDP', width: 0, height: 1964, top: 64 },
|
||||
{ connector: 'eDP', width: 3024, height: 1964, top: 0 },
|
||||
{ connector: 'eDP', width: 3024, height: 1964, top: 1964 },
|
||||
null
|
||||
] })), [], 'entries without a connector, a size or a sensible cutout are dropped')
|
||||
|
||||
// A cutout is described in physical rows; the bar works in logical pixels at
|
||||
// any scale. Hyprland reports each output's physical mode, as hyprctl monitors
|
||||
// does; Qt's devicePixelRatio is a whole number even at a fractional scale.
|
||||
const mode = (width, height, transform = 0) => ({ width, height, transform })
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 32, 'a 64-row cutout is 32 px at scale 2')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 1, mode(3024, 1964)), 64, 'scale 1 uses the rows as they are')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1280, 832, 2, mode(2560, 1664)), 28, 'each described panel gets its own cutout')
|
||||
|
||||
// A 16" MacBook Pro panel at scale 1.33: Qt says 2, Hyprland says 3456x2234.
|
||||
const pro16 = [{ connector: 'eDP', width: 3456, height: 2234, top: 64 }]
|
||||
assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), 48, 'a fractional scale matches its panel by the mode Hyprland reports')
|
||||
assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234), 0), 48, 'a top bar at a fractional scale floors at the cutout')
|
||||
assertEqual(bar.centerBesideRight(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), true, 'a top bar at a fractional scale moves the center section')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1890, 1228, 2, mode(3024, 1964)), 40, 'scale 1.6 matches too')
|
||||
|
||||
// Until Hyprland answers, the logical size times Qt's ratio stands in for the
|
||||
// mode, which holds at whole scales only.
|
||||
for (const pending of [null, undefined, {}, mode(0, 0)])
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, pending), 32, `a whole scale matches before Hyprland reports the mode (${JSON.stringify(pending)})`)
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 0, null), 64, 'a missing scale reads as 1')
|
||||
assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, null), 0, 'a fractional scale finds nothing on Qt\'s ratio alone')
|
||||
|
||||
// A mode Hyprland reports is the answer: one no panel describes has no cutout,
|
||||
// whatever the logical size times the ratio would say.
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1890)), 0, 'the same panel with its cutout rows hidden has none')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'DP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'another connector with the same mode has none')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1728, 1117, 2, mode(3456, 2234)), 0, 'a panel nobody described has none')
|
||||
assertEqual(bar.cutoutTop([], 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'with no description no panel has a cutout')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 0, 0, 2, mode(3024, 1964)), 0, 'degenerate screen sizes have none')
|
||||
|
||||
// Turned a quarter or upside down, the cutout is on another edge; mirrored,
|
||||
// it is still at the top.
|
||||
for (const transform of [1, 2, 3, 5, 6, 7])
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', transform % 2 ? 982 : 1512, transform % 2 ? 1512 : 982, 2, mode(3024, 1964, transform)), 0, `a panel with transform ${transform} has no cutout at its top`)
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964, 4)), 32, 'a mirrored panel keeps its cutout at the top')
|
||||
assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 982, 1512, 2, mode(3024, 1964)), 0, 'a portrait screen on a landscape mode is turned, whatever its transform says')
|
||||
|
||||
// Only a top bar on a described panel has a floor; a calibrated [bar]
|
||||
// notch-height replaces the described value there and nowhere else.
|
||||
assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 32, 'a top bar floors at the cutout')
|
||||
for (const position of ['bottom', 'left', 'right'])
|
||||
assertEqual(bar.notchFloor(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 0, `a ${position} bar has no floor`)
|
||||
assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 36, 'a calibrated notch-height overrides the described floor')
|
||||
assertEqual(bar.notchFloor(cutouts, 'top', 'DP-1', 2560, 1440, 1, mode(2560, 1440), 36), 0, 'a calibrated notch-height never floors an external monitor')
|
||||
assertEqual(bar.notchFloor([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 0, 'a calibrated notch-height never floors a panel without a cutout')
|
||||
assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 'x'), 32, 'an unreadable calibration falls back to the described floor')
|
||||
|
||||
// Source wiring only (no Quickshell here): the bar reads the description from
|
||||
// the fixed platform root, which no environment variable moves, not from a
|
||||
// detector.
|
||||
assert(
|
||||
/path: "\/usr\/share\/omarchy-platform\/display-cutouts\.json"/.test(barSource) &&
|
||||
/readonly property var displayCutouts: displayCutoutsFile\.missing \? \[\] : BarModel\.parseCutouts\(displayCutoutsFile\.text\(\)\)/.test(barSource),
|
||||
'bar reads the platform package\'s cutout description from the platform root'
|
||||
)
|
||||
assert(
|
||||
/id: displayCutoutsFile[\s\S]{0,400}blockLoading: true[\s\S]{0,200}onLoadFailed: missing = true/.test(barSource),
|
||||
'bar reads the description as the shell starts, before its surfaces map, and a removed file describes nothing'
|
||||
)
|
||||
assert(!/_PACKAGED_PATH|packagedPath/.test(barSource), 'no environment variable moves the cutout description')
|
||||
assert(!/omarchy-hw-/.test(barSource), 'bar asks no hardware detector')
|
||||
assert(
|
||||
/BarModel\.notchFloor\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, root\.panelModeFor\(screen\), Style\.bar\.notchHeight\)/.test(barSource),
|
||||
'bar derives the floor from its own screen geometry, its mode and the calibration'
|
||||
)
|
||||
assert(
|
||||
/function panelModeFor\(screen\) \{\s*var monitor = screen \? Hyprland\.monitorFor\(screen\) : null\s*return monitor \? \(\{\s*width: monitor\.width,\s*height: monitor\.height,\s*transform: monitor\.lastIpcObject \? monitor\.lastIpcObject\.transform : 0\s*\}\) : null/.test(barSource) &&
|
||||
/readonly property var panelMode: root\.panelModeFor\(screen\)/.test(barSource),
|
||||
'bar takes its screen\'s physical mode from Hyprland'
|
||||
)
|
||||
assert(
|
||||
/function thicknessFor\(screen\) \{\s*if \(root\.vertical \|\| !screen\) return root\.barSize\s*return Math\.max\(root\.barSize, BarModel\.notchFloor\(/.test(barSource) &&
|
||||
/readonly property int thickness: root\.thicknessFor\(screen\)/.test(barSource) &&
|
||||
/implicitHeight: root\.vertical \? 0 : thickness/.test(barSource),
|
||||
'bar height is floored at the cutout, never shrunk to it'
|
||||
)
|
||||
// A hidden bar parks by that same thickness (bar-test.sh), so a floored top
|
||||
// bar leaves no strip of itself on screen.
|
||||
|
||||
// The calibration must not scale with the font: it describes physical pixels
|
||||
// beside the camera.
|
||||
assert(
|
||||
/notchHeight:[\s\S]{0,240}barOverrides\["notch-height"\]/.test(styleSource) &&
|
||||
!/barToken\("notch-height"/.test(styleSource),
|
||||
'notch-height is read raw, not through the font-scaled bar tokens'
|
||||
)
|
||||
|
||||
// A top bar on a described panel draws its center section beside the right
|
||||
// one; everything else keeps the configured layout.
|
||||
for (const scale of [1, 2])
|
||||
assertEqual(bar.centerBesideRight(cutouts, 'top', 'eDP-1', 3024 / scale, 1964 / scale, scale, mode(3024, 1964)), true, `a described panel at scale ${scale} moves the center section`)
|
||||
assertEqual(bar.centerBesideRight(cutouts, 'top', 'USB-2', 3440, 1440, 1, mode(3440, 1440)), false, 'an external monitor keeps the center section')
|
||||
assertEqual(bar.centerBesideRight([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, 'a machine without a description keeps the center section')
|
||||
for (const position of ['bottom', 'left', 'right'])
|
||||
assertEqual(bar.centerBesideRight(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, `a ${position} bar keeps the center section`)
|
||||
|
||||
// The move is per screen and draw-time only: the user's layout is read as
|
||||
// configured, and the center entries keep their order and their own region.
|
||||
assert(
|
||||
/centerBesideRight: BarModel\.centerBesideRight\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource),
|
||||
'each bar surface decides the move from its own screen'
|
||||
)
|
||||
assert(
|
||||
/CenterModules \{\s*anchors\.fill: parent\s*entries: barWindow\.centerBesideRight \? \[\] : root\.layoutEntries\("center"\)\s*\}/.test(barSource),
|
||||
'a moved center section draws nothing in the middle of the bar'
|
||||
)
|
||||
assert(
|
||||
/ModuleList \{\s*entries: barWindow\.centerBesideRight \? root\.layoutEntries\("center"\) : \[\]\s*region: "center"\s*anchors\.right: rightModules\.left\s*anchors\.rightMargin: Style\.space\(\d+\)/.test(barSource),
|
||||
'the center entries sit just left of the right section in their own region'
|
||||
)
|
||||
|
||||
// With no gap the last center slot and the first right slot would share an
|
||||
// edge, and a drop there would always land in whichever registered first.
|
||||
const seam = [
|
||||
{ slot: 'right-first', x: 104, width: 20 },
|
||||
{ slot: 'center-last', x: 80, width: 20 }
|
||||
]
|
||||
assertDeepEqual(bar.nearestDropTarget(seam, { x: 101 }, false), { slot: 'center-last', after: true }, 'a drop at the seam nearer the center section lands after its last entry')
|
||||
assertDeepEqual(bar.nearestDropTarget(seam, { x: 103 }, false), { slot: 'right-first', after: false }, 'a drop at the seam nearer the right section lands before its first entry')
|
||||
assert(
|
||||
/anchorEntry: root\.findCenterAnchorEntry\(entries\)/.test(barSource),
|
||||
'an emptied center section does not keep a hidden copy of its anchor'
|
||||
)
|
||||
|
||||
// Until Hyprland reports the mode, a top bar waits to map where the mode may
|
||||
// still bring a cutout: a described connector whose whole-scale rebuild
|
||||
// matched nothing, as at a fractional scale. It never guesses a floor there.
|
||||
assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, null), true, 'a fractional scale on a described connector waits for the mode')
|
||||
for (const pending of [undefined, {}, mode(0, 0)])
|
||||
assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, pending), true, `no mode yet (${JSON.stringify(pending)}) still waits`)
|
||||
assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, null, 0), 0, 'a waiting bar has no guessed floor')
|
||||
assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), false, 'the mode ends the wait')
|
||||
assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 1728, 1117, 2, mode(3456, 2160)), false, 'a mode no panel describes ends the wait too')
|
||||
assertEqual(bar.cutoutPending(cutouts, 'top', 'eDP-1', 1512, 982, 2, null), false, 'a whole scale that matches needs no wait')
|
||||
assertEqual(bar.cutoutPending(cutouts, 'top', 'DP-1', 2560, 1440, 1, null), false, 'a connector no entry names never waits')
|
||||
assertEqual(bar.cutoutPending([], 'top', 'eDP-1', 2592, 1676, 2, null), false, 'without a description nothing waits')
|
||||
for (const position of ['bottom', 'left', 'right'])
|
||||
assertEqual(bar.cutoutPending(pro16, position, 'eDP-1', 2592, 1676, 2, null), false, `a ${position} bar never waits`)
|
||||
assert(
|
||||
/readonly property bool cutoutPending: BarModel\.cutoutPending\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource) &&
|
||||
/visible: !remapGuard\.remapping && \(cutoutWaitOver \|\| !cutoutPending\)/.test(barSource),
|
||||
'a bar surface maps only once its floor is known, and still remaps on a screen move'
|
||||
)
|
||||
assert(
|
||||
/property bool cutoutWaitOver: false\s*onCutoutPendingChanged: if \(!cutoutPending\) cutoutWaitOver = true\s*Component\.onCompleted: if \(!cutoutPending\) cutoutWaitOver = true/.test(barSource) &&
|
||||
/Timer \{\s*interval: 2000\s*running: barWindow\.cutoutPending && !barWindow\.cutoutWaitOver\s*onTriggered: barWindow\.cutoutWaitOver = true\s*\}/.test(barSource),
|
||||
'the wait ends once, for good: when the mode settles it or after two seconds'
|
||||
)
|
||||
|
||||
// Toasts and plugins clear the bar as thick as it is on their screen. The bar
|
||||
// publishes each screen's thickness by name from the same calculation its
|
||||
// surfaces use; barSize stays the configured size the widgets are drawn at.
|
||||
const sizes = { 'eDP-1': 40, 'DP-1': 26 }
|
||||
assertEqual(bar.barSizeFor(sizes, 'eDP-1', 26), 40, 'a notch screen reports its floored thickness')
|
||||
assertEqual(bar.barSizeFor(sizes, 'DP-1', 26), 26, 'another screen reports its own')
|
||||
assertEqual(bar.barSizeFor(sizes, 'HDMI-A-1', 26), 26, 'a screen without a surface falls back to the configured size')
|
||||
assertEqual(bar.barSizeFor({}, 'eDP-1', 26), 26, 'no published sizes fall back to the configured size')
|
||||
assertEqual(bar.barSizeFor(sizes, 'constructor', 26), 26, 'inherited object keys are no screen')
|
||||
assert(
|
||||
/readonly property var screenBarSizes: \{\s*var sizes = \(\{\}\)\s*var screens = Quickshell\.screens\s*for \(var i = 0; i < screens\.length; i\+\+\) sizes\[screens\[i\]\.name\] = root\.thicknessFor\(screens\[i\]\)\s*return sizes\s*\}/.test(barSource) &&
|
||||
/function barSizeFor\(screenName\) \{\s*return BarModel\.barSizeFor\(root\.screenBarSizes, screenName, root\.barSize\)\s*\}/.test(barSource),
|
||||
'bar publishes every screen\'s thickness from the same calculation its surfaces use'
|
||||
)
|
||||
assert(
|
||||
/readonly property int barSize: vertical \? Style\.bar\.sizeVertical : Style\.bar\.sizeHorizontal/.test(barSource),
|
||||
'barSize stays the configured size'
|
||||
)
|
||||
|
||||
// The plugin facade copies the sizes, holding no reference into the bar, and
|
||||
// keeps barSize the configured size.
|
||||
const shellSource = fs.readFileSync(root + '/shell/shell.qml', 'utf8')
|
||||
const stateApiSource = fs.readFileSync(root + '/shell/services/PluginBarStateApi.qml', 'utf8')
|
||||
const extract = (source, name) => {
|
||||
const match = source.match(new RegExp(`function ${name}\\((\\w+)\\) \\{([\\s\\S]*?)\\n \\}\\n`))
|
||||
assert(match, `${name} is defined`)
|
||||
return match
|
||||
}
|
||||
const detached = extract(shellSource, 'detachedBarSizes')
|
||||
const Util = { isPlainObject: value => value !== null && typeof value === 'object' && !Array.isArray(value) }
|
||||
const detachedBarSizes = new Function('Util', detached[1], detached[2])
|
||||
const live = { 'eDP-1': 40, 'DP-1': 26, broken: 'x', none: 0 }
|
||||
const copy = detachedBarSizes.call(null, Util, live)
|
||||
assertDeepEqual(copy, { 'eDP-1': 40, 'DP-1': 26 }, 'the plugin facade copies each screen\'s thickness')
|
||||
assert(copy !== live, 'the copy is detached from the bar\'s map')
|
||||
assertDeepEqual(detachedBarSizes.call(null, Util, undefined), {}, 'a bar without per-screen sizes gives an empty map')
|
||||
assert(
|
||||
/api\.barSize = Qt\.binding\(function\(\) \{ return shell\.bar \? Math\.max\(0, shell\.bar\.barSize \|\| 0\) : 0 \}\)/.test(shellSource) &&
|
||||
/api\.barSizes = Qt\.binding\(function\(\) \{ return shell\.bar \? shell\.detachedBarSizes\(shell\.bar\.screenBarSizes\) : \(\{\}\) \}\)/.test(shellSource),
|
||||
'plugins get the configured barSize and every screen\'s thickness'
|
||||
)
|
||||
const apiFor = extract(stateApiSource, 'barSizeFor')
|
||||
const stateBarSizeFor = new Function('barSizes', 'barSize', apiFor[1], apiFor[2])
|
||||
assertEqual(stateBarSizeFor(copy, 26, 'eDP-1'), 40, 'a plugin reads a notch screen\'s thickness')
|
||||
assertEqual(stateBarSizeFor(copy, 26, 'HDMI-A-1'), 26, 'a plugin falls back to barSize for a screen with none')
|
||||
assertEqual(stateBarSizeFor({}, 26, 'eDP-1'), 26, 'a plugin under a bar without per-screen sizes gets barSize')
|
||||
|
||||
const parsed = JSON.parse(shellJson)
|
||||
assertEqual(parsed.bar.centerAnchor, 'omarchy.clock', 'shipped bar layout still anchors on the clock')
|
||||
assert(
|
||||
JSON.stringify(parsed.bar.layout.center).indexOf('omarchy.indicators') >= 0,
|
||||
'shipped bar layout still keeps the default center widgets'
|
||||
)
|
||||
JS
|
||||
@@ -81,8 +81,8 @@ assert(
|
||||
)
|
||||
for (const edge of ['top', 'bottom', 'left', 'right']) {
|
||||
assert(
|
||||
new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -root\\.barSize : 0`).test(barSource),
|
||||
`a hidden bar parks past the ${edge} edge`
|
||||
new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -barWindow\\.thickness : 0`).test(barSource),
|
||||
`a hidden bar parks past the ${edge} edge by its whole thickness`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,58 @@ require_compositor() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Fake the hardware identity omarchy-hw-platform reads: a device tree under
|
||||
# $1/proc and a uname in $1/bin reporting the platform's CPU. Run the code under
|
||||
# test with OMARCHY_PROC_ROOT="$1/proc" and PATH="$1/bin:$ROOT/bin:...", so the
|
||||
# real detector and its wrappers answer from the fixture.
|
||||
fake_platform() {
|
||||
local dir="$1" platform="$2" machine=aarch64
|
||||
local -a compatible=()
|
||||
|
||||
case $platform in
|
||||
aarch64-apple) compatible=(apple,j416c apple,t6021 apple,arm-platform) ;;
|
||||
aarch64) compatible=(raspberrypi,5-model-b brcm,bcm2712) ;;
|
||||
x86) machine=x86_64 ;;
|
||||
*) fail "fake_platform knows the platform $platform" ;;
|
||||
esac
|
||||
|
||||
rm -rf "$dir/proc"
|
||||
mkdir -p "$dir/proc" "$dir/bin"
|
||||
if (( ${#compatible[@]} > 0 )); then
|
||||
mkdir -p "$dir/proc/device-tree"
|
||||
printf '%s\0' "${compatible[@]}" >"$dir/proc/device-tree/compatible"
|
||||
fi
|
||||
|
||||
cat >"$dir/bin/uname" <<SH
|
||||
#!/bin/bash
|
||||
[[ \${1:-} == -m ]] && { printf '%s\\n' $machine; exit 0; }
|
||||
exec /usr/bin/uname "\$@"
|
||||
SH
|
||||
chmod +x "$dir/bin/uname"
|
||||
}
|
||||
|
||||
# omarchy-hw-platform ignores fixture roots when it runs as root, so a root run
|
||||
# cannot fake a platform. Skip the rest of the file there.
|
||||
require_platform_fixtures() {
|
||||
(( EUID != 0 )) && return 0
|
||||
skip "running as root, where omarchy-hw-platform ignores fixtures; skipping $1"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# A script reads the installed platform package's files from the fixed
|
||||
# /usr/share/omarchy-platform, which nothing in its environment moves. Copies
|
||||
# script $1 to $2 reading fixture root $3 instead (a directory that doesn't
|
||||
# exist stands for no platform package), the way the security tests rewrite
|
||||
# other fixed paths.
|
||||
platform_root_copy() {
|
||||
local script="$1" copy="$2" fixture="$3"
|
||||
|
||||
sed -e "s|/usr/share/omarchy-platform|$fixture|g" "$script" >"$copy" &&
|
||||
chmod +x "$copy" || fail "copy $script for a fixture platform root"
|
||||
grep -qF -- "$fixture" "$copy" && ! grep -qF /usr/share/omarchy-platform "$copy" ||
|
||||
fail "$(basename -- "$script") reads the platform root from the fixture"
|
||||
}
|
||||
|
||||
run_node_test() {
|
||||
require_command node
|
||||
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
mkdir -p "$test_tmp/macsmc-ac" "$test_tmp/CMB0"
|
||||
printf 'Mains\n' >"$test_tmp/macsmc-ac/type"
|
||||
printf 'Battery\n' >"$test_tmp/CMB0/type"
|
||||
printf '1\n' >"$test_tmp/CMB0/present"
|
||||
|
||||
OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts non-BAT native paths"
|
||||
pass "battery presence accepts non-BAT native paths"
|
||||
|
||||
printf '0\n' >"$test_tmp/CMB0/present"
|
||||
if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then
|
||||
fail "battery presence rejects an absent battery"
|
||||
fi
|
||||
pass "battery presence rejects an absent battery"
|
||||
|
||||
# A wireless mouse registers a Battery of its own, scoped Device; it must not
|
||||
# make a desktop look like a laptop.
|
||||
printf '1\n' >"$test_tmp/CMB0/present"
|
||||
printf 'System\n' >"$test_tmp/CMB0/scope"
|
||||
mkdir -p "$test_tmp/hidpp_battery_0"
|
||||
printf 'Battery\n' >"$test_tmp/hidpp_battery_0/type"
|
||||
printf '1\n' >"$test_tmp/hidpp_battery_0/present"
|
||||
printf 'Device\n' >"$test_tmp/hidpp_battery_0/scope"
|
||||
OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts a System-scoped battery beside a peripheral"
|
||||
pass "battery presence accepts a System-scoped battery beside a peripheral"
|
||||
|
||||
rm -r "$test_tmp/CMB0"
|
||||
if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then
|
||||
fail "battery presence ignores a peripheral's Device-scoped battery"
|
||||
fi
|
||||
pass "battery presence ignores a peripheral's Device-scoped battery"
|
||||
@@ -8,20 +8,40 @@ tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/bin"
|
||||
mkdir -p "$tmp_dir/power/BAT0"
|
||||
printf '900000\n' >"$tmp_dir/power/BAT0/current_now"
|
||||
printf '12000000\n' >"$tmp_dir/power/BAT0/voltage_now"
|
||||
# Apple Silicon: a battery not called BAT*, power_now signed by direction, and
|
||||
# the thresholds and cycle count only under the native path.
|
||||
mkdir -p "$tmp_dir/power/macsmc-battery"
|
||||
printf '%s\n' '-10800000' >"$tmp_dir/power/macsmc-battery/power_now"
|
||||
printf '75\n' >"$tmp_dir/power/macsmc-battery/charge_control_start_threshold"
|
||||
printf '80\n' >"$tmp_dir/power/macsmc-battery/charge_control_end_threshold"
|
||||
printf '212\n' >"$tmp_dir/power/macsmc-battery/cycle_count"
|
||||
cat >"$tmp_dir/bin/upower" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
# A wireless mouse enumerates first, as a battery_ device that is not a power
|
||||
# supply; the machine's own battery follows it.
|
||||
if [[ $1 == "-e" ]]; then
|
||||
echo "/org/freedesktop/UPower/devices/battery_BAT0"
|
||||
echo "/org/freedesktop/UPower/devices/line_power_macsmc_ac"
|
||||
echo "/org/freedesktop/UPower/devices/battery_hidpp_battery_0"
|
||||
echo "/org/freedesktop/UPower/devices/battery_${OMARCHY_TEST_NATIVE_PATH//-/_}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $1 == "-i" && $2 == */battery_hidpp_battery_0 ]]; then
|
||||
cat <<'INFO'
|
||||
native-path: hidpp_battery_0
|
||||
model: Wireless Mouse
|
||||
power supply: no
|
||||
state: discharging
|
||||
percentage: 5%
|
||||
INFO
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $1 == "-i" ]]; then
|
||||
cat <<'INFO'
|
||||
native-path: BAT0
|
||||
native-path: macsmc-battery
|
||||
power supply: yes
|
||||
state: discharging
|
||||
energy: 28.3 Wh
|
||||
energy-full: 56.7 Wh
|
||||
@@ -36,13 +56,69 @@ exit 1
|
||||
STUB
|
||||
chmod +x "$tmp_dir/bin/upower"
|
||||
|
||||
shell_output=$(OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
|
||||
shell_output=$(OMARCHY_TEST_NATIVE_PATH=macsmc-battery OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
|
||||
|
||||
grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports percentage"
|
||||
grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports the machine's battery, not the mouse's"
|
||||
grep -Fx $'state\tdischarging' <<<"$shell_output" >/dev/null || fail "battery status reports state"
|
||||
grep -Fx $'rate\t10.8W' <<<"$shell_output" >/dev/null || fail "battery status reports live sysfs power rate"
|
||||
grep -Fx $'size\t56Wh' <<<"$shell_output" >/dev/null || fail "battery status reports full capacity"
|
||||
grep -Fx $'time\t2h 30m' <<<"$shell_output" >/dev/null || fail "battery status reports remaining time"
|
||||
grep -Fx $'cycles\t212' <<<"$shell_output" >/dev/null || fail "battery status reports native-path cycle count"
|
||||
grep -Fx $'threshold\t75-80%' <<<"$shell_output" >/dev/null || fail "battery status reports native-path charge thresholds"
|
||||
|
||||
# The BAT* name the script used to key on is one of many; CMB0 is another.
|
||||
mkdir -p "$tmp_dir/power/CMB0"
|
||||
printf '7300000\n' >"$tmp_dir/power/CMB0/power_now"
|
||||
sed -i 's/native-path: macsmc-battery/native-path: CMB0/' "$tmp_dir/bin/upower"
|
||||
generic_output=$(OMARCHY_TEST_NATIVE_PATH=CMB0 OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
|
||||
grep -Fx $'rate\t7.3W' <<<"$generic_output" >/dev/null || fail "battery status accepts arbitrary UPower battery paths"
|
||||
pass "battery status supports Apple Silicon and arbitrary native battery paths"
|
||||
|
||||
# An 80% hold threshold doesn't trip while the battery is still below it.
|
||||
hold_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir" "$hold_dir"' EXIT
|
||||
|
||||
mkdir -p "$hold_dir/bin" "$hold_dir/power/BAT0" "$hold_dir/power/ac"
|
||||
printf 'Mains\n' >"$hold_dir/power/ac/type"
|
||||
printf '1\n' >"$hold_dir/power/ac/online"
|
||||
printf '80\n' >"$hold_dir/power/BAT0/charge_control_end_threshold"
|
||||
cat >"$hold_dir/bin/upower" <<'STUB'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ $1 == "-e" ]]; then
|
||||
echo "/org/freedesktop/UPower/devices/battery_BAT0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $1 == "-i" ]]; then
|
||||
cat <<'INFO'
|
||||
native-path: BAT0
|
||||
power supply: yes
|
||||
state: charging
|
||||
energy-full: 69.6 Wh
|
||||
energy-rate: 0.1 W
|
||||
time to full: 0.2 hours
|
||||
percentage: 79.5%
|
||||
charge-end-threshold: 80%
|
||||
INFO
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
STUB
|
||||
chmod +x "$hold_dir/bin/upower"
|
||||
|
||||
hold_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
|
||||
|
||||
grep -Fx $'state\tcharging' <<<"$hold_output" >/dev/null || fail "a hold below its threshold is still charging"
|
||||
pass "battery status doesn't trip a hold early"
|
||||
|
||||
# Once the battery reaches the threshold, idle charging is holding.
|
||||
sed -i 's/percentage: 79.5%/percentage: 80.0%/' "$hold_dir/bin/upower"
|
||||
held_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell)
|
||||
grep -Fx $'percentage\t80%' <<<"$held_output" >/dev/null || fail "threshold percentage still displays as 80%"
|
||||
grep -Fx $'state\tholding' <<<"$held_output" >/dev/null || fail "idle charging at the threshold is holding"
|
||||
pass "battery status reports holding once the raw percentage reaches the threshold"
|
||||
|
||||
if matches=$(rg -n 'omarchy-battery-(capacity|remaining|remaining-time)' "$ROOT/bin" "$ROOT/test" "$ROOT/shell" "$ROOT/docs"); then
|
||||
fail "battery status owns capacity and remaining calculations" "$matches"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
|
||||
HOME="$(mktemp -d)" OMARCHY_PATH="$ROOT" lua <<'LUA'
|
||||
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
|
||||
|
||||
local binds, ran = {}, {}
|
||||
hl = setmetatable({
|
||||
dsp = setmetatable({}, {
|
||||
__index = function(_, name)
|
||||
return function(arg) return { dsp = name, arg = arg } end
|
||||
end,
|
||||
}),
|
||||
bind = function(keys, dispatcher, opts)
|
||||
table.insert(binds, { keys = keys, dispatcher = dispatcher, opts = opts })
|
||||
end,
|
||||
exec_cmd = function(command) table.insert(ran, command) end,
|
||||
dispatch = function(dispatcher) table.insert(ran, dispatcher.dsp .. " " .. dispatcher.arg) end,
|
||||
}, {
|
||||
__index = function()
|
||||
return function() return {} end
|
||||
end,
|
||||
})
|
||||
|
||||
require("default.hypr.helpers")
|
||||
|
||||
local function ran_since(fn)
|
||||
local before = #ran
|
||||
fn()
|
||||
return table.concat({ table.unpack(ran, before + 1) }, "\n")
|
||||
end
|
||||
|
||||
local options = { locked = true }
|
||||
o.bind_hold("SUPER + X", "Start", "start-cmd", "Stop", "stop-cmd", options)
|
||||
local press, release = binds[1], binds[2]
|
||||
assert(press.keys == "SUPER + X" and release.keys == "SUPER + X", "both halves bind the same keys")
|
||||
assert(press.opts.description == "Start" and release.opts.description == "Stop", "each half keeps its description")
|
||||
assert(not press.opts.release and not press.opts.transparent, "the press half is an ordinary bind")
|
||||
assert(release.opts.release and release.opts.transparent and release.opts.ignore_mods,
|
||||
"the release half survives keys typed and modifiers changed during the hold")
|
||||
assert(release.opts.non_consuming, "the release half leaves the key to apps when its press did not run")
|
||||
assert(press.opts.locked and release.opts.locked, "caller options apply to both halves")
|
||||
assert(options.release == nil and options.description == nil, "caller options are not modified")
|
||||
assert(o.bind_commands[press.dispatcher] == "start-cmd" and o.bind_commands[release.dispatcher] == "stop-cmd",
|
||||
"the keybindings menu can run each half")
|
||||
|
||||
assert(ran_since(release.dispatcher) == "", "a release without its press does nothing")
|
||||
assert(ran_since(press.dispatcher) == "start-cmd", "the press runs its command")
|
||||
assert(ran_since(release.dispatcher) == "stop-cmd", "the release ends what the press started")
|
||||
assert(ran_since(release.dispatcher) == "", "a release runs once per press")
|
||||
|
||||
o.bind_hold("SUPER + R", "Start", "start-cmd", "Stop", "stop-cmd",
|
||||
{ repeating = true, long_press = true, click = true, drag = true, locked = true })
|
||||
local repeat_release, repeat_press = table.remove(binds), table.remove(binds)
|
||||
assert(repeat_press.opts.repeating, "the press half keeps its options")
|
||||
for _, key in ipairs({ "repeating", "long_press", "click", "drag" }) do
|
||||
assert(repeat_release.opts[key] == nil, "the release half drops " .. key)
|
||||
end
|
||||
assert(repeat_release.opts.locked, "the release half keeps other options")
|
||||
|
||||
o.bind_hold("SUPER + Z", "Start", hl.dsp.exec_cmd("start-dsp"), "Stop", function() table.insert(ran, "stop-fn") end)
|
||||
assert(ran_since(binds[3].dispatcher) == "exec_cmd start-dsp", "dispatchers are dispatched")
|
||||
assert(ran_since(binds[4].dispatcher) == "stop-fn", "functions are called")
|
||||
assert(ran_since(binds[2].dispatcher) == "", "each hold binding tracks its own press")
|
||||
LUA
|
||||
pass "hold bindings pair each release with its press"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
stub_bin="$test_tmp/bin"
|
||||
omarchy_path="$test_tmp/omarchy"
|
||||
opt_path="$test_tmp/opt"
|
||||
cdm="$opt_path/WidevineCdm/chromium"
|
||||
calls="$test_tmp/calls.log"
|
||||
mkdir -p "$stub_bin" "$test_tmp/home" "$omarchy_path/install/helpers" "$omarchy_path/config"
|
||||
|
||||
# The real policy helper writes under /etc without sudo when run as root, so
|
||||
# stand it in; this test covers only the CDM link. Its as_root goes through the
|
||||
# sudo stub, which logs every link, whatever the test's EUID.
|
||||
printf 'browser_policy_setup_dir() { :; }\nas_root() { sudo "$@"; }\n' >"$omarchy_path/install/helpers/browser-policy.sh"
|
||||
cp "$ROOT/config/chromium-flags.conf" "$omarchy_path/config/"
|
||||
|
||||
for command in omarchy-pkg-add omarchy-pkg-aur-add omarchy-install-chromium-copy-url omarchy-install-chromium-ytdlp omarchy-theme-set-browser omarchy-lifecycle-dispatch; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command"
|
||||
done
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'sudo:%s\n' "$*" >>"$TEST_LOG"
|
||||
"$@"
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
reset_opt() {
|
||||
rm -rf "$opt_path"
|
||||
mkdir -p "$opt_path"
|
||||
local dir
|
||||
for dir in "$@"; do
|
||||
mkdir -p "$opt_path/$dir"
|
||||
done
|
||||
}
|
||||
|
||||
run() {
|
||||
: >"$calls"
|
||||
HOME="$test_tmp/home" OMARCHY_PATH="$omarchy_path" OMARCHY_OPT_PATH="$opt_path" \
|
||||
TEST_LOG="$calls" PATH="$stub_bin:$PATH" "$@" >/dev/null
|
||||
}
|
||||
|
||||
install_browser() {
|
||||
run bash "$ROOT/bin/omarchy-install-browser" "$1"
|
||||
}
|
||||
|
||||
links_made() {
|
||||
grep -c '^sudo:ln ' "$calls" || true
|
||||
}
|
||||
|
||||
assert_cdm_linked() {
|
||||
[[ -L $opt_path/$1/WidevineCdm && $(readlink "$opt_path/$1/WidevineCdm") == "$cdm" ]] ||
|
||||
fail "$2" "$(ls -la "$opt_path/$1" 2>&1)"
|
||||
}
|
||||
|
||||
for browser in brave brave-origin; do
|
||||
install_dir=$browser-bin
|
||||
|
||||
reset_opt WidevineCdm/chromium "$install_dir"
|
||||
install_browser "$browser"
|
||||
assert_cdm_linked "$install_dir" "$browser install links the system Widevine CDM into $install_dir"
|
||||
install_browser "$browser"
|
||||
(( $(links_made) == 0 )) || fail "$browser reinstall keeps the existing CDM link" "$(<"$calls")"
|
||||
assert_cdm_linked "$install_dir" "$browser reinstall keeps the existing CDM link"
|
||||
pass "$browser install links the system Widevine CDM into $install_dir once"
|
||||
|
||||
reset_opt "$install_dir"
|
||||
install_browser "$browser"
|
||||
[[ ! -e $opt_path/$install_dir/WidevineCdm && ! -L $opt_path/$install_dir/WidevineCdm ]] ||
|
||||
fail "$browser install links nothing without the widevine package"
|
||||
pass "$browser install links nothing without the widevine package"
|
||||
|
||||
reset_opt WidevineCdm/chromium "$install_dir" own-cdm
|
||||
ln -s "$opt_path/own-cdm" "$opt_path/$install_dir/WidevineCdm"
|
||||
install_browser "$browser"
|
||||
[[ $(readlink "$opt_path/$install_dir/WidevineCdm") == "$opt_path/own-cdm" ]] ||
|
||||
fail "$browser install keeps a CDM the browser already has"
|
||||
pass "$browser install keeps a CDM the browser already has"
|
||||
done
|
||||
|
||||
reset_opt WidevineCdm/chromium
|
||||
install_browser chromium
|
||||
(( $(links_made) == 0 )) || fail "Chromium install leaves Widevine to the widevine package" "$(<"$calls")"
|
||||
pass "Chromium install leaves Widevine to the widevine package"
|
||||
@@ -9,6 +9,11 @@ fix_t2="$ROOT/install/hardware/apple/fix-t2.sh"
|
||||
all="$ROOT/install/hardware/all.sh"
|
||||
migration="$ROOT/migrations/1786391100.sh"
|
||||
|
||||
# Apple Silicon's BCM4387 is in the ID list, but there the firmware supplicant
|
||||
# is the one that works, so the leaf and the migration step aside: a fresh
|
||||
# install marks the migration done only for its first user.
|
||||
grep -Fq 'omarchy-hw-aarch64-apple && exit 0' "$migration" || fail "Broadcom migration excludes Apple Silicon"
|
||||
|
||||
grep -q 'apple/fix-brcmfmac-supplicant.sh' "$all" ||
|
||||
fail "the brcmfmac quirk runs during hardware setup"
|
||||
|
||||
@@ -61,13 +66,19 @@ printf '\t%s' "$@" >>"$TEST_LOG"
|
||||
printf '\n' >>"$TEST_LOG"
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
(( ${APPLE_SILICON:-0} == 1 ))
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
# The leaf reads the vendor from an absolute path, so point it at a fixture by
|
||||
# running with a fake root on PATH-independent state. pipefail is on, so a
|
||||
# grep -q gate would go silent here the way #6608 did.
|
||||
run_leaf() {
|
||||
local vendor="$1" wifi_id="${2:-}" t2="${3:-0}"
|
||||
local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}"
|
||||
rm -rf "$test_tmp/etc"
|
||||
mkdir -p "$test_tmp/etc"
|
||||
printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor"
|
||||
@@ -78,7 +89,7 @@ run_leaf() {
|
||||
-e "s|/etc/modprobe.d|$test_tmp/etc/modprobe.d|g" \
|
||||
"$leaf" >"$script"
|
||||
|
||||
WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" \
|
||||
WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" \
|
||||
bash -eE -o pipefail -c 'source "$1"' bash "$script" </dev/null
|
||||
}
|
||||
|
||||
@@ -97,6 +108,10 @@ for wifi_id in 43ba 43bb 43bc 43a3 43dc 4464 4425 4433; do
|
||||
done
|
||||
pass "every brcmfmac part on a Mac without a T2 gets the quirk"
|
||||
|
||||
run_leaf "Apple Inc." 4433 0 1 >/dev/null
|
||||
[[ ! -f $conf ]] || fail "Apple Silicon is excluded from the Intel Mac quirk"
|
||||
pass "Apple Silicon is excluded from the Intel Mac quirk"
|
||||
|
||||
# Older Macs report the vendor differently.
|
||||
run_leaf "Apple Computer, Inc." 43ba 0 >/dev/null
|
||||
[[ -f $conf ]] || fail "the older Apple vendor string is recognized"
|
||||
@@ -120,11 +135,11 @@ pass "a Mac with no wireless device is left alone"
|
||||
# Installs that predate the quirk never ran the leaf, so the migration has to
|
||||
# reach them. It runs as the user under pipefail, the context #6608 was about.
|
||||
run_migration() {
|
||||
local vendor="$1" wifi_id="${2:-}" t2="${3:-0}"
|
||||
local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}"
|
||||
printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor"
|
||||
: >"$calls"
|
||||
|
||||
WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \
|
||||
WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \
|
||||
OMARCHY_BRCMFMAC_DMI_VENDOR="$test_tmp/dmi/sys_vendor" \
|
||||
OMARCHY_BRCMFMAC_CONF="$conf" \
|
||||
bash -euo pipefail "$migration" >/dev/null
|
||||
@@ -148,6 +163,12 @@ run_migration "Apple Inc." 4488 1
|
||||
[[ ! -s $calls ]] || fail "a repaired install is left untouched" "$(cat "$calls")"
|
||||
pass "the migration is idempotent"
|
||||
|
||||
rm -rf "$test_tmp/etc"
|
||||
run_migration "Apple Inc." 4433 0 1
|
||||
[[ ! -e $conf ]] || fail "the migration leaves Apple Silicon Wi-Fi alone"
|
||||
[[ ! -s $calls ]] || fail "the migration escalates nothing on Apple Silicon" "$(cat "$calls")"
|
||||
pass "the migration leaves Apple Silicon Wi-Fi alone"
|
||||
|
||||
# The machine this was written for, with no T2 to fall back on.
|
||||
rm -rf "$test_tmp/etc"
|
||||
run_migration "Apple Inc." 43ba 0
|
||||
|
||||
@@ -53,9 +53,19 @@ SH
|
||||
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
# A copy of the command reads its platform file from a fixture root (none
|
||||
# unless a case writes one, whatever the machine running the suite has
|
||||
# installed) and its DRM connectors from a fixture directory.
|
||||
platform_root="$test_tmp/platform"
|
||||
mkdir -p "$platform_root" "$test_tmp/copy"
|
||||
brightness_display="$test_tmp/copy/omarchy-brightness-display"
|
||||
platform_root_copy "$ROOT/bin/omarchy-brightness-display" "$brightness_display" "$platform_root"
|
||||
sed -i "s|/sys/class/drm|$test_tmp/drm|g" "$brightness_display"
|
||||
grep -qF "$test_tmp/drm/card" "$brightness_display" || fail "omarchy-brightness-display reads DRM connectors from the fixture"
|
||||
|
||||
run_brightness() {
|
||||
CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" PATH="$mock_bin:$ROOT/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-brightness-display" "$@"
|
||||
CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" \
|
||||
PATH="$mock_bin:$ROOT/bin:$PATH" "$brightness_display" "$@"
|
||||
}
|
||||
|
||||
brightness=$(run_brightness --monitor DP-1)
|
||||
@@ -144,3 +154,43 @@ if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hyprland-monitor-focused-apple"; th
|
||||
fail "focused non-Apple display is not detected as Apple"
|
||||
fi
|
||||
pass "named Apple display is detected independently of focus"
|
||||
|
||||
# A platform whose display driver has no DDC channel says so: an external
|
||||
# monitor whose connector has no ddc node is not probed and never dims the
|
||||
# built-in panel in its place.
|
||||
mkdir -p "$test_tmp/drm/card2-USB-1" "$test_tmp/drm/card2-eDP-1"
|
||||
# The last line needs no newline.
|
||||
printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf"
|
||||
: >"$call_log"
|
||||
if run_brightness --monitor USB-1 >/dev/null 2>&1; then
|
||||
fail "an external monitor without a ddc node has no brightness backend"
|
||||
fi
|
||||
if run_brightness --no-osd --monitor USB-1 +5% >/dev/null 2>&1; then
|
||||
fail "setting an external monitor without a ddc node fails"
|
||||
fi
|
||||
printf 'ddc-require-connector-ddc\r\n' >"$platform_root/displays.conf"
|
||||
if run_brightness --monitor USB-1 >/dev/null 2>&1; then
|
||||
fail "a displays.conf saved with CRLF line ends reads the same"
|
||||
fi
|
||||
printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf"
|
||||
[[ ! -s $call_log ]] || fail "an external monitor without a ddc node probes nothing" "$(<"$call_log")"
|
||||
brightness=$(run_brightness --monitor eDP-1)
|
||||
(( brightness == 40 )) || fail "the built-in panel still uses the kernel backlight" "actual: $brightness"
|
||||
pass "a platform without DDC channels skips DDC for a connector without one"
|
||||
|
||||
rm -f "$runtime_dir/omarchy-brightness-display-ddc/"*
|
||||
: >"$test_tmp/drm/card2-USB-1/ddc"
|
||||
brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1)
|
||||
(( brightness == 50 )) || fail "an external monitor with a ddc node uses DDC" "actual: $brightness"
|
||||
pass "a platform without DDC channels still uses DDC where a connector has one"
|
||||
|
||||
# Without the directive, every external monitor is probed, ddc node or not.
|
||||
rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* "$test_tmp/drm/card2-USB-1/ddc"
|
||||
for conf in "" "ddc-require-connector-ddc extra" "ddc-future"; do
|
||||
printf '%s\n' "$conf" >"$platform_root/displays.conf"
|
||||
rm -f "$runtime_dir/omarchy-brightness-display-ddc/"*
|
||||
brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1)
|
||||
(( brightness == 50 )) || fail "an external monitor is probed without the directive" "conf: $conf, actual: $brightness"
|
||||
done
|
||||
rm -f "$platform_root/displays.conf"
|
||||
pass "every external monitor is probed without the directive"
|
||||
@@ -69,6 +69,51 @@ for channel in stable rc edge dev; do
|
||||
pass "$channel starts cold, authorizes the switch per command, runs the refresh hook cold, hands off to one update authorization and exits cold"
|
||||
done
|
||||
|
||||
# Every aarch64 platform switches to edge and dev, copying its own templates.
|
||||
for platform in aarch64 aarch64-apple; do
|
||||
case $platform in
|
||||
aarch64-apple) templates=default/pacman/aarch64-apple ;;
|
||||
*) templates=default/pacman/aarch64 ;;
|
||||
esac
|
||||
for channel in edge dev; do
|
||||
# dev refreshes from the checkout it links, on edge.
|
||||
pacman_channel=$channel root=$SUDO_TEST_ROOT
|
||||
[[ $channel != "dev" ]] || pacman_channel=edge root=$SUDO_TEST_HOME/omarchy
|
||||
reset_boundary
|
||||
SUDO_TEST_PLATFORM=$platform run_channel "$channel" || fail "$platform $channel failed" "$(<"$boundary_tmp/output")"
|
||||
assert_scoped_channel "$platform $channel"
|
||||
grep -Fqx "step:cp -f $root/$templates/pacman-$pacman_channel.conf /etc/pacman.conf" "$SUDO_TEST_LOG" ||
|
||||
fail "$platform $channel copies its own template" "$(<"$SUDO_TEST_LOG")"
|
||||
done
|
||||
done
|
||||
pass "aarch64 platforms switch to edge and dev through their own templates"
|
||||
|
||||
# stable and rc would install the release line, which has no aarch64 support:
|
||||
# an aarch64 machine refuses them before anything changes.
|
||||
for platform in aarch64 aarch64-apple; do
|
||||
for channel in stable rc; do
|
||||
reset_boundary
|
||||
if SUDO_TEST_PLATFORM=$platform run_channel "$channel"; then fail "$platform accepted $channel"; fi
|
||||
if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$platform $channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
grep -q "Omarchy has no $channel channel for $platform" "$boundary_tmp/output" || fail "$platform $channel: the refusal says why" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "$platform $channel"
|
||||
done
|
||||
done
|
||||
pass "aarch64 platforms refuse stable and rc before any change"
|
||||
|
||||
# A channel the platform has no template for stops before anything, the dev
|
||||
# confirmation included.
|
||||
mv "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf" "$boundary_tmp/saved-template"
|
||||
for channel in edge dev; do
|
||||
reset_boundary
|
||||
if SUDO_TEST_PLATFORM=aarch64-apple run_channel "$channel"; then fail "a channel without a template was accepted ($channel)"; fi
|
||||
if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi
|
||||
grep -q "Omarchy has no edge channel for aarch64-apple" "$boundary_tmp/output" || fail "the refusal says why" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "missing template $channel"
|
||||
done
|
||||
mv "$boundary_tmp/saved-template" "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf"
|
||||
pass "a channel without a template for the platform is refused before any change"
|
||||
|
||||
reset_boundary
|
||||
wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo"
|
||||
mv "$wrapper" "$boundary_tmp/saved-wrapper"
|
||||
@@ -81,6 +126,40 @@ assert_boundary_cold "stale checkout"
|
||||
mv "$boundary_tmp/saved-wrapper" "$wrapper"
|
||||
pass "a stale dev checkout is rejected before linking or privileged work"
|
||||
|
||||
# On aarch64 the checkout's own refresh keeps the machine's repositories only
|
||||
# once it tells platforms apart: a checkout without that is refused the same way.
|
||||
checkout="$SUDO_TEST_HOME/omarchy"
|
||||
for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do
|
||||
for platform in aarch64; do
|
||||
reset_boundary
|
||||
mv "$checkout/$required" "$boundary_tmp/saved-required"
|
||||
if SUDO_TEST_PLATFORM=$platform run_channel dev; then fail "$platform: a dev checkout without $required was accepted"; fi
|
||||
mv "$boundary_tmp/saved-required" "$checkout/$required"
|
||||
if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then
|
||||
fail "$platform: a dev checkout without $required changed the system before rejection" "$(<"$SUDO_TEST_LOG")"
|
||||
fi
|
||||
grep -q "Update the checkout before switching to dev; on $platform it needs $required" "$boundary_tmp/output" ||
|
||||
fail "$platform: the rejection names $required" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "$platform checkout without $required"
|
||||
done
|
||||
done
|
||||
pass "on aarch64 a dev checkout that can't keep the machine's repositories is rejected before linking"
|
||||
|
||||
# The checkout's own refresh copies its own templates, so one without them for
|
||||
# this platform is refused before linking.
|
||||
template=$checkout/default/pacman/aarch64/mirrorlist-edge
|
||||
mv "$template" "$boundary_tmp/saved-template"
|
||||
reset_boundary
|
||||
if SUDO_TEST_PLATFORM=aarch64 run_channel dev; then fail "a dev checkout without its templates was accepted"; fi
|
||||
mv "$boundary_tmp/saved-template" "$template"
|
||||
if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then
|
||||
fail "a dev checkout without its templates changed the system before rejection" "$(<"$SUDO_TEST_LOG")"
|
||||
fi
|
||||
grep -q "Update the checkout before switching to dev; it has no edge templates for aarch64" "$boundary_tmp/output" ||
|
||||
fail "the rejection names the missing templates" "$(<"$boundary_tmp/output")"
|
||||
assert_boundary_cold "checkout without templates"
|
||||
pass "a dev checkout without its templates for the platform is rejected before linking"
|
||||
|
||||
reset_boundary
|
||||
OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")"
|
||||
assert_scoped_channel "dev to stable"
|
||||
|
||||
@@ -108,6 +108,7 @@ pass "default bar widget ids resolve to manifests and entry points"
|
||||
|
||||
ROOT="$ROOT" python3 <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -173,6 +174,32 @@ for source, destination, legacy in package_defaults:
|
||||
if destination and (source not in pkgbuild or destination not in pkgbuild):
|
||||
errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}")
|
||||
|
||||
# A user unit has to be on the machine before anything turns it on: shipped in
|
||||
# /usr/lib/systemd/user, or copied out of default/ by the command enabling it.
|
||||
def installs_first(text):
|
||||
source = re.search(r'(\w+)="[^"\n]*default/systemd/user/', text)
|
||||
held = rf"|\$\{{?{source[1]}\b" if source else ""
|
||||
copy = re.search(rf"^[ \t]*(?:sudo[ \t]+)?(?:install|cp)\b[^\n]*(?:default/systemd/user/{held})", text, re.MULTILINE)
|
||||
enable = text.find("systemctl --user enable")
|
||||
return bool(copy) and (enable < 0 or copy.start() < enable)
|
||||
|
||||
named = 'unit="$OMARCHY_PATH/default/systemd/user/a.service"\n'
|
||||
if installs_first(named + "systemctl --user enable a.service\n"):
|
||||
errors.append("unit check accepts a command that names a unit's source but never installs it")
|
||||
if installs_first(named + 'systemctl --user enable a.service\ninstall -Dm644 "$unit" "$target"\n'):
|
||||
errors.append("unit check accepts a command that installs a unit after enabling it")
|
||||
|
||||
scripts = [path.read_text(errors="ignore") for folder in ("bin", "install", "migrations")
|
||||
for path in (root / folder).rglob("*") if path.is_file()]
|
||||
for unit in sorted(path.name for path in (root / "default/systemd/user").glob("*.service")):
|
||||
if f"/usr/lib/systemd/user/{unit}" in pkgbuild:
|
||||
continue
|
||||
named = [text for text in scripts if unit in text]
|
||||
if not any(installs_first(text) for text in named):
|
||||
errors.append(f"PKGBUILD does not ship default/systemd/user/{unit} and no command installs it")
|
||||
elif any("systemctl --user enable" in text and not installs_first(text) for text in named):
|
||||
errors.append(f"{unit} is enabled without being shipped by PKGBUILD or installed first")
|
||||
|
||||
# Existing users have an absolute wants symlink to the old unit path, and the
|
||||
# migration that repoints it only runs for users who run an update -- the
|
||||
# opposite of who the notifier is for. Dropping this alias strands them.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The migration that puts systemd.tty.term.console=dumb into effect on an
|
||||
# existing install: the package has already written the parameter into
|
||||
# omarchy-defaults.conf, so what is left is rebuilding the boot image, once per
|
||||
# machine, and only where it is needed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1791581004.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
stub_bin="$test_dir/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
# sudo just drops the prefix; limine-mkinitcpio records that it ran, and fails
|
||||
# when told to.
|
||||
printf '#!/bin/bash\nexec "$@"\n' >"$stub_bin/sudo"
|
||||
# A rebuild writes the new command line into the boot menu, unless it is the
|
||||
# kind that builds nothing and still exits 0.
|
||||
cat >"$stub_bin/limine-mkinitcpio" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo rebuilt >>"${REBUILDS:?}"
|
||||
[[ -z ${REBUILD_FAILS:-} ]] || exit 1
|
||||
[[ -n ${REBUILD_BUILDS_NOTHING:-} ]] || printf ' cmdline: %s\n' "${NEW_CMDLINE:?}" >"${LIMINE_CONF:?}"
|
||||
STUB
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
old_cmdline="root=PARTUUID=1 rw quiet splash initramfs_async=0"
|
||||
new_cmdline="$old_cmdline systemd.tty.term.console=dumb"
|
||||
|
||||
run() { # booted cmdline, defaults conf
|
||||
: >"$test_dir/rebuilds"
|
||||
printf '%s\n' "$1" >"$test_dir/cmdline"
|
||||
printf ' cmdline: %s\n' "$1" >"$test_dir/limine.conf"
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" REBUILDS="$test_dir/rebuilds" \
|
||||
LIMINE_CONF="$test_dir/limine.conf" NEW_CMDLINE="$new_cmdline" OMARCHY_LIMINE_CONF="$test_dir/limine.conf" \
|
||||
OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$2" \
|
||||
OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" bash -euo pipefail "$migration" >/dev/null
|
||||
}
|
||||
rebuilds() { wc -l <"$test_dir/rebuilds"; }
|
||||
|
||||
# omarchy-migrate runs a migration with bash -euo pipefail, and so does run:
|
||||
# that is what stops a failed rebuild before the marker is written.
|
||||
if REBUILD_FAILS=1 run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"; then
|
||||
fail "a rebuild that failed was reported as a success"
|
||||
fi
|
||||
[[ $(rebuilds) == 1 && ! -e $test_dir/marker ]] || fail "a failed rebuild was marked as done"
|
||||
pass "a rebuild that fails stops the migration and leaves no marker"
|
||||
|
||||
# limine-mkinitcpio exits 0 past a kernel it could not build.
|
||||
if REBUILD_BUILDS_NOTHING=1 run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"; then
|
||||
fail "a rebuild that changed nothing was reported as a success"
|
||||
fi
|
||||
[[ ! -e $test_dir/marker ]] || fail "a rebuild that changed nothing was marked as done"
|
||||
pass "a rebuild that exits 0 without changing the boot menu leaves the migration pending"
|
||||
|
||||
run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"
|
||||
[[ $(rebuilds) == 1 && -e $test_dir/marker ]] ||
|
||||
fail "a machine booted without the parameter did not get its boot image rebuilt"
|
||||
pass "the next run rebuilds the boot image, with the packaged defaults, and marks it"
|
||||
|
||||
run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"
|
||||
[[ $(rebuilds) == 0 ]] || fail "the rebuild ran a second time before a reboot"
|
||||
pass "the rebuild runs once per machine, not once per user"
|
||||
|
||||
rm -f "$test_dir/marker"
|
||||
run "$new_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"
|
||||
[[ $(rebuilds) == 0 && ! -e $test_dir/marker ]] || fail "a machine that already boots with the parameter was rebuilt"
|
||||
pass "a machine that already boots with the parameter is left alone"
|
||||
|
||||
grep -v "tty.term.console" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" >"$test_dir/edited.conf"
|
||||
run "$old_cmdline" "$test_dir/edited.conf"
|
||||
[[ $(rebuilds) == 0 ]] || fail "a machine whose defaults leave the parameter out was rebuilt anyway"
|
||||
pass "a machine whose defaults were edited to leave the parameter out is left alone"
|
||||
|
||||
# No Limine: the stand-in goes, and the PATH holds only what the migration
|
||||
# needs, so a limine-mkinitcpio installed on the machine running this test is
|
||||
# not found either.
|
||||
rm "$stub_bin/limine-mkinitcpio"
|
||||
mkdir -p "$test_dir/tools"
|
||||
for tool in bash grep install cat sed tail; do ln -s "$(command -v "$tool")" "$test_dir/tools/$tool"; done
|
||||
: >"$test_dir/rebuilds"
|
||||
printf '%s\n' "$old_cmdline" >"$test_dir/cmdline"
|
||||
PATH="$stub_bin:$ROOT/bin:$test_dir/tools" REBUILDS="$test_dir/rebuilds" \
|
||||
OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \
|
||||
OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" "$test_dir/tools/bash" -euo pipefail "$migration" >/dev/null
|
||||
[[ ! -e $test_dir/marker ]] || fail "a machine without Limine was marked as rebuilt"
|
||||
pass "a machine without Limine is left alone"
|
||||
@@ -125,6 +125,9 @@ for setup_command in \
|
||||
ln -s omarchy-test-setup-call "$mock_bin/$setup_command"
|
||||
done
|
||||
|
||||
# The real browser installer below calls the platform's post-install hook; on a
|
||||
# Mac with the platform package installed that would run, so answer it here.
|
||||
printf '#!/bin/bash\nexit 0\n' >"$mock_bin/omarchy-lifecycle-dispatch"
|
||||
chmod +x "$mock_bin"/*
|
||||
|
||||
export HOME="$test_home"
|
||||
|
||||
@@ -22,7 +22,7 @@ cat >"$mock_bin/omarchy-font-set" <<'SH'
|
||||
printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG"
|
||||
SH
|
||||
|
||||
for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do
|
||||
for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32 omarchy-lifecycle-dispatch; do
|
||||
cat >"$mock_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
|
||||
@@ -212,21 +212,41 @@ pass "selection updates are atomic for concurrent readers"
|
||||
|
||||
lua <<'LUA'
|
||||
local root = os.getenv("ROOT")
|
||||
local configured, bindings = false, {}
|
||||
local selected, installed, bindings = nil, true, {}
|
||||
io.popen = function(command)
|
||||
assert(command == "omarchy-default-dictation 2>/dev/null")
|
||||
return { read = function() return selected end, close = function() end }
|
||||
end
|
||||
o = {
|
||||
shell_succeeds = function(command)
|
||||
assert(command == "omarchy-default-dictation")
|
||||
return configured
|
||||
cmd_missing = function(command)
|
||||
assert(command == selected)
|
||||
return not installed
|
||||
end,
|
||||
bind = function(...) table.insert(bindings, {...}) end,
|
||||
bind_hold = function(keys, _, press, _, release, options)
|
||||
table.insert(bindings, { keys, press, options })
|
||||
table.insert(bindings, { keys, release, options })
|
||||
end,
|
||||
}
|
||||
dofile(root .. "/default/hypr/bindings/dictation.lua")
|
||||
assert(#bindings == 0, "unconfigured dictation leaves application keys available")
|
||||
configured = true
|
||||
selected = "some-backend"
|
||||
dofile(root .. "/default/hypr/bindings/dictation.lua")
|
||||
assert(#bindings == 5, "any configured backend receives the same bindings")
|
||||
assert(#bindings == 5, "any installed backend receives the same bindings")
|
||||
local function binds(keys, command)
|
||||
for _, binding in ipairs(bindings) do
|
||||
if binding[1] == keys and binding[2] == command then return true end
|
||||
end
|
||||
end
|
||||
for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do
|
||||
assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"),
|
||||
keys .. " starts dictation on press and stops it on release")
|
||||
end
|
||||
bindings, installed = {}, false
|
||||
dofile(root .. "/default/hypr/bindings/dictation.lua")
|
||||
assert(#bindings == 0, "a selected backend that is not installed leaves application keys available")
|
||||
LUA
|
||||
pass "dictation shortcuts require a selection without limiting backend names"
|
||||
pass "dictation shortcuts require an installed selection without limiting backend names"
|
||||
|
||||
# Fresh users receive the backend choice through the settings package's skel.
|
||||
cp "$ROOT/config/omarchy/defaults/dictation" "$config"
|
||||
@@ -234,6 +254,26 @@ cp "$ROOT/config/omarchy/defaults/dictation" "$config"
|
||||
printf '%s\n' voxtype > "$config"
|
||||
pass "fresh users get Superwhisper as their default backend"
|
||||
|
||||
# On aarch64, where Superwhisper has no build, user setup drops that preset; an
|
||||
# owner's own selection, and every x86_64 preset, stay.
|
||||
leaf_bin="$test_tmp/leaf-bin"
|
||||
mkdir -p "$leaf_bin"
|
||||
printf '#!/bin/bash\n[[ ${TEST_ARCH:-aarch64} == x86_64 ]]\n' >"$leaf_bin/omarchy-hw-x86"
|
||||
printf '#!/bin/bash\n[[ -z ${TEST_HAS_SUPERWHISPER:-} ]]\n' >"$leaf_bin/omarchy-cmd-missing"
|
||||
chmod +x "$leaf_bin"/*
|
||||
run_leaf() { env "$@" PATH="$leaf_bin:$PATH" bash -c 'source "$1"' bash "$ROOT/install/user/dictation-default.sh"; }
|
||||
cp "$ROOT/config/omarchy/defaults/dictation" "$config"
|
||||
run_leaf
|
||||
[[ ! -e $config ]] || fail "aarch64 user setup drops the Superwhisper preset"
|
||||
printf '%s\n' voxtype >"$config"
|
||||
run_leaf
|
||||
[[ $(<"$config") == "voxtype" ]] || fail "aarch64 user setup keeps a selection someone made"
|
||||
cp "$ROOT/config/omarchy/defaults/dictation" "$config"
|
||||
run_leaf TEST_ARCH=x86_64
|
||||
[[ $(<"$config") == "superwhisper" ]] || fail "x86_64 keeps the Superwhisper preset"
|
||||
printf '%s\n' voxtype >"$config"
|
||||
pass "aarch64 new users start with no dictation backend instead of an unbuildable one"
|
||||
|
||||
lua <<'LUA'
|
||||
local root = os.getenv("ROOT")
|
||||
package.path = root .. "/?.lua;" .. package.path
|
||||
|
||||
@@ -4,46 +4,828 @@ set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -r "$tmp_dir"' EXIT
|
||||
# omarchy-drive-password against fake drives. findmnt and lsblk describe a
|
||||
# system drive under / and a data drive, and blkid has the empty cache a user
|
||||
# sees until root runs blkid in that boot; cryptsetup is either a slot-table fake
|
||||
# or the real binary on file-backed volumes; a chpasswd fake shows the login and
|
||||
# root passwords never change. Each cryptsetup call is a crash point, so a run
|
||||
# can be killed after every step and rerun, like a power loss. The runs repeat on an Apple fixture,
|
||||
# where a fake boot package records the owner's slot through the real
|
||||
# omarchy-lifecycle-dispatch; on the x86 fixture that is a no-op.
|
||||
|
||||
cat >"$tmp_dir/blkid" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo /dev/test-luks
|
||||
EOF
|
||||
|
||||
cat >"$tmp_dir/gum" <<'EOF'
|
||||
#!/bin/bash
|
||||
head -n 1 "$TEST_INPUTS"
|
||||
sed -i '1d' "$TEST_INPUTS"
|
||||
EOF
|
||||
|
||||
cat >"$tmp_dir/sudo" <<'EOF'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >"$TEST_ARGS"
|
||||
cat >"$TEST_STDIN"
|
||||
EOF
|
||||
|
||||
chmod +x "$tmp_dir/blkid" "$tmp_dir/gum" "$tmp_dir/sudo"
|
||||
export PATH="$tmp_dir:$ROOT/bin:$PATH"
|
||||
export TEST_ARGS="$tmp_dir/args" TEST_INPUTS="$tmp_dir/inputs" TEST_STDIN="$tmp_dir/stdin"
|
||||
|
||||
printf '\n' >"$TEST_INPUTS"
|
||||
if "$ROOT/bin/omarchy-drive-password" >/dev/null; then
|
||||
fail "drive password rejects an empty passphrase"
|
||||
# Root's dispatcher ignores the fixtures and sees this machine.
|
||||
if (( EUID == 0 )) && [[ $("$ROOT/bin/omarchy-hw-platform") == "aarch64-apple" ]]; then
|
||||
skip "running as root on Apple Silicon, where dispatch ignores fixtures; skipping"
|
||||
exit 0
|
||||
fi
|
||||
[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for an empty passphrase"
|
||||
|
||||
printf 'secret123\n*\n' >"$TEST_INPUTS"
|
||||
if "$ROOT/bin/omarchy-drive-password" >/dev/null; then
|
||||
fail "drive password rejects a mismatched confirmation"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
old_password=old-password
|
||||
new_password='new pass:word'
|
||||
recovery_key=recovery-passphrase
|
||||
data_password=data-password
|
||||
journal=$tmp/state/omarchy/drive-password.state
|
||||
|
||||
mkdir -p "$tmp/bin" "$tmp/real" "$tmp/dev"
|
||||
|
||||
cat >"$tmp/crash.sh" <<'SH'
|
||||
crash_point() {
|
||||
local count
|
||||
count=$(( $(cat "$TEST_TMP/steps") + 1 ))
|
||||
echo "$count" >"$TEST_TMP/steps"
|
||||
printf '%s %s\n' "$count" "$1" >>"$TEST_TMP/trace"
|
||||
if (( count == ${CRASH_AT:-0} )); then
|
||||
kill -9 "$TEST_PID"
|
||||
# An orphan outlives the script for a moment, like a cryptsetup child.
|
||||
if [[ -n ${CRASH_ORPHAN:-} ]]; then sleep 1; else kill -9 $$; fi
|
||||
fi
|
||||
}
|
||||
SH
|
||||
|
||||
cat >"$tmp/bin/blkid" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 2
|
||||
SH
|
||||
|
||||
cat >"$tmp/bin/findmnt" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $* == "-no SOURCE /" && -z ${TEST_FINDMNT_FAIL:-} ]] || exit 1
|
||||
echo '/dev/mapper/root[/@]'
|
||||
SH
|
||||
|
||||
cat >"$tmp/bin/lsblk" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "-dno" && $2 == "UUID" ]]; then
|
||||
cat "$3.uuid"
|
||||
exit
|
||||
fi
|
||||
[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for a mismatched confirmation"
|
||||
if [[ $* == "-nrpo NAME,FSTYPE" ]]; then
|
||||
echo "/dev/fake-disk "
|
||||
while IFS= read -r drive; do
|
||||
printf '%s crypto_LUKS\n/dev/mapper/%s btrfs\n' "$drive" "${drive##*/}"
|
||||
done <"$TEST_TMP/drives"
|
||||
exit
|
||||
fi
|
||||
[[ $* == "-nsrpo NAME,TYPE,FSTYPE /dev/mapper/root" ]] || exit 98
|
||||
cat "$TEST_TMP/root-ancestry"
|
||||
SH
|
||||
|
||||
printf 'new password\nnew password\n' >"$TEST_INPUTS"
|
||||
"$ROOT/bin/omarchy-drive-password" >/dev/null
|
||||
cat >"$tmp/bin/omarchy-drive-select" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >"$TEST_TMP/offered"
|
||||
cat "$TEST_TMP/select"
|
||||
SH
|
||||
|
||||
[[ $(<"$TEST_STDIN") == "new password" ]] || fail "drive password passes the validated passphrase without a newline"
|
||||
grep -F 'cryptsetup luksChangeKey' "$TEST_ARGS" >/dev/null || fail "drive password changes the LUKS key"
|
||||
grep -Fx /dev/test-luks "$TEST_ARGS" >/dev/null || fail "drive password targets the selected drive"
|
||||
pass "drive password rejects empty and mismatched passphrases and passes validated input to cryptsetup"
|
||||
cat >"$tmp/bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "input" ]] || exit 97
|
||||
printf '%s\n' "$*" >>"$TEST_TMP/prompts"
|
||||
[[ -s $TEST_TMP/inputs ]] || exit 130
|
||||
# Another key enrolled while the owner types the new password.
|
||||
if [[ -n ${TEST_ENROLL_AT_PROMPT:-} && $* == *"$TEST_ENROLL_AT_PROMPT"* ]]; then
|
||||
printf '3\tlater-key\n' >>"$TEST_ENROLL_DEVICE.slots"
|
||||
fi
|
||||
head -n 1 "$TEST_TMP/inputs"
|
||||
sed -i '1d' "$TEST_TMP/inputs"
|
||||
SH
|
||||
|
||||
# Runs as the caller, closing every descriptor above stderr as sudo does.
|
||||
cat >"$tmp/bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$TEST_TMP/sudo-calls"
|
||||
for fd in /proc/$$/fd/*; do
|
||||
fd=${fd##*/}
|
||||
(( fd > 2 && fd != 255 )) && eval "exec $fd>&-" 2>/dev/null
|
||||
done
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat >"$tmp/bin/chpasswd" <<'SH'
|
||||
#!/bin/bash
|
||||
source "$TEST_TMP/crash.sh"
|
||||
IFS= read -r line
|
||||
name=${line%%:*}
|
||||
{
|
||||
grep -v "^$name " "$TEST_TMP/accounts" || true
|
||||
printf '%s\t%s\n' "$name" "${line#*:}"
|
||||
} >"$TEST_TMP/accounts.next"
|
||||
mv -f "$TEST_TMP/accounts.next" "$TEST_TMP/accounts"
|
||||
SH
|
||||
|
||||
# A LUKS1-style volume as a table of "slot<TAB>passphrase" lines next to the
|
||||
# device: luksChangeKey adds the new key to a free slot, then removes the old,
|
||||
# and luksKillSlot needs a key that opens another slot, as cryptsetup does.
|
||||
cat >"$tmp/bin/cryptsetup" <<'SH'
|
||||
#!/bin/bash
|
||||
source "$TEST_TMP/crash.sh"
|
||||
|
||||
slot_of() {
|
||||
local slot key
|
||||
while IFS=$'\t' read -r slot key; do
|
||||
[[ $key == "$1" && $slot != "${2:-}" ]] && { echo "$slot"; return; }
|
||||
done <"$slots"
|
||||
}
|
||||
|
||||
op=$1
|
||||
shift
|
||||
key_file="" token_type="" key_slot="" positional=()
|
||||
while (( $# )); do
|
||||
case $1 in
|
||||
--key-file) key_file=$2; shift 2 ;;
|
||||
--token-type) token_type=$2; shift 2 ;;
|
||||
--key-slot) key_slot=$2; shift 2 ;;
|
||||
--pbkdf | --iter-time) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) positional+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
slots=${positional[0]}.slots
|
||||
[[ -f $slots ]] || { echo "Device ${positional[0]} does not exist or access denied."; exit 4; }
|
||||
if [[ $key_file == "-" ]]; then key=$(cat); elif [[ -n $key_file ]]; then key=$(cat "$key_file"); fi
|
||||
|
||||
[[ $op != "isLuks" ]] || exit 1
|
||||
if [[ $op == "luksDump" ]]; then
|
||||
echo "Keyslots:"
|
||||
cut -f1 "$slots" | sed 's/^/ /; s/$/: luks2/'
|
||||
[[ -z ${TEST_TOKEN_SLOT:-} ]] || printf 'Tokens:\n 0: luks2-keyring\n\tKeyslot: %s\n' "$TEST_TOKEN_SLOT"
|
||||
printf 'Digests:\n 0: pbkdf2\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
crash_point "cryptsetup $op"
|
||||
[[ $op != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1
|
||||
# An enrolled token unlocks its slot whatever key is given, unless the allowed
|
||||
# token types exclude it, as with cryptsetup.
|
||||
if [[ $op == "open" && -z $token_type && -n ${TEST_TOKEN_SLOT:-} ]]; then
|
||||
echo "Key slot $TEST_TOKEN_SLOT unlocked."
|
||||
exit 0
|
||||
fi
|
||||
target=""
|
||||
[[ $op == "luksKillSlot" ]] && target=${positional[1]}
|
||||
if [[ $op == "open" && -n $key_slot ]]; then
|
||||
grep -qxF "$key_slot"$'\t'"$key" "$slots" || { echo "No key available with this passphrase."; exit 2; }
|
||||
echo "Key slot $key_slot unlocked."
|
||||
exit 0
|
||||
fi
|
||||
slot=$(slot_of "$key" "$target")
|
||||
[[ -n $slot ]] || { echo "No key available with this passphrase."; exit 2; }
|
||||
|
||||
case $op in
|
||||
open) echo "Key slot $slot unlocked." ;;
|
||||
luksChangeKey)
|
||||
[[ -z ${TEST_CHANGE_FAIL:-} ]] || exit 1
|
||||
for (( next = 0; next < 32; next++ )); do
|
||||
cut -f1 "$slots" | grep -qx "$next" || break
|
||||
done
|
||||
printf '%s\t%s\n' "$next" "$(cat "${positional[1]}")" >>"$slots"
|
||||
crash_point "new key added"
|
||||
[[ -z ${TEST_CHANGE_PARTIAL:-} ]] || exit 137
|
||||
awk -F'\t' -v s="$slot" '$1 != s' "$slots" >"$slots.next"
|
||||
mv -f "$slots.next" "$slots"
|
||||
crash_point "old key removed"
|
||||
;;
|
||||
luksKillSlot)
|
||||
[[ -z ${TEST_KILL_FAIL:-} ]] || exit 1
|
||||
awk -F'\t' -v s="$target" '$1 != s' "$slots" >"$slots.next"
|
||||
mv -f "$slots.next" "$slots"
|
||||
crash_point "slot $target killed"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
# The real cryptsetup, keeping the command's KDF but at a fast cost, counting
|
||||
# the same crash points.
|
||||
cat >"$tmp/real/cryptsetup" <<'SH'
|
||||
#!/bin/bash
|
||||
source "$TEST_TMP/crash.sh"
|
||||
args=()
|
||||
while (( $# )); do
|
||||
case $1 in
|
||||
--iter-time) shift 2 ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
if [[ ${args[0]} == "luksChangeKey" ]]; then
|
||||
if [[ " ${args[*]} " == *" argon2id "* ]]; then
|
||||
args+=(--pbkdf-force-iterations 4 --pbkdf-memory 32768)
|
||||
else
|
||||
args+=(--pbkdf-force-iterations 1000)
|
||||
fi
|
||||
fi
|
||||
[[ ${args[0]} == "luksDump" || ${args[0]} == "isLuks" ]] || crash_point "cryptsetup ${args[0]}"
|
||||
[[ ${args[0]} != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1
|
||||
status=0
|
||||
"$REAL_CRYPTSETUP" "${args[@]}" || status=$?
|
||||
if (( status == 0 )) && [[ ${args[0]} == "luksChangeKey" || ${args[0]} == "luksKillSlot" ]]; then
|
||||
crash_point "${args[0]} done"
|
||||
fi
|
||||
exit "$status"
|
||||
SH
|
||||
|
||||
chmod +x "$tmp"/bin/* "$tmp/real/cryptsetup"
|
||||
|
||||
# The boot package's luks-slots, run by dispatch with an empty environment: it
|
||||
# records its arguments and is a crash point like the rest.
|
||||
fake_platform "$tmp/x86" x86
|
||||
fake_platform "$tmp/apple" aarch64-apple
|
||||
mkdir -p "$tmp/lifecycle/usr/lib/omarchy/mac-boot"
|
||||
cat >"$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" <<SH
|
||||
#!/bin/bash
|
||||
# --owner names the recorded owner slot, read-only; setup recorded slot 0. A Mac
|
||||
# set up before slots were recorded has none (4); a record the header doesn't
|
||||
# hold is a failure (1).
|
||||
if [[ \$1 == --owner ]]; then
|
||||
[[ ! -e $tmp/owner-query-fail ]] || { echo "The LUKS header has no key in the recorded owner slot 0." >&2; exit 1; }
|
||||
[[ ! -e $tmp/owner-query-garbage ]] || { echo "slot zero"; exit 0; }
|
||||
[[ ! -e $tmp/owner-slot ]] || { cat "$tmp/owner-slot"; exit 0; }
|
||||
[[ ! -e $tmp/owner-unrecorded ]] || { echo "No owner key slot is recorded." >&2; exit 4; }
|
||||
echo 0
|
||||
exit 0
|
||||
fi
|
||||
[[ ! -e $tmp/record-fail ]] || exit 1
|
||||
printf '%s\n' "\$*" >>"$tmp/slot-record"
|
||||
[[ \$1 != owner=* ]] || echo "\${1#owner=}" >"$tmp/owner-slot"
|
||||
count=\$(( \$(cat "$tmp/steps") + 1 ))
|
||||
echo "\$count" >"$tmp/steps"
|
||||
printf '%s slots recorded\n' "\$count" >>"$tmp/trace"
|
||||
if (( count == \$(cat "$tmp/crash-at") )); then
|
||||
kill -9 "\$PPID"
|
||||
kill -9 \$\$
|
||||
fi
|
||||
SH
|
||||
chmod 755 "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots"
|
||||
chmod -R go-w "$tmp/lifecycle"
|
||||
|
||||
export TEST_TMP=$tmp OMARCHY_PATH=$ROOT XDG_STATE_HOME=$tmp/state SUDO_USER=owner OMARCHY_LIFECYCLE_ROOT=$tmp/lifecycle
|
||||
base_path=$PATH
|
||||
platform=x86
|
||||
|
||||
# The command's PATH for this backend on this platform fixture.
|
||||
use() {
|
||||
backend=$1 platform=$2
|
||||
export OMARCHY_PROC_ROOT=$tmp/$platform/proc
|
||||
if [[ $backend == "fake" ]]; then
|
||||
export PATH="$tmp/$platform/bin:$tmp/bin:$ROOT/bin:$base_path"
|
||||
else
|
||||
export PATH="$tmp/$platform/bin:$tmp/real:$tmp/bin:$ROOT/bin:$base_path"
|
||||
fi
|
||||
}
|
||||
data=$tmp/dev/data
|
||||
|
||||
# The slot the key opens, or nothing.
|
||||
opens() {
|
||||
local device=$1 key=$2 slot k
|
||||
if [[ $backend == "fake" ]]; then
|
||||
while IFS=$'\t' read -r slot k; do
|
||||
[[ $k == "$key" ]] && { echo "$slot"; return; }
|
||||
done <"$device.slots"
|
||||
else
|
||||
LC_ALL=C "$REAL_CRYPTSETUP" open --test-passphrase --verbose --key-file <(printf '%s' "$key") "$device" 2>&1 |
|
||||
grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' || true
|
||||
fi
|
||||
}
|
||||
|
||||
account() {
|
||||
awk -F'\t' -v n="$1" '$1 == n { print $2 }' "$tmp/accounts"
|
||||
}
|
||||
|
||||
volume() {
|
||||
local device=$1 key=$2 extra=${3:-}
|
||||
echo "uuid-$(basename "$device")" >"$device.uuid"
|
||||
if [[ $backend == "fake" ]]; then
|
||||
: >"$device"
|
||||
printf '0\t%s\n' "$key" >"$device.slots"
|
||||
[[ -z $extra ]] || printf '1\t%s\n' "$extra" >>"$device.slots"
|
||||
else
|
||||
rm -f "$device"
|
||||
truncate -s 32M "$device"
|
||||
"$REAL_CRYPTSETUP" luksFormat -q --type "$backend" --pbkdf pbkdf2 --pbkdf-force-iterations 1000 "$device" <(printf '%s' "$key")
|
||||
if [[ -n $extra ]]; then
|
||||
"$REAL_CRYPTSETUP" luksAddKey --pbkdf pbkdf2 --pbkdf-force-iterations 1000 --key-file <(printf '%s' "$key") "$device" <(printf '%s' "$extra")
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# / on the system drive, which also holds a recovery key; a data drive beside it.
|
||||
fixture() {
|
||||
rm -rf "$tmp/state" "$tmp/output" "$tmp/trace" "$tmp/offered" "$tmp"/dev/* "$tmp/slot-record" "$tmp/record-fail" "$tmp/owner-slot" "$tmp/owner-query-fail" "$tmp/owner-query-garbage" "$tmp/owner-unrecorded"
|
||||
unset TEST_ENROLL_AT_PROMPT TEST_ENROLL_DEVICE TEST_OPEN_FAIL TEST_CHANGE_FAIL TEST_CHANGE_PARTIAL TEST_KILL_FAIL TEST_FINDMNT_FAIL TEST_TOKEN_SLOT CRASH_ORPHAN
|
||||
system=$tmp/dev/system
|
||||
recovery=${1-$recovery_key}
|
||||
volume "$system" "$old_password" "$recovery"
|
||||
volume "$data" "$data_password"
|
||||
printf 'owner\t%s\nroot\t%s\n' "$old_password" "$old_password" >"$tmp/accounts"
|
||||
printf '%s\n%s\n' "$system" "$data" >"$tmp/drives"
|
||||
printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry"
|
||||
echo "$system" >"$tmp/select"
|
||||
: >"$tmp/prompts"
|
||||
: >"$tmp/sudo-calls"
|
||||
}
|
||||
|
||||
# One run of the command: an optional crash step, then answers for its prompts.
|
||||
attempt() {
|
||||
local crash_at=$1
|
||||
shift
|
||||
if (( $# )); then printf '%s\n' "$@" >"$tmp/inputs"; else : >"$tmp/inputs"; fi
|
||||
echo 0 >"$tmp/steps"
|
||||
echo "$crash_at" >"$tmp/crash-at"
|
||||
: >"$tmp/trace"
|
||||
{
|
||||
CRASH_AT=$crash_at bash -c 'TEST_PID=$$ exec bash ${TEST_TRACE:+-x} "$0"' "$ROOT/bin/omarchy-drive-password"
|
||||
} >>"$tmp/output" 2>&1
|
||||
}
|
||||
|
||||
consistent() {
|
||||
local context=$1 password=$2 other=$old_password
|
||||
[[ $password == "$old_password" ]] && other=$new_password
|
||||
untouched_accounts "$context"
|
||||
[[ -n $(opens "$system" "$password") ]] || fail "$backend: $context: the disk opens with that password" "$(cat "$tmp/output")"
|
||||
[[ -z $(opens "$system" "$other") ]] || fail "$backend: $context: the other password no longer opens the disk" "$(cat "$tmp/trace" "$tmp/output")"
|
||||
[[ -z $recovery || -n $(opens "$system" "$recovery") ]] || fail "$backend: $context: the recovery key still opens the disk"
|
||||
[[ ! -e $journal ]] || fail "$backend: $context: the journal is gone" "$(cat "$journal")"
|
||||
recorded "$context" "$password"
|
||||
}
|
||||
|
||||
# On Apple the boot package holds the slot the owner's password opens: recorded
|
||||
# whenever the key moved. x86 records nothing and runs no extra sudo.
|
||||
recorded() {
|
||||
local context=$1 password=$2 slot
|
||||
if [[ $platform == "apple" ]]; then
|
||||
slot=$(opens "$system" "$password")
|
||||
if [[ -s $tmp/slot-record ]]; then
|
||||
[[ $(tail -n 1 "$tmp/slot-record") == "owner=$slot" ]] ||
|
||||
fail "$backend: apple: $context: the owner's slot is recorded" "$(cat "$tmp/slot-record")"
|
||||
else
|
||||
[[ $slot == "0" ]] || fail "$backend: apple: $context: a key that moved is recorded" "$(cat "$tmp/trace")"
|
||||
fi
|
||||
else
|
||||
[[ ! -e $tmp/slot-record ]] && ! grep -q 'lifecycle-dispatch' "$tmp/sudo-calls" ||
|
||||
fail "$backend: x86: $context: no slot is recorded and no dispatch runs under sudo"
|
||||
fi
|
||||
}
|
||||
|
||||
# The disk password never changes the login or root password.
|
||||
untouched_accounts() {
|
||||
[[ $(account owner) == "$old_password" && $(account root) == "$old_password" ]] && ! grep -q 'chpasswd' "$tmp/sudo-calls" ||
|
||||
fail "$backend: $1: the login and root passwords stay as they were" "$(cat "$tmp/accounts" "$tmp/sudo-calls")"
|
||||
}
|
||||
|
||||
said() {
|
||||
grep -Fq "$1" "$tmp/output" || fail "$backend: the command says: $1" "$(cat "$tmp/output")"
|
||||
}
|
||||
|
||||
no_secrets() {
|
||||
local file
|
||||
for file in "$journal" "$tmp/sudo-calls" "$tmp/prompts"; do
|
||||
[[ -e $file ]] || continue
|
||||
! grep -Fq -e "$old_password" -e "$new_password" -e "$recovery_key" -e "$data_password" "$file" ||
|
||||
fail "$backend: $1: $(basename "$file") holds no password" "$(cat "$file")"
|
||||
done
|
||||
}
|
||||
|
||||
# Between runs the disk opens with the old or the new password, and the
|
||||
# accounts never change.
|
||||
recoverable() {
|
||||
local context=$1
|
||||
[[ -n $(opens "$system" "$old_password") || -n $(opens "$system" "$new_password") ]] ||
|
||||
fail "$backend: $context: the disk opens with the old or the new password" "$(cat "$tmp/trace")"
|
||||
untouched_accounts "$context"
|
||||
[[ ! -e $journal || $(stat -c %a "$journal") == "600" ]] || fail "$backend: $context: the journal is private"
|
||||
no_secrets "$context"
|
||||
}
|
||||
|
||||
backends=(fake)
|
||||
if REAL_CRYPTSETUP=$(command -v cryptsetup); then
|
||||
export REAL_CRYPTSETUP
|
||||
backends+=(luks2 luks1)
|
||||
else
|
||||
skip "cryptsetup is not installed; skipping the file-backed volume runs"
|
||||
fi
|
||||
|
||||
matrix=()
|
||||
for backend in "${backends[@]}"; do
|
||||
matrix+=("$backend x86" "$backend apple")
|
||||
done
|
||||
|
||||
for run_spec in "${matrix[@]}"; do
|
||||
use $run_spec
|
||||
|
||||
fixture
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: changing the system disk password succeeds" "$(cat "$tmp/output")"
|
||||
consistent "uninterrupted" "$new_password"
|
||||
[[ $(opens "$data" "$data_password") == "0" ]] || fail "$backend: the data drive is untouched"
|
||||
! grep -Fq 'login password' "$tmp/output" || fail "$backend: the command does not mention the login password" "$(cat "$tmp/output")"
|
||||
no_secrets "uninterrupted"
|
||||
total_steps=$(cat "$tmp/steps")
|
||||
(( total_steps >= 4 )) || fail "$backend: every change is a crash point" "$(cat "$tmp/trace")"
|
||||
pass "$backend: the system disk key changes and verifies, and the login and root passwords stay as they were"
|
||||
|
||||
fixture
|
||||
if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "$backend: a wrong current password fails the command"; fi
|
||||
said "That password does not open $system."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "$backend: a wrong current password changes nothing"
|
||||
[[ -n $(opens "$system" "$old_password") && $(account owner) == "$old_password" ]] || fail "$backend: a wrong current password leaves the disk and accounts"
|
||||
pass "$backend: a wrong current password is refused before anything changes"
|
||||
|
||||
fixture ""
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: a single-slot system disk changes" "$(cat "$tmp/output")"
|
||||
consistent "single slot" "$new_password"
|
||||
pass "$backend: a disk with one key slot keeps it through the change"
|
||||
|
||||
fixture
|
||||
echo "$data" >"$tmp/select"
|
||||
attempt 0 "$data_password" "$new_password" "$new_password" || fail "$backend: changing a data drive password succeeds" "$(cat "$tmp/output")"
|
||||
[[ -n $(opens "$data" "$new_password") && -z $(opens "$data" "$data_password") ]] || fail "$backend: the data drive takes the new password"
|
||||
untouched_accounts "data drive"
|
||||
[[ -n $(opens "$system" "$old_password") ]] || fail "$backend: a data drive change leaves the system disk alone"
|
||||
[[ ! -e $journal ]] || fail "$backend: a data drive change keeps no journal"
|
||||
pass "$backend: changing a non-root encrypted drive's password leaves the system disk alone"
|
||||
|
||||
# Killed after each step, the owner runs the command again and answers with a
|
||||
# password that opens the disk now: the new one, or the old one while it still
|
||||
# works. Each rerun is itself killed after each of its steps until one finishes.
|
||||
for (( step = 1; step <= total_steps; step++ )); do
|
||||
for answer in "$new_password" "$old_password"; do
|
||||
fixture
|
||||
if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "$backend: the run is killed at step $step"; fi
|
||||
point=$(sed -n "${step}p" "$tmp/trace")
|
||||
recoverable "killed after '$point'"
|
||||
if [[ ! -e $journal ]]; then
|
||||
if [[ -n $(opens "$system" "$new_password") ]]; then
|
||||
consistent "killed after '$point'" "$new_password"
|
||||
else
|
||||
consistent "killed after '$point'" "$old_password"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
[[ -n $(opens "$system" "$answer") ]] || continue
|
||||
if grep -qx 'phase=record' "$journal" && [[ $answer == "$old_password" ]]; then
|
||||
if attempt 0 "$answer"; then fail "$backend: after '$point' a confirmed change refuses the old password"; fi
|
||||
[[ -e $journal ]] || fail "$backend: after '$point' a refused rerun keeps the journal"
|
||||
answer=$new_password
|
||||
fi
|
||||
for (( again = 1; ; again++ )); do
|
||||
status=0
|
||||
attempt "$again" "$answer" || status=$?
|
||||
(( status == 0 )) && break
|
||||
(( status == 137 )) || fail "$backend: the rerun after '$point' finishes" "$(cat "$tmp/output")"
|
||||
recoverable "rerun after '$point' killed at step $again"
|
||||
done
|
||||
consistent "rerun with $answer after '$point'" "$answer"
|
||||
no_secrets "rerun after '$point'"
|
||||
done
|
||||
done
|
||||
pass "$backend $platform: killed after each of $total_steps steps, and each rerun after each of its own, the disk ends on one password and the accounts never change"
|
||||
done
|
||||
|
||||
use fake x86
|
||||
|
||||
fixture
|
||||
printf '%s\n%s\n%s\n' "$system" "$data" "$data" >"$tmp/drives"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a fresh boot finds the system disk without a blkid cache" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/offered") == "$system"$'\n'"$data" ]] || fail "each LUKS drive lsblk names is offered once" "$(cat "$tmp/offered")"
|
||||
consistent "fresh boot" "$new_password"
|
||||
pass "before root has run blkid, every LUKS drive is offered once and the system disk changes"
|
||||
|
||||
fixture
|
||||
printf '5\t%s\n' tpm-sealed-key >>"$system.slots"
|
||||
export TEST_TOKEN_SLOT=5
|
||||
if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "an enrolled token does not stand in for the current password"; fi
|
||||
said "That password does not open $system."
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a disk with an enrolled token changes its password" "$(cat "$tmp/output")"
|
||||
consistent "beside an enrolled token" "$new_password"
|
||||
[[ $(opens "$system" tpm-sealed-key) == "5" ]] || fail "the token's slot is left alone"
|
||||
pass "an enrolled token never answers for a password, and its slot is left alone"
|
||||
|
||||
fixture
|
||||
export TEST_CHANGE_FAIL=1
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed luksChangeKey fails the command"; fi
|
||||
[[ -n $(opens "$system" "$old_password") && ! -e $journal ]] || fail "a failed luksChangeKey leaves the disk and no journal"
|
||||
said "The disk password did not change."
|
||||
pass "a failed LUKS change leaves the disk on its old password"
|
||||
|
||||
fixture
|
||||
export TEST_CHANGE_PARTIAL=1
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a luksChangeKey that dies after adding the new key is finished" "$(cat "$tmp/output")"
|
||||
consistent "luksChangeKey died after adding the new key" "$new_password"
|
||||
pass "a luksChangeKey that dies between keys has its old key retired"
|
||||
|
||||
fixture
|
||||
export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed retirement fails the command"; fi
|
||||
[[ -e $journal ]] || fail "a failed retirement keeps the journal"
|
||||
unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL
|
||||
if attempt 0 "$recovery_key"; then fail "an unconfirmed change refuses the recovery key"; fi
|
||||
said "That password does not finish the change"
|
||||
[[ -e $journal && -n $(opens "$system" "$recovery_key") ]] || fail "refusing the recovery key changes nothing"
|
||||
attempt 0 "$new_password" || fail "the rerun retires the old key and finishes" "$(cat "$tmp/output")"
|
||||
consistent "rerun after a failed retirement" "$new_password"
|
||||
pass "before the change is confirmed a rerun refuses the recovery key and retires the old key"
|
||||
|
||||
fixture
|
||||
export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || true
|
||||
unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL
|
||||
attempt 0 "$old_password" || fail "the rerun with the old password rolls back" "$(cat "$tmp/output")"
|
||||
consistent "rollback with the old password" "$old_password"
|
||||
pass "a rerun with the old password removes the half-added new key"
|
||||
|
||||
# On Apple the slot record follows the confirmed change: a crash there leaves
|
||||
# the confirmed phase.
|
||||
use fake apple
|
||||
fixture
|
||||
attempt 0 "$old_password" "$new_password" "$new_password"
|
||||
record_step=$(grep -n 'slots recorded' "$tmp/trace" | cut -d: -f1)
|
||||
fixture
|
||||
attempt "$record_step" "$old_password" "$new_password" "$new_password" || true
|
||||
grep -qx 'phase=record' "$journal" || fail "a crash at the slot record leaves the confirmed phase" "$(cat "$journal" "$tmp/trace")"
|
||||
if attempt 0 "$recovery_key"; then fail "a confirmed change refuses the recovery key"; fi
|
||||
said "That password does not finish the change"
|
||||
[[ -e $journal ]] || fail "refusing the recovery key keeps the journal"
|
||||
if attempt 0 "not-the-password"; then fail "a rerun refuses a password that opens nothing"; fi
|
||||
for suffix in "" .slots .uuid; do
|
||||
mv "$system$suffix" "$tmp/dev/renumbered$suffix"
|
||||
done
|
||||
system=$tmp/dev/renumbered
|
||||
printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry"
|
||||
attempt 0 "$new_password" || fail "the confirmed change finishes on a renumbered disk" "$(cat "$tmp/output")"
|
||||
consistent "rerun after a confirmed change" "$new_password"
|
||||
pass "apple: once the new key is confirmed only its slot finishes the change, on the disk found by UUID"
|
||||
|
||||
fixture
|
||||
export TEST_OPEN_FAIL=1
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a disk that cannot be checked fails the command"; fi
|
||||
said "Could not check the password against $system."
|
||||
! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a disk that cannot be checked is not changed"
|
||||
pass "the current password is checked before anything changes"
|
||||
|
||||
fixture
|
||||
attempt "$record_step" "$old_password" "$new_password" "$new_password" || true
|
||||
export TEST_FINDMNT_FAIL=1
|
||||
if attempt 0 "$new_password"; then fail "a run that cannot find / fails"; fi
|
||||
said "Could not tell which drive holds the system."
|
||||
[[ -e $journal ]] || fail "a run that cannot find / keeps the journal"
|
||||
unset TEST_FINDMNT_FAIL
|
||||
attempt 0 "$new_password" || fail "the rerun finishes once / is found" "$(cat "$tmp/output")"
|
||||
consistent "rerun after a failed root lookup" "$new_password"
|
||||
pass "apple: a failed root lookup changes nothing and keeps the journal"
|
||||
|
||||
fixture
|
||||
attempt "$record_step" "$old_password" "$new_password" "$new_password" || true
|
||||
sed -i 's/^phase=record$/phase=accounts/' "$journal"
|
||||
grep -qx 'phase=accounts' "$journal" || fail "the journal is rewritten as an earlier version wrote it" "$(cat "$journal")"
|
||||
if attempt 0 "$old_password"; then fail "an earlier version's confirmed change refuses the old password"; fi
|
||||
attempt 0 "$new_password" || fail "an earlier version's confirmed change finishes" "$(cat "$tmp/output")"
|
||||
consistent "journal from an earlier version" "$new_password"
|
||||
pass "apple: a confirmed change journaled by an earlier version records its slot and leaves the accounts alone"
|
||||
use fake x86
|
||||
|
||||
fixture
|
||||
attempt 0 "$old_password" "$new_password" "$new_password"
|
||||
change_step=$(grep -n 'cryptsetup luksChangeKey' "$tmp/trace" | cut -d: -f1)
|
||||
fixture
|
||||
export CRASH_ORPHAN=1
|
||||
attempt "$change_step" "$old_password" "$new_password" "$new_password" || true
|
||||
unset CRASH_ORPHAN
|
||||
if attempt 0 "$old_password"; then fail "a rerun waits for a luksChangeKey the killed run left behind"; fi
|
||||
attempt 0 "$new_password" || fail "the rerun finishes after the orphaned change" "$(cat "$tmp/output")"
|
||||
consistent "orphaned luksChangeKey" "$new_password"
|
||||
pass "a luksChangeKey that outlives its run finishes before the rerun reads the disk"
|
||||
|
||||
fixture
|
||||
printf '/dev/mapper/root crypt btrfs\n%s part \n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a crypt layer without a named LUKS partition fails"; fi
|
||||
said "Could not tell which drive holds the system."
|
||||
! grep -q 'luksChangeKey' "$tmp/sudo-calls" || fail "an unresolved crypt layer changes nothing"
|
||||
pass "a crypt layer whose LUKS partition cannot be named fails closed"
|
||||
|
||||
fixture
|
||||
mkdir -p "${journal%/*}"
|
||||
printf 'uuid=uuid-system\nold_slot=0\nslots=\nphase=luks\n' >"$journal"
|
||||
if attempt 0 "$recovery_key"; then fail "a journal without slots refuses to finish"; fi
|
||||
said "is unreadable"
|
||||
[[ -e $journal && -n $(opens "$system" "$old_password") ]] || fail "an unreadable journal changes nothing"
|
||||
pass "a journal without its slot list is refused rather than trusted"
|
||||
|
||||
fixture
|
||||
export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || true
|
||||
unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL
|
||||
printf '3\tlater-key\n' >>"$system.slots"
|
||||
if attempt 0 "$new_password"; then fail "a disk that gained two slots refuses to finish"; fi
|
||||
said "changed since the interrupted change"
|
||||
[[ -e $journal && -n $(opens "$system" "later-key") && -n $(opens "$system" "$old_password") ]] || fail "a disk that gained two slots is left alone"
|
||||
pass "a rerun never removes a slot enrolled after the interrupted change"
|
||||
|
||||
fixture
|
||||
mkdir -p "${journal%/*}"
|
||||
exec {held}>"${journal%/*}/drive-password.lock"
|
||||
flock -n "$held"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a second run waits for the first"; fi
|
||||
exec {held}>&-
|
||||
grep -Fq 'Another drive password change is running.' "$tmp/output" && ! grep -q 'luksChangeKey' "$tmp/sudo-calls" ||
|
||||
fail "a second run changes nothing while another holds the lock"
|
||||
pass "two runs never interleave"
|
||||
|
||||
fixture
|
||||
export TEST_TRACE=1
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a traced run succeeds" "$(cat "$tmp/output")"
|
||||
unset TEST_TRACE
|
||||
! grep -Fq -e "$old_password" -e "$new_password" "$tmp/output" || fail "tracing never shows a password" "$(cat "$tmp/output")"
|
||||
pass "bash -x does not trace passwords"
|
||||
|
||||
fixture
|
||||
printf '/dev/mapper/root lvm btrfs\n/dev/mapper/cryptlvm crypt LVM2_member\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "an LVM-on-LUKS system disk changes" "$(cat "$tmp/output")"
|
||||
consistent "LVM on LUKS" "$new_password"
|
||||
pass "the system disk is found beneath an LVM volume group"
|
||||
|
||||
fixture
|
||||
printf '/dev/mapper/root part ext4\n/dev/fake-disk disk \n' >"$tmp/root-ancestry"
|
||||
echo "$data" >"$tmp/drives"
|
||||
attempt 0 "$data_password" "$new_password" "$new_password" || fail "an unencrypted root still changes a data drive" "$(cat "$tmp/output")"
|
||||
[[ -n $(opens "$data" "$new_password") ]] || fail "an unencrypted root still changes the data drive"
|
||||
untouched_accounts "unencrypted root"
|
||||
pass "with / unencrypted, every encrypted drive changes alone"
|
||||
|
||||
fixture
|
||||
mkdir -p "${journal%/*}"
|
||||
printf 'uuid=uuid-elsewhere\nphase=record\nslot=0\n' >"$journal"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a journal for another disk does not block a change" "$(cat "$tmp/output")"
|
||||
consistent "stale journal" "$new_password"
|
||||
pass "a journal for a disk that is not / is dropped"
|
||||
|
||||
fixture
|
||||
if attempt 0 "$old_password" ""; then fail "an empty password is refused"; fi
|
||||
said "Password cannot be empty."
|
||||
if attempt 0 "$old_password" "secret123" "*"; then fail "a mismatched confirmation is refused"; fi
|
||||
said "Passwords do not match."
|
||||
if attempt 0 "$old_password" "$old_password" "$old_password"; then fail "the current password is refused as the new one"; fi
|
||||
said "The new password is the current one."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] ||
|
||||
fail "refused passwords change nothing"
|
||||
pass "drive password rejects empty, mismatched and unchanged passphrases before changing anything"
|
||||
|
||||
# A change interrupted part way is finished from the one slot the password
|
||||
# opened, so a system disk whose password is in two slots is refused before
|
||||
# anything changes: the other copy would outlive the change.
|
||||
use fake x86
|
||||
fixture "$old_password"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a password in two slots is refused"; fi
|
||||
said "That password also opens key slot 1 on $system"
|
||||
said "The system disk password did not change."
|
||||
! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a password in two slots changes nothing"
|
||||
[[ $(opens "$system" "$old_password") == "0" ]] && grep -qxF $'1\t'"$old_password" "$system.slots" || fail "both copies are left as they were"
|
||||
pass "a system disk whose password opens two key slots is refused before anything changes"
|
||||
|
||||
# A change journaled by an earlier version did not check the old password for
|
||||
# other slots. With other slots on the disk a copy of it may have outlived the
|
||||
# change, and no password entered now can show whose slot is whose, so it is
|
||||
# left for the owner to settle by hand, in every phase. With one slot it settles.
|
||||
for legacy_phase in luks record accounts; do
|
||||
fixture ""
|
||||
printf '0\t%s\n2\t%s\n' "$new_password" "$old_password" >"$system.slots"
|
||||
mkdir -p "${journal%/*}"
|
||||
printf 'uuid=uuid-system\nold_slot=0\nslots=0,2\nslot=0\nphase=%s\n' "$legacy_phase" >"$journal"
|
||||
if attempt 0 "$new_password" "$old_password"; then fail "an earlier version's $legacy_phase change on a disk with other slots is not settled"; fi
|
||||
said "may still hold a copy of the old password"
|
||||
said "Settle it by hand"
|
||||
! grep -q 'luksKillSlot\|luksChangeKey' "$tmp/sudo-calls" && [[ -e $journal && -n $(opens "$system" "$old_password") ]] ||
|
||||
fail "an earlier version's $legacy_phase change is left as it was" "$(cat "$tmp/sudo-calls")"
|
||||
done
|
||||
fixture ""
|
||||
printf '0\t%s\n' "$new_password" >"$system.slots"
|
||||
mkdir -p "${journal%/*}"
|
||||
printf 'uuid=uuid-system\nold_slot=0\nslots=0\nphase=luks\n' >"$journal"
|
||||
attempt 0 "$new_password" || fail "an earlier version's change on a one-slot disk settles" "$(cat "$tmp/output")"
|
||||
[[ ! -e $journal && $(opens "$system" "$new_password") == "0" ]] || fail "the one-slot change is settled"
|
||||
pass "an earlier version's interrupted change is left to the owner when other slots remain, and settles on a one-slot disk"
|
||||
|
||||
# The new key's slot is how the change is followed, so a new password that
|
||||
# already opens a slot (another key on the disk) is refused.
|
||||
fixture
|
||||
if attempt 0 "$old_password" "$recovery_key" "$recovery_key"; then fail "a new password that opens a slot already is refused"; fi
|
||||
said "That password already opens a key slot on $system. Choose another."
|
||||
! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "an enrolled new password changes nothing"
|
||||
echo "$data" >"$tmp/select"
|
||||
volume "$data" "$data_password" "$new_password"
|
||||
if attempt 0 "$data_password" "$new_password" "$new_password"; then fail "a data drive refuses a new password it already has"; fi
|
||||
said "That password already opens a key slot on $data. Choose another."
|
||||
pass "a new password that already opens a key slot is refused, on the system disk and data drives"
|
||||
|
||||
# Only the owner's slot changes the system disk. A second key an earlier setup
|
||||
# added (a recovery key) is refused before anything changes, and so is a change
|
||||
# the boot package cannot vouch for.
|
||||
use fake apple
|
||||
fixture
|
||||
if attempt 0 "$recovery_key" "$new_password" "$new_password"; then fail "apple: a key in another slot is refused"; fi
|
||||
said "That password opens another key slot on the system disk"
|
||||
said "The system disk password did not change."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: refusing another slot's key changes nothing"
|
||||
[[ -n $(opens "$system" "$recovery_key") ]] || fail "apple: the other key still opens the disk"
|
||||
consistent "another slot's key refused" "$old_password"
|
||||
touch "$tmp/owner-query-fail"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a boot package that cannot name the owner's slot stops the change"; fi
|
||||
said "Could not read which key slot holds the system disk password: The LUKS header has no key in the recorded owner slot 0."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: an unanswered owner query changes nothing"
|
||||
consistent "owner query failed" "$old_password"
|
||||
rm "$tmp/owner-query-fail"
|
||||
touch "$tmp/owner-query-garbage"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an owner slot that is not a number stops the change"; fi
|
||||
said "named 'slot zero', which is not a key slot"
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: a garbled owner slot changes nothing"
|
||||
rm "$tmp/owner-query-garbage"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: the owner's password changes the disk" "$(cat "$tmp/output")"
|
||||
consistent "owner's password after refusals" "$new_password"
|
||||
pass "apple: only the owner's slot changes the system disk; another slot's key and an unanswered query change nothing"
|
||||
|
||||
# A Mac set up before its boot package recorded slots has no owner slot (4).
|
||||
# Its disk's only key is the owner's: the change goes through and records the
|
||||
# new slot. With another key beside it the owner's can't be told, so the change
|
||||
# is refused, saying how to settle it: record both, or remove the extra key.
|
||||
use fake apple
|
||||
fixture ""
|
||||
touch "$tmp/owner-unrecorded"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: an unrecorded single-key disk changes its password" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the owner's new slot is recorded" "$(cat "$tmp/slot-record")"
|
||||
consistent "no owner slot recorded, one key" "$new_password"
|
||||
step=$(grep -n 'cryptsetup' "$tmp/trace" | tail -1 | cut -d: -f1)
|
||||
fixture ""
|
||||
touch "$tmp/owner-unrecorded"
|
||||
if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "apple: the unrecorded change is killed before the slot record"; fi
|
||||
[[ -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a change killed before the slot record keeps its journal" "$(cat "$tmp/trace")"
|
||||
attempt 0 "$new_password" || fail "apple: the rerun finishes an unrecorded change" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the rerun records the owner's new slot" "$(cat "$tmp/slot-record")"
|
||||
consistent "no owner slot recorded, interrupted" "$new_password"
|
||||
pass "apple: with no owner slot recorded, the disk's only key changes and its new slot is recorded, after an interruption too"
|
||||
|
||||
fixture
|
||||
touch "$tmp/owner-unrecorded"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an unrecorded disk with two keys refuses the change"; fi
|
||||
said "has keys in slots 0,1, so which one is yours can't be told"
|
||||
said "record both: sudo omarchy-lifecycle-dispatch luks-slots owner=0 recovery=1"
|
||||
said "sudo cryptsetup luksKillSlot $system <slot>"
|
||||
said "The system disk password did not change."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: an unrecorded two-key disk changes nothing"
|
||||
"$ROOT/bin/omarchy-lifecycle-dispatch" luks-slots owner=0 recovery=1
|
||||
: >"$tmp/slot-record"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: once both slots are recorded the change goes through" "$(cat "$tmp/output")"
|
||||
consistent "slots recorded as the refusal said" "$new_password"
|
||||
pass "apple: with no owner slot recorded, a disk with another key refuses the change and says how to record or remove it"
|
||||
|
||||
fixture ""
|
||||
touch "$tmp/owner-unrecorded"
|
||||
export TEST_ENROLL_AT_PROMPT="Confirm new encryption password" TEST_ENROLL_DEVICE=$system
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a key enrolled during the prompts stops an unrecorded change"; fi
|
||||
said "changed while you entered the new password"
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a key enrolled during the prompts changes nothing"
|
||||
pass "apple: an unrecorded disk that gains a key while the owner types is left alone"
|
||||
|
||||
# A slot the boot package could not record keeps the journal: the rerun records it.
|
||||
use fake apple
|
||||
fixture
|
||||
touch "$tmp/record-fail"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed record fails the command"; fi
|
||||
said "Could not record the new key slot for the boot checks."
|
||||
[[ -e $journal && ! -e $tmp/slot-record ]] || fail "a failed record keeps the journal"
|
||||
rm "$tmp/record-fail"
|
||||
attempt 0 "$new_password" || fail "the rerun records the slot" "$(cat "$tmp/output")"
|
||||
[[ -s $tmp/slot-record ]] || fail "the rerun records the moved key's slot"
|
||||
consistent "rerun after a failed record" "$new_password"
|
||||
pass "apple: the change finishes only once the boot package recorded the owner's new slot"
|
||||
|
||||
# An installed boot package older than luks-slots could not record the slot, so
|
||||
# the system disk does not change; a data drive still does.
|
||||
fixture
|
||||
mv "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" "$tmp/luks-slots.off"
|
||||
mkdir -p "$tmp/lifecycle/var/lib/pacman/local/omarchy-mac-boot-20260925-2"
|
||||
if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "an Apple boot package without luks-slots fails the command"; fi
|
||||
said "which omarchy-mac-boot 20260925-2 does not provide; update omarchy-mac-boot"
|
||||
said "The system disk password did not change."
|
||||
! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a boot package without luks-slots changes nothing"
|
||||
consistent "boot package without luks-slots" "$old_password"
|
||||
echo "$data" >"$tmp/select"
|
||||
attempt 0 "$data_password" "$new_password" "$new_password" || fail "a data drive changes without luks-slots" "$(cat "$tmp/output")"
|
||||
pass "apple: a boot package without luks-slots stops a system disk change before it starts, naming the package"
|
||||
|
||||
# A Mac without its boot package at all predates it: nothing checks its slots,
|
||||
# so the change goes through and records nothing.
|
||||
fixture
|
||||
rm -r "$tmp/lifecycle/var/lib/pacman"
|
||||
attempt 0 "$old_password" "$new_password" "$new_password" || fail "a Mac without the boot package changes its disk password" "$(cat "$tmp/output")"
|
||||
# Checked as on x86: no slot recorded and no dispatch under sudo.
|
||||
platform=x86
|
||||
consistent "no boot package" "$new_password"
|
||||
platform=apple
|
||||
! grep -q 'Error:' "$tmp/output" || fail "without the boot package nothing is reported" "$(cat "$tmp/output")"
|
||||
mv "$tmp/luks-slots.off" "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots"
|
||||
pass "apple: a Mac without its boot package changes its disk password and records no slot"
|
||||
|
||||
fixture
|
||||
echo "$data" >"$tmp/select"
|
||||
attempt 0 "$data_password" "$new_password" "$new_password" || fail "apple: a data drive changes" "$(cat "$tmp/output")"
|
||||
[[ ! -e $tmp/slot-record && -n $(opens "$data" "$new_password") ]] || fail "apple: a data drive records no slot"
|
||||
pass "apple: only the system disk's slot is recorded"
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Factory reset's boot step through the real omarchy-lifecycle-dispatch. On an
|
||||
# x86 fixture the generic Limine UKI path runs and the boot-package entrypoints
|
||||
# on disk never do. On an Apple fixture a fake boot package owns the factory
|
||||
# root's boot chain: it prepares and verifies it before the throwaway slot is
|
||||
# added, gets the key on standard input after the switch, and rolls back when
|
||||
# the reset fails before the switch. Subvolumes are directories, and
|
||||
# cryptsetup is a slot-table fake.
|
||||
require_platform_fixtures "factory reset through lifecycle dispatch"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
fake_platform "$tmp/x86" x86
|
||||
fake_platform "$tmp/apple" aarch64-apple
|
||||
|
||||
# Load the production functions without self-elevation or the reset entrypoint.
|
||||
awk '
|
||||
/^[a-z_]+\(\) \{/ { copying = 1 }
|
||||
copying { print }
|
||||
/^}/ { copying = 0 }
|
||||
' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/functions"
|
||||
|
||||
# The fake boot package: each entrypoint records its call, reset-commit its
|
||||
# standard input, and reset-verify fails on request.
|
||||
lifecycle=$tmp/lifecycle
|
||||
mac_boot=$lifecycle/usr/lib/omarchy/mac-boot
|
||||
mkdir -p "$mac_boot"
|
||||
for operation in reset-prepare reset-verify reset-commit reset-rollback; do
|
||||
cat >"$mac_boot/$operation" <<SH
|
||||
#!/bin/bash
|
||||
echo "$operation\${*:+ \$*}" >>"$tmp/calls"
|
||||
[[ $operation != reset-commit ]] || cat >"$tmp/committed-key"
|
||||
[[ ! -e $tmp/$operation-fail ]] || { echo "$operation: the fixture refuses" >&2; exit 1; }
|
||||
SH
|
||||
done
|
||||
chmod 755 "$mac_boot"/*
|
||||
chmod -R go-w "$lifecycle"
|
||||
|
||||
cat >"$tmp/reset" <<'SH'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$TMP/functions"
|
||||
TOP_MNT=$TMP/top
|
||||
NEXT_NAME=@omarchy-reset-next
|
||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
||||
LOG_FILE=$TMP/reset.log
|
||||
DISPATCH=${TEST_DISPATCH:-$ROOT/bin/omarchy-lifecycle-dispatch}
|
||||
RESET_BOOT=""
|
||||
RESET_BOOT_ERROR=""
|
||||
swap_done=0
|
||||
|
||||
gum() {
|
||||
if [[ $1 == "input" ]]; then
|
||||
printf '%s\n' "$CURRENT"
|
||||
elif [[ $1 == "style" ]]; then
|
||||
printf '%s\n' "${!#}" >>"$TMP/screen"
|
||||
fi
|
||||
}
|
||||
encrypted_install() { [[ -n $DEVICE ]]; }
|
||||
luks_device() { echo "$DEVICE"; }
|
||||
scrub_factory_accounts() { :; }
|
||||
sanitize_factory_baseline() { :; }
|
||||
install_provisioning_units() { :; }
|
||||
systemd-id128() { printf '%032d\n' 7; }
|
||||
sync() { :; }
|
||||
mountpoint() { return 0; }
|
||||
mount() { echo "mount $*" >>"$TMP/calls"; }
|
||||
umount() { :; }
|
||||
chroot() { echo "chroot ${*:2}" >>"$TMP/calls"; }
|
||||
reset_limine_config() { echo "limine menu reset" >>"$TMP/calls"; }
|
||||
verify_limine_hashes() { :; }
|
||||
|
||||
btrfs() {
|
||||
case "$1 $2" in
|
||||
"subvolume snapshot") cp -a "$3" "$4" ;;
|
||||
"subvolume delete") rm -rf "${!#}" ;;
|
||||
"subvolume show") return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mv() {
|
||||
if [[ -e $TMP/switch-fail && $1 == "$TOP_MNT/@" ]]; then
|
||||
echo "mv: the fixture refuses the switch" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -e $TMP/activate-fail && $1 == "$TOP_MNT/$NEXT_NAME" ]]; then
|
||||
echo "mv: the fixture refuses the activation" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -e $TMP/restore-fail && $1 == "$TOP_MNT"/@omarchy-old-* ]]; then
|
||||
echo "mv: the fixture refuses the restore" >&2
|
||||
return 1
|
||||
fi
|
||||
command mv "$@"
|
||||
}
|
||||
|
||||
# "<slot> <key>" per line.
|
||||
cryptsetup() {
|
||||
local op=$1 key_file="" slot material next
|
||||
local -a args=()
|
||||
shift
|
||||
while (( $# )); do
|
||||
case $1 in
|
||||
--key-file) key_file=$2; shift 2 ;;
|
||||
--token-type) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
echo "cryptsetup $op" >>"$TMP/calls"
|
||||
case $op in
|
||||
open)
|
||||
material=$(cat "$key_file")
|
||||
if [[ -e $TMP/lookup-fail-once && $material != "$CURRENT" ]]; then
|
||||
rm "$TMP/lookup-fail-once"
|
||||
return 2
|
||||
fi
|
||||
slot=$(awk -v m="$material" '$2 == m { print $1; exit }' "$TMP/slots")
|
||||
[[ -n $slot ]] || return 2
|
||||
echo "Key slot $slot unlocked."
|
||||
;;
|
||||
luksAddKey)
|
||||
material=$(cat "$key_file")
|
||||
awk -v m="$material" '$2 == m { found = 1 } END { exit !found }' "$TMP/slots" || return 2
|
||||
for (( next = 0; next < 32; next++ )); do
|
||||
awk '{ print $1 }' "$TMP/slots" | grep -qx "$next" || break
|
||||
done
|
||||
printf '%s %s\n' "$next" "$(cat "${args[1]}")" >>"$TMP/slots"
|
||||
;;
|
||||
luksDump)
|
||||
echo "Keyslots:"
|
||||
awk '{ printf " %s: luks2\n", $1 }' "$TMP/slots"
|
||||
;;
|
||||
luksKillSlot)
|
||||
slot=${args[1]}
|
||||
awk -v s="$slot" '$1 != s' "$TMP/slots" >"$TMP/slots.next"
|
||||
command mv -f "$TMP/slots.next" "$TMP/slots"
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case $MODE in
|
||||
owner) reset_boot_owner && echo "$RESET_BOOT" ;;
|
||||
reset)
|
||||
trap cleanup EXIT
|
||||
stage_full_reset
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
|
||||
current_password=owner-password
|
||||
device=$tmp/luks-device
|
||||
|
||||
# A top-level Btrfs volume: the running root and a factory baseline whose ESP
|
||||
# the generic path mounts from its fstab.
|
||||
fixture() {
|
||||
rm -rf "$tmp/top" "$tmp/calls" "$tmp/screen" "$tmp/reset.log" "$tmp/committed-key" "$tmp"/*-fail
|
||||
mkdir -p "$tmp/top/@" "$tmp/top/@factory/etc" "$tmp/top/@factory/usr/bin" \
|
||||
"$tmp/top/@factory/usr/share/omarchy/install/provisioning" "$tmp/top/@factory/boot"
|
||||
touch "$tmp/top/@/old-system" "$tmp/top/@factory/factory-system" "$tmp/esp" \
|
||||
"$tmp/top/@factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service"
|
||||
printf '#!/bin/bash\n' >"$tmp/top/@factory/usr/bin/omarchy-provision-owner"
|
||||
chmod +x "$tmp/top/@factory/usr/bin/omarchy-provision-owner"
|
||||
printf '%s /boot vfat defaults 0 2\n' "$tmp/esp" >"$tmp/top/@factory/etc/fstab"
|
||||
: >"$device"
|
||||
printf '0 %s\n' "$current_password" >"$tmp/slots"
|
||||
: >"$tmp/reset.log"
|
||||
}
|
||||
|
||||
run() {
|
||||
local mode=$1 platform=$2
|
||||
TMP=$tmp ROOT=$ROOT MODE=$mode CURRENT=$current_password DEVICE=${DEVICE-$device} TEST_DISPATCH=${TEST_DISPATCH:-} \
|
||||
OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT=$lifecycle PATH="$tmp/$platform/bin:$PATH" \
|
||||
bash "$tmp/reset" >"$tmp/out" 2>&1
|
||||
}
|
||||
|
||||
activated() {
|
||||
[[ -e $tmp/top/@/factory-system && -f $tmp/top/@/var/lib/omarchy/provisioning/pending ]] &&
|
||||
compgen -G "$tmp/top/@omarchy-old-*/old-system" >/dev/null
|
||||
}
|
||||
|
||||
untouched() {
|
||||
[[ -e $tmp/top/@/old-system && ! -e $tmp/top/@omarchy-reset-next ]] && [[ $(cat "$tmp/slots") == "0 $current_password" ]]
|
||||
}
|
||||
|
||||
# x86: the generic Limine UKI path, with boot-package entrypoints on disk that
|
||||
# would record themselves if they ran.
|
||||
fixture
|
||||
run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: the reset boot step is generic" "$(cat "$tmp/out")"
|
||||
run reset x86 || fail "x86: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")"
|
||||
activated || fail "x86: the factory root is activated"
|
||||
grep -qx 'chroot /usr/bin/limine-update' "$tmp/calls" || fail "x86: limine-update rebuilds the factory root's UKI" "$(cat "$tmp/calls")"
|
||||
[[ -f $tmp/top/@/etc/omarchy/provisioning.key && -f $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf &&
|
||||
-f $tmp/top/@/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf ]] || fail "x86: the UKI carries the reset's auto-unlock"
|
||||
[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key")" ]] ||
|
||||
fail "x86: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")"
|
||||
! grep -q '^reset-' "$tmp/calls" || fail "x86: no boot-package entrypoint runs" "$(cat "$tmp/calls")"
|
||||
pass "x86: dispatch is a no-op and the generic Limine UKI path resets the machine"
|
||||
|
||||
# A failed activation on the generic path puts the previous root back and
|
||||
# revokes the throwaway slot. The ESP already holds the factory root's UKI, so
|
||||
# the screen says to rebuild the boot files rather than claiming nothing changed.
|
||||
fixture
|
||||
touch "$tmp/activate-fail"
|
||||
if run reset x86; then fail "x86: a failed activation fails the reset"; fi
|
||||
untouched || fail "x86: a failed activation puts the previous root back and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")"
|
||||
! grep -q '^reset-' "$tmp/calls" || fail "x86: a failed activation runs no boot-package entrypoint" "$(cat "$tmp/calls")"
|
||||
grep -q "run 'sudo limine-update' before rebooting" "$tmp/screen" ||
|
||||
fail "x86: a failed activation says the boot files need rebuilding" "$(cat "$tmp/screen")"
|
||||
pass "x86: a failed activation puts the previous root back, revokes the throwaway slot and says to rebuild the boot files"
|
||||
|
||||
# Apple: the boot package prepares and verifies the factory root before the
|
||||
# throwaway slot exists, and gets the key only once the factory root is active.
|
||||
fixture
|
||||
run owner apple && [[ $(cat "$tmp/out") == "platform" ]] || fail "apple: the boot package owns the reset boot step" "$(cat "$tmp/out")"
|
||||
run reset apple || fail "apple: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")"
|
||||
activated || fail "apple: the factory root is activated"
|
||||
expected="reset-prepare $tmp/top/@omarchy-reset-next $device
|
||||
reset-verify $tmp/top/@omarchy-reset-next
|
||||
cryptsetup luksAddKey
|
||||
reset-commit"
|
||||
[[ $(grep -v '^cryptsetup \(open\|luksDump\)' "$tmp/calls") == "$expected" ]] ||
|
||||
fail "apple: prepare, verify, the throwaway slot, then commit" "$(cat "$tmp/calls")"
|
||||
staged=$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key")
|
||||
[[ -n $staged && $(cat "$tmp/committed-key") == "$staged" ]] || fail "apple: reset-commit gets the staged key on standard input"
|
||||
[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$staged" ]] || fail "apple: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")"
|
||||
[[ ! -e $tmp/top/@/etc/omarchy/provisioning.key && ! -e $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf ]] ||
|
||||
fail "apple: no Limine UKI unlock is written"
|
||||
! grep -q 'limine' "$tmp/calls" || fail "apple: the Limine UKI path never runs" "$(cat "$tmp/calls")"
|
||||
! grep -Fq "$staged" "$tmp/reset.log" "$tmp/calls" "$tmp/out" || fail "apple: the throwaway key is never logged"
|
||||
pass "apple: the boot package prepares and verifies the factory root before the throwaway slot, and gets the key after the switch"
|
||||
|
||||
# A failed verification adds no credential and rolls the boot state back; the
|
||||
# previous root keeps booting.
|
||||
fixture
|
||||
touch "$tmp/reset-verify-fail"
|
||||
if run reset apple; then fail "apple: a failed verification fails the reset"; fi
|
||||
[[ $(grep '^reset-' "$tmp/calls" | cut -d' ' -f1) == $'reset-prepare\nreset-verify\nreset-rollback' ]] ||
|
||||
fail "apple: a failed verification rolls back" "$(cat "$tmp/calls")"
|
||||
! grep -q 'luksAddKey' "$tmp/calls" || fail "apple: a failed verification adds no slot"
|
||||
untouched || fail "apple: a failed verification leaves the previous root and its slots" "$(cat "$tmp/slots")"
|
||||
grep -q 'reset-verify: the fixture refuses' "$tmp/screen" || fail "apple: the verification's reason is shown" "$(cat "$tmp/screen")"
|
||||
pass "apple: a failed verification rolls back, adds no slot and keeps the previous root"
|
||||
|
||||
# A failure after the slot was added and before the switch revokes it too.
|
||||
fixture
|
||||
touch "$tmp/switch-fail"
|
||||
if run reset apple; then fail "apple: a failed switch fails the reset"; fi
|
||||
grep -q 'luksAddKey' "$tmp/calls" && grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" ||
|
||||
fail "apple: a failed switch rolls back without committing" "$(cat "$tmp/calls")"
|
||||
untouched || fail "apple: a failed switch revokes the throwaway slot and keeps the previous root" "$(cat "$tmp/slots")"
|
||||
pass "apple: a failure before the switch revokes the slot it added and rolls the boot state back"
|
||||
|
||||
# The old root already moved aside when the factory root cannot take @: the old
|
||||
# root goes back, and the reset is undone as if it failed before the switch.
|
||||
fixture
|
||||
touch "$tmp/activate-fail"
|
||||
if run reset apple; then fail "apple: a failed activation fails the reset"; fi
|
||||
grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" ||
|
||||
fail "apple: a failed activation rolls back without committing" "$(cat "$tmp/calls")"
|
||||
untouched || fail "apple: a failed activation puts the previous root back at @ and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")"
|
||||
! compgen -G "$tmp/top/@omarchy-old-*" >/dev/null || fail "apple: a failed activation leaves no renamed root behind" "$(ls "$tmp/top")"
|
||||
grep -q 'the current root is back at @' "$tmp/screen" || fail "apple: a failed activation says the current root is back" "$(cat "$tmp/screen")"
|
||||
pass "apple: a failed activation puts the previous root back, rolls back and revokes the slot"
|
||||
|
||||
# When the old root cannot go back either, neither root is at @: both survive
|
||||
# for recovery, and the screen names the rename that restores the machine.
|
||||
fixture
|
||||
touch "$tmp/activate-fail" "$tmp/restore-fail"
|
||||
if run reset apple; then fail "apple: a failed activation and restore fails the reset"; fi
|
||||
old=$(compgen -G "$tmp/top/@omarchy-old-*") || fail "apple: the previous root survives under its moved-aside name" "$(ls "$tmp/top")"
|
||||
[[ -e $old/old-system && -e $tmp/top/@omarchy-reset-next/factory-system && ! -e $tmp/top/@ ]] ||
|
||||
fail "apple: a failed restore keeps both roots" "$(ls "$tmp/top")"
|
||||
grep -qx 'reset-rollback' "$tmp/calls" && [[ $(cat "$tmp/slots") == "0 $current_password" ]] ||
|
||||
fail "apple: a failed restore still rolls the boot state back and revokes the slot" "$(cat "$tmp/calls" "$tmp/slots")"
|
||||
grep -q "rename ${old##*/} to @" "$tmp/screen" || fail "apple: a failed restore names the recovery rename" "$(cat "$tmp/screen")"
|
||||
pass "apple: a failed activation and restore keeps both roots and names the recovery rename"
|
||||
|
||||
# A slot added but not confirmed is found by its key and revoked all the same.
|
||||
fixture
|
||||
touch "$tmp/lookup-fail-once"
|
||||
if run reset apple; then fail "apple: an unconfirmed throwaway slot fails the reset"; fi
|
||||
grep -qx 'reset-rollback' "$tmp/calls" && grep -q 'luksKillSlot' "$tmp/calls" || fail "apple: an unconfirmed slot is rolled back and killed" "$(cat "$tmp/calls")"
|
||||
untouched || fail "apple: an unconfirmed throwaway slot is revoked" "$(cat "$tmp/slots")"
|
||||
pass "apple: a throwaway slot the reset could not confirm is found by its key and revoked"
|
||||
|
||||
# Once the factory root is active nothing rolls back: a failed commit is logged
|
||||
# and costs the first boot one password prompt.
|
||||
fixture
|
||||
touch "$tmp/reset-commit-fail"
|
||||
run reset apple || fail "apple: a failed commit still completes the reset" "$(cat "$tmp/out")"
|
||||
activated && ! grep -qx 'reset-rollback' "$tmp/calls" || fail "apple: a failed commit keeps the factory root active" "$(cat "$tmp/calls")"
|
||||
grep -q 'reset-commit: the fixture refuses' "$tmp/reset.log" || fail "apple: a failed commit is logged" "$(cat "$tmp/reset.log")"
|
||||
[[ $(wc -l <"$tmp/slots") == 2 ]] || fail "apple: a failed commit keeps the throwaway slot"
|
||||
pass "apple: a failed commit after the switch is logged and the reset stands"
|
||||
|
||||
# An unencrypted Mac still has its boot files rebuilt and verified.
|
||||
fixture
|
||||
DEVICE="" run reset apple || fail "apple: an unencrypted reset stages" "$(cat "$tmp/out")"
|
||||
[[ $(grep '^reset-' "$tmp/calls") == "reset-prepare $tmp/top/@omarchy-reset-next"$'\n'"reset-verify $tmp/top/@omarchy-reset-next"$'\n'"reset-commit" ]] ||
|
||||
fail "apple: an unencrypted reset names no LUKS device" "$(cat "$tmp/calls")"
|
||||
! grep -q '^cryptsetup' "$tmp/calls" || fail "apple: an unencrypted reset touches no key slot"
|
||||
pass "apple: an unencrypted reset rebuilds and verifies the boot files without a key"
|
||||
|
||||
# Without the boot package, or with one that lacks a reset operation, the
|
||||
# reset stops before anything is asked or changed, naming the package.
|
||||
fixture
|
||||
mv "$mac_boot" "$tmp/mac-boot.off"
|
||||
if run owner apple; then fail "apple without the boot package: the reset refuses"; fi
|
||||
grep -q 'reset-prepare on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple without the boot package: the package is named" "$(cat "$tmp/out")"
|
||||
mkdir -p "$mac_boot"
|
||||
cp -p "$tmp/mac-boot.off/reset-prepare" "$tmp/mac-boot.off/reset-verify" "$mac_boot/"
|
||||
chmod -R go-w "$lifecycle"
|
||||
run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: a partial boot package on disk is ignored" "$(cat "$tmp/out")"
|
||||
if run owner apple; then fail "apple with part of the boot package: the reset refuses"; fi
|
||||
grep -q 'reset-commit on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple with part of the boot package: the missing operation is named" "$(cat "$tmp/out")"
|
||||
rm -rf "$mac_boot"
|
||||
mv "$tmp/mac-boot.off" "$mac_boot"
|
||||
pass "apple: without the boot package's reset operations, or with only some of them, the reset refuses naming the package"
|
||||
|
||||
# A registration that left the reset operations optional and got only some of
|
||||
# them would mix two boot paths: the reset refuses that too.
|
||||
mkdir -p "$tmp/half"
|
||||
cat >"$tmp/half/omarchy-lifecycle-dispatch" <<SH
|
||||
#!/bin/bash
|
||||
[[ \$* != "--resolve reset-prepare" ]] || echo /usr/lib/omarchy/mac-boot/reset-prepare
|
||||
SH
|
||||
chmod +x "$tmp/half/omarchy-lifecycle-dispatch"
|
||||
fixture
|
||||
if TEST_DISPATCH=$tmp/half/omarchy-lifecycle-dispatch run owner apple; then fail "a partial set of reset operations is refused"; fi
|
||||
grep -q 'implements only some of the factory reset operations' "$tmp/out" || fail "a partial set of reset operations is explained" "$(cat "$tmp/out")"
|
||||
pass "a boot package implementing only some of the reset operations is refused"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
if (( EUID == 0 )); then
|
||||
skip "factory-reset self-elevation requires an unprivileged caller"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/bin" "$tmp/checkout with spaces"
|
||||
|
||||
# Exercise the real startup, but never include the destructive reset body.
|
||||
awk '
|
||||
/^export PATH=/ { found = 1; print "exit 99"; exit }
|
||||
{ print }
|
||||
END { if (!found) exit 1 }
|
||||
' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/checkout with spaces/reset"
|
||||
grep -Fxq 'PACKAGED_PATH=/usr/bin/omarchy-system-factory-reset' "$ROOT/bin/omarchy-system-factory-reset" || fail "factory reset pins the packaged path"
|
||||
sed -i "s|^PACKAGED_PATH=.*|PACKAGED_PATH=\"$tmp/packaged-reset\"|" "$tmp/checkout with spaces/reset"
|
||||
cp "$tmp/checkout with spaces/reset" "$tmp/packaged-reset"
|
||||
chmod +x "$tmp/checkout with spaces/reset" "$tmp/packaged-reset"
|
||||
ln -s "$tmp/checkout with spaces/reset" "$tmp/reset-link"
|
||||
|
||||
cat >"$tmp/bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >"$CALL_LOG"
|
||||
exit "${SUDO_STATUS:-0}"
|
||||
SH
|
||||
chmod +x "$tmp/bin/sudo"
|
||||
|
||||
args=('argument with spaces' '' '--flag' '$(touch should-not-run)' $'two\nlines')
|
||||
expected=(env 'GUM_INPUT_PROMPT=Reset this computer? ' "$tmp/packaged-reset" "${args[@]}")
|
||||
for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/reset' "$tmp/reset-link" "$tmp/packaged-reset" reset; do
|
||||
for status in 0 1 127; do
|
||||
actual_status=0
|
||||
(cd "$tmp" && env -i HOME="$tmp" PATH="$tmp/bin:$tmp/checkout with spaces:/usr/bin:/bin" \
|
||||
OMARCHY_PATH="$tmp/checkout with spaces" PACKAGED_PATH="$tmp/wrong-target" \
|
||||
GUM_INPUT_PROMPT='Reset this computer? ' CALL_LOG="$tmp/call" SUDO_STATUS="$status" \
|
||||
"$invocation" "${args[@]}") || actual_status=$?
|
||||
(( actual_status == status )) || fail "elevation status is preserved for $invocation"
|
||||
mapfile -d '' -t actual <"$tmp/call"
|
||||
(( ${#actual[@]} == ${#expected[@]} )) || fail "elevation argument count is preserved"
|
||||
for i in "${!expected[@]}"; do
|
||||
[[ ${actual[i]} == "${expected[i]}" ]] || fail "elevation argument $i is preserved for $invocation"
|
||||
done
|
||||
[[ ! -e $tmp/should-not-run ]] || fail "caller arguments were executed"
|
||||
done
|
||||
pass "factory reset pins $invocation to the packaged command and preserves arguments, styling and status"
|
||||
done
|
||||
|
||||
if unshare -Ur true >/dev/null 2>&1; then
|
||||
status=0
|
||||
unshare -Ur "$tmp/packaged-reset" || status=$?
|
||||
(( status == 99 )) || fail "matching root invocation did not reach the guarded body"
|
||||
pass "matching packaged root invocation reaches the reset body"
|
||||
else
|
||||
skip "user namespaces unavailable; root invocation coverage"
|
||||
fi
|
||||
|
||||
# A checkout must not hand off to older installed code, even with explicit sudo.
|
||||
printf '\n# older packaged copy\n' >>"$tmp/packaged-reset"
|
||||
rm -f "$tmp/call"
|
||||
if CALL_LOG="$tmp/call" PATH="$tmp/bin:/usr/bin:/bin" "$tmp/reset-link" >"$tmp/mismatch" 2>&1; then
|
||||
fail "mismatched reset implementation was accepted"
|
||||
fi
|
||||
[[ ! -e $tmp/call ]] || fail "mismatched reset requested elevation"
|
||||
grep -Fq 'Install the matching Omarchy package' "$tmp/mismatch" || fail "mismatch explains recovery"
|
||||
if unshare -Ur true >/dev/null 2>&1; then
|
||||
status=0
|
||||
unshare -Ur "$tmp/reset-link" >"$tmp/root-mismatch" 2>&1 || status=$?
|
||||
(( status == 1 )) || fail "root invocation did not refuse mismatch before the reset body"
|
||||
pass "root checkout invocation refuses a mismatched packaged reset"
|
||||
fi
|
||||
pass "mismatched checkout refuses before elevation"
|
||||
@@ -33,3 +33,38 @@ if grep -F 'skip-first-run-update-notification' "$ROOT/install/user/first-run/wi
|
||||
fi
|
||||
|
||||
pass "first-run uses one lifecycle completion marker"
|
||||
|
||||
# systemctl enables none of a list when one unit in it is unknown, so a unit
|
||||
# missing from a build must cost first-run only that unit.
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $* != *"$OMARCHY_TEST_MISSING_UNIT"* ]] || exit 1
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_CALLS"
|
||||
SH
|
||||
cat >"$mock_bin/omarchy-hook-install" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "hook $*" >>"$OMARCHY_TEST_CALLS"
|
||||
SH
|
||||
chmod +x "$mock_bin/systemctl" "$mock_bin/omarchy-hook-install"
|
||||
|
||||
enable_user_units() {
|
||||
rm -f "$test_tmp/calls"
|
||||
PATH="$mock_bin:$PATH" OMARCHY_TEST_CALLS="$test_tmp/calls" OMARCHY_TEST_MISSING_UNIT="$1" \
|
||||
bash "$ROOT/install/user/first-run/enable-user-units.sh"
|
||||
}
|
||||
|
||||
enable_user_units no-such-unit || fail "first-run enables its user units"
|
||||
clean_run=$(<"$test_tmp/calls")
|
||||
grep -Fq 'hook theme-set' <<<"$clean_run" || fail "first-run installs the theme hook"
|
||||
mapfile -t units < <(sed -n 's/^--user enable --now //p' <<<"$clean_run")
|
||||
|
||||
for unit in "${units[@]}"; do
|
||||
[[ $unit != *" "* ]] || fail "first-run enables each unit on its own" "$unit"
|
||||
if enable_user_units "$unit"; then
|
||||
fail "first-run reports a unit it could not enable" "$unit"
|
||||
fi
|
||||
[[ $(<"$test_tmp/calls") == "$(grep -Fvx -- "--user enable --now $unit" <<<"$clean_run")" ]] ||
|
||||
fail "a unit missing from the build costs first-run only that unit" "$unit"
|
||||
done
|
||||
(( ${#units[@]} > 1 )) || fail "first-run enables each unit on its own"
|
||||
pass "a unit missing from the build costs first-run only that unit"
|
||||
@@ -41,12 +41,23 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: remoteSessionService
|
||||
property bool active: false
|
||||
property var peers: []
|
||||
property int refreshes: 0
|
||||
function refresh() {
|
||||
refreshes++
|
||||
}
|
||||
}
|
||||
|
||||
QtObject {
|
||||
id: mockShell
|
||||
function firstPartyServiceFor(id) {
|
||||
if (id === "omarchy.notifications") return notificationService
|
||||
if (id === "omarchy.idle") return idleService
|
||||
if (id === "omarchy.nightlight") return nightlightService
|
||||
if (id === "omarchy.remote-session") return remoteSessionService
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -74,6 +85,7 @@ ShellRoot {
|
||||
QtObject {
|
||||
id: indicatorHost
|
||||
property bool revealInactiveIndicators: true
|
||||
signal refreshRequested()
|
||||
}
|
||||
|
||||
function createIndicator(name) {
|
||||
@@ -198,6 +210,19 @@ ShellRoot {
|
||||
root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording")
|
||||
}
|
||||
|
||||
var remoteSession = root.createIndicator("RemoteSession")
|
||||
if (remoteSession) {
|
||||
remoteSession.moduleName = "RemoteSession"
|
||||
root.injectBar(remoteSession)
|
||||
root.assertTrue(remoteSession.active === false, "Remote Session starts inactive without a gliff server")
|
||||
remoteSessionService.active = true
|
||||
remoteSessionService.peers = ["10.0.0.5"]
|
||||
root.assertTrue(remoteSession.active === true, "Remote Session follows the service state")
|
||||
root.assertTrue(remoteSession.tooltipText === "Remote session from 10.0.0.5", "Remote Session tooltip names the connected peer")
|
||||
indicatorHost.refreshRequested()
|
||||
root.assertTrue(remoteSessionService.refreshes === 1, "Remote Session refresh re-probes the service")
|
||||
}
|
||||
|
||||
var stayAwake = root.createIndicator("StayAwake")
|
||||
if (stayAwake) {
|
||||
stayAwake.moduleName = "StayAwake"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by the Omarchy Apple Silicon image builder.
|
||||
_omarchy_asahi_hooks=()
|
||||
_omarchy_asahi_added=false
|
||||
for _omarchy_asahi_hook in "${HOOKS[@]}"; do
|
||||
if [[ $_omarchy_asahi_hook == asahi ]]; then
|
||||
_omarchy_asahi_added=true
|
||||
fi
|
||||
if [[ $_omarchy_asahi_hook == filesystems && $_omarchy_asahi_added == false ]]; then
|
||||
_omarchy_asahi_hooks+=(asahi omarchy-vendorfw)
|
||||
_omarchy_asahi_added=true
|
||||
fi
|
||||
_omarchy_asahi_hooks+=("$_omarchy_asahi_hook")
|
||||
done
|
||||
if [[ $_omarchy_asahi_added == false ]]; then
|
||||
_omarchy_asahi_hooks+=(asahi omarchy-vendorfw)
|
||||
fi
|
||||
HOOKS=("${_omarchy_asahi_hooks[@]}")
|
||||
unset _omarchy_asahi_hooks _omarchy_asahi_hook _omarchy_asahi_added
|
||||
@@ -0,0 +1,90 @@
|
||||
# Insert omarchy-mac-encrypt after vendorfw/block and sd-encrypt immediately
|
||||
# before filesystems, each only if that hook is absent. 90-omarchy-mac.conf
|
||||
# already put asahi and omarchy-vendorfw before filesystems; this drop-in is
|
||||
# sourced after it.
|
||||
#
|
||||
# Both are systemd initrd units: the systemd hook replaces udev (mkinitcpio's
|
||||
# stock HOOKS line) and keymap/consolefont become sd-vconsole. A HOOKS line
|
||||
# carrying the busybox encrypt hook belongs to a Mac unlocked by cryptdevice=,
|
||||
# which sd-encrypt cannot parse: that line is left exactly as it is.
|
||||
_omarchy_mac_encrypt_hooks=()
|
||||
_omarchy_mac_encrypt_have_systemd=false
|
||||
_omarchy_mac_encrypt_have_vconsole=false
|
||||
_omarchy_mac_encrypt_have_encrypt=false
|
||||
for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do
|
||||
case $_omarchy_mac_encrypt_hook in
|
||||
systemd) _omarchy_mac_encrypt_have_systemd=true ;;
|
||||
sd-vconsole) _omarchy_mac_encrypt_have_vconsole=true ;;
|
||||
encrypt) _omarchy_mac_encrypt_have_encrypt=true ;;
|
||||
esac
|
||||
done
|
||||
if [[ $_omarchy_mac_encrypt_have_encrypt == false ]]; then
|
||||
for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do
|
||||
case $_omarchy_mac_encrypt_hook in
|
||||
udev)
|
||||
if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(systemd)
|
||||
_omarchy_mac_encrypt_have_systemd=true
|
||||
fi
|
||||
;;
|
||||
keymap|consolefont)
|
||||
if [[ $_omarchy_mac_encrypt_have_vconsole == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(sd-vconsole)
|
||||
_omarchy_mac_encrypt_have_vconsole=true
|
||||
fi
|
||||
;;
|
||||
*) _omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook") ;;
|
||||
esac
|
||||
done
|
||||
if [[ $_omarchy_mac_encrypt_have_systemd == false ]]; then
|
||||
if [[ ${_omarchy_mac_encrypt_hooks[0]:-} == base ]]; then
|
||||
_omarchy_mac_encrypt_hooks=(base systemd "${_omarchy_mac_encrypt_hooks[@]:1}")
|
||||
else
|
||||
_omarchy_mac_encrypt_hooks=(systemd "${_omarchy_mac_encrypt_hooks[@]}")
|
||||
fi
|
||||
fi
|
||||
HOOKS=("${_omarchy_mac_encrypt_hooks[@]}")
|
||||
_omarchy_mac_encrypt_hooks=()
|
||||
_omarchy_mac_encrypt_have_ours=false
|
||||
_omarchy_mac_encrypt_have_sd=false
|
||||
_omarchy_mac_encrypt_added_ours=false
|
||||
_omarchy_mac_encrypt_added_sd=false
|
||||
for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do
|
||||
if [[ $_omarchy_mac_encrypt_hook == omarchy-mac-encrypt ]]; then
|
||||
_omarchy_mac_encrypt_have_ours=true
|
||||
fi
|
||||
if [[ $_omarchy_mac_encrypt_hook == sd-encrypt ]]; then
|
||||
_omarchy_mac_encrypt_have_sd=true
|
||||
fi
|
||||
done
|
||||
for _omarchy_mac_encrypt_hook in "${HOOKS[@]}"; do
|
||||
if [[ $_omarchy_mac_encrypt_hook == sd-encrypt && $_omarchy_mac_encrypt_have_ours == false &&
|
||||
$_omarchy_mac_encrypt_added_ours == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt)
|
||||
_omarchy_mac_encrypt_added_ours=true
|
||||
fi
|
||||
if [[ $_omarchy_mac_encrypt_hook == filesystems ]]; then
|
||||
if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt)
|
||||
_omarchy_mac_encrypt_added_ours=true
|
||||
fi
|
||||
if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(sd-encrypt)
|
||||
_omarchy_mac_encrypt_added_sd=true
|
||||
fi
|
||||
fi
|
||||
_omarchy_mac_encrypt_hooks+=("$_omarchy_mac_encrypt_hook")
|
||||
done
|
||||
if [[ $_omarchy_mac_encrypt_have_ours == false && $_omarchy_mac_encrypt_added_ours == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(omarchy-mac-encrypt)
|
||||
fi
|
||||
if [[ $_omarchy_mac_encrypt_have_sd == false && $_omarchy_mac_encrypt_added_sd == false ]]; then
|
||||
_omarchy_mac_encrypt_hooks+=(sd-encrypt)
|
||||
fi
|
||||
HOOKS=("${_omarchy_mac_encrypt_hooks[@]}")
|
||||
fi
|
||||
unset _omarchy_mac_encrypt_hooks _omarchy_mac_encrypt_hook \
|
||||
_omarchy_mac_encrypt_have_ours _omarchy_mac_encrypt_have_sd \
|
||||
_omarchy_mac_encrypt_added_ours _omarchy_mac_encrypt_added_sd \
|
||||
_omarchy_mac_encrypt_have_systemd _omarchy_mac_encrypt_have_vconsole \
|
||||
_omarchy_mac_encrypt_have_encrypt
|
||||
@@ -0,0 +1,31 @@
|
||||
# Origin: omarchy-mx-mac install/hardware/apple/fix-asahi-hid-race.sh
|
||||
# Apple Silicon internal keyboard and trackpad at the sd-encrypt passphrase
|
||||
# prompt. dockchannel-hid creates the HID devices; hid_apple binds the
|
||||
# keyboard and hid_magicmouse the SPI trackpad. MTP machines (M2 Air, M2 Max
|
||||
# j416c) use dockchannel-hid — linux-aurora has no apple-mtp module. SPI HID
|
||||
# (M1 Air, M1 Pro j314s) is spi-hid-apple-of, built-in on aurora. usbhid is
|
||||
# an external USB keyboard at the prompt. thunderbolt (CONFIG_USB4=m) and
|
||||
# thunderbolt_apple, the Apple Silicon USB4 host router (CONFIG_USB4_APPLE_SOC=m
|
||||
# on linux-aurora), bring up the USB4/Thunderbolt tunnels, so a keyboard behind
|
||||
# a USB-C or Thunderbolt dock types at the prompt too (omarchy-mx-mac#86).
|
||||
#
|
||||
# mkinitcpio fails the whole image over a MODULES entry it cannot find, or a
|
||||
# built-in it reports as "(builtin)". Each name is added only when modinfo
|
||||
# returns a real path. Ending on unset keeps the exit status zero.
|
||||
|
||||
for _omarchy_mac_hid_module in \
|
||||
hid_apple hid_magicmouse dockchannel-hid usbhid \
|
||||
spi-apple spi-hid-apple spi-hid-apple-of \
|
||||
apple-dockchannel apple-rtkit-helper thunderbolt thunderbolt_apple; do
|
||||
_omarchy_mac_hid_path=$(modinfo -k "${KERNELVERSION:-$(uname -r)}" -F filename \
|
||||
"$_omarchy_mac_hid_module" 2>/dev/null) || continue
|
||||
[[ $_omarchy_mac_hid_path == /* ]] || continue
|
||||
MODULES+=("$_omarchy_mac_hid_module")
|
||||
done
|
||||
unset _omarchy_mac_hid_module _omarchy_mac_hid_path
|
||||
|
||||
# MTP trackpad firmware (apple/tpmtfw-<board>.bin) is not a MODULES entry.
|
||||
# omarchy-vendorfw-initrd.service unpacks ESP vendorfw/firmware.cpio onto
|
||||
# /lib/firmware/vendor (symlink to /vendorfw) before cryptsetup-pre.target.
|
||||
# Do not FILES+= under /lib/firmware/vendor, and do not pre-create
|
||||
# /vendorfw/apple in the image: that skipped ESP extraction.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Plymouth draws the disk password prompt and the boot splash, as on x86
|
||||
# Omarchy. It goes right after systemd so its initrd units order correctly,
|
||||
# only when the hook is installed, and never twice.
|
||||
if [[ -f /usr/lib/initcpio/install/plymouth && " ${HOOKS[*]} " != *" plymouth "* ]]; then
|
||||
_omarchy_mac_plymouth_hooks=()
|
||||
_omarchy_mac_plymouth_added=false
|
||||
for _omarchy_mac_plymouth_hook in "${HOOKS[@]}"; do
|
||||
_omarchy_mac_plymouth_hooks+=("$_omarchy_mac_plymouth_hook")
|
||||
if [[ $_omarchy_mac_plymouth_hook == systemd && $_omarchy_mac_plymouth_added == false ]]; then
|
||||
_omarchy_mac_plymouth_hooks+=(plymouth)
|
||||
_omarchy_mac_plymouth_added=true
|
||||
fi
|
||||
done
|
||||
if [[ $_omarchy_mac_plymouth_added == true ]]; then
|
||||
HOOKS=("${_omarchy_mac_plymouth_hooks[@]}")
|
||||
fi
|
||||
unset _omarchy_mac_plymouth_hooks _omarchy_mac_plymouth_added _omarchy_mac_plymouth_hook
|
||||
fi
|
||||
@@ -0,0 +1,46 @@
|
||||
# The disk passphrase prompt types with the owner's keyboard layout, as on
|
||||
# x86 Omarchy: sd-vconsole loads KEYMAP on the console (systemd-ask-password)
|
||||
# and /etc/vconsole.conf gives Plymouth its XKBLAYOUT. The aarch64
|
||||
# omarchy-settings drops upstream's omarchy_hooks.conf, so this drop-in
|
||||
# carries its guard: a layout that does not type Latin letters stays out of
|
||||
# the initramfs, because a Latin passphrase would be untypeable in it
|
||||
# (upstream #6229). The prompt then uses the kernel's US map, which is what
|
||||
# such a passphrase was typed with.
|
||||
#
|
||||
# A systemd HOOKS line gets sd-vconsole exactly once, after keyboard (or
|
||||
# after systemd without one); keymap and consolefont are its busybox
|
||||
# counterparts and never belong on such a line. A busybox line (a Mac
|
||||
# unlocked by cryptdevice=, which 91 leaves alone) keeps its hooks; it only
|
||||
# gets the file for Plymouth, as upstream does.
|
||||
# No vconsole.conf is the kernel's US map: sd-vconsole stays, nothing to bundle.
|
||||
_omarchy_mac_vconsole_latin=true
|
||||
if [[ -f /etc/vconsole.conf ]]; then
|
||||
_omarchy_mac_vconsole_layout=$(unset XKBLAYOUT; . /etc/vconsole.conf 2>/dev/null; printf '%s' "${XKBLAYOUT:-}")
|
||||
case ${_omarchy_mac_vconsole_layout%%,*} in
|
||||
af | am | ara | bd | bg | by | et | ge | gr | il | in | iq | ir | kg | kh | kz | la | lk | mk | mm | mn | mv | np | rs | ru | sy | th | tj | ua)
|
||||
_omarchy_mac_vconsole_latin=false ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ " ${HOOKS[*]} " == *" systemd "* ]]; then
|
||||
_omarchy_mac_vconsole_hooks=()
|
||||
_omarchy_mac_vconsole_anchor=systemd
|
||||
[[ " ${HOOKS[*]} " != *" keyboard "* ]] || _omarchy_mac_vconsole_anchor=keyboard
|
||||
for _omarchy_mac_vconsole_hook in "${HOOKS[@]}"; do
|
||||
case $_omarchy_mac_vconsole_hook in
|
||||
sd-vconsole | keymap | consolefont) continue ;;
|
||||
esac
|
||||
_omarchy_mac_vconsole_hooks+=("$_omarchy_mac_vconsole_hook")
|
||||
if [[ $_omarchy_mac_vconsole_hook == "$_omarchy_mac_vconsole_anchor" && $_omarchy_mac_vconsole_latin == true ]]; then
|
||||
_omarchy_mac_vconsole_hooks+=(sd-vconsole)
|
||||
_omarchy_mac_vconsole_anchor=
|
||||
fi
|
||||
done
|
||||
HOOKS=("${_omarchy_mac_vconsole_hooks[@]}")
|
||||
fi
|
||||
|
||||
if [[ $_omarchy_mac_vconsole_latin == true && -f /etc/vconsole.conf ]]; then
|
||||
FILES+=(/etc/vconsole.conf)
|
||||
fi
|
||||
unset _omarchy_mac_vconsole_latin _omarchy_mac_vconsole_layout _omarchy_mac_vconsole_hooks \
|
||||
_omarchy_mac_vconsole_anchor _omarchy_mac_vconsole_hook
|
||||
@@ -0,0 +1,84 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
// Drives the real remote-session service against a stub gliff-server on PATH:
|
||||
// idle at start, active with the ssh peer once a server runs, idle again after
|
||||
// it exits. The service is never refreshed by hand: a one-shot grim capture
|
||||
// raises the same Hyprland screencast event that gliff-server does, so the
|
||||
// event path is what gets exercised.
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT")
|
||||
property string stubPidFile: Quickshell.env("OMARCHY_QML_TEST_STUB_PID")
|
||||
property var failures: []
|
||||
property var service: null
|
||||
|
||||
function fail(message) {
|
||||
failures.push(String(message))
|
||||
}
|
||||
|
||||
function assertTrue(condition, message) {
|
||||
if (!condition) fail(message)
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
||||
}
|
||||
|
||||
function writeResult() {
|
||||
var payload = JSON.stringify({ ok: failures.length === 0, failures: failures })
|
||||
Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)])
|
||||
}
|
||||
|
||||
function captureFrame() {
|
||||
Quickshell.execDetached(["grim", "-g", "0,0 1x1", Quickshell.env("OMARCHY_QML_TEST_FRAME")])
|
||||
}
|
||||
|
||||
// Polls `condition` every 100 ms for up to `timeout` ms, then continues with
|
||||
// `next` either way, recording `message` as a failure on timeout. `tick`,
|
||||
// when given, runs every 500 ms while waiting.
|
||||
function waitFor(condition, timeout, message, next, tick) {
|
||||
var timer = Qt.createQmlObject("import QtQuick; Timer { repeat: true; interval: 100 }", root)
|
||||
var waited = 0
|
||||
timer.triggered.connect(function() {
|
||||
waited += timer.interval
|
||||
if (tick && waited % 500 === 0) tick()
|
||||
if (!condition() && waited < timeout) return
|
||||
timer.stop()
|
||||
timer.destroy()
|
||||
root.assertTrue(condition(), message)
|
||||
next()
|
||||
})
|
||||
timer.start()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/Service.qml")
|
||||
if (component.status !== Component.Ready) {
|
||||
fail("remote session service failed to load: " + component.errorString())
|
||||
writeResult()
|
||||
return
|
||||
}
|
||||
service = component.createObject(root, { shell: null })
|
||||
if (!service) {
|
||||
fail("remote session service failed to instantiate: " + component.errorString())
|
||||
writeResult()
|
||||
return
|
||||
}
|
||||
|
||||
waitFor(function() { return service.stateLoaded === true }, 3000, "service probes on startup", function() {
|
||||
root.assertTrue(service.active === false, "service starts idle without a gliff server")
|
||||
Quickshell.execDetached(["bash", "-c", "SSH_CONNECTION='10.0.0.5 51234 10.0.0.1 22' gliff-server --stdio & echo $! > " + root.shellQuote(root.stubPidFile)])
|
||||
// Each capture raises a screencast event; the first one after the stub
|
||||
// is up is what should flip the service, with no refresh() by hand.
|
||||
waitFor(function() { return service.active === true }, 4000, "a screencast event makes the service report the session while gliff-server runs", function() {
|
||||
root.assertTrue(JSON.stringify(service.peers) === JSON.stringify(["10.0.0.5"]), "service reports the ssh peer, got " + JSON.stringify(service.peers))
|
||||
Quickshell.execDetached(["bash", "-c", "kill \"$(cat " + root.shellQuote(root.stubPidFile) + ")\""])
|
||||
waitFor(function() { return service.active === false }, 5000, "service returns to idle once gliff-server exits", function() {
|
||||
root.writeResult()
|
||||
})
|
||||
}, root.captureFrame)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,17 @@ PY
|
||||
copy_boundary_file bin/omarchy-security-functions
|
||||
copy_boundary_file bin/omarchy-update-pacman
|
||||
copy_boundary_file default/omarchy/sudo-no-update/sudo
|
||||
copy_boundary_file install/helpers/pacman.sh
|
||||
# Channel changes copy the platform's templates; the copy itself is a stand-in.
|
||||
cp -r "$ROOT/default/pacman" "$SUDO_TEST_ROOT/default/"
|
||||
|
||||
# Channel changes ask for the platform. x86 unless a test says otherwise, so no
|
||||
# test reads the host's hardware.
|
||||
cat >"$SUDO_TEST_ROOT/bin/omarchy-hw-platform" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "${SUDO_TEST_PLATFORM:-x86}"
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/omarchy-hw-platform"
|
||||
|
||||
cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
@@ -132,7 +143,7 @@ case "$step" in
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$SUDO_TEST_ROOT/bin/test-step"
|
||||
for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do
|
||||
for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-boot omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do
|
||||
ln -s test-step "$SUDO_TEST_ROOT/bin/$step"
|
||||
done
|
||||
ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman"
|
||||
@@ -140,7 +151,7 @@ ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman"
|
||||
reset_boundary() {
|
||||
: >"$SUDO_TEST_LOG"
|
||||
/usr/bin/rm -f "$SUDO_TEST_CACHE"
|
||||
unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP
|
||||
unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP SUDO_TEST_PLATFORM
|
||||
}
|
||||
assert_boundary_cold() {
|
||||
[[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Runs against the private real boltd created by bolt-integration.py. No policy,
|
||||
# busctl, firmware reads, or writes are mocked here.
|
||||
set -euo pipefail
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
TB_STATE="$TB_TEST_DIR/policy.json"
|
||||
TB_RUNTIME="$TB_TEST_DIR/runtime"
|
||||
mkdir -p "$TB_RUNTIME"
|
||||
check() { jq -e "$1" "$2" >/dev/null; }
|
||||
reject() { if "$@"; then echo 'Unexpected approval success' >&2; exit 1; fi; }
|
||||
case "$1" in
|
||||
approve)
|
||||
echo '{"version":1,"enabled":true,"trusted":{}}' | tb_json_write "$TB_STATE"
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
tb_snapshot > "$TB_TEST_DIR/before.json"
|
||||
capture=$(tb_capture)
|
||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" --argjson capture "$capture" \
|
||||
'.devices[] | select(.Uid==$uid) | $capture[$uid]=={Uid,Name,Vendor}' >/dev/null
|
||||
check 'all(.devices[]; .status=="connected" and (.stored|not))' "$TB_TEST_DIR/before.json"
|
||||
id=$(jq -c --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .identity' "$TB_TEST_DIR/before.json")
|
||||
printf '%s\n' "$id" > "$TB_TEST_DIR/identity.json"
|
||||
tb_approve "$id" true true > "$TB_TEST_DIR/after.json"
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized" and .policy=="manual"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
check 'all(.domains[0].BootACL[]; .=="")' "$TB_TEST_DIR/after.json"
|
||||
inventory=$(tb_inventory)
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.Uid==$uid) | .Stored and .Key=="have"' <<< "$inventory" >/dev/null
|
||||
[[ -s $TB_TEST_KEYS/$TB_TEST_UID ]]
|
||||
sha256sum "$TB_TEST_KEYS/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
device=$(jq -c --arg uid "$TB_TEST_UID" '.stored[] | select(.Uid==$uid)' <<< "$inventory")
|
||||
domain=$(jq -c '.domains[0]' <<< "$inventory")
|
||||
device_path=$(jq -r .path <<< "$device"); domain_path=$(jq -r .path <<< "$domain")
|
||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"auto"'
|
||||
acl=$(jq -c --arg uid "$TB_TEST_UID" '.BootACL | map("") | .[0]=$uid' <<< "$domain")
|
||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$acl" as
|
||||
tb_boot_change true
|
||||
check .boot_protection "$TB_STATE"
|
||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
||||
check 'all(.domains[0].BootACL[]; .=="") and .stored[0].Policy=="manual"' "$TB_TEST_DIR/inventory.json"
|
||||
tb_boot_change false
|
||||
check '.boot_protection==false' "$TB_STATE"
|
||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
||||
jq -e --argjson acl "$acl" '.domains[0].BootACL==$acl and .stored[0].Policy=="auto"' "$TB_TEST_DIR/inventory.json" >/dev/null
|
||||
jq --arg other "$TB_TEST_OTHER" 'del(.trusted[$other])' "$TB_STATE" | tb_json_write "$TB_STATE"
|
||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"manual"'
|
||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$(jq -c 'map("")' <<< "$acl")" as
|
||||
;;
|
||||
restart)
|
||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
||||
reject tb_approve "$(cat "$TB_TEST_DIR/identity.json")" true
|
||||
tb_reconcile > "$TB_TEST_DIR/after.json"
|
||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
||||
tb_inventory | jq -e '.manager.AuthMode=="disabled"' >/dev/null
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
# Bolt must challenge with the originally saved key, not provision a new
|
||||
# key merely because a reconnecting device advertises the same identity.
|
||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" \
|
||||
'.devices[] | select(.Uid==$uid) | .Key=="have" and (.AuthFlags | contains("secure"))' >/dev/null
|
||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
;;
|
||||
daemon)
|
||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_LOCK="$TB_TEST_DIR/root.lock"
|
||||
touch "$TB_MARKER"
|
||||
tb_daemon
|
||||
;;
|
||||
status)
|
||||
snapshot=$(tb_public_snapshot)
|
||||
jq -e --arg owner "$(tb_owner)" '.owner==$owner' <<< "$snapshot" >/dev/null
|
||||
printf '%s\n' "$snapshot"
|
||||
;;
|
||||
once)
|
||||
snapshot=$(tb_public_snapshot)
|
||||
id=$(jq -c --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .identity' <<< "$snapshot")
|
||||
exec 9> "$TB_TEST_DIR/root.lock"
|
||||
flock -x 9
|
||||
tb_approve "$id" false > "$TB_TEST_DIR/once.json"
|
||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="authorized" and (.trusted|not)' "$TB_TEST_DIR/once.json" >/dev/null
|
||||
;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Only the hardware fixture is Python: upstream Bolt uses UMockdev/GObject.
|
||||
|
||||
All Omarchy behavior and assertions run in bolt-exercise.sh against real boltd,
|
||||
real busctl, a private system bus, and disposable sysfs/firmware/store state.
|
||||
"""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
root = Path(sys.argv.pop(1))
|
||||
loader = importlib.machinery.SourceFileLoader('bolt_upstream', str(Path(os.environ['BOLT_TEST_SOURCE']) / 'tests/test-integration'))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
u = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(u)
|
||||
|
||||
|
||||
class ApprovalIntegration(u.BoltTest):
|
||||
def exercise(self, security, numeric=False):
|
||||
self.user_config(AuthMode='disabled', DefaultPolicy='manual')
|
||||
_, host = self.add_domain_host(security=security, iommu='1')
|
||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
||||
_, other = self.add_device(host, 2, 'Unknown', 'Example', authorized=0, key=None, boot='0')
|
||||
if numeric:
|
||||
# Linux omits these files when the peripheral has no descriptive names.
|
||||
for attribute in ('device_name', 'vendor_name'):
|
||||
Path(self.testbed.get_root_dir(), path.lstrip('/'), attribute).unlink()
|
||||
self.daemon_start()
|
||||
self.polkitd_start()
|
||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
||||
'TB_TEST_KEYS': str(Path(self.dbpath, 'keys')), 'TB_TEST_SECURITY': security}
|
||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh')]
|
||||
subprocess.run(command + ['approve'], env=env, check=True)
|
||||
self.daemon_stop()
|
||||
self.testbed.set_attribute(path, 'authorized', '0')
|
||||
if security == 'secure':
|
||||
self.testbed.set_attribute(path, 'key', '\n')
|
||||
self.daemon_start()
|
||||
subprocess.run(command + ['restart'], env=env, check=True)
|
||||
# The real Bash daemon publishes a fresh readable snapshot and reconnects
|
||||
# to Bolt after a restart. No custom Omarchy D-Bus server is involved.
|
||||
child = subprocess.Popen(command + ['daemon'], env=env)
|
||||
try:
|
||||
for _ in range(100):
|
||||
result = subprocess.run(command + ['status'], env=env, capture_output=True)
|
||||
if result.returncode == 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError('Bash daemon did not publish a valid snapshot')
|
||||
subprocess.run(command + ['once'], env=env, check=True)
|
||||
self.daemon_stop()
|
||||
self.daemon_start()
|
||||
for _ in range(100):
|
||||
if subprocess.run(command + ['status'], env=env, capture_output=True).returncode == 0:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError('Bash daemon did not publish the new Bolt generation')
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=15)
|
||||
self.daemon_stop()
|
||||
|
||||
def test_user_approval(self):
|
||||
self.exercise('user')
|
||||
|
||||
def test_secure_approval(self):
|
||||
self.exercise('secure')
|
||||
|
||||
def test_numeric_identity(self):
|
||||
self.exercise('user', numeric=True)
|
||||
|
||||
def removal(self, security, boot=False, disconnected=False):
|
||||
self.user_config(AuthMode='disabled')
|
||||
_, host = self.add_domain_host(security=security, iommu='1')
|
||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
||||
other_path, other = self.add_device(host, 2, 'Existing', 'Example', authorized=0, key=None, boot='0')
|
||||
self.daemon_start()
|
||||
self.polkitd_start()
|
||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
||||
'TB_TEST_STORE': self.dbpath, 'TB_TEST_SECURITY': security}
|
||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-removal.sh')]
|
||||
service = 'omarchy-thunderbolt-authorization.service'
|
||||
child = None
|
||||
|
||||
def operation(action, unit):
|
||||
nonlocal child
|
||||
if unit == 'bolt.service':
|
||||
if action == 'stop':
|
||||
self.daemon_stop()
|
||||
elif self.daemon is None:
|
||||
self.daemon_start()
|
||||
elif unit == service:
|
||||
if action == 'stop' and child is not None:
|
||||
child.terminate()
|
||||
child.wait(timeout=15)
|
||||
child = None
|
||||
elif action == 'start' and child is None:
|
||||
child = subprocess.Popen(['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh'), 'daemon'], env=env)
|
||||
else:
|
||||
raise RuntimeError('Unexpected private service: ' + unit)
|
||||
marker = Path(self.rundir, unit + '.active')
|
||||
if action == 'start':
|
||||
marker.touch()
|
||||
else:
|
||||
marker.unlink(missing_ok=True)
|
||||
|
||||
def run(action):
|
||||
process = subprocess.Popen(command + [action], env=env)
|
||||
control = Path(self.rundir, 'control')
|
||||
deadline = time.monotonic() + 90
|
||||
try:
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Private setup timed out: ' + action)
|
||||
if control.exists():
|
||||
operation(*control.read_text().strip().split())
|
||||
control.unlink()
|
||||
time.sleep(0.05)
|
||||
if process.returncode:
|
||||
raise subprocess.CalledProcessError(process.returncode, command + [action])
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=15)
|
||||
|
||||
def wait_for(action):
|
||||
for _ in range(50):
|
||||
result = subprocess.run(command + [action], env=env, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(result.stdout, end='', flush=True)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(action + ': ' + result.stdout + result.stderr)
|
||||
|
||||
def unplug():
|
||||
for device_path in (path, other_path):
|
||||
self.testbed.uevent(device_path, 'remove')
|
||||
self.testbed.remove_device(device_path)
|
||||
wait_for('disconnected')
|
||||
|
||||
try:
|
||||
Path(self.rundir, 'bolt.service.active').touch()
|
||||
run('approve')
|
||||
operation('start', service)
|
||||
Path(self.rundir, service + '.enabled').touch()
|
||||
if boot:
|
||||
run('boot-enable')
|
||||
run('rollback')
|
||||
if disconnected:
|
||||
unplug()
|
||||
run('disable')
|
||||
if not disconnected:
|
||||
unplug()
|
||||
for number, device_uid, name in ((1, uid, 'Dock'), (2, other, 'Existing')):
|
||||
attributes = ['device_name', name, 'device', '0x23', 'vendor_name', 'Example',
|
||||
'vendor', '0x23', 'authorized', '0', 'unique_id', device_uid, 'boot', '0']
|
||||
if number == 1 and security == 'secure':
|
||||
attributes += ['key', '\n']
|
||||
self.testbed.add_device('thunderbolt', '0-' + str(number), host, attributes, ['DEVTYPE', 'thunderbolt_device'])
|
||||
wait_for('reconnected')
|
||||
finally:
|
||||
operation('stop', service)
|
||||
self.daemon_stop()
|
||||
|
||||
def test_user_removal(self):
|
||||
self.removal('user')
|
||||
|
||||
def test_secure_removal(self):
|
||||
self.removal('secure', boot=True, disconnected=True)
|
||||
|
||||
|
||||
names = sys.argv[1:] or ('test_user_approval', 'test_secure_approval', 'test_numeric_identity', 'test_user_removal', 'test_secure_removal')
|
||||
suite = unittest.TestSuite(ApprovalIntegration(name) for name in names)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
sys.exit(not result.wasSuccessful())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Real Bolt policy/approval/setup calls. Only systemd is bridged to the private
|
||||
# daemon processes owned by bolt-integration.py; never contact host systemd.
|
||||
set -euo pipefail
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
TB_STATE="$TB_TEST_DIR/policy.json" TB_CONFIG="$TB_TEST_STORE/boltd.conf"
|
||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_PENDING="$TB_TEST_DIR/pending"
|
||||
TB_RUNTIME="$TB_TEST_DIR/runtime" TB_LOCK="$TB_TEST_DIR/root.lock"
|
||||
mkdir -p "$TB_RUNTIME"
|
||||
|
||||
service_request() {
|
||||
printf '%s\n' "$*" > "$TB_TEST_DIR/control.tmp"
|
||||
mv "$TB_TEST_DIR/control.tmp" "$TB_TEST_DIR/control"
|
||||
for (( attempt=0; attempt<300; attempt++ )); do
|
||||
[[ -f $TB_TEST_DIR/control ]] || return 0
|
||||
sleep 0.05
|
||||
done
|
||||
echo "Private service operation timed out: $*" >&2
|
||||
return 1
|
||||
}
|
||||
systemctl() {
|
||||
local action=$1 unit
|
||||
shift
|
||||
printf '%s\n' "$action $*" >> "$TB_TEST_DIR/services.log"
|
||||
case "$action" in
|
||||
show)
|
||||
unit=${!#}
|
||||
if [[ -f $TB_TEST_DIR/$unit.active ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi
|
||||
if [[ -f $TB_TEST_DIR/$unit.enabled ]]; then echo UnitFileState=enabled; else echo UnitFileState=disabled; fi ;;
|
||||
start|stop)
|
||||
for unit in "$@"; do service_request "$action" "$unit" || return 1; done ;;
|
||||
enable)
|
||||
unit=${!#}
|
||||
touch "$TB_TEST_DIR/$unit.enabled"
|
||||
if [[ $1 == "--now" ]]; then service_request start "$unit"; fi ;;
|
||||
disable)
|
||||
if [[ -f $TB_TEST_DIR/fail-disable ]]; then rm "$TB_TEST_DIR/fail-disable"; return 1; fi
|
||||
rm -f -- "$TB_TEST_DIR/$1.enabled" ;;
|
||||
daemon-reload) ;;
|
||||
*) echo "Unexpected systemctl action: $action $*" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
check_inventory() { tb_inventory | jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" "$1" >/dev/null; }
|
||||
case "$1" in
|
||||
approve)
|
||||
jq -cn '{version:1,enabled:true,trusted:{},original_authmode:"enabled"}' > "$TB_STATE"
|
||||
echo removal-test > "$TB_RUNTIME/generation"
|
||||
touch "$TB_MARKER"
|
||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_UID" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
||||
tb_approve "$identity" true true >/dev/null
|
||||
# An existing manual record, even explicitly trusted through Omarchy,
|
||||
# must keep the policy its administrator chose before enrollment.
|
||||
inventory=$(tb_inventory)
|
||||
owner=$(jq -r .owner <<< "$inventory")
|
||||
path=$(jq -r --arg uid "$TB_TEST_OTHER" '.devices[]|select(.Uid==$uid)|.path' <<< "$inventory")
|
||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null
|
||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$TB_TEST_OTHER" manual '' >/dev/null
|
||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_OTHER" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
||||
tb_approve "$identity" true true >/dev/null
|
||||
jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" \
|
||||
'.enrolled[$uid].StoreTime>0 and .enrolled[$other]==null and .trusted[$other]!=null' "$TB_STATE" >/dev/null
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
sha256sum "$TB_TEST_STORE/keys/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
||||
fi ;;
|
||||
boot-enable) tb_admin boot-enable ;;
|
||||
rollback)
|
||||
cp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
||||
tb_inventory | tb_boot_saved > "$TB_TEST_DIR/old-firmware.json"
|
||||
touch "$TB_TEST_DIR/fail-disable"
|
||||
if tb_admin disable; then echo 'Failed removal unexpectedly succeeded' >&2; exit 1; fi
|
||||
cmp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
||||
cmp <(tb_inventory | tb_boot_saved | jq -cS .) <(jq -cS . "$TB_TEST_DIR/old-firmware.json")
|
||||
[[ -f $TB_MARKER && -f $TB_TEST_DIR/$TB_SERVICE.active && -f $TB_TEST_DIR/$TB_SERVICE.enabled && ! -f $TB_TEST_DIR/setup-recovery.json ]]
|
||||
check_inventory '.manager.AuthMode=="disabled"'
|
||||
echo 'Removal failure restored policy, firmware ACLs and approval service' ;;
|
||||
disable)
|
||||
tb_admin disable
|
||||
[[ ! -f $TB_MARKER && ! -f $TB_TEST_DIR/$TB_SERVICE.active && ! -f $TB_TEST_DIR/$TB_SERVICE.enabled ]]
|
||||
jq -e '.enabled==false and .enrolled==null' "$TB_STATE" >/dev/null
|
||||
check_inventory '.manager.AuthMode=="enabled" and any(.stored[]; .Uid==$uid and .Policy=="auto") and any(.stored[]; .Uid==$other and .Policy=="manual")'
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then sha256sum --status -c "$TB_TEST_DIR/key-checksum"; fi
|
||||
echo 'Removal handed our record back to Bolt and preserved existing manual policy' ;;
|
||||
disconnected)
|
||||
check_inventory 'any(.stored[]; .Uid==$uid and .Status=="disconnected") and any(.stored[]; .Uid==$other and .Status=="disconnected")' ;;
|
||||
reconnected)
|
||||
check_inventory 'any(.devices[]; .Uid==$uid and .Policy=="auto" and (.Status|startswith("authorized"))) and any(.devices[]; .Uid==$other and .Policy=="manual" and .Status=="connected")'
|
||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
||||
check_inventory 'any(.devices[]; .Uid==$uid and .Key=="have" and (.AuthFlags|contains("secure")))'
|
||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
||||
fi
|
||||
echo "Automatic reconnect succeeded in $TB_TEST_SECURITY mode; pre-existing manual device stayed blocked" ;;
|
||||
*) exit 64 ;;
|
||||
esac
|
||||
@@ -0,0 +1,640 @@
|
||||
#!/bin/bash
|
||||
|
||||
# These behavior cases were first run as the existing Python assertions against
|
||||
# the Bash implementation. Fixtures replace only Bolt, systemd and desktop I/O.
|
||||
set -euo pipefail
|
||||
source "$ROOT/test/shell.d/base-test.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-review.sh"
|
||||
|
||||
fixture() {
|
||||
T=$(mktemp -d)
|
||||
trap 'rm -rf -- "$T"' EXIT
|
||||
export HOME="$T/home"
|
||||
TB_STATE="$T/policy.json" TB_CONFIG="$T/boltd.conf" TB_MARKER="$T/enabled"
|
||||
TB_PENDING="$T/pending" TB_SYSFS="$T/sysfs" TB_KEYS="$T/keys"
|
||||
TB_RUNTIME="$T/runtime" TB_LOCK="$T/root.lock"
|
||||
mkdir -p "$TB_RUNTIME" "$HOME" "$TB_SYSFS" "$TB_KEYS"
|
||||
echo generation-a > "$TB_RUNTIME/generation"
|
||||
echo '{"version":1,"enabled":true,"trusted":{},"original_authmode":"enabled"}' > "$TB_STATE"
|
||||
cat > "$T/inventory" <<'JSON'
|
||||
{"owner":":1.5","manager":{"AuthMode":"disabled"},"domains":[{"Uid":"host","SecurityLevel":"user","IOMMU":true,"SysfsPath":"/sys/domain","path":"/domain","BootACL":["device-1",""]}],"devices":[{"Uid":"device-1","Name":"Dock","Vendor":"Vendor","Parent":"host","SysfsPath":"/sys/devices/test/0-1","ConnectTime":1,"Status":"connected","Stored":false,"Policy":"default","AuthFlags":"","path":"/devices/1","inode":"1"}]}
|
||||
JSON
|
||||
: > "$T/operations"; : > "$T/units"; : > "$T/notifications"
|
||||
echo '{}' > "$T/capture"
|
||||
tb_requests
|
||||
}
|
||||
|
||||
edit_inventory() { jq "$@" "$T/inventory" > "$T/next"; mv "$T/next" "$T/inventory"; }
|
||||
edit_state() { jq "$@" "$TB_STATE" | tb_json_write "$TB_STATE"; }
|
||||
check() { jq -e "$1" "$2" >/dev/null || fail "$1" "$(cat "$2")"; }
|
||||
reject() { if ("$@") > "$T/output" 2> "$T/error"; then fail "unexpected success: $*"; fi; }
|
||||
identity() { tb_snapshot | jq -c '.devices[0].identity'; }
|
||||
|
||||
tb_inventory() {
|
||||
[[ ! -f $T/inventory-failure ]] || { tb_fail 'inventory unavailable'; return 1; }
|
||||
jq '. + {stored:[.devices[] | select(.Stored)]}' "$T/inventory"
|
||||
}
|
||||
tb_properties() { jq -c --arg path "$2" '(.devices + .domains)[] | select(.path==$path)' "$T/inventory"; }
|
||||
tb_bus() {
|
||||
local command=$1 path=$3 member=$5 value
|
||||
printf '%s\n' "$member" >> "$T/operations"
|
||||
case "$member" in
|
||||
Authorize)
|
||||
[[ ! -f $T/authorize-failure ]] || return 1
|
||||
if [[ ! -f $T/authorize-noop ]]; then edit_inventory --arg path "$path" '(.devices[] | select(.path==$path)).Status="authorized"'; fi ;;
|
||||
EnrollDevice)
|
||||
if [[ ! -f $T/enroll-noop ]]; then
|
||||
edit_inventory '(.devices[0] | .Stored=true | .Policy="manual" | .StoreTime=100) as $d | .devices[0]=$d'
|
||||
if [[ -f $TB_SYSFS/0-1/key && $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ && ! -f $T/key-store-failure ]]; then
|
||||
edit_inventory '.devices[0].Key="have"'
|
||||
fi
|
||||
fi
|
||||
if [[ -f $T/enroll-lost-reply ]]; then rm "$T/enroll-lost-reply"; return 1; fi ;;
|
||||
Policy|BootACL)
|
||||
if [[ $member == "BootACL" && -f $T/boot-failure ]] || [[ $member == "Policy" && ${7:-} == "auto" && -f $T/restore-failure ]]; then
|
||||
tb_fail 'firmware rejected write'; return 1
|
||||
fi
|
||||
[[ $member != "Policy" || ! -f $T/policy-noop ]] || return 0
|
||||
if [[ $member == "BootACL" ]]; then
|
||||
shift 7
|
||||
value=$(jq -cn '$ARGS.positional' --args "$@")
|
||||
else
|
||||
value=$(jq -cn --arg value "$7" '$value')
|
||||
fi
|
||||
edit_inventory --arg path "$path" --arg key "$member" --argjson value "$value" \
|
||||
'((.devices[],.domains[]) | select(.path==$path))[$key]=$value'
|
||||
if [[ $member == "Policy" ]]; then
|
||||
# Real Bolt changes firmware ACLs when a stored policy changes.
|
||||
edit_inventory --arg path "$path" --arg value "$7" '
|
||||
(.devices[] | select(.path==$path) | .Uid) as $uid |
|
||||
.domains[].BootACL |= (if $value=="auto" and (index($uid)==null) then
|
||||
(index("") // 0) as $slot | .[$slot]=$uid
|
||||
elif $value!="auto" then map(if .==$uid then "" else . end) else . end)'
|
||||
fi ;;
|
||||
*) tb_fail "Unexpected Bolt call: $*"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
tb_capture() { [[ ! -f $T/capture-failure ]] && cat "$T/capture"; }
|
||||
tb_unit_state() { jq -cn --arg unit "$1" '{active:($unit=="bolt.service"),enabled:false}'; }
|
||||
systemctl() {
|
||||
if [[ $1 == --root=* ]]; then command systemctl "$@"; return; fi
|
||||
printf '%s\n' "$*" >> "$T/units"
|
||||
if [[ -f $T/unit-failure && $* == "$(cat "$T/unit-failure")" ]]; then
|
||||
[[ -f $T/repeat-failure ]] || rm "$T/unit-failure"
|
||||
tb_fail 'systemd failed'; return 1
|
||||
fi
|
||||
if [[ $* == "start bolt.service" && -f $TB_CONFIG ]]; then
|
||||
edit_inventory --arg mode "$(tb_config_authmode "$TB_CONFIG")" '.manager.AuthMode=$mode'
|
||||
if [[ ! -f $TB_MARKER && -f $T/restart-policy-failure ]]; then
|
||||
rm "$T/restart-policy-failure"
|
||||
edit_inventory '.devices[0].Policy="manual"'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
tb_public_snapshot() { cat "$T/public"; }
|
||||
tb_notify() {
|
||||
echo "$1" >> "$T/notifications"
|
||||
if [[ -f $T/notify-failure ]]; then rm "$T/notify-failure"; return 1; fi
|
||||
}
|
||||
tb_attention() { echo attention >> "$T/notifications"; }
|
||||
gum() { if [[ $1 == "choose" ]]; then cat "$T/choice"; fi; }
|
||||
tb_request_apply() {
|
||||
jq --argjson identity "$2" --argjson permanent "$1" \
|
||||
'(.devices[] | select(.identity==$identity)) |= (.status="authorized" | .trusted=$permanent | .stored=$permanent)' "$T/public" > "$T/next"
|
||||
mv "$T/next" "$T/public"
|
||||
[[ ! -f $T/lost-reply ]] || { tb_fail 'reply lost'; return 1; }
|
||||
cat "$T/public"
|
||||
}
|
||||
request() { local paths=("$TB_REQUESTS"/request-*.json); printf '%s\n' "${paths[0]}"; }
|
||||
request_count() { local paths; shopt -s nullglob; paths=("$TB_REQUESTS"/request-*.json); echo "${#paths[@]}"; }
|
||||
public_fixture() { tb_snapshot > "$T/public"; }
|
||||
boot_fixture() { edit_inventory '.devices[0] |= (.Stored=true | .Policy="auto")'; }
|
||||
review_tick() {
|
||||
local path watch
|
||||
path=$(request); watch=$(jq -r .watch "$path")
|
||||
tb_scan "$watch" >/dev/null
|
||||
}
|
||||
|
||||
test_iommu_is_not_consent() {
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Policy="iommu")'
|
||||
tb_reconcile > "$T/result"
|
||||
[[ ! -s $T/operations ]]; check '.devices[0].trusted==false' "$T/result"
|
||||
}
|
||||
test_once_is_not_saved() {
|
||||
tb_approve "$(identity)" false > "$T/result"
|
||||
check '.warnings==[]' "$T/result"; check '.trusted=={}' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Status="connected"'
|
||||
tb_reconcile >/dev/null
|
||||
[[ $(cat "$T/operations") == "Authorize" ]]
|
||||
}
|
||||
test_always_survives_restart() {
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.owner=":1.9" | .devices[0] |= (.Status="connected" | .inode="2")'
|
||||
echo generation-b > "$TB_RUNTIME/generation"
|
||||
tb_reconcile > "$T/result"
|
||||
check '.devices[0].status=="authorized" and .devices[0].trusted' "$T/result"
|
||||
check '.manager.AuthMode=="disabled"' "$T/inventory"
|
||||
}
|
||||
test_enrollment_lost_reply_retains_ownership() {
|
||||
touch "$T/enroll-lost-reply"
|
||||
reject tb_approve "$(identity)" true
|
||||
check '.trusted=={} and .enrolled["device-1"].StoreTime==null' "$TB_STATE"
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
check '.enrolled["device-1"].StoreTime==100 and .trusted["device-1"]!=null' "$TB_STATE"
|
||||
}
|
||||
secure_fixture() {
|
||||
mkdir -p "$TB_SYSFS/0-1"
|
||||
echo 11111111-2222-3333-4444-555555555555 > "$TB_SYSFS/0-1/unique_id"
|
||||
echo 0 > "$TB_SYSFS/0-1/authorized"
|
||||
: > "$TB_SYSFS/0-1/key"
|
||||
edit_inventory --arg path "$TB_SYSFS/0-1" --arg inode "$(stat -Lc %i "$TB_SYSFS/0-1")" \
|
||||
'.domains[0].SecurityLevel="secure" | .devices[0] |= (.Secure=true | .Key="missing" |
|
||||
.Uid="11111111-2222-3333-4444-555555555555" | .SysfsPath=$path | .inode=$inode)'
|
||||
}
|
||||
test_secure_approval_initializes_before_trust() {
|
||||
secure_fixture
|
||||
tb_approve "$(identity)" true true > "$T/result"
|
||||
[[ $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ ]]
|
||||
check '.devices[0].trusted and .devices[0].stored' "$T/result"
|
||||
cp "$TB_SYSFS/0-1/key" "$T/original-key"
|
||||
tb_approve "$(identity)" true true >/dev/null
|
||||
cmp "$TB_SYSFS/0-1/key" "$T/original-key"
|
||||
}
|
||||
test_secure_automatic_restore_cannot_initialize_keys() {
|
||||
secure_fixture
|
||||
jq '.devices[0] | {(.Uid):{Uid,Name,Vendor}}' "$T/inventory" > "$T/trusted"
|
||||
edit_state --slurpfile trusted "$T/trusted" '.trusted=$trusted[0]'
|
||||
tb_reconcile > "$T/result"
|
||||
check '.devices[0].trusted==false' "$T/result"
|
||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
||||
reject tb_approve "$(identity)" true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_secure_missing_saved_key_cannot_claim_success() {
|
||||
secure_fixture
|
||||
touch "$T/key-store-failure"
|
||||
reject tb_approve "$(identity)" true true
|
||||
check '.trusted=={}' "$TB_STATE"
|
||||
tb_snapshot > "$T/result"; check '.devices[0].trusted==false' "$T/result"
|
||||
}
|
||||
test_secure_key_failure_does_not_authorize() {
|
||||
secure_fixture
|
||||
openssl() { return 1; }
|
||||
reject tb_approve "$(identity)" true true
|
||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
||||
unset -f openssl
|
||||
echo invalid > "$TB_SYSFS/0-1/key"
|
||||
reject tb_approve "$(identity)" true true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_authorized_keyless_device_requires_explicit_reconnect() {
|
||||
secure_fixture
|
||||
edit_inventory '.devices[0].Status="authorized"'
|
||||
echo 1 > "$TB_SYSFS/0-1/authorized"
|
||||
reject tb_approve "$(identity)" true true
|
||||
rg -q 'disconnect, reconnect' "$T/error"
|
||||
[[ ! -s $TB_SYSFS/0-1/key ]]; check '.trusted=={}' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Status="connected"'
|
||||
tb_reconcile >/dev/null
|
||||
[[ $(cat "$T/operations") == "EnrollDevice" ]]
|
||||
}
|
||||
test_stale_identity_rejected() {
|
||||
local id field
|
||||
id=$(identity)
|
||||
for field in inode Name ConnectTime; do
|
||||
cp "$T/inventory" "$T/original"
|
||||
edit_inventory --arg field "$field" '.devices[0][$field]="replacement"'
|
||||
reject tb_approve "$id" true
|
||||
mv "$T/original" "$T/inventory"
|
||||
done
|
||||
edit_inventory '.owner=":1.9"'; reject tb_approve "$id" true
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_false_success_rejected() {
|
||||
local id; id=$(identity)
|
||||
touch "$T/authorize-noop"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/authorize-noop"; touch "$T/enroll-noop"
|
||||
reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/enroll-noop"; tb_approve "$id" true > "$T/result"
|
||||
check '.devices[0].trusted' "$T/result"
|
||||
[[ $(rg -c '^Authorize$' "$T/operations") == 2 ]]
|
||||
}
|
||||
test_write_failure_retains_retry() {
|
||||
local id; id=$(identity)
|
||||
eval "$(declare -f tb_json_write | sed '1s/tb_json_write/disk_write/')"
|
||||
tb_json_write() { [[ ! -f $T/disk-full ]] && disk_write "$@"; }
|
||||
touch "$T/disk-full"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
||||
rm "$T/disk-full"; tb_approve "$id" true > "$T/result"
|
||||
check '.devices[0].trusted' "$T/result"
|
||||
[[ $(rg -c '^Authorize$' "$T/operations") == 1 ]]
|
||||
}
|
||||
test_firmware_bypass_warns() {
|
||||
edit_inventory '.domains[0].SecurityLevel="none" | .devices[0] |= (.Status="authorized" | .AuthFlags="boot")'
|
||||
tb_snapshot > "$T/result"; check '.warnings|length==2' "$T/result"; [[ ! -s $T/operations ]]
|
||||
}
|
||||
test_global_enable_not_protected() {
|
||||
edit_inventory '.manager.AuthMode="enabled"'
|
||||
reject tb_snapshot; rg -q 'not active' "$T/error"
|
||||
reject tb_reconcile
|
||||
}
|
||||
test_display_only_needs_no_approval() {
|
||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
||||
edit_inventory '.devices[0].AuthFlags="nopcie"'
|
||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"; [[ ! -s $T/operations ]]
|
||||
}
|
||||
test_empty_capture_preserved() {
|
||||
rm "$TB_STATE"; tb_prepare
|
||||
touch "$T/capture-failure"; tb_prepare
|
||||
check '.trusted=={}' "$TB_STATE"; [[ $(tb_config_authmode "$TB_CONFIG") == "disabled" ]]
|
||||
}
|
||||
test_capture_failure_does_not_publish() {
|
||||
rm "$TB_STATE"; touch "$T/capture-failure"; reject tb_prepare
|
||||
[[ ! -e $TB_STATE && ! -e $TB_MARKER ]]
|
||||
}
|
||||
test_capture_optional_names_match_numeric_identity() {
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
mkdir -p "$TB_SYSFS/0-1"
|
||||
echo device-1 > "$TB_SYSFS/0-1/unique_id"
|
||||
echo 0x1234 > "$TB_SYSFS/0-1/device"
|
||||
echo 0xab > "$TB_SYSFS/0-1/vendor"
|
||||
tb_capture > "$T/result"
|
||||
check '.["device-1"]=={Uid:"device-1",Name:"0x1234",Vendor:"0xab"}' "$T/result"
|
||||
echo Dock > "$TB_SYSFS/0-1/device_name"
|
||||
tb_capture > "$T/result"; check '.["device-1"].Name=="Dock" and .["device-1"].Vendor=="0xab"' "$T/result"
|
||||
rm "$TB_SYSFS/0-1/device_name"; echo Vendor > "$TB_SYSFS/0-1/vendor_name"
|
||||
tb_capture > "$T/result"; check '.["device-1"].Name=="0x1234" and .["device-1"].Vendor=="Vendor"' "$T/result"
|
||||
mkdir "$TB_SYSFS/0-1/device_name"
|
||||
reject tb_capture
|
||||
}
|
||||
test_first_activation_defers_without_touching_bolt() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
printf '[config]\nAuthMode=enabled\n' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
||||
tb_admin owner
|
||||
[[ -f $TB_PENDING && ! -f $TB_STATE && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
cmp "$TB_CONFIG" "$T/original"
|
||||
rm -rf "$TB_SYSFS/0-1"
|
||||
tb_admin migrate
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
tb_admin enable
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_saved_secure_binding_allows_initial_capture() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
||||
tb_admin owner
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_key_without_record_defers_initial_capture() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0].Key="have"'
|
||||
tb_admin owner
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
}
|
||||
test_offline_prepare_cannot_trust_installers_bolt() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
||||
tb_admin prepare
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
}
|
||||
test_deferred_opt_out_retains_initial_enrollment() {
|
||||
secure_fixture; rm "$TB_STATE"
|
||||
tb_admin owner
|
||||
tb_admin disable
|
||||
[[ ! -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
check '.enabled==false and .enrollment_pending==true' "$TB_STATE"
|
||||
tb_admin migrate
|
||||
[[ ! -f $TB_PENDING && ! -s $T/units ]]
|
||||
tb_admin enable
|
||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
||||
rm -rf "$TB_SYSFS/0-1"
|
||||
echo '{"new-device":{}}' > "$T/capture"
|
||||
tb_admin enable
|
||||
check '.enabled and .enrollment_pending==null and .trusted=={"new-device":{}}' "$TB_STATE"
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_pending_notice_retries_without_claiming_protection() {
|
||||
touch "$TB_PENDING"
|
||||
local count=0 notices=0
|
||||
sleep() { count=$((count + 1)); (( count < 3 )) || exit 99; }
|
||||
tb_setup_notice() { notices=$((notices + 1)); (( notices > 1 )) && echo pending >> "$T/notifications"; }
|
||||
reject tb_watch
|
||||
[[ $(cat "$T/notifications") == "pending" ]]
|
||||
tb_status > "$T/result"; rg -q 'protection is deferred' "$T/result"
|
||||
}
|
||||
test_active_policy_is_not_deferred() {
|
||||
secure_fixture; touch "$TB_MARKER"
|
||||
tb_admin owner
|
||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
||||
}
|
||||
test_pending_only_factory_reset_reaches_both_roots() {
|
||||
local function_name root="$T/top/@factory"
|
||||
local TOP_MNT="$T/top" NEXT_NAME=@next PROVISIONING_DIR=/var/lib/omarchy/provisioning LOG_FILE="$T/reset.log" swap_done=0
|
||||
mkdir -p "$root/etc/omarchy" "$root/etc/systemd/system" "$root/usr/bin" \
|
||||
"$root/usr/share/omarchy/install/provisioning" "$T/top/@"
|
||||
touch "$root/etc/omarchy/thunderbolt-authorization.pending" "$root/usr/bin/omarchy-provision-owner"
|
||||
chmod +x "$root/usr/bin/omarchy-provision-owner"
|
||||
echo 'root:x:0:0:root:/root:/bin/bash' > "$root/etc/passwd"
|
||||
cp "$ROOT/install/provisioning/omarchy-provision-owner.service" \
|
||||
"$ROOT/install/provisioning/omarchy-system-factory-reset-finish.service" "$root/usr/share/omarchy/install/provisioning/"
|
||||
cp "$ROOT/etc/systemd/system/omarchy-thunderbolt-authorization.service" "$root/etc/systemd/system/"
|
||||
for function_name in install_provisioning_units scrub_factory_accounts sanitize_factory_baseline stage_full_reset; do
|
||||
eval "$(sed -n "/^$function_name() {$/,/^}/p" "$ROOT/bin/omarchy-system-factory-reset")"
|
||||
done
|
||||
log() { :; }
|
||||
btrfs() {
|
||||
if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then cp -a "$3" "$4"
|
||||
elif [[ $1 == "property" ]]; then :
|
||||
else return 1; fi
|
||||
}
|
||||
usermod() { [[ $1 == "--root" && $2 == "$TOP_MNT/"* && $3 == "--password" && $4 == "!" && $5 == "root" ]]; }
|
||||
systemd-id128() { echo new-machine-id; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { :; }
|
||||
sync() { :; }
|
||||
systemctl() {
|
||||
[[ ( $1 == "--root=$TOP_MNT/@next" || $1 == "--root=$TOP_MNT/@factory" ) && $2 == "disable" && $3 == "$TB_SERVICE" ]] || return 1
|
||||
command systemctl "$@"
|
||||
}
|
||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() {
|
||||
[[ $1 == "reset-root" ]] || return 1
|
||||
printf '%s\n' "$2" >> "$T/reset-roots"
|
||||
(
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
||||
tb_reset_root "$2"
|
||||
)
|
||||
}
|
||||
stage_full_reset
|
||||
[[ $(wc -l < "$T/reset-roots") == 2 && $swap_done == 1 ]]
|
||||
[[ ! -f $TOP_MNT/@/etc/omarchy/thunderbolt-authorization.pending &&
|
||||
! -f $TOP_MNT/@factory/etc/omarchy/thunderbolt-authorization.pending ]]
|
||||
[[ -f $TOP_MNT/@/var/lib/omarchy/provisioning/pending ]]
|
||||
}
|
||||
test_config_preserves_settings() {
|
||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
||||
tb_prepare; rg -q '^DefaultPolicy=manual$' "$TB_CONFIG"
|
||||
check '.original_authmode=="enabled"' "$TB_STATE"
|
||||
}
|
||||
test_fresh_owner_replaces_trust() {
|
||||
rm "$TB_STATE"; echo '{"old":{}}' > "$T/capture"; tb_prepare
|
||||
echo '{"new":{}}' > "$T/capture"; tb_prepare true
|
||||
check '.trusted=={"new":{}} and .original_authmode=="enabled"' "$TB_STATE"
|
||||
}
|
||||
test_enable_rollback() {
|
||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
||||
cp "$TB_CONFIG" "$T/original"; echo 'start bolt.service' > "$T/unit-failure"
|
||||
reject tb_transaction tb_enable
|
||||
cmp "$TB_CONFIG" "$T/original"; [[ ! -e $TB_STATE && ! -e $TB_MARKER && ! -e $T/setup-recovery.json ]]
|
||||
[[ $(tail -2 "$T/units") == $'start bolt.service\nstop omarchy-thunderbolt-authorization.service' ]]
|
||||
}
|
||||
test_failed_restore_keeps_checkpoint() {
|
||||
rm "$TB_STATE"; echo '[config]' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
||||
echo 'stop omarchy-thunderbolt-authorization.service bolt.service' > "$T/unit-failure"; touch "$T/repeat-failure"
|
||||
reject tb_transaction tb_enable; rg -q 'restoration is incomplete' "$T/error"
|
||||
[[ -f $T/setup-recovery.json ]]; reject tb_transaction tb_enable; rg -q 'needs recovery' "$T/error"
|
||||
echo partial > "$TB_CONFIG"; rm "$T/unit-failure"
|
||||
tb_config_restore; cmp "$TB_CONFIG" "$T/original"
|
||||
}
|
||||
test_disable_restores_matching_boot_state() {
|
||||
boot_fixture; tb_prepare; tb_boot_change true; cp "$TB_STATE" "$T/original"
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
reject tb_transaction tb_disable
|
||||
cmp "$TB_STATE" "$T/original"; [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
||||
}
|
||||
test_disable_hands_off_only_our_records() {
|
||||
tb_prepare
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices += [(.devices[0] | .Uid="existing" | .path="/devices/2" | .Policy="manual")]'
|
||||
tb_approve "$(tb_snapshot | jq -c '.devices[1].identity')" true >/dev/null
|
||||
tb_admin disable
|
||||
check '.enabled==false and .enrolled==null and (.trusted|length)==2' "$TB_STATE"
|
||||
check '.manager.AuthMode=="enabled" and .devices[0].Policy=="auto" and .devices[1].Policy=="manual"' "$T/inventory"
|
||||
[[ ! -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
# After a completed handoff, an administrator may choose manual themselves.
|
||||
edit_inventory '.devices[0].Policy="manual"'
|
||||
tb_admin enable
|
||||
tb_admin disable
|
||||
check 'all(.devices[]; .Policy=="manual")' "$T/inventory"
|
||||
}
|
||||
test_disable_hands_off_disconnected_device() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices[0].Status="disconnected"'
|
||||
tb_admin disable
|
||||
check '.devices[0].Policy=="auto"' "$T/inventory"
|
||||
}
|
||||
test_disable_preserves_recreated_manual_record() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.devices[0].StoreTime=200'
|
||||
tb_admin disable
|
||||
check '.devices[0].Policy=="manual"' "$T/inventory"
|
||||
}
|
||||
test_disable_preserves_original_disabled_mode() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_state '.original_authmode="disabled"'
|
||||
tb_admin disable
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="auto"' "$T/inventory"
|
||||
}
|
||||
test_disable_handoff_after_boot_protection() {
|
||||
tb_prepare; tb_boot_change true
|
||||
tb_approve "$(identity)" true >/dev/null
|
||||
tb_admin disable
|
||||
check '.enabled==false and .boot_protection==false' "$TB_STATE"
|
||||
check '.devices[0].Policy=="auto" and (.domains[0].BootACL|index("device-1"))!=null' "$T/inventory"
|
||||
}
|
||||
test_disable_handoff_rolls_back_policies_and_acl() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.domains[0].BootACL=["unrelated",""]'
|
||||
cp "$TB_STATE" "$T/original"
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
reject tb_admin disable
|
||||
cmp "$TB_STATE" "$T/original"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["unrelated",""]' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_failure_keeps_protection() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
tb_unit_state() { echo '{"active":true,"enabled":true}'; }
|
||||
touch "$T/restore-failure"
|
||||
reject tb_admin disable
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
! rg -q '^disable ' "$T/units"
|
||||
[[ $(tail -1 "$T/units") == "start omarchy-thunderbolt-authorization.service" ]]
|
||||
}
|
||||
test_disable_handoff_verifies_policy_writes() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
touch "$T/policy-noop"
|
||||
reject tb_admin disable
|
||||
rg -q 'did not retain the requested Policy' "$T/error"
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_verifies_policy_after_restart() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
touch "$T/restart-policy-failure"
|
||||
reject tb_admin disable
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_handoff_incomplete_rollback_is_recoverable() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
||||
touch "$T/boot-failure"
|
||||
reject tb_admin disable
|
||||
rg -q 'restoration is incomplete' "$T/error"
|
||||
[[ -f $T/setup-recovery.json ]]
|
||||
rm "$T/boot-failure"
|
||||
tb_admin recover
|
||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_disable_offline_controller_is_unchanged() {
|
||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
||||
edit_inventory '.domains[0].SysfsPath=""'
|
||||
reject tb_admin disable
|
||||
rg -q 'Reconnect every Thunderbolt controller' "$T/error"
|
||||
check '.enabled' "$TB_STATE"
|
||||
[[ ! -s $T/units && ! -f $T/setup-recovery.json ]]
|
||||
}
|
||||
test_boot_policy_reconnection_and_restore() {
|
||||
boot_fixture; tb_boot_change true
|
||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
||||
check '.boot_protection and .trusted["device-1"]!=null' "$TB_STATE"
|
||||
edit_inventory '.devices[0].Policy="auto" | .domains[0].BootACL=["device-1",""]'
|
||||
tb_snapshot > "$T/result"; check '.warnings|length>0' "$T/result"
|
||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"
|
||||
check '.domains[0].BootACL==["",""]' "$T/inventory"
|
||||
tb_boot_change false
|
||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
||||
}
|
||||
test_boot_change_rollback() {
|
||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure"
|
||||
reject tb_boot_change true
|
||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1","other"]' "$T/inventory"
|
||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
||||
}
|
||||
test_boot_recovery_retained() {
|
||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" "$T/restore-failure"
|
||||
reject tb_boot_change true; rg -q 'recovery is incomplete' "$T/error"; [[ -f $T/boot-recovery.json ]]
|
||||
rm "$T/boot-failure" "$T/restore-failure"; tb_boot_recover
|
||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
||||
}
|
||||
test_unsupported_boot_cannot_succeed() {
|
||||
boot_fixture; cp "$T/inventory" "$T/original"
|
||||
local expression
|
||||
for expression in '.SysfsPath=""' '.SecurityLevel="none"' '.BootACL=[]'; do
|
||||
cp "$T/original" "$T/inventory"; edit_inventory ".domains[0] |= ($expression)"
|
||||
reject tb_boot_change true
|
||||
done
|
||||
[[ ! -s $T/operations ]]
|
||||
}
|
||||
test_install_defers_owner() {
|
||||
install() { :; }
|
||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() { echo "helper $*"; }
|
||||
export OMARCHY_PATH=$ROOT OMARCHY_INSTALL_USER=''
|
||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
||||
! rg -q helper "$T/output"; [[ ! -s $T/units ]]
|
||||
OMARCHY_INSTALL_USER=lab
|
||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
||||
rg -q 'helper prepare' "$T/output"; rg -q '^enable omarchy-thunderbolt-authorization.service$' "$T/units"
|
||||
}
|
||||
test_offline_reset_scrubs_private_state() {
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
local root="$T/clone" unit="$TB_SERVICE"
|
||||
mkdir -p "$root/etc/systemd/system" "$root${TB_STATE%/*}" "$root${TB_MARKER%/*}"
|
||||
cp "$ROOT/etc/systemd/system/$unit" "$root/etc/systemd/system/"
|
||||
command systemctl --root="$root" enable "$unit" >/dev/null 2>&1
|
||||
touch "$root$TB_MARKER" "$root$TB_STATE" "$root${TB_STATE%/*}/setup-recovery.json"
|
||||
local kind
|
||||
for kind in devices keys domains; do mkdir -p "$root/var/lib/boltd/$kind"; touch "$root/var/lib/boltd/$kind/old"; done
|
||||
tb_reset_root "$root"
|
||||
[[ ! -e $root$TB_MARKER && ! -e $root${TB_STATE%/*} && ! -e $root/etc/systemd/system/multi-user.target.wants/$unit ]]
|
||||
[[ $(ls "$root/var/lib/boltd") == "boltd.conf" && $(tb_config_authmode "$root/var/lib/boltd/boltd.conf") == "enabled" ]]
|
||||
}
|
||||
test_live_reset_refused() { reject tb_reset_root /; }
|
||||
test_trusted_reconnect_no_alert() {
|
||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
||||
public_fixture; tb_scan watch1 >/dev/null; [[ ! -s $T/notifications ]]
|
||||
}
|
||||
test_unavailable_warns_once() {
|
||||
tb_scan() { return 1; }
|
||||
sleep() { count=$((count+1)); ((count<4)) || exit 99; }
|
||||
local count=0
|
||||
reject tb_watch
|
||||
[[ $(cat "$T/notifications") == "attention" ]]
|
||||
}
|
||||
test_failed_delivery_retries_other_devices() {
|
||||
edit_inventory '.devices += [(.devices[0] | .Uid="device-2")]'; public_fixture; touch "$T/notify-failure"
|
||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 1 ]]
|
||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 2 && $(wc -l < "$T/notifications") == 3 ]]
|
||||
}
|
||||
test_restarts_restore_requests() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local old; old=$(request)
|
||||
tb_scan watch1 >/dev/null; [[ $(wc -l < "$T/notifications") == 1 ]]
|
||||
edit_inventory '.owner=":1.9"'; public_fixture
|
||||
tb_scan watch1 >/dev/null; [[ $(request) != "$old" ]]
|
||||
tb_scan watch2 >/dev/null; [[ $(request_count) == 1 && $(wc -l < "$T/notifications") == 3 ]]
|
||||
}
|
||||
test_inventory_failure_preserves_request() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
rm "$T/public"; reject tb_scan watch1; [[ -f $path ]]
|
||||
}
|
||||
test_replacement_during_dialog_rejected() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
gum() {
|
||||
if [[ $1 == "choose" ]]; then
|
||||
jq '.devices[0].identity.Uid="replacement"' "$T/public" > "$T/next"; mv "$T/next" "$T/public"
|
||||
echo 'Always allow this device'
|
||||
fi
|
||||
}
|
||||
reject tb_review "${path##*/}" # A filename is not a valid token.
|
||||
reject tb_review "$(basename "$path" .json)"
|
||||
check '.devices[0].status=="connected"' "$T/public"
|
||||
}
|
||||
test_lost_reply_keeps_approval_intent() {
|
||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
||||
echo 'Always allow this device' > "$T/choice"; touch "$T/lost-reply"
|
||||
sleep() { review_tick; }
|
||||
reject tb_review "$(basename "$path" .json)"
|
||||
check '.approval=="Always allow this device"' "$path"
|
||||
tb_scan watch1 >/dev/null; [[ -f $path && $(wc -l < "$T/notifications") == 2 ]]
|
||||
tb_scan watch2 >/dev/null; [[ ! -f $path && $(wc -l < "$T/notifications") == 3 ]]; path=$(request)
|
||||
rm "$T/lost-reply"; gum() { fail 'Saved approval must not ask again'; }
|
||||
tb_review "$(basename "$path" .json)" >/dev/null
|
||||
[[ ! -f $path ]]
|
||||
}
|
||||
test_disconnect_and_untrusted_text() {
|
||||
edit_inventory '.devices[0].Name="$(touch /tmp/untrusted)\u001b[2J"'
|
||||
public_fixture; tb_scan watch1 >/dev/null
|
||||
[[ $(basename "$(request)") =~ ^request-[0-9a-f]{64}.json$ ]]
|
||||
[[ $(jq '.devices[0].Name' "$T/inventory" | tb_safe_text) != *$'\e'* ]]
|
||||
edit_inventory '.devices=[]'; public_fixture; tb_scan watch1 >/dev/null; [[ $(request_count) == 0 ]]
|
||||
}
|
||||
test_snapshot_freshness_and_disabled_state() {
|
||||
# Exercise the real public reader, including freshness after daemon failure.
|
||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
||||
TB_RUNTIME="$T/runtime"
|
||||
jq -cn --argjson now "$(date +%s)" '{available:true,time:$now,generation:"generation-a"}' > "$TB_RUNTIME/snapshot.json"
|
||||
tb_public_snapshot >/dev/null
|
||||
jq '.time-=30' "$TB_RUNTIME/snapshot.json" > "$T/next"; mv "$T/next" "$TB_RUNTIME/snapshot.json"
|
||||
reject tb_public_snapshot
|
||||
echo generation-b > "$TB_RUNTIME/generation"; reject tb_public_snapshot
|
||||
}
|
||||
test_invalid_state_not_skipped_by_migration() {
|
||||
echo '{"enabled":false}' > "$TB_STATE"; reject tb_admin migrate
|
||||
[[ ! -s $T/units ]]
|
||||
}
|
||||
|
||||
for case in $(declare -F | awk '$3 ~ /^test_/ {print $3}'); do
|
||||
(fixture; "$case")
|
||||
pass "Thunderbolt ${case#test_}"
|
||||
done
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$ROOT/test/shell.d/base-test.sh"
|
||||
fixture=$(mktemp -d)
|
||||
trap 'rm -rf -- "$fixture"' EXIT
|
||||
for name in admin approve daemon; do
|
||||
# Map the security library and stop before service work, including when the
|
||||
# suite itself runs as root. Startup checks remain the production code.
|
||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
||||
-e 's|source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh|exit 126|' \
|
||||
"$ROOT/bin/omarchy-thunderbolt-authorization-$name" > "$fixture/entry"
|
||||
chmod 755 "$fixture/entry"
|
||||
printf 'set -p\n' > "$fixture/decoy"
|
||||
if BASH_ENV="$fixture/decoy" /usr/bin/bash "$fixture/entry" -p; then
|
||||
fail "$name accepts ordinary Bash with a decoy -p"
|
||||
fi
|
||||
printf 'touch "%s"\n' "$fixture/injected" > "$fixture/startup"
|
||||
# The entrypoint must suppress startup files and exported functions before
|
||||
# checking EUID or reaching the fixture's service-work boundary.
|
||||
if BASH_ENV="$fixture/startup" "$fixture/entry" >/dev/null 2>&1; then
|
||||
fail "$name passed the fixture service-work boundary"
|
||||
fi
|
||||
[[ ! -e $fixture/injected ]] || fail "$name executed BASH_ENV"
|
||||
if /usr/bin/env 'BASH_FUNC_source%%=() { echo injected > "$TB_TEST_INJECTED"; }' \
|
||||
TB_TEST_INJECTED="$fixture/injected" "$fixture/entry" >/dev/null 2>&1; then
|
||||
fail "$name passed the fixture boundary with an exported function"
|
||||
fi
|
||||
[[ ! -e $fixture/injected ]] || fail "$name imported an exported function"
|
||||
pass "Thunderbolt $name rejects unsafe Bash startup"
|
||||
done
|
||||
@@ -0,0 +1,57 @@
|
||||
#include <usbguard/Rule.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
static std::string read(const char* path) {
|
||||
std::ifstream file(path);
|
||||
std::string text;
|
||||
std::getline(file, text);
|
||||
if (text.empty()) throw std::runtime_error("empty rule");
|
||||
return text;
|
||||
}
|
||||
|
||||
static std::string replace(std::string text, const std::string& from, const std::string& to) {
|
||||
const auto index = text.find(from);
|
||||
if (index == std::string::npos) throw std::runtime_error("missing attribute");
|
||||
text.replace(index, from.size(), to);
|
||||
return text;
|
||||
}
|
||||
|
||||
static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) {
|
||||
if (policy.appliesTo(device) != expected) throw std::runtime_error(name);
|
||||
std::cout << "ok - native USBGuard: " << name << '\n';
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) return 2;
|
||||
// Test the policy produced by the real Bash implementation, not a C++ copy.
|
||||
const auto policy = usbguard::Rule::fromString(read(argv[1]));
|
||||
const auto text = read(argv[2]);
|
||||
const auto device = usbguard::Rule::fromString(text);
|
||||
check("original connection", policy, device, true);
|
||||
auto moved = device;
|
||||
moved.setViaPort("9-9.9");
|
||||
check("another port", policy, moved, true);
|
||||
moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("another hub", policy, moved, true);
|
||||
auto reloaded = usbguard::Rule::fromString(policy.toString());
|
||||
check("serialized policy reload on new hub", reloaded, moved, true);
|
||||
check("reconnect on original port", reloaded, device, true);
|
||||
auto different = moved;
|
||||
different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
||||
check("different hash denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setSerial("different");
|
||||
check("different serial denied", policy, different, false);
|
||||
different = moved;
|
||||
different.setName("different");
|
||||
check("different name denied", policy, different, false);
|
||||
check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false);
|
||||
check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false);
|
||||
check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false);
|
||||
check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false);
|
||||
// Existing unmarked/manual rules remain location-bound.
|
||||
check("legacy rule still restricts ports and hubs", device, moved, false);
|
||||
}
|
||||
@@ -17,8 +17,18 @@ write_backlights() {
|
||||
done
|
||||
}
|
||||
|
||||
# A copy of the command reads its platform file from a fixture root: none
|
||||
# unless a case writes one, whatever the machine running the suite has installed.
|
||||
platform_root="$tmp_dir/platform"
|
||||
mkdir -p "$platform_root" "$tmp_dir/bin"
|
||||
platform_root_copy "$ROOT/bin/omarchy-hw-display" "$tmp_dir/bin/omarchy-hw-display" "$platform_root"
|
||||
|
||||
write_displays_conf() {
|
||||
printf '%s\n' "$@" >"$platform_root/displays.conf"
|
||||
}
|
||||
|
||||
hw_display() {
|
||||
OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$ROOT/bin/omarchy-hw-display"
|
||||
OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$tmp_dir/bin/omarchy-hw-display"
|
||||
}
|
||||
|
||||
write_backlights intel_backlight
|
||||
@@ -48,9 +58,9 @@ pass "a T2 Mac uses gmux instead of the Touch Bar"
|
||||
|
||||
write_backlights appletb_backlight
|
||||
if hw_display >/dev/null 2>&1; then
|
||||
fail "the Touch Bar is never used as the display backlight"
|
||||
fail "the T2 Touch Bar is never used as the display backlight"
|
||||
fi
|
||||
pass "the Touch Bar is never used as the display backlight"
|
||||
pass "the T2 Touch Bar is never used as the display backlight"
|
||||
|
||||
write_backlights acpi_video0 amdgpu_bl0 appletb_backlight gmux_backlight intel_backlight
|
||||
device=$(hw_display)
|
||||
@@ -62,3 +72,71 @@ if hw_display >/dev/null 2>&1; then
|
||||
fail "no backlight device reports failure"
|
||||
fi
|
||||
pass "no backlight device reports failure"
|
||||
|
||||
# An empty platform file changes nothing.
|
||||
: >"$platform_root/displays.conf"
|
||||
write_backlights acpi_video0 amdgpu_bl1
|
||||
device=$(hw_display)
|
||||
[[ $device == "amdgpu_bl1" ]] || fail "an empty platform file keeps the candidate order" "actual: $device"
|
||||
write_backlights nvidia_wmi_ec_backlight
|
||||
device=$(hw_display)
|
||||
[[ $device == "nvidia_wmi_ec_backlight" ]] || fail "an empty platform file keeps the fallback" "actual: $device"
|
||||
pass "an empty platform file changes nothing"
|
||||
|
||||
write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl'
|
||||
write_backlights 1000.dsi.0 accessory-pipe
|
||||
if hw_display >/dev/null 2>&1; then
|
||||
fail "a skipped backlight is never the fallback"
|
||||
fi
|
||||
write_backlights 1000.dsi.0 accessory-pipe zz-other
|
||||
device=$(hw_display)
|
||||
[[ $device == "zz-other" ]] || fail "the fallback passes over skipped backlights" "actual: $device"
|
||||
pass "a platform's skipped backlights are never the fallback"
|
||||
|
||||
write_backlights 1000.dsi.0 amdgpu_bl0 panel-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "panel-bl" ]] || fail "a preferred backlight beats the GPU backlights" "actual: $device"
|
||||
printf 'backlight-prefer panel-bl\r\n' >"$platform_root/displays.conf"
|
||||
device=$(hw_display)
|
||||
[[ $device == "panel-bl" ]] || fail "a file saved with CRLF line ends reads the same" "actual: $device"
|
||||
write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl'
|
||||
write_backlights gmux_backlight panel-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "gmux_backlight" ]] || fail "gmux still beats a preferred backlight" "actual: $device"
|
||||
pass "a platform's preferred backlight follows gmux and beats the GPU backlights"
|
||||
|
||||
write_displays_conf 'backlight-prefer second-bl' 'backlight-prefer first-bl'
|
||||
write_backlights first-bl second-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "second-bl" ]] || fail "preferences are tried in file order" "actual: $device"
|
||||
pass "preferences are tried in file order"
|
||||
|
||||
write_displays_conf 'backlight-skip intel_backlight' 'backlight-prefer panel-bl' 'backlight-skip panel-bl'
|
||||
write_backlights acpi_video0 intel_backlight panel-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "acpi_video0" ]] || fail "a skip beats preferred and built-in candidates" "actual: $device"
|
||||
write_displays_conf 'backlight-skip gmux_backlight'
|
||||
write_backlights gmux_backlight intel_backlight
|
||||
device=$(hw_display)
|
||||
[[ $device == "intel_backlight" ]] || fail "a skip beats gmux" "actual: $device"
|
||||
pass "a skip beats preferred and built-in candidates"
|
||||
|
||||
write_displays_conf 'backlight-prefer panel-*' 'backlight-prefer .' 'backlight-prefer ..'
|
||||
write_backlights amdgpu_bl0 panel-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "amdgpu_bl0" ]] || fail "a preference is a literal name" "actual: $device"
|
||||
pass "a preference is a literal name"
|
||||
|
||||
# Only whole directives count: an unknown one, a path, an extra word or a
|
||||
# trailing comment leaves the line out; the last line needs no newline.
|
||||
printf '%s\n' 'backlight-future panel-bl' 'backlight-prefer ../panel-bl' 'backlight-prefer panel-bl extra' \
|
||||
'backlight-skip acpi_video0 # comment' >"$platform_root/displays.conf"
|
||||
printf 'backlight-prefer last-bl' >>"$platform_root/displays.conf"
|
||||
write_backlights acpi_video0 last-bl panel-bl
|
||||
device=$(hw_display)
|
||||
[[ $device == "last-bl" ]] || fail "malformed directives are ignored and the last line is read" "actual: $device"
|
||||
write_displays_conf 'backlight-skip acpi_video0 # comment'
|
||||
write_backlights acpi_video0
|
||||
device=$(hw_display)
|
||||
[[ $device == "acpi_video0" ]] || fail "a directive with a trailing comment is ignored" "actual: $device"
|
||||
pass "malformed directives are ignored and the last line is read"
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
acpi_lid="$test_tmp/acpi/button/lid"
|
||||
input_class="$test_tmp/input"
|
||||
dmi_chassis="$test_tmp/chassis_type"
|
||||
mkdir -p "$stub_bin" "$acpi_lid/LID0" "$input_class"
|
||||
|
||||
busctl_log="$test_tmp/busctl.log"
|
||||
cat >"$stub_bin/busctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$BUSCTL_LOG"
|
||||
[[ ${OMARCHY_TEST_LID_STATE:-} != "fail" ]] || exit 1
|
||||
printf '%s\n' "${OMARCHY_TEST_LID_STATE:-}"
|
||||
SH
|
||||
chmod +x "$stub_bin/busctl"
|
||||
|
||||
run_laptop() {
|
||||
OMARCHY_DMI_CHASSIS_TYPE_PATH="$dmi_chassis" \
|
||||
OMARCHY_ACPI_LID_PATH="$acpi_lid" \
|
||||
OMARCHY_INPUT_CLASS_PATH="$input_class" \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-hw-laptop"
|
||||
}
|
||||
|
||||
run_lid_closed() {
|
||||
OMARCHY_ACPI_LID_PATH="$acpi_lid" \
|
||||
OMARCHY_INPUT_CLASS_PATH="$input_class" \
|
||||
BUSCTL_LOG="$busctl_log" \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
OMARCHY_TEST_LID_STATE="$1" \
|
||||
"$ROOT/bin/omarchy-hw-laptop-closed"
|
||||
}
|
||||
|
||||
# Input devices as sysfs shows them: a name and a switch capability bitmap
|
||||
# whose last word holds SW_LID in bit 0.
|
||||
input_device() {
|
||||
mkdir -p "$input_class/$1/capabilities"
|
||||
printf '%s\n' "$2" >"$input_class/$1/name"
|
||||
printf '%s\n' "$3" >"$input_class/$1/capabilities/sw"
|
||||
}
|
||||
|
||||
# No ACPI lid, no lid switch, desktop chassis: a Mac mini, a Studio or a tower.
|
||||
: >"$acpi_lid/LID0/state"
|
||||
rm -f "$acpi_lid/LID0/state"
|
||||
printf '3\n' >"$dmi_chassis"
|
||||
input_device input0 "Power Button" 0
|
||||
input_device input1 "Apple SMC power/lid events" 0
|
||||
input_device input2 "MacBook Pro J416 Headphone Jack" 14
|
||||
if run_laptop; then
|
||||
fail "a desktop without a lid is not classified as a laptop"
|
||||
fi
|
||||
pass "a desktop without a lid is not classified as a laptop, even with the SMC's power device"
|
||||
|
||||
# Apple Silicon MacBook: no ACPI lid, but the SMC reports a lid switch.
|
||||
input_device input1 "Apple SMC power/lid events" 1
|
||||
run_laptop || fail "an Apple SMC lid switch is classified as a laptop"
|
||||
pass "an Apple SMC lid switch is classified as a laptop"
|
||||
|
||||
input_device input1 "Lid Switch" "0 1"
|
||||
run_laptop || fail "a lid switch in the low word of a longer bitmap is classified as a laptop"
|
||||
pass "a lid switch in the low word of a longer bitmap is classified as a laptop"
|
||||
|
||||
input_device input1 "Lid Switch" "1 0"
|
||||
if run_laptop; then
|
||||
fail "a switch bit in a higher word is not a lid"
|
||||
fi
|
||||
pass "a switch bit in a higher word is not a lid"
|
||||
|
||||
# x86 laptop via DMI when ACPI and evdev lids are absent.
|
||||
input_device input1 "Power Button" 0
|
||||
printf '9\n' >"$dmi_chassis"
|
||||
run_laptop || fail "DMI laptop chassis is recognized when ACPI is absent"
|
||||
pass "DMI laptop chassis is recognized when ACPI is absent"
|
||||
|
||||
printf '3\n' >"$dmi_chassis"
|
||||
if run_laptop; then
|
||||
fail "a desktop DMI chassis is not classified as a laptop"
|
||||
fi
|
||||
pass "a desktop DMI chassis is not classified as a laptop"
|
||||
|
||||
printf 'closed\n' >"$acpi_lid/LID0/state"
|
||||
run_laptop || fail "an ACPI lid is classified as a laptop"
|
||||
pass "an ACPI lid is classified as a laptop"
|
||||
|
||||
# An ACPI lid answers for itself, as it always has, without asking logind over
|
||||
# D-Bus: this runs in sudo's PAM stack where fingerprint login is set up.
|
||||
: >"$busctl_log"
|
||||
run_lid_closed 'b false' ||
|
||||
fail "an ACPI lid reports itself closed"
|
||||
pass "an ACPI lid reports itself closed"
|
||||
|
||||
printf 'open\n' >"$acpi_lid/LID0/state"
|
||||
if run_lid_closed 'b true'; then
|
||||
fail "an ACPI lid reports itself open"
|
||||
fi
|
||||
pass "an ACPI lid reports itself open"
|
||||
[[ ! -s $busctl_log ]] || fail "an ACPI lid leaves logind unasked" "$(cat "$busctl_log")"
|
||||
pass "an ACPI lid leaves logind unasked"
|
||||
|
||||
# Without an ACPI lid, logind answers only for a lid switch it can track: it
|
||||
# says "open" of a machine it tracks no lid on.
|
||||
rm -f "$acpi_lid/LID0/state"
|
||||
input_device input1 "Power Button" 0
|
||||
if run_lid_closed 'b true'; then
|
||||
fail "a machine without a lid is never closed"
|
||||
fi
|
||||
[[ ! -s $busctl_log ]] || fail "a machine without a lid leaves logind unasked" "$(cat "$busctl_log")"
|
||||
pass "a machine without a lid is never closed and leaves logind unasked"
|
||||
|
||||
input_device input1 "Apple SMC power/lid events" 1
|
||||
run_lid_closed 'b true' ||
|
||||
fail "logind recognizes a closed lid switch"
|
||||
pass "logind recognizes a closed lid switch"
|
||||
[[ $(head -1 "$busctl_log") == "--timeout=2 "*" LidClosed" ]] ||
|
||||
fail "logind gets a short timeout, not busctl's 25 s" "$(cat "$busctl_log")"
|
||||
pass "logind gets a short timeout, not busctl's 25 s"
|
||||
|
||||
if run_lid_closed 'b false'; then
|
||||
fail "logind recognizes an open lid switch"
|
||||
fi
|
||||
pass "logind recognizes an open lid switch"
|
||||
|
||||
input_device input1 "Lid Switch" "0 1"
|
||||
run_lid_closed 'b true' ||
|
||||
fail "a lid switch in the low word of a longer bitmap is asked about"
|
||||
pass "a lid switch in the low word of a longer bitmap is asked about"
|
||||
|
||||
for answer in '' 'not-a-property' 'fail'; do
|
||||
if run_lid_closed "$answer"; then
|
||||
fail "a logind that does not answer leaves the lid open" "$answer"
|
||||
fi
|
||||
done
|
||||
pass "a logind that does not answer leaves the lid open"
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
detector="$ROOT/bin/omarchy-hw-platform"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fake_platform "$test_tmp/$platform" "$platform"
|
||||
done
|
||||
|
||||
# A privileged caller reads the live device tree and the system uname, never
|
||||
# the fixture roots or PATH its environment names. Run it as root when the suite
|
||||
# is root and as namespaced root otherwise.
|
||||
root_runner=()
|
||||
if (( EUID != 0 )); then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
fi
|
||||
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
|
||||
live=$("${root_runner[@]}" "$detector") || fail "root detects the live platform"
|
||||
[[ $live =~ ^(aarch64-apple|aarch64|x86)$ ]] || fail "root detects the live platform" "live: $live"
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fixture="$test_tmp/$platform"
|
||||
if [[ -f $fixture/proc/device-tree/compatible ]]; then
|
||||
mkdir -p "$fixture/sys/firmware/devicetree/base"
|
||||
cp "$fixture/proc/device-tree/compatible" "$fixture/sys/firmware/devicetree/base/compatible"
|
||||
fi
|
||||
overridden=$(OMARCHY_PROC_ROOT="$fixture/proc" OMARCHY_SYS_ROOT="$fixture/sys" PATH="$fixture/bin:$ROOT/bin:$PATH" \
|
||||
"${root_runner[@]}" "$detector") || fail "root detects the live platform with a $platform fixture in its environment"
|
||||
[[ $overridden == "$live" ]] ||
|
||||
fail "root ignores a $platform fixture in its environment" "live: $live
|
||||
with fixture: $overridden"
|
||||
done
|
||||
pass "root ignores fixture roots and PATH when detecting the platform"
|
||||
else
|
||||
skip "no unprivileged user namespace; skipping the root override probe"
|
||||
fi
|
||||
|
||||
# -p in the shebang is what keeps exported functions and BASH_ENV out, so an
|
||||
# ordinary Bash launch with a decoy -p argument is refused before it reads anything.
|
||||
for command in omarchy-hw-platform omarchy-hw-aarch64-apple omarchy-hw-apple-silicon; do
|
||||
if /usr/bin/bash "$ROOT/bin/$command" -p >/dev/null 2>"$test_tmp/error"; then
|
||||
fail "$command refuses an ordinary Bash launch"
|
||||
fi
|
||||
grep -Fq "Refusing an unsafe Bash startup" "$test_tmp/error" || fail "$command explains the refusal" "$(cat "$test_tmp/error")"
|
||||
done
|
||||
pass "the detector and the Apple predicate refuse an ordinary Bash launch with a decoy -p"
|
||||
|
||||
require_platform_fixtures "the platform fixtures"
|
||||
|
||||
# The three platforms every caller is written against.
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fixture="$test_tmp/$platform"
|
||||
actual=$(OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$detector") ||
|
||||
fail "the $platform fixture is detected"
|
||||
[[ $actual == "$platform" ]] || fail "the $platform fixture is detected" "actual: $actual"
|
||||
|
||||
apple_status=0
|
||||
OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-hw-aarch64-apple" || apple_status=$?
|
||||
if [[ $platform == "aarch64-apple" ]]; then
|
||||
(( apple_status == 0 )) || fail "the Apple predicate accepts the Apple fixture"
|
||||
else
|
||||
(( apple_status != 0 )) || fail "the Apple predicate rejects the $platform fixture"
|
||||
fi
|
||||
pass "the $platform fixture is detected and the Apple predicate agrees"
|
||||
done
|
||||
|
||||
# Systemd and the shell run the predicate by absolute path with whatever PATH
|
||||
# they have; it must use the detector shipped beside it.
|
||||
fixture="$test_tmp/aarch64-apple"
|
||||
OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-hw-aarch64-apple" ||
|
||||
fail "the Apple predicate finds its detector without Omarchy on PATH"
|
||||
pass "the Apple predicate finds its detector without Omarchy on PATH"
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
cat >"$stub_bin/uname" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${1:-} == -m ]] && { printf '%s\n' "${TEST_ARCH:-aarch64}"; exit 0; }
|
||||
exec /usr/bin/uname "$@"
|
||||
SH
|
||||
chmod +x "$stub_bin/uname"
|
||||
|
||||
# $1 names a case directory holding proc/ and sys/ roots; $2 is the CPU.
|
||||
detect() {
|
||||
local case_dir="$test_tmp/cases/$1"
|
||||
TEST_ARCH="${2:-aarch64}" OMARCHY_PROC_ROOT="$case_dir/proc" OMARCHY_SYS_ROOT="$case_dir/sys" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" "$detector" 2>"$test_tmp/error"
|
||||
}
|
||||
|
||||
# $1 case, $2 proc|sys, then the compatible tokens.
|
||||
write_tree() {
|
||||
local case_dir="$test_tmp/cases/$1" tree="$2" file
|
||||
shift 2
|
||||
mkdir -p "$case_dir/proc" "$case_dir/sys"
|
||||
if [[ $tree == "proc" ]]; then
|
||||
file="$case_dir/proc/device-tree/compatible"
|
||||
else
|
||||
file="$case_dir/sys/firmware/devicetree/base/compatible"
|
||||
fi
|
||||
mkdir -p "$(dirname "$file")"
|
||||
printf '%s\0' "$@" >"$file"
|
||||
}
|
||||
|
||||
expect() {
|
||||
local name="$1" machine="$2" expected="$3" description="$4" actual
|
||||
actual=$(detect "$name" "$machine") || fail "$description" "$(cat "$test_tmp/error")"
|
||||
[[ $actual == "$expected" ]] || fail "$description" "expected: $expected
|
||||
actual: $actual"
|
||||
}
|
||||
|
||||
expect_contradiction() {
|
||||
local name="$1" machine="$2" description="$3"
|
||||
if detect "$name" "$machine" >/dev/null; then
|
||||
fail "$description"
|
||||
fi
|
||||
grep -Fq "contradictory platform identity" "$test_tmp/error" || fail "$description explains itself" "$(cat "$test_tmp/error")"
|
||||
}
|
||||
|
||||
# Real boards: the M1 Pro and M2 Max test Macs, an M1 Mac mini, and the
|
||||
# Snapdragon X laptops Dragon supports.
|
||||
write_tree m1-pro proc apple,j314s apple,t6000 apple,arm-platform
|
||||
write_tree m2-max proc apple,j416c apple,t6021 apple,arm-platform
|
||||
write_tree m1-mini proc apple,j274 apple,t8103 apple,arm-platform
|
||||
write_tree yoga-slim7x proc lenovo,yoga-slim7x qcom,x1e80100
|
||||
write_tree xps13-9345 proc dell,xps13-9345 qcom,x1e80100
|
||||
write_tree t14s proc lenovo,thinkpad-t14s qcom,x1e78100 qcom,x1e80100
|
||||
for board in m1-pro m2-max m1-mini; do
|
||||
expect "$board" aarch64 aarch64-apple "the $board device tree is Apple Silicon"
|
||||
done
|
||||
for board in yoga-slim7x xps13-9345 t14s; do
|
||||
expect "$board" aarch64 aarch64 "the $board device tree is plain aarch64, never Apple Silicon"
|
||||
done
|
||||
pass "real Apple and Snapdragon device trees are recognised"
|
||||
|
||||
write_tree qemu-virt proc linux,dummy-virt
|
||||
write_tree raspberry-pi proc raspberrypi,5-model-b brcm,bcm2712
|
||||
mkdir -p "$test_tmp/cases/acpi/proc" "$test_tmp/cases/acpi/sys"
|
||||
expect qemu-virt aarch64 aarch64 "a QEMU virt board is plain aarch64"
|
||||
expect raspberry-pi aarch64 aarch64 "a Raspberry Pi is plain aarch64"
|
||||
expect acpi aarch64 aarch64 "an aarch64 machine without a device tree is plain aarch64"
|
||||
expect acpi x86_64 x86 "an x86 machine without a device tree is x86"
|
||||
pass "unknown aarch64 and x86 machines need no family"
|
||||
|
||||
# Only a token's vendor prefix identifies the board; the old detector matched
|
||||
# "apple," anywhere in the file.
|
||||
write_tree substring proc pineapple,board acmeqcom,soc vendor,apple,x vendor,qcom,y
|
||||
expect substring aarch64 aarch64 "vendor names inside other tokens do not identify the board"
|
||||
pass "only a token's vendor prefix identifies the board"
|
||||
|
||||
# /proc/device-tree is a link into sysfs; read sysfs when it is missing.
|
||||
write_tree sysfs-qualcomm sys lenovo,yoga-slim7x qcom,x1e80100
|
||||
write_tree sysfs-apple sys apple,j314s apple,t6000 apple,arm-platform
|
||||
expect sysfs-qualcomm aarch64 aarch64 "sysfs reads a Snapdragon tree without /proc/device-tree"
|
||||
expect sysfs-apple aarch64 aarch64-apple "sysfs identifies Apple Silicon without /proc/device-tree"
|
||||
write_tree agree proc apple,j314s apple,t6000 apple,arm-platform
|
||||
write_tree agree sys apple,j314s apple,t6000 apple,arm-platform
|
||||
expect agree aarch64 aarch64-apple "matching proc and sysfs trees agree"
|
||||
pass "sysfs is the fallback for the device tree"
|
||||
|
||||
write_tree both-vendors proc apple,j314s qcom,x1e80100
|
||||
expect_contradiction both-vendors aarch64 "a device tree naming Apple and Qualcomm fails"
|
||||
write_tree sources-disagree proc apple,j314s apple,t6000 apple,arm-platform
|
||||
write_tree sources-disagree sys lenovo,yoga-slim7x qcom,x1e80100
|
||||
expect_contradiction sources-disagree aarch64 "proc and sysfs naming different vendors fails"
|
||||
write_tree vendor-vs-none proc apple,j314s apple,t6000 apple,arm-platform
|
||||
write_tree vendor-vs-none sys linux,dummy-virt
|
||||
expect_contradiction vendor-vs-none aarch64 "proc naming Apple while sysfs names nobody fails"
|
||||
expect_contradiction m1-pro x86_64 "an Apple device tree on an x86 CPU fails"
|
||||
expect_contradiction yoga-slim7x x86_64 "a Qualcomm device tree on an x86 CPU fails"
|
||||
if TEST_ARCH=x86_64 OMARCHY_PROC_ROOT="$test_tmp/cases/m1-pro/proc" PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-hw-aarch64-apple" 2>/dev/null; then
|
||||
fail "the Apple predicate fails closed on contradictory identity"
|
||||
fi
|
||||
pass "contradictory identity fails with an explanation"
|
||||
|
||||
failing_uname="$test_tmp/failing-uname"
|
||||
mkdir -p "$failing_uname"
|
||||
printf '#!/bin/bash\nexit 1\n' >"$failing_uname/uname"
|
||||
chmod +x "$failing_uname/uname"
|
||||
if OMARCHY_PROC_ROOT="$test_tmp/cases/acpi/proc" PATH="$failing_uname:$PATH" "$detector" 2>"$test_tmp/error"; then
|
||||
fail "an unreadable CPU architecture fails instead of guessing generic"
|
||||
fi
|
||||
grep -Fq "cannot read the CPU architecture" "$test_tmp/error" || fail "an unreadable CPU architecture explains itself" "$(cat "$test_tmp/error")"
|
||||
pass "an unreadable CPU architecture fails instead of guessing generic"
|
||||
|
||||
# The architecture predicates answer from the CPU alone: aarch64 covers every
|
||||
# ARM family, Apple Silicon included, and x86 none of them.
|
||||
for platform in x86 aarch64 aarch64-apple; do
|
||||
fake_platform "$test_tmp/arch-$platform" "$platform"
|
||||
arch_path="$test_tmp/arch-$platform/bin:$ROOT/bin:$PATH"
|
||||
if [[ $platform == "x86" ]]; then
|
||||
PATH=$arch_path omarchy-hw-x86 || fail "x86: omarchy-hw-x86 accepts it"
|
||||
! PATH=$arch_path omarchy-hw-aarch64 || fail "x86: omarchy-hw-aarch64 rejects it"
|
||||
else
|
||||
PATH=$arch_path omarchy-hw-aarch64 || fail "$platform: omarchy-hw-aarch64 accepts it"
|
||||
! PATH=$arch_path omarchy-hw-x86 || fail "$platform: omarchy-hw-x86 rejects it"
|
||||
fi
|
||||
done
|
||||
pass "omarchy-hw-x86 and omarchy-hw-aarch64 split every platform by its CPU"
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
touchpad="$ROOT/bin/omarchy-hw-touchpad"
|
||||
|
||||
require_command jq
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
input_class="$test_tmp/input"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
# Hyprland's mice, in its order, from a space-separated MICE.
|
||||
cat >"$stub_bin/hyprctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
jq -n --arg names "${MICE:-}" \
|
||||
'{mice: [$names | split(" ")[] | select(. != "") | {name: .}], keyboards: []}'
|
||||
SH
|
||||
|
||||
# udev's properties for an event device, kept beside it in the fixture.
|
||||
cat >"$stub_bin/udevadm" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
cat "${!#}/udev" 2>/dev/null
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
# An event device as sysfs shows it: its input device's name, and what udev
|
||||
# made of it.
|
||||
event_device() {
|
||||
mkdir -p "$input_class/$1/device"
|
||||
printf '%s\n' "$2" >"$input_class/$1/device/name"
|
||||
printf '%s\n' "$3" >"$input_class/$1/udev"
|
||||
}
|
||||
|
||||
detected() {
|
||||
MICE="$1" OMARCHY_INPUT_CLASS_PATH="$input_class" PATH="$stub_bin:$PATH" bash "$touchpad"
|
||||
}
|
||||
|
||||
# Apple Silicon's trackpad is named after its MTP HID interface, so neither
|
||||
# word is in its name; udev still calls it a touchpad.
|
||||
event_device event1 "Apple MTP keyboard" "ID_INPUT_KEYBOARD=1"
|
||||
event_device event2 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1"
|
||||
[[ $(detected "apple-mtp-multi-touch") == "apple-mtp-multi-touch" ]] ||
|
||||
fail "the Apple Silicon trackpad is detected" "$(detected "apple-mtp-multi-touch")"
|
||||
pass "the Apple Silicon trackpad is detected"
|
||||
|
||||
# omarchy-toggle-input-device passes the name straight to hl.device, so a name
|
||||
# that does not match leaves the menu entry hidden and the toggle dead.
|
||||
for name in "elan-touchpad" "apple-magic-trackpad-2"; do
|
||||
[[ $(detected "$name") == "$name" ]] || fail "the touchpads that already worked still do" "$(detected "$name")"
|
||||
done
|
||||
pass "the touchpads that already worked still do"
|
||||
|
||||
# Some touchscreens' mouse interfaces are called multi-touch as well. A named
|
||||
# touchpad wins over one listed first, and without one it takes udev calling
|
||||
# the device a touchpad, not its name.
|
||||
rm -rf "$input_class"
|
||||
event_device event5 "ELAN9008:00 04F3:2C82 Multi-Touch" "ID_INPUT_TOUCHSCREEN=1"
|
||||
event_device event7 "SYNA2BA6:00 06CB:CEF5 Touchpad" "ID_INPUT_TOUCHPAD=1"
|
||||
[[ $(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad") == "syna2ba6:00-06cb:cef5-touchpad" ]] ||
|
||||
fail "a touchscreen listed first does not take the touchpad's place" "$(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad")"
|
||||
[[ -z $(detected "elan9008:00-04f3:2c82-multi-touch") ]] ||
|
||||
fail "a multi-touch touchscreen alone is no touchpad" "$(detected "elan9008:00-04f3:2c82-multi-touch")"
|
||||
pass "a multi-touch touchscreen is never taken for the touchpad"
|
||||
|
||||
# Hyprland turns commas into dashes as well as spaces.
|
||||
event_device event9 "Acme, Inc. Precision Pad" "ID_INPUT_TOUCHPAD=1"
|
||||
[[ $(detected "acme--inc.-precision-pad") == "acme--inc.-precision-pad" ]] ||
|
||||
fail "a touchpad named with a comma is found by Hyprland's name for it" "$(detected "acme--inc.-precision-pad")"
|
||||
pass "a touchpad named with a comma is found by Hyprland's name for it"
|
||||
|
||||
# Hyprland adds -1, -2 and so on to a name another device already took, as when
|
||||
# the MTP interface exposes the trackpad twice; that name is the one to toggle,
|
||||
# and an unrelated name that merely starts the same is not.
|
||||
event_device event11 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1"
|
||||
[[ $(detected "apple-mtp-multi-touch-1") == "apple-mtp-multi-touch-1" ]] ||
|
||||
fail "a touchpad under Hyprland's numbered name is found" "$(detected "apple-mtp-multi-touch-1")"
|
||||
[[ -z $(detected "apple-mtp-multi-touch-extra") ]] ||
|
||||
fail "a name that only starts with the touchpad's is not taken for it" "$(detected "apple-mtp-multi-touch-extra")"
|
||||
pass "a touchpad under Hyprland's numbered name for a duplicate is found"
|
||||
|
||||
# udev's touchpad has to be one of Hyprland's mice for its name to toggle.
|
||||
[[ -z $(detected "logitech-mx-master-3") ]] ||
|
||||
fail "a udev touchpad Hyprland does not list is not reported" "$(detected "logitech-mx-master-3")"
|
||||
pass "an ordinary mouse is not mistaken for a touchpad"
|
||||
|
||||
[[ -z $(detected "") ]] ||
|
||||
fail "no touchpad answers with an empty detection" "$(detected "")"
|
||||
pass "no touchpad answers with an empty detection"
|
||||
@@ -151,7 +151,9 @@ cat > "$stub_bin/omarchy-default-dictation" <<'SH'
|
||||
#!/bin/bash
|
||||
echo voxtype
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-default-dictation"
|
||||
# The dictation keys are bound only once the selected backend is installed.
|
||||
printf '#!/bin/bash\nexit 0\n' > "$stub_bin/voxtype"
|
||||
chmod +x "$stub_bin/omarchy-default-dictation" "$stub_bin/voxtype"
|
||||
|
||||
bindings=$(PATH="$stub_bin:$PATH" list_bindings "$home")
|
||||
[[ -n $bindings ]] || fail "default bindings load for the conflict check"
|
||||
|
||||
@@ -158,6 +158,10 @@ echo future-backend
|
||||
SH
|
||||
chmod +x "$missing_bin/omarchy-default-dictation"
|
||||
dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home")
|
||||
if grep -Fq 'dictation' <<<"$dictation_output"; then fail "a selected backend that is not installed leaves application shortcuts available"; fi
|
||||
printf '#!/bin/bash\nexit 0\n' > "$missing_bin/future-backend"
|
||||
chmod +x "$missing_bin/future-backend"
|
||||
dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home")
|
||||
for binding in \
|
||||
$'SUPER + CTRL + X\tToggle dictation' \
|
||||
$'F9\tStart dictation (push-to-talk)' \
|
||||
@@ -166,7 +170,7 @@ for binding in \
|
||||
$'ALT + Alt_R\tStop dictation (push-to-talk)'; do
|
||||
grep -Fq "$binding" <<<"$dictation_output" || fail "dictation bindings load for any selected backend" "$binding"
|
||||
done
|
||||
pass "dictation bindings require a selection without hardcoding backends"
|
||||
pass "dictation bindings require an installed selection without hardcoding backends"
|
||||
|
||||
# Lazydocker is optional on new installs, while existing installs keep the hotkey.
|
||||
lazydocker_bin="$tmpdir/lazydocker-bin"
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Root never reads the fixture root, so as root the build and first boot below
|
||||
# would act on this machine. A skip is a pass.
|
||||
if (( EUID == 0 )); then
|
||||
skip "running as root, where image setup ignores fixture roots; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 022
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
unit_name=omarchy-provision-hardware.service
|
||||
unit_source="$ROOT/install/provisioning/$unit_name"
|
||||
|
||||
# omarchy-apply-hardware refuses anyone but root, and root never reads the
|
||||
# fixture root. Run a copy whose only change is that check, so a normal user
|
||||
# can build an image into a fixture root.
|
||||
apply_hardware="$test_tmp/omarchy-apply-hardware"
|
||||
sed 's/^if (( EUID != 0 )); then$/if false; then/' "$ROOT/bin/omarchy-apply-hardware" >"$apply_hardware"
|
||||
chmod +x "$apply_hardware"
|
||||
grep -q '^if false; then$' "$apply_hardware" || fail "the test copy of omarchy-apply-hardware drops only its root check"
|
||||
|
||||
# Commands a hardware leaf would reach for. Any call means a leaf ran.
|
||||
stub_bin="$test_tmp/stub-bin"
|
||||
mkdir -p "$stub_bin"
|
||||
for command in sudo pacman pacman-key systemctl lspci omarchy-pkg-add modinfo; do
|
||||
cat >"$stub_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${0##*/}" "$*" >>"$LEAF_CALLS"
|
||||
exit 1
|
||||
SH
|
||||
done
|
||||
stub_rebuild() {
|
||||
cat >"$stub_bin/$1" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${0##*/}" "$*" >>"$REBUILDS"
|
||||
SH
|
||||
chmod +x "$stub_bin/$1"
|
||||
}
|
||||
stub_rebuild mkinitcpio
|
||||
# Only the stubs count as present, so a machine with Limine never runs its own
|
||||
# limine-mkinitcpio here.
|
||||
cat >"$stub_bin/omarchy-cmd-present" <<SH
|
||||
#!/bin/bash
|
||||
[[ -x $stub_bin/\$1 ]]
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
export LEAF_CALLS="$test_tmp/leaf-calls" REBUILDS="$test_tmp/rebuilds"
|
||||
|
||||
fake_platform "$test_tmp/hw" aarch64-apple
|
||||
base_path="$test_tmp/hw/bin:$stub_bin:$ROOT/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
# An Omarchy tree whose hardware setup is three leaves that log their runs.
|
||||
# Leaves a and b write initramfs drop-ins; b then fails while $FAIL_B exists.
|
||||
fixture="$test_tmp/omarchy"
|
||||
mkdir -p "$fixture/install/helpers" "$fixture/install/provisioning" "$fixture/install/hardware/apple" "$fixture/bin"
|
||||
cp "$ROOT/install/helpers/logging.sh" "$ROOT/install/helpers/image-target.sh" "$fixture/install/helpers/"
|
||||
cp "$unit_source" "$fixture/install/provisioning/"
|
||||
cat >"$fixture/install/hardware/all.sh" <<'SH'
|
||||
run_logged "$OMARCHY_INSTALL/hardware/a.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/apple/b.sh"
|
||||
run_logged "$OMARCHY_INSTALL/hardware/c.sh"
|
||||
SH
|
||||
cat >"$fixture/install/hardware/a.sh" <<'SH'
|
||||
printf 'a user=%s path=%s\n' "${OMARCHY_INSTALL_USER-unset}" "$OMARCHY_PATH" >>"$RUNS"
|
||||
mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d"
|
||||
echo 'MODULES+=(a)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/a.conf"
|
||||
SH
|
||||
cat >"$fixture/install/hardware/apple/b.sh" <<'SH'
|
||||
mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d"
|
||||
echo 'MODULES+=(b)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/b.conf"
|
||||
[[ ! -e $FAIL_B ]] || return 1
|
||||
echo b >>"$RUNS"
|
||||
SH
|
||||
cat >"$fixture/install/hardware/c.sh" <<'SH'
|
||||
echo c >>"$RUNS"
|
||||
[[ ! -e $REQUEST_REBUILD ]] || touch "$OMARCHY_IMAGE_BOOT_REBUILD"
|
||||
SH
|
||||
export RUNS="$test_tmp/runs" FAIL_B="$test_tmp/fail-b" REQUEST_REBUILD="$test_tmp/request-rebuild"
|
||||
|
||||
new_root() {
|
||||
local root="$test_tmp/root-$1"
|
||||
rm -rf "$root"
|
||||
mkdir -p "$root/var/log"
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
write_manifest() {
|
||||
local root=$1 body=${2:-$'format=1\nplatform=aarch64-apple\n'}
|
||||
mkdir -p "$root/var/lib/omarchy/image"
|
||||
chmod 0755 "$root/var/lib/omarchy/image"
|
||||
printf '%s' "$body" >"$root/var/lib/omarchy/image/target"
|
||||
chmod 0644 "$root/var/lib/omarchy/image/target"
|
||||
}
|
||||
|
||||
build() {
|
||||
local root=$1 omarchy=${2:-$fixture}
|
||||
OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$omarchy" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \
|
||||
PATH="$base_path" "$apply_hardware" --defer-provisioning
|
||||
}
|
||||
|
||||
first_boot() {
|
||||
local root=$1
|
||||
OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \
|
||||
PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware"
|
||||
}
|
||||
|
||||
reset_logs() {
|
||||
rm -f "$RUNS" "$LEAF_CALLS" "$REBUILDS" "$FAIL_B"
|
||||
}
|
||||
|
||||
queue_of() {
|
||||
cat "$1/var/lib/omarchy/image/deferred-steps"
|
||||
}
|
||||
|
||||
# --- The build ---------------------------------------------------------------
|
||||
|
||||
# The real hardware setup: every leaf is queued in order and none of them runs.
|
||||
reset_logs
|
||||
root=$(new_root real)
|
||||
write_manifest "$root"
|
||||
output=$(build "$root" "$ROOT") || fail "an image build defers the real hardware setup" "$output"
|
||||
expected=$(sed -n 's|^run_logged "\$OMARCHY_INSTALL/\(hardware/[^"]*\)"$|install/\1|p' "$ROOT/install/hardware/all.sh")
|
||||
[[ -n $expected && $(queue_of "$root") == "$expected" ]] ||
|
||||
fail "an image build queues every hardware leaf in order" "expected:
|
||||
$expected
|
||||
queued:
|
||||
$(queue_of "$root")"
|
||||
[[ ! -e $LEAF_CALLS ]] || fail "an image build runs no hardware leaf" "$(cat "$LEAF_CALLS")"
|
||||
[[ $output == *"Image build for aarch64-apple: deferred $(wc -l <<<"$expected") hardware steps to first boot"* ]] ||
|
||||
fail "an image build reports what it deferred" "$output"
|
||||
pass "an image build queues every hardware leaf in order and runs none of them"
|
||||
|
||||
[[ $(<"$unit_source") == "$(<"$root/etc/systemd/system/$unit_name")" ]] ||
|
||||
fail "an image build installs the first-boot hardware service"
|
||||
[[ $(readlink "$root/etc/systemd/system/multi-user.target.wants/$unit_name") == "/etc/systemd/system/$unit_name" ]] ||
|
||||
fail "an image build enables the first-boot hardware service"
|
||||
[[ -f $root/var/lib/omarchy/image/target && ! -e $root/var/lib/omarchy/image/target.booted ]] ||
|
||||
fail "an image build leaves its manifest for the first boot"
|
||||
[[ $(stat -c '%a' "$root/var/lib/omarchy/image/deferred-steps") == "644" ]] ||
|
||||
fail "the queue is readable and only its owner writes it"
|
||||
pass "an image build arms the first-boot hardware service"
|
||||
|
||||
before=$(queue_of "$root")
|
||||
build "$root" "$ROOT" >/dev/null || fail "an image build can run hardware setup again"
|
||||
[[ $(queue_of "$root") == "$before" ]] || fail "running hardware setup again in the build queues the same steps"
|
||||
pass "running hardware setup again in the build queues the same steps"
|
||||
|
||||
# Without a manifest nothing is deferred: the leaves run as they always did.
|
||||
reset_logs
|
||||
root=$(new_root live)
|
||||
build "$root" >/dev/null || fail "hardware setup without a manifest runs"
|
||||
[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] || fail "hardware setup without a manifest runs every leaf" "$(cat "$RUNS")"
|
||||
[[ ! -e $root/var/lib/omarchy/image && ! -e $root/etc/systemd/system/$unit_name ]] ||
|
||||
fail "hardware setup without a manifest queues and arms nothing"
|
||||
pass "hardware setup without a manifest runs every leaf and defers nothing"
|
||||
|
||||
# Hardware setup that has to wait for a first boot in progress decides only
|
||||
# once it has the lock: by then the manifest is retired and the leaves run here.
|
||||
reset_logs
|
||||
root=$(new_root race)
|
||||
write_manifest "$root"
|
||||
(
|
||||
exec {lock}>>"$root/var/lib/omarchy/image/lock"
|
||||
flock "$lock"
|
||||
touch "$test_tmp/locked"
|
||||
until [[ -e $test_tmp/release || ! -d $test_tmp ]]; do sleep 0.1; done
|
||||
mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted"
|
||||
) &
|
||||
first_boot_pid=$!
|
||||
until [[ -e $test_tmp/locked ]]; do sleep 0.1; done
|
||||
build "$root" >/dev/null &
|
||||
setup_pid=$!
|
||||
sleep 1
|
||||
kill -0 "$setup_pid" 2>/dev/null || fail "hardware setup waits while a first boot holds the lock"
|
||||
touch "$test_tmp/release"
|
||||
wait "$first_boot_pid"
|
||||
wait "$setup_pid" || fail "hardware setup runs after waiting for a first boot"
|
||||
[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] ||
|
||||
fail "hardware setup that waited for a first boot runs its leaves instead of queueing them"
|
||||
pass "hardware setup waits for a first boot in progress and then runs on the machine"
|
||||
|
||||
# A manifest that is not unambiguously root's own stops hardware setup outright.
|
||||
check_refused() {
|
||||
local description=$1 root=$2
|
||||
reset_logs
|
||||
if build "$root" >/dev/null 2>&1; then
|
||||
fail "hardware setup refuses $description"
|
||||
fi
|
||||
[[ ! -e $RUNS && ! -e $root/var/lib/omarchy/image/deferred-steps && ! -e $root/etc/systemd/system/$unit_name ]] ||
|
||||
fail "hardware setup refuses $description without running, queueing or arming anything"
|
||||
pass "hardware setup refuses $description"
|
||||
}
|
||||
|
||||
root=$(new_root format)
|
||||
write_manifest "$root" $'format=2\nplatform=aarch64-apple\n'
|
||||
check_refused "a manifest of an unknown format" "$root"
|
||||
|
||||
root=$(new_root platform)
|
||||
write_manifest "$root" $'format=1\nplatform=apple\n'
|
||||
check_refused "a manifest naming an unknown platform" "$root"
|
||||
|
||||
root=$(new_root malformed)
|
||||
write_manifest "$root" $'format=1\napple-silicon\n'
|
||||
check_refused "a malformed manifest" "$root"
|
||||
|
||||
root=$(new_root writable)
|
||||
write_manifest "$root"
|
||||
chmod 0666 "$root/var/lib/omarchy/image/target"
|
||||
check_refused "a manifest others can write" "$root"
|
||||
|
||||
root=$(new_root writable-dir)
|
||||
write_manifest "$root"
|
||||
chmod 0777 "$root/var/lib/omarchy/image"
|
||||
check_refused "a manifest in a directory others can write" "$root"
|
||||
|
||||
root=$(new_root symlink)
|
||||
write_manifest "$root"
|
||||
mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/real-target"
|
||||
ln -s real-target "$root/var/lib/omarchy/image/target"
|
||||
check_refused "a symlinked manifest" "$root"
|
||||
|
||||
# --- The first boot ----------------------------------------------------------
|
||||
|
||||
reset_logs
|
||||
root=$(new_root fresh)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
[[ $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] ||
|
||||
fail "the fixture image queues its three leaves" "$(queue_of "$root")"
|
||||
|
||||
output=$(first_boot "$root") || fail "the first boot finishes the deferred hardware setup" "$output"
|
||||
[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] ||
|
||||
fail "the first boot runs each deferred leaf once, in order, with no install user" "$(cat "$RUNS")"
|
||||
[[ $output == *"First boot of an image built for aarch64-apple, on aarch64-apple hardware"* ]] ||
|
||||
fail "the first boot reports the image target and the live platform" "$output"
|
||||
[[ ! -e $root/var/lib/omarchy/image/target && -f $root/var/lib/omarchy/image/target.booted ]] ||
|
||||
fail "the first boot retires the build manifest"
|
||||
[[ ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot empties the queue"
|
||||
[[ ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -f $root/etc/systemd/system/$unit_name ]] ||
|
||||
fail "the first boot disables its service and keeps the unit, so a start job already queued is skipped rather than failed"
|
||||
[[ $(cat "$REBUILDS") == "mkinitcpio -P" ]] ||
|
||||
fail "the first boot rebuilds the initramfs once after a leaf changed it" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
[[ ! -e $root/var/lib/omarchy/image/initramfs-inputs ]] || fail "the first boot clears the rebuild it owed"
|
||||
grep -q 'Completed: .*/install/hardware/c.sh' "$root/var/log/omarchy-install.log" ||
|
||||
fail "the first boot logs each step to the install log"
|
||||
pass "the first boot runs the deferred hardware setup with no command from the owner"
|
||||
|
||||
rm -f "$RUNS" "$REBUILDS"
|
||||
output=$(first_boot "$root") || fail "running the first-boot hardware setup again succeeds"
|
||||
[[ -z $output && ! -e $RUNS && ! -e $REBUILDS ]] ||
|
||||
fail "running the first-boot hardware setup again does nothing" "$output"
|
||||
pass "running the first-boot hardware setup again does nothing"
|
||||
|
||||
# The live platform is asked once the manifest is retired, so it is the
|
||||
# hardware's even where the detector cannot tell a booted root (this fixture
|
||||
# runs no systemd): an Apple Silicon image booted in a VM reports plain aarch64.
|
||||
fake_platform "$test_tmp/vm" aarch64
|
||||
reset_logs
|
||||
root=$(new_root vm)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds for a VM boot"
|
||||
output=$(OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/vm/proc" \
|
||||
PATH="$test_tmp/vm/bin:$stub_bin:$ROOT/bin:/usr/local/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-provision-hardware") ||
|
||||
fail "the first boot on other hardware finishes" "$output"
|
||||
[[ $output == *"First boot of an image built for aarch64-apple, on aarch64 hardware"* ]] ||
|
||||
fail "the first boot reports the hardware it runs on, not the image target" "$output"
|
||||
pass "the first boot reports the hardware it runs on, not the image target"
|
||||
|
||||
# A failed step stays queued with everything after it; the next boot resumes there.
|
||||
reset_logs
|
||||
root=$(new_root resume)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
# A repository without a sync database, which the image never has: the failed
|
||||
# step is not retried after a fetch.
|
||||
mkdir -p "$root/etc"
|
||||
printf '[options]\nArchitecture = auto\n\n[core]\nServer = https://mirror.invalid/$arch/$repo\n' >"$root/etc/pacman.conf"
|
||||
touch "$FAIL_B"
|
||||
status=0
|
||||
output=$(first_boot "$root" 2>&1) || status=$?
|
||||
(( status == 75 )) || fail "a failed step exits 75, so a platform first boot can tell it from a refusal" "status $status: $output"
|
||||
[[ $output == *"Deferred hardware step failed: install/hardware/apple/b.sh"* ]] ||
|
||||
fail "the first boot names the failed step" "$output"
|
||||
[[ $(cat "$RUNS") == "a user= path=$fixture" ]] || fail "the first boot stops at the failed step" "$(cat "$RUNS")"
|
||||
[[ $(queue_of "$root") == $'install/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] ||
|
||||
fail "the failed step and those after it stay queued" "$(queue_of "$root")"
|
||||
[[ -f $root/var/lib/omarchy/image/target.booted && ! -e $root/var/lib/omarchy/image/target ]] ||
|
||||
fail "the machine stops being an image build once its first boot starts"
|
||||
[[ -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -e $root/var/lib/omarchy/image/initramfs-inputs && ! -e $REBUILDS ]] ||
|
||||
fail "the service stays armed and the initramfs rebuild stays owed until the queue is empty"
|
||||
[[ ! -e $LEAF_CALLS ]] || fail "a failed step fetches no package databases" "$(cat "$LEAF_CALLS")"
|
||||
pass "a failed step stays queued with the steps after it"
|
||||
|
||||
rm -f "$FAIL_B" "$RUNS"
|
||||
stub_rebuild limine-mkinitcpio
|
||||
output=$(first_boot "$root") || fail "the next boot finishes the deferred hardware setup" "$output"
|
||||
[[ $(cat "$RUNS") == $'b\nc' ]] || fail "the next boot resumes at the failed step" "$(cat "$RUNS")"
|
||||
[[ $(cat "$REBUILDS") == "limine-mkinitcpio " ]] ||
|
||||
fail "the next boot rebuilds the initramfs the earlier boot owed, through Limine when present" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
[[ ! -e $root/var/lib/omarchy/image/deferred-steps && ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name ]] ||
|
||||
fail "the next boot disarms the service"
|
||||
pass "the next boot resumes at the failed step and finishes"
|
||||
|
||||
# The step that changed the initramfs inputs is the one that failed: its rerun
|
||||
# changes nothing more, and the rebuild is still owed.
|
||||
reset_logs
|
||||
rm -f "$stub_bin/limine-mkinitcpio"
|
||||
root=$(new_root rebuild)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' install/hardware/apple/b.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
touch "$FAIL_B"
|
||||
first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step"
|
||||
rm -f "$FAIL_B"
|
||||
first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup"
|
||||
[[ $(cat "$RUNS") == $'b\nc' && $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] ||
|
||||
fail "a step that changed the initramfs and then failed still gets it rebuilt" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
pass "a step that changed the initramfs and then failed still gets it rebuilt"
|
||||
|
||||
# A step that only adds to the kernel command line Limine's entry tool builds
|
||||
# (as intel/fred.sh does) gets the rebuild too.
|
||||
reset_logs
|
||||
root=$(new_root cmdline)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
cat >"$fixture/install/hardware/cmdline.sh" <<'SH'
|
||||
mkdir -p "$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d"
|
||||
echo 'KERNEL_CMDLINE[default]+=" example=on"' >"$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d/example.conf"
|
||||
SH
|
||||
printf '%s\n' install/hardware/cmdline.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
first_boot "$root" >/dev/null || fail "a step that changes the kernel command line finishes"
|
||||
[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] ||
|
||||
fail "a step that only changed Limine's kernel command line gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
rm -f "$fixture/install/hardware/cmdline.sh"
|
||||
pass "a step that only changed Limine's kernel command line gets the rebuild"
|
||||
|
||||
# Module options reach the initramfs through mkinitcpio's modconf hook, so a
|
||||
# step that only writes /etc/modprobe.d (as nvidia.sh does) gets the rebuild too.
|
||||
reset_logs
|
||||
root=$(new_root modprobe)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
cat >"$fixture/install/hardware/modprobe.sh" <<'SH'
|
||||
mkdir -p "$OMARCHY_IMAGE_ROOT/etc/modprobe.d"
|
||||
echo 'options example flag=1' >"$OMARCHY_IMAGE_ROOT/etc/modprobe.d/example.conf"
|
||||
SH
|
||||
printf '%s\n' install/hardware/modprobe.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
first_boot "$root" >/dev/null || fail "a step that changes module options finishes"
|
||||
[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] ||
|
||||
fail "a step that only changed module options gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
rm -f "$fixture/install/hardware/modprobe.sh"
|
||||
pass "a step that only changed module options gets the rebuild"
|
||||
|
||||
# --- The pacman keyring -------------------------------------------------------
|
||||
|
||||
# Install finalization makes the pacman keyring on plain aarch64.
|
||||
# An image build asks for it instead, so every machine flashed from the image
|
||||
# makes its own master key on its first boot, before any step installs a package.
|
||||
keyring_bin="$test_tmp/keyring-bin"
|
||||
mkdir -p "$keyring_bin" "$fixture/install/post-install"
|
||||
# Finalization's repositories are pacman-templates-test.sh's; here they land in
|
||||
# the root under test, never the host's /etc.
|
||||
sed 's|/etc/pacman|$OMARCHY_IMAGE_ROOT/etc/pacman|g' "$ROOT/install/post-install/pacman.sh" >"$fixture/install/post-install/pacman.sh"
|
||||
cp "$ROOT/install/helpers/pacman.sh" "$fixture/install/helpers/pacman.sh"
|
||||
mkdir -p "$fixture/default"
|
||||
cp -r "$ROOT/default/pacman" "$fixture/default/"
|
||||
: >"$fixture/install/hardware/pacman.sh"
|
||||
for command in pacman-key omarchy-pkg-add; do
|
||||
cat >"$keyring_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "${0##*/}" "$*" >>"$KEYRING"
|
||||
[[ ${0##*/} != "pacman-key" || ! -e $KEYRING_FAIL ]]
|
||||
SH
|
||||
done
|
||||
printf '#!/bin/bash\necho "$PLATFORM"\n' >"$keyring_bin/omarchy-hw-platform"
|
||||
chmod +x "$keyring_bin"/*
|
||||
export KEYRING="$test_tmp/keyring" KEYRING_FAIL="$test_tmp/keyring-fail"
|
||||
request=var/lib/omarchy/image/pacman-keyring
|
||||
|
||||
finalize() {
|
||||
mkdir -p "$1/etc/pacman.d"
|
||||
OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_INSTALL="$fixture/install" PLATFORM=$2 \
|
||||
PATH="$keyring_bin:$base_path" bash -e -c 'source "$1"' bash "$fixture/install/post-install/pacman.sh"
|
||||
}
|
||||
|
||||
keyring_boot() {
|
||||
OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \
|
||||
PATH="$keyring_bin:$base_path" "$ROOT/bin/omarchy-provision-hardware"
|
||||
}
|
||||
|
||||
for platform in aarch64; do
|
||||
rm -f "$KEYRING"
|
||||
root=$(new_root "install-$platform")
|
||||
finalize "$root" "$platform" || fail "$platform: install finalization succeeds"
|
||||
[[ $(cat "$KEYRING") == $'omarchy-pkg-add archlinuxarm-keyring\npacman-key --init\npacman-key --populate' ]] ||
|
||||
fail "$platform: an install makes its pacman keyring in finalization, as before" "$(cat "$KEYRING")"
|
||||
[[ ! -e $root/$request ]] || fail "$platform: an install requests no first-boot keyring"
|
||||
|
||||
rm -f "$KEYRING"
|
||||
root=$(new_root "image-$platform")
|
||||
write_manifest "$root" $'format=1\nplatform='"$platform"$'\n'
|
||||
build "$root" >/dev/null || fail "$platform: the fixture image builds"
|
||||
finalize "$root" "$platform" || fail "$platform: image finalization succeeds"
|
||||
[[ $(cat "$KEYRING") == "omarchy-pkg-add archlinuxarm-keyring" ]] ||
|
||||
fail "$platform: an image build makes no pacman keyring" "$(cat "$KEYRING")"
|
||||
[[ -f $root/$request ]] || fail "$platform: an image build asks its first boot for the keyring"
|
||||
done
|
||||
for platform in aarch64-apple x86; do
|
||||
rm -f "$KEYRING"
|
||||
root=$(new_root "image-$platform")
|
||||
write_manifest "$root" $'format=1\nplatform='"$platform"$'\n'
|
||||
build "$root" >/dev/null || fail "$platform: the fixture image builds"
|
||||
finalize "$root" "$platform" || fail "$platform: image finalization succeeds"
|
||||
finalize "$(new_root "install-$platform")" "$platform" || fail "$platform: install finalization succeeds"
|
||||
[[ ! -e $KEYRING && ! -e $root/$request ]] || fail "$platform: finalization leaves the keyring alone" "$(cat "$KEYRING" 2>/dev/null)"
|
||||
done
|
||||
pass "an image build on plain aarch64 leaves the pacman keyring to the first boot"
|
||||
|
||||
reset_logs
|
||||
rm -f "$KEYRING"
|
||||
root=$(new_root image-aarch64-apple)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the Mac fixture image builds"
|
||||
keyring_boot "$root" >/dev/null || fail "a Mac image's first boot finishes"
|
||||
[[ ! -e $KEYRING ]] || fail "a first boot nobody asked for a keyring leaves the keyring alone" "$(cat "$KEYRING")"
|
||||
pass "a first boot nobody asked for a keyring leaves the keyring alone"
|
||||
|
||||
reset_logs
|
||||
root=$test_tmp/root-image-aarch64
|
||||
mkdir -p "$root/etc/pacman.d/gnupg"
|
||||
touch "$root/etc/pacman.d/gnupg/build-master-key" "$KEYRING_FAIL"
|
||||
status=0
|
||||
output=$(KEYRING=$RUNS keyring_boot "$root" 2>&1) || status=$?
|
||||
(( status == 75 )) || fail "a keyring that can't be made exits 75" "status $status: $output"
|
||||
[[ $output == *"could not create the pacman keyring"* ]] || fail "the first boot says the keyring failed" "$output"
|
||||
[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init" ]] ||
|
||||
fail "no step runs before the keyring exists" "$(cat "$RUNS")"
|
||||
[[ -f $root/$request && $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] ||
|
||||
fail "the keyring request and the whole queue stay for the next boot"
|
||||
pass "a keyring that can't be made stays requested and holds back every step"
|
||||
|
||||
rm -f "$KEYRING_FAIL" "$RUNS"
|
||||
mkdir -p "$root/etc/pacman.d/gnupg"
|
||||
touch "$root/etc/pacman.d/gnupg/build-master-key"
|
||||
output=$(KEYRING=$RUNS keyring_boot "$root") || fail "the next boot makes the keyring and finishes" "$output"
|
||||
[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate"$'\na user= path='"$fixture"$'\nb\nc' ]] ||
|
||||
fail "the first boot makes the keyring, then runs the steps" "$(cat "$RUNS")"
|
||||
[[ ! -e $root/etc/pacman.d/gnupg/build-master-key ]] || fail "the first boot replaces a keyring the build left"
|
||||
[[ ! -e $root/$request && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot clears the keyring request"
|
||||
pass "the first boot replaces any build keyring with the machine's own before the steps run"
|
||||
|
||||
# A keyring request with nothing queued (a build whose hardware setup queued no
|
||||
# step) still gets its keyring.
|
||||
reset_logs
|
||||
rm -f "$RUNS"
|
||||
root=$test_tmp/root-keyring-only
|
||||
mkdir -p "$root/var/lib/omarchy/image"
|
||||
install -m 0644 /dev/null "$root/$request"
|
||||
output=$(KEYRING=$RUNS keyring_boot "$root") || fail "a first boot with only a keyring request finishes" "$output"
|
||||
[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate" && ! -e $root/$request ]] ||
|
||||
fail "a keyring request is honoured with no queued step" "$(cat "$RUNS" 2>/dev/null)"
|
||||
grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring' "$ROOT/install/provisioning/omarchy-provision-hardware.service" ||
|
||||
fail "the first-boot unit also starts for a keyring request alone"
|
||||
pass "a keyring request with no queued step still gets the machine's own keyring"
|
||||
|
||||
# A leaf a later Omarchy no longer ships is dropped; a queue entry outside the
|
||||
# hardware setup stops the run before anything runs.
|
||||
reset_logs
|
||||
root=$(new_root gone)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' install/hardware/removed.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
output=$(first_boot "$root") || fail "a step no longer shipped does not block the rest" "$output"
|
||||
[[ $output == *"Skipping deferred hardware step install/hardware/removed.sh"* && $(cat "$RUNS") == "c" ]] ||
|
||||
fail "a step no longer shipped is skipped and the rest run" "$output"
|
||||
pass "a deferred step Omarchy no longer ships is skipped"
|
||||
|
||||
# A step that would build the boot image itself asks for the rebuild after the
|
||||
# last step instead: one rebuild, even when no
|
||||
# initramfs input changed, and the request outlives a run that stopped early.
|
||||
reset_logs
|
||||
root=$(new_root request)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
first_boot "$root" >/dev/null || fail "a step that asks for nothing finishes"
|
||||
[[ ! -e $REBUILDS ]] || fail "no rebuild without a changed input or a request" "$(cat "$REBUILDS")"
|
||||
reset_logs
|
||||
root=$(new_root request)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
touch "$REQUEST_REBUILD"
|
||||
first_boot "$root" >/dev/null || fail "a step that asks for the rebuild finishes"
|
||||
rm -f "$REQUEST_REBUILD"
|
||||
[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] ||
|
||||
fail "the request alone, with no changed input, gets one rebuild and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
reset_logs
|
||||
root=$(new_root request)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' install/hardware/c.sh install/hardware/apple/b.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
rm -f "$root/etc/mkinitcpio.conf.d/b.conf"
|
||||
touch "$REQUEST_REBUILD" "$FAIL_B"
|
||||
first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step"
|
||||
[[ -f $root/var/lib/omarchy/image/boot-rebuild && ! -e $REBUILDS ]] ||
|
||||
fail "a requested rebuild waits for the queue to empty, and stays requested"
|
||||
rm -f "$FAIL_B" "$REQUEST_REBUILD"
|
||||
first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup"
|
||||
[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] ||
|
||||
fail "the requested rebuild runs once after the last step and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)"
|
||||
pass "a step can ask for the one rebuild after the last step"
|
||||
|
||||
for entry in /etc/passwd install/hardware/../../bin/x.sh install/login/sddm.sh; do
|
||||
reset_logs
|
||||
root=$(new_root unsafe)
|
||||
write_manifest "$root"
|
||||
build "$root" >/dev/null || fail "the fixture image builds"
|
||||
printf '%s\n' "$entry" install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps"
|
||||
status=0
|
||||
first_boot "$root" >/dev/null 2>&1 || status=$?
|
||||
(( status == 1 )) || fail "the first boot refuses the queue entry $entry with status 1, not a step failure's 75" "status $status"
|
||||
[[ ! -e $RUNS && $(head -n 1 "$root/var/lib/omarchy/image/deferred-steps") == "$entry" ]] ||
|
||||
fail "the first boot refuses the queue entry $entry before running anything"
|
||||
done
|
||||
pass "the first boot runs only hardware setup leaves from its queue"
|
||||
|
||||
reset_logs
|
||||
root=$(new_root nothing)
|
||||
output=$(first_boot "$root") || fail "the first-boot hardware setup succeeds with nothing deferred"
|
||||
[[ -z $output && ! -e $root/var/lib/omarchy ]] || fail "the first-boot hardware setup does nothing with nothing deferred" "$output"
|
||||
pass "with nothing deferred the first-boot hardware setup does nothing"
|
||||
|
||||
if env -u OMARCHY_IMAGE_ROOT OMARCHY_PATH="$fixture" PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1; then
|
||||
fail "the first-boot hardware setup refuses a normal user"
|
||||
fi
|
||||
pass "the first-boot hardware setup refuses a normal user"
|
||||
|
||||
# The service fires on the queue this helper writes and hands the machine to
|
||||
# owner setup and the login screen only after it.
|
||||
grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/deferred-steps' "$unit_source" &&
|
||||
grep -qx 'ExecStart=/usr/bin/omarchy-provision-hardware' "$unit_source" &&
|
||||
grep -qx 'Before=omarchy-provision-owner.service display-manager.service' "$unit_source" &&
|
||||
grep -qx 'WantedBy=multi-user.target' "$unit_source" ||
|
||||
fail "the first-boot hardware service runs on the queue before owner setup and the login screen"
|
||||
pass "the first-boot hardware service runs on the queue before owner setup and the login screen"
|
||||
|
||||
# --- Bluetooth on the first boot ---------------------------------------------
|
||||
|
||||
# The adapter brings up bluetooth.target before the first boot runs the
|
||||
# Bluetooth leaf, and a target never starts a unit enabled after its start job
|
||||
# was made, so that boot has to start bluetooth.service itself. Nothing else
|
||||
# does: an install or a rerun of hardware setup leaves the service as it finds it.
|
||||
bt_fixture="$test_tmp/omarchy-bluetooth"
|
||||
mkdir -p "$bt_fixture/install/helpers" "$bt_fixture/install/provisioning" "$bt_fixture/install/hardware"
|
||||
cp "$ROOT/install/helpers/logging.sh" "$ROOT/install/helpers/image-target.sh" "$bt_fixture/install/helpers/"
|
||||
cp "$unit_source" "$bt_fixture/install/provisioning/"
|
||||
cp "$ROOT/install/hardware/bluetooth.sh" "$bt_fixture/install/hardware/"
|
||||
cp "$fixture/install/hardware/c.sh" "$bt_fixture/install/hardware/"
|
||||
printf 'run_logged "$OMARCHY_INSTALL/hardware/%s"\n' bluetooth.sh c.sh >"$bt_fixture/install/hardware/all.sh"
|
||||
|
||||
bt_bin="$test_tmp/bt-bin"
|
||||
mkdir -p "$bt_bin"
|
||||
cat >"$bt_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$SYSTEMCTL_CALLS"
|
||||
case $* in
|
||||
"start --no-block bluetooth.service") [[ ! -e $BT_START_REFUSED ]] ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$bt_bin/systemctl"
|
||||
export SYSTEMCTL_CALLS="$test_tmp/systemctl-calls" BT_START_REFUSED="$test_tmp/bt-start-refused"
|
||||
bt_path="$bt_bin:$base_path"
|
||||
|
||||
bt_image() {
|
||||
local root
|
||||
root=$(new_root "bt-$1")
|
||||
write_manifest "$root"
|
||||
OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \
|
||||
PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null || fail "the Bluetooth fixture image builds"
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
bt_first_boot() {
|
||||
rm -f "$SYSTEMCTL_CALLS" "$RUNS"
|
||||
OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$bt_fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \
|
||||
PATH="$bt_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
started=$'enable bluetooth.service\nstart --no-block bluetooth.service'
|
||||
|
||||
reset_logs
|
||||
rm -f "$BT_START_REFUSED" "$SYSTEMCTL_CALLS"
|
||||
root=$(bt_image started)
|
||||
[[ ! -e $SYSTEMCTL_CALLS ]] || fail "an image build touches no Bluetooth unit" "$(cat "$SYSTEMCTL_CALLS")"
|
||||
bt_first_boot "$root" || fail "the first boot finishes the Bluetooth leaf"
|
||||
[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] ||
|
||||
fail "the first boot enables Bluetooth and starts it without waiting for the job" "$(<"$SYSTEMCTL_CALLS")"
|
||||
[[ $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] ||
|
||||
fail "the first boot goes on past the Bluetooth leaf"
|
||||
pass "the first boot enables Bluetooth and starts it in the same session"
|
||||
|
||||
touch "$BT_START_REFUSED"
|
||||
root=$(bt_image refused)
|
||||
bt_first_boot "$root" || fail "a refused Bluetooth start does not fail the first boot"
|
||||
[[ $(<"$SYSTEMCTL_CALLS") == "$started" && $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] ||
|
||||
fail "a refused Bluetooth start leaves the step done and the queue going" "$(<"$SYSTEMCTL_CALLS")"
|
||||
pass "a refused Bluetooth start does not fail the first boot"
|
||||
rm -f "$BT_START_REFUSED"
|
||||
|
||||
# A later boot that retries the queue is still the machine's own first setup.
|
||||
root=$(bt_image retry)
|
||||
mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted"
|
||||
bt_first_boot "$root" || fail "a retried queue finishes"
|
||||
[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] ||
|
||||
fail "a retried queue starts Bluetooth like the first run" "$(<"$SYSTEMCTL_CALLS")"
|
||||
pass "a retried queue starts Bluetooth like the first run"
|
||||
|
||||
# Hardware setup outside the first boot, even with the marker inherited.
|
||||
rm -f "$SYSTEMCTL_CALLS" "$RUNS"
|
||||
root=$(new_root bt-live)
|
||||
OMARCHY_IMAGE_DEFERRED_HARDWARE=1 OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" \
|
||||
OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null ||
|
||||
fail "hardware setup without a manifest runs the Bluetooth leaf"
|
||||
[[ $(<"$SYSTEMCTL_CALLS") == "enable bluetooth.service" && $(cat "$RUNS") == c ]] ||
|
||||
fail "hardware setup outside the first boot only enables Bluetooth" "$(<"$SYSTEMCTL_CALLS")"
|
||||
pass "hardware setup outside the first boot only enables Bluetooth, as an install always has"
|
||||
|
||||
# --- Root ignores the fixture root ------------------------------------------
|
||||
|
||||
# As namespaced root the fixture root in the environment is ignored: the
|
||||
# fixture image is not built into, and hardware setup runs as on any live system.
|
||||
if [[ -e /var/lib/omarchy/image ]]; then
|
||||
pass "this machine keeps image state of its own; skipping the root override probe"
|
||||
elif unshare --user --map-root-user true 2>/dev/null; then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
|
||||
reset_logs
|
||||
root=$(new_root root-build)
|
||||
write_manifest "$root"
|
||||
OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \
|
||||
PATH="$base_path" "${root_runner[@]}" "$ROOT/bin/omarchy-apply-hardware" --defer-provisioning >/dev/null ||
|
||||
fail "root runs hardware setup with a fixture root in its environment"
|
||||
[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] ||
|
||||
fail "root ignores a fixture manifest named by its environment"
|
||||
|
||||
# Both commands take every path from the helper, which as root answers with
|
||||
# the live ones whatever the environment says.
|
||||
resolved=$(OMARCHY_IMAGE_ROOT="$root" "${root_runner[@]}" bash -c \
|
||||
'source "$1"; omarchy_image_init && printf "%s\n" "$omarchy_image_manifest" "$omarchy_image_queue" "$omarchy_image_systemd_dir"' \
|
||||
bash "$ROOT/install/helpers/image-target.sh") || fail "root resolves the image paths"
|
||||
[[ $resolved == $'/var/lib/omarchy/image/target\n/var/lib/omarchy/image/deferred-steps\n/etc/systemd/system' ]] ||
|
||||
fail "root resolves the live image paths whatever its environment names" "$resolved"
|
||||
pass "root ignores fixture roots in its environment"
|
||||
else
|
||||
pass "no unprivileged user namespace; skipping the root override probe"
|
||||
fi
|
||||
@@ -0,0 +1,442 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# omarchy-hw-platform answers with the image-target manifest while a root is
|
||||
# being built, and with the hardware once it boots. Each world below is what one
|
||||
# detector run sees: image/ is the root, proc/ its /proc (the host's device tree
|
||||
# and PID 1), bin/ a uname for the CPU it runs on.
|
||||
|
||||
detector="$ROOT/bin/omarchy-hw-platform"
|
||||
umask 022
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
apple_manifest=$'format=1\nplatform=aarch64-apple\n'
|
||||
|
||||
# $1 world, $2 CPU, then the host's device-tree tokens (none: no device tree).
|
||||
world() {
|
||||
local dir="$test_tmp/worlds/$1" cpu=$2
|
||||
shift 2
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir/image" "$dir/proc" "$dir/bin" "$dir/host"
|
||||
if (( $# )); then
|
||||
mkdir -p "$dir/proc/device-tree"
|
||||
printf '%s\0' "$@" >"$dir/proc/device-tree/compatible"
|
||||
fi
|
||||
cat >"$dir/bin/uname" <<SH
|
||||
#!/bin/bash
|
||||
[[ \${1:-} == -m ]] && { echo $cpu; exit 0; }
|
||||
exec /usr/bin/uname "\$@"
|
||||
SH
|
||||
chmod +x "$dir/bin/uname"
|
||||
}
|
||||
|
||||
# $1 world, $2 how PID 1 sees the root: chroot (PID 1 is the build host's),
|
||||
# own (PID 1 runs in the root, as in a PID namespace or a booted system),
|
||||
# hidden (PID 1 is visible but its root is not, as for a normal user); $3 what
|
||||
# PID 1 is, systemd unless named. Without a call the world has no /proc/1, as
|
||||
# in a root without /proc or behind hidepid.
|
||||
pid1() {
|
||||
local dir="$test_tmp/worlds/$1"
|
||||
mkdir -p "$dir/proc/1"
|
||||
echo "${3:-systemd}" >"$dir/proc/1/comm"
|
||||
case $2 in
|
||||
chroot) ln -s ../../host "$dir/proc/1/root" ;;
|
||||
own) ln -s ../../image "$dir/proc/1/root" ;;
|
||||
hidden) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
systemd_runs() {
|
||||
mkdir -p "$test_tmp/worlds/$1/image/run/systemd/system"
|
||||
}
|
||||
|
||||
# $1 world, $2 manifest body.
|
||||
manifest() {
|
||||
local image_dir="$test_tmp/worlds/$1/image/var/lib/omarchy/image"
|
||||
mkdir -p "$image_dir"
|
||||
chmod 0755 "$image_dir"
|
||||
printf '%s' "$2" >"$image_dir/target"
|
||||
chmod 0644 "$image_dir/target"
|
||||
}
|
||||
|
||||
in_world() {
|
||||
local dir="$test_tmp/worlds/$1"
|
||||
shift
|
||||
OMARCHY_PROC_ROOT="$dir/proc" OMARCHY_IMAGE_ROOT="$dir/image" PATH="$dir/bin:$ROOT/bin:$PATH" "$@"
|
||||
}
|
||||
|
||||
detect() {
|
||||
in_world "$1" "$detector" 2>"$test_tmp/error"
|
||||
}
|
||||
|
||||
expect() {
|
||||
local name=$1 expected=$2 description=$3 actual
|
||||
actual=$(detect "$name") || fail "$description" "$(cat "$test_tmp/error")"
|
||||
[[ $actual == "$expected" ]] || fail "$description" "expected: $expected
|
||||
actual: $actual"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
expect_refused() {
|
||||
local name=$1 message=$2 description=$3
|
||||
if detect "$name" >/dev/null; then
|
||||
fail "$description"
|
||||
fi
|
||||
grep -Fq "$message" "$test_tmp/error" || fail "$description explains itself" "$(cat "$test_tmp/error")"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
# --- Root reads only the live system -----------------------------------------
|
||||
|
||||
root_runner=()
|
||||
if (( EUID != 0 )); then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
fi
|
||||
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
|
||||
live=$("${root_runner[@]}" "$detector") || fail "root detects the live platform"
|
||||
apple_live=0
|
||||
"${root_runner[@]}" "$ROOT/bin/omarchy-hw-aarch64-apple" || apple_live=$?
|
||||
|
||||
# A world that would make any environment-led detector answer with another
|
||||
# platform than the live one.
|
||||
if [[ $live == "aarch64-apple" ]]; then
|
||||
hostile=aarch64
|
||||
hostile_tokens=("raspberrypi,5-model-b" "brcm,bcm2712")
|
||||
else
|
||||
hostile=aarch64-apple
|
||||
hostile_tokens=("apple,j416c" "apple,t6021" "apple,arm-platform")
|
||||
fi
|
||||
world hostile aarch64 "${hostile_tokens[@]}"
|
||||
pid1 hostile chroot
|
||||
manifest hostile $'format=1\nplatform='"$hostile"$'\n'
|
||||
hostile_dir="$test_tmp/worlds/hostile"
|
||||
printf 'echo %s\nexit 0\n' "$hostile" >"$test_tmp/bash-env"
|
||||
|
||||
# Exported functions shadow commands and even builtins in a plain bash, and
|
||||
# BASH_ENV runs before its first line; --clean-environment is what the
|
||||
# detector passes itself once it has restarted.
|
||||
hostile_env=(
|
||||
OMARCHY_PROC_ROOT="$hostile_dir/proc" OMARCHY_SYS_ROOT="$hostile_dir/sys" OMARCHY_IMAGE_ROOT="$hostile_dir/image"
|
||||
PATH="$hostile_dir/bin:$ROOT/bin:$PATH" BASH_ENV="$test_tmp/bash-env"
|
||||
'BASH_FUNC_uname%%=() { echo aarch64; }'
|
||||
'BASH_FUNC_mapfile%%=() { tokens=('"${hostile_tokens[0]}"'); }'
|
||||
'BASH_FUNC_stat%%=() { echo 0 644; }'
|
||||
'BASH_FUNC_dirname%%=() { echo '"$hostile_dir/bin"'; }'
|
||||
)
|
||||
printf '#!/bin/bash\necho %s\n' "$hostile" >"$hostile_dir/bin/omarchy-hw-platform"
|
||||
chmod +x "$hostile_dir/bin/omarchy-hw-platform"
|
||||
for flag in "" --clean-environment; do
|
||||
args=()
|
||||
[[ -z $flag ]] || args=("$flag")
|
||||
overridden=$(env "${hostile_env[@]}" "${root_runner[@]}" "$detector" "${args[@]}") ||
|
||||
fail "root detects the live platform with a hostile environment${flag:+ and $flag}"
|
||||
[[ $overridden == "$live" ]] ||
|
||||
fail "root ignores fixture roots, PATH, BASH_ENV and exported functions${flag:+ with $flag}" "live: $live
|
||||
hostile: $overridden"
|
||||
done
|
||||
apple_hostile=0
|
||||
env "${hostile_env[@]}" "${root_runner[@]}" "$ROOT/bin/omarchy-hw-aarch64-apple" || apple_hostile=$?
|
||||
(( apple_hostile == apple_live )) || fail "the Apple predicate ignores a hostile root environment"
|
||||
pass "root ignores fixture roots, PATH, BASH_ENV and exported functions"
|
||||
else
|
||||
skip "no unprivileged user namespace; skipping the root environment probe"
|
||||
fi
|
||||
|
||||
# Root with a manifest at the live path: a private mount namespace lays a
|
||||
# fixture over /var/lib and /run, and in a PID namespace PID 1 is a copy of bash
|
||||
# named for what it plays, the build or systemd, and runs the detector.
|
||||
mkdir -p "$test_tmp/init"
|
||||
cp "$BASH" "$test_tmp/init/bash"
|
||||
cp "$BASH" "$test_tmp/init/systemd"
|
||||
if (( EUID != 0 )) && "$test_tmp/init/bash" -c true 2>/dev/null &&
|
||||
unshare --user --map-root-user --mount --pid --fork --mount-proc true 2>/dev/null; then
|
||||
# $1 fixture, $2 PID 1 (bash or systemd), $3 a file to lay over the manifest.
|
||||
live_root() {
|
||||
local fixture=$1 init=$2 owner_file=${3:-}
|
||||
unshare --user --map-root-user --mount --pid --fork --mount-proc "$test_tmp/init/$init" -c '
|
||||
mount --bind "$1/var/lib" /var/lib && mount --bind "$1/run" /run || exit 99
|
||||
if [[ -n $3 ]]; then mount --bind "$3" /var/lib/omarchy/image/target || exit 99; fi
|
||||
"$2"
|
||||
status=$?
|
||||
exit "$status"
|
||||
' "$init" "$fixture" "$detector" "$owner_file" 2>"$test_tmp/error"
|
||||
}
|
||||
baseline=$(unshare --user --map-root-user --mount --pid --fork --mount-proc "$detector") ||
|
||||
fail "root detects the live platform in a PID namespace"
|
||||
|
||||
# A target the hardware would not give, so only the manifest can name it.
|
||||
target=aarch64-apple
|
||||
[[ $baseline != "aarch64-apple" ]] || target=aarch64
|
||||
fixture="$test_tmp/live-root"
|
||||
mkdir -p "$fixture/var/lib/omarchy/image" "$fixture/run"
|
||||
printf 'format=1\nplatform=%s\n' "$target" >"$fixture/var/lib/omarchy/image/target"
|
||||
|
||||
expect_live_manifest() {
|
||||
local description=$1 actual
|
||||
if [[ $(uname -m) == aarch64 ]]; then
|
||||
actual=$(live_root "$fixture" bash) || fail "$description" "$(cat "$test_tmp/error")"
|
||||
[[ $actual == "$target" ]] || fail "$description" "expected: $target
|
||||
actual: $actual"
|
||||
else
|
||||
if live_root "$fixture" bash >/dev/null; then
|
||||
fail "$description, and a $target target on $(uname -m) contradicts it"
|
||||
fi
|
||||
grep -Fq "/var/lib/omarchy/image/target names $target hardware but the CPU is" "$test_tmp/error" ||
|
||||
fail "$description" "$(cat "$test_tmp/error")"
|
||||
fi
|
||||
pass "$description"
|
||||
}
|
||||
expect_live_manifest "root answers with the live manifest in a build with a /run of its own"
|
||||
|
||||
mkdir -p "$fixture/run/systemd/system"
|
||||
expect_live_manifest "root answers with the live manifest in a build whose PID 1 is not systemd, with systemd in /run"
|
||||
|
||||
actual=$(live_root "$fixture" systemd) || fail "a booted root ignores the live manifest" "$(cat "$test_tmp/error")"
|
||||
[[ $actual == "$baseline" ]] || fail "a booted root ignores the live manifest" "expected: $baseline
|
||||
actual: $actual"
|
||||
pass "a booted root answers with its hardware despite a manifest"
|
||||
rmdir "$fixture/run/systemd/system"
|
||||
|
||||
# A file the namespace's root does not own: the real root's, unmapped here.
|
||||
if live_root "$fixture" bash /etc/os-release >/dev/null; then
|
||||
fail "root refuses a manifest another user owns"
|
||||
fi
|
||||
grep -Fq "is not a root-owned regular file" "$test_tmp/error" || fail "root refuses a manifest another user owns" "$(cat "$test_tmp/error")"
|
||||
pass "root refuses a manifest another user owns"
|
||||
else
|
||||
skip "no unprivileged user, mount and PID namespaces, or an executable temporary directory; skipping the live manifest probe"
|
||||
fi
|
||||
|
||||
require_platform_fixtures "the image-target fixtures"
|
||||
|
||||
# --- Image builds -------------------------------------------------------------
|
||||
|
||||
# An Apple image built on an x86 host: the chroot runs under aarch64 emulation,
|
||||
# the host has no device tree, and PID 1 is the host's.
|
||||
world x86-host aarch64
|
||||
pid1 x86-host chroot
|
||||
manifest x86-host "$apple_manifest"
|
||||
expect x86-host aarch64-apple "an Apple image built in a chroot on an x86 host is Apple Silicon"
|
||||
|
||||
# The same with the host's /run bound into the chroot, systemd's directory and all.
|
||||
systemd_runs x86-host
|
||||
expect x86-host aarch64-apple "a chroot build ignores the host's systemd in a bound /run"
|
||||
|
||||
# On a plain aarch64 host, whose own device tree the chroot can see.
|
||||
world arm-host aarch64 linux,dummy-virt
|
||||
pid1 arm-host chroot
|
||||
manifest arm-host "$apple_manifest"
|
||||
expect arm-host aarch64-apple "an Apple image built in a chroot on a plain aarch64 host is Apple Silicon"
|
||||
|
||||
# The image builder's isolated chroot: a PID namespace whose PID 1 is the build.
|
||||
world builder aarch64 raspberrypi,5-model-b brcm,bcm2712
|
||||
pid1 builder own bash
|
||||
manifest builder "$apple_manifest"
|
||||
expect builder aarch64-apple "an Apple image built in its own PID namespace is Apple Silicon"
|
||||
|
||||
# The same with the host's /run bound in: PID 1 is still not systemd.
|
||||
systemd_runs builder
|
||||
expect builder aarch64-apple "a PID namespace build ignores the host's systemd in a bound /run"
|
||||
|
||||
# A root without /proc, with a /run of its own.
|
||||
world no-proc aarch64
|
||||
manifest no-proc "$apple_manifest"
|
||||
expect no-proc aarch64-apple "an Apple image built without /proc is Apple Silicon"
|
||||
|
||||
# The host never decides the target, even when it is a Mac or its tree is
|
||||
# contradictory.
|
||||
world on-a-mac aarch64 apple,j416c apple,t6021 apple,arm-platform
|
||||
pid1 on-a-mac chroot
|
||||
manifest on-a-mac $'format=1\nplatform=aarch64\n'
|
||||
expect on-a-mac aarch64 "a plain aarch64 image built on a Mac is plain aarch64"
|
||||
world odd-host aarch64 apple,j416c qcom,x1e80100
|
||||
pid1 odd-host chroot
|
||||
manifest odd-host $'format=1\nplatform=aarch64\n'
|
||||
expect odd-host aarch64 "a build never reads the host's device tree"
|
||||
|
||||
# Image builders written before the platform names settled write the old ones.
|
||||
world legacy-apple aarch64
|
||||
pid1 legacy-apple chroot
|
||||
manifest legacy-apple $'format=1\nplatform=apple-silicon\n'
|
||||
expect legacy-apple aarch64-apple "an old builder's apple-silicon manifest reads as aarch64-apple"
|
||||
world legacy-arm aarch64
|
||||
pid1 legacy-arm chroot
|
||||
manifest legacy-arm $'format=1\nplatform=generic-aarch64\n'
|
||||
expect legacy-arm aarch64 "an old builder's generic-aarch64 manifest reads as aarch64"
|
||||
world legacy-x86 x86_64
|
||||
pid1 legacy-x86 chroot
|
||||
manifest legacy-x86 $'format=1\nplatform=generic\n'
|
||||
expect legacy-x86 x86 "an old builder's generic manifest reads as x86"
|
||||
|
||||
# Comments and keys a later format adds are ignored.
|
||||
world commented aarch64
|
||||
pid1 commented chroot
|
||||
manifest commented $'# written by the image builder\nformat=1\nbuilder=ci\nplatform=aarch64-apple\n'
|
||||
expect commented aarch64-apple "comments and unknown manifest keys are ignored"
|
||||
|
||||
# --- Booted systems -----------------------------------------------------------
|
||||
|
||||
# A booted Mac whose manifest outlived its first boot: the hardware decides, and
|
||||
# not even a broken manifest stops it.
|
||||
world booted-mac aarch64 apple,j416c apple,t6021 apple,arm-platform
|
||||
pid1 booted-mac own
|
||||
systemd_runs booted-mac
|
||||
manifest booted-mac $'format=1\nplatform=aarch64\n'
|
||||
expect booted-mac aarch64-apple "a booted Mac with a stale manifest is Apple Silicon"
|
||||
manifest booted-mac "not a manifest"
|
||||
expect booted-mac aarch64-apple "a booted Mac ignores a malformed stale manifest"
|
||||
|
||||
world booted-qualcomm aarch64 lenovo,yoga-slim7x qcom,x1e80100
|
||||
pid1 booted-qualcomm own
|
||||
systemd_runs booted-qualcomm
|
||||
manifest booted-qualcomm "$apple_manifest"
|
||||
expect booted-qualcomm aarch64 "a booted Snapdragon laptop with an Apple manifest is plain aarch64"
|
||||
|
||||
world booted-x86 x86_64
|
||||
pid1 booted-x86 own
|
||||
systemd_runs booted-x86
|
||||
manifest booted-x86 "$apple_manifest"
|
||||
expect booted-x86 x86 "a booted x86 machine with an Apple manifest is x86"
|
||||
|
||||
# An Apple image under VM acceptance, booted before its first-boot setup ran.
|
||||
world booted-vm aarch64 linux,dummy-virt
|
||||
pid1 booted-vm own
|
||||
systemd_runs booted-vm
|
||||
manifest booted-vm "$apple_manifest"
|
||||
expect booted-vm aarch64 "a booted VM with an Apple manifest is plain aarch64"
|
||||
|
||||
# What a normal user cannot see never makes a build: PID 1's root, or PID 1
|
||||
# itself behind hidepid.
|
||||
world user-view aarch64 apple,j416c apple,t6021 apple,arm-platform
|
||||
pid1 user-view hidden
|
||||
systemd_runs user-view
|
||||
manifest user-view $'format=1\nplatform=aarch64\n'
|
||||
expect user-view aarch64-apple "a booted system whose PID 1 root cannot be compared uses its hardware"
|
||||
world hidepid aarch64 apple,j416c apple,t6021 apple,arm-platform
|
||||
systemd_runs hidepid
|
||||
manifest hidepid "not a manifest"
|
||||
expect hidepid aarch64-apple "a booted system whose PID 1 is hidden uses its hardware"
|
||||
|
||||
# --- No manifest --------------------------------------------------------------
|
||||
|
||||
# An installer on the target machine chroots into the new root with no
|
||||
# manifest: the hardware it runs on is the target.
|
||||
world installer aarch64 apple,j314s apple,t6000 apple,arm-platform
|
||||
pid1 installer chroot
|
||||
expect installer aarch64-apple "a chroot without a manifest uses the hardware"
|
||||
|
||||
# A manifest retired by the first boot no longer names anything.
|
||||
world retired aarch64 linux,dummy-virt
|
||||
pid1 retired chroot
|
||||
manifest retired "$apple_manifest"
|
||||
mv "$test_tmp/worlds/retired/image/var/lib/omarchy/image/target" "$test_tmp/worlds/retired/image/var/lib/omarchy/image/target.booted"
|
||||
expect retired aarch64 "a retired manifest is not read"
|
||||
|
||||
# --- Invalid manifests stop a build -------------------------------------------
|
||||
|
||||
invalid() {
|
||||
local name=$1 body=$2 message=$3 description=$4
|
||||
world "$name" aarch64 linux,dummy-virt
|
||||
pid1 "$name" chroot
|
||||
manifest "$name" "$body"
|
||||
expect_refused "$name" "$message" "$description"
|
||||
}
|
||||
|
||||
invalid no-format $'platform=aarch64-apple\n' "is not format=1" "a manifest without a format is refused"
|
||||
invalid format-2 $'format=2\nplatform=aarch64-apple\n' "is not format=1" "a manifest of another format is refused"
|
||||
invalid empty "" "is not format=1" "an empty manifest is refused"
|
||||
invalid unknown $'format=1\nplatform=intel-mac\n' "names no known platform: intel-mac" "a manifest naming an unknown platform is refused"
|
||||
invalid no-platform $'format=1\n' "names no known platform: none" "a manifest naming no platform is refused"
|
||||
invalid bare-line $'format=1\naarch64-apple\n' "is malformed: aarch64-apple" "a manifest line without a key is refused"
|
||||
|
||||
world x86-cpu x86_64
|
||||
pid1 x86-cpu chroot
|
||||
manifest x86-cpu "$apple_manifest"
|
||||
expect_refused x86-cpu "names aarch64-apple hardware but the CPU is x86_64" "an Apple manifest on an x86 CPU contradicts it"
|
||||
world arm-generic aarch64
|
||||
pid1 arm-generic chroot
|
||||
manifest arm-generic $'format=1\nplatform=x86\n'
|
||||
expect_refused arm-generic "names x86 hardware but the CPU is aarch64" "an x86 manifest on an aarch64 CPU contradicts it"
|
||||
|
||||
untrusted="is not a root-owned regular file"
|
||||
world symlink aarch64
|
||||
pid1 symlink chroot
|
||||
manifest symlink "$apple_manifest"
|
||||
image_dir="$test_tmp/worlds/symlink/image/var/lib/omarchy/image"
|
||||
mv "$image_dir/target" "$image_dir/real-target"
|
||||
ln -s real-target "$image_dir/target"
|
||||
expect_refused symlink "$untrusted" "a symlinked manifest is refused"
|
||||
|
||||
world dangling aarch64
|
||||
pid1 dangling chroot
|
||||
mkdir -p "$test_tmp/worlds/dangling/image/var/lib/omarchy/image"
|
||||
ln -s missing "$test_tmp/worlds/dangling/image/var/lib/omarchy/image/target"
|
||||
expect_refused dangling "$untrusted" "a dangling manifest symlink is refused"
|
||||
|
||||
world directory aarch64
|
||||
pid1 directory chroot
|
||||
mkdir -p "$test_tmp/worlds/directory/image/var/lib/omarchy/image/target"
|
||||
expect_refused directory "$untrusted" "a manifest that is a directory is refused"
|
||||
|
||||
world writable aarch64
|
||||
pid1 writable chroot
|
||||
manifest writable "$apple_manifest"
|
||||
chmod 0664 "$test_tmp/worlds/writable/image/var/lib/omarchy/image/target"
|
||||
expect_refused writable "$untrusted" "a group-writable manifest is refused"
|
||||
|
||||
world writable-dir aarch64
|
||||
pid1 writable-dir chroot
|
||||
manifest writable-dir "$apple_manifest"
|
||||
chmod 0777 "$test_tmp/worlds/writable-dir/image/var/lib/omarchy/image"
|
||||
expect_refused writable-dir "$untrusted" "a manifest in a world-writable directory is refused"
|
||||
|
||||
# Another owner: a fixture is trusted when the test's own user owns it, so a
|
||||
# stat reporting root stands in for a file this user did not write.
|
||||
world other-owner aarch64
|
||||
pid1 other-owner chroot
|
||||
manifest other-owner "$apple_manifest"
|
||||
real_stat=$(command -v stat)
|
||||
cat >"$test_tmp/worlds/other-owner/bin/stat" <<SH
|
||||
#!/bin/bash
|
||||
[[ \${!#} == */var/lib/omarchy/image/target ]] && { echo "0 644"; exit 0; }
|
||||
exec $real_stat "\$@"
|
||||
SH
|
||||
chmod +x "$test_tmp/worlds/other-owner/bin/stat"
|
||||
expect_refused other-owner "$untrusted" "a manifest another user owns is refused"
|
||||
|
||||
# --- What a build sets up from it ---------------------------------------------
|
||||
|
||||
# The Apple predicate of an Apple image built on an x86 host, and of a generic
|
||||
# image built on a Mac.
|
||||
in_world x86-host "$ROOT/bin/omarchy-hw-aarch64-apple" || fail "the Apple predicate accepts an Apple image build"
|
||||
if in_world on-a-mac "$ROOT/bin/omarchy-hw-aarch64-apple"; then
|
||||
fail "the Apple predicate rejects a plain aarch64 image built on a Mac"
|
||||
fi
|
||||
pass "the Apple predicate follows the image target"
|
||||
|
||||
# omarchy-mac's packages and units still call the predicate by its old name.
|
||||
in_world x86-host "$ROOT/bin/omarchy-hw-apple-silicon" || fail "the old Apple predicate name accepts an Apple image build"
|
||||
if in_world on-a-mac "$ROOT/bin/omarchy-hw-apple-silicon"; then
|
||||
fail "the old Apple predicate name rejects a plain aarch64 image built on a Mac"
|
||||
fi
|
||||
pass "the old Apple predicate name answers as omarchy-hw-aarch64-apple"
|
||||
|
||||
packages=$(in_world x86-host env OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults") || fail "an Apple image build composes its packages"
|
||||
[[ $packages == "$(OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults" aarch64-apple)" ]] ||
|
||||
fail "an Apple image build installs the Apple Silicon package set"
|
||||
packages=$(in_world on-a-mac env OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults") || fail "a plain aarch64 image build composes its packages"
|
||||
[[ $packages == "$(OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults" aarch64)" ]] ||
|
||||
fail "a plain aarch64 image built on a Mac installs the plain aarch64 package set"
|
||||
packages=$(in_world odd-host env OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults") || fail "an image built on a contradictory host composes its packages"
|
||||
[[ $packages == "$(OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-pkg-defaults" aarch64)" ]] ||
|
||||
fail "an image built on a contradictory host installs its target's package set"
|
||||
pass "the default package set follows the image target"
|
||||
|
||||
# A caller in a private PID namespace looks like a build, so no unit may use one.
|
||||
units=$(grep -rlE '^[[:space:]]*PrivatePIDs=' "$ROOT" --include='*.service' --include='*.conf' --exclude-dir=.git || true)
|
||||
[[ -z $units ]] || fail "no unit runs in a private PID namespace" "$units"
|
||||
pass "no unit runs in a private PID namespace"
|
||||
@@ -14,6 +14,10 @@ stub_bin="$tmpdir/bin"
|
||||
mkdir -p "$home/.config" "$stub_bin"
|
||||
cp -r "$ROOT/config/hypr" "$home/.config/hypr"
|
||||
|
||||
# No platform package: neither its key names nor its binds.
|
||||
menu="$tmpdir/omarchy-menu-keybindings"
|
||||
platform_root_copy "$ROOT/bin/omarchy-menu-keybindings" "$menu" "$tmpdir/no-platform"
|
||||
|
||||
# The menu reads binds from Hyprland, which is not running here, so stand in for
|
||||
# it. A Lua bind reports dispatcher __lua and no arg, and the menu recovers both
|
||||
# from the Lua source; an exec bind carries its own command. Both shapes matter:
|
||||
@@ -43,7 +47,7 @@ stub_hyprctl() {
|
||||
keybindings() {
|
||||
env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" \
|
||||
XDG_CACHE_HOME="$tmpdir/cache" OMARCHY_PATH="$ROOT" \
|
||||
bash "$ROOT/bin/omarchy-menu-keybindings" --print
|
||||
bash "$menu" --print
|
||||
}
|
||||
|
||||
# Closing a window and toggling the scratchpad are two of the actions Omarchy
|
||||
@@ -69,6 +73,17 @@ grep -q 'SUPER + I *→ Switch input language' <<<"$rendered" ||
|
||||
fail "the input switch shortcut appears in the cheatsheet" "$rendered"
|
||||
pass "the input switch shortcut appears in the cheatsheet"
|
||||
|
||||
# The menu's own scan of the config runs lua -E, so no LUA_INIT or LUA_PATH from
|
||||
# the environment reaches it and nothing there can move the platform root.
|
||||
printf "io.open('%s', 'w'):close()\n" "$tmpdir/init-ran" >"$tmpdir/init.lua"
|
||||
LUA_INIT="@$tmpdir/init.lua" lua -e '' && [[ -e $tmpdir/init-ran ]] || fail "the LUA_INIT probe runs in a plain lua"
|
||||
rm -f "$tmpdir/init-ran"
|
||||
env -i PATH="$stub_bin:$ROOT/bin:$PATH" HOME="$home" XDG_CACHE_HOME="$tmpdir/cache-init" OMARCHY_PATH="$ROOT" \
|
||||
LUA_INIT="@$tmpdir/init.lua" LUA_INIT_5_5="@$tmpdir/init.lua" LUA_INIT_5_4="@$tmpdir/init.lua" LUA_PATH="$tmpdir/?.lua" \
|
||||
bash "$ROOT/bin/omarchy-menu-keybindings" --print >/dev/null
|
||||
[[ ! -e $tmpdir/init-ran ]] || fail "the menu's config scan ignores LUA_INIT from the environment"
|
||||
pass "the menu's config scan ignores LUA_INIT and LUA_PATH from the environment"
|
||||
|
||||
(( $(grep -c '→ Close window$' <<<"$rendered") == 1 )) ||
|
||||
fail "an alternative chord joins the row of the first one" "$rendered"
|
||||
grep -q 'SUPER + W / SUPER + Q *→ Close window' <<<"$rendered" ||
|
||||
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# The watcher half opens its event stream with {fd}> redirection.
|
||||
(( BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 401 )) || fail "this test runs under bash 4.1 or newer" "$BASH_VERSION"
|
||||
|
||||
lid_inhibit="$ROOT/bin/omarchy-system-lid-inhibit"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
watch_pid=""
|
||||
events_fd=""
|
||||
|
||||
# The watcher forks clamshell retries and a poll; orphaning them would leave
|
||||
# them syncing against a deleted test tree.
|
||||
stop_watcher() {
|
||||
local child descendants
|
||||
|
||||
if [[ -n $watch_pid ]]; then
|
||||
descendants=$(pgrep -P "$watch_pid" 2>/dev/null || true)
|
||||
for child in $descendants; do
|
||||
descendants+=" $(pgrep -P "$child" 2>/dev/null || true)"
|
||||
done
|
||||
|
||||
kill -KILL "$watch_pid" 2>/dev/null || true
|
||||
wait "$watch_pid" 2>/dev/null || true
|
||||
for child in $descendants; do
|
||||
kill -KILL "$child" 2>/dev/null || true
|
||||
done
|
||||
watch_pid=""
|
||||
fi
|
||||
|
||||
if [[ -n $events_fd ]]; then
|
||||
exec {events_fd}>&-
|
||||
events_fd=""
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
stop_watcher
|
||||
rm -rf "$test_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fake_bin="$test_tmp/bin"
|
||||
drm_path="$test_tmp/drm"
|
||||
unit_state="$test_tmp/unit-active"
|
||||
call_log="$test_tmp/calls"
|
||||
monitors="$test_tmp/monitors.json"
|
||||
hyprctl_fail="$test_tmp/hyprctl-fails"
|
||||
busctl_fail="$test_tmp/busctl-fails"
|
||||
cgroup="$test_tmp/cgroup"
|
||||
events="$test_tmp/events"
|
||||
mkdir -p "$fake_bin"
|
||||
|
||||
# The user manager, reduced to the one unit. Like the real one, it refuses to
|
||||
# start a unit name that is already loaded.
|
||||
cat >"$fake_bin/systemd-run" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
if [[ -e $OMARCHY_TEST_UNIT_STATE ]]; then
|
||||
printf 'refused\n' >>"$OMARCHY_TEST_CALL_LOG"
|
||||
echo "Unit already loaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'start %s\n' "$*" >>"$OMARCHY_TEST_CALL_LOG"
|
||||
touch "$OMARCHY_TEST_UNIT_STATE"
|
||||
SH
|
||||
|
||||
cat >"$fake_bin/systemctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
case "$*" in
|
||||
"--user is-active --quiet omarchy-lid-inhibit.service")
|
||||
[[ -e $OMARCHY_TEST_UNIT_STATE ]]
|
||||
;;
|
||||
"--user stop omarchy-lid-inhibit.service")
|
||||
printf 'stop\n' >>"$OMARCHY_TEST_CALL_LOG"
|
||||
rm -f "$OMARCHY_TEST_UNIT_STATE"
|
||||
;;
|
||||
*)
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$fake_bin/busctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ $* == *" HandleLidSwitchDocked" ]] || exit 1
|
||||
[[ -f $OMARCHY_TEST_BUSCTL_FAIL ]] && exit 1
|
||||
printf 's "%s"\n' "$OMARCHY_TEST_DOCKED_ACTION"
|
||||
SH
|
||||
|
||||
cat >"$fake_bin/omarchy-hw-laptop" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ $OMARCHY_TEST_LAPTOP == 1 ]]
|
||||
SH
|
||||
|
||||
cat >"$fake_bin/hyprctl" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ $* == "monitors all -j" ]] || exit 0
|
||||
[[ -f $OMARCHY_TEST_HYPRCTL_FAIL ]] && exit 1
|
||||
cat "$OMARCHY_TEST_MONITORS"
|
||||
SH
|
||||
|
||||
chmod +x "$fake_bin"/*
|
||||
|
||||
export OMARCHY_DRM_PATH="$drm_path"
|
||||
export OMARCHY_TEST_UNIT_STATE="$unit_state"
|
||||
export OMARCHY_TEST_CALL_LOG="$call_log"
|
||||
export OMARCHY_TEST_MONITORS="$monitors"
|
||||
export OMARCHY_TEST_HYPRCTL_FAIL="$hyprctl_fail"
|
||||
export OMARCHY_TEST_BUSCTL_FAIL="$busctl_fail"
|
||||
export OMARCHY_CGROUP_PATH="$cgroup"
|
||||
export OMARCHY_TEST_DOCKED_ACTION=ignore
|
||||
export OMARCHY_TEST_LAPTOP=1
|
||||
|
||||
# Arguments come in pairs: connector and status. The card itself sits beside
|
||||
# its connectors, as in sysfs.
|
||||
write_connectors() {
|
||||
rm -rf "$drm_path"
|
||||
mkdir -p "$drm_path/card2"
|
||||
|
||||
while (( $# )); do
|
||||
mkdir -p "$drm_path/$1"
|
||||
printf '%s\n' "$2" >"$drm_path/$1/status"
|
||||
shift 2
|
||||
done
|
||||
}
|
||||
|
||||
# What Hyprland reports: every output it knows, with whether it is disabled.
|
||||
write_monitors() {
|
||||
printf '%s\n' "$1" >"$monitors"
|
||||
}
|
||||
|
||||
reset_unit() {
|
||||
rm -f "$unit_state" "$hyprctl_fail" "$busctl_fail"
|
||||
: >"$call_log"
|
||||
}
|
||||
|
||||
# Where the caller runs: an SSH login's session scope, outside the user manager.
|
||||
printf '0::/user.slice/user-1000.slice/session-3.scope\n' >"$cgroup"
|
||||
|
||||
clamshell='[{"name":"eDP-1","disabled":true,"dpmsStatus":true},{"name":"USB-2","disabled":false,"dpmsStatus":true}]'
|
||||
|
||||
sync_inhibit() {
|
||||
PATH="$fake_bin:$PATH" "$lid_inhibit"
|
||||
}
|
||||
|
||||
held() {
|
||||
[[ -e $unit_state ]]
|
||||
}
|
||||
|
||||
count_calls() {
|
||||
grep -c "^$1" "$call_log" || true
|
||||
}
|
||||
|
||||
# Apple Silicon in clamshell: the panel on eDP, a USB-C display on a "USB"
|
||||
# connector that logind does not count as external.
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
write_monitors "$clamshell"
|
||||
sync_inhibit
|
||||
|
||||
held || fail "a USB-C display on a USB connector takes the lid inhibitor"
|
||||
start=$(grep '^start' "$call_log")
|
||||
for arg in --no-ask-password --what=handle-lid-switch --mode=block PartOf=graphical-session.target "sleep infinity"; do
|
||||
[[ $start == *"$arg"* ]] || fail "the inhibitor unit is started with $arg" "$start"
|
||||
done
|
||||
pass "a USB-C display on a USB connector takes a blocking lid inhibitor without prompting"
|
||||
|
||||
[[ $start != *BindsTo=* ]] || fail "a caller outside the user manager binds the inhibitor to nothing" "$start"
|
||||
pass "a caller outside the user manager binds the inhibitor to nothing"
|
||||
|
||||
# The watcher runs in a scope of the user manager; the inhibitor stops with it.
|
||||
reset_unit
|
||||
watcher_scope='app-Hyprland-omarchy\x2dhyprland\x2dmonitor\x2dwatch-f1fda151.scope'
|
||||
printf '0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-graphical.slice/%s\n' "$watcher_scope" >"$cgroup"
|
||||
sync_inhibit
|
||||
start=$(grep '^start' "$call_log")
|
||||
# systemd-run unquotes property values: a single backslash would name a unit
|
||||
# that does not exist, and the inhibitor would never start.
|
||||
bound_scope='app-Hyprland-omarchy\\x2dhyprland\\x2dmonitor\\x2dwatch-f1fda151.scope'
|
||||
[[ $start == *"--property=BindsTo=$bound_scope --property=After=$bound_scope "* ]] ||
|
||||
fail "the inhibitor is bound to the watcher's unit" "$start"
|
||||
printf '0::/user.slice/user-1000.slice/session-3.scope\n' >"$cgroup"
|
||||
pass "the inhibitor is bound to the watcher's unit, so it cannot outlive it"
|
||||
|
||||
sync_inhibit
|
||||
sync_inhibit
|
||||
(( $(count_calls start) == 1 && $(count_calls stop) == 0 && $(count_calls refused) == 0 )) ||
|
||||
fail "repeated syncs keep a single inhibitor" "$(<"$call_log")"
|
||||
pass "repeated syncs keep a single inhibitor"
|
||||
|
||||
# Some drivers report a connector disabled while DPMS blanks it; Hyprland keeps
|
||||
# the monitor enabled. Releasing here would suspend a closed laptop the moment
|
||||
# its external display went idle.
|
||||
write_monitors '[{"name":"eDP-1","disabled":true,"dpmsStatus":false},{"name":"USB-2","disabled":false,"dpmsStatus":false}]'
|
||||
sync_inhibit
|
||||
held || fail "a USB-C display blanked by DPMS keeps the inhibitor" "$(<"$call_log")"
|
||||
pass "a USB-C display blanked by DPMS keeps the inhibitor"
|
||||
|
||||
# Hyprland misses queries while it reconfigures outputs, which is exactly what
|
||||
# a lid close sets off. Releasing on a missed answer would suspend the machine.
|
||||
touch "$hyprctl_fail"
|
||||
sync_inhibit || fail "an unanswered compositor query is not an error"
|
||||
held || fail "an unanswered compositor query keeps the inhibitor"
|
||||
rm -f "$hyprctl_fail"
|
||||
write_monitors ''
|
||||
sync_inhibit || fail "an empty compositor answer is not an error"
|
||||
held || fail "an empty compositor answer keeps the inhibitor"
|
||||
write_monitors 'HYPRLAND_INSTANCE_SIGNATURE not set!'
|
||||
sync_inhibit || fail "a compositor answer that is not JSON is not an error"
|
||||
held || fail "a compositor answer that is not JSON keeps the inhibitor"
|
||||
write_monitors "$clamshell"
|
||||
pass "a compositor that does not answer changes nothing"
|
||||
|
||||
# logind can miss a query too, and the release would suspend the machine just the same.
|
||||
touch "$busctl_fail"
|
||||
sync_inhibit || fail "an unanswered logind query is not an error"
|
||||
held || fail "an unanswered logind query keeps the inhibitor"
|
||||
rm -f "$busctl_fail"
|
||||
pass "logind not answering changes nothing"
|
||||
|
||||
# Unplugging is read from the connector, so it releases even when the compositor
|
||||
# cannot be asked.
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 disconnected
|
||||
touch "$hyprctl_fail"
|
||||
sync_inhibit
|
||||
! held || fail "unplugging the USB-C display releases the inhibitor"
|
||||
(( $(count_calls stop) == 1 )) || fail "the inhibitor is released once" "$(<"$call_log")"
|
||||
pass "unplugging the USB-C display releases the inhibitor"
|
||||
|
||||
sync_inhibit
|
||||
(( $(count_calls stop) == 1 && $(count_calls start) == 1 )) ||
|
||||
fail "a sync with nothing held changes nothing" "$(<"$call_log")"
|
||||
pass "a sync with nothing held changes nothing"
|
||||
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
touch "$hyprctl_fail"
|
||||
sync_inhibit || fail "an unanswered compositor query is not an error"
|
||||
touch "$busctl_fail"
|
||||
rm -f "$hyprctl_fail"
|
||||
sync_inhibit || fail "an unanswered logind query is not an error"
|
||||
[[ ! -s $call_log ]] || fail "an unanswered query takes no inhibitor" "$(<"$call_log")"
|
||||
pass "an unanswered query takes no inhibitor either"
|
||||
|
||||
# A display left plugged in but switched off in the monitor config is not in use,
|
||||
# and a closed lid should suspend as it would with that display on HDMI.
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
write_monitors '[{"name":"eDP-1","disabled":false},{"name":"USB-2","disabled":true}]'
|
||||
sync_inhibit
|
||||
[[ ! -s $call_log ]] || fail "a display switched off in the monitor config takes no inhibitor" "$(<"$call_log")"
|
||||
pass "a USB-C display switched off in the monitor config takes no inhibitor"
|
||||
|
||||
# Machines logind already handles must behave exactly as before.
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected
|
||||
write_monitors '[{"name":"eDP-1","disabled":false}]'
|
||||
sync_inhibit
|
||||
[[ ! -s $call_log ]] || fail "the built-in panel alone takes no inhibitor" "$(<"$call_log")"
|
||||
pass "the built-in panel alone takes no inhibitor"
|
||||
|
||||
reset_unit
|
||||
write_connectors card0-eDP-1 connected card0-HDMI-A-1 connected card0-DP-1 connected
|
||||
write_monitors '[{"name":"eDP-1","disabled":true},{"name":"HDMI-A-1","disabled":false},{"name":"DP-1","disabled":false}]'
|
||||
sync_inhibit
|
||||
[[ ! -s $call_log ]] || fail "displays logind counts take no inhibitor" "$(<"$call_log")"
|
||||
pass "HDMI and DisplayPort displays, which logind counts, take no inhibitor"
|
||||
|
||||
reset_unit
|
||||
write_connectors card2-USB-2 connected
|
||||
write_monitors "$clamshell"
|
||||
OMARCHY_TEST_LAPTOP=0 sync_inhibit
|
||||
[[ ! -s $call_log ]] || fail "a machine without a lid takes no inhibitor" "$(<"$call_log")"
|
||||
pass "a machine without a lid takes no inhibitor"
|
||||
|
||||
# Only where logind would ignore a docked lid is there anything to stand in for.
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
write_monitors "$clamshell"
|
||||
OMARCHY_TEST_DOCKED_ACTION=suspend sync_inhibit
|
||||
[[ ! -s $call_log ]] || fail "a docked lid set to suspend takes no inhibitor" "$(<"$call_log")"
|
||||
sync_inhibit
|
||||
held || fail "the inhibitor is taken under the default docked action"
|
||||
OMARCHY_TEST_DOCKED_ACTION=suspend sync_inhibit
|
||||
! held || fail "changing the docked lid action away from ignore releases the inhibitor"
|
||||
pass "the inhibitor follows logind's docked lid action"
|
||||
|
||||
# logind lists DVI-A and Composite, but a missing comma fuses them into one
|
||||
# entry, so they are uncounted too.
|
||||
for connector in DVI-A-1 Composite-1; do
|
||||
reset_unit
|
||||
write_connectors card0-LVDS-1 connected "card0-$connector" connected
|
||||
write_monitors "[{\"name\":\"LVDS-1\",\"disabled\":true},{\"name\":\"$connector\",\"disabled\":false}]"
|
||||
sync_inhibit
|
||||
held || fail "$connector takes the inhibitor"
|
||||
done
|
||||
pass "every external connector type logind misses takes the inhibitor"
|
||||
|
||||
# Held alongside a counted display too, or unplugging that one would let logind
|
||||
# suspend before the watcher could take over.
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-HDMI-A-1 connected card2-USB-2 connected
|
||||
write_monitors '[{"name":"eDP-1","disabled":true},{"name":"HDMI-A-1","disabled":false},{"name":"USB-2","disabled":false}]'
|
||||
sync_inhibit
|
||||
held || fail "a USB-C display beside an HDMI one still holds the inhibitor"
|
||||
pass "a USB-C display beside an HDMI one still holds the inhibitor"
|
||||
|
||||
# The monitor watcher is what keeps the inhibitor in step as displays come and go.
|
||||
cat >"$fake_bin/socat" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
exec cat "$OMARCHY_TEST_EVENTS"
|
||||
SH
|
||||
|
||||
for command in omarchy-hyprland-monitor-clamshell omarchy-hyprland-monitor-external-active; do
|
||||
cat >"$fake_bin/$command" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
exit 0
|
||||
SH
|
||||
done
|
||||
|
||||
cat >"$fake_bin/omarchy-hyprland-monitor-modeless" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
exit 1
|
||||
SH
|
||||
|
||||
chmod +x "$fake_bin"/*
|
||||
ln -s "$lid_inhibit" "$fake_bin/omarchy-system-lid-inhibit"
|
||||
|
||||
start_watcher() {
|
||||
rm -f "$events"
|
||||
mkfifo "$events"
|
||||
|
||||
PATH="$fake_bin:$PATH" \
|
||||
XDG_RUNTIME_DIR="$test_tmp" \
|
||||
HYPRLAND_INSTANCE_SIGNATURE=test \
|
||||
OMARCHY_TEST_EVENTS="$events" \
|
||||
"$ROOT/bin/omarchy-hyprland-monitor-watch" &
|
||||
watch_pid=$!
|
||||
|
||||
exec {events_fd}>"$events"
|
||||
}
|
||||
|
||||
await() {
|
||||
local waited
|
||||
|
||||
for (( waited = 0; waited < 100; waited++ )); do
|
||||
"$@" && return 0
|
||||
sleep 0.05
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
released() {
|
||||
! held
|
||||
}
|
||||
|
||||
reset_unit
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
write_monitors "$clamshell"
|
||||
start_watcher
|
||||
|
||||
await held || fail "the watcher takes the inhibitor at startup with a USB-C display attached"
|
||||
pass "the watcher takes the inhibitor at startup with a USB-C display attached"
|
||||
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 disconnected
|
||||
write_monitors '[{"name":"eDP-1","disabled":false}]'
|
||||
printf 'monitorremovedv2>>1,USB-2,Display\n' >&"$events_fd"
|
||||
await released || fail "the watcher releases the inhibitor when the USB-C display is removed"
|
||||
pass "the watcher releases the inhibitor when the USB-C display is removed"
|
||||
|
||||
write_connectors card2-eDP-1 connected card2-USB-2 connected
|
||||
write_monitors "$clamshell"
|
||||
printf 'monitoraddedv2>>2,USB-2,Display\n' >&"$events_fd"
|
||||
await held || fail "the watcher takes the inhibitor again when the USB-C display returns"
|
||||
|
||||
# Across the delayed retries and the docked poll, one unit stays the only one.
|
||||
sleep 3.5
|
||||
(( $(count_calls start) == 2 && $(count_calls stop) == 1 && $(count_calls refused) == 0 )) ||
|
||||
fail "the watcher never stacks inhibitors across its retries and poll" "$(<"$call_log")"
|
||||
pass "the watcher keeps one inhibitor across hotplugs, retries and its poll"
|
||||
|
||||
stop_watcher
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
dispatch="$ROOT/bin/omarchy-lifecycle-dispatch"
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
operations=(provision-prepare provision-commit provision-verify reset-prepare reset-verify reset-commit reset-rollback update-verify update-takeover luks-slots setup-boot)
|
||||
apple_optional=()
|
||||
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fake_platform "$tmp/$platform" "$platform"
|
||||
done
|
||||
mkdir -p "$tmp/contradiction/proc/device-tree"
|
||||
printf '%s\0' apple,j416c qcom,x1e80100 >"$tmp/contradiction/proc/device-tree/compatible"
|
||||
cp -r "$tmp/aarch64-apple/bin" "$tmp/contradiction/bin"
|
||||
|
||||
# A root-owned directory of entrypoints stands in for the boot package; in a
|
||||
# fixture root the caller's own files count as root's.
|
||||
implementation=usr/lib/omarchy/mac-boot
|
||||
install_implementation() {
|
||||
local root=$1 operation
|
||||
rm -rf "$root"
|
||||
mkdir -p "$root/$implementation"
|
||||
for operation in "${operations[@]}"; do
|
||||
cat >"$root/$implementation/$operation" <<SH
|
||||
#!/bin/bash
|
||||
{ printf '%s' "$operation"; printf ' %q' "\$@"; echo; } >>"$tmp/ran"
|
||||
env >"$tmp/env"
|
||||
[[ ! -e $tmp/fail-with ]] || exit "\$(cat "$tmp/fail-with")"
|
||||
SH
|
||||
chmod 755 "$root/$implementation/$operation"
|
||||
done
|
||||
chmod -R go-w "$root"
|
||||
}
|
||||
|
||||
# Runs the dispatcher on a platform fixture: $1 platform, $2 lifecycle root.
|
||||
on() {
|
||||
local platform=$1 root=$2
|
||||
shift 2
|
||||
OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT="$root" PATH="$tmp/$platform/bin:$PATH" \
|
||||
"$dispatch" "$@"
|
||||
}
|
||||
|
||||
# -p in the shebang is what keeps a root caller's exported functions and
|
||||
# BASH_ENV out, so an ordinary Bash launch with a decoy -p argument is refused
|
||||
# before it resolves anything.
|
||||
status=0
|
||||
output=$(/usr/bin/bash "$dispatch" -p 2>&1) || status=$?
|
||||
(( status == 126 )) && [[ $output == "Refusing an unsafe Bash startup." ]] ||
|
||||
fail "an ordinary Bash launch with a decoy -p is refused" "status $status: $output"
|
||||
pass "an ordinary Bash launch with a decoy -p is refused"
|
||||
|
||||
require_platform_fixtures "lifecycle dispatch on platform fixtures"
|
||||
|
||||
full=$tmp/full
|
||||
install_implementation "$full"
|
||||
empty=$tmp/empty
|
||||
mkdir -p "$empty"
|
||||
|
||||
# x86 and plain aarch64 register no boot package: every operation
|
||||
# is a no-op there, even with Mac entrypoints on disk that would fail.
|
||||
echo 9 >"$tmp/fail-with"
|
||||
for platform in x86 aarch64; do
|
||||
for operation in "${operations[@]}"; do
|
||||
rm -f "$tmp/ran"
|
||||
output=$(on "$platform" "$full" "$operation" --flag 2>&1) || fail "$platform: $operation is a no-op" "$output"
|
||||
[[ -z $output && ! -e $tmp/ran ]] || fail "$platform: $operation runs nothing and says nothing" "$output"
|
||||
output=$(on "$platform" "$full" --resolve "$operation" 2>&1) || fail "$platform: $operation resolves" "$output"
|
||||
[[ -z $output ]] || fail "$platform: $operation resolves to nothing" "$output"
|
||||
done
|
||||
pass "$platform: every operation is a no-op and no Mac entrypoint runs"
|
||||
done
|
||||
rm -f "$tmp/fail-with"
|
||||
|
||||
# Apple with omarchy-mac-boot: each operation runs its entrypoint with the
|
||||
# caller's arguments, a cleared environment and a fixed PATH.
|
||||
export CALLER_SECRET=leak
|
||||
for operation in "${operations[@]}"; do
|
||||
rm -f "$tmp/ran"
|
||||
on aarch64-apple "$full" "$operation" first "second arg" || fail "apple: $operation runs its entrypoint"
|
||||
[[ $(cat "$tmp/ran") == "$operation first second\\ arg" ]] || fail "apple: $operation passes its arguments" "$(cat "$tmp/ran")"
|
||||
! grep -q CALLER_SECRET "$tmp/env" || fail "apple: $operation does not pass the caller's environment" "$(cat "$tmp/env")"
|
||||
grep -qx 'PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$tmp/env" || fail "apple: $operation runs with a fixed PATH" "$(cat "$tmp/env")"
|
||||
resolved=$(on aarch64-apple "$full" --resolve "$operation") || fail "apple: $operation resolves"
|
||||
[[ $resolved == "$full/$implementation/$operation" ]] || fail "apple: $operation resolves to its entrypoint" "$resolved"
|
||||
done
|
||||
unset CALLER_SECRET
|
||||
echo 7 >"$tmp/fail-with"
|
||||
status=0
|
||||
on aarch64-apple "$full" provision-commit || status=$?
|
||||
(( status == 7 )) || fail "apple: the entrypoint's exit status is the dispatcher's" "status: $status"
|
||||
rm -f "$tmp/fail-with"
|
||||
pass "apple: each operation runs the boot package's entrypoint with its arguments and status"
|
||||
|
||||
# Apple without omarchy-mac-boot: required operations fail naming the package
|
||||
# and entrypoint; optional ones are no-ops.
|
||||
for operation in "${operations[@]}"; do
|
||||
status=0
|
||||
output=$(on aarch64-apple "$empty" "$operation" 2>&1) || status=$?
|
||||
if [[ " ${apple_optional[*]} " == *" $operation "* ]]; then
|
||||
(( status == 0 )) && [[ -z $output ]] || fail "apple: optional $operation is a no-op without the boot package" "$output"
|
||||
output=$(on aarch64-apple "$empty" --resolve "$operation" 2>&1) && [[ -z $output ]] ||
|
||||
fail "apple: optional $operation resolves to nothing without the boot package" "$output"
|
||||
else
|
||||
(( status == 3 )) || fail "apple: required $operation fails with status 3 without the boot package" "status: $status"
|
||||
[[ $output == "Error: $operation on aarch64-apple needs omarchy-mac-boot, which provides /usr/lib/omarchy/mac-boot/$operation; it is not installed" ]] ||
|
||||
fail "apple: required $operation names the missing package and entrypoint" "$output"
|
||||
status=0
|
||||
on aarch64-apple "$empty" --resolve "$operation" >/dev/null 2>&1 || status=$?
|
||||
(( status == 3 )) || fail "apple: required $operation does not resolve without the boot package" "status: $status"
|
||||
fi
|
||||
done
|
||||
pass "apple: without omarchy-mac-boot required operations fail with a clear message and optional ones are no-ops"
|
||||
|
||||
# An installed omarchy-mac-boot that predates an operation is named with its
|
||||
# version, as an update away rather than missing.
|
||||
older=$tmp/older
|
||||
mkdir -p "$older/usr/lib/omarchy/mac-boot" "$older/var/lib/pacman/local/omarchy-mac-boot-20260921-10"
|
||||
for operation in "${operations[@]}"; do
|
||||
[[ " ${apple_optional[*]} " == *" $operation "* ]] && continue
|
||||
status=0
|
||||
output=$(on aarch64-apple "$older" "$operation" 2>&1) || status=$?
|
||||
(( status == 1 )) && [[ $output == "Error: $operation on aarch64-apple needs /usr/lib/omarchy/mac-boot/$operation, which omarchy-mac-boot 20260921-10 does not provide; update omarchy-mac-boot" ]] ||
|
||||
fail "apple: an omarchy-mac-boot without $operation is named with its version" "status $status: $output"
|
||||
done
|
||||
pass "apple: an installed omarchy-mac-boot that lacks a required operation fails asking for its update"
|
||||
|
||||
# An entrypoint anyone but root could have changed never runs, optional or not.
|
||||
untrusted() {
|
||||
local description=$1 operation=$2
|
||||
rm -f "$tmp/ran"
|
||||
if output=$(on aarch64-apple "$full" "$operation" 2>&1); then
|
||||
fail "apple: $description is refused"
|
||||
fi
|
||||
[[ ! -e $tmp/ran ]] || fail "apple: $description never runs"
|
||||
[[ $output == *"refusing /usr/lib/omarchy/mac-boot/$operation"* ]] || fail "apple: $description is named" "$output"
|
||||
if on aarch64-apple "$full" --resolve "$operation" >/dev/null 2>&1; then
|
||||
fail "apple: $description does not resolve"
|
||||
fi
|
||||
}
|
||||
|
||||
install_implementation "$full"
|
||||
chmod g+w "$full/$implementation/reset-commit"
|
||||
untrusted "a group-writable entrypoint" reset-commit
|
||||
|
||||
install_implementation "$full"
|
||||
chmod o+w "$full/$implementation/provision-commit"
|
||||
untrusted "a world-writable entrypoint" provision-commit
|
||||
|
||||
install_implementation "$full"
|
||||
chmod o+w "$full/usr/lib/omarchy"
|
||||
untrusted "an entrypoint in a world-writable directory" provision-verify
|
||||
|
||||
install_implementation "$full"
|
||||
mv "$full/$implementation/provision-prepare" "$tmp/elsewhere"
|
||||
ln -s "$tmp/elsewhere" "$full/$implementation/provision-prepare"
|
||||
untrusted "a symlinked entrypoint" provision-prepare
|
||||
|
||||
install_implementation "$full"
|
||||
chmod 644 "$full/$implementation/update-verify"
|
||||
untrusted "a non-executable entrypoint" update-verify
|
||||
|
||||
install_implementation "$full"
|
||||
rm "$full/$implementation/reset-verify"
|
||||
mkdir "$full/$implementation/reset-verify"
|
||||
chmod 755 "$full/$implementation/reset-verify"
|
||||
untrusted "a directory in place of an entrypoint" reset-verify
|
||||
pass "apple: an entrypoint that is not a root-owned file in root-owned directories never runs"
|
||||
|
||||
install_implementation "$full"
|
||||
for arguments in "" "unknown-operation" "--resolve" "--resolve unknown-operation"; do
|
||||
rm -f "$tmp/ran"
|
||||
status=0
|
||||
output=$(on aarch64-apple "$full" $arguments 2>&1) || status=$?
|
||||
(( status == 2 )) && [[ $output == Usage:* ]] || fail "'$arguments' is a usage error" "status $status: $output"
|
||||
[[ ! -e $tmp/ran ]] || fail "'$arguments' runs nothing"
|
||||
done
|
||||
status=0
|
||||
output=$(on aarch64-apple "$full" "provision-prepare provision-commit" 2>&1) || status=$?
|
||||
(( status == 2 )) && [[ ! -e $tmp/ran ]] || fail "two operation names in one argument are a usage error" "status $status: $output"
|
||||
pass "an operation outside the fixed set is a usage error"
|
||||
|
||||
rm -f "$tmp/ran"
|
||||
if output=$(on contradiction "$full" provision-commit 2>&1); then
|
||||
fail "a platform the detector cannot settle fails the operation"
|
||||
fi
|
||||
[[ ! -e $tmp/ran && $output == *"cannot determine the hardware platform for provision-commit"* ]] ||
|
||||
fail "an undetermined platform runs nothing and says why" "$output"
|
||||
pass "an undetermined platform fails closed"
|
||||
|
||||
rm -f "$tmp/ran"
|
||||
if output=$(cd "$tmp" && on aarch64-apple full provision-commit 2>&1); then
|
||||
fail "a relative fixture root is refused"
|
||||
fi
|
||||
[[ ! -e $tmp/ran && $output == "Error: OMARCHY_LIFECYCLE_ROOT must be an absolute path" ]] ||
|
||||
fail "a relative fixture root runs nothing and says why" "$output"
|
||||
pass "a relative fixture root is refused"
|
||||
|
||||
# Root resolves only the fixed /usr/lib path, whatever fixture root its
|
||||
# environment names. A copy beside a detector that always answers Apple keeps
|
||||
# the live platform out of the way.
|
||||
if unshare --user --map-root-user true 2>/dev/null; then
|
||||
mkdir -p "$tmp/rootbin"
|
||||
cp "$dispatch" "$tmp/rootbin/"
|
||||
printf '#!/bin/bash\necho aarch64-apple\n' >"$tmp/rootbin/omarchy-hw-platform"
|
||||
chmod +x "$tmp/rootbin/omarchy-hw-platform"
|
||||
rm -f "$tmp/ran"
|
||||
status=0
|
||||
output=$(OMARCHY_LIFECYCLE_ROOT="$full" unshare --user --map-root-user "$tmp/rootbin/omarchy-lifecycle-dispatch" reset-prepare 2>&1) ||
|
||||
status=$?
|
||||
(( status != 0 )) && [[ ! -e $tmp/ran && $output != *"$tmp"* && $output == *" /usr/lib/omarchy/mac-boot/reset-prepare"* ]] ||
|
||||
fail "root ignores a fixture root in its environment" "status $status: $output"
|
||||
pass "root ignores fixture roots when resolving an operation"
|
||||
|
||||
printf 'touch %q\n' "$tmp/bash-env-ran" >"$tmp/bash-env"
|
||||
dirname() { touch "$tmp/function-ran"; echo /nonexistent; }
|
||||
export -f dirname
|
||||
BASH_ENV="$tmp/bash-env" unshare --user --map-root-user "$dispatch" --resolve provision-commit >/dev/null 2>&1 || true
|
||||
unset -f dirname
|
||||
[[ ! -e $tmp/bash-env-ran && ! -e $tmp/function-ran ]] || fail "root runs no code from BASH_ENV or exported functions"
|
||||
pass "root runs no code from BASH_ENV or exported functions"
|
||||
else
|
||||
skip "no unprivileged user namespace; skipping the root override probe"
|
||||
fi
|
||||
|
||||
# ── setup and app-install operations ─────────────────────────────────────────
|
||||
|
||||
# System and user setup and the app-install hooks resolve in omarchy-mac's
|
||||
# directory on a Mac, never in omarchy-mac-boot's, and are optional everywhere.
|
||||
setup_operations=(setup-system setup-user post-install pre-remove)
|
||||
user_operations=(setup-user post-install pre-remove)
|
||||
setup_implementation=usr/lib/omarchy/mac
|
||||
install_setup() {
|
||||
local root=$1 dir=$2 operation
|
||||
mkdir -p "$root/$dir"
|
||||
for operation in "${setup_operations[@]}"; do
|
||||
cat >"$root/$dir/$operation" <<SH
|
||||
#!/bin/bash
|
||||
{ printf '%s' "$operation"; (( \$# == 0 )) || printf ' %q' "\$@"; echo; } >>"$tmp/ran"
|
||||
env >"$tmp/env"
|
||||
[[ ! -e $tmp/fail-with ]] || exit "\$(cat "$tmp/fail-with")"
|
||||
SH
|
||||
chmod 755 "$root/$dir/$operation"
|
||||
done
|
||||
chmod -R go-w "$root"
|
||||
}
|
||||
|
||||
with_mac=$tmp/with-mac
|
||||
rm -rf "$with_mac"
|
||||
install_setup "$with_mac" "$setup_implementation"
|
||||
boot_only=$tmp/boot-only
|
||||
rm -rf "$boot_only"
|
||||
install_setup "$boot_only" "$implementation"
|
||||
|
||||
echo 9 >"$tmp/fail-with"
|
||||
for platform in x86 aarch64; do
|
||||
for operation in "${setup_operations[@]}"; do
|
||||
rm -f "$tmp/ran"
|
||||
output=$(on "$platform" "$with_mac" "$operation" 2>&1) && [[ -z $output && ! -e $tmp/ran ]] ||
|
||||
fail "$platform: $operation is a no-op" "$output"
|
||||
output=$(on "$platform" "$with_mac" --resolve "$operation" 2>&1) && [[ -z $output ]] ||
|
||||
fail "$platform: $operation resolves to nothing" "$output"
|
||||
done
|
||||
done
|
||||
rm -f "$tmp/fail-with"
|
||||
pass "x86 and plain aarch64: setup and app-install operations are no-ops, even with Mac entrypoints on disk"
|
||||
|
||||
for operation in "${setup_operations[@]}"; do
|
||||
rm -f "$tmp/ran"
|
||||
resolved=$(on aarch64-apple "$with_mac" --resolve "$operation") && [[ $resolved == "$with_mac/$setup_implementation/$operation" ]] ||
|
||||
fail "apple: $operation resolves to omarchy-mac's entrypoint" "$resolved"
|
||||
output=$(on aarch64-apple "$empty" "$operation" 2>&1) && [[ -z $output ]] ||
|
||||
fail "apple: $operation is a no-op without omarchy-mac" "$output"
|
||||
output=$(on aarch64-apple "$boot_only" "$operation" 2>&1) && [[ -z $output && ! -e $tmp/ran ]] ||
|
||||
fail "apple: $operation never runs from omarchy-mac-boot's directory" "$output"
|
||||
done
|
||||
pass "apple: setup and the app-install hooks resolve in omarchy-mac's directory, and are no-ops without omarchy-mac"
|
||||
|
||||
session=(CALLER_SECRET=leak HOME=/home/owner USER=owner XDG_RUNTIME_DIR=/run/user/1000 XDG_CONFIG_HOME=/home/owner/.cfg
|
||||
XDG_STATE_HOME=/home/owner/.st XDG_DATA_HOME=/home/owner/.data DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus OMARCHY_PATH=/usr/share/omarchy)
|
||||
rm -f "$tmp/ran"
|
||||
env -u WAYLAND_DISPLAY "${session[@]}" OMARCHY_PROC_ROOT="$tmp/aarch64-apple/proc" OMARCHY_LIFECYCLE_ROOT="$with_mac" \
|
||||
PATH="$tmp/aarch64-apple/bin:$PATH" "$dispatch" setup-system image-first-boot || fail "apple: setup-system runs"
|
||||
[[ $(cat "$tmp/ran") == "setup-system image-first-boot" ]] || fail "apple: setup-system gets its argument" "$(cat "$tmp/ran")"
|
||||
[[ $(grep -Ev '^(_|PWD|OLDPWD|SHLVL)=' "$tmp/env" | sort) == "PATH=/usr/local/sbin:/usr/local/bin:/usr/bin" ]] ||
|
||||
fail "apple: setup-system gets PATH alone" "$(cat "$tmp/env")"
|
||||
expected=$(printf '%s\n' DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus HOME=/home/owner OMARCHY_PATH=/usr/share/omarchy \
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/bin USER=owner XDG_CONFIG_HOME=/home/owner/.cfg XDG_RUNTIME_DIR=/run/user/1000 XDG_STATE_HOME=/home/owner/.st)
|
||||
for invocation in "setup-user" "post-install steam" "pre-remove steam"; do
|
||||
rm -f "$tmp/ran"
|
||||
env -u WAYLAND_DISPLAY "${session[@]}" OMARCHY_PROC_ROOT="$tmp/aarch64-apple/proc" OMARCHY_LIFECYCLE_ROOT="$with_mac" \
|
||||
PATH="$tmp/aarch64-apple/bin:$PATH" "$dispatch" $invocation || fail "apple: $invocation runs"
|
||||
[[ $(cat "$tmp/ran") == "$invocation" ]] || fail "apple: $invocation runs its entrypoint with its argument" "$(cat "$tmp/ran")"
|
||||
[[ $(grep -Ev '^(_|PWD|OLDPWD|SHLVL)=' "$tmp/env" | sort) == "$expected" ]] ||
|
||||
fail "apple: $invocation gets the user's home and session, and nothing else" "$(cat "$tmp/env")"
|
||||
done
|
||||
echo 3 >"$tmp/fail-with"
|
||||
status=0
|
||||
on aarch64-apple "$with_mac" setup-user || status=$?
|
||||
(( status == 3 )) || fail "apple: a setup entrypoint's own status 3 passes through" "status $status"
|
||||
rm -f "$tmp/fail-with"
|
||||
pass "apple: setup-system gets PATH alone and the user operations the user's home and session, with the entrypoint's status"
|
||||
|
||||
chmod o+w "$with_mac/$setup_implementation/setup-user"
|
||||
rm -f "$tmp/ran"
|
||||
if output=$(on aarch64-apple "$with_mac" setup-user 2>&1); then
|
||||
fail "apple: a world-writable setup entrypoint is refused"
|
||||
fi
|
||||
[[ ! -e $tmp/ran && $output == *"refusing /usr/lib/omarchy/mac/setup-user"* ]] ||
|
||||
fail "apple: a world-writable setup entrypoint never runs" "$output"
|
||||
chmod o-w "$with_mac/$setup_implementation/setup-user"
|
||||
pass "apple: a setup entrypoint that fails the trust rules never runs"
|
||||
|
||||
# Root is refused a user operation only where the platform registers it, so an
|
||||
# installer run with sudo still finishes elsewhere. As root the dispatcher runs
|
||||
# the detector beside it, so each platform gets a copy beside a stub.
|
||||
if unshare --user --map-root-user true 2>/dev/null; then
|
||||
for platform in aarch64-apple x86 aarch64; do
|
||||
mkdir -p "$tmp/root-$platform"
|
||||
cp "$dispatch" "$tmp/root-$platform/"
|
||||
printf '#!/bin/bash\necho %s\n' "$platform" >"$tmp/root-$platform/omarchy-hw-platform"
|
||||
chmod +x "$tmp/root-$platform/omarchy-hw-platform"
|
||||
for operation in "${user_operations[@]}"; do
|
||||
for arguments in "$operation" "--resolve $operation" "$operation steam"; do
|
||||
rm -f "$tmp/ran"
|
||||
status=0
|
||||
output=$(OMARCHY_LIFECYCLE_ROOT="$with_mac" unshare --user --map-root-user \
|
||||
"$tmp/root-$platform/omarchy-lifecycle-dispatch" $arguments 2>&1) || status=$?
|
||||
[[ ! -e $tmp/ran ]] || fail "$platform: root never runs '$arguments'"
|
||||
if [[ $platform == "aarch64-apple" ]]; then
|
||||
(( status == 1 )) && [[ $output == "Error: $operation runs as the user, never as root" ]] ||
|
||||
fail "apple: root is refused '$arguments'" "status $status: $output"
|
||||
else
|
||||
(( status == 0 )) && [[ -z $output ]] || fail "$platform: '$arguments' is a no-op for root" "status $status: $output"
|
||||
fi
|
||||
done
|
||||
done
|
||||
done
|
||||
pass "setup-user, post-install and pre-remove refuse root on Apple Silicon, and are no-ops for root elsewhere"
|
||||
else
|
||||
skip "no unprivileged user namespace; skipping the root refusal of the user operations"
|
||||
fi
|
||||
@@ -10,6 +10,10 @@ grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" ||
|
||||
fail "the packaged Limine defaults still unpack the initramfs synchronously"
|
||||
pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt"
|
||||
|
||||
grep -Fxq 'KERNEL_CMDLINE[default]+=" systemd.tty.term.console=dumb"' "$packaged_defaults" ||
|
||||
fail "the packaged Limine defaults let PID 1 wait for the console to answer a size query"
|
||||
pass "packaged Limine defaults keep PID 1 from waiting on the console"
|
||||
|
||||
grep -Fxq 'BOOT_ORDER="linux-t2, linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' "$packaged_defaults" ||
|
||||
fail "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere"
|
||||
pass "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere"
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
leaf="$ROOT/install/config/locale.sh"
|
||||
|
||||
[[ -f $leaf ]] || fail "the locale step ships"
|
||||
|
||||
grep -Fq 'config/locale.sh' "$ROOT/install/config/all.sh" ||
|
||||
fail "config phase sets a UTF-8 locale"
|
||||
pass "config phase sets a UTF-8 locale"
|
||||
|
||||
# Root always writes the real locale files: the overrides below are for
|
||||
# unprivileged runs, so as root the step would change this machine's locale.
|
||||
if (( EUID == 0 )); then
|
||||
skip "running as root, where the locale step ignores its fixture; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
calls="$test_tmp/calls.log"
|
||||
locale_conf="$test_tmp/etc/locale.conf"
|
||||
locale_gen="$test_tmp/etc/locale.gen"
|
||||
mkdir -p "$stub_bin" "$test_tmp/etc"
|
||||
|
||||
# GENERATED_LOCALES stands in for what the machine already has built.
|
||||
cat >"$stub_bin/locale" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ ${1:-} == "-a" ]] || exit 0
|
||||
printf '%s\n' ${GENERATED_LOCALES:-C}
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/locale-gen" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
printf 'locale-gen\n' >>"$TEST_LOG"
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
run_leaf() {
|
||||
: >"$calls"
|
||||
GENERATED_LOCALES="${1:-C}" TEST_LOG="$calls" PATH="$stub_bin:$PATH" \
|
||||
OMARCHY_LOCALE_CONF="$locale_conf" OMARCHY_LOCALE_GEN="$locale_gen" \
|
||||
bash -euo pipefail -c 'source "$1"' bash "$leaf" >/dev/null
|
||||
}
|
||||
|
||||
# A root from a distribution tarball: LANG=C, nothing enabled in locale.gen.
|
||||
printf 'LANG=C\n' >"$locale_conf"
|
||||
printf '#en_US.UTF-8 UTF-8\n#de_DE.UTF-8 UTF-8\n' >"$locale_gen"
|
||||
run_leaf C
|
||||
/usr/bin/grep -qx 'LANG=en_US.UTF-8' "$locale_conf" || fail "a C locale is replaced with UTF-8" "$(cat "$locale_conf")"
|
||||
/usr/bin/grep -qx 'en_US.UTF-8 UTF-8' "$locale_gen" || fail "en_US.UTF-8 is enabled in locale.gen" "$(cat "$locale_gen")"
|
||||
/usr/bin/grep -qx '#de_DE.UTF-8 UTF-8' "$locale_gen" || fail "other locales are left commented" "$(cat "$locale_gen")"
|
||||
/usr/bin/grep -qx 'locale-gen' "$calls" || fail "the locale is generated" "$(cat "$calls")"
|
||||
pass "a stock LANG=C machine gets en_US.UTF-8"
|
||||
|
||||
# Only the stock state is repaired. Every named locale is somebody's choice --
|
||||
# including C.UTF-8, and including one that is not UTF-8 at all.
|
||||
for chosen in en_DK.UTF-8 C.UTF-8 en_US.ISO-8859-1; do
|
||||
printf 'LANG=%s\n' "$chosen" >"$locale_conf"
|
||||
run_leaf "C en_DK.utf8 C.utf8"
|
||||
/usr/bin/grep -qx "LANG=$chosen" "$locale_conf" || fail "a chosen locale is left alone" "$chosen -> $(cat "$locale_conf")"
|
||||
[[ ! -s $calls ]] || fail "a chosen locale is not regenerated" "$chosen: $(cat "$calls")"
|
||||
done
|
||||
pass "a machine with a chosen locale is left alone"
|
||||
|
||||
# POSIX is the same stock state under another name, and a missing file means
|
||||
# the system never had one set.
|
||||
printf 'LANG=POSIX\n' >"$locale_conf"
|
||||
run_leaf C
|
||||
/usr/bin/grep -qx 'LANG=en_US.UTF-8' "$locale_conf" || fail "a POSIX locale is repaired" "$(cat "$locale_conf")"
|
||||
|
||||
rm -f "$locale_conf"
|
||||
run_leaf C
|
||||
/usr/bin/grep -qx 'LANG=en_US.UTF-8' "$locale_conf" || fail "a machine with no locale.conf is repaired" "$(cat "$locale_conf" 2>&1)"
|
||||
pass "POSIX and an absent locale.conf count as stock"
|
||||
|
||||
# Already generated, just not selected: set it without a rebuild.
|
||||
printf 'LANG=C\n' >"$locale_conf"
|
||||
run_leaf "C en_US.utf8"
|
||||
/usr/bin/grep -qx 'LANG=en_US.UTF-8' "$locale_conf" || fail "an available locale is selected" "$(cat "$locale_conf")"
|
||||
[[ ! -s $calls ]] || fail "a generated locale is not regenerated" "$(cat "$calls")"
|
||||
pass "a generated locale is selected without rebuilding"
|
||||
|
||||
# Other locale variables somebody set stay as they are.
|
||||
printf 'LANG=C\nLC_TIME=en_DK.UTF-8\nLC_PAPER=de_DE.UTF-8\n' >"$locale_conf"
|
||||
run_leaf "C en_US.utf8"
|
||||
[[ $(cat "$locale_conf") == $'LANG=en_US.UTF-8\nLC_TIME=en_DK.UTF-8\nLC_PAPER=de_DE.UTF-8' ]] ||
|
||||
fail "only LANG is repaired; LC_* settings stay" "$(cat "$locale_conf")"
|
||||
printf 'LC_TIME=en_DK.UTF-8\n' >"$locale_conf"
|
||||
run_leaf "C en_US.utf8"
|
||||
[[ $(cat "$locale_conf") == $'LC_TIME=en_DK.UTF-8\nLANG=en_US.UTF-8' ]] ||
|
||||
fail "an unset LANG is added beside the LC_* settings" "$(cat "$locale_conf")"
|
||||
pass "only LANG is repaired; LC_* settings stay"
|
||||
|
||||
printf 'LANG=C\n' >"$locale_conf"
|
||||
run_leaf "C en_US.utf8"
|
||||
# Second pass over a repaired machine changes nothing.
|
||||
run_leaf "C en_US.utf8"
|
||||
(( $(/usr/bin/grep -c . "$locale_conf") == 1 )) || fail "the locale step is idempotent" "$(cat "$locale_conf")"
|
||||
pass "the locale step is idempotent"
|
||||
|
||||
# The x86 ISO writes the locale the owner picked before this runs, so there the
|
||||
# step changes nothing.
|
||||
printf 'LANG=en_US.UTF-8\n' >"$locale_conf"
|
||||
printf '#en_US.UTF-8 UTF-8\n' >"$locale_gen"
|
||||
: >"$calls"
|
||||
output=$(GENERATED_LOCALES="C en_US.utf8" TEST_LOG="$calls" PATH="$stub_bin:$PATH" \
|
||||
OMARCHY_LOCALE_CONF="$locale_conf" OMARCHY_LOCALE_GEN="$locale_gen" \
|
||||
bash -euo pipefail -c 'source "$1"' bash "$leaf")
|
||||
[[ $output == "Leaving the locale as en_US.UTF-8" && $(cat "$locale_conf") == "LANG=en_US.UTF-8" ]] ||
|
||||
fail "an ISO install's locale is left as the ISO wrote it" "$output"
|
||||
[[ $(cat "$locale_gen") == "#en_US.UTF-8 UTF-8" && ! -s $calls ]] || fail "an ISO install's locale.gen is not touched"
|
||||
pass "an ISO install's locale is left as the ISO wrote it"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
findmnt() {
|
||||
[[ $* == "-no SOURCE /" ]] || return 99
|
||||
printf '%s\n' "$root_source"
|
||||
}
|
||||
lsblk() {
|
||||
[[ ${*: -1} == "/dev/mapper/root" ]] || return 98
|
||||
printf '%s\n' "$*" >>"$test_tmp/lsblk-calls"
|
||||
[[ $disk_state != "failure" ]] || return 1
|
||||
if [[ $disk_state == "lvm" ]]; then
|
||||
printf '/dev/mapper/root btrfs\n/dev/mapper/cryptlvm LVM2_member\n/dev/nvme0n1p2 crypto_LUKS\n/dev/nvme0n1 \n'
|
||||
elif [[ $disk_state == "encrypted" ]]; then
|
||||
if [[ $1 == -*r* ]]; then
|
||||
printf '/dev/mapper/root btrfs\n/dev/nvme0n1p6 crypto_LUKS\n/dev/nvme0n1 \n'
|
||||
else
|
||||
printf '/dev/mapper/root btrfs\n└─/dev/nvme0n1p6 crypto_LUKS\n └─/dev/nvme0n1 \n'
|
||||
fi
|
||||
else
|
||||
printf '/dev/mapper/root btrfs\n/dev/nvme0n1p6 ext4\n'
|
||||
fi
|
||||
}
|
||||
|
||||
for script in omarchy-provision-owner omarchy-system-factory-reset; do
|
||||
# Load only this read-only helper, never the provisioning/reset entrypoint.
|
||||
sed -n '/^luks_device() {/,/^}/p' "$ROOT/bin/$script" |
|
||||
sed "s|/proc/cmdline|$test_tmp/cmdline|g" >"$test_tmp/helper"
|
||||
[[ -s $test_tmp/helper ]] || fail "$script exposes its LUKS lookup"
|
||||
source "$test_tmp/helper"
|
||||
printf 'root=/dev/mapper/root rd.luks.name=fixture=root\n' >"$test_tmp/cmdline"
|
||||
root_source='/dev/mapper/root[/@]'
|
||||
disk_state=encrypted
|
||||
actual=$(luks_device) || fail "$script resolves a crypttab root"
|
||||
[[ $actual == "/dev/nvme0n1p6" ]] || fail "$script returns the raw partition path" "$actual"
|
||||
pass "$script strips the btrfs subvolume and returns the LUKS parent without tree glyphs"
|
||||
|
||||
disk_state=lvm
|
||||
actual=$(luks_device) || fail "$script resolves a root on LVM on LUKS"
|
||||
[[ $actual == "/dev/nvme0n1p2" ]] || fail "$script walks past the LVM layer to the LUKS partition" "$actual"
|
||||
pass "$script finds the LUKS partition beneath an LVM volume group"
|
||||
|
||||
for disk_state in plain failure; do
|
||||
if actual=$(luks_device); then
|
||||
fail "$script rejects $disk_state ancestry" "$actual"
|
||||
fi
|
||||
[[ -z $actual ]] || fail "$script emits no device on $disk_state ancestry"
|
||||
done
|
||||
root_source=""
|
||||
if luks_device; then fail "$script rejects a missing root source"; fi
|
||||
|
||||
for spec in UUID=fixture PARTUUID=fixture LABEL=fixture PARTLABEL=fixture /dev/nvme0n1p6; do
|
||||
case $spec in
|
||||
UUID=*) expected=/dev/disk/by-uuid/fixture ;;
|
||||
PARTUUID=*) expected=/dev/disk/by-partuuid/fixture ;;
|
||||
LABEL=*) expected=/dev/disk/by-label/fixture ;;
|
||||
PARTLABEL=*) expected=/dev/disk/by-partlabel/fixture ;;
|
||||
*) expected=$spec ;;
|
||||
esac
|
||||
: >"$test_tmp/lsblk-calls"
|
||||
printf 'cryptdevice=%s:root root=/dev/mapper/root\n' "$spec" >"$test_tmp/cmdline"
|
||||
actual=$(luks_device) || fail "$script preserves $spec lookup"
|
||||
[[ $actual == "$expected" && ! -s $test_tmp/lsblk-calls ]] || fail "$script prefers the explicit cryptdevice"
|
||||
done
|
||||
pass "$script preserves explicit cryptdevice lookup and rejects unencrypted or missing roots"
|
||||
done
|
||||
Executable
+904
@@ -0,0 +1,904 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Owner provisioning's LUKS re-key, killed after every durable step and rerun.
|
||||
# Each attempt is its own process, like a reboot: omarchy-provision-owner's
|
||||
# setup attempt from the accepted owner form on, the shared re-key, and a
|
||||
# cryptsetup that is either a slot-table fake or the real binary on a
|
||||
# file-backed LUKS2 or LUKS1 volume. The callbacks reach the platform through
|
||||
# the real omarchy-lifecycle-dispatch: on an x86 fixture it is a no-op and the
|
||||
# Limine UKI path runs; on an Apple fixture a fake boot package with
|
||||
# provisioning and luks-slots entrypoints owns an unlock kept on a boot
|
||||
# partition and the kernel command line and records the kept slot.
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
token_key=""
|
||||
trap 'rm -rf "$tmp"; [[ -z $token_key ]] || keyctl unlink "$token_key" @s >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
fake_platform "$tmp/x86" x86
|
||||
fake_platform "$tmp/apple" aarch64-apple
|
||||
platform=x86
|
||||
|
||||
# Root's dispatcher ignores the fixtures and sees this machine, which stands in
|
||||
# for x86 only where no boot package is registered.
|
||||
platforms=(x86 apple)
|
||||
if (( EUID == 0 )); then
|
||||
if [[ $("$ROOT/bin/omarchy-hw-platform") == "aarch64-apple" ]]; then
|
||||
skip "running as root on Apple Silicon, where dispatch ignores fixtures; skipping"
|
||||
exit 0
|
||||
fi
|
||||
platforms=(x86)
|
||||
skip "running as root, where dispatch ignores fixtures; skipping the Apple runs"
|
||||
fi
|
||||
runtime=$ROOT
|
||||
|
||||
mac_boot=$tmp/lifecycle/usr/lib/omarchy/mac-boot
|
||||
mkdir -p "$mac_boot"
|
||||
cat >"$tmp/mac-boot-lib.sh" <<'SH'
|
||||
ran() { echo "$1" >>"$TMP/mac-boot-ran"; }
|
||||
crash() {
|
||||
local count
|
||||
count=$(( $(cat "$TMP/steps") + 1 ))
|
||||
echo "$count" >"$TMP/steps"
|
||||
printf '%s %s\n' "$count" "$1" >>"$TMP/trace"
|
||||
if (( count == $(cat "$TMP/crash-at") )); then
|
||||
kill -9 "$PPID"
|
||||
kill -9 $$
|
||||
fi
|
||||
}
|
||||
SH
|
||||
mac_boot_entrypoint() {
|
||||
{ printf '#!/bin/bash\nset -euo pipefail\nTMP=%q\nsource "$TMP/mac-boot-lib.sh"\nran %s\n' "$tmp" "$1"; cat; } >"$mac_boot/$1"
|
||||
}
|
||||
mac_boot_entrypoint provision-prepare <<'SH'
|
||||
if [[ -e $TMP/prepare-fail ]]; then
|
||||
echo "The boot partition is not mounted." >&2
|
||||
exit 1
|
||||
fi
|
||||
SH
|
||||
mac_boot_entrypoint provision-commit <<'SH'
|
||||
[[ ! -e $TMP/rebuild-fail ]] || exit 1
|
||||
rm -f "$TMP/boot/omarchy/luks-key"
|
||||
crash "boot-partition key removed"
|
||||
sed -i 's/ rd\.luks\.key=[^" ]*//' "$TMP/etc/default/grub"
|
||||
echo rebuild >>"$TMP/rebuilds"
|
||||
crash "boot rebuilt"
|
||||
SH
|
||||
mac_boot_entrypoint provision-verify <<'SH'
|
||||
[[ ! -e $TMP/boot/omarchy/luks-key ]] && ! grep -q 'rd\.luks\.key=' "$TMP/etc/default/grub"
|
||||
SH
|
||||
mac_boot_entrypoint luks-slots <<'SH'
|
||||
[[ ! -e $TMP/record-fail ]] || exit 1
|
||||
printf '%s\n' "$@" >"$TMP/slot-record"
|
||||
crash "slots recorded"
|
||||
SH
|
||||
chmod 755 "$mac_boot"/*
|
||||
chmod -R go-w "$tmp/lifecycle"
|
||||
|
||||
staged_key=staged-install-key
|
||||
seller_key=previous-owner-key
|
||||
owner_password=owner-password
|
||||
|
||||
sed -n '/^PROVISIONING_UNLOCK_FILES=(/,/^)/p; /^UNLOCK_OWNER=/p; /^limine_auto_unlock_present() {/,/^}/p; /^limine_auto_unlock_drop() {/,/^}/p
|
||||
/^unlock_owner() {/,/^}/p; /^luks_auto_unlock_present() {/,/^}/p; /^luks_auto_unlock_drop() {/,/^}/p
|
||||
/^luks_record_slots() {/,/^}/p' \
|
||||
"$ROOT/bin/omarchy-provision-owner" | sed "s|/etc/|$tmp/etc/|g" >"$tmp/unlock.sh"
|
||||
grep -q '^luks_auto_unlock_drop() {' "$tmp/unlock.sh" && grep -q '^limine_auto_unlock_drop() {' "$tmp/unlock.sh" &&
|
||||
grep -q '^luks_record_slots() {' "$tmp/unlock.sh" ||
|
||||
fail "omarchy-provision-owner defines the dispatched and Limine auto-unlock callbacks and the slot record"
|
||||
sed -n '/^rekey_luks() {/,/^}/p; /^run_provisioning() {/,/^}/p; /^cleanup_oem_state() {/,/^}/p
|
||||
/^platform_ready() {/,/^}/p; /^run_setup() {/,/^}/p
|
||||
/^rekey_accepts_password() {/,/^}/p' \
|
||||
"$ROOT/bin/omarchy-provision-owner" |
|
||||
sed -e "s|/etc/|$tmp/etc/|g" -e "s|/usr/bin/omarchy-thunderbolt-authorization-admin|omarchy-thunderbolt-authorization-admin|" >"$tmp/provision.sh"
|
||||
grep -q '^run_provisioning() {' "$tmp/provision.sh" && grep -q '^run_setup() {' "$tmp/provision.sh" ||
|
||||
fail "omarchy-provision-owner defines its setup and provisioning worker"
|
||||
|
||||
cat >"$tmp/attempt.sh" <<'SH'
|
||||
set -euo pipefail
|
||||
|
||||
source "$ROOT/install/provisioning/luks-rekey.sh"
|
||||
source "$TMP/unlock.sh"
|
||||
source <(sed -n '/^luks_boot_layout() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner" | sed "s|/etc/|$TMP/etc/|g")
|
||||
declare -F luks_boot_layout >/dev/null
|
||||
|
||||
PROVISIONING_DIR=$TMP/provisioning
|
||||
REKEY_STATE=$PROVISIONING_DIR/luks-rekey.state
|
||||
LOG_FILE=$TMP/log
|
||||
password=$PASSWORD
|
||||
|
||||
log_step() { printf '%s\n' "$*" >>"$LOG_FILE"; }
|
||||
say() { :; }
|
||||
|
||||
crash_point() {
|
||||
local count
|
||||
count=$(( $(cat "$TMP/steps") + 1 ))
|
||||
echo "$count" >"$TMP/steps"
|
||||
printf '%s %s\n' "$count" "$1" >>"$TMP/trace"
|
||||
if (( count == ${CRASH_AT:-0} )); then
|
||||
kill -9 $$
|
||||
kill -9 "$BASHPID"
|
||||
fi
|
||||
}
|
||||
|
||||
eval "$(declare -f rekey_state_put | sed '1s/^rekey_state_put /journal_write /')"
|
||||
rekey_state_put() {
|
||||
journal_write "$@" || return 1
|
||||
crash_point "journal $*"
|
||||
}
|
||||
|
||||
reset_limine_config() {
|
||||
echo reset >>"$TMP/limine-ran"
|
||||
crash_point "auto-unlock files removed"
|
||||
}
|
||||
|
||||
limine-update() {
|
||||
echo update >>"$TMP/limine-ran"
|
||||
echo rebuild >>"$TMP/rebuilds"
|
||||
[[ ! -e $TMP/rebuild-fail ]] || return 1
|
||||
crash_point "boot rebuilt"
|
||||
}
|
||||
|
||||
shred() {
|
||||
command rm -f -- "${@: -1}"
|
||||
crash_point "staged key destroyed"
|
||||
}
|
||||
|
||||
fake_cryptsetup() {
|
||||
local op=$1 key_file="" token_type="" target="" extra="" requested="" material slot next
|
||||
shift
|
||||
while (( $# )); do
|
||||
case $1 in
|
||||
--key-file) key_file=$2; shift 2 ;;
|
||||
--key-slot) requested=$2; shift 2 ;;
|
||||
--token-type) token_type=$2; shift 2 ;;
|
||||
-*) shift ;;
|
||||
*)
|
||||
if [[ -z $target ]]; then target=$1; else extra=$1; fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[[ $target == "$DEVICE" ]] || return 4
|
||||
|
||||
if [[ $op == "luksDump" ]]; then
|
||||
echo "Keyslots:"
|
||||
awk '{ printf " %s: luks2\n", $1 }' "$TMP/slots"
|
||||
if [[ -s $TMP/token-slot ]]; then
|
||||
printf 'Tokens:\n 0: luks2-keyring\n\tKeyslot: %s\n' "$(cat "$TMP/token-slot")"
|
||||
fi
|
||||
printf 'Digests:\n 0: pbkdf2\n'
|
||||
return 0
|
||||
fi
|
||||
if [[ $op == "luksKillSlot" ]]; then
|
||||
[[ ! -e $TMP/kill-noop ]] || return 0
|
||||
awk -v s="$extra" '$1 != s' "$TMP/slots" >"$TMP/slots.next"
|
||||
command mv -f "$TMP/slots.next" "$TMP/slots"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# An enrolled token unlocks its slot whatever key is given, unless the
|
||||
# allowed token types exclude it, as with cryptsetup.
|
||||
if [[ $op == "open" && -z $token_type && -s $TMP/token-slot ]]; then
|
||||
echo "Key slot $(cat "$TMP/token-slot") unlocked."
|
||||
return 0
|
||||
fi
|
||||
|
||||
material=$(cat "$key_file")
|
||||
slot=$(awk -v m="$material" '$2 == m { print $1; exit }' "$TMP/slots")
|
||||
if [[ -z $slot ]]; then
|
||||
echo "No key available with this passphrase."
|
||||
return 2
|
||||
fi
|
||||
case $op in
|
||||
open) echo "Key slot $slot unlocked." ;;
|
||||
luksAddKey)
|
||||
next=$requested
|
||||
if [[ -z $next ]]; then
|
||||
for (( next = 0; next < 32; next++ )); do
|
||||
awk '{ print $1 }' "$TMP/slots" | grep -qx "$next" || break
|
||||
done
|
||||
fi
|
||||
! awk '{ print $1 }' "$TMP/slots" | grep -qx "$next" || return 1
|
||||
printf '%s %s\n' "$next" "$(cat "$extra")" >>"$TMP/slots"
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cryptsetup() {
|
||||
local status=0
|
||||
if [[ $BACKEND == "fake" ]]; then
|
||||
fake_cryptsetup "$@" || status=$?
|
||||
elif [[ $1 == "luksAddKey" ]]; then
|
||||
command cryptsetup luksAddKey --pbkdf pbkdf2 --pbkdf-force-iterations 1000 "${@:2}" || status=$?
|
||||
else
|
||||
command cryptsetup "$@" || status=$?
|
||||
fi
|
||||
(( status == 0 )) || return "$status"
|
||||
case $1 in
|
||||
luksAddKey)
|
||||
echo owner >>"$TMP/adds"
|
||||
crash_point "owner key added"
|
||||
;;
|
||||
luksKillSlot) crash_point "slot ${*: -1} killed" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# omarchy-provision-owner's setup functions, with account and boot setup and
|
||||
# the screen stubbed.
|
||||
setup_functions() {
|
||||
source "$TMP/provision.sh"
|
||||
STATE_FILE=$TMP/state
|
||||
FINALIZE_WARNING_FLAG=$TMP/finalize-warning
|
||||
username=owner hostname="" timezone=""
|
||||
create_user() { :; }
|
||||
install_authorized_keys() { :; }
|
||||
configure_login() { :; }
|
||||
configure_hostname() { :; }
|
||||
configure_timezone() { :; }
|
||||
finalize_user() { :; }
|
||||
# Accessory enrollment, recorded with whether the staged unlock was gone.
|
||||
omarchy-pkg-present() { return 1; }
|
||||
omarchy-thunderbolt-authorization-admin() {
|
||||
if luks_staged_unlock_remains; then echo "$* early" >>"$TMP/accessories"; else echo "$*" >>"$TMP/accessories"; fi
|
||||
}
|
||||
limine_entries_stale() {
|
||||
crash_point "re-key returned"
|
||||
[[ -e $TMP/stale ]]
|
||||
}
|
||||
luks_device() { echo "$DEVICE"; }
|
||||
systemctl() { :; }
|
||||
clear_logo() { :; }
|
||||
sleep() { :; }
|
||||
say() { printf '%s\n' "$*" >>"$TMP/screen"; }
|
||||
}
|
||||
|
||||
# The first-boot worker around the re-key.
|
||||
provision() {
|
||||
setup_functions
|
||||
run_provisioning
|
||||
}
|
||||
|
||||
# A whole setup attempt once the owner form is filled in: the password check,
|
||||
# then the worker.
|
||||
attempt() {
|
||||
setup_functions
|
||||
keyboard_form() { :; }
|
||||
user_form() { :; }
|
||||
confirm_form() { :; }
|
||||
notice() {
|
||||
printf 'refused: %s\n' "$1" >>"$TMP/screen"
|
||||
exit 3
|
||||
}
|
||||
set_now() { NOW=0; }
|
||||
render_setup_static() { :; }
|
||||
render_setup_dynamic() { :; }
|
||||
SHOW_CURSOR=""
|
||||
run_setup
|
||||
}
|
||||
|
||||
# First-boot setup up to the owner form, with the screen captured.
|
||||
setup() {
|
||||
source "$TMP/provision.sh"
|
||||
clear_logo() { :; }
|
||||
sleep() { :; }
|
||||
say() { printf '%s\n' "$*" >>"$TMP/screen"; }
|
||||
keyboard_form() {
|
||||
echo "owner form" >>"$TMP/screen"
|
||||
exit 0
|
||||
}
|
||||
run_setup
|
||||
}
|
||||
|
||||
case $MODE in
|
||||
provision) provision ;;
|
||||
attempt) attempt ;;
|
||||
setup) setup ;;
|
||||
rekey) luks_rekey "$DEVICE" ;;
|
||||
accepts) luks_rekey_accepts_password "$DEVICE" ;;
|
||||
remains) luks_staged_unlock_remains ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
run() {
|
||||
local mode=$1 password=$2 crash_at=${3:-0}
|
||||
echo "$crash_at" >"$tmp/crash-at"
|
||||
{
|
||||
ROOT=$ROOT TMP=$tmp BACKEND=$backend DEVICE=$device MODE=$mode PASSWORD=$password CRASH_AT=$crash_at \
|
||||
OMARCHY_PATH=$runtime OMARCHY_PROC_ROOT=$tmp/$platform/proc OMARCHY_LIFECYCLE_ROOT=$tmp/lifecycle \
|
||||
PATH="$tmp/$platform/bin:$PATH" bash "$tmp/attempt.sh"
|
||||
} >>"$tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# The slot the key opens on the volume, or nothing. Tokens never answer.
|
||||
opens() {
|
||||
if [[ $backend == "fake" ]]; then
|
||||
awk -v m="$1" '$2 == m { print $1; exit }' "$tmp/slots"
|
||||
else
|
||||
LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file <(printf '%s' "$1") "$device" 2>&1 |
|
||||
grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' || true
|
||||
fi
|
||||
}
|
||||
|
||||
slot_count() {
|
||||
if [[ $backend == "fake" ]]; then
|
||||
wc -l <"$tmp/slots"
|
||||
else
|
||||
cryptsetup luksDump "$device" 2>/dev/null | grep -cE '^ +[0-9]+: luks2$|^Key Slot [0-9]+: ENABLED'
|
||||
fi
|
||||
}
|
||||
|
||||
fixture() {
|
||||
local format=${1:-luks2}
|
||||
rm -rf "$tmp/provisioning" "$tmp/etc" "$tmp/boot" "$tmp/log" "$tmp/output" "$tmp/trace" "$tmp/rebuilds" "$tmp/adds" "$tmp/rebuild-fail" "$tmp/kill-noop" \
|
||||
"$tmp/prepare-fail" "$tmp/screen" "$tmp/stale" "$tmp/token-slot" "$tmp/slot-record" "$tmp/record-fail" "$tmp/accessories"
|
||||
mkdir -p "$tmp/provisioning"
|
||||
chmod 755 "$tmp/provisioning"
|
||||
touch "$tmp/provisioning/pending"
|
||||
printf '%s' "$staged_key" >"$tmp/provisioning/luks-key"
|
||||
if [[ $platform == "apple" ]]; then
|
||||
mkdir -p "$tmp/boot/omarchy" "$tmp/etc/default"
|
||||
printf '%s' "$staged_key" >"$tmp/boot/omarchy/luks-key"
|
||||
echo 'GRUB_CMDLINE_LINUX="rd.luks.name=root-uuid=root rd.luks.key=root-uuid=/omarchy/luks-key:UUID=boot-uuid"' >"$tmp/etc/default/grub"
|
||||
else
|
||||
mkdir -p "$tmp/etc/omarchy" "$tmp/etc/limine-entry-tool.d" "$tmp/etc/mkinitcpio.conf.d"
|
||||
printf '%s' "$staged_key" >"$tmp/etc/omarchy/provisioning.key"
|
||||
echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \
|
||||
>"$tmp/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf"
|
||||
echo 'FILES+=(/etc/omarchy/provisioning.key)' >"$tmp/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf"
|
||||
fi
|
||||
echo 0 >"$tmp/steps"
|
||||
: >"$tmp/log"
|
||||
|
||||
# A reset leaves the previous owner's slot next to the staged key.
|
||||
if [[ $backend == "fake" ]]; then
|
||||
device=$tmp/volume
|
||||
: >"$device"
|
||||
printf '0 %s\n1 %s\n' "$staged_key" "$seller_key" >"$tmp/slots"
|
||||
else
|
||||
device=$tmp/volume.img
|
||||
rm -f "$device"
|
||||
truncate -s 32M "$device"
|
||||
local pbkdf=(--pbkdf pbkdf2 --pbkdf-force-iterations 1000)
|
||||
[[ $format == "luks1" ]] && pbkdf=(--pbkdf-force-iterations 1000)
|
||||
cryptsetup luksFormat -q --type "$format" "${pbkdf[@]}" "$device" <(printf '%s' "$staged_key")
|
||||
cryptsetup luksAddKey "${pbkdf[@]}" --key-file <(printf '%s' "$staged_key") "$device" <(printf '%s' "$seller_key")
|
||||
fi
|
||||
}
|
||||
|
||||
# What a previous owner can leave on the real volume: a systemd-tpm2 token and
|
||||
# a luks2-keyring token on slot $1, the second answered from the session keyring
|
||||
# with $2. Fails when the kernel keyring is out of reach (no keyctl, or a
|
||||
# container's seccomp profile), so that a bare cryptsetup open does not take the
|
||||
# token in place of any key.
|
||||
enroll_tokens() {
|
||||
local slot=$1 key=$2 description=omarchy-test-token-$$
|
||||
command -v keyctl >/dev/null || return 1
|
||||
token_key=$(printf '%s' "$key" | keyctl padd user "$description" @s 2>/dev/null) || return 1
|
||||
keyctl timeout "$token_key" 600 >/dev/null 2>&1 || true
|
||||
printf '{"type":"systemd-tpm2","keyslots":["%s"],"tpm2-blob":"AA==","tpm2-pcrs":[7],"tpm2-pcr-bank":"sha256","tpm2-primary-alg":"ecc","tpm2-policy-hash":"00","tpm2-pin":false}' "$slot" |
|
||||
cryptsetup token import --disable-external-tokens "$device" || return 1
|
||||
cryptsetup token add --key-description "$description" --key-slot "$slot" "$device" >/dev/null || return 1
|
||||
LC_ALL=C cryptsetup open --test-passphrase --verbose --key-file <(printf 'not-a-key') "$device" 2>&1 | grep -qx "Key slot $slot unlocked."
|
||||
}
|
||||
|
||||
unlock_files_present() {
|
||||
[[ -e $tmp/etc/omarchy/provisioning.key || -e $tmp/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf ||
|
||||
-e $tmp/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf || -e $tmp/boot/omarchy/luks-key ]] ||
|
||||
grep -qs 'rd\.luks\.key=' "$tmp/etc/default/grub"
|
||||
}
|
||||
|
||||
no_secrets_in() {
|
||||
local file
|
||||
local -a keys=(-e "$staged_key" -e "$seller_key" -e "$owner_password" -e "other-password")
|
||||
for file in "$@"; do
|
||||
[[ -e $file ]] || continue
|
||||
! grep -Fq "${keys[@]}" "$file" ||
|
||||
fail "$backend: $(basename "$file") holds no key material" "$(cat "$file")"
|
||||
done
|
||||
}
|
||||
|
||||
# Between attempts the disk must still open at the next boot: unattended while
|
||||
# the auto-unlock remains, otherwise with a password the owner typed.
|
||||
assert_recoverable() {
|
||||
local context=$1 password=$2
|
||||
if unlock_files_present; then
|
||||
[[ -n $(opens "$staged_key") ]] || fail "$backend: $context: the auto-unlock key still opens the disk"
|
||||
else
|
||||
[[ -n $(opens "$password") ]] || fail "$backend: $context: the owner's password opens the disk once auto-unlock is gone"
|
||||
fi
|
||||
if [[ -n $(opens "$staged_key") && ! -f $tmp/provisioning/luks-key ]]; then
|
||||
fail "$backend: $context: the staged key file is kept while it still unlocks the volume"
|
||||
fi
|
||||
if [[ -f $tmp/provisioning/luks-rekey.state ]]; then
|
||||
[[ $(stat -c %a "$tmp/provisioning/luks-rekey.state") == "600" ]] || fail "$backend: $context: the journal is private"
|
||||
fi
|
||||
[[ $(stat -c %a "$tmp/provisioning") == "755" ]] || fail "$backend: $context: the provisioning directory stays readable for the new user"
|
||||
no_secrets_in "$tmp/provisioning/luks-rekey.state" "$tmp/log"
|
||||
}
|
||||
|
||||
# The re-key finished: the owner's slot is all the volume holds.
|
||||
assert_finished() {
|
||||
local context=$1 password=$2 max_adds=${3:-1} adds=0 owner
|
||||
owner=$(opens "$password")
|
||||
[[ $(slot_count) == "1" && -n $owner ]] || fail "$backend: $context: only the owner's slot remains" "$(cat "$tmp/log")"
|
||||
[[ -z $(opens "$staged_key") ]] || fail "$backend: $context: the staged install key no longer unlocks the volume"
|
||||
[[ -z $(opens "$seller_key") ]] || fail "$backend: $context: the previous owner's key no longer unlocks the volume"
|
||||
[[ ! -e $tmp/provisioning/luks-key ]] || fail "$backend: $context: the staged key file is destroyed"
|
||||
! unlock_files_present || fail "$backend: $context: no boot-time auto-unlock remains"
|
||||
[[ -f $tmp/adds ]] && adds=$(grep -c '^owner' "$tmp/adds" || true)
|
||||
(( adds <= max_adds )) || fail "$backend: $context: the owner's key is added at most $max_adds time(s)"
|
||||
! run remains "$password" || fail "$backend: $context: nothing of the staged unlock remains"
|
||||
if [[ $platform == "apple" ]]; then
|
||||
[[ $(cat "$tmp/slot-record" 2>/dev/null) == "owner=$owner"$'\n'"recovery=" ]] ||
|
||||
fail "$backend: $context: the boot package records the owner's slot and no recovery slot" "$(cat "$tmp/slot-record" 2>/dev/null)"
|
||||
else
|
||||
[[ ! -e $tmp/slot-record ]] || fail "$backend: $context: x86 records no slots"
|
||||
fi
|
||||
no_secrets_in "$tmp/provisioning/luks-rekey.state" "$tmp/log" "$tmp/output" "$tmp/slot-record"
|
||||
}
|
||||
|
||||
# Setup completed: the disk opens with the password the account got, and the
|
||||
# provisioning state is gone.
|
||||
assert_provisioned() {
|
||||
assert_finished "$@"
|
||||
[[ ! -e $tmp/provisioning/pending && ! -e $tmp/provisioning/luks-rekey.state ]] ||
|
||||
fail "$backend: $1: setup drops pending and the journal together"
|
||||
}
|
||||
|
||||
backends=(fake)
|
||||
if command -v cryptsetup >/dev/null; then
|
||||
backends+=(luks2 luks1)
|
||||
else
|
||||
skip "cryptsetup is not installed; skipping the file-backed volume runs"
|
||||
fi
|
||||
|
||||
# The crash matrix runs whole setup attempts on every backend, for x86 and for
|
||||
# Apple.
|
||||
matrix=()
|
||||
for backend in "${backends[@]}"; do
|
||||
matrix+=("x86 $backend")
|
||||
[[ " ${platforms[*]} " != *" apple "* ]] || matrix+=("apple $backend")
|
||||
done
|
||||
|
||||
for run_spec in "${matrix[@]}"; do
|
||||
read -r platform backend <<<"$run_spec"
|
||||
rm -f "$tmp/mac-boot-ran" "$tmp/limine-ran"
|
||||
format=$backend
|
||||
[[ $backend == "fake" ]] && format=luks2
|
||||
|
||||
fixture "$format"
|
||||
run attempt "$owner_password" || fail "$backend: uninterrupted setup completes" "$(cat "$tmp/log" "$tmp/output")"
|
||||
assert_provisioned "uninterrupted" "$owner_password"
|
||||
[[ $(cat "$tmp/accessories" 2>/dev/null) == "owner" ]] ||
|
||||
fail "$backend: accessories are enrolled once, after the staged unlock is gone" "$(cat "$tmp/accessories" 2>/dev/null)"
|
||||
total_steps=$(cat "$tmp/steps")
|
||||
(( total_steps >= 11 )) || fail "$backend: every durable step is a crash point" "$(cat "$tmp/trace")"
|
||||
if [[ $platform == "apple" ]]; then
|
||||
pass "apple $backend: uninterrupted setup leaves only the owner's slot, records it and destroys the staged key"
|
||||
else
|
||||
pass "x86 $backend: uninterrupted setup leaves only the owner's slot and destroys the staged key"
|
||||
fi
|
||||
|
||||
# After each kill the owner reboots and answers the form again, with the same
|
||||
# password or a new one. The account takes whatever the form accepts, so the
|
||||
# disk must open with that password when setup finishes.
|
||||
for (( step = 1; step <= total_steps; step++ )); do
|
||||
for retry_password in "$owner_password" other-password; do
|
||||
fixture "$format"
|
||||
if run attempt "$owner_password" "$step"; then
|
||||
fail "$backend: setup is killed at step $step"
|
||||
fi
|
||||
point=$(sed -n "${step}p" "$tmp/trace")
|
||||
assert_recoverable "killed after '$point'" "$owner_password"
|
||||
[[ -e $tmp/provisioning/pending ]] || fail "$backend: killed after '$point', setup runs again"
|
||||
staged_alive=$([[ -n $(opens "$staged_key") ]] && echo 1 || echo 0)
|
||||
|
||||
if run accepts "$retry_password"; then
|
||||
[[ $retry_password == "$owner_password" ]] || (( staged_alive )) ||
|
||||
fail "$backend: after '$point' a new password is refused once the staged key is retired"
|
||||
run attempt "$retry_password" || fail "$backend: after '$point' setup completes" "$(cat "$tmp/log")"
|
||||
if [[ $retry_password == "$owner_password" ]]; then
|
||||
assert_provisioned "rerun after '$point'" "$retry_password"
|
||||
if head -n "$step" "$tmp/trace" | grep -q 'journal phase boot'; then
|
||||
[[ $(wc -l <"$tmp/rebuilds") == "1" ]] || fail "$backend: rerun after '$point' does not rebuild boot again"
|
||||
fi
|
||||
else
|
||||
assert_provisioned "new password after '$point'" "$retry_password" 2
|
||||
[[ -z $(opens "$owner_password") ]] || fail "$backend: after '$point' the abandoned password is retired"
|
||||
fi
|
||||
else
|
||||
[[ $retry_password != "$owner_password" ]] || fail "$backend: after '$point' the original password is always accepted"
|
||||
(( ! staged_alive )) || fail "$backend: after '$point' a new password is accepted while the staged key works"
|
||||
if run attempt "$retry_password"; then fail "$backend: after '$point' setup refuses a new password"; fi
|
||||
assert_recoverable "refused new password after '$point'" "$owner_password"
|
||||
[[ -e $tmp/provisioning/pending ]] || fail "$backend: after '$point' a refused password keeps setup pending"
|
||||
run attempt "$owner_password" || fail "$backend: after '$point' the original password completes" "$(cat "$tmp/log")"
|
||||
assert_provisioned "original password after '$point'" "$owner_password"
|
||||
fi
|
||||
done
|
||||
done
|
||||
if [[ $platform == "apple" ]]; then
|
||||
pass "apple $backend: killed after each of $total_steps steps, setup resumes to a disk that opens with the account's password, with its slot recorded"
|
||||
else
|
||||
pass "x86 $backend: killed after each of $total_steps steps, setup resumes to a disk that opens with the account's password"
|
||||
fi
|
||||
|
||||
if [[ $platform == "x86" ]]; then
|
||||
[[ ! -e $tmp/mac-boot-ran ]] || fail "x86 $backend: no Mac boot entrypoint runs" "$(cat "$tmp/mac-boot-ran")"
|
||||
pass "x86 $backend: dispatch is a no-op and the Limine UKI path runs unchanged"
|
||||
else
|
||||
[[ ! -e $tmp/limine-ran ]] || fail "apple $backend: the Limine UKI path never runs on Apple"
|
||||
grep -qx provision-commit "$tmp/mac-boot-ran" && grep -qx provision-verify "$tmp/mac-boot-ran" ||
|
||||
fail "apple $backend: the boot package commits and verifies the unlock"
|
||||
pass "apple $backend: the boot package owns the boot-time unlock through dispatch"
|
||||
fi
|
||||
done
|
||||
|
||||
platform=x86
|
||||
backend=fake
|
||||
|
||||
fixture
|
||||
touch "$tmp/rebuild-fail"
|
||||
if run rekey "$owner_password"; then fail "a failed boot rebuild fails the attempt"; fi
|
||||
unlock_files_present || fail "a failed boot rebuild restores the auto-unlock"
|
||||
[[ $(cat "$tmp/etc/omarchy/provisioning.key") == "$staged_key" ]] || fail "the restored keyfile is the staged key"
|
||||
[[ -n $(opens "$staged_key") && -n $(opens "$seller_key") ]] || fail "a failed boot rebuild retires no slot"
|
||||
rm "$tmp/rebuild-fail"
|
||||
run rekey "$owner_password" || fail "the retry after a failed rebuild completes" "$(cat "$tmp/log")"
|
||||
assert_finished "retry after a failed rebuild" "$owner_password"
|
||||
pass "a failed boot rebuild keeps the unattended unlock and every slot for the retry"
|
||||
|
||||
fixture
|
||||
printf '2 %s\n' tpm-sealed-key >>"$tmp/slots"
|
||||
echo 2 >"$tmp/token-slot"
|
||||
run provision "$owner_password" || fail "setup completes beside an enrolled token" "$(cat "$tmp/log")"
|
||||
assert_provisioned "beside the previous owner's token" "$owner_password"
|
||||
pass "a token the previous owner enrolled never answers for a key and is retired with its slot"
|
||||
|
||||
if [[ " ${backends[*]} " == *" luks2 "* ]]; then
|
||||
backend=luks2
|
||||
fixture luks2
|
||||
if enroll_tokens 1 "$seller_key"; then
|
||||
run provision "$owner_password" || fail "luks2: setup completes beside live tokens" "$(cat "$tmp/log" "$tmp/output")"
|
||||
assert_provisioned "beside the previous owner's live tokens" "$owner_password"
|
||||
pass "luks2: with a live keyring token and a TPM2 token on the previous owner's slot, only real keys count"
|
||||
else
|
||||
skip "the kernel keyring is out of reach; skipping the live token run"
|
||||
fi
|
||||
backend=fake
|
||||
fi
|
||||
|
||||
fixture
|
||||
touch "$tmp/kill-noop"
|
||||
if run rekey "$owner_password"; then fail "a slot kill that changes nothing fails the re-key"; fi
|
||||
[[ -f $tmp/provisioning/luks-key && -f $tmp/provisioning/luks-rekey.state ]] || fail "an unverified retirement keeps the staged key and journal"
|
||||
rm "$tmp/kill-noop"
|
||||
run rekey "$owner_password" || fail "the retry after an ineffective slot kill completes" "$(cat "$tmp/log")"
|
||||
assert_finished "retry after an ineffective slot kill" "$owner_password"
|
||||
pass "the staged key is destroyed only after the volume proves it no longer opens"
|
||||
|
||||
fixture
|
||||
printf '0 %s\n1 %s\n2 %s\n' "$staged_key" "$seller_key" "$owner_password" >"$tmp/slots"
|
||||
printf 'staged_slot=0\nowner_slot=2\nphase=boot\n' >"$tmp/provisioning/luks-rekey.state"
|
||||
run rekey "$owner_password" || fail "a leftover auto-unlock after the boot step is removed" "$(cat "$tmp/log")"
|
||||
assert_finished "leftover auto-unlock after the boot step" "$owner_password"
|
||||
[[ $(wc -l <"$tmp/rebuilds") == "1" ]] || fail "the leftover auto-unlock triggers a boot rebuild"
|
||||
pass "an auto-unlock that outlives the boot step is rebuilt away rather than blocking forever"
|
||||
|
||||
# The boot files ask for the disk password in the layout vconsole.conf held
|
||||
# when they were built. A retry after the owner picked another layout rebuilds
|
||||
# them once, at the boot step and after done; the same layout rebuilds nothing,
|
||||
# and a journal from before the fingerprint rebuilds once.
|
||||
choose_layout() {
|
||||
mkdir -p "$tmp/etc"
|
||||
printf 'KEYMAP=%s\nXKBLAYOUT=%s\nXKBMODEL=pc105\n' "$1" "$2" >"$tmp/etc/vconsole.conf"
|
||||
}
|
||||
|
||||
rebuild_count() {
|
||||
wc -l <"$tmp/rebuilds" 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
assert_fingerprint() {
|
||||
local context=$1 journal=$tmp/provisioning/luks-rekey.state
|
||||
grep -Eqx 'boot_layout=[0-9a-f]{64}' "$journal" || fail "$context: the journal records the layout as a hash" "$(cat "$journal")"
|
||||
! grep -Eqw 'dk|us' "$journal" "$tmp/log" "$tmp/trace" || fail "$context: the journal and log never name the layout"
|
||||
}
|
||||
|
||||
boot_layout_runs() {
|
||||
local name=$1 stop step refresh before
|
||||
|
||||
fixture
|
||||
choose_layout us us
|
||||
run rekey "$owner_password" || fail "$name: the re-key completes with a layout" "$(cat "$tmp/log")"
|
||||
assert_fingerprint "$name: uninterrupted"
|
||||
cp "$tmp/trace" "$tmp/layout-trace"
|
||||
|
||||
for stop in boot done; do
|
||||
step=$(grep -m1 "^[0-9]* journal phase $stop" "$tmp/layout-trace" | cut -d' ' -f1)
|
||||
[[ -n $step ]] || fail "$name: the trace names the $stop journal write" "$(cat "$tmp/layout-trace")"
|
||||
|
||||
fixture
|
||||
choose_layout us us
|
||||
if run rekey "$owner_password" "$step"; then fail "$name: the re-key is killed after the $stop journal write"; fi
|
||||
[[ $(rebuild_count) == 1 ]] || fail "$name: killed at $stop after one rebuild"
|
||||
run rekey "$owner_password" || fail "$name: the same layout completes after $stop" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 1 ]] || fail "$name: an unchanged layout after $stop rebuilds nothing"
|
||||
assert_finished "$name: unchanged layout after $stop" "$owner_password"
|
||||
|
||||
# The owner picks another layout on the retry right after the interruption.
|
||||
fixture
|
||||
choose_layout us us
|
||||
if run rekey "$owner_password" "$step"; then fail "$name: the re-key is killed after the $stop journal write"; fi
|
||||
before=$(grep -m1 '^boot_layout=' "$tmp/provisioning/luks-rekey.state")
|
||||
choose_layout dk dk
|
||||
run rekey "$owner_password" || fail "$name: a changed layout after $stop completes" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 2 ]] || fail "$name: a changed layout after $stop rebuilds the boot files"
|
||||
[[ $(grep -m1 '^boot_layout=' "$tmp/provisioning/luks-rekey.state") != "$before" ]] ||
|
||||
fail "$name: the journal takes the new layout's fingerprint after $stop"
|
||||
[[ $(grep -c '^[0-9]* journal boot_layout ' "$tmp/trace") == 1 && $(grep -c "^[0-9]* journal phase $stop" "$tmp/trace") == 1 ]] ||
|
||||
fail "$name: the rebuild after $stop records only the fingerprint, keeping the phase" "$(cat "$tmp/trace")"
|
||||
refresh=$(awk -v s="$step" '$1 > s && / boot rebuilt$/ { print $1; exit }' "$tmp/trace")
|
||||
run rekey "$owner_password" || fail "$name: a rerun after the rebuild completes" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 2 ]] || fail "$name: the changed layout after $stop rebuilds only once"
|
||||
assert_finished "$name: changed layout after $stop" "$owner_password"
|
||||
assert_fingerprint "$name: changed layout after $stop"
|
||||
|
||||
# Killed between that rebuild and its fingerprint: the retry rebuilds again.
|
||||
fixture
|
||||
choose_layout us us
|
||||
if run rekey "$owner_password" "$step"; then fail "$name: the re-key is killed after the $stop journal write"; fi
|
||||
choose_layout dk dk
|
||||
if run rekey "$owner_password" "$refresh"; then fail "$name: the re-key is killed after the layout rebuild at $stop"; fi
|
||||
[[ $(grep -m1 '^boot_layout=' "$tmp/provisioning/luks-rekey.state") == "$before" ]] ||
|
||||
fail "$name: a layout rebuild killed before its journal write keeps the old fingerprint"
|
||||
run rekey "$owner_password" || fail "$name: the retry after a killed layout rebuild at $stop completes" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 3 ]] || fail "$name: a layout rebuild killed at $stop runs again"
|
||||
assert_finished "$name: killed layout rebuild at $stop" "$owner_password"
|
||||
assert_fingerprint "$name: killed layout rebuild at $stop"
|
||||
done
|
||||
|
||||
# Interrupted at the boot step, the owner picks another layout and a new
|
||||
# password while the staged key still opens the disk.
|
||||
step=$(grep -m1 "^[0-9]* journal phase boot" "$tmp/layout-trace" | cut -d' ' -f1)
|
||||
fixture
|
||||
choose_layout us us
|
||||
if run rekey "$owner_password" "$step"; then fail "$name: the re-key is killed after the boot journal write"; fi
|
||||
choose_layout dk dk
|
||||
run rekey other-password || fail "$name: a new layout and password after the boot step complete" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 2 ]] || fail "$name: a new layout and password after the boot step rebuild once"
|
||||
assert_finished "$name: new layout and password after the boot step" other-password 2
|
||||
assert_fingerprint "$name: new layout and password after the boot step"
|
||||
|
||||
# A journal from before the fingerprint, at the boot step with the auto-unlock
|
||||
# already gone and after done: rebuilt once, then recorded.
|
||||
for stop in boot done; do
|
||||
fixture
|
||||
choose_layout dk dk
|
||||
run rekey "$owner_password" || fail "$name: the re-key completes" "$(cat "$tmp/log")"
|
||||
step=$(grep -m1 -n "^[0-9]* journal phase $stop" "$tmp/trace" | cut -d: -f1)
|
||||
|
||||
fixture
|
||||
choose_layout dk dk
|
||||
if run rekey "$owner_password" "$step"; then fail "$name: the re-key is killed after the $stop journal write"; fi
|
||||
sed -i '/^boot_layout=/d' "$tmp/provisioning/luks-rekey.state"
|
||||
rm -f "$tmp/rebuilds"
|
||||
run rekey "$owner_password" || fail "$name: an older journal at $stop completes" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 1 ]] || fail "$name: an older journal at $stop rebuilds the boot files once"
|
||||
run rekey "$owner_password" || fail "$name: a rerun of an older journal at $stop completes" "$(cat "$tmp/log")"
|
||||
[[ $(rebuild_count) == 1 ]] || fail "$name: an older journal at $stop rebuilds only once"
|
||||
assert_finished "$name: older journal at $stop" "$owner_password"
|
||||
assert_fingerprint "$name: older journal at $stop"
|
||||
done
|
||||
|
||||
# After done a failed rebuild keeps the old fingerprint, so the retry rebuilds.
|
||||
fixture
|
||||
choose_layout us us
|
||||
run rekey "$owner_password" || fail "$name: the re-key completes" "$(cat "$tmp/log")"
|
||||
before=$(grep -m1 '^boot_layout=' "$tmp/provisioning/luks-rekey.state")
|
||||
choose_layout dk dk
|
||||
touch "$tmp/rebuild-fail"
|
||||
if run rekey "$owner_password"; then fail "$name: a failed layout rebuild after done fails the attempt"; fi
|
||||
[[ $(grep -m1 '^boot_layout=' "$tmp/provisioning/luks-rekey.state") == "$before" ]] ||
|
||||
fail "$name: a failed layout rebuild keeps the old fingerprint"
|
||||
rm "$tmp/rebuild-fail"
|
||||
run rekey "$owner_password" || fail "$name: the retry after a failed layout rebuild completes" "$(cat "$tmp/log")"
|
||||
assert_finished "$name: retry after a failed layout rebuild" "$owner_password"
|
||||
assert_fingerprint "$name: retry after a failed layout rebuild"
|
||||
}
|
||||
|
||||
boot_layout_runs x86
|
||||
pass "x86: a changed keyboard layout rebuilds the boot files once, an unchanged one never, and an older journal once"
|
||||
|
||||
fixture
|
||||
printf 'wrong-key' >"$tmp/provisioning/luks-key"
|
||||
if run rekey "$owner_password"; then fail "a staged key that opens nothing fails the re-key"; fi
|
||||
[[ ! -e $tmp/provisioning/luks-rekey.state && $(slot_count) == "2" ]] || fail "a dead staged key changes nothing"
|
||||
pass "a fresh re-key refuses a staged key that no longer unlocks the volume"
|
||||
|
||||
fixture
|
||||
rm "$tmp/provisioning/luks-key"
|
||||
run remains "$owner_password" || fail "a leftover auto-unlock counts as the staged unlock remaining"
|
||||
if run rekey "$owner_password"; then fail "a leftover auto-unlock without its staged key fails closed"; fi
|
||||
unlock_files_present || fail "the leftover auto-unlock is not silently accepted"
|
||||
pass "a boot-time auto-unlock without its staged key keeps provisioning from finishing"
|
||||
|
||||
fixture
|
||||
if run rekey "$staged_key"; then fail "the staged key is refused as the owner's password"; fi
|
||||
[[ $(slot_count) == "2" ]] && unlock_files_present || fail "refusing the staged key as a password changes nothing"
|
||||
run rekey "$owner_password" || fail "a retry with a real password completes" "$(cat "$tmp/log")"
|
||||
assert_finished "retry after refusing the staged key" "$owner_password"
|
||||
pass "the owner's password must differ from the staged install key"
|
||||
|
||||
fixture
|
||||
printf 'phase=owner\nstaged_slot=0\nowner_slot=2\nrecovery_slot=5\n' >"$tmp/provisioning/luks-rekey.state"
|
||||
chmod 600 "$tmp/provisioning/luks-rekey.state"
|
||||
(
|
||||
source "$ROOT/install/provisioning/luks-rekey.sh"
|
||||
REKEY_STATE=$tmp/provisioning/luks-rekey.state
|
||||
rekey_state_put phase boot
|
||||
)
|
||||
[[ $(grep -c . "$tmp/provisioning/luks-rekey.state") == "4" ]] && grep -qx 'phase=boot' "$tmp/provisioning/luks-rekey.state" &&
|
||||
grep -qx 'recovery_slot=5' "$tmp/provisioning/luks-rekey.state" || fail "a journal write keeps every other recorded key" "$(cat "$tmp/provisioning/luks-rekey.state")"
|
||||
pass "journal writes replace only their own keys"
|
||||
|
||||
fixture
|
||||
rm "$tmp/provisioning/luks-key"
|
||||
if run provision "$owner_password"; then fail "provisioning with a leftover auto-unlock and no staged key fails"; fi
|
||||
[[ -e $tmp/provisioning/pending ]] && unlock_files_present || fail "provisioning keeps its state while the auto-unlock remains"
|
||||
pass "first-boot provisioning never finishes with the boot-time auto-unlock still configured"
|
||||
|
||||
fixture
|
||||
rm -rf "$tmp/provisioning/luks-key" "$tmp/etc"
|
||||
run provision "$owner_password" || fail "unencrypted provisioning finishes" "$(cat "$tmp/log" "$tmp/output")"
|
||||
[[ ! -e $tmp/provisioning/pending && ! -e $tmp/provisioning/luks-rekey.state && $(slot_count) == "2" ]] ||
|
||||
fail "unencrypted provisioning skips the re-key"
|
||||
pass "provisioning without a staged key or auto-unlock skips the re-key"
|
||||
|
||||
if [[ " ${platforms[*]} " == *" apple "* ]]; then
|
||||
# Apple: a failed boot-package commit keeps the unattended unlock and every slot.
|
||||
platform=apple
|
||||
fixture
|
||||
touch "$tmp/rebuild-fail"
|
||||
if run rekey "$owner_password"; then fail "apple: a failed boot-package commit fails the attempt"; fi
|
||||
unlock_files_present || fail "apple: a failed commit keeps the boot-time unlock"
|
||||
[[ -n $(opens "$staged_key") && -n $(opens "$seller_key") ]] || fail "apple: a failed commit retires no slot"
|
||||
rm "$tmp/rebuild-fail"
|
||||
run rekey "$owner_password" || fail "apple: the retry after a failed commit completes" "$(cat "$tmp/log")"
|
||||
assert_finished "apple: retry after a failed commit" "$owner_password"
|
||||
pass "apple: a failed boot-package commit keeps the unattended unlock and every slot for the retry"
|
||||
|
||||
# Apple: an inherited decision never replaces resolving who owns the unlock.
|
||||
fixture
|
||||
rm -f "$tmp/limine-ran"
|
||||
export UNLOCK_OWNER=limine
|
||||
run provision "$owner_password" || fail "apple: setup completes with UNLOCK_OWNER in its environment" "$(cat "$tmp/log")"
|
||||
unset UNLOCK_OWNER
|
||||
assert_provisioned "apple: UNLOCK_OWNER inherited" "$owner_password"
|
||||
[[ ! -e $tmp/limine-ran ]] || fail "apple: an inherited UNLOCK_OWNER does not select the Limine path"
|
||||
pass "apple: an inherited UNLOCK_OWNER is ignored"
|
||||
|
||||
# Apple: the boot package's commit rebuilds for a changed layout, after done too.
|
||||
boot_layout_runs apple
|
||||
pass "apple: a changed keyboard layout reruns the boot package's commit once, an unchanged one never, and an older journal once"
|
||||
fi
|
||||
|
||||
# After a factory reset left entries for another machine identity, the menu
|
||||
# starts over and limine-update rebuilds, on Apple too, and setup still
|
||||
# finishes only once the boot package verifies nothing of the staged unlock
|
||||
# remains.
|
||||
for platform in "${platforms[@]}"; do
|
||||
fixture
|
||||
rm -rf "$tmp/provisioning/luks-key" "$tmp/etc" "$tmp/boot" "$tmp/limine-ran" "$tmp/mac-boot-ran"
|
||||
touch "$tmp/stale"
|
||||
run provision "$owner_password" || fail "$platform: stale boot entries are refreshed" "$(cat "$tmp/log" "$tmp/output")"
|
||||
[[ ! -e $tmp/provisioning/pending && $(cat "$tmp/limine-ran") == $'reset\nupdate' ]] ||
|
||||
fail "$platform: the Limine menu is reset and limine-update rebuilds" "$(cat "$tmp/limine-ran")"
|
||||
if [[ $platform == "x86" ]]; then
|
||||
[[ ! -e $tmp/mac-boot-ran ]] || fail "x86: no Mac entrypoint runs"
|
||||
else
|
||||
grep -qx provision-verify "$tmp/mac-boot-ran" || fail "apple: the boot package still has the last word"
|
||||
fi
|
||||
done
|
||||
if [[ " ${platforms[*]} " == *" apple "* ]]; then
|
||||
platform=apple
|
||||
fixture
|
||||
rm -rf "$tmp/provisioning/luks-key" "$tmp/limine-ran"
|
||||
touch "$tmp/stale"
|
||||
if run provision "$owner_password"; then fail "apple: a leftover boot-partition unlock still stops setup after the refresh"; fi
|
||||
[[ -e $tmp/provisioning/pending ]] || fail "apple: setup stays pending"
|
||||
fi
|
||||
pass "stale boot entries are rebuilt by limine-update, behind the boot package's verify on Apple"
|
||||
|
||||
# Before the owner form: a no-op on x86 even with Mac entrypoints on disk, the
|
||||
# boot package's own answer on Apple.
|
||||
platform=x86
|
||||
fixture
|
||||
rm -f "$tmp/mac-boot-ran"
|
||||
touch "$tmp/prepare-fail"
|
||||
run setup "$owner_password" || fail "x86: setup reaches the owner form without a boot package" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/screen") == "owner form" && ! -e $tmp/mac-boot-ran ]] ||
|
||||
fail "x86: no Mac boot entrypoint runs before the owner form" "$(cat "$tmp/screen")"
|
||||
pass "x86: the platform check before the owner form is a no-op"
|
||||
|
||||
if [[ " ${platforms[*]} " == *" apple "* ]]; then
|
||||
platform=apple
|
||||
fixture
|
||||
run setup "$owner_password" && grep -qx 'owner form' "$tmp/screen" ||
|
||||
fail "apple: setup reaches the owner form with the boot package ready" "$(cat "$tmp/output")"
|
||||
fixture
|
||||
touch "$tmp/prepare-fail"
|
||||
if run setup "$owner_password"; then fail "apple: setup stops when the boot package is not ready"; fi
|
||||
! grep -qx 'owner form' "$tmp/screen" || fail "apple: the owner is asked nothing when the boot package is not ready"
|
||||
grep -q 'The boot partition is not mounted.' "$tmp/screen" && grep -q 'The boot partition is not mounted.' "$tmp/log" ||
|
||||
fail "apple: the boot package's reason reaches the screen and the log" "$(cat "$tmp/screen" "$tmp/log" 2>/dev/null)"
|
||||
pass "apple: setup stops before the owner form when the boot package is not ready"
|
||||
|
||||
# Apple without omarchy-mac-boot, or with one older than its provisioning
|
||||
# entrypoints: setup stops before the owner form naming the package, and a
|
||||
# worker that got past it anyway never finishes while any part of the staged
|
||||
# unlock remains.
|
||||
mv "$mac_boot" "$tmp/mac-boot.off"
|
||||
for package in missing old; do
|
||||
# An older package can already ship other files in that directory.
|
||||
if [[ $package == old ]]; then
|
||||
install -d -m 755 "$mac_boot"
|
||||
install -m 755 /dev/null "$mac_boot/boot-hook"
|
||||
fi
|
||||
fixture
|
||||
rm -f "$tmp/limine-ran" "$tmp/mac-boot-ran"
|
||||
if run setup "$owner_password"; then fail "apple ($package package): setup refuses without provisioning entrypoints"; fi
|
||||
! grep -qx 'owner form' "$tmp/screen" || fail "apple ($package package): the owner is asked nothing"
|
||||
grep -q 'provision-prepare on aarch64-apple needs omarchy-mac-boot' "$tmp/screen" ||
|
||||
fail "apple ($package package): the boot package is named on the screen" "$(cat "$tmp/screen" 2>/dev/null)"
|
||||
grep -q '/usr/lib/omarchy/mac-boot/provision-prepare' "$tmp/log" || fail "apple ($package package): the log names the entrypoint" "$(cat "$tmp/log")"
|
||||
if run provision "$owner_password"; then fail "apple ($package package): provisioning fails"; fi
|
||||
[[ -e $tmp/provisioning/pending && -f $tmp/provisioning/luks-key ]] && unlock_files_present ||
|
||||
fail "apple ($package package): provisioning keeps its state and the staged unlock"
|
||||
[[ -n $(opens "$staged_key") && -n $(opens "$seller_key") ]] || fail "apple ($package package): no slot is retired"
|
||||
fixture
|
||||
rm "$tmp/provisioning/luks-key"
|
||||
if run provision "$owner_password"; then fail "apple ($package package): a leftover boot-partition unlock fails provisioning"; fi
|
||||
[[ -e $tmp/provisioning/pending ]] && unlock_files_present || fail "apple ($package package): a leftover boot-partition unlock keeps provisioning pending"
|
||||
[[ ! -e $tmp/limine-ran && ! -e $tmp/mac-boot-ran ]] || fail "apple ($package package): the Limine UKI path is no fallback"
|
||||
[[ $package == missing ]] || rm -r "$mac_boot"
|
||||
done
|
||||
mv "$tmp/mac-boot.off" "$mac_boot"
|
||||
pass "apple: without omarchy-mac-boot's provisioning entrypoints setup stops naming the package, and the worker fails closed"
|
||||
fi
|
||||
|
||||
# A boot package that implements only one of the commit/verify pair owns
|
||||
# nothing: provisioning fails closed rather than mixing it with the Limine path.
|
||||
mkdir -p "$tmp/half/bin"
|
||||
cat >"$tmp/half/bin/omarchy-lifecycle-dispatch" <<SH
|
||||
#!/bin/bash
|
||||
echo "\$*" >>"$tmp/half-ran"
|
||||
[[ \$* != "--resolve provision-commit" ]] || echo /usr/lib/omarchy/mac-boot/provision-commit
|
||||
SH
|
||||
chmod +x "$tmp/half/bin/omarchy-lifecycle-dispatch"
|
||||
runtime=$tmp/half
|
||||
fixture
|
||||
rm -f "$tmp/limine-ran" "$tmp/half-ran"
|
||||
if run provision "$owner_password"; then fail "a half-implemented unlock fails provisioning"; fi
|
||||
[[ -e $tmp/provisioning/pending ]] && unlock_files_present || fail "a half-implemented unlock keeps provisioning pending"
|
||||
[[ -s $tmp/half-ran && ! -e $tmp/limine-ran ]] && ! grep -qv -- '--resolve' "$tmp/half-ran" ||
|
||||
fail "a half-implemented unlock runs neither the platform nor the Limine path" "$(cat "$tmp/half-ran")"
|
||||
runtime=$ROOT
|
||||
pass "a boot package implementing only half of the unlock pair fails closed"
|
||||
|
||||
# Any slot but the owner's is retired, a recovery key an earlier setup added
|
||||
# and recorded in the journal included.
|
||||
platform=x86
|
||||
backend=fake
|
||||
fixture
|
||||
printf '2 recovery-key\n' >>"$tmp/slots"
|
||||
printf 'recovery_slot=2\nrecovery_shown=1\n' >"$tmp/provisioning/luks-rekey.state"
|
||||
chmod 600 "$tmp/provisioning/luks-rekey.state"
|
||||
run rekey "$owner_password" || fail "the re-key beside an earlier recovery slot completes" "$(cat "$tmp/log")"
|
||||
[[ -z $(opens "$staged_key") && -z $(opens "$seller_key") && -z $(opens recovery-key) && -n $(opens "$owner_password") && $(slot_count) == 1 ]] ||
|
||||
fail "every slot but the owner's is retired" "$(cat "$tmp/slots")"
|
||||
pass "the re-key retires every slot but the owner's, an earlier recovery slot included"
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# A platform package names keys the way its keyboards print them. On a MacBook
|
||||
# XF86MonBrightnessUp/Down are the F2/F1 keys, so without the names the menu
|
||||
# shows rows like "SHIFT + XF86MonBrightnessUp". A cache written before the
|
||||
# names were installed must not win over them.
|
||||
|
||||
mock_bin=$(mktemp -d)
|
||||
cache_dir=$(mktemp -d)
|
||||
platform=$(mktemp -d)
|
||||
cleanup() {
|
||||
rm -rf "$mock_bin" "$cache_dir" "$platform"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cat >"$mock_bin/hyprctl" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == "devices" ]]; then
|
||||
echo "Keyboards:"
|
||||
echo " active keymap: Apple Keyboard (apple_vndr)"
|
||||
exit 0
|
||||
fi
|
||||
cat <<'BINDS'
|
||||
bindl
|
||||
modmask: 1
|
||||
key: XF86MonBrightnessUp
|
||||
keycode: 0
|
||||
description: Keyboard brightness up
|
||||
dispatcher: exec
|
||||
arg: omarchy-brightness-keyboard up
|
||||
|
||||
bindl
|
||||
modmask: 1
|
||||
key: XF86MonBrightnessDown
|
||||
keycode: 0
|
||||
description: Keyboard brightness down
|
||||
dispatcher: exec
|
||||
arg: omarchy-brightness-keyboard down
|
||||
|
||||
bindi
|
||||
modmask: 8
|
||||
key: XF86MonBrightnessUp
|
||||
keycode: 0
|
||||
description: Brightness up precise
|
||||
dispatcher: exec
|
||||
arg: omarchy-brightness-display +1%
|
||||
|
||||
bind
|
||||
modmask: 64
|
||||
key: F5
|
||||
keycode: 0
|
||||
description: Press brightness up
|
||||
dispatcher: exec
|
||||
arg: wtype -k XF86MonBrightnessUp
|
||||
|
||||
bindm
|
||||
modmask: 64
|
||||
key: Q
|
||||
keycode: 0
|
||||
description: Close window
|
||||
dispatcher: killactive
|
||||
arg:
|
||||
BINDS
|
||||
SH
|
||||
chmod +x "$mock_bin/hyprctl"
|
||||
|
||||
# Minimal valid keymap so keycode resolution has something to chew on.
|
||||
cat >"$mock_bin/xkbcli" <<'SH'
|
||||
#!/bin/bash
|
||||
cat <<'KEYMAP'
|
||||
xkb_keycodes {
|
||||
<AD01> = 24;
|
||||
};
|
||||
xkb_symbols {
|
||||
key <AD01> { [ q ] };
|
||||
};
|
||||
KEYMAP
|
||||
SH
|
||||
chmod +x "$mock_bin/xkbcli"
|
||||
|
||||
cat >"$mock_bin/omarchy-cmd-present" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$mock_bin/omarchy-cmd-present"
|
||||
|
||||
# The menu reads key names from the fixed platform root; copies of it read a
|
||||
# fixture root with key names, and one without.
|
||||
mkdir -p "$platform/root" "$platform/bin"
|
||||
printf '%s\n' 'XF86MonBrightnessUp F2' 'XF86MonBrightnessDown F1' 'not a line' 'Bad-Sym F3' >"$platform/root/key-names"
|
||||
platform_root_copy "$ROOT/bin/omarchy-menu-keybindings" "$platform/bin/with-names" "$platform/root"
|
||||
platform_root_copy "$ROOT/bin/omarchy-menu-keybindings" "$platform/bin/without-names" "$platform/none"
|
||||
|
||||
# Pre-seed the exact cache file that v13, which knew no key names, would read
|
||||
# for these mocked inputs. Cached records use the rendered row as field 1, followed by
|
||||
# the dispatcher and argument fields.
|
||||
mkdir -p "$cache_dir/omarchy"
|
||||
v13_key=$({
|
||||
printf 'v13\n'
|
||||
"$mock_bin/hyprctl" devices 2>/dev/null | grep -F 'active keymap:'
|
||||
"$mock_bin/hyprctl" binds 2>/dev/null
|
||||
} | sha256sum | awk '{ print $1 }')
|
||||
cat >"$cache_dir/omarchy/keybindings-$v13_key.records" <<'EOF'
|
||||
SHIFT + XF86MonBrightnessUp → Keyboard brightness up exec omarchy-brightness-keyboard up
|
||||
SHIFT + XF86MonBrightnessDown → Keyboard brightness down exec omarchy-brightness-keyboard down
|
||||
ALT + XF86MonBrightnessUp → Brightness up precise exec omarchy-brightness-display +1%
|
||||
SUPER + Q → Close window killactive
|
||||
EOF
|
||||
|
||||
output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$platform/bin/with-names" --print)
|
||||
|
||||
tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F2 → Keyboard brightness up' || \
|
||||
fail "SHIFT + XF86MonBrightnessUp renders as SHIFT + F2 with its description" "$output"
|
||||
|
||||
tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F1 → Keyboard brightness down' || \
|
||||
fail "SHIFT + XF86MonBrightnessDown renders as SHIFT + F1 with its description" "$output"
|
||||
|
||||
tr -s ' ' <<<"$output" | grep -qF 'ALT + F2 → Brightness up precise' || \
|
||||
fail "modifier combos keep the F-key name (ALT + F2)" "$output"
|
||||
|
||||
grep -qF 'XF86MonBrightness' <<<"$output" && \
|
||||
fail "raw XF86MonBrightness symbols no longer leak into the menu" "$output"
|
||||
|
||||
tr -s ' ' <<<"$output" | grep -qF 'SUPER + Q → Close window' || \
|
||||
fail "unrelated bindings render unchanged (SUPER + Q)" "$output"
|
||||
|
||||
# The menu runs a chosen row from its cached record, which carries what the
|
||||
# binding runs.
|
||||
records=$(cat "$cache_dir"/omarchy/keybindings-*.records)
|
||||
grep -qF 'wtype -k XF86MonBrightnessUp' <<<"$records" && ! grep -qF 'wtype -k F2' <<<"$records" ||
|
||||
fail "a key name never changes what a binding runs" "$records"
|
||||
|
||||
pass "keybindings menu shows a platform's key names (F1/F2), stale caches ignored"
|
||||
|
||||
# A key-names file saved with CRLF line ends, or with blanks around a line,
|
||||
# reads the same, as displays.conf does.
|
||||
mkdir -p "$platform/crlf"
|
||||
printf '%s\r\n' 'XF86MonBrightnessUp F2' ' XF86MonBrightnessDown F1 ' >"$platform/crlf/key-names"
|
||||
platform_root_copy "$ROOT/bin/omarchy-menu-keybindings" "$platform/bin/crlf-names" "$platform/crlf"
|
||||
output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$platform/bin/crlf-names" --print)
|
||||
tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F2 → Keyboard brightness up' && tr -s ' ' <<<"$output" | grep -qF 'SHIFT + F1 → Keyboard brightness down' ||
|
||||
fail "key names saved with CRLF line ends and blanks around them still apply" "$output"
|
||||
pass "keybindings menu reads key names saved with CRLF line ends"
|
||||
|
||||
# Without a platform package the keys keep their own names, and a cache made
|
||||
# with the names is not reused.
|
||||
output=$(PATH="$mock_bin:$PATH" XDG_CACHE_HOME="$cache_dir" "$platform/bin/without-names" --print)
|
||||
tr -s ' ' <<<"$output" | grep -qF 'SHIFT + XF86MonBrightnessUp → Keyboard brightness up' ||
|
||||
fail "without key names the brightness key keeps its own name" "$output"
|
||||
grep -qF 'SHIFT + F2' <<<"$output" && fail "without key names nothing is renamed F2" "$output"
|
||||
pass "keybindings menu keeps keysym names without a platform package"
|
||||
@@ -286,18 +286,27 @@ assert(
|
||||
assert(!defaultById['install.ai.crush'], 'menu removes Crush from Install > AI')
|
||||
// Software you already have keeps its place in Install, dimmed rather than
|
||||
// dropped, so the list reads as a catalog of what Omarchy can install.
|
||||
// Chromium Account is the sole Install row with anything left to hide for, so
|
||||
// any other `when:` here is a row that went back to vanishing once installed.
|
||||
// Chromium Account is the sole Install row with anything left to hide for. The
|
||||
// Windows VM, whose guest can't run elsewhere, and the installers whose vendors
|
||||
// ship Linux builds for x86_64 alone hide only off x86_64, so any other `when:`
|
||||
// here is a row that went back to vanishing once installed.
|
||||
const windowsGuard = 'omarchy-hw-x86'
|
||||
const x86OnlyInstalls = ['install.windows', 'install.browser.edge', 'install.service.dropbox', 'install.service.spotify', 'install.gaming.minecraft', 'install.gaming.heroic']
|
||||
assertDeepEqual(
|
||||
defaultItems
|
||||
.filter(item => item.id.startsWith('install.') && item.action && item.when)
|
||||
.map(item => item.id),
|
||||
['install.service.chromium-account'],
|
||||
.map(item => item.id)
|
||||
.sort(),
|
||||
[...x86OnlyInstalls, 'install.service.chromium-account'].sort(),
|
||||
'menu never hides an Install row because the software is already there'
|
||||
)
|
||||
assert(
|
||||
x86OnlyInstalls.every(id => defaultById[id].when === windowsGuard),
|
||||
'menu hides the Windows VM and the x86_64-only installers only off x86_64'
|
||||
)
|
||||
assert(
|
||||
['install.browser.zen', 'install.editor.vscode', 'install.gaming.steam', 'install.development.rust', 'install.windows'].every(
|
||||
id => defaultById[id].disabled && !defaultById[id].when
|
||||
id => defaultById[id].disabled && (!defaultById[id].when || defaultById[id].when === windowsGuard)
|
||||
),
|
||||
'menu dims the Install rows for software that is already installed'
|
||||
)
|
||||
@@ -343,6 +352,13 @@ assert(
|
||||
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
||||
'menu places Passwordless Sudo under Setup > Security'
|
||||
)
|
||||
assert(
|
||||
defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization')
|
||||
&& defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization')
|
||||
&& defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot')
|
||||
&& defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'),
|
||||
'menu can enable and remove USB device authorization under Security'
|
||||
)
|
||||
assert(
|
||||
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
||||
'menu removes the relocated toggles from Trigger > Toggle'
|
||||
@@ -467,6 +483,11 @@ assertEqual(
|
||||
'omarchy-hw-webcam',
|
||||
'menu only shows webcam screen recording when a webcam is available'
|
||||
)
|
||||
assertEqual(
|
||||
defaultById['trigger.capture.screenrecord.stop'].when,
|
||||
'omarchy-capture-screenrecording --status',
|
||||
'menu shows Stop Screenrecording only when its stop has a recording to end'
|
||||
)
|
||||
assert(
|
||||
/font\.family: row\.iconFont\.length > 0 \? row\.iconFont : root\.fontFamily/.test(menuQml),
|
||||
'menu rows support per-icon font families'
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Apple Silicon's boot package ships mkinitcpio drop-ins 90- to 94- that sort
|
||||
# after the HOOKS baseline and add the Asahi firmware and systemd unlock hooks
|
||||
# to it. The fixtures are the drop-ins omarchy-mac-boot 20260926-1.45 installs,
|
||||
# as read from an encrypted M1 Pro, byte for byte the same as
|
||||
# omarchy-mac-boot/files/etc/mkinitcpio.conf.d in omacom/omarchy-mac-pkgs at
|
||||
# 4399105 (MIT). The encryption drop-in leaves a line carrying
|
||||
# busybox encrypt alone, since that is a Mac unlocked through cryptdevice=, so
|
||||
# the baseline must give a Mac the systemd line.
|
||||
require_platform_fixtures "the Apple boot drop-ins on the HOOKS baseline"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
fake_platform "$tmp/platform" aarch64-apple
|
||||
# No kernel modules here: the HID drop-in adds only what modinfo finds.
|
||||
printf '#!/bin/bash\nexit 1\n' >"$tmp/platform/bin/modinfo"
|
||||
chmod +x "$tmp/platform/bin/modinfo"
|
||||
|
||||
# $1 is the HOOKS line in mkinitcpio.conf. The drop-ins read /etc/vconsole.conf,
|
||||
# so point them at a fixture.
|
||||
compose() {
|
||||
local config=$tmp/buildconfig conf
|
||||
local -a conf_files=()
|
||||
rm -rf "$tmp/conf.d"
|
||||
mkdir -p "$tmp/conf.d"
|
||||
cp "$ROOT"/etc/mkinitcpio.conf.d/*.conf "$ROOT"/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/*.conf "$tmp/conf.d/"
|
||||
sed -i "s|/etc/vconsole.conf|$tmp/vconsole.conf|g" "$tmp/conf.d"/*.conf
|
||||
printf 'MODULES=()\nBINARIES=()\nFILES=()\nHOOKS=(%s)\n' "$1" >"$config"
|
||||
mapfile -d '' conf_files < <(LC_ALL=C.UTF-8 find "$tmp/conf.d" -maxdepth 1 -xtype f -name '*.conf' -print0 |
|
||||
sed -z 's/.*\///' | LC_ALL=C.UTF-8 sort -zVu)
|
||||
for conf in "${conf_files[@]}"; do
|
||||
cat -- "$tmp/conf.d/$conf" >>"$config"
|
||||
done
|
||||
OMARCHY_PROC_ROOT="$tmp/platform/proc" PATH="$tmp/platform/bin:$ROOT/bin:$PATH" "$BASH" -c '
|
||||
. "$1" || exit 1
|
||||
printf "HOOKS=%s\nFILES=%s\n" "${HOOKS[*]}" "${FILES[*]}"
|
||||
' -- "$config"
|
||||
}
|
||||
|
||||
assert_hooks() {
|
||||
local description="$1" stock="$2" expected="$3" out
|
||||
out=$(compose "$stock") || fail "$description" "the drop-ins do not source cleanly"
|
||||
[[ $(sed -n 's/^HOOKS=//p' <<<"$out") == "$expected" ]] ||
|
||||
fail "$description" "expected: HOOKS=$expected"$'\n'"actual: $out"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
# The HOOKS the M1 Pro builds its encrypted image from today.
|
||||
apple="base systemd plymouth autodetect microcode modconf kms keyboard sd-vconsole block asahi omarchy-vendorfw omarchy-mac-encrypt sd-encrypt filesystems fsck"
|
||||
alarm_systemd="base systemd autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck"
|
||||
alarm_busybox="base udev autodetect microcode modconf kms keyboard keymap consolefont block filesystems fsck"
|
||||
|
||||
printf 'KEYMAP=us\nXKBLAYOUT=us\n' >"$tmp/vconsole.conf"
|
||||
assert_hooks "a Mac keeps its firmware and systemd unlock hooks" "$alarm_systemd" "$apple"
|
||||
assert_hooks "a Mac whose mkinitcpio.conf has the busybox line gets the same hooks" "$alarm_busybox" "$apple"
|
||||
out=$(compose "$alarm_systemd")
|
||||
[[ $(sed -n 's/^FILES=//p' <<<"$out") == *"$tmp/vconsole.conf"* ]] ||
|
||||
fail "a Latin layout reaches the image" "$out"
|
||||
pass "a Latin layout reaches the image"
|
||||
|
||||
printf 'KEYMAP=ru\nXKBLAYOUT=ru\n' >"$tmp/vconsole.conf"
|
||||
assert_hooks "a non-Latin layout keeps the prompt on the US map" "$alarm_systemd" "${apple/ sd-vconsole / }"
|
||||
out=$(compose "$alarm_systemd")
|
||||
[[ $(sed -n 's/^FILES=//p' <<<"$out") != *vconsole* ]] ||
|
||||
fail "a non-Latin layout stays out of the image" "$out"
|
||||
pass "a non-Latin layout stays out of the image"
|
||||
|
||||
# A Mac that unlocks through cryptdevice= keeps its busybox line, drop-ins and all.
|
||||
printf 'KEYMAP=us\nXKBLAYOUT=us\n' >"$tmp/vconsole.conf"
|
||||
legacy="base udev plymouth autodetect microcode modconf kms keyboard keymap consolefont block encrypt asahi filesystems fsck"
|
||||
assert_hooks "a busybox encrypt Mac keeps its own line" "$legacy" "$legacy"
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_platform_fixtures "the composed mkinitcpio HOOKS"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fake_platform "$test_tmp/platforms/$platform" "$platform"
|
||||
done
|
||||
# omarchy-settings without the runtime package: no detector on PATH.
|
||||
mkdir -p "$test_tmp/platforms/no-detector/bin"
|
||||
# A device tree naming both Apple and Qualcomm, which the detector refuses.
|
||||
fake_platform "$test_tmp/platforms/contradiction" aarch64-apple
|
||||
printf '%s\0' apple,j416c qcom,x1e80100 >"$test_tmp/platforms/contradiction/proc/device-tree/compatible"
|
||||
|
||||
etc="$test_tmp/etc"
|
||||
devices="$test_tmp/devices"
|
||||
|
||||
omarchy_hooks="base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs"
|
||||
omarchy_hooks_without_kms=${omarchy_hooks/ kms / }
|
||||
apple_hooks="base systemd plymouth autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck"
|
||||
nvidia_modules="nvidia nvidia_modeset nvidia_uvm nvidia_drm"
|
||||
|
||||
# The kernel being built, as modinfo answers for it: KERNEL_MODULES names its
|
||||
# modules (thunderbolt, as on x86 and Aurora, unless a case says otherwise) and
|
||||
# KERNEL_BUILTINS the ones built in.
|
||||
cat >"$test_tmp/modinfo" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == -k && $2 == "$KERNELVERSION" && $3 == -F && $4 == filename ]] || exit 1
|
||||
if [[ " ${KERNEL_MODULES-thunderbolt} " == *" $5 "* ]]; then
|
||||
printf '/usr/lib/modules/%s/kernel/%s.ko.zst\n' "$KERNELVERSION" "$5"
|
||||
elif [[ " ${KERNEL_BUILTINS-} " == *" $5 "* ]]; then
|
||||
printf '(builtin)\n'
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
SH
|
||||
chmod +x "$test_tmp/modinfo"
|
||||
for fixture in "$test_tmp"/platforms/*; do
|
||||
cp "$test_tmp/modinfo" "$fixture/bin/"
|
||||
done
|
||||
|
||||
# mkinitcpio's stock mkinitcpio.conf, which the baseline replaces, plus every
|
||||
# drop-in Omarchy ships, on a machine with no PCI devices.
|
||||
new_etc() {
|
||||
rm -rf "$etc" "$devices"
|
||||
mkdir -p "$etc/mkinitcpio.conf.d" "$devices"
|
||||
cat >"$etc/mkinitcpio.conf" <<'CONF'
|
||||
MODULES=()
|
||||
BINARIES=()
|
||||
FILES=()
|
||||
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck)
|
||||
CONF
|
||||
cp "$ROOT"/etc/mkinitcpio.conf.d/*.conf "$etc/mkinitcpio.conf.d/"
|
||||
}
|
||||
|
||||
# $1 names the drop-in; stdin is its content.
|
||||
drop_in() {
|
||||
cat >"$etc/mkinitcpio.conf.d/$1"
|
||||
}
|
||||
|
||||
# Each argument is a PCI device as "vendor:class", in sysfs's own format.
|
||||
pci_devices() {
|
||||
local index=0 spec slot
|
||||
for spec in "$@"; do
|
||||
slot=$(printf '0000:%02x:00.0' "$index")
|
||||
mkdir -p "$devices/$slot"
|
||||
printf '%s\n' "${spec%%:*}" >"$devices/$slot/vendor"
|
||||
printf '%s\n' "${spec##*:}" >"$devices/$slot/class"
|
||||
index=$((index + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# mkinitcpio appends the drop-ins to mkinitcpio.conf in its own order and
|
||||
# sources the result once. Do the same on a platform fixture and print what the
|
||||
# image is built from. The vconsole.conf entry comes from the host's own file,
|
||||
# so it is left out of FILES.
|
||||
compose() {
|
||||
local fixture="$test_tmp/platforms/$1" config="$test_tmp/buildconfig" conf
|
||||
local -a conf_files=()
|
||||
mapfile -d '' conf_files < <(LC_ALL=C.UTF-8 find "$etc/mkinitcpio.conf.d" -maxdepth 1 -xtype f -name '*.conf' -print0 |
|
||||
sed -z 's/.*\///' | LC_ALL=C.UTF-8 sort -zVu)
|
||||
cat -- "$etc/mkinitcpio.conf" >"$config"
|
||||
for conf in "${conf_files[@]}"; do
|
||||
cat -- "$etc/mkinitcpio.conf.d/$conf" >>"$config"
|
||||
done
|
||||
|
||||
local path="$fixture/bin:$ROOT/bin:$PATH"
|
||||
[[ $1 != "no-detector" ]] || path="$fixture/bin"
|
||||
KERNELVERSION=6.99.0-test OMARCHY_PROC_ROOT="$fixture/proc" OMARCHY_PCI_DEVICES_PATH="$devices" PATH="$path" "$BASH" -c '
|
||||
. "$1" || exit 1
|
||||
files=()
|
||||
for file in "${FILES[@]}"; do
|
||||
[[ $file == /etc/vconsole.conf ]] || files+=("$file")
|
||||
done
|
||||
printf "HOOKS=%s\nMODULES=%s\nFILES=%s\n" "${HOOKS[*]}" "${MODULES[*]}" "${files[*]}"
|
||||
' -- "$config"
|
||||
}
|
||||
|
||||
assert_composed() {
|
||||
local description="$1" platform="$2" hooks="$3" modules="$4" files="$5"
|
||||
local expected actual
|
||||
expected=$(printf 'HOOKS=%s\nMODULES=%s\nFILES=%s\n' "$hooks" "$modules" "$files")
|
||||
actual=$(compose "$platform") || fail "$description" "the drop-ins do not source cleanly"
|
||||
[[ $actual == "$expected" ]] ||
|
||||
fail "$description" "expected:"$'\n'"$expected"$'\n'"actual:"$'\n'"$actual"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
assert_hooks() {
|
||||
local description="$1" platform="$2" expected="$3" actual
|
||||
actual=$(compose "$platform") || fail "$description" "the drop-ins do not source cleanly"
|
||||
actual=$(sed -n 's/^HOOKS=//p' <<<"$actual")
|
||||
[[ $actual == "$expected" ]] ||
|
||||
fail "$description" "expected: $expected"$'\n'"actual: $actual"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
# Each platform starts from its own baseline, whatever mkinitcpio.conf says.
|
||||
new_etc
|
||||
assert_hooks "Apple Silicon starts from the systemd baseline" aarch64-apple "$apple_hooks"
|
||||
assert_hooks "plain aarch64 starts from the Omarchy baseline" aarch64 "$omarchy_hooks"
|
||||
assert_hooks "x86 starts from the Omarchy baseline" x86 "$omarchy_hooks"
|
||||
assert_hooks "without the detector the Omarchy baseline stays" no-detector "$omarchy_hooks"
|
||||
|
||||
if composed=$(compose contradiction 2>"$test_tmp/contradiction.err"); then
|
||||
fail "a platform the detector cannot place stops the build" "composed: $composed"
|
||||
fi
|
||||
grep -q "contradictory platform identity" "$test_tmp/contradiction.err" ||
|
||||
fail "a platform the detector cannot place stops the build" "stderr: $(<"$test_tmp/contradiction.err")"
|
||||
pass "a platform the detector cannot place stops the build"
|
||||
|
||||
# A Mac that set up its own initramfs before the Apple boot package keeps it:
|
||||
# busybox encrypt unlocks through cryptdevice=, which sd-encrypt cannot parse,
|
||||
# and the asahi hook carries the firmware. The asahi hook marks such a root off
|
||||
# a Mac too. Busybox encrypt alone means nothing off Apple Silicon.
|
||||
legacy_encrypted="base udev plymouth autodetect microcode modconf kms keyboard keymap consolefont block encrypt asahi filesystems fsck"
|
||||
legacy_plain="base udev autodetect microcode modconf kms keyboard keymap consolefont block asahi filesystems fsck"
|
||||
for legacy in "$legacy_encrypted" "$legacy_plain" "${legacy_encrypted/ asahi / }"; do
|
||||
new_etc
|
||||
sed -i "s/^HOOKS=.*/HOOKS=($legacy)/" "$etc/mkinitcpio.conf"
|
||||
assert_hooks "a Mac keeps its own HOOKS=($legacy)" aarch64-apple "$legacy"
|
||||
done
|
||||
new_etc
|
||||
sed -i "s/^HOOKS=.*/HOOKS=($legacy_plain)/" "$etc/mkinitcpio.conf"
|
||||
assert_hooks "an asahi root keeps its HOOKS off a Mac" aarch64 "$legacy_plain"
|
||||
# A legacy GRUB Mac has the asahi hook right after base, where Asahi's images
|
||||
# put it, and may run a runtime older than the detector.
|
||||
legacy_grub="base asahi udev plymouth autodetect microcode modconf kms keyboard keymap consolefont block encrypt filesystems fsck"
|
||||
for platform in aarch64-apple no-detector; do
|
||||
for legacy in "$legacy_grub" "${legacy_grub/ encrypt / }"; do
|
||||
new_etc
|
||||
sed -i "s/^HOOKS=.*/HOOKS=($legacy)/" "$etc/mkinitcpio.conf"
|
||||
assert_hooks "a legacy GRUB Mac keeps HOOKS=($legacy) ($platform)" "$platform" "$legacy"
|
||||
done
|
||||
done
|
||||
new_etc
|
||||
sed -i "s/^HOOKS=.*/HOOKS=(base udev autodetect modconf block encrypt filesystems fsck)/" "$etc/mkinitcpio.conf"
|
||||
assert_hooks "busybox encrypt off Apple Silicon still gets the Omarchy baseline" x86 "$omarchy_hooks"
|
||||
new_etc
|
||||
sed -i "s/^HOOKS=.*/HOOKS=(base udev autodetect modconf kms keyboard keymap consolefont block filesystems fsck)/" "$etc/mkinitcpio.conf"
|
||||
assert_hooks "a Mac with a stock busybox line and no asahi hook gets the systemd baseline" aarch64-apple "$apple_hooks"
|
||||
|
||||
# A platform fragment sorts after the baseline and before omarchy_hooks.conf,
|
||||
# as omarchy-mac-boot's 90- drop-ins do. Its hooks must reach the image.
|
||||
new_etc
|
||||
drop_in 90-platform-fragment.conf <<'CONF'
|
||||
_fragment_hooks=()
|
||||
for _fragment_hook in "${HOOKS[@]}"; do
|
||||
[[ $_fragment_hook == "filesystems" ]] && _fragment_hooks+=(platform-firmware)
|
||||
_fragment_hooks+=("$_fragment_hook")
|
||||
done
|
||||
HOOKS=("${_fragment_hooks[@]}" platform-late)
|
||||
unset _fragment_hooks _fragment_hook
|
||||
CONF
|
||||
assert_hooks "a platform fragment's hooks survive on Apple Silicon" aarch64-apple \
|
||||
"base systemd plymouth autodetect microcode modconf kms keyboard sd-vconsole block platform-firmware filesystems fsck platform-late"
|
||||
assert_hooks "a platform fragment's hooks survive on plain aarch64" aarch64 \
|
||||
"base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt platform-firmware filesystems fsck btrfs-overlayfs platform-late"
|
||||
assert_hooks "a platform fragment's hooks survive on x86" x86 \
|
||||
"base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt platform-firmware filesystems fsck btrfs-overlayfs platform-late"
|
||||
|
||||
# The NVIDIA filter still runs after the fragment and removes only kms.
|
||||
drop_in nvidia.conf <<<"MODULES+=($nvidia_modules)"
|
||||
pci_devices 0x10de:0x030000
|
||||
assert_hooks "the NVIDIA filter keeps a platform fragment's hooks" x86 \
|
||||
"base udev plymouth keyboard autodetect microcode modconf keymap consolefont block encrypt platform-firmware filesystems fsck btrfs-overlayfs platform-late"
|
||||
|
||||
# Existing x86 configurations build the image they built before the baseline
|
||||
# moved: the same HOOKS, MODULES and FILES.
|
||||
new_etc
|
||||
assert_composed "a machine without hardware drop-ins is unchanged" x86 \
|
||||
"$omarchy_hooks" "thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
drop_in nvidia.conf <<<"MODULES+=($nvidia_modules)"
|
||||
pci_devices 0x10de:0x030000
|
||||
assert_composed "NVIDIA-only drops only kms" x86 \
|
||||
"$omarchy_hooks_without_kms" "$nvidia_modules thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
drop_in nvidia.conf <<<"MODULES+=($nvidia_modules)"
|
||||
pci_devices 0x8086:0x030000 0x10de:0x030200
|
||||
assert_composed "hybrid graphics keeps kms for the iGPU" x86 \
|
||||
"$omarchy_hooks" "$nvidia_modules thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
pci_devices 0x10de:0x030000
|
||||
assert_composed "NVIDIA-only without early nvidia_drm keeps kms" x86 \
|
||||
"$omarchy_hooks" "thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
drop_in nvidia.conf <<<"MODULES+=($nvidia_modules)"
|
||||
drop_in omarchy_resume.conf <<<"HOOKS+=(resume)"
|
||||
drop_in 99-omarchy-provisioning-key.conf <<<"FILES+=(/etc/omarchy/provisioning.key)"
|
||||
pci_devices 0x10de:0x030000
|
||||
assert_composed "NVIDIA-only with hibernation and a provisioning key is unchanged" x86 \
|
||||
"$omarchy_hooks_without_kms resume" "$nvidia_modules thunderbolt" "/etc/omarchy/provisioning.key"
|
||||
|
||||
new_etc
|
||||
drop_in apple-t2.conf <<<"MODULES+=(t2bce_vhci usbhid hid_apple hid_generic xhci_pci xhci_hcd)"
|
||||
assert_composed "a T2 Mac is unchanged" x86 \
|
||||
"$omarchy_hooks" "t2bce_vhci usbhid hid_apple hid_generic xhci_pci xhci_hcd thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
drop_in macbook_spi_modules.conf <<<"MODULES=(applespi intel_lpss_pci spi_pxa2xx_platform)"
|
||||
assert_composed "an SPI keyboard MacBook is unchanged" x86 \
|
||||
"$omarchy_hooks" "applespi intel_lpss_pci spi_pxa2xx_platform thunderbolt" ""
|
||||
|
||||
new_etc
|
||||
drop_in nvidia.conf <<<"MODULES+=($nvidia_modules)"
|
||||
drop_in surface_device_modules.conf <<<"MODULES=(pinctrl_tigerlake surface_aggregator surface_aggregator_registry surface_aggregator_hub surface_hid_core surface_hid surface_kbd intel_lpss_pci 8250_dw)"
|
||||
pci_devices 0x8086:0x030000
|
||||
assert_composed "a Surface is unchanged" x86 \
|
||||
"$omarchy_hooks" "pinctrl_tigerlake surface_aggregator surface_aggregator_registry surface_aggregator_hub surface_hid_core surface_hid surface_kbd intel_lpss_pci 8250_dw thunderbolt" ""
|
||||
|
||||
|
||||
# Snapdragon and other aarch64 machines build the same image as x86, whatever
|
||||
# systemd line their mkinitcpio.conf starts from.
|
||||
for platform in aarch64; do
|
||||
new_etc
|
||||
drop_in omarchy_resume.conf <<<"HOOKS+=(resume)"
|
||||
drop_in 99-omarchy-provisioning-key.conf <<<"FILES+=(/etc/omarchy/provisioning.key)"
|
||||
assert_composed "$platform with hibernation and a provisioning key is unchanged" "$platform" \
|
||||
"$omarchy_hooks resume" "thunderbolt" "/etc/omarchy/provisioning.key"
|
||||
done
|
||||
|
||||
# thunderbolt is early-loaded only where the kernel builds it as a module. A
|
||||
# systemd initramfs writes MODULES into modules-load.d as they are, so neither
|
||||
# an optional thunderbolt? nor a module the kernel lacks may reach it.
|
||||
new_etc
|
||||
assert_composed "Apple Silicon early-loads the Aurora kernel's thunderbolt module" aarch64-apple \
|
||||
"$apple_hooks" "thunderbolt" ""
|
||||
new_etc
|
||||
KERNEL_MODULES="" assert_composed "a plain aarch64 kernel without thunderbolt leaves it out" aarch64 \
|
||||
"$omarchy_hooks" "" ""
|
||||
new_etc
|
||||
KERNEL_MODULES="" KERNEL_BUILTINS=thunderbolt assert_composed "a kernel with thunderbolt built in leaves it out" x86 \
|
||||
"$omarchy_hooks" "" ""
|
||||
@@ -97,6 +97,8 @@ cat >"$fake_bin/omarchy-hyprland-monitor-clamshell" <<'SH'
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cp "$fake_bin/omarchy-hyprland-monitor-clamshell" "$fake_bin/omarchy-system-lid-inhibit"
|
||||
|
||||
chmod +x "$fake_bin"/*
|
||||
ln -s "$ROOT/bin/omarchy-hyprland-monitor-modeless" "$fake_bin/omarchy-hyprland-monitor-modeless"
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ grep -F 'hyprctl monitors all -j' "$ROOT/bin/omarchy-hyprland-monitor-modeless"
|
||||
pass "modeless helper sees mirrors and ignores monitors disabled on purpose"
|
||||
|
||||
grep -F 'omarchy-hw-laptop-closed && omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null
|
||||
grep -F '/proc/acpi/button/lid/*/state' "$hw_laptop_closed" >/dev/null
|
||||
grep -F 'org.freedesktop.login1.Manager LidClosed' "$hw_laptop_closed" >/dev/null
|
||||
grep -F 'OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid' "$hw_laptop_closed" >/dev/null
|
||||
pass "clamshell helper detects closed-lid external monitor state"
|
||||
|
||||
# A mirrored external is absent from plain `monitors`, so asking without `all`
|
||||
@@ -99,6 +100,10 @@ pass "internal mirror helper recovers when no active external display remains"
|
||||
|
||||
grep -F 'switch:on:Lid Switch", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null
|
||||
grep -F 'switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null
|
||||
grep -F 'switch:on:Apple SMC power/lid events", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null ||
|
||||
fail "closing an Apple Silicon lid runs the same lid-close as any laptop"
|
||||
grep -F 'switch:off:Apple SMC power/lid events", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null ||
|
||||
fail "opening an Apple Silicon lid restores the panel like any laptop"
|
||||
pass "lid switch bindings lock on close and reconcile clamshell display state"
|
||||
|
||||
grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$system_wake" >/dev/null
|
||||
|
||||
@@ -259,6 +259,18 @@ assertDeepEqual(
|
||||
'notifications ignore a left bar for popup placement'
|
||||
)
|
||||
|
||||
// A toast clears the bar as thick as it is on the toast's own screen: a notch
|
||||
// floor can make a top bar thicker on one screen only. A bar without
|
||||
// per-screen sizes (a plugin bar) counts its configured size, and a hidden or
|
||||
// missing bar the default size, as before.
|
||||
const notchBar = { barHidden: false, barSize: 26, barSizeFor: name => (name === 'eDP-1' ? 40 : 26) }
|
||||
assertEqual(notifications.barClearance(notchBar, 'eDP-1', 26, 6), 46, 'a toast clears a notch-floored bar on its screen')
|
||||
assertEqual(notifications.barClearance(notchBar, 'DP-1', 26, 6), 32, 'a toast on another screen clears the configured size')
|
||||
assertEqual(notifications.barClearance({ barHidden: false, barSize: 30 }, 'eDP-1', 26, 6), 36, 'a bar without per-screen sizes counts its configured size')
|
||||
assertEqual(notifications.barClearance({ barHidden: true, barSize: 30, barSizeFor: () => 40 }, 'eDP-1', 26, 6), 32, 'a hidden bar counts the default size')
|
||||
assertEqual(notifications.barClearance(null, 'eDP-1', 28, 6), 34, 'a missing bar counts the default size')
|
||||
assertEqual(notifications.barClearance({ barHidden: false, barSize: undefined }, 'eDP-1', 26, 6), 6, 'an unreadable size counts as none')
|
||||
|
||||
const notification = {
|
||||
id: 12,
|
||||
appName: 'Mail',
|
||||
@@ -599,6 +611,11 @@ assert(!('exec' in legacyRestored), 'a restored legacy popup drops the old exec
|
||||
assertEqual(notifications.parseExecArgv(legacyRestored.execArgv || ''), null, 'a restored legacy popup has no runnable click action')
|
||||
|
||||
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8')
|
||||
assert(
|
||||
/function barClearanceFor\(screenName\) \{\s*return NotificationLogic\.barClearance\(shell \? shell\.bar : null, screenName, defaultBarSize, Style\.gapsOut\)\s*\}/.test(serviceQml) &&
|
||||
/NotificationLogic\.popupPlacement\(\s*service\.barPosition, service\.barClearanceFor\(modelData \? modelData\.name : ""\), Style\.gapsOut\)/.test(serviceQml),
|
||||
'each screen\'s toasts clear the bar as thick as it is on that screen'
|
||||
)
|
||||
assert(
|
||||
/readonly property int historyLimit: 10/.test(serviceQml),
|
||||
'notifications service keeps the last ten notifications in history'
|
||||
|
||||
@@ -7,7 +7,9 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
baseline_conf="$ROOT/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf"
|
||||
hooks_conf="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf"
|
||||
fake_platform "$tmp_dir/generic" x86
|
||||
|
||||
# Each argument is a PCI device as "vendor:class", in sysfs's own format.
|
||||
write_pci_devices() {
|
||||
@@ -26,8 +28,8 @@ write_pci_devices() {
|
||||
done
|
||||
}
|
||||
|
||||
# Sources the hook config the way mkinitcpio does — with earlier drop-ins
|
||||
# already applied — and prints the resulting HOOKS. mkinitcpio does not run
|
||||
# Sources the hook config the way mkinitcpio does — after the baseline and
|
||||
# the earlier drop-ins — and prints the resulting HOOKS. mkinitcpio does not run
|
||||
# under set -u, but the config must survive it, so source under it anyway.
|
||||
# "unset" leaves MODULES undeclared entirely.
|
||||
resolved_hooks() {
|
||||
@@ -37,9 +39,11 @@ resolved_hooks() {
|
||||
# The vconsole block sources the host's /etc/vconsole.conf, which may set
|
||||
# only KEYMAP; predefine XKBLAYOUT so its expansion survives set -u and the
|
||||
# test stays independent of the machine it runs on.
|
||||
OMARCHY_PCI_DEVICES_PATH="$tmp_dir/devices" bash -uc "
|
||||
OMARCHY_PCI_DEVICES_PATH="$tmp_dir/devices" OMARCHY_PROC_ROOT="$tmp_dir/generic/proc" \
|
||||
PATH="$tmp_dir/generic/bin:$ROOT/bin:$PATH" bash -uc "
|
||||
FILES=()
|
||||
XKBLAYOUT=us
|
||||
source '$baseline_conf'
|
||||
$modules_decl
|
||||
source '$hooks_conf'
|
||||
echo \"\${HOOKS[*]}\"
|
||||
@@ -101,3 +105,28 @@ write_pci_devices 0x10de:0x030000
|
||||
mkdir -p "$tmp_dir/devices/0000:01:00.0"
|
||||
assert_hooks "unreadable device beside an NVIDIA GPU keeps kms" \
|
||||
"$nvidia_modules" "$with_kms"
|
||||
|
||||
mkdir -p "$tmp_dir/conf" "$tmp_dir/bin"
|
||||
cp "$baseline_conf" "$tmp_dir/conf/00-omarchy-hooks.conf"
|
||||
cp "$hooks_conf" "$tmp_dir/conf/omarchy_hooks.conf"
|
||||
printf 'MODULES=(%s)\n' "$nvidia_modules" >"$tmp_dir/conf/nvidia.conf"
|
||||
printf '#!/bin/bash\nexec "$@"\n' >"$tmp_dir/bin/sudo"
|
||||
printf '#!/bin/bash\necho rebuild >>"$TEST_REBUILD_LOG"\n' >"$tmp_dir/bin/limine-mkinitcpio"
|
||||
chmod +x "$tmp_dir/bin/"*
|
||||
export PATH="$tmp_dir/bin:$tmp_dir/generic/bin:$ROOT/bin:$PATH"
|
||||
export OMARCHY_PROC_ROOT="$tmp_dir/generic/proc" OMARCHY_PCI_DEVICES_PATH="$tmp_dir/devices"
|
||||
export OMARCHY_MKINITCPIO_HOOKS_CONF="$tmp_dir/conf/omarchy_hooks.conf"
|
||||
export OMARCHY_MKINITCPIO_NVIDIA_CONF="$tmp_dir/conf/nvidia.conf"
|
||||
export OMARCHY_KMS_REBUILD_MARKER="$tmp_dir/rebuilt" TEST_REBUILD_LOG="$tmp_dir/rebuild-log"
|
||||
|
||||
write_pci_devices 0x1002:0x030000 0x10de:0x030200
|
||||
bash -euo pipefail "$ROOT/migrations/1786605598.sh" >/dev/null
|
||||
[[ ! -e $TEST_REBUILD_LOG && ! -e $OMARCHY_KMS_REBUILD_MARKER ]] || fail "the NVIDIA migration skips hybrid graphics with the split hook baseline"
|
||||
pass "the NVIDIA migration reads the new baseline and skips hybrid graphics"
|
||||
|
||||
write_pci_devices 0x10de:0x030000
|
||||
bash -euo pipefail "$ROOT/migrations/1786605598.sh" >/dev/null
|
||||
[[ $(<"$TEST_REBUILD_LOG") == "rebuild" && -e $OMARCHY_KMS_REBUILD_MARKER ]] || fail "the NVIDIA migration still rebuilds NVIDIA-only graphics"
|
||||
bash -euo pipefail "$ROOT/migrations/1786605598.sh" >/dev/null
|
||||
[[ $(<"$TEST_REBUILD_LOG") == "rebuild" ]] || fail "the NVIDIA migration respects its completed marker"
|
||||
pass "the NVIDIA migration still rebuilds NVIDIA-only graphics once"
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
require_command python3
|
||||
require_command pacman-conf
|
||||
|
||||
# Every platform's repositories are a pacman.conf and mirrorlist per channel,
|
||||
# copied into place whole on a channel change and at install finalization, as
|
||||
# x86_64's always were.
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
source "$ROOT/install/helpers/pacman.sh"
|
||||
|
||||
platforms="x86 aarch64 aarch64-apple"
|
||||
# aarch64 has edge alone: stable and rc there would install the release line,
|
||||
# which has no aarch64 support.
|
||||
channels_for() {
|
||||
if [[ $1 == "x86" ]]; then echo "stable rc edge"; else echo "edge"; fi
|
||||
}
|
||||
|
||||
# ── the templates ────────────────────────────────────────────────────────────
|
||||
|
||||
[[ $(omarchy_pacman_templates x86) == "$ROOT/default/pacman" ]] || fail "x86 keeps its templates where they were"
|
||||
[[ $(omarchy_pacman_templates aarch64) == "$ROOT/default/pacman/aarch64" ]] || fail "plain aarch64 uses the aarch64 templates"
|
||||
[[ $(omarchy_pacman_templates aarch64-apple) == "$ROOT/default/pacman/aarch64-apple" ]] || fail "Apple Silicon uses its own templates"
|
||||
! omarchy_pacman_templates riscv 2>/dev/null || fail "an unknown platform has no templates"
|
||||
pass "each platform's templates sit in a directory of their own, x86_64's where they always were"
|
||||
|
||||
[[ $(omarchy_pacman_default_channel x86) == stable ]] || fail "x86 defaults to stable"
|
||||
for platform in aarch64 aarch64-apple; do
|
||||
[[ $(omarchy_pacman_default_channel "$platform") == edge ]] || fail "$platform defaults to edge"
|
||||
done
|
||||
! omarchy_pacman_default_channel riscv 2>/dev/null || fail "an unknown platform has no default channel"
|
||||
pass "x86 defaults to stable and every aarch64 platform to edge"
|
||||
|
||||
# pacman reads each repository list from the template, with its mirrorlist
|
||||
# standing in for /etc/pacman.d/mirrorlist.
|
||||
repos() {
|
||||
local templates=$1 channel=$2
|
||||
sed "s|/etc/pacman.d/mirrorlist|$templates/mirrorlist-$channel|" "$templates/pacman-$channel.conf" >"$work/pacman.conf"
|
||||
pacman-conf --config "$work/pacman.conf" --repo-list | tr '\n' ' '
|
||||
}
|
||||
for platform in $platforms; do
|
||||
templates=$(omarchy_pacman_templates "$platform")
|
||||
for channel in stable rc; do
|
||||
[[ $platform == "x86" ]] && continue
|
||||
[[ ! -e $templates/pacman-$channel.conf && ! -e $templates/mirrorlist-$channel ]] ||
|
||||
fail "$platform has no $channel template or mirrorlist"
|
||||
done
|
||||
for channel in $(channels_for "$platform"); do
|
||||
[[ -f $templates/pacman-$channel.conf && -f $templates/mirrorlist-$channel ]] ||
|
||||
fail "$platform has a $channel template and mirrorlist"
|
||||
list=$(repos "$templates" "$channel") || fail "$platform $channel: pacman reads the template"
|
||||
case $platform in
|
||||
x86) expected="omarchy core extra multilib " ;;
|
||||
aarch64) expected="omarchy core extra alarm aur " ;;
|
||||
aarch64-apple) expected="omarchy asahi-alarm core extra alarm aur " ;;
|
||||
esac
|
||||
[[ $list == "$expected" ]] || fail "$platform $channel: repositories in order" "$list"
|
||||
# $arch stays literal: pacman fills it in on the machine.
|
||||
server=$(sed -n '/^\[omarchy\]/,/^\[/s/^Server = //p' "$templates/pacman-$channel.conf")
|
||||
[[ $server == "https://pkgs.omarchy.org/$channel/\$arch" ]] ||
|
||||
fail "$platform $channel: Omarchy's $channel repository" "$server"
|
||||
done
|
||||
done
|
||||
pass "x86 has templates for stable, rc and edge and aarch64 for edge alone, each with its repositories in order"
|
||||
|
||||
# ── install finalization ─────────────────────────────────────────────────────
|
||||
|
||||
finalize_bin=$work/finalize-bin
|
||||
mkdir -p "$finalize_bin"
|
||||
printf '#!/bin/bash\necho "$PLATFORM"\n' >"$finalize_bin/omarchy-hw-platform"
|
||||
for command in omarchy-pkg-add pacman-key; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$finalize_bin/$command"
|
||||
done
|
||||
chmod +x "$finalize_bin"/*
|
||||
sed "s|/etc/pacman|$work/etc/pacman|g" "$ROOT/install/post-install/pacman.sh" >"$work/finalize.sh"
|
||||
mkdir -p "$work/install/hardware"
|
||||
: >"$work/install/hardware/pacman.sh"
|
||||
|
||||
# Finalization asks the image helper whether this is an image build, which root
|
||||
# answers from the real /var/lib/omarchy; everyone else from the test's root.
|
||||
if (( EUID == 0 )); then
|
||||
skip "install finalization copies the platform's channel template and mirrorlist (refuses to run as root)"
|
||||
else
|
||||
for platform in $platforms; do
|
||||
templates=$(omarchy_pacman_templates "$platform")
|
||||
# An install built for a channel the platform has no templates for, or for
|
||||
# none, gets the platform's default channel.
|
||||
for channel in stable rc edge ""; do
|
||||
expected=$channel
|
||||
[[ -n $expected && -f $templates/pacman-$expected.conf ]] || expected=$(omarchy_pacman_default_channel "$platform")
|
||||
rm -rf "$work/etc"
|
||||
mkdir -p "$work/etc/pacman.d"
|
||||
printf 'offline\n' | tee "$work/etc/pacman.conf" >"$work/etc/pacman.d/mirrorlist"
|
||||
OMARCHY_IMAGE_ROOT=$work OMARCHY_MIRROR=$channel PLATFORM=$platform OMARCHY_INSTALL="$work/install" PATH="$finalize_bin:$PATH" \
|
||||
bash -e -c 'source "$1"' bash "$work/finalize.sh" >/dev/null || fail "$platform finalization on '$channel'"
|
||||
cmp -s "$work/etc/pacman.conf" "$templates/pacman-$expected.conf" || fail "$platform '$channel': finalization copies the $expected template"
|
||||
cmp -s "$work/etc/pacman.d/mirrorlist" "$templates/mirrorlist-$expected" || fail "$platform '$channel': finalization copies the $expected mirrorlist"
|
||||
done
|
||||
done
|
||||
pass "install finalization copies the platform's channel template and mirrorlist, or its default channel's when it has none for that channel"
|
||||
fi
|
||||
|
||||
# ── refresh through the command, with every privileged step a stand-in ───────
|
||||
|
||||
rm -rf "$work"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
copy_boundary_file bin/omarchy-refresh-pacman
|
||||
|
||||
refresh() {
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" "$@" >"$boundary_tmp/output" 2>&1
|
||||
}
|
||||
events() {
|
||||
grep -vE '^sudo (-h|-k)$' "$SUDO_TEST_LOG" || true
|
||||
}
|
||||
|
||||
for platform in $platforms; do
|
||||
case $platform in
|
||||
x86) templates=$SUDO_TEST_ROOT/default/pacman ;;
|
||||
aarch64) templates=$SUDO_TEST_ROOT/default/pacman/aarch64 ;;
|
||||
aarch64-apple) templates=$SUDO_TEST_ROOT/default/pacman/aarch64-apple ;;
|
||||
esac
|
||||
# No channel named refreshes to the platform's default one.
|
||||
for channel in $(channels_for "$platform") ""; do
|
||||
reset_boundary
|
||||
SUDO_TEST_PLATFORM=$platform refresh $channel || fail "$platform refreshes to '$channel'" "$(cat "$boundary_tmp/output")"
|
||||
[[ -n $channel ]] || channel=$(omarchy_pacman_default_channel "$platform")
|
||||
python3 - "$SUDO_TEST_LOG" "$templates" "$channel" <<'PY'
|
||||
import sys
|
||||
events = [e for e in open(sys.argv[1]).read().splitlines() if e not in ('sudo -h', 'sudo -k')]
|
||||
templates, channel = sys.argv[2:]
|
||||
expected = [
|
||||
'step:cp -f /etc/pacman.conf /etc/pacman.conf.bak',
|
||||
'step:cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak',
|
||||
f'step:cp -f {templates}/pacman-{channel}.conf /etc/pacman.conf',
|
||||
f'step:cp -f {templates}/mirrorlist-{channel} /etc/pacman.d/mirrorlist',
|
||||
]
|
||||
copies = [e for e in events if e.startswith('step:cp ')]
|
||||
assert copies == expected, events
|
||||
hook = events.index('step:omarchy-hook pre-refresh-pacman')
|
||||
transaction = events.index('step:pacman -Syyuu --noconfirm')
|
||||
assert events.index(expected[-1]) < hook < transaction, events
|
||||
PY
|
||||
assert_boundary_cold "$platform $channel"
|
||||
done
|
||||
done
|
||||
pass "a refresh backs up and copies the platform's channel template and mirrorlist, or its default channel's when none is named, then runs the cold hook and the upgrade"
|
||||
|
||||
# A channel the platform has no template for (stable or rc on aarch64), or a
|
||||
# machine whose platform can't be told, stops before anything changes.
|
||||
for platform in aarch64 aarch64-apple; do
|
||||
for channel in stable rc; do
|
||||
reset_boundary
|
||||
if SUDO_TEST_PLATFORM=$platform refresh "$channel"; then fail "$platform: $channel is refused"; fi
|
||||
[[ -z $(events) ]] || fail "$platform: $channel stops before anything" "$(events)"
|
||||
grep -q "Omarchy has no $channel channel for $platform" "$boundary_tmp/output" || fail "the refusal says why" "$(cat "$boundary_tmp/output")"
|
||||
assert_boundary_cold "$platform $channel"
|
||||
done
|
||||
done
|
||||
cat >"$SUDO_TEST_ROOT/bin/omarchy-hw-platform" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
STUB
|
||||
reset_boundary
|
||||
if refresh stable; then fail "an unknown platform is refused"; fi
|
||||
[[ -z $(events) ]] || fail "an unknown platform stops before anything" "$(events)"
|
||||
assert_boundary_cold "unknown platform"
|
||||
pass "a channel without a template, or a machine whose platform can't be told, changes nothing"
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# Browser and Steam installs, and Steam's removal, call the platform's own
|
||||
# hooks through the real dispatcher: a Mac fixture with omarchy-mac's
|
||||
# entrypoints, and x86.
|
||||
|
||||
require_platform_fixtures "app-install hooks on platform fixtures"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
fake_platform "$tmp/apple" aarch64-apple
|
||||
fake_platform "$tmp/x86" x86
|
||||
|
||||
# The hooks record what they were called with and which flags files the
|
||||
# browser install had written by then.
|
||||
lifecycle=$tmp/lifecycle
|
||||
mkdir -p "$lifecycle/usr/lib/omarchy/mac"
|
||||
for operation in post-install pre-remove; do
|
||||
cat >"$lifecycle/usr/lib/omarchy/mac/$operation" <<SH
|
||||
#!/bin/bash
|
||||
printf '%s %s\n' "$operation" "\$*" >>"$tmp/calls"
|
||||
ls "\$HOME/.config" 2>/dev/null | grep -- '-flags.conf$' >"$tmp/flags-at-hook" || true
|
||||
[[ ! -e $tmp/fail ]]
|
||||
SH
|
||||
chmod 755 "$lifecycle/usr/lib/omarchy/mac/$operation"
|
||||
done
|
||||
chmod -R go-w "$lifecycle"
|
||||
|
||||
stub_bin=$tmp/bin
|
||||
omarchy_path=$tmp/omarchy
|
||||
mkdir -p "$stub_bin" "$omarchy_path/install/helpers" "$omarchy_path/config"
|
||||
printf 'browser_policy_setup_dir() { :; }\nbrowser_policy_setup_firefox_distribution() { :; }\nas_root() { "$@"; }\n' \
|
||||
>"$omarchy_path/install/helpers/browser-policy.sh"
|
||||
cp "$ROOT/config/chromium-flags.conf" "$omarchy_path/config/"
|
||||
for command in omarchy-pkg-add omarchy-pkg-aur-add omarchy-pkg-drop omarchy-install-gaming-gpu-lib32; do
|
||||
printf '#!/bin/bash\nprintf "%%s %%s\\n" "%s" "$*" >>"%s"\n' "$command" "$tmp/calls" >"$stub_bin/$command"
|
||||
done
|
||||
for command in omarchy-install-chromium-copy-url omarchy-install-chromium-ytdlp omarchy-theme-set-browser setsid; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command"
|
||||
done
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
run() {
|
||||
local platform=$1
|
||||
shift
|
||||
rm -f "$tmp/calls" "$tmp/flags-at-hook"
|
||||
rm -rf "$tmp/home"
|
||||
mkdir -p "$tmp/home"
|
||||
HOME="$tmp/home" OMARCHY_PATH="$omarchy_path" OMARCHY_OPT_PATH="$tmp/opt" \
|
||||
OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT="$lifecycle" \
|
||||
PATH="$stub_bin:$tmp/$platform/bin:$ROOT/bin:$PATH" bash "$@" >"$tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# ── browsers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
declare -A flags=(
|
||||
[chromium]=chromium-flags.conf [chrome]=chrome-flags.conf [edge]=microsoft-edge-stable-flags.conf
|
||||
[brave]=brave-flags.conf [brave-origin]=brave-origin-flags.conf [firefox]="" [zen]=""
|
||||
)
|
||||
for browser in chromium chrome edge brave brave-origin firefox zen; do
|
||||
run apple "$ROOT/bin/omarchy-install-browser" "$browser" || fail "apple: $browser installs" "$(cat "$tmp/output")"
|
||||
[[ $(grep -c '^post-install ' "$tmp/calls") == 1 ]] && grep -qx "post-install $browser" "$tmp/calls" ||
|
||||
fail "apple: a $browser install runs post-install $browser once" "$(cat "$tmp/calls")"
|
||||
[[ $(cat "$tmp/flags-at-hook") == "${flags[$browser]}" ]] ||
|
||||
fail "apple: the $browser hook runs once its flags file is written" "$(cat "$tmp/flags-at-hook")"
|
||||
[[ $(grep -n . "$tmp/calls" | grep -m1 'post-install' | cut -d: -f1) -gt 1 ]] ||
|
||||
fail "apple: the $browser hook runs after its package step" "$(cat "$tmp/calls")"
|
||||
done
|
||||
pass "apple: every browser install runs post-install <browser> after its package and flags"
|
||||
|
||||
touch "$tmp/fail"
|
||||
for browser in chromium brave; do
|
||||
if run apple "$ROOT/bin/omarchy-install-browser" "$browser"; then
|
||||
fail "apple: a failed $browser hook fails the install"
|
||||
fi
|
||||
! grep -q 'browser installed' "$tmp/output" || fail "apple: a failed $browser hook is not announced as installed" "$(cat "$tmp/output")"
|
||||
done
|
||||
pass "apple: a failed browser hook fails the install"
|
||||
|
||||
for browser in chromium brave-origin zen; do
|
||||
run x86 "$ROOT/bin/omarchy-install-browser" "$browser" || fail "x86: $browser installs" "$(cat "$tmp/output")"
|
||||
! grep -q '^post-install' "$tmp/calls" || fail "x86: a $browser install runs no hook" "$(cat "$tmp/calls")"
|
||||
done
|
||||
rm -f "$tmp/fail"
|
||||
pass "x86: browser installs run no hook, even with Mac entrypoints on disk"
|
||||
|
||||
# ── Steam ────────────────────────────────────────────────────────────────────
|
||||
|
||||
run apple "$ROOT/bin/omarchy-install-gaming-steam" || fail "apple: Steam installs" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/calls") == $'omarchy-install-gaming-gpu-lib32 \nomarchy-pkg-add steam\npost-install steam' ]] ||
|
||||
fail "apple: Steam's hook runs after the 32-bit drivers and the package" "$(cat "$tmp/calls")"
|
||||
run apple "$ROOT/bin/omarchy-remove-gaming-steam" || fail "apple: Steam is removed" "$(cat "$tmp/output")"
|
||||
[[ $(head -n 2 "$tmp/calls") == $'pre-remove steam\nomarchy-pkg-drop steam' ]] ||
|
||||
fail "apple: Steam's pre-remove hook runs before the package goes" "$(cat "$tmp/calls")"
|
||||
touch "$tmp/fail"
|
||||
if run apple "$ROOT/bin/omarchy-install-gaming-steam"; then
|
||||
fail "apple: a failed Steam hook fails the install"
|
||||
fi
|
||||
if run apple "$ROOT/bin/omarchy-remove-gaming-steam"; then
|
||||
fail "apple: a failed pre-remove hook stops the removal"
|
||||
fi
|
||||
! grep -q '^omarchy-pkg-drop' "$tmp/calls" || fail "apple: a failed pre-remove hook removes nothing" "$(cat "$tmp/calls")"
|
||||
pass "apple: Steam's install and removal run their hooks in order, and fail with them"
|
||||
|
||||
run x86 "$ROOT/bin/omarchy-install-gaming-steam" || fail "x86: Steam installs" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/calls") == $'omarchy-install-gaming-gpu-lib32 \nomarchy-pkg-add steam' ]] ||
|
||||
fail "x86: Steam installs as before" "$(cat "$tmp/calls")"
|
||||
run x86 "$ROOT/bin/omarchy-remove-gaming-steam" || fail "x86: Steam is removed" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/calls") == "omarchy-pkg-drop steam" ]] || fail "x86: Steam is removed as before" "$(cat "$tmp/calls")"
|
||||
rm -f "$tmp/fail"
|
||||
pass "x86: Steam installs and is removed as before, running no hook"
|
||||
|
||||
# ── 32-bit drivers ───────────────────────────────────────────────────────────
|
||||
|
||||
# Without an Intel, AMD or NVIDIA GPU there is nothing to add, which must not
|
||||
# fail the Steam install that asked.
|
||||
gpu_bin=$tmp/gpu-bin
|
||||
mkdir -p "$gpu_bin"
|
||||
printf '#!/bin/bash\necho "00:02.0 VGA compatible controller: %s"\n' '${GPU:-Apple Inc. AGX}' >"$gpu_bin/lspci"
|
||||
printf '#!/bin/bash\nexit 1\n' >"$gpu_bin/omarchy-hw-nvidia-gsp"
|
||||
printf '#!/bin/bash\nexit 1\n' >"$gpu_bin/omarchy-hw-nvidia-without-gsp"
|
||||
printf '#!/bin/bash\necho "omarchy-pkg-add $*" >>"%s"\n' "$tmp/calls" >"$gpu_bin/omarchy-pkg-add"
|
||||
chmod +x "$gpu_bin"/*
|
||||
rm -f "$tmp/calls"
|
||||
PATH="$gpu_bin:$PATH" bash "$ROOT/bin/omarchy-install-gaming-gpu-lib32" >"$tmp/output" 2>&1 ||
|
||||
fail "no supported GPU is not a failure" "$(cat "$tmp/output")"
|
||||
[[ ! -e $tmp/calls ]] || fail "no supported GPU installs nothing" "$(cat "$tmp/calls")"
|
||||
GPU="Advanced Micro Devices, Inc. [AMD/ATI] Navi 31" PATH="$gpu_bin:$PATH" bash "$ROOT/bin/omarchy-install-gaming-gpu-lib32" >/dev/null 2>&1 ||
|
||||
fail "an AMD GPU installs its 32-bit driver"
|
||||
[[ $(cat "$tmp/calls") == "omarchy-pkg-add lib32-vulkan-radeon" ]] || fail "an AMD GPU gets lib32-vulkan-radeon" "$(cat "$tmp/calls")"
|
||||
pass "32-bit drivers: a machine with no supported GPU adds nothing without failing"
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_platform_fixtures "platform package lists"
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
export OMARCHY_PATH="$ROOT"
|
||||
export PATH="$ROOT/bin:$PATH"
|
||||
|
||||
names() {
|
||||
sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$@"
|
||||
}
|
||||
|
||||
base=$(names "$ROOT/install/omarchy-base.packages")
|
||||
aarch64=$(names "$ROOT/install/omarchy-aarch64.packages")
|
||||
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fake_platform "$work/$platform" "$platform"
|
||||
defaults=$(OMARCHY_PROC_ROOT="$work/$platform/proc" PATH="$work/$platform/bin:$ROOT/bin:$PATH" omarchy-pkg-defaults)
|
||||
[[ $defaults == "$(omarchy-pkg-defaults "$platform")" ]] ||
|
||||
fail "$platform: the detected platform selects its own list"
|
||||
printf '%s\n' "$defaults" >"$work/$platform.packages"
|
||||
|
||||
[[ -z $(sort "$work/$platform.packages" | uniq -d) ]] || fail "$platform: each package is listed once"
|
||||
# aarch64 platforms leave out the base packages only x86_64 builds.
|
||||
expected_base=$base
|
||||
[[ $platform == "x86" ]] || expected_base=$(grep -vxF -f <(names "$ROOT/install/omarchy-x86_64-only.packages") <<<"$base")
|
||||
[[ $(head -n "$(wc -l <<<"$expected_base")" "$work/$platform.packages") == "$expected_base" ]] ||
|
||||
fail "$platform: the base list comes first, unchanged"
|
||||
|
||||
case $platform in
|
||||
x86)
|
||||
[[ $defaults == "$base" ]] || fail "x86_64 installs exactly the base list"
|
||||
[[ $defaults == "$(grep -v '^#' "$ROOT/install/omarchy-base.packages" | grep -v '^$')" ]] ||
|
||||
fail "x86_64 installs what omarchy-reinstall-pkgs read from the base list before"
|
||||
;;
|
||||
*)
|
||||
while IFS= read -r package; do
|
||||
grep -Fxq "$package" "$work/$platform.packages" || fail "$platform: aarch64 addition $package"
|
||||
done <<<"$aarch64"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The Mac's packages, and wf-recorder, which records its screen: nothing else
|
||||
# captures on Apple Silicon.
|
||||
for package in omarchy-mac omarchy-mac-boot wf-recorder; do
|
||||
if [[ $platform == "aarch64-apple" ]]; then
|
||||
grep -Fxq "$package" "$work/$platform.packages" || fail "Apple Silicon adds $package"
|
||||
else
|
||||
! grep -Fxq "$package" "$work/$platform.packages" || fail "$platform: no $package"
|
||||
fi
|
||||
done
|
||||
done
|
||||
pass "each platform composes the base, architecture and platform lists"
|
||||
|
||||
# A hardware family's own list joins only that family's set, after the base and
|
||||
# architecture lists; plain aarch64 composes the others alone.
|
||||
tree="$work/tree"
|
||||
mkdir -p "$tree/install"
|
||||
cp "$ROOT"/install/omarchy-*.packages "$tree/install/"
|
||||
printf '# test addition\nexample-board-support\nzram-generator\n' >"$tree/install/omarchy-aarch64-apple.packages"
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
defaults=$(OMARCHY_PATH="$tree" omarchy-pkg-defaults "$platform")
|
||||
if [[ $platform == "aarch64-apple" ]]; then
|
||||
[[ $(tail -n 1 <<<"$defaults") == "example-board-support" ]] || fail "a platform list is added after the others" "$defaults"
|
||||
(( $(grep -cx zram-generator <<<"$defaults") == 1 )) || fail "a name in two lists is installed once" "$defaults"
|
||||
else
|
||||
! grep -Fxq example-board-support <<<"$defaults" || fail "$platform: another platform's list stays out"
|
||||
fi
|
||||
done
|
||||
pass "a platform list joins only its own platform's set"
|
||||
|
||||
# Image builders written before the platform names settled pass the old names.
|
||||
for legacy in apple-silicon:aarch64-apple generic-aarch64:aarch64 generic:x86; do
|
||||
[[ $(omarchy-pkg-defaults "${legacy%%:*}") == "$(omarchy-pkg-defaults "${legacy#*:}")" ]] ||
|
||||
fail "the old name ${legacy%%:*} composes the ${legacy#*:} set"
|
||||
done
|
||||
pass "the old platform names compose the same sets"
|
||||
|
||||
# Base packages that only x86_64 builds stay out of every aarch64 set.
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
defaults=$(OMARCHY_PATH="$ROOT" omarchy-pkg-defaults "$platform")
|
||||
while read -r name; do
|
||||
[[ -n $name && $name != \#* ]] || continue
|
||||
if [[ $platform == "x86" ]]; then
|
||||
grep -Fxq "$name" <<<"$defaults" || fail "x86_64 keeps $name"
|
||||
else
|
||||
! grep -Fxq "$name" <<<"$defaults" || fail "$platform leaves out the x86_64-only $name"
|
||||
fi
|
||||
done <"$ROOT/install/omarchy-x86_64-only.packages"
|
||||
done
|
||||
grep -Fxq superwhisper-bin "$ROOT/install/omarchy-x86_64-only.packages" || fail "superwhisper-bin is listed as x86_64-only"
|
||||
pass "base packages only x86_64 builds stay out of every aarch64 set"
|
||||
|
||||
! omarchy-pkg-defaults riscv 2>/dev/null || fail "an unknown platform is refused"
|
||||
failing_bin="$work/failing-bin"
|
||||
mkdir -p "$failing_bin"
|
||||
printf '#!/bin/bash\nexit 1\n' >"$failing_bin/omarchy-hw-platform"
|
||||
chmod +x "$failing_bin/omarchy-hw-platform"
|
||||
! PATH="$failing_bin:$ROOT/bin:$PATH" omarchy-pkg-defaults >/dev/null 2>&1 || fail "a platform the detector cannot tell is refused"
|
||||
pass "an unknown or undetectable platform is refused"
|
||||
|
||||
# omarchy-reinstall-pkgs installs the detected platform's set.
|
||||
mkdir -p "$work/stubs"
|
||||
cat >"$work/stubs/omarchy-update-pacman" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$STUB_LOG"
|
||||
SH
|
||||
cat >"$work/stubs/omarchy-refresh-pacman" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'refresh %s\n' "$*" >>"$STUB_LOG"
|
||||
[[ -z ${REFRESH_FAILS:-} ]]
|
||||
SH
|
||||
chmod +x "$work/stubs/"*
|
||||
|
||||
for platform in aarch64 x86; do
|
||||
export STUB_LOG="$work/$platform.log"
|
||||
: >"$STUB_LOG"
|
||||
OMARCHY_PROC_ROOT="$work/$platform/proc" PATH="$work/stubs:$work/$platform/bin:$ROOT/bin:$PATH" \
|
||||
omarchy-reinstall-pkgs
|
||||
expected="-Syu --noconfirm --needed $(tr '\n' ' ' <"$work/$platform.packages")"
|
||||
[[ $(tail -n 1 "$STUB_LOG") == "${expected% }" ]] ||
|
||||
fail "$platform: reinstall installs the platform's default set" "$(tail -n 1 "$STUB_LOG")"
|
||||
done
|
||||
pass "omarchy-reinstall-pkgs installs the platform's default set"
|
||||
|
||||
# Every platform resets to its stable templates, as x86_64 always has; a failed
|
||||
# refresh stops the reinstall.
|
||||
reinstall_on() {
|
||||
local platform=$1
|
||||
export STUB_LOG="$work/channel.log"
|
||||
: >"$STUB_LOG"
|
||||
OMARCHY_PROC_ROOT="$work/$platform/proc" PATH="$work/stubs:$work/$platform/bin:$ROOT/bin:$PATH" \
|
||||
omarchy-reinstall-pkgs >"$work/channel.out" 2>&1
|
||||
}
|
||||
for platform in x86 aarch64 aarch64-apple; do
|
||||
reinstall_on "$platform" || fail "$platform: reinstall completes" "$(cat "$work/channel.out")"
|
||||
[[ $(head -n 1 "$STUB_LOG") == "refresh " ]] || fail "$platform: reinstall refreshes on stable" "$(cat "$STUB_LOG")"
|
||||
expected="-Syu --noconfirm --needed $(tr '\n' ' ' <"$work/$platform.packages")"
|
||||
[[ $(tail -n 1 "$STUB_LOG") == "${expected% }" ]] || fail "$platform: reinstall installs the platform's set"
|
||||
done
|
||||
pass "omarchy-reinstall-pkgs refreshes on stable on every platform"
|
||||
|
||||
if REFRESH_FAILS=1 reinstall_on aarch64; then
|
||||
fail "reinstall stops when the refresh fails"
|
||||
fi
|
||||
[[ $(cat "$STUB_LOG") == "refresh " ]] || fail "reinstall runs nothing after a failed refresh" "$(cat "$STUB_LOG")"
|
||||
pass "omarchy-reinstall-pkgs stops when the refresh fails"
|
||||
|
||||
export STUB_LOG="$work/undetected.log"
|
||||
: >"$STUB_LOG"
|
||||
if PATH="$failing_bin:$work/stubs:$ROOT/bin:$PATH" omarchy-reinstall-pkgs >/dev/null 2>&1; then
|
||||
fail "reinstall stops when the platform cannot be told"
|
||||
fi
|
||||
! grep -q -- '--needed' "$STUB_LOG" || fail "reinstall installs nothing when the platform cannot be told" "$(cat "$STUB_LOG")"
|
||||
pass "omarchy-reinstall-pkgs stops when the platform cannot be told"
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# The two setup leaves and first run call the platform's own setup through the
|
||||
# real dispatcher: a Mac fixture with omarchy-mac's entrypoints, and x86.
|
||||
|
||||
hardware_leaf="$ROOT/install/hardware/platform-setup.sh"
|
||||
user_leaf="$ROOT/install/user/platform-setup.sh"
|
||||
|
||||
[[ $(grep '^run_logged' "$ROOT/install/hardware/all.sh" | tail -n 1) == 'run_logged "$OMARCHY_INSTALL/hardware/platform-setup.sh"' ]] ||
|
||||
fail "the platform's system setup is the last hardware leaf"
|
||||
[[ $(grep '^run_logged' "$ROOT/install/user/all.sh" | tail -n 1) == 'run_logged "$OMARCHY_INSTALL/user/platform-setup.sh"' ]] ||
|
||||
fail "the platform's user setup is the last user leaf"
|
||||
pass "the platform's setup is the last leaf of hardware and of user setup"
|
||||
|
||||
require_platform_fixtures "platform setup on platform fixtures"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
fake_platform "$tmp/apple" aarch64-apple
|
||||
fake_platform "$tmp/x86" x86
|
||||
|
||||
lifecycle=$tmp/lifecycle
|
||||
mkdir -p "$lifecycle/usr/lib/omarchy/mac" "$lifecycle/usr/lib/omarchy/mac-boot"
|
||||
for entry in mac/setup-system mac/setup-user mac-boot/setup-boot; do
|
||||
operation=${entry#*/}
|
||||
cat >"$lifecycle/usr/lib/omarchy/$entry" <<SH
|
||||
#!/bin/bash
|
||||
{ printf '%s' "$operation"; (( \$# == 0 )) || printf ' %s' "\$@"; echo; } >>"$tmp/ran"
|
||||
[[ ! -e $tmp/fail && ! -e $tmp/fail-$operation ]]
|
||||
SH
|
||||
chmod 755 "$lifecycle/usr/lib/omarchy/$entry"
|
||||
done
|
||||
chmod -R go-w "$lifecycle"
|
||||
|
||||
# Sources a leaf as run_logged does, on a platform fixture.
|
||||
leaf() {
|
||||
local platform=$1 script=$2
|
||||
shift 2
|
||||
rm -f "$tmp/ran"
|
||||
env "$@" OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT="$lifecycle" \
|
||||
PATH="$tmp/$platform/bin:$ROOT/bin:$PATH" bash -eE -c 'source "$1"' bash "$script" >"$tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# ── system setup ─────────────────────────────────────────────────────────────
|
||||
|
||||
leaf apple "$hardware_leaf" || fail "apple: the system setup leaf runs" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/ran") == $'setup-boot\nsetup-system' ]] || fail "apple: an install or rerun runs setup-boot, then setup-system, with no argument" "$(cat "$tmp/ran")"
|
||||
leaf apple "$hardware_leaf" OMARCHY_IMAGE_DEFERRED_HARDWARE=1 || fail "apple: the system setup leaf runs on an image's first boot"
|
||||
[[ $(cat "$tmp/ran") == $'setup-boot image-first-boot\nsetup-system image-first-boot' ]] ||
|
||||
fail "apple: an image's first boot tells setup-boot and setup-system so" "$(cat "$tmp/ran")"
|
||||
touch "$tmp/fail-setup-system"
|
||||
if leaf apple "$hardware_leaf"; then
|
||||
fail "apple: a failed setup-system fails the leaf"
|
||||
fi
|
||||
[[ $(cat "$tmp/ran") == $'setup-boot\nsetup-system' ]] || fail "apple: setup-system ran after the boot setup and failed" "$(cat "$tmp/ran")"
|
||||
rm -f "$tmp/fail-setup-system"
|
||||
touch "$tmp/fail-setup-boot"
|
||||
if leaf apple "$hardware_leaf"; then
|
||||
fail "apple: a failed setup-boot fails the leaf"
|
||||
fi
|
||||
[[ $(cat "$tmp/ran") == "setup-boot" ]] || fail "apple: setup-system waits for a boot setup that failed" "$(cat "$tmp/ran")"
|
||||
rm -f "$tmp/fail-setup-boot"
|
||||
mv "$lifecycle/usr/lib/omarchy/mac-boot/setup-boot" "$tmp/setup-boot.saved"
|
||||
if leaf apple "$hardware_leaf"; then
|
||||
fail "apple: a Mac whose boot package lacks setup-boot fails the leaf instead of skipping its boot setup"
|
||||
fi
|
||||
grep -q "setup-boot on aarch64-apple needs omarchy-mac-boot" "$tmp/output" || fail "apple: the failure names the boot package" "$(cat "$tmp/output")"
|
||||
mv "$tmp/setup-boot.saved" "$lifecycle/usr/lib/omarchy/mac-boot/setup-boot"
|
||||
pass "apple: the system setup leaf runs omarchy-mac-boot's setup-boot, then omarchy-mac's setup-system, says when it is an image's first boot, and fails with either"
|
||||
|
||||
touch "$tmp/fail"
|
||||
leaf x86 "$hardware_leaf" OMARCHY_IMAGE_DEFERRED_HARDWARE=1 || fail "x86: the system setup leaf is a no-op" "$(cat "$tmp/output")"
|
||||
[[ ! -e $tmp/ran && ! -s $tmp/output ]] || fail "x86: the system setup leaf runs nothing and says nothing" "$(cat "$tmp/output")"
|
||||
rm -f "$tmp/fail"
|
||||
pass "x86: the system setup leaf is a no-op, even with Mac entrypoints on disk"
|
||||
|
||||
# ── user setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
leaf apple "$user_leaf" OMARCHY_IMAGE_ROOT="$tmp/booted" || fail "apple: the user setup leaf runs" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/ran") == "setup-user" ]] || fail "apple: the user setup leaf runs setup-user" "$(cat "$tmp/ran" 2>/dev/null)"
|
||||
touch "$tmp/fail"
|
||||
if leaf apple "$user_leaf" OMARCHY_IMAGE_ROOT="$tmp/booted"; then
|
||||
fail "apple: a failed setup-user fails finalization's leaf"
|
||||
fi
|
||||
rm -f "$tmp/fail"
|
||||
|
||||
mkdir -p "$tmp/image/var/lib/omarchy/image"
|
||||
: >"$tmp/image/var/lib/omarchy/image/target"
|
||||
leaf apple "$user_leaf" OMARCHY_IMAGE_ROOT="$tmp/image" || fail "apple: the user setup leaf succeeds in an image build"
|
||||
[[ ! -e $tmp/ran && $(cat "$tmp/output") == *"waits for first run on the machine"* ]] ||
|
||||
fail "apple: an image build leaves the platform's user setup to first run" "$(cat "$tmp/output")"
|
||||
pass "apple: the user setup leaf runs omarchy-mac's setup-user, fails with it, and waits for the machine in an image build"
|
||||
|
||||
touch "$tmp/fail"
|
||||
leaf x86 "$user_leaf" OMARCHY_IMAGE_ROOT="$tmp/booted" || fail "x86: the user setup leaf is a no-op" "$(cat "$tmp/output")"
|
||||
[[ ! -e $tmp/ran && ! -s $tmp/output ]] || fail "x86: the user setup leaf runs nothing and says nothing" "$(cat "$tmp/output")"
|
||||
rm -f "$tmp/fail"
|
||||
pass "x86: the user setup leaf is a no-op, even with Mac entrypoints on disk"
|
||||
|
||||
# ── first run ────────────────────────────────────────────────────────────────
|
||||
|
||||
# First run on the machine runs setup-user once the session is up. Its failure
|
||||
# leaves first run pending for the next login, as a failed finalization does.
|
||||
first_bin=$tmp/first-bin
|
||||
first_root=$tmp/omarchy
|
||||
mkdir -p "$first_bin" "$first_root/install/user/first-run" "$tmp/home"
|
||||
cat >"$first_bin/omarchy-done" <<'SH'
|
||||
#!/bin/bash
|
||||
case "$1:$2" in
|
||||
check:first-run-user) exit 1 ;;
|
||||
mark:first-run-user) touch "$FIRST_RUN_MARKER" ;;
|
||||
esac
|
||||
SH
|
||||
for helper in omarchy-provision-user omarchy-hook-install omarchy-notification-wait; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$first_bin/$helper"
|
||||
done
|
||||
chmod +x "$first_bin"/*
|
||||
for script in welcome.sh wifi.sh enable-user-units.sh gnome-theme.sh gtk-primary-paste.sh audio-tuning.sh; do
|
||||
printf 'exit 0\n' >"$first_root/install/user/first-run/$script"
|
||||
done
|
||||
|
||||
first_run() {
|
||||
local platform=$1
|
||||
rm -f "$tmp/ran" "$tmp/first-run-marker" "$tmp/home/.local/state/omarchy/first-run.log"
|
||||
HOME="$tmp/home" OMARCHY_PATH="$first_root" FIRST_RUN_MARKER="$tmp/first-run-marker" \
|
||||
OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT="$lifecycle" \
|
||||
PATH="$first_bin:$tmp/$platform/bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-provision-first-run" >"$tmp/output" 2>&1
|
||||
}
|
||||
|
||||
first_run apple || fail "apple: first run finishes" "$(cat "$tmp/output")"
|
||||
[[ $(cat "$tmp/ran") == "setup-user" && -e $tmp/first-run-marker ]] ||
|
||||
fail "apple: first run runs setup-user and completes" "$(cat "$tmp/ran" 2>/dev/null)"
|
||||
touch "$tmp/fail"
|
||||
first_run apple || fail "apple: first run with a failing setup-user still runs its other steps"
|
||||
[[ ! -e $tmp/first-run-marker ]] || fail "apple: a failed setup-user keeps first run pending"
|
||||
grep -q "Failed: run the platform's user setup" "$tmp/home/.local/state/omarchy/first-run.log" ||
|
||||
fail "apple: first run logs the failed platform setup" "$(cat "$tmp/home/.local/state/omarchy/first-run.log")"
|
||||
first_run x86 || fail "x86: first run finishes"
|
||||
[[ ! -e $tmp/ran && -e $tmp/first-run-marker ]] || fail "x86: first run runs no platform setup and completes"
|
||||
rm -f "$tmp/fail"
|
||||
pass "first run runs the platform's user setup, and a failure keeps first run pending"
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# First-boot setup's keyboard step. The owner types the disk password at boot in
|
||||
# the layout /etc/vconsole.conf names, so a layout that did not load, did not
|
||||
# persist, or does not read back must be asked for again before the password
|
||||
# form. keyboard_form and apply_keyboard run as omarchy-provision-owner defines
|
||||
# them, against a fixture root, with loadkeys, localectl and systemd-firstboot
|
||||
# faked.
|
||||
|
||||
tmp=$(cd -- "$(mktemp -d)" && pwd -P)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
root=$tmp/root
|
||||
stub_bin=$tmp/bin
|
||||
conf=$root/etc/vconsole.conf
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
# Each fake takes its next behaviour from a queue file, and behaves normally
|
||||
# once the queue is empty.
|
||||
cat >"$stub_bin/next-mode" <<'SH'
|
||||
#!/bin/bash
|
||||
queue=$1
|
||||
[[ -s $queue ]] || { echo normal; exit 0; }
|
||||
head -n1 "$queue"
|
||||
sed -i 1d "$queue"
|
||||
SH
|
||||
cat >"$stub_bin/tty" <<SH
|
||||
#!/bin/bash
|
||||
[[ -e "$tmp/no-tty" ]] && { echo "not a tty"; exit 1; }
|
||||
echo /dev/tty1
|
||||
SH
|
||||
cat >"$stub_bin/loadkeys" <<SH
|
||||
#!/bin/bash
|
||||
echo "loadkeys \$*" >>"$tmp/calls"
|
||||
[[ \$("$stub_bin/next-mode" "$tmp/loadkeys-modes") == normal ]] || { echo "cannot open file \$1" >&2; exit 1; }
|
||||
SH
|
||||
cat >"$stub_bin/localectl" <<SH
|
||||
#!/bin/bash
|
||||
args=("\$@")
|
||||
[[ \${args[0]} == --no-pager ]] && args=("\${args[@]:1}")
|
||||
case \${args[0]} in
|
||||
list-keymaps)
|
||||
[[ \$("$stub_bin/next-mode" "$tmp/list-modes") == normal ]] || { echo "Failed to connect to bus" >&2; exit 1; }
|
||||
printf '%s\n' bg-cp1251 colemak cz de-latin1 dk fr pl ua us
|
||||
;;
|
||||
set-keymap)
|
||||
echo "localectl set-keymap \${args[1]}" >>"$tmp/calls"
|
||||
[[ \$("$stub_bin/next-mode" "$tmp/localectl-modes") == normal ]] || exit 1
|
||||
printf 'KEYMAP=%s\nXKBLAYOUT=%s\n' "\${args[1]}" "\${args[1]}" >"$conf"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
SH
|
||||
cat >"$stub_bin/systemd-firstboot" <<SH
|
||||
#!/bin/bash
|
||||
keymap=\${1#--keymap=}
|
||||
echo "systemd-firstboot \$*" >>"$tmp/calls"
|
||||
case \$("$stub_bin/next-mode" "$tmp/firstboot-modes") in
|
||||
normal) printf 'KEYMAP=%s\nXKBLAYOUT=%s\nXKBMODEL=pc105\n' "\$keymap" "\$keymap" >"$conf" ;;
|
||||
nothing) ;;
|
||||
mismatch) printf 'KEYMAP=us\nXKBLAYOUT=us\n' >"$conf" ;;
|
||||
no-xkb) printf 'KEYMAP=%s\n' "\$keymap" >"$conf" ;;
|
||||
fail) echo "Failed to write vconsole.conf" >&2; exit 1 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
export PATH="$stub_bin:$PATH"
|
||||
|
||||
sed -n '/^keyboard_form() {/,/^}/p; /^keyboard_xkb_settings() {/,/^}/p; /^apply_keyboard() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner" |
|
||||
sed "s|/etc/|$root/etc/|g" >"$tmp/keyboard.sh"
|
||||
grep -q '^keyboard_form() {' "$tmp/keyboard.sh" && grep -q '^apply_keyboard() {' "$tmp/keyboard.sh" ||
|
||||
fail "omarchy-provision-owner defines the keyboard step"
|
||||
|
||||
# The owner's answers, one "label|keymap" per prompt. Running out means the step
|
||||
# asked once more than the test answered.
|
||||
cat >"$tmp/form.sh" <<SH
|
||||
set -euo pipefail
|
||||
source "$tmp/keyboard.sh"
|
||||
LOG_FILE=$tmp/log
|
||||
OMARCHY_FORM_BACK=2
|
||||
step() { echo "prompt" >>"$tmp/screen"; }
|
||||
notice() { echo "notice: \$1" >>"$tmp/screen"; }
|
||||
log_step() { echo "\$1" >>"\$LOG_FILE"; }
|
||||
confirm_reboot() { exit 5; }
|
||||
omarchy_prompt_keyboard() {
|
||||
local answer
|
||||
answer=\$("$stub_bin/next-mode" "$tmp/answers")
|
||||
[[ \$answer != normal ]] || { echo "asked again" >>"$tmp/screen"; exit 9; }
|
||||
keyboard_label=\${answer%%|*}
|
||||
keyboard=\${answer#*|}
|
||||
}
|
||||
keyboard_form
|
||||
echo "password form" >>"$tmp/screen"
|
||||
SH
|
||||
|
||||
# A fresh deferred install: the placeholder US layout from the image build.
|
||||
fresh_root() {
|
||||
rm -rf "$root" "$tmp"/*-modes "$tmp/answers" "$tmp/calls" "$tmp/screen" "$tmp/log" "$tmp/no-tty"
|
||||
mkdir -p "$root/etc"
|
||||
printf 'KEYMAP=us\nXKBLAYOUT=us\n' >"$conf"
|
||||
: >"$tmp/calls"
|
||||
: >"$tmp/log"
|
||||
}
|
||||
|
||||
answers() {
|
||||
printf '%s\n' "$@" >"$tmp/answers"
|
||||
}
|
||||
|
||||
form() {
|
||||
local status=0
|
||||
bash "$tmp/form.sh" || status=$?
|
||||
return "$status"
|
||||
}
|
||||
|
||||
prompts() {
|
||||
grep -cx prompt "$tmp/screen" || true
|
||||
}
|
||||
|
||||
assert_layout() {
|
||||
grep -qx "KEYMAP=$1" "$conf" && grep -q '^XKBLAYOUT=.' "$conf" || fail "$2: vconsole.conf holds $1" "$(cat "$conf")"
|
||||
}
|
||||
|
||||
# Asked once more after the failure, with the reason on the screen and in the
|
||||
# log, and only the second answer reaches the password form.
|
||||
assert_asked_again() {
|
||||
local context=$1 log_line=$2
|
||||
[[ $(prompts) == 2 ]] || fail "$context: the keyboard is asked for again" "$(cat "$tmp/screen")"
|
||||
[[ $(sed -n 2p "$tmp/screen") == "notice: Could not set the Danish keyboard layout. Choose it again." ]] ||
|
||||
fail "$context: the owner is told the layout did not take" "$(cat "$tmp/screen")"
|
||||
[[ $(tail -n1 "$tmp/screen") == "password form" && $(grep -c 'password form' "$tmp/screen") == 1 ]] ||
|
||||
fail "$context: the password form comes only after a layout took" "$(cat "$tmp/screen")"
|
||||
grep -q "$log_line" "$tmp/log" || fail "$context: the log records the failure" "$(cat "$tmp/log")"
|
||||
}
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk'
|
||||
form || fail "a known layout is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
[[ $(prompts) == 1 ]] && ! grep -q '^notice' "$tmp/screen" || fail "a layout that takes is asked for once" "$(cat "$tmp/screen")"
|
||||
grep -qx 'loadkeys dk' "$tmp/calls" && grep -qx 'systemd-firstboot --keymap=dk --force' "$tmp/calls" ||
|
||||
fail "the layout is loaded on the console and persisted" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "a known layout"
|
||||
pass "a layout that loads, persists and reads back goes straight to the password form"
|
||||
|
||||
# systemd-firstboot writes only KEYMAP= for a keymap kbd-model-map lacks, as it
|
||||
# does for Polish, Ukrainian and Colemak. Setup supplies the XKB layout.
|
||||
fresh_root
|
||||
answers 'Polish|pl'
|
||||
echo no-xkb >"$tmp/firstboot-modes"
|
||||
form || fail "a keymap firstboot has no XKB layout for is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
[[ $(prompts) == 1 ]] && ! grep -q '^notice' "$tmp/screen" || fail "Polish is asked for once" "$(cat "$tmp/screen")"
|
||||
grep -qx 'XKBLAYOUT=pl' "$conf" || fail "Polish gets the pl XKB layout" "$(cat "$conf")"
|
||||
|
||||
fresh_root
|
||||
answers 'Ukrainian|ua'
|
||||
echo no-xkb >"$tmp/firstboot-modes"
|
||||
form || fail "Ukrainian is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
grep -qx 'KEYMAP=ua' "$conf" && grep -qx 'XKBLAYOUT=ua,us' "$conf" &&
|
||||
grep -qx 'XKBOPTIONS=terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll' "$conf" ||
|
||||
fail "a non-Latin layout keeps a US layout to switch to" "$(cat "$conf")"
|
||||
|
||||
fresh_root
|
||||
answers 'English (US, Colemak)|colemak'
|
||||
echo no-xkb >"$tmp/firstboot-modes"
|
||||
form || fail "Colemak is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
grep -qx 'XKBLAYOUT=us' "$conf" && grep -qx 'XKBVARIANT=colemak' "$conf" ||
|
||||
fail "Colemak is the us layout's colemak variant" "$(cat "$conf")"
|
||||
# The desktop must type what the console does, or the shared password differs:
|
||||
# kbd's cz is QWERTY and bg-cp1251 phonetic, unlike XKB's defaults.
|
||||
fresh_root
|
||||
answers 'Czech|cz'
|
||||
echo no-xkb >"$tmp/firstboot-modes"
|
||||
form || fail "Czech is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
grep -qx 'XKBLAYOUT=cz,us' "$conf" && grep -qx 'XKBVARIANT=qwerty,' "$conf" ||
|
||||
fail "Czech is QWERTY on the desktop as on the console" "$(cat "$conf")"
|
||||
|
||||
fresh_root
|
||||
answers 'Bulgarian|bg-cp1251'
|
||||
echo no-xkb >"$tmp/firstboot-modes"
|
||||
form || fail "Bulgarian is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
grep -qx 'XKBLAYOUT=bg,us' "$conf" && grep -qx 'XKBVARIANT=phonetic,' "$conf" ||
|
||||
fail "Bulgarian is phonetic on the desktop as on the console" "$(cat "$conf")"
|
||||
pass "a keymap systemd-firstboot has no XKB layout for gets one from setup"
|
||||
|
||||
# Every layout the form offers must end with an XKB layout: kbd-model-map's, or
|
||||
# setup's own. A keymap in neither could never finish the keyboard step.
|
||||
model_map=/usr/share/systemd/kbd-model-map
|
||||
if [[ -r $model_map ]]; then
|
||||
source "$ROOT/install/provisioning/setup-form.sh"
|
||||
source "$tmp/keyboard.sh"
|
||||
while IFS='|' read -r label keymap; do
|
||||
awk -v k="$keymap" '$1 == k { found = 1 } END { exit !found }' "$model_map" ||
|
||||
{ declare -F keyboard_xkb_settings >/dev/null && keyboard_xkb_settings "$keymap" >/dev/null; } ||
|
||||
fail "the $label layout ($keymap) gets an XKB layout"
|
||||
done <<<"$OMARCHY_KEYBOARD_LAYOUTS"
|
||||
pass "every layout the form offers gets an XKB layout"
|
||||
else
|
||||
echo "ok - # SKIP no $model_map to check the form's layouts against"
|
||||
fi
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo fail >"$tmp/loadkeys-modes"
|
||||
form || fail "loadkeys failing once, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "loadkeys fails" "loadkeys could not load keymap dk"
|
||||
[[ $(grep -c '^systemd-firstboot' "$tmp/calls") == 1 ]] || fail "a layout the console refused is not persisted" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "after loadkeys failed once"
|
||||
pass "a layout loadkeys refuses is asked for again"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo nothing >"$tmp/firstboot-modes"
|
||||
form || fail "firstboot writing nothing, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "firstboot writes nothing" "does not read back as keymap dk"
|
||||
assert_layout dk "after firstboot wrote nothing once"
|
||||
pass "a systemd-firstboot that writes nothing is asked for again"
|
||||
|
||||
for mode in mismatch no-xkb; do
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo "$mode" >"$tmp/firstboot-modes"
|
||||
form || fail "readback $mode, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "readback $mode" "does not read back as keymap dk"
|
||||
assert_layout dk "after readback $mode"
|
||||
done
|
||||
pass "a vconsole.conf that reads back another keymap, or no XKB layout, is asked for again"
|
||||
|
||||
# The readback runs in a clean shell: values this process exports never stand
|
||||
# in for the file.
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo nothing >"$tmp/firstboot-modes"
|
||||
: >"$conf"
|
||||
KEYMAP=dk XKBLAYOUT=dk form || fail "an exported layout, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "exported KEYMAP" "does not read back as keymap dk"
|
||||
pass "the readback ignores KEYMAP and XKBLAYOUT in the environment"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|xx' 'Danish|dk'
|
||||
form || fail "an unknown keymap, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "unknown keymap" "keymap xx unknown to localectl"
|
||||
! grep -q 'keymap=xx\|set-keymap xx' "$tmp/calls" || fail "an unknown keymap is never persisted" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "after an unknown keymap"
|
||||
pass "a keymap localectl does not know is asked for again rather than keeping the default"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|DK' 'Danish|dk'
|
||||
form || fail "a keymap in the wrong case, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "keymap case" "keymap DK unknown to localectl"
|
||||
assert_layout dk "after a keymap in the wrong case"
|
||||
pass "a keymap matches localectl's list exactly, case included"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo fail >"$tmp/list-modes"
|
||||
form || fail "localectl failing to list keymaps, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "localectl list fails" "localectl could not list keymaps to check dk"
|
||||
[[ $(grep -c '^systemd-firstboot' "$tmp/calls") == 1 ]] || fail "a keymap that could not be checked is not persisted" "$(cat "$tmp/calls")"
|
||||
pass "a keymap list localectl cannot produce is a failure, logged apart from an unknown keymap"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk'
|
||||
echo fail >"$tmp/firstboot-modes"
|
||||
form || fail "localectl persists when firstboot fails" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
[[ $(prompts) == 1 ]] && grep -qx 'localectl set-keymap dk' "$tmp/calls" || fail "the localectl fallback persists the layout" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "the localectl fallback"
|
||||
pass "a failed systemd-firstboot falls back to localectl, checked the same way"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
echo fail >"$tmp/firstboot-modes"
|
||||
echo fail >"$tmp/localectl-modes"
|
||||
form || fail "both persisting paths failing, then the retry" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
assert_asked_again "firstboot and localectl fail" "could not persist keymap dk"
|
||||
pass "a layout neither systemd-firstboot nor localectl persists is asked for again"
|
||||
|
||||
# A layout that never takes keeps the owner at the keyboard step.
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'Danish|dk'
|
||||
printf 'fail\nfail\n' >"$tmp/loadkeys-modes"
|
||||
if form; then fail "a layout that never loads does not end the keyboard step"; fi
|
||||
[[ $(tail -n1 "$tmp/screen") == "asked again" ]] && ! grep -q 'password form' "$tmp/screen" ||
|
||||
fail "the password form is never reached while the layout fails" "$(cat "$tmp/screen")"
|
||||
grep -qx 'KEYMAP=us' "$conf" || fail "the install's layout is left alone" "$(cat "$conf")"
|
||||
pass "while the layout fails the owner never reaches the password form"
|
||||
|
||||
# A machine that can't set any layout (localectl gone for good) must not keep the
|
||||
# owner at the keyboard step: after three failures in a row the attempt fails,
|
||||
# and setup's own retry-or-console screen takes over.
|
||||
fresh_root
|
||||
answers 'Danish|dk' 'German|de-latin1' 'Danish|dk' 'Danish|dk'
|
||||
printf 'fail\nfail\nfail\nfail\n' >"$tmp/list-modes"
|
||||
status=0
|
||||
form || status=$?
|
||||
(( status == 1 )) || fail "a layout that can never be set fails the attempt" "status $status: $(cat "$tmp/screen")"
|
||||
[[ $(prompts) == 3 ]] && [[ $(tail -n1 "$tmp/screen") == "notice: Setup could not set a keyboard layout on this machine." ]] ||
|
||||
fail "the keyboard step gives up after three failures, and says so" "$(cat "$tmp/screen")"
|
||||
! grep -q 'password form' "$tmp/screen" || fail "a layout that can never be set never reaches the password form" "$(cat "$tmp/screen")"
|
||||
grep -q 'no keyboard layout could be set after 3 attempts' "$tmp/log" || fail "the log records giving up" "$(cat "$tmp/log")"
|
||||
sed -n '/^run_setup() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner" | grep -qx ' keyboard_form || return 1' ||
|
||||
fail "a failed keyboard step fails the setup attempt, which offers retry or a console"
|
||||
pass "a machine that can't set any layout fails the attempt after three tries, reaching setup's retry-or-console screen"
|
||||
|
||||
fresh_root
|
||||
answers 'Danish|dk'
|
||||
touch "$tmp/no-tty"
|
||||
form || fail "off a console the layout is only persisted" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
! grep -q '^loadkeys' "$tmp/calls" || fail "loadkeys runs only on a virtual console" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "off a console"
|
||||
pass "off a virtual console the layout is persisted and checked without loadkeys"
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# A deferred install (an Apple Silicon image, an OEM ISO install) runs its
|
||||
# hardware steps on the UTC placeholder, so the wireless regulatory domain can
|
||||
# only come from the timezone the owner picks in first-boot setup. The setup
|
||||
# step runs against a fixture root: the real install step with its paths moved
|
||||
# there, and wireless-regdb's boot helper as it ships, with iw recording calls.
|
||||
|
||||
tmp=$(cd -- "$(mktemp -d)" && pwd -P)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
root=$tmp/root
|
||||
omarchy=$tmp/omarchy
|
||||
stub_bin=$tmp/bin
|
||||
conf=$root/etc/conf.d/wireless-regdom
|
||||
calls=$tmp/calls
|
||||
mkdir -p "$stub_bin" "$omarchy/bin" "$omarchy/install/hardware"
|
||||
|
||||
ln -s "$ROOT/bin/omarchy-cmd-present" "$omarchy/bin/omarchy-cmd-present"
|
||||
sed -e "s|/etc/|$root/etc/|g" -e "s|/usr/share/zoneinfo|$root/usr/share/zoneinfo|g" \
|
||||
"$ROOT/install/hardware/set-wireless-regdom.sh" >"$omarchy/install/hardware/set-wireless-regdom.sh"
|
||||
|
||||
cat >"$stub_bin/timedatectl" <<SH
|
||||
#!/bin/bash
|
||||
[[ \$1 == set-timezone && -f "$root/usr/share/zoneinfo/\$2" ]] || exit 1
|
||||
ln -sfn "../usr/share/zoneinfo/\$2" "$root/etc/localtime"
|
||||
SH
|
||||
printf '#!/bin/bash\nexec "$@"\n' >"$stub_bin/sudo"
|
||||
cat >"$stub_bin/set-wireless-regdom" <<SH
|
||||
#!/bin/bash
|
||||
|
||||
unset WIRELESS_REGDOM
|
||||
. "$conf"
|
||||
[ -n "\${WIRELESS_REGDOM}" ] && iw reg set \${WIRELESS_REGDOM}
|
||||
SH
|
||||
cat >"$stub_bin/iw" <<SH
|
||||
#!/bin/bash
|
||||
echo "iw \$*" >>"$calls"
|
||||
[[ ! -e "$tmp/no-wifi" ]] || { echo "nl80211 not found." >&2; exit 1; }
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
# A fresh deferred install: wireless-regdb's commented list, the UTC placeholder.
|
||||
fresh_root() {
|
||||
rm -rf "$root" "$calls" "$tmp/no-wifi"
|
||||
mkdir -p "$root/etc/conf.d" "$root/usr/share/zoneinfo/Australia" "$root/usr/share/zoneinfo/America"
|
||||
printf '#\n# Wireless regulatory domain configuration\n#\n#WIRELESS_REGDOM="AU"\n#WIRELESS_REGDOM="US"\n' >"$conf"
|
||||
: >"$root/usr/share/zoneinfo/UTC"
|
||||
: >"$root/usr/share/zoneinfo/Australia/Brisbane"
|
||||
: >"$root/usr/share/zoneinfo/America/New_York"
|
||||
printf 'AU\t-2728+15302\tAustralia/Brisbane\tQueensland (most areas)\nUS\t+404251-0740023\tAmerica/New_York\tEastern (most areas)\n' \
|
||||
>"$root/usr/share/zoneinfo/zone.tab"
|
||||
ln -s ../usr/share/zoneinfo/UTC "$root/etc/localtime"
|
||||
: >"$calls"
|
||||
}
|
||||
|
||||
export PATH="$stub_bin:$omarchy/bin:$PATH"
|
||||
export OMARCHY_PATH=$omarchy
|
||||
|
||||
# The two setup functions as omarchy-provision-owner defines them, with its
|
||||
# fixed paths moved into the fixture root.
|
||||
sed -n '/^configure_timezone() {/,/^}/p; /^configure_wireless_regdom() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner" |
|
||||
sed -e "s|/usr/share/zoneinfo|$root/usr/share/zoneinfo|g" -e "s|/etc/|$root/etc/|g" >"$tmp/setup.sh"
|
||||
grep -q '^configure_wireless_regdom() {' "$tmp/setup.sh" || fail "omarchy-provision-owner sets the regulatory domain in setup"
|
||||
# shellcheck disable=SC1091
|
||||
source "$tmp/setup.sh"
|
||||
|
||||
regdom_lines() {
|
||||
grep '^WIRELESS_REGDOM=' "$conf" || true
|
||||
}
|
||||
|
||||
# The install step itself stays as an up-front install runs it: persisted only,
|
||||
# the radios untouched until the reboot that follows an install.
|
||||
fresh_root
|
||||
ln -sfn ../usr/share/zoneinfo/America/New_York "$root/etc/localtime"
|
||||
bash "$omarchy/install/hardware/set-wireless-regdom.sh"
|
||||
[[ $(regdom_lines) == 'WIRELESS_REGDOM="US"' ]] || fail "an up-front install persists its timezone's country" "$(cat "$conf")"
|
||||
[[ ! -s $calls ]] || fail "an up-front install leaves the radios alone" "$(cat "$calls")"
|
||||
pass "an up-front install persists its timezone's country and leaves the radios to the reboot"
|
||||
|
||||
fresh_root
|
||||
bash "$omarchy/install/hardware/set-wireless-regdom.sh"
|
||||
[[ -z $(regdom_lines) ]] || fail "the UTC placeholder names no country" "$(cat "$conf")"
|
||||
pass "a deferred install's hardware step sets nothing from the UTC placeholder"
|
||||
|
||||
timezone=Australia/Brisbane
|
||||
configure_timezone >/dev/null
|
||||
[[ $(readlink "$root/etc/localtime") == ../usr/share/zoneinfo/Australia/Brisbane ]] ||
|
||||
fail "setup applies the owner's timezone"
|
||||
[[ $(regdom_lines) == 'WIRELESS_REGDOM="AU"' ]] || fail "setup persists the owner's country" "$(cat "$conf")"
|
||||
[[ $(cat "$calls") == "iw reg set AU" ]] || fail "setup applies the owner's country to the radios" "$(cat "$calls")"
|
||||
pass "setup persists the owner's timezone's country and applies it without a reboot"
|
||||
|
||||
configure_timezone >/dev/null
|
||||
[[ $(regdom_lines) == 'WIRELESS_REGDOM="AU"' ]] || fail "a retried setup writes the domain once" "$(cat "$conf")"
|
||||
pass "a retried setup keeps one regulatory domain line"
|
||||
|
||||
fresh_root
|
||||
printf 'WIRELESS_REGDOM="DE"\n' >>"$conf"
|
||||
timezone=America/New_York
|
||||
configure_timezone >/dev/null
|
||||
[[ $(regdom_lines) == 'WIRELESS_REGDOM="DE"' ]] || fail "setup keeps a domain already chosen" "$(cat "$conf")"
|
||||
[[ $(cat "$calls") == "iw reg set DE" ]] || fail "setup applies the domain already chosen" "$(cat "$calls")"
|
||||
pass "a regulatory domain already chosen is kept"
|
||||
|
||||
fresh_root
|
||||
timezone=UTC
|
||||
configure_timezone >/dev/null
|
||||
[[ -z $(regdom_lines) ]] || fail "a timezone without a country sets no domain" "$(cat "$conf")"
|
||||
[[ ! -s $calls ]] || fail "a timezone without a country leaves the radios alone" "$(cat "$calls")"
|
||||
pass "a timezone without a country sets no regulatory domain"
|
||||
|
||||
fresh_root
|
||||
: >"$tmp/no-wifi"
|
||||
timezone=America/New_York
|
||||
configure_timezone >/dev/null || fail "setup goes on on a machine without Wi-Fi"
|
||||
[[ $(regdom_lines) == 'WIRELESS_REGDOM="US"' ]] || fail "a machine without Wi-Fi still gets the domain for later" "$(cat "$conf")"
|
||||
pass "a machine without Wi-Fi finishes setup with the domain persisted for its next boot"
|
||||
|
||||
fresh_root
|
||||
timezone=Omarchy/Nowhere
|
||||
configure_timezone >/dev/null || fail "setup goes on after a timezone it cannot apply"
|
||||
[[ -z $(regdom_lines) && ! -s $calls ]] || fail "a timezone that did not apply sets no domain" "$(cat "$conf" "$calls")"
|
||||
pass "a timezone that could not be applied sets no regulatory domain"
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
TMPDIR=""
|
||||
QS_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
|
||||
kill "$QS_PID" 2>/dev/null || true
|
||||
wait "$QS_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $TMPDIR && -f $TMPDIR/stub.pid ]]; then
|
||||
kill "$(<"$TMPDIR/stub.pid")" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
|
||||
rm -rf "$TMPDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
require_compositor "remote session service test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
skip "quickshell not installed; skipping remote session service test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
require_command grim
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
result="$TMPDIR/result.json"
|
||||
log="$TMPDIR/quickshell.log"
|
||||
config_dir="$TMPDIR/remote-session"
|
||||
mkdir -p "$config_dir" "$TMPDIR/home" "$TMPDIR/bin"
|
||||
cp "$SHELL_TEST_DIR/fixtures/remote-session/shell.qml" "$config_dir/shell.qml"
|
||||
|
||||
# A stand-in for the real server: the probe matches it by process name, so it
|
||||
# must stay a script called gliff-server rather than exec into sleep, and it
|
||||
# takes its sleep down with it when killed. Its ssh peer comes from the
|
||||
# environment the fixture launches it with. It cannot capture the screen, so
|
||||
# the fixture uses grim for the screencast event.
|
||||
cat >"$TMPDIR/bin/gliff-server" <<'SH'
|
||||
#!/bin/bash
|
||||
sleep 30 &
|
||||
trap 'kill $!' TERM EXIT
|
||||
wait $!
|
||||
SH
|
||||
|
||||
chmod +x "$TMPDIR/bin/gliff-server"
|
||||
|
||||
OMARCHY_PATH="$ROOT" \
|
||||
OMARCHY_QML_TEST_RESULT="$result" \
|
||||
OMARCHY_QML_TEST_STUB_PID="$TMPDIR/stub.pid" \
|
||||
OMARCHY_QML_TEST_FRAME="$TMPDIR/frame.png" \
|
||||
HOME="$TMPDIR/home" \
|
||||
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
|
||||
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
|
||||
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
|
||||
QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \
|
||||
QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \
|
||||
PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
|
||||
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
|
||||
QS_PID=$!
|
||||
|
||||
for _ in {1..150}; do
|
||||
[[ -s $result ]] && break
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
sed -n '1,160p' "$log" >&2
|
||||
fail "remote session quickshell exited before writing result"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ -s $result ]] || {
|
||||
sed -n '1,160p' "$log" >&2
|
||||
fail "remote session service test timed out"
|
||||
}
|
||||
|
||||
if ! jq -e '.ok == true' "$result" >/dev/null; then
|
||||
jq . "$result" >&2
|
||||
sed -n '1,160p' "$log" >&2
|
||||
fail "remote session service tracks a gliff-server session"
|
||||
fi
|
||||
pass "remote session service tracks a gliff-server session"
|
||||
|
||||
[[ -s $TMPDIR/frame.png ]] || fail "the fixture raised screencast events through grim"
|
||||
pass "the fixture raised screencast events through grim"
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const read = name => fs.readFileSync(path.join(root, name), 'utf8')
|
||||
const model = requireFromRoot('shell/plugins/services/remote-session/RemoteSessionModel.js')
|
||||
|
||||
assert(model.isCaptureEvent('screencast'), 'the screencast event triggers a probe')
|
||||
assert(!model.isCaptureEvent('screencastv2'), 'the paired screencastv2 event does not double the probe')
|
||||
assert(!model.isCaptureEvent('openwindow') && !model.isCaptureEvent(''), 'unrelated Hyprland events are ignored')
|
||||
|
||||
assertDeepEqual(model.stateFromOutput(''), { active: false, peers: [] }, 'no gliff-server means no session')
|
||||
assertDeepEqual(model.stateFromOutput('\n'), { active: false, peers: [] }, 'blank output means no session')
|
||||
assertDeepEqual(model.stateFromOutput('4321 \n'), { active: true, peers: [] }, 'a local server without ssh is a session with no known peer')
|
||||
assertDeepEqual(model.stateFromOutput('4321 10.0.0.5\n'), { active: true, peers: ['10.0.0.5'] }, 'an ssh-spawned server reports the client address')
|
||||
assertDeepEqual(model.stateFromOutput('1 10.0.0.5\n2 10.0.0.5\n3 fd00::9\n'), { active: true, peers: ['10.0.0.5', 'fd00::9'] }, 'peers are listed once each')
|
||||
|
||||
const service = read('shell/plugins/services/remote-session/Service.qml')
|
||||
const manifest = JSON.parse(read('shell/plugins/services/remote-session/manifest.json'))
|
||||
assertEqual(manifest.id, 'omarchy.remote-session', 'service manifest id matches the indicator lookup')
|
||||
assert(manifest.kinds.includes('service') && manifest.entryPoints.service === 'Service.qml', 'service manifest declares a service entry point')
|
||||
assert(service.includes('target: Hyprland') && service.includes('isCaptureEvent(event ? event.name : "")'), 'service re-probes on Hyprland screencast events')
|
||||
assert(service.includes('eventDebounce.restart()'), 'service debounces event bursts into one probe')
|
||||
assert(service.includes('/shell/plugins/services/remote-session/probe.sh"'), 'service runs the shared probe script')
|
||||
assert(service.includes('running: root.active') && service.includes('interval: 2000'), 'service polls only while a session is active, since the stop event is not reliable')
|
||||
assert(!service.includes('omarchy-notification-send'), 'a session is shown by the indicator alone, with no notification')
|
||||
|
||||
const indicator = read('shell/plugins/bar/indicators/RemoteSession.qml')
|
||||
const widget = read('shell/plugins/bar/widgets/Indicators.qml')
|
||||
const widgetManifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json'))
|
||||
assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "ScreenRecording", "RemoteSession"/), 'remote session is included in the default indicator tray')
|
||||
assert(widgetManifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'RemoteSession'), 'remote session is configurable alongside the other indicators')
|
||||
assert(indicator.includes('firstPartyServiceFor("omarchy.remote-session")'), 'indicator reads the remote session service')
|
||||
assert(indicator.includes('"Remote session from " + peers.join(", ")'), 'active tooltip names the connected peers')
|
||||
assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Commons.Color.urgent'), 'active remote session uses the theme danger color')
|
||||
assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'remote session keeps the same icon in both states')
|
||||
assert(!indicator.includes('visible:'), 'remote session uses the shared indicator visibility and hover behavior')
|
||||
assert(indicator.includes('root.remoteSessionService.refresh()'), 'indicator refresh re-probes the service')
|
||||
|
||||
const shell = read('shell/shell.qml')
|
||||
assert((shell.match(/"omarchy\.remote-session"/g) || []).length >= 2, 'third-party bar clones can read the remote session service')
|
||||
const api = read('shell/services/PluginFirstPartyServiceApi.qml')
|
||||
assert(api.includes('property bool active: false') && api.includes('property var peers: []'), 'service proxy exposes the remote session state')
|
||||
assert(api.includes('serviceId === "omarchy.remote-session" && _refresh') && shell.includes('_refresh: function()'), 'service proxy forwards refresh for cloned indicator widgets')
|
||||
JS
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
mkdir -p "$TMPDIR/bin" "$TMPDIR/proc/100" "$TMPDIR/proc/200"
|
||||
|
||||
probe_script="$ROOT/shell/plugins/services/remote-session/probe.sh"
|
||||
bash -n "$probe_script" || fail "remote session probe is valid bash"
|
||||
pass "remote session probe is valid bash"
|
||||
|
||||
cat >"$TMPDIR/bin/pgrep" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "-x" && $2 == "-u" && $3 == "$(id -u)" && $4 == "gliff-server" ]] || exit 2
|
||||
[[ -n ${GLIFF_PIDS:-} ]] || exit 1
|
||||
printf '%s\n' $GLIFF_PIDS
|
||||
SH
|
||||
chmod +x "$TMPDIR/bin/pgrep"
|
||||
|
||||
# The probe reads /proc/<pid>/environ; point a copy at fixtures instead.
|
||||
sed "s|/proc/\$pid/environ|$TMPDIR/proc/\$pid/environ|" "$probe_script" >"$TMPDIR/probe.sh"
|
||||
grep -q "$TMPDIR/proc" "$TMPDIR/probe.sh" || fail "probe reads the server environment from /proc"
|
||||
pass "probe reads the server environment from /proc"
|
||||
|
||||
probe() {
|
||||
PATH="$TMPDIR/bin:$PATH" GLIFF_PIDS="${1:-}" bash "$TMPDIR/probe.sh"
|
||||
}
|
||||
|
||||
[[ -z $(probe) ]] || fail "probe prints nothing without a gliff server"
|
||||
pass "probe prints nothing without a gliff server"
|
||||
|
||||
printf 'HOME=/home/x\0SSH_CONNECTION=10.0.0.5 51234 10.0.0.1 22\0TERM=foot\0' >"$TMPDIR/proc/100/environ"
|
||||
printf 'HOME=/home/x\0TERM=foot\0' >"$TMPDIR/proc/200/environ"
|
||||
|
||||
[[ $(probe "100") == "100 10.0.0.5" ]] || fail "probe reports the ssh client address of a spawned server"
|
||||
pass "probe reports the ssh client address of a spawned server"
|
||||
|
||||
[[ $(probe "200") == "200 " ]] || fail "probe reports a local server with an empty peer"
|
||||
pass "probe reports a local server with an empty peer"
|
||||
|
||||
(( $(probe "100 200" | wc -l) == 2 )) || fail "probe lists every server"
|
||||
pass "probe lists every server"
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
cleanup() {
|
||||
for child in $(jobs -pr); do kill "$child" 2>/dev/null || true; done
|
||||
if [[ -f $tmp/recorder-pid ]]; then kill "$(<"$tmp/recorder-pid")" 2>/dev/null || true; fi
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$tmp/bin" "$tmp/runtime" "$tmp/recordings"
|
||||
mkfifo "$tmp/release"
|
||||
|
||||
cat >"$tmp/bin/omarchy-capture-screenrecording-process" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} != "--pid" || $2 == "123" ]]; then
|
||||
if [[ ${TEST_ROLE:-} == "old-status" && ${2:-} == "123" ]]; then
|
||||
touch "$TEST_STATE/checked"
|
||||
read -r _ <"$TEST_STATE/release"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
[[ -f $TEST_STATE/recorder-pid && $2 == "$(<"$TEST_STATE/recorder-pid")" ]] || exit 1
|
||||
if [[ ${3:-} == "--signal" ]]; then kill -TERM "$2"; else kill -0 "$2" 2>/dev/null; fi
|
||||
SH
|
||||
cat >"$tmp/bin/wf-recorder" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "$$" >"$TEST_STATE/recorder-pid"
|
||||
for arg in "$@"; do
|
||||
if [[ ${next:-} == 1 ]]; then [[ -n ${TEST_NEVER_READY:-} ]] || touch "$arg"; break; fi
|
||||
[[ $arg == "-f" ]] && next=1
|
||||
done
|
||||
if [[ -n ${TEST_IGNORE_TERM:-} ]]; then trap '' TERM; else trap 'exit 0' TERM INT; fi
|
||||
while true; do sleep 0.05; done
|
||||
SH
|
||||
cat >"$tmp/bin/pactl" <<'SH'
|
||||
#!/bin/bash
|
||||
case $1 in
|
||||
get-default-sink) echo desktop ;;
|
||||
get-default-source) echo microphone ;;
|
||||
load-module)
|
||||
id=$(<"$TEST_STATE/next-module")
|
||||
echo $(( id + 1 )) >"$TEST_STATE/next-module"
|
||||
touch "$TEST_STATE/module-$id"
|
||||
echo "$id"
|
||||
;;
|
||||
unload-module) rm -f "$TEST_STATE/module-$2" ;;
|
||||
esac
|
||||
SH
|
||||
cat >"$tmp/bin/flock" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ ${TEST_ROLE:-} == "new-start" ]] && touch "$TEST_STATE/lock-attempted"
|
||||
exec /usr/bin/flock "$@"
|
||||
SH
|
||||
cat >"$tmp/bin/omarchy-cmd-present" <<'SH'
|
||||
#!/bin/bash
|
||||
[[ $1 == "wf-recorder" ]]
|
||||
SH
|
||||
cat >"$tmp/bin/omarchy-hyprland-monitor-focused" <<'SH'
|
||||
#!/bin/bash
|
||||
echo Virtual-1
|
||||
SH
|
||||
cat >"$tmp/bin/ffmpeg" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ ${TEST_ROLE:-} == "old-stop" && ! -e $TEST_STATE/checked ]]; then
|
||||
touch "$TEST_STATE/checked"
|
||||
read -r _ <"$TEST_STATE/release"
|
||||
fi
|
||||
touch "${@: -3:1}"
|
||||
SH
|
||||
for command in omarchy-shell omarchy-notification-send ffprobe pkill; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$tmp/bin/$command"
|
||||
done
|
||||
cat >"$tmp/bin/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
# A stop that finds no start pending while the start is finishing.
|
||||
if [[ -n ${TEST_LATE_STOP:-} && ! -e $TEST_STATE/late-stop-sent ]]; then
|
||||
touch "$TEST_STATE/late-stop-sent"
|
||||
rm -f "$XDG_RUNTIME_DIR/omarchy-screenrecord-starting"
|
||||
TEST_LATE_STOP= "$TEST_RECORD" --stop-recording 9>&- >/dev/null 2>&1 &
|
||||
echo $! >"$TEST_STATE/late-stop-pid"
|
||||
until [[ -e $XDG_RUNTIME_DIR/omarchy-screenrecord-cancel ]]; do sleep 0.05; done
|
||||
fi
|
||||
if [[ -n ${TEST_PAUSE_INDICATOR:-} && ! -e $TEST_STATE/indicator-paused ]]; then
|
||||
touch "$TEST_STATE/indicator-paused"
|
||||
read -r _ <"$TEST_STATE/release"
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tmp/bin/"*
|
||||
export PATH="$tmp/bin:$ROOT/bin:$PATH" TEST_STATE="$tmp" XDG_RUNTIME_DIR="$tmp/runtime"
|
||||
export OMARCHY_SCREENRECORD_DIR="$tmp/recordings"
|
||||
record="$ROOT/bin/omarchy-capture-screenrecording"
|
||||
export TEST_RECORD=$record
|
||||
|
||||
wait_for() {
|
||||
for _ in {1..200}; do
|
||||
for file in "$@"; do [[ ! -e $file ]] || return 0; done
|
||||
sleep 0.01
|
||||
done
|
||||
fail "concurrent recording command reached its barrier" "$*"
|
||||
}
|
||||
|
||||
assert_recording() {
|
||||
[[ -s $XDG_RUNTIME_DIR/omarchy-screenrecord-pid && -s $XDG_RUNTIME_DIR/omarchy-screenrecord-filename ]] ||
|
||||
fail "the new recording retains its saved state"
|
||||
[[ -f $XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules ]] || fail "the new recording retains its audio mix"
|
||||
for module in $(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules"); do
|
||||
[[ -f $tmp/module-$module ]] || fail "the new recording's audio module stays loaded" "$module"
|
||||
done
|
||||
}
|
||||
|
||||
echo 123 >"$XDG_RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
printf '10\n11\n12\n' >"$XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules"
|
||||
touch "$tmp/module-10" "$tmp/module-11" "$tmp/module-12"
|
||||
echo 20 >"$tmp/next-module"
|
||||
TEST_ROLE=old-status "$record" --status >/dev/null 2>&1 & old=$!
|
||||
wait_for "$tmp/checked"
|
||||
TEST_ROLE=new-start "$record" --fullscreen --resolution=1280x800 --with-desktop-audio --with-microphone-audio >/dev/null 2>&1 & new=$!
|
||||
wait_for "$tmp/lock-attempted" "$tmp/recorder-pid"
|
||||
if [[ ! -e $tmp/lock-attempted ]]; then
|
||||
wait_for "$XDG_RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
for _ in {1..200}; do
|
||||
[[ $(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-pid") == 123 ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ $(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-pid") != 123 ]] || fail "the concurrent recording saves its PID"
|
||||
fi
|
||||
echo release >"$tmp/release"
|
||||
status=0
|
||||
wait "$old" || status=$?
|
||||
(( status == 1 )) || fail "the old status reports its exited recorder" "$status"
|
||||
wait "$new" || fail "the new recording starts"
|
||||
assert_recording
|
||||
pass "stale status cleanup cannot unload a concurrent new recording's audio"
|
||||
|
||||
rm -f "$tmp/checked" "$tmp/lock-attempted"
|
||||
TEST_ROLE=old-stop "$record" --stop-recording >/dev/null 2>&1 & old=$!
|
||||
wait_for "$tmp/checked"
|
||||
rm "$tmp/recorder-pid"
|
||||
stop_locked=0
|
||||
if ! /usr/bin/flock -n "$XDG_RUNTIME_DIR/omarchy-screenrecord.lock" true; then stop_locked=1; fi
|
||||
TEST_ROLE=new-start "$record" --fullscreen --resolution=1280x800 --with-desktop-audio --with-microphone-audio >/dev/null 2>&1 & new=$!
|
||||
wait_for "$tmp/lock-attempted" "$tmp/recorder-pid"
|
||||
if (( ! stop_locked )); then
|
||||
wait "$new" || fail "the concurrent recording starts while the old stop is paused"
|
||||
new=""
|
||||
fi
|
||||
echo release >"$tmp/release"
|
||||
wait "$old" || fail "the old recording stops"
|
||||
if [[ -n $new ]]; then
|
||||
wait "$new" || fail "another recording starts after stop"
|
||||
fi
|
||||
assert_recording
|
||||
pass "a finishing stop cannot remove a concurrent new recording's state"
|
||||
|
||||
"$record" --stop-recording >/dev/null 2>&1 || fail "the earlier recording stops"
|
||||
|
||||
# A recorder that never creates its file keeps the start waiting. Another
|
||||
# toggle, or a stop, cancels that start instead of queueing a new one behind it.
|
||||
for second in toggle stop; do
|
||||
# Names are per second, so an earlier recording's file would look ready.
|
||||
rm -f "$tmp/recorder-pid" "$tmp/recordings/"*
|
||||
# The stop's recorder ignores TERM, so ending it needs the KILL that follows.
|
||||
ignore_term=""
|
||||
[[ $second == "stop" ]] && ignore_term=1
|
||||
TEST_IGNORE_TERM=$ignore_term TEST_NEVER_READY=1 "$record" --fullscreen --resolution=1280x800 --with-desktop-audio --with-microphone-audio >/dev/null 2>&1 & pending=$!
|
||||
wait_for "$tmp/recorder-pid"
|
||||
waiting=$(<"$tmp/recorder-pid")
|
||||
args=(--fullscreen --resolution=1280x800)
|
||||
[[ $second == "stop" ]] && args=(--stop-recording)
|
||||
timeout 5 "$record" "${args[@]}" >/dev/null 2>&1 || fail "a $second during a pending start returns at once"
|
||||
timeout 5 tail --pid="$pending" -f /dev/null || fail "the pending start ends once a $second cancels it"
|
||||
wait "$pending" || true
|
||||
! kill -0 "$waiting" 2>/dev/null || fail "a cancelled start stops its recorder"
|
||||
[[ $(<"$tmp/recorder-pid") == "$waiting" ]] || fail "a $second during a pending start does not begin another recording"
|
||||
for state in pid filename pa-modules starting cancel; do
|
||||
[[ ! -e $XDG_RUNTIME_DIR/omarchy-screenrecord-$state ]] || fail "a cancelled start leaves no $state behind"
|
||||
done
|
||||
pass "a $second during a start that is still waiting on its recorder cancels it"
|
||||
done
|
||||
|
||||
# A stop that arrives while the start is saving its recording as started still
|
||||
# ends that recording.
|
||||
rm -f "$tmp/recorder-pid" "$tmp/recordings/"* "$tmp/indicator-paused"
|
||||
TEST_PAUSE_INDICATOR=1 "$record" --fullscreen --resolution=1280x800 --with-desktop-audio >/dev/null 2>&1 & pending=$!
|
||||
wait_for "$tmp/indicator-paused"
|
||||
committed=$(<"$tmp/recorder-pid")
|
||||
timeout 5 "$record" --stop-recording >/dev/null 2>&1 || fail "a stop during the start's last step returns"
|
||||
echo release >"$tmp/release"
|
||||
timeout 5 tail --pid="$pending" -f /dev/null || fail "the start finishes after its last step"
|
||||
wait "$pending" || true
|
||||
! kill -0 "$committed" 2>/dev/null || fail "a stop during the start's last step ends the recording"
|
||||
for state in pid filename pa-modules starting cancel; do
|
||||
[[ ! -e $XDG_RUNTIME_DIR/omarchy-screenrecord-$state ]] || fail "a stop during the start's last step leaves no $state behind"
|
||||
done
|
||||
pass "a stop while the start saves its recording as started still ends it"
|
||||
|
||||
# A stop that finds no start pending while the start is still finishing waits
|
||||
# for the lock, and the recording ends either way.
|
||||
rm -f "$tmp/recorder-pid" "$tmp/recordings/"* "$tmp/late-stop-sent" "$tmp/late-stop-pid"
|
||||
TEST_LATE_STOP=1 "$record" --fullscreen --resolution=1280x800 >/dev/null 2>&1 || fail "the start with a late stop finishes"
|
||||
started=$(<"$tmp/recorder-pid")
|
||||
timeout 10 tail --pid="$(<"$tmp/late-stop-pid")" -f /dev/null || fail "the late stop finishes"
|
||||
! kill -0 "$started" 2>/dev/null || fail "a stop waiting behind a finishing start still ends the recording"
|
||||
[[ ! -e $XDG_RUNTIME_DIR/omarchy-screenrecord-pid && ! -e $XDG_RUNTIME_DIR/omarchy-screenrecord-cancel ]] ||
|
||||
fail "a late stop leaves no state behind"
|
||||
pass "a stop that waits behind a finishing start still ends the recording"
|
||||
|
||||
"$record" --stop-recording >/dev/null 2>&1 || true
|
||||
|
||||
# A start that was killed leaves its marker behind. A toggle that waits on the
|
||||
# lock (held here as a stop would) does not take that stale marker for a start
|
||||
# still in progress: it waits its turn and starts the recording.
|
||||
rm -f "$tmp/recorder-pid" "$tmp/recordings/"* "$XDG_RUNTIME_DIR/omarchy-screenrecord-cancel"
|
||||
true & dead=$!
|
||||
wait "$dead"
|
||||
echo "$dead" >"$XDG_RUNTIME_DIR/omarchy-screenrecord-starting"
|
||||
/usr/bin/flock "$XDG_RUNTIME_DIR/omarchy-screenrecord.lock" sleep 2 & holder=$!
|
||||
sleep 0.3
|
||||
timeout 15 "$record" --fullscreen --resolution=1280x800 >/dev/null 2>&1 || fail "a toggle behind a stale start marker finishes"
|
||||
wait "$holder" || true
|
||||
[[ -s $XDG_RUNTIME_DIR/omarchy-screenrecord-pid ]] && kill -0 "$(<"$tmp/recorder-pid")" 2>/dev/null ||
|
||||
fail "a toggle behind a stale start marker starts the recording instead of cancelling a start that is gone"
|
||||
"$record" --stop-recording >/dev/null 2>&1 || true
|
||||
pass "a killed start's leftover marker does not swallow a later toggle"
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
SCRIPT="$ROOT/bin/omarchy-capture-screenrecording-process"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "$tmp"/*.pid; do
|
||||
[[ -f $file ]] && kill -KILL "$(<"$file")" 2>/dev/null || true
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$tmp/bin"
|
||||
|
||||
# pgrep reports the pids listed in $TEST_STATE/running, as a recorder search would.
|
||||
cat >"$tmp/bin/pgrep" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >"$TEST_STATE/pgrep-args"
|
||||
[[ -s $TEST_STATE/running ]] || exit 1
|
||||
cat "$TEST_STATE/running"
|
||||
SH
|
||||
chmod +x "$tmp/bin/pgrep"
|
||||
export TEST_STATE="$tmp"
|
||||
|
||||
# A fake /proc gives each pid the executable link only Linux provides.
|
||||
exe() {
|
||||
mkdir -p "$tmp/proc/$1"
|
||||
ln -sfn "$2" "$tmp/proc/$1/exe"
|
||||
}
|
||||
|
||||
recorders() {
|
||||
local pid
|
||||
printf '%s\n' "$@" >"$tmp/running"
|
||||
for pid; do
|
||||
[[ -L $tmp/proc/$pid/exe ]] || exe "$pid" /usr/bin/gpu-screen-recorder
|
||||
done
|
||||
}
|
||||
|
||||
run() {
|
||||
local status=0
|
||||
OMARCHY_PROC_ROOT="$tmp/proc" PATH="$tmp/bin:$PATH" "$SCRIPT" "$@" 2>"$tmp/stderr" || status=$?
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
# Job control keeps SIGINT deliverable to background jobs, which a
|
||||
# non-interactive shell would otherwise start with it ignored.
|
||||
set -m
|
||||
spawn() {
|
||||
local name=$1
|
||||
bash -c 'trap "echo INT >\"$0\"; exit 0" INT; while :; do sleep 0.05; done' "$tmp/$name.signal" &
|
||||
echo $! >"$tmp/$name.pid"
|
||||
disown
|
||||
}
|
||||
|
||||
got_int() {
|
||||
local name=$1 tries=0
|
||||
while [[ ! -s $tmp/$name.signal ]] && ((tries < 100)); do
|
||||
sleep 0.02
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
[[ -s $tmp/$name.signal ]]
|
||||
}
|
||||
|
||||
alive() {
|
||||
kill -0 "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_dead() {
|
||||
local tries=0
|
||||
while alive "$1" && ((tries < 100)); do
|
||||
sleep 0.02
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
! alive "$1"
|
||||
}
|
||||
|
||||
: >"$tmp/running"
|
||||
[[ $(run) == 1 ]] || fail "no recorder running exits 1"
|
||||
[[ $(run --pid 123) == 1 ]] || fail "a pid with no recorder running exits 1"
|
||||
pass "no recorder running exits 1"
|
||||
|
||||
recorders 4242
|
||||
[[ $(run) == 0 ]] || fail "a running recorder exits 0"
|
||||
[[ $(<"$tmp/pgrep-args") == "-u $UID -x gpu-screen-reco(rder)?|wf-recorder" ]] ||
|
||||
fail "recorders are found by the current user's process name" "$(<"$tmp/pgrep-args")"
|
||||
pass "a running recorder exits 0"
|
||||
|
||||
recorders 4242 4343
|
||||
[[ $(run --pid 4343) == 0 ]] || fail "--pid of a recorder exits 0"
|
||||
[[ $(run --pid 04343) == 0 ]] || fail "--pid ignores leading zeros"
|
||||
[[ $(run --pid 0000000000000004343) == 0 ]] || fail "--pid ignores leading zeros past ten digits"
|
||||
[[ $(run --pid 4194304) == 1 ]] || fail "--pid accepts the largest Linux pid"
|
||||
[[ $(run --pid 999) == 1 ]] || fail "--pid of a non-recorder exits 1"
|
||||
[[ $(run --pid 434) == 1 ]] || fail "--pid matches whole pids only"
|
||||
[[ $(run --pid 999 --pid 4242) == 0 ]] || fail "repeated --pid matches any of them"
|
||||
pass "--pid restricts the check to the given recorder pids"
|
||||
|
||||
spawn a
|
||||
spawn b
|
||||
spawn other
|
||||
recorders "$(<"$tmp/a.pid")" "$(<"$tmp/b.pid")"
|
||||
[[ $(run --signal INT) == 0 ]] || fail "--signal INT with recorders exits 0"
|
||||
got_int a && got_int b || fail "--signal INT interrupts every recorder"
|
||||
alive "$(<"$tmp/other.pid")" && [[ ! -s $tmp/other.signal ]] || fail "--signal INT leaves other processes alone"
|
||||
pass "--signal INT interrupts every recorder and nothing else"
|
||||
|
||||
spawn c
|
||||
spawn d
|
||||
recorders "$(<"$tmp/c.pid")" "$(<"$tmp/d.pid")"
|
||||
[[ $(run --pid "$(<"$tmp/c.pid")" --signal INT) == 0 ]] || fail "--pid with --signal INT exits 0"
|
||||
got_int c || fail "--pid with --signal INT interrupts the given recorder"
|
||||
[[ ! -s $tmp/d.signal ]] && alive "$(<"$tmp/d.pid")" || fail "--pid with --signal INT leaves other recorders alone"
|
||||
[[ $(run --pid "$(<"$tmp/d.pid")" --signal KILL) == 0 ]] || fail "--signal KILL exits 0"
|
||||
wait_dead "$(<"$tmp/d.pid")" || fail "--signal KILL kills the given recorder"
|
||||
[[ ! -s $tmp/d.signal ]] || fail "--signal KILL does not interrupt first"
|
||||
alive "$(<"$tmp/other.pid")" || fail "--signal KILL leaves other processes alone"
|
||||
[[ $(run --pid 999 --signal KILL) == 1 ]] || fail "--signal with no matching recorder exits 1"
|
||||
alive "$(<"$tmp/other.pid")" || fail "--signal with no matching recorder signals nothing"
|
||||
pass "--pid with --signal INT or KILL sends that signal to only the given recorder"
|
||||
|
||||
spawn e
|
||||
gone=$(<"$tmp/d.pid")
|
||||
recorders "$gone" "$(<"$tmp/e.pid")"
|
||||
[[ $(run --signal INT) == 0 ]] || fail "a recorder exiting before its signal is not an error"
|
||||
got_int e || fail "a recorder exiting before its signal does not stop the others being signalled"
|
||||
pass "a recorder exiting before its signal is not an error"
|
||||
|
||||
exe 5001 /usr/bin/gpu-screen-recorder-helper
|
||||
exe 5002 "/usr/bin/wf-recorder (deleted)"
|
||||
recorders 5001
|
||||
[[ $(run) == 1 ]] || fail "a process named like a recorder but running another executable is not a recorder"
|
||||
[[ $(run --pid 5001) == 1 ]] || fail "--pid of a recorder-prefixed helper exits 1"
|
||||
recorders 5003
|
||||
rm -rf "$tmp/proc/5003"
|
||||
[[ $(run) == 1 ]] || fail "a candidate whose executable cannot be read is not a recorder"
|
||||
recorders 5002
|
||||
[[ $(run) == 0 ]] || fail "a recorder whose executable an upgrade replaced is still a recorder"
|
||||
spawn helper
|
||||
exe "$(<"$tmp/helper.pid")" /usr/bin/gpu-screen-recorder-helper
|
||||
recorders "$(<"$tmp/helper.pid")"
|
||||
[[ $(run --signal INT) == 1 ]] || fail "--signal with only a recorder-prefixed helper exits 1"
|
||||
alive "$(<"$tmp/helper.pid")" && [[ ! -s $tmp/helper.signal ]] || fail "--signal leaves a recorder-prefixed helper alone"
|
||||
pass "the executable, not the truncated process name, decides what is a recorder"
|
||||
|
||||
true &
|
||||
dead1=$!
|
||||
wait "$dead1"
|
||||
true &
|
||||
dead2=$!
|
||||
wait "$dead2"
|
||||
recorders "$dead1" "$dead2"
|
||||
[[ $(run --signal INT) == 1 ]] || fail "--signal exits 1 when every recorder exits before its signal"
|
||||
pass "--signal exits 1 when no signal was delivered"
|
||||
|
||||
for args in "--pid" "--pid abc" "--pid -1" "--pid 12x" "--signal" "--signal TERM" "--signal int" "--bogus" "123" "--pid 1 extra" \
|
||||
"--pid 4194305" "--pid 12345678901" "--pid 18446744073709555959" "--pid 99999999999999999999"; do
|
||||
# shellcheck disable=SC2086
|
||||
[[ $(run $args) == 2 ]] || fail "invalid arguments exit 2: $args"
|
||||
grep -q '^Usage: omarchy-capture-screenrecording-process ' "$tmp/stderr" || fail "invalid arguments print usage: $args"
|
||||
done
|
||||
pass "invalid arguments print usage and exit 2"
|
||||
|
||||
# The real pgrep sees Linux's 15-character process names, which cut
|
||||
# gpu-screen-recorder short.
|
||||
if [[ -r /proc/self/comm ]]; then
|
||||
mkdir -p "$tmp/real"
|
||||
cp "$(command -v sleep)" "$tmp/real/gpu-screen-recorder"
|
||||
cp "$(command -v sleep)" "$tmp/real/wf-recorder"
|
||||
"$tmp/real/gpu-screen-recorder" 30 &
|
||||
gpu=$!
|
||||
echo "$gpu" >"$tmp/gpu.pid"
|
||||
"$tmp/real/wf-recorder" 30 &
|
||||
wf=$!
|
||||
echo "$wf" >"$tmp/wf.pid"
|
||||
disown -a
|
||||
sleep 0.2
|
||||
"$SCRIPT" --pid "$gpu" || fail "the real pgrep finds gpu-screen-recorder"
|
||||
"$SCRIPT" --pid "$wf" || fail "the real pgrep finds wf-recorder"
|
||||
"$SCRIPT" --pid "$gpu" --pid "$wf" --signal KILL || fail "the real pgrep signals both recorders"
|
||||
wait_dead "$gpu" && wait_dead "$wf" || fail "the real recorders are killed"
|
||||
pass "the real pgrep finds and signals both recorders by name"
|
||||
else
|
||||
skip "the real pgrep finds and signals both recorders by name (needs Linux /proc)"
|
||||
fi
|
||||
|
||||
grep -Fq '["omarchy-capture-screenrecording", "--status"]' "$ROOT/shell/plugins/bar/indicators/ScreenRecording.qml" ||
|
||||
fail "the bar recording indicator asks the recorder script, which stop also uses"
|
||||
! grep -Fq 'gpu-screen-recorder' "$ROOT/shell/plugins/bar/indicators/ScreenRecording.qml" ||
|
||||
fail "the bar recording indicator still greps gpu-screen-recorder"
|
||||
grep -Fq '"when":"omarchy-capture-screenrecording --status"' "$ROOT/default/omarchy/omarchy-menu.jsonc" ||
|
||||
fail "Stop Screenrecording asks the recorder script, which its action also uses"
|
||||
! grep -Fq "pgrep -f '^gpu-screen-recorder'" "$ROOT/default/omarchy/omarchy-menu.jsonc" ||
|
||||
fail "Stop Screenrecording still greps gpu-screen-recorder"
|
||||
pass "the bar indicator and the menu ask the recorder script whether its stop has a recording to end"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
require_command ffmpeg
|
||||
require_command ffprobe
|
||||
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
# loudnorm declares 192 kHz output, so without a pinned rate the finalize pass
|
||||
# writes 96 kHz AAC from a 48 kHz recording. Many editors decode such a track as
|
||||
# silence, and on Apple Silicon playing it switches the speakers to 96 kHz,
|
||||
# which their safety daemon answers by locking the amps.
|
||||
source <(sed -n '/^finalize_recording() {/,/^}/p' "$ROOT/bin/omarchy-capture-screenrecording")
|
||||
recording="$work/recording.mp4"
|
||||
ffmpeg -loglevel error -f lavfi -i testsrc=size=64x64:rate=30 -f lavfi -i sine=frequency=440:sample_rate=48000 \
|
||||
-t 1 -c:v libx264 -c:a aac -ar 48000 "$recording"
|
||||
RECORDING_FILE="$work/latest"
|
||||
echo "$recording" >"$RECORDING_FILE"
|
||||
finalize_recording
|
||||
|
||||
# The pass trims the first 0.1 s, so a shorter file proves it ran.
|
||||
duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$recording")
|
||||
awk -v d="$duration" 'BEGIN { exit !(d < 0.95) }' || fail "the loudness pass processed the recording" "$duration"
|
||||
rate=$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of csv=p=0 "$recording")
|
||||
[[ $rate == "48000" ]] || fail "the finalized recording keeps 48 kHz audio" "$rate"
|
||||
pass "the finalized recording keeps 48 kHz audio"
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
mkdir -p "$tmp/bin" "$tmp/runtime" "$tmp/recordings"
|
||||
|
||||
cat >"$tmp/bin/omarchy-capture-screenrecording-process" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$TEST_HELPER_CALLS"
|
||||
[[ ${1:-} == "--pid" && ${2:-} == "123" && ${TEST_RECORDER_ACTIVE:-0} == "1" ]]
|
||||
SH
|
||||
|
||||
cat >"$tmp/bin/pactl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$TEST_AUDIO_CALLS"
|
||||
SH
|
||||
chmod +x "$tmp/bin/omarchy-capture-screenrecording-process" "$tmp/bin/pactl"
|
||||
|
||||
export PATH="$tmp/bin:$ROOT/bin:$PATH"
|
||||
export XDG_RUNTIME_DIR="$tmp/runtime"
|
||||
export OMARCHY_SCREENRECORD_DIR="$tmp/recordings"
|
||||
export TEST_HELPER_CALLS="$tmp/helper-calls"
|
||||
export TEST_AUDIO_CALLS="$tmp/audio-calls"
|
||||
echo 123 >"$XDG_RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
|
||||
for action in --status --stop-recording; do
|
||||
printf '10\n11\n12\n' >"$XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules"
|
||||
: >"$TEST_HELPER_CALLS"
|
||||
: >"$TEST_AUDIO_CALLS"
|
||||
status=0
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" "$action" >/dev/null 2>&1 || status=$?
|
||||
(( status == 1 )) || fail "$action reports no recorder after it exits" "$status"
|
||||
[[ $(<"$TEST_AUDIO_CALLS") == $'unload-module 12\nunload-module 11\nunload-module 10' ]] ||
|
||||
fail "$action releases the exited recorder's audio loopbacks before its sink" "$(<"$TEST_AUDIO_CALLS")"
|
||||
[[ ! -e $XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules ]] || fail "$action removes the stale audio module list"
|
||||
! grep -q -- '--signal' "$TEST_HELPER_CALLS" || fail "$action signals no other recorder"
|
||||
pass "$action releases audio capture after the saved recorder exits"
|
||||
done
|
||||
|
||||
printf '10\n11\n12\n' >"$XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules"
|
||||
: >"$TEST_AUDIO_CALLS"
|
||||
TEST_RECORDER_ACTIVE=1 "$ROOT/bin/omarchy-capture-screenrecording" --status || fail "status recognizes the active recorder"
|
||||
[[ ! -s $TEST_AUDIO_CALLS && -e $XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules ]] ||
|
||||
fail "status leaves an active recording's audio modules loaded"
|
||||
pass "status preserves the active recorder's audio capture"
|
||||
|
||||
rm "$XDG_RUNTIME_DIR/omarchy-screenrecord-pid"
|
||||
: >"$TEST_AUDIO_CALLS"
|
||||
status=0
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --status >/dev/null 2>&1 || status=$?
|
||||
(( status == 1 )) || fail "status reports inactive before the recorder starts"
|
||||
[[ ! -s $TEST_AUDIO_CALLS && -e $XDG_RUNTIME_DIR/omarchy-screenrecord-pa-modules ]] ||
|
||||
fail "status preserves the audio mix created before the recorder starts"
|
||||
pass "status leaves the starting recorder's audio mix alone"
|
||||
@@ -307,7 +307,8 @@ pass "webcam size rules place the initial window in its final corner"
|
||||
recording_dir="$tmp_dir/recordings"
|
||||
mkdir -p "$recording_dir"
|
||||
|
||||
cat >"$stub_bin/pgrep" <<'SH'
|
||||
# Nothing is recording yet, whatever else runs on the machine.
|
||||
cat >"$stub_bin/omarchy-capture-screenrecording-process" <<'SH'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
SH
|
||||
@@ -331,7 +332,7 @@ cat >"$stub_bin/omarchy-shell" <<'SH'
|
||||
exit 0
|
||||
SH
|
||||
|
||||
chmod +x "$stub_bin"/pgrep "$stub_bin"/omarchy-hyprland-monitor-focused \
|
||||
chmod +x "$stub_bin"/omarchy-capture-screenrecording-process "$stub_bin"/omarchy-hyprland-monitor-focused \
|
||||
"$stub_bin"/gpu-screen-recorder "$stub_bin"/omarchy-shell
|
||||
|
||||
# Compare that name across the run rather than demanding it be absent: the
|
||||
@@ -410,3 +411,131 @@ pass "without a runtime dir the webcam anchors to the recorded region"
|
||||
mode=$(stat -c '%a' "$state_home/omarchy" 2>/dev/null || stat -f '%Lp' "$state_home/omarchy")
|
||||
[[ $mode == "700" ]] || fail "fallback directory is private even when it already existed" "mode: $mode"
|
||||
pass "fallback directory is private even when it already existed"
|
||||
|
||||
# gpu-screen-recorder aborts before recording on a GPU it does not know, such
|
||||
# as Apple Silicon's. wf-recorder takes over when it is installed, on the same
|
||||
# monitor, and its pid is the one stop and status act on.
|
||||
cat >"$stub_bin/gpu-screen-recorder" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "gsr error: unknown gpu vendor" >&2
|
||||
exit 1
|
||||
SH
|
||||
|
||||
cat >"$stub_bin/wf-recorder" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >"$OMARCHY_TEST_WF_ARGS"
|
||||
echo "$$" >"$OMARCHY_TEST_WF_PID"
|
||||
for i in "$@"; do
|
||||
[[ -n ${take_next:-} ]] && { : >"$i"; break; }
|
||||
[[ $i == "-f" ]] && take_next=1
|
||||
done
|
||||
sleep 5
|
||||
SH
|
||||
chmod +x "$stub_bin/gpu-screen-recorder" "$stub_bin/wf-recorder"
|
||||
|
||||
# Fresh directories: a recording named for the same second must not exist yet.
|
||||
wf_runtime="$tmp_dir/wf-runtime"
|
||||
wf_recordings="$tmp_dir/wf-recordings"
|
||||
mkdir -p "$wf_runtime" "$wf_recordings"
|
||||
wf_args="$tmp_dir/wf-args"
|
||||
wf_pid="$tmp_dir/wf-pid"
|
||||
XDG_RUNTIME_DIR="$wf_runtime" OMARCHY_TEST_WF_ARGS="$wf_args" OMARCHY_TEST_WF_PID="$wf_pid" \
|
||||
OMARCHY_SCREENRECORD_DIR="$wf_recordings" \
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1
|
||||
|
||||
pkill -f "$stub_bin/wf-recorder" 2>/dev/null || true
|
||||
|
||||
[[ -s $wf_args ]] || fail "wf-recorder records when gpu-screen-recorder cannot start"
|
||||
grep -Fxq -- '-o' "$wf_args" && grep -Fxq DP-1 "$wf_args" && grep -Fxq 48000 "$wf_args" ||
|
||||
fail "wf-recorder records the focused monitor at 48 kHz" "$(<"$wf_args")"
|
||||
[[ $(<"$wf_runtime/omarchy-screenrecord-pid") == "$(<"$wf_pid")" ]] ||
|
||||
fail "the recorder pid is the wf-recorder that is recording" "$(ls -a "$wf_runtime")"
|
||||
[[ $(<"$wf_runtime/omarchy-screenrecord-filename") == "$wf_recordings"/* ]] ||
|
||||
fail "the wf-recorder recording is the one recorded as started"
|
||||
pass "wf-recorder records when gpu-screen-recorder cannot start"
|
||||
|
||||
# Without wf-recorder, a recorder that cannot start leaves nothing behind.
|
||||
rm "$stub_bin/wf-recorder"
|
||||
none_runtime="$tmp_dir/none-runtime"
|
||||
none_recordings="$tmp_dir/none-recordings"
|
||||
mkdir -p "$none_runtime" "$none_recordings"
|
||||
echo 424242 >"$none_runtime/omarchy-screenrecord-pid"
|
||||
XDG_RUNTIME_DIR="$none_runtime" OMARCHY_SCREENRECORD_DIR="$none_recordings" \
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1 || true
|
||||
[[ ! -e $none_runtime/omarchy-screenrecord-pid && ! -e $none_runtime/omarchy-screenrecord-filename ]] ||
|
||||
fail "a recorder that cannot start records no state, and clears a stale pid" "$(ls -a "$none_runtime")"
|
||||
pass "a recorder that cannot start records no state, and clears a stale pid"
|
||||
|
||||
# Another recorder runs in both cases below: the helper answers for any
|
||||
# selection but a pid, and a stop by name ends it.
|
||||
helper_calls="$tmp_dir/helper-calls"
|
||||
recording_flag="$tmp_dir/recording"
|
||||
cat >"$stub_bin/omarchy-capture-screenrecording-process" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$OMARCHY_TEST_HELPER_CALLS"
|
||||
if [[ $1 == "--pid" ]]; then
|
||||
[[ $2 == "${OMARCHY_TEST_LIVE_PID:-}" ]]
|
||||
exit
|
||||
fi
|
||||
if [[ $* == *"--signal INT" ]]; then
|
||||
rm -f "$OMARCHY_TEST_RECORDING"
|
||||
exit 0
|
||||
fi
|
||||
[[ -e $OMARCHY_TEST_RECORDING ]]
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-capture-screenrecording-process"
|
||||
|
||||
# A saved pid that is no longer a recorder is a recording that ended without a
|
||||
# stop: nothing of ours records, so stop has nothing to do and signals no other
|
||||
# recorder the user runs.
|
||||
stale_runtime="$tmp_dir/stale-runtime"
|
||||
mkdir -p "$stale_runtime"
|
||||
echo 424242 >"$stale_runtime/omarchy-screenrecord-pid"
|
||||
touch "$recording_flag"
|
||||
: >"$helper_calls"
|
||||
if XDG_RUNTIME_DIR="$stale_runtime" OMARCHY_TEST_HELPER_CALLS="$helper_calls" OMARCHY_TEST_RECORDING="$recording_flag" \
|
||||
OMARCHY_SCREENRECORD_DIR="$recording_dir" \
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --stop-recording >/dev/null 2>&1; then
|
||||
fail "stop finds no recording of ours behind a stale pid" "$(<"$helper_calls")"
|
||||
fi
|
||||
! grep -q -- '--signal' "$helper_calls" ||
|
||||
fail "stop signals nothing when the saved pid is stale" "$(<"$helper_calls")"
|
||||
[[ -e $recording_flag ]] || fail "the other recorder keeps recording"
|
||||
pass "a stale saved pid leaves every other recorder alone"
|
||||
|
||||
# Without a saved pid (a recording started before the pid was saved), status
|
||||
# and stop select every recorder the user runs, as the bar and the menu do.
|
||||
legacy_runtime="$tmp_dir/legacy-runtime"
|
||||
mkdir -p "$legacy_runtime"
|
||||
touch "$recording_flag"
|
||||
: >"$helper_calls"
|
||||
XDG_RUNTIME_DIR="$legacy_runtime" OMARCHY_TEST_HELPER_CALLS="$helper_calls" OMARCHY_TEST_RECORDING="$recording_flag" \
|
||||
OMARCHY_SCREENRECORD_DIR="$recording_dir" \
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --stop-recording >/dev/null 2>&1 ||
|
||||
fail "stop finds a recording started before the pid was saved" "$(<"$helper_calls")"
|
||||
grep -Fxq -- '--signal INT' "$helper_calls" ||
|
||||
fail "stop signals the user's recorders when no pid was saved" "$(<"$helper_calls")"
|
||||
! grep -Fq -- '--signal KILL' "$helper_calls" ||
|
||||
fail "a recorder that stops on INT is not killed" "$(<"$helper_calls")"
|
||||
pass "without a saved pid, status and stop select the user's recorders"
|
||||
|
||||
# The bar indicator and the menu's Stop row ask --status, so they show a stop
|
||||
# exactly when the toggle has a recording of ours to end. It needs no
|
||||
# recordings directory, never notifies and never signals.
|
||||
status() {
|
||||
XDG_RUNTIME_DIR="$1" OMARCHY_TEST_HELPER_CALLS="$helper_calls" OMARCHY_TEST_RECORDING="$recording_flag" \
|
||||
OMARCHY_TEST_LIVE_PID="${2:-}" OMARCHY_SCREENRECORD_DIR="$tmp_dir/no-such-recordings" \
|
||||
"$ROOT/bin/omarchy-capture-screenrecording" --status >/dev/null 2>&1
|
||||
}
|
||||
touch "$recording_flag"
|
||||
: >"$helper_calls"
|
||||
rm -f "$OMARCHY_TEST_NOTIFICATION_ARGS"
|
||||
echo 424242 >"$stale_runtime/omarchy-screenrecord-pid"
|
||||
if status "$stale_runtime"; then fail "--status reports nothing of ours behind a stale pid"; fi
|
||||
status "$legacy_runtime" || fail "--status reports a recording started before the pid was saved"
|
||||
status "$stale_runtime" 424242 || fail "--status reports the recorder its saved pid names"
|
||||
rm -f "$recording_flag"
|
||||
if status "$legacy_runtime"; then fail "--status reports nothing when no recorder runs"; fi
|
||||
! grep -q -- '--signal' "$helper_calls" || fail "--status signals nothing" "$(<"$helper_calls")"
|
||||
[[ ! -e $OMARCHY_TEST_NOTIFICATION_ARGS ]] || fail "--status never notifies, even without a recordings directory" "$(<"$OMARCHY_TEST_NOTIFICATION_ARGS")"
|
||||
pass "--status answers what stop would act on, without a recordings directory, notifications or signals"
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
# wf-recorder takes one audio device, so desktop and microphone audio together
|
||||
# go through a throwaway null sink. A source whose loopback won't load is left
|
||||
# out with a notification naming it, never recorded as silence.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
awk '
|
||||
/^(wf_audio_device|wf_audio_unavailable|wf_audio_cleanup)\(\) \{/ { copying = 1 }
|
||||
copying { print }
|
||||
/^}/ { copying = 0 }
|
||||
' "$ROOT/bin/omarchy-capture-screenrecording" >"$tmp/functions"
|
||||
|
||||
mkdir -p "$tmp/bin"
|
||||
# Modules number from 10 up; a load fails when its arguments name a source
|
||||
# listed in $tmp/refuse.
|
||||
cat >"$tmp/bin/pactl" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "pactl $*" >>"$TMP/calls"
|
||||
case $1 in
|
||||
get-default-sink) [[ -e $TMP/no-sink ]] || echo speakers ;;
|
||||
get-default-source) [[ -e $TMP/no-source ]] || echo mic ;;
|
||||
load-module)
|
||||
for refused in $(cat "$TMP/refuse" 2>/dev/null); do
|
||||
[[ " $* " != *" $refused "* ]] || exit 1
|
||||
done
|
||||
n=$(( $(cat "$TMP/next" 2>/dev/null || echo 10) ))
|
||||
echo $(( n + 1 )) >"$TMP/next"
|
||||
echo "$n"
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
cat >"$tmp/bin/omarchy-notification-send" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "notify $*" >>"$TMP/notes"
|
||||
echo "noise on stdout"
|
||||
SH
|
||||
chmod +x "$tmp/bin"/*
|
||||
|
||||
device() {
|
||||
rm -f "$tmp/calls" "$tmp/notes" "$tmp/next" "$tmp/modules"
|
||||
TMP=$tmp PATH="$tmp/bin:$PATH" DESKTOP_AUDIO=$1 MICROPHONE_AUDIO=$2 PA_MODULES_FILE=$tmp/modules \
|
||||
bash -c 'source "$TMP/functions"; wf_audio_device' >"$tmp/out"
|
||||
}
|
||||
|
||||
loaded() {
|
||||
grep -c 'load-module' "$tmp/calls" || true
|
||||
}
|
||||
|
||||
unloaded() {
|
||||
grep 'unload-module' "$tmp/calls" | awk '{ print $3 }' | tr '\n' ' '
|
||||
}
|
||||
|
||||
: >"$tmp/refuse"
|
||||
device true true
|
||||
[[ $(cat "$tmp/out") == omarchy_screenrec.monitor ]] || fail "both sources record through the null sink" "$(cat "$tmp/out")"
|
||||
[[ $(cat "$tmp/modules") == $'10\n11\n12' ]] || fail "all three module ids are kept for stop" "$(cat "$tmp/modules")"
|
||||
[[ ! -e $tmp/notes ]] || fail "a full mix notifies nothing" "$(cat "$tmp/notes")"
|
||||
pass "desktop and microphone audio record together through the null sink"
|
||||
|
||||
echo "source=mic" >"$tmp/refuse"
|
||||
device true true
|
||||
[[ $(cat "$tmp/out") == speakers.monitor ]] || fail "a microphone that won't mix leaves desktop audio" "$(cat "$tmp/out")"
|
||||
[[ $(unloaded) == "11 10 " && ! -e $tmp/modules ]] || fail "the modules that loaded are unloaded" "$(cat "$tmp/calls")"
|
||||
grep -q 'notify .*microphone audio' "$tmp/notes" && ! grep -q 'desktop' "$tmp/notes" ||
|
||||
fail "the dropped microphone is named" "$(cat "$tmp/notes" 2>/dev/null)"
|
||||
pass "a microphone that won't mix is dropped, named, and desktop audio records alone"
|
||||
|
||||
echo "source=speakers.monitor" >"$tmp/refuse"
|
||||
device true true
|
||||
[[ $(cat "$tmp/out") == mic ]] || fail "desktop audio that won't mix leaves the microphone" "$(cat "$tmp/out")"
|
||||
[[ $(unloaded) == "11 10 " ]] || fail "the modules that loaded are unloaded" "$(cat "$tmp/calls")"
|
||||
grep -q 'notify .*desktop audio' "$tmp/notes" && ! grep -q 'microphone' "$tmp/notes" ||
|
||||
fail "the dropped desktop audio is named" "$(cat "$tmp/notes" 2>/dev/null)"
|
||||
pass "desktop audio that won't mix is dropped, named, and the microphone records alone"
|
||||
|
||||
echo "source=speakers.monitor source=mic" >"$tmp/refuse"
|
||||
device true true
|
||||
[[ ! -s $tmp/out ]] || fail "with neither source there is no audio device" "$(cat "$tmp/out")"
|
||||
[[ $(grep -c '^notify' "$tmp/notes") == 2 ]] || fail "both dropped sources are named" "$(cat "$tmp/notes")"
|
||||
pass "with neither loopback the recording has no audio track rather than a silent one, and says so"
|
||||
|
||||
echo "module-null-sink" >"$tmp/refuse"
|
||||
device true true
|
||||
[[ $(cat "$tmp/out") == speakers.monitor && $(loaded) == 1 ]] || fail "without the null sink desktop audio records alone" "$(cat "$tmp/out")"
|
||||
grep -q 'notify .*microphone audio' "$tmp/notes" || fail "without the null sink the microphone is named" "$(cat "$tmp/notes" 2>/dev/null)"
|
||||
pass "without the null sink, desktop audio records alone and the microphone is named"
|
||||
|
||||
: >"$tmp/refuse"
|
||||
touch "$tmp/no-source"
|
||||
device true true
|
||||
[[ $(cat "$tmp/out") == speakers.monitor && $(loaded) == 0 ]] || fail "without a default source desktop audio records alone" "$(cat "$tmp/out")"
|
||||
grep -q 'notify .*microphone audio' "$tmp/notes" || fail "a missing default source is named" "$(cat "$tmp/notes" 2>/dev/null)"
|
||||
device false true
|
||||
[[ ! -s $tmp/out ]] && grep -q 'notify .*microphone audio' "$tmp/notes" || fail "a microphone-only recording without a source says so" "$(cat "$tmp/out")"
|
||||
rm "$tmp/no-source"
|
||||
pass "a requested source with no default device is named"
|
||||
|
||||
device true false
|
||||
[[ $(cat "$tmp/out") == speakers.monitor && ! -e $tmp/notes ]] || fail "desktop audio alone records the sink monitor" "$(cat "$tmp/out")"
|
||||
device false true
|
||||
[[ $(cat "$tmp/out") == mic && ! -e $tmp/notes ]] || fail "microphone audio alone records the source" "$(cat "$tmp/out")"
|
||||
device false false
|
||||
[[ ! -s $tmp/out && ! -e $tmp/calls ]] || fail "no audio asks pactl nothing" "$(cat "$tmp/calls")"
|
||||
pass "a single source records directly and no audio asks for nothing"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
check=omarchy_security_require_privileged_bash_startup
|
||||
printf '#!/bin/bash -p\nsource "%s"\n%s && ( %s )\n' "$ROOT/bin/omarchy-security-functions" "$check" "$check" >"$test_tmp/entrypoint"
|
||||
# Privileged mode turned on after startup: the mode is set, the proof is not.
|
||||
printf 'set -p\nsource "%s"\n%s\n' "$ROOT/bin/omarchy-security-functions" "$check" >"$test_tmp/late"
|
||||
chmod +x "$test_tmp/entrypoint"
|
||||
|
||||
"$test_tmp/entrypoint" || fail "a script started as bash -p passes the startup check, from a subshell too"
|
||||
pass "a script started as bash -p passes the startup check, from a subshell too"
|
||||
|
||||
for arguments in "" "-p"; do
|
||||
if /usr/bin/bash "$test_tmp/late" $arguments; then
|
||||
fail "bash that only turned -p on later is rejected" "arguments: $arguments"
|
||||
fi
|
||||
done
|
||||
if /usr/bin/bash "$test_tmp/entrypoint"; then
|
||||
fail "a script not started with -p is rejected"
|
||||
fi
|
||||
pass "bash not started with -p is rejected, whatever its other arguments"
|
||||
|
||||
# arch-chroot runs the installer in a new PID namespace under the outer /proc,
|
||||
# so the caller's own PID is not the number /proc knows it by.
|
||||
in_new_pid_namespace=(unshare --user --map-root-user --fork --pid)
|
||||
if "${in_new_pid_namespace[@]}" /usr/bin/true 2>/dev/null; then
|
||||
"${in_new_pid_namespace[@]}" /usr/bin/bash -c "for i in {1..50}; do /usr/bin/true; done; '$test_tmp/entrypoint'; exit" </dev/null ||
|
||||
fail "the startup check passes in a new PID namespace under the outer /proc"
|
||||
if "${in_new_pid_namespace[@]}" /usr/bin/bash -c "for i in {1..50}; do /usr/bin/true; done; /usr/bin/bash '$test_tmp/late' -p; exit" </dev/null; then
|
||||
fail "bash that only turned -p on later is rejected in a new PID namespace"
|
||||
fi
|
||||
pass "the startup check sees its caller in a new PID namespace, as under arch-chroot"
|
||||
else
|
||||
skip "a new PID namespace needs unprivileged user namespaces"
|
||||
fi
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
# The omarchy-settings recipe ships the same files on every architecture when
|
||||
# the source has default/settings-runtime-profile, which lists the files that
|
||||
# decide at runtime whether they apply. Every file it lists must exist.
|
||||
profile="$ROOT/default/settings-runtime-profile"
|
||||
[[ -f $profile ]] || fail "the source marks itself for the omarchy-settings recipe"
|
||||
|
||||
listed=$(grep -oE '(^|[[:space:]])(etc|default)/[^ ,]*' "$profile" | sed 's/^[[:space:]]*//' | sort -u)
|
||||
[[ -n $listed ]] || fail "the runtime profile lists its files"
|
||||
while IFS= read -r path; do
|
||||
[[ -e $ROOT/${path%/} ]] || fail "the runtime profile lists an existing file: $path"
|
||||
done <<<"$listed"
|
||||
pass "every file the runtime profile lists exists in the source"
|
||||
|
||||
# The files that decide at runtime, and what they decide it from.
|
||||
grep -q 'omarchy-hw-platform' "$ROOT/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf" ||
|
||||
fail "the HOOKS baseline asks the platform"
|
||||
grep -q 'modinfo' "$ROOT/etc/mkinitcpio.conf.d/thunderbolt_module.conf" ||
|
||||
fail "thunderbolt is added only where the kernel has the module"
|
||||
grep -qx zram-generator "$ROOT/install/omarchy-aarch64.packages" ||
|
||||
fail "aarch64 installs the zram generator the zram drop-in needs"
|
||||
pass "the profile's runtime decisions are in place"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
mkdir "$work/bin"
|
||||
cat >"$work/bin/cat" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ $1 == /sys/class/dmi/id/product_name ]]; then
|
||||
[[ ${DMI_STATUS:-0} == 0 ]] || exit "$DMI_STATUS"
|
||||
printf '%s\n' "${DMI_MODEL:-Unknown}"
|
||||
else
|
||||
/usr/bin/cat "$@"
|
||||
fi
|
||||
STUB
|
||||
cat >"$work/bin/omarchy-pkg-add" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "$*" >>"$SPI_CALLS"
|
||||
STUB
|
||||
cat >"$work/bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
# No host filesystem or kernel mutations in this fixture.
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$work/bin/"*
|
||||
export SPI_CALLS="$work/calls"
|
||||
for failure in 1 13; do
|
||||
DMI_STATUS=$failure PATH="$work/bin:$PATH" bash -eE "$ROOT/install/hardware/apple/fix-spi-keyboard.sh"
|
||||
[[ ! -f $SPI_CALLS ]] || fail 'missing/unreadable DMI must not install SPI drivers'
|
||||
done
|
||||
DMI_MODEL=MacBookPro13,2 PATH="$work/bin:$PATH" bash -eE "$ROOT/install/hardware/apple/fix-spi-keyboard.sh"
|
||||
grep -Fxq macbook12-spi-driver-dkms "$SPI_CALLS" || fail 'matching Intel Mac retains SPI driver'
|
||||
pass 'missing/unreadable DMI survives errexit and matching Intel Macs retain drivers'
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/policy-test.sh"
|
||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/startup-test.sh"
|
||||
|
||||
if [[ -n ${BOLT_TEST_SOURCE:-} ]]; then
|
||||
/usr/bin/python3 "$ROOT/test/shell.d/fixtures/thunderbolt/bolt-integration.py" "$ROOT"
|
||||
else
|
||||
skip "real boltd integration needs BOLT_TEST_SOURCE and an UMockdev environment"
|
||||
fi
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||
|
||||
# omarchy update's boot check through the real omarchy-update-boot, dispatcher
|
||||
# and platform detector, inside the sudo boundary fixture: every other update
|
||||
# step is a stub that records itself, and sudo records its arguments. A fake
|
||||
# boot package's update-verify records itself and exits with the status it is
|
||||
# given.
|
||||
require_platform_fixtures "omarchy update's boot check on platform fixtures"
|
||||
|
||||
copy_boundary_file bin/omarchy-update
|
||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update-boot"
|
||||
for command in omarchy-update-boot omarchy-lifecycle-dispatch omarchy-hw-platform; do
|
||||
ln -sfn "$ROOT/bin/$command" "$SUDO_TEST_ROOT/bin/$command"
|
||||
done
|
||||
export OMARCHY_UPDATE_LOGGED=1
|
||||
|
||||
tmp=$boundary_tmp/boot
|
||||
mkdir -p "$tmp"
|
||||
for platform in aarch64-apple aarch64 x86; do
|
||||
fake_platform "$tmp/$platform" "$platform"
|
||||
done
|
||||
|
||||
# A lifecycle root whose update-verify exits with $2.
|
||||
boot_package() {
|
||||
local lifecycle=$1 verify=$2
|
||||
rm -rf "$lifecycle"
|
||||
mkdir -p "$lifecycle/usr/lib/omarchy/mac-boot"
|
||||
printf '#!/bin/bash\necho update-verify >>%q\necho "the boot files do not match" >&2\nexit %s\n' "$tmp/boot-ran" "$verify" \
|
||||
>"$lifecycle/usr/lib/omarchy/mac-boot/update-verify"
|
||||
chmod 755 "$lifecycle"/usr/lib/omarchy/mac-boot/*
|
||||
chmod -R go-w "$lifecycle"
|
||||
}
|
||||
boot_package "$tmp/passing" 0
|
||||
boot_package "$tmp/failing" 9
|
||||
boot_package "$tmp/unverified" 1
|
||||
mkdir -p "$tmp/none" "$tmp/older/usr/lib/omarchy/mac-boot" "$tmp/older/var/lib/pacman/local/omarchy-mac-boot-20260921-10"
|
||||
|
||||
# omarchy update -y on platform $1 with the boot package in lifecycle root $2.
|
||||
# The update's own PATH is fixed, so the platform's uname goes beside the
|
||||
# stubbed steps.
|
||||
run_update() {
|
||||
local platform=$1 lifecycle=$2
|
||||
reset_boundary
|
||||
rm -f "$tmp/boot-ran"
|
||||
cp "$tmp/$platform/bin/uname" "$SUDO_TEST_ROOT/bin/uname"
|
||||
status=0
|
||||
OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT="$lifecycle" \
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$tmp/out" 2>"$tmp/err" || status=$?
|
||||
}
|
||||
|
||||
# The line number of the first event in the sudo log that starts with $1.
|
||||
at() {
|
||||
grep -n -m 1 -F -- "$1" "$SUDO_TEST_LOG" | cut -d: -f1
|
||||
}
|
||||
|
||||
reboot_offered() {
|
||||
grep -q '^step:omarchy-update-restart --reboot-only' "$SUDO_TEST_LOG"
|
||||
}
|
||||
|
||||
# x86 and plain aarch64: the boot check is a no-op, even with a
|
||||
# failing boot-package entrypoint on disk, and nothing asks for root.
|
||||
for platform in x86 aarch64; do
|
||||
run_update "$platform" "$tmp/failing"
|
||||
(( status == 0 )) || fail "$platform: an update reports success" "status $status: $(cat "$tmp/err")"
|
||||
[[ ! -e $tmp/boot-ran ]] || fail "$platform: no boot-package entrypoint runs" "$(cat "$tmp/boot-ran")"
|
||||
! grep -q 'omarchy-lifecycle-dispatch' "$SUDO_TEST_LOG" || fail "$platform: the boot check asks for no root" "$(cat "$SUDO_TEST_LOG")"
|
||||
[[ ! -s $tmp/err ]] || fail "$platform: the boot check prints nothing" "$(cat "$tmp/err")"
|
||||
reboot_offered || fail "$platform: the reboot is offered" "$(cat "$SUDO_TEST_LOG")"
|
||||
assert_boundary_cold "$platform update"
|
||||
done
|
||||
pass "x86 and plain aarch64 updates are unchanged: no boot check runs, nothing asks for root and the reboot is offered"
|
||||
|
||||
# Apple: verify runs once the last package step, AUR, is done, cold through
|
||||
# the no-update wrapper, before the reboot offer.
|
||||
run_update aarch64-apple "$tmp/passing"
|
||||
(( status == 0 )) || fail "apple: an update whose boot check passes reports success" "status $status: $(cat "$tmp/err")"
|
||||
[[ $(cat "$tmp/boot-ran") == "update-verify" ]] || fail "apple: verify runs once" "$(cat "$tmp/boot-ran")"
|
||||
aur=$(at 'step:omarchy-update-aur-pkgs') verify=$(at 'sudo -N omarchy-lifecycle-dispatch update-verify') hook=$(at 'step:omarchy-hook post-update')
|
||||
[[ -n $aur && -n $verify && -n $hook ]] && (( hook < aur && aur < verify )) ||
|
||||
fail "apple: verify follows AUR through the no-update wrapper" "$(cat "$SUDO_TEST_LOG")"
|
||||
! grep -q '^sudo omarchy-lifecycle-dispatch update-verify' "$SUDO_TEST_LOG" || fail "apple: verification never uses a reusable timestamp" "$(cat "$SUDO_TEST_LOG")"
|
||||
[[ $(sed -n "$((verify - 1))p;$((verify + 1))p" "$SUDO_TEST_LOG") == $'sudo -k\nsudo -k' ]] ||
|
||||
fail "apple: verification starts cold after AUR and sudo is revoked right after it" "$(cat "$SUDO_TEST_LOG")"
|
||||
reboot_offered || fail "apple: a verified update offers the reboot"
|
||||
assert_boundary_cold "apple update"
|
||||
pass "apple: verify runs as root through the boot package, and a verified update offers the reboot"
|
||||
|
||||
# omarchy-update-boot takes no operation, so a caller still asking for a
|
||||
# preflight is a usage error rather than a verification.
|
||||
status=0
|
||||
"$SUDO_TEST_ROOT/bin/omarchy-update-boot" preflight >/dev/null 2>&1 || status=$?
|
||||
(( status == 2 )) || fail "omarchy-update-boot refuses an argument" "status $status"
|
||||
pass "omarchy-update-boot refuses an argument"
|
||||
|
||||
# A failed verification lets the update finish its remaining steps, then fails
|
||||
# it without offering the reboot.
|
||||
run_update aarch64-apple "$tmp/unverified"
|
||||
(( status == 1 )) || fail "apple: a failed verification fails the update" "status $status: $(cat "$tmp/err")"
|
||||
! reboot_offered || fail "apple: a failed verification offers no reboot"
|
||||
grep -q '^step:omarchy-update-stay-awake stop' "$SUDO_TEST_LOG" ||
|
||||
fail "apple: after a failed verification Stay Awake is released" "$(cat "$SUDO_TEST_LOG")"
|
||||
grep -q 'the boot files do not match' "$tmp/err" && grep -q 'The update is not finished' "$tmp/err" ||
|
||||
fail "apple: a failed verification says why, and that the update is not finished" "$(cat "$tmp/err")"
|
||||
! grep -q 'Something went wrong during the update' "$tmp/out" "$tmp/err" || fail "apple: a failed verification is not reported as a crash"
|
||||
assert_boundary_cold "apple failed verification"
|
||||
pass "apple: a failed verification fails the update, explained, with no reboot offered"
|
||||
|
||||
# A Mac without the boot package at all predates it: the update warns that its
|
||||
# boot files were not verified and finishes, asking for no root.
|
||||
run_update aarch64-apple "$tmp/none"
|
||||
(( status == 0 )) && reboot_offered || fail "apple without the boot package: the update finishes and offers the reboot" "status $status: $(cat "$tmp/err")"
|
||||
grep -q 'update-verify on aarch64-apple needs omarchy-mac-boot' "$tmp/err" && grep -q 'The boot files were not verified' "$tmp/err" ||
|
||||
fail "apple without the boot package: the update says the boot files were not verified" "$(cat "$tmp/err")"
|
||||
! grep -q 'omarchy-lifecycle-dispatch' "$SUDO_TEST_LOG" || fail "apple without the boot package: nothing asks for root"
|
||||
pass "apple: without the boot package the update warns that the boot files were not verified"
|
||||
|
||||
# A boot package from before update-verify is one package update away.
|
||||
run_update aarch64-apple "$tmp/older"
|
||||
(( status == 1 )) && ! reboot_offered || fail "apple: a boot package without update-verify fails the update" "status $status: $(cat "$tmp/err")"
|
||||
grep -q 'which omarchy-mac-boot 20260921-10 does not provide; update omarchy-mac-boot' "$tmp/err" ||
|
||||
fail "apple: a boot package without update-verify is named with its version" "$(cat "$tmp/err")"
|
||||
pass "apple: a boot package without update-verify fails the update and asks for its update"
|
||||
@@ -79,6 +79,7 @@ for command in \
|
||||
omarchy-update-aur-pkgs \
|
||||
omarchy-update-mise \
|
||||
omarchy-update-orphan-pkgs \
|
||||
omarchy-update-boot \
|
||||
omarchy-hook \
|
||||
omarchy-update-analyze-logs \
|
||||
omarchy-shell \
|
||||
|
||||
@@ -52,14 +52,32 @@ chmod +x "$stub_bin/sudo" "$stub_bin/systemd-run" "$stub_bin/pacman"
|
||||
|
||||
replaced="$test_tmp/replaced"
|
||||
|
||||
# Every case says which platform it runs on (x86 unless it sets PLATFORM), and
|
||||
# which boot package entrypoints are installed (none unless it sets
|
||||
# LIFECYCLE_ROOT), rather than inherit the machine running the suite.
|
||||
for platform in x86 aarch64-apple; do
|
||||
fake_platform "$test_tmp/$platform" "$platform"
|
||||
done
|
||||
mkdir -p "$test_tmp/no-boot-package"
|
||||
# Root ignores both fixtures and would ask the machine's own boot package, so
|
||||
# as root the cases before the platform ones get a dispatcher that registers
|
||||
# nothing, and the platform ones are skipped.
|
||||
if (( EUID == 0 )); then
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/omarchy-lifecycle-dispatch"
|
||||
chmod +x "$stub_bin/omarchy-lifecycle-dispatch"
|
||||
fi
|
||||
|
||||
run_update() {
|
||||
local platform=${PLATFORM:-x86}
|
||||
OMARCHY_REPLACED_DIR="$replaced" \
|
||||
RETRY_FAILS="${RETRY_FAILS:-}" \
|
||||
RETRY_INSTALLS="${RETRY_INSTALLS:-}" \
|
||||
PACMAN_ATTEMPTS="$test_tmp/attempts" \
|
||||
CONFLICT_REPORT="$test_tmp/report" \
|
||||
OWNED_PATHS="${OWNED_PATHS:-}" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
OMARCHY_PROC_ROOT="$test_tmp/$platform/proc" \
|
||||
OMARCHY_LIFECYCLE_ROOT="${LIFECYCLE_ROOT:-$test_tmp/no-boot-package}" \
|
||||
PATH="$stub_bin:$test_tmp/$platform/bin:$ROOT/bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-update-system-pkgs"
|
||||
}
|
||||
|
||||
@@ -282,3 +300,66 @@ run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
|
||||
[[ $(cat "$test_tmp/attempts") == 2 ]] ||
|
||||
fail "a clean upgrade runs more than one pacman transaction"
|
||||
pass "a clean upgrade runs a single pacman transaction"
|
||||
|
||||
# A platform whose boot package owns files no package does vouches for a
|
||||
# takeover before anything moves. Only a boot package's answer lets it through.
|
||||
require_platform_fixtures "the platform's say over a takeover"
|
||||
|
||||
boot_package="$test_tmp/boot-package"
|
||||
mkdir -p "$boot_package/usr/lib/omarchy/mac-boot"
|
||||
cat >"$boot_package/usr/lib/omarchy/mac-boot/update-takeover" <<SH
|
||||
#!/bin/bash
|
||||
printf '%s\\n' "\$@" >"$test_tmp/vouched"
|
||||
exit "\$(cat "$test_tmp/takeover-status")"
|
||||
SH
|
||||
chmod 755 "$boot_package/usr/lib/omarchy/mac-boot/update-takeover"
|
||||
chmod -R go-w "$boot_package"
|
||||
|
||||
fresh_work
|
||||
echo "ours" >"$stray"
|
||||
write_report omarchy-settings "$stray"
|
||||
echo 0 >"$test_tmp/takeover-status"
|
||||
rm -f "$test_tmp/vouched"
|
||||
PLATFORM=aarch64-apple LIFECYCLE_ROOT="$boot_package" run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
|
||||
fail "a takeover the boot package vouches for goes ahead" "$(cat "$test_tmp/err")"
|
||||
[[ ! -e $stray && -f $replaced$stray && $(cat "$test_tmp/vouched") == "$stray" ]] ||
|
||||
fail "the boot package is asked about exactly the files that move"
|
||||
pass "apple: a takeover the boot package vouches for goes ahead"
|
||||
|
||||
fresh_work
|
||||
echo "ours" >"$stray"
|
||||
write_report omarchy-settings "$stray"
|
||||
echo 1 >"$test_tmp/takeover-status"
|
||||
if PLATFORM=aarch64-apple LIFECYCLE_ROOT="$boot_package" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
|
||||
fail "a takeover the boot package refuses stops the upgrade"
|
||||
fi
|
||||
[[ -f $stray && ! -e $replaced$stray && $(cat "$test_tmp/attempts") == 1 ]] ||
|
||||
fail "a refused takeover moves nothing and retries nothing"
|
||||
grep -Fq "$stray" "$test_tmp/err" || fail "a refused takeover names the files it keeps" "$(cat "$test_tmp/err")"
|
||||
pass "apple: a takeover the boot package refuses moves nothing"
|
||||
|
||||
# A Mac without its boot package, or with one too old to answer, keeps the files.
|
||||
older="$test_tmp/older-boot-package"
|
||||
mkdir -p "$older/usr/lib/omarchy/mac-boot" "$older/var/lib/pacman/local/omarchy-mac-boot-20260921-10"
|
||||
for root in "$test_tmp/no-boot-package" "$older"; do
|
||||
fresh_work
|
||||
echo "ours" >"$stray"
|
||||
write_report omarchy-settings "$stray"
|
||||
if PLATFORM=aarch64-apple LIFECYCLE_ROOT="$root" run_update >"$test_tmp/out" 2>"$test_tmp/err"; then
|
||||
fail "a Mac whose boot package can't answer keeps the files" "$root"
|
||||
fi
|
||||
[[ -f $stray && ! -e $replaced$stray ]] || fail "a Mac whose boot package can't answer moves nothing" "$root"
|
||||
grep -Fq omarchy-mac-boot "$test_tmp/err" || fail "the refusal names the boot package" "$(cat "$test_tmp/err")"
|
||||
done
|
||||
pass "apple: a Mac whose boot package can't answer keeps the files"
|
||||
|
||||
# x86 with Mac entrypoints on disk still takes over as before, asking no one.
|
||||
fresh_work
|
||||
echo "ours" >"$stray"
|
||||
write_report omarchy-settings "$stray"
|
||||
echo 1 >"$test_tmp/takeover-status"
|
||||
rm -f "$test_tmp/vouched"
|
||||
LIFECYCLE_ROOT="$boot_package" run_update >"$test_tmp/out" 2>"$test_tmp/err" ||
|
||||
fail "x86 takes over as before" "$(cat "$test_tmp/err")"
|
||||
[[ ! -e $stray && ! -e $test_tmp/vouched ]] || fail "x86 asks no boot package"
|
||||
pass "x86 takes over as before, asking no boot package"
|
||||
@@ -58,6 +58,22 @@ exit "${KEYRING_TEST_PKG_MISSING:-1}"
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-pkg-missing"
|
||||
|
||||
# KEYRING_TEST_ARM=1: Arch Linux ARM's keyring is installed.
|
||||
# KEYRING_TEST_PLATFORM: the platform (default x86).
|
||||
cat >"$stub_bin/omarchy-pkg-present" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ ($* == "archlinuxarm-keyring" && ${KEYRING_TEST_ARM:-0} == 1) || " ${KEYRING_TEST_INSTALLED:-} " == *" $* "* ]]
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-pkg-present"
|
||||
|
||||
cat >"$stub_bin/omarchy-hw-aarch64" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
[[ ${KEYRING_TEST_PLATFORM:-x86} == aarch64* ]]
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-hw-aarch64"
|
||||
|
||||
cat >"$stub_bin/omarchy-pkg-add" <<'SH'
|
||||
#!/bin/bash
|
||||
|
||||
@@ -66,11 +82,17 @@ exit 0
|
||||
SH
|
||||
chmod +x "$stub_bin/omarchy-pkg-add"
|
||||
|
||||
# The platform's keyrings list is read at a fixed path; the test runs a copy
|
||||
# rewritten to read a fixture there.
|
||||
platform_keyrings="$test_tmp/platform-keyrings"
|
||||
sed "s|/usr/share/omarchy-platform/keyrings|$platform_keyrings|" "$ROOT/bin/omarchy-update-keyring" >"$test_tmp/omarchy-update-keyring"
|
||||
chmod +x "$test_tmp/omarchy-update-keyring"
|
||||
|
||||
run_keyring() {
|
||||
KEYRING_TEST_LOG="$log_file" \
|
||||
KEYRING_TEST_DIR="$test_tmp" \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
"$ROOT/bin/omarchy-update-keyring" "$@"
|
||||
"$test_tmp/omarchy-update-keyring" "$@"
|
||||
}
|
||||
|
||||
# Everything healthy: the key and package are present, the reinstall works.
|
||||
@@ -86,6 +108,48 @@ grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring$' "$log_file" ||
|
||||
fail "update-keyring still reinstalls archlinux-keyring" "$(cat "$log_file")"
|
||||
pass "update-keyring still reinstalls archlinux-keyring"
|
||||
|
||||
# An aarch64 machine keeps Arch Linux ARM's keyring current alongside Arch's.
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
for platform in aarch64-apple aarch64; do
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
KEYRING_TEST_ARM=1 KEYRING_TEST_PLATFORM=$platform run_keyring >"$test_tmp/arm.out"
|
||||
grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring\tarchlinuxarm-keyring$' "$log_file" ||
|
||||
fail "update-keyring also reinstalls Arch Linux ARM's keyring on $platform" "$(cat "$log_file")"
|
||||
done
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
KEYRING_TEST_ARM=1 run_keyring >"$test_tmp/x86.out"
|
||||
grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring$' "$log_file" ||
|
||||
fail "x86 reinstalls only Arch's keyring, even with Arch Linux ARM's installed" "$(cat "$log_file")"
|
||||
pass "aarch64 machines also reinstall Arch Linux ARM's keyring where it is installed, x86 never"
|
||||
|
||||
# The platform package's list adds the installed keyrings it names: Apple
|
||||
# Silicon's [asahi-alarm], with no platform branch here.
|
||||
printf '%s\n' '# Asahi' '' asahi-alarm-keyring archlinuxarm-keyring other-keyring asahi-alarm-keyring >"$platform_keyrings"
|
||||
printf 'asahi-alarm-keyring' >>"$platform_keyrings"
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
KEYRING_TEST_ARM=1 KEYRING_TEST_PLATFORM=aarch64-apple KEYRING_TEST_INSTALLED=asahi-alarm-keyring run_keyring >"$test_tmp/platform.out"
|
||||
grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring\tarchlinuxarm-keyring\tasahi-alarm-keyring$' "$log_file" ||
|
||||
fail "update-keyring reinstalls the installed keyrings the platform names, once each" "$(cat "$log_file")"
|
||||
pass "update-keyring reinstalls the installed keyrings the platform names, once each"
|
||||
|
||||
for listed in 'asahi-alarm-keyring;reboot' '../asahi-alarm-keyring' 'asahi-alarm' '-Syu-keyring' 'asahi-alarm-keyring extra'; do
|
||||
printf '%s\n%s\n' "$listed" asahi-alarm-keyring >"$platform_keyrings"
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
KEYRING_TEST_INSTALLED=asahi-alarm-keyring run_keyring >"$test_tmp/invalid.out" 2>&1 ||
|
||||
fail "update-keyring carries on past a platform list line naming $listed" "$(cat "$test_tmp/invalid.out")"
|
||||
grep -qF "skipping $listed" "$test_tmp/invalid.out" ||
|
||||
fail "update-keyring warns about $listed" "$(cat "$test_tmp/invalid.out")"
|
||||
grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring\tasahi-alarm-keyring$' "$log_file" ||
|
||||
fail "update-keyring skips $listed and still reinstalls the valid keyrings, and nothing else" "$(cat "$log_file")"
|
||||
done
|
||||
rm -f "$platform_keyrings"
|
||||
pass "update-keyring skips a platform list line naming anything but a keyring package, with a warning"
|
||||
|
||||
# Key and package missing: the full populate path runs and verifies at the end.
|
||||
: >"$log_file"
|
||||
rm -f "$test_tmp/list-calls"
|
||||
|
||||
@@ -68,6 +68,7 @@ for command in \
|
||||
omarchy-update-aur-pkgs \
|
||||
omarchy-update-mise \
|
||||
omarchy-update-orphan-pkgs \
|
||||
omarchy-update-boot \
|
||||
omarchy-hook \
|
||||
omarchy-update-analyze-logs \
|
||||
omarchy-shell \
|
||||
|
||||
@@ -18,6 +18,7 @@ steps=(
|
||||
omarchy-snapshot
|
||||
omarchy-update-stay-awake
|
||||
omarchy-update-dev
|
||||
omarchy-update-boot
|
||||
omarchy-update-keyring
|
||||
omarchy-update-system-pkgs
|
||||
omarchy-migrate
|
||||
@@ -79,6 +80,7 @@ expected_steps() {
|
||||
omarchy-hook \
|
||||
omarchy-update-mise \
|
||||
omarchy-update-aur-pkgs \
|
||||
omarchy-update-boot \
|
||||
omarchy-update-stay-awake \
|
||||
omarchy-update-restart
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/install/helpers/usb-authorization-policy.sh"
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="'
|
||||
interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"'
|
||||
location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"'
|
||||
device="allow $identity $location $interfaces"
|
||||
portable=$(usb_authorization_portable_rule "$device")
|
||||
[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed"
|
||||
[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity"
|
||||
pass "portable USB rules retain every device identity attribute"
|
||||
|
||||
# A canonical escaped name cannot cause its contents to become attributes.
|
||||
name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"'
|
||||
hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces"
|
||||
expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"'
|
||||
[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data"
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi
|
||||
if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi
|
||||
pass "USB portable parsing preserves escaped and hostile descriptor text"
|
||||
|
||||
# Exercise the actual root entrypoint startup boundary without root work.
|
||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
||||
-e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \
|
||||
"$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry"
|
||||
chmod 755 "$scratch/entry"
|
||||
printf 'set -p\n' >"$scratch/decoy"
|
||||
if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi
|
||||
printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup"
|
||||
if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi
|
||||
if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi
|
||||
[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code"
|
||||
pass "USB privileged approval rejects unsafe Bash startup"
|
||||
|
||||
if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then
|
||||
skip "native USBGuard matcher requires g++ and USBGuard development headers"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$device" >"$scratch/device"
|
||||
printf '%s\n' "$portable" >"$scratch/policy"
|
||||
g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match"
|
||||
"$scratch/match" "$scratch/policy" "$scratch/device"
|
||||
Executable
+1002
File diff suppressed because it is too large.
Load diff
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
machine=12345678901234567890123456789012
|
||||
export calls="$scratch/calls"
|
||||
: >"$calls"
|
||||
|
||||
# Optional integration run uses real sbctl keys and sbattach in isolated paths.
|
||||
sbctl() {
|
||||
printf '%s\n' "$1" >>"$calls"
|
||||
[[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1
|
||||
[[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == verify ]]; then
|
||||
jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
sbattach() {
|
||||
if [[ -n ${USB_TEST_SBATTACH:-} ]]; then
|
||||
"$USB_TEST_SBATTACH" "$@"
|
||||
fi
|
||||
}
|
||||
omarchy-cmd-missing() { return 1; }
|
||||
limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; }
|
||||
usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; }
|
||||
# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite.
|
||||
usb_authorization_verify_bootloader() { return 0; }
|
||||
|
||||
fixture() {
|
||||
local embedded="$1" hash digest image_base
|
||||
esp="$scratch/case-$RANDOM"
|
||||
history="$esp/$machine/limine_history"
|
||||
manifest="$history/snapshots.json"
|
||||
cache="$esp/cache.json"
|
||||
config="$esp/limine.conf"
|
||||
mkdir -p "$history"
|
||||
printf '%s\0' "$embedded" >"$esp/cmdline"
|
||||
cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi"
|
||||
image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}')
|
||||
objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
sbctl sign "$esp/image.efi" >/dev/null
|
||||
fi
|
||||
digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *}
|
||||
old_name="test.efi_sha256_$digest"
|
||||
cp "$esp/image.efi" "$history/$old_name"
|
||||
hash=$(b2sum "$esp/image.efi"); hash=${hash%% *}
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \
|
||||
"$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config"
|
||||
jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" '
|
||||
def kernel:
|
||||
{imageDetails: [{fileHashName: $name, fileName: "test.efi",
|
||||
snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash,
|
||||
filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}],
|
||||
cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}],
|
||||
allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline],
|
||||
allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []};
|
||||
{jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]}
|
||||
' >"$manifest"
|
||||
cp "$manifest" "$manifest.old"
|
||||
cp "$manifest" "$cache"
|
||||
cp "$manifest" "$esp/before.json"
|
||||
cp "$config" "$esp/before.conf"
|
||||
: >"$calls"
|
||||
}
|
||||
|
||||
for expected in enabled disabled; do
|
||||
old='quiet'
|
||||
[[ $expected != disabled ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
[[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies"
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged"
|
||||
digest=$(sha256sum "$history/$new_name")
|
||||
[[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash"
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
embedded=$(tr '\0' '\n' <"$esp/section")
|
||||
wanted=quiet
|
||||
[[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0'
|
||||
[[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy"
|
||||
objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before"
|
||||
objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after"
|
||||
cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests"
|
||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$file"
|
||||
# Both cached and structured restore representations must regenerate valid entries.
|
||||
for format in cached structured; do
|
||||
{
|
||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine"
|
||||
if [[ $format == cached ]]; then
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file"
|
||||
else
|
||||
printf 'comment: kernel-id=linux\n'
|
||||
jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file"
|
||||
fi
|
||||
} >"$esp/regenerated.conf"
|
||||
usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify"
|
||||
done
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot"
|
||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
||||
[[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive"
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
usb_authorization_verify_signature "$history/$new_name"
|
||||
fi
|
||||
pass "USB $expected transition updates signed history and all cached/structured references"
|
||||
done
|
||||
|
||||
fixture quiet
|
||||
old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *}
|
||||
printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config"
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content"
|
||||
pass "USB archive preflight preserves unchanged current-image hashes"
|
||||
|
||||
fixture 'quiet rd.test="two spaces"'
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
||||
[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace"
|
||||
pass "USB archive policy changes preserve unrelated embedded arguments and executable code"
|
||||
|
||||
for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do
|
||||
fixture quiet
|
||||
if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then
|
||||
fail "$failure must prevent publication"
|
||||
fi
|
||||
cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu"
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest"
|
||||
done
|
||||
cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image"
|
||||
done
|
||||
pass "USB archive signing, verification and enrollment failures preserve original references"
|
||||
|
||||
fixture quiet
|
||||
printf corrupt >>"$history/$old_name"
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then
|
||||
fail "corrupt original archive must never be signed"
|
||||
fi
|
||||
[[ ! -s $calls ]] || fail "hash failure must occur before signing"
|
||||
cmp "$config" "$esp/before.conf"
|
||||
SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
||||
[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images"
|
||||
pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems"
|
||||
|
||||
if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
fixture quiet
|
||||
if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then
|
||||
fail "a false-success sbctl result cannot authorize signing"
|
||||
fi
|
||||
! grep -qx sign "$calls" || fail "an unsigned original must not be resigned"
|
||||
fi
|
||||
for missing in menu manifest; do
|
||||
fixture quiet
|
||||
if [[ $missing == menu ]]; then
|
||||
sed -i '/cmdline:/d' "$config"
|
||||
else
|
||||
jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified"
|
||||
mv "$esp/modified" "$manifest"
|
||||
fi
|
||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes"
|
||||
usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine"
|
||||
usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line"
|
||||
done
|
||||
for section in .profile .pcrsig; do
|
||||
fixture quiet
|
||||
printf protected >"$esp/protected"
|
||||
objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null
|
||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then
|
||||
fail "a $section image must require its original builder"
|
||||
fi
|
||||
grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error"
|
||||
[[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned"
|
||||
done
|
||||
pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements"
|
||||
|
||||
# Package hooks use this same lock. Installing the optional signature tool
|
||||
# must finish before preparation takes the lock, while sync must run under it.
|
||||
fixture quiet
|
||||
prepare=$(declare -f usb_authorization_prepare_archived_images)
|
||||
prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock}
|
||||
eval "$prepare"
|
||||
omarchy-cmd-missing() { return 0; }
|
||||
omarchy-pkg-add() {
|
||||
flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock"
|
||||
printf 'package\n' >>"$calls"
|
||||
}
|
||||
limine-snapper-sync() {
|
||||
[[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock"
|
||||
if flock --nonblock "$scratch/preflight.lock" true; then
|
||||
fail "snapshot synchronization must hold the boot lock"
|
||||
fi
|
||||
}
|
||||
usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine"
|
||||
grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives"
|
||||
pass "USB archive preparation installs optional tools before taking Limine's lock"
|
||||
|
||||
# The disposable Lab runs the actual root-only main function with only boot paths
|
||||
# and external build/sync commands redirected into this fixture.
|
||||
if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root"
|
||||
usb_authorization_prepare_archived_images() {
|
||||
usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache"
|
||||
}
|
||||
limine-mkinitcpio() { return 0; }
|
||||
usb_authorization_sync_and_verify() {
|
||||
local file
|
||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
||||
usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1
|
||||
done
|
||||
usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1
|
||||
usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled
|
||||
}
|
||||
for action in enable disable; do
|
||||
old=quiet
|
||||
[[ $action != disable ]] || old+=' usbcore.authorized_default=0'
|
||||
fixture "$old"
|
||||
drop_in="$esp/entry-tool/usb-authorization.conf"
|
||||
limine_defaults="$esp/defaults"
|
||||
limine_conf="$config"
|
||||
boot_path="$esp"
|
||||
printf '# fixture defaults\n' >"$limine_defaults"
|
||||
if [[ $action == disable ]]; then
|
||||
mkdir -p "${drop_in%/*}"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
||||
fi
|
||||
cp "$limine_defaults" "$esp/defaults.before"
|
||||
if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then
|
||||
fail "$action must stop if archive preparation fails"
|
||||
fi
|
||||
cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes"
|
||||
if [[ $action == enable ]]; then
|
||||
[[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting"
|
||||
else
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting"
|
||||
fi
|
||||
(usb_authorization_main "$action")
|
||||
if [[ $action == enable ]]; then
|
||||
[[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy"
|
||||
else
|
||||
[[ ! -e $drop_in ]] || fail "successful disable clears the boot policy"
|
||||
fi
|
||||
pass "USB $action main path prepares archives before changing boot policy"
|
||||
done
|
||||
fi
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
usb_authorization_secure_boot_enabled() { return 0; }
|
||||
usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; }
|
||||
|
||||
# Real PE enrollment always runs. An optional isolated sbctl configuration also
|
||||
# exercises real signatures; default runs track signed content without host keys.
|
||||
sbctl() {
|
||||
if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then
|
||||
return 1
|
||||
fi
|
||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
||||
elif [[ $1 == sign ]]; then
|
||||
sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed"
|
||||
elif [[ $1 == verify ]]; then
|
||||
local digest signed=0
|
||||
digest=$(sha256sum "${!#}")
|
||||
if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi
|
||||
jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]'
|
||||
fi
|
||||
}
|
||||
|
||||
fixture() {
|
||||
case_dir=$(mktemp -d "$scratch/case.XXXXXXXX")
|
||||
boot_path="$case_dir/boot"
|
||||
cache="$case_dir/cache"
|
||||
limine_conf="$boot_path/limine.conf"
|
||||
limine_defaults="$case_dir/defaults"
|
||||
drop_in="$case_dir/dropins/usb-authorization.conf"
|
||||
image="$boot_path/EFI/limine/limine_x64.efi"
|
||||
mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}"
|
||||
printf 'initial menu\n' >"$limine_conf"
|
||||
printf 'initial kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'initial snapshot\n' >"$cache/lss/snapshots.json"
|
||||
printf 'initial defaults\n' >"$limine_defaults"
|
||||
: >"$scratch/signed"
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes ]]; then
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet
|
||||
fi
|
||||
sbctl sign "$image" >/dev/null
|
||||
cp "$image" "$case_dir/original.efi"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
}
|
||||
|
||||
# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign,
|
||||
# then mask failure with a successful final sync. No real ESP is ever touched.
|
||||
limine-enroll-config() {
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then
|
||||
local digest
|
||||
digest=$(b2sum "$limine_conf")
|
||||
limine enroll-config "$image" "${digest%% *}" --quiet || return 1
|
||||
fi
|
||||
sbctl sign "$image" || true
|
||||
sync -f "$image"
|
||||
}
|
||||
|
||||
# Relocate the outer real flock into scratch. The runner stands in for the
|
||||
# namespace child and deliberately mutates the same files as rebuild hooks.
|
||||
transaction=$(declare -f usb_authorization_boot_transaction)
|
||||
transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock}
|
||||
eval "$transaction"
|
||||
usb_authorization_run_transaction() {
|
||||
if flock --nonblock "$scratch/boot.lock" true; then
|
||||
fail "the real boot lock must cover the entire transaction"
|
||||
fi
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
printf 'changed metadata\n' >"$cache/lss/snapshots.json"
|
||||
printf 'new cached backup\n' >"$cache/lss/snapshots.json.old"
|
||||
mkdir -p "$boot_path/$(</etc/machine-id)/limine_history"
|
||||
printf 'new archived backup\n' >"$boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old"
|
||||
printf 'changed defaults\n' >"$limine_defaults"
|
||||
printf '%s\n' "$setting" >"$drop_in"
|
||||
if [[ ${EARLY_FAILURE:-0} == 1 ]]; then
|
||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
||||
return 1
|
||||
fi
|
||||
if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then
|
||||
limine-enroll-config
|
||||
else
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do
|
||||
fixture
|
||||
if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "$failure must reject the boot transition"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing"
|
||||
[[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu"
|
||||
[[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu"
|
||||
[[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata"
|
||||
[[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old ]] || fail "$failure must remove newly created recovery metadata"
|
||||
[[ $(<"$limine_defaults") == 'initial defaults' && ! -e $drop_in ]] || fail "$failure must restore settings including an absent drop-in"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions restore signed Limine, matching menu, images, metadata and settings on failure"
|
||||
|
||||
fixture
|
||||
printf 'original opt-in\n' >"$drop_in"
|
||||
if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then
|
||||
fail "final verification must reject false-success rebuild hooks"
|
||||
fi
|
||||
cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine"
|
||||
[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in"
|
||||
pass "USB boot transitions independently verify enrollment after external hooks"
|
||||
|
||||
for enrollment in yes no; do
|
||||
export ENROLLMENT="$enrollment"
|
||||
fixture
|
||||
usb_authorization_boot_transaction enable "$cache"
|
||||
[[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit"
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
done
|
||||
pass "USB boot transitions commit verified signing with enrollment enabled or disabled"
|
||||
|
||||
# Disposable Lab only: exercise the actual namespace runner and both lock
|
||||
# inodes, with the installed enrollment/restore functions and real signatures.
|
||||
if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then
|
||||
(( EUID == 0 )) || fail "namespace tests require root in the disposable guest"
|
||||
[[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys"
|
||||
export ENROLLMENT=yes
|
||||
for outcome in failure success; do
|
||||
fixture
|
||||
cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi"
|
||||
tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi
|
||||
lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock)
|
||||
# Source the shipped functions in both the parent and namespace child.
|
||||
sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper"
|
||||
for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do
|
||||
printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper"
|
||||
done
|
||||
declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper"
|
||||
cat >>"$case_dir/helper" <<'SCRIPT'
|
||||
usb_authorization_main() {
|
||||
[[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1
|
||||
exec 9>/run/lock/boot-partition.lock
|
||||
flock --nonblock 9 || return 1
|
||||
printf 'changed menu\n' >"$limine_conf"
|
||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
||||
source /usr/lib/limine/limine-common-functions
|
||||
LIMINE_DIR_PATH="${image%/*}"
|
||||
LIMINE_EFI_FILE=limine_x64.efi
|
||||
BINARY_TARGET_PATH="$image"
|
||||
BINARY_BACKUP_PATH="$case_dir/limine.bak"
|
||||
LIMINE_CONFIG_PATH="$limine_conf"
|
||||
ENABLE_ENROLL_LIMINE_CONFIG=yes
|
||||
is_sb_installed() { return 0; }
|
||||
is_uefi() { return 0; }
|
||||
limine-enroll-config() { enroll_config; }
|
||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
||||
}
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
usb_authorization_boot_transaction "$1" "$cache"
|
||||
fi
|
||||
SCRIPT
|
||||
if [[ $outcome == failure ]]; then
|
||||
if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then
|
||||
fail "actual namespace must reject installed wrapper's false success"
|
||||
fi
|
||||
grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function"
|
||||
cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader"
|
||||
[[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files"
|
||||
else
|
||||
bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1
|
||||
[[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits"
|
||||
fi
|
||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
||||
flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released"
|
||||
done
|
||||
pass "USB real namespace isolates nested locks and recovers installed Limine signing failure"
|
||||
fi
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
# Exercise reset staging in a temporary root with the real offline systemctl.
|
||||
# Never invoke the reset command's main function or any actual disk operations.
|
||||
python - "$ROOT" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
repo = Path(sys.argv[1])
|
||||
source = (repo / 'bin/omarchy-system-factory-reset').read_text()
|
||||
functions = []
|
||||
for name in ['install_provisioning_units', 'scrub_factory_accounts', 'sanitize_factory_baseline', 'stage_full_reset']:
|
||||
match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
functions.append(match.group())
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp:
|
||||
scratch = Path(tmp)
|
||||
for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']:
|
||||
root = scratch / scenario
|
||||
top = root / 'top'
|
||||
factory = top / '@factory'
|
||||
for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system',
|
||||
'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']:
|
||||
(factory / rel).mkdir(parents=True, exist_ok=True)
|
||||
(top / '@').mkdir()
|
||||
(top / '@' / 'original-root').touch()
|
||||
(factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n')
|
||||
(factory / 'etc/machine-id').write_text('old-machine-id\n')
|
||||
policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n'
|
||||
(factory / 'etc/usbguard/rules.conf').write_text(policy)
|
||||
unit = factory / 'usr/lib/systemd/system/usbguard.service'
|
||||
enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service')
|
||||
if scenario != 'absent':
|
||||
unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n')
|
||||
if scenario in ['enabled', 'disable-failure']:
|
||||
(factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service')
|
||||
owner = factory / 'usr/bin/omarchy-provision-owner'
|
||||
owner.touch()
|
||||
owner.chmod(0o755)
|
||||
for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']:
|
||||
shutil.copyfile(repo / 'install/provisioning' / name,
|
||||
factory / 'usr/share/omarchy/install/provisioning' / name)
|
||||
harness = '''#!/bin/bash
|
||||
set -euo pipefail
|
||||
TOP_MNT="$1/top"
|
||||
NEXT_NAME=@next
|
||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
||||
LOG_FILE="$1/log"
|
||||
log() { :; }
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
btrfs() {
|
||||
if [[ $1 == subvolume && $2 == snapshot ]]; then
|
||||
cp -a "$3" "$4"
|
||||
elif [[ $1 == property ]]; then
|
||||
:
|
||||
else
|
||||
return 90
|
||||
fi
|
||||
}
|
||||
# Account cleanup has its own real-tool suite; keep this fixture focused on USB.
|
||||
usermod() {
|
||||
[[ $1 == --root && ( $2 == "$TOP_MNT/$NEXT_NAME" || $2 == "$TOP_MNT/@factory" ) &&
|
||||
$3 == --password && $4 == '!' && $5 == root ]]
|
||||
}
|
||||
systemd-id128() { echo new-machine-id; }
|
||||
encrypted_install() { return 1; }
|
||||
rebuild_next_boot() { :; }
|
||||
sync() { :; }
|
||||
systemctl() {
|
||||
# All service changes must address the staged root, never the live machine.
|
||||
[[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91
|
||||
[[ $2 != "${FAIL_OPERATION:-}" ]] || return 92
|
||||
command systemctl "$@"
|
||||
}
|
||||
''' + '\n\n'.join(functions) + '\nstage_full_reset\n'
|
||||
runner = root / 'run'
|
||||
runner.write_text(harness)
|
||||
env = os.environ.copy()
|
||||
env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else ''
|
||||
result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True)
|
||||
if scenario == 'disable-failure':
|
||||
assert result.returncode != 0, 'failed disable must abort reset staging'
|
||||
assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root'
|
||||
assert 'could not disable USBGuard' in result.stderr
|
||||
else:
|
||||
assert result.returncode == 0, result.stderr
|
||||
staged = top / '@'
|
||||
assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment'
|
||||
assert (staged / 'var/lib/omarchy/provisioning/pending').exists()
|
||||
assert (staged / 'etc/usbguard/rules.conf').read_text() == policy
|
||||
if scenario == 'enabled':
|
||||
assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled'
|
||||
# The owner-enrollment helper enables the daemon after policy and
|
||||
# ACL setup. Confirm an ordinary enable can reverse this disable.
|
||||
subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'],
|
||||
check=True, capture_output=True, text=True)
|
||||
assert (staged / enabled_path).is_symlink()
|
||||
print('ok - factory USB enrollment staging: ' + scenario)
|
||||
PY
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
stub_bin="$test_tmp/bin"
|
||||
lib_path="$test_tmp/lib/voxtype"
|
||||
calls="$test_tmp/calls.log"
|
||||
mkdir -p "$stub_bin" "$lib_path"
|
||||
|
||||
for command in gum omarchy-pkg-drop omarchy-pkg-add hyprctl omarchy-restart-shell omarchy-notification-send systemctl; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command"
|
||||
done
|
||||
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/omarchy-pkg-present"
|
||||
# Voxtype is already installed, so the install asks nothing and only sets it up.
|
||||
printf '#!/bin/bash\nexit 1\n' >"$stub_bin/omarchy-cmd-missing"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$stub_bin/omarchy-dictation-use"
|
||||
cat >"$stub_bin/omarchy-hw-vulkan" <<'SH'
|
||||
#!/bin/bash
|
||||
(( ${VULKAN:-1} == 1 ))
|
||||
SH
|
||||
cat >"$stub_bin/voxtype" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'voxtype %s\n' "$*" >>"$TEST_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin"/*
|
||||
|
||||
run_install() {
|
||||
local vulkan=$1 backend=$2
|
||||
|
||||
: >"$calls"
|
||||
rm -rf "$test_tmp/home" "$lib_path/voxtype-vulkan"
|
||||
mkdir -p "$test_tmp/home"
|
||||
case $backend in
|
||||
executable)
|
||||
printf '#!/bin/bash\n' >"$lib_path/voxtype-vulkan"
|
||||
chmod +x "$lib_path/voxtype-vulkan"
|
||||
;;
|
||||
unusable)
|
||||
: >"$lib_path/voxtype-vulkan"
|
||||
;;
|
||||
esac
|
||||
|
||||
HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" OMARCHY_VOXTYPE_LIB_PATH="$lib_path" \
|
||||
VULKAN="$vulkan" TEST_LOG="$calls" PATH="$stub_bin:$PATH" \
|
||||
XDG_CONFIG_HOME="$test_tmp/home/.config" bash "$ROOT/bin/omarchy-install-dictation-voxtype" >/dev/null ||
|
||||
fail "Voxtype install completes (vulkan=$vulkan backend=$backend)"
|
||||
|
||||
grep -Fx 'voxtype setup systemd' "$calls" >/dev/null ||
|
||||
fail "Voxtype install sets up the user service (vulkan=$vulkan backend=$backend)"
|
||||
}
|
||||
|
||||
run_install 1 executable
|
||||
grep -Fx 'voxtype setup gpu --enable' "$calls" >/dev/null ||
|
||||
fail "Voxtype enables the GPU backend when Vulkan and the backend binary are present"
|
||||
pass "Voxtype enables the GPU backend when Vulkan and the backend binary are present"
|
||||
|
||||
run_install 1 missing
|
||||
! grep -q 'setup gpu' "$calls" ||
|
||||
fail "Voxtype does not enable a Vulkan backend the package did not ship"
|
||||
pass "Voxtype does not enable a Vulkan backend the package did not ship"
|
||||
|
||||
run_install 1 unusable
|
||||
! grep -q 'setup gpu' "$calls" ||
|
||||
fail "Voxtype does not enable a Vulkan backend that is not executable"
|
||||
pass "Voxtype does not enable a Vulkan backend that is not executable"
|
||||
|
||||
run_install 0 executable
|
||||
! grep -q 'setup gpu' "$calls" ||
|
||||
fail "Voxtype does not enable the GPU backend without a Vulkan runtime"
|
||||
pass "Voxtype does not enable the GPU backend without a Vulkan runtime"
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
home="$test_dir/home"
|
||||
migration="$ROOT/migrations/1791470456.sh"
|
||||
mkdir -p "$test_dir/bin"
|
||||
call_log="$test_dir/shell-calls"
|
||||
cat >"$test_dir/bin/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$CALL_LOG"
|
||||
SH
|
||||
chmod +x "$test_dir/bin/omarchy-shell"
|
||||
|
||||
reset_home() {
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home/.local/state/omarchy/current/theme/backgrounds" "$home/.local/state/omarchy/theme-backgrounds"
|
||||
: >"$call_log"
|
||||
}
|
||||
|
||||
run_migration() {
|
||||
HOME="$home" OMARCHY_PATH="$ROOT" CALL_LOG="$call_log" PATH="$test_dir/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >"$test_dir/output"
|
||||
}
|
||||
|
||||
backgrounds="$home/.local/state/omarchy/current/theme/backgrounds"
|
||||
current="$home/.local/state/omarchy/current/background"
|
||||
remembered="$home/.local/state/omarchy/theme-backgrounds/tokyo-night"
|
||||
remembered_png="$home/.local/state/omarchy/theme-backgrounds/catppuccin"
|
||||
|
||||
reset_home
|
||||
touch "$backgrounds/current.webp"
|
||||
ln -s "$backgrounds/current.jpg" "$current"
|
||||
printf '%s\n' "$backgrounds/2-swirl-buck.jpg" >"$remembered"
|
||||
printf '%s\n' "$backgrounds/omarchy.png" >"$remembered_png"
|
||||
run_migration
|
||||
[[ $(readlink "$current") == "$backgrounds/current.webp" ]] || fail "the current background moves from the removed JPEG to its WebP replacement"
|
||||
[[ $(<"$call_log") == "-q background set $backgrounds/current.webp" ]] || fail "the running shell is told about the repaired current background"
|
||||
[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "an inactive theme's remembered JPEG moves to its bundled WebP replacement"
|
||||
[[ $(<"$remembered_png") == "$backgrounds/omarchy.webp" ]] || fail "an inactive theme's remembered PNG moves to its bundled WebP replacement"
|
||||
pass "converted bundled backgrounds keep their current and remembered selections"
|
||||
|
||||
run_migration
|
||||
[[ $(readlink "$current") == "$backgrounds/current.webp" ]] || fail "the repair can be rerun"
|
||||
[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "the remembered JPEG repair can be rerun"
|
||||
[[ $(<"$remembered_png") == "$backgrounds/omarchy.webp" ]] || fail "the remembered PNG repair can be rerun"
|
||||
pass "the background repair is idempotent"
|
||||
|
||||
reset_home
|
||||
touch "$backgrounds/kept.jpg" "$backgrounds/kept.webp"
|
||||
ln -s "$backgrounds/kept.jpg" "$current"
|
||||
printf '%s\n' "$backgrounds/kept.jpg" >"$remembered"
|
||||
run_migration
|
||||
[[ $(readlink "$current") == "$backgrounds/kept.jpg" ]] || fail "an existing JPEG background is preserved"
|
||||
[[ $(<"$remembered") == "$backgrounds/kept.jpg" ]] || fail "an existing remembered JPEG is preserved"
|
||||
pass "existing backgrounds are not replaced just because a WebP shares their stem"
|
||||
|
||||
reset_home
|
||||
ln -s "$backgrounds/missing.jpeg" "$current"
|
||||
printf '%s\n' "$backgrounds/missing.png" >"$remembered"
|
||||
run_migration
|
||||
[[ $(readlink "$current") == "$backgrounds/missing.jpeg" ]] || fail "a dangling selection without an exact WebP replacement is preserved"
|
||||
[[ $(<"$remembered") == "$backgrounds/missing.png" ]] || fail "a dangling remembered selection without an exact WebP replacement is preserved"
|
||||
pass "unrelated dangling background paths are left alone"
|
||||
|
||||
reset_home
|
||||
mkdir -p "$home/.config/omarchy/themes/custom/backgrounds"
|
||||
touch "$backgrounds/cover.webp" "$home/.config/omarchy/themes/custom/backgrounds/cover.jpg"
|
||||
custom_remembered="$home/.local/state/omarchy/theme-backgrounds/custom"
|
||||
printf '%s\n' "$backgrounds/cover.jpg" >"$custom_remembered"
|
||||
run_migration
|
||||
[[ $(<"$custom_remembered") == "$backgrounds/cover.jpg" ]] || fail "an inactive custom theme's existing JPEG is not confused with the active theme's WebP"
|
||||
pass "remembered selections are checked against their named theme"
|
||||
|
||||
reset_home
|
||||
touch "$backgrounds/2-swirl-buck.jpg"
|
||||
printf '%s\n' "$backgrounds/2-swirl-buck.jpg" >"$remembered"
|
||||
run_migration
|
||||
[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "an active-theme filename collision does not hide an inactive theme's WebP replacement"
|
||||
pass "active theme files do not control inactive remembered repairs"
|
||||
|
||||
reset_home
|
||||
printf 'tokyo-night\n' >"$home/.local/state/omarchy/current/theme.name"
|
||||
touch "$backgrounds/2-swirl-buck.jpg"
|
||||
ln -s "$backgrounds/2-swirl-buck.jpg" "$current"
|
||||
run_migration
|
||||
[[ -f $backgrounds/2-swirl-buck.webp ]] || fail "the active staged theme receives its bundled WebP replacement"
|
||||
[[ $(readlink "$current") == "$backgrounds/2-swirl-buck.webp" ]] || fail "a selected staged JPEG moves before a later refresh can strand it"
|
||||
pass "the active staged theme is repaired before its old bundled image disappears"
|
||||
@@ -20,6 +20,15 @@ export OMARCHY_WINDOWS_DIR="$TMPDIR/win"
|
||||
export HOME="$TMPDIR/home"
|
||||
mkdir -p "$HOME"
|
||||
|
||||
# The command refuses any CPU but x86_64 before defining anything, and sourcing
|
||||
# it there would exit this test with it. What is under test is the x86_64 path,
|
||||
# so omarchy-hw-x86 answers as one.
|
||||
STUB_BIN="$TMPDIR/bin"
|
||||
mkdir -p "$STUB_BIN"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$STUB_BIN/omarchy-hw-x86"
|
||||
chmod +x "$STUB_BIN/omarchy-hw-x86"
|
||||
PATH="$STUB_BIN:$PATH"
|
||||
|
||||
set -- help
|
||||
source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1
|
||||
COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml"
|
||||
|
||||
@@ -30,6 +30,13 @@ mount -t tmpfs -o uid=0,gid=0,mode=0710,size=1g home-alice /home/alice
|
||||
|
||||
export HOME=/home/alice
|
||||
unset OMARCHY_WINDOWS_DIR
|
||||
# The command refuses any CPU but x86_64 before defining anything, and sourcing
|
||||
# it there would exit this test with it. What is under test is the x86_64 path,
|
||||
# so omarchy-hw-x86 answers as one.
|
||||
mkdir -p "$test_tmp/bin"
|
||||
printf '#!/bin/bash\nexit 0\n' >"$test_tmp/bin/omarchy-hw-x86"
|
||||
chmod +x "$test_tmp/bin/omarchy-hw-x86"
|
||||
PATH="$test_tmp/bin:$PATH"
|
||||
set -- help
|
||||
source "$test_tmp/omarchy-windows-vm" >/dev/null 2>&1
|
||||
|
||||
|
||||
Reference in new issue
Block a user