1use serde::{Deserialize, Serialize};
9
10use crate::escalation::TransportTier;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ProfileEvidence {
15 pub kind: ProfileEvidenceKind,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub field: Option<String>,
20 pub value: String,
22 pub source: EvidenceSource,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ProfileEvidenceKind {
30 Username,
32 DisplayName,
34 Bio,
36 AvatarUrl,
38 AvatarHash,
40 ExternalLink,
42 Location,
44 JoinedDate,
46 ProfileTitle,
48 MetaDescription,
50 ExtractedField,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct EvidenceSource {
57 pub site: String,
59 pub url: String,
61 pub origin: EvidenceOrigin,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub observed_at_ms: Option<u64>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub access_path: Option<EvidenceAccessPath>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct EvidenceAccessPath {
77 pub transport: TransportTier,
79 #[serde(default, skip_serializing_if = "is_false")]
82 pub escalated: bool,
83 #[serde(default, skip_serializing_if = "is_false")]
85 pub authenticated: bool,
86 #[serde(default, skip_serializing_if = "is_false")]
89 pub session_required: bool,
90}
91
92impl EvidenceAccessPath {
93 #[must_use]
95 pub const fn new(transport: TransportTier, escalations: u8, authenticated: bool) -> Self {
96 Self {
97 transport,
98 escalated: escalations > 0,
99 authenticated,
100 session_required: false,
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum EvidenceOrigin {
109 Signal,
111 Extractor,
113 Derived,
116}
117
118impl ProfileEvidence {
119 #[must_use]
121 pub fn from_enrichment(site: &str, url: &str, field: &str, value: &str) -> Self {
122 Self::from_enrichment_with_source(site, url, field, value, None, None)
123 }
124
125 #[must_use]
128 pub fn from_enrichment_with_source(
129 site: &str,
130 url: &str,
131 field: &str,
132 value: &str,
133 observed_at_ms: Option<u64>,
134 access_path: Option<EvidenceAccessPath>,
135 ) -> Self {
136 Self {
137 kind: ProfileEvidenceKind::from_field(field),
138 field: Some(field.to_owned()),
139 value: value.to_owned(),
140 source: EvidenceSource {
141 site: site.to_owned(),
142 url: url.to_owned(),
143 origin: EvidenceOrigin::Extractor,
144 observed_at_ms,
145 access_path,
146 },
147 }
148 }
149
150 #[must_use]
153 pub fn from_signal_username(
154 site: &str,
155 url: &str,
156 username: &str,
157 observed_at_ms: Option<u64>,
158 access_path: Option<EvidenceAccessPath>,
159 ) -> Self {
160 Self {
161 kind: ProfileEvidenceKind::Username,
162 field: None,
163 value: username.to_owned(),
164 source: EvidenceSource {
165 site: site.to_owned(),
166 url: url.to_owned(),
167 origin: EvidenceOrigin::Signal,
168 observed_at_ms,
169 access_path,
170 },
171 }
172 }
173
174 #[must_use]
177 pub fn from_avatar_hash(
178 site: &str,
179 url: &str,
180 hash: &str,
181 observed_at_ms: Option<u64>,
182 access_path: Option<EvidenceAccessPath>,
183 ) -> Self {
184 Self {
185 kind: ProfileEvidenceKind::AvatarHash,
186 field: Some("avatar_hash".to_owned()),
187 value: hash.to_owned(),
188 source: EvidenceSource {
189 site: site.to_owned(),
190 url: url.to_owned(),
191 origin: EvidenceOrigin::Derived,
192 observed_at_ms,
193 access_path,
194 },
195 }
196 }
197}
198
199impl ProfileEvidenceKind {
200 #[must_use]
202 pub fn from_field(field: &str) -> Self {
203 match field {
204 "name" | "display_name" | "fullname" | "full_name" => Self::DisplayName,
205 "bio" | "description" => Self::Bio,
206 "avatar" | "avatar_url" | "image" | "profile_image" => Self::AvatarUrl,
207 "avatar_hash" | "avatar_perceptual_hash" | "profile_image_hash" => Self::AvatarHash,
208 "website" | "url" | "link" | "external_url" => Self::ExternalLink,
209 "location" => Self::Location,
210 "joined" | "created" | "created_at" | "join_date" => Self::JoinedDate,
211 "title" => Self::ProfileTitle,
212 "meta_description" | "og_description" => Self::MetaDescription,
213 _ => Self::ExtractedField,
214 }
215 }
216}
217
218#[allow(clippy::trivially_copy_pass_by_ref)]
219fn is_false(value: &bool) -> bool {
220 !*value
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn maps_common_enrichment_fields_to_profile_evidence_kinds() {
229 assert_eq!(
230 ProfileEvidenceKind::from_field("name"),
231 ProfileEvidenceKind::DisplayName
232 );
233 assert_eq!(
234 ProfileEvidenceKind::from_field("avatar"),
235 ProfileEvidenceKind::AvatarUrl
236 );
237 assert_eq!(
238 ProfileEvidenceKind::from_field("avatar_hash"),
239 ProfileEvidenceKind::AvatarHash
240 );
241 assert_eq!(
242 ProfileEvidenceKind::from_field("website"),
243 ProfileEvidenceKind::ExternalLink
244 );
245 assert_eq!(
246 ProfileEvidenceKind::from_field("custom"),
247 ProfileEvidenceKind::ExtractedField
248 );
249 }
250
251 #[test]
252 fn serializes_profile_evidence_as_snake_case_wire_data() {
253 let ev =
254 ProfileEvidence::from_enrichment("GitHub", "https://github.com/alice", "name", "Alice");
255 let json = serde_json::to_value(&ev).unwrap();
256 assert_eq!(json["kind"], "display_name");
257 assert_eq!(json["field"], "name");
258 assert_eq!(json["source"]["origin"], "extractor");
259 assert!(json["source"].get("observed_at_ms").is_none());
260 assert!(json["source"].get("access_path").is_none());
261 }
262
263 #[test]
264 fn signal_username_evidence_serializes_as_signal_origin_without_field() {
265 let ev = ProfileEvidence::from_signal_username(
266 "GitLab",
267 "https://gitlab.com/api/v4/users?username=alice",
268 "alice",
269 Some(123),
270 Some(EvidenceAccessPath::new(TransportTier::Http, 0, false)),
271 );
272
273 let json = serde_json::to_value(&ev).unwrap();
274 assert_eq!(json["kind"], "username");
275 assert_eq!(json["value"], "alice");
276 assert!(json.get("field").is_none());
277 assert_eq!(json["source"]["origin"], "signal");
278 assert_eq!(json["source"]["observed_at_ms"], 123);
279 assert_eq!(json["source"]["access_path"]["transport"], "http");
280 }
281
282 #[test]
283 fn avatar_hash_evidence_serializes_without_raw_image_data() {
284 let ev = ProfileEvidence::from_avatar_hash(
285 "Example",
286 "https://example.com/alice",
287 "dhash64_v1:0123456789abcdef",
288 Some(456),
289 Some(EvidenceAccessPath::new(TransportTier::Http, 0, false)),
290 );
291
292 let json = serde_json::to_value(&ev).unwrap();
293 assert_eq!(json["kind"], "avatar_hash");
294 assert_eq!(json["field"], "avatar_hash");
295 assert_eq!(json["value"], "dhash64_v1:0123456789abcdef");
296 assert_eq!(json["source"]["origin"], "derived");
297 assert!(!json.to_string().contains("PNG"));
298 }
299
300 #[test]
301 fn old_profile_evidence_json_defaults_missing_source_metadata() {
302 let raw = r#"{
303 "kind": "display_name",
304 "field": "name",
305 "value": "Alice",
306 "source": {
307 "site": "GitHub",
308 "url": "https://github.com/alice",
309 "origin": "extractor"
310 }
311 }"#;
312
313 let ev: ProfileEvidence = serde_json::from_str(raw).unwrap();
314
315 assert_eq!(ev.source.site, "GitHub");
316 assert_eq!(ev.source.observed_at_ms, None);
317 assert_eq!(ev.source.access_path, None);
318 }
319
320 #[test]
321 fn source_metadata_serializes_without_secret_names() {
322 let ev = ProfileEvidence::from_enrichment_with_source(
323 "GitHub",
324 "https://github.com/alice",
325 "name",
326 "Alice",
327 Some(1_800_000_000_000),
328 Some(EvidenceAccessPath::new(TransportTier::Browser, 1, true)),
329 );
330
331 let json = serde_json::to_value(&ev).unwrap();
332
333 assert_eq!(json["source"]["observed_at_ms"], 1_800_000_000_000_u64);
334 assert_eq!(json["source"]["access_path"]["transport"], "browser");
335 assert_eq!(json["source"]["access_path"]["escalated"], true);
336 assert_eq!(json["source"]["access_path"]["authenticated"], true);
337 assert!(
338 json["source"]["access_path"]
339 .get("session_required")
340 .is_none()
341 );
342
343 let encoded = serde_json::to_string(&ev).unwrap();
344 assert!(!encoded.contains("sessionid"));
345 assert!(!encoded.contains("acct"));
346 assert!(!encoded.contains("proxy"));
347 }
348}