142 lines
6.2 KiB
C++
142 lines
6.2 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 system 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 system match /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");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- a well-formed table
|
||
|
|
{
|
||
|
|
Parsed p = P("# finger where verdict command\n"
|
||
|
|
"right-index-finger session match\n"
|
||
|
|
"left-little-finger system no-match /etc/fingerprintd/panic.sh\n");
|
||
|
|
Check(p.Ok(), "a valid file parses");
|
||
|
|
Check(p.rules.size() == 2, "both rules");
|
||
|
|
|
||
|
|
const Rule* idx = Find(p.rules, Finger::RightIndex);
|
||
|
|
Check(idx != nullptr, "the index finger has a rule");
|
||
|
|
Check(idx && idx->where == Where::Session, "session");
|
||
|
|
Check(idx && idx->verdict == Verdict::Match, "and it still unlocks");
|
||
|
|
Check(idx && idx->command.empty(), "a session rule carries no command");
|
||
|
|
|
||
|
|
const Rule* pin = Find(p.rules, Finger::LeftLittle);
|
||
|
|
Check(pin != nullptr, "the duress finger has a rule");
|
||
|
|
Check(pin && pin->where == Where::System, "root runs it");
|
||
|
|
Check(pin && pin->verdict == Verdict::NoMatch,
|
||
|
|
"and the client is told it did NOT match -- the whole point of duress");
|
||
|
|
Check(pin && pin->command == "/etc/fingerprintd/panic.sh", "the command");
|
||
|
|
|
||
|
|
Check(Find(p.rules, Finger::RightThumb) == nullptr,
|
||
|
|
"a finger with no rule has no rule");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- a command may contain spaces; the tail is not re-split
|
||
|
|
{
|
||
|
|
Parsed p = P("right-ring-finger system match /usr/bin/env FOO=1 /usr/local/bin/x -v\n");
|
||
|
|
Check(p.Ok(), "a command with arguments parses");
|
||
|
|
const Rule* r = Find(p.rules, Finger::RightRing);
|
||
|
|
Check(r && r->command == "/usr/bin/env FOO=1 /usr/local/bin/x -v",
|
||
|
|
"and arrives whole");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- every rejection rejects the whole file
|
||
|
|
{
|
||
|
|
struct Case { std::string_view text; Error want; std::string_view why; };
|
||
|
|
const Case cases[] = {
|
||
|
|
{ "not-a-finger system match /bin/true\n", Error::UnknownFinger,
|
||
|
|
"an unknown finger name" },
|
||
|
|
{ "right-index-finger elsewhere match /bin/true\n", Error::UnknownWhere,
|
||
|
|
"an unknown 'where'" },
|
||
|
|
{ "right-index-finger system maybe /bin/true\n", Error::UnknownVerdict,
|
||
|
|
"an unknown verdict" },
|
||
|
|
{ "right-index-finger system match\n", Error::MissingCommand,
|
||
|
|
"a system rule with no command" },
|
||
|
|
{ "right-index-finger session match /bin/true\n", Error::UnexpectedCommand,
|
||
|
|
"a session rule with a command" },
|
||
|
|
{ "right-index-finger system match reboot\n", Error::RelativeCommand,
|
||
|
|
"a command that is not an absolute path" },
|
||
|
|
{ "right-index-finger system match\n", Error::MissingCommand,
|
||
|
|
"a truncated line" },
|
||
|
|
{ "right-index-finger session match\nright-index-finger system match /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 session match\n"
|
||
|
|
"left-thumb system match 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::UnknownWhere, Error::UnknownVerdict, Error::MissingCommand,
|
||
|
|
Error::UnexpectedCommand, Error::DuplicateFinger,
|
||
|
|
Error::RelativeCommand };
|
||
|
|
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;
|
||
|
|
}
|