The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.
The Public Suffix List (PSL)
is a community-curated list of the domain suffixes under which Internet
users can directly register names. pslr bundles a pinned
snapshot of that list and implements the official
prevailing-rule algorithm to answer two core questions about a
hostname:
co.uk for example.co.uk.example.co.uk.public_suffix("www.example.co.uk")
#> [1] "co.uk"
registrable_domain("www.example.co.uk")
#> [1] "example.co.uk"The matcher is compiled with cpp11 and needs no external
system library. Hostname canonicalization (case folding and Unicode/IDNA
handling) is delegated to the punycoder
package.
com, *.ck, or !www.ck.com,
co.uk).*.ck means
every label directly under ck is itself a public
suffix.!www.ck carves a
single name back out of a wildcard.*:
any unlisted TLD label is treated as a public suffix.github.io).The prevailing rule is chosen as: an exception beats a wildcard, the longest match beats shorter matches, and the implicit default applies only when nothing else does.
section selects which rules are eligible. Filtering
happens before prevailing-rule selection, so asking for one
section never silently borrows a rule from the other.
# github.io is a PRIVATE rule sitting under the ICANN suffix io.
public_suffix("user.github.io", section = "all") # default scope, both sections
#> [1] "github.io"
public_suffix("user.github.io", section = "icann") # the ICANN rule for io
#> [1] "io"
public_suffix("user.github.io", section = "private")
#> [1] "github.io"section = "private" fall-throughWhen you restrict to a section and the host matches no explicit rule
there, the query falls through to the implicit default rule rather than
failing. A plain ICANN host queried under
section = "private" therefore resolves to its own last
label via the default rule:
To distinguish “no explicit rule matched” from a real match, combine
the section with unknown = "na" (below).
By default an unlisted suffix is handled by the implicit
* rule, so a made-up TLD still yields a public suffix. Pass
unknown = "na" to require an explicit rule and get
NA otherwise.
public_suffix("example.madeuptld") # default rule
#> [1] "madeuptld"
public_suffix("example.madeuptld", unknown = "na") # explicit-only
#> [1] NAis_public_suffix() reports whether a host is itself a
public suffix. Under the default policy an unlisted single label is
TRUE via the implicit rule; use unknown = "na"
to test explicit list membership instead.
Input may be ASCII, Unicode, or A-label (xn--)
hostnames; equivalent spellings canonicalize to the same answer. Output
is ASCII A-labels by default; pass output = "unicode" to
decode them.
A single terminal root dot is preserved on hostname-shaped output, so a fully-qualified name round-trips:
suffix_extract() splits each host into subdomain,
registrant label, and suffix; public_suffix_rule() reports
which rule prevailed, useful for auditing.
suffix_extract("blog.user.github.io")
#> input host subdomain domain suffix
#> 1 blog.user.github.io blog.user.github.io blog user github.io
#> registrable_domain
#> 1 user.github.io
public_suffix_rule(c("www.ck", "a.b.kobe.jp", "example.madeuptld"))
#> input host_ascii rule kind rule_section
#> 1 www.ck www.ck !www.ck exception icann
#> 2 a.b.kobe.jp a.b.kobe.jp *.kobe.jp wildcard icann
#> 3 example.madeuptld example.madeuptld * default <NA>
#> public_suffix_ascii
#> 1 ck
#> 2 b.kobe.jp
#> 3 madeuptldAll query functions are vectorised, length- and name-preserving, and
NA-safe. Invalid input (URLs, IPv6, empty labels, dotted-decimal IPv4
literals, …) is NA by default; pass
invalid = "error" to abort on the first invalid
element.
The package ships with a pinned snapshot, so it works fully offline
and the bundled list is the default for every query.
psl_refresh() is the only function that touches
the network: an explicit, HTTPS-only, validated download into a user
cache. psl_use() chooses which list backs the session.
# Revalidate against upstream, publish any changed bytes, and activate them:
psl_refresh(activate = TRUE)
# Switch the active list for this session:
psl_use("cache") # the snapshot the last refresh selected
psl_use("bundled") # back to the shipped snapshot
psl_use("path", path = "my_list.dat") # a custom fileactivate and force are named-only
arguments. Both are logical and both mean “do more than a bare check”,
so psl_refresh(url, TRUE) would be unreadable whichever one
it meant; naming them makes every call say what it does.
Activation is session-only and validated before any state changes; a failed refresh never replaces a working cache or active list.
The upstream list keeps changing, so sooner or later you will want to
know how current the list answering your queries actually is.
pslr answers that with evidence, not with arithmetic on a
date.
psl_status() is the offline read. It makes no request,
writes nothing, and reports the strongest claim the locally stored
evidence supports:
psl_status() # the list active in this session
#> <psl_status: never_checked>
#> Never checked against its source.
#> No successful check has confirmed these bytes against the source. Run
#> psl_refresh() to check.
#> snapshot: active (bundled)
#> checksum: sha256:00dda6fa8406...
#> source: https://publicsuffix.org/list/public_suffix_list.dat
#> content date: 2026-09-05 14:17 UTC
#> retrieved: 2026-09-09 12:39 UTC
psl_status("bundled")$state # the snapshot installed with the package
#> [1] "never_checked"Its state, in precedence order:
| State | Meaning |
|---|---|
missing |
The requested cache selection does not exist. |
unknown |
Local state is corrupt or ambiguous, or the clock moved backwards. |
untracked |
The snapshot has no remote source, so no claim is possible — a custom file, say. |
update_available |
A successful earlier check observed a different checksum for the source. |
never_checked |
The source is known, but nothing has ever confirmed this snapshot against it. |
check_due |
The snapshot was confirmed current, and the reminder interval has since elapsed. |
confirmed_current |
Confirmed current, and the interval has not elapsed. |
These are different kinds of statement, and conflating them is the mistake this design exists to avoid.
check_due is about your knowledge: some days
have passed since anything last confirmed this snapshot, so a check is
worth making. It says nothing whatsoever about upstream. The list may
not have changed in months.
update_available is about upstream: a check
actually ran, and the source reported a checksum that differs from the
snapshot you are looking at. That is an observation, and
pslr will only ever make it after a real check.
Elapsed time alone therefore never produces
update_available. A year-old snapshot that nobody has
checked is never_checked, not “outdated” — because nothing
in the local state knows whether it is out of date. (This is why the old
psl_outdated() was removed: a Boolean derived from the list
date could only ever answer the age question while sounding like it
answered the upstream one.)
Two different identifiers do two different jobs, and they are not interchangeable.
A SHA-256 checksum identifies bytes.
pslr computes it over the exact source bytes of every
snapshot, and it is the package’s own, verifiable answer to “are these
the same list?” Two snapshots with the same SHA-256 are the same list; a
stored file whose bytes no longer hash to its name is corrupt. Checksums
are computed locally, so nothing upstream has to be trusted for them to
mean something.
An ETag or Last-Modified value is
an opaque token the server issues for one URL.
pslr stores it and sends it back on the next check, which
lets the server answer 304 Not Modified and skip resending
a list that has not changed — a courtesy to publicsuffix.org and a
saving for you. But a validator proves nothing about content: it is
scoped to the server that issued it, it can change while the bytes do
not, and it is never used as an integrity or authenticity check.
Downloaded bytes are always hashed and fully validated on their own
merits.
A successful psl_refresh() returns invisibly with
exactly one outcome:
| Outcome | Requests | Body | Meaning |
|---|---|---|---|
skipped_recently |
0 | no | The last successful check is still inside its courtesy window. |
not_modified |
1 | no | The conditional request answered 304; local bytes were
verified and stand. |
updated |
1 | yes | A 200 whose validated bytes are a new snapshot. |
downloaded_unchanged |
1 | yes | A 200 whose validated bytes hash to the snapshot
already held. |
downloaded_unchanged is the honest name for the case
where no validator was usable, so the list had to be fetched to find out
that it was identical.
Anything that is not one of those four is a failure, and failures are
classed errors rooted at pslr_refresh_error — never a
success-shaped result. A failed refresh leaves the cache, the selected
snapshot, and the active matcher exactly as they were.
The Public Suffix List asks clients to download it no more than once
a day. An ordinary psl_refresh() therefore makes no request
at all while the last successful check is inside a courtesy window of at
least 24 hours; it returns skipped_recently without
touching the network. If the server advertises longer freshness, the
window follows it, up to a 30-day cap.
force = TRUE bypasses that local window and nothing
else. It is not an unconditional download: the request it makes still
carries a validator when one is available, so upstream can still answer
304.
Reminders are off until you turn them on, and they never make a
request. When enabled, a direct library(pslr) prints at
most one startup message per R session, and only for the three states
where local evidence supports advice: never_checked,
check_due, and update_available.
psl_reminder(enable = TRUE) # weekly by default
psl_reminder(enable = TRUE, every = 30) # or a month
psl_reminder(enable = FALSE) # off; the interval is remembered
psl_reminder() # just query, writes nothingThe preference is configuration rather than cache: it lives under
tools::R_user_dir("pslr", "config"), so refreshing,
pruning, or deleting the snapshot cache never changes it.
suppressPackageStartupMessages() silences the message as
usual, and a package that merely imports the namespace never triggers
it.
Note that a reminder is a prompt to check, not a claim that
something changed — with the one exception of
update_available, where the newer snapshot is already
stored locally and the message suggests activating it with
psl_use("cache") rather than downloading anything
again.
pslr installs no scheduler, runs no daemon, and starts
no background request. If you want a daily or weekly refresh, drive it
from a scheduler you already have, with a one-line script:
# cron: every day at 04:30
30 4 * * * Rscript /path/to/refresh-psl.R
# or a weekly GitHub Actions / systemd timer / Task Scheduler entryBecause psl_refresh() enforces the courtesy window
itself, over-scheduling is harmless: extra runs return
skipped_recently without making a request. Nothing is
activated unless you pass activate = TRUE, so a scheduled
refresh stages new bytes and leaves it to the next session to pick them
up.
Every distinct validated download is preserved by its checksum until
you explicitly remove it. psl_snapshots() lists what this
installation can resolve — one row per distinct SHA-256, so bytes stored
twice collapse into one row:
snapshots <- psl_snapshots()
snapshots[c("checksum", "bundled", "size", "integrity")]
#> checksum
#> 1 sha256:00dda6fa84060cca59415b5ba30bbe9d6700deb78d177f59fc83673531e30099
#> bundled size integrity
#> 1 TRUE 335611 okpsl_cache_prune() is the explicit, offline, destructive
counterpart. It protects the selected cache snapshot, every snapshot any
source record still names, the snapshot active in this session, and the
keep most recently retrieved snapshots beyond those:
psl_cache_prune() # referenced snapshots, plus one more
psl_cache_prune(keep = 0) # referenced snapshots onlyKeeping history is not an accident: stable checksums are the seam
psl_diff() needs to say what changed between two snapshots.
Pruning to keep = 0 gives that up in exchange for disk.
psl_diff(old, new) reports which rules were added,
removed, or changed between two snapshots — offline, from bytes already
on this machine. The usual comparison is the snapshot you installed
against one you have since retrieved:
# After at least one psl_refresh(), the cache selection is a second snapshot:
changes <- psl_diff("bundled", "cache")
table(changes$change)
head(changes)That works only once local collection has started. Installing pslr
makes no request and gives you exactly one snapshot: the bundled one.
The first explicit psl_refresh() is what begins the history
— from then on every distinct validated download is retained by checksum
until you prune it, and each retained file is a valid old
or new. Any path from psl_snapshots() can be
passed directly, as can a historical revision you materialized as a file
by other means; psl_diff() resolves no dates and downloads
nothing.
Either side also accepts a source-file path, a
psl_engine(), or a psl_rules()-shaped table,
which is enough to see the semantics without a cache:
write_list <- function(private) {
path <- tempfile(fileext = ".dat")
writeLines(
c(
"// ===BEGIN ICANN DOMAINS===",
"com",
"// ===END ICANN DOMAINS===",
"// ===BEGIN PRIVATE DOMAINS===",
private,
"// ===END PRIVATE DOMAINS==="
),
path
)
path
}
psl_diff(
write_list(c("a.example.com", "*.b.example.com")),
write_list(c("b.example.com", "c.example.com"))
)
#> change rule old_rule new_rule old_kind new_kind
#> 1 removed a.example.com a.example.com <NA> normal <NA>
#> 2 changed b.example.com *.b.example.com b.example.com wildcard normal
#> 3 added c.example.com <NA> c.example.com <NA> normal
#> old_section new_section
#> 1 private <NA>
#> 2 private private
#> 3 <NA> privateA row is keyed on a rule’s logical identity — its canonical
labels with any leading *. or ! removed — so
*.b.example.com becoming b.example.com is one
changed row rather than an unrelated removal and addition.
A rule moving between the ICANN and PRIVATE sections is
changed for the same reason. The comparison happens after
parsing and canonicalization, so comments, blank lines, whitespace,
letter case, source ordering, and raw Unicode spelling never appear as
changes. Two snapshots that agree return the same columns with zero
rows.
psl_diff() never activates anything: both sides resolve
independently, and the session-global list keeps answering from whatever
psl_use() last selected. Provenance travels with the result
as attributes, so a diff can be filed alongside the identities it
compared:
psl_use() switches the one session-global list every
query sees by default. psl_engine() instead builds a
self-contained engine you can hold in a variable and query independently
— so you can work with several lists at once, or give a component its
own list, without mutating global session state. Every query function
takes an engine = argument that threads a specific engine
through that one call.
engine <- psl_engine("bundled")
public_suffix("example.co.uk", engine = engine)
#> [1] "co.uk"
suffix_extract("www.example.co.uk", engine = engine)
#> input host subdomain domain suffix
#> 1 www.example.co.uk www.example.co.uk www example co.uk
#> registrable_domain
#> 1 example.co.ukAn engine holds a compiled matcher backed by a C++ external pointer,
which does not serialize across R sessions or parallel worker processes:
saving and reloading an engine, or sending one to a worker, does not
carry the matcher. This is why engines are described as process-local.
To persist or ship an engine, record its snapshot descriptor with
psl_version() and rebuild the engine in the target
process:
A public-suffix result depends on both which list answered
and how hosts were normalized. psl_version()
reports both — the source-snapshot provenance and the runtime
normalization identifiers — so a result can be reproduced later. Record
this row alongside reproducibility-sensitive output.
psl_version()
#> source
#> 1 bundled
#> url
#> 1 https://raw.githubusercontent.com/publicsuffix/list/46ae48ceff01716409d78d38c2bbb36489cce68e/public_suffix_list.dat
#> path retrieved_at list_date
#> 1 <NA> 2026-09-09 12:39:06 UTC 2026-09-05T14:17:12Z
#> commit size
#> 1 46ae48ceff01716409d78d38c2bbb36489cce68e 335611
#> checksum
#> 1 sha256:00dda6fa84060cca59415b5ba30bbe9d6700deb78d177f59fc83673531e30099
#> normalizer normalizer_version normalization_profile unicode_version
#> 1 punycoder 1.2.1.9000 uts46-nontransitional-std3-v2 17.0.0psl_rules() exposes the active rule table itself:
nrow(psl_rules("icann"))
#> [1] 6950
head(psl_rules("private"), 3)
#> rule canonical_rule kind section labels
#> 1 co.krd co.krd normal private 2
#> 2 edu.krd edu.krd normal private 2
#> 3 art.pl art.pl normal private 2If the shipped index was generated under a different normalization
profile or Unicode version than the installed punycoder,
the list is transparently rebuilt in memory from source on activation,
so an index is never mixed with hosts normalized under a different
profile.
psl_refresh() does, and
only when you call it. It is HTTPS-only, rejects embedded credentials,
query strings, and downgrade or cross-origin redirects, and enforces a
source-size ceiling.psl_snapshots() reports only how many sources
reference a snapshot, never which.pslr is part of a small ecosystem of R packages by the
same author:
pslr uses for host
canonicalization. Use it directly for raw Unicode ↔︎ ACE round-trips
outside the PSL context.pslr as its PSL engine. Reach for it when you need to work
with complete URLs rather than bare hostnames.pslr serves the Public Suffix List, maintained by
Mozilla and the wider community under the Mozilla Public License 2.0,
and delegates host normalization (UTS #46 / IDNA) to the sibling
punycoder package. Its matcher is built on
cpp11.
The full list of credits — prior art, dependencies, the standards
this code implements, and the data sources it serves — is in ACKNOWLEDGMENTS.md.
These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.