Introduction and concepts
1.1 · What RootSpeak is
RootSpeak — Advanced Messaging System for Linux is the tool with which the administrators of a Linux machine send text messages to users and know who has confirmed reading them. It answers a question that the system's own tools leave open: “who has not read it yet?”
wall and write write to the terminals of logged-in users without keeping track of anything; notify-send shows a notification that may disappear; the motd and login banners are fixed texts. None of them keeps a state per message and per user. RootSpeak puts the pieces together (logind, the terminals, desktop autostart, zenity, the journal) and adds exactly this state.
Version 0.2.0 is written in C (the 0.1.0 prototype was in bash): two programs, no daemon, no database. The rspeak command always runs as root and writes to the message store; on the user's side the helper rootspeak-user reads the mailbox and records the confirmation, in two ways: in the shell prompt (rootspeak-user prompt) and in the graphical session (rootspeak-user agent).
1.2 · Basic concepts
A few concepts are enough to read the rest of the manual. They are the same in the code, in the tests and in the manuals.
- Message
- a text (at most 65536 bytes) with an optional title and an optional expiry, identified by a sequential number, the ID. The sender is the administrator who ran
rspeak. - Recipient
- a human user of the machine to whom the message is addressed: by name, by group (
@group), everyone (all) or the logged-in users (online). - Reference copy
- the message in
/var/lib/rootspeak/sent/ID/, readable only by root: what was sent, to whom, when, with which expiry. It is the authoritative source. - Mailbox
- a user's
/var/lib/rootspeak/users/U/folder: a working copy that the user controls, with the subfoldersinbox,read,acksandstate. - Channel
- where the user sees the message and confirms it: a terminal (
terminal pts/N,terminal tty3) or the desktop (desktop). - Confirmation
- the user's statement of having read the message, written into their mailbox in
acks/IDwith date and channel. The first one counts. - Postponement
- the answer “no” in the terminal or “Later” on the desktop: the question comes back after
RSPEAK_REMIND_MINUTESminutes, or at the next login. - State
- for each recipient exactly one of three: sent, delivered, confirmed. Revoked and expired are properties of the message, not states.
1.3 · Subsystems at a glance
The code lives in src/ and is organised in small modules with clear-cut responsibilities. This is the map that the manual explores chapter by chapter.
rspeak.c- the administrators' command: relaunch through sudo,
send,list,status,revoke,remind,log,purge, recovery of interrupted sends (chapter 4, chapter 8, chapter 9, chapter 10). rootspeak-user.c- the helper that runs as the user: the question at the prompt, the desktop agent, the recording of the confirmation (chapter 11, chapter 12, chapter 13).
store.c- the operations on the mailboxes, always with the user's identity; writing to the terminals; waking up the agent (chapter 7, chapter 8).
user.cas_user: a child process with a user's identity and a time limit (chapter 7).sessions.c- human users, groups, sessions and terminals, from passwd and from logind (chapter 6).
text.c- text cleaning, message format, configuration (chapter 5, chapter 4).
event.c- the structured events in the journal and their text (chapter 17).
tui.c- the TUI for administrators (chapter 14).
help.c- the built-in help of
rspeak --help(chapter 15). common.c- environment, errors, memory, growable strings, safe reading and writing of files (chapter 5.5, chapter 18).
1.4 · How to read this manual
| If the question is… | Where |
|---|---|
| What must always be true, whatever happens? | chapter 19 · Specification |
| How is the message store organised, and who can write to it? | chapter 5 and chapter 16 |
| What happens, step by step, during a send? | chapter 8 |
| Why does root never write directly into the mailboxes? | chapter 7 |
| How does the question reach the terminal, and the desktop? | chapter 11 and chapter 12 |
| What exactly does “confirmed” mean? | chapter 13 and chapter 19.1 |
| How is the TUI built, and why does it have no states of its own? | chapter 14 |
| Where can the history of a message be seen? | chapter 17 |
| Which errors can RootSpeak report, and what do they mean? | chapter 18 |
| How is a change tested, and with what results? | chapter 3.5 and chapter 20 |
| Which defects have already been found and fixed? | chapter 21 |
General architecture
2.1 · Architectural principles
RootSpeak is small on purpose: two programs, files in a single folder, the systemd libraries already present on the system. The design favours verifiability: every state can be reconstructed by looking at the files, and every step that can be interrupted leaves a mark that the next command knows how to repair.
| Principle | What it means in the code |
|---|---|
| No daemon | Delivery is done by rspeak send at the moment of sending; the rest is done by the shell hook and the desktop agent, which start with the user's sessions. Recovery of interrupted sends runs at the start of the following commands. |
rspeak always as root | The command relaunches itself with sudo before looking at its arguments, including --help and version (chapter 4.1). |
| Never block | Every message asks for confirmation and insists with reminders, but the user can always postpone. Sessions without a terminal are not touched. |
| Three states | For each recipient: sent, delivered, confirmed. Expiry and revocation are properties of the message; the state_e enumeration in src/rspeak.h has only three values. |
| Root does not trust the mailboxes | Every access by root to users/U happens in a child process with U's identity, with a time limit (chapter 7). |
| Data, not code | Configuration, messages and confirmations are data files read with size limits; nothing that comes from outside is executed. |
| No written state | The state of a recipient is not written anywhere: it is deduced from sent/ID/delivery and from the mailbox (chapter 10.1). |
| A single source | The TUI uses the same functions as rspeak list and rspeak status; the built-in help and the user manual's reference come from the same texts. |
2.2 · The three privilege domains
A message crosses three domains with different privileges. The rule is simple: everything that passes between root and a user crosses a controlled boundary, in both directions.
| Domain | Who | Writes | Reads |
|---|---|---|---|
| administrator | rspeak, as root | sent/, the mailboxes (only as the user), the users' terminals, the journal | the whole message store; the mailboxes only as the user |
| user | rootspeak-user, as U | their own mailbox, the journal (with their own _UID) | their own mailbox; never sent/ |
rspeak itself, and its actions are the commands run in a child process (chapter 14.1).2.3 · The two programs
The Makefile produces two executables. The common parts (common text event user sessions) go into both; rspeak adds rspeak.o, help.o, store.o and tui.o.
| Program | Where | Permissions | Who runs it |
|---|---|---|---|
rspeak | /usr/local/bin/rspeak | 750, root: administrators' group | an administrator; it relaunches itself with sudo |
rootspeak-user | /usr/local/lib/rootspeak/rootspeak-user | 755 | the shell hook (prompt) and desktop autostart (agent); never the user by hand |
Both depend only on glibc and libsystemd (sd-login for sessions, sd-journal for the log). At run time they also need sudo, zenity, systemd-run and date; notify-send if available.
2.4 · The path of a message
The complete path touches every subsystem. Here it is in brief; each step has its own chapter.
| Step | Who | Effect | Where |
|---|---|---|---|
| 1 | rspeak send | resolves the recipients, cleans text and title, takes an ID, writes sent/ID/ with the incomplete marker | chapter 8.2 |
| 2 | rspeak send as U | writes users/U/inbox/ID (first .tmp, then renames), checks that it is there, then records the delivery in sent/ID/delivery | chapter 8.3 |
| 3 | rspeak send | writes the text to each of U's terminals | chapter 8.4 |
| 4 | rspeak send as U | wakes the desktop agent with SIGUSR1, or starts it with systemd-run --user | chapter 8.5 |
| 5 | hook or agent | show the message and ask for confirmation | chapter 11, chapter 12 |
| 6 | rootspeak-user | creates acks/ID (the first one counts) and moves the message to read/ | chapter 13 |
| 7 | rspeak status, TUI | deduce the state by comparing sent/ID with the mailboxes | chapter 10 |
2.5 · Sessions and channels
RootSpeak does not use utmp, which does not record many modern terminals, but logind and the terminal devices. The useful question is not “how is the user logged in” but “on which channels can I reach them right now”.
| User session | How RootSpeak finds it | Channel |
|---|---|---|
text console (tty3) | logind session of type tty with terminal tty3 | terminal |
| ssh with a terminal | pseudo-terminal /dev/pts/N owned by the user | terminal |
| desktop terminal, tmux | pseudo-terminal owned by the user | terminal |
| X11 or Wayland desktop | logind session of type x11 or wayland | desktop agent |
| ssh without a terminal, scp, cron | no terminal | none: they are not touched |
| user not logged in | — | the mailbox, read at the next login |
- The mailbox acts as a queue: a message that reaches no channel when it is sent appears at the next login (question in the terminal) or at the agent's first round (dialog).
- Writing the text to a terminal does not count as a confirmation: the question arrives at the next prompt.
su still belongs to whoever opened it: rspeak send does not write to it. The shell hook reaches the user anyway at the next prompt.Repository, build and installation
3.1 · File map
The repository contains the C code, the files to install on the system, the manuals with their sources, the tests and the consistency checks. The following table is the file map: the lines are counted every time the manual is generated, and tools/check-docs.py fails if a code file is missing or if one that does not exist is listed.
RootSpeak/ ├── src/ # the C code: two programs, common parts ├── etc/ # shell hooks, autostart, configuration ├── install.sh # installation, upgrade, removal ├── Makefile # build, tests, analysis, sanitizer ├── docs/ # the two generated manuals, decisions, reviews │ └── sources/ # generator, style, one file per chapter ├── tests/ # engine, TUI, help, units, fuzzing, container, VM │ └── */results/ # results recorded with commit and fingerprint ├── tools/ # manual consistency, fingerprint, screenshots, release └── site/ # the website: landing page, publication
| File | Lines | Role |
|---|---|---|
src/rspeak.c | 1436 | The administrators' command: relaunch through sudo, recipients, send, list, status, revoke, remind, log, purge, message summaries, recovery. |
src/rspeak.h | 62 | The parts of rspeak also used by the TUI: recipient states, summary, commands. |
src/tui.c | 1676 | The TUI for administrators: list, detail and history, writing and sending, actions with confirmation. |
src/help.c | 709 | The built-in help: tabbed guide, manual-style page, plain text. |
src/help.h | 11 | Interface of help.c. |
src/store.c | 443 | The operations on the mailboxes, always with the user's identity; writing to terminals; waking up the agent. |
src/store.h | 45 | Interface of store.c. |
src/rootspeak-user.c | 628 | The helper that runs as the user: prompt (shell hook), agent (desktop), recording of confirmations. |
src/user.c | 198 | as_user: a child process with a user's identity and a time limit. |
src/user.h | 30 | Interface of user.c. |
src/sessions.c | 175 | Human users, groups, sessions and terminals, from passwd, group and logind. |
src/sessions.h | 22 | Interface of sessions.c. |
src/text.c | 244 | Text cleaning, message format, configuration. |
src/text.h | 48 | Interface of text.c: msg_t, conf_t. |
src/event.c | 85 | Structured events in the journal and their text. |
src/event.h | 22 | Interface of event.c. |
src/common.c | 429 | Utilities: environment, errors, memory, growable strings, safe reading and writing of files. |
src/common.h | 104 | Interface of common.c, version (RSPEAK_VERSION) and limits. |
Makefile | 82 | Build, internal tests, static analysis, sanitizer. |
install.sh | 119 | Installation, upgrade, removal. |
etc/profile.d/rootspeak.sh | 23 | The hook for the shells that read /etc/profile, in plain sh: it hands bash over to rootspeak.bash and asks the others (except zsh) at login. |
etc/rootspeak.bash | 24 | The bash hook: the question at login and at every prompt. |
etc/rootspeak.zsh | 25 | The zsh hook, sourced by the system zshrc. |
etc/rootspeak.fish | 22 | The fish hook, installed in /etc/fish/conf.d. |
etc/xdg/autostart/rootspeak-agent.desktop | 7 | Autostart of the desktop agent. |
etc/rootspeak/rootspeak.conf | 10 | The default configuration. |
tests/engine.sh | 411 | Engine tests in test mode: confirmations, delivery, states, expiry, configuration, purge, agent, second review, reminder. |
tests/unit-tests.c | 117 | Tests of the internal parts: cleaning, messages, configuration, events. |
tests/tui.py | 187 | Test of the TUI on a virtual terminal: list, detail, sending, editor, reminder, revocation, search, sizes. |
tests/help-tui.py | 104 | Test of the built-in help on a virtual terminal. |
tests/fuzz/fuzz.c | 67 | Fuzzing with libFuzzer of what reads untrusted data (four targets). |
tests/fuzz/run.sh | 36 | Builds and runs the fuzzing in a container with clang. |
tests/container/Containerfile | 13 | The test machine: Debian 13, or any distribution with systemd, with sshd, several users and shells. |
tests/container/setup.sh | 114 | Prepares the test machine on any distribution (apt, dnf, zypper, pacman): packages, sshd, shells, Italian locale, test users. |
tests/container/run.sh | 61 | Starts the test machine, builds and installs RootSpeak there, runs the scenarios, saves the results. |
tests/distros/run.sh | 109 | Compatibility tests on the test server: the scenarios on every distribution of the list, several at a time, with a summary. |
tests/distros/list | 25 | The distributions of the compatibility tests and their container images. |
tests/container/scenarios.py | 1004 | The real-world scenarios, run as root in the test machine (also in the VM, together with the desktop ones). |
tests/vm/run.sh | 108 | Tests in a VM with a desktop on the test server: code, clean VM, scenarios, screenshots, results; one desktop profile per run. |
tests/vm/prepare.sh | 43 | Builds once the base disk of a desktop profile, from the cloud image of its distribution. |
tests/vm/users.sh | 49 | Run in the VM while its base disk is prepared: users, ssh keys, Italian locale. |
tests/vm/desktops/debian-gnome.yaml | 48 | Desktop profile (cloud-init): Debian 13 with GNOME on Wayland. |
tests/vm/desktops/ubuntu-gnome.yaml | 45 | Desktop profile: Ubuntu 24.04 with GNOME on Wayland. |
tests/vm/desktops/ubuntu-cinnamon.yaml | 58 | Desktop profile: Cinnamon on X11 with LightDM on Ubuntu 24.04, the base of Linux Mint 22. |
tests/vm/desktops/fedora-kde.yaml | 34 | Desktop profile: Fedora 44 with KDE Plasma on Wayland. |
tests/vm/desktops/rocky-gnome.yaml | 50 | Desktop profile: Rocky Linux 9 with GNOME, SELinux enforcing. |
tests/vm/start.sh | 28 | Starts a VM from a clean copy of its base disk. |
tests/vm/stop.sh | 12 | Shuts down the VM and deletes the working disk. |
tests/vm/capture.py | 15 | Captures the VM screen from the QEMU monitor. |
tests/vm/desktop.py | 78 | Reads and presses the buttons of the RootSpeak dialogs through the desktop's accessibility (AT-SPI). |
tools/check-docs.py | 167 | Consistency checks between code, help, manuals and tests. |
tools/fingerprint.sh | 17 | Code fingerprint (SHA-256 of src, Makefile, etc, install.sh) and commit, recorded with the test results. |
tools/tui-screenshots.sh | 97 | Real TUI screenshots for the manuals, captured in the test machine. |
tools/setup-dev.sh | 108 | Says what a freshly cloned copy is missing (packages, git identity); --install installs the packages. |
tools/backup.sh | 34 | The whole repository, with its history, in a single git bundle. |
tools/release.sh | 84 | The downloadable archive: the programs built on RHEL 9 on the test server, install.sh, etc/, the manuals and the licence, in build/release/. |
site/publish.sh | 79 | Builds the website in site/public/ and copies it to the VPS. |
site/seo-head.py | 29 | Adds description, canonical address and link preview to a page of the website. |
site/licence-page.py | 84 | Turns LICENSE into licence.html. |
docs/sources/build.py | 647 | Generator of the two manuals and functions for figures and tables. |
docs/sources/style.css | 148 | The common style of the manuals of the seven projects, embedded unchanged in every manual; the rules that only RootSpeak needs are in build.py. |
docs/sources/manual.js | 326 | The common script of the manuals: search in the sidebar, index, Copy button on the command boxes. |
The chapters of the manuals are in docs/sources/user/ and docs/sources/technical/, one file per chapter; docs/decisions-and-history.md and CLAUDE.md collect the decisions and the working agreements, docs/adversarial-review.md the two external reviews. The bash prototype (version 0.1.0) is in the repository history.
3.2 · Building: the Makefile
The build is a Makefile with no external tools besides gcc and pkg-config (for libsystemd). Everything ends up in build/ (or in a folder chosen with B=); the installation folder of the helper is compiled into the program with -DLIBDIR.
| Target | What it does |
|---|---|
make | builds build/rspeak and build/rootspeak-user |
make unit | builds and runs build/unit-tests in test mode |
make analyze | rebuilds everything in build-analyze/ with -fanalyzer |
make sanitizer | rebuilds in build-san/ with AddressSanitizer and UBSan, plus unit-tests; the engine is then run on them by hand with RSPEAK_BUILD=build-san tests/engine.sh |
make docs | regenerates the two manuals (python3 docs/sources/build.py) |
make docs-check | runs tools/check-docs.py (chapter 20.5) |
make test | make, make unit, make docs-check, then tests/engine.sh |
make clean | removes build, build-analyze and build-san |
WARNINGS = -Wall -Wextra -Wpedantic -Wformat=2 -Wformat-security -Wshadow -Wpointer-arith \
-Wcast-qual -Wwrite-strings -Wstrict-prototypes -Wmissing-prototypes -Wvla \
-Wnull-dereference -Wimplicit-fallthrough -Wundef -Werror
HARDENING = -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection -fcf-protection -fPIE
LDFLAGS += -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstackWarnings are errors (-Werror): the code does not build if gcc finds anything to object to. The meaning of each hardening flag is in chapter 16.3.
3.3 · install.sh step by step
- It relaunches itself with
sudoif it is not root, and picks the administrators' group: the first one that exists amongsudo,wheel,admin(otherwiseroot). It warns ifzenity,loginctlorsystemd-runare missing. - If the programs are missing from
build/, or were built for other folders (theLIBDIRpath does not appear insidebuild/rspeak), it builds them withmake PREFIX=…(gcc, make, pkg-config and libsystemd-dev are needed). From the downloadable archive, which has noMakefile, it never builds: the programs are already inbuild/, built for/usr/local; with anotherPREFIXit stops with “The programs of this archive install only in /usr/local (PREFIX=…)”. - It installs
$PREFIX/lib/rootspeak/rootspeak-user(755) and$PREFIX/bin/rspeakwith ownerroot:group(the administrators' group) and permissions750.PREFIXis/usr/local. It removes the language catalogs left by the first 0.2.0 builds: RootSpeak speaks English only. - It installs the manuals in
$PREFIX/share/doc/rootspeak/, the configuration (only if missing), the shell hooks and the autostart entry, replacing@LIBDIR@and@VARDIR@. - It adds to
/etc/bash.bashrcthe line marked# RootSpeak hook(on Debian, interactive non-login shells do not read/etc/profile.d); if the system zshrc exists, it adds the line that sourcesrootspeak.zsh; for fish it writes/etc/fish/conf.d/rootspeak.fish. - It creates
/var/lib/rootspeakandusers/(755) andsent/(700). - It closes the agents of the previous version: they restart, updated, at the first send or at login.
| Option | Effect |
|---|---|
| none | installs or upgrades |
--uninstall | removes programs, manuals, hooks and the marked lines of bash.bashrc and of the zshrc, stops the agents; keeps messages and configuration |
--purge | like --uninstall, and also removes /var/lib/rootspeak and /etc/rootspeak |
3.4 · What it installs, and where
| Path | Owner | Permissions | Contents |
|---|---|---|---|
/usr/local/bin/rspeak | root:sudo (or wheel, admin) | 750 | the administrators' command |
/usr/local/lib/rootspeak/rootspeak-user | root | 755 | the helper, outside the PATH |
/usr/local/lib/rootspeak/rootspeak.bash, rootspeak.zsh | root | 644 | the bash and zsh hooks |
/usr/local/share/doc/rootspeak/ | root | 644 | the two HTML manuals |
/etc/rootspeak/rootspeak.conf | root | 644 | the configuration (never overwritten) |
/etc/profile.d/rootspeak.sh | root | 644 | the hook for the shells that read /etc/profile |
/etc/fish/conf.d/rootspeak.fish | root | 644 | the fish hook |
/etc/xdg/autostart/rootspeak-agent.desktop | root | 644 | the agent's autostart entry |
/etc/bash.bashrc, zshrc | — | — | a line marked # RootSpeak hook |
/var/lib/rootspeak/ | root | 755 (sent/ 700) | the message store (chapter 5) |
rootspeak-user is not in the PATH and, when run by hand without prompt or agent, replies that it is an internal helper and exits with 1.3.5 · Before committing a change
make(all warnings are errors) andmake unit.python3 docs/sources/build.py, thentools/check-docs.py: generated manuals up to date, reference aligned with the help, file map complete, internal links valid, tests run on the current code (chapter 20.5).tests/engine.shalways;tests/help-tui.pyif the help has changed;tests/tui.pyandtools/tui-screenshots.shif the TUI has changed;make sanitizerwithRSPEAK_BUILD=build-san tests/engine.sh,make analyzeandtests/fuzz/run.shif the code that reads data has changed;tests/container/run.shif sending, delivery, confirmations or permissions have changed;tests/vm/run.shif the desktop has changed.- A test of the affected flow in test mode (chapter 4.4); for the desktop, sudo and other users, a live test after
./install.sh, done by whoever has the root password.
3.6 · The downloadable archive and the website
RootSpeak is freeware and is distributed as built programs, not as source: one archive, rootspeak-VERSION-linux-x86_64.tar.gz, the same for every supported distribution.
tools/release.shcopies the files under version control to the test server and builds them in the image of RHEL 9 of the compatibility tests: RHEL 9 and its rebuilds have the oldest C library among the supported distributions (glibc 2.34; Ubuntu 22.04 has 2.35, SLES 15 2.38), and a program built against an older glibc runs on the newer ones. The programs are stripped of their symbols.- It checks that no symbol needs a glibc newer than 2.34 and that the programs were built for
/usr/local, then makes the archive withinstall.sh,etc/,build/rspeak,build/rootspeak-user, the two manuals,LICENSE,NOTICE.mdand aREADME.txt, and itsSHA256SUMS. RSPEAK_ARCHIVE=build/release/….tar.gz tests/distros/run.shruns all the scenarios on the 22 distributions with the archive installed as a customer would (no build on the test machines); the summary records how RootSpeak was installed.site/publish.shbuilds the website insite/public/(landing page, manuals,licence.html,download/,og.jpg,robots.txt,sitemap.xml) and copies it to/srv/www/rootspeak.nicfio.iton the VPS;--buildonly builds.
libsystemd.so.0) keeps its interface across versions: the one of RHEL 9 is enough everywhere.Startup and configuration
4.1 · Starting rspeak
The main of src/rspeak.c is short and its order is a rule: nothing runs without root, not even the help. Before looking at the arguments it reads the environment; then, if it is not root and not in test mode, it relaunches itself with sudo.
int main(int argc, char **argv)
{
const char *cmd = argc > 1 ? argv[1] : "";
rspeak_init("rspeak");
/* As a non-root administrator, rspeak re-runs itself with sudo, even for
* the help and the version. */
if (geteuid() != 0 && !rspeak_test)
rerun_with_sudo(argv);
umask(022);
/* rspeak alone in a terminal opens the TUI; in a pipe, or with a
* terminal too small, the help */
if (!*cmd && isatty(0) && isatty(1)) {
recover();
if (rspeak_tui())
return 0;
}
if (!*cmd || !strcmp(cmd, "help") || !strcmp(cmd, "-h") || !strcmp(cmd, "--help")) {
rspeak_help();
return 0;
}
if (!strcmp(cmd, "version") || !strcmp(cmd, "--version")) {
printf("RootSpeak - Advanced Messaging System for Linux %s\n", RSPEAK_VERSION);
return 0;
}
if (strcmp(cmd, "send") && strcmp(cmd, "list") && strcmp(cmd, "status") && strcmp(cmd, "revoke") &&
strcmp(cmd, "remind") && strcmp(cmd, "log") && strcmp(cmd, "purge"))
die("unknown command: %s (rspeak --help for the guide)", cmd);
…
}rerun_with_sudoreads its own path from/proc/self/exeand callsexecvp("sudo", ["sudo", "--", path, arguments…]): the arguments arrive unchanged, and--prevents them from being taken as sudo options.- Under sudo the environment is what sudo preserves:
RSPEAK_TESTis not kept.SUDO_USERtells who launched the command: it becomes the sender (from) and the author of the events. - Recovery of interrupted sends (chapter 8.6) runs before
send,list,status,revoke,remind,logand the TUI; not beforepurge, which skips incomplete sends by itself. - An unknown command ends with
die:rspeak: unknown command: X (rspeak --help for the guide), exit status 1.
4.2 · Starting rootspeak-user
rootspeak-user never relaunches itself: it runs with the identity of whoever launches it, that is the user. Its main has a different order, dictated by the desktop agent.
| Step | Why |
|---|---|
1. handler for SIGUSR1 | it is the first statement: by default SIGUSR1 terminates the process, and rspeak send might send it to an agent that has just started (chapter 12.2) |
2. rspeak_init("rootspeak-user"), conf_read | environment, configuration |
3. user from getuid and getpwuid | the mailbox is users/<name>: the name never comes from an argument |
4. prompt [--login] | the question in the terminal (chapter 11) |
5. agent | the desktop agent (chapter 12) |
6. confirm ID CHANNEL | only in test mode: recording the confirmation on its own, for tests/engine.sh |
| otherwise | “rootspeak-user: internal RootSpeak helper, not meant to be run directly”, exit status 1 |
4.3 · The environment and test mode
rspeak_init in src/common.c sets the working folders. The defaults are compiled into the program; the environment variables count only in test mode: a root program does not take folders from the environment of whoever launches it.
| Global variable | Default | In test mode |
|---|---|---|
rspeak_var | /var/lib/rootspeak | RSPEAK_VAR |
rspeak_libdir | LIBDIR from the Makefile (/usr/local/lib/rootspeak) | RSPEAK_LIB |
rspeak_conf_path | /etc/rootspeak/rootspeak.conf | RSPEAK_CONF |
rspeak_test | false | true if RSPEAK_TEST is not empty |
rspeak_name | — | "rspeak" or "rootspeak-user": the prefix of error messages |
- RSPEAK_TEST=1
rspeakdoes not relaunch itself with sudo, does not wake or start agents, does not change identity in child processes and writes only to the “terminal”RSPEAK_TEST_TTY. An ordinary user who sets it keeps their own permissions (scenario G4: “Permission denied” as soon asrspeakis executed), and sudo removes it from the environment.- RSPEAK_TEST_TTY=file
- the “terminal” that
rspeak sendwrites to in test mode; empty: none. - RSPEAK_BUILD=folder
- for the tests: the folder of the programs to test (for example
build-san).
4.4 · Testing without root
The code is tested from the repository folder as an ordinary user, on a test message store, after make. This is how tests/engine.sh, tests/tui.py and tests/help-tui.py run.
nicfio@server:~/ROOTSPEAK$ make
nicfio@server:~/ROOTSPEAK$ export RSPEAK_TEST=1 RSPEAK_LIB=$PWD/build
nicfio@server:~/ROOTSPEAK$ export RSPEAK_VAR=/tmp/test-store RSPEAK_CONF=/dev/null RSPEAK_TEST_TTY=/tmp/tty
nicfio@server:~/ROOTSPEAK$ build/rspeak send --to nicfio "Test"
Message 1 sent to 1 user.
nicfio@server:~/ROOTSPEAK$ script -qec 'build/rootspeak-user prompt' /dev/nullRSPEAK_TEST, build/rspeak goes through sudo; if the sudo credentials are still valid it starts as root without asking anything, on the real message store. In tests always set test mode.getpwnam), but their mailboxes are folders of the test message store written with the identity of whoever runs the test: tests/engine.sh uses root as its second recipient.4.5 · The configuration: rootspeak.conf
The configuration is a data file, not a script: in the bash prototype it was read with source, by root and by the users, and could execute code. Now conf_read in src/text.c reads at most 65536 bytes of it and conf_from_text accepts only two keys, with positive integers.
# RootSpeak - Advanced Messaging System for Linux: configuration
# A data file, not a script: KEY=value lines with positive whole numbers.
# Unknown keys and invalid values are ignored.
# After how many minutes a message postponed by the user is shown again
RSPEAK_REMIND_MINUTES=30
# Every how many seconds the desktop agent checks the messages again
# (new messages wake it at once anyway)
RSPEAK_AGENT_POLL=60| Key | Default | Who uses it | When it is re-read |
|---|---|---|---|
RSPEAK_REMIND_MINUTES | 30 | rootspeak-user prompt and the agent: how long a postponement lasts | at every rootspeak-user prompt; at every round of the agent |
RSPEAK_AGENT_POLL | 60 | the agent: the periodic check, in addition to the wake-up | at every round of the agent |
- Each line loses its comment (from
#onwards) and all spaces; a line that is still longer than 255 characters is not valid. - The value must consist of digits only, at most 6, and be greater than zero:
0,-3,abcand$(…)are ignored and the default stays (tests/engine.sh, group 5). - Unknown keys are ignored; a missing file, or one that is not a regular file, counts as empty.
rspeakitself does not read the configuration: the two keys concern only whatrootspeak-userdoes.
The message store
5.1 · Structure
The message store is made of files and folders under /var/lib/rootspeak. One part belongs to root, one part to each user: the boundary between the two is the heart of RootSpeak security. There is no database and no index: every piece of information is in a file that can be read with cat.
/var/lib/rootspeak/ # root 755 ├── sent/ # root 700 · root only │ ├── .seq .seq.lock # last ID assigned; lock for flock │ ├── 12/ │ │ ├── msg # the message (reference copy) │ │ ├── recipients # one recipient per line │ │ ├── delivery # “user date terminals” for each delivered copy │ │ ├── lock # held by rspeak send while delivering (flock) │ │ ├── incomplete # marker: send in progress or to be completed │ │ ├── revoked # date of revocation, if revoked │ │ ├── revoking # marker: revoked copies still to be removed │ │ └── failed # marker: send interrupted before it was saved │ └── .deleting-9/ # a message that rspeak purge is deleting └── users/ # root 755 └── mario/ # mario 700 · the mailbox: only Mario and root ├── inbox/12 # to be shown ├── read/11 # confirmed or expired ├── acks/11 # “date channel” of the confirmation └── state/12.term # postponement: the file's modification time counts
incomplete, revoking and failed are internal recovery markers: they say that an operation must be completed or has been cancelled. They are not states of a message or of a recipient, and no command shows them as such (chapter 19.2).5.2 · Format of a message
A message is a UTF-8 text file: a key: value header, an empty line, the text. The same file, byte for byte, is the reference copy in sent/ID/msg and the copy in the mailbox of each recipient.
format: 1
id: 12
from: admin
date: 1790714288
title: Maintenance
expires: 1790800688
On Saturday from 8 am to 12 noon the server will be off for disk maintenance.| Field | Contents |
|---|---|
format | format version: 1. Readers ignore the keys they do not know; a different format must be read by an implementation that knows it |
id | sequential number, from next_id |
from | the administrator: SUDO_USER if it is an existing user, otherwise the user running rspeak |
date | seconds since the Unix epoch; mandatory: a message without a valid date is rejected |
title | title, possibly empty, on a single line (newlines and tabs become spaces) |
expires | seconds since the Unix epoch; empty: no expiry |
| Function (src/text.c) | What it does |
|---|---|
msg_format | writes the header in the order shown in the figure, then the text |
msg_from_text | reads line by line up to the first empty line; also accepts key: without a value; what follows is the text |
msg_read | reads a regular file, without following links, at most RSPEAK_MAX_MSG bytes (65536 + 8192), then msg_from_text |
msg_expired | true if expires is present and is less than or equal to the given time |
msg_header | “Title · 29/09 22:15”, or just the date: the line that precedes the text in the terminal, in the dialog and in rspeak status |
5.3 · Message numbers
Every message has a sequential number. next_id in src/rspeak.c takes it under an exclusive flock on sent/.seq.lock: two simultaneous rspeak send runs never get the same number.
- It reads the last number from
sent/.seq(0 if missing or not a number), increments it and creates the folder withmkdir(…, 0700). - If the folder already exists, it moves on to the next number: this happens if
.seqwent backwards, for example after a power failure that lost the last write. No message is ever overwritten. - It writes the new value with
write_atomicand releases the lock by closing the descriptor. - A number taken and never used (an interruption right afterwards) remains a gap in the numbering: harmless.
5.4 · Owners and permissions
| Path | Owner | Permissions | Why |
|---|---|---|---|
/var/lib/rootspeak | root | 755 | traversable by everyone to reach their own mailbox |
sent/ | root | 700 | messages and recipients visible only to administrators |
sent/ID/ | root | 700 | created by next_id |
sent/ID/* | root | 600 | written by rspeak with O_NOFOLLOW |
users/ | root | 755 | only root creates mailboxes: a user can neither create nor move them |
users/U/ | U | 700 | created by root and handed over to U; only U (and root) can enter it |
users/U/inbox, read, acks, state | U | 755 | created by U, in a child with U's identity; protected by the 700 folder |
inbox/ID, acks/ID, state/ID.* | U | 644 | written by U |
A user can delete or modify the files in their own mailbox: they only spoil their own copy. The reference copy stays in sent/, and a message deleted from the mailbox simply shows up as not confirmed. A confirmation written by hand remains a statement by the user (chapter 19.1).
as_user sets it to 022, rootspeak-user uses the one of the user's session. The real protection is the users/U folder at 700.5.5 · Reading and writing files safely
All file I/O goes through a few functions in src/common.c with fixed rules. These are the functions that make it safe to read what users write and that guarantee durability after an interruption.
| Function | Rule |
|---|---|
read_file(path, max, nofollow, out) | opens with O_NONBLOCK (a pipe does not block the open) and, if requested, O_NOFOLLOW; rejects anything that is not a regular file and files larger than max |
read_fd(fd, max, out) | reads to the end, keeps at most max bytes and discards the rest |
write_all(fd, p, n) | retries after partial writes and signal interruptions |
write_atomic(path, p, n, mode) | writes path.tmp (O_EXCL|O_NOFOLLOW), fsync, renames; on error removes the temporary file |
sync_path, syncfs_path | fsync of a file or a folder; syncfs of the file system that contains it |
exists, is_folder | with lstat: a symbolic link is not the folder it points to |
list_numbers(dir, out) | only names made of digits (at most 18), in numeric order |
read_number(s, out) | digits only, at most 18: no sign, no spaces, no overflow |
Every descriptor is opened with O_CLOEXEC: no open file is passed on to the programs launched by RootSpeak (zenity, sudo, systemd-run, the editor). Memory is requested with xmalloc and texts grow in a buf_t (chapter 23.5): no fixed-length buffer receives data that comes from outside.
5.6 · Preserving the message store
RootSpeak has no backup or restore command: the message store is made of files, and the only tools provided are the purge of old messages (rspeak purge, chapter 9.3) and complete removal (install.sh --purge).
| What | Where | Note |
|---|---|---|
| messages, recipients, deliveries, revocations | /var/lib/rootspeak/sent/ | authoritative; root only |
| copies, confirmations, postponements | /var/lib/rootspeak/users/ | per user; the confirmations are the state |
| event history | system journal | kept according to the journal's rules, not RootSpeak's |
| configuration | /etc/rootspeak/rootspeak.conf | not overwritten by upgrades |
- A reset message store reuses message numbers, the journal does not: this is why
rspeak log IDreads only the events after the message's sending date (chapter 17.4). - A copy taken while a send is in progress contains the
incompletemarker: given how recovery works (chapter 8.6), after a restore that send would be completed by the firstrspeakcommand.
users/U with 700 and root's sent/ with 700).Recipients and sessions
6.1 · From --to to the list
resolve_recipients in src/rspeak.c turns the value of --to into the list of recipients. It does so before taking a number and before writing anything: an error here leaves no trace in the message store.
- The value is split on commas; each part is one of the four forms in the figure. Empty parts (two commas in a row) are skipped; a space, on the other hand, becomes part of the name, which therefore does not exist: this is why the help asks for names separated by commas without spaces.
- An unknown user or group ends with
die(“no such user: nobodyhere”, “no such group: x”): the unknown user is scenario B6 on the test machine. - Duplicates are removed along the way (scenario B2) and the final list is in alphabetical order: this is the order of the deliveries and of
sent/ID/recipients. - An empty list (for example a group with no members) ends with “no recipients” (scenario B7).
- An explicit name may also be a system account or root: the filter on human users applies only to
allandonline.
6.2 · Human users, logged-in users, groups
| Function (src/sessions.c) | What it returns | Source |
|---|---|---|
human_users | the users with a UID between UID_MIN and UID_MAX and a shell that does not end in nologin or false | /etc/login.defs (defaults 1000 and 60000), getpwent |
online_users | the human users with at least one logind session of class user | sd_get_sessions, sd_session_get_class, sd_session_get_uid |
group_members G | the members listed in the group plus the users whose primary group is G; false if the group does not exist | getgrnam, getpwent |
user_terminals U | U's pseudo-terminals and the consoles of U's text sessions | see chapter 6.3 |
graphical_session U | true if U has a session of type x11 or wayland | sd_session_get_type |
Everything goes through the system interfaces (getpwent, getgrnam, libsystemd's sd-login library): LDAP users or users from other name services work if the system exposes them this way. all excludes root and system accounts because they have a UID below UID_MIN or a non-login shell (scenario B4).
user and then filters them against the human users: the test VM showed that root and logged-in system accounts (for example the graphical login manager) ended up among the recipients (scenario B5). They are now excluded even when logged in.6.3 · A user's terminals
user_terminals collects the terminals to which rspeak send writes the text when sending. There are two sources, because neither is enough on its own.
| Source | What it finds | What it does not find |
|---|---|---|
/dev/pts/N owned by U | ssh with a terminal, desktop terminal emulators, tmux and screen | text consoles; terminals opened with su (they stay owned by whoever opened them) |
U's logind sessions of type tty | text consoles (tty1…tty6) | pseudo-terminals that are not a session (tmux, emulators) |
- Here the owner of
/dev/pts/Nserves only to find the candidates: at the moment of writing,write_terminalreopens the terminal and checks the owner of the opened terminal (chapter 8.4). The name/dev/pts/Ndoes not identify a session: between the search and the write it might pass to another user. - In test mode there is a single «terminal»: the file
RSPEAK_TEST_TTY, if set. - An ssh session without a terminal (
scp, remote commands) and services have no terminals: they are not touched (scenario C3).
6.4 · The graphical session
graphical_session tells whether a user has an X11 or Wayland session open. It is needed in two places, with the same code.
| Who calls it | Why |
|---|---|
rspeak send, through wake_agent | only users with an open desktop receive the wake-up or a new agent (chapter 8.5) |
rootspeak-user agent | the agent stays alive as long as the user has a graphical session, then exits (chapter 12.1) |
A user with several graphical sessions (for example a local desktop and a remote one that creates a new session) still has a single agent: the lock rootspeak-agent.lock lives in $XDG_RUNTIME_DIR, which is unique per user (chapter 12.1). The behaviour with several simultaneous desktops of the same user has not been tested.
Root with the user's identity
7.1 · The danger of symbolic links
The mailbox users/U belongs to the user, who can replace its subfolders with symbolic links, pipes or odd files. If root wrote into it directly, it would follow the link and could write anywhere; if it read from it, it could show files the user is not allowed to read. This is why every access by root to the contents of a mailbox happens in a child process with the user's identity: as_user in src/user.c, in place of the runuser used by the prototype.
In the child the user's permissions apply: a link to /etc leads to «permission denied», and the worst the user can achieve is to spoil their own copy. Scenario G1 on the test machine checks this: with the mailbox turned into a link to /etc, rspeak send writes nothing in /etc and the recipient stays “sent, not delivered”.
users/U itself is touched by root: root creates it (700) inside users/, which belongs to root, and hands it over to U with lchown. If it already exists it must be a real folder (lstat); if it is still owned by root, because a send was interrupted between creation and handover, it is handed over at the next send (scenario F6).7.2 · How the child process starts
as_user(u, fn, arg, in, out, max, seconds) runs fn(arg) in a child process with the identity of u. The parent passes it data on stdin, collects its output on a pipe and waits for it to finish, always with a time limit.
static void child(const user_t *u, as_user_fn fn, void *arg, int in, int out)
{
int nul = open("/dev/null", O_RDWR | O_CLOEXEC);
if (nul < 0 || dup2(in >= 0 ? in : nul, 0) < 0 || dup2(out >= 0 ? out : nul, 1) < 0 || dup2(nul, 2) < 0)
_exit(126);
if (geteuid() == 0 && !rspeak_test) {
/* the user's full identity: supplementary groups, then group, then user */
if (initgroups(u->name, u->gid) < 0 || setresgid(u->gid, u->gid, u->gid) < 0 ||
setresuid(u->uid, u->uid, u->uid) < 0)
_exit(126);
if (getuid() != u->uid || geteuid() != u->uid || getegid() != u->gid || setuid(0) == 0)
_exit(126);
}
if (chdir("/") < 0)
_exit(126);
umask(022);
signal(SIGPIPE, SIG_DFL);
{
int r = fn(arg);
fflush(stdout);
_exit(r & 0xff);
}
}- The order matters: first the supplementary groups (
initgroups), then the group, then the user; aftersetresuidthe group can no longer be changed. - The child checks that it can not become root again: if
setuid(0)succeeded, the identity would not really have been assumed. Any anomaly ends with exit code 126, which the parent turns into an error. - After the identity change the kernel makes the process non-inspectable by the user (not «dumpable»): U cannot read its memory while it works.
- In test mode, without root, the child keeps the identity of whoever runs the test.
7.3 · Time limits and results
The child runs with the user's identity, so the user can stop it (SIGSTOP) or slow it down. rspeak must not hang: the time limit applies both while the child is writing and afterwards, until it exits.
| Result of as_user | When |
|---|---|
the exit code of fn (0–125, 127–255) | the child exited normally |
-1 | the child did not start (pipe2, fork); it could not assume the identity (code 126); it was killed by a signal; the time ran out (the parent kills it with SIGKILL) |
| Call | Time limit | Output collected |
|---|---|---|
| delivery, revocation, reminder, deletion | 20 s | none: the exit code is what counts |
reading a mailbox (read_mailbox) | 20 s | at most 4 MiB |
| waking the agent | 5 s | none |
The pipes are created with O_CLOEXEC; the child's stdin is written in non-blocking mode, alternating with reading the output via poll (at most one second per round), so a child that does not read does not block the parent. gcc's static analysis found pipes left open in an error path of as_user: fixed (chapter 20.7).
7.4 · Operations on the mailboxes
All operations on the mailboxes are functions in src/store.c that run inside the child. They are small on purpose: each does one thing, with paths built from the message number.
| Operation | Called by | In the child (as U) | Result |
|---|---|---|---|
op_deliver | deliver_copy: sending and recovery | creates inbox read acks state if missing; if the message is already in read/ or acks/ it does nothing; otherwise it writes inbox/ID.tmp (O_EXCL|O_NOFOLLOW), renames it and checks that inbox/ID is a regular, non-empty file | 0 if the copy is there (or was already read) |
op_take | remove_copy: revocation | removes inbox/ID, state/ID.term and state/ID.gui | 0 if the copy is no longer there |
op_clear_postponements | clear_postponements: reminder | removes state/ID.term and state/ID.gui | 0 if none are left |
op_remove | remove_copies: deletion | removes inbox/ID, read/ID, acks/ID and the postponements | always 0 |
op_read | read_mailbox: list, status, TUI | for each ID received on stdin: first line of acks/ID (at most 200 bytes, only from a regular file) and most recent date of state/ID.* | lines «A ID line» and «S ID date» (chapter 10.2) |
op_wake | wake_agent | reads the ready PID, checks the command line, sends SIGUSR1 | 0 if woken (chapter 8.5) |
users/U is not there (a user never reached) there is nothing to remove and the operation succeeds without starting the child.Sending and recovery
8.1 · Options and text
cmd_send in src/rspeak.c checks everything it can check before writing to the message store: options, text, expiry, recipients. An error at this stage ends with die and leaves nothing behind.
| Option | Value | Check |
|---|---|---|
--to DEST | recipients separated by commas | mandatory (“missing --to”); resolved after the text (chapter 6.1) |
--title TITLE | one line | cleaned like the text; newlines and tabs become spaces |
--expires DURATION | 30m, 2h, 3d or a date | computed at once; it must be in the future (“the expiry is already in the past”) |
--file FILE | the text from a file | read up to 64 MiB; it counts only if there is no text on the command line |
-- | — | end of options: whatever follows is text even if it starts with - |
- Options end at the first argument that is not an option: from there on the arguments, joined by a space, are the text. Without text and without
--filethe text is read from stdin (at most 64 MiB). - An unknown option (“unknown option: --level”) or one without a value (“missing value for --to”) ends at once.
--levelbelonged to version 0.1.0 and is rejected (tests/engine.sh, group 3). - A duration is a number followed by
m,hord; everything else is interpreted bydate -d … +%sin a child process (for example"2026-10-04 12:00"). Dates in words are those understood bydate, in English. - Trailing newlines are removed from the text, which is then cleaned (chapter 8.7). A text made only of spaces is an “empty message”; beyond 65536 bytes, after cleaning, it is a “message too long”: the desktop dialog receives the text as an argument to zenity, and Linux does not accept an argument larger than 128 KiB.
8.2 · The sending sequence
After the checks, sending is a fixed sequence. The order is chosen so that every interruption leaves a state that the next command can recognise and repair.
| Step | What | Why here |
|---|---|---|
| 1 | prepare_store: creates /var/lib/rootspeak, users/ (755) and sent/ (700) if missing | RootSpeak also works without install.sh (test mode) |
| 2 | next_id: the number and the folder sent/ID/ | under flock: unique numbers (chapter 5.3) |
| 3 | sent/ID/lock taken exclusively, then the marker incomplete | from here on recovery, revocation and deletion know that a send is in progress |
| 4 | msg and recipients with write_atomic; fsync of incomplete, of sent/ID/ and of sent/ | message, recipients and marker on disk before any delivery |
| 5 | for each recipient: if revoked exists, stop; otherwise copy, terminals, wake-up, line in delivery, event DELIVERED | a revocation stops the send at the next recipient |
| 6 | event SENT with the list of recipients and the author | after the deliveries: in the log the send appears last |
| 7a | missing copies: “Message N delivered to X of Y recipients.”, then die with the names | incomplete stays; the lock is released on exit; the next command retries |
| 7b | everything delivered: syncfs of users/, fsync of sent/ID/, incomplete removed, fsync again | when the marker disappears, copies and deliveries are on disk |
admin@server:~$ sudo rspeak send --to @developers --title Maintenance --expires 1d \
"On Saturday from 8 am to 12 noon the server will be off for disk maintenance."
Message 12 sent to 2 users.8.3 · Verified delivery
“Delivered” means that the copy was in the mailbox, verified, at the time of delivery. deliver_copy in src/store.c writes and checks in the same child process; only if it succeeds does rspeak send record the line in delivery.
- Root prepares only
users/U(chapter 7.1); the subfolders and the copy are written by the child with U's identity. - If the message is already in
read/oracks/the copy is not rewritten: recovery does not present again to a user a message already read or confirmed (tests/engine.sh, group 2). - The copy is born as
inbox/ID.tmpand becomesinbox/IDthrough a rename:rootspeak-user, which lists only names made of digits, never sees a half-written file. - A failure for one recipient (mailbox not writable, link to
/etc, timeout) does not stop the others: the eventNOT_DELIVEREDgoes to the log and sending continues (scenario F1).
8.4 · Writing to the terminals
For each terminal from user_terminals (chapter 6.3), rspeak send writes the formatted text, in English whatever the language of the session. Writing to the terminal does not count as confirmation: the question comes at the next prompt.
── RootSpeak · Message from the administrator · Maintenance · 30/09 16:55 ──
On Saturday from 8 am to 12 noon the server will be off for disk maintenance.
────────────────────────────────────────| Step | What | Why |
|---|---|---|
terminal_text | header with title and date, text, closing line; every \n becomes \r\n | the terminal may be in raw mode, for example inside an editor |
write_terminal | opens with O_WRONLY|O_NOCTTY|O_NONBLOCK; fstat of the opened terminal: a character device whose owner is the recipient | between the search and the open, /dev/pts/N may pass to another user |
| writing | in pieces, with a poll of 100 ms between attempts, for at most 3 seconds | a blocked terminal (Ctrl+S, slow network) does not stop the send |
| count | the terminals written in full end up in the delivery line | “written to 2 terminals” in rspeak status |
RSPEAK_TEST_TTY, and the check on the file type is skipped; the one on the owner remains: tests/engine.sh (group 9) checks that /dev/null, owned by root, is not written to.8.5 · Waking or starting the agent
After the copy, wake_agent makes the dialog appear on the desktop at once, without waiting for the agent's periodic check. If the user has no graphical session (or in test mode) it does nothing.
- The signal goes only to a ready agent: the PID written in
/run/user/UID/rootspeak-agent.ready(at most 64 bytes, without following links), which the agent writes after it has prepared to receive the signal (chapter 12.2). - The signal is sent from a child with U's identity, and only if
/proc/PID/cmdlineis exactlyLIBDIR/rootspeak-userfollowed byagent: it cannot reach other users' processes, nor a process of U that had inherited an old PID. - If there is no ready agent,
systemd-run --user --machine=U@.host --collect --quietstarts one in the systemd user manager, which already knows the graphical environment of the session. If an agent was starting, the new one finds the lock and exits; the one that was starting reads the mailbox anyway on its first round (scenario I8: dialog in about half a second).
rootspeak-agent.ready in $XDG_RUNTIME_DIR, while rspeak send looks for it in /run/user/UID: they coincide in sessions started by logind, which is the supported case.8.6 · Recovery
recover runs at the start of almost every command (chapter 4.1) and repairs what an interrupted command left half done: sends, revocations, deletions. It is idempotent: repeating it changes nothing.
| Finds | Condition | Does |
|---|---|---|
sent/ID/incomplete | lock free, but msg or recipients missing or unreadable | writes failed with the date, removes incomplete, event CANCELLED, warning “message N was interrupted before being saved and has been cancelled” |
sent/ID/incomplete | lock free, message revoked or expired | removes the marker without delivering |
sent/ID/incomplete | lock free, valid message | for each recipient deliver_copy, including those already recorded; for the new ones: line in delivery with 0 terminals, event DELIVERED, wake-up; if none is missing: syncfs, fsync, removes incomplete; event RECOVERED with the number of new ones |
sent/ID/revoking | lock free | removes the remaining copies (chapter 9.1) |
sent/.deleting-ID/ | always | completes the deletion (chapter 9.3) |
| any marker | lock held | nothing: it is a send in progress, not an interrupted send |
- The lock tells a send in progress from an interrupted one:
rspeak sendholds it for the whole delivery and the kernel releases it when the process dies, even withSIGKILL. Without this rule recovery, started during a long send, delivered in parallel and recorded duplicates (found by the real-world suite; scenarios F3 and F5). - Copies already recorded are checked again because, after a power failure, a
deliveryline might be on disk while the copy is not (tests/engine.sh, group 9). - In the last recorded run on the test machine (scenario F3), an
rspeak sendkilled withSIGKILLafter 4 deliveries out of 68 was completed by the next command in less than a second.
incomplete and warns: “message N still not delivered to K recipients (see rspeak status N)”. Every following command retries.8.7 · Cleaning the text
A message's text and title come from an administrator but end up in the users' terminals: they must not be able to move the cursor, change the window title or write to the clipboard. clean_text in src/text.c removes, in three passes, everything a terminal could interpret as a command.
| Pass | Removed | Example | Why |
|---|---|---|---|
| 1, per line | CSI sequences: ESC [, digits, ; or ?, a final character | ESC [ 31 m | colours, cursor movements, erasures |
| 1, per line | OSC sequences: ESC ] … BEL | ESC ] 0 ; title BEL | window title, hyperlinks, clipboard |
| 2 | C0 control characters, except tab and newline; DEL | 0x01, \r, 0x7F | bell, carriage return, leftover lone escapes |
| 3 | C1 characters in UTF-8 | C2 80 … C2 9F | some terminals interpret them as commands |
- An OSC sequence without a final
BELis not removed as a sequence: itsESCdisappears in pass 2 and the rest remains as harmless text (tests/unit-tests.c). - Normal UTF-8 text stays intact, including non-breaking spaces (
C2 A0). clean_textis applied to text and title before they are saved, to whatrspeakreads from the mailboxes (chapter 10.2), to the lines ofrspeak logand to what the TUI shows of the commands it ran.- It gives the same result as the bash prototype (
sedandtr): the two were compared on 2000 random texts full of sequences and control characters before the prototype was removed. Scenario G2 checks that noESCreaches the terminal.
Revocation, reminders and deletion
9.1 · Revoking: rspeak revoke
rspeak revoke ID revokes a message: whoever has not seen it yet will not see it, open questions and dialogs close by themselves. The revocation takes place at the instant sent/ID/revoked appears: from then on no new delivery, and no confirmation with a later date counts.
| Step | What | Why |
|---|---|---|
| 1 | marker sent/ID/revoking | if the command is interrupted, recovery knows there are copies to remove |
| 2 | sent/ID/revoked with the date, written with write_atomic; fsync of revoking and of the folder; event REVOKED | the revocation is on disk before the mailboxes are touched; a second revocation of the same message rewrites neither the date nor the event |
| 3 | flock on sent/ID/lock, blocking | waits for an rspeak send in progress, which checks revoked before each recipient and stops |
| 4 | for each recipient, as the user: removes inbox/ID, state/ID.term, state/ID.gui | copies already read or confirmed (read/, acks/) stay: they are the history |
| 5 | all removed: revoking removed; “Message N revoked.” | otherwise die: “message N revoked, but some copies could not be removed from the mailboxes: the next rspeak command will retry” |
- Serialising revocation with sending comes from the second review: before, a copy could arrive after the revocation.
tests/engine.sh(group 9) checks thatrspeak revokewaits for a lock held for 2 seconds; scenario F7 revokes a send to many users while it is in progress, and no copy is left behind. - If the revocation arrives during the send,
rspeak sendends with “Message N revoked while sending: delivery stopped.”: the recipients not yet reached stay sent. - The question in the terminal and the dialog check
inbox/IDevery second and close (scenarios E3 and I6); a confirmation that arrives between their last check and the revocation does not count, because its date is later thanrevoked(chapter 13.1).
9.2 · Reminding: rspeak remind
rspeak remind ID brings the question back at once to those who have not confirmed yet, even if they had postponed it. It rewrites nothing: it removes the postponements and wakes the agent.
- A revoked or expired message cannot be reminded: “message N has been revoked: nobody to remind” (
tests/engine.sh, group 10). - The recipients' state comes from
recipients, the same function used byrspeak status: the reminder goes to those who are delivered. Those who are sent do not have the copy (recovery will complete it), those who have confirmed must not be disturbed. If everyone has already confirmed, the command says so (“Message N: everyone has already confirmed, nobody to remind.”) instead of “0 recipients reminded”, which looked like a fault in the first live test (tests/engine.sh, group 10). - For each of them, as the user,
state/ID.termandstate/ID.guiare removed; thenwake_agent. The question comes back at the next prompt (scenario D5); the dialog reappears at once (scenario I11: 0.37 s in the last recorded run). - The reminder takes no lock: if a confirmation arrives in the meantime, removing the postponements of someone who has confirmed has no effect (chapter 19.4).
admin@server:~$ sudo rspeak remind 12
Message 12: 3 recipients reminded.rspeak remind arriving within a second of “Later” was lost, because the agent recorded the postponement on its next round. Now the agent records the answer as soon as zenity closes (chapter 12.3).9.3 · Deleting: rspeak purge
rspeak purge DURATION deletes the messages sent more than DURATION ago, together with the copies, confirmations and postponements in the mailboxes. The events stay in the journal.
| Step | What |
|---|---|
| duration | a number followed by m, h or d; otherwise “invalid duration: 7x (for example 90d, 12h, 30m)” |
| selection | for each message: the sending date from msg (or, if the file cannot be read, the modification date of the folder); a message with incomplete is skipped |
| lock | non-blocking flock on sent/ID/lock: a send in progress is skipped |
| rename | sent/ID → sent/.deleting-ID, fsync of sent/, lock released, event DELETED with duration and author |
| mailboxes | for each recipient, as the user: removes inbox/ID, read/ID, acks/ID and the postponements |
| folder | removes the files in sent/.deleting-ID/ and the folder itself |
- The rename is the point at which the message stops existing for all commands:
list_numberssees only names made of digits.rspeak listreads what it needs from each message before using it and skips a message that has disappeared in the meantime (summarisereturns false). - A deletion interrupted after the rename is completed by the recovery of the next command (
tests/engine.sh, group 9). rspeak purgedoes not run recovery before itself: it skips incomplete sends anyway.
admin@server:~$ sudo rspeak purge 90d
Deleted 4 messages older than 90d..seq sequence stays), but rspeak log of a deleted number no longer finds the message: its history can then be read only with journalctl RSPEAK_MESSAGE=ID.State: list, status and the summary
10.1 · The state deduced from the files
A recipient's state is not written anywhere: recipient_state in src/rspeak.c deduces it every time from sent/ID/delivery, from the confirmation in the mailbox and from the reference copy. The expiry and revocation that count are root's, not those of the copy, which the user might have altered.
| Condition | State | Line in rspeak status |
|---|---|---|
no line for U in delivery | sent | “sent, not delivered”; if there is incomplete: “sent, not delivered: the copy could not be written (see the log; the next rspeak command will retry)” |
line in delivery, no confirmation | delivered | “delivered”, plus the details: “, written to N terminals”, “, postponed on …”; or “, not confirmed (revoked)” or “, not confirmed (expired)” |
confirmation dated after the expiry in sent/ID/msg | delivered | “delivered, not confirmed (expired; confirmation arrived after the expiry)” |
confirmation dated after sent/ID/revoked | delivered | “delivered, not confirmed (revoked)” |
| valid confirmation | confirmed | “confirmed on 01/10 17:10 (terminal pts/2)” |
- The date of the confirmation is the number before the space in
acks/ID(0 if it is not a number); the rest of the line is the channel, stored as it is shown (“terminal pts/2”, “desktop”;channel_text). - A confirmation with the same date as the revocation counts; one from the second after does not (
tests/engine.sh, group 9). - A recipient is in exactly one of the three states: the function returns a value of
state_e(SENT,DELIVERED,CONFIRMED) plus, separately, if the confirmation was late, its date and channel.
10.2 · Reading the mailboxes
rspeak status and rspeak list read the confirmations from the mailboxes, which the users control. read_mailbox in src/store.c treats them as untrusted data and reads them once per user, in a single child process.
- The child receives on stdin the numbers of the messages that exist in
sent/and looks only for those: thousands of fake files inacks/do not matter (5000 files:rspeak listunder a second,tests/engine.shgroup 9; before the fix, 3.9 s). - Of
acks/IDit reads at most 200 bytes, and only if it is a regular file opened without blocking: a pipe in place of the confirmation does not blockrspeak status(group 9). - Of
state/ID.*it reads the most recent modification date: that is the time of the postponement. - The child's output (at most 4 MiB) goes through
clean_textbefore it is interpreted: a confirmation forged with escape sequences does not reach the administrator's terminal (scenario G3). - The mailboxes read stay in memory for the whole command; the TUI forgets them at every refresh (
mailboxes_empty). Deliveries are read once per message (read_deliveries).
10.3 · A message's summary
In the list of messages, in rspeak list and in the TUI, a message is summarised by its recipients: how many are in each of the three states, in words and with a bar, plus the properties revoked and expired. No other word describes the state of a message (chapter 19.3).
/* A message summarised with its recipients. */
typedef struct {
long long id;
msg_t m;
int confirmed, delivered, sent;
bool revoked, expired;
} summary_t;| Function | What it does |
|---|---|
summarise(id, r) | reads message, recipients, deliveries and revocation date before counting; false if the message no longer exists, has no recipients or if the send was cancelled (failed) |
summary_text(r, out) | “14 confirmed · 5 delivered · 2 sent”, omitting zero counts; “all confirmed (58)” if nobody is missing |
| the TUI bar | 12 marks: █ confirmed (green), ▒ delivered (yellow), ░ sent (red); a single sent recipient still gets at least one mark |
rspeak status.10.4 · rspeak list and rspeak status
The two query commands show the same state at two levels: the list of messages and the detail of one. Both run recovery first; after that, only reads, without locks.
rspeak list: one message per line (the messages in the screenshots of chapter 14)admin@server:~$ sudo rspeak list
ID DATE TEXT RECIPIENTS
1 01/10 10:49 Tonight at 11 pm the mail server is upda all confirmed (3)
2 01/10 10:49 On Saturday from 8 am to 12 noon the ser 4 confirmed · 3 delivered · 1 sent
3 01/10 10:49 Your account expires on Friday: please d 1 delivered
4 01/10 10:49 [revoked] Reboot at 1 pm. 2 delivered- Columns: ID, sending date (“01/10 10:49”), the first 40 columns of the first line of the text (preceded by
[revoked]and[expired]), the summary phrase. - A cancelled send appears as “[send cancelled: interrupted before it was saved]”: it reports an internal marker, not a state.
admin@server:~$ sudo rspeak status 2
Message 2 · Maintenance · 01/10 10:49 · from root
│ On Saturday from 8 am to 12 noon the server will be off for disk maintenance.
│ Please save your work by Friday evening.
USER STATE
admin confirmed on 01/10 10:49 (terminal pts/0)
anna confirmed on 01/10 10:49 (desktop)
dario sent, not delivered: the copy could not be written (see the log; the next rspeak command will retry)
fabio confirmed on 01/10 10:49 (desktop)
kora delivered, postponed on 01/10 10:49
luca delivered
mario delivered, postponed on 01/10 10:49
zeno confirmed on 01/10 10:49 (terminal pts/2)- Header: number, title and date, sender; then “REVOKED on …” and “EXPIRED” if they apply; then the text, indented.
- One line per recipient, in the order of
recipients(alphabetical), with the state line from the previous table; the same lines appear in the TUI's detail view. - A non-existent or cancelled message ends with an error: “no such message: 99”, “message N was cancelled: sending was interrupted before it was saved”.
Times measured in the last recorded run (scenarios H2 and H3, 10 messages to 58 recipients on the test machine): rspeak list 0.24 s, rspeak status 0.28 s. In the prototype, before reading per user, rspeak list took about 6 s.
Receiving in the terminal
11.1 · The shell hooks
Each shell has its own hook, installed where that shell reads it. They all do the same thing: if the mailbox contains messages, they run rootspeak-user prompt, right at login and then before every prompt.
| Shell | Installed file | Read by | When it asks |
|---|---|---|---|
| bash | /usr/local/lib/rootspeak/rootspeak.bash | /etc/profile.d/rootspeak.sh (login shells) and /etc/bash.bashrc (the others) | at login and at every prompt (PROMPT_COMMAND) |
| zsh | /usr/local/lib/rootspeak/rootspeak.zsh | a line in /etc/zsh/zshrc (or /etc/zshrc) | at login and at every prompt (precmd) |
| fish | /etc/fish/conf.d/rootspeak.fish | fish itself, at startup | at login and at every prompt (fish_prompt event) |
| sh, dash, ksh… | /etc/profile.d/rootspeak.sh | /etc/profile, in login shells | only at login |
The message text reaches the terminal at send time whatever the shell: root writes it, not the shell (chapter 8.4). dash, ksh and similar shells have no place to hook into before the prompt: the question arrives at the next login, or through the desktop dialog (scenarios K of the test machine). profile.d/rootspeak.sh is written in plain sh because every shell reads it: it hands bash over to rootspeak.bash, leaves zsh to its own hook and asks the others at login.
install.sh again to add the line. fish reads conf.d even if it arrives later.11.2 · From the hook to the question
__rspeak_prompt() {
local s=$?
# A check without external processes: with an empty mailbox it costs nothing.
if compgen -G "$__RSPEAK_INBOX/[0-9]*" >/dev/null; then
@LIBDIR@/rootspeak-user prompt
fi
return $s
}- The hook acts only in an interactive shell, if
rootspeak-userexists, and once per shell (variable__RSPEAK_HOOK, in fish__rspeak_hook). - It adds
__rspeak_prompttoPROMPT_COMMAND: as an element if it is an array (bash 5.1 and later), at the front if it is a string. It preserves$?: the user's prompt sees the result of their last command. - The mailbox check uses
compgen -G, a bash builtin that starts no processes: with an empty mailbox the prompt costs nothing. zsh does the same with the(N)glob, fish withstring matchon the names (fish globs have no[0-9]). - In a login shell it calls
rootspeak-user prompt --loginstraight away: the message appears before the first prompt (scenario C1b: about 0.3 s after login). - On Debian, interactive non-login shells (the desktop terminals) do not read
/etc/profile.d: the hook is also called from/etc/bash.bashrc(chapter 3.3).
11.3 · rootspeak-user prompt
rootspeak-user prompt in src/rootspeak-user.c takes the messages in the mailbox in numeric order. If stdin is not a terminal it does nothing; the confirmation channel is the terminal name (terminal pts/3).
pending_nlistsinbox/(only names made of digits, and regular files). A message whose confirmation is already present, because of an interruption between the confirmation and the move, goes toread/without asking; an expired one goes toread/without a confirmation.- A message postponed less than
RSPEAK_REMIND_MINUTESminutes ago (modification time ofstate/ID.term) is not asked about: it is counted, and at the end the one-line reminder appears. With--loginthe postponement does not apply.
| Result of ask | What it does | Prints |
|---|---|---|
| 0 · “y” or “Y” | confirm(ID, "terminal pts/N") | “[RootSpeak] Confirmed.”; or “[RootSpeak] The message has expired: the confirmation was not recorded.”, “[RootSpeak] The administrator has revoked the message.”, “[RootSpeak] The confirmation could not be recorded: you will be asked again.” |
| 1 · anything else, or just Enter | postpone(ID, "term"): touches state/ID.term, event POSTPONED | “[RootSpeak] You will be asked again in 30 minutes.” |
| 2 · confirmed elsewhere | moves to read/ | “[RootSpeak] Message already confirmed elsewhere (for example on the desktop).” |
| 3 · revoked | nothing | “[RootSpeak] The administrator has revoked the message.” |
| 4 · expired while the question was open | moves to read/ | “[RootSpeak] The message has expired.” |
mario@server:~$
── RootSpeak · Message from the administrator · Maintenance · 01/10 10:49 ──
On Saturday from 8 am to 12 noon the server will be off for disk maintenance.
────────────────────────────────────────
[RootSpeak] Do you confirm you have read the message? [y/N] y
[RootSpeak] Confirmed.
mario@server:~$ rootspeak-user runs inside the user's prompt: it never ends with an error that would disrupt the shell, and any problem it meets (unreadable mailbox, no terminal) turns into doing nothing.11.4 · The question and the wait
ask waits for the answer on /dev/tty with poll, one second at a time, then reads it with read. At every second without an answer it checks the mailbox: if the confirmation has appeared, the user confirmed elsewhere; if the message has disappeared, it was revoked; if it has expired, the question stops.
- The terminal stays in canonical mode: whatever the user is typing stays in the terminal's buffer and is not lost from one second to the next.
- The question closes within about a second: confirmation from another session (scenario C4b, 0.95 s), revocation (scenario E3, 1.0 s), expiry (
tests/engine.sh, group 4). - Without
/dev/tty(no controlling terminal) the answer counts as “no”: the message stays and comes back later. - Only the first character of the answer is checked: only
yandYmean yes. RootSpeak speaks English, whatever the language of the session.
Receiving on the desktop
12.1 · The agent's loop
The agent, rootspeak-user agent, starts with the desktop's autostart (/etc/xdg/autostart/rootspeak-agent.desktop) or from systemd-run when rspeak send does not find one, and lives as long as the graphical session.
- Only one agent per user:
flock(LOCK_EX | LOCK_NB)on$XDG_RUNTIME_DIR/rootspeak-agent.lock; if the lock is taken the agent exits at once, without an error. The descriptor is opened withO_CLOEXECand is not passed on to zenity: otherwise, if the agent died, one of its orphaned dialogs would hold the lock and the agent started in its place would exit at once (a defect found by the test VM). - Ready: after taking the lock the agent writes its PID to
$XDG_RUNTIME_DIR/rootspeak-agent.readyand deletes it on exit (atexit).rspeak sendwakes only that PID (chapter 8.5). - Lives as long as the session: the loop goes on as long as
graphical_session(getuid())finds an X11 or Wayland session of the user, or until a termination signal arrives. - Configuration always current:
conf_readat every round; a change toRSPEAK_REMIND_MINUTESorRSPEAK_AGENT_POLLapplies from the next round. - One dialog at a time: the messages not postponed on the desktop channel (
state/ID.gui), oldest first (scenario I7).
12.2 · Signals and waits without losses
The agent must react to three things: a new message (SIGUSR1 from rspeak send), the end of the session (SIGTERM from logind) and the closing of the dialog (SIGCHLD). It handles them without losing signals and without interrupting a confirmation halfway.
| Signal | Handler | Effect |
|---|---|---|
SIGUSR1 | on_wake: wake = 1 | the wait ends and the round starts again at once; it is installed as the first statement of main, because by default SIGUSR1 terminates the process (in the prototype, 20 out of 20 agents woken while starting up died) |
SIGTERM, SIGHUP, SIGINT | on_end: fine = 1 | the agent finishes recording an answer already given and exits at the first safe point |
SIGCHLD | on_child: nothing | interrupts the wait: the dialog's answer is recorded within a few milliseconds |
- The five signals stay blocked with
sigprocmaskall the time, except during the waits, done withppoll, which unblocks and re-blocks them atomically: a signal that arrives while the agent is working stays pending and interrupts the next wait. - Each wait lasts at most one second; the one between two rounds lasts
RSPEAK_AGENT_POLLseconds (60) in total, and ends earlier if a wake-up or termination arrives. The periodic check stays as a safety net: a lost signal delays the dialog, it does not lose it. - The wake-up clears its flag at the start of the round: a wake-up that arrives while a dialog is open makes the next round start at once.
12.3 · The confirmation dialog
show_dialog starts zenity --question --no-markup --width=460 with title, text and buttons in English, whatever the language of the session, and waits for it to finish, checking the mailbox every second as well.
| How it ends | Effect |
|---|---|
| exit 0 (“I have read it”) | confirm(ID, "desktop"); if writing fails and the message is still in the mailbox, a notification says so and the dialog comes back at the next round |
| exit 1 (“Later”, or dialog closed by the user) | postpone(ID, "gui"): touches state/ID.gui, event POSTPONED; only if the display is still there: zenity also exits with 1 when it loses the display (session ended, display manager restarted) |
| closed by the agent: confirmation elsewhere, revocation or expiry | nothing: if it has expired, the message moves to read/ |
| other results (dialog killed, for example when the session ends) | nothing: it is not the user's choice; the dialog comes back at the next round or at the next login |
--no-markup: the message text is not interpreted as zenity markup. The text has already been cleaned at send time (chapter 8.7).- The child that becomes zenity restores the signal mask and handlers before
exec: zenity does not inherit the agent's blocked signals. - The end of zenity interrupts the wait at once (
SIGCHLD): the answer is recorded within a few milliseconds, so anrspeak remindarriving right after a “Later” finds the postponement already written and removes it (scenario I11; with a check once a second, the VM had found the reminder lost). - The wake-up signal does not close the dialog: the agent keeps waiting for it. In the bash prototype the signal interrupted
waitand the agent took the dialog as closed, recording a postponement never chosen (test VM, scenario I7). - When the session ends the agent waits for the dialog at most 2 seconds, then closes it: a confirmation already given is not lost (scenario I10: logout as soon as zenity has exited with the answer, confirmation recorded).
- After an exit 1 the agent tells “Later” from a lost display: the session must still be there for logind (not
closing) and the display socket ($WAYLAND_DISPLAYor/tmp/.X11-unix/XN) must accept a connection. Otherwise nothing is recorded and the dialog comes back at the next login (scenario I13). Until 1 October 2026 the end of the session was recorded as a postponement, and the dialog came back only afterRSPEAK_REMIND_MINUTES: the tests on Ubuntu, Fedora with KDE Plasma and Cinnamon found it. - Timings of the last run recorded in the VM: dialog after sending 0.58 s (I1), closing after a confirmation from the terminal 0.65 s (I5), after a revocation 0.67 s (I6), at graphical login 2.64 s after the login manager restart (I9).
12.4 · The error notification
The agent uses notify-send, if it is installed, for one case only: telling the user that their confirmation has not been recorded (disk full, permissions). The text is fixed: no message content goes through notifications, which may disappear by themselves or be ignored.
Confirmations
13.1 · The first one counts
confirm(ID, CHANNEL) in src/rootspeak-user.c is the only place where a confirmation is born. It writes it to a temporary file, flushes it to disk and publishes it with link, which fails if acks/ID already exists: the confirmation is born whole or not at all, and “already confirmed” stays distinct from “write failed”.
static int confirm(long long id, const char *channel)
{
if (exists(ack)) { mark_read(id); return 0; } /* the first one counts */
if (!has_entry("inbox", id, "")) return 3; /* revoked: no confirmation */
if (expired(id)) { mark_read(id); return 2; }
fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
if (fd < 0 || n <= 0 || write_all(fd, line, (size_t)n) < 0 || fsync(fd) < 0 || close(fd) < 0 ||
link(tmp, ack) < 0) { /* link fails if acks/ID exists */
unlink(tmp);
if (exists(ack)) { mark_read(id); return 0; } /* another confirmation came first */
event("CONFIRMATION_FAILED", id, U, channel, err, "");
return 1; /* the message stays in the mailbox */
}
unlink(tmp);
sync_path(dir); /* on disk before “Confirmed” */
mark_read(id);
event("CONFIRMED", id, U, channel, "", "");
return 0;
}| Case | Result | What happens |
|---|---|---|
| confirmation written | 0 | the message moves to read/, the postponements are removed; event in the log |
acks/ID already present | 0 | moves to read/; the first confirmation stays |
| write failed (disk full, permissions) | 1 | stays in inbox/; the user is told, the error goes to the log, the question or the dialog comes back (tests/engine.sh, group 1; scenario F4) |
| message expired | 2 | moves to read/ without a confirmation: a confirmation after the expiry does not count |
| copy gone (revoked) | 3 | no confirmation |
- The line is
date channel: seconds since the Unix epoch, a space, the channel as it is shown (desktop,terminal pts/3,terminal tty3). - The temporary file
acks/.ID.PIDstarts with a dot: it is never mistaken for a confirmation. If an interruption leaves it behind, it is ignored. - If the revocation arrives between the check of
inbox/IDandlink, the confirmation is born, butrspeak statusdoes not count it: its date is later thansent/ID/revoked(chapter 10.1). mark_readrenamesinbox/IDtoread/IDand removes the postponements. If it is interrupted between the confirmation and the move, whoever reads the mailbox next completes the move without asking again (tests/engine.sh, group 1).
13.2 · Two simultaneous confirmations
A user may have the dialog open on the desktop and the question in two terminals. The confirmation must be one: the first to arrive.
- Two almost simultaneous confirmations produce a single record:
linksucceeds only once; the other findsacks/IDand only moves the message. - The questions and dialogs still open notice it at their next check (within a second) and close: “[RootSpeak] Message already confirmed elsewhere (for example on the desktop).” in the terminal, while the dialog closes by itself (scenarios C4b and I5).
- This rule came from a live test of the prototype: the user had to confirm twice, from the dialog and from the terminal.
13.3 · The confirmations read by root
rspeak status reads the confirmations from the mailbox, which the user controls. That is why it treats them as untrusted data, and their meaning is deliberately limited.
| Risk | Rule |
|---|---|
a symbolic link acks/ID pointing to a restricted file | the read happens as the user (chapter 7): a file the user cannot read is not read |
| a pipe or a device in place of the confirmation | only a regular file is read, opened without blocking |
| a huge confirmation | at most 200 bytes are read, and only the first line |
| escape sequences in the channel | the output goes through clean_text (scenario G3) |
| a bogus date | it is just a number: it counts only if it is not later than the expiry and revocation in sent/ |
rspeak status for an audit must read it this way (chapter 19.1).The TUI for administrators
14.1 · Architecture: same data, same commands
rspeak without arguments, with stdin and stdout on a terminal of at least 80 × 20, opens the TUI of src/tui.c after recovery; in a pipe, or on a smaller terminal, it shows the help. The TUI has no way of its own of doing things and no states of its own: it reads the store with the functions of rspeak list and rspeak status, and every action is the command itself.
- Same functions for reading: the list uses
summarise(the recipients counted in the three states, revoked and expired), the detail usesrecipients(the same line asrspeak status). - The actions are the commands: sending, revoking, reminding and purging run in a child process that calls
cmd_send,cmd_revoke,cmd_remind,cmd_purgewith stdout and stderr collected on a pipe and shown in a window. An error of the command (die) ends only the child; the child's exit status gives the result. The recipients preview and the history (cmd_log) also come from a child. - The decision of the user who wanted the TUI is also a design constraint: while discussing the previews, invented states had appeared (“pending”, “complete”, “incomplete”, “cancelled”) and were removed;
tests/tui.pychecks that none of these words appears on the screen.
14.2 · The list
The list shows the messages from the most recent, one per row, with the recipients summary: a three-colour bar and a phrase. It refreshes by itself every 3 seconds.
┌─ RootSpeak · sent messages ──────────────────────────────────────────────────────────────── updated at 11:20:20 ─┐
│ │
│ ID DATE MESSAGE RECIPIENTS │
│ 4 01/10 11:20 [revoked] Reboot · Reboot at 1 pm. ▒▒▒▒▒▒▒▒▒▒▒▒ 2 delivered │
│ 3 01/10 11:20 Your account expires on Friday: ple ▒▒▒▒▒▒▒▒▒▒▒▒ 1 delivered │
│ > 2 01/10 11:20 Maintenance · On Saturday from 8 am ██████▒▒▒▒▒░ 4 confirmed · 3 delivered · 1 sent │
│ 1 01/10 11:20 Server update · Tonight at 11 pm th ████████████ all confirmed (3) │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ █ confirmed ▒ delivered ░ sent │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Enter open n new r revoke m remind p purge / search ? keys q quit │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘| Column | Content |
|---|---|
| ID, DATE | number and date of sending; > marks the selected row, drawn in reverse video |
| MESSAGE | [revoked] and [expired] in grey, the title in bold, the first line of the text, cut to the available width |
| RECIPIENTS | the 12-cell bar and the summary phrase (chapter 10.3); below 106 inner columns, instead of the phrase, “confirmed/total” (for example 4/8) |
| legend | always at the bottom: █ confirmed, ▒ delivered, ░ sent; with an active search, the searched text |
| Key | Effect |
|---|---|
| ↑ ↓ PgUp PgDn Home End | scrolls the list |
| Enter | opens the detail of the selected message |
| n | writes and sends a new message |
| r | revokes the selected message, if it is not already revoked (with confirmation) |
| m | reminds whoever has not confirmed: only if the message is neither revoked nor expired and has at least one delivered recipient (with confirmation) |
| p | deletes the messages older than a duration (with confirmation; suggested 90d) |
| / | searches by text or title, case-insensitively; Esc clears the search |
| ? | the key guide |
| q | quits and gives the terminal back as it was |
14.3 · The detail and the history
The detail of a message has two tabs: the recipients, with the status line of rspeak status coloured according to the state, and the history, that is the output of rspeak log. It is re-read every 3 seconds and after every action; Esc leaves it.
┌─ Message 2 · Maintenance ────────────────────────────────────────────────────────────────── updated at 11:20:20 ─┐
│ Maintenance · 01/10 11:20 · from root │
│ │ On Saturday from 8 am to 12 noon the server will be off for disk maintenance. │
│ │ Please save your work by Friday evening. │
│ │
│ Recipients (8) History ██████▒▒▒▒▒░ 4 confirmed · 3 delivered · 1 sent │
│ │
│ USER STATE │
│ admin confirmed on 01/10 11:20 (terminal pts/0) │
│ anna confirmed on 01/10 11:20 (desktop) │
│ dario sent, not delivered: the copy could not be written (see the log; the next rspeak command will ret │
│ fabio confirmed on 01/10 11:20 (desktop) │
│ kora delivered, postponed on 01/10 11:20 │
│ luca delivered │
│ mario delivered, postponed on 01/10 11:20 │
│ zeno confirmed on 01/10 11:20 (terminal pts/2) │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Tab recipients/history ↑/↓ scroll m remind r revoke Esc back │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘┌─ Message 2 · Maintenance ────────────────────────────────────────────────────────────────── updated at 11:20:26 ─┐
│ Maintenance · 01/10 11:20 · from root │
│ │ On Saturday from 8 am to 12 noon the server will be off for disk maintenance. │
│ │ Please save your work by Friday evening. │
│ │
│ Recipients (8) History ██████▒▒▒▒▒░ 4 confirmed · 3 delivered · 1 sent │
│ │
│ 2026-10-01 11:20:19 message 2 delivered to admin (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 delivered to anna (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2: ERROR, copy for dario not written │
│ 2026-10-01 11:20:19 message 2 delivered to fabio (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 delivered to kora (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 delivered to luca (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 delivered to mario (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 delivered to zeno (terminals written: 0) │
│ 2026-10-01 11:20:19 message 2 sent by root to: admin anna dario fabio kora luca mario zeno │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ - The header shows title, date, sender, expiry (“expires on …” or “expired on …”) and revocation (“revoked on …”, in red); then at most six lines of the text, indented.
- The tab row repeats bar and phrase: the summary stays visible while scrolling through the list.
- Colours of the STATE column: green confirmed, yellow delivered, red sent. They are the three states, and only those.
- Keys: Tab switches tab, ↑ ↓ scroll, m and r remind and revoke with the same rules as the list.
14.4 · Writing and sending
The new-message form has four fields: recipients, title, expiry and text. When the recipients field is left, a child performs the same resolution as rspeak send and shows the number of recipients, or the error in red.
┌─ New message ────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ To [ @developers ] 2 recipients │
│ Title [ Maintenance ] optional, one line │
│ Expires [ 2d ] optional: 30m, 2h, 3d │
│ Text ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ On Saturday from 8 am to 12 noon the server will be off. │ │
│ │ Please save your work by Friday evening. │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ 97 of 65536 bytes │
│ │
│ │
│ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Tab next field Ctrl+E open in the editor Ctrl+S send Esc cancel │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘┌─ New message ────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ To [ @developers ] 2 recipients │
│ Title [ Maintenance ] optional, one line │
│ Expires [ 2d ] optional: 30m, 2h, 3d │
│ Text ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ On Saturday from 8 am to 12 noon the server will be off. │ │
│ │ Please save your work by Friday evening. │ │
│ │ │ │
│ ┌─ Send the message? ──────────────────────────────────────┐ │
│ │ │ │
│ │ To: @developers (2 recipients) │ │
│ │ Title: Maintenance │ │
│ │ Expires: 2d │ │
│ │ Text: 97 bytes │ │
│ │ │ │
│ │ y Confirm n Cancel │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ 97 of 65536 bytes │
│ │
│ │
│ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ y confirm n or Esc cancel │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘| Key | Effect |
|---|---|
| Tab, Shift+Tab | next or previous field |
| Ctrl+E | opens the text in the administrator's editor |
| Ctrl+S | asks for confirmation and sends: cmd_send in a child, with the text on stdin |
| Esc | discards the message, with confirmation if something has been written |
- One-line fields accept at most 1000 bytes; the text shows the count “97 of 65536 bytes”. The real checks are those of
rspeak send: an error arrives in the result window, “Sending result”. - Editor: Ctrl+E writes the text to a temporary file in
/tmp(mkstemp), hands it over to the administrator inSUDO_USERand opensVISUAL,EDITOR,nanoorviwith their identity, not as root; then it re-reads the file (at most twice the maximum length, without following links) and deletes it. - After sending, the list reloads and the new message appears at the top (
tests/tui.py).
tests/tui.py: the child that performs the send also inherited the end of the pipe through which the parent writes the text, and never saw the end of the text. Now it closes it before calling cmd_send.14.5 · The actions with confirmation
Revoking, reminding and purging always ask for a confirmation that describes the action with the real numbers; the result of the command then appears in a window.
┌─ RootSpeak · sent messages ──────────────────────────────────────────────────────────────── updated at 11:20:26 ─┐
│ │
│ ID DATE MESSAGE RECIPIENTS │
│ 4 01/10 11:20 [revoked] Reboot · Reboot at 1 pm. ▒▒▒▒▒▒▒▒▒▒▒▒ 2 delivered │
│ 3 01/10 11:20 Your account expires on Friday: ple ▒▒▒▒▒▒▒▒▒▒▒▒ 1 delivered │
│ > 2 01/10 11:20 Maintenance · On Saturday from 8 am ██████▒▒▒▒▒░ 4 confirmed · 3 delivered · 1 sent │
│ 1 01/10 11:20 Server update · Tonight at 11 pm th ████████████ all confirmed (3) │
│ │
│ │
│ ┌─ Remind message 2? ──────────────────────────────────────┐ │
│ │ │ │
│ │ 3 recipients have not confirmed yet. │ │
│ │ │ │
│ │ They are asked again now: at the next prompt │ │
│ │ and with the dialog on the desktop. │ │
│ │ │ │
│ │ y Confirm n Cancel │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ █ confirmed ▒ delivered ░ sent │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ y confirm n or Esc cancel │
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘| Action | The confirmation says | Then |
|---|---|---|
| revoke | “Revoke message N?”, title and summary; “Whoever has not seen it yet will not see it; open dialogs and questions close by themselves.” | cmd_revoke in a child |
| remind | how many recipients have not confirmed yet; “They are asked again now: at the next prompt and with the dialog on the desktop.” | cmd_remind in a child |
| purge | first the duration (“Delete the messages sent more than (for example 90d, 12h):”), then “Messages older than D, with copies and confirmations. The events stay in the system log.” | cmd_purge in a child |
| send | recipients with their number, title, expiry, bytes of the text | cmd_send in a child |
Windows and confirmations are drawn on a copy of the base screen: a later window does not stay on top of the previous one. y confirms, n or Esc cancels.
14.6 · Terminal, keys and refresh
| Part | How it works |
|---|---|
| terminal | /dev/tty without echo or canonical mode; without IXON (so that Ctrl+S reaches the TUI) and without ICRNL; alternate screen and hidden cursor. On exit, and on SIGINT, SIGTERM, SIGHUP, everything goes back to how it was |
| drawing | without ncurses: every screen is composed in memory and written with a single write; lengths are counted in visible characters (UTF-8, without the colour sequences) |
| keys | one byte at a time with a poll of 500 ms; after ESC the rest of the sequence with a 40 ms wait: Esc on its own, arrows, PgUp, PgDn, Home, End, Del and Shift+Tab are recognised |
| refresh | every 6 empty waits (3 seconds) the list or the detail is re-read, forgetting the mailboxes already read (mailboxes_empty); at the top right “updated at …” |
| size | at every wait the size is compared (TIOCGWINSZ) and the screen redrawn; below 80 × 20 the TUI exits |
| colours | turned off with NO_COLOR (if sudo preserves it) |
clean_text as in the commands; the only external program the TUI starts on its own, the editor, runs with the administrator's identity.The built-in help
15.1 · A single source
The built-in help of rspeak --help is modelled on that of MTERM: no system manual pages, but a tabbed help in the terminal. Its texts live in src/help.c, in English, as "label¦description" items, one array per group, and are shown as they are.
| Array | Content | In the user manual |
|---|---|---|
COMMANDS | the commands: send, list, status, revoke, remind, log, purge, version, help and rspeak on its own | reference, with data-help |
OPTIONS | --to, --title, --expires, --file | reference, with data-help |
EXAMPLES | example commands with their explanation | the table of examples |
FILES | configuration, message store, log, manual | — |
SYNOPSIS | the synopsis lines | — |
The manual generator reads the items straight from the arrays of src/help.c (help_items in docs/sources/build.py), in code order, and builds from them the user manual's reference, with the data-help attribute on every entry; tools/check-docs.py reads src/help.c again at check time and verifies that commands and options match the manual, word for word (chapter 20.5).
15.2 · The three forms
rspeak_help chooses the form according to where the output goes. All three have the same content.
| Form | When | How |
|---|---|---|
| tabbed help | stdin and stdout on a terminal of at least 44 × 10 | five tabs: Info, Commands, Options, Examples, Files |
| manual-style page | smaller terminal | the page (NAME, SYNOPSIS, DESCRIPTION, COMMANDS, SEND OPTIONS, EXAMPLES, FILES, ENVIRONMENT, EXIT STATUS, SEE ALSO) as wide as the terminal, between 60 and 100 columns, through $PAGER or less -R -F -X if it is in the PATH; otherwise directly |
| plain text | stdin or stdout is not a terminal (a pipe, a file) | the same page, 80 columns, without colours |
- Colours are turned off with
NO_COLOR;PAGERandNO_COLORreachrspeakonly if sudo preserves them (env_keepin sudoers): the help says so in the ENVIRONMENT section. - The help too requires root:
rspeak --helpre-runs itself with sudo like every other command (chapter 4.1). It is the user's decision, at the cost of asking for the password even for the help. rspeakon its own in a pipe shows the help as plain text (tests/tui.py).
admin@server:~$ sudo rspeak --help | head -5
RSPEAK(1) rspeak manual RSPEAK(1)
NAME
rspeak — sends messages to the users of the machine and tracks their read
confirmation15.3 · The tabbed help
| Part | How it works |
|---|---|
| terminal | /dev/tty without echo or canonical mode (termios), alternate screen and hidden cursor; on exit and on SIGINT, SIGTERM, SIGHUP everything goes back to how it was |
| keys | one byte at a time with a poll of 0.3 s; after ESC the rest with a wait of 50 ms: Esc on its own quits; arrows, PgUp, PgDn, Home, End are recognised |
| tabs | ← →, Tab, Shift+Tab, h l, Enter, or the digits 1…5 |
| scrolling | ↑ ↓ (k j), PgUp PgDn (b Space), g G; each tab remembers its position; at the bottom “lines 1-20/57” |
| resizing | at every 0.3 s wait the help compares the size (TIOCGWINSZ) with the last one and redraws; below 44 × 10 it exits |
| drawing | the tab's lines are prepared without colours, so the lengths are the visible ones; frame, tabs and status line are written in a single write; the status line is shortened if it does not fit |
| exit | q or Esc: main screen restored, exit code 0 |
tests/help-tui.py opens the help on a 100 × 30 pseudo-terminal, presses keys like a person and checks every screen: opening and frame, tab switching with arrow and digit, end and start of a tab, resizing to 70 × 20, clean exit, and the page with the pager on a 40 × 8 terminal (chapter 20.4).
Security
16.1 · The trust model
Administrators are trusted: they can already do anything on the machine, and RootSpeak does not protect them from themselves. Users are not trusted: they control their own mailbox and their own terminals, and they can write to the journal. Everything that goes from root to a user, or comes back from a user to root, crosses a controlled boundary.
| Direction | What crosses | Control |
|---|---|---|
| root → mailbox | copies, revocations, reminders, deletions | always as the user, with a time limit (chapter 7) |
| root → terminal | the message text | cleaned; owner of the opened terminal; 3 s at most (chapter 8.4) |
| root → agent | SIGUSR1 | from a child running as the user, only to the ready PID with the exact command line |
| mailbox → root | confirmations and postponement dates | read as the user, 200 bytes, regular files only, cleaned (chapter 10.2) |
| journal → root | events with the RSPEAK_* fields | _UID compared with who was supposed to write them; states never taken from the journal (chapter 17.4) |
16.2 · Risks and countermeasures
| Risk | Countermeasure | Test |
|---|---|---|
an ordinary user runs rspeak | permissions 750 root:<admin group>; re-launch with sudo, which applies its own rules | scenarios A3, A4 |
RSPEAK_TEST used to bypass sudo | it grants nothing: without root a user keeps their own permissions; the test variables apply only in test mode | scenario G4 |
| symbolic links in the mailbox | every access by root to the mailboxes happens in a child with the user's identity, with a time limit | scenario G1 |
| abnormal mailboxes (pipes, thousands of files) against root's commands | only regular files and only existing messages are read, with a time limit | engine.sh, group 9 |
| escape sequences in messages | clean_text on text and title; --no-markup in zenity | scenario G2 |
| forged confirmations | read as the user, at most 200 bytes, cleaned; a confirmation remains a statement by the user, not a proof | scenario G3 |
| fake events in the journal | the journal adds _UID; rspeak log flags as “not trustworthy” an event written by the wrong user; states are read from the message store | scenario J3 |
| text meant for one user written to another user's terminal | the owner of the terminal is checked after opening it, not the name /dev/pts/N | engine.sh, group 9 |
| signals to the wrong processes | the signal is sent from a child with the user's identity, and only to the ready PID with the agent's exact command line | engine.sh, group 7 |
| a stuck terminal halts sending | non-blocking write, at most 3 s per terminal | — |
a user stops root's child (SIGSTOP) | time limit even after the output has ended, then SIGKILL | — |
| configuration as code | rootspeak.conf is a data file: two keys, positive integers | engine.sh, group 5 |
| an editor opened as root from the TUI | the editor runs with the identity of the administrator in SUDO_USER | — |
| text too long for the dialog | at most 65536 bytes: zenity receives the text as an argument, and Linux does not accept arguments over 128 KiB | engine.sh, group 9 |
| messages read by other users | mailboxes 700; root's sent/ 700 | — |
| memory errors in a root program | compiler hardening, static analysis, sanitizers, fuzzing (chapter 16.3) | chapter 20.7 |
/var/lib/rootspeak created before installation. Only root writes in /var/lib, and root is already trusted (second review, finding not accepted).16.3 · Compiler hardening
A root program in C that reads user data must not have memory errors. Besides the coding rules (buf_t for every text that comes from outside, no variable-length arrays, O_CLOEXEC everywhere), the code is compiled with the hardening that gcc and the linker offer.
| Option | What it does |
|---|---|
-Wall -Wextra -Wpedantic … -Werror | all the useful warnings, as errors: among others formats (-Wformat=2, -Wformat-security), shadowed names (-Wshadow), null pointers (-Wnull-dereference), variable-length arrays (-Wvla) |
-D_FORTIFY_SOURCE=3 | size checks in library functions (copies, formats) |
-fstack-protector-strong | a stack canary in functions with arrays or addresses of local variables |
-fstack-clash-protection | the stack cannot jump past the guard page |
-fcf-protection | control-flow protection (where the processor provides it) |
-fPIE -pie | position-independent executable at random addresses |
-z relro -z now | linkage tables read-only after startup |
-z noexecstack | non-executable stack |
-fanalyzer (make analyze) | gcc static analysis: error paths, descriptors not closed, use after free |
-fsanitize=address,undefined (make sanitizer) | AddressSanitizer and UBSan in the tests: any out-of-bounds access or undefined behaviour stops the test |
- Fuzzing with libFuzzer (
tests/fuzz/) exercises, with AddressSanitizer and UBSan, everything that reads untrusted data, with 4 targets: text cleaning, message format, the output of mailbox reading, configuration. - The choice of C, rather than a language that avoids memory errors by construction, is the owner's: the hardening in this table is the condition under which it was made (chapter 21.3).
The event log
17.1 · Structured events in the journal
Every relevant fact in the life of a message becomes a structured event in the system journal. The log answers a different question from rspeak status: not “where does it stand” but “what happened, when, and by whose hand”.
event(EV, ID, USER, CHANNEL, DETAILS, AUTHOR)insrc/event.cwrites withsd_journal_senda readable text (MESSAGE) and the structured fields. If the journal does not respond, it writes at least the text withsyslog.- The readable text is in English, whatever the language of the system;
rspeak logrewrites it from the fields with the same function,event_text. - The details fit on a single line: line breaks become spaces.
- The log is the history, not the state: states are always derived from the message store, never from the journal (chapter 19.1). Retention of the events follows the journal's rules, not those of RootSpeak:
rspeak purgedoes not touch them.
17.2 · The fields of an event
| Field | Content | Set by |
|---|---|---|
SYSLOG_IDENTIFIER | rspeak, for all events, including those of rootspeak-user | RootSpeak |
MESSAGE | the readable text, in English | RootSpeak |
PRIORITY | 6 (informational) | RootSpeak |
RSPEAK_EVENT | the name of the event: SENT, CONFIRMED… | RootSpeak |
RSPEAK_MESSAGE | the message number | RootSpeak |
RSPEAK_USER | the recipient, where relevant | RootSpeak |
RSPEAK_CHANNEL | terminal pts/N, desktop; for postponements terminal or desktop | RootSpeak |
RSPEAK_DETAILS | recipients, terminals written, number reminded or recovered, duration, error | RootSpeak |
RSPEAK_AUTHOR | the administrator (SUDO_USER) | RootSpeak |
_UID, _PID, time | who actually wrote the event, and when | the journal |
For an audit, journalctl RSPEAK_MESSAGE=12 -o verbose shows all the fields, _UID included; journalctl -t rspeak shows the text of all RootSpeak events.
17.3 · The events
| Event | Written by | When | Text |
|---|---|---|---|
SENT | rspeak (root) | at the end of rspeak send, after the deliveries | message 12 sent by admin to: anna mario |
DELIVERED | rspeak (root) | for every copy written and verified; also by recovery | message 12 delivered to mario (terminals written: 1) · message 12 delivered to mario (recovery) |
NOT_DELIVERED | rspeak (root) | copy not written | message 12: ERROR, copy for anna not written |
RECOVERED | rspeak (root) | recovery of an interrupted sending | message 12: delivery completed for 3 recipients after an interruption |
CANCELLED | rspeak (root) | recovery of a sending that was not saved | message 12: sending interrupted before it was saved, cancelled |
REVOKED | rspeak (root) | rspeak revoke, the first time | message 12 revoked by admin |
REMINDED | rspeak (root) | rspeak remind | message 12: 3 recipients reminded by admin |
DELETED | rspeak (root) | rspeak purge, at the rename | message 12 deleted by admin (older than 90d) |
POSTPONED | rootspeak-user (U) | “Later” or an answer other than yes | user mario: postponed message 12 (terminal) |
CONFIRMED | rootspeak-user (U) | confirmation recorded | user mario: confirmed reading message 12 (desktop) |
CONFIRMATION_FAILED | rootspeak-user (U) | confirmation could not be recorded | user mario: ERROR, confirmation of message 12 not recorded (desktop): … |
17.4 · rspeak log and trustworthiness
rspeak log ID reconstructs the history of a message from the events; the History tab of the TUI shows the same output.
- Events are searched with two conditions together:
SYSLOG_IDENTIFIER=rspeakandRSPEAK_MESSAGE=ID, and only from the sending date of the message (minus one second): a message store that has been reset reuses the numbers, the journal does not. - Anyone can write an event with the
RSPEAK_*fields to the journal:_UIDsays who wrote it, not that it is true.rspeak logexpects root (in test mode, whoever runs it) for the administrator's events and the recipient themselves forPOSTPONED,CONFIRMEDandCONFIRMATION_FAILED; if_UIDdiffers, the line begins with “[not trustworthy: written by UID N]” (scenario J3). - An event with an unknown name is shown with its
MESSAGE; every line goes throughclean_textbefore it reaches the terminal.
admin@server:~$ sudo rspeak log 1
Message 1 · Maintenance · 30/09 20:42
2026-09-30 20:42:25 message 1 delivered to mario (terminals written: 1)
2026-09-30 20:42:25 message 1 delivered to anna (terminals written: 0)
2026-09-30 20:42:25 message 1 sent by admin to: anna mario
2026-09-30 20:43:02 user mario: confirmed reading message 1 (terminal pts/2)syslog remains, which rspeak log does not read.Error taxonomy
18.1 · Three families of errors
RootSpeak distinguishes three families of errors. Those that can be detected before writing stop the command without leaving traces; those that happen during the work do not stop the other recipients and leave a mark; interruptions leave a state that the next command knows how to repair.
| Function (src/common.c) | What it does | Used by |
|---|---|---|
die(fmt, …) | flushes stdout, writes “rspeak: …” (or “rootspeak-user: …”) to stderr, exits with 1 | rspeak: every error that stops the command |
notice(fmt, …) | like die, but the program continues | recovery, to say what it has completed or cancelled |
xmalloc, xrealloc, xasprintf | if memory runs out: “out of memory” and exit 1 | everyone |
rootspeak-userdoes not usediein normal work: it runs inside the prompt or in the graphical session, and every problem it meets becomes “do nothing” or a notice to the user (chapter 11.3).- In the TUI a
dieof the command closes only the child process: its message appears in the result dialog (chapter 14.1).
18.2 · The errors of rspeak
| Message | When | What remains |
|---|---|---|
| unknown command: X (rspeak --help for the guide) | first argument not recognised | nothing |
| too many arguments for C: X (rspeak --help for the guide) | a command other than send received more arguments than it takes (list none, the others one); X is the first extra one | nothing |
| missing --to · missing value for --title · unknown option: --level | options of send | nothing |
| invalid expiry: X · the expiry is already in the past | --expires cannot be interpreted, or is in the past | nothing |
| FILE: followed by the system's explanation | --file not readable | nothing |
| empty message · message too long: N bytes (at most 65536) | text after cleaning | nothing |
| no such user: X · no such group: X · no recipients | --to | nothing |
| Message N delivered to K of M recipients. · message N not delivered to: … (see the log; the next rspeak command will retry) | some copies not written | incomplete: recovery retries |
| missing message ID · no such message: X | status, revoke, remind, log | nothing |
| message N was cancelled: sending was interrupted before it was saved | status of a cancelled sending | — |
| message N revoked, but some copies could not be removed from the mailboxes: the next rspeak command will retry | revoke | revoked and revoking: recovery retries |
| message N has been revoked: nobody to remind · message N has expired: nobody to remind | remind | nothing |
| missing duration, for example 90d · invalid duration: X (for example 90d, 12h, 30m) | purge | nothing |
/var/lib/rootspeak: … · …/sent/.seq.lock: … · sudo: … | the message store cannot be created, the lock cannot be taken, sudo does not start (system messages) | nothing, or a skipped number |
admin@server:~$ sudo rspeak send --to nobodyhere "Test"
rspeak: no such user: nobodyhere
admin@server:~$ sudo rspeak purge 7x
rspeak: invalid duration: 7x (for example 90d, 12h, 30m)18.3 · Internal results
Inside RootSpeak the functions talk to each other through numeric results. They are the contract between the parts, and the chapters that describe them use them with these values.
| Function | Results |
|---|---|
confirm (rootspeak-user) | 0 recorded or already present · 1 not recorded, the message stays · 2 expired · 3 revoked (chapter 13.1) |
ask (rootspeak-user) | 0 yes · 1 no, or no answer · 2 confirmed elsewhere · 3 revoked · 4 expired while the question was open (chapter 11.3) |
| zenity | 0 “I have read it” · 1 “Later” or dialog closed by the user · other: not a choice (chapter 12.3) |
as_user | the code of fn · −1 child not started, identity not assumed (126), time limit exceeded, child killed (chapter 7.3) |
op_deliver, op_take, op_clear_postponements | 0 success · 1 failure |
read_file, write_atomic | 0 · −1 with errno (EINVAL if it is not a regular file, EFBIG if too large) |
msg_read, msg_from_text | 0 · −1 if the file is missing, is not regular, is too large or does not have a valid date |
summarise | true · false if the message no longer exists, has no recipients or has been cancelled |
as_user treats it as a failure even if fn could have returned it.18.4 · Recovery warnings and exit status
| Warning (stderr) | What happened | Event |
|---|---|---|
| completed delivery of message N to K recipients | an interrupted sending has been completed | RECOVERED |
| message N still not delivered to K recipients (see rspeak status N) | recovery could not write some copies; it will retry | — |
| message N was interrupted before being saved and has been cancelled | a sending without msg or recipients has been closed with failed | CANCELLED |
The warnings appear at the start of any command that runs recovery, even one that has nothing to do with that message: rspeak list can therefore say “rspeak: completed delivery of message 2 to 64 recipients” (scenario F3). The command then carries on normally.
| Code | Meaning |
|---|---|
| 0 | completed |
| 1 | error: the message, which begins with “rspeak:”, says which |
| other | a sudo code, before rspeak starts |
Specification
19.1 · Authoritative sources
This chapter is the contract of RootSpeak: what any implementation must respect, regardless of how it is written. The rest of the manual describes how the C version implements it; the bash prototype (0.1.0) worked on the same message store, and the same tests apply to both. Every change to the code respects this chapter or updates it explicitly.
| Source | Authority | Written by |
|---|---|---|
sent/ID/ (msg, recipients, delivery, revoked, incomplete, revoking, failed, lock) | authoritative: what was sent, to whom, when, with which expiry, to whom it was delivered, whether and when it was revoked | root only |
users/U/ (inbox, read, acks, state) | working copy, controlled by the user: it can be deleted or altered, and the system must stay consistent | the user (and root, as the user) |
journal (SYSLOG_IDENTIFIER=rspeak) | history: events with a date and _UID, the user who wrote them (set by the journal). Anyone can write an event with the RSPEAK_* fields: _UID says who wrote it, not that it is true; rspeak log flags as not trustworthy an event written by the wrong user. States are derived from the message store, never from the journal | root and the user |
- A confirmation is a fact declared by the user in their own mailbox; it is valid only if it does not contradict root's message store: its date must not be later than the expiry written in
sent/ID/msgnor than the revocation (sent/ID/revoked). - “Confirmed” means: the mailbox contains a valid read statement from the user. It is not an action observed by RootSpeak, nor proof of reading: the user controls their own mailbox and could write it themselves. Anyone using
rspeak statusfor an audit must read it this way. - If the user deletes their own copy, the message simply shows as not confirmed; if they alter it (text, expiry), only what they see changes.
19.2 · States and transitions
Each recipient of a message is in one of three states; the state is derived from the files, according to fixed rules (chapter 10.1). There are only these three states (the owner's decision): revocation and expiry are properties of the message, not states; incomplete, failed, revoking and .deleting-ID are internal marks with which recovery completes or cancels an interrupted operation, and they are not shown as states. An interrupted sending shows for itself: some recipients stay sent.
| Transition | Precondition | Atomic operation | Effect | Event |
|---|---|---|---|---|
| sending | recipients resolved, text cleaned, at most 65536 bytes | ID with flock; lock and incomplete before anything else; msg and recipients written, renamed and flushed to disk (fsync) | all SENT | SENT (at the end of sending) |
| delivery | sending lock held, message not revoked | copy written as the user and renamed, then verified; then a line in delivery; at the end of sending everything to disk, then incomplete removed | SENT → DELIVERED | DELIVERED (or NOT_DELIVERED) |
| confirmation | copy in inbox/, not expired, no confirmation present | temporary file + link (fails if it exists), then fsync | DELIVERED → CONFIRMED | CONFIRMED |
| postponement | DELIVERED | modification time of state/ID.term or state/ID.gui | stays DELIVERED | POSTPONED |
| revocation | existing message | sent/ID/revoking and sent/ID/revoked (on disk); then the lock (waits for a sending in progress); then copies removed as the user and revoking removed | no state changes; it is no longer shown; later confirmations are not valid | REVOKED |
| expiry | — | none (time passes) | it is no longer shown; a confirmation after it is not valid | — |
| recovery | incomplete present and lock free | delivery of the missing copies and check of those already recorded | SENT → DELIVERED | DELIVERED, RECOVERED |
| revocation recovery | revoking present and lock free | remaining copies removed | — | — |
| reminder | message neither revoked nor expired | for each DELIVERED recipient: postponements state/ID.* removed, agent woken up | stays DELIVERED; the question and the dialog come back at once | REMINDED |
| deletion | older than the duration, lock free, complete | sent/ID renamed to sent/.deleting-ID; then copies, confirmations and postponements removed as the user; then the folder removed | the message no longer exists | DELETED |
19.3 · How a message is summarised
In the message list (TUI and rspeak list) a message is summarised by its recipients: how many are in each of the three states, in words and with a bar (confirmed, delivered, sent), plus the properties revoked and expired. No other word describes the state of a message.
- The phrase leaves out zero counts; if everyone has confirmed it says “all confirmed (N)”.
- Words such as “pending”, “complete”, “incomplete sending”, “cancelled” are not states and do not appear:
tests/engine.sh(group 3) andtests/tui.pycheck this. - A sending cancelled before it was saved has no recipients to count:
rspeak listreports it with a warning line, the TUI does not show it (chapter 10.4).
19.4 · Concurrent operations
For each command that changes the message store: which lock it holds, at which instant the operation is considered to have happened (the linearisation point: before that instant it has not happened, after it has, for any observer) and what happens with other commands at the same time.
| Operation | Lock | Happens when | Concurrently |
|---|---|---|---|
send | sent/.seq.lock for the ID; sent/ID/lock exclusive for the whole delivery | the message: recipients renamed; each delivery: its line in delivery | recovery and deletion skip the message; revocation stops it at the next recipient |
| recovery | sent/ID/lock if free, otherwise skips | like delivery | never works at the same time as a sending in progress |
revoke | sent/ID/lock waited for after writing revoked | sent/ID/revoked renamed into place | the sending in progress delivers nothing more; confirmations with a later date are not valid; the copies are removed once the sending has released the lock |
confirmation (rootspeak-user) | none: exclusive link | acks/ID created; what counts is the date written inside | between two confirmations the first one wins; with revocation the date decides: equal or earlier is valid, later is not; without a copy in inbox/ there is no confirmation |
purge | sent/ID/lock if free, otherwise skips | sent/ID renamed to sent/.deleting-ID | from that instant no command sees the message; list reads what it needs beforehand and skips a message that has disappeared in the meantime |
remind | none | each postponement removed | with a confirmation that arrived in the meantime: that recipient is no longer DELIVERED and removing their postponements has no effect |
list, status, log, the TUI | none (read-only) | — | they see the state at that moment: during a sending, the recipients not yet reached are SENT |
19.5 · Invariants
| # | Invariant |
|---|---|
| I1 | Every complete sent/ID has msg and recipients; an incomplete one has incomplete, a cancelled one has failed. |
| I2 | delivery contains at most one line per recipient, and only for recipients: delivery and recovery hold the same lock. |
| I3 | A line in delivery means that the copy was in the mailbox, verified, at that moment. |
| I4 | A confirmation comes into being whole or not at all, and once written it does not change: the first one counts. |
| I5 | A valid confirmation has a date no later than the expiry of the reference copy nor than the revocation. |
| I6 | A message with incomplete and a free lock is an interrupted sending: the first subsequent rspeak command completes it or, if it had not been saved, cancels it. |
| I7 | Root never reads or writes the contents of a mailbox with its own identity: always as the user. |
| I8 | The saved text and title contain no control characters or escape sequences. |
| I9 | IDs always increase (a sequence with flock); they are reused only if the message store is reset. |
| I10 | RootSpeak never locks a session and does not touch sessions without a terminal. |
| I11 | The wake-up signal goes only to a ready agent; the mailbox remains the authoritative queue: a lost signal delays the dialog by at most RSPEAK_AGENT_POLL seconds, it does not lose it. The signal neither closes nor changes a dialog that is already open. |
| I12 | A postponement is recorded only for a choice by the user (“Later”, dialog closed, an answer other than yes): a dialog closed by the system is not a postponement. |
| I13 | Durability: when incomplete is gone, copies and delivery lines are on disk; a confirmation is on disk before the user reads “Confirmed.”; revoked is on disk before rspeak revoke touches the mailboxes. After a power failure with incomplete present, recovery rechecks all the copies, including those already recorded. |
| I14 | The text is written to a terminal only if the terminal, once opened, belongs to the recipient: the name /dev/pts/N does not identify a session. |
| I15 | No root command reads from the mailboxes more than what concerns the messages in sent/, and every read has a time limit: an abnormal mailbox neither blocks nor slows down rspeak. |
19.6 · Interruptions and recovery
For each point at which a process can be interrupted, the state that remains and who repairs it.
| Interruption | Remaining state | Repair |
|---|---|---|
| after the ID, before the folder | a skipped number | none (harmless) |
folder and incomplete, without msg or recipients | sending not saved | the next command cancels it (failed, event CANCELLED) |
| during the deliveries | some recipients SENT | the next command delivers the missing copies |
after the deliveries, before removing incomplete | everything delivered, mark left behind | the next command removes the mark |
inside the confirmation, before link | a temporary file acks/.ID.PID, no confirmation | the question comes back; the temporary file is ignored |
after link, before the move to read/ | confirmation present, copy still in inbox/ | whoever reads the mailbox completes the move without asking again |
| leaving the session right after “I have read it” | confirmation recorded: the agent waits for the dialog, writes the answer as soon as it arrives and defers SIGTERM until after the write | none (scenario I10: exit as soon as zenity has the answer) |
| leaving the session with the dialog open | no postponement recorded (zenity's exit 1 counts as “Later” only if the display is still there): the dialog comes back at the next login | none (scenario I13) |
| the desktop agent dies | lock released; one of its dialogs may stay open | rspeak send or the login start a new agent; the orphaned dialog no longer records anything |
rspeak revoke after revoked, before removing the copies | revoked and revoking; some copies still in the mailboxes | the next rspeak command removes the copies (until then the recipients do not see them as revoked) |
| a mailbox unreachable during revocation | revoking remains | the next command retries |
rspeak purge after the rename | sent/.deleting-ID, some copies in the mailboxes | the next rspeak command removes copies, confirmations, postponements and the folder |
| power failure during sending | incomplete on disk; copies and delivery lines perhaps not | recovery rewrites every missing copy, even if recorded |
| power failure after “Confirmed.” | confirmation on disk | none |
| power failure at other moments | writes not flushed to disk may be missing: a postponement (the question comes back earlier) or the move to read/ (completed at the next read) | none needed |
tests/engine.sh (groups 1, 2 and 9: copy gone after an interruption, interrupted revocation and deletion), an rspeak send actually killed with SIGKILL (scenario F3) and a revocation during a sending to many users (scenario F7). A real power failure is not tested: durability relies on fsync of files and folders and on syncfs at the end of sending.19.7 · Time, revocation and expiry
| Aspect | Rule |
|---|---|
| reference | all dates are seconds since the Unix epoch on the machine's clock; users and root use the same clock |
| expiry | compared with the current time when a message is about to be shown, while a question or a dialog is open (every second), and when the confirmation is recorded; in rspeak status with the date of the confirmation |
| postponement | lasts RSPEAK_REMIND_MINUTES from the modification time of state/ID.term or state/ID.gui, each for its own channel; in the terminal, the postponement does not apply at login; for the dialog it applies even after a new graphical login |
| clock set back | expiries and postponements last longer; no message is lost |
| clock set forward | expiries and postponements arrive earlier |
| suspend and resume | the agent resumes at the next round (at most RSPEAK_AGENT_POLL seconds); the elapsed time counts for expiries and postponements |
| Situation | Revocation | Expiry |
|---|---|---|
| copy not yet shown | removed from the mailbox; not shown | stays; no longer shown |
| dialog or question open | they close (within 1 s) | they close (within 1 s) |
| confirmations already given | remain valid if dated no later than the revocation | remain valid if dated no later than the expiry |
| during sending | sending stops at the next recipient | sending continues: whoever receives the copy after the expiry never sees it (“delivered, not confirmed (expired)”) |
| text already written to the terminals | stays on screen | stays on screen |
in rspeak status | “delivered, not confirmed (revoked)” | “delivered, not confirmed (expired)” |
19.8 · Message store format
Message store format 1. Readers ignore header keys they do not know.
| File | Content |
|---|---|
sent/.seq | the last ID assigned, a number |
sent/ID/msg | header key: value (format, id, from, date, title, expires), blank line, text; UTF-8 without control characters |
sent/ID/recipients | one user name per line, no duplicates |
sent/ID/delivery | one line per delivery: user date terminals, fields separated by a space; date in seconds since the Unix epoch, terminals the number of terminals written (0 in recovery) |
sent/ID/revoked | the revocation date, in seconds since the Unix epoch |
sent/ID/failed | the cancellation date; its presence is what counts |
sent/ID/incomplete, revoking, lock | marks (the content does not matter): incomplete sending, revocation to be completed; lock is used with flock |
sent/.deleting-ID/ | a message that rspeak purge is deleting |
users/U/inbox/ID, read/ID | copy of msg |
users/U/acks/ID | date channel: date in seconds since the Unix epoch, a space, channel (desktop or terminal pts/N); the first line counts, at most 200 bytes |
users/U/state/ID.term, ID.gui | empty; the modification time counts |
19.9 · Supported and verified scope
| Requirement | |
|---|---|
| system | Linux with systemd and logind (sessions, systemd-run --user) |
| libraries and tools | glibc, libsystemd (sessions and journal), sudo, systemd-run and date -d (GNU coreutils); to build: gcc, make, pkg-config and the systemd development files (libsystemd-dev or systemd-devel) |
| desktop | zenity; a session that runs XDG autostart (/etc/xdg/autostart) |
| question in the terminal | bash, zsh or fish at every prompt; sh, dash, ksh and the other shells that read /etc/profile at login; the text at sending time with any shell |
Supported means that the requirements are met and RootSpeak is designed to work; verified that it has been tested, and how. The two are distinct.
| Environment | Supported | Verified |
|---|---|---|
| Debian 13, GNOME on Wayland | yes | yes: the automated suite in the VM (tests/vm); live on the user's PC with GNOME (sending, dialog, postponement, reminder, revocation, the question in a desktop terminal, sending and the TUI from an ssh session), and the 0.1.0 prototype on GNOME 48 |
| 22 distributions with systemd without a desktop: Debian, Ubuntu, Fedora, CentOS Stream, RHEL, Rocky, Alma, Oracle, openSUSE, SLES, Arch | yes | yes: the automated suite in a container on each, built there (tests/distros, chapter 20.4) |
| zsh, fish, ksh, dash | yes | yes: K scenarios in the containers, where the distribution packages the shell |
| other desktops: GNOME on Ubuntu and Rocky (SELinux enforcing), KDE Plasma on Fedora, Cinnamon; Wayland and X11 | yes, with zenity and XDG autostart | yes: the automated suite in a VM for each (tests/vm/desktops) |
| XFCE, MATE and other desktops | yes, with zenity and XDG autostart | no |
| systems without systemd (sysvinit, OpenRC) | no | — |
Tests and checks
20.1 · The tests and their results
Every statement about how RootSpeak behaves comes from a run. The tests are organised in levels: at the bottom the internal parts and the engine, fast and without root; at the top the test machine and the VM, where RootSpeak runs as root with real users, sessions and dialogs.
Test figures are never written by hand: each suite records its result together with the commit and the fingerprint of the code under test (tools/fingerprint.sh: SHA-256 of src/, Makefile, etc/ and install.sh), and this table reads them every time the manual is generated. “Valid for the current code” says whether the recorded fingerprint is that of the code this manual describes.
| Suite | Result | Date | Commit | Fingerprint | Valid for the current code |
|---|---|---|---|---|---|
tests/engine.sh | 76 of 76 checks passed | 2026-10-05 05:46:18 CEST | 4aa454f7cb-modified | 422f39936495 | yes |
tests/container/run.sh | 69 of 70 scenarios passed, 1 not tested | 2026-10-01 18:23:39 UTC | 81199e69f0-modified | 3368c39af24d | no: code changed after the run |
tests/vm/run.sh | 65 of 69 scenarios passed, 4 not tested | 2026-10-01 18:33:04 UTC | 81199e69f0-modified | 3368c39af24d | no: code changed after the run |
tests/distros/run.sh | 22 of 22 distributions passed | 2026-10-05 05:48:46 CEST | 4aa454f7cb-modified | 422f39936495 | yes |
tests/*/results/); fingerprint of the current code: 422f3993649520.2 · The engine: tests/engine.sh
tests/engine.sh tests the engine in test mode, without root, on a fresh temporary message store for each case, with the programs in build/ (or in RSPEAK_BUILD, for example the sanitizer build). Every defect that gets fixed adds checks that fail without the fix.
| Group | Checks |
|---|---|
| 1. confirmations | normal confirmation; write impossible (the message stays, the user is warned, no temporary file, the question comes back and then succeeds); confirmation already present (the first one is kept); confirmation recorded but not moved |
| 2. delivery | normal sending to two recipients; copy not writable for one of them (error, the other one receives it, “sent, not delivered”, incomplete-sending marker); fault fixed and recovery; simulated interruption halfway; recovery that does not bring back a confirmed message; unsaved sending cancelled; sending in progress (lock held) left alone, then completed without duplicates; sending just started not cancelled |
| 3. states | --level rejected; delivered after sending; no level in the message; postponement shown as a detail and not asked again at once; confirmed at the next login; rspeak list with the recipients in the three states and no invented state; expired shown as “delivered, not confirmed (expired)” |
| 4. expiry | the question stops if the message expires while it is open, no confirmation recorded; a confirmation timed after the expiry of the reference copy is not valid and is not counted |
| 5. configuration | the file is not executed as code; valid values read, invalid, unknown, zero and negative values ignored; the message declares format: 1 |
| 6. purge | rspeak purge 7d deletes a message from 10 days ago with its copies and confirmations, keeps the recent one and incomplete or in-progress sendings, rejects an invalid duration |
| 7. agent | PID written to rootspeak-agent.ready when it is ready; the wake-up signal to that PID does not kill it; the file disappears on exit (only with a graphical session, otherwise skipped) |
| 8. English only | whatever the language of the session, header, question, rspeak status, errors and help are in English and the administrator's text is untouched; “s” is not a yes (the message is postponed), “Y” confirms; a confirmation from a terminal is shown as “terminal” |
| 9. second review | rspeak revoke waits for a sending in progress; a confirmation later than the revocation is not valid, one in the same second is valid; copy removed while the user is answering; another user's terminal not written to; copy lost after an interruption rewritten by recovery; interrupted revocation and deletion completed; 5000 fake files in acks/ without slowdowns; a pipe in place of the confirmation; text over 65536 bytes rejected, of 65536 accepted; comments in rootspeak.conf |
| 10. reminder | a postponed question does not come back at once; rspeak remind brings it back; those who have confirmed are not reminded, and the command says so; a revoked message cannot be reminded |
| 11. arguments | an extra argument is an error and nothing is done (remind 1 1, list extra, revoke 1 2) |
At the end tests/engine.sh writes tests/results/engine.json with date, commit, fingerprint and counts; tools/check-docs.py fails if that fingerprint is not the one of the current code. tests/unit-tests.c (make unit) tests the internal parts one by one: text cleaning, messages, configuration, event texts.
20.3 · For real: the test machine and the VM
tests/container/run.sh tests RootSpeak for real in an isolated test machine: a Debian 13 container with systemd running (logind, sshd), without root on the host system. RootSpeak is built there and installed with install.sh; the users are admin (group sudo), mario and anna (group developers), luca, service (without a shell) and four users with other shells: zeno (zsh), fabio (fish), kora (ksh), dario (dash). scenarios.py runs as root, opens real ssh sessions with a terminal, answers the questions as a person would, measures the timings and records every result.
| Area | Scenarios |
|---|---|
| A. Access | root sends; an administrator sends through sudo; an ordinary user cannot run rspeak, not even the help |
| B. Recipients | user, list with duplicates, group, all (root and nologin excluded), online, non-existent user, empty group |
| C. Delivery | user logged out, then at login; user logged in (time to reach the terminal); ssh without a terminal; two sessions, confirmation in one stopping the other |
| D. Confirmation | postponement, reminder at the prompt, reminder by the administrator, question again at login, confirmation with its channel |
| E. Expiry and revocation | expired message not shown; revocation with the question pending; revocation before login |
| F. Faults | copy not writable and recovery; rspeak send killed with SIGKILL halfway; user folder left owned by root; rspeak revoke during a sending to many users; recovery concurrent with a sending in progress; confirmation not writable |
| G. Security | mailbox turned into a link to /etc; escape sequences; forged confirmation; RSPEAK_TEST from an ordinary user |
| J. Log | rspeak log rebuilds the history; the confirmation carries the _UID of whoever wrote it; a fake event written by a user is flagged |
| T. TUI | rspeak on its own as root: list with the recipients in the three states, detail and history, exit |
| K. Shells | zsh, fish, ksh and dash: question at login; text on sending; question at the prompt in zsh and fish, not in ksh and dash |
| L. Language | a user whose session is in Italian: header, question and answer in English, “y” confirms |
| H. Performance | sending to everybody (about sixty users); list with 10 messages; status |
| I. Desktop | not tested in the container (no graphical session); tested in the VM |
tests/vm/run.sh runs the same scenarios in a virtual machine with a desktop, on the test server (QEMU with KVM, without root or libvirt), plus the dialog scenarios. Each desktop is a profile in tests/vm/desktops/: the cloud image of its distribution, the packages of the desktop, the automatic login of sara (GDM, SDDM or LightDM) and the accessibility settings. prepare.sh builds the base disk of a profile once with cloud-init (users.sh adds the users), and again when the profile changes; each run starts from a clean copy of it, builds RootSpeak in the VM and puts the system and the desktop in Italian. The dialog buttons are pressed through the desktop's accessibility (desktop.py, AT-SPI), as a person would, and the screenshots come from the QEMU monitor (capture.py).
| Profile | System | Desktop | Scenarios |
|---|---|---|---|
debian-gnome | Debian GNU/Linux 13 (trixie) | GNOME on Wayland | 65 of 69 passed — older code |
fedora-kde | Fedora Linux 44 (Cloud Edition) | KDE Plasma on Wayland | 65 of 69 passed — older code |
rocky-gnome | Rocky Linux 9.8 (Blue Onyx) | GNOME on Wayland | 65 of 69 passed — older code |
ubuntu-cinnamon | Ubuntu 24.04.5 LTS | Cinnamon on X11 | 65 of 69 passed — older code |
ubuntu-gnome | Ubuntu 24.04.5 LTS | GNOME (Ubuntu session) on X11 | 65 of 69 passed — older code |
tests/vm/run.sh PROFILE (the four shells other than bash are tested in the containers)Cinnamon is tested on Ubuntu 24.04, the base of Linux Mint 22, because Mint publishes no cloud image; Rocky Linux 9 runs with SELinux enforcing, as RHEL ships it. The measurements of each run are in tests/vm/results/report.md (Debian) and in tests/vm/desktops/results/.
| Scenario | What is checked and measured |
|---|---|
| I1 · the dialog appears by itself after sending | seconds to the dialog |
| I2 · “I have read it” closes the dialog and records the confirmation (desktop) | state of the recipient |
| I12 · desktop session in Italian: the dialog is in English | title and button of the dialog |
| I3, I4 · “Later”, then the dialog comes back (test postponement: 1 minute) | seconds until it comes back |
I11 · rspeak remind after “Later”: the dialog comes back at once | seconds until it comes back |
| I5 · confirmation from a terminal with the dialog open | seconds until the dialog closes |
| I6 · revocation with the dialog open | seconds until the dialog closes |
| I7 · two messages, one dialog after the other, oldest first | order and confirmations |
I8 · agent not running: rspeak send starts it | seconds to the dialog |
| I9 · message while the desktop is closed, then graphical login | seconds after the login restarts |
| I10 · “I have read it”, then logout as soon as zenity has the answer | confirmation recorded; seconds from the click to the answer |
| I13 · logout with the dialog open | no postponement: the dialog comes back first at the next login |
RSPEAK_TEST_ONLY=i_desktopruns only the desktop scenarios.- Each run writes
results.jsonandreport.mdtotests/container/results/andtests/vm/results/, with the measurements of every scenario.
20.4 · On other distributions: tests/distros
tests/distros/run.sh runs the same real-world scenarios on every distribution listed in tests/distros/list: the families of Debian and Ubuntu, Fedora, RHEL and its rebuilds (CentOS Stream, Rocky, Alma, Oracle; RHEL itself through its free UBI images), SUSE (openSUSE and SLES) and Arch. Only distributions with systemd: RootSpeak relies on logind and systemd-run.
Each distribution gets its own image, built from the official image of the distribution by the same tests/container/Containerfile with tests/container/setup.sh: the package manager tells the family, and the script installs the compiler, sshd, the shells and the test users. The administrators' group is the one of the distribution (sudo or wheel). RootSpeak is then built inside the machine, with the compiler and the libraries of that distribution, and installed with install.sh, as a customer would. The images carry the fingerprint of the preparation: a change to setup.sh builds them again.
A shell that a distribution does not package (ksh on Arch, or the shells missing from the limited repositories of RHEL's UBI images) is “not tested”, not failed; so is a login shell that already stops on the distribution's own files before reaching RootSpeak (dash on Fedora and RHEL: /etc/profile.d/lang.sh is not POSIX).
| Name | System | Scenarios | Not tested, and why |
|---|---|---|---|
debian-12 | Debian GNU/Linux 12 (bookworm) | 69 of 70 passed | — |
debian-13 | Debian GNU/Linux 13 (trixie) | 69 of 70 passed | — |
ubuntu-22.04 | Ubuntu 22.04.5 LTS | 69 of 70 passed | — |
ubuntu-24.04 | Ubuntu 24.04.5 LTS | 69 of 70 passed | — |
ubuntu-26.04 | Ubuntu 26.04.1 LTS | 69 of 70 passed | — |
fedora-44 | Fedora Linux 44 (Container Image) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
fedora-45 | Fedora Linux 45 (Container Image Prerelease) | 69 of 70 passed | — |
centos-stream-9 | CentOS Stream 9 | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
centos-stream-10 | CentOS Stream 10 (Coughlan) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
rhel-9 | Red Hat Enterprise Linux 9.8 (Plow) | 56 of 60 passed | fish: not in the distribution's packages; ksh: not in the distribution's packages; dash: not in the distribution's packages |
rhel-10 | Red Hat Enterprise Linux 10.2 (Coughlan) | 56 of 60 passed | fish: not in the distribution's packages; ksh: not in the distribution's packages; dash: not in the distribution's packages |
rocky-9 | Rocky Linux 9.8 (Blue Onyx) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
rocky-10 | Rocky Linux 10.2 (Red Quartz) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
alma-9 | AlmaLinux 9.8 (Olive Jaguar) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
alma-10 | AlmaLinux 10.2 (Lavender Lion) | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
oracle-9 | Oracle Linux Server 9.8 | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
oracle-10 | Oracle Linux Server 10.2 | 65 of 67 passed | dash: the login shell already fails on the distribution's own files (-dash: 62: /etc/profile.d/lang.sh: Syntax error: redirection unexpected) |
opensuse-leap-16 | openSUSE Leap 16.0 | 69 of 70 passed | — |
opensuse-tumbleweed | openSUSE Tumbleweed | 69 of 70 passed | — |
sles-15 | SUSE Linux Enterprise Server 15 SP7 | 64 of 66 passed | fish: not in the distribution's packages |
sles-16 | SUSE Linux Enterprise Server 16.0 | 65 of 67 passed | dash: not in the distribution's packages |
arch | Arch Linux | 65 of 67 passed | ksh: not in the distribution's packages |
tests/distros/run.sh (the desktop scenario is never tested in a container); RootSpeak installed from the downloadable archive rootspeak-0.2.0-linux-x86_64.tar.gz, built on RHEL 9- The runs go on the test server (192.168.0.2), four distributions at a time (
RSPEAK_PARALLEL); the images live in/media/ROOTSPEAK/distros/storage, on the SSD, because the server's/is in RAM. tests/distros/run.sh fedora-44 archruns only some of them; the results are intests/distros/results/(summary.md, and thereport.mdof each one).- The desktop of other distributions is tested in a VM (
tests/vm), not here.
20.5 · The interfaces: tests/tui.py and tests/help-tui.py
The two full-screen interfaces are tested on a pseudo-terminal, pressing keys as a person would and checking the last screen drawn after each one.
| Test | Check |
|---|---|
| opening | list with bar and sentence, correct size; no invented state |
Enter, Tab | detail with the recipients and the state from rspeak status; history from the log |
n, Ctrl+E, Ctrl+S | form, preview of the recipients, text on two lines; the text goes through the editor and comes back; confirmation with recipients, title and expiry; sending result in a single dialog; the message is in the message store and at the top of the list |
m, r | reminder confirmed with y and carried out; confirmation before revoking, revocation cancelled with n, not confirmed by s, then carried out with y: “[revoked]” in the list and in the message store |
/ | the search shows only the messages containing the text |
| 80 × 24 | bar and “confirmed/total” instead of the sentence |
q | clean exit |
| 60 × 15, pipe | no TUI: the help |
| Test | Check |
|---|---|
| opening at 100 × 30 | alternate screen, frame, Info tab |
→ | Commands tab |
3 | Options tab |
4 G | end of the Examples tab |
PgUp g | start of the tab |
| resize to 70 × 20 | 20 lines of 70 columns |
q | back to the main screen, exit with code 0 |
| terminal 40 × 8 | manual-style page through the pager |
tools/tui-screenshots.sh uses the test machine to capture the real TUI screens shown in chapter 14: the colours are the ones the administrator sees.
20.6 · The documentation: tools/check-docs.py
| Check | Fails if |
|---|---|
| manuals up to date | the two manuals in docs/ differ from the ones regenerated from the sources |
| reference | a command or option entry of the built-in help is missing from the user manual, has a different description from the source text in src/help.c, or the manual documents one that does not exist |
| file map | a code file does not appear in the map in chapter 3.1, or the map lists one that does not exist |
| version | RSPEAK_VERSION does not appear in both manuals |
| common style | a manual does not embed docs/sources/style.css and docs/sources/manual.js as they are |
| links | a #anchor link does not lead to any id |
| tests | the last run of tests/engine.sh does not have the fingerprint of the current code, or has failed checks |
tools/check-docs.py is the last step before recording a change (chapter 3.5): if it passes, it prints “documentation aligned” with the number of help entries, the number of files in the map and the version.
20.7 · Live tests
Before the rewrite in C, the 0.1.0 prototype was tested live on Debian 13 with GNOME 48 on Wayland, with two accounts. On 1 October 2026 the C version, as RootSpeak, was tested live on the user's PC (Debian 13 with GNOME, after ./install.sh): sending from the administrator's session to the account user logged in on the desktop, the dialog, “I have read it” and rspeak status “confirmed … (desktop)”; a second message postponed with “Later”, brought back with rspeak remind and then confirmed. The same day, with the English-only build: the question in a desktop terminal, confirmed with “y” (terminal pts/2); a sending from an administrator connected over ssh; rspeak revoke before any answer; the TUI opened over ssh from a phone. Not yet tested live with the C version: expiry, and the question in a terminal opened over ssh.
| Tested (0.1.0 prototype) | How |
|---|---|
| immediate dialog for a logged-in user | live |
| dialog at login for a user who was not logged in | live, with a second account |
| text and question in the terminal, also over ssh | live, with ssh user@localhost |
| single confirmation across dialog and terminal | live |
list, status | live |
revoke with a pending question, expiries, errors, text cleaning | in test mode |
./install.sh; in tests run by those working on the code, test mode must always be set (chapter 4.4).20.8 · The defences of the C code
A root program in C that reads user data must have no memory errors. Besides the behaviour tests, version 0.2.0 has these defences, to be run again whenever the code that reads data changes.
| Defence | How it is run | What it found |
|---|---|---|
| all gcc warnings as errors, and the compiler hardening (chapter 16.3) | make | nothing to fix: the code was written that way from the start |
tests of the internal parts (tests/unit-tests.c) | make unit | — |
| AddressSanitizer and UBSan on all the engine and help tests, with reports written to files (none allowed) | make sanitizer, then RSPEAK_BUILD=build-san tests/engine.sh | no memory errors |
| gcc static analysis | make analyze | pipes not closed in an error path of as_user (fixed); 3 false alarms remain about the copies of the standard descriptors in a child that exits at once |
| fuzzing with libFuzzer, in a container with clang, of four targets: text cleaning, message format, output of the mailbox reading, configuration | tests/fuzz/run.sh SECONDS | a function that assumed an already zeroed structure (fixed); then 30 minutes without defects |
| comparison with the prototype: text cleaning in C and in bash on 2000 random texts | (before removing the prototype) | identical results |
Defects found and reviews
21.1 · The two external reviews
The project went through two external critical reviews, carried out by language models on the technical manual, without the code. Every finding was verified on the code or with a test before being accepted; the point-by-point detail is in docs/adversarial-review.md.
| Review | On what | What came out of it |
|---|---|---|
| first (A and B), on version 0.1.0 | real defects, specification, robustness, tests | confirmations never silently lost; verified delivery with recovery; three states for every message, without levels; structured events and rspeak log; rspeak purge; configuration as data; the “Specification” chapter; the real-world suites in the container and in the VM |
| second (C and D), on the manual with the covering note | races, durability, boundaries | revocation serialised with sending; confirmations later than the revocation not valid; terminal checked after opening; durability after a power loss; deletion in a single step; anomalous mailboxes; texts over 65536 bytes rejected; test figures tied to the code fingerprint; “supported” distinguished from “verified”; linearisation points |
- Criticisms not accepted, with the reason: “the confirmation does not prove reading” (intended: never block, chapter 19.1); “if the terminal is blocked the message does not arrive” (inaccurate: the mailbox acts as a queue); “rewrite in a compiled language right away” (the specification first; the rewrite came later);
rspeakalways as root (the user's decision); a pipe in place ofacks/IDblockingrspeak statusand an end-of-line comment inrootspeak.conf(inaccurate, tested); a/var/lib/rootspeakprepared before installation (outside the model). - A criticism rejected by mistake and then accepted: locks left stuck. The kernel releases the lock when the process dies, but the test VM showed that the agent's lock was inherited by its children.
21.2 · The defects found and fixed
| Defect | Found by | Fix | Where |
|---|---|---|---|
| an unwritable confirmation was treated as “already confirmed”: the message disappeared without a confirmation | review A | publication with an exclusive link; exit status 1, the message stays | chapter 13.1 |
an interrupted rspeak send left recipients without a copy, never recovered | review A | delivery verified and recorded; incomplete; recovery | chapter 8.6 |
| an agent woken up while starting died (20 out of 20) | review A, test | handler for SIGUSR1 as the first instruction; rootspeak-agent.ready | chapter 12.2 |
| open dialogs and questions accepted the confirmation after the expiry | review A | they close at the expiry; confirmation rejected; date compared with sent/ | chapter 19.7 |
| recovery, during a long sending, delivered in parallel and recorded duplicates | container, F3 and F5 | sent/ID/lock held for the whole delivery | chapter 8.6 |
online included root and logged-in system accounts | VM, B5 | filter for human users | chapter 6.2 |
| the agent's lock was inherited by its children: an orphaned dialog blocked the new agent | VM | O_CLOEXEC (in the prototype, closing the descriptor) | chapter 12.1 |
| logging out of the session right after “I have read it” could lose the confirmation | VM, I10 | signals blocked outside the waits; the dialog is waited for 2 s | chapter 12.3 |
| the wake-up interrupted the wait for the dialog and recorded a postponement never chosen | VM, I7 | the wake-up does not close the dialog | chapter 12.3 |
| a dialog closed by the system counted as a postponement | VM | postponement only with zenity exit status 1 | chapter 12.3 |
| a reminder within one second of “Later” was lost | VM, I11 | the answer is recorded as soon as zenity closes (SIGCHLD) | chapter 12.3 |
rspeak revoke did not wait for a sending in progress | review C | revocation on disk, then the lock | chapter 9.1 |
| a confirmation could come into being after the revocation | review C | no confirmation without a copy; date compared with the revocation | chapter 13.1 |
| the text could end up on another user's terminal | review C | owner of the opened terminal | chapter 8.4 |
| no guarantee after a power loss | review C | fsync, syncfs, recorded copies checked again | chapter 19.6 |
rspeak purge together with rspeak list: a message disappeared halfway through reading | review D | rename to .deleting-ID; read before use | chapter 9.3 |
5000 fake files in acks/: rspeak list took 3.9 s | review D | only the messages in sent/, with a time limit | chapter 10.2 |
| texts over 128 KiB did not reach the dialog | review D | limit of 65536 bytes on sending | chapter 8.1 |
| the TUI's sending child never saw the end of the text | tests/tui.py | it closes the pipe end it does not use | chapter 14.4 |
pipes not closed in an error path of as_user | -fanalyzer | closed | chapter 7.3 |
| a function assumed an already zeroed structure | fuzzing | fixed | chapter 20.7 |
| states invented in the TUI and in the specification (“pending”, “complete”, “incomplete sending”, “cancelled”) | the user, on the previews | only the three states; summary with the counts | chapter 19.3 |
The live tests of the prototype also gave rise to three rules: the first confirmation counts for all channels (one had to confirm twice), waking the agent with a signal (the dialog arrived after 3-4 seconds) and the command in /usr/local/bin instead of sbin (on Debian sbin is not in the PATH of non-root users).
21.3 · From bash to C
Version 0.2.0 is a complete rewrite in C of the bash prototype, decided by the user after the second review: the defects found almost all stemmed from bash (races between check and use, time limits, one process per operation). A language that avoids memory errors by construction had been proposed; the user chose C, with mandatory defences.
| In the prototype (bash) | In the C version |
|---|---|
runuser -u U -- … for every access to a mailbox | as_user: a child with the identity of U and a time limit; a mailbox is read in a single child (chapter 7) |
set -C, then ln for the confirmation | open(O_EXCL), fsync, link (chapter 13.1) |
pkill -F for the wake-up | op_wake: ready PID, exact command line, kill as the user (chapter 8.5) |
sed and tr for text cleaning | clean_text, with the same result on 2000 random texts (chapter 8.7) |
configuration read with source | data file, two keys (chapter 4.5) |
| texts in two languages, in shell files | in 0.2.0 gettext catalogues read by RootSpeak; English only from 1 October 2026 |
wait interrupted by the wake-up | ppoll with signals blocked outside the waits (chapter 12.2) |
21.4 · 1 October 2026: RootSpeak, English only
On 1 October 2026 the product took its final name and became English only.
- Renamed to RootSpeak: the administrators' command is
rspeak, the helperrootspeak-user, the paths/etc/rootspeakand/var/lib/rootspeak; the old name was taken for software in the EU. - English only: the translations were removed, because the product is for administrators. Every message, the terminal text, the dialog and the journal text are in English, whatever the language of the session.
- TUI keys: confirmation dialogs use
y(yes) andn(no); the reminder moved tom. - Visible names in English: the journal fields (
RSPEAK_EVENT,RSPEAK_MESSAGE…), the event names (SENT,CONFIRMED…), the marks in the message store (revoking,.deleting-ID) and the stored channel (terminal pts/N).
Known limits and open items
22.1 · Known limits
The limits below are known and accepted: they stem from the project's decisions or from what the system offers. Each one says what happens, so that whoever administers the machine knows what to expect.
| Limit | Consequence |
|---|---|
| sh, dash, ksh and similar shells have no hook before the prompt | the question arrives at login (and from the desktop); during the session only the text arrives, on sending |
| zsh installed after RootSpeak | you need to run install.sh again to add the line to the zshrc |
--expires dates in words, in English | they are interpreted by date -d; the ISO format always works |
su does not change the owner of the terminal | rspeak send does not write to that terminal on sending; the question still arrives at the prompt |
| if the desktop agent dies, its dialog stays open | the agent that replaces it opens a second dialog for the same message; the first one no longer records anything |
| a single agent per user, even with several graphical sessions | the lock is one per user; the behaviour with several desktops open by the same user has not been tested |
rootspeak-agent.ready is written in $XDG_RUNTIME_DIR (or in /tmp if it is missing) and looked for in /run/user/UID | they coincide in logind sessions; otherwise the wake-up does not find the agent, and this case has not been tested |
| the postponement on the desktop also holds after a new graphical login | unlike the terminal, where the question always comes back at login (chapter 19.7) |
without /dev/tty the question counts as “no” | a postponement is recorded; in practice the hook runs rootspeak-user prompt only with a terminal |
events written with syslog when the journal is missing | rspeak log does not read them |
| the TUI search is case-insensitive only for unaccented letters | “ä” does not find “Ä” |
| backup and restore of the message store, a real power loss | not tested (chapter 5.6, chapter 19.6) |
22.2 · Open items
| Item | Status |
|---|---|
| live test of the C version on the user's PC | done on 1 October 2026: sending (also from an ssh session), the dialog, postponement, reminder, the question in a desktop terminal, revocation, the TUI over ssh; still to do: expiry, the question in a terminal opened over ssh |
scheduled sending (--at) | planned, second version |
| reusable message templates | planned, second version |
| CSV or JSON export for audit | planned, second version |
| deb and rpm packages | planned for version 1.0 |
| tests on other distributions and desktops | done: 22 distributions and 5 desktops (chapter 20.4, chapter 20.3) |
rspeak remind: manual reminder | done (chapter 9.2) |
| tabbed interface for the administrator | done: the TUI (chapter 14) |
| rewrite in a compiled language | done: version 0.2.0 in C (decision of 30 September) |
| confirmation question in all shells | done where the shell allows it (chapter 11.1) |
| interface in each user's language | done in 0.2.0, then removed: RootSpeak is for administrators and speaks English only (decision of 1 October) |
| user reply to the administrator | not planned (decision of 30 September) |
block level, disconnections, full-screen lock | excluded: the user can always postpone |
docs/decisions-and-history.md.Appendix A — Data structures
23.1 · Index of the data structures
This appendix collects the data structures of RootSpeak, with the file that defines each one and the chapter that covers it in depth. They are the “contracts” that run through the code. None is written to disk as it is: only the text formats of chapter 19.8 go into files.
| Structure | Defined in | Role | Chapter |
|---|---|---|---|
msg_t | src/text.h | a message that has been read or is to be written | chapter 5.2 |
conf_t | src/text.h | the configuration | chapter 4.5 |
state_e | src/rspeak.h | the three states of a recipient | chapter 10.1 |
summary_t | src/rspeak.h | a message with its recipients counted in the three states | chapter 10.3 |
recipient_t, recipients_t | src/rspeak.h | the recipients with the line from rspeak status | chapter 10.4 |
mailbox_item_t, mailbox_t | src/store.h | confirmations and postponements read from a mailbox | chapter 10.2 |
user_t | src/user.h | a user: name, UID, primary group | chapter 7.2 |
as_user_fn | src/user.h | the function run in the child | chapter 7.2 |
op_t | src/store.c | argument of the mailbox operations | chapter 7.4 |
deliveries_t | src/rspeak.c | the delivery lines of a message | chapter 10.1 |
state_t | src/rspeak.c | the state of a recipient, with date and channel of the confirmation | chapter 10.1 |
buf_t, strv_t | src/common.h | growing texts and lists of strings | chapter 5.5 |
screen_t, list_t, form_t, launch_t | src/tui.c | screen, list, form and command to run in the TUI | chapter 14 |
23.2 · msg_t: a message
src/text.h/* Format 1: header "key: value", blank line, text. */
typedef struct {
long long id;
char *from;
long long date;
char *title;
long long expires; /* 0: no expiry */
char *body;
} msg_t;| Field | Type | Description |
|---|---|---|
id | long long | the message number (id:) |
from | char * | the administrator; never null after reading (empty if missing) |
date | long long | seconds since the Unix epoch; a message without a valid date is not read |
title | char * | one line, possibly empty; never null after reading |
expires | long long | msg_expired is true if it is not 0 and is less than or equal to the given time |
body | char * | everything after the first empty line, with the final line break |
msg_free frees the three texts and zeroes the structure: a zeroed msg_t can be freed safely.
23.3 · state_e, summary_t, recipient_t
src/rspeak.h/* The states of a recipient: the only three («Specification» chapter). */
typedef enum { SENT, DELIVERED, CONFIRMED } state_e;
/* A message summarised with its recipients. */
typedef struct {
long long id;
msg_t m;
int confirmed, delivered, sent;
bool revoked, expired;
} summary_t;
/* The recipients of a message with the status line of rspeak status. */
typedef struct {
char *user;
state_e state;
char *line;
} recipient_t;state_e- the only place in the code where the states are enumerated. Revoked and expired are not there: they are the two
boolfields ofsummary_t. summary_t- produced by
summarise;confirmed + delivered + sentis the number of recipients.summary_freefrees the message it contains. recipient_t- produced by
recipientsforrspeak status, for the TUI and forrspeak remind, which reminds those withstate == DELIVERED.
23.4 · mailbox_item_t and mailbox_t
src/store.htypedef struct {
long long id;
char *confirm; /* first line of acks/ID, or NULL */
long long postponement; /* most recent modification time of state/ID.*, or 0 */
} mailbox_item_t;
typedef struct {
mailbox_item_t *v;
size_t n;
} mailbox_t;A mailbox_t is built by mailbox_from_text, which parses the child's output (A ID line for confirmations, S ID date for postponements), already cleaned; malformed lines are discarded, and for each ID the first confirmation and the most recent postponement count. mailbox_from_text is also exposed for the tests and for fuzzing.
23.5 · user_t, conf_t, buf_t, strv_t
src/user.h, src/text.h, src/common.h (in brief)typedef struct { char *name; uid_t uid; gid_t gid; } user_t;
typedef int (*as_user_fn)(void *arg);
typedef struct { long remind_minutes; long agent_poll; } conf_t; /* 30, 60 */
typedef struct { char *s; size_t n, cap; } buf_t; /* s always terminated by '\0' */
typedef struct { char **v; size_t n, cap; } strv_t;user_t- from
user_find(by name) oruser_from_uid; the name is copied. conf_t- filled in by
conf_readwith the default values and the valid keys of the file. buf_t- every text that comes from outside ends up here:
buf_addchecks for size overflow, and the memory grows by doubling.buf_freebrings it back to empty. strv_t- lists of recipients, of IDs, of terminals;
strv_sortin alphabetical order,list_numbersin numerical order.
{0}) and have a function that frees them; fuzzing found precisely a function that assumed an already zeroed structure (chapter 20.7).Glossary
24.1 · Terms A–L
- administrators' group
- the existing group
sudo,wheeloradmin: RootSpeak has no group of its own. - agent
rootspeak-user agent: shows the confirmation dialogs in the graphical session; one per user (chapter 12).- as_user
- the function in
src/user.cthat runs an operation in a child process with a user's identity, with a time limit (chapter 7). - the administrator who ran
rspeak(SUDO_USER): the sender of the messages and the author of the events. - channel
- where a confirmation comes from:
desktoporterminal pts/N; it is stored as it is shown. - confirmation
- the user's statement of having read the message, in
acks/ID, with date and channel; the first one counts (chapter 13). - confirmed
- state of a recipient with a valid confirmation: not later than the expiry or the revocation. It is a statement by the user, not a proof of reading.
- delivered
- state of a recipient whose copy has been written and verified in the mailbox (line in
sent/ID/delivery) and who has no valid confirmation. - event
- a structured line in the journal, with the
RSPEAK_*fields (chapter 17). - expired
- property of a message whose expiry has passed: it is no longer shown, and later confirmations are not valid.
- fingerprint
- the abbreviated SHA-256 of the code (
tools/fingerprint.sh), recorded with the test results (chapter 20.1). - hook
- the file that connects RootSpeak to the shell:
rootspeak.bash,rootspeak.zsh,rootspeak.fishand, for the other shells,/etc/profile.d/rootspeak.sh(chapter 11). - internal marker
incomplete,revoking,failed,.deleting-ID: files through which recovery knows what to complete or cancel. They are not states.- linearisation point
- the instant at which an operation is considered to have happened for any observer (chapter 19.4).
- lock
- the file
sent/ID/lockused withflock: held byrspeak sendfor the whole delivery, it tells a sending in progress from an interrupted one.
24.2 · Terms M–Z
- mailbox
- a user's folder
/var/lib/rootspeak/users/U, owned by the user, with permissions700. - message
- a text with an optional title and expiry and a sequential number (ID).
- message properties
- revoked and expired: they apply to the message, not to a recipient, and they are not states.
- message store
- the folder
/var/lib/rootspeak: root'ssent/and the users' mailboxes (chapter 5). - postponement
- the answer “no” or “Later”: the file
state/ID.termorstate/ID.gui, whose modification time counts forRSPEAK_REMIND_MINUTESminutes. - ready
- an agent that has set up the reception of the wake-up signal and has written its PID to
rootspeak-agent.ready. - recipient
- a user the message is addressed to; the list is in
sent/ID/recipients. - recovery
recover: completes interrupted sendings, revocations and deletions at the start of the following commands (chapter 8.6).- reference copy
- the message in
sent/ID/msg, accessible only to root: the only expiry that counts. - reminder
rspeak remind: removes the postponements of the delivered recipients and wakes up their agent (chapter 9.2).- revoked
- property of a message with
sent/ID/revoked: it is no longer shown, and later confirmations are not valid. - sent
- state of a recipient who does not yet have the copy in the mailbox.
- summary
- a message described by the counts of its recipients in the three states, in words and with a bar (chapter 10.3).
- test mode
RSPEAK_TEST=1with a test message store: the code is tested without root (chapter 4.3).- TUI
- the full-screen interface for administrators:
rspeakwithout arguments in a terminal (chapter 14). - wake-up
- the
SIGUSR1signal thatrspeak sendandrspeak remindsend to the ready agent. - zenity
- the program that draws the “I have read it” / “Later” dialog on the desktop.
Index
The terms of the glossary, in alphabetical order, with their definition and the chapters that the definition refers to.