Skip to main content

qualia_client_core/
cookie_graph.rs

1//! Cookie / personal-data transparency graph (v0).
2//!
3//! Honest scope:
4//! - Model: site origin → cookie name → attributes → purpose hypothesis (heuristic).
5//! - Persist under `{storage}/webizen/cookie_graph.json`.
6//! - Observe: v0 accepts agent-fetched `Set-Cookie` headers and/or explicit observations
7//!   from the host. Full WebView2 cookie-jar parity is **not** claimed.
8//! - No MITM proxy.
9
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15pub const COOKIE_GRAPH_FILE: &str = "webizen/cookie_graph.json";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum CookiePurposeHypothesis {
20    Session,
21    Analytics,
22    Tracker,
23    Preference,
24    Auth,
25    Unknown,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CookieNode {
30    pub origin: String,
31    pub name: String,
32    pub domain: String,
33    pub path: String,
34    pub secure: bool,
35    pub same_site: String,
36    pub expiry: Option<String>,
37    pub purpose: CookiePurposeHypothesis,
38    pub third_party: bool,
39    /// How we learned about this cookie (agent_set_cookie | host_observe | manual).
40    pub source: String,
41    pub observed_unix: u64,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct CookieGraph {
46    pub version: u32,
47    pub nodes: Vec<CookieNode>,
48    /// Honest coverage label for UI.
49    pub coverage_note: String,
50}
51
52impl CookieGraph {
53    pub fn new() -> Self {
54        Self {
55            version: 1,
56            nodes: Vec::new(),
57            coverage_note: "v1: WebView jar (cookies_for_url) + agent Set-Cookie observe — not complete Chromium parity.".into(),
58        }
59    }
60
61    pub fn path(storage_root: &Path) -> PathBuf {
62        storage_root.join(COOKIE_GRAPH_FILE)
63    }
64
65    pub fn load(storage_root: &Path) -> Self {
66        let p = Self::path(storage_root);
67        match fs::read_to_string(&p) {
68            Ok(s) => serde_json::from_str(&s).unwrap_or_else(|_| Self::new()),
69            Err(_) => Self::new(),
70        }
71    }
72
73    pub fn save(&self, storage_root: &Path) -> Result<(), String> {
74        let p = Self::path(storage_root);
75        if let Some(parent) = p.parent() {
76            fs::create_dir_all(parent).map_err(|e| e.to_string())?;
77        }
78        let bytes = serde_json::to_vec_pretty(self).map_err(|e| e.to_string())?;
79        let tmp = p.with_extension("json.tmp");
80        fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
81        fs::rename(&tmp, &p).map_err(|e| e.to_string())
82    }
83
84    pub fn upsert(&mut self, node: CookieNode) {
85        if let Some(existing) = self
86            .nodes
87            .iter_mut()
88            .find(|n| n.origin == node.origin && n.name == node.name && n.domain == node.domain)
89        {
90            *existing = node;
91        } else {
92            self.nodes.push(node);
93        }
94    }
95
96    pub fn for_origin(&self, origin: &str) -> Vec<&CookieNode> {
97        let o = origin.trim().trim_end_matches('/');
98        self.nodes
99            .iter()
100            .filter(|n| n.origin.trim_end_matches('/') == o)
101            .collect()
102    }
103
104    pub fn third_parties_for_host(&self, host: &str) -> Vec<String> {
105        let host = host.trim().to_ascii_lowercase();
106        let mut out = Vec::new();
107        for n in &self.nodes {
108            if !n.third_party {
109                continue;
110            }
111            let page_host = host_of(&n.origin);
112            if page_host == host || n.origin.contains(&host) {
113                let d = n.domain.trim_start_matches('.').to_ascii_lowercase();
114                if !d.is_empty() && d != host && !out.contains(&d) {
115                    out.push(d);
116                }
117            }
118        }
119        out.sort();
120        out
121    }
122
123    /// Remove all graph nodes for an origin (exact match, trailing slash normalized).
124    pub fn clear_origin(&mut self, origin: &str) -> usize {
125        let o = origin.trim().trim_end_matches('/');
126        let before = self.nodes.len();
127        self.nodes.retain(|n| n.origin.trim_end_matches('/') != o);
128        before.saturating_sub(self.nodes.len())
129    }
130
131    /// Remove nodes whose page host matches (origin host or domain).
132    pub fn clear_host(&mut self, host: &str) -> usize {
133        let host = host.trim().to_ascii_lowercase();
134        let before = self.nodes.len();
135        self.nodes.retain(|n| {
136            let page = host_of(&n.origin);
137            let d = n.domain.trim_start_matches('.').to_ascii_lowercase();
138            page != host && d != host
139        });
140        before.saturating_sub(self.nodes.len())
141    }
142
143    /// Clear entire graph (principal request).
144    pub fn clear_all(&mut self) -> usize {
145        let n = self.nodes.len();
146        self.nodes.clear();
147        n
148    }
149}
150
151fn host_of(url_or_origin: &str) -> String {
152    let u = url_or_origin.trim();
153    let rest = u
154        .strip_prefix("https://")
155        .or_else(|| u.strip_prefix("http://"))
156        .unwrap_or(u);
157    rest.split(['/', '?', '#', ':'])
158        .next()
159        .unwrap_or("")
160        .to_ascii_lowercase()
161}
162
163/// Heuristic purpose from cookie name (not ground truth).
164pub fn hypothesize_purpose(name: &str) -> CookiePurposeHypothesis {
165    let n = name.to_ascii_lowercase();
166    if n.contains("session") || n == "sid" || n.starts_with("jsession") {
167        return CookiePurposeHypothesis::Session;
168    }
169    if n.contains("auth") || n.contains("token") || n.contains("login") || n == "jwt" {
170        return CookiePurposeHypothesis::Auth;
171    }
172    if n.contains("ga")
173        || n.contains("_utm")
174        || n.contains("analytics")
175        || n.starts_with("_gid")
176        || n.starts_with("_gat")
177    {
178        return CookiePurposeHypothesis::Analytics;
179    }
180    if n.contains("fbp")
181        || n.contains("fbc")
182        || n.contains("doubleclick")
183        || n.contains("track")
184        || n.contains("ads")
185    {
186        return CookiePurposeHypothesis::Tracker;
187    }
188    if n.contains("pref") || n.contains("theme") || n.contains("lang") || n.contains("consent") {
189        return CookiePurposeHypothesis::Preference;
190    }
191    CookiePurposeHypothesis::Unknown
192}
193
194/// Parse a single Set-Cookie header line into a CookieNode (best-effort).
195pub fn parse_set_cookie(
196    page_url: &str,
197    set_cookie: &str,
198    now: u64,
199    source: &str,
200) -> Option<CookieNode> {
201    let line = set_cookie.trim();
202    if line.is_empty() {
203        return None;
204    }
205    let mut parts = line.split(';');
206    let nv = parts.next()?.trim();
207    let (name, _value) = nv.split_once('=')?;
208    let name = name.trim();
209    if name.is_empty() {
210        return None;
211    }
212    let page_host = host_of(page_url);
213    let origin = if page_url.starts_with("http") {
214        // scheme://host
215        let scheme = if page_url.starts_with("https") {
216            "https"
217        } else {
218            "http"
219        };
220        format!("{scheme}://{page_host}")
221    } else {
222        page_url.to_string()
223    };
224
225    let mut domain = page_host.clone();
226    let mut path = "/".to_string();
227    let mut secure = false;
228    let mut same_site = "Lax".to_string();
229    let mut expiry = None;
230    for p in parts {
231        let p = p.trim();
232        let (k, v) = match p.split_once('=') {
233            Some((a, b)) => (a.trim(), b.trim()),
234            None => (p, ""),
235        };
236        let kl = k.to_ascii_lowercase();
237        match kl.as_str() {
238            "domain" => domain = v.trim_start_matches('.').to_ascii_lowercase(),
239            "path" => path = if v.is_empty() { "/".into() } else { v.into() },
240            "secure" => secure = true,
241            "samesite" => same_site = v.to_string(),
242            "expires" | "max-age" => expiry = Some(v.to_string()),
243            _ => {}
244        }
245    }
246    let third_party = {
247        let d = domain.trim_start_matches('.');
248        !d.is_empty() && d != page_host && !page_host.ends_with(&format!(".{d}"))
249    };
250    Some(CookieNode {
251        origin,
252        name: name.into(),
253        domain,
254        path,
255        secure,
256        same_site,
257        expiry,
258        purpose: hypothesize_purpose(name),
259        third_party,
260        source: source.into(),
261        observed_unix: now,
262    })
263}
264
265/// Observe many Set-Cookie lines for a page URL; persist graph.
266pub fn observe_set_cookies(
267    storage_root: &Path,
268    page_url: &str,
269    set_cookies: &[String],
270    now: u64,
271) -> Result<CookieGraph, String> {
272    let mut g = CookieGraph::load(storage_root);
273    for sc in set_cookies {
274        if let Some(node) = parse_set_cookie(page_url, sc, now, "agent_set_cookie") {
275            g.upsert(node);
276        }
277    }
278    g.save(storage_root)?;
279    Ok(g)
280}
281
282/// Summary for chrome UI.
283pub fn summary_for_url(storage_root: &Path, url: &str) -> serde_json::Value {
284    let g = CookieGraph::load(storage_root);
285    let host = host_of(url);
286    let origin = if url.starts_with("https") {
287        format!("https://{host}")
288    } else if url.starts_with("http") {
289        format!("http://{host}")
290    } else {
291        url.to_string()
292    };
293    let nodes = g.for_origin(&origin);
294    let third = g.third_parties_for_host(&host);
295    serde_json::json!({
296        "url": url,
297        "origin": origin,
298        "cookie_count": nodes.len(),
299        "cookies": nodes,
300        "third_parties": third,
301        "coverage_note": g.coverage_note,
302        "honesty": "view + graph coverage is best-effort; not complete Chromium jar parity",
303    })
304}
305
306/// Clear cookie graph for origin and optionally log intent for site-data clear.
307/// Does **not** by itself wipe the WebView jar — pair with platform clear.
308pub fn clear_graph_for_origin(
309    storage_root: &Path,
310    origin_or_url: &str,
311) -> Result<serde_json::Value, String> {
312    let host = host_of(origin_or_url);
313    let origin = if origin_or_url.starts_with("https") {
314        format!("https://{host}")
315    } else if origin_or_url.starts_with("http") {
316        format!("http://{host}")
317    } else if origin_or_url.contains("://") {
318        origin_or_url.trim().trim_end_matches('/').to_string()
319    } else {
320        format!("https://{host}")
321    };
322    let mut g = CookieGraph::load(storage_root);
323    let removed = g.clear_origin(&origin);
324    // Also drop host-matching third-party rows observed under this page host
325    let removed2 = g.clear_host(&host);
326    g.save(storage_root)?;
327    append_clear_audit(storage_root, &origin, removed + removed2, "origin")?;
328    Ok(serde_json::json!({
329        "origin": origin,
330        "host": host,
331        "removed_graph_nodes": removed + removed2,
332        "coverage_note": g.coverage_note,
333        "note": "Graph rows cleared. WebView jar clear is platform-side (see browser_clear_site_data).",
334    }))
335}
336
337pub fn clear_graph_all(storage_root: &Path) -> Result<serde_json::Value, String> {
338    let mut g = CookieGraph::load(storage_root);
339    let n = g.clear_all();
340    g.save(storage_root)?;
341    append_clear_audit(storage_root, "*", n, "all")?;
342    Ok(serde_json::json!({
343        "removed_graph_nodes": n,
344        "note": "Entire cookie graph cleared. WebView jar may still hold cookies until platform clear.",
345    }))
346}
347
348fn append_clear_audit(
349    storage_root: &Path,
350    scope: &str,
351    removed: usize,
352    kind: &str,
353) -> Result<(), String> {
354    let path = storage_root.join("webizen/cookie_clear_audit.jsonl");
355    if let Some(p) = path.parent() {
356        fs::create_dir_all(p).map_err(|e| e.to_string())?;
357    }
358    let unix = std::time::SystemTime::now()
359        .duration_since(std::time::UNIX_EPOCH)
360        .map(|d| d.as_secs())
361        .unwrap_or(0);
362    let line = serde_json::json!({
363        "unix": unix,
364        "scope": scope,
365        "kind": kind,
366        "removed": removed,
367    });
368    use std::io::Write;
369    let mut f = fs::OpenOptions::new()
370        .create(true)
371        .append(true)
372        .open(&path)
373        .map_err(|e| e.to_string())?;
374    writeln!(
375        f,
376        "{}",
377        serde_json::to_string(&line).map_err(|e| e.to_string())?
378    )
379    .map_err(|e| e.to_string())?;
380    Ok(())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn parse_and_persist() {
389        let dir = tempfile::tempdir().unwrap();
390        let sc = "SID=abc; Domain=example.org; Path=/; Secure; SameSite=None".to_string();
391        let g = observe_set_cookies(dir.path(), "https://example.org/page", &[sc], 1).unwrap();
392        assert_eq!(g.nodes.len(), 1);
393        assert_eq!(g.nodes[0].name, "SID");
394        assert_eq!(g.nodes[0].purpose, CookiePurposeHypothesis::Session);
395        let loaded = CookieGraph::load(dir.path());
396        assert_eq!(loaded.nodes.len(), 1);
397        let sum = summary_for_url(dir.path(), "https://example.org/x");
398        assert_eq!(sum["cookie_count"], 1);
399    }
400
401    #[test]
402    fn ga_is_analytics() {
403        assert_eq!(
404            hypothesize_purpose("_ga"),
405            CookiePurposeHypothesis::Analytics
406        );
407    }
408
409    #[test]
410    fn clear_origin_removes_rows() {
411        let dir = tempfile::tempdir().unwrap();
412        let sc = "SID=abc; Domain=example.org; Path=/".to_string();
413        observe_set_cookies(dir.path(), "https://example.org/page", &[sc], 1).unwrap();
414        let r = clear_graph_for_origin(dir.path(), "https://example.org/x").unwrap();
415        assert!(r["removed_graph_nodes"].as_u64().unwrap() >= 1);
416        let g = CookieGraph::load(dir.path());
417        assert!(g.nodes.is_empty());
418    }
419}