logo

drewdevault.com

[mirror] blog and personal website of Drew DeVault git clone https://hacktivis.me/git/mirror/drewdevault.com.git

powerctl-a-hare-case-study.md (10055B)


  1. ---
  2. title: "powerctl: A small case study in Hare for systems programming"
  3. date: 2022-08-28
  4. ---
  5. [powerctl](https://sr.ht/~sircmpwn/powerctl/) is a little weekend project I put
  6. together to provide a simple tool for managing power states on Linux. I had
  7. previously put my laptop into suspend with a basic "echo mem | doas tee
  8. /sys/power/state", but this leaves a lot to be desired. I have to use doas to
  9. become root, and it's annoying to enter my password — not to mention
  10. difficult to use in a script or to attach to a key binding. powerctl is the
  11. solution: a small 500-line Hare program which provides comprehensive support for
  12. managing power states on Linux for non-privileged users.
  13. This little project ended up being a useful case-study in writing a tight
  14. systems program in Hare. It has to do a few basic tasks which Hare shines in:
  15. - setuid binaries
  16. - Group lookup from /etc/group
  17. - Simple string manipulation
  18. - Simple I/O within sysfs constraints
  19. Linux documents these features [here][0], so it's a simple matter of rigging it
  20. up to a nice interface. Let's take a look at how it works.
  21. [0]: https://www.kernel.org/doc/html/latest/admin-guide/pm/sleep-states.html
  22. First, one of the base requirements for this tool is to run as a non-privileged
  23. user. However, since writing to sysfs requires root, this program will have to
  24. be setuid, so that it runs as root regardless of who executes it. To prevent any
  25. user from suspending the system, I added a "power" group and only users who are
  26. in this group are allowed to use the program. Enabling this functionality in
  27. Hare is quite simple:
  28. ```hare
  29. use fmt;
  30. use unix;
  31. use unix::passwd;
  32. def POWER_GROUP: str = "power";
  33. // Determines if the current user is a member of the power group.
  34. fn checkgroup() bool = {
  35. const uid = unix::getuid();
  36. const euid = unix::geteuid();
  37. if (uid == 0) {
  38. return true;
  39. } else if (euid != 0) {
  40. fmt::fatal("Error: this program must be installed with setuid (chmod u+s)");
  41. };
  42. const group = match (passwd::getgroup(POWER_GROUP)) {
  43. case let grent: passwd::grent =>
  44. yield grent;
  45. case void =>
  46. fmt::fatal("Error: {} group missing from /etc/group", POWER_GROUP);
  47. };
  48. defer passwd::grent_finish(&group);
  49. const gids = unix::getgroups();
  50. for (let i = 0z; i < len(gids); i += 1) {
  51. if (gids[i] == group.gid) {
  52. return true;
  53. };
  54. };
  55. return false;
  56. };
  57. ```
  58. The POWER\_GROUP variable allows distributions that package powerctl to
  59. configure exactly which group is allowed to use this tool. Following this, we
  60. compare the uid and effective uid. If the uid is zero, we're already running
  61. this tool as root, so we move on. Otherwise, if the euid is nonzero, we lack the
  62. permissions to continue, so we bail out and tell the user to fix their
  63. installation.
  64. Then we fetch the details for the power group from /etc/group. Hare's standard
  65. library includes [a module](https://docs.harelang.org/unix/passwd) for working
  66. with this file. Once we have the group ID from the string, we check the current
  67. user's supplementary group IDs to see if they're a member of the appropriate
  68. group. Nice and simple. This is also the only place in powerctl where dynamic
  69. memory allocation is required, to store the group details, which are freed with
  70. "defer passwd::grent_finish".
  71. The tool also requires some simple string munging to identify the supported set
  72. of states. If we look at /sys/power/disk, we can see the kind of data we're
  73. working with:
  74. ```
  75. $ cat /sys/power/disk
  76. [platform] shutdown reboot suspend test_resume
  77. ```
  78. These files are a space-separated list of supported states, with the currently
  79. enabled state enclosed in square brackets. Parsing these files is a simple
  80. matter for Hare. We start with a simple utility function which reads the file
  81. and prepares a [string tokenizer](https://docs.harelang.org/strings#tokenize)
  82. which splits the string on spaces:
  83. ```hare
  84. fn read_states(path: str) (strings::tokenizer | fs::error | io::error) = {
  85. static let buf: [512]u8 = [0...];
  86. const file = os::open(path)?;
  87. defer io::close(file)!;
  88. const z = match (io::read(file, buf)?) {
  89. case let z: size =>
  90. yield z;
  91. case =>
  92. abort("Unexpected EOF from sysfs");
  93. };
  94. const string = strings::rtrim(strings::fromutf8(buf[..z]), '\n');
  95. return strings::tokenize(string, " ");
  96. };
  97. ```
  98. The error handling here warrants a brief note. This function can fail if the
  99. file does not exist or if there is an I/O error when reading it. I don't think
  100. that I/O errors are possible in this specific case (they can occur when
  101. *writing* to these files, though), but we bubble it up regardless using
  102. "io::read()?". The file might not exist if these features are not supported by
  103. the current kernel configuration, in which case it's bubbled up as
  104. "errors::noentry" via "os::open()?". These cases are handled further up the call
  105. stack. The other potential error site is "io::close", which can fail but only in
  106. certain circumstances (such as closing the same file twice), and we use the
  107. error assertion operator ("!") to indicate that the programmer believes this
  108. case cannot occur. The compiler will check our work and abort at runtime should
  109. this assumption be proven wrong in practice.
  110. In the happy path, we read the file, trim off the newline, and return a
  111. tokenizer which splits on spaces. The storage for this string is borrowed from
  112. "buf", which is statically allocated.
  113. The usage of this function to query supported disk suspend behaviors is here:
  114. ```hare
  115. fn read_disk_states() ((disk_state, disk_state) | fs::error | io::error) = {
  116. const tok = read_states("/sys/power/disk")?;
  117. let states: disk_state = 0, active: disk_state = 0;
  118. for (true) {
  119. let tok = match (strings::next_token(&tok)) {
  120. case let s: str =>
  121. yield s;
  122. case void =>
  123. break;
  124. };
  125. const trimmed = strings::trim(tok, '[', ']');
  126. const state = switch (trimmed) {
  127. case "platform" =>
  128. yield disk_state::PLATFORM;
  129. case "shutdown" =>
  130. yield disk_state::SHUTDOWN;
  131. case "reboot" =>
  132. yield disk_state::REBOOT;
  133. case "suspend" =>
  134. yield disk_state::SUSPEND;
  135. case "test_resume" =>
  136. yield disk_state::TEST_RESUME;
  137. case =>
  138. continue;
  139. };
  140. states |= state;
  141. if (trimmed != tok) {
  142. active = state;
  143. };
  144. };
  145. return (states, active);
  146. };
  147. ```
  148. This function returns a tuple which includes all of the supported disk states
  149. OR'd together, and a value which indicates which state is currently enabled. The
  150. loop iterates through each of the tokens from the tokenizer returned by
  151. `read_states`, trims off the square brackets, and adds the appropriate state
  152. bits. We also check the trimmed token against the original token to detect which
  153. state is currently active.
  154. There's two edge cases to be taken into account here: what happens if Linux adds
  155. more states in the future, and what happens if none of the states are active? In
  156. the former case, we have the `continue` branch of the switch statement mid-loop.
  157. Hare requires all switch statements to be exhaustive, so the compiler forces us
  158. to consider this edge case. For the latter case, the return value will be zero,
  159. simply indicating that none of these states are active. This is not actually
  160. possible given the invariants for this kernel interface, but we could end up in
  161. this situation if the kernel adds a new disk mode *and* that disk mode is active
  162. when this code runs.
  163. When the time comes to modify these states, either to put the system to sleep or
  164. to configure its behavior when put to sleep, we use the following function:
  165. ```hare
  166. fn write_state(path: str, state: str) (void | fs::error | io::error) = {
  167. const file = os::open(path, fs::flags::WRONLY | fs::flags::TRUNC)?;
  168. defer io::close(file)!;
  169. let buf: [128]u8 = [0...];
  170. const file = &bufio::buffered(file, [], buf);
  171. fmt::fprintln(file, state)?;
  172. };
  173. ```
  174. This code is working within a specific constraint of sysfs: it must complete
  175. the write operation in a single syscall. One of Hare's design goals is giving
  176. you sufficient control over the program's behavior to plan for such concerns.
  177. The means of opening the file &mdash; WRONLY | TRUNC &mdash; was also chosen
  178. deliberately. The "single syscall" is achieved by using a buffered file, which
  179. soaks up writes until the buffer is full and then flushes them out all at once.
  180. The buffered stream flushes automatically on newlines by default, so the "ln" of
  181. "fprintln" causes the write to complete in a single call.
  182. With this helper in place, we can write power states. The ones which configure
  183. the kernel, but don't immediately sleep, are straightforward:
  184. ```hare
  185. // Sets the current mem state.
  186. fn set_mem_state(state: mem_state) (void | fs::error | io::error) = {
  187. write_state("/sys/power/mem_sleep", mem_state_unparse(state))?;
  188. };
  189. ```
  190. The star of the show, however, has some extra concerns:
  191. ```hare
  192. // Sets the current sleep state, putting the system to sleep.
  193. fn set_sleep_state(state: sleep_state) (void | fs::error | io::error) = {
  194. // Sleep briefly so that the keyboard driver can process the key up if
  195. // the user runs this program from the terminal.
  196. time::sleep(250 * time::MILLISECOND);
  197. write_state("/sys/power/state", sleep_state_unparse(state))?;
  198. };
  199. ```
  200. If you enter sleep with a key held down, key repeat will kick in for the
  201. duration of the sleep, so when running this from the terminal you'll resume to
  202. find a bunch of new lines. The time::sleep call is a simple way to avoid this,
  203. by giving the system time to process your key release event before sleeping. A
  204. more sophisticated solution could open the uinput devices and wait for all keys
  205. to be released, but that doesn't seem entirely necessary.
  206. Following this, we jump into the dark abyss of a low-power coma.
  207. And that's all there is to it! A few hours of work and 500 lines of code later
  208. and we have a nice little systems program to make suspending my laptop easier. I
  209. was pleasantly surprised to find out how well this little program plays to
  210. Hare's strengths. I hope you found it interesting! And if you happen to need a
  211. simple tool for suspending your Linux machines,
  212. [powerctl](https://sr.ht/~sircmpwn/powerctl) might be the program for you.