Module: DomainSanity::IDN

Defined in:
lib/domain_sanity/idn.rb

Overview

Internationalized Domain Name helpers.

Conversion between the ASCII (A-label / punycode) and Unicode (U-label) forms is delegated to SimpleIDN, which implements IDNA2003-style punycode. It is NOT a full UTS-46 / IDNA2008 processor: mapping of a handful of code points (final sigma, ß, ZWJ/ZWNJ, deviation characters) differs from the current standard. If you need IDNA2008 exactness, normalize upstream with a UTS-46 library before handing names to DomainSanity. Length and label validation here operate on the ASCII form regardless, so the wire-format limits stay correct either way.

Separately, IDN.mixed_script? provides a lightweight homograph guard: it flags labels that combine scripts in ways a Unicode registry would not allow (e.g. Latin mixed with Cyrillic). This is opt-in via Policy (require_single_script) because legitimate names occasionally trip it.

None of these helpers raise: unconvertible input comes back as nil so callers can branch cleanly. Only SimpleIDN::ConversionError (a malformed A-label) is swallowed.

Constant Summary collapse

ALLOWED_MULTI_SCRIPT =

Script groups that legitimately co-occur within one label, per the spirit of UTS-39 "Highly Restrictive": a run may mix Latin with one East Asian script family, but not, say, Latin with Cyrillic.

[
  Set[:latin, :han, :hiragana, :katakana], # Japanese
  Set[:latin, :han, :bopomofo],            # Chinese
  Set[:latin, :han, :hangul]               # Korean
].freeze
SCRIPT_PATTERNS =

Scripts we can name. Anything letter-like outside this list collapses to :other, which still participates in the "more than one script" test.

{
  latin: /\p{Latin}/,
  greek: /\p{Greek}/,
  cyrillic: /\p{Cyrillic}/,
  armenian: /\p{Armenian}/,
  hebrew: /\p{Hebrew}/,
  arabic: /\p{Arabic}/,
  han: /\p{Han}/,
  hiragana: /\p{Hiragana}/,
  katakana: /\p{Katakana}/,
  hangul: /\p{Hangul}/,
  bopomofo: /\p{Bopomofo}/,
  thai: /\p{Thai}/,
  devanagari: /\p{Devanagari}/
}.freeze

Class Method Summary collapse

Class Method Details

.label_mixed_script?(label) ⇒ Boolean

Returns:

  • (Boolean)


114
115
116
117
118
119
# File 'lib/domain_sanity/idn.rb', line 114

def label_mixed_script?(label)
  found = scripts(label)
  return false if found.size <= 1

  ALLOWED_MULTI_SCRIPT.none? { |allowed| found.subset?(allowed) }
end

.mixed_script?(name) ⇒ Boolean

True when any label in the name mixes scripts in a way that reads as a homograph risk. Operates on the Unicode form; an unconvertible name is not flagged (there is nothing to compare). Mixing is judged per label, since cross-label mixing (e.g. an xn-- label beside an ASCII one) is normal.

Returns:

  • (Boolean)


102
103
104
# File 'lib/domain_sanity/idn.rb', line 102

def mixed_script?(name)
  mixed_script_unicode?(to_unicode(name) || name)
end

.mixed_script_unicode?(unicode) ⇒ Boolean

As mixed_script?, but for a name already decoded to its Unicode form. Callers that hold the U-label form (Name.normalize does) skip re-decoding.

Returns:

  • (Boolean)


108
109
110
111
112
# File 'lib/domain_sanity/idn.rb', line 108

def mixed_script_unicode?(unicode)
  return false unless unicode.is_a?(String)

  unicode.split(".").any? { |label| label_mixed_script?(label) }
end

.punycode?(name) ⇒ Boolean

True when the name (or any of its labels) uses the xn-- ACE prefix.

Returns:

  • (Boolean)


79
80
81
82
83
# File 'lib/domain_sanity/idn.rb', line 79

def punycode?(name)
  return false unless name.is_a?(String)

  name.downcase.split(".").any? { |label| label.start_with?("xn--") }
end

.scripts(string) ⇒ Object

The set of scripts among the letters of a string (Common/Inherited and non-letters are ignored). Unknown letters collapse to :other.



87
88
89
90
91
92
93
94
95
96
# File 'lib/domain_sanity/idn.rb', line 87

def scripts(string)
  return Set.new unless string.is_a?(String)

  string.each_char.with_object(Set.new) do |char, found|
    next unless char.match?(/\p{L}/)

    name, = SCRIPT_PATTERNS.find { |_, pattern| char.match?(pattern) }
    found << (name || :other)
  end
end

.to_ascii(name) ⇒ Object

Convert a name to its ASCII/punycode form. Returns nil for non-strings, empty input, or input that cannot be encoded.



58
59
60
61
62
63
64
65
# File 'lib/domain_sanity/idn.rb', line 58

def to_ascii(name)
  return nil unless name.is_a?(String)
  return nil if name.empty?

  SimpleIDN.to_ascii(name)
rescue SimpleIDN::ConversionError
  nil
end

.to_unicode(name) ⇒ Object

Convert a name to its Unicode form. Returns nil for non-strings, empty input, or input that cannot be decoded.



69
70
71
72
73
74
75
76
# File 'lib/domain_sanity/idn.rb', line 69

def to_unicode(name)
  return nil unless name.is_a?(String)
  return nil if name.empty?

  SimpleIDN.to_unicode(name)
rescue SimpleIDN::ConversionError
  nil
end