Predicates

Functions that inspect text and return boolean or structured results without modifying the input.

detect_scripts

detect_scripts

detect_scripts(text: str) -> list[Script]

Return the set of Unicode scripts present in text, in order of first appearance.

Parameters:
  • text (str) –

    Input string.

Returns:
  • list[Script]

    List of Script enum values, ordered by first appearance.

Examples:

>>> detect_scripts("Hello")
[Script.LATIN]
>>> detect_scripts("Hello Мир")
[Script.LATIN, Script.CYRILLIC]

inspect_auto_lang

inspect_auto_lang

inspect_auto_lang(text: str) -> dict[str, str | list[str] | None]

Inspect how lang="auto" would resolve for the given text.

Use this to audit or log the detection decision made by the three-stage auto-detection pipeline.

Parameters:
  • text (str) –

    Input string.

Returns:
  • dict[str, str | list[str] | None]

    Dict with keys:

  • dict[str, str | list[str] | None]
    • script: primary non-Latin script name, or None
  • dict[str, str | list[str] | None]
    • chosen_lang: resolved language code, or None
  • dict[str, str | list[str] | None]
    • reason: one of "unambiguous_script", "discriminator", "script_default", "latin_discriminator", "no_detection"
  • dict[str, str | list[str] | None]
    • discriminators_hit: list of discriminator characters found

Examples:

>>> inspect_auto_lang("Київ")["chosen_lang"]
'uk'
>>> inspect_auto_lang("Москва")["reason"]
'script_default'
from disarm import inspect_auto_lang

inspect_auto_lang("Київ")
# {'script': 'Cyrillic', 'chosen_lang': 'uk', 'reason': 'discriminator', 'discriminators_hit': ['ї']}

inspect_auto_lang("Москва")
# {'script': 'Cyrillic', 'chosen_lang': 'ru', 'reason': 'script_default', 'discriminators_hit': []}

inspect_auto_lang("hello")
# {'script': None, 'chosen_lang': None, 'reason': 'no_detection', 'discriminators_hit': []}

See Language Detection for details.


Bilingual text is not a spoof

is_mixed_script, has_bidi_conflict and find_confusables each answer a question about the whole string, and each is accurate about it. The trouble is what a caller does with them: they look like standalone detectors, so callers compose them —

from disarm import find_confusables, has_bidi_conflict, is_mixed_script


def rejected(x: str) -> bool:
    return bool(is_mixed_script(x) or has_bidi_conflict(x) or find_confusables(x))


sentence = "\u05e9\u05dc\u05d5\u05dd world"  # a Hebrew word, then an English one
assert rejected(sentence)  # ...turned away
assert rejected("hello \u043c\u0438\u0440")  # and so is "hello мир"
text string-level per word is it a spoof?
hellо (Cyrillic о) fires fires yes — one word, two scripts
שלוםworld fires fires yes — glued into one token
hello мир fires silent no — two words, two scripts
שלום world fires silent no — a sentence in Tel Aviv
مرحبا hello fires silent no
IT-специалист fires silent no — a hyphenated compound

has_anomalies gets every row right, and has since it was written. per_word=True is that distinction exposed on its own, so the rule can be built from parts:

ALLOWED = ["Latin", "Cyrillic", "Hebrew", "Arabic", "Han"]


def rejected(x: str) -> bool:
    return bool(
        is_mixed_script(x, per_word=True)
        or has_bidi_conflict(x, per_word=True)
        or find_confusables(x, allowed_scripts=ALLOWED)  # allowed_scripts is #900
    )


assert not rejected(sentence)  # the sentence goes through
assert not rejected("IT-\u0441\u043f\u0435\u0446\u0438\u0430\u043b\u0438\u0441\u0442")
assert rejected("hell\u043e")  # ...and the spoof still does not

Words, not whitespace tokens

per_word splits on whitespace and the joining punctuation - _ / : @ ,, which is the detector's own splitter. Splitting on whitespace alone would report IT-специалист, email:почта, user@почта.рф, Tokyo/東京 and ru_текст — five ordinary shapes, and every one of them a case has_anomalies calls clean.


is_mixed_script

is_mixed_script

is_mixed_script(text: str, *, per_word: bool = False) -> bool

True if text contains characters from more than one Unicode writing system.

Resolves the UTS #39 §5.1 augmented script sets (#776), so a script pair that one writing system uses is not "mixed":

================================== ============================ Han + Hiragana + Katakana Japanese Han + Hangul Korean Han + Bopomofo Chinese ================================== ============================

So 日本語テスト is one writing system, not three scripts. Anything without a writing system in common is still mixed, including a CJK script beside a non-CJK one — 例えa is Japanese and Latin, and that is the case this check exists for.

Note

inspect_anomalies is deliberately more permissive: it also exempts CJK beside Latin, because it runs over prose where a Japanese sentence carrying a product name in Latin is ordinary text. A label doing the same is not, which is why this function and the hostname screen both flag it.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if the text spans more than one writing system (Common/Inherited excluded).

Examples:

>>> is_mixed_script("Hello")
False
>>> is_mixed_script("Hello Мир")  # Latin + Cyrillic
True
>>> is_mixed_script("日本語テスト")  # Han + Katakana, one writing system
False
>>> is_mixed_script("ひら한")  # Japanese + Korean, no set in common
True

This is a string-level question, and callers compose it into a different one (#901). Bilingual text triggers it by design. A reject rule written as is_mixed_script(x) or has_bidi_conflict(x) or find_confusables(x) turns away every bilingual user — measured, it rejects all four bilingual strings in #901's table alongside the two spoofs. has_anomalies is the composition that tells them apart; per_word=True is that distinction on its own.

per_word splits on words, not whitespace tokens: IT-специалист, email:почта and user@почта.рф are ordinary text, and a whitespace split reports all three. It uses the detector's own splitter, so the two agree.


has_bidi_conflict

has_bidi_conflict

has_bidi_conflict(text: str, *, per_word: bool = False) -> bool

True if text mixes strong left-to-right and strong right-to-left characters.

This is the precondition for Unicode Bidi display-reordering (UAX #9) — the structural signal behind "BiDi Swap"-style spoofs, where an LTR brand label sits beside an RTL domain (e.g. "varonis.com.ו.קום"). Unlike a bidi-override (U+202x) check, it fires on the real letters: Latin / Cyrillic / Greek / CJK are left-to-right; Hebrew / Arabic / Syriac / Thaana / N'Ko are right-to-left; digits, punctuation and combining marks are neutral and never create a conflict on their own.

A False result is not a safety guarantee.

Warning

This is not the RLO check. Because it reads letters, it is structurally blind to the U+202x overrides — the classic extension spoof "invoice\u202Egpj.exe" returns False here. The two conditions are disjoint; a string can satisfy either, both, or neither.

To cover an override instead, use inspect_anomalies (kind bidi) to detect and strip_bidi to remove. Note strip_bidi does not close this function's case: on a real-letter conflict it returns the input unchanged, because there is no format character to remove.

Warning

This reads the whole string; inspect_anomalies reads one token at a time (#769). bidi_mixed is the closest thing the detector has to this check, and it fires on a token that mixes directions. So a string whose directions are split across two whitespace-separated words is a conflict here and clean there::

has_bidi_conflict("hello שלום")            True
inspect_anomalies("hello שלום").kinds      []
has_bidi_conflict("helloשלום")             True
inspect_anomalies("helloשלום").kinds       ['bidi_mixed']

Neither is wrong. A label made of two words in two scripts is ordinary multilingual text, and the detector declining to flag it is why it can be run over prose. This function asks the structural question — can UAX #9 reorder this string — and the answer for two words is yes.

Pick by what you are protecting. A single identifier, filename or hostname label is one token, and the detector is the better fit because it says which token and why. A whole line, a display name or anything that may legitimately contain a space needs this function, because the detector will not look across the space.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if both a strong-LTR and a strong-RTL character are present.

Examples:

>>> has_bidi_conflict("hello")
False
>>> has_bidi_conflict("helloא")  # Latin + Hebrew
True
>>> has_bidi_conflict("hello שלום")  # whole string, so the space is no barrier
True
>>> inspect_anomalies("hello שלום").kinds  # per token, so it is two clean words
[]
>>> has_bidi_conflict("invoice\u202Egpj.exe")  # RLO override, not letters
False
>>> inspect_anomalies("invoice\u202Egpj.exe").kinds  # this is the check
['bidi']

This is a string-level question, and callers compose it into a different one (#901). Bilingual text triggers it by design. A reject rule written as is_mixed_script(x) or has_bidi_conflict(x) or find_confusables(x) turns away every bilingual user — measured, it rejects all four bilingual strings in #901's table alongside the two spoofs. has_anomalies is the composition that tells them apart; per_word=True is that distinction on its own.

per_word splits on words, not whitespace tokens: IT-специалист, email:почта and user@почта.рф are ordinary text, and a whitespace split reports all three. It uses the detector's own splitter, so the two agree.


is_confusable

is_confusable

is_confusable(text: str, *, target_script: str | Script = 'latin', greedy: bool | None = None, preferred_aliases: list[str] | None = None) -> bool

True if text contains characters confusable with target-script characters.

Printable ASCII is never a detection (#957). ", the backtick and | are TR39 confusable sources and the fold rewrites all three — deliberately, and recorded under Five surfaces rewrite printable ASCII in the limitations page. Counting them here made this return True for every quoted sentence and every JSON document; 588 of the 1,342 pure-ASCII lines of this repository's own prose fired. The rows stay in the fold and stop being reported, so normalize_confusables('|') is still 'l' while is_confusable('|') is False.

Parameters:
  • text (str) –

    Input string.

  • target_script (str | Script, default: 'latin' ) –

    Script to check confusability against. Currently only "latin" is supported; any other value raises DisarmError.

  • greedy (bool | None, default: None ) –

    confusable_homoglyphs compatibility — ignored, with a DeprecationWarning when explicitly passed. disarm always checks all characters.

  • preferred_aliases (list[str] | None, default: None ) –

    confusable_homoglyphs compatibility — ignored, with a DeprecationWarning when explicitly passed. disarm uses its own script detection engine.

Returns:
  • bool

    True if any confusable homoglyphs are present.

Raises:
  • DisarmError

    If target_script is not "latin".

Examples:

>>> is_confusable("pаypal")  # Cyrillic а looks like Latin a
True
>>> is_confusable("paypal")  # all genuine Latin
False

unmapped_confusables

unmapped_confusables

unmapped_confusables(*, target_script: str | Script = 'latin') -> frozenset[str]

Every upstream confusable source disarm's bundled table does not fold (#563).

Read this as exposure, not as a score. A tool at 95% per-source coverage is not 95% safe — it is one query away from the other 5%, and this set is where an adaptive attacker goes when the mapped sources stop working.

Most of the set is out of scope rather than missing: a source whose upstream target is non-Latin has no business in the to-Latin table. Cross-reference CONFUSABLES_VERSION and docs/provenance.md before reading any one codepoint as a defect.

The population is TR39's, not the world's (#738). This enumerates the 6,565 single-code-point sources in confusables_upstream_sources.tsv — what upstream lists and disarm drops. A pair upstream never listed is outside the denominator as well as outside the table: U+4E28 and U+3021 score higher against l than most of TR39 in a measured font survey, and neither appears here. THREAT_MODEL.md's normalization is enumerate-the-known is the honest reading; this is not a complete gap report. It is also single-code-point by construction, so the multi-character direction (rnm, vvw, cld) has no denominator at all — disarm's answer there is three contraction rows, reachable only from is_suspicious_hostname with contractions=True, against a measured population of 571,753 bigram-to-character pairs, most of them not registrable.

The set includes five ASCII characters — %, 0, 1, I and m. TR39 is a skeleton transform (m→rn, I/1→l, 0→O), so those are upstream sources; disarm does not apply those rows because folding a legitimate ASCII m to rn corrupts prose. Nothing is filtered out here: a coverage report that quietly drops rows reads as coverage it does not have.

Parameters:
  • target_script (str | Script, default: 'latin' ) –

    Which bundled table to report against — a Script member, or the lowercase token "latin" (default), "cyrillic", "arabic" or "hebrew". They have genuinely different coverage, and the residue is largest for the RTL targets because most of TR39 has no Arabic or Hebrew member at all.

    Two spellings, and the member bridges them (#767). list_scripts returns "Arabic" and this parameter's raw-string form takes "arabic"; a raw string stays strict on purpose, so a caller who hard-coded the wrong one finds out. Pass the member and the question does not arise, since Script("Arabic") converts a name from list_scripts: unmapped_confusables(target_script=Script(name)).

    Four tables, 61 known scripts. list_scripts answers "which scripts can disarm identify"; this parameter answers "which scripts can disarm fold toward", and those are different questions. A script with no bundled table is refused rather than answered with a count, because a number determined entirely by a table's absence reads as coverage and is not. For the fair per-script figure — of the sources whose prototype is in script X, how many disarm reaches — call confusable_coverage, which answers for every script including the ones with no bundled table (#963).

Returns:
  • frozenset[str]

    A frozenset of single-character strings.

Raises:
  • InvalidArgumentError

    If target_script is not a supported script.

Examples:

>>> unmapped = unmapped_confusables()
>>> "а" in unmapped  # Cyrillic а IS folded, so it is not exposure
False
>>> "m" in unmapped  # TR39 skeleton source m→rn, deliberately not applied
True

find_unmapped_confusables

find_unmapped_confusables

find_unmapped_confusables(text: str, *, target_script: str | Script = 'latin') -> list[tuple[str, int]]

Find confusable sources in text that disarm's table does not fold (#563).

The confusables analogue of find_untranslatable, and it follows the same convention: (character, byte_offset) pairs in order of appearance. This is what turns unmapped_confusables from a global number into something answerable against your own traffic.

Composition runs exactly as it does in normalize_confusables, so a decomposed homoglyph whose precomposed form is mapped counts as covered rather than as a gap — otherwise the report would disagree with what the transform does. Offsets are anchored in text, never in the composed intermediate.

Ordinary English will report the letter m; see unmapped_confusables for why that is deliberate.

Parameters:
  • text (str) –

    Input Unicode string.

  • target_script (str | Script, default: 'latin' ) –

    Which bundled table to report against (default "latin").

Returns:
  • list[tuple[str, int]]

    List of (char, byte_offset) for each unmapped confusable source.

Raises:
  • TypeError

    If text is not a str.

  • InvalidArgumentError

    If target_script is not a supported script.

Examples:

>>> find_unmapped_confusables("pаypal")  # Cyrillic а folds — covered
[]
>>> find_unmapped_confusables("hello")
[]

confusable_coverage

confusable_coverage

confusable_coverage(script: str | Script) -> ConfusableCoverage

TR39 sources whose prototype is in script, and how many disarm folds (#963).

The denominator unmapped_confusables does not have. That function measures one bundled table against the whole 6,565-source population, which is the right question for a target disarm ships and a misleading one for a script it does not: Greek reports almost the entire population unmapped, and the number means only "there is no Greek table". A count determined by a table's absence is a blind spot with a number in front of it, which is worse than the exception it replaced, because it looks like data.

This is the fair figure — of the sources whose prototype is in this script, how many does disarm reach:

Parameters:
  • script (str | Script) –

    Script name in disarm's spelling ("Greek") or a Script member.

Returns:
  • ConfusableCoverage

    A ConfusableCoverage dict with script, sources and folded keys.

Raises:
  • InvalidArgumentError

    If script is neither a script disarm knows nor one the census has a row for.

Note

folded counts sources any bundled table reaches, not sources folded toward this script. Greek is not zero: 71 of its 159 sources are Greek letters the Latin table folds. The question a caller has is whether disarm neutralizes the source at all, not which prototype TR39 picked for it.

The grouping uses the UCD's script property, but the census is keyed in disarm's namespace — the UCD name with underscores removed, which is the spelling list_scripts returns for every script the two tables share. So 19 scripts appear that disarm's own enum does not name ("Yi", "Siddham", "PauCinHau" and 16 others, 72 sources between them), spelled the same way as the rest. A script disarm knows that TR39 never uses as a prototype returns 0 of 0.

Examples:

>>> confusable_coverage("Greek")["sources"]
159
>>> confusable_coverage("Han")["folded"]  # 1,393 sources, no CJK fold table
0
>>> confusable_coverage(Script.THAANA)  # a script TR39 never targets
{'script': 'Thaana', 'sources': 0, 'folded': 0}

The denominator, and why it is a separate question

unmapped_confusables("latin") measures one bundled table against all 6,565 TR39 sources. That is the right question for a target disarm ships. Asked about a script it does not ship a table for, the same shape of answer is dominated by the absence:

from disarm import confusable_coverage, unmapped_confusables

# One table against the whole population: 4,330 sources it does not fold.
assert len(unmapped_confusables(target_script="latin")) == 4330

# The same question per script, against that script's own prototypes.
assert confusable_coverage("Greek") == {"script": "Greek", "sources": 159, "folded": 71}
assert confusable_coverage("Han") == {"script": "Han", "sources": 1393, "folded": 0}

Greek is 71 of 159 rather than 0 because folded counts sources any bundled table reaches: those 71 are Greek letters the Latin table folds. Han is 0 of 1,393 because no CJK fold table ships — a real gap, and now one with a denominator attached to it.

The census sums to the whole population, so no script's row is a share of a bucket:

from pathlib import Path

import disarm

rows = [
    line.split("\t")[0]
    for line in Path("src/tables/data/confusable_prototype_census.tsv")
    .read_text(encoding="utf-8")
    .splitlines()
    if line and not line.startswith("#")
]
assert sum(disarm.confusable_coverage(name)["sources"] for name in rows) == 6565

is_ascii

is_ascii

is_ascii(text: str) -> bool

True if all characters are in U+0000–U+007F.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if the string is pure ASCII.

Examples:

>>> is_ascii("hello 123")
True
>>> is_ascii("café")
False

find_key_collisions

find_key_collisions

find_key_collisions(values: list[str], *, key: str, lang: str | None = None) -> list[KeyCollision]

Which of values reduce to the same identity key (#620).

Every other disarm detector is a single-string predicate, and a collision is not a property of a single string — groß.txt is an ordinary German filename, and аdmin is only a problem next to admin. This is the set-shaped question: given these names, which of them are the same name?

That is what node-tar's PathReservations guard failed to ask before extracting two paths in parallel (CVE-2026-23950), and what a registry has to ask before accepting a second admin (CVE-2013-7236). The two want opposite policies from the same answer — one refuses the batch, the other refuses the registration — so this reports and decides nothing.

Choosing key is choosing the policy, and there is no default. Measured against the four collision CVEs in the validation matrix:

============================ ========== ========== ========= ========= key 2026-23950 2019-19844 2013-7236 2020-12063 ============================ ========== ========== ========= ========= "fold_case" yes -- -- -- "search_key" yes yes yes yes "catalog_key" yes yes yes yes "canonicalize" -- yes yes yes "canonicalize_strict" -- yes yes yes "normalize_confusables" -- yes yes yes ============================ ========== ========== ========= =========

A stronger key finds more collisions, including ones nobody attacked: search_key collides Muller with Müller and Ivan with Иван. That is not a false positive — they really are one key — it is the cost of the key you chose. sort_key is deliberately not offered: a sort key exists to collide, so reporting its collisions would be noise.

Reducing and grouping happen in one pass over one reducer, so the report cannot disagree with the collapse it describes. A group is returned only when it holds two or more distinct inputs — the same string twice is the same name twice, which a reservation table already handles.

The return is not a partition, and the two counts do not add (#763). A name that collides with nothing never appears, so the groups do not cover the input. The quantity a registry actually wants — after reduction, how many distinct identities does this batch hold? — has to be derived, and there is one correct spelling::

reduced = len(set(values)) - sum(len(g.values) for g in groups) + len(groups)

values and indices have different denominators by design (see KeyCollision), so they must never be arithmetically combined. Substituting g.indices for g.values above, or len(values) for len(set(values)), gives a formula that is right on every duplicate-free batch and wrong the moment an input repeats. Measured over 400 duplicate-free batches all four spellings agree with the truth; over 400 with one repeat injected, only this one does.

Parameters:
  • values (list[str]) –

    The set to check. Order is preserved in the report; the batch cap is the same 100,000 every other batch entry point uses.

  • key (str) –

    Which reducer builds the keys — one of "fold_case", "search_key", "catalog_key", "canonicalize", "canonicalize_strict", "normalize_confusables".

  • lang (str | None, default: None ) –

    Language hint, reaching search_key and catalog_key, whose romanization is language-dependent; ignored by the rest. Under lang="de", Müller and Mueller are one key.

Returns:
  • list[KeyCollision]

    A KeyCollision per colliding group, in order of the first index

  • list[KeyCollision]

    that participates. Each has key (the shared reduced form), values

  • list[KeyCollision]

    (the distinct inputs, first-appearance order) and indices (every

  • list[KeyCollision]

    position, ascending — not parallel to values).

Raises:
  • TypeError

    If values is not a list of str.

  • InvalidArgumentError

    If key is not one of the six.

  • ResourceLimitError

    If values exceeds the batch cap.

Examples:

>>> found = find_key_collisions(
...     ["groß.txt", "gross.txt", "other.txt"], key="fold_case"
... )
>>> found[0].key
'gross.txt'
>>> found[0].values
['groß.txt', 'gross.txt']
>>> found[0].indices
[0, 1]
>>> find_key_collisions(["a.txt", "b.txt"], key="fold_case")
[]

A repeated input — the shape every other example omits, and the only shape that separates the correct derivation from its three near-misses:

>>> names = ["admin", "admin", "Admin"]
>>> groups = find_key_collisions(names, key="fold_case")
>>> groups[0].values          # distinct inputs: two
['admin', 'Admin']
>>> groups[0].indices         # occurrences: three
[0, 1, 2]
>>> len(set(names)) - sum(len(g.values) for g in groups) + len(groups)
1

Three names, one identity. The three near-misses give 2, 0 and 1 — the last by cancellation rather than by construction.

from disarm import find_key_collisions

find_key_collisions(["groß.txt", "gross.txt", "other.txt"], key="fold_case")
# [KeyCollision(key="gross.txt", values=["groß.txt", "gross.txt"], indices=[0, 1])]

find_key_collisions(["admin", "аdmin"], key="canonicalize")
# [KeyCollision(key="admin", values=["admin", "аdmin"], indices=[0, 1])]

find_key_collisions(["a.txt", "b.txt"], key="fold_case")
# []

Every other function on this page answers about one string. This one answers about a set, because a collision is not a property of a single string: groß.txt is an ordinary German filename, and аdmin is only a problem next to admin. It is the question node-tar's PathReservations guard failed to ask before extracting two paths in parallel (CVE-2026-23950), and the one a registry has to ask before accepting a second admin (CVE-2013-7236). Those two want opposite policies from the same answer — refuse the batch, or refuse the registration — so the function reports and decides nothing.

Each result is a KeyCollision with three fields:

Field Meaning
key The reduced form every member of the group shares.
values The distinct inputs that reduce to it, in order of first appearance.
indices Every position in the input list, ascending. Not parallel to values: a value repeated verbatim appears once there and once per occurrence here.

A group is reported only when it holds two or more distinct inputs. The same name twice is the same name twice, which a reservation table already handles.

The return is not a partition, and the two counts do not add

A name that collides with nothing never appears in the result, so the groups do not cover the input. The quantity a registry usually wants next — after reduction, how many distinct identities does this batch hold? — is not returned, and has to be derived. There is one correct spelling:

reduced = len(set(values)) - sum(len(g.values) for g in groups) + len(groups)

values and indices have different denominators by design, which is why the table above says they are not parallel. The consequence is that they cannot be added to each other, and it is easy to miss, because every example on this page is duplicate-free and all four plausible spellings agree on a duplicate-free batch:

from disarm import find_key_collisions

names = ["admin", "admin", "Admin"]
groups = find_key_collisions(names, key="fold_case")
assert groups[0].values == ["admin", "Admin"]  # distinct inputs: two
assert groups[0].indices == [0, 1, 2]  # occurrences: three

by_values = sum(len(g.values) for g in groups)
by_indices = sum(len(g.indices) for g in groups)

assert len(set(names)) - by_values + len(groups) == 1  # correct
assert len(names) - by_values + len(groups) == 2  # counts a repeat
assert len(set(names)) - by_indices + len(groups) == 0  # mixed denominators
assert len(names) - by_indices + len(groups) == 1  # right by cancellation

Three names, one identity. Measured over 400 duplicate-free batches, all four spellings agree with the truth; over 400 of the same batches with one repeat injected, only the first does.

One reduced slot can hold unrelated values

Every key builder maps some non-empty input to "", so a reduced count can include a slot holding several strings that have nothing to do with each other. ["", "\u200b", "\u0301\u0302", "bob"] reduces to 2 under search_key, and one of those two is the empty key. Tracked separately in #728.


is_case_fold_stable

is_case_fold_stable

is_case_fold_stable(text: str) -> bool

True if text is a stable identity key under case folding.

Answers fold_case(text) == text.lower(). A False result says some other string folds to the same value, so a table keyed on this one can collide — groß.txt and gross.txt are the pair node-tar collided on (CVE-2026-23950), and ſtraße/straße and file/file are the same shape. Roughly 2,000 code points behave this way, including every Latin ligature, , the micro sign, and all of Cherokee (whose fold direction runs small→capital, so both cases move).

This is a fact about the string, not an accusation. groß is an ordinary German word, so a False here is not a report of an attack and the predicate is deliberately kept out of has_anomalies. What to do about it is the caller's decision: reserve both forms, reject the name, or key the table on fold_case rather than str.lower().

str.lower() is the correct comparison basis and str.casefold() is not: casefolding performs the very transform under test, so a predicate written against it is True everywhere.

Answers about disarm's own folding table (Unicode 16.0), so it also reports False for characters your Python's str.lower() knows about and that table does not — which is a collision hazard for the same reason.

A True result is not a uniqueness guarantee: two distinct stable strings can still collide under some other normalization.

Parameters:
  • text (str) –

    Input string.

Returns:
  • bool

    True if full case folding and simple lowercasing agree on text.

Examples:

>>> is_case_fold_stable("gross.txt")
True
>>> is_case_fold_stable("groß.txt")
False
>>> is_case_fold_stable("ΟΔΟΣ")  # Greek final sigma: οδος vs οδοσ
False
from disarm import is_case_fold_stable

is_case_fold_stable("gross.txt")  # True
is_case_fold_stable("groß.txt")  # False — folds to gross.txt, so the two collide
is_case_fold_stable("file")  # False — folds to file
is_case_fold_stable("ΟΔΟΣ")  # False — lowercases to οδος, folds to οδοσ

Use it before a name becomes a key: a reservation table, a username registry, an extraction path. False says the value shares its folded form with some other string, which is the precondition node-tar's PathReservations guard missed in CVE-2026-23950. It says nothing about intent, since groß is an ordinary German word, so the predicate is kept out of anomaly detection and the response is the caller's to choose: reserve both forms, reject the name, or key the table on fold_case instead of str.lower().


decode_smuggled

decode_smuggled

decode_smuggled(text: str) -> list[SmuggledPayload]

Decode what a smuggled run spells, rather than reporting one is present (#701).

disarm strips the three ASCII-smuggling carriers and inspect_anomalies reports that invisible characters are there. Neither answer tells you the run reads tracked-by:acct-99213.

Presence and decode are different strengths of evidence. An invisible character can arrive by accident — a copy-paste artefact, a BOM, an editor quirk. A run that decodes to readable text cannot: random damage does not spell words. A successful decode needs no threshold and no policy to interpret, which is why it is worth reporting separately from the invisible kind.

Three schemes, all arithmetic on code point values with no table behind them:

scheme carrier encoding
tag_ascii U+E0020U+E007E subtract 0xE0000, one byte each
variation_bytes U+FE00U+FE0F, U+E0100U+E01EF index 0–255
zero_width_binary U+200B = 0, U+200C = 1 MSB first; ZWJ/WJ/BOM separate
percent_escape %XX, two hex digits one byte per triple, decoded once (#727)

text is populated only when the bytes are valid UTF-8 and wholly printable — meaning a reader would see it, not merely "no control character". A payload of U+202E + U+200B is valid UTF-8 with no control in it and renders as nothing, so it comes back as bytes with text=None, as does a run of arbitrary selectors. Reporting garbage would undo the reason a decode is trustworthy.

units counts the characters the run consumed, which is not the same as the carriers that carried a byte: the zero-width scheme counts its ZWJ/WJ/BOM separators and the tag scheme counts a trailing CANCEL TAG.

A well-formed emoji subdivision flag is not a payload: U+1F3F4 + tag letters + U+E007F spelling one of the three RGI values is the Scotland flag, and the allowlist used here is the stripper's own rather than a second copy of it.

percent_escape is the one scheme that is not fed to the anomaly detector. A %XX run spelling readable text is ordinary in any URL, where the three invisible carriers are never ordinary; inspect_anomalies would fire smuggled on every escaped query string. This function reports it — as evidence, decoded once — and the detector does not. %25%32%45 spells %2E, and that text is the sign of double-encoding, not a prompt to decode again.

Parameters:
  • text (str) –

    Input string.

Returns:
  • list[SmuggledPayload]

    One SmuggledPayload per run, in order of appearance.

Examples:

>>> hidden = "".join(chr(ord(c) + 0xE0000) for c in "hi")
>>> found = decode_smuggled(f"hello{hidden}")
>>> found[0].text, found[0].scheme, found[0].start
('hi', 'tag_ascii', 5)
>>> decode_smuggled("hello world")
[]

Presence and decode are different evidence

inspect_anomalies reports that invisible characters are present. It does not say the run reads tracked-by:acct-99213.

from disarm import decode_smuggled, inspect_anomalies

hidden = "".join(chr(ord(c) + 0xE0000) for c in "tracked-by:acct-99213")
payload = decode_smuggled(f"Invoice #4471{hidden}")[0]
payload.text  # 'tracked-by:acct-99213'
payload.scheme  # 'tag_ascii'
payload.units  # 21 carrier characters

inspect_anomalies(f"Invoice #4471{hidden}").kinds  # ['smuggled', 'invisible']

An invisible character can arrive by accident — a copy-paste artefact, a BOM, an editor quirk. A run that decodes to readable text cannot: random damage does not spell words. So a decode is the one signal in this area that needs no threshold and no policy, which is why it is a separate kind rather than a longer invisible detail.

text is populated only when the bytes are valid UTF-8 and wholly printable. A run of arbitrary selectors comes back as a payload of n bytes with text=None rather than as a bogus decode — reporting garbage would undo the reason a decode is trustworthy.

The fourth scheme is for URLs, and stays out of the detector

percent_escape decodes %XX runs (#727). disarm ships percent_encode and shipped no decoder, so every detector reported clean on an encoded value — has_anomalies is True on ad\u200bmin and False on ad%E2%80%8Bmin. This is the inspection half of that:

from disarm import Component, decode_smuggled, has_anomalies, percent_encode

hidden = percent_encode("ad\u200bmin", component=Component.QUERY)  # 'ad%E2%80%8Bmin'
has_anomalies(hidden)  # False — the blindness
[payload] = decode_smuggled(hidden)
payload.data  # b'\xe2\x80\x8b' — a bare ZWSP
payload.text  # None — spelled, but not text

Decoded exactly once: %25%32%45 spells %2E, and that text is the evidence of double-encoding rather than a prompt to decode again. A single %20 is an escaped space, not a payload. And unlike the three invisible carriers, this scheme is not fed to inspect_anomalies: a percent run spelling readable text is ordinary in any URL, and reporting it as smuggled would fire on every escaped query string.

The offsets are bytes

start and end are byte offsets, matching Finding.start/end. Slice text.encode(), not the str — slicing the str works only while everything before the run is ASCII.


is_canonical

is_canonical

is_canonical(text: str, *, preset: str = 'canonicalize') -> bool

True if text is already its own canonical form under preset.

Every other normalization surface in disarm is generation path: text in, normalized text out. This is the verification path counterpart — the question to ask about bytes that arrive already bound to a decision, where quietly re-normalizing defends the comparison you are about to make and leaves the second representation free to keep circulating.

has_anomalies is not this predicate, and the gap is not small. Over every assigned code point, 142,760 of them (5,292 excluding the Private Use Area) are reported clean by the detector and are not their own canonical form — CJK compatibility ideographs, Arabic presentation forms, Kangxi radicals, fullwidth and halfwidth forms. None go the other way. An accept gate written as "reject if has_anomalies, otherwise take the bytes as given" admits every one of them.

That is not a detector bug to be fixed by widening it: NHK is how a Japanese broadcaster writes its own name, and a detector that flags it is one callers turn off (#633, #907). Two questions, two answers — ask this one when you need canonicity, and has_anomalies when you need suspicion.

Equivalent to globals()[preset](text) == text and defined by it, but it does not build the normalized copy to answer a boolean, and nothing crosses the extension boundary but the result.

Parameters:
  • text (str) –

    Input string.

  • preset (str, default: 'canonicalize' ) –

    A preset name (PRESETS) or a policy profile name (list_profiles). Defaults to "canonicalize".

Returns:
  • bool

    True if preset would leave text unchanged.

Raises:
  • DisarmError

    If preset names neither a preset nor a profile.

Examples:

>>> is_canonical("paypal.com")
True
>>> is_canonical("ABC")  # fullwidth — clean to the detector
False
>>> has_anomalies("ABC")  # ...and this is the point
False
>>> is_canonical("abc", preset="search_key")
True
from disarm import canonicalize, has_anomalies, is_canonical

is_canonical("paypal.com")  # True
is_canonical("paypal.com")  # False — fullwidth, canonicalizes to paypal.com
is_canonical("abc", preset="search_key")  # True

Generation path and verification path

The two are different questions and the answers do not substitute for one another.

ask function
Generation path what should I store? canonicalize and the other presets
Verification path may I accept what I was handed? is_canonical

Normalize on the way in and the verification path never comes up, because the stored value is canonical by construction. It comes up wherever bytes arrive already bound to a decision — a signed payload whose signature covers the original bytes, a primary key that already has rows pointing at it, an identifier a third party will re-derive. Silently recomputing the canonical form there defends the comparison you are about to make and leaves the non-canonical representation in circulation, still able to reach a system that compares differently.

has_anomalies does not answer this

The obvious accept gate — reject if has_anomalies, otherwise take the bytes as given — is not a canonicity check. Over every assigned code point (UCD 16.0.0, excluding unassigned and surrogates):

count
clean to the detector and not canonical 142,760 (5,292 excluding the Private Use Area)
flagged by the detector and already canonical 0

The relationship is one-way: the detector never fires on text the canonicalizer would leave alone, but it stays silent on 5,292 non-PUA code points that are not their own canonical form, including CJK compatibility ideographs, Arabic presentation forms, Kangxi radicals and all of fullwidth Latin.

Widening the detector is the wrong fix. NHK is how a Japanese broadcaster writes its own name and is an ordinary unit, so a detector that flagged them is one callers switch off — that is what #633 and #907 were about. has_anomalies answers does this look disguised; is_canonical answers is this already the form I store.


is_normalized

is_normalized

is_normalized(text: str, *, form: NormalizationForm | NF = 'NFC') -> bool

True if text is already in the specified normalization form.

Parameters:
  • text (str) –

    Input string.

  • form (NormalizationForm | NF, default: 'NFC' ) –

    Normalization form — "NFC", "NFD", "NFKC", or "NFKD".

Returns:
  • bool

    True if the string is already normalized.

Examples:

>>> is_normalized("café")  # NFC by default
True
>>> is_normalized("e\u0301", form="NFC")  # NFD decomposed
False

is_zalgo

is_zalgo

is_zalgo(text: str, *, threshold: int = 3) -> bool

Detect whether text contains zalgo-style combining mark abuse.

Returns True if any base character has more than threshold consecutive combining marks in NFD decomposition.

Parameters:
  • text (str) –

    Input string to check.

  • threshold (int, default: 3 ) –

    Maximum allowed combining marks per base character (default: 3). Vietnamese has 2 marks in NFD — the default is safe for all legitimate scripts.

Returns:
  • bool

    True if zalgo-style stacking is detected.

Examples:

>>> is_zalgo("café")
False
>>> is_zalgo("Việt Nam")
False
>>> is_zalgo("ḧ̸̡̢̧̛̗̱̜̼̯̞̙́̑̾̊̿̏̒̓̕ě̵̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕l̸̡̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕l̸̡̢̧̛̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕ơ̵̢̧̗̱̜̼̯̞̙̈́̑̾̊̿̏̒̓̕")
True
from disarm import is_zalgo

is_zalgo("café")  # False (1 combining mark — normal)
is_zalgo("Việt Nam")  # False (2 combining marks — normal)
# Zalgo: 'a' with 20 stacked combining graves
is_zalgo("a" + "\u0300" * 20)  # True

is_suspicious_hostname

Renamed from is_safe_hostname in 0.9.1 — with the boolean inverted

If you are upgrading from is_safe_hostname, the return value's polarity was flipped (safesuspicious); a mechanical rename silently reverses your allow/deny branch. See the Upgrading guide.

is_suspicious_hostname

is_suspicious_hostname(hostname: str, *, contractions: bool = False) -> tuple[bool, HostnameAnalysis]

Flag a hostname as suspicious for Unicode homoglyph spoofing.

Returns (suspicious, analysis) where analysis is a HostnameAnalysis with attributes:

  • suspicious: bool — True if a problem was detected (mixed-script, a bundled-table confusable, or a bidi-direction conflict). Because the confusable check is an any-character screen, this flags essentially every hostname with a non-Latin letter — legitimate (москва.рф) as well as spoofs — so it is a maximally conservative screen, not a precise verdict.
  • scripts: list[str] — Unicode scripts found across all labels.
  • mixed_script: bool — True if any single label contains more than one script.
  • has_confusables: bool — True if confusable homoglyphs found. Read after the UTS #46 mapping and NFKC, so it cannot see a compatibility form by construction: google.com is already google.com by the time this is computed, and False is the correct answer — after mapping there is no confusable left. Seeing canonical differ from the input while this stays False means compat_fold, not a defect.
  • bidi_conflict: bool — True if the decoded hostname mixes strong left-to-right and strong right-to-left characters (the "BiDi Swap" reorder precondition). Folded into suspicious.
  • bidi_control: bool — True if the decoded hostname carries a UAX #9 bidi control character: an override (U+202D/U+202E), embedding (U+202AU+202C), isolate (U+2066U+2069) or directional mark (U+200E/U+200F/U+061C). Disjoint from bidi_conflict, which reads strong-direction letters only and is therefore blind to the RLO extension spoof. IDNA2008 disallows every character in the set, so this is folded into suspicious and the characters are stripped from canonical.
  • has_invisible: bool — True if the decoded hostname carries an invisible character of any class: zero-width (U+200B-U+200D, U+2060-U+2064, U+FEFF, U+180E), tag (U+E0000-U+E007F), variation selector (U+FE00-U+FE0F, U+E0100-U+E01EF), noncharacter (U+FDD0-U+FDEF and the last two of every plane), or private use (U+E000-U+F8FF, planes 15 and 16). Disjoint from bidi_control — these carry no direction at all, so neither bidi field can see them. RFC 5892 puts the tag, variation-selector, noncharacter and private-use classes in DISALLOWED outright, which is what justifies including private use and variation selectors here. U+200C/U+200D are the exception — CONTEXTJ, so conditionally permitted; the screen flags them anyway as a deliberate fail-closed policy. Folded into suspicious. They are removed per label before any other field is computed, so a hostname whose only non-ASCII is an invisible no longer reports a phantom script (U+FEFF sits in the Arabic Presentation Forms block, U+FDD0 in its range).
  • compat_fold: bool — True if any label carried a Unicode compatibility form before normalization: fullwidth (google), ligature (file), Roman numeral (BM), mathematical alphanumeric (𝗀𝗈𝗈𝗀𝗅𝖾), circled, superscript, and the rest of the compatibility repertoire. The predicate is RFC 5892 §2.1's, applied per code point: a character c where toNFKC(c) != c is DISALLOWED in an IDN label, so IDNA2008 disallows the whole set and this is folded into suspicious on the same footing as bidi_control and has_invisible. The threat is a blocklist bypass rather than a lookalike: evil.com is absent from a blocked set, screens clean, and resolves to evil.com. Tested per character rather than "NFKC changed the label", which would fire on decomposed input that is entirely valid (한국.kr written with conjoining jamo). Read per label, not over the whole hostname: three of the four UTS #46 label separators carry a compatibility decomposition (U+FF0E and U+FF61 do, U+3002 does not), and a separator is structure rather than label content. This is the one field read from the raw input — every other field is computed after normalization, which is what makes them work and also what erases this evidence.
  • cross_label_script: bool — True if the labels span more than one distinct script. Broader and noisier than bidi_conflict (it fires on benign IDN ccTLDs like google.рф), so it is not folded into suspicious; exposed for caller policy.
  • label_scripts: list[list[str]] — per-label resolved scripts, left to right.
  • whole_script_confusable: bool — True if any label is a whole-script confusable: single-script, non-Latin, whose confusable skeleton is entirely Latin (e.g. Cyrillic аррӏеapple). A graded signal, not a verdict — on its own it fires on short non-Latin ccTLDs (руpy) and on real words (осаoca), so it is not folded into suspicious.
  • label_whole_script_confusable: list[bool] — per-label flags, parallel to label_scripts, so a caller can exclude the TLD label. The precise, low-false-positive policy is wsc(non-TLD label) and TLD-is-Latin (plus a caller-supplied protected-name list for the irreducible оса-style case).
  • canonical: str — Latin-normalized form of the hostname.

A hostname is flagged suspicious if any single label is mixed-script (draws on more than one Unicode script, excluding Common/Inherited), contains confusable homoglyphs, or has a bidi-direction conflict (bidi_conflict), carries a bidi control character (bidi_control), or carries a zero-width/invisible character (has_invisible), or carries a compatibility form (compat_fold). The mixed-script rule is conservative and fails closed: it flags benign combinations such as Latin+CJK as well as spoofing ones, so a caller wanting a more permissive policy can inspect the mixed_script and scripts fields and decide for itself.

A False (not-suspicious) result is not a safety guarantee. It means only that no mixed-script label and no confusable from the bundled TR39 table was found. Confusables outside the bundled table are not detected and report not-suspicious. Base allow/deny decisions on the granular findings (including whole_script_confusable) plus your own policy — a detector can attest the presence of a problem, never the absence of all problems.

Parameters:
  • hostname (str) –

    Hostname string to check (e.g. "example.com").

  • contractions (bool, default: False ) –

    Also fold ASCII digraphs that can impersonate a single letter — rn to m, vv to w, cl to d — into canonical, so arnazon.com canonicalizes to amazon.com (#562).

    Off by default, and deliberately confined to hostnames. Unconditional contraction is worse than none: rn to m is right for arnazon and wrong for earnings, turnip and born. A hostname is the one place where the threat model justifies those false positives and there is no running prose to corrupt, so this is not reachable from normalize_confusables at all.

    Matching is leftmost-longest, and applied per label, so a digraph can never form across a dot.

Returns:
  • tuple[bool, HostnameAnalysis]

    Tuple of (suspicious, analysis) where analysis is a HostnameAnalysis.

Examples:

>>> suspicious, analysis = is_suspicious_hostname("google.com")
>>> suspicious
False
>>> analysis.canonical
'google.com'
>>> _s, a = is_suspicious_hostname("arnazon.com", contractions=True)
>>> a.canonical
'amazon.com'

HostnameAnalysis

The second element of the tuple returned by is_suspicious_hostname():

Attribute Type Description
suspicious bool True if any label is mixed-script, contains a Latin-confusable character, or the hostname has a bidi-direction conflict, a bidi control character, or a zero-width/invisible character. An any-character confusable screen — it flags essentially every non-Latin hostname, so it is a maximally conservative screen, not a precise verdict
scripts list[str] Unicode scripts found across all labels
mixed_script bool True if any single label contains more than one script
has_confusables bool True if any label contains a Latin-confusable character. Read after the UTS #46 mapping and NFKC, so it cannot see a compatibility form by construction — google.com is already google.com by then, and False is the correct answer. canonical differing from the input while this stays False means compat_fold, not a defect
bidi_conflict bool True if the decoded hostname mixes strong LTR and RTL characters (the "BiDi Swap" precondition); folded into suspicious
bidi_control bool True if the decoded hostname carries a UAX #9 bidi control character — override (U+202D/U+202E), embedding (U+202AU+202C), isolate (U+2066U+2069) or directional mark (U+200E/U+200F/U+061C). Disjoint from bidi_conflict, which reads strong-direction letters only. Folded into suspicious; the characters are stripped from canonical
has_invisible bool True if the decoded hostname carries an invisible character of any class: zero-width (U+200BU+200D, U+2060U+2064, U+FEFF, U+180E), tag (U+E0000U+E007F), variation selector (U+FE00U+FE0F, U+E0100U+E01EF), noncharacter (U+FDD0U+FDEF and the last two of every plane), private use (U+E000U+F8FF, planes 15 and 16). Disjoint from bidi_control: these carry no direction at all. RFC 5892 puts the tag, variation-selector, noncharacter and private-use classes in DISALLOWED outright; U+200C/U+200D are CONTEXTJ (conditionally permitted) and the screen flags them anyway, as a deliberate fail-closed policy. Folded into suspicious, and removed per label before any other field is computed, so they never reach scripts, mixed_script or canonical
compat_fold bool True if any label carried a Unicode compatibility form before normalization (#709) — fullwidth (google), ligature (file), Roman numeral (BM), mathematical alphanumeric (𝗀𝗈𝗈𝗀𝗅𝖾), circled, superscript. The predicate is RFC 5892 §2.1's, applied per code point: toNFKC(c) != c is DISALLOWED in an IDN label, so IDNA2008 disallows the whole set. Folded into suspicious, on the same footing as bidi_control and has_invisible. The threat is a blocklist bypass rather than a lookalike — evil.com is absent from a blocked set, screens clean, and resolves to evil.com. Per character, not "NFKC changed the label", which would fire on legitimate decomposed input (한국.kr in conjoining jamo). The one field read from the raw input
cross_label_script bool True if the labels span more than one script; broader/noisier than bidi_conflict (fires on benign IDN ccTLDs like google.рф), so not folded into suspicious
label_scripts list[list[str]] Per-label resolved scripts, left to right
whole_script_confusable bool True if any label is single-script, non-Latin, whose confusable skeleton is entirely Latin (аррӏеapple). A graded signal, not a verdictnot folded into suspicious (fires on руpy, осаoca)
label_whole_script_confusable list[bool] Per-label whole-script-confusable flags, parallel to label_scripts (exclude the TLD label for the precise policy)
canonical str Latin-normalized form of the hostname
from disarm import is_suspicious_hostname

suspicious, analysis = is_suspicious_hostname("google.com")
# suspicious = False, analysis.canonical = "google.com"

suspicious, analysis = is_suspicious_hostname("gооgle.com")  # Cyrillic о's
# suspicious = True, analysis.mixed_script = True, analysis.has_confusables = True

# Whole-script spoof: an all-Cyrillic label whose skeleton is Latin
suspicious, analysis = is_suspicious_hostname("аррӏе.com")
# analysis.whole_script_confusable = True
# analysis.label_whole_script_confusable = [True, False]  # spoof label, then the TLD
# analysis.canonical = "apple.com"

suspicious is a maximally conservative screen: because the confusable check is an any-character test and the most frequent Cyrillic/Greek letters are TR39 confusables, it flags essentially every non-Latin hostname — москва.рф as readily as аррӏе.com. A not-suspicious result is not a safety guarantee, and a suspicious one is not a precise verdict. For whole-script spoofs, use whole_script_confusable / label_whole_script_confusable: the precise, low-false-positive policy is whole_script_confusable(non-TLD label) ∧ (TLD is Latin/ASCII), applied by the caller — disarm deliberately does not model registrable boundaries (no PSL), and the irreducible оса-style case (a real word that skeletons to Latin) needs a caller-supplied protected-name list. See the Threat Model.