store: mint group ids through ChangeId
The k-to-z mint moved out of Store and takes a length instead of four;
it draws with alphanumeric, so an odd width spells as well as it did before.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/lib/pro_tacts/change_id.rb b/lib/pro_tacts/change_id.rb
new file mode 100644
index 0000000..7e07e6d
--- /dev/null
+++ b/lib/pro_tacts/change_id.rb
@@ -0,0 +1,18 @@
+require "securerandom"
+
+module ProTacts
+ # Ids in the shape jj spells a change id: letters from k to z, so an id
+ # is never a hash and never a word its author meant. Minted rather
+ # than chosen, which is what lets a person read one
+ # (db/migrations/005_group_identity.rb).
+ module ChangeId
+ ALPHABET = "klmnopqrstuvwxyz".chars.freeze #: Array[String]
+
+ # SecureRandom rather than rand: the draws have to be independent of
+ # anything a caller can observe or seed.
+ #: (Integer length) -> String
+ def self.mint(length)
+ SecureRandom.alphanumeric(length, chars: ALPHABET)
+ end
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 0b35349..696bbde 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,12 +1,12 @@
require "date"
require "pathname"
-require "securerandom"
require "sequel"
require "sentry-ruby"
require "pro_tacts/birthday"
require "pro_tacts/birthday_line"
require "pro_tacts/card_diff"
+require "pro_tacts/change_id"
require "pro_tacts/contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -134,12 +134,6 @@ module ProTacts
# ordinary, and SQLITE_BUSY straight back to the client is not.
BUSY_TIMEOUT = 5_000 #: Integer
- # Hex in the letters jj renders a change id with: 0 is z, f is k,
- # and every digit lands in k-z, so an id is never a hash and never
- # a word its author meant. `tr` maps the alphabets in one pass, in
- # the order this pair is written.
- REVERSE_HEX = "zyxwvutsrqponmlk" #: String
-
# How many ids a create draws before giving up. Four letters is one
# of 65,536 and an address book holds groups by the dozen, so a
# first collision is already unlucky and a run of eight is a broken
@@ -588,7 +582,7 @@ module ProTacts
#: (?name: String?) -> String
def create_group(name: nil)
GROUP_ID_ATTEMPTS.times do
- id = next_group_id
+ id = ChangeId.mint(4)
begin
groups.insert(id:, name:)
return id
@@ -798,15 +792,6 @@ module ProTacts
@database[:group_properties]
end
- # Four hex digits spelled in REVERSE_HEX. SecureRandom rather than
- # rand: the draws have to be independent of anything a caller can
- # observe or seed, and two bytes of it is exactly the four digits
- # the id is wide.
- #: () -> String
- def next_group_id
- SecureRandom.hex(2).tr("0-9a-f", REVERSE_HEX)
- end
-
# The diff comes in already computed rather than being taken here
# off a `before` and an `after`: only the caller knows which two
# cards its write was between, and a delete's `after` is nothing at
diff --git a/test/pro_tacts/test_change_id.rb b/test/pro_tacts/test_change_id.rb
new file mode 100644
index 0000000..07f443c
--- /dev/null
+++ b/test/pro_tacts/test_change_id.rb
@@ -0,0 +1,15 @@
+require_relative "../test_helper"
+
+require "pro_tacts/change_id"
+
+class ChangeIdTest < Minitest::Test
+ def test_an_id_is_as_many_letters_from_k_to_z_as_asked_for
+ [1, 4, 7, 32].each do |length|
+ assert_match(/\A[k-z]{#{length}}\z/, ProTacts::ChangeId.mint(length))
+ end
+ end
+
+ def test_the_draws_cover_the_whole_alphabet
+ assert_equal ProTacts::ChangeId::ALPHABET.sort, ProTacts::ChangeId.mint(1_000).chars.uniq.sort
+ end
+end