commit: 963f7458c1f1702d9f78ced7247e6c6e80b77814
parent 1cf5065eb43fcfe4fce266a995861de32bb290b1
Author: Drew DeVault <sir@cmpwn.com>
Date: Sun, 28 Aug 2022 15:50:00 +0200
powerctl
Diffstat:
1 file changed, 251 insertions(+), 0 deletions(-)
diff --git a/content/blog/powerctl-a-hare-case-study.md b/content/blog/powerctl-a-hare-case-study.md
@@ -0,0 +1,251 @@
+---
+title: "powerctl: A small case study in Hare for systems programming"
+date: 2022-08-28
+---
+
+[powerctl](https://sr.ht/~sircmpwn/powerctl/) is a little weekend project I put
+together to provide a simple tool for managing power states on Linux. I had
+previously put my laptop into suspend with a basic "echo mem | doas tee
+/sys/power/state", but this leaves a lot to be desired. I have to use doas to
+become root, and it's annoying to enter my password — not to mention
+difficult to use in a script or to attach to a key binding. powerctl is the
+solution: a small 500-line Hare program which provides comprehensive support for
+managing power states on Linux for non-privileged users.
+
+This little project ended up being a useful case-study in writing a tight
+systems program in Hare. It has to do a few basic tasks which Hare shines in:
+
+- setuid binaries
+- Group lookup from /etc/group
+- Simple string manipulation
+- Simple I/O within sysfs constraints
+
+Linux documents these features [here][0], so it's a simple matter of rigging it
+up to a nice interface. Let's take a look at how it works.
+
+[0]: https://www.kernel.org/doc/html/latest/admin-guide/pm/sleep-states.html
+
+First, one of the base requirements for this tool is to run as a non-privileged
+user. However, since writing to sysfs requires root, this program will have to
+be setuid, so that it runs as root regardless of who executes it. To prevent any
+user from suspending the system, I added a "power" group and only users who are
+in this group are allowed to use the program. Enabling this functionality in
+Hare is quite simple:
+
+```hare
+use fmt;
+use unix;
+use unix::passwd;
+
+def POWER_GROUP: str = "power";
+
+// Determines if the current user is a member of the power group.
+fn checkgroup() bool = {
+ const uid = unix::getuid();
+ const euid = unix::geteuid();
+ if (uid == 0) {
+ return true;
+ } else if (euid != 0) {
+ fmt::fatal("Error: this program must be installed with setuid (chmod a+s)");
+ };
+
+ const group = match (passwd::getgroup(POWER_GROUP)) {
+ case let grent: passwd::grent =>
+ yield grent;
+ case void =>
+ fmt::fatal("Error: {} power group missing from /etc/group", POWER_GROUP);
+ };
+ defer passwd::grent_finish(&group);
+
+ const gids = unix::getgroups();
+ for (let i = 0z; i < len(gids); i += 1) {
+ if (gids[i] == group.gid) {
+ return true;
+ };
+ };
+
+ return false;
+};
+```
+
+The POWER\_GROUP variable allows distributions that package powerctl to
+configure exactly which group is allowed to use this tool. Following this, we
+compare the uid and effective uid. If the uid is zero, we're already running
+this tool as root, so we move on. Otherwise, if the euid is nonzero, we lack the
+permissions to continue, so we bail out and tell the user to fix their
+installation.
+
+Then we fetch the details for the power group from /etc/group. Hare's standard
+library includes [a module](https://docs.harelang.org/unix/passwd) for working
+with this file. Once we have the group ID from the string, we check the current
+user's supplementary group IDs to see if they're a member of the appropriate
+group. Nice and simple. This is also the only place in powerctl where dynamic
+memory allocation is required, to store the group details, which are freed with
+"defer passwd::grent_finish".
+
+The tool also requires some simple string munging to identify the supported set
+of states. If we look at /sys/power/disk, we can see the kind of data we're
+working with:
+
+```
+$ cat /sys/power/disk
+[platform] shutdown reboot suspend test_resume
+```
+
+These files are a space-separated list of supported states, with the currently
+enabled state enclosed in square brackets. Parsing these files is a simple
+matter for Hare. We start with a simple utility function which reads the file
+and prepares a [string tokenizer](https://docs.harelang.org/strings#tokenize)
+which splits the string on spaces:
+
+```hare
+fn read_states(path: str) (strings::tokenizer | fs::error | io::error) = {
+ static let buf: [512]u8 = [0...];
+
+ const file = os::open(path)?;
+ defer io::close(file)!;
+
+ const z = match (io::read(file, buf)?) {
+ case let z: size =>
+ yield z;
+ case =>
+ abort("Unexpected EOF from sysfs");
+ };
+ const string = strings::rtrim(strings::fromutf8(buf[..z]), '\n');
+ return strings::tokenize(string, " ");
+};
+```
+
+The error handling here warrants a brief note. This function can fail if the
+file does not exist or if there is an I/O error when reading it. I don't think
+that I/O errors are possible in this specific case (they can occur when
+*writing* to these files, though), but we bubble it up regardless using
+"io::read()?". The file might not exist if these features are not supported by
+the current kernel configuration, in which case it's bubbled up as
+"errors::noentry" via "os::open()?". These cases are handled further up the call
+stack. The other potential error site is "io::close", which can fail but only in
+certain circumstances (such as closing the same file twice), and we use the
+error assertion operator ("!") to indicate that the programmer believes this
+case cannot occur. The compiler will check our work and abort at runtime should
+this assumption be proven wrong in practice.
+
+In the happy path, we read the file, trim off the newline, and return a
+tokenizer which splits on spaces. The storage for this string is borrowed from
+"buf", which is statically allocated.
+
+The usage of this function to query supported disk suspend behaviors is here:
+
+```hare
+fn read_disk_states() ((disk_state, disk_state) | fs::error | io::error) = {
+ const tok = read_states("/sys/power/disk")?;
+
+ let states: disk_state = 0, active: disk_state = 0;
+ for (true) {
+ let tok = match (strings::next_token(&tok)) {
+ case let s: str =>
+ yield s;
+ case void =>
+ break;
+ };
+ const trimmed = strings::trim(tok, '[', ']');
+
+ const state = switch (trimmed) {
+ case "platform" =>
+ yield disk_state::PLATFORM;
+ case "shutdown" =>
+ yield disk_state::SHUTDOWN;
+ case "reboot" =>
+ yield disk_state::REBOOT;
+ case "suspend" =>
+ yield disk_state::SUSPEND;
+ case "test_resume" =>
+ yield disk_state::TEST_RESUME;
+ case =>
+ continue;
+ };
+ states |= state;
+ if (trimmed != tok) {
+ active = state;
+ };
+ };
+
+ return (states, active);
+};
+```
+
+This function returns a tuple which includes all of the supported disk states
+OR'd together, and a value which indicates which state is currently enabled. The
+loop iterates through each of the tokens from the tokenizer returned by
+`read_states`, trims off the square brackets, and adds the appropriate state
+bits. We also check the trimmed token against the original token to detect which
+state is currently active.
+
+There's two edge cases to be taken into account here: what happens if Linux adds
+more states in the future, and what happens if none of the states are active? In
+the former case, we have the `continue` branch of the switch statement mid-loop.
+Hare requires all switch statements to be exhaustive, so the compiler forces us
+to consider this edge case. For the latter case, the return value will be zero,
+simply indicating that none of these states are active. This is not actually
+possible given the invariants for this kernel interface, but we could end up in
+this situation if the kernel adds a new disk mode *and* that disk mode is active
+when this code runs.
+
+When the time comes to modify these states, either to put the system to sleep or
+to configure its behavior when put to sleep, we use the following function:
+
+```hare
+fn write_state(path: str, state: str) (void | fs::error | io::error) = {
+ const file = os::open(path, fs::flags::WRONLY | fs::flags::TRUNC)?;
+ defer io::close(file)!;
+ let buf: [128]u8 = [0...];
+ const file = &bufio::buffered(file, [], buf);
+ fmt::fprintln(file, state)?;
+};
+```
+
+This code is working within a specific constraint of sysfs: it must complete
+the write operation in a single syscall. One of Hare's design goals is giving
+you sufficient control over the program's behavior to plan for such concerns.
+The means of opening the file — WRONLY | TRUNC — was also chosen
+deliberately. The "single syscall" is achieved by using a buffered file, which
+soaks up writes until the buffer is full and then flushes them out all at once.
+The buffered stream flushes automatically on newlines by default, so the "ln" of
+"fprintln" causes the write to complete in a single call.
+
+With this helper in place, we can write power states. The ones which configure
+the kernel, but don't immediately sleep, are straightforward:
+
+```hare
+// Sets the current mem state.
+fn set_mem_state(state: mem_state) (void | fs::error | io::error) = {
+ write_state("/sys/power/mem_sleep", mem_state_unparse(state))?;
+};
+```
+
+The star of the show, however, has some extra concerns:
+
+```hare
+// Sets the current sleep state, putting the system to sleep.
+fn set_sleep_state(state: sleep_state) (void | fs::error | io::error) = {
+ // Sleep briefly so that the keyboard driver can process the key up if
+ // the user runs this program from the terminal.
+ time::sleep(250 * time::MILLISECOND);
+ write_state("/sys/power/state", sleep_state_unparse(state))?;
+};
+```
+
+If you enter sleep with a key held down, key repeat will kick in for the
+duration of the sleep, so when running this from the terminal you'll resume to
+find a bunch of new lines. The time::sleep call is a simple way to avoid this,
+by giving the system time to process your key release event before sleeping. A
+more sophisticated solution could open the uinput devices and wait for all keys
+to be released, but that doesn't seem entirely necessary.
+
+Following this, we jump into the dark abyss of a low-power coma.
+
+And that's all there is to it! A few hours of work and 500 lines of code later
+and we have a nice little systems program to make suspending my laptop easier. I
+was pleasantly surprised to find out how well this little program plays to
+Hare's strengths. I hope you found it interesting! And if you happen to need a
+simple tool for suspending your Linux machines,
+[powerctl](https://sr.ht/~sircmpwn/powerctl) might be the program for you.