diff --git a/app/models/gem_typo.rb b/app/models/gem_typo.rb new file mode 100644 index 00000000000..d0585e286cc --- /dev/null +++ b/app/models/gem_typo.rb @@ -0,0 +1,42 @@ +require "rubygems/text" + +class GemTypo + attr_reader :protected_gem + + include Gem::Text + + DOWNLOADS_THRESHOLD = 10_000_000 + SIZE_THRESHOLD = 4 + + def initialize(rubygem_name) + @rubygem_name = rubygem_name.downcase + @distance_threshold = distance_threshold + end + + def protected_typo? + return false if @rubygem_name.size < GemTypo::SIZE_THRESHOLD + + protected_gems.each do |protected_gem| + distance = levenshtein_distance(@rubygem_name, protected_gem) + if distance <= @distance_threshold + @protected_gem = protected_gem + return true + end + end + + false + end + + private + + def distance_threshold + @rubygem_name.size == GemTypo::SIZE_THRESHOLD ? 1 : 2 + end + + def protected_gems + Rubygem.joins(:gem_download) + .where("gem_downloads.count > ?", GemTypo::DOWNLOADS_THRESHOLD) + .where.not(name: @rubygem_name) + .pluck(:name) + end +end diff --git a/app/models/rubygem.rb b/app/models/rubygem.rb index 9388d0fe95e..49f6b9ddce5 100644 --- a/app/models/rubygem.rb +++ b/app/models/rubygem.rb @@ -18,6 +18,7 @@ class Rubygem < ApplicationRecord uniqueness: { case_sensitive: false }, if: :needs_name_validation? validate :blacklist_names_exclusion + validate :protected_gem_typo, on: :create after_create :update_unresolved before_destroy :mark_unresolved @@ -308,6 +309,13 @@ def blacklist_names_exclusion errors.add :name, "'#{name}' is a reserved gem name." end + def protected_gem_typo + gem_typo = GemTypo.new(name) + + return unless gem_typo.protected_typo? + errors.add :name, "'#{name}' is too close to typo-protected gem: #{gem_typo.protected_gem}" + end + def update_unresolved Dependency.where(unresolved_name: name).find_each do |dependency| dependency.update_resolved(self) diff --git a/test/functional/api/v1/rubygems_controller_test.rb b/test/functional/api/v1/rubygems_controller_test.rb index 0a9f3076502..2a2108fb394 100644 --- a/test/functional/api/v1/rubygems_controller_test.rb +++ b/test/functional/api/v1/rubygems_controller_test.rb @@ -308,6 +308,20 @@ def self.should_respond_to(format) end end + context "On POST to create with a protected gem name" do + setup do + above_downloads_thres = GemTypo::DOWNLOADS_THRESHOLD + 1 + create(:rubygem, name: "best", downloads: above_downloads_thres) + post :create, body: gem_file("test-1.0.0.gem").read + end + + should respond_with :forbidden + should "not register new gem" do + assert_equal 1, Rubygem.count + assert_equal "There was a problem saving your gem: Name 'test' is too close to typo-protected gem: best", @response.body + end + end + context "On POST to create for someone else's gem" do setup do @other_user = create(:user) diff --git a/test/unit/gem_typo_test.rb b/test/unit/gem_typo_test.rb new file mode 100644 index 00000000000..7fa51811d95 --- /dev/null +++ b/test/unit/gem_typo_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class GemTypoTest < ActiveSupport::TestCase + context "with above downloads threshold gem" do + setup do + above_downloads_thres = GemTypo::DOWNLOADS_THRESHOLD + 1 + create(:rubygem, name: "four", downloads: above_downloads_thres) + end + + should "return false for exact match" do + gem_typo = GemTypo.new("four") + assert_equal false, gem_typo.protected_typo? + end + + should "return false for gem name size below protected threshold" do + gem_typo = GemTypo.new("fou") + assert_equal false, gem_typo.protected_typo? + end + + context "size equals protected threshold" do + should "return true for one character distance" do + gem_typo = GemTypo.new("fous") + assert_equal true, gem_typo.protected_typo? + end + + should "return false for two character distance" do + gem_typo = GemTypo.new("foss") + assert_equal false, gem_typo.protected_typo? + end + end + + context "size above protected threshold" do + should "return true for two character distance" do + gem_typo = GemTypo.new("fourss") + assert_equal true, gem_typo.protected_typo? + end + + should "return false for three characher distance" do + gem_typo = GemTypo.new("foursss") + assert_equal false, gem_typo.protected_typo? + end + end + end +end