vcard, match: read \N as a line break, and an empty likeness as alike
Two more the adversarial pass found, each now a property. RFC 2426
section 4's ESCAPED-CHAR spells the line-break escape "\n" or "\N",
and unescape read only the first, so a "\N" came back as a backslash
and an N, which a re-escape then doubled. And Match's likeness of two
empty values was 0/0: two cards sharing an email with nothing before
its @ (or no @ at all) made a NaN that sorting the candidates raised
on.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index 0c9b89d..21d8fc0 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -82,8 +82,13 @@ module ProTacts
ours.product(theirs).map { |a, b| likeness_of(a, b) }.max || 0.0
end
+ # A value they share is wholly alike, the empty one included: two
+ # addresses with nothing before the @ at one domain are one
+ # address, not a 0/0.
#: (String a, String b) -> Float
def self.likeness_of(a, b)
+ return 1.0 if a == b
+
1.0 - distance(a, b).fdiv([a.length, b.length].max.to_i)
end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index a02aaa8..d8df766 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -41,13 +41,14 @@ module ProTacts
end
# escape's inverse: unescapes backslash, the component separators, and
- # the `\n` line-break escape (RFC 2426 section 2.4.2). Nothing that
+ # the line-break escape in either case, `\n` or `\N` (RFC 2426
+ # section 4's ESCAPED-CHAR; escape writes the first). Nothing that
# serves a card needs this — a served card is the stored bytes going
# out unparsed — but a reader of values does (Parser::Property#text
# and #components).
#: (String text) -> String
def self.unescape(text)
- text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
+ text.gsub(/\\[\\;,nN]/) { it.casecmp?("\\n") ? "\n" : it[1..].to_s }
end
# Takes Apple's own label wrapping back off: the vocabulary Contacts
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
index 2d0dd04..1860532 100644
--- a/test/pro_tacts/import/test_match.rb
+++ b/test/pro_tacts/import/test_match.rb
@@ -161,4 +161,23 @@ class ImportMatchTest < Minitest::Test
assert_equal digits.chars.last(Match::PHONE_DIGITS).join, Match.phone(spelled + tc.draw(from_regex("[^0-9]{0,3}", fullmatch: true)))
end
end
+
+ # Emails as a card might carry them: real addresses, ones sharing a
+ # local part or a domain, and the malformed — no @, nothing before it.
+ def email = one_of(emails, sampled_from(["ada@example.com", "ADA@example.com", "@example.com", "ada", ""]), text(max_size: 8))
+
+ def phone = one_of(sampled_from(["+1 555 0100", "(555) 0100", "n/a", ""]), text(max_size: 8))
+
+ # A likeness is a share, between 0 for nothing alike and 1 for a value
+ # both share, whatever the cards hold — and so the ranking over them
+ # always has numbers to sort.
+ def test_every_likeness_is_a_share
+ Hegel.test(test_cases: 1_000) do |tc|
+ lines = ->(name, values) { values.map { "#{name}:#{ProTacts::VCard.escape(it)}" } }
+ arriving = contact("ada", tc.draw(text(max_size: 8)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
+ stored = contact("bob", tc.draw(text(max_size: 8)).gsub(/[\r\n]/, ""), *lines.("EMAIL", tc.draw(arrays(email, max_size: 2))), *lines.("TEL", tc.draw(arrays(phone, max_size: 2))))
+
+ assert Match.likeness(arriving, stored).all? { (0.0..1.0).cover?(it) }, Match.likeness(arriving, stored).inspect
+ end
+ end
end
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 28cb73d..256263e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -103,6 +103,18 @@ class VCardTest < Minitest::Test
end
end
+ # RFC 2426 section 4 spells the line-break escape two ways, "\n" and
+ # "\N", and a value read back is the text written whichever spelling
+ # each line break arrived in.
+ def test_either_spelling_of_the_line_break_escape_reads_as_a_line_break
+ Hegel.test(test_cases: 1_000) do |tc|
+ value = tc.draw(escapey_text).gsub(/\r\n|\r/, "\n")
+ escaped = value.chars.map { it == "\n" ? tc.draw(sampled_from(["\\n", "\\N"])) : ProTacts::VCard.escape(it) }.join
+
+ assert_equal value, ProTacts::VCard.unescape(escaped)
+ end
+ end
+
# The byte-stability a splice relies on: any value, escapes the
# writer never makes included, joins back from its raw components as
# the bytes it was.