contact: report an undecodable PHOTO to Sentry
The rescue had been eating the strict decoder's ArgumentError
silently. Warning grade, and at the read rather than the arrival: no
PUT probe reads PHOTO, and a stored card can predate any report.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 1c288e2..f48d513 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,5 +1,6 @@
require "base64"
require "digest"
+require "sentry-ruby"
require "pro_tacts/birthday"
require "pro_tacts/vcard"
@@ -161,6 +162,15 @@ module ProTacts
# PHOTO, an undecodable one, or one that sniffs to no known format
# — the initials an avatar falls back to, an ordinary absence
# rather than an error, exactly like the other accessors' nils.
+ # The undecodable nil is the one departure, and reports on its way
+ # there — warning-grade, carrying no card content — because a
+ # PHOTO this server cannot read is news, the URI form included:
+ # legal vCard (RFC 2426 section 3.1.4) that no client here sends
+ # yet. At the read rather than the arrival, unlike the parser's
+ # reports (Web#report_broken_assumptions): no arrival probe reads
+ # PHOTO, and a stored card can predate the report, while the
+ # decode runs only where an avatar renders and Sentry groups the
+ # repeats into one issue.
#: () -> Photo?
def photo
property = properties.find { it.name.casecmp?("PHOTO") }
@@ -168,7 +178,8 @@ module ProTacts
begin
bytes = Base64.strict_decode64(property.value)
- rescue ArgumentError
+ rescue ArgumentError => error
+ Sentry.capture_exception(error, level: :warning)
return
end
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 198f223..919beef 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -1,5 +1,7 @@
-# Sentry is reached at exactly two points: the exception-capturing
-# middleware web.rb mounts, and the message the not_found handler sends.
+# Sentry is reached wherever a handler has something to report: the
+# exception-capturing middleware web.rb mounts, the 404 and arrival
+# messages web.rb and store.rb send, and the photo decode refusal
+# contact.rb captures.
#
# Events are left untyped. SentryScrubber walks whatever before_send
# hands it, feature-detecting as it goes, and typing that shape would
@@ -7,6 +9,8 @@
module Sentry
def self.capture_message: (String message, ?level: Symbol) -> untyped
+ def self.capture_exception: (Exception exception, ?level: Symbol) -> untyped
+
module Rack
class CaptureExceptions
end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 2e730c7..abeb54e 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -8,6 +8,9 @@ require "pro_tacts/contact"
require "pro_tacts/vcard"
class ContactTest < Minitest::Test
+ include Sentry::TestHelper
+ include SentryMessages
+
CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nEND:VCARD\r\n"
STRUCTURED = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
@@ -24,6 +27,16 @@ class ContactTest < Minitest::Test
ProTacts::Contact.for(id:, stored: ProTacts::VCard.new(vcard), birthday:)
end
+ # The decode's refusal reports (see #photo); the transport this pins
+ # holds it for the assertions below, and teardown clears it.
+ def setup
+ setup_sentry
+ end
+
+ def teardown
+ teardown_sentry_test
+ end
+
def test_the_etag_hashes_the_card
assert_equal %("#{Digest::SHA256.hexdigest(CARD)}"), contact.etag
end
@@ -260,6 +273,31 @@ class ContactTest < Minitest::Test
assert_nil contact(not_an_image).photo
end
+ # The departure from the accessor's ordinary nils: a value the
+ # strict decoder refuses reports on its way to nil — warning-grade,
+ # bad-input level, no card content — so a PHOTO this server cannot
+ # read is news rather than initials with no why.
+ def test_an_undecodable_photo_reports_the_refusal
+ undecodable = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:not base64!!\r\n")
+
+ assert_nil contact(undecodable).photo
+
+ assert_equal 1, sentry_events.length
+ event = sentry_events.fetch(0)
+ assert_equal :warning, event.level
+ assert_equal "ArgumentError", event.exception.values.first.type
+ end
+
+ # The other nils stay quiet: an image that decodes but sniffs to no
+ # known format is an allowlist gap with no exception to name, an
+ # ordinary absence exactly like no PHOTO at all.
+ def test_a_photo_of_no_known_format_stays_quiet
+ unknown = CARD.sub("FN:Aiden\r\n", "PHOTO;ENCODING=b:#{Base64.strict_encode64("RIFF____WEBP")}\r\n")
+
+ assert_nil contact(unknown).photo
+ assert_empty sentry_events
+ end
+
# One PHOTO property carrying a PNG payload, for the sniff's rows.
def photo_property(payload)
"PHOTO;ENCODING=b;TYPE=JPEG:#{Base64.strict_encode64(payload)}\r\n"