Add fingerprint reader selection and lid-based routing

This commit is contained in:
Niklas Haiden
2026-09-21 19:02:37 +02:00
commit b8967e9505
15 changed files with 2979 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target/
/research/
Generated
+769
View File
@@ -0,0 +1,769 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fingerprint-switch"
version = "0.1.0"
dependencies = [
"anyhow",
"futures-util",
"tokio",
"zbus",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "js-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-recursion",
"async-trait",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc",
"ordered-stream",
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
"windows-sys",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.5",
"winnow",
]
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "fingerprint-switch"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Select fingerprint readers through fprintd, with optional lid-based routing"
[dependencies]
anyhow = "1.0.104"
futures-util = "0.3.34"
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal", "time", "process"] }
zbus = { version = "5.19.0", default-features = false, features = ["tokio"] }
[profile.release]
strip = true
lto = "thin"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 fingerprint-switch contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+157
View File
@@ -0,0 +1,157 @@
# fingerprint-switch
A Rust tool for selecting a fingerprint reader through **fprintd**, plus optional
lid-based routing for normal login, lock-screen and sudo authentication that
already uses fprintd. Uses the installed libfprint drivers and Polkit permissions.
Built for this laptop's two readers:
| Role | USB ID | libfprint driver | fprintd name |
| --- | --- | --- | --- |
| Internal | `27c6:609c` | `goodixmoc` | Goodix MOC Fingerprint Sensor |
| External | `3274:8012` | `mafpmoc` | MAFP MOC Fingerprint Sensor |
The USB sensor is already supported by libfprint **1.94.100**, installed on this
Aurora 44 machine. A new USB driver is unnecessary. These are match-on-chip
readers; this tool does not export fingerprint images or implement matching.
## Build and use
```sh
cargo build --release --locked
./target/release/fingerprint-switch list
./target/release/fingerprint-switch status
./target/release/fingerprint-switch probe --sensor external
```
Run these as your normal desktop user. `probe` opens and releases the reader
without enrolling. Commands that access fprintd need the host's system D-Bus;
run them outside a restricted sandbox. Polkit may request authentication.
Enroll one finger on the external sensor, then verify it:
```sh
./target/release/fingerprint-switch enroll --sensor external \
--finger right-index-finger --allow-template-reset
./target/release/fingerprint-switch verify --sensor external
```
**The template-reset flag acknowledges a real driver limitation.** In libfprint
1.94.100, the `mafpmoc` enrollment path unconditionally sends command `0x0d`, the
same command used to clear all device storage. Thus enrollment can erase all
previously stored templates on the external sensor, including other users' or
Windows templates. It does not erase the separate Goodix reader. This was
confirmed from source, not by erasing this device. Use one enrolled finger on the
external reader with this driver; enrolling a second can invalidate the first.
The tool requires the flag for MAFP/Microarray readers even when fprintd lists no
prints, because device storage may contain templates from another installation.
Follow the prompts and lift your finger between samples. Enrollment defaults to
120 seconds, verification to 30; use `--timeout SECONDS` (1–600) to change this.
Ctrl-C cancels and releases the reader. Only an explicit successful completion
produces exit code 0; no-match, cancellation and timeout produce nonzero exits.
Other examples:
```sh
./target/release/fingerprint-switch verify --sensor internal
./target/release/fingerprint-switch verify --sensor auto
./target/release/fingerprint-switch verify --sensor 'MAFP MOC Fingerprint Sensor'
```
`auto` reads the lid state at the start of the command: closed prefers external,
open prefers internal, and an absent preferred reader falls back to the other.
Explicit selection never silently falls back. Duplicate device names require a
D-Bus path from `list`; paths may change when fprintd restarts. Fingerprints must
be enrolled separately on each reader. `--user USER` selects another account
subject to fprintd's normal permissions; omitting it uses the calling user.
## Automatic routing for login and sudo
CLI selection affects only that invocation. Stock `pam_fprintd` chooses the reader
with the most enrolled fingers; it does not use the CLI's selected reader.
The optional system service checks the lid and USB presence every second and
writes a root-owned environment file under `/run/fingerprint-switch`. A fprintd
service drop-in reads `FP_DRIVERS_ALLOWLIST` from this file. It allows the internal
driver with the lid open and the external driver with it closed, falling back to
the connected reader if the preferred one is missing. With neither connected it
allows both drivers for hotplug discovery. If lid state is unavailable, it prefers
the internal reader and logs the condition.
Only the selected driver is exposed through fprintd in automatic mode. Both
devices remain physically connected. Changes queue a restart of an active
fprintd; they do not start an idle fprintd. **Switching can cancel an enrollment or
authentication already in progress.** The next attempt uses the new reader.
Keep the lid and USB connection stable while enrolling.
After enrolling and verifying both readers, install the optional integration:
```sh
sudo ./scripts/install.sh
fingerprint-switch status
journalctl -u fingerprint-switch -n 30 --no-pager
```
The installer copies the built binary to `/usr/local/bin`, installs its own
systemd service and fprintd drop-in, and enables the watcher. It does not replace
libfprint, edit PAM, change suspend behavior or delete enrollment data. Existing
fingerprint-enabled authentication flows use the selected reader. Applications
that access USB directly are outside this routing mechanism. The allowlist
operates per driver, so it is intended for this Goodix-plus-Microarray pair.
The installer restarts an already-running fprintd once to apply the new drop-in.
The runtime environment is preserved across watcher stops/restarts, and is
removed during uninstall or reboot.
If the watcher is interrupted between writing a selection and restarting
fprintd, force application with `sudo systemctl try-restart fprintd.service`.
Manual overrides, stored in `/etc/fingerprint-switch/mode`:
```sh
sudo fingerprint-switch mode external # only Microarray, regardless of lid
sudo fingerprint-switch mode internal # only Goodix, regardless of lid
sudo fingerprint-switch mode both # expose both for enrollment/CLI selection
sudo fingerprint-switch mode auto # return to lid-based routing
```
The installed watcher applies changes within a few seconds. In `both` mode PAM
returns to its usual enrollment-count selection; it does not listen on both
readers simultaneously. If the service is not installed/running, `mode` only
saves a preference. Automatic routing does not check whether the current user
has enrolled a finger on the selected reader; enroll first. It also cannot make
an inaccessible built-in reader usable when the lid is closed and USB unplugged;
use the normal password fallback in that situation.
Rollback:
```sh
sudo ./scripts/uninstall.sh
```
This removes the watcher and its drop-in, restarts an active fprintd to restore
stock discovery, and preserves all enrollments. No password/PAM settings change.
## Validation and sources
```sh
cargo test --locked
cargo clippy --all-targets --locked -- -D warnings
cargo fmt --check
bash -n scripts/install.sh scripts/uninstall.sh
```
Tests use `dbus-daemon` to run isolated mock buses and cover reader-selection
policy and D-Bus operation handling without storing biometrics. Run them outside
a sandbox that blocks local sockets. Full enrollment/verification needs a person touching the sensor;
lid-based system routing must also be tested after opting into installation.
See `VALIDATION.md` for the checks actually performed on this machine.
- [Official supported-device list](https://fprint.freedesktop.org/supported-devices.html)
- [libfprint v1.94.100 mafpmoc source](https://gitlab.freedesktop.org/libfprint/libfprint/-/blob/v1.94.100/libfprint/drivers/mafpmoc/mafpmoc.c)
- [Commit introducing unconditional enrollment clear](https://gitlab.freedesktop.org/libfprint/libfprint/-/commit/67649a0efde02211b89a8c8153d37d797bd2ca6f)
- [fprintd Device D-Bus API](https://fprint.freedesktop.org/fprintd-dev/Device.html)
- [PAM device-selection implementation](https://gitlab.freedesktop.org/libfprint/fprintd/-/blob/v1.94.5/pam/pam_fprintd.c)
- [UPower lid properties](https://upower.freedesktop.org/docs/UPower/)
`research/` contains ignored, unmodified upstream checkouts used during the
investigation; they are not dependencies of this program.
+50
View File
@@ -0,0 +1,50 @@
# Validation — 2026-09-20
Host: Aurora 44; libfprint `1.94.100-1.fc44`; fprintd `1.94.5-5.fc44`.
Completed:
- `cargo build --release --offline --locked` — passed; executable at
`target/release/fingerprint-switch`.
- `cargo test --offline --locked` — **24 passed**: 12 private-D-Bus lifecycle
tests, 8 routing tests, 3 reader-selection tests, 1 CLI validation test.
Mock buses require permission to create local sockets. They do not use the
system fprintd or write biometrics.
- `cargo clippy --all-targets --offline --locked -- -D warnings` — passed.
- `cargo fmt --check` — passed.
- Shell syntax checks and ShellCheck for both installer scripts — passed.
- `systemd-analyze verify` — passed for temporary copies of both service units
and the fprintd drop-in together. The staged unit's executable path was replaced
with the built workspace binary solely for this check. Nothing was installed.
- Final release `probe --sensor external` — real Microarray reader opened and
released successfully through fprintd; no enrollment.
- Final release `probe --sensor internal` — real Goodix reader opened and
released successfully through fprintd; no enrollment.
- Final release `status` — detected both readers, open lid, and selected Goodix
for CLI auto mode. Goodix still listed its three existing finger enrollments;
Microarray listed none.
- ELF interpreter and dependencies resolve to system `/lib64` libraries, so the
binary does not depend on a Homebrew path hidden by the systemd service's
`ProtectHome` setting.
Not yet performed:
- Real enrollment, matching and rejection with a person touching each sensor.
- Installing/starting the watcher or changing host authentication configuration.
- Physical lid-close/open and USB-unplug routing with the installed service.
The tests validate policy and error handling; they do not establish fingerprint
matching accuracy or prove the uninstalled system integration end to end.
Suggested hands-on check after enrollment and opt-in installation:
1. Verify the enrolled finger on each reader using explicit CLI selection while
both readers are exposed (`mode both` if the watcher is installed).
2. Check rejection with a different, unenrolled finger on each reader.
3. Set `mode auto`, keep the lid open and confirm `list` exposes Goodix.
4. Close the lid while docked and awake; confirm Microarray is exposed and can
authenticate. Existing system power settings still determine suspend behavior.
5. Reopen the lid and confirm Goodix returns. Unplug the USB reader and check
fallback. Keep a password-based session available while testing.
6. Verify an ordinary configured fingerprint authentication flow, such as the
screen lock or sudo, then test the uninstall script if rollback is desired.
+41
View File
@@ -0,0 +1,41 @@
[Unit]
Description=Select the fingerprint reader for the laptop lid state
Wants=dbus.service
After=dbus.service
[Service]
Type=simple
ExecStartPre=/usr/local/bin/fingerprint-switch prepare
ExecStart=/usr/local/bin/fingerprint-switch watch-lid
Restart=on-failure
RestartSec=3
RuntimeDirectory=fingerprint-switch
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
UMask=0077
# The watcher only reads lid/USB state and writes fprintd's environment file.
# Root identity allows its system-bus request to restart fprintd.
User=root
Group=root
NoNewPrivileges=true
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/run/fingerprint-switch
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
RestrictAddressFamilies=AF_UNIX
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallFilter=@system-service
[Install]
WantedBy=multi-user.target
+6
View File
@@ -0,0 +1,6 @@
[Unit]
Wants=fingerprint-switch.service
After=fingerprint-switch.service
[Service]
EnvironmentFile=-/run/fingerprint-switch/fprintd.env
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo 'Run as root after building: sudo ./scripts/install.sh' >&2
exit 1
fi
project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ ! -x "$project_dir/target/release/fingerprint-switch" ]]; then
echo 'First build as your normal user: cargo build --release --locked' >&2
exit 1
fi
if [[ ! -f /usr/lib/systemd/system/fprintd.service ]]; then
echo 'This integration expects the Fedora/Aurora fprintd.service. Install fprintd first.' >&2
exit 1
fi
# Install only our executable, service and named drop-in. Do not edit PAM or the
# vendor service, and never run the build toolchain as root.
/usr/bin/install -D -m 0755 "$project_dir/target/release/fingerprint-switch" /usr/local/bin/fingerprint-switch
/usr/bin/install -D -m 0644 "$project_dir/deploy/fingerprint-switch.service" /etc/systemd/system/fingerprint-switch.service
/usr/bin/install -D -m 0644 "$project_dir/deploy/fprintd-override.conf" /etc/systemd/system/fprintd.service.d/60-fingerprint-switch.conf
/usr/bin/install -d -m 0755 /etc/fingerprint-switch
if [[ ! -e /etc/fingerprint-switch/mode ]]; then
/usr/local/bin/fingerprint-switch mode auto
fi
/usr/bin/systemctl daemon-reload
/usr/bin/systemctl enable fingerprint-switch.service
/usr/bin/systemctl restart fingerprint-switch.service
# An existing daemon may predate our EnvironmentFile drop-in. Subsequent
# changes are handled by the watcher; a fresh D-Bus activation needs no restart.
/usr/bin/systemctl try-restart fprintd.service
echo 'Installed. An active fprintd has been restarted; future routing changes are handled by the watcher.'
echo 'Check: fingerprint-switch status'
echo 'Logs: journalctl -u fingerprint-switch -n 30 --no-pager'
echo 'Restore access to both readers: sudo fingerprint-switch mode both'
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/bash
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo 'Run as root: sudo ./scripts/uninstall.sh' >&2
exit 1
fi
/usr/bin/systemctl disable --now fingerprint-switch.service
/usr/bin/rm -f /etc/systemd/system/fprintd.service.d/60-fingerprint-switch.conf
/usr/bin/rm -f /etc/systemd/system/fingerprint-switch.service
/usr/bin/rm -f /usr/local/bin/fingerprint-switch
/usr/bin/rm -f /run/fingerprint-switch/fprintd.env
/usr/bin/systemctl daemon-reload
/usr/bin/systemctl try-restart fprintd.service
echo 'Removed automatic routing. Stock fprintd sees both readers again.'
echo 'Enrollments and /etc/fingerprint-switch/mode were preserved.'
+877
View File
@@ -0,0 +1,877 @@
//! Reader selection and fingerprint operations using fprintd's public D-Bus API.
use std::{future::Future, time::Duration};
use anyhow::{Context, Result, anyhow, bail};
use futures_util::StreamExt;
use zbus::{Connection, Proxy, zvariant::OwnedObjectPath};
const SERVICE: &str = "net.reactivated.Fprint";
const MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager";
const DEVICE_INTERFACE: &str = "net.reactivated.Fprint.Device";
const METHOD_TIMEOUT: Duration = Duration::from_secs(10);
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Clone, Debug)]
pub struct Device {
pub path: String,
pub name: String,
pub fingers: Vec<String>,
/// fprintd reports -1 while an unclaimed reader's stage count is unknown.
pub stages: i32,
}
pub fn is_mafp(device: &Device) -> bool {
let name = device.name.to_ascii_lowercase();
name.contains("mafp") || name.contains("microarray")
}
pub async fn connect() -> Result<Connection> {
tokio::time::timeout(
METHOD_TIMEOUT,
zbus::connection::Builder::system()?
.method_timeout(METHOD_TIMEOUT)
.build(),
)
.await
.context("Timed out connecting to the system bus")?
.context("Cannot connect to the system bus")
}
fn is_remote_error(error: &zbus::Error, expected: &str) -> bool {
matches!(error, zbus::Error::MethodError(name, _, _) if name.as_str() == expected)
}
async fn device_proxy<'a>(conn: &Connection, device: &'a Device) -> Result<Proxy<'a>> {
Proxy::new(conn, SERVICE, device.path.as_str(), DEVICE_INTERFACE)
.await
.with_context(|| format!("Cannot access {} at {}", device.name, device.path))
}
pub async fn enumerate(conn: &Connection, username: &str) -> Result<Vec<Device>> {
let manager = Proxy::new(
conn,
SERVICE,
MANAGER_PATH,
"net.reactivated.Fprint.Manager",
)
.await?;
let paths: Vec<OwnedObjectPath> = manager
.call("GetDevices", &())
.await
.context("Cannot enumerate readers; check that fprintd is installed and available")?;
let mut devices = Vec::new();
for path in paths {
let proxy = Proxy::new(conn, SERVICE, path.as_str(), DEVICE_INTERFACE).await?;
let name: String = proxy.get_property("name").await?;
let stages: i32 = proxy.get_property("num-enroll-stages").await?;
let fingers = match proxy.call("ListEnrolledFingers", &(username,)).await {
Ok(fingers) => fingers,
Err(error)
if is_remote_error(&error, "net.reactivated.Fprint.Error.NoEnrolledPrints") =>
{
Vec::new()
}
Err(error) => {
return Err(error)
.with_context(|| format!("Cannot list enrolled fingers on {name}"));
}
};
devices.push(Device {
path: path.to_string(),
name,
fingers,
stages,
});
}
Ok(devices)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Probe,
Enroll,
Verify,
}
impl Action {
fn start_method(self) -> &'static str {
match self {
Self::Enroll => "EnrollStart",
Self::Verify => "VerifyStart",
Self::Probe => unreachable!(),
}
}
fn stop_method(self) -> &'static str {
match self {
Self::Enroll => "EnrollStop",
Self::Verify => "VerifyStop",
Self::Probe => unreachable!(),
}
}
fn signal(self) -> &'static str {
match self {
Self::Enroll => "EnrollStatus",
Self::Verify => "VerifyStatus",
Self::Probe => unreachable!(),
}
}
/// Accept success only when the expected terminal status and done agree.
fn completed(self, status: &str, done: bool) -> Result<bool> {
let success = match self {
Self::Enroll => "enroll-completed",
Self::Verify => "verify-match",
Self::Probe => unreachable!(),
};
if status == success {
if !done {
bail!("Invalid fprintd status: {status} without done=true");
}
return Ok(true);
}
let progress = match self {
Self::Enroll => matches!(
status,
"enroll-stage-passed"
| "enroll-retry-scan"
| "enroll-swipe-too-short"
| "enroll-finger-not-centered"
| "enroll-remove-and-retry"
| "enroll-too-fast"
),
Self::Verify => matches!(
status,
"verify-retry-scan"
| "verify-swipe-too-short"
| "verify-finger-not-centered"
| "verify-remove-and-retry"
| "verify-too-fast"
),
Self::Probe => false,
};
if progress && !done {
Ok(false)
} else {
bail!("Fingerprint operation failed: {status} (done={done})")
}
}
}
#[derive(Default)]
struct OperationState {
claimed: bool,
claim_uncertain: bool,
start_attempted: bool,
start_uncertain: bool,
}
/// An externally dropped future must also release its D-Bus identity. fprintd
/// tracks the sender and cleans up the claim when that identity disappears.
struct DisconnectOnDrop(Option<Connection>);
impl Drop for DisconnectOnDrop {
fn drop(&mut self) {
if let Some(conn) = self.0.take()
&& let Ok(runtime) = tokio::runtime::Handle::try_current()
{
runtime.spawn(async move {
let _ = tokio::time::timeout(CLEANUP_TIMEOUT, conn.close()).await;
});
}
}
}
async fn cleanup(proxy: &Proxy<'_>, action: Action, state: &OperationState) -> Result<()> {
let mut errors = Vec::new();
if state.claimed && state.start_attempted {
match tokio::time::timeout(
CLEANUP_TIMEOUT,
proxy.call::<_, _, ()>(action.stop_method(), &()),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(error))
if is_remote_error(&error, "net.reactivated.Fprint.Error.NoActionInProgress") => {}
Ok(Err(error)) => errors.push(format!("{}: {error}", action.stop_method())),
Err(_) => errors.push(format!("{} timed out", action.stop_method())),
}
}
if state.claimed || state.claim_uncertain {
match tokio::time::timeout(CLEANUP_TIMEOUT, proxy.call::<_, _, ()>("Release", &())).await {
Ok(Ok(())) => {}
Ok(Err(error))
if state.claim_uncertain
&& is_remote_error(&error, "net.reactivated.Fprint.Error.ClaimDevice") => {}
Ok(Err(error)) => errors.push(format!("Release: {error}")),
Err(_) => errors.push("Release timed out".to_owned()),
}
}
if !errors.is_empty() {
bail!("Reader cleanup failed: {}", errors.join("; "));
}
Ok(())
}
async fn run_action<F>(
conn: &Connection,
device: &Device,
username: &str,
finger: &str,
action: Action,
timeout: Duration,
cancel: F,
) -> Result<()>
where
F: Future<Output = std::io::Result<()>>,
{
if timeout.is_zero() {
bail!("Operation timeout must be greater than zero");
}
let proxy = device_proxy(conn, device).await?;
let mut state = OperationState::default();
let mut disconnect = DisconnectOnDrop(None);
let operation = async {
let mut owner_changes = proxy.receive_owner_changed().await?;
disconnect.0 = Some(conn.clone());
state.claim_uncertain = true;
let claim = proxy.call::<_, _, ()>("Claim", &(username,)).await;
state.claim_uncertain = claim
.as_ref()
.is_err_and(|error| !matches!(error, zbus::Error::MethodError(..)));
claim.with_context(|| format!("Cannot claim {}", device.name))?;
state.claimed = true;
if action == Action::Probe {
return Ok(());
}
// Subscribe after Claim to delimit our session, but before Start:
// some drivers emit their result before returning that method's reply.
let mut statuses = proxy.receive_signal(action.signal()).await?;
if action == Action::Enroll {
let stages: i32 = proxy.get_property("num-enroll-stages").await?;
println!(
"Enroll {finger} on {} ({stages} stages). Touch and lift your finger when prompted.",
device.name
);
} else {
println!(
"Verify {finger} on {}. Touch the selected reader.",
device.name
);
}
state.start_attempted = true;
state.start_uncertain = true;
let start = proxy
.call::<_, _, ()>(action.start_method(), &(finger,))
.await;
state.start_uncertain = start
.as_ref()
.is_err_and(|error| !matches!(error, zbus::Error::MethodError(..)));
start.with_context(|| format!("{} failed on {}", action.start_method(), device.name))?;
loop {
tokio::select! {
biased;
_ = owner_changes.next() => bail!("fprintd disconnected or restarted during the operation"),
status = statuses.next() => {
let status = status.context("Fingerprint status stream disconnected")?;
let (status, done): (String, bool) = status.body().deserialize()
.context("Invalid fingerprint status signal")?;
println!("{status}");
if action.completed(&status, done)? {
return Ok(());
}
}
}
}
};
let result = tokio::select! {
result = operation => result,
cancel = cancel => match cancel {
Ok(()) => Err(anyhow!("Fingerprint operation cancelled")),
Err(error) => Err(error).context("Cannot listen for Ctrl-C"),
},
_ = tokio::time::sleep(timeout) => Err(anyhow!("Fingerprint operation timed out after {} seconds", timeout.as_secs_f64())),
_ = conn.closed() => Err(anyhow!("System D-Bus connection closed")),
};
let cleanup_result = cleanup(&proxy, action, &state).await;
// A cancelled method may still finish at the server after our Release.
// Closing its sender identity prevents a late Claim/Start retaining it.
if state.claim_uncertain || state.start_uncertain || cleanup_result.is_err() {
let _ = tokio::time::timeout(CLEANUP_TIMEOUT, conn.clone().close()).await;
}
disconnect.0 = None;
match (result, cleanup_result) {
(Ok(()), cleanup) => cleanup,
(Err(error), Ok(())) => Err(error),
(Err(error), Err(cleanup_error)) => Err(error.context(cleanup_error)),
}
}
/// Open and close a reader, without starting capture or changing enrollment.
pub async fn probe(conn: &Connection, device: &Device, username: &str) -> Result<()> {
run_action(
conn,
device,
username,
"",
Action::Probe,
METHOD_TIMEOUT,
tokio::signal::ctrl_c(),
)
.await
}
pub async fn enroll(
conn: &Connection,
device: &Device,
username: &str,
finger: &str,
timeout: Duration,
allow_template_reset: bool,
) -> Result<()> {
if is_mafp(device) && !allow_template_reset {
bail!(
"Enrollment on {} requires --allow-template-reset: the installed libfprint 1.94.100 MAFP driver clears sensor template storage whenever enrollment starts. This can erase existing sensor templates, including ones created by Windows or another user, even when fprintd lists no enrolled fingers.",
device.name
);
}
run_action(
conn,
device,
username,
finger,
Action::Enroll,
timeout,
tokio::signal::ctrl_c(),
)
.await
}
pub async fn verify(
conn: &Connection,
device: &Device,
username: &str,
finger: &str,
timeout: Duration,
) -> Result<()> {
run_action(
conn,
device,
username,
finger,
Action::Verify,
timeout,
tokio::signal::ctrl_c(),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
io::{BufRead, BufReader},
process::{Child, Command, Stdio},
sync::{Arc, Mutex},
};
const PATH: &str = "/net/reactivated/Fprint/Device/0";
// Every test starts its own private session bus and fake fprintd. No tests
// connect to the system bus or touch a real fingerprint reader.
struct PrivateBus(Child);
impl PrivateBus {
fn start() -> Result<(Self, String)> {
let mut child = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.context("D-Bus lifecycle tests require dbus-daemon")?;
let stdout = child.stdout.take().context("Missing dbus-daemon stdout")?;
let guard = Self(child);
let mut address = String::new();
BufReader::new(stdout).read_line(&mut address)?;
if address.trim().is_empty() {
bail!("Private dbus-daemon did not provide an address");
}
Ok((guard, address.trim().to_owned()))
}
}
impl Drop for PrivateBus {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[derive(Debug, zbus::DBusError)]
#[zbus(prefix = "net.reactivated.Fprint.Error")]
enum MockError {
NoEnrolledPrints(String),
PermissionDenied(String),
ClaimDevice(String),
Internal(String),
}
struct State {
calls: Vec<&'static str>,
claimed: bool,
status: Option<(&'static str, bool)>,
fail_start: bool,
permission_denied: bool,
claim_delay: Duration,
start_delay: Duration,
emit_before_claim: bool,
}
impl Default for State {
fn default() -> Self {
Self {
calls: Vec::new(),
claimed: false,
status: Some(("verify-match", true)),
fail_start: false,
permission_denied: false,
claim_delay: Duration::ZERO,
start_delay: Duration::ZERO,
emit_before_claim: false,
}
}
}
struct MockDevice(Arc<Mutex<State>>);
impl MockDevice {
async fn start(
&self,
conn: &Connection,
method: &'static str,
signal: &str,
) -> std::result::Result<(), MockError> {
let (delay, fail, status) = {
let mut state = self.0.lock().unwrap();
state.calls.push(method);
(state.start_delay, state.fail_start, state.status)
};
tokio::time::sleep(delay).await;
if fail {
return Err(MockError::Internal("start rejected".to_owned()));
}
if let Some((status, done)) = status {
// Send before returning Start's reply, exercising the race
// that subscribing after Start would lose.
conn.emit_signal(
None::<&str>,
PATH,
DEVICE_INTERFACE,
signal,
&(status, done),
)
.await
.map_err(|error| MockError::Internal(error.to_string()))?;
}
Ok(())
}
}
#[zbus::interface(name = "net.reactivated.Fprint.Device")]
impl MockDevice {
#[zbus(property, name = "name")]
fn name(&self) -> &str {
"MAFP MOC Fingerprint Sensor"
}
#[zbus(property, name = "num-enroll-stages")]
fn num_enroll_stages(&self) -> i32 {
8
}
fn list_enrolled_fingers(
&self,
_username: &str,
) -> std::result::Result<Vec<String>, MockError> {
if self.0.lock().unwrap().permission_denied {
Err(MockError::PermissionDenied(
"NoEnrolledPrints text is not an error name".to_owned(),
))
} else {
Err(MockError::NoEnrolledPrints("no prints".to_owned()))
}
}
async fn claim(&self, _username: &str, #[zbus(connection)] conn: &Connection) {
let (delay, emit_before_claim) = {
let mut state = self.0.lock().unwrap();
state.calls.push("Claim");
(state.claim_delay, state.emit_before_claim)
};
if emit_before_claim {
conn.emit_signal(
None::<&str>,
PATH,
DEVICE_INTERFACE,
"VerifyStatus",
&("verify-match", true),
)
.await
.unwrap();
}
tokio::time::sleep(delay).await;
self.0.lock().unwrap().claimed = true;
}
fn release(&self) -> std::result::Result<(), MockError> {
let mut state = self.0.lock().unwrap();
state.calls.push("Release");
if !state.claimed {
return Err(MockError::ClaimDevice("not claimed".to_owned()));
}
state.claimed = false;
Ok(())
}
async fn verify_start(
&self,
_finger: &str,
#[zbus(connection)] conn: &Connection,
) -> std::result::Result<(), MockError> {
self.start(conn, "VerifyStart", "VerifyStatus").await
}
async fn enroll_start(
&self,
_finger: &str,
#[zbus(connection)] conn: &Connection,
) -> std::result::Result<(), MockError> {
self.start(conn, "EnrollStart", "EnrollStatus").await
}
fn verify_stop(&self) {
self.0.lock().unwrap().calls.push("VerifyStop");
}
fn enroll_stop(&self) {
self.0.lock().unwrap().calls.push("EnrollStop");
}
}
struct MockManager;
#[zbus::interface(name = "net.reactivated.Fprint.Manager")]
impl MockManager {
fn get_devices(&self) -> Vec<OwnedObjectPath> {
vec![OwnedObjectPath::try_from(PATH).unwrap()]
}
}
struct Harness {
client: Connection,
service: Connection,
state: Arc<Mutex<State>>,
_bus: PrivateBus,
}
impl Harness {
async fn new() -> Result<Self> {
let (bus, address) = PrivateBus::start()?;
let state = Arc::new(Mutex::new(State::default()));
let service = zbus::connection::Builder::address(address.as_str())?
.name(SERVICE)?
.serve_at(PATH, MockDevice(state.clone()))?
.serve_at(MANAGER_PATH, MockManager)?
.build()
.await?;
let client = zbus::connection::Builder::address(address.as_str())?
.method_timeout(Duration::from_secs(2))
.build()
.await?;
Ok(Self {
client,
service,
state,
_bus: bus,
})
}
fn device(&self) -> Device {
Device {
path: PATH.to_owned(),
name: "MAFP MOC Fingerprint Sensor".to_owned(),
fingers: Vec::new(),
stages: 8,
}
}
fn calls(&self) -> Vec<&'static str> {
self.state.lock().unwrap().calls.clone()
}
async fn verify(&self, timeout: Duration) -> Result<()> {
run_action(
&self.client,
&self.device(),
"",
"any",
Action::Verify,
timeout,
std::future::pending(),
)
.await
}
}
async fn wait_for_call(state: &Arc<Mutex<State>>, method: &str) {
tokio::time::timeout(Duration::from_secs(2), async {
while !state.lock().unwrap().calls.contains(&method) {
tokio::time::sleep(Duration::from_millis(2)).await;
}
})
.await
.expect("mock method was never called");
}
#[tokio::test]
async fn enumeration_only_swallows_exact_no_enrolled_error() -> Result<()> {
let harness = Harness::new().await?;
let devices = enumerate(&harness.client, "").await?;
assert_eq!(devices.len(), 1);
assert!(devices[0].fingers.is_empty());
harness.state.lock().unwrap().permission_denied = true;
let error = enumerate(&harness.client, "").await.unwrap_err();
assert!(format!("{error:#}").contains("PermissionDenied"));
assert!(harness.calls().is_empty());
Ok(())
}
#[tokio::test]
async fn probe_only_claims_and_releases() -> Result<()> {
let harness = Harness::new().await?;
probe(&harness.client, &harness.device(), "").await?;
assert_eq!(harness.calls(), ["Claim", "Release"]);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn verification_catches_early_match_and_cleans_up() -> Result<()> {
let harness = Harness::new().await?;
harness.verify(Duration::from_secs(2)).await?;
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn statuses_before_our_claim_are_not_accepted() -> Result<()> {
let harness = Harness::new().await?;
{
let mut state = harness.state.lock().unwrap();
state.emit_before_claim = true;
state.status = None;
}
let error = harness
.verify(Duration::from_millis(100))
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"));
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
Ok(())
}
#[tokio::test]
async fn no_match_or_inconsistent_terminal_status_never_succeeds() -> Result<()> {
let harness = Harness::new().await?;
for status in [
("verify-no-match", true),
("verify-match", false),
("verify-retry-scan", true),
("unexpected", false),
] {
harness.state.lock().unwrap().status = Some(status);
assert!(
harness.verify(Duration::from_secs(2)).await.is_err(),
"accepted {status:?}"
);
assert!(!harness.state.lock().unwrap().claimed);
assert!(harness.calls().ends_with(&["VerifyStop", "Release"]));
}
Ok(())
}
#[tokio::test]
async fn start_failure_still_stops_and_releases() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().fail_start = true;
assert!(harness.verify(Duration::from_secs(2)).await.is_err());
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn timeout_stops_and_releases() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().status = None;
let error = harness
.verify(Duration::from_millis(100))
.await
.unwrap_err();
assert!(format!("{error:#}").contains("timed out"));
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn cancellation_stops_and_releases() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().status = None;
let cancellation = async {
wait_for_call(&harness.state, "VerifyStart").await;
Ok(())
};
let error = run_action(
&harness.client,
&harness.device(),
"",
"any",
Action::Verify,
Duration::from_secs(2),
cancellation,
)
.await
.unwrap_err();
assert!(format!("{error:#}").contains("cancelled"));
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn cancellation_during_claim_closes_sender_identity() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().claim_delay = Duration::from_secs(1);
let cancellation = async {
wait_for_call(&harness.state, "Claim").await;
Ok(())
};
assert!(
run_action(
&harness.client,
&harness.device(),
"",
"any",
Action::Verify,
Duration::from_secs(2),
cancellation
)
.await
.is_err()
);
tokio::time::timeout(Duration::from_millis(100), harness.client.closed()).await?;
assert_eq!(harness.calls(), ["Claim", "Release"]);
Ok(())
}
#[tokio::test]
async fn cancellation_during_start_stops_releases_and_closes_sender() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().start_delay = Duration::from_secs(1);
let cancellation = async {
wait_for_call(&harness.state, "VerifyStart").await;
Ok(())
};
assert!(
run_action(
&harness.client,
&harness.device(),
"",
"any",
Action::Verify,
Duration::from_secs(2),
cancellation
)
.await
.is_err()
);
tokio::time::timeout(Duration::from_millis(100), harness.client.closed()).await?;
assert_eq!(
harness.calls(),
["Claim", "VerifyStart", "VerifyStop", "Release"]
);
assert!(!harness.state.lock().unwrap().claimed);
Ok(())
}
#[tokio::test]
async fn fprintd_disappearance_fails_without_waiting_for_operation_timeout() -> Result<()> {
let harness = Harness::new().await?;
harness.state.lock().unwrap().status = None;
let service = harness.service.clone();
let state = harness.state.clone();
let closer = tokio::spawn(async move {
wait_for_call(&state, "VerifyStart").await;
service.close().await.unwrap();
});
let error = tokio::time::timeout(
Duration::from_secs(2),
harness.verify(Duration::from_secs(30)),
)
.await?
.unwrap_err();
assert!(format!("{error:#}").contains("disconnected or restarted"));
closer.await?;
Ok(())
}
#[tokio::test]
async fn enrollment_needs_explicit_reset_consent_even_without_host_prints() -> Result<()> {
let harness = Harness::new().await?;
let error = enroll(
&harness.client,
&harness.device(),
"",
"right-index-finger",
Duration::from_secs(2),
false,
)
.await
.unwrap_err();
assert!(error.to_string().contains("--allow-template-reset"));
assert!(harness.calls().is_empty());
harness.state.lock().unwrap().status = Some(("enroll-completed", true));
enroll(
&harness.client,
&harness.device(),
"",
"right-index-finger",
Duration::from_secs(2),
true,
)
.await?;
assert_eq!(
harness.calls(),
["Claim", "EnrollStart", "EnrollStop", "Release"]
);
Ok(())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod fprint;
pub mod routing;
pub mod selection;
+287
View File
@@ -0,0 +1,287 @@
use std::time::Duration;
use anyhow::{Context, Result, bail};
use fingerprint_switch::{fprint, routing, selection};
const HELP: &str = "fingerprint-switch — choose a reader using the installed fprintd drivers
Usage:
fingerprint-switch list [--user USER]
fingerprint-switch status
fingerprint-switch probe [--sensor SELECTOR] [--user USER]
fingerprint-switch enroll [--sensor SELECTOR] [--finger FINGER] [--timeout SECONDS]
[--user USER] [--allow-template-reset]
fingerprint-switch verify [--sensor SELECTOR] [--finger FINGER] [--timeout SECONDS]
[--user USER]
sudo fingerprint-switch mode auto|internal|external|both
fingerprint-switch prepare (system service only)
fingerprint-switch watch-lid (system service only)
SELECTOR: auto (default), internal, external, exact reader name, or D-Bus path.
Auto prefers Goodix with the lid open and MAFP with it closed, with unplug fallback.
Enroll defaults to right-index-finger; verify defaults to any enrolled finger.
Timeout defaults to 120 seconds for enroll, 30 for verify. Ctrl-C cancels.
Run as your normal desktop user; fprintd handles authorization through Polkit.
The MAFP driver in libfprint 1.94.100 clears ALL on-device templates when enrolling.
--allow-template-reset explicitly acknowledges that limitation, including prints
stored by Windows or other users. It is unnecessary for listing/probing/verifying.
CLI selection affects this command. Optional system integration is required to
route ordinary login/sudo authentication. See README.md for install and rollback.
";
struct Options {
command: String,
sensor: String,
user: String,
finger: Option<String>,
timeout: Option<u64>,
allow_template_reset: bool,
mode: Option<String>,
}
fn parse(args: impl Iterator<Item = String>) -> Result<Options> {
let mut args = args.peekable();
let command = args.next().unwrap_or_else(|| "help".into());
if matches!(command.as_str(), "help" | "--help" | "-h") {
return Ok(Options {
command: "help".into(),
sensor: "auto".into(),
user: String::new(),
finger: None,
timeout: None,
allow_template_reset: false,
mode: None,
});
}
if !matches!(
command.as_str(),
"list" | "status" | "probe" | "enroll" | "verify" | "mode" | "prepare" | "watch-lid"
) {
bail!("Unknown command {command:?}. Use --help.");
}
let mut options = Options {
command,
sensor: "auto".into(),
user: String::new(),
finger: None,
timeout: None,
allow_template_reset: false,
mode: None,
};
if options.command == "mode" {
options.mode = Some(
args.next()
.context("mode requires auto, internal, external, or both")?,
);
if args.next().is_some() {
bail!("mode accepts exactly one argument");
}
return Ok(options);
}
let mut seen = std::collections::HashSet::new();
while let Some(arg) = args.next() {
if !seen.insert(arg.clone()) {
bail!("Duplicate option {arg}");
}
let operation = matches!(options.command.as_str(), "probe" | "enroll" | "verify");
let scan = matches!(options.command.as_str(), "enroll" | "verify");
match arg.as_str() {
"--sensor" if operation => {
options.sensor = args.next().context("--sensor needs a selector")?
}
"--user" if operation || options.command == "list" => {
options.user = args.next().context("--user needs a username")?
}
"--finger" if scan => {
options.finger = Some(args.next().context("--finger needs a finger name")?)
}
"--timeout" if scan => {
let value: u64 = args
.next()
.context("--timeout needs seconds")?
.parse()
.context("Invalid timeout")?;
if !(1..=600).contains(&value) {
bail!("Timeout must be between 1 and 600 seconds");
}
options.timeout = Some(value);
}
"--allow-template-reset" if options.command == "enroll" => {
options.allow_template_reset = true
}
_ => bail!(
"Invalid option {arg:?} for {}. Use --help.",
options.command
),
}
}
if let Some(finger) = &options.finger {
let valid = [
"left-thumb",
"left-index-finger",
"left-middle-finger",
"left-ring-finger",
"left-little-finger",
"right-thumb",
"right-index-finger",
"right-middle-finger",
"right-ring-finger",
"right-little-finger",
];
if !valid.contains(&finger.as_str()) && !(options.command == "verify" && finger == "any") {
bail!("Invalid finger {finger:?}; e.g. right-index-finger (or any for verify)");
}
}
Ok(options)
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
match run().await {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error:#}");
std::process::ExitCode::FAILURE
}
}
}
async fn run() -> Result<()> {
let options = parse(std::env::args().skip(1))?;
match options.command.as_str() {
"help" => {
print!("{HELP}");
return Ok(());
}
"prepare" => return routing::prepare().await,
"watch-lid" => return routing::watch().await,
"mode" => {
routing::set_mode(options.mode.as_deref().unwrap())?;
println!(
"Routing mode saved. The installed fingerprint-switch service applies it within a few seconds. Switching can cancel an active scan."
);
return Ok(());
}
_ => {}
}
let connection = fprint::connect().await?;
let devices = fprint::enumerate(&connection, &options.user).await?;
if options.command == "list" || options.command == "status" {
if devices.is_empty() {
println!("No readers available through fprintd.");
}
for device in &devices {
let stages = if device.stages < 0 {
"reported after opening".into()
} else {
device.stages.to_string()
};
println!(
"{}\n {}\n Enrolled: {}\n Enrollment stages: {}",
device.name,
device.path,
if device.fingers.is_empty() {
"none".into()
} else {
device.fingers.join(", ")
},
stages
);
}
if options.command == "status" {
match routing::lid_state(&connection).await {
Ok(state) => {
println!(
"Lid: {}",
match state {
Some(true) => "closed",
Some(false) => "open",
None => "not present",
}
);
if let Ok(device) = selection::select(&devices, "auto", state.unwrap_or(false))
{
println!("CLI auto selection: {}", device.name);
}
}
Err(error) => eprintln!("Lid state unavailable: {error:#}"),
}
let mode = std::fs::read_to_string("/etc/fingerprint-switch/mode")
.unwrap_or_else(|_| "auto (default)".into());
println!("Configured system mode: {}", mode.trim());
match std::fs::read_to_string("/run/fingerprint-switch/fprintd.env") {
Ok(environment) => println!(
"Watcher environment: {} (applies when fprintd starts/restarts)",
environment.trim()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => println!(
"Watcher environment: absent; the watcher may not be installed or running"
),
Err(error) => println!(
"Watcher environment: cannot read ({error}); check systemctl status fingerprint-switch"
),
}
println!(
"CLI selection does not change PAM. The optional system service filters the readers available to all fprintd clients."
);
}
return Ok(());
}
let closed = if options.sensor == "auto" {
routing::lid_state(&connection)
.await
.context("Cannot determine lid state; use --sensor internal or --sensor external")?
.unwrap_or(false)
} else {
false
};
let device = selection::select(&devices, &options.sensor, closed)?;
println!("Selected: {} ({})", device.name, device.path);
match options.command.as_str() {
"probe" => {
fprint::probe(&connection, device, &options.user).await?;
println!("Reader opened and released successfully; no enrollment performed.");
}
"enroll" => {
fprint::enroll(
&connection,
device,
&options.user,
options.finger.as_deref().unwrap_or("right-index-finger"),
Duration::from_secs(options.timeout.unwrap_or(120)),
options.allow_template_reset,
)
.await?
}
"verify" => {
fprint::verify(
&connection,
device,
&options.user,
options.finger.as_deref().unwrap_or("any"),
Duration::from_secs(options.timeout.unwrap_or(30)),
)
.await?
}
_ => unreachable!(),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn options(args: &[&str]) -> Result<Options> {
parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn invalid_operations_are_rejected_before_dbus() {
assert!(options(&["enroll", "--finger", "any"]).is_err());
assert!(options(&["verify", "--allow-template-reset"]).is_err());
assert!(options(&["verify", "--timeout", "0"]).is_err());
assert!(options(&["enroll", "--sensor", "external", "--sensor", "internal"]).is_err());
assert!(options(&["probe", "--finger", "right-thumb"]).is_err());
}
}
+611
View File
@@ -0,0 +1,611 @@
//! Optional system-wide selection of the fingerprint driver fprintd loads.
//!
//! The running fprintd process must restart to observe an allowlist change.
//! This module changes only a fixed environment file and asks systemd to restart
//! an already running fprintd; it never writes USB devices or fingerprint data.
use anyhow::{Context, Result, bail};
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::{ErrorKind, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
use std::process::Stdio;
use std::str::FromStr;
use std::time::Duration;
use zbus::zvariant::OwnedValue;
const ENVIRONMENT_FILE: &str = "/run/fingerprint-switch/fprintd.env";
const MODE_FILE: &str = "/etc/fingerprint-switch/mode";
const DBUS_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Mode {
Auto,
Internal,
External,
Both,
}
impl FromStr for Mode {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value {
"auto" => Ok(Self::Auto),
"internal" => Ok(Self::Internal),
"external" => Ok(Self::External),
"both" => Ok(Self::Both),
_ => bail!("invalid routing mode; choose auto, internal, external, or both"),
}
}
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auto => "auto",
Self::Internal => "internal",
Self::External => "external",
Self::Both => "both",
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Route {
Internal,
External,
Both,
}
impl Route {
fn drivers(self) -> &'static str {
match self {
Self::Internal => "goodixmoc",
Self::External => "mafpmoc",
Self::Both => "goodixmoc:mafpmoc",
}
}
fn environment(self) -> String {
format!("FP_DRIVERS_ALLOWLIST={}\n", self.drivers())
}
}
/// A fresh routing prediction, including the exact supported USB devices.
#[derive(Debug)]
pub struct Snapshot {
pub mode: String,
/// `Some(true)` means closed; `None` means absent or unavailable.
pub lid_closed: Option<bool>,
pub lid_error: Option<String>,
pub internal_present: bool,
pub external_present: bool,
pub drivers: &'static str,
}
fn select_route(mode: Mode, closed: Option<bool>, internal: bool, external: bool) -> Route {
match mode {
Mode::Internal => Route::Internal,
Mode::External => Route::External,
Mode::Both => Route::Both,
Mode::Auto => match (internal, external) {
(true, true) if closed == Some(true) => Route::External,
(true, _) => Route::Internal,
(false, true) => Route::External,
(false, false) => Route::Both,
},
}
}
async fn system_connection() -> Result<zbus::Connection> {
tokio::time::timeout(
DBUS_TIMEOUT,
zbus::connection::Builder::system()?
.method_timeout(DBUS_TIMEOUT)
.build(),
)
.await
.context("system D-Bus connection timed out")?
.context("connect to the system D-Bus")
}
/// Read UPower's lid properties directly, without a cached property value.
/// A computer without a lid returns `None`; communication errors propagate.
pub async fn lid_state(connection: &zbus::Connection) -> Result<Option<bool>> {
let properties = zbus::Proxy::new(
connection,
"org.freedesktop.UPower",
"/org/freedesktop/UPower",
"org.freedesktop.DBus.Properties",
)
.await?;
let present: OwnedValue = properties
.call("Get", &("org.freedesktop.UPower", "LidIsPresent"))
.await
.context("read UPower LidIsPresent")?;
if !bool::try_from(present).context("decode UPower LidIsPresent")? {
return Ok(None);
}
let closed: OwnedValue = properties
.call("Get", &("org.freedesktop.UPower", "LidIsClosed"))
.await
.context("read UPower LidIsClosed")?;
Ok(Some(
bool::try_from(closed).context("decode UPower LidIsClosed")?,
))
}
fn read_mode() -> Result<Mode> {
match fs::read_to_string(MODE_FILE) {
Ok(value) => value.trim().parse().context("read routing configuration"),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(Mode::Auto),
Err(error) => Err(error).context("read routing configuration"),
}
}
fn usb_presence(root: &Path) -> Result<(bool, bool)> {
let mut internal = false;
let mut external = false;
for entry in fs::read_dir(root).context("enumerate USB devices")? {
let path = entry.context("read a USB directory entry")?.path();
// Interface entries have no IDs; a device may also disappear during a scan.
let read_id = |name| -> Result<Option<u16>> {
match fs::read_to_string(path.join(name)) {
Ok(id) => Ok(u16::from_str_radix(id.trim(), 16).ok()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).context("read USB device identity"),
}
};
match (read_id("idVendor")?, read_id("idProduct")?) {
(Some(0x27c6), Some(0x609c)) => internal = true,
(Some(0x3274), Some(0x8012)) => external = true,
_ => {}
}
}
Ok((internal, external))
}
async fn snapshot_with_connection(connection: &zbus::Connection) -> Result<Snapshot> {
let mode = read_mode()?;
let (lid_closed, lid_error) = match lid_state(connection).await {
Ok(state) => (state, None),
Err(error) => (None, Some(format!("{error:#}"))),
};
let (internal_present, external_present) = usb_presence(Path::new("/sys/bus/usb/devices"))?;
let route = select_route(mode, lid_closed, internal_present, external_present);
Ok(Snapshot {
mode: mode.to_string(),
lid_closed,
lid_error,
internal_present,
external_present,
drivers: route.drivers(),
})
}
/// Predict the route from current configuration, lid state, and USB presence.
/// This is read-only and does not require root.
pub async fn snapshot() -> Result<Snapshot> {
snapshot_with_connection(&system_connection().await?).await
}
fn require_root() -> Result<()> {
let status = fs::read_to_string("/proc/self/status").context("read effective user ID")?;
let effective = status
.lines()
.find_map(|line| line.strip_prefix("Uid:"))
.and_then(|uids| uids.split_whitespace().nth(1))
.context("effective user ID missing from /proc/self/status")?;
if effective != "0" {
bail!("system-wide fingerprint routing requires root; run this command with sudo");
}
Ok(())
}
fn parse_environment(value: &str) -> Option<Route> {
match value.strip_suffix('\n').unwrap_or(value) {
"FP_DRIVERS_ALLOWLIST=goodixmoc" => Some(Route::Internal),
"FP_DRIVERS_ALLOWLIST=mafpmoc" => Some(Route::External),
"FP_DRIVERS_ALLOWLIST=goodixmoc:mafpmoc" => Some(Route::Both),
_ => None,
}
}
/// Atomically replace a fixed configuration file, returning whether it changed.
fn write_if_changed(path: &Path, value: &[u8]) -> Result<bool> {
match fs::read(path) {
Ok(existing) if existing == value => return Ok(false),
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error).context("read existing routing file"),
}
let parent = path.parent().context("routing file has no parent")?;
fs::create_dir_all(parent).context("create routing configuration directory")?;
let name = path
.file_name()
.context("routing file has no name")?
.to_string_lossy();
for serial in 0..100 {
let temporary = parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), serial));
let file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.open(&temporary);
let mut file = match file {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error).context("create temporary routing file"),
};
let result = (|| -> Result<()> {
file.write_all(value)
.context("write routing configuration")?;
// Both files contain only a public, fixed selection. Override a
// restrictive service umask so an unprivileged status can read them.
file.set_permissions(fs::Permissions::from_mode(0o644))
.context("set routing configuration permissions")?;
file.sync_all().context("sync routing configuration")?;
fs::rename(&temporary, path).context("replace routing configuration")?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result?;
return Ok(true);
}
bail!("could not allocate a temporary routing file");
}
fn write_snapshot(state: &Snapshot) -> Result<bool> {
let value = format!("FP_DRIVERS_ALLOWLIST={}\n", state.drivers);
let route = parse_environment(&value).context("invalid computed driver allowlist")?;
write_if_changed(Path::new(ENVIRONMENT_FILE), route.environment().as_bytes())
}
fn environment_exists(path: &Path) -> Result<bool> {
match fs::read(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
Err(error) => Err(error).context("read existing routing environment"),
}
}
fn initialize_environment(path: &Path, route: Route) -> Result<bool> {
if environment_exists(path)? {
return Ok(false);
}
write_if_changed(path, route.environment().as_bytes())
}
/// Persist a fixed routing mode. The enabled watcher applies it on its next poll.
pub fn set_mode(value: &str) -> Result<()> {
let mode: Mode = value.parse()?;
require_root()?;
write_if_changed(Path::new(MODE_FILE), format!("{mode}\n").as_bytes())?;
Ok(())
}
/// Initialize a missing environment file before systemd starts fprintd.
///
/// Preserve an existing file so the watcher can detect a changed selection and
/// restart an older fprintd. RuntimeDirectoryPreserve keeps it across restarts.
pub async fn prepare() -> Result<()> {
require_root()?;
let path = Path::new(ENVIRONMENT_FILE);
if !environment_exists(path)? {
let state = snapshot().await?;
let route = parse_environment(&format!("FP_DRIVERS_ALLOWLIST={}\n", state.drivers))
.context("invalid computed driver allowlist")?;
initialize_environment(path, route)?;
}
Ok(())
}
#[derive(Default)]
struct Reconciliation {
pending: bool,
}
impl Reconciliation {
fn route_changed(&mut self) {
self.pending = true;
}
fn should_restart(&mut self, activity: &str) -> bool {
if !self.pending {
return false;
}
match activity {
"active" => true,
// A future activation will read the environment just written.
"inactive" | "failed" => {
self.pending = false;
false
}
// If a start or stop is in progress, reconcile after it settles.
_ => false,
}
}
fn restart_succeeded(&mut self) {
self.pending = false;
}
}
async fn fprintd_state(connection: &zbus::Connection) -> Result<String> {
let manager = zbus::Proxy::new(
connection,
"org.freedesktop.systemd1",
"/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager",
)
.await?;
let unit: zbus::zvariant::OwnedObjectPath = manager
.call("LoadUnit", &("fprintd.service",))
.await
.context("find fprintd systemd unit")?;
let properties = zbus::Proxy::new(
connection,
"org.freedesktop.systemd1",
unit,
"org.freedesktop.DBus.Properties",
)
.await?;
let state: OwnedValue = properties
.call("Get", &("org.freedesktop.systemd1.Unit", "ActiveState"))
.await
.context("read fprintd activity")?;
String::try_from(state).context("decode fprintd activity")
}
async fn restart_active_fprintd() -> Result<()> {
let output = tokio::time::timeout(
DBUS_TIMEOUT,
tokio::process::Command::new("/usr/bin/systemctl")
.args(["try-restart", "--no-block", "fprintd.service"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.output(),
)
.await
.context("systemctl restart request timed out")?
.context("run systemctl restart request")?;
if !output.status.success() {
bail!(
"systemctl restart request failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Watch lid, USB presence, and configuration once per second.
///
/// Restart a running fprintd only after a changed allowlist. A freshly prepared
/// environment needs no restart: fprintd's first activation reads it directly.
/// Failed requests remain pending for retry; an inactive fprintd is never
/// started. Installation separately restarts a preexisting fprintd so it loads
/// the newly installed systemd drop-in.
pub async fn watch() -> Result<()> {
require_root()?;
let connection = system_connection().await?;
let mut reconciliation = Reconciliation::default();
let mut last_error = None;
let mut last_lid_error = None;
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = connection.closed() => bail!("system D-Bus connection closed; restart the watcher to reconnect"),
result = tokio::signal::ctrl_c() => {
result.context("wait for interrupt")?;
return Ok(());
},
_ = interval.tick() => {},
}
let result = async {
let state = snapshot_with_connection(&connection).await?;
if state.lid_error != last_lid_error {
if let Some(error) = &state.lid_error {
eprintln!("Lid state unavailable; preferring the internal reader in auto mode: {error}");
} else if last_lid_error.is_some() {
eprintln!("Lid state available again");
}
last_lid_error = state.lid_error.clone();
}
if write_snapshot(&state)? {
reconciliation.route_changed();
eprintln!("Fingerprint routing changed: mode={}, drivers={}", state.mode, state.drivers);
}
if reconciliation.pending
&& reconciliation.should_restart(&fprintd_state(&connection).await?)
{
restart_active_fprintd().await?;
reconciliation.restart_succeeded();
eprintln!("Requested fprintd restart to apply drivers={}", state.drivers);
}
Ok::<(), anyhow::Error>(())
}
.await;
match result {
Ok(()) => last_error = None,
Err(error) => {
let message = format!("{error:#}");
if last_error.as_ref() != Some(&message) {
eprintln!("Could not apply fingerprint routing; will retry: {message}");
}
last_error = Some(message);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn auto_selects_lid_preference_and_falls_back() {
assert_eq!(
select_route(Mode::Auto, Some(false), true, true),
Route::Internal
);
assert_eq!(
select_route(Mode::Auto, Some(true), true, true),
Route::External
);
assert_eq!(select_route(Mode::Auto, None, true, true), Route::Internal);
for lid in [None, Some(false), Some(true)] {
assert_eq!(select_route(Mode::Auto, lid, true, false), Route::Internal);
assert_eq!(select_route(Mode::Auto, lid, false, true), Route::External);
assert_eq!(select_route(Mode::Auto, lid, false, false), Route::Both);
}
}
#[test]
fn explicit_modes_remain_explicit_even_when_unplugged() {
for lid in [None, Some(false), Some(true)] {
for internal in [false, true] {
for external in [false, true] {
assert_eq!(
select_route(Mode::Internal, lid, internal, external),
Route::Internal
);
assert_eq!(
select_route(Mode::External, lid, internal, external),
Route::External
);
assert_eq!(
select_route(Mode::Both, lid, internal, external),
Route::Both
);
}
}
}
}
#[test]
fn environment_contains_only_a_known_allowlist() {
for route in [Route::Internal, Route::External, Route::Both] {
assert_eq!(parse_environment(&route.environment()), Some(route));
}
for invalid in [
"FP_DRIVERS_ALLOWLIST=unknown\n",
"FP_DRIVERS_ALLOWLIST=goodixmoc\nLD_PRELOAD=/tmp/evil.so\n",
"FP_DRIVERS_ALLOWLIST='mafpmoc'\n",
"FP_DRIVERS_ALLOWLIST=mafpmoc\n\n",
] {
assert_eq!(parse_environment(invalid), None);
}
assert!("external\nanything".parse::<Mode>().is_err());
}
struct TemporaryDirectory(std::path::PathBuf);
impl TemporaryDirectory {
fn new() -> Self {
static SERIAL: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"fingerprint-switch-routing-test-{}-{}",
std::process::id(),
SERIAL.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for TemporaryDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn identical_environment_does_not_request_another_restart() {
let directory = TemporaryDirectory::new();
let path = directory.0.join("fprintd.env");
let internal = Route::Internal.environment();
assert!(write_if_changed(&path, internal.as_bytes()).unwrap());
assert!(!write_if_changed(&path, internal.as_bytes()).unwrap());
let external = Route::External.environment();
assert!(write_if_changed(&path, external.as_bytes()).unwrap());
assert_eq!(fs::read_to_string(&path).unwrap(), external);
assert!(!write_if_changed(&path, external.as_bytes()).unwrap());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn first_activation_uses_prepared_environment_without_restart() {
let directory = TemporaryDirectory::new();
let path = directory.0.join("fprintd.env");
let route = Route::Internal;
assert!(initialize_environment(&path, route).unwrap());
let mut reconciliation = Reconciliation::default();
if write_if_changed(&path, route.environment().as_bytes()).unwrap() {
reconciliation.route_changed();
}
assert!(!reconciliation.should_restart("activating"));
assert!(!reconciliation.should_restart("active"));
}
#[test]
fn watcher_restart_detects_route_change_preserved_by_prepare() {
let directory = TemporaryDirectory::new();
let path = directory.0.join("fprintd.env");
assert!(initialize_environment(&path, Route::Internal).unwrap());
// The lid closed while the watcher was stopped. prepare must not hide
// this change from the watcher while fprintd still uses Internal.
assert!(!initialize_environment(&path, Route::External).unwrap());
assert_eq!(
fs::read_to_string(&path).unwrap(),
Route::Internal.environment()
);
let mut reconciliation = Reconciliation::default();
if write_if_changed(&path, Route::External.environment().as_bytes()).unwrap() {
reconciliation.route_changed();
}
assert!(!reconciliation.should_restart("activating"));
assert!(reconciliation.should_restart("active"));
// A failed request must be retried even when the file no longer changes.
assert!(reconciliation.should_restart("active"));
reconciliation.restart_succeeded();
assert!(!reconciliation.should_restart("active"));
}
#[test]
fn route_change_never_starts_inactive_fprintd() {
for inactive in ["inactive", "failed"] {
let mut reconciliation = Reconciliation::default();
reconciliation.route_changed();
assert!(!reconciliation.should_restart(inactive));
// Its next activation reads the new allowlist itself.
assert!(!reconciliation.should_restart("active"));
}
}
#[test]
fn usb_detection_requires_the_exact_vendor_and_product() {
let directory = TemporaryDirectory::new();
let add = |name: &str, vendor: &str, product: &str| {
let device = directory.0.join(name);
fs::create_dir(&device).unwrap();
fs::write(device.join("idVendor"), vendor).unwrap();
fs::write(device.join("idProduct"), product).unwrap();
};
add("other-goodix", "27c6\n", "0001\n");
add("other-microarray", "3274\n", "8013\n");
fs::create_dir(directory.0.join("interface-without-ids")).unwrap();
assert_eq!(usb_presence(&directory.0).unwrap(), (false, false));
add("internal", "27c6\n", "609c\n");
assert_eq!(usb_presence(&directory.0).unwrap(), (true, false));
add("external", "3274\n", "8012\n");
assert_eq!(usb_presence(&directory.0).unwrap(), (true, true));
}
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::{Result, bail};
use crate::fprint::Device;
pub const INTERNAL_NAME: &str = "Goodix MOC Fingerprint Sensor";
pub const EXTERNAL_NAME: &str = "MAFP MOC Fingerprint Sensor";
/// Names are rediscovered on each invocation. D-Bus paths are not persistent IDs.
pub fn select<'a>(devices: &'a [Device], selector: &str, lid_closed: bool) -> Result<&'a Device> {
if selector == "auto" {
let (preferred, fallback) = if lid_closed {
(EXTERNAL_NAME, INTERNAL_NAME)
} else {
(INTERNAL_NAME, EXTERNAL_NAME)
};
if devices.iter().any(|d| d.name == preferred) {
return select(devices, preferred, lid_closed);
}
return select(devices, fallback, lid_closed);
}
let selector = match selector {
"internal" => INTERNAL_NAME,
"external" => EXTERNAL_NAME,
other => other,
};
let matches: Vec<_> = devices
.iter()
.filter(|d| d.name == selector || d.path == selector)
.collect();
match matches.as_slice() {
[device] => Ok(device),
[] => bail!(
"No available reader matches {selector:?}. Run `fingerprint-switch list`. \
If automatic routing is installed, `sudo fingerprint-switch mode both` exposes both readers."
),
_ => bail!(
"More than one reader matches {selector:?}; use an explicit D-Bus path from `fingerprint-switch list`."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device(name: &str, path: &str) -> Device {
Device {
name: name.into(),
path: path.into(),
fingers: vec![],
stages: 12,
}
}
#[test]
fn lid_selection_ignores_enumeration_order_and_finger_count() {
let external = device(EXTERNAL_NAME, "/external");
let mut internal = device(INTERNAL_NAME, "/internal");
internal.fingers = vec!["right-thumb".into(); 3];
let devices = vec![external, internal];
assert_eq!(select(&devices, "auto", true).unwrap().path, "/external");
assert_eq!(select(&devices, "auto", false).unwrap().path, "/internal");
assert_eq!(
select(&devices, "external", false).unwrap().path,
"/external"
);
}
#[test]
fn auto_falls_back_but_explicit_selection_does_not() {
let devices = vec![device(INTERNAL_NAME, "/internal")];
assert_eq!(select(&devices, "auto", true).unwrap().path, "/internal");
assert!(select(&devices, "external", true).is_err());
assert!(select(&[], "auto", false).is_err());
}
#[test]
fn duplicate_names_need_explicit_path() {
let devices = vec![device(EXTERNAL_NAME, "/one"), device(EXTERNAL_NAME, "/two")];
assert!(select(&devices, "external", true).is_err());
assert!(select(&devices, "auto", true).is_err());
assert_eq!(select(&devices, "/two", true).unwrap().path, "/two");
}
}