@pipeworx/mcp-edge-case-museum 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -12
- package/bin/cli.js +55 -11
- package/lib/index.js +333 -0
- package/package.json +17 -16
- package/server.json +1 -1
- package/tools.json +98 -0
- package/src/bake.mjs +0 -112
- package/src/ecm-index-data.ts +0 -15
- package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
- package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
- package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
- package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
- package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
- package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
- package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
- package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
- package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
- package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
- package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
- package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
- package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
- package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
- package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
- package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
- package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
- package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
- package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
- package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
- package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
- package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
- package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
- package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
- package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
- package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
- package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
- package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
- package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
- package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
- package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
- package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
- package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
- package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
- package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
- package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
- package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
- package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
- package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
- package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
- package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
- package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
- package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
- package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
- package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
- package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
- package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
- package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
- package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
- package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
- package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
- package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
- package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
- package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
- package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
- package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
- package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
- package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
- package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
- package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
- package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
- package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
- package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
- package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
- package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
- package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
- package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
- package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
- package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
- package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
- package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
- package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
- package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
- package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
- package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
- package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
- package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
- package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
- package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
- package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
- package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
- package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
- package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
- package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
- package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
- package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
- package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
- package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
- package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
- package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
- package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
- package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
- package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
- package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
- package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
- package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
- package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
- package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
- package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
- package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
- package/src/exhibits/sorting/nan-in-sort.json +0 -59
- package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
- package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
- package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
- package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
- package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
- package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
- package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
- package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
- package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
- package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
- package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
- package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
- package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
- package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
- package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
- package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
- package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
- package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
- package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
- package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
- package/src/exhibits/unicode/zero-width-space-username.json +0 -62
- package/src/harness.mjs +0 -331
- package/src/index.ts +0 -686
- package/src/server.ts +0 -49
- package/src/verified.json +0 -977
- package/tsconfig.json +0 -18
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "cyrillic-homoglyph-paypal-spoof",
|
|
3
|
-
"title": "\"paypal\" with one Cyrillic letter looks identical and NFKC does not catch it",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "Two strings that render identically are either literally equal, or become equal after Unicode normalization.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"payp\\u0430l\" (Cyrillic \\u0430, CYRILLIC SMALL LETTER A, in place of the fourth letter) vs \"paypal\"",
|
|
8
|
-
"description": "An impersonation username, support-ticket display name, or phishing domain label built by swapping one Latin letter for a visually identical letter from another script."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "The spoofed string has the same length as the real word and looks the same in most fonts, but == and a database UNIQUE constraint treat it as a different value, and NFKC normalization leaves it unchanged, so it does not merge with the real spelling.",
|
|
11
|
-
"why": "Unicode gives Cyrillic \\u0430 and Latin \"a\" separate code points because they are different letters in different scripts, even though most fonts draw them the same; UTS #39's confusables data lists exactly this kind of pair. Normalization (NFC, NFKC) only replaces a character with an equivalent that Unicode's decomposition data declares, such as a ligature, a fullwidth form or a superscript; there is no such mapping from Cyrillic \\u0430 to Latin a, so NFKC leaves the lookalike in place. UTS #39 provides the checks that catch it: comparing \"skeletons\" built from confusables.txt (which maps \\u0430 to a), or mixed-script detection, which flags a single word that mixes Latin and Cyrillic letters.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import unicodedata\na = \"paypal\"\nb = \"payp\\u0430l\"\nprint(a == b, len(a), len(b))\nprint(unicodedata.normalize(\"NFKC\", a) == unicodedata.normalize(\"NFKC\", b))\nprint([hex(ord(c)) for c in b])\n",
|
|
20
|
-
"expected_output": "False 6 6\nFalse\n['0x70', '0x61', '0x79', '0x70', '0x430', '0x6c']",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const a = \"paypal\";\nconst b = \"payp\\u0430l\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(a.normalize(\"NFKC\") === b.normalize(\"NFKC\"));\n",
|
|
26
|
-
"expected_output": "false 6 6\nfalse",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "For security-sensitive comparisons (brand names, support impersonation, domain labels), run Unicode's confusables skeleton algorithm (UTS #39) in addition to normalization, or restrict the field to a single script/ASCII where the context allows it, rather than trusting that normalization catches lookalikes.",
|
|
31
|
-
"related": [
|
|
32
|
-
"zero-width-space-username",
|
|
33
|
-
"fullwidth-bypasses-keyword-filter"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "UTS #39: Unicode Security Mechanisms, Confusable Detection",
|
|
38
|
-
"url": "https://www.unicode.org/reports/tr39/#Confusable_Detection"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "UTS #39: Mixed-Script Detection",
|
|
42
|
-
"url": "https://www.unicode.org/reports/tr39/#Mixed_Script_Detection"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "confusables.txt",
|
|
46
|
-
"url": "https://www.unicode.org/Public/security/latest/confusables.txt"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"username",
|
|
51
|
-
"uniqueness",
|
|
52
|
-
"security",
|
|
53
|
-
"login",
|
|
54
|
-
"validation",
|
|
55
|
-
"i18n"
|
|
56
|
-
],
|
|
57
|
-
"test_cases": [
|
|
58
|
-
{
|
|
59
|
-
"inputs": [
|
|
60
|
-
"paypal",
|
|
61
|
-
"paypаl"
|
|
62
|
-
],
|
|
63
|
-
"relation": "equal",
|
|
64
|
-
"note": "A confusable-aware key for impersonation checks (for example the UTS #39 skeleton) should map both strings to the same key. Plain equality and NFKC both say they differ, which is the gap."
|
|
65
|
-
}
|
|
66
|
-
]
|
|
67
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "fullwidth-bypasses-keyword-filter",
|
|
3
|
-
"title": "A fullwidth \"select\" slips past a keyword filter, then reappears as literal \"select\" after normalization",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "If a blocklist check on the raw input says a dangerous keyword isn't present, it isn't present anywhere downstream either.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\" (FULLWIDTH LATIN letters spelling \"select\")",
|
|
8
|
-
"description": "User input containing a blocked keyword spelled with fullwidth Unicode letters (common in East Asian input methods, and a known WAF/filter-bypass technique), which is later normalized for display or by a downstream system/collation before being used."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "\"select\" in payload.lower() is False on the raw fullwidth string, so a naive keyword blocklist lets it through. Once the same string is NFKC-normalized (for display, by a second validation layer, or by a downstream service), it becomes the literal ASCII word \"select\", and the keyword the filter was supposed to catch is present after all. Some database collations also compare fullwidth and ASCII letters as equal without changing the stored text.",
|
|
11
|
-
"why": "FULLWIDTH LATIN SMALL LETTER <X> code points (U+FF41-U+FF5A) are compatibility characters: Unicode's own decomposition mapping, used by NFKC, defines each one as equivalent to its ordinary ASCII letter, specifically so systems that care about the abstract text (rather than its East-Asian-typesetting width) can fold them together. A substring/equality check on the un-normalized string sees entirely different code points and finds no match; the moment anything in the pipeline normalizes before the keyword check runs, the two representations become the same string. This is why normalize-then-validate is safe and validate-then-normalize is not — order matters.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import unicodedata\npayload = \"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\"\nprint(\"select\" in payload.lower())\nnormalized = unicodedata.normalize(\"NFKC\", payload)\nprint(normalized, \"select\" in normalized.lower())\n",
|
|
20
|
-
"expected_output": "False\nselect True",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const payload = \"\\uff53\\uff45\\uff4c\\uff45\\uff43\\uff54\";\nconsole.log(payload.toLowerCase().includes(\"select\"));\nconst normalized = payload.normalize(\"NFKC\");\nconsole.log(normalized, normalized.toLowerCase().includes(\"select\"));\n",
|
|
26
|
-
"expected_output": "false\nselect true",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Normalize (NFKC) untrusted text BEFORE running any keyword, blocklist, or validation check against it, never after. If anything later in the pipeline normalizes or compares width-insensitively (a display layer, another service, a database collation), the filter in front of it must assume the same folding. Keyword blocklists are a weak defence against injection anyway; parameterized queries are the real fix for SQL.",
|
|
31
|
-
"related": [
|
|
32
|
-
"nfkc-compatibility-folding",
|
|
33
|
-
"cyrillic-homoglyph-paypal-spoof"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "Unicode Standard Annex #15: Unicode Normalization Forms (NFKC)",
|
|
38
|
-
"url": "https://www.unicode.org/reports/tr15/"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "Unicode Technical Standard #39: Unicode Security Mechanisms",
|
|
42
|
-
"url": "https://www.unicode.org/reports/tr39/"
|
|
43
|
-
}
|
|
44
|
-
],
|
|
45
|
-
"tags": [
|
|
46
|
-
"security",
|
|
47
|
-
"validation",
|
|
48
|
-
"normalization",
|
|
49
|
-
"parsing",
|
|
50
|
-
"forms"
|
|
51
|
-
],
|
|
52
|
-
"test_cases": [
|
|
53
|
-
{
|
|
54
|
-
"inputs": [
|
|
55
|
-
"select"
|
|
56
|
-
],
|
|
57
|
-
"relation": "no_crash",
|
|
58
|
-
"note": "A keyword blocklist must reject this string if it would reject \"select\" after any normalization step anywhere later in the system — check both forms, or normalize first."
|
|
59
|
-
}
|
|
60
|
-
]
|
|
61
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "german-eszett-case-fold",
|
|
3
|
-
"title": "\"STRASSE\".lower() is not \"straße\" — uppercasing and lowercasing aren't inverses",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "If a.upper() == b, then a == b.lower().",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"stra\\u00dfe\" (German for \"street\", with ß U+00DF) vs \"STRASSE\"",
|
|
8
|
-
"description": "A German street name or surname containing ß, compared against an all-caps form from a passport, an old database export, or a form that forces uppercase input."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "\"stra\\u00dfe\".upper() correctly equals \"STRASSE\", but \"STRASSE\".lower() comes back as \"strasse\", not \"stra\\u00dfe\" — so the round trip breaks and a plain .lower()-keyed uniqueness check treats the two spellings as different strings.",
|
|
11
|
-
"why": "Unicode's default full uppercase mapping of ß is the two letters \"SS\" (a capital ẞ, U+1E9E, exists but is not the default mapping), which is why \"stra\\u00dfe\".upper() == \"STRASSE\" and why this case operation changes string length. The mapping only goes one way: lowercasing \"SS\" gives \"ss\", because plenty of words have a real double s. Python's str.casefold() (added in Python 3.3) applies Unicode case folding, which maps ß to \"ss\", so casefold() == casefold() succeeds where lower() == lower() fails. JavaScript has no casefold(); toLowerCase() and toUpperCase() show the same asymmetry, and the built-in caseless comparison is Intl.Collator or localeCompare with sensitivity \"base\", which treats \"straße\" and \"STRASSE\" as equal.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "a = \"STRASSE\"\nb = \"stra\\u00dfe\"\nprint(b.upper() == a, a.lower() == b)\nprint(a.casefold() == b.casefold())\nprint(\"\\u00df\".upper(), len(\"\\u00df\".upper()))\n",
|
|
20
|
-
"expected_output": "True False\nTrue\nSS 2",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const a = \"STRASSE\";\nconst b = \"stra\\u00dfe\";\nconsole.log(b.toUpperCase() === a, a.toLowerCase() === b);\nconsole.log(\"\\u00df\".toUpperCase(), \"\\u00df\".toUpperCase().length);\n",
|
|
26
|
-
"expected_output": "true false\nSS 2",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "For caseless comparison, use Python's str.casefold() rather than str.lower(). In JavaScript, compare with new Intl.Collator(undefined, { sensitivity: \"base\" }).compare(a, b) === 0 (note that \"base\" also ignores accents), or normalize ß to \"ss\" explicitly before comparing. For stored uniqueness keys, also normalize (NFKC or NFC) before folding.",
|
|
31
|
-
"related": [
|
|
32
|
-
"turkish-dotless-i-case-mapping",
|
|
33
|
-
"greek-final-sigma-casefold"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "Unicode Standard: SpecialCasing.txt (ß → SS uppercase mapping)",
|
|
38
|
-
"url": "https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "Python docs: str.casefold",
|
|
42
|
-
"url": "https://docs.python.org/3/library/stdtypes.html#str.casefold"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Unicode Technical Standard #10 / TR21: Case Mappings",
|
|
46
|
-
"url": "https://www.unicode.org/notes/tn21/"
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
"title": "MDN: Intl.Collator() constructor, sensitivity option",
|
|
50
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator"
|
|
51
|
-
}
|
|
52
|
-
],
|
|
53
|
-
"tags": [
|
|
54
|
-
"username",
|
|
55
|
-
"uniqueness",
|
|
56
|
-
"login",
|
|
57
|
-
"addresses",
|
|
58
|
-
"i18n",
|
|
59
|
-
"validation",
|
|
60
|
-
"comparison"
|
|
61
|
-
],
|
|
62
|
-
"test_cases": [
|
|
63
|
-
{
|
|
64
|
-
"inputs": [
|
|
65
|
-
"STRASSE",
|
|
66
|
-
"straße"
|
|
67
|
-
],
|
|
68
|
-
"relation": "equal",
|
|
69
|
-
"note": "A caseless-match key should map both to the same value (Python's casefold() does; lower() does not)."
|
|
70
|
-
}
|
|
71
|
-
]
|
|
72
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "grapheme-vs-codepoint-length",
|
|
3
|
-
"title": "One family emoji, three different \"lengths\": 1, 5, and 8",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "A string's length property tells you how many characters a person sees on screen.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"\\u{1F468}\\u200D\\u{1F469}\\u200D\\u{1F467}\" (man + ZWJ + woman + ZWJ + girl = one family emoji)",
|
|
8
|
-
"description": "Any emoji picker's \"family\"/\"couple with heart\"/skin-tone-modified emoji, typed into a bio, nickname, or chat message with a character-count limit."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "The single glyph a user sees as ONE character reports length 8 in JavaScript's .length, 5 via JavaScript's [...s].length or Python's len(), and 1 via JavaScript's Intl.Segmenter grapheme count. A \"max 10 characters\" bio limit enforced with .length rejects two family emoji (16 code units) that the user sees as two characters, and truncating by any of the non-grapheme measures can cut the glyph apart (see the truncation exhibits).",
|
|
11
|
-
"why": "JavaScript strings are sequences of UTF-16 code units, and .length counts those units; each of the three emoji here is outside the Basic Multilingual Plane, so it needs a surrogate pair (2 code units), giving 3×2 + 2 ZWJ = 8. Python strings are sequences of Unicode code points, and len() counts those; the same string is 3 emoji + 2 ZWJ = 5 code points (the spread operator [...s] in JavaScript iterates by code point too, also giving 5). But a person perceives the whole ZWJ-joined sequence as a single \"grapheme cluster\" — exactly one visual unit — which is what JavaScript's Intl.Segmenter({granularity: \"grapheme\"}) counts, and what Python's standard library has no built-in equivalent for.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "s = \"\\U0001F468\\u200D\\U0001F469\\u200D\\U0001F467\"\nprint(len(s))\n",
|
|
20
|
-
"expected_output": "5",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const s = \"\\u{1F468}\\u200D\\u{1F469}\\u200D\\u{1F467}\";\nconsole.log(s.length);\nconsole.log([...s].length);\nconst seg = new Intl.Segmenter(\"en\", { granularity: \"grapheme\" });\nconsole.log([...seg.segment(s)].length);\n",
|
|
26
|
-
"expected_output": "8\n5\n1",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Enforce user-facing length limits with a grapheme-cluster counter, not raw .length/len(). In JavaScript use Intl.Segmenter (built in, no dependency); Python's standard library has no grapheme-cluster segmenter, so either accept code-point length as an approximation (it undercounts far less often than UTF-16 length) or add a UAX #29-compliant library.",
|
|
31
|
-
"related": [
|
|
32
|
-
"truncation-splits-surrogate-pair",
|
|
33
|
-
"zalgo-combining-mark-stacking"
|
|
34
|
-
],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "Unicode Standard Annex #29: Unicode Text Segmentation (grapheme clusters)",
|
|
38
|
-
"url": "https://www.unicode.org/reports/tr29/"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "MDN: Intl.Segmenter",
|
|
42
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "Unicode Emoji ZWJ Sequences",
|
|
46
|
-
"url": "https://www.unicode.org/Public/emoji/latest/emoji-zwj-sequences.txt"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"length validation",
|
|
51
|
-
"forms",
|
|
52
|
-
"display",
|
|
53
|
-
"truncation",
|
|
54
|
-
"i18n",
|
|
55
|
-
"validation"
|
|
56
|
-
]
|
|
57
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "greek-final-sigma-casefold",
|
|
3
|
-
"title": "lower() and casefold() disagree on the same Greek word",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "casefold() is just a more aggressive lower(), so it can only make more strings equal, never fewer, and never disagree with lower() about how to spell an individual word.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\" (Greek \"ODYSSEUS\", all-capital sigma \\u03a3)",
|
|
8
|
-
"description": "Comparing a Greek name typed in all caps against its lowercase spelling, or canonicalizing Greek text for a search index."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "word.lower() produces \"\\u03bf\\u03b4\\u03c5\\u03c3\\u03c3\\u03b5\\u03c5\\u03c2\" (ending in final sigma \\u03c2, the form Greek orthography requires at the end of a word), but word.casefold() produces \"\\u03bf\\u03b4\\u03c5\\u03c3\\u03c3\\u03b5\\u03c5\\u03c3\" (ending in plain sigma \\u03c3) — two different strings from the exact same input, so lower() == casefold() is False for this word.",
|
|
11
|
-
"why": "Greek has two lowercase forms of sigma: \\u03c3 inside a word and \\u03c2 at the end of a word. Python's str.lower() applies the Final_Sigma rule from SpecialCasing.txt, a context condition (the sigma follows a cased letter and is not followed by one) that picks \\u03c2 for the last letter here. str.casefold() exists for caseless MATCHING and uses CaseFolding.txt, which maps \\u03a3, \\u03c3 and \\u03c2 all to \\u03c3 with no context, so that words differing only in sigma form compare equal. JavaScript has no casefold(), so it has no built-in way to show this disagreement; its toLowerCase() applies the same Final_Sigma rule and matches Python's lower().",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python"
|
|
14
|
-
],
|
|
15
|
-
"repros": [
|
|
16
|
-
{
|
|
17
|
-
"language": "python",
|
|
18
|
-
"code": "word = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\"\nprint(word.lower())\nprint(word.casefold())\nprint(word.lower() == word.casefold())\n",
|
|
19
|
-
"expected_output": "οδυσσευς\nοδυσσευσ\nFalse",
|
|
20
|
-
"verified_with": "Python 3.14.2"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"language": "javascript",
|
|
24
|
-
"code": "const word = \"\\u039f\\u0394\\u03a5\\u03a3\\u03a3\\u0395\\u03a5\\u03a3\";\nconsole.log(word.toLowerCase());\n",
|
|
25
|
-
"expected_output": "οδυσσευς",
|
|
26
|
-
"verified_with": "Node.js v22.22.2"
|
|
27
|
-
}
|
|
28
|
-
],
|
|
29
|
-
"fix": "Do not assume casefold() is a strict superset of lower()'s equalities, or that it preserves orthographic rules — it is only guaranteed to make caseless comparison consistent, not to produce \"correctly spelled\" lowercase text. For display, use lower(); for comparison, use casefold(); never mix the two results together or assert they're interchangeable.",
|
|
30
|
-
"related": [
|
|
31
|
-
"german-eszett-case-fold"
|
|
32
|
-
],
|
|
33
|
-
"sources": [
|
|
34
|
-
{
|
|
35
|
-
"title": "Unicode Standard: SpecialCasing.txt (Greek final sigma rule)",
|
|
36
|
-
"url": "https://www.unicode.org/Public/UCD/latest/ucd/SpecialCasing.txt"
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"title": "Unicode Standard: CaseFolding.txt",
|
|
40
|
-
"url": "https://www.unicode.org/Public/UCD/latest/ucd/CaseFolding.txt"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"title": "Python docs: str.casefold",
|
|
44
|
-
"url": "https://docs.python.org/3/library/stdtypes.html#str.casefold"
|
|
45
|
-
}
|
|
46
|
-
],
|
|
47
|
-
"tags": [
|
|
48
|
-
"comparison",
|
|
49
|
-
"sorting",
|
|
50
|
-
"i18n",
|
|
51
|
-
"normalization",
|
|
52
|
-
"search"
|
|
53
|
-
]
|
|
54
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "js-word-regex-ascii-only",
|
|
3
|
-
"title": "The same regex \\w+ accepts \"café\" in Python and rejects it in JavaScript",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "\\w in a regex means \"letters\", in whichever language you're running it, maybe with an extra flag for Unicode.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "/^\\w+$/ applied to \"caf\\u00e9\" and to \"\\u65e5\\u672c\\u8a9e\" (Japanese for \"Japanese language\")",
|
|
8
|
-
"description": "A \"letters and numbers only\" validator applied to a name field, reused as-is between a Python backend and a JavaScript frontend."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "Python's re.fullmatch(r\"\\w+\", \"caf\\u00e9\") matches, and even matches \"\\u65e5\\u672c\\u8a9e\". The textually identical /^\\w+$/ in JavaScript rejects both — and adding the /u (Unicode) flag, which people reach for specifically to fix Unicode regex problems, does not change the result at all.",
|
|
11
|
-
"why": "In JavaScript, \\w is defined by the ECMAScript spec as the ASCII class [A-Za-z0-9_]. The u flag does not turn it into a Unicode letter class; the only change is that with both u and i set it also matches U+017F (long s) and U+212A (Kelvin sign), because they case-fold to s and k. To match non-ASCII letters in JavaScript you need a Unicode property escape such as \\p{L}, which requires the u (or v) flag. Python's re module makes \\w Unicode-aware by default for str patterns (letters, digits and underscore in the Unicode sense) unless you pass re.ASCII. Same regex syntax, opposite default behaviour.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import re\nprint(bool(re.fullmatch(r\"\\w+\", \"caf\\u00e9\")))\nprint(bool(re.fullmatch(r\"\\w+\", \"\\u65e5\\u672c\\u8a9e\")))\nprint(bool(re.fullmatch(r\"\\w+\", \"caf\\u00e9\", re.ASCII)))\n",
|
|
20
|
-
"expected_output": "True\nTrue\nFalse",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const name = \"caf\\u00e9\";\nconsole.log(/^\\w+$/.test(name), /^\\w+$/u.test(name));\nconsole.log(/^[\\p{L}]+$/u.test(name));\nconst name2 = \"\\u65e5\\u672c\\u8a9e\";\nconsole.log(/^\\w+$/.test(name2), /^[\\p{L}]+$/u.test(name2));\n",
|
|
26
|
-
"expected_output": "false false\ntrue\nfalse true",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Never port a \\w-based regex between Python and JavaScript expecting matching behavior. In JavaScript, use /^[\\p{L}\\p{N}_]+$/u for a Unicode-aware \"word characters\" check; in Python, pass re.ASCII explicitly if you actually want \\w restricted to [A-Za-z0-9_].",
|
|
31
|
-
"related": [
|
|
32
|
-
"arabic-indic-digit-parses"
|
|
33
|
-
],
|
|
34
|
-
"sources": [
|
|
35
|
-
{
|
|
36
|
-
"title": "ECMA-262: RegExp \\w class definition (CharacterClassEscape)",
|
|
37
|
-
"url": "https://tc39.es/ecma262/#sec-characterclassescape"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
"title": "MDN: Unicode property escapes",
|
|
41
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"title": "Python docs: re module, \\w and re.ASCII",
|
|
45
|
-
"url": "https://docs.python.org/3/library/re.html#re.ASCII"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"tags": [
|
|
49
|
-
"validation",
|
|
50
|
-
"forms",
|
|
51
|
-
"parsing",
|
|
52
|
-
"names",
|
|
53
|
-
"i18n"
|
|
54
|
-
],
|
|
55
|
-
"test_cases": [
|
|
56
|
-
{
|
|
57
|
-
"inputs": [
|
|
58
|
-
"café"
|
|
59
|
-
],
|
|
60
|
-
"relation": "no_crash",
|
|
61
|
-
"note": "A \"letters and numbers only\" validator ported between a Python backend and a JS frontend must not accept this on one side and reject it on the other."
|
|
62
|
-
}
|
|
63
|
-
]
|
|
64
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "nfc-nfd-same-name-two-accounts",
|
|
3
|
-
"title": "Two usernames that look identical, compare unequal, and both register",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "If two strings render the same, they are the same string.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"Jos\\u00e9\" (precomposed é, 4 code points) vs \"Jose\\u0301\" (e + combining acute, 5 code points)",
|
|
8
|
-
"description": "The same visible name typed on two different systems: macOS file APIs and some keyboards produce the decomposed form, most web forms produce the precomposed form."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "A uniqueness check with == or a plain UNIQUE index lets both through, so two accounts share one visible name.",
|
|
11
|
-
"why": "Unicode allows the same abstract character to be encoded either as one precomposed code point (U+00E9) or as a base letter plus a combining mark (U+0065 U+0301). The two sequences are canonically equivalent, so every renderer draws them identically, but their code points differ and every byte- or code-point comparison says they are different. Normalizing both sides to the same form (NFC or NFD) before comparing or storing makes them equal.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript",
|
|
15
|
-
"postgres",
|
|
16
|
-
"mysql",
|
|
17
|
-
"any byte-wise comparison"
|
|
18
|
-
],
|
|
19
|
-
"repros": [
|
|
20
|
-
{
|
|
21
|
-
"language": "python",
|
|
22
|
-
"code": "import unicodedata\na = \"Jos\\u00e9\"\nb = \"Jose\\u0301\"\nprint(a == b, len(a), len(b))\nprint(unicodedata.normalize(\"NFC\", a) == unicodedata.normalize(\"NFC\", b))\n",
|
|
23
|
-
"expected_output": "False 4 5\nTrue",
|
|
24
|
-
"verified_with": "Python 3.14.2"
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
"language": "javascript",
|
|
28
|
-
"code": "const a = \"Jos\\u00e9\";\nconst b = \"Jose\\u0301\";\nconsole.log(a === b, a.length, b.length);\nconsole.log(a.normalize(\"NFC\") === b.normalize(\"NFC\"));\n",
|
|
29
|
-
"expected_output": "false 4 5\ntrue",
|
|
30
|
-
"verified_with": "Node.js v22.22.2"
|
|
31
|
-
}
|
|
32
|
-
],
|
|
33
|
-
"fix": "Normalize to NFC (or NFKC for identifiers, which also folds compatibility forms) at the boundary where the value enters your system, and store the normalized form; compare normalized forms only.",
|
|
34
|
-
"related": [],
|
|
35
|
-
"sources": [
|
|
36
|
-
{
|
|
37
|
-
"title": "Unicode Standard Annex #15: Unicode Normalization Forms",
|
|
38
|
-
"url": "https://www.unicode.org/reports/tr15/"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"title": "Python docs: unicodedata.normalize",
|
|
42
|
-
"url": "https://docs.python.org/3/library/unicodedata.html#unicodedata.normalize"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"title": "MDN: String.prototype.normalize()",
|
|
46
|
-
"url": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize"
|
|
47
|
-
}
|
|
48
|
-
],
|
|
49
|
-
"tags": [
|
|
50
|
-
"username",
|
|
51
|
-
"uniqueness",
|
|
52
|
-
"login",
|
|
53
|
-
"normalization",
|
|
54
|
-
"nfc",
|
|
55
|
-
"nfd",
|
|
56
|
-
"unicode",
|
|
57
|
-
"string comparison",
|
|
58
|
-
"database unique index"
|
|
59
|
-
],
|
|
60
|
-
"test_cases": [
|
|
61
|
-
{
|
|
62
|
-
"inputs": [
|
|
63
|
-
"José",
|
|
64
|
-
"José"
|
|
65
|
-
],
|
|
66
|
-
"relation": "equal",
|
|
67
|
-
"note": "Your username canonicalizer should map both spellings to the same key."
|
|
68
|
-
}
|
|
69
|
-
]
|
|
70
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "nfkc-compatibility-folding",
|
|
3
|
-
"title": "A superscript \"\\u00b2\" says isdigit() but int() refuses it",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "If isdigit() returns True (or a /\\d/ regex matches), the value is safe to pass to a numeric parser.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"\\u00b2\" (SUPERSCRIPT TWO, U+00B2), fullwidth \"\\uff46\\uff4f\\uff4f\" (\"foo\"), ligature \"\\ufb01le\" (\"file\")",
|
|
8
|
-
"description": "Fullwidth letters pasted from CJK input methods, a superscript exponent copied from a web page into a quantity field, or a ligature character from a PDF copy-paste landing in a filename or search box."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "\"\\u00b2\".isdigit() is True, but int(\"\\u00b2\") raises ValueError; meanwhile fullwidth \"\\uff46\\uff4f\\uff4f\" does not equal \"foo\" and the 3-character ligature \"\\ufb01le\" has length 4 after normalization even though no character was literally inserted.",
|
|
11
|
-
"why": "Python's str.isdigit() is true for any character whose Numeric_Type is Digit or Decimal, which includes superscript and subscript digits, but int() only accepts Numeric_Type=Decimal (General_Category Nd) characters, so isdigit() cannot predict whether int() will succeed; str.isdecimal() is the check that matches int(). NFKC (UAX #15) replaces compatibility characters, such as fullwidth letters, ligatures and superscripts, with their ordinary equivalents, which is why NFKC turns \"\\u00b2\" into \"2\" and makes the fullwidth string equal \"foo\". It does not fix every case: ETHIOPIC DIGIT ONE (U+1369) is isdigit() but not isdecimal(), has no compatibility decomposition, and still fails int() after NFKC. A single compatibility character can also expand: \"\\ufb01\" (the fi ligature) becomes two letters, so the 3-code-point \"\\ufb01le\" normalizes to 4.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import unicodedata\nsup = \"\\u00b2\"\nprint(sup.isdigit(), sup.isdecimal())\ntry:\n int(sup)\nexcept ValueError as e:\n print(\"ValueError\")\nn = unicodedata.normalize(\"NFKC\", sup)\nprint(n, n.isdecimal(), int(n))\n\nfullwidth = \"\\uff46\\uff4f\\uff4f\"\nprint(fullwidth == \"foo\", unicodedata.normalize(\"NFKC\", fullwidth) == \"foo\")\n\nlig = \"\\ufb01le\"\nprint(len(lig), len(unicodedata.normalize(\"NFKC\", lig)))\n",
|
|
20
|
-
"expected_output": "True False\nValueError\n2 True 2\nFalse True\n3 4",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const sup = \"\\u00b2\";\nconsole.log(/^\\d+$/.test(sup), Number(sup));\nconst n = sup.normalize(\"NFKC\");\nconsole.log(n, /^\\d+$/.test(n), Number(n));\n\nconst fullwidth = \"\\uff46\\uff4f\\uff4f\";\nconsole.log(fullwidth === \"foo\", fullwidth.normalize(\"NFKC\") === \"foo\");\n\nconst lig = \"\\ufb01le\";\nconsole.log(lig.length, lig.normalize(\"NFKC\").length);\n",
|
|
26
|
-
"expected_output": "false NaN\n2 true 2\nfalse true\n3 4",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Normalize untrusted text with NFKC at the input boundary before validating it as an identifier, filename, or number, and when checking \"is this parseable as an integer\" use str.isdecimal() in Python (not isdigit()) or attempt-and-catch the parse — never trust isdigit()/regex \\d alone to predict int()/Number() success.",
|
|
31
|
-
"related": [
|
|
32
|
-
"arabic-indic-digit-parses",
|
|
33
|
-
"fullwidth-bypasses-keyword-filter",
|
|
34
|
-
"nfc-nfd-same-name-two-accounts"
|
|
35
|
-
],
|
|
36
|
-
"sources": [
|
|
37
|
-
{
|
|
38
|
-
"title": "Unicode Standard Annex #15: Unicode Normalization Forms (NFKC)",
|
|
39
|
-
"url": "https://www.unicode.org/reports/tr15/"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"title": "Python docs: str.isdigit / str.isdecimal",
|
|
43
|
-
"url": "https://docs.python.org/3/library/stdtypes.html#str.isdigit"
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
"tags": [
|
|
47
|
-
"username",
|
|
48
|
-
"validation",
|
|
49
|
-
"normalization",
|
|
50
|
-
"parsing",
|
|
51
|
-
"length validation",
|
|
52
|
-
"forms"
|
|
53
|
-
],
|
|
54
|
-
"test_cases": [
|
|
55
|
-
{
|
|
56
|
-
"inputs": [
|
|
57
|
-
"²"
|
|
58
|
-
],
|
|
59
|
-
"relation": "raises",
|
|
60
|
-
"note": "A strict integer parser should reject this (Python's int() raises ValueError) even though isdigit() returns True; validate with isdecimal() or attempt-and-catch, or NFKC-normalize first if you intend to accept it as 2."
|
|
61
|
-
}
|
|
62
|
-
]
|
|
63
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "truncation-orphans-combining-mark",
|
|
3
|
-
"title": "\"Take the last character of the name\" for an avatar initial can return a bare accent mark",
|
|
4
|
-
"category": "unicode",
|
|
5
|
-
"assumption": "The last character of a person's name is always a letter you can use as an initial.",
|
|
6
|
-
"input": {
|
|
7
|
-
"display": "\"Jose\\u0301\" (decomposed \\u00e9: \"e\" + COMBINING ACUTE ACCENT, 5 code points) and name[-1] / name.slice(-1)",
|
|
8
|
-
"description": "Generating a one-letter avatar/initial from a user's display name by taking its first or last character, where the name arrived in decomposed (NFD) form from a different system or keyboard."
|
|
9
|
-
},
|
|
10
|
-
"what_goes_wrong": "name[-1] (Python) and name.slice(-1) (JavaScript) both return a single COMBINING ACUTE ACCENT by itself — not the letter \"e\" or the letter \"\\u00e9\" a person would expect — because in a decomposed string the accent is its own trailing code point. The result is not even classified as a letter.",
|
|
11
|
-
"why": "Unicode represents an accented letter either as one precomposed code point (U+00E9) or as a base letter followed by a separate combining mark (U+0065 U+0301). A combining mark always comes AFTER the base character it modifies, so taking the last code point (Python) or code unit (JavaScript) of a decomposed string can return the mark alone. That isolated mark has General Category Mn (\"Mark, nonspacing\"), not a letter category, which is why isalpha() and \\p{L} both reject it. Shown on its own it has no base to attach to; depending on the renderer it appears floating over nothing, over a dotted-circle placeholder, or attached to whatever text precedes it.",
|
|
12
|
-
"affects": [
|
|
13
|
-
"python",
|
|
14
|
-
"javascript"
|
|
15
|
-
],
|
|
16
|
-
"repros": [
|
|
17
|
-
{
|
|
18
|
-
"language": "python",
|
|
19
|
-
"code": "import unicodedata\nname = \"Jose\\u0301\"\ninitial = name[-1]\nprint(name, len(name))\nprint(repr(initial), initial.isalpha(), unicodedata.category(initial))\n",
|
|
20
|
-
"expected_output": "José 5\n'́' False Mn",
|
|
21
|
-
"verified_with": "Python 3.14.2"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"language": "javascript",
|
|
25
|
-
"code": "const name = \"Jose\\u0301\";\nconst initial = name.slice(-1);\nconsole.log(name, name.length);\nconsole.log(JSON.stringify(initial), /\\p{L}/u.test(initial));\n",
|
|
26
|
-
"expected_output": "José 5\n\"́\" false",
|
|
27
|
-
"verified_with": "Node.js v22.22.2"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"fix": "Normalize to NFC before taking a single character for display (so the accent recombines into one code point with its base), or better, take the first/last grapheme cluster (Intl.Segmenter in JS) rather than the first/last code point/code unit.",
|
|
31
|
-
"related": [
|
|
32
|
-
"grapheme-vs-codepoint-length",
|
|
33
|
-
"nfc-nfd-same-name-two-accounts",
|
|
34
|
-
"truncation-splits-surrogate-pair"
|
|
35
|
-
],
|
|
36
|
-
"sources": [
|
|
37
|
-
{
|
|
38
|
-
"title": "Unicode Standard Annex #15: Unicode Normalization Forms",
|
|
39
|
-
"url": "https://www.unicode.org/reports/tr15/"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"title": "Unicode Technical Report #44: General Category values (Mn)",
|
|
43
|
-
"url": "https://www.unicode.org/reports/tr44/#General_Category_Values"
|
|
44
|
-
}
|
|
45
|
-
],
|
|
46
|
-
"tags": [
|
|
47
|
-
"names",
|
|
48
|
-
"display",
|
|
49
|
-
"truncation",
|
|
50
|
-
"normalization",
|
|
51
|
-
"i18n"
|
|
52
|
-
],
|
|
53
|
-
"test_cases": [
|
|
54
|
-
{
|
|
55
|
-
"inputs": [
|
|
56
|
-
"José"
|
|
57
|
-
],
|
|
58
|
-
"relation": "no_crash",
|
|
59
|
-
"note": "An avatar-initial function should not return an isolated combining mark for this (valid, real-world) decomposed name."
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|