Skip to main content

adler_core/
profile.rs

1//! Normalized profile evidence collected from a positive result.
2//!
3//! The legacy `CheckOutcome::evidence` field records human-readable signal
4//! matches such as `HTTP 200 (status_found)`. This module is the product
5//! layer above that: typed profile facts that can later feed confidence,
6//! identity clustering, timelines, and reports.
7
8use serde::{Deserialize, Serialize};
9
10use crate::escalation::TransportTier;
11
12/// A normalized fact observed on a profile or profile-like endpoint.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ProfileEvidence {
15    /// What kind of profile fact this is.
16    pub kind: ProfileEvidenceKind,
17    /// Original extractor/enrichment field name, when one exists.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub field: Option<String>,
20    /// Cleaned observed value.
21    pub value: String,
22    /// Where this fact came from.
23    pub source: EvidenceSource,
24}
25
26/// Profile evidence categories that higher-level analysis can reason over.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ProfileEvidenceKind {
30    /// Exact username value confirmed by a site-authored detection signal.
31    Username,
32    /// A displayed human name.
33    DisplayName,
34    /// Profile biography or description text.
35    Bio,
36    /// Avatar or profile image URL.
37    AvatarUrl,
38    /// Privacy-safe perceptual hash derived from an avatar image.
39    AvatarHash,
40    /// External website or social link.
41    ExternalLink,
42    /// Location-like profile field.
43    Location,
44    /// Account creation or joined date.
45    JoinedDate,
46    /// HTML/profile title.
47    ProfileTitle,
48    /// Meta/OpenGraph/Twitter description.
49    MetaDescription,
50    /// Field that does not yet map to a richer category.
51    ExtractedField,
52}
53
54/// Source metadata attached to every normalized evidence item.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct EvidenceSource {
57    /// Site name that produced the result.
58    pub site: String,
59    /// Concrete profile URL that was probed.
60    pub url: String,
61    /// Which Adler subsystem produced the fact.
62    pub origin: EvidenceOrigin,
63    /// Unix epoch milliseconds when Adler observed this fact. Missing on
64    /// older persisted scans and on manually-built evidence.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub observed_at_ms: Option<u64>,
67    /// Coarse, non-secret access context for the probe that produced this
68    /// evidence. Deliberately excludes session names, proxy URLs, header
69    /// values, and egress identifiers.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub access_path: Option<EvidenceAccessPath>,
72}
73
74/// Non-secret access context for an evidence item.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct EvidenceAccessPath {
77    /// Transport that produced the response.
78    pub transport: TransportTier,
79    /// Whether the result came from an automatic retry through a heavier
80    /// transport.
81    #[serde(default, skip_serializing_if = "is_false")]
82    pub escalated: bool,
83    /// Whether an operator-supplied authenticated session was applied.
84    #[serde(default, skip_serializing_if = "is_false")]
85    pub authenticated: bool,
86    /// Reserved for future evidence types that record a missing session as
87    /// evidence. Extracted profile evidence should normally leave this false.
88    #[serde(default, skip_serializing_if = "is_false")]
89    pub session_required: bool,
90}
91
92impl EvidenceAccessPath {
93    /// Build a non-secret access summary from the live probe path.
94    #[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/// Adler subsystem that produced an evidence item.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum EvidenceOrigin {
109    /// Evidence came from a detection signal that matched the concrete probe.
110    Signal,
111    /// Evidence came from a registry extractor rule.
112    Extractor,
113    /// Evidence was derived from another observed fact without storing the
114    /// raw source material.
115    Derived,
116}
117
118impl ProfileEvidence {
119    /// Build normalized evidence from a legacy enrichment field.
120    #[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    /// Build normalized evidence from a live enrichment field with optional
126    /// observation/access metadata.
127    #[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    /// Build exact username evidence from a signal that matched the concrete
151    /// probed username.
152    #[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    /// Build privacy-safe avatar hash evidence derived from a previously
175    /// extracted avatar URL.
176    #[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    /// Map a registry enrichment field name to a normalized evidence kind.
201    #[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}