feat: add more import/export formats for cookies

This commit is contained in:
zhom
2026-02-21 22:13:02 +04:00
parent 206be3ff12
commit 8b9ad44ebc
17 changed files with 769 additions and 42 deletions
+397 -3
View File
@@ -2,6 +2,7 @@ use crate::profile::manager::ProfileManager;
use crate::profile::BrowserProfile;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tauri::AppHandle;
@@ -551,8 +552,150 @@ impl CookieManager {
(cookies, errors)
}
/// Public API: Import cookies from Netscape format content
pub async fn import_netscape_cookies(
/// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions)
fn parse_json_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
let mut cookies = Vec::new();
let mut errors = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let arr: Vec<Value> = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
errors.push(format!("Failed to parse JSON: {e}"));
return (cookies, errors);
}
};
for (i, obj) in arr.iter().enumerate() {
let name = match obj.get("name").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
errors.push(format!("Cookie {}: missing 'name' field", i + 1));
continue;
}
};
let value = obj
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let domain = match obj.get("domain").and_then(|v| v.as_str()) {
Some(s) => s.to_string(),
None => {
errors.push(format!("Cookie {}: missing 'domain' field", i + 1));
continue;
}
};
let path = obj
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("/")
.to_string();
let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
let is_http_only = obj
.get("httpOnly")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_session = obj
.get("session")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let expires = if is_session {
0
} else {
obj
.get("expirationDate")
.and_then(|v| v.as_f64())
.map(|f| f as i64)
.unwrap_or(0)
};
let same_site = obj
.get("sameSite")
.and_then(|v| v.as_str())
.map(|s| match s {
"lax" => 1,
"strict" => 2,
_ => 0, // "no_restriction" or unrecognized
})
.unwrap_or(0);
cookies.push(UnifiedCookie {
name,
value,
domain,
path,
expires,
is_secure,
is_http_only,
same_site,
creation_time: now,
last_accessed: now,
});
}
(cookies, errors)
}
/// Auto-detect cookie format and parse
fn parse_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
let trimmed = content.trim();
if trimmed.starts_with('[') && serde_json::from_str::<Vec<Value>>(trimmed).is_ok() {
return Self::parse_json_cookies(trimmed);
}
Self::parse_netscape_cookies(content)
}
/// Format cookies as Netscape TXT
pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String {
let mut lines = Vec::new();
lines.push("# Netscape HTTP Cookie File".to_string());
for cookie in cookies {
let flag = if cookie.domain.starts_with('.') {
"TRUE"
} else {
"FALSE"
};
let secure = if cookie.is_secure { "TRUE" } else { "FALSE" };
lines.push(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value
));
}
lines.join("\n")
}
/// Format cookies as JSON
pub fn format_json_cookies(cookies: &[UnifiedCookie]) -> String {
let arr: Vec<Value> = cookies
.iter()
.map(|c| {
let same_site_str = match c.same_site {
1 => "lax",
2 => "strict",
_ => "no_restriction",
};
serde_json::json!({
"name": c.name,
"value": c.value,
"domain": c.domain,
"path": c.path,
"secure": c.is_secure,
"httpOnly": c.is_http_only,
"sameSite": same_site_str,
"expirationDate": c.expires,
"session": c.expires == 0,
"hostOnly": !c.domain.starts_with('.'),
})
})
.collect();
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string())
}
/// Public API: Import cookies with auto-format detection
pub async fn import_cookies(
app_handle: &AppHandle,
profile_id: &str,
content: &str,
@@ -580,7 +723,7 @@ impl CookieManager {
));
}
let (cookies, parse_errors) = Self::parse_netscape_cookies(content);
let (cookies, parse_errors) = Self::parse_cookies(content);
if cookies.is_empty() {
return Err("No valid cookies found in the file".to_string());
@@ -603,4 +746,255 @@ impl CookieManager {
Err(e) => Err(format!("Failed to write cookies: {e}")),
}
}
/// Public API: Export cookies from a profile in the specified format
pub fn export_cookies(profile_id: &str, format: &str) -> Result<String, String> {
let result = Self::read_cookies(profile_id)?;
let all_cookies: Vec<UnifiedCookie> =
result.domains.into_iter().flat_map(|d| d.cookies).collect();
match format {
"json" => Ok(Self::format_json_cookies(&all_cookies)),
"netscape" => Ok(Self::format_netscape_cookies(&all_cookies)),
_ => Err(format!("Unsupported export format: {format}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_netscape_cookies_valid() {
let content = "# Netscape HTTP Cookie File\n\
.example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\
example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 2);
assert!(errors.is_empty());
assert_eq!(cookies[0].domain, ".example.com");
assert_eq!(cookies[0].name, "session_id");
assert_eq!(cookies[0].value, "abc123");
assert_eq!(cookies[0].path, "/");
assert!(cookies[0].is_secure);
assert_eq!(cookies[0].expires, 1700000000);
assert_eq!(cookies[1].domain, "example.com");
assert!(!cookies[1].is_secure);
assert_eq!(cookies[1].expires, 0);
}
#[test]
fn test_parse_netscape_cookies_skips_comments_and_blanks() {
let content = "# Comment line\n\n \n# Another comment\n\
.test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
}
#[test]
fn test_parse_netscape_cookies_malformed_lines() {
let content = "not\tenough\tfields\n\
.ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("expected 7 tab-separated fields"));
}
#[test]
fn test_parse_json_cookies_valid() {
let content = r#"[
{
"name": "sid",
"value": "abc",
"domain": ".example.com",
"path": "/",
"secure": true,
"httpOnly": true,
"sameSite": "lax",
"expirationDate": 1700000000,
"session": false
}
]"#;
let (cookies, errors) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
assert_eq!(cookies[0].name, "sid");
assert_eq!(cookies[0].domain, ".example.com");
assert!(cookies[0].is_secure);
assert!(cookies[0].is_http_only);
assert_eq!(cookies[0].same_site, 1);
assert_eq!(cookies[0].expires, 1700000000);
}
#[test]
fn test_parse_json_cookies_session() {
let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#;
let (cookies, errors) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies.len(), 1);
assert!(errors.is_empty());
assert_eq!(cookies[0].expires, 0);
}
#[test]
fn test_parse_json_cookies_same_site_mapping() {
let content = r#"[
{"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"},
{"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"},
{"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"}
]"#;
let (cookies, _) = CookieManager::parse_json_cookies(content);
assert_eq!(cookies[0].same_site, 0);
assert_eq!(cookies[1].same_site, 1);
assert_eq!(cookies[2].same_site, 2);
}
#[test]
fn test_parse_cookies_auto_detect_json() {
let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#;
let (cookies, _) = CookieManager::parse_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "x");
}
#[test]
fn test_parse_cookies_auto_detect_netscape() {
let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue";
let (cookies, _) = CookieManager::parse_cookies(content);
assert_eq!(cookies.len(), 1);
assert_eq!(cookies[0].name, "name");
}
#[test]
fn test_format_netscape_cookies() {
let cookies = vec![UnifiedCookie {
name: "sid".to_string(),
value: "abc".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
}];
let output = CookieManager::format_netscape_cookies(&cookies);
assert!(output.contains("# Netscape HTTP Cookie File"));
assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc"));
}
#[test]
fn test_format_json_cookies() {
let cookies = vec![UnifiedCookie {
name: "sid".to_string(),
value: "abc".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: true,
same_site: 1,
creation_time: 0,
last_accessed: 0,
}];
let output = CookieManager::format_json_cookies(&cookies);
let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0]["name"], "sid");
assert_eq!(parsed[0]["sameSite"], "lax");
assert_eq!(parsed[0]["session"], false);
assert_eq!(parsed[0]["hostOnly"], false);
}
#[test]
fn test_netscape_roundtrip() {
let cookies = vec![
UnifiedCookie {
name: "a".to_string(),
value: "1".to_string(),
domain: ".d.com".to_string(),
path: "/".to_string(),
expires: 1700000000,
is_secure: true,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
},
UnifiedCookie {
name: "b".to_string(),
value: "2".to_string(),
domain: "d.com".to_string(),
path: "/p".to_string(),
expires: 0,
is_secure: false,
is_http_only: false,
same_site: 0,
creation_time: 0,
last_accessed: 0,
},
];
let formatted = CookieManager::format_netscape_cookies(&cookies);
let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted);
assert!(errors.is_empty());
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "a");
assert_eq!(parsed[0].domain, ".d.com");
assert!(parsed[0].is_secure);
assert_eq!(parsed[1].name, "b");
assert_eq!(parsed[1].domain, "d.com");
}
#[test]
fn test_json_roundtrip() {
let cookies = vec![UnifiedCookie {
name: "tok".to_string(),
value: "xyz".to_string(),
domain: ".site.org".to_string(),
path: "/app".to_string(),
expires: 1700000000,
is_secure: false,
is_http_only: true,
same_site: 2,
creation_time: 0,
last_accessed: 0,
}];
let formatted = CookieManager::format_json_cookies(&cookies);
let (parsed, errors) = CookieManager::parse_json_cookies(&formatted);
assert!(errors.is_empty());
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].name, "tok");
assert_eq!(parsed[0].domain, ".site.org");
assert_eq!(parsed[0].path, "/app");
assert!(!parsed[0].is_secure);
assert!(parsed[0].is_http_only);
assert_eq!(parsed[0].same_site, 2);
assert_eq!(parsed[0].expires, 1700000000);
}
#[test]
fn test_chrome_time_to_unix() {
assert_eq!(CookieManager::chrome_time_to_unix(0), 0);
let chrome_time: i64 = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
assert_eq!(CookieManager::chrome_time_to_unix(chrome_time), 1700000000);
}
#[test]
fn test_unix_to_chrome_time() {
assert_eq!(CookieManager::unix_to_chrome_time(0), 0);
let expected = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
assert_eq!(CookieManager::unix_to_chrome_time(1700000000), expected);
}
#[test]
fn test_chrome_time_roundtrip() {
let unix = 1700000000_i64;
let chrome = CookieManager::unix_to_chrome_time(unix);
assert_eq!(CookieManager::chrome_time_to_unix(chrome), unix);
}
}