Skip to content

more corrections, tests and docs - #114

Merged
LeChatP merged 85 commits into
mainfrom
dev
Nov 4, 2026
Merged

LeChatP merged 85 commits into
mainfrom
dev

Conversation

@LeChatP

@LeChatP LeChatP commented Nov 4, 2026

Copy link
Copy Markdown
Owner

No description provided.

… good as it, in any order

* fix: since 68ea667, a task whose setuid user or setgid group does not exist is left out like a task that does not match, but only when it was better than the best task so far; a task as good as the best was checked for a tie before its credentials were resolved, so the decision depended on the order of the tasks: the valid task first, then the broken one, refused the command as a tie; the other order ran the valid task
* fix: the credentials of a task that would win or tie are resolved first, and a task that cannot run counts for neither; it is logged in both cases
* tests: a valid task and a task of an unknown user with the same score choose the valid task in any order; the test fails without the fix (the command was refused in one order), found while writing the property tests of the decision

Co-Authored-By: Claude
* tests: finder::tests::properties generates 200 policies from fixed seeds, of 1 to 3 roles of 1 to 3 tasks: roles held by the user, one of its groups or another user; tasks allowing paths, arguments, patterns, ** or every command but one, with capabilities, and running as no one written, root by name or id, nobody, or a user that does not exist; each is asked for /usr/bin/ls, /usr/bin/ls -l, ls and /usr/bin/cat
* tests: the decision does not depend on the order of the roles nor of the tasks; the chosen task is the best one, no task asked for by name scores better; a restriction (no actor, a task less, a command denied) never chooses a task that the whole policy refuses when asked for by name; JSON and CBOR files decide the same; a task of an unknown user is never chosen
* tests: the order property finds the tie of 012258c without its fix (seed 34); no dependency is added, the generator is a xorshift64* in the test; a failure prints its seed and the policies
* tests: 18 s in a debug build

Co-Authored-By: Claude
…ly the two roles it names

* fix: only the role that names another in ssd was excluded: a user who held dev, naming auditor, and auditor could still run the tasks of auditor, and a senior role inherited the tasks of the parent that another parent named; a user holding two conflicting roles must run no task of either: a conflict now holds both ways, whichever role names the other (decided by the maintainer on 2026-10-28)
* fix: the check followed the ssd of the roles named, along a chain: a naming b and b naming c excluded a for a user holding a and c, who holds nothing in conflict with a; a conflict is now only between the two roles that it names, which allows a again (decided by the maintainer on 2026-10-28)
* fix: after a failed lookup of an actor, the roles that another names in ssd are disabled too, with the roles that have an ssd field: they take part in the conflict that cannot be checked
* chore: ssd::excludes is the check of the actor matching and of the parents, without recursion nor hashing
* tests: both roles of a conflict are excluded, also with dosr -r; a chain of conflicts excludes nothing; a role named in an ssd is disabled after a failed lookup; test_ssd_counts_the_roles_held_through_parents and test_conflicting_parents_give_no_conflicting_task now expect no task of either side
* tests: finder::tests::properties checks dosr against reference rules written in the test, for random actors, parents and conflicts; it fails on the previous code (seed 2)

Co-Authored-By: Claude
* fix: calc_timeout took the whole timeout of the most specific level that has one: a task that only changed the duration lost the max_usage of its role, so a cookie meant to serve once served without limit, and the type of the role fell back to ppid; a task that only set max_usage or type got no duration, so its cookie never served; a protection must not disappear because a lower level does not mention it
* fix: each field of the timeout, type, duration and max_usage, now comes from the most specific level that sets it, else from the build-time default (RAR_TIMEOUT_TYPE, before ppid whenever a level had a timeout without a type), like the password option
* tests: a task that sets the duration, max_usage, type or nothing keeps the other fields of its role; finder::tests::properties checks, for random options at the policy, role and task levels, that each scalar field (authentication, bounding, root, umask, tty owner, password tries and delay, timeout type, duration and max_usage) comes from the most specific level that sets it; it fails on the previous code (seed 1)

Co-Authored-By: Claude
…v options give the environment of the command

* tests: finder::tests::properties reads 200 generated policies with rootasrole-core (SPolicy), as chsr does, writes them back, and checks that dosr decides the same for each command: task, path, capabilities, groups, options, PATH and environment; the policies have options at every level (scalar fields, env and path lists), setgid, parents and ssd
* tests: the five cases of "Concrete behavior example" (book/src/configuration.md) give the variables listed there
* tests: for random env options at the global, role and task levels, the variables that the command receives are those of reference rules (mode and lists inherited or replaced, keep and delete modes, check, shell functions, set); the property fails when the reference merges a list that a level replaces
* tests: the decisions of the properties get a richer user environment (a value with a slash, LD_PRELOAD, TZ)

Co-Authored-By: Claude
* tests: for random path options at the global, role and task levels, with relative directories in the lists and in the caller's PATH, the PATH of the command is the one of reference rules, with the inheritance of ResolvedPath::apply: a level that sets its default replaces the lists it defines, another adds them; delete mode starts empty, keep modes start from the caller's PATH; the directories of the policy come first, relative ones only stay in keep-unsafe mode, sub is removed, each directory once
* tests: the property fails when the reference merges a list that a level replaces (seed 1), or keeps relative directories in keep-safe mode (seed 15)

Co-Authored-By: Claude
… ancestor

* feat: a role inherited the tasks of its direct parents only, not of their parents; it now inherits those of every ancestor, each once even when the parents form a cycle, scored with the actors of the role that the user holds (decided by the maintainer on 2026-10-28, as in RBAC1)
* feat: an ancestor that the separation of duty excludes gives no task, and neither do the ancestors that the role only reaches through it, as a role excluded by SSD gave no task of its parents
* feat: for the separation of duty, the user holds every ancestor of the roles it is an actor of, not only their parents
* chore: hierarchy::parents reads the parents of a role for both plugins; an array stops no longer at its first entry that is not a name, which is skipped
* tests: a task inherited from a grandparent, a cycle of parents, an excluded ancestor that cuts the chain, a conflict with a grandparent; the reference of finder::tests::properties follows the ancestors, and fails on a hierarchy of one level (seed 71)

Co-Authored-By: Claude
* tests: senior, of which the user is an actor, inherits middle and, through it, junior, which names auditor in ssd: a failed lookup of an actor disables junior, not middle; dosr -r senior gives the task of junior; dosr -r middle and -r junior are refused, since -r names a role that the user is an actor of

Co-Authored-By: Claude
…nd dosr -r

* tests: for each generated hierarchy, the decision is checked as before, then with a random variant: the lookup of the actors of one role failed, and or dosr -r names one of the roles; the reference excludes every role that takes part in a conflict after a failed lookup, and with -r only takes the tasks of the named role, which the user must be an actor of, and of its ancestors
* tests: the property fails when the reference ignores the failed lookup (seed 6) or the role filter (seed 1), and when ssd::excludes no longer disables a role that another names after a failed lookup (seed 6)

Co-Authored-By: Claude
…that it names

* tests: cli::locality generates 300 policies with actors, commands, credentials, options at every level, ssd, parents and unknown fields, saves each through chsr, then runs one command of a catalog of 44 (options at the three levels, actors, ssd, parents, the commands, credentials, options and purpose of a task, and show commands): the saved file, read with rootasrole-core like before the command, is the same outside the part that the command names; a command that changes nothing leaves the file as it was
* tests: every edit of the catalog changes the file; the property fails when the catalog gives a command the wrong part (seed 59)

Co-Authored-By: Claude
… policy file

* fix: FileSettings::save_all moved the root settings into the policy file that they name whenever it was a file and not the root file itself: it opened that file for writing while the loaded policy file held its lock, and waited for it forever, so every chsr edit of such a policy hung; when it did not hang, it wrote the root settings in place of the policy and left the root file that dosr reads unchanged
* fix: the root settings are now always saved in their own file, and the policy in its own files, as FileSettings::load_all reads them; chsr convert -r, which relied on the move, is fixed in the next commit
* tests: a root file that names a separate policy file: an edit of the policy is saved in that file, the root file is unchanged, and the save finishes; the test fails after 20 s on the previous code instead of hanging

Co-Authored-By: Claude
… in the format asked

* fix: convert -r only changed the path and the method of the root settings, and save_all moved the whole root settings to that path, in the format of the root file: the root file that dosr reads was not changed, the new file held root settings in JSON while it named CBOR, and chsr said it succeeded; -r is meant to update the root file, so that dosr uses the new file
* fix: convert -r <type> <file> writes the policy in <file> in <type>, then the root file names it and keeps no policy of its own; convert -r <type> <root file> puts the policy back into the root file (only in the format of the root file, as before)
* fix: without --from, convert took the policy of the root file even when the root file names a policy file or a directory, so it wrote an empty policy; it now takes the named policy file, and refuses a directory, whose files are converted one by one with --from; -r refuses a directory; a file of the current policy is never overwritten
* tests: on disk, convert -r cbor writes a CBOR file with the roles, the root file names it without roles, an edit lands in it, and convert -r json back to the root file puts the roles inside; a directory is refused without an empty file; dosr reads a JSON root file that names a CBOR policy file; the convert -r test fails on the previous code

Co-Authored-By: Claude
…ot migrate

* fix: the root file was read at any version: a policy that it holds, written by a version older than 4.0.0 or newer than this one, was edited with the meaning of this version and saved with its old version, so that no later chsr migrated it; the same policy in a policy file is refused, with "No migration path found"
* fix: the root file is now refused the same way when it holds a policy (roles, options or other fields); its storage settings alone are still read at any version, so that the policy files that it names can be edited and reported by chsr upgrade
* tests: a root file at 3.0.0 or 99.0.0 that names a policy file is read; holding roles or options, it is refused with the message of a policy file, and nothing is written; the test fails on the previous code

Co-Authored-By: Claude
… meaning

* fix: dosr ignored the version of the policy, and read every document with the meaning of the current version: a 4.0 policy that chsr upgrade did not migrate (it did not run, or could not load the file because of one entry) gave the keep and check lists of the global options to a task in delete mode, which 4.0 did not, and passed EDITOR, LANG and TZ where 4.0, and the migrated file, pass nothing; a policy of a newer version, or of 3.x, was read with the meaning of this version too
* fix: dosr now reads a policy file only from UNCHANGED_SINCE (4.1.0, since which no migration rewrites a document on the way up) to the current version, decided by the maintainer: an older one, a newer one, one without a version or with an invalid one is refused with the reason in the log; in a policy directory, only that file is skipped; the storage settings of the root file are still read at any version, and a policy in the root file is checked like a policy file
* fix: a second version in a JSON policy is refused, since it would be the one checked
* feat: rootasrole-core has UNCHANGED_SINCE and read_as_is, used by dosr and by fuzzing::decide
* tests: the tests and fuzz policies under tests/fixtures were at 3.2.0, now 4.2.0; the integration test runner kept the version of target/rootasrole.json, a stale 4.0.3, and writes the current one now; the policy_decision fuzz target writes the version of the template like the installer
* tests: read_as_is at each kind of version; no migration since UNCHANGED_SINCE rewrites a document that covers every rewrite up to now, which fails when UNCHANGED_SINCE is lowered to 4.0.3; dosr on disk refuses a policy file without a version, at 4.0.3, 3.2.0, an invalid version or the next major one, reads it at 4.1.0 and at the current version, reads a root file at 3.2.0 that names a policy file, refuses a policy in a root file at 4.0.3, skips only the old file of a CBOR policy directory, and refuses a duplicate version; the test fails without the check and without the duplicate refusal

Co-Authored-By: Claude
…e it is installed

* fix: no command applied the migrations down, which only the newer version knows: after a downgrade, the policy kept the words of the newer version, which the older one reads with another meaning (4.1.0 reads ^start|stop$ as ^start or stop$, and does not know the password and tty options or the from of setgid), while the 4.2.0 release notes describe the downgrade
* feat: chsr upgrade --to <version>, decided by the maintainer, writes every loaded file for an older version that has a migration path, root file first, in the format of the file, with the changed values like chsr upgrade; --check writes nothing and fails; a newer version, a version without a migration path or not a version is refused and nothing is written; the current version is chsr upgrade; below 4.1.0, chsr says that dosr of this version refuses the files until the older version is installed
* fix: the downgrade to 4.1.0 removed the password and tty options only under the key options: a CBOR policy, which writes o, kept them
* feat: rootasrole-core has migrate_document_to, writable_version and FileSettings::write_for, which writes a document in a locked file; the version of dosr refusing a newer policy now names chsr upgrade --to
* tests: --to a newer version, 3.0.0, 4.1 and --check write nothing; --to 4.1.0 writes the version, removes password, tty and setgid from, groups the top-level alternation, and this version upgrades the file again; --to 4.0.3 writes the env patterns as regexes; a CBOR policy directory is written for 4.1.0 without the options of 4.2 under the short keys, and the root file only changes its version; the removal under o fails on the previous code; migrate_document_to and writable_version at each kind of version

Co-Authored-By: Claude
…k that dosr refuses an older one

* tests: the planted policy file of e2e.sh had no version, which dosr refuses since 2073374: the check that the same file with the immutable flag is used would fail; it is written at the installed version now
* tests: e2e.sh checks that a policy file at 4.0.3, or without a version, is refused even with the immutable flag, and the same file at the installed version is used; 26 checks pass on the Debian box, with the 5 integration tests

Co-Authored-By: Claude
…ile names another one

* fix: when the root file named a policy where chsr loaded no file (a file that does not exist, an empty directory, or a directory whose files cannot be loaded), chsr edited the policy of the root file and said it succeeded; dosr reads the policy only where the root file names it, so it read none and refused every command, and the edit changed nothing for it; chsr convert converted that policy too
* fix: these commands now fail and name the remedy: add a policy file like default.json with its version, or name another policy with chsr editor <root file>, which still edits the root file; the policy of the root file is edited only when it is the one that dosr reads
* feat: rootasrole-core FileSettings::policy_path gives the policy that the root file names when it is not the root file itself
* tests: an edit, show and convert with a root file that names a missing file or an empty directory fail and write nothing, an edit too with a directory whose only file cannot be loaded, and the root file that names itself is still edited; the test fails with the previous fallback of the edits and of convert

Co-Authored-By: Claude
* fix: at the end of the input, like chsr editor < /dev/null, the question that confirms the configuration read an empty answer, the default one, and saved the edit without a yes; after an invalid edit, the question to edit again read the same empty answer and started the editor again, forever
* fix: the end of the input now ends the editor without saving, with "No answer: nothing is saved."; an empty line is still the default answer
* tests: with no input, a valid edit and an invalid one each start the editor once, save nothing and leave the policy as it was; the test fails on the previous code instead of hanging

Co-Authored-By: Claude
…sr reads

* fix: a root file whose storage names its own path holds the policy, and dosr reads it there; chsr loaded the data path instead (/etc/security/rootasrole.d/ by default) when it was another path, edited its files, and ignored the policy of the root file with a warning; chsr convert -r json <root file> writes this layout since 0354f22, so every later edit went to the directory that dosr no longer read
* fix: the policy is now where the root file names it, else at the data path, like dosr reads it, in one function that load_all uses
* tests: a root file that names itself, with a data path that is a directory holding a policy, loads no policy file; without a path, the directory is loaded; the test fails on the previous code

Co-Authored-By: Claude
…ne that the root file names

* fix: convert only looked for a directory in the path that the root file names: a root file without a path has its policy at the data path, which can be a directory, and convert took its first file as the whole policy
* fix: the directory is now the policy that chsr loaded, named or at the data path
* tests: a root file without a path, whose data path is a directory of two files: convert is refused and writes nothing; the test fails on the previous code

Co-Authored-By: Claude
…saving them

* fix: chsr editor <root file> saved any storage settings: a method that is not the format of the policy file, or a path to a file of another format, made the policy unreadable for chsr, which no longer started, and for dosr, which refused every command, until the root file was repaired by hand from a root shell
* fix: decided by the maintainer: when the method or the policy that the root file names changes, chsr reads that policy in the new format, without locking it, the file or every file of a directory, which must hold one; a root file that holds the policy keeps the format of the root file; otherwise the editor says why and asks to edit again; a change of immutable only is not checked, and the editor can still move the policy between a file and a directory
* feat: rootasrole-core FileSettings::storage_check and StorageCheck, which finds the policy like load_all and dosr
* tests: the check accepts the loaded settings, a change of immutable, a CBOR file in CBOR, a directory of JSON files and the root file itself, and refuses a JSON file in CBOR and the reverse, a missing file, an empty directory, a JSON directory in CBOR and the root file in CBOR, while this process holds the lock of the policy; the editor that writes cbor for a JSON policy file shows the reason, saves nothing and leaves the root settings as they were; both fail when the check accepts everything, the editor test also when the editor ignores it

Co-Authored-By: Claude
…d never saves two

* fix: the editor edits the policy without its version, which chsr writes when it saves the file; a version typed in the editor, which a policy written by hand carries, was kept with the unknown fields and saved next to it: dosr refuses a policy with two versions since 2073374, so every rule of that file stopped working, and chsr then read the typed one and migrated the content from it
* fix: a typed version older than this one migrates the content from it, like a file of that version, and the editor shows the changed values before the confirmation; an unknown or invalid version is refused and the editor asks to edit again; the version is not kept, chsr writes its own; without a version, or with the current one, the content is still read from the text, whose errors give the line and the column
* tests: an edit without a version, with the current one under version or v, saves one version; an edit at 4.0.3 shows and saves its migration; 3.0.0, the next major version, x and a number are refused and nothing is saved; the duplicate fails when the typed version is not forgotten; the tests that typed 1.0.0, which no version knows, type none now

Co-Authored-By: Claude
…and dosr shows nothing of it

* fix: a policy file without a version stays refused, decided by the maintainer; the log said only "the policy has no version", it now names the field to write, like "version": "4.2.0", for a policy written by hand
* fix: the reason is in syslog only, decided by the maintainer: dosr shows nothing of the policy to the user who runs it, the terminal keeps "Configuration error"
* tests: the message names the field; e2e.sh checks that a policy file without a version is refused with the reason in the journal of dosr and nothing about it on the terminal; 26 checks pass on the Debian box, with the 5 integration tests

Co-Authored-By: Claude
* feat: after "Configuration error", or after any error when a policy file was refused (skipped in a policy directory, writable by others, without the immutable flag, missing, or a root file that cannot be read), dosr adds "see the reason with `journalctl -t dosr`, or ask your system administrator"; a plain denial adds nothing, and the terminal still shows nothing of the policy, decided by the maintainer
* feat: the command is the build variable RAR_LOG_COMMAND in the [env] of .cargo/config.toml, journalctl -t dosr upstream, read with env! like the other build values; a distribution without journald sets its own, and an empty one gives "see the reason in the system log"; the Debian copies of [env] need it
* feat: the decision says whether the log holds the reason, in its result: find_best_exec_settings returns a Failure, the error and reason_logged, which dosr_main reads; a policy file gives a FileError, Denied when it allows nothing of the command, Refused when dosr refused it and logged why, which PermissionDenied alone did not tell apart; the directory loop moves to best_of_directory, and no state is kept between decisions
* fix: the reasons that only reached debug!, which the release logger drops, are now in syslog: an invalid policy file, a root file or a policy directory that cannot be read, a policy file that cannot be opened or whose evaluation fails, an invalid ssd or parents value, a skipped file of a directory; the Landlock errors of a task, which were not logged at all, too
* tests: the failure of a decision, for a policy directory and a policy file: a plain denial has no logged reason, a file without a version, that others can write, or with an invalid ssd has one, and so have a missing policy file and a root file that cannot be read or is missing; each of 10 mutations of the classification fails the test
* tests: e2e.sh checks the hint after a file without the immutable flag and after a file without a version, none after a plain denial, and that the test user reads the reason with journalctl -t dosr; 26 checks pass on the Debian and Arch boxes, with the 5 integration tests

Co-Authored-By: Claude
* fix: a command given by its absolute path logged "match_path: found better match" at info level, which the release logger sends to syslog: one line at each run, in the journal that dosr now tells the user to read, and one syslog message for each task whose command matches better; the same line for a command found in the PATH was already at debug level
* tests: the probe of the Debian and Arch boxes showed the line at priority 6 after every run of dosr /usr/bin/true

Co-Authored-By: Claude
* feat: RAR_LOG_COMMAND is journalctl -t dosr -p info upstream, decided by the maintainer: since Linux-PAM 1.6.0, pam_unix tries setuid(0) before running unix_chkpwd, even when the caller is not root, and logs "setuid failed: Operation not permitted" at debug level when it cannot; it then runs unix_chkpwd, which checks the account as before; this line came at each run of dosr in the journal that the hint names, among the reasons of a refused policy, and -p info leaves it out; dosr logs nothing below info in release
* fix: no setuid(0) and no CAP_SETUID around the account management of PAM: the line is not an error, and the account check does not need it; on the Debian and Arch boxes, an expired account is refused by pam_unix with the line logged before
* tests: e2e.sh reads the reason with the command that dosr names, and a new check runs a command and reads the journal with it: the execution line is there, the line of pam_unix is not, and no match_path line is logged at any level

Co-Authored-By: Claude
* fix: a CBOR file of the policy began with version, options and roles, and a CBOR root file with version: Versioning flattened its data with #[serde(flatten)], whose serializer is always human-readable (serde does not forward is_human_readable), so the data wrote its long keys; only the levels below had the short ones
* fix: a CBOR file now begins with v, o and r, which every 4.x reader of dosr and rootasrole-core already accepts, so no version is needed; JSON is unchanged, byte for byte, on the default policy, the fixtures and a policy that sets every field
* feat: rootasrole-core VersionedData, the fields that a versioned document writes next to its version, with the keys of the format; implemented by SPolicy, RootSettings, and SettingsContent, written as the storage of a root file like dosr reads it; ser::field gives the long or the short key
* tests: the top-level keys of a policy file and of a root file in CBOR and in JSON, failing when the version or the data take the long keys; dosr decides the same on the files that rootasrole-core writes like chsr, in JSON and in CBOR, from a root file that holds the policy or that names a policy file, on 50 generated policies with every option

Co-Authored-By: Claude
* chore: the next release is 4.3.0, decided by the maintainer: chsr upgrade --to is a new command, and the CBOR policy gets one-character keys for its options, its actors and the root file, which 4.2.0 does not read
* feat: migration edge from 4.2.0 to 4.3.0, without any change for now; every released version needs one, otherwise its documents cannot be migrated
* tests: a 4.2.0 document with its own options (password, tty, setgid from) goes up to 4.3.0 and back down unchanged, but for its version

Co-Authored-By: Claude
…character keys

* feat: decided by the maintainer after the review of the CBOR policy: the options, the actors and the storage settings of the root file were the only parts of the policy still written with their long keys in CBOR
* feat: options p, e, r, b, a, i, w, t, k, y, u for path, env, root, bounding, authentication, execinfo, workdir, timeout, password, tty and umask; path and workdir d, f, a, s for default, fallback, add and sub; env d, o, v, k, c, s for default, override_behavior, set, keep, check and delete; timeout t, d, m for type, duration and max_usage; password t, d for tries and delay; tty o for owner
* feat: an actor alone is {"u": user} or {"g": groups}, one with other fields keeps the typed form; the root file is {"v", "s": {"m": method, "s": {"i": immutable, "p": path}}}
* feat: rootasrole-core and dosr read the short keys next to the names, in CBOR and in JSON; JSON is written as before, byte for byte, on the default policy, the fixtures and a policy that sets every field
* feat: the downgrade to 4.2.0 gives the storage settings, the options and the actors their names back, the actors in the typed form that every 4.x version reads; the older short keys stay, 4.2.0 reads them; the levels are walked one by one, since o is also the from of a setgid selector; the upgrade changes nothing, UNCHANGED_SINCE stays 4.1.0
* perf: the policy that sets every field takes 660 bytes in CBOR instead of 932, its root file 58 instead of 93; with options in each of 10 000 tasks, dosr decides in 35 to 38 ms on the file that chsr writes, against 39 to 40 ms with the long keys (3.5 to 3.8 µs per task instead of 3.9 to 4.0, three runs)
* chore: the serializers of these types are written by hand with ser::field, like those of the roles and the tasks, and the derive only reads them; ser::List replaces lhs_serialize and lhs_serialize_envkey
* tests: the CBOR of every option and of its fields, of the actors and of a root file, JSON unchanged and both read back; a CBOR policy and root file written for 4.2.0; the decision of dosr on the files that core writes now compares the password, the tty owner, the umask and the workdir too; each of 17 mutations, a short key that dosr or core does not read, a key written in full, or a name that the downgrade forgets, fails a test; perf indicator perf_written_by_chsr

Co-Authored-By: Claude
* tests: CVE-2019-14287, -u#-1 ran the command as root: -u and -g with #-1, -1 or 4294967295 are refused before the policy is read, since dosr takes users and groups by names that exist; the test fails when a number is read as an id
* tests: CVE-2010-1646, a second PATH bypassed secure_path: two PATH entries of the user, even kept by name, give the PATH of the path options, in delete and keep mode; the test fails when the PATH of the user wins
* tests: CVE-2010-2956, -g gave a group that the rule did not list: a group that a task does not give is refused or changes nothing, for a task without cred, with setuid only, with fixed groups, with a selector without it and with from caller, and a task that lists it gives it; the test fails when fixed groups accept any -g
* tests: the review of the 33 advisories of sudo and of the list of sudo-rs found the other applicable CVEs covered by existing tests; -u and -g are ignored, not refused, by a task that does not set them

Co-Authored-By: Claude
…xits

* fix: flush_left wrote the ring buffer with one write_into, which writes once from the first of its two slices: when the buffer wrapped around its end (a terminal slower than the command), the second slice stayed behind the bytes read from the pty, so the end of the output came out of order (the runner flushes twice) or was lost; sudo-rs writes both slices with one write_vectored
* fix: the ring buffer is written until it is empty, a write of 0 bytes being WriteZero
* tests: test_pipe_flush_left_after_the_buffer_wrapped, 6 KiB through the buffer, 6 KiB that wrap around its end and a last line still in the pty; the former flush_left gave 8220 bytes of 12316

Co-Authored-By: Claude
* fix: the loop over the tasks of a role read the unknown fields and the unknown option fields of the role instead of those of the task, and swapped the names of the role and the task: a field misspelled in a task ("option" for "options", whose env or path settings were then lost) was never reported, and an unknown field of the role was reported once per task
* tests: test_warn_anomalies passed for the wrong reason, the field of the role standing for the one of the task; it now checks the field and the option field of a task by their names, nothing for the other tasks, and the field of the role once; it fails on the former loop

Co-Authored-By: Claude
…are typed

* fix: set_raw_mode put ICRNL and IXON back on the user's terminal after cfmakeraw, where sudo and sudo-rs leave it raw: Enter reached the pty as ^J instead of ^M (C-j and Enter differ in emacs, ^J moves down in fzf), and ^S and ^Q were taken for flow control by the user's terminal, so they never reached the command (save and quit in nano and micro, search and save in emacs, where ^S froze the output); the pty, whose settings are copied from the user's terminal, still turns Enter into a newline and does the flow control for a command that does not turn them off
* chore: the line that set OPOST and ONLCR had no effect, c_oflag being set right after
* tests: test_raw_mode_gives_the_keys_as_typed, on the follower of a new pty: no ICRNL, INLCR, IGNCR, IXON, ICANON nor ECHO in raw mode, ISIG kept, c_oflag with and without preserve_oflag, the settings given back by restore; its mutant with ICRNL and IXON fails it
* tests: e2e.sh raw_command_gets_the_keys_as_typed: stty raw -echo -ixon then dd of 3 bytes under dosr must read 0d 13 11 for Enter, ^S, ^Q; on the former dosr both boxes read 0a only; now Debian 56 PASS, 2 SKIP (no pam_faillock, no auditd), Arch 57 PASS, 1 SKIP (no update-alternatives)

Co-Authored-By: Claude
…put of the command

* fix: dosr printed PAM_TEXT_INFO on stdout, so with dosr cmd >file or dosr cmd | other the message of a module went into the output of the command; both kinds of messages now go where the prompts go, the terminal or stdin and stderr with -S, like sudo-rs (handle_info in src/pam/converse.rs), and to stderr when no terminal opens; they are still logged
* fix: dosr passes PAM_SILENT to the authentication and the account, which pam_unix and pam_faillock heed, but some modules do not, and the session is opened without it
* tests: the unit tests of the messages write on stderr, not on the terminal of the developer
* tests: e2e.sh pam_messages_stay_out_of_the_output: a module of the account stack built in the box sends pam_info and pam_error whatever PAM_SILENT; without a terminal, stdout holds the output of the command only and stderr both messages; from a terminal, a file holds the output only and the terminal both messages; on the former dosr both boxes had the information on stdout and in the file; now Debian 56 PASS, 2 SKIP (no pam_faillock, no auditd), Arch 57 PASS, 1 SKIP (no update-alternatives)

Co-Authored-By: Claude
* chore: under ptrace, the default engine, test_no_core_dump_after_disable makes the test process undumpable, which takes the tracer's access away: tarpaulin stopped on "A segfault occurred while executing tests"; the llvm engine instruments the code instead, all the tests pass under it (unit coverage of the project 79.7%, exec 41%: the pty path is covered by the e2e checks of the boxes only)

Co-Authored-By: Claude
* chore: the assertions added to test_warn_anomalies by 569f96e were not formatted

Co-Authored-By: Claude
…s numbers

* fix: chsr r r1 grant -u 1000 saved {"type": "user", "id": "1000"}, which dosr took for the name of a user that nobody has: the role was granted to nobody until another chsr command read the file and wrote it again as 1000; dosr read the same string as an id for a group or a mandatory setuid, and chsr's own reading too
* feat: decided by the maintainer: "1000" is uid or gid 1000 wherever a policy names a user or a group, since useradd and groupadd refuse such names (shadow 4.17.4 on Debian, unless --badname, and 4.20.0 on Arch); numeric_id is the one rule, for dosr and chsr: a non-empty string of ASCII digits that fits a u32, except 4294967295, which is (uid_t)-1 (sudo CVE-2019-14287); +1000 or a number beyond u32 is a name, where str::parse took +1000 for an id
* fix: DGenericActorType has a deserializer of its own, the derived untagged one took the digits for a name; chsr writes an actor, a setuid or a setgid typed on its command line as a number
* fix: dosr's -u and -g still take names only (SUserType::by_name, SGroupType::by_name): through From<&str>, -u 0 became uid 0, which FilterMatcher takes without checking that it exists
* tests: test_a_string_of_digits_is_an_id (fails with the derived deserializer and without the u32::MAX filter), test_ids_typed_in_chsr_are_saved_as_numbers (fails with the former From<&str>), test_actors_finder_deserializer with the uid as a string, test_getopt_refuses_users_and_groups_that_do_not_exist with -u 0 and -g 0 (it caught the change of -u)
* tests: e2e.sh chsr_uids_are_uids_for_dosr: chsr adds a role and a task with --setuid nobody, then grants it by the uid of the test user, last; dosr must run id -u as nobody; on the former chsr and dosr both boxes refused it, the file holding "id": "1001"; now Debian 57 PASS, 2 SKIP (no pam_faillock, no auditd), Arch 58 PASS, 1 SKIP (no update-alternatives)

Co-Authored-By: Claude
* tests: test_every_spelling_means_the_same_to_dosr_and_core: each generated policy, some of its tasks with sets of users and groups, is written again with other spellings, the aliases of core and those of dosr, which reads the policy with deserializers of its own, the short forms of the actors, the ids as strings of digits; dosr must decide as for the canonical policy, and core must read the same policy; with the former deserializer of the actors it fails at its second seed, as the uid typed in chsr did
* fix: dosr took "users" for the actors of a role and "setgroups" for setgid, which core kept as unknown fields: chsr showed a role without its actors, or a task without its groups, that dosr granted; core reads both now, and chsr writes them back as actors and setgid
* fix: core took "default_behavior" for the default of a set of groups, which dosr refused as an unknown field: chsr saved a policy that dosr refused whole; dosr reads it now, as it does for the commands

Co-Authored-By: Claude
…, as sudo-rs makes it

* fix: dosr made the user's terminal raw with ISIG, and kept OPOST and ONLCR in the foreground, where sudo-rs calls set_raw_mode(false, preserve_oflag) (exec/use_pty/parent.rs): ^C, ^Z and ^\ became signals of dosr instead of keys of the command, so C-c of emacs or ^C read by a program in raw mode killed the command; and the newlines that the pty had made CR LF came as CR CR LF, and as CR LF from a command in raw mode that writes LF to move down
* fix: the terminal is raw only in the foreground and with the input of the command from the terminal, as in sudo-rs, and a terminal that cannot be made raw is a warning, not an error; the pty, set like the user's terminal, makes the signals, the flow control and the line ends of a command that keeps them
* tests: test_raw_mode_gives_the_keys_as_typed checks ISIG for both values of with_signals; e2e.sh raw_command_gets_the_keys_as_typed with ^C, ^Z and ^\, command_output_reaches_the_terminal_as_written (CR LF once in cooked mode, LF in raw mode) and suspended_command_resumes_with_fg (^Z and fg of a command in a pty, which ISIG no longer handles in dosr); on the former dosr both boxes got ^\ as Quit and CR CR LF; now Debian 62 PASS, 2 SKIP (no pam_faillock, no auditd), Arch 63 PASS, 1 SKIP (no update-alternatives), with the fixes of the next commits

Co-Authored-By: Claude
…estored, and what the user typed ahead is kept

* fix: at the end, dosr restored the user's terminal before it wrote the output left, and with TCSAFLUSH: the last lines, whose newlines the pty had made CR LF, came as CR CR LF through the restored terminal, and what the user had typed ahead was discarded; as sudo-rs does (exec/use_pty/parent.rs, exec_pty), the output left is written first, while the terminal is raw, then the settings of the user come back with TCSADRAIN, in the foreground only, a failure being a warning
* fix: when the runner sees the monitor exit, it only ends the event loop: it restored the terminal and set the foreground process group of the user's terminal there too, which the end does, or which dosr never changes

Co-Authored-By: Claude
* fix: an error of the user's terminal ended the event loop of dosr, whose monitor then terminated the command: after a hangup (ssh closed, terminal revoked), the first output of the command, written to the terminal, failed with EIO, and a command that ignores SIGHUP (nohup, trap '' HUP) was killed anyway; as sudo-rs does (exec/use_pty/parent.rs, on_event), a terminal that is gone (tcgetsid fails) is no longer polled, and its errors are not fatal; the command gets the SIGHUP of the kernel, through dosr, and decides
* tests: e2e.sh command_that_ignores_sighup_survives_the_terminal: under setpriv, dosr is the controlling process of a pty that hangs up; the command, which ignores SIGHUP and writes after the hangup, must end its work; on the former dosr both boxes killed it

Co-Authored-By: Claude
…as sudo-rs does

* fix: the monitor sent the signals of the runner, and its own, to the process of the command only, where sudo-rs sends them to its process group (exec/use_pty/monitor.rs, send_signal): with sh -c 'a | b', SIGTERM to dosr ended sh, and a and b went on, holding the pty; SIGALRM still terminates the process only, as in sudo-rs
* fix: CAP_KILL is raised for the group too when the command runs as another user; the group of a child that is not reaped is its pid, and only the processes of its session can join it
* tests: e2e.sh signal_reaches_every_process_of_the_command: SIGTERM to dosr running sh -c 'sleep 42 | cat' in a pty must end sleep; on the former dosr, Arch left it running (Debian ended it another way)

Co-Authored-By: Claude
…it again, as sudo-rs decides

* fix: dosr gave the command any signal that it got, unless the command itself had sent it: without a pty the command is in the process group of dosr, so a kill 0 from a process of the command reached the command, then dosr gave it a second time; with a pty, a signal from the group of the command was skipped, not one from the group of dosr
* fix: is_self_terminating, shared by both paths, follows sudo-rs (exec/use_pty/parent.rs and exec/no_pty.rs): sent by the command, by a process of its group, or by a process of the group that dosr leads
* tests: e2e.sh signal_to_the_group_reaches_the_command_once: without a terminal, a process of the command sends SIGTERM to its group, and the command counts what it gets through the wakeup fd of python, a byte for each; on the former dosr both boxes counted 2

Co-Authored-By: Claude
… differs

* docs: event, pipe, terminal, pty, signal, runner and monitor name the files of sudo-rs they are adapted from, and list where they differ and why: the hang-up events given to the handlers, the ring buffer of ringbuf, the logger of the session, ISIG in c_lflag, the owner of the pty, the frames of the signal pipe, CAP_KILL, the messages sent at once, the resume on SIGTTOU or SIGTTIN, the pre-exec steps, the check of the foreground of the pty, the order of the end of the monitor; the backchannel already said why it uses rkyv
* docs: the comment of the resume on SIGTTOU or SIGTTIN no longer cites an issue number of sudo-rs, which its history does not show

Co-Authored-By: Claude
* chore: BestExecSettings::retrieve_settings is only used by the tests and the fuzzing module since ff2b22c, so cargo build --release --features finder, the build of cargo xtask install and of the packages, warned that it is never used; it is compiled for the tests and the fuzzing feature only

Co-Authored-By: Claude
* fix: boottime returned TimeSpec::tv_sec, a time_t, as an i64: libc makes time_t an i32 on the 32-bit glibc targets but riscv32, and Debian keeps it so on i386, where the build of 4.1.0-1 failed (E0308), which holds 4.1.0 back from testing (missing build on i386); since 136654e, so 4.0.3, 4.1.0 and 4.2.0; the seconds since boot now go through Duration, which fits both widths
* fix: the event loop gave fds.len() to poll with as, which clippy refuses on i386 (nfds_t is a u32 there): the conversion is checked, more descriptors than nfds_t holds being the EINVAL of poll
* tests: cargo check and cargo clippy --target i686-unknown-linux-gnu --workspace --all-targets --all-features -- -D warnings are clean; the tests run on i386 in a Debian box with the i386 libraries

Co-Authored-By: Claude
…le CPU

* fix: run_no_pty read its signal pipe, which is non-blocking, and on WouldBlock read it again at once, with try_wait between: from the start of the command to its end, dosr used a whole CPU, with cron, scripts, ssh without -t, or any run whose ends are not a terminal (since e11aa3f, in every 4.x release); it now waits in poll on the pipe, through which the handler of SIGCHLD writes too
* docs: the divergence of the runner from sudo-rs says that the loop waits; written up in 21a79fd as a plain loop, without seeing that it spun
* tests: run_no_pty_waits_without_using_the_cpu: the CPU of the thread that runs sleep 1 (RUSAGE_THREAD, not disturbed by the other tests), 989 ms with the former loop, under 200 ms required
* tests: e2e.sh dosr_waits_without_a_terminal: the command reads the CPU ticks of its parent dosr for 2 s, under setsid with stdin from /dev/null; the former dosr used 200 and 201 on Debian and Arch, at most 20 required

Co-Authored-By: Claude
…made it spin

* fix: resumed in the background after ^Z and bg, dosr polled the user's terminal again: a line typed for the job in the foreground made it readable, and each read failed with EIO, SIGTTIN being ignored; since a990b27 the error was only logged, and dosr used a whole CPU until the line was read by another job; before, it ended the event loop, and the monitor terminated the command
* fix: the input of the terminal follows the foreground: read and raw in the foreground when dosr relays the keys, neither in the background; relay_input says whether it relays them, the input of the command being the terminal, or the command having asked for it (SIGTTIN, SIGTTOU), which raw mode needs too: with the input of the command from a pipe, the terminal was made raw after ^Z and fg, and read by nobody (by the code: ^Z terminated such a command before, see the next commit)
* fix: the settings of the user's terminal go to the pty in the foreground only: resumed in the background by bg, dosr copied those of bash at its prompt, without echo, canonical mode nor ICRNL (seen with strace on Debian), as the former dosr and sudo-rs (resume_terminal in exec/use_pty/parent.rs) do; bash 5.2 brings a running job to the foreground with fg without SIGCONT, so nothing copied them again, and the command, once it read the terminal, got Enter as ^M and waited for the end of its line
* fix: one resume_terminal for the resume after a suspension, SIGCONT (which checks the foreground now, instead of a former state) and the command that asks for the terminal (SIGTTIN, SIGTTOU); it does not copy to the pty the settings of a terminal that dosr made raw
* fix: a read of the terminal that fails, other than WouldBlock or EINTR, turns the input off, instead of failing again at each poll
* tests: e2e.sh background_dosr_leaves_the_terminal_alone: ^Z, bg, sleep 3 in the foreground and a line typed meanwhile, the CPU ticks of the runner for 2 s, then fg and a line read by the command; the former dosr used 200 on both boxes, and the first version of this fix waited for the line on Debian (bash 5.2); the terminal checks of in_a_python_terminal take cpu:<seconds>, which prints CPU=<ticks> of the runner (the dosr whose parent is not dosr: the monitor is named dosr too)

Co-Authored-By: Claude
* fix: before stopping its job, dosr tested kill(pgrp, 0), the leader of its process group, not the group: in a pipeline, the leader is the first command, so with true | dosr cmd, the leader having ended, dosr found no group to suspend and terminated the command (the shell showed ^ZKilled, as git diff | dosr vim - would have lost the editor); dosr is in the group, which exists while dosr runs, so killpg decides alone; since 70c8209, and sudo-rs tests the same in suspend_pty (exec/use_pty/parent.rs)
* tests: e2e.sh piped_command_gets_ctrl_c_after_fg: true | dosr sh -c 'sleep 33', ^Z must show Stopped and never Killed, then after fg ^C ends the command (130), with no sleep left; on the former dosr the Debian box showed Killed, and the first version of the check passed there, its 130 coming from ^C at the prompt

Co-Authored-By: Claude
* tests: e2e.sh keys_signal_a_command_in_a_pty: in an interactive bash, ^C ends dosr sh -c 'sleep 31 | cat' with the status 130, ^\ another one with 131, and no sleep is left: since 08f176a the user's terminal is raw without ISIG, and the keys reach the pty, whose settings make them signals for the process group of the command; the status is read on a line of its own, bash drops the rest of a line whose job died of SIGINT, and the ESC[?2004l of its bracketed paste is taken out; it passed 3 times of 3 on both boxes

Co-Authored-By: Claude
* chore: build.yml runs cargo check --target i686-unknown-linux-gnu --workspace --all-targets --all-features after the dependencies, the target added with rustup first: i386 did not build since 136654e (time_t is 32 bits there), which no workflow saw, and which holds 4.1.0-1 back from Debian testing; check does not link, and no build script of the dependency graph runs pkg-config or cc for the target, so the i386 libraries are not needed

Co-Authored-By: Claude
… panic

* fix: with_privileges emptied the whole effective set at the end, not only what it raised: a caller that held another effective capability lost it without a word, and a panic in the operation left the raised capabilities effective; a guard now gives back the effective set found before the operation, with what is still permitted after it (a change of the uids can empty the permitted set), so what the kernel raised meanwhile (a change of the effective or the filesystem uid to 0) leaves it too, and when it cannot drop them after a panic the process aborts; the end of the PAM window is the only caller that held one (CAP_AUDIT_WRITE), and drops the whole effective set right after
* tests: test_with_privileges_gives_back_the_effective_set: a held capability stays effective after the operation, after an error and after a panic, and the raised one leaves; as root in the Debian box, the former code had emptied the effective set after the first call

Co-Authored-By: Claude
…ERRIDE

* fix: immutable_required_privileges raised CAP_DAC_OVERRIDE with CAP_FOWNER and CAP_LINUX_IMMUTABLE when the effective uid does not own the file; FS_IOC_SETFLAGS on an open descriptor checks the owner or CAP_FOWNER, then CAP_LINUX_IMMUTABLE for the flag (vfs_fileattr_set), and no DAC permission, so it was raised for nothing
* tests: test_immutable_flag_of_another_owner: a file given to another uid gets the flag and loses it with CAP_FOWNER and CAP_LINUX_IMMUTABLE as only effective capabilities; as root in the Debian box, the former code raised CAP_DAC_OVERRIDE too

Co-Authored-By: Claude
…k as admin capabilities

* fix: when several tasks match, dosr prefers the one without the capabilities that lead almost directly to a privilege escalation (capabilities_are_exploitable), which missed CAP_BPF with CAP_PERFMON, which load the tracing programs of eBPF (they read the memory of the kernel and write the memory of any process), CAP_MAC_ADMIN, which changes the policy of the LSM, and CAP_MAC_OVERRIDE, which bypasses it: a task with one of them won against a task with more capabilities, none of them admin; CAP_BPF or CAP_PERFMON alone stays out of the list
* tests: test_capabilities_are_exploitable covers them

Co-Authored-By: Claude
…which follow symbolic links

* chore: both retry an open refused with CAP_DAC_READ_SEARCH, then CAP_DAC_OVERRIDE, and follow symbolic links: a caller that passes a path chosen by the user would read any file, which is why their three siblings are deprecated since 4.0.3; the note points to an open relative to a checked directory with O_NOFOLLOW
* chore: the callers of today keep them, each with the reason it is safe (expect deprecated): the root file and the policy files of dosr and chsr, at paths that the build or the administrator chose, the descriptor checked next by dosr, and the hashchecker plugin, deprecated, which reads a command that the policy allows
* tests: the tests of file.rs read the files that they wrote with File::open

Co-Authored-By: Claude
…ed 0022

* fix: the umask of the build was read as a decimal number since the umask option exists: "0022" gave 0o026, so a command whose policy sets no umask (the policy of the Debian package) got 0026 instead of the documented 0022, and a stricter value was weakened ("077" gave 0o115, files writable by others); core and xtask now read it in octal, like the umask option of a policy, and xtask no longer writes "026" in the policy that it deploys (maintainer's decision of 2026-11-04: a bugfix, with the documented default)
* fix: a value of RAR_UMASK that is not an octal number up to 777 stops the build, where it gave 0022 without a word
* tests: test_umask_build_value_is_octal, with a value of decimal digits and one above 777 that stop the build, and xtask's test_umask_written_is_the_octal_build_value; the reference of test_absent_field_is_inherited reads the build value in octal instead of the constant of core; e2e.sh umask_of_the_policy: the command gets 0022 where the policy sets none, and 0027 from a task, whatever the umask of the caller; each failed on the former code, the e2e check on the dosr installed in the Debian box; both boxes pass it

Co-Authored-By: Claude
I still need to rewrite some parts.
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.74684% with 256 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.50%. Comparing base (996ab91) to head (9ab0f81).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
exec/src/runner.rs 23.88% 51 Missing ⚠️
exec/src/signal.rs 43.39% 30 Missing ⚠️
src/dosr/pam/mod.rs 52.94% 24 Missing ⚠️
src/dosr/pam/rpassword.rs 72.05% 19 Missing ⚠️
src/dosr/finder/mod.rs 88.28% 13 Missing ⚠️
src/dosr/pam/privileges.rs 77.19% 13 Missing ⚠️
core/src/database/ser.rs 93.33% 12 Missing ⚠️
core/src/file.rs 91.72% 11 Missing ⚠️
src/dosr/finder/api/landlock.rs 0.00% 9 Missing ⚠️
exec/src/monitor/mod.rs 0.00% 8 Missing ⚠️
... and 17 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #114      +/-   ##
==========================================
+ Coverage   77.01%   80.50%   +3.49%     
==========================================
  Files          62       65       +3     
  Lines        7569     8517     +948     
==========================================
+ Hits         5829     6857    +1028     
+ Misses       1740     1660      -80     
Flag Coverage Δ
admin-unittests 80.48% <79.58%> (+3.47%) ⬆️
unittests 79.76% <79.58%> (+2.79%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@LeChatP
LeChatP merged commit 9927ce2 into main Nov 4, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant