From f7b6464523bf986ccc48508da11161f3e463732c Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:39:57 +0100 Subject: [PATCH 01/12] 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 | 79 +++++++++++++++++++ .../src/ops/webauthn/{psl.rs => psl/mod.rs} | 76 +----------------- 2 files changed, 81 insertions(+), 74 deletions(-) create mode 100644 libwebauthn/src/ops/webauthn/psl/dat.rs rename libwebauthn/src/ops/webauthn/{psl.rs => psl/mod.rs} (59%) diff --git a/libwebauthn/src/ops/webauthn/psl/dat.rs b/libwebauthn/src/ops/webauthn/psl/dat.rs new file mode 100644 index 00000000..f317e637 --- /dev/null +++ b/libwebauthn/src/ops/webauthn/psl/dat.rs @@ -0,0 +1,79 @@ +//! `.dat` (text) format Public Suffix List reader. + +use std::path::{Path, PathBuf}; + +use publicsuffix::{List, Psl}; + +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: 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: List = data + .parse() + .map_err(|e: publicsuffix::Error| 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 { + // `is_known()` filter drops `publicsuffix`'s implicit-wildcard match for + // unlisted TLDs (e.g. `localhost`), so bare `localhost` stays a valid rp.id. + fn registrable_domain(&self, host: &str) -> Option { + let suffix = self.list.suffix(host.as_bytes())?; + if !suffix.is_known() { + return None; + } + let domain = self.list.domain(host.as_bytes())?; + std::str::from_utf8(domain.as_bytes()) + .ok() + .map(String::from) + } + + fn public_suffix(&self, host: &str) -> Option { + let suffix = self.list.suffix(host.as_bytes())?; + if !suffix.is_known() { + return None; + } + std::str::from_utf8(suffix.as_bytes()) + .ok() + .map(String::from) + } +} diff --git a/libwebauthn/src/ops/webauthn/psl.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs similarity index 59% rename from libwebauthn/src/ops/webauthn/psl.rs rename to libwebauthn/src/ops/webauthn/psl/mod.rs index 1be13a3c..c85cb124 100644 --- a/libwebauthn/src/ops/webauthn/psl.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -12,9 +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; -use publicsuffix::{List, Psl}; +pub use dat::{DatFileLoadError, DatFilePublicSuffixList, SYSTEM_PSL_PATH}; /// Public Suffix List lookup interface. /// @@ -29,78 +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: 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: List = data - .parse() - .map_err(|e: publicsuffix::Error| 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 { - // `is_known()` filter drops `publicsuffix`'s implicit-wildcard match for - // unlisted TLDs (e.g. `localhost`), so bare `localhost` stays a valid rp.id. - fn registrable_domain(&self, host: &str) -> Option { - let suffix = self.list.suffix(host.as_bytes())?; - if !suffix.is_known() { - return None; - } - let domain = self.list.domain(host.as_bytes())?; - std::str::from_utf8(domain.as_bytes()) - .ok() - .map(String::from) - } - - fn public_suffix(&self, host: &str) -> Option { - let suffix = self.list.suffix(host.as_bytes())?; - if !suffix.is_known() { - return None; - } - std::str::from_utf8(suffix.as_bytes()) - .ok() - .map(String::from) - } -} - /// Test-only PSL that recognises a small fixed set of public suffixes. /// /// Sufficient for unit tests of the suffix-check algorithm without reading From b44fceb0f681c442aa58500b330940bbaa94e582 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:48:48 +0100 Subject: [PATCH 02/12] 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 00000000..d8330d7d --- /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 c85cb124..bed577fc 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 bdf4d23611d10b64f160826562d600695a58819d Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:49:48 +0100 Subject: [PATCH 03/12] 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 bed577fc..6cad52de 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 00000000..d39c8900 --- /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 35502f8a947f5ff3d87ceba4720b6a45d7f6f3df Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:50:53 +0100 Subject: [PATCH 04/12] 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 09a44678..85018720 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 0c09801d..aa03a04b 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 2a74c66c..6c58fdb9 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 70b41a94..767c6c0a 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -31,7 +31,11 @@ pub use make_credential::{ 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 e3ca81a4561ca891526dcef528c15be9c3b7e800 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:51:29 +0100 Subject: [PATCH 05/12] 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 6c9c1a0d..090d58bc 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 7fe07b625f35a3d4dfbc1e188149bf9a9392177f Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:51:40 +0100 Subject: [PATCH 06/12] 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 b44fc9d6..6c1a23e5 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 c000061776fbe645ee9b18b180ddad027295447f Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 19:52:39 +0100 Subject: [PATCH 07/12] 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 d8330d7d..79946347 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 2497322f99119d3ca981de40954419b5abd7458c Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 12 May 2026 20:11:46 +0100 Subject: [PATCH 08/12] 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 79946347..cf542990 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")); } From f275aec03b05214362fd153f55a2fef6348bbd85 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:08:46 +0100 Subject: [PATCH 09/12] fix(psl): harden get_next_offset bounds against malformed input Replace the raw graph indexing and the over-strict `*pos + 2 >= end` guard with .get() lookups and checked_add for the offset accumulator. The old guard rejected structurally-valid 1- and 2-byte offset codes at the end of the graph and could overflow usize on `*pos + 2`; the new form is panic-free by construction and accepts those codes. --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 26 +++++++++-------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index cf542990..c6f5bea0 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -147,13 +147,12 @@ fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { /// 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) { + while get_next_offset(graph, &mut pos, &mut offset).is_some() { let mut did_consume = false; if key_pos < key_end && !is_eol(graph, offset) { @@ -196,32 +195,27 @@ fn lookup(graph: &[u8], key: &[u8]) -> Option { 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]; +fn get_next_offset(graph: &[u8], pos: &mut usize, offset: &mut usize) -> Option<()> { + let b = *graph.get(*pos)?; let consumed = match b & 0x60 { 0x60 => { - *offset += ((b as usize & 0x1F) << 16) - | ((graph[*pos + 1] as usize) << 8) - | (graph[*pos + 2] as usize); + let hi = *graph.get(*pos + 1)? as usize; + let lo = *graph.get(*pos + 2)? as usize; + *offset = offset.checked_add(((b as usize & 0x1F) << 16) | (hi << 8) | lo)?; 3 } 0x40 => { - *offset += ((b as usize & 0x1F) << 8) | (graph[*pos + 1] as usize); + let lo = *graph.get(*pos + 1)? as usize; + *offset = offset.checked_add(((b as usize & 0x1F) << 8) | lo)?; 2 } _ => { - *offset += (b as usize) & 0x3F; + *offset = offset.checked_add((b as usize) & 0x3F)?; 1 } }; if b & 0x80 != 0 { - *pos = end; + *pos = graph.len(); } else { *pos += consumed; } From a594fc3d862d3b499be1819dba15112dd1588ee9 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:09:24 +0100 Subject: [PATCH 10/12] refactor(psl): hoist registrable_domain to a trait default method DafsaFilePublicSuffixList and MockPublicSuffixList had byte-identical registrable_domain impls expressed purely in terms of public_suffix. Move that body to a default method on PublicSuffixList; DatFilePublicSuffixList keeps its override (it uses the publicsuffix crate's own root()). --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 10 ------- libwebauthn/src/ops/webauthn/psl/mod.rs | 33 +++++++++++++---------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index c6f5bea0..8704363d 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -102,16 +102,6 @@ impl PublicSuffixList for DafsaFilePublicSuffixList { } } } - - 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> { diff --git a/libwebauthn/src/ops/webauthn/psl/mod.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs index 6cad52de..bd2c2da5 100644 --- a/libwebauthn/src/ops/webauthn/psl/mod.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -33,12 +33,27 @@ pub use system::{SystemLoadError, SystemPublicSuffixList}; /// Implementations decide where the PSL data lives (system file, embedded /// snapshot, HTTP-cached, etc). pub trait PublicSuffixList: Send + Sync { - /// Returns the registrable domain (eTLD+1) of `host`, or `None` if - /// `host` has no registrable domain (e.g. it is itself a public suffix). - fn registrable_domain(&self, host: &str) -> Option; - /// Returns the public suffix of `host`, or `None` if none applies. fn public_suffix(&self, host: &str) -> Option; + + /// Returns the registrable domain (eTLD+1) of `host`, or `None` if + /// `host` has no registrable domain (e.g. it is itself a public suffix). + /// + /// The default implementation derives this from [`public_suffix`]: the + /// registrable domain is the public suffix plus one more label. An + /// implementation whose backing library computes it directly may override + /// this. + /// + /// [`public_suffix`]: PublicSuffixList::public_suffix + 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}")) + } } /// Test-only PSL that recognises a small fixed set of public suffixes. @@ -63,16 +78,6 @@ impl PublicSuffixList for MockPublicSuffixList { } 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}")) - } } #[cfg(test)] From 1a2bbd165212a900fb7ad34b0f1ec1129f08f331 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Thu, 14 May 2026 19:11:11 +0100 Subject: [PATCH 11/12] refactor(psl): deny indexing_slicing and remove slice indexing Add a module-scoped deny(clippy::indexing_slicing) for non-test code, mirroring the crate-wide lint planned in #207, as an interim guard. Replace the remaining panic-capable slice indexing in parse_header, is_public_suffix and public_suffix with split_at_checked, starts_with, last, .get() and split_once. Behaviour is unchanged (verified by the existing unit tests and the gated system-file test). --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 32 ++++++++++++----------- libwebauthn/src/ops/webauthn/psl/mod.rs | 3 +++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index 8704363d..83afe29c 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -79,8 +79,7 @@ impl DafsaFilePublicSuffixList { 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((_, parent)) = domain.split_once('.') { if let Some(flags) = lookup(&self.graph, parent.as_bytes()) { return (flags & FLAG_WILDCARD) != 0; } @@ -96,8 +95,8 @@ impl PublicSuffixList for DafsaFilePublicSuffixList { if self.is_public_suffix(current) { return Some(current.to_string()); } - match current.find('.') { - Some(i) => current = ¤t[i + 1..], + match current.split_once('.') { + Some((_, rest)) => current = rest, None => return None, } } @@ -105,31 +104,34 @@ impl PublicSuffixList for DafsaFilePublicSuffixList { } fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { - if bytes.len() < HEADER_LEN { - return Err(DafsaFileLoadError::Truncated); - } - if &bytes[..MAGIC.len()] != MAGIC { + let (header, graph) = bytes + .split_at_checked(HEADER_LEN) + .ok_or(DafsaFileLoadError::Truncated)?; + if !header.starts_with(MAGIC) { return Err(DafsaFileLoadError::BadMagic); } - if bytes[HEADER_LEN - 1] != b'\n' { + if header.last() != Some(&b'\n') { return Err(DafsaFileLoadError::BadMagic); } - let version_field = &bytes[MAGIC.len()..HEADER_LEN - 1]; + let version_field = header + .get(MAGIC.len()..HEADER_LEN - 1) + .ok_or(DafsaFileLoadError::BadMagic)?; 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]) + let version_digits = version_field + .get(..digit_count) + .filter(|digits| !digits.is_empty()) + .ok_or(DafsaFileLoadError::BadMagic)?; + let version: u32 = std::str::from_utf8(version_digits) .map_err(|_| DafsaFileLoadError::BadMagic)? .parse() .map_err(|_| DafsaFileLoadError::BadMagic)?; if version != 0 { return Err(DafsaFileLoadError::UnsupportedVersion(version)); } - Ok(bytes[HEADER_LEN..].to_vec()) + Ok(graph.to_vec()) } /// Port of `LookupStringInFixedSet` from libpsl's `lookup_string_in_fixed_set.c`. diff --git a/libwebauthn/src/ops/webauthn/psl/mod.rs b/libwebauthn/src/ops/webauthn/psl/mod.rs index bd2c2da5..10ed069d 100644 --- a/libwebauthn/src/ops/webauthn/psl/mod.rs +++ b/libwebauthn/src/ops/webauthn/psl/mod.rs @@ -20,6 +20,9 @@ //! Most callers should use [`SystemPublicSuffixList::auto`], which probes //! the standard system paths for whichever format is available. +// Module-scoped until the crate-wide indexing_slicing deny lands. +#![cfg_attr(not(any(test, feature = "virt")), deny(clippy::indexing_slicing))] + pub mod dafsa; pub mod dat; mod system; From b98aa45974a324ecd8cf7412a718fd7e7c4b2109 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 18 May 2026 19:39:48 +0100 Subject: [PATCH 12/12] refactor(psl): add BadHeader variant for non-magic header errors --- libwebauthn/src/ops/webauthn/psl/dafsa.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/psl/dafsa.rs b/libwebauthn/src/ops/webauthn/psl/dafsa.rs index 83afe29c..d1abb240 100644 --- a/libwebauthn/src/ops/webauthn/psl/dafsa.rs +++ b/libwebauthn/src/ops/webauthn/psl/dafsa.rs @@ -34,6 +34,8 @@ pub enum DafsaFileLoadError { Truncated, #[error("not a libpsl DAFSA file (missing or malformed magic)")] BadMagic, + #[error("malformed DAFSA header (magic present but header is otherwise invalid)")] + BadHeader, #[error("unsupported DAFSA version: {0}")] UnsupportedVersion(u32), } @@ -111,11 +113,11 @@ fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { return Err(DafsaFileLoadError::BadMagic); } if header.last() != Some(&b'\n') { - return Err(DafsaFileLoadError::BadMagic); + return Err(DafsaFileLoadError::BadHeader); } let version_field = header .get(MAGIC.len()..HEADER_LEN - 1) - .ok_or(DafsaFileLoadError::BadMagic)?; + .ok_or(DafsaFileLoadError::BadHeader)?; let digit_count = version_field .iter() .take_while(|b| b.is_ascii_digit()) @@ -123,11 +125,11 @@ fn parse_header(bytes: &[u8]) -> Result, DafsaFileLoadError> { let version_digits = version_field .get(..digit_count) .filter(|digits| !digits.is_empty()) - .ok_or(DafsaFileLoadError::BadMagic)?; + .ok_or(DafsaFileLoadError::BadHeader)?; let version: u32 = std::str::from_utf8(version_digits) - .map_err(|_| DafsaFileLoadError::BadMagic)? + .map_err(|_| DafsaFileLoadError::BadHeader)? .parse() - .map_err(|_| DafsaFileLoadError::BadMagic)?; + .map_err(|_| DafsaFileLoadError::BadHeader)?; if version != 0 { return Err(DafsaFileLoadError::UnsupportedVersion(version)); } @@ -407,7 +409,7 @@ mod tests { bad[HEADER_LEN - 1] = b' '; assert!(matches!( parse_header(&bad), - Err(DafsaFileLoadError::BadMagic) + Err(DafsaFileLoadError::BadHeader) )); } }