Another Password Generator with Spec
A while ago, I tinkered with using data.generators
and test.check
for generating random data. Here's a link with more info , and here's an example in clojurescript.
A few months ago it was announced that clojure 1.9 will have a new core library called clojure.spec.
I'm just wrapping my brain around it, but clojure.spec seems really useful.
This post is a description of how I created a spec for a random password generator.
For this example, here are the requirements for the passwords we'll be spec'ing:
- Must contain at least 2 lowercase letters
- Must contain at least 2 uppercase letters
- Must contain at least 2 digits
- Must contain at least 2 of the following symbols:
!
,$
,^
,&
- Must be at least 10 chars long but no longer than 15
Spec Functions
A spec is simply a function that takes one argument and returns true or false, a.k.a, a predicate function.
So, let's try to create a spec for the first lowercase letter rule, shall we?
(s/def ::two-lowers #(re-matches #".*[a-z]+.*[a-z]+.*" %))
(s/valid? ::two-lowers "1234") ;;=> false
(s/valid? ::two-lowers "12b34a") ;;=> true
What's really cool is that these spec functions can be used to create generator functions using s/gen
. So in theory, once you've written a spec function, you'll get a generator function for free!
(gen/generate (s/gen ::two-lowers))
;;=> clojure.lang.ExceptionInfo: Unable to construct gen at: [] for: :boot.user/two-lowers
Oops, hmm, that didn't work. So, as you can see, s/gen
can't always figure out how to create a generator function from a spec. It really only understands how to randomly generate primitive data. So, in this case, by hinting that the password should always be a string, s/gen
is then able to start cranking out random strings and eventually can find one that satisfies the spec:
(s/def ::two-lowers
(s/and string? #(re-matches #".*[a-z]+.*[a-z]+.*" %)))
(gen/generate (s/gen ::two-lowers))
;;=> "lErTQG3Jb26d4575S46VV8"
Nice! A string with at least 2 lower case letters. Pretty cool, right?
Here's another way I came up with to write the same spec. I like this version better because it has the added bonus of ensuring that the two lower case letters chosen are unique.
(def char-lower? (into #{} (map char (range 97 122))))
(s/def ::two-lowers
(s/and string? #(<= 2 (count (filter char-lower? (into #{} %))))))
(gen/generate (s/gen ::two-lowers))
;;=> "9ob4Kej25Rz6259CD"
Ok, that looks nice. Next we need to write specs for the rest of the rules:
(def char-upper? (into #{} (map char (range 65 91))))
(s/def ::two-uppers
(s/and string? #(<= 2 (count (filter char-upper? (into #{} %))))))
(s/valid? ::two-uppers "AB") ;;=> true
(gen/generate (s/gen ::two-uppers)) ;;=> "zjxjglIn282U3HNjZg"
(def char-digit? (into #{} (map char (range 48 58))))
(s/def ::two-digits
(s/and string? #(<= 2 (count (filter char-digit? (into #{} %))))))
(s/valid? ::two-digits "12") ;;=> true
(gen/generate (s/gen ::two-digits)) ;;=> "fWC10nU0C80IvJ0a3fjR6Z6LVV4f"
(def char-symbol? #{\! \$ \^ \&})
(s/def ::two-symbols
(s/and string? #(<= 2 (count (filter char-symbol? (into #{} %))))))
(s/valid? ::two-symbols "$!") ;;=> true
But, oh no! Look what happens when I try to generate examples data from the ::two-symbols
spec:
(gen/generate (s/gen ::two-symbols))
;;=> clojure.lang.ExceptionInfo: Couldn't satisfy such-that predicate after 100 tries.
The generator function created from s/gen
tried 100 times to generate a string that matches this spec, but couldn't do it.
When this happens, it means the basic, primitive generators that s/gen
has access too just won't cut it. We need to write our own custom generator.
Personally, I've found writing generators is a little bit of a challenge. You have to think a little differently than when writing "normal" functions. The best way to get better at it (for me, anyway) is just to try writing a bunch of them.
Here's what I came up with:
(defn gen-two-symbols []
(gen/fmap (fn [[a b]] (apply str (shuffle (concat a b))))
(gen/tuple (gen/vector-distinct (gen/elements char-symbol?)
{:min-elements 2})
(gen/vector (gen/char-alphanumeric)))))
(gen/generate (gen-two-symbols)) ;;=> "w$IhRo493&DNp!Sd40^8"
Next, this custom generator can be registered with ::two-symbols
via the with-gen
function:
(s/def ::two-symbols
(s/with-gen
(s/and string? #(<= 2 (count (filter char-symbol? (into #{} %)))))
gen-two-symbols))
(gen/generate (s/gen ::two-symbols)) ;;=> "J8I^H&n$Tk0k!i"
Sweet!
We're heading down the home stretch.
Next, I used the s/int-in-range
function to create a spec which ensures that the length of the password is between 10 and 15 characters long.
(s/def ::10-to-15-chars (s/and string? #(s/int-in-range? 10 16 (count %))))
(s/valid? ::10-to-15-chars "012345678") ;;=> false
(s/valid? ::10-to-15-chars "0123456789") ;;=> true
(gen/generate (s/gen ::10-to-15-chars)) ;;=> "LyJ5BU9r57Vr3H"
I love how those specs read almost like they would be spoken out loud.
It's time to combine all these into one spec for the password and hold our breath!
(s/def ::password
(s/and ::two-lowers
::two-uppers
::two-digits
::two-symbols
::10-to-15-chars))
(s/valid? ::password "abCD12$!34") ;;=> true
Woohoo!
But wait. Look a this:
(gen/generate (s/gen ::password))
clojure.lang.ExceptionInfo: Couldn't satisfy such-that predicate after 100 tries.
That's a bummer. But we know how to fix that, right? Yep, another custom generator.
(defn gen-chars [& args]
(apply gen/vector
(concat [(gen/one-of [(gen/elements char-lower?)
(gen/elements char-upper?)
(gen/elements char-digit?)
(gen/elements char-symbol?)])] args)))
(defn gen-password []
(gen/fmap (fn [[a b c d e]]
(let [;; make sure we satisfy the rules
rules (concat a b c d)
;; replace first chars in random string with rules
end (concat rules (subvec e (- (count rules) 1)))]
(apply str (shuffle end))))
(gen/tuple (gen/vector-distinct (gen/elements char-lower?)
{:num-elements 2})
(gen/vector-distinct (gen/elements char-upper?)
{:num-elements 2})
(gen/vector-distinct (gen/elements char-digit?)
{:num-elements 2})
(gen/vector-distinct (gen/elements char-symbol?)
{:num-elements 2})
;; generate between 10 and 15 chars
(gen-chars 10 15))))
That generator is pretty big and intimidating, so here's some explanation:
The gen-chars
is a helper generator to generate lists of valid characters.
Then, in gen-password
, I first generate 2 of each of the required types of characters (which results in 8 characters that I bind to a variable rules
), then I generate a random string of 10-15 chars using gen-chars
which I bind to a variable end
. The final step is to replace the first 8 chars in end
with the 8 chars from rules
string and shuffle them all up.
Now we can associate this custom generator with ::password
for the grand finale.
(s/def ::password
(s/with-gen
(s/and ::two-lowers
::two-uppers
::two-digits
::two-symbols
::10-to-15-chars)
gen-password))
(gen/generate (s/gen ::password))
;;=> "^22T$^cCM!Yv$5^"
(s/valid? ::password (gen/generate (s/gen ::password)))
;;=> true
How cool is that?!
Next up: I want to implement this as a clojurescript widget and also learn more about how to use clojure.spec.test
to run tests against specs such as this one.