1use 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 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 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 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 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 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
163pub 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
194pub 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 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
265pub 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
282pub 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
306pub 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 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}