From 32553efa89d8694e218ee5782ec27dd04acef003 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:39:57 +0100 Subject: [PATCH 1/8] refactor(psl): split psl.rs into psl/ module with dat.rs Pure code move with no behavior change. Splits the single-file PSL module into a directory layout to make room for additional reader implementations. The trait and MockPublicSuffixList stay in mod.rs; DatFilePublicSuffixList moves to dat.rs. --- libwebauthn/src/ops/webauthn/psl/dat.rs | 63 +++++++++++++++++++ .../src/ops/webauthn/{psl.rs => psl/mod.rs} | 62 +----------------- 2 files changed, 66 insertions(+), 59 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/psl/dat.rs rename libwebauthn/src/ops/webauthn/{psl.rs => psl/mod.rs} (64%) diff --git a/libwebauthn/src/ops/webauthn/psl/dat.rs b/libwebauthn/src/ops/webauthn/psl/dat.rs new file mode 100644 index 0000000..343144b --- /dev/null +++ b/libwebauthn/src/ops/webauthn/psl/dat.rs @@ -0,0 +1,63 @@ +//! `.dat` (text) format Public Suffix List reader. + +use std::path::{Path, PathBuf}; + +use super::PublicSuffixList; + +#[derive(thiserror::Error, Debug)] +pub enum DatFileLoadError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("invalid PSL data: {0}")] + Parse(String), +} + +/// Standard system path for the `.dat` Public Suffix List on Linux distros +/// that ship the `publicsuffix-list` (or equivalent) package. +pub const SYSTEM_PSL_PATH: &str = "/usr/share/publicsuffix/public_suffix_list.dat"; + +/// `PublicSuffixList` implementation backed by a Public Suffix List `.dat` +/// file loaded from disk at construction time. +pub struct DatFilePublicSuffixList { + list: publicsuffix::List, + source: PathBuf, +} + +impl std::fmt::Debug for DatFilePublicSuffixList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatFilePublicSuffixList") + .field("source", &self.source) + .finish() + } +} + +impl DatFilePublicSuffixList { + /// Reads a PSL `.dat` file from `path`. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let data = std::fs::read_to_string(path)?; + let list = publicsuffix::List::from_str(&data) + .map_err(|e| DatFileLoadError::Parse(e.to_string()))?; + Ok(Self { + list, + source: path.to_path_buf(), + }) + } + + /// Reads the system-managed `.dat` PSL at [`SYSTEM_PSL_PATH`]. + pub fn from_system_file() -> Result { + Self::from_path(SYSTEM_PSL_PATH) + } +} + +impl PublicSuffixList for DatFilePublicSuffixList { + fn registrable_domain(&self, host: &str) -> Option { + let domain = self.list.parse_domain(host).ok()?; + domain.root().map(|s| s.to_string()) + } + + fn public_suffix(&self, host: &str) -> Option { + let domain = self.list.parse_domain(host).ok()?; + domain.suffix().map(|s| s.to_string()) + } +} diff --git a/libwebauthn/src/ops/webauthn/psl.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs similarity index 64% rename from libwebauthn/src/ops/webauthn/psl.rs rename to libwebauthn/src/ops/webauthn/psl/mod.rs index 47967ad..c85cb12 100644 --- a/libwebauthn/src/ops/webauthn/psl.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -12,7 +12,9 @@ //! file shipped by the `publicsuffix-list` distribution package, kept fresh //! by the system package manager. -use std::path::{Path, PathBuf}; +pub mod dat; + +pub use dat::{DatFileLoadError, DatFilePublicSuffixList, SYSTEM_PSL_PATH}; /// Public Suffix List lookup interface. /// @@ -27,64 +29,6 @@ pub trait PublicSuffixList: Send + Sync { fn public_suffix(&self, host: &str) -> Option; } -#[derive(thiserror::Error, Debug)] -pub enum DatFileLoadError { - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("invalid PSL data: {0}")] - Parse(String), -} - -/// Standard system path for the Public Suffix List on most Linux distros that -/// ship the `publicsuffix-list` (or equivalent) package. -pub const SYSTEM_PSL_PATH: &str = "/usr/share/publicsuffix/public_suffix_list.dat"; - -/// `PublicSuffixList` implementation backed by a Public Suffix List `.dat` -/// file loaded from disk at construction time. -pub struct DatFilePublicSuffixList { - list: publicsuffix::List, - source: PathBuf, -} - -impl std::fmt::Debug for DatFilePublicSuffixList { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DatFilePublicSuffixList") - .field("source", &self.source) - .finish() - } -} - -impl DatFilePublicSuffixList { - /// Reads a PSL `.dat` file from `path`. - pub fn from_path(path: impl AsRef) -> Result { - let path = path.as_ref(); - let data = std::fs::read_to_string(path)?; - let list = publicsuffix::List::from_str(&data) - .map_err(|e| DatFileLoadError::Parse(e.to_string()))?; - Ok(Self { - list, - source: path.to_path_buf(), - }) - } - - /// Reads the system-managed PSL at [`SYSTEM_PSL_PATH`]. - pub fn from_system_file() -> Result { - Self::from_path(SYSTEM_PSL_PATH) - } -} - -impl PublicSuffixList for DatFilePublicSuffixList { - fn registrable_domain(&self, host: &str) -> Option { - let domain = self.list.parse_domain(host).ok()?; - domain.root().map(|s| s.to_string()) - } - - fn public_suffix(&self, host: &str) -> Option { - let domain = self.list.parse_domain(host).ok()?; - domain.suffix().map(|s| s.to_string()) - } -} - /// Test-only PSL that recognises a small fixed set of public suffixes. /// /// Sufficient for unit tests of the suffix-check algorithm without reading From 9c394091bdffd9b0cc31a6bbf2cbba0774e0fe46 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:48:48 +0100 Subject: [PATCH 2/8] feat(psl): add DAFSA-format reader (DafsaFilePublicSuffixList) Adds a safe-Rust reader for libpsl's binary .dafsa file format. The reader ports LookupStringInFixedSet from libpsl's lookup_string_in_fixed_set.c (BSD-licensed by The Chromium Authors), translating the byte-coded DAFSA walk to safe Rust without unsafe or extra dependencies. Closes the Fedora gap from issue #210: Fedora ships only the .dafsa file by default (via publicsuffix-list-dafsa, which libpsl requires). Tests cover plain rules, wildcard, exception, private section, and the file-header parser edge cases. The fixture was generated by libpsl's psl-make-dafsa script from a small synthetic PSL. --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 405 ++++++++++++++++++++++ libwebauthn/src/ops/webauthn/psl/mod.rs | 15 +- 2 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/psl/dafsa.rs diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs new file mode 100644 index 0000000..d8330d7 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -0,0 +1,405 @@ +//! libpsl binary `.dafsa` format Public Suffix List reader. +//! +//! Format reference: +//! (writer) and +//! (reader). The on-disk file is a 16-byte ASCII header (`.DAFSA@PSL_` padded +//! to 16 bytes with spaces and terminated by LF) followed by a byte-coded DAFSA. +//! Only version 0 exists today. + +use std::path::{Path, PathBuf}; + +use super::PublicSuffixList; + +const MAGIC: &[u8] = b".DAFSA@PSL_"; +const HEADER_LEN: usize = 16; + +const FLAG_EXCEPTION: u8 = 1 << 0; +const FLAG_WILDCARD: u8 = 1 << 1; + +#[derive(thiserror::Error, Debug)] +pub enum DafsaFileLoadError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("file too small to contain a valid DAFSA header")] + Truncated, + #[error("not a libpsl DAFSA file (missing or malformed magic)")] + BadMagic, + #[error("unsupported DAFSA version: {0}")] + UnsupportedVersion(u32), +} + +/// Standard system path for the binary `.dafsa` Public Suffix List shipped +/// by libpsl's distribution package (e.g. `publicsuffix-list-dafsa` on +/// Fedora, the `publicsuffix` package on Debian/Ubuntu). +pub const SYSTEM_PSL_DAFSA_PATH: &str = "/usr/share/publicsuffix/public_suffix_list.dafsa"; + +/// `PublicSuffixList` implementation backed by libpsl's binary `.dafsa` file. +pub struct DafsaFilePublicSuffixList { + graph: Vec, + source: PathBuf, +} + +impl std::fmt::Debug for DafsaFilePublicSuffixList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DafsaFilePublicSuffixList") + .field("source", &self.source) + .field("graph_bytes", &self.graph.len()) + .finish() + } +} + +impl DafsaFilePublicSuffixList { + /// Reads a libpsl `.dafsa` file from `path`. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = std::fs::read(path)?; + let graph = parse_header(&bytes)?; + Ok(Self { + graph, + source: path.to_path_buf(), + }) + } + + /// Reads the system-managed `.dafsa` PSL at [`SYSTEM_PSL_DAFSA_PATH`]. + pub fn from_system_file() -> Result { + Self::from_path(SYSTEM_PSL_DAFSA_PATH) + } + + fn is_public_suffix(&self, domain: &str) -> bool { + if let Some(flags) = lookup(&self.graph, domain.as_bytes()) { + return (flags & FLAG_EXCEPTION) == 0; + } + if let Some(parent_start) = domain.find('.').map(|i| i + 1) { + let parent = &domain[parent_start..]; + if let Some(flags) = lookup(&self.graph, parent.as_bytes()) { + return (flags & FLAG_WILDCARD) != 0; + } + } + false + } +} + +impl PublicSuffixList for DafsaFilePublicSuffixList { + fn public_suffix(&self, host: &str) -> Option { + let mut current = host; + loop { + if self.is_public_suffix(current) { + return Some(current.to_string()); + } + match current.find('.') { + Some(i) => current = ¤t[i + 1..], + None => return None, + } + } + } + + fn registrable_domain(&self, host: &str) -> Option { + let suffix = self.public_suffix(host)?; + if host == suffix { + return None; + } + let prefix = host.strip_suffix(&suffix)?.strip_suffix('.')?; + let last_label = prefix.rsplit('.').next()?; + Some(format!("{last_label}.{suffix}")) + } +} + +fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { + if bytes.len() < HEADER_LEN { + return Err(DafsaFileLoadError::Truncated); + } + if &bytes[..MAGIC.len()] != MAGIC { + return Err(DafsaFileLoadError::BadMagic); + } + if bytes[HEADER_LEN - 1] != b'\n' { + return Err(DafsaFileLoadError::BadMagic); + } + let version_field = &bytes[MAGIC.len()..HEADER_LEN - 1]; + let digit_count = version_field + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digit_count == 0 { + return Err(DafsaFileLoadError::BadMagic); + } + let version: u32 = std::str::from_utf8(&version_field[..digit_count]) + .expect("ascii digits are valid utf-8") + .parse() + .map_err(|_| DafsaFileLoadError::BadMagic)?; + if version != 0 { + return Err(DafsaFileLoadError::UnsupportedVersion(version)); + } + Ok(bytes[HEADER_LEN..].to_vec()) +} + +/// Port of `LookupStringInFixedSet` from libpsl's `lookup_string_in_fixed_set.c`. +/// Returns the low nibble of the return-value byte (ICANN/PRIVATE/WILDCARD/EXCEPTION +/// flag bits) if `key` is present in `graph`, `None` otherwise. ASCII-only: callers +/// must pass keys already converted to IDN-ASCII (punycode for non-ASCII labels). +fn lookup(graph: &[u8], key: &[u8]) -> Option { + let end = graph.len(); + let mut pos: usize = 0; + let mut offset: usize = 0; + let mut key_pos: usize = 0; + let key_end = key.len(); + + while let Some(()) = get_next_offset(graph, end, &mut pos, &mut offset) { + let mut did_consume = false; + + if key_pos < key_end && !is_eol(graph, offset) { + if !is_match(graph, offset, key, key_pos) { + continue; + } + did_consume = true; + offset += 1; + key_pos += 1; + + while !is_eol(graph, offset) && key_pos < key_end { + if !is_match(graph, offset, key, key_pos) { + return None; + } + offset += 1; + key_pos += 1; + } + } + + if key_pos == key_end { + if let Some(rv) = get_return_value(graph, offset) { + return Some(rv); + } + if did_consume { + return None; + } + continue; + } + if !is_end_char_match(graph, offset, key, key_pos) { + if did_consume { + return None; + } + continue; + } + offset += 1; + key_pos += 1; + pos = offset; + } + None +} + +fn get_next_offset(graph: &[u8], end: usize, pos: &mut usize, offset: &mut usize) -> Option<()> { + if *pos >= end { + return None; + } + if *pos + 2 >= end { + return None; + } + let b = graph[*pos]; + let consumed = match b & 0x60 { + 0x60 => { + *offset += ((b as usize & 0x1F) << 16) + | ((graph[*pos + 1] as usize) << 8) + | (graph[*pos + 2] as usize); + 3 + } + 0x40 => { + *offset += ((b as usize & 0x1F) << 8) | (graph[*pos + 1] as usize); + 2 + } + _ => { + *offset += (b as usize) & 0x3F; + 1 + } + }; + if b & 0x80 != 0 { + *pos = end; + } else { + *pos += consumed; + } + Some(()) +} + +fn is_eol(graph: &[u8], offset: usize) -> bool { + graph.get(offset).is_some_and(|b| b & 0x80 != 0) +} + +fn is_match(graph: &[u8], offset: usize, key: &[u8], key_pos: usize) -> bool { + match (graph.get(offset), key.get(key_pos)) { + (Some(g), Some(k)) => g == k, + _ => false, + } +} + +fn is_end_char_match(graph: &[u8], offset: usize, key: &[u8], key_pos: usize) -> bool { + match (graph.get(offset), key.get(key_pos)) { + (Some(g), Some(k)) => (g ^ 0x80) == *k, + _ => false, + } +} + +fn get_return_value(graph: &[u8], offset: usize) -> Option { + let b = *graph.get(offset)?; + if b & 0xE0 == 0x80 { + Some(b & 0x0F) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture generated by psl-make-dafsa from the rules: + /// ICANN: com, uk, co.uk, *.kw, !foo.kw + /// PRIVATE: github.io + /// (ASCII mode; 51 bytes total, 16-byte header + 35-byte graph). + const FIXTURE: &[u8] = &[ + 0x2e, 0x44, 0x41, 0x46, 0x53, 0x41, 0x40, 0x50, 0x53, 0x4c, 0x5f, 0x30, 0x20, 0x20, 0x20, + 0x0a, // header + 0x05, 0x03, 0x0a, 0x07, 0x87, // root offset list + 0x6b, 0x77, 0x86, // kw, flag 6 = WILDCARD | ICANN + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x69, 0x6f, 0x88, // github.io, flag 8 = PRIVATE + 0x66, 0x6f, 0x6f, 0x2e, 0x6b, 0x77, 0x85, // foo.kw, flag 5 = EXCEPTION | ICANN + 0x63, 0xef, // c + end_char 'o' + 0x02, 0x82, // offsets for "com" and "co.uk" branches + 0xed, // end_char 'm' + 0x84, // flag 4 = ICANN (for "com") + 0x2e, 0x75, 0x6b, 0x84, // .uk, flag 4 = ICANN (for "co.uk") + ]; + + fn loaded() -> DafsaFilePublicSuffixList { + let graph = parse_header(FIXTURE).expect("fixture parses"); + DafsaFilePublicSuffixList { + graph, + source: PathBuf::from(""), + } + } + + #[test] + fn lookup_simple_icann_rule() { + let psl = loaded(); + assert_eq!(lookup(&psl.graph, b"com"), Some(4)); + assert_eq!(lookup(&psl.graph, b"uk"), Some(4)); + assert_eq!(lookup(&psl.graph, b"co.uk"), Some(4)); + } + + #[test] + fn lookup_wildcard_and_exception() { + let psl = loaded(); + assert_eq!(lookup(&psl.graph, b"kw"), Some(0b0110)); + assert_eq!(lookup(&psl.graph, b"foo.kw"), Some(0b0101)); + } + + #[test] + fn lookup_private_section() { + let psl = loaded(); + assert_eq!(lookup(&psl.graph, b"github.io"), Some(0b1000)); + } + + #[test] + fn lookup_unknown_returns_none() { + let psl = loaded(); + assert_eq!(lookup(&psl.graph, b"example"), None); + assert_eq!(lookup(&psl.graph, b"example.com"), None); + assert_eq!(lookup(&psl.graph, b"c"), None); + assert_eq!(lookup(&psl.graph, b"comm"), None); + assert_eq!(lookup(&psl.graph, b""), None); + } + + #[test] + fn public_suffix_finds_longest_match() { + let psl = loaded(); + assert_eq!(psl.public_suffix("example.com").as_deref(), Some("com")); + assert_eq!(psl.public_suffix("example.co.uk").as_deref(), Some("co.uk")); + assert_eq!(psl.public_suffix("co.uk").as_deref(), Some("co.uk")); + assert_eq!(psl.public_suffix("uk").as_deref(), Some("uk")); + } + + #[test] + fn public_suffix_wildcard_synthesis() { + let psl = loaded(); + assert_eq!(psl.public_suffix("anything.kw").as_deref(), Some("anything.kw")); + assert_eq!(psl.public_suffix("a.b.kw").as_deref(), Some("b.kw")); + } + + #[test] + fn public_suffix_exception_overrides_wildcard() { + let psl = loaded(); + // foo.kw has the exception flag, so it is NOT a public suffix even + // though *.kw would otherwise make it one. The longest suffix that + // applies is `kw` itself (which is a suffix because *.kw implicitly + // makes the parent a public suffix per the libpsl/PSL algorithm). + assert_eq!(psl.public_suffix("foo.kw").as_deref(), Some("kw")); + // The exception rule matches sub.foo.kw too (its rightmost two + // labels are foo.kw), so the prevailing rule is the exception with + // its leftmost label stripped, giving "kw". + assert_eq!(psl.public_suffix("sub.foo.kw").as_deref(), Some("kw")); + } + + #[test] + fn public_suffix_private_section_included() { + let psl = loaded(); + assert_eq!( + psl.public_suffix("repo.github.io").as_deref(), + Some("github.io"), + ); + assert_eq!(psl.public_suffix("github.io").as_deref(), Some("github.io")); + } + + #[test] + fn public_suffix_none_for_non_psl_host() { + let psl = loaded(); + assert_eq!(psl.public_suffix("localhost"), None); + assert_eq!(psl.public_suffix("invalid"), None); + } + + #[test] + fn registrable_domain_computed_from_suffix() { + let psl = loaded(); + assert_eq!( + psl.registrable_domain("login.example.com").as_deref(), + Some("example.com"), + ); + assert_eq!( + psl.registrable_domain("example.com").as_deref(), + Some("example.com"), + ); + assert_eq!(psl.registrable_domain("com"), None); + assert_eq!( + psl.registrable_domain("a.b.example.co.uk").as_deref(), + Some("example.co.uk"), + ); + } + + #[test] + fn parse_header_rejects_truncated() { + let too_short = &FIXTURE[..10]; + assert!(matches!( + parse_header(too_short), + Err(DafsaFileLoadError::Truncated) + )); + } + + #[test] + fn parse_header_rejects_bad_magic() { + let mut bad = FIXTURE.to_vec(); + bad[0] = b'X'; + assert!(matches!(parse_header(&bad), Err(DafsaFileLoadError::BadMagic))); + } + + #[test] + fn parse_header_rejects_unsupported_version() { + let mut v1 = FIXTURE.to_vec(); + v1[11] = b'1'; + assert!(matches!( + parse_header(&v1), + Err(DafsaFileLoadError::UnsupportedVersion(1)) + )); + } + + #[test] + fn parse_header_rejects_missing_newline() { + let mut bad = FIXTURE.to_vec(); + bad[HEADER_LEN - 1] = b' '; + assert!(matches!(parse_header(&bad), Err(DafsaFileLoadError::BadMagic))); + } +} diff --git a/libwebauthn/src/ops/webauthn/psl/mod.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs index c85cb12..bed577f 100644 --- a/libwebauthn/src/ops/webauthn/psl/mod.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -7,13 +7,20 @@ //! //! Rather than bundle a snapshot of the PSL inside the crate (which would go //! stale with each release), libwebauthn defines a [`PublicSuffixList`] trait -//! and lets callers plug in an implementation. A simple -//! [`DatFilePublicSuffixList`] is provided that reads the standard `.dat` -//! file shipped by the `publicsuffix-list` distribution package, kept fresh -//! by the system package manager. +//! and lets callers plug in an implementation. Two built-in loaders are +//! provided that read system-managed Public Suffix List files kept fresh by +//! the package manager: +//! +//! * [`DatFilePublicSuffixList`] reads the text `.dat` format (shipped on +//! Debian/Ubuntu, Arch, and Fedora's `publicsuffix-list` package). +//! * [`DafsaFilePublicSuffixList`] reads libpsl's binary `.dafsa` format +//! (shipped on Debian/Ubuntu, and on Fedora as `publicsuffix-list-dafsa`, +//! which is required by `libpsl` and thus present on most installs). +pub mod dafsa; pub mod dat; +pub use dafsa::{DafsaFileLoadError, DafsaFilePublicSuffixList, SYSTEM_PSL_DAFSA_PATH}; pub use dat::{DatFileLoadError, DatFilePublicSuffixList, SYSTEM_PSL_PATH}; /// Public Suffix List lookup interface. From 2711177fab6e1c46c66ed6715f5b421cc7d222d6 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:49:48 +0100 Subject: [PATCH 3/8] feat(psl): add SystemPublicSuffixList::auto() probing both formats Auto-detects which system-managed PSL file is available, preferring .dafsa over .dat. Returns SystemLoadError::NoneFound listing the paths tried if neither is present. Includes an integration test gated by LIBWEBAUTHN_PSL_SYSTEM_TEST=1 that loads the real system PSL and validates lookups against common suffixes. The gating env var is intentional so that local 'cargo test' runs do not require any package to be installed. --- libwebauthn/src/ops/webauthn/psl/mod.rs | 5 + libwebauthn/src/ops/webauthn/psl/system.rs | 109 +++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 libwebauthn/src/ops/webauthn/psl/system.rs diff --git a/libwebauthn/src/ops/webauthn/psl/mod.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs index bed577f..6cad52d 100644 --- a/libwebauthn/src/ops/webauthn/psl/mod.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -16,12 +16,17 @@ //! * [`DafsaFilePublicSuffixList`] reads libpsl's binary `.dafsa` format //! (shipped on Debian/Ubuntu, and on Fedora as `publicsuffix-list-dafsa`, //! which is required by `libpsl` and thus present on most installs). +//! +//! Most callers should use [`SystemPublicSuffixList::auto`], which probes +//! the standard system paths for whichever format is available. pub mod dafsa; pub mod dat; +mod system; pub use dafsa::{DafsaFileLoadError, DafsaFilePublicSuffixList, SYSTEM_PSL_DAFSA_PATH}; pub use dat::{DatFileLoadError, DatFilePublicSuffixList, SYSTEM_PSL_PATH}; +pub use system::{SystemLoadError, SystemPublicSuffixList}; /// Public Suffix List lookup interface. /// diff --git a/libwebauthn/src/ops/webauthn/psl/system.rs b/libwebauthn/src/ops/webauthn/psl/system.rs new file mode 100644 index 0000000..d39c890 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/psl/system.rs @@ -0,0 +1,109 @@ +//! System-managed Public Suffix List loader. +//! +//! Probes the standard distribution paths in priority order and loads the +//! first format that is present. Most callers should use this rather than +//! picking [`DafsaFilePublicSuffixList`] or [`DatFilePublicSuffixList`] +//! directly, since which file is shipped depends on the distribution. + +use std::path::PathBuf; + +use super::dafsa::{DafsaFileLoadError, DafsaFilePublicSuffixList, SYSTEM_PSL_DAFSA_PATH}; +use super::dat::{DatFileLoadError, DatFilePublicSuffixList, SYSTEM_PSL_PATH}; +use super::PublicSuffixList; + +#[derive(thiserror::Error, Debug)] +pub enum SystemLoadError { + #[error("no system Public Suffix List found at any of the standard paths: {tried:?}")] + NoneFound { tried: Vec }, + #[error("failed to load `.dafsa` PSL: {0}")] + Dafsa(#[from] DafsaFileLoadError), + #[error("failed to load `.dat` PSL: {0}")] + Dat(#[from] DatFileLoadError), +} + +enum Inner { + Dafsa(DafsaFilePublicSuffixList), + Dat(DatFilePublicSuffixList), +} + +/// `PublicSuffixList` implementation that auto-detects which system-managed +/// PSL file is available, preferring the binary `.dafsa` format if present. +pub struct SystemPublicSuffixList { + inner: Inner, +} + +impl std::fmt::Debug for SystemPublicSuffixList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.inner { + Inner::Dafsa(d) => f.debug_tuple("SystemPublicSuffixList").field(d).finish(), + Inner::Dat(d) => f.debug_tuple("SystemPublicSuffixList").field(d).finish(), + } + } +} + +impl SystemPublicSuffixList { + /// Probes the standard system paths and loads the first format found. + /// + /// Order: [`SYSTEM_PSL_DAFSA_PATH`] then [`SYSTEM_PSL_PATH`]. The DAFSA + /// path is preferred because on Fedora it is the only file that ships + /// on a default install; on distributions that ship both (Debian/Ubuntu) + /// either choice has the same content. + pub fn auto() -> Result { + let dafsa_path = PathBuf::from(SYSTEM_PSL_DAFSA_PATH); + let dat_path = PathBuf::from(SYSTEM_PSL_PATH); + + if dafsa_path.exists() { + let psl = DafsaFilePublicSuffixList::from_path(&dafsa_path)?; + return Ok(Self { + inner: Inner::Dafsa(psl), + }); + } + if dat_path.exists() { + let psl = DatFilePublicSuffixList::from_path(&dat_path)?; + return Ok(Self { + inner: Inner::Dat(psl), + }); + } + Err(SystemLoadError::NoneFound { + tried: vec![dafsa_path, dat_path], + }) + } +} + +impl PublicSuffixList for SystemPublicSuffixList { + fn registrable_domain(&self, host: &str) -> Option { + match &self.inner { + Inner::Dafsa(d) => d.registrable_domain(host), + Inner::Dat(d) => d.registrable_domain(host), + } + } + + fn public_suffix(&self, host: &str) -> Option { + match &self.inner { + Inner::Dafsa(d) => d.public_suffix(host), + Inner::Dat(d) => d.public_suffix(host), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Integration test against the actual system PSL. Skipped unless + /// `LIBWEBAUTHN_PSL_SYSTEM_TEST=1` is set, because the test depends on + /// the host machine having a PSL package installed. + #[test] + fn system_psl_loads_and_resolves_common_suffixes() { + if std::env::var("LIBWEBAUTHN_PSL_SYSTEM_TEST").as_deref() != Ok("1") { + return; + } + let psl = SystemPublicSuffixList::auto().expect("system PSL must be installed"); + assert_eq!(psl.public_suffix("example.com").as_deref(), Some("com")); + assert_eq!(psl.public_suffix("bbc.co.uk").as_deref(), Some("co.uk")); + assert_eq!( + psl.registrable_domain("login.example.com").as_deref(), + Some("example.com"), + ); + } +} From 324683da8217dac7605068ab3f062a4613d06f72 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:50:53 +0100 Subject: [PATCH 4/8] feat(examples): use SystemPublicSuffixList::auto() in ceremony examples Switches the three ceremony examples (cable, hid, nfc) to the auto-detecting loader so they work out of the box on Fedora (where only .dafsa is shipped) and on Debian/Ubuntu/Arch. Also re-exports the new public types (SystemPublicSuffixList, DafsaFilePublicSuffixList, etc.) from ops::webauthn alongside the existing DatFilePublicSuffixList for callers wiring the list themselves. --- libwebauthn/examples/ceremony/webauthn_cable.rs | 6 +++--- libwebauthn/examples/ceremony/webauthn_hid.rs | 6 +++--- libwebauthn/examples/ceremony/webauthn_nfc.rs | 6 +++--- libwebauthn/src/ops/webauthn/mod.rs | 6 +++++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 09a4467..8501872 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -12,7 +12,7 @@ use qrcode::QrCode; use tokio::time::sleep; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::{Channel as _, Device}; @@ -66,8 +66,8 @@ pub async fn main() -> Result<(), Box> { let device_info_store = Arc::new(EphemeralDeviceInfoStore::default()); let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin"); - let psl = DatFilePublicSuffixList::from_system_file().expect( - "PSL not available; install the publicsuffix-list package or pass an explicit path", + let psl = SystemPublicSuffixList::auto().expect( + "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); { diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 0c09801..aa03a04 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -2,7 +2,7 @@ use std::error::Error; use std::time::Duration; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; @@ -29,8 +29,8 @@ pub async fn main() -> Result<(), Box> { let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin"); - let psl = DatFilePublicSuffixList::from_system_file().expect( - "PSL not available; install the publicsuffix-list package or pass an explicit path", + let psl = SystemPublicSuffixList::auto().expect( + "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); let request_json = r#" { diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 2a74c66..6c58fdb 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -1,7 +1,7 @@ use std::error::Error; use libwebauthn::ops::webauthn::{ - DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available}; @@ -27,8 +27,8 @@ pub async fn main() -> Result<(), Box> { let mut channel = device.channel().await?; let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin"); - let psl = DatFilePublicSuffixList::from_system_file().expect( - "PSL not available; install the publicsuffix-list package or pass an explicit path", + let psl = SystemPublicSuffixList::auto().expect( + "PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path", ); let make_credentials_request = MakeCredentialRequest::from_json( &request_origin, diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 2a57e67..5957003 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -30,7 +30,11 @@ pub use make_credential::{ MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, }; -pub use psl::{DatFileLoadError, DatFilePublicSuffixList, PublicSuffixList, SYSTEM_PSL_PATH}; +pub use psl::{ + DafsaFileLoadError, DafsaFilePublicSuffixList, DatFileLoadError, DatFilePublicSuffixList, + PublicSuffixList, SystemLoadError, SystemPublicSuffixList, SYSTEM_PSL_DAFSA_PATH, + SYSTEM_PSL_PATH, +}; use serde::Deserialize; #[derive(Debug, Clone, Copy, Deserialize, PartialEq)] From 8c808c5a1c0ce7423fd0a11953517d36521ccd4b Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:51:29 +0100 Subject: [PATCH 5/8] docs(readme): document DAFSA support and per-distro shipping Updates the Runtime requirements section to reflect that the loader now auto-detects the .dafsa format alongside .dat, and explains which package ships which format on each distribution. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c10f4c6..b87b3f5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ _Looking for the D-Bus API proposal?_ Check out [credentialsd][credentialsd]. ## Runtime requirements -Validating the relying party ID against the calling origin requires the [Public Suffix List][psl]. The built-in loader reads it from the standard system path. The `publicsuffix` package on Debian/Ubuntu or `publicsuffix-list` on Fedora and Arch installs it there, but these are not always present on minimal installs. Install explicitly if needed. Callers wiring their own list don't need a system package. +Validating the relying party ID against the calling origin requires the [Public Suffix List][psl]. The built-in `SystemPublicSuffixList::auto()` loader reads it from the standard system path, probing the binary `.dafsa` format first and falling back to the text `.dat` format. The `publicsuffix` package on Debian/Ubuntu ships both. On Fedora the binary `.dafsa` file is shipped by `publicsuffix-list-dafsa` (a transitive dependency of `libpsl`, so usually already installed), while the text `.dat` file requires the optional `publicsuffix-list` package. On Arch only the text `.dat` format is packaged. Callers wiring their own list don't need a system package. ## Transports From 99614e5f8d66f3820eec6afba27e0ff0f77cd852 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:51:40 +0100 Subject: [PATCH 6/8] ci: install publicsuffix and run gated system-file PSL test apt-get installs Debian's publicsuffix package (ships both .dat and .dafsa). Sets LIBWEBAUTHN_PSL_SYSTEM_TEST=1 on the test step so the SystemPublicSuffixList::auto() integration test runs against the real system file in CI. --- .github/workflows/rust.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b44fc9d..6c1a23e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,7 +14,7 @@ jobs: - name: Update apt cache run: sudo apt-get update - name: Install system dependencies - run: sudo apt-get install libudev-dev libdbus-1-dev libsodium-dev libnfc-dev libpcsclite-dev + run: sudo apt-get install libudev-dev libdbus-1-dev libsodium-dev libnfc-dev libpcsclite-dev publicsuffix - name: Clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Check formatting @@ -27,5 +27,7 @@ jobs: run: cargo build -p libwebauthn --examples --features nfc-backend-libnfc - name: Run tests run: cargo test --workspace --verbose + env: + LIBWEBAUTHN_PSL_SYSTEM_TEST: "1" - name: Verify libwebauthn publishes cleanly run: cargo publish --dry-run -p libwebauthn From ba492b5a275c87df19323a69f9233b599485e1d6 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:52:39 +0100 Subject: [PATCH 7/8] fix(psl): satisfy clippy::expect_used and rustfmt in DAFSA reader Crate denies clippy::expect_used outside tests; the version parse now propagates BadMagic on UTF-8 failure even though the bytes were already validated as ASCII digits. Also rustfmt reflow of test code. --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index d8330d7..7994634 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -123,7 +123,7 @@ fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { return Err(DafsaFileLoadError::BadMagic); } let version: u32 = std::str::from_utf8(&version_field[..digit_count]) - .expect("ascii digits are valid utf-8") + .map_err(|_| DafsaFileLoadError::BadMagic)? .parse() .map_err(|_| DafsaFileLoadError::BadMagic)?; if version != 0 { @@ -257,7 +257,8 @@ mod tests { 0x0a, // header 0x05, 0x03, 0x0a, 0x07, 0x87, // root offset list 0x6b, 0x77, 0x86, // kw, flag 6 = WILDCARD | ICANN - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x69, 0x6f, 0x88, // github.io, flag 8 = PRIVATE + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x69, 0x6f, + 0x88, // github.io, flag 8 = PRIVATE 0x66, 0x6f, 0x6f, 0x2e, 0x6b, 0x77, 0x85, // foo.kw, flag 5 = EXCEPTION | ICANN 0x63, 0xef, // c + end_char 'o' 0x02, 0x82, // offsets for "com" and "co.uk" branches @@ -317,7 +318,10 @@ mod tests { #[test] fn public_suffix_wildcard_synthesis() { let psl = loaded(); - assert_eq!(psl.public_suffix("anything.kw").as_deref(), Some("anything.kw")); + assert_eq!( + psl.public_suffix("anything.kw").as_deref(), + Some("anything.kw") + ); assert_eq!(psl.public_suffix("a.b.kw").as_deref(), Some("b.kw")); } @@ -383,7 +387,10 @@ mod tests { fn parse_header_rejects_bad_magic() { let mut bad = FIXTURE.to_vec(); bad[0] = b'X'; - assert!(matches!(parse_header(&bad), Err(DafsaFileLoadError::BadMagic))); + assert!(matches!( + parse_header(&bad), + Err(DafsaFileLoadError::BadMagic) + )); } #[test] @@ -400,6 +407,9 @@ mod tests { fn parse_header_rejects_missing_newline() { let mut bad = FIXTURE.to_vec(); bad[HEADER_LEN - 1] = b' '; - assert!(matches!(parse_header(&bad), Err(DafsaFileLoadError::BadMagic))); + assert!(matches!( + parse_header(&bad), + Err(DafsaFileLoadError::BadMagic) + )); } } From 1179d97075a112583c16ebc4d67347c3660b7532 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 20:11:46 +0100 Subject: [PATCH 8/8] docs(psl): clarify DAFSA reader deviations from libpsl Module docs now call out the two intentional deviations from libpsl's psl_is_public_suffix: no prevailing-star rule for unknown single-label TLDs (so localhost works as its own rp.id), and no multibyte key support (WebAuthn only ever passes IDN-ASCII, and the DAFSA stores IDN rules in punycode form regardless of encoding mode). Test comment for the exception-overrides-wildcard case rewritten to describe the actual lookup chain rather than conflating two mechanisms. --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 30 ++++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index 7994634..cf54299 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -3,8 +3,18 @@ //! Format reference: //! (writer) and //! (reader). The on-disk file is a 16-byte ASCII header (`.DAFSA@PSL_` padded -//! to 16 bytes with spaces and terminated by LF) followed by a byte-coded DAFSA. -//! Only version 0 exists today. +//! to 16 bytes with spaces and terminated by LF) followed by a byte-coded DAFSA, +//! optionally with a trailing `0x01` byte in UTF-8 mode. Only version 0 exists today. +//! +//! Deviations from libpsl `psl_is_public_suffix`: +//! +//! * No prevailing `*` rule for unknown single-label TLDs. libpsl treats any +//! single-label host as a public suffix; this reader returns `None`, so +//! `localhost` can be used as a relying-party id against itself. +//! * Multibyte (UTF-8) keys are not supported. WebAuthn rp.ids and origin +//! hosts are always IDN-ASCII (punycode) by the time they reach the PSL, +//! and the DAFSA stores IDN rules in punycode form regardless of its +//! internal encoding mode, so ASCII queries match correctly. use std::path::{Path, PathBuf}; @@ -180,6 +190,7 @@ fn lookup(graph: &[u8], key: &[u8]) -> Option { } offset += 1; key_pos += 1; + // Dive into the child node. pos = offset; } None @@ -328,14 +339,15 @@ mod tests { #[test] fn public_suffix_exception_overrides_wildcard() { let psl = loaded(); - // foo.kw has the exception flag, so it is NOT a public suffix even - // though *.kw would otherwise make it one. The longest suffix that - // applies is `kw` itself (which is a suffix because *.kw implicitly - // makes the parent a public suffix per the libpsl/PSL algorithm). + // foo.kw has the EXCEPTION flag so direct lookup returns "not a + // suffix"; the search then strips a label to "kw", which is in the + // DAFSA with the WILDCARD flag (no EXCEPTION), so kw itself is the + // public suffix. assert_eq!(psl.public_suffix("foo.kw").as_deref(), Some("kw")); - // The exception rule matches sub.foo.kw too (its rightmost two - // labels are foo.kw), so the prevailing rule is the exception with - // its leftmost label stripped, giving "kw". + // For sub.foo.kw: exact lookup misses; parent foo.kw is found but + // has no WILDCARD bit, so the wildcard-fallback rejects it; the + // search then strips down to foo.kw (still excepted) and finally to + // kw (wildcard, suffix). assert_eq!(psl.public_suffix("sub.foo.kw").as_deref(), Some("kw")); }