// 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 two 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 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. */ 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 were dropped by the decoder"); } // ---- 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 survive"); 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 was dropped"); 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"); } // ---- Groups { Check(GidForUid(1000) == 1000, "gid is the uid"); Check(GidForUid(0) == 0, "root maps too"); Check(LegacyGid == 60, "the dev phone's accidental group is remembered"); Check(GidForUid(1000) != LegacyGid, "and a real uid does not collide with it"); 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; }