// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types /* Fingerprintd:Store unit tests. The map is small, but three of its properties are load-bearing. A corrupt or partly-unreadable map must degrade to "fewer names known" rather than to a daemon that refuses to start, because that daemon is what unlocks the phone -- and it must say so, because the map now decides which templates an enrolment removes, and a map with lines it could not read must not decide that. Fid 0 must never be storable or matchable: 0 is what the trustlet writes into the fid field when authentication FAILS, so a stored 0 would turn every rejection into a match. And group 0 is never managed. */ import std; import Fingerprintd; using namespace fingerprintd::store; namespace { int Failures = 0; void Check(bool cond, std::string_view msg) { if (!cond) { std::println(std::cerr, "FAIL: {}", msg); ++Failures; } } } int main() { // ---- fid 0 is the failure sentinel, never an identity { Map m; Check(!m.Add(Finger::RightIndex, 0), "fid 0 is refused"); Check(m.Size() == 0, "and nothing was stored"); Check(!m.Lookup(0).has_value(), "a zero fid never resolves to a finger"); m.Add(Finger::RightIndex, 1296911490); Check(!m.Lookup(0).has_value(), "still not, with entries present"); // Lookup's own zero guard is unreachable by construction: Add is the // only way an entry is created and it refuses 0, so the guard is // defence in depth against a future writer, not a live branch. What // IS testable is the invariant that makes it unreachable -- assert // that, including through the decoder, which is the other way entries // arrive. Map decoded = Map::Decode("left-thumb 0\nright-thumb 5\nleft-index-finger 0\n"); bool anyZero = false; for (const Entry& e : decoded.Entries()) if (e.fid == 0) anyZero = true; Check(!anyZero, "no entry ever holds fid 0, however it was created"); Check(decoded.Size() == 1, "the zero-fid lines make no entry"); } // ---- Names round-trip through fprintd's vocabulary { for (auto& [f, name] : FingerNames) { Check(NameOf(f) == name, std::format("name of {}", name)); auto back = FingerFromName(name); Check(back && *back == f, std::format("{} round-trips", name)); } Check(FingerNames.size() == 10, "ten fingers"); Check(!FingerFromName(AnyFinger).has_value(), "'any' is a verify argument, not a storable finger"); Check(!FingerFromName("").has_value(), "empty name"); Check(!FingerFromName("nose").has_value(), "unknown name"); } // ---- Add, replace, remove { Map m; Check(m.Add(Finger::RightIndex, 1296911490), "add"); Check(m.Size() == 1 && m.Has(Finger::RightIndex), "stored"); auto f = m.Lookup(1296911490); Check(f && *f == Finger::RightIndex, "a match resolves to the finger"); // Re-enrolling replaces: the trustlet allocated a new template and the // old id is not what a match will report any more. Check(m.Add(Finger::RightIndex, 42), "re-enrol"); Check(m.Size() == 1, "no duplicate entry"); Check(!m.Lookup(1296911490).has_value(), "the stale id no longer resolves"); auto g = m.Lookup(42); Check(g && *g == Finger::RightIndex, "the new id does"); Check(m.Remove(Finger::RightIndex), "remove"); Check(m.Size() == 0 && !m.Has(Finger::RightIndex), "removed"); Check(!m.Remove(Finger::RightIndex), "removing twice is not an error the second time"); } // ---- The group limit { Map m; Finger fingers[] = { Finger::LeftThumb, Finger::LeftIndex, Finger::LeftMiddle, Finger::LeftRing, Finger::LeftLittle }; for (std::size_t i = 0; i < 5; i++) Check(m.Add(fingers[i], static_cast(100 + i)), "fill the group"); Check(m.Full() && m.Size() == MaxFingersPerGroup, "five is the limit"); Check(!m.Add(Finger::RightThumb, 200), "a sixth finger is refused"); // But replacing one already present still works when full. Check(m.Add(Finger::LeftThumb, 999), "replacing while full is allowed"); Check(m.Size() == 5, "still five"); } // ---- Serialisation round-trip { Map m; m.Add(Finger::RightIndex, 1296911490); m.Add(Finger::LeftThumb, 7); std::string text = m.Encode(); Check(text.contains("right-index-finger 1296911490"), "encodes the name and id"); Check(text.contains("left-thumb 7"), "encodes the second entry"); Map back = Map::Decode(text); Check(back.Size() == 2, "decodes both"); auto f = back.Lookup(1296911490); Check(f && *f == Finger::RightIndex, "round-trips the mapping"); Check(back.Encode() == text, "re-encoding is stable"); } // ---- A damaged map degrades, it does not fail { std::string damaged = "right-index-finger 1296911490\n" // good "\n" // blank "left-thumb\n" // no id "nose 5\n" // unknown finger "left-index-finger notanumber\n" // unparsable id "left-middle-finger 12x\n" // trailing junk "left-ring-finger 0\n" // the failure sentinel "right-thumb 8\n"; // good Map m = Map::Decode(damaged); Check(m.Size() == 2, "only the two good lines become entries"); auto a = m.Lookup(1296911490); auto b = m.Lookup(8); Check(a && *a == Finger::RightIndex, "first good entry kept"); Check(b && *b == Finger::RightThumb, "last good entry kept"); Check(!m.Has(Finger::LeftRing), "the zero-fid line made no entry"); Check(!m.Has(Finger::LeftMiddle), "trailing junk rejected, not truncated to 12"); // Total garbage yields an empty map, not a throw. Map junk = Map::Decode("\0\0\xff nonsense \n\n\n"); Check(junk.Size() == 0, "garbage decodes to empty"); Map empty = Map::Decode(""); Check(empty.Size() == 0, "empty input decodes to empty"); // A final line without a newline is still read. Map noNl = Map::Decode("right-thumb 3"); Check(noNl.Size() == 1, "a missing trailing newline is not a lost entry"); } // ---- A damaged map is not trusted, and stays that way through a save { const char* damaged = "right-thumb 1819675103\nleft-index-finger notanumber\nnonsense\n" "right-index-finger 0\nright-index 123\n"; Map m = Map::Decode(damaged); Check(m.Size() == 1, "the one good line is kept"); Check(m.Skipped() == 4 && m.Unparsed().size() == 4, "the bad number, the line without a space, the fid 0 and the unknown name are kept as text"); Check(Map::Decode("right-thumb 7\n\n").Skipped() == 0, "empty lines are not damage"); Check(Map::Decode("").Skipped() == 0, "an empty map is a clean map"); Check(!m.Trusted(), "a damaged map is not trusted"); Check(Map::Decode("").Trusted(), "an absent or empty map is: no fingers is a true answer"); // The round trip: a save after an enrolment must not launder the damage. m.Add(Finger::LeftThumb, 42); Map again = Map::Decode(m.Encode()); Check(!again.Trusted(), "still not trusted after Add + Encode + Decode"); Check(again.Size() == 2 && again.Has(Finger::LeftThumb), "and the new finger was saved"); Check(again.Unparsed().size() == 4 && again.Unparsed()[3] == "right-index 123", "the lines it could not use come back verbatim"); Check(Map::Decode(Map::Decode(damaged).Encode()).Encode() == Map::Decode(damaged).Encode(), "a second round trip changes nothing"); // A finger named twice: the second line parses, but the first line's // fid would be lost in memory while it still stands on disk. It is // kept as data, so a delete of that finger finds it. Map twice = Map::Decode("right-thumb 111\nright-thumb 222\n"); Check(twice.Size() == 1 && twice.Lookup(111).has_value(), "the first line wins"); Check(!twice.Trusted() && twice.Shadowed().size() == 1 && twice.Shadowed()[0].fid == 222, "the second is kept as the finger and fid it names"); Check(twice.Skipped() == 1 && twice.Unparsed().empty(), "and counted, but not as text"); Check(Map::Decode(twice.Encode()).Encode() == "right-thumb 111\nright-thumb 222\n", "it survives a round trip as the same line"); auto named = twice.FidsNamed(Finger::RightThumb); Check(named.size() == 2 && named[0] == 111 && named[1] == 222, "a delete of the finger gets both fids"); Check(twice.Remove(Finger::RightThumb), "removing the finger"); Map after = Map::Decode(twice.Encode()); Check(!after.Has(Finger::RightThumb) && after.Trusted() && after.Size() == 0, "takes the shadowed line with it: nothing comes back under that name"); // A sixth finger shadows the same way, and goes with its finger's delete. Map six = Map::Decode("left-thumb 1\nleft-index-finger 2\nleft-middle-finger 3\n" "left-ring-finger 4\nleft-little-finger 5\nright-thumb 6\n"); Check(six.Size() == 5 && six.Full() && !six.Trusted() && six.Shadowed().size() == 1, "the sixth is shadowed"); Check(six.FidsNamed(Finger::RightThumb).size() == 1 && six.FidsNamed(Finger::RightThumb)[0] == 6, "and its finger's delete gets its fid"); Check(six.AllFids().size() == 6, "every fid the file carries"); // Deleting every finger drops the kept lines too: "every" means every. m.Clear(); Check(m.Size() == 0 && m.Trusted(), "cleared, and trusted again"); Check(!Map::Unreadable().Trusted(), "a map that could not be read is not trusted"); Check(!Map::Unreadable().Readable() && Map::Decode(damaged).Readable(), "and is the only kind that is not readable"); Check(Map::Unreadable().Size() == 0, "and it names nothing"); } // ---- Groups { Check(!GroupIsManaged(0), "group 0 is never tidied"); Check(GroupIsManaged(10000), "a session user's group is"); Check(GidForUid(1000) == 1000, "gid is the uid"); Check(GidForUid(0) == 0, "root maps too"); Check(GidForUid(4294967295u) == 4294967295u, "the whole uid range maps"); Check(PathForUid("/var/lib/fingerprintd", 1000) == "/var/lib/fingerprintd/fingers-1000.map", "state path"); } if (Failures == 0) std::println("Store: all tests passed"); return Failures; }