Confusables
Check confusable characters
Paste text to fold homoglyphs toward Latin under both of disarm's digit policies at once. They are not two spellings of one answer — where they disagree, one reading is a number and the other is a word, and which you want depends on what you are about to do with it.
The tool
Three lines, each making a different point. The first spells a brand with
Cyrillic letters, which fold the same way under either policy because no digits
are involved. The second hides two Devanagari zeros in another brand, where the
policies disagree and one reading is the brand being imitated. The third is the
trio from disarm’s own documentation —
० ೦ ١ — which
TR39 folds to three different letters, o, O and
l, where the numeric policy gives 0, 0
and 1.
Paste text to fold it.
Nothing is uploaded. The engine runs inside this page.
Which characters are impostors
Your text again, with every confusable character called out. This is the part
a folded result cannot show you: раураl
and paypal are drawn identically, so an output reading
“paypal” looks the same whether anything was wrong or not.
Numeric policy
A non-Latin digit becomes the ASCII digit. What prose means.
TR39 policy
A non-Latin digit becomes a Latin letter. What a skeleton needs.
Loading the engine…
A worked example
The tool above needs JavaScript. This is the same folding written out, so it is legible without running anything.
| Input | Numeric | TR39 | Agree? |
|---|---|---|---|
| g००gle | g00gle | no | |
| раураl | paypal | paypal | yes |
| ०೦١ | 001 | oOl | no |
| paypal | paypal | paypal | yes — nothing to fold |
The first row is the argument for having two policies. Under the numeric policy
g००gle becomes g00gle, which reads as a
string with two zeros in it. Under TR39 it becomes google, which
collides with the brand being imitated. If you are storing the text, the first
is right. If you are asking whether someone is impersonating a domain, only the
second answers the question.
The second row involves no digits, so both policies agree: five Cyrillic letters fold to their Latin lookalikes either way.
The same thing in your own code
Each block is a file CI compiles and runs, so none can quietly stop working, and all seven print the same line. disarm on GitHub →
# Fold confusables under both digit policies. They answer different questions.
# pip install disarm
from disarm import normalize_confusables
# A brand spelled with two Devanagari zeros standing in for the letter o.
SPOOF = "g००gle"
numeric = normalize_confusables(SPOOF) # the default
tr39 = normalize_confusables(SPOOF, digit_policy="tr39")
# Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
# to a letter, which is what makes a spoof collide with the brand it imitates.
assert numeric == "g00gle", numeric
assert tr39 == "google", tr39
assert numeric != tr39, "the policies must disagree here"
print(f'ok: numeric gives "{numeric}", tr39 gives "{tr39}"')
// Fold confusables under both digit policies. They answer different questions.
// cargo add disarm
use disarm::api::{normalize_confusables_with, DigitPolicy, TargetScript};
fn main() {
// A brand spelled with two Devanagari zeros standing in for the letter o.
let spoof = "g\u{0966}\u{0966}gle";
let numeric = normalize_confusables_with(spoof, TargetScript::Latin, DigitPolicy::Numeric);
let tr39 = normalize_confusables_with(spoof, TargetScript::Latin, DigitPolicy::Tr39);
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds
// it to a letter, which is what makes a spoof collide with the brand.
assert_eq!(numeric, "g00gle");
assert_eq!(tr39, "google");
assert_ne!(numeric, tr39, "the policies must disagree here");
println!(r#"ok: numeric gives "{numeric}", tr39 gives "{tr39}""#);
}
// Fold confusables under both digit policies. They answer different questions.
// npm i disarm
const assert = require("node:assert/strict");
const { normalizeConfusables } = require("disarm");
// A brand spelled with two Devanagari zeros standing in for the letter o.
const SPOOF = "g००gle";
const numeric = normalizeConfusables(SPOOF); // the default
const tr39 = normalizeConfusables(SPOOF, { digitPolicy: "tr39" });
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
// to a letter, which is what makes a spoof collide with the brand it imitates.
assert.equal(numeric, "g00gle");
assert.equal(tr39, "google");
assert.notEqual(numeric, tr39, "the policies must disagree here");
console.log(`ok: numeric gives "${numeric}", tr39 gives "${tr39}"`);
# Fold confusables under both digit policies. They answer different questions.
# gem install disarm
require "disarm"
# A brand spelled with two Devanagari zeros standing in for the letter o.
SPOOF = "g००gle"
numeric = Disarm.normalize_confusables(SPOOF) # the default
tr39 = Disarm.normalize_confusables(SPOOF, digit_policy: :tr39)
# Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
# to a letter, which is what makes a spoof collide with the brand it imitates.
raise "expected g00gle, got #{numeric}" unless numeric == "g00gle"
raise "expected google, got #{tr39}" unless tr39 == "google"
raise "the policies must disagree here" if numeric == tr39
puts %(ok: numeric gives "#{numeric}", tr39 gives "#{tr39}")
// Fold confusables under both digit policies. They answer different questions.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import dev.disarm.DigitPolicy;
import dev.disarm.TargetScript;
public class FoldConfusables {
public static void main(String[] args) {
// A brand spelled with two Devanagari zeros standing in for the letter o.
String spoof = "g००gle";
String numeric = Disarm.normalizeConfusables(spoof, TargetScript.LATIN, DigitPolicy.NUMERIC);
String tr39 = Disarm.normalizeConfusables(spoof, TargetScript.LATIN, DigitPolicy.TR39);
// Numeric keeps a digit a digit, which is what stored text means. TR39
// folds it to a letter, which is what makes a spoof collide with the brand.
if (!numeric.equals("g00gle")) throw new AssertionError("expected g00gle, got " + numeric);
if (!tr39.equals("google")) throw new AssertionError("expected google, got " + tr39);
if (numeric.equals(tr39)) throw new AssertionError("the policies must disagree here");
System.out.printf("ok: numeric gives \"%s\", tr39 gives \"%s\"%n", numeric, tr39);
}
}
// Fold confusables under both digit policies. They answer different questions.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.DigitPolicy
import dev.disarm.TargetScript
import dev.disarm.kotlin.*
fun main() {
// A brand spelled with two Devanagari zeros standing in for the letter o.
val spoof = "g००gle"
val numeric = spoof.normalizeConfusables(TargetScript.LATIN, DigitPolicy.NUMERIC)
val tr39 = spoof.normalizeConfusables(TargetScript.LATIN, DigitPolicy.TR39)
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds
// it to a letter, which is what makes a spoof collide with the brand.
check(numeric == "g00gle") { "expected g00gle, got $numeric" }
check(tr39 == "google") { "expected google, got $tr39" }
check(numeric != tr39) { "the policies must disagree here" }
println("""ok: numeric gives "$numeric", tr39 gives "$tr39"""")
}
/* Fold confusables under both digit policies. They answer different questions.
*
* The C ABI is not published to any registry, so this links against a cdylib
* built from bindings/cabi in the disarm repository. Unlike the strip functions,
* the confusable entry points are fallible: they return a DisarmResult_t with
* exactly one of value / error set, and the caller frees whichever it is.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
int main(void) {
/* A brand spelled with two Devanagari zeros standing in for the letter o. */
const char *spoof = "g\U00000966\U00000966gle";
DisarmResult_t n = disarm_normalize_confusables_opts(spoof, "latin", "numeric");
DisarmResult_t t = disarm_normalize_confusables_opts(spoof, "latin", "tr39");
if (n.error || t.error) {
fprintf(stderr, "disarm: %s\n", n.error ? n.error : t.error);
return 1;
}
/* Numeric keeps a digit a digit, which is what stored text means. TR39 folds
* it to a letter, which is what makes a spoof collide with the brand. */
if (strcmp(n.value, "g00gle") != 0) { fprintf(stderr, "expected g00gle, got %s\n", n.value); return 1; }
if (strcmp(t.value, "google") != 0) { fprintf(stderr, "expected google, got %s\n", t.value); return 1; }
if (strcmp(n.value, t.value) == 0) { fprintf(stderr, "the policies must disagree here\n"); return 1; }
printf("ok: numeric gives \"%s\", tr39 gives \"%s\"\n", n.value, t.value);
disarm_string_free(n.value);
disarm_string_free(t.value);
return 0;
}
Choosing a policy
| You are | Use | Because |
|---|---|---|
| Cleaning text you will store or display | numeric | A digit keeps its value. Folding ० to o would turn a quantity into a word. |
| Comparing usernames or identifiers | TR39 | A skeleton only has to collide. Whether it reads sensibly is not the question being asked. |
| Checking a domain against a brand | TR39 | The spoof must land on the same skeleton as the thing it imitates. |
| Building a search index | numeric | Query and document should agree, and a user typing a digit means a digit. |
| Comparing against a published TR39 benchmark | TR39 | It is the upstream mapping; anything else will differ from the reference by design. |
Folding is one control, not the whole answer. It catches cross-script substitution, where a Latin word borrows a Cyrillic letter. It does not by itself separate a label written entirely in Cyrillic that skeletons to a Latin brand from a legitimate Russian word — that needs a mixed-script or whole-script check, which disarm reports separately.