fingerprintd/tests/Actions/main.cpp
Jorijn van der Graaf 93d7f96a63 Drop two fields nobody needed: session was a no-op, and no-unlock is the finger
Jorijn caught both.

`session` declared nothing. The FingerMatched signal is emitted for every
matched finger unconditionally -- it never consulted the config -- so a
`session` line was a rule the format invited you to write that did exactly
nothing. Announcing every finger is the right default anyway: a session agent
should not need a root-owned file to declare its interest in a signal it is
free to ignore. The column is gone.

Which leaves the config for the two things that really do need the daemon, and
with `session` gone the verdict column had no partner left to vary against. It
read as a property of the finger while being a property of the attempt, so it
is now written as what it is:

    <finger>  [no-unlock]  [absolute command...]

no-unlock says the finger never unlocks; a command is what root runs. At least
one is required, because a finger listed alone says nothing the signal does not
already say -- and that is a parse error rather than a silently useless line.

The example config now also states plainly what no-unlock is not. It is a panic
button, not deniability: the rejection it fabricates comes back in milliseconds
where a real one takes about three seconds, the journal records that the finger
actually matched, the file names the finger in plain text, and the finger still
shows as enrolled. Both of those weaknesses are real and neither is fixed here.
2026-09-05 05:23:40 +02:00

138 lines
5.7 KiB
C++

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Fingerprintd:Actions unit tests.
This table decides what root executes when a finger touches the sensor, so the
tests here are mostly about REFUSAL. The load-bearing properties:
* a file anyone but root can write is rejected before a single rule is read,
* a malformed rule rejects the WHOLE file rather than being skipped -- a
half-applied policy is the dangerous outcome, not the safe one,
* a command must be an absolute path, because resolving a bare name through
PATH would make what root runs depend on an environment this daemon does
not control,
* and no rule at all means no behaviour change, which is what keeps the
feature absent until someone configures it.
*/
import std;
import Fingerprintd;
using namespace fingerprintd::actions;
using fingerprintd::store::Finger;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
Parsed P(std::string_view t, bool rootOnly = true) { return Parse(t, rootOnly); }
}
int main() {
// ---- the file's own permissions are checked before its contents
{
Parsed p = P("right-index-finger /bin/true", /*rootOnly*/ false);
Check(!p.Ok(), "a file others can write is refused");
Check(p.error == Error::NotWritableOnlyByRoot, "and refused for that reason");
Check(p.line == 0, "the file is the fault, not a line");
Check(p.rules.empty(), "nothing is parsed out of it");
}
// ---- no config is not an error; it is the feature being off
{
Parsed p = P("");
Check(p.Ok() && p.rules.empty(), "an empty file yields no rules");
Parsed c = P("# nothing but a comment\n\n # indented\n");
Check(c.Ok() && c.rules.empty(), "comments and blank lines are ignored");
}
// ---- the three shapes a line can take
{
Parsed p = P("# finger what\n"
"right-ring-finger /usr/bin/logger -t fp ring\n"
"left-little-finger no-unlock /etc/fingerprintd/panic.sh\n"
"left-thumb no-unlock\n");
Check(p.Ok(), "a valid file parses");
Check(p.rules.size() == 3, "all three rules");
const Rule* ring = Find(p.rules, Finger::RightRing);
Check(ring && ring->unlocks, "a command-only finger still unlocks");
Check(ring && ring->command == "/usr/bin/logger -t fp ring",
"and the command arrives whole, spaces and all");
const Rule* duress = Find(p.rules, Finger::LeftLittle);
Check(duress && !duress->unlocks, "no-unlock is recorded");
Check(duress && duress->command == "/etc/fingerprintd/panic.sh",
"alongside its command -- the duress case needs both");
const Rule* thumb = Find(p.rules, Finger::LeftThumb);
Check(thumb && !thumb->unlocks, "no-unlock alone is a complete rule");
Check(thumb && thumb->command.empty(), "with no command");
Check(Find(p.rules, Finger::RightIndex) == nullptr,
"a finger with no rule has no rule");
}
// ---- a command that merely STARTS like the keyword is a command
{
Parsed p = P("left-index-finger /usr/local/bin/no-unlock-helper\n");
Check(p.Ok(), "parses");
const Rule* r = Find(p.rules, Finger::LeftIndex);
Check(r && r->unlocks, "the finger still unlocks");
Check(r && r->command == "/usr/local/bin/no-unlock-helper",
"and the path was not mistaken for the keyword");
}
// ---- every rejection rejects the whole file
{
struct Case { std::string_view text; Error want; std::string_view why; };
const Case cases[] = {
{ "not-a-finger /bin/true\n", Error::UnknownFinger,
"an unknown finger name" },
{ "right-index-finger\n", Error::NothingToDo,
"a finger on its own, which the signal already covers" },
{ "right-index-finger reboot\n", Error::RelativeCommand,
"a command that is not an absolute path" },
{ "right-index-finger no-unlock reboot\n", Error::RelativeCommand,
"a relative command after the keyword" },
{ "left-thumb no-unlock\nleft-thumb /bin/true\n",
Error::DuplicateFinger, "two rules for one finger" },
};
for (const Case& c : cases) {
Parsed p = P(c.text);
Check(!p.Ok(), std::format("rejected: {}", c.why));
Check(p.error == c.want, std::format("for the right reason: {}", c.why));
Check(p.rules.empty(),
std::format("and yields NO rules at all: {}", c.why));
}
}
// ---- a valid rule before a bad one is discarded with it
{
Parsed p = P("right-index-finger no-unlock\n"
"left-thumb reboot\n");
Check(!p.Ok(), "the file fails");
Check(p.line == 2, "on the offending line");
Check(p.rules.empty(),
"and the GOOD rule above it is discarded too -- a half-applied "
"policy is the dangerous outcome");
}
// ---- every Error has a description; a switch that forgets one shows up here
{
const Error all[] = { Error::None, Error::NotWritableOnlyByRoot, Error::UnknownFinger,
Error::RelativeCommand, Error::NothingToDo,
Error::DuplicateFinger };
for (Error e : all)
Check(Describe(e) != "unknown", "every error describes itself");
}
if (Failures == 0) std::println("Actions: all checks passed");
return Failures == 0 ? 0 : 1;
}