Skip to content

Commit bb16683

Browse files
author
Tod Beardsley
committed
Land rapid7#2087, @egypt's random ID generator
2 parents 173661c + 4cc179a commit bb16683

File tree

2 files changed

+291
-0
lines changed

2 files changed

+291
-0
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
2+
# A quick way to produce unique random strings that follow the rules of
3+
# identifiers, i.e., begin with a letter and contain only alphanumeric
4+
# characters and underscore.
5+
#
6+
# The advantage of using this class over, say, {Rex::Text.rand_text_alpha}
7+
# each time you need a new identifier is that it ensures you don't have
8+
# collisions.
9+
#
10+
# @example
11+
# vars = Rex::RandomIdentifierGenerator.new
12+
# asp_code = <<-END_CODE
13+
# Sub #{vars[:func]}()
14+
# Dim #{vars[:fso]}
15+
# Set #{vars[:fso]} = CreateObject("Scripting.FileSystemObject")
16+
# ...
17+
# End Sub
18+
# #{vars[:func]}
19+
# END_CODE
20+
#
21+
class Rex::RandomIdentifierGenerator
22+
23+
# Raised when a RandomIdentifierGenerator cannot create any more
24+
# identifiers without collisions.
25+
class ExhaustedSpaceError < StandardError; end
26+
27+
# Default options
28+
DefaultOpts = {
29+
# Arbitrary
30+
:max_length => 12,
31+
:min_length => 3,
32+
# This should be pretty universal for identifier rules
33+
:char_set => Rex::Text::AlphaNumeric+"_",
34+
:first_char_set => Rex::Text::LowerAlpha
35+
}
36+
37+
# @param opts [Hash] Options, see {DefaultOpts} for default values
38+
# @option opts :max_length [Fixnum]
39+
# @option opts :min_length [Fixnum]
40+
# @option opts :char_set [String]
41+
def initialize(opts={})
42+
# Holds all identifiers.
43+
@value_by_name = {}
44+
# Inverse of value_by_name so we can ensure uniqueness without
45+
# having to search through the whole list of values
46+
@name_by_value = {}
47+
48+
@opts = DefaultOpts.merge(opts)
49+
if @opts[:min_length] < 1 || @opts[:max_length] < 1 || @opts[:max_length] < @opts[:min_length]
50+
raise ArgumentError, "Invalid length options"
51+
end
52+
53+
# This is really just the maximum number of shortest names. This
54+
# will still be a pretty big number most of the time, so don't
55+
# bother calculating the real one, which will potentially be
56+
# expensive, since we're talking about a 36-digit decimal number to
57+
# represent the total possibilities for the range of 10- to
58+
# 20-character identifiers.
59+
#
60+
# 26 because the first char is lowercase alpha, (min_length - 1) and
61+
# not just min_length because it includes that first alpha char.
62+
@max_permutations = 26 * (@opts[:char_set].length ** (@opts[:min_length]-1))
63+
# The real number of permutations could be calculated thusly:
64+
#((@opts[:min_length]-1) .. (@opts[:max_length]-1)).reduce(0) { |a, e|
65+
# a + (26 * @opts[:char_set].length ** e)
66+
#}
67+
end
68+
69+
# Return a unique random identifier for +name+, generating a new one
70+
# if necessary.
71+
#
72+
# @param name [Symbol] A descriptive, intention-revealing name for an
73+
# identifier. This is what you would normally call the variable if
74+
# you weren't generating it.
75+
# @return [String]
76+
def get(name)
77+
return @value_by_name[name] if @value_by_name[name]
78+
79+
@value_by_name[name] = generate
80+
@name_by_value[@value_by_name[name]] = name
81+
82+
@value_by_name[name]
83+
end
84+
alias [] get
85+
86+
# Add a new identifier. Its name will be checked for uniqueness among
87+
# previously-generated names.
88+
#
89+
# @note This should be called *before* any calls to {#get} to avoid
90+
# potential collisions. If you do hit a collision, this method will
91+
# raise.
92+
#
93+
# @param name (see #get)
94+
# @param value [String] The identifier that will be returned by
95+
# subsequent calls to {#get} with the sane +name+.
96+
# @raise RuntimeError if +value+ already exists
97+
# @return [void]
98+
def store(name, value)
99+
100+
case @name_by_value[value]
101+
when name
102+
# we already have this value and it is associated with this name
103+
# nothing to do here
104+
when nil
105+
# don't have this value yet, so go ahead and just insert
106+
@value_by_name[name] = value
107+
@name_by_value[value] = name
108+
else
109+
# then the caller is trying to insert a duplicate
110+
raise RuntimeError, "Value is not unique!"
111+
end
112+
113+
self
114+
end
115+
116+
# Create a random string that satisfies most languages' requirements
117+
# for identifiers. In particular, with a default configuration, the
118+
# first character will always be lowercase alpha (unless modified by a
119+
# block), and the whole thing will contain only a-zA-Z0-9_ characters.
120+
#
121+
# If called with a block, the block will be given the identifier before
122+
# uniqueness checks. The block's return value will be the new
123+
# identifier. Note that the block may be called multiple times if it
124+
# returns a non-unique value.
125+
#
126+
# @note Calling this method with a block that returns only values that
127+
# this generator already contains will result in an infinite loop.
128+
#
129+
# @example
130+
# rig = Rex::RandomIdentifierGenerator.new
131+
# const = rig.generate { |val| val.capitalize }
132+
# rig.insert(:SOME_CONSTANT, const)
133+
# ruby_code = <<-EOC
134+
# #{rig[:SOME_CONSTANT]} = %q^generated ruby constant^
135+
# def #{rig[:my_method]}; ...; end
136+
# EOC
137+
#
138+
# @param len [Fixnum] Avoid setting this unless a specific size is
139+
# necessary. Default is random within range of min .. max
140+
# @return [String] A string that matches <tt>[a-z][a-zA-Z0-9_]*</tt>
141+
# @yield [String] The identifier before uniqueness checks. This allows
142+
# you to modify the value and still avoid collisions.
143+
def generate(len=nil)
144+
raise ArgumentError, "len must be positive integer" if len && len < 1
145+
raise ExhaustedSpaceError if @value_by_name.length >= @max_permutations
146+
147+
# pick a random length within the limits
148+
len ||= rand(@opts[:min_length] .. (@opts[:max_length]))
149+
150+
ident = ""
151+
152+
# XXX: Infinite loop if block returns only values we've already
153+
# generated.
154+
loop do
155+
ident = Rex::Text.rand_base(1, "", @opts[:first_char_set])
156+
ident << Rex::Text.rand_base(len-1, "", @opts[:char_set])
157+
if block_given?
158+
ident = yield ident
159+
end
160+
# Try to make another one if it collides with a previously
161+
# generated one.
162+
break unless @name_by_value.key?(ident)
163+
end
164+
165+
ident
166+
end
167+
168+
end
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
require 'spec_helper'
2+
require 'rex/random_identifier_generator'
3+
4+
describe Rex::RandomIdentifierGenerator do
5+
let(:options) do
6+
{ :min_length => 10, :max_length => 20 }
7+
end
8+
9+
subject(:rig) { described_class.new(options) }
10+
11+
it { should respond_to(:generate) }
12+
it { should respond_to(:[]) }
13+
it { should respond_to(:get) }
14+
15+
describe "#generate" do
16+
it "should respect :min_length" do
17+
1000.times do
18+
rig.generate.length.should >= options[:min_length]
19+
end
20+
end
21+
22+
it "should respect :max_length" do
23+
1000.times do
24+
rig.generate.length.should <= options[:max_length]
25+
end
26+
end
27+
28+
it "should allow mangling in a block" do
29+
ident = rig.generate { |identifier| identifier.upcase }
30+
ident.should match(/\A[A-Z0-9_]*\Z/)
31+
32+
ident = subject.generate { |identifier| identifier.downcase }
33+
ident.should match(/\A[a-z0-9_]*\Z/)
34+
35+
ident = subject.generate { |identifier| identifier.gsub("A","B") }
36+
ident.should_not include("A")
37+
end
38+
end
39+
40+
describe "#get" do
41+
let(:options) do
42+
{ :min_length=>3, :max_length=>3 }
43+
end
44+
it "should return the same thing for subsequent calls" do
45+
rig.get(:rspec).should == rig.get(:rspec)
46+
end
47+
it "should not return the same for different names" do
48+
# Statistically...
49+
count = 1000
50+
a = Set.new
51+
count.times do |n|
52+
a.add rig.get(n)
53+
end
54+
a.size.should == count
55+
end
56+
57+
context "with an exhausted set" do
58+
let(:options) do
59+
{ :char_set => "abcd", :min_length=>2, :max_length=>2 }
60+
end
61+
let(:max_permutations) do
62+
# 26 because first char is hardcoded to be lowercase alpha
63+
26 * (options[:char_set].length ** options[:min_length])
64+
end
65+
66+
it "doesn't infinite loop" do
67+
Timeout.timeout(1) do
68+
expect {
69+
(max_permutations + 1).times { |i| rig.get(i) }
70+
}.to raise_error(Rex::RandomIdentifierGenerator::ExhaustedSpaceError)
71+
# don't rescue TimeoutError here because we want that to be a
72+
# failure case
73+
end
74+
end
75+
76+
end
77+
78+
end
79+
80+
describe "#store" do
81+
let(:options) do
82+
{ :char_set => "abcd", :min_length=>8, :max_length=>20 }
83+
end
84+
85+
it "should allow smaller than minimum length" do
86+
value = "a"*(options[:min_length]-1)
87+
rig.store(:spec, value)
88+
rig.get(:spec).should == value
89+
end
90+
91+
it "should allow bigger than maximum length" do
92+
value = "a"*(options[:max_length]+1)
93+
rig.store(:spec, value)
94+
rig.get(:spec).should == value
95+
end
96+
97+
it "should raise if value is not unique" do
98+
value = "a"*(options[:max_length]+1)
99+
rig.store(:spec0, value)
100+
rig.get(:spec0).should == value
101+
expect { rig.store(:spec1, value) }.to raise_error
102+
end
103+
104+
it "should overwrite a previously stored value" do
105+
orig_value = "a"*(options[:max_length])
106+
rig.store(:spec, orig_value)
107+
rig.get(:spec).should == orig_value
108+
109+
new_value = "b"*(options[:max_length])
110+
rig.store(:spec, new_value)
111+
rig.get(:spec).should == new_value
112+
end
113+
114+
it "should overwrite a previously generated value" do
115+
rig.get(:spec)
116+
117+
new_value = "a"*(options[:max_length])
118+
rig.store(:spec, new_value)
119+
rig.get(:spec).should == new_value
120+
end
121+
122+
end
123+
end

0 commit comments

Comments
 (0)