Upgraduju si tu svoje rozhraní pro webovou administraci e-mailových schránek, aliasů a dalších nesmyslů spjatých s pořádným e-mailovým serverem. Je to jeden z mých „pet projectů“, takže si s ním pořádně hraju a mazlím a vypiplávám k dokonalosti. Jelikož by mělo být skrze rozhraní možno přidávat i domény serverem obsluhované, a ze svých zkušeností z předchozích mnoha let vím, že uživatelé jsou to vstupního chlívku napsat cokoliv, potřebuju pořádný regulární výraz pro validaci.
Internet is for porn
To je přece jednoduché,
^[a-zA-Z0-9-]+\.[a-zA-Z]{2,4}$
Co tam máš dál?
Ne. Tohle prostě ne. Takových naivních regexpů je plný internet. Některé, trošku méně naivní, ale stále velmi nedokonalé jsou často přijímány jako správné odpovědi i na StackOverflow, který, byv v minulosti velice nápomocným webem, stává se poslední dobou útočištěm neskutečných bastlířů. Takový typický StackOverflowovský regexp pro daný problém vypadá následovně:
^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$
Je sice lepší než ten zmíněný nahoře, ale stále daleko od pravdy.
Tak co vlastně chceš?
Pravidla pro doménová jména nejsou tak jednoduchá, jak se na první pohled zdá a já bych rád vyrobil nějaký regulární výraz, který je jedním šmahem obsáhne všechna. Nebo alespoň většinu a ten zbytek vyřeším nějakým rovnákem na vohejbák. Validace tedy musí počítat s následujícími pravidly:
- Doménové jméno může obsahovat subdomény (úrovně), hierarchicky oddělené tečkou.
- Doménové jméno nesmí mít více než 127 úrovní, včetně TLD.
- Jméno úrovně smí být složeno pouze z malých a velkých písmen, číslic a znaku pomlčky.
- Jméno úrovně nesmí začínat nebo končit pomlčkou.
- Jméno úrovně nesmí být delší než 63 znaků.
- Doména musí mít platnou TLD, a to buď přímo ze seznamu existujících TLD, nebo naivní, pro které, stejně jako pro subdoménu platí, že nesmí začínat nebo končit pomlčkou a navíc nesmí být plně numerické.
- A konečně, celé doménové jméno nesmí být delší než 253 znaků.
Tohle všechno je popsáno v té hromadě RFC, která mají co do činění s doménami. Když už tedy vím, jek vlastně může doména vypadat, je krásně vidět, jak je první regulární výraz nevhodný. Nejen, že ignoruje požadavky na délku a povolené znaky, ale navíc nepřipouští existenci subdomén a ani domén s TLD delší než 4 znaky. Jak k tomu pak chudáci .travel nebo .museum přijdou? A to nezmiňuju takové špeky jako podle IANA platnou TLD .xn--clchc0ea0b2g2a9gcd.
Druhý regulár už je na tom o něco lépe, protože má liberálnější přístup k TLD, kontroluje délku názvu a ošetřuje nepovolené pomlčky na obou koncích, nicméně stejně jako ten první, nepřipouští subdomény oddělené tečkou a platná doména g.co skrz něj také neproleze. Je tedy čas znovu vynalézt kolo a napsat si svůj opravdový, dlouhý a pěkně hnusný regulární výraz.
Myšlenkový průjem
Nad stvořením onoho reguláru jsem nějakou půlhodinku proseděl, takže postupně popíšu, jakým směrem jsem uvažoval a co mi z toho vylezlo. Regulární výraz, o kterém se tu bavím, samozřejmě musí být Perl-compatible (PCRE).
V prvé řadě jsem vypustil velká písmena. Doménová jména nejsou case-sensitive a já je stejně proženu nějakou lower() funkcí, než je vůbec začnu validovat. Pokud toto není váš případ, všude, kde v regexpu mám a-z použijte a-zA-Z a case-insensitivness zůstane zachována.
Napřed jsem se soustředil pouze na validaci samotného doménového segmentu, tj. názvu bez dalších subdomén a bez TLD. Jak uvádím výše, takový název musí být minimálně 1 a maximálně 63 znaků dlouhý a nesmí začínat ani končit pomlčkou. Základem reguláru tedy bude
[a-z0-9-]{1,63}
Pro kontrolu pomlček na začátku a konci nemohu použít
[a-z0-9][a-z0-9-]{0,61}[a-z0-9]
protože pak bych musel dodržet pravidla všech skupin, čímž by se mi minimální délka zvedla na 2 znaky a doména g.co by už skrze regexp neprošla. Pomlčky tedy ošetřím skvělou a málo doceněnou regexpovou featurou - lookaroundy.
(?!-)[a-z0-9-]{1,63}(?<!-)
Tamto (?!-) na začátku se jmenuje negative lookahead a říká „Předpokládej, že není možné nalézt regexp v závorce začínající na této pozici.“ A regexp v závorce je, jak vidíte, prachobyčejná pomlčka. Totéž v bledě modrém pak provedu i na konci a vytvořím negative lookbehind (?<!-), který říká „Předpokládej, že není možné nalézt regexp v závorce končící na této pozici.“ Regexpem je opět jednoduchá pomlčka bez jakýchkoliv kvantifikátorů, takže se regulární výraz lookaroundu vztahuje pouze na jeden znak.
Super. Tak teď ten regexp potřebuju zkopírovat takovým způsobem, aby mi umožňoval existenci jedné povinné kopie a 125 nepovinných, oddělených tečkami. TLD zatím stále neřeším. Jednoduše bych tedy udělal
(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-)){0,125}
Ale protože nepotřebuji, aby si regexp engine pamatoval jednotlivé subdomény v backreferencích, řeknu mu, pomocí ?: na začátku subdoménové závorky, že segmenty má pouze porovnávat, ale pamatovat si je nemusí.
(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-)){0,125}
No a konečně můžu přistoupit k validaci TLD. Pokud chci pouze TLD registrovanou IANA, musím si sjet tenhle seznam platných TLD a otrocky do reguláru všechny domény naflákat.
\.(?:ac|ad|ae|aero|af|ag|...)
Pokud chci pouze naivní validaci (a tu zrovna já chci), tak pro TLD platí opět stejná pravidla jako pro subdomény, ale navíc TLD nesmí být čistě numerická. Třikrát hurá, ať žijí lookaroundy. Samotnou TLD budu tedy prohánět částí
\.(?!-)(?![0-9]+$)[a-z0-9-]{1,63}(?<!-)
Kterou přilepím za výše uvedený regexp pro validaci subdomén. Lookahead (?![0-9]+$) mi zde zajistí onu kýženou nenumeričnost od poslední tečky před TLD až do konce řetězce.
A protože chci validovat celý řetězec, dlouhému regexpu ještě přidám na začátek assert ^ a na konec $. Celý nádherný regulární výraz tedy bude vypadat takto:
# Full domain validation Perl-compatible regular expression
^(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-)){0,125}\.(?!-)(?![0-9]+$)[a-z0-9-]{1,63}(?<!-)$
A v porovnání s prvním regexpem už vypadá, že opravdu něco validuje.
Test
#!/usr/bin/python
import re
def validate(domain):
valid = re.match('^(?!-)[a-z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-z0-9-]{1,63}(?<!-)){0,125}\.(?!-)(?![0-9]+$)[a-z0-9-]{1,63}(?<!-)$', domain)
print 'Domain "{}" is {}'.format(domain, 'VALID' if valid else 'INVALID')
print 'Domains which should be valid:'
validate('g.co')
validate('1234.cz')
validate('example.com')
validate('example-test.com')
validate('xn--bcher-kva.ch')
validate('1.1.168.192.in-addr.arpa')
validate('subdomain.example.com')
validate('sub-domain.example.com')
validate('naprosto-silene-jmeno-subdomeny-ktere-ma-zrovna-akorat-63-znaku.example.com')
validate('naprosto.silene.jmeno.domeny.ktere.ma.hromadu.subdomen.ale.je.kratsi.nez.253.znaku.example.com')
print '\nDomains with naively valid TLD, otherwise invalid:'
validate('example.a')
validate('example.a1b')
validate('example.1ab')
validate('example.ab1')
validate('example.1a2')
validate('example.1-2')
print '\nDomains which should be invalid:'
validate('example.')
validate('.example')
validate('example.123')
validate('example.-com')
validate('example.com-')
validate('-example.com')
validate('example..com')
validate('example&test.com')
validate('subdomain-.example.com')
Jak dopadne test si můžete vyzkoušet sami.
Nezapomněl jsi na něco?
Jo. Na ošetření maximální délky celého názvu. Funkci strlen() znáte, že jo? Pravidlo na počet subdomén pak vyzní trochu absurdně, protože do 253 znaků se 127 doménových segmentů se 126 oddělovači vleze pouze v případě, kdy má každý segment (včetně TLD) pouze jeden znak. Nemožné to však není.
V případě, že byste našli nějakou doménu, která regulárem prošla, ale neměla by, případně nějakou, která naopak neprošla, ale měla by, dejte mi prosím vědět v komentářích.