aboutsummaryrefslogtreecommitdiff
path: root/src/cookies.rs
blob: 4e33dcc4c2614fc3eb7b574e6010e2046466bf19 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
use serde::Deserialize;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};

/// One cookie entry; domain/path could be added later if needed.
#[derive(Debug, Clone, Deserialize)]
pub struct CookieEntry {
    pub name: String,
    pub value: String,
}

/// The input JSON can be either a map or a list of cookie entries.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CookiesJson {
    Map(HashMap<String, String>),
    List(Vec<CookieEntry>),
}

/// Normalized cookie store (name -> value). We keep it simple for now.
/// If later we need domain/path scoping, we can extend this type.
#[derive(Debug, Clone)]
pub struct CookieStore {
    map: HashMap<String, String>,
}

impl CookieStore {
    /// Create a CookieStore from a serde_json::Value (already parsed).
    fn from_value(v: Value) -> anyhow::Result<Self> {
        // Try to deserialize into either a map or a list.
        let cj: CookiesJson = serde_json::from_value(v)?;
        let mut map = HashMap::new();

        match cj {
            CookiesJson::Map(m) => {
                // Direct mapping: { "name": "value", ... }
                map.extend(m);
            }
            CookiesJson::List(list) => {
                // Keep last occurrence on duplicates.
                for e in list {
                    map.insert(e.name, e.value);
                }
            }
        }

        Ok(Self { map })
    }

    /// Load cookies from a file path.
    pub fn load_from(path: &Path) -> anyhow::Result<Self> {
        let raw = fs::read_to_string(path)?;
        let v: Value = serde_json::from_str(&raw)?;
        Self::from_value(v)
    }

    /// Number of cookies.
    pub fn len(&self) -> usize {
        self.map.len()
    }

    pub fn is_empty(&self) -> bool {
        self.map.is_empty()
    }

    /// Return a sorted list of cookie names (safe to log).
    pub fn cookie_names(&self) -> Vec<String> {
        let mut names: Vec<_> = self.map.keys().cloned().collect();
        names.sort();
        names
    }

    /// Render the `Cookie` header value, e.g.: "a=1; b=2".
    /// Deterministic order (by name) to help testing and reproducibility.
    pub fn to_header_value(&self) -> String {
        let mut pairs: Vec<_> = self.map.iter().collect();
        pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
        pairs
            .into_iter()
            .map(|(k, v)| format!("{k}={v}"))
            .collect::<Vec<_>>()
            .join("; ")
    }
}

#[cfg(test)]
mod tests {
    use super::CookieStore;
    use serde_json::json;

    #[test]
    fn loads_from_map() {
        let v = json!({
            "sess": "abc",
            "OptanonConsent": "xyz"
        });
        let store = CookieStore::from_value(v).unwrap();
        assert_eq!(store.len(), 2);
        let names = store.cookie_names();
        assert_eq!(
            names,
            vec!["OptanonConsent".to_string(), "sess".to_string()]
        );
        let header = store.to_header_value();
        assert_eq!(header, "OptanonConsent=xyz; sess=abc");
    }

    #[test]
    fn loads_from_list() {
        let v = json!([
            { "name": "sess", "value": "abc" },
            { "name": "OptanonConsent", "value": "xyz", "domain": "learning.oreilly.com" }
        ]);
        let store = CookieStore::from_value(v).unwrap();
        assert_eq!(store.len(), 2);
        assert_eq!(store.cookie_names(), vec!["OptanonConsent", "sess"]);
        assert_eq!(store.to_header_value(), "OptanonConsent=xyz; sess=abc");
    }

    #[test]
    fn duplicate_names_keep_last() {
        let v = json!([
            { "name": "sess", "value": "OLD" },
            { "name": "sess", "value": "NEW" }
        ]);
        let store = CookieStore::from_value(v).unwrap();
        assert_eq!(store.len(), 1);
        assert_eq!(store.to_header_value(), "sess=NEW");
    }

    #[test]
    fn invalid_json_fails() {
        let v = serde_json::Value::String("not-json-shape".to_string());
        let err = CookieStore::from_value(v).unwrap_err();
        let msg = format!("{err}");
        assert!(msg.to_lowercase().contains("did not match any variant"));
    }
}