headless_lms_utils/page_visit_hasher.rs
1use uuid::Uuid;
2
3use crate::stable_digest::stable_digest;
4
5/// A visitor identifier that cannot be traced back to the visitor once the day's key is rotated
6/// away.
7///
8/// `hashing_key_for_the_day` is what keeps the identifier from being reproducible from an IP
9/// address alone, so it must be the current day's secret and never a constant. The same visitor
10/// gets a different identifier on the next day and on another course.
11///
12/// Touching how the parts are combined re-keys every identifier the moment it deploys, so
13/// `COUNT(DISTINCT anonymous_identifier)` counts every visitor already seen that day a second time.
14/// The key rotation caps that to the day of the deploy; nothing else does.
15pub fn hash_anonymous_identifier(
16 course_id: Uuid,
17 hashing_key_for_the_day: &[u8],
18 user_agent: &str,
19 ip_address: &str,
20) -> String {
21 stable_digest(&[
22 course_id.as_bytes(),
23 hashing_key_for_the_day,
24 ip_address.as_bytes(),
25 user_agent.as_bytes(),
26 ])
27}
28
29#[cfg(test)]
30mod tests {
31 use super::*;
32
33 /// The identifier is the join key for a course's visit counts, so two visitors that differ only
34 /// in where one field ends and the next begins have to be told apart. Unframed fields run
35 /// together and count that pair as one visitor.
36 #[test]
37 fn visitors_whose_fields_run_together_get_different_identifiers() {
38 let course_id = Uuid::new_v4();
39 let key = vec![1, 2, 3];
40
41 assert_ne!(
42 hash_anonymous_identifier(course_id, &key, "Firefox", "10.0.0.1"),
43 hash_anonymous_identifier(course_id, &key, "efox", "10.0.0.1Fir"),
44 );
45 }
46}