Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ca93c42
A touchscreen drives the pointer: a tap is a click, a drag a drag
DevomB Oct 23, 2026
db8f04f
The hardware report says what woke a sleep, which TPM and clock drive…
DevomB Oct 23, 2026
0463118
The guide says how a laptop gets its country's Wi-Fi rules, and which…
DevomB Oct 23, 2026
cf07926
The report ranks wakeup sources by their events, and says what amd-de…
DevomB Oct 23, 2026
c9d7a95
The guide's AMD sleep note says what AMD's checker asks for, and no more
DevomB Oct 23, 2026
833fad4
The software watchdog is not fed on a machine that sleeps to idle
DevomB Oct 23, 2026
6354761
The guide's country steps give iw by its path, and name radios that k…
DevomB Oct 23, 2026
a9751e6
su gives root a PATH with /usr/sbin
DevomB Oct 23, 2026
cf733df
The guide's country steps drop the path note #639 makes untrue, and k…
DevomB Oct 23, 2026
f8b7d3d
A camera still held from its last grant is refused, with the reason, …
DevomB Oct 23, 2026
aa1cd46
Merge hwreport-sleep-tpm-clock (#634): the hardware report says what …
DevomB Oct 23, 2026
02398c0
Merge guide-wifi-country-firmware (#636): the guide says how a laptop…
DevomB Oct 23, 2026
94e4efe
Merge root-path-sbin (#639): su gives root a PATH with /usr/sbin
DevomB Oct 23, 2026
76e122c
The state suite's watchdog reset runs with S3, and the idle-sleep rul…
DevomB Oct 23, 2026
3bc33d2
Merge camera-held-refused (#640): a camera still held from its last g…
DevomB Oct 23, 2026
fcd8451
state-p5b's idle-sleep check also needs Intel's timer armed in the sa…
DevomB Oct 23, 2026
d8fdf18
state-p5b's failure message names both halves of its check
DevomB Oct 23, 2026
a7bf2f1
The held-camera test also covers uvcvideo unloaded
DevomB Oct 23, 2026
c796c91
Merge camera-held-refused (#640): its test covers uvcvideo unloaded too
DevomB Oct 23, 2026
3aad9b6
console-dead-port.sh quotes the word its stand-in writes
DevomB Oct 23, 2026
95dc924
Merge console-test-sc1010 (#642): console-dead-port.sh quotes the wor…
DevomB Oct 23, 2026
0724b5d
A tap's press goes to the window tapped
DevomB Oct 23, 2026
e453db0
No touch acts through a shut lid or on a panel that is off
DevomB Oct 23, 2026
e594f80
Merge softdog-not-fed-s2idle (#638): a machine that sleeps to idle le…
DevomB Oct 23, 2026
d2597d4
Merge dwl-touch-pointer (#628): a touchscreen drives the pointer, a t…
DevomB Oct 23, 2026
004b992
The scale docs say what a zone is told, and what auto gives back
DevomB Oct 23, 2026
02018b3
Merge scale-docs-fractional (#643): the docs say a zone's window gets…
DevomB Oct 23, 2026
9ae3a44
A laptop woken at the passphrase prompt powers off at 3% instead of r…
DevomB Oct 23, 2026
87e4652
The power service leaves a sealed machine's 3% power-off to kryptikd,…
DevomB Oct 23, 2026
618389a
The report's sleep and camera lines are functions of their own
DevomB Oct 23, 2026
791333f
Merge sealed-battery-off (#645): a battery near empty at the passphra…
DevomB Oct 23, 2026
1104eb0
Merge hwreport-sleep-tpm-clock (#634): the report's laptop lines spli…
DevomB Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions build/guest-tests/gui-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
# GT CONSENT-CODE 1 NN type NN and Enter (the question's code)
# GT CONSENT-WAIT 2 type y and Enter (not the code: refused)
# GT POINTER-ZONE0, GT POINTER-UNTRUSTED move the pointer onto the newest window
# GT TOUCH-HOVER-ZONE0 move the pointer right, onto zone 0's window
# GT TOUCH-UNTRUSTED tap the screen's left half (the newest window)
# GT SCREENSHOT-POPUP take a screenshot
# GT KEY-MENU-POPUP press Alt+p (the chrome menu)
# GT KEY-FOCUS-POPUP press Alt+j
Expand Down Expand Up @@ -1184,6 +1186,50 @@ if since_mark cursor untrusted | grep -q 'asked for the text cursor by name' &&
else
fail "zone-cursor-by-name" "$(since_mark cursor untrusted | tail -4 | tr '\n' ' ')"
fi
# --- a touchscreen drives the pointer -------------------------------------------------------
# dwl takes a touchscreen as the pointer, so a tap is a click: it gives a window the keyboard as
# a click does, and zones are offered no touch. The VM's touchscreen is QEMU's virtio-multitouch
# (run-ovmf.sh --touch). untrusted's window maps beside zone 0's, unasked, where dwl puts the
# newest: the screen's left half, which the host taps.
stop_zone untrusted
as_user "/usr/libexec/kryptik/wlprobe cursor 40" > "$LOG/touch-zone0.out" 2>&1 &
touch0=$!
wait_for 20 grep -q committed "$LOG/touch-zone0.out"
wait_for 10 grep -qx 'zone=0' "$RT/kryptik/focus"
mark touch untrusted
launch_plain untrusted "/usr/libexec/kryptik/wlprobe cursor 30" > "$LOG/launch-touch.out" 2>&1
wait_for 20 probe_committed touch
sleep 2
# The pointer rests on zone 0's window, the right half, when the tap comes: the press must
# reach the window tapped, not the one under the pointer. zone 0's probe says each entry.
entered="$(grep -c 'set a ' "$LOG/touch-zone0.out")"
zone0_entered() { [[ "$(grep -c 'set a ' "$LOG/touch-zone0.out")" -gt "$entered" ]]; }
echo "GT TOUCH-HOVER-ZONE0"
wait_for 10 zone0_entered
hovered="$(grep -c 'set a ' "$LOG/touch-zone0.out")"
touch_before="$(grep '^zone=' "$RT/kryptik/focus" 2>/dev/null)"
echo "GT TOUCH-UNTRUSTED"
if ! grep -q 'Name="QEMU Virtio MultiTouch"' /proc/bus/input/devices; then
fail "touch-picks-window" "no touchscreen in this guest: $(grep '^N: ' /proc/bus/input/devices | tr '\n' ' ')"
elif [[ "$touch_before" != "zone=0" ]]; then
fail "touch-picks-window" "zone 0's window did not have the keyboard before the tap: ${touch_before:-no focus record}"
elif wait_for 10 grep -qx 'zone=untrusted' "$RT/kryptik/focus"; then
pass "touch-picks-window" "a tap on untrusted's window gave it the keyboard, as a click does"
else
fail "touch-picks-window" "after the tap: $(tr '\n' ' ' < "$RT/kryptik/focus" 2>/dev/null); $(since_mark touch untrusted | tail -2 | tr '\n' ' ')"
fi
touch_pressed() { since_mark touch untrusted | grep -q 'pointer button 272 released'; }
if [[ "$hovered" -le "$entered" ]]; then
fail "touch-presses-window" "the pointer never came onto zone 0's window before the tap, so nothing was shown: $(tail -3 "$LOG/touch-zone0.out" | tr '\n' ' ')"
elif ! wait_for 10 touch_pressed; then
fail "touch-presses-window" "untrusted's window was sent no press and release: $(since_mark touch untrusted | tail -3 | tr '\n' ' '); zone 0's: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')"
elif grep -q 'pointer button' "$LOG/touch-zone0.out"; then
fail "touch-presses-window" "the tap's button also went to zone 0's window, where the pointer rested: $(grep 'pointer button' "$LOG/touch-zone0.out" | tr '\n' ' ')"
else
pass "touch-presses-window" "the tap's press and release went to untrusted's window, and none to zone 0's, under the pointer before"
fi
pkill -u "$USER_NAME" -f 'wlprobe cursor 40' 2>/dev/null; wait "$touch0" 2>/dev/null

# --- a zone's window hears the scale it is shown at ------------------------------------------
# wp_fractional_scale_v1 through the proxy: the compositor tells each window its output's scale,
# in 120ths, so a browser draws text sharp at 1.5 instead of being scaled down from 2. This
Expand Down
28 changes: 18 additions & 10 deletions build/recipes/etc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@
# etc: a stage 04 recipe, sourced by build/stages/04-base-system.sh,
# which runs it in the order its list gives.

# etc_login_defs FILE: lines set in shadow's login.defs, not left to the file
# a release ships. login reads the terminals root may use from CONSOLE's file.
# su gives root ENV_SUPATH's PATH with or without "-", and shadow's step strips
# /usr/sbin from it, where ip, iw and cryptsetup live.
etc_login_defs() {
local def
for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes" "ENV_SUPATH PATH=/usr/sbin:/usr/bin"; do
if grep -q "^${def%% *}[[:space:]]" "$1"; then
sed -i "s|^${def%% *}[[:space:]].*|${def}|" "$1"
else
printf '%s\n' "$def" >> "$1"
fi
done
}

# /etc/os-release and friends. BUILD_ID, the commit that built the image, is
# added after the steps (at the end of this file): as this step's input it
# would re-fingerprint this step, and every step after it, on each commit.
Expand Down Expand Up @@ -51,18 +66,11 @@ EOF
usermod -p '*' root
grep -q '^root:\*:' /etc/shadow && echo "root: no password" || { echo "FAIL: root has a password in the image"; return 1; }
: > /etc/securetty
# Both set here, not left to shadow's login.defs, which a release may
# change: login reads the terminals root may use from CONSOLE's file.
local def
for def in "CONSOLE /etc/securetty" "SU_WHEEL_ONLY yes"; do
if grep -q "^${def%% *}[[:space:]]" /etc/login.defs; then
sed -i "s|^${def%% *}[[:space:]].*|${def}|" /etc/login.defs
else
printf '%s\n' "$def" >> /etc/login.defs
fi
done
etc_login_defs /etc/login.defs
grep -qx 'CONSOLE /etc/securetty' /etc/login.defs && [[ ! -s /etc/securetty ]] \
|| { echo "FAIL: root is not kept off the terminals"; return 1; }
grep -qx 'ENV_SUPATH PATH=/usr/sbin:/usr/bin' /etc/login.defs \
|| { echo "FAIL: su would give root a PATH without /usr/sbin"; return 1; }

# Kernel interface names (eth0, wlan0), the same on every machine: eudev's
# slot-naming rule is masked.
Expand Down
14 changes: 13 additions & 1 deletion build/service-scripts/power.sh
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ if [ "${1:-}" = --once ]; then
exit 0
fi

# The state partition waits for its passphrase, read with builtins alone: a program started now
# can wait on it. Then kryptikd keeps the 3% rule (suspend.rs), as neither the zones' stop nor
# the shutdown's sync and unmounts could finish.
sealed() {
local d n s
for d in "$SYS"/block/dm-*/dm; do
read -r n 2>/dev/null < "$d/name" && [ "$n" = kryptik-state ] || continue
read -r s 2>/dev/null < "$d/suspended" && [ "$s" = 1 ] && return 0
done
return 1
}

# Each running zone stopped as `kryptikd stop` does, its launcher closing its volume, then off.
shut_down() {
local z
Expand Down Expand Up @@ -158,7 +170,7 @@ while :; do
fi
# Twice running, so one odd reading does not end the session.
if [ "$LOW" = yes ]; then low=$((low + 1)); else low=0; fi
if [ "$low" -ge 2 ]; then
if [ "$low" -ge 2 ] && ! sealed; then
echo "power: the battery is at ${CHARGE}%: stopping the zones and powering off before it runs out"
shut_down
fi
Expand Down
7 changes: 5 additions & 2 deletions build/service-scripts/watchdog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ set -u
# AMD's TCO timer counts on through a sleep to idle (s2idle): its driver does not stop it for
# one, as Intel's does, and the ticks that would feed it stop. Fed, it would reset a machine
# that sleeps that way a minute into each sleep, so there it is left unopened, and stopped as
# its driver leaves it. In ACPI's own sleep states the firmware stops it.
# its driver leaves it. In ACPI's own sleep states the firmware stops it. The software
# watchdog likewise: its clock runs whenever the s2idle loop wakes for an event that wakes
# nothing, amd_pmc's 2.5 s pause after each deepest sleep included, so a night of an EC's
# battery and charger events would spend its minute and reboot the sleeping machine.
sleeps_to_idle() { grep -q '\[s2idle\]' /sys/power/mem_sleep 2>/dev/null; }
fds=()
for dev in /dev/watchdog[0-9]*; do
[ -c "$dev" ] || continue
name="${dev##*/}"
id="$(cat "/sys/class/watchdog/${name}/identity" 2>/dev/null || echo unknown)"
if [ "$id" = "SP5100 TCO timer" ] && sleeps_to_idle; then
if { [ "$id" = "SP5100 TCO timer" ] || [ "$id" = "Software Watchdog" ]; } && sleeps_to_idle; then
echo "watchdog: not feeding ${dev} (${id}): it would count through this machine's sleep to idle"
continue
fi
Expand Down
11 changes: 11 additions & 0 deletions compartments/kryptikd/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ fn read_trim(p: &Path) -> Option<String> {
fs::read_to_string(p).ok().map(|s| s.trim().to_string())
}

/// Whether a program kept the camera from an earlier grant: its descriptor holds the driver in
/// `sys`, and with it the streaming interface of the device in `LET_GO`, which, probed again,
/// would come back with no capture node.
fn kept(sys: &Path, run: &Path) -> bool {
run.join(LET_GO).exists()
&& read_trim(&sys.join("module").join(DRIVER).join("refcnt")).is_some_and(|n| n != "0")
}

/// The camera's node in `sys`, standing for /sys: of the video4linux devices that are the first
/// node of their device (`index` 0, a UVC camera's capture node and not its metadata one), the
/// lowest numbered; its name, major and minor, and what the device calls itself.
Expand Down Expand Up @@ -240,6 +248,9 @@ pub fn grant(zone: &crate::zone::Zone) -> Result<Grant, String> {
end(g)?;
}
let dir = zone_dir(&zone.name).map_err(|e| format!("{RUN_DIR}/{}: {e}", zone.name))?;
if kept(Path::new("/sys"), Path::new(RUN_DIR)) {
return Err("a program still holds the camera from its last grant: turn the camera off there, then give it again".into());
}
modprobe(&[DRIVER])?;
let _ = fs::remove_file(Path::new(RUN_DIR).join(STAYED));
if let Ok(text) = fs::read_to_string(Path::new(RUN_DIR).join(LET_GO)) {
Expand Down
17 changes: 17 additions & 0 deletions compartments/kryptikd/src/camera/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,20 @@ fn names_from_sysfs_are_one_component() {
assert!(!is_sys_name(bad), "{bad:?}");
}
}

#[test]
fn a_camera_still_held_is_not_taken_again() {
let s = sys("kept");
let run = s.join("run");
fs::create_dir_all(&run).unwrap();
fs::create_dir_all(s.join("module").join(DRIVER)).unwrap();
fs::write(s.join("module").join(DRIVER).join("refcnt"), "1\n").unwrap();
assert!(!kept(&s, &run), "held, but nothing to take again");
fs::write(run.join(LET_GO), "usb 3-6:1.0\n").unwrap();
assert!(kept(&s, &run), "held, with an interface to take again");
fs::write(s.join("module").join(DRIVER).join("refcnt"), "0\n").unwrap();
assert!(!kept(&s, &run), "let go of");
fs::remove_dir_all(s.join("module").join(DRIVER)).unwrap();
assert!(!kept(&s, &run), "unloaded");
let _ = fs::remove_dir_all(&s);
}
34 changes: 33 additions & 1 deletion compartments/kryptikd/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,8 @@ fn handle(cfg: &ServeConfig, conn: UnixStream, jobs: &mut Vec<Job>, pauses: &mut

/// How often the login prompt's update notice is checked.
const NOTICE_EVERY: Duration = Duration::from_secs(3600);
/// How often the battery is looked at while the state partition is suspended: power.sh's round.
const SEALED_EVERY: Duration = Duration::from_secs(30);

pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode {
// First, before anything that could read /etc or /var: a daemon started again while the state
Expand Down Expand Up @@ -1977,7 +1979,31 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode {
// The login prompt carries what `kryptik update status` says of a stale statement, on the
// installed system's instance: checked as it starts, then hourly, as days are what count.
let mut notice_due = (!cfg.developer).then(Instant::now);
// From each wake until the passphrase is given, power.sh waits on the state partition, so
// its 3% rule is kept here: a laptop the battery's alarm woke, its lid shut, would
// otherwise run flat at the prompt. A daemon started again at the prompt looks too.
let mut sealed_due = (!cfg.developer && crate::suspend::state_suspended()).then(Instant::now);
let mut sealed_low = 0;
loop {
if sealed_due.is_some_and(|due| Instant::now() >= due) {
sealed_due = None;
// A sleep under way looks again when it ends, the count carried across it: a machine
// put back to sleep within 30 s of each wake still powers off.
if !crate::suspend::state_suspended() {
sealed_low = 0;
} else if !jobs.iter().any(|j| j.what == JobKind::Sleep) {
let (low, off) = crate::suspend::sealed_look(sealed_low, crate::suspend::critical());
sealed_low = low;
sealed_due = Some(Instant::now() + SEALED_EVERY);
if off {
eprintln!("kryptikd serve: the battery is at {}% or under, the state partition suspended: powering off", crate::suspend::CRITICAL);
let e = crate::suspend::power_off();
// Once: the next look would be refused the same way.
eprintln!("kryptikd serve: the machine did not power off: {e}");
sealed_due = None;
}
}
}
if notice_due.is_some_and(|due| Instant::now() >= due) {
// The update state is on the state partition: while that is suspended the notice
// keeps, and the daemon stays free to answer the screen lock.
Expand All @@ -1992,13 +2018,19 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode {
notice_due = Some(Instant::now() + NOTICE_EVERY);
}
reap(&mut pending, &mut jobs, &mut proxies);
let mut slept = false;
jobs.retain_mut(|j| match j.exited {
Some(st) => {
slept |= j.what == JobKind::Sleep;
finish_job(j, st);
false
}
None => true,
});
// Woken or refused, a sleep leaves the state partition suspended.
if slept && !cfg.developer {
sealed_due = Some(Instant::now());
}

// fds[0] is the listener, then one per launch awaiting readiness; timeout: nearest deadline.
let mut fds: Vec<libc::pollfd> = vec![libc::pollfd { fd: listener.as_raw_fd(), events: libc::POLLIN, revents: 0 }];
Expand All @@ -2013,7 +2045,7 @@ pub fn cmd_serve(zones_dir: &Path, args: &[String]) -> ExitCode {
let left = p.deadline().saturating_duration_since(now).as_millis() as i32;
timeout = if timeout < 0 { left } else { timeout.min(left) };
}
if let Some(due) = notice_due {
for due in [notice_due, sealed_due].into_iter().flatten() {
let left = due.saturating_duration_since(now).as_millis().min(i32::MAX as u128) as i32;
timeout = if timeout < 0 { left } else { timeout.min(left) };
}
Expand Down
64 changes: 64 additions & 0 deletions compartments/kryptikd/src/suspend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,70 @@ pub fn sleep() -> Result<(), String> {
.map_err(|e| format!("/sys/power/state: {e}"))
}

/// The charge, in percent, at or under which a battery running down powers the machine off
/// (power.sh's CRITICAL).
pub const CRITICAL: u64 = 3;

fn attr(dir: &Path, name: &str) -> Option<String> {
fs::read_to_string(dir.join(name)).ok().map(|s| s.trim().to_string())
}

/// A battery's charge in percent: its capacity, or the energy or the charge it holds against
/// what it holds full.
fn percent(bat: &Path) -> Option<u64> {
if let Some(c) = attr(bat, "capacity").filter(|c| !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit())) {
return c.parse().ok();
}
["energy", "charge"].iter().find_map(|k| {
let now = attr(bat, &format!("{k}_now"))?.parse::<u64>().ok()?;
let full = attr(bat, &format!("{k}_full"))?.parse::<u64>().ok().filter(|&f| f > 0)?;
Some((now.saturating_mul(100) / full).min(100))
})
}

/// Whether the battery is critical as power.sh judges it, from what `sys` says: the fullest of
/// the machine's own batteries (not a mouse's, scope Device) at or under CRITICAL, and running
/// down: one says it discharges, or none says anything and no adapter is online. power.sh waits
/// on the state partition while it is suspended, as anything started then does, so the daemon
/// keeps the rule meanwhile; the two must agree.
pub fn critical_in(sys: &Path) -> bool {
let Ok(rd) = fs::read_dir(sys.join("class/power_supply")) else { return false };
let all: Vec<(PathBuf, String)> = rd.flatten().filter_map(|e| Some((e.path(), attr(&e.path(), "type")?))).collect();
let mains = all.iter().any(|(d, t)| matches!(t.as_str(), "Mains" | "USB") && attr(d, "online").as_deref() == Some("1"));
let bats: Vec<&PathBuf> = all
.iter()
.filter(|(d, t)| t == "Battery" && attr(d, "scope").as_deref() != Some("Device") && attr(d, "present").is_none_or(|p| p == "1"))
.map(|(d, _)| d)
.collect();
let Some(charge) = bats.iter().filter_map(|d| percent(d)).max() else { return false };
let said: Vec<String> = bats.iter().filter_map(|d| attr(d, "status")).collect();
let draining = said.iter().any(|s| s == "Discharging")
|| (!said.iter().any(|s| matches!(s.as_str(), "Charging" | "Full" | "Not charging")) && !mains);
draining && charge <= CRITICAL
}

pub fn critical() -> bool {
critical_in(Path::new("/sys"))
}

/// One look at the battery while the state partition is suspended, after `low` critical looks
/// in a row: the count now, and whether to power off. Twice running, as power.sh has it, so one
/// odd reading does not end the session.
pub fn sealed_look(low: u32, critical: bool) -> (u32, bool) {
let low = if critical { low + 1 } else { 0 };
(low, low >= 2)
}

/// Off at once, the battery running out with the state partition suspended. Everything was
/// written out before the seal, nothing can be written to the partition now, and a sync, or
/// the shutdown that would stop the zones first, would wait on it for good. Returns only with
/// why the kernel refused.
pub fn power_off() -> std::io::Error {
// SAFETY: reboot takes a command alone, and RB_POWER_OFF does not return when it works.
unsafe { libc::reboot(libc::RB_POWER_OFF) };
std::io::Error::last_os_error()
}

#[derive(Debug)]
pub enum ResumeError {
/// cryptsetup exited 2: no keyslot took the passphrase.
Expand Down
Loading
Loading