├── .github └── workflows │ └── test.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── Steepfile ├── bin ├── console └── setup ├── idnx.gemspec ├── lib ├── idnx.rb └── idnx │ ├── idn2.rb │ ├── ruby.rb │ ├── version.rb │ └── windows.rb ├── sig └── idnx.rbs └── test ├── idn_test.rb └── test_helper.rb /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: >- 8 | ${{ matrix.os }} ${{ matrix.ruby }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | os: [ ubuntu-latest, macos-latest, windows-latest ] 14 | ruby: 15 | - "3.4" 16 | - "3.3" 17 | - "3.2" 18 | - "3.1" 19 | - "3.0" 20 | - "2.7" 21 | - "2.6" 22 | - "2.5" 23 | - "2.4" 24 | - "head" 25 | - "jruby" 26 | - "jruby-head" 27 | - "truffleruby" 28 | - "truffleruby-head" 29 | exclude: 30 | - os: windows-latest 31 | ruby: truffleruby 32 | - os: windows-latest 33 | ruby: truffleruby-head 34 | - os: macos-latest 35 | ruby: 2.4 36 | - os: macos-latest 37 | ruby: 2.5 38 | steps: 39 | - name: repo checkout 40 | uses: actions/checkout@v2 41 | 42 | - name: load ruby 43 | uses: ruby/setup-ruby@v1 44 | with: 45 | ruby-version: ${{ matrix.ruby }} 46 | bundler-cache: true 47 | 48 | - name: install libidn2 49 | if: matrix.os == 'ubuntu-latest' 50 | run: sudo apt-get update -qq -o Acquire::Retries=3 && 51 | sudo apt-get install --fix-missing -qq -o Acquire::Retries=3 idn2 52 | 53 | - name: install libidn2 54 | if: matrix.os == 'macos-latest' 55 | run: brew install libidn2 56 | 57 | - name: test 58 | run: bundle exec rake 59 | continue-on-error: ${{ matrix.os == 'windows-latest' || matrix.ruby == 'head' || matrix.ruby == 'jruby-head' || matrix.ruby == 'truffleruby-head' }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-thread_safety 4 | 5 | AllCops: 6 | TargetRubyVersion: 2.5 7 | NewCops: enable 8 | Include: 9 | - lib/**/*.rb 10 | - test/**/*.rb 11 | Exclude: 12 | - 'lib/idnx/ruby.rb' 13 | - '.bundle/**/*' 14 | - 'vendor/**/*' 15 | 16 | Style/StringLiterals: 17 | Enabled: true 18 | EnforcedStyle: double_quotes 19 | 20 | Style/StringLiteralsInInterpolation: 21 | Enabled: true 22 | EnforcedStyle: double_quotes 23 | 24 | Style/Documentation: 25 | Enabled: false 26 | 27 | Layout/LineLength: 28 | Max: 120 29 | 30 | Metrics/MethodLength: 31 | Enabled: false 32 | 33 | Metrics/AbcSize: 34 | Enabled: false 35 | 36 | Metrics/CyclomaticComplexity: 37 | Enabled: false 38 | 39 | Gemspec/RequiredRubyVersion: 40 | Enabled: false 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.1] - 2021-10-03 4 | 5 | Added missing RBS sig files to the gemspec. 6 | 7 | ## [0.1.0] - 2021-09-04 8 | 9 | ### improvements 10 | 11 | It ships RBS signatures for the main public API. 12 | 13 | ### compliance 14 | 15 | `idnx` ships with a pure ruby punycode implementation (IDNA 2003), which will get loaded when `libidn2` isn't installed in the system. This was necessary for license compatibility, given that Apache 2.0 projects can rely on GPL dependencies (such as `libidn2`) but nnot exclusively. 16 | 17 | ## [0.0.1] - 2021-06-11 18 | 19 | This is the initial release. 20 | 21 | ### Features 22 | 23 | `idnx` ships with a single function, which translate Innternational domain names into Punycode names, which can be used for DNS requests: 24 | 25 | ```ruby 26 | require "idnx" 27 | 28 | Idnx.to_punycode("bücher.de") #=> "xn--bcher-kva.de" 29 | ``` 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in idnx.gemspec 6 | gemspec 7 | 8 | if RUBY_VERSION >= "3.0.0" && RUBY_ENGINE == "ruby" 9 | gem "rbs" 10 | gem "steep" 11 | gem "debug" 12 | end 13 | 14 | gem "minitest", "~> 5.0" 15 | gem "rake", "~> 13.0" 16 | gem "rubocop" 17 | gem "rubocop-performance" 18 | gem "rubocop-thread_safety" 19 | 20 | platform :jruby do 21 | gem "jruby-win32ole" if Gem.win_platform? 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | Copyright 2013-2017 Docker, Inc. 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | https://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | 193 | 194 | * lib/idnx/ruby.rb 195 | 196 | This file is derived from the implementation of punycode available at 197 | here: 198 | 199 | https://www.verisign.com/en_US/channel-resources/domain-registry-products/idn-sdks/index.xhtml 200 | 201 | Copyright (C) 2000-2002 Verisign Inc., All rights reserved. 202 | 203 | Redistribution and use in source and binary forms, with or 204 | without modification, are permitted provided that the following 205 | conditions are met: 206 | 207 | 1) Redistributions of source code must retain the above copyright 208 | notice, this list of conditions and the following disclaimer. 209 | 210 | 2) Redistributions in binary form must reproduce the above copyright 211 | notice, this list of conditions and the following disclaimer in 212 | the documentation and/or other materials provided with the 213 | distribution. 214 | 215 | 3) Neither the name of the VeriSign Inc. nor the names of its 216 | contributors may be used to endorse or promote products derived 217 | from this software without specific prior written permission. 218 | 219 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 220 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 221 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 222 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 223 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 224 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 225 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 226 | OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED 227 | AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 228 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 229 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 230 | POSSIBILITY OF SUCH DAMAGE. 231 | 232 | This software is licensed under the BSD open source license. For more 233 | information visit www.opensource.org. 234 | 235 | Authors: 236 | John Colosi (VeriSign) 237 | Srikanth Veeramachaneni (VeriSign) 238 | Nagesh Chigurupati (Verisign) 239 | Praveen Srinivasan(Verisign) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Idnx 2 | 3 | [![Gem Version](https://badge.fury.io/rb/idnx.svg)](http://rubygems.org/gems/idnx) 4 | [![CI](https://github.com/HoneyryderChuck/idnx/actions/workflows/test.yml/badge.svg)](https://github.com/HoneyryderChuck/idnx/actions/workflows/test.yml) 5 | 6 | 7 | 8 | `idnx` provides a Ruby API for decoding Internationalized domain names into Punycode. 9 | 10 | It provides multi-platform support by using the most approriate strategy based on the target environment: 11 | 12 | * It uses (and requires the installation of) [libidn2](https://github.com/libidn/libidn2) in Linux / MacOS; 13 | * It uses [the appropriate winnls APIs](https://docs.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-idntoascii) in Windows; 14 | * It falls back to a pure ruby Punycode 2003 implementation; 15 | 16 | ## Installation 17 | 18 | If you're on Linux or Mac OS, you'll have to install `libidn2` first: 19 | 20 | ``` 21 | # Mac OS 22 | > brew install libidn2 23 | # Ubuntu, as an example 24 | > apt-get install idn2 25 | ``` 26 | 27 | Add this line to your application's Gemfile: 28 | 29 | ```ruby 30 | gem 'idnx' 31 | ``` 32 | 33 | And then execute: 34 | 35 | $ bundle install 36 | 37 | Or install it yourself as: 38 | 39 | $ gem install idnx 40 | 41 | ## Usage 42 | 43 | ```ruby 44 | require "idnx" 45 | 46 | Idnx.to_punycode("bücher.de") #=> "xn--bcher-kva.de" 47 | ``` 48 | 49 | ## Ruby Support Policy 50 | 51 | This library supports at least ruby 2.4 .It also supports both JRuby and Truffleruby. 52 | 53 | ## Known Issues 54 | 55 | ### JRuby on MacOS 56 | 57 | `idnx` won't work in MacOS when using JRuby 9.2 or lower, due to jruby FFI not having the same path lookup logic than it's counterpart for CRuby, thereby not finding `brew`-installed `libidn2`. This has been fixed since JRuby 9.3 . 58 | 59 | ## Development 60 | 61 | If you want to contribute, fork this project, and submit changes via a PR on github. 62 | 63 | For running tests, you can run `rake test`. 64 | 65 | ## Contributing 66 | 67 | Bug reports and pull requests are welcome on GitHub at https://github.com/HoneyryderChuck/idnx. 68 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | require "rubocop/rake_task" 13 | 14 | RuboCop::RakeTask.new 15 | 16 | if RUBY_ENGINE == "ruby" && RUBY_VERSION > "3.0.0" && !Gem.win_platform? 17 | task :type_check do 18 | # Steep doesn't provide Rake integration yet, 19 | # but can do that ourselves 20 | require "steep" 21 | require "steep/cli" 22 | 23 | Steep::CLI.new(argv: ["check"], stdout: $stdout, stderr: $stderr, stdin: $stdin).run 24 | end 25 | 26 | task default: %i[test type_check rubocop] 27 | else 28 | task default: %i[test] 29 | end 30 | -------------------------------------------------------------------------------- /Steepfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | target :lib do 4 | check "lib" 5 | signature "sig" 6 | 7 | ignore "lib/idnx/idn2.rb" 8 | ignore "lib/idnx/windows.rb" 9 | ignore "lib/idnx/ruby.rb" 10 | end 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "ruby/indx" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /idnx.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/idnx/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "idnx" 7 | spec.version = Idnx::VERSION 8 | spec.authors = ["HoneyryderChuck"] 9 | spec.email = ["cardoso_tiago@hotmail.com"] 10 | 11 | spec.summary = <<-DESC 12 | Converts International Domain Names into Punycode. 13 | It uses (via FFI) 'libidn2' for Mac and Linux; for Windows, it uses native APIs. 14 | DESC 15 | spec.description = <<-DESC 16 | Converts International Domain Names into Punycode. 17 | It uses (via FFI) 'libidn2' for Mac and Linux; for Windows, it uses native APIs. 18 | DESC 19 | spec.homepage = "https://github.com/honeyryderchuck/idnx" 20 | spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0") 21 | 22 | spec.metadata["homepage_uri"] = spec.homepage 23 | spec.metadata["source_code_uri"] = spec.homepage 24 | spec.metadata["changelog_uri"] = "#{spec.homepage}/-/blob/master/CHANGELOG.md" 25 | spec.license = "Apache 2.0" 26 | 27 | spec.files = Dir["LICENSE.txt", "README.md", "CHANGELOG.md", "sig/**/*.rbs", "lib/**/*.rb"] 28 | spec.extra_rdoc_files = Dir["LICENSE.txt", "README.md"] 29 | 30 | spec.require_paths = ["lib"] 31 | 32 | # Uncomment to register a new dependency of your gem 33 | spec.add_dependency "ffi", ["~> 1.12"] 34 | 35 | # For more information and examples about making a new gem, checkout our 36 | # guide at: https://bundler.io/guides/creating_gem.html 37 | end 38 | -------------------------------------------------------------------------------- /lib/idnx.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "idnx/version" 4 | require "ffi" 5 | 6 | module Idnx 7 | class Error < StandardError; end 8 | 9 | module_function 10 | 11 | def to_punycode(hostname) 12 | Lib.lookup(hostname) 13 | end 14 | end 15 | 16 | if FFI::Platform.windows? 17 | require "idnx/windows" 18 | else 19 | begin 20 | require "idnx/idn2" 21 | rescue LoadError 22 | # fallback to pure ruby punycode 2003 implementation 23 | require "idnx/ruby" 24 | Idnx::Lib = Idnx::Ruby 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/idnx/idn2.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Idnx 4 | module Lib 5 | extend FFI::Library 6 | 7 | if FFI::Platform.mac? 8 | ffi_lib ["libidn2", "libidn2.0"] 9 | else 10 | ffi_lib ["libidn2.so", "libidn2.so.0"] 11 | end 12 | 13 | attach_function :idn2_check_version, [:string], :string 14 | 15 | VERSION = idn2_check_version(nil) 16 | 17 | IDN2_OK = 0 18 | 19 | IDN2_NFC_INPUT = 1 20 | IDN2_TRANSITIONAL = 4 21 | IDN2_NONTRANSITIONAL = 8 22 | 23 | FLAGS = if Gem::Version.new(VERSION) >= Gem::Version.new("0.14.0") 24 | IDN2_NFC_INPUT | IDN2_NONTRANSITIONAL 25 | else 26 | IDN2_NFC_INPUT 27 | end 28 | 29 | attach_function :idn2_lookup_ul, %i[string pointer int], :int 30 | attach_function :idn2_strerror, [:int], :string 31 | attach_function :idn2_free, [:pointer], :void 32 | 33 | module_function 34 | 35 | def lookup(hostname) 36 | string_ptr = FFI::MemoryPointer.new(:pointer) 37 | result = idn2_lookup_ul(hostname, string_ptr, FLAGS) 38 | 39 | result = idn2_lookup_ul(hostname, string_ptr, IDN2_TRANSITIONAL) if result != IDN2_OK 40 | 41 | if result != IDN2_OK 42 | string_ptr.free 43 | raise Error, "Failed to convert \"#{hostname}\" to ascii; (error: #{idn2_strerror(result)})" 44 | end 45 | 46 | ptr = string_ptr.read_pointer 47 | 48 | raise Error, "Failed to read \"#{hostname}\" to ascii" if ptr.null? 49 | 50 | ascii = ptr.read_string 51 | 52 | idn2_free(ptr) 53 | string_ptr.free 54 | 55 | ascii 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/idnx/ruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Idnx 4 | module Ruby 5 | module_function 6 | 7 | def lookup(hostname) 8 | Punycode.encode_hostname(hostname) 9 | end 10 | 11 | # :nocov: 12 | # -*- coding: utf-8 -*- 13 | #-- 14 | # punycode.rb - PunyCode encoder for the Domain Name library 15 | # 16 | # Copyright (C) 2011-2017 Akinori MUSHA, All rights reserved. 17 | # 18 | # Ported from puny.c, a part of VeriSign XCode (encode/decode) IDN 19 | # Library. 20 | # 21 | # Copyright (C) 2000-2002 Verisign Inc., All rights reserved. 22 | # 23 | # Redistribution and use in source and binary forms, with or 24 | # without modification, are permitted provided that the following 25 | # conditions are met: 26 | # 27 | # 1) Redistributions of source code must retain the above copyright 28 | # notice, this list of conditions and the following disclaimer. 29 | # 30 | # 2) Redistributions in binary form must reproduce the above copyright 31 | # notice, this list of conditions and the following disclaimer in 32 | # the documentation and/or other materials provided with the 33 | # distribution. 34 | # 35 | # 3) Neither the name of the VeriSign Inc. nor the names of its 36 | # contributors may be used to endorse or promote products derived 37 | # from this software without specific prior written permission. 38 | # 39 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 40 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 41 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 42 | # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 43 | # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 44 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 45 | # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS 46 | # OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED 47 | # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 48 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 49 | # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 50 | # POSSIBILITY OF SUCH DAMAGE. 51 | # 52 | # This software is licensed under the BSD open source license. For more 53 | # information visit www.opensource.org. 54 | # 55 | # Authors: 56 | # John Colosi (VeriSign) 57 | # Srikanth Veeramachaneni (VeriSign) 58 | # Nagesh Chigurupati (Verisign) 59 | # Praveen Srinivasan(Verisign) 60 | #++ 61 | module Punycode 62 | BASE = 36 63 | TMIN = 1 64 | TMAX = 26 65 | SKEW = 38 66 | DAMP = 700 67 | INITIAL_BIAS = 72 68 | INITIAL_N = 0x80 69 | DELIMITER = "-" 70 | 71 | MAXINT = (1 << 32) - 1 72 | 73 | LOBASE = BASE - TMIN 74 | CUTOFF = LOBASE * TMAX / 2 75 | 76 | RE_NONBASIC = /[^\x00-\x7f]/.freeze 77 | 78 | # Returns the numeric value of a basic code point (for use in 79 | # representing integers) in the range 0 to base-1, or nil if cp 80 | # is does not represent a value. 81 | DECODE_DIGIT = {}.tap do |map| 82 | # ASCII A..Z map to 0..25 83 | # ASCII a..z map to 0..25 84 | (0..25).each { |i| map[65 + i] = map[97 + i] = i } 85 | # ASCII 0..9 map to 26..35 86 | (26..35).each { |i| map[22 + i] = i } 87 | end 88 | 89 | # Returns the basic code point whose value (when used for 90 | # representing integers) is d, which must be in the range 0 to 91 | # BASE-1. The lowercase form is used unless flag is true, in 92 | # which case the uppercase form is used. The behavior is 93 | # undefined if flag is nonzero and digit d has no uppercase 94 | # form. 95 | ENCODE_DIGIT = proc { |d, flag| 96 | (d + 22 + (d < 26 ? 75 : 0) - (flag ? (1 << 5) : 0)).chr 97 | # 0..25 map to ASCII a..z or A..Z 98 | # 26..35 map to ASCII 0..9 99 | } 100 | 101 | DOT = "." 102 | PREFIX = "xn--" 103 | 104 | # Most errors we raise are basically kind of ArgumentError. 105 | class ArgumentError < ::ArgumentError; end 106 | class BufferOverflowError < ArgumentError; end 107 | 108 | module_function 109 | 110 | # Encode a +string+ in Punycode 111 | def encode(string) 112 | input = string.unpack("U*") 113 | output = +"" 114 | 115 | # Initialize the state 116 | n = INITIAL_N 117 | delta = 0 118 | bias = INITIAL_BIAS 119 | 120 | # Handle the basic code points 121 | input.each { |cp| output << cp.chr if cp < 0x80 } 122 | 123 | h = b = output.length 124 | 125 | # h is the number of code points that have been handled, b is the 126 | # number of basic code points, and out is the number of characters 127 | # that have been output. 128 | 129 | output << DELIMITER if b > 0 130 | 131 | # Main encoding loop 132 | 133 | while h < input.length 134 | # All non-basic code points < n have been handled already. Find 135 | # the next larger one 136 | 137 | m = MAXINT 138 | input.each do |cp| 139 | m = cp if (n...m) === cp 140 | end 141 | 142 | # Increase delta enough to advance the decoder's state to 143 | # , but guard against overflow 144 | 145 | delta += (m - n) * (h + 1) 146 | raise BufferOverflowError if delta > MAXINT 147 | 148 | n = m 149 | 150 | input.each do |cp| 151 | # AMC-ACE-Z can use this simplified version instead 152 | if cp < n 153 | delta += 1 154 | raise BufferOverflowError if delta > MAXINT 155 | elsif cp == n 156 | # Represent delta as a generalized variable-length integer 157 | q = delta 158 | k = BASE 159 | loop do 160 | t = k <= bias ? TMIN : k - bias >= TMAX ? TMAX : k - bias 161 | break if q < t 162 | 163 | q, r = (q - t).divmod(BASE - t) 164 | output << ENCODE_DIGIT[t + r, false] 165 | k += BASE 166 | end 167 | 168 | output << ENCODE_DIGIT[q, false] 169 | 170 | # Adapt the bias 171 | delta = h == b ? delta / DAMP : delta >> 1 172 | delta += delta / (h + 1) 173 | bias = 0 174 | while delta > CUTOFF 175 | delta /= LOBASE 176 | bias += BASE 177 | end 178 | bias += (LOBASE + 1) * delta / (delta + SKEW) 179 | 180 | delta = 0 181 | h += 1 182 | end 183 | end 184 | 185 | delta += 1 186 | n += 1 187 | end 188 | 189 | output 190 | end 191 | 192 | # Encode a hostname using IDN/Punycode algorithms 193 | def encode_hostname(hostname) 194 | hostname.match(RE_NONBASIC) || (return hostname) 195 | 196 | hostname.split(DOT).map do |name| 197 | if name.match(RE_NONBASIC) 198 | PREFIX + encode(name) 199 | else 200 | name 201 | end 202 | end.join(DOT) 203 | end 204 | 205 | # Decode a +string+ encoded in Punycode 206 | def decode(string) 207 | # Initialize the state 208 | n = INITIAL_N 209 | i = 0 210 | bias = INITIAL_BIAS 211 | 212 | if j = string.rindex(DELIMITER) 213 | b = string[0...j] 214 | 215 | b.match(RE_NONBASIC) && 216 | raise(ArgumentError, "Illegal character is found in basic part: #{string.inspect}") 217 | 218 | # Handle the basic code points 219 | 220 | output = b.unpack("U*") 221 | u = string[(j + 1)..-1] 222 | else 223 | output = [] 224 | u = string 225 | end 226 | 227 | # Main decoding loop: Start just after the last delimiter if any 228 | # basic code points were copied; start at the beginning 229 | # otherwise. 230 | 231 | input = u.unpack("C*") 232 | input_length = input.length 233 | h = 0 234 | out = output.length 235 | 236 | while h < input_length 237 | # Decode a generalized variable-length integer into delta, 238 | # which gets added to i. The overflow checking is easier 239 | # if we increase i as we go, then subtract off its starting 240 | # value at the end to obtain delta. 241 | 242 | oldi = i 243 | w = 1 244 | k = BASE 245 | 246 | loop do 247 | (digit = DECODE_DIGIT[input[h]]) || 248 | raise(ArgumentError, "Illegal character is found in non-basic part: #{string.inspect}") 249 | h += 1 250 | i += digit * w 251 | raise BufferOverflowError if i > MAXINT 252 | 253 | t = k <= bias ? TMIN : k - bias >= TMAX ? TMAX : k - bias 254 | break if digit < t 255 | 256 | w *= BASE - t 257 | raise BufferOverflowError if w > MAXINT 258 | 259 | k += BASE 260 | (h < input_length) || raise(ArgumentError, "Malformed input given: #{string.inspect}") 261 | end 262 | 263 | # Adapt the bias 264 | delta = oldi == 0 ? i / DAMP : (i - oldi) >> 1 265 | delta += delta / (out + 1) 266 | bias = 0 267 | while delta > CUTOFF 268 | delta /= LOBASE 269 | bias += BASE 270 | end 271 | bias += (LOBASE + 1) * delta / (delta + SKEW) 272 | 273 | # i was supposed to wrap around from out+1 to 0, incrementing 274 | # n each time, so we'll fix that now: 275 | 276 | q, i = i.divmod(out + 1) 277 | n += q 278 | raise BufferOverflowError if n > MAXINT 279 | 280 | # Insert n at position i of the output: 281 | 282 | output[i, 0] = n 283 | 284 | out += 1 285 | i += 1 286 | end 287 | output.pack("U*") 288 | end 289 | 290 | # Decode a hostname using IDN/Punycode algorithms 291 | def decode_hostname(hostname) 292 | hostname.gsub(/(\A|#{Regexp.quote(DOT)})#{Regexp.quote(PREFIX)}([^#{Regexp.quote(DOT)}]*)/o) do 293 | Regexp.last_match(1) << decode(Regexp.last_match(2)) 294 | end 295 | end 296 | end 297 | # :nocov: 298 | end 299 | end -------------------------------------------------------------------------------- /lib/idnx/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Idnx 4 | VERSION = "0.1.1" 5 | end 6 | -------------------------------------------------------------------------------- /lib/idnx/windows.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "win32ole" 4 | 5 | module Idnx 6 | module Lib 7 | extend FFI::Library 8 | 9 | ffi_lib "Normaliz.dll" 10 | ffi_lib "kernel32.dll" 11 | 12 | IDN_MAX_LENGTH = 255 13 | MB_ERR_INVALID_CHARS = 0x00000008 14 | 15 | ERROR_INSUFFICIENT_BUFFER = 0x7A 16 | ERROR_INVALID_FLAGS = 0x3EC 17 | ERROR_INVALID_NAME = 0x7B 18 | ERROR_INVALID_PARAMETER = 0x57 19 | ERROR_NO_UNICODE_TRANSLATION = 0x459 20 | 21 | # int IdnToAscii( 22 | # DWORD dwFlags, 23 | # LPCWSTR lpUnicodeCharStr, 24 | # int cchUnicodeChar, 25 | # LPWSTR lpASCIICharStr, 26 | # int cchASCIIChar 27 | # ); 28 | # 29 | # int MultiByteToWideChar( 30 | # UINT CodePage, 31 | # DWORD dwFlags, 32 | # _In_NLS_string_(cbMultiByte)LPCCH lpMultiByteStr, 33 | # int cbMultiByte, 34 | # LPWSTR lpWideCharStr, 35 | # int cchWideChar 36 | # ); 37 | # 38 | # int WideCharToMultiByte( 39 | # UINT CodePage, 40 | # DWORD dwFlags, 41 | # _In_NLS_string_(cchWideChar)LPCWCH lpWideCharStr, 42 | # int cchWideChar, 43 | # LPSTR lpMultiByteStr, 44 | # int cbMultiByte, 45 | # LPCCH lpDefaultChar, 46 | # LPBOOL lpUsedDefaultChar 47 | # ); 48 | attach_function :IdnToAscii, %i[uint pointer int pointer int], :int 49 | attach_function :MultiByteToWideChar, %i[uint uint string int pointer int], :int 50 | attach_function :WideCharToMultiByte, %i[uint uint pointer int pointer int pointer pointer], :int 51 | 52 | module_function 53 | 54 | def lookup(hostname) 55 | # turn to wchar 56 | wchar_len = MultiByteToWideChar(WIN32OLE::CP_UTF8, MB_ERR_INVALID_CHARS, hostname, -1, nil, 0) 57 | raise Error, "Failed to convert \"#{hostname}\" to wchar" if wchar_len.zero? 58 | 59 | wchar_ptr = FFI::MemoryPointer.new(:wchar_t, wchar_len) 60 | wchar_len = MultiByteToWideChar(WIN32OLE::CP_UTF8, 0, hostname, -1, wchar_ptr, wchar_len) 61 | raise Error, "Failed to convert \"#{hostname}\" to wchar" if wchar_len.zero? 62 | 63 | # translate to punycode 64 | punycode = FFI::MemoryPointer.new(:wchar_t, IDN_MAX_LENGTH) 65 | punycode_len = IdnToAscii(0, wchar_ptr, -1, punycode, IDN_MAX_LENGTH) 66 | wchar_ptr.free 67 | 68 | if punycode_len.zero? 69 | last_error = FFI::LastError.error 70 | 71 | # operation completed successfully, hostname is not an IDN 72 | # return hostname if last_error == 0 73 | 74 | message = case last_error 75 | when ERROR_INSUFFICIENT_BUFFER 76 | "The supplied buffer size was not large enough, or it was incorrectly set to NULL" 77 | when ERROR_INVALID_FLAGS 78 | "The values supplied for flags were not valid" 79 | when ERROR_INVALID_NAME 80 | "An invalid name was supplied to the function" 81 | when ERROR_INVALID_PARAMETER 82 | "Any of the parameter values was invalid" 83 | when ERROR_NO_UNICODE_TRANSLATION 84 | "An invalid Unicode was found in a string" 85 | else 86 | "Failed to convert \"#{hostname}\"; (error: #{last_error})" \ 87 | "\n\nhttps://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes#system-error-codes-1" 88 | end 89 | punycode.free 90 | raise Error, message 91 | end 92 | 93 | # turn to unicode 94 | unicode_len = WideCharToMultiByte(WIN32OLE::CP_UTF8, 0, punycode, -1, nil, 0, nil, nil) 95 | raise Error, "Failed to convert \"#{hostname}\" to utf8" if unicode_len.zero? 96 | 97 | utf8_ptr = FFI::MemoryPointer.new(:char, unicode_len) 98 | unicode_len = WideCharToMultiByte(WIN32OLE::CP_UTF8, 0, punycode, -1, utf8_ptr, unicode_len, nil, nil) 99 | raise Error, "Failed to convert \"#{hostname}\" to utf8" if unicode_len.zero? 100 | 101 | unicode = utf8_ptr.read_string(utf8_ptr.size) 102 | unicode.strip! # remove null-byte 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /sig/idnx.rbs: -------------------------------------------------------------------------------- 1 | module Idnx 2 | VERSION: String 3 | 4 | class Error < StandardError 5 | end 6 | 7 | def self?.to_punycode: (String hostname) -> String 8 | 9 | module Lib 10 | def self?.lookup: (String hostname) -> String 11 | end 12 | end -------------------------------------------------------------------------------- /test/idn_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "idnx/ruby" 5 | 6 | class IdnTest < Minitest::Test 7 | def test_to_punycode 8 | idnname = Idnx.to_punycode("bücher.ch") 9 | 10 | assert idnname == "xn--bcher-kva.ch", "waiting for idn version, instead got '#{idnname}'" 11 | end 12 | 13 | def test_to_punycode_ascii 14 | idnname = Idnx.to_punycode("google.ch") 15 | 16 | assert idnname == "google.ch", "waiting for 'google.ch', instead got '#{idnname}'" 17 | end 18 | 19 | def test_to_punycode_error 20 | error_pattern = FFI::Platform.windows? ? /invalid name was supplied/ : /domain name longer than/ 21 | error = assert_raises(Idnx::Error) do 22 | Idnx.to_punycode("ü" * 2000) 23 | end 24 | assert error_pattern =~ error.message, "expect \"#{error.message}\" to contain \"#{error_pattern}\"" 25 | end 26 | 27 | def test_native_lookup 28 | idnname = Idnx::Ruby.lookup("bücher.ch") 29 | 30 | assert idnname == "xn--bcher-kva.ch", "waiting for idn version, instead got '#{idnname}'" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "idnx" 5 | 6 | require "minitest/autorun" 7 | --------------------------------------------------------------------------------