feat(06-01): implement Netscape cookie file parser
- Create src/auth/cookies.rs with parse_netscape_cookies() - Implement load_cookies_from_file() for file-based cookie loading - Support Netscape HTTP Cookie File format (tab-separated) - Add CookieError type for error handling - Add tests for parsing, loading, and roundtrip operations - Export auth module in lib.rs - 137 tests pass
This commit is contained in:
294
src/auth/cookies.rs
Normal file
294
src/auth/cookies.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
//! Cookie file parsing for Netscape-format cookie files
|
||||
//!
|
||||
//! Supports the standard Netscape HTTP Cookie File format used by
|
||||
//! browser extensions like "Get cookies.txt LOCALLY" and "EditThisCookie".
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
/// Error type for cookie parsing operations
|
||||
#[derive(Debug)]
|
||||
pub enum CookieError {
|
||||
/// I/O error when reading file
|
||||
Io(io::Error),
|
||||
/// Invalid cookie file format
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CookieError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CookieError::Io(e) => write!(f, "I/O error: {}", e),
|
||||
CookieError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CookieError {}
|
||||
|
||||
impl From<io::Error> for CookieError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
CookieError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Netscape-format cookies file
|
||||
///
|
||||
/// The Netscape cookie file format is:
|
||||
/// ```
|
||||
/// # Netscape HTTP Cookie File
|
||||
/// # This file was generated by Get cookies.txt LOCALLY
|
||||
/// .example.com TRUE / FALSE 0 cookie_name cookie_value
|
||||
/// ```
|
||||
///
|
||||
/// Fields (tab-separated):
|
||||
/// 1. domain - The domain that created the cookie
|
||||
/// 2. flag - TRUE if this is a domain cookie, FALSE otherwise
|
||||
/// 3. path - The path the cookie is valid for
|
||||
/// 4. secure - TRUE if the cookie should only be sent over secure connections
|
||||
/// 5. expiration - Unix timestamp when the cookie expires
|
||||
/// 6. name - Cookie name
|
||||
/// 7. value - Cookie value
|
||||
///
|
||||
/// Lines starting with '#' are comments and are skipped.
|
||||
/// Lines starting with '#HttpOnly_' indicate HTTP-only cookies (also skipped by default).
|
||||
pub fn parse_netscape_cookies(content: &str) -> Result<HashMap<String, String>, CookieError> {
|
||||
let mut cookies = HashMap::new();
|
||||
let mut line_number = 0;
|
||||
|
||||
for line in content.lines() {
|
||||
line_number += 1;
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip comment lines (including header)
|
||||
if trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip HttpOnly cookies (they start with #HttpOnly_ prefix after uncommenting)
|
||||
// In practice, these appear as regular lines but are flagged in the domain field
|
||||
// We'll handle the standard case where HttpOnly is a prefix marker
|
||||
|
||||
// Split by tabs (Netscape format uses tabs)
|
||||
let fields: Vec<&str> = trimmed.split('\t').collect();
|
||||
|
||||
// Validate we have at least 7 fields
|
||||
if fields.len() < 7 {
|
||||
log::warn!(
|
||||
"Skipping malformed cookie line {}: expected 7 fields, got {}",
|
||||
line_number,
|
||||
fields.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract cookie name and value (fields 5 and 6 in 0-indexed)
|
||||
let name = fields[5].to_string();
|
||||
let value = fields[6].to_string();
|
||||
|
||||
if name.is_empty() {
|
||||
log::warn!("Skipping cookie with empty name on line {}", line_number);
|
||||
continue;
|
||||
}
|
||||
|
||||
log::debug!("Parsed cookie: {} ({} bytes)", name, value.len());
|
||||
cookies.insert(name, value);
|
||||
}
|
||||
|
||||
log::info!("Parsed {} cookies from Netscape cookie file", cookies.len());
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Load cookies from a Netscape-format file
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to the cookie file
|
||||
///
|
||||
/// # Returns
|
||||
/// A HashMap of cookie name -> cookie value
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use std::collections::HashMap;
|
||||
/// use gallery_dl::auth::load_cookies_from_file;
|
||||
///
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let cookies = load_cookies_from_file("/path/to/cookies.txt")?;
|
||||
/// // Use cookies with your HTTP client
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
pub fn load_cookies_from_file(path: &Path) -> Result<HashMap<String, String>, CookieError> {
|
||||
log::info!("Loading cookies from: {:?}", path);
|
||||
|
||||
// Verify file exists
|
||||
if !path.exists() {
|
||||
return Err(CookieError::Parse(format!(
|
||||
"Cookie file not found: {:?}",
|
||||
path
|
||||
)));
|
||||
}
|
||||
|
||||
// Read file content
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// Check for the Netscape header (optional but good to verify)
|
||||
let has_netscape_header = content
|
||||
.lines()
|
||||
.any(|line| line.trim().starts_with("# Netscape HTTP Cookie File"));
|
||||
|
||||
if has_netscape_header {
|
||||
log::debug!("Cookie file has Netscape header");
|
||||
} else {
|
||||
log::debug!("Cookie file does not have Netscape header, attempting to parse anyway");
|
||||
}
|
||||
|
||||
// Parse the cookie content
|
||||
let cookies = parse_netscape_cookies(&content)?;
|
||||
|
||||
if cookies.is_empty() {
|
||||
log::warn!("No cookies found in file: {:?}", path);
|
||||
}
|
||||
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Write cookies to a Netscape-format file
|
||||
///
|
||||
/// This is useful for exporting cookies for use with other tools.
|
||||
/// Note: This is primarily for debugging/testing purposes.
|
||||
pub fn write_netscape_cookies(
|
||||
cookies: &HashMap<String, String>,
|
||||
path: &Path,
|
||||
) -> Result<(), CookieError> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
|
||||
// Write header
|
||||
writeln!(file, "# Netscape HTTP Cookie File")?;
|
||||
writeln!(file, "# This file was generated by gallery-dl-rs")?;
|
||||
|
||||
// Write each cookie as a domain cookie (simplified)
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Expire in 1 year
|
||||
let expiration = now + (365 * 24 * 60 * 60);
|
||||
|
||||
for (name, value) in cookies {
|
||||
// Format: domain flag path secure expiration name value
|
||||
writeln!(
|
||||
file,
|
||||
".example.com\tTRUE\t/\tFALSE\t{}\t{}\t{}",
|
||||
expiration, name, value
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_basic() {
|
||||
let content = r#"# Netscape HTTP Cookie File
|
||||
.example.com TRUE / FALSE 0 auth_token abc123
|
||||
.twitter.com TRUE / TRUE 0 ct0 def456
|
||||
"#;
|
||||
let cookies = parse_netscape_cookies(content).unwrap();
|
||||
|
||||
assert_eq!(cookies.len(), 2);
|
||||
assert_eq!(cookies.get("auth_token"), Some(&"abc123".to_string()));
|
||||
assert_eq!(cookies.get("ct0"), Some(&"def456".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_with_comments() {
|
||||
let content = r#"# Netscape HTTP Cookie File
|
||||
# This is a comment
|
||||
.example.com TRUE / FALSE 0 cookie1 value1
|
||||
|
||||
# Another comment
|
||||
.example.org TRUE / FALSE 0 cookie2 value2
|
||||
"#;
|
||||
let cookies = parse_netscape_cookies(content).unwrap();
|
||||
|
||||
assert_eq!(cookies.len(), 2);
|
||||
assert_eq!(cookies.get("cookie1"), Some(&"value1".to_string()));
|
||||
assert_eq!(cookies.get("cookie2"), Some(&"value2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_content() {
|
||||
let cookies = parse_netscape_cookies("").unwrap();
|
||||
assert!(cookies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_only_comments() {
|
||||
let content = r#"# Netscape HTTP Cookie File
|
||||
# Comment line 1
|
||||
# Comment line 2
|
||||
"#;
|
||||
let cookies = parse_netscape_cookies(content).unwrap();
|
||||
assert!(cookies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookies_with_special_chars() {
|
||||
let content = r#".example.com TRUE / FALSE 0 token abc%20def%3D%26
|
||||
"#;
|
||||
let cookies = parse_netscape_cookies(content).unwrap();
|
||||
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies.get("token"), Some(&"abc%20def%3D%26".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_cookies_from_file() {
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
writeln!(temp_file, "# Netscape HTTP Cookie File").unwrap();
|
||||
writeln!(temp_file, ".test.com\tTRUE\t/\tFALSE\t0\tsession\ttest123").unwrap();
|
||||
|
||||
let cookies = load_cookies_from_file(temp_file.path()).unwrap();
|
||||
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies.get("session"), Some(&"test123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_file() {
|
||||
let result = load_cookies_from_file(Path::new("/nonexistent/cookies.txt"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_and_read_roundtrip() {
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let path = temp_file.path().to_path_buf();
|
||||
|
||||
let mut cookies = HashMap::new();
|
||||
cookies.insert("test1".to_string(), "value1".to_string());
|
||||
cookies.insert("test2".to_string(), "value2".to_string());
|
||||
|
||||
write_netscape_cookies(&cookies, &path).unwrap();
|
||||
|
||||
let loaded = load_cookies_from_file(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.len(), 2);
|
||||
assert_eq!(loaded.get("test1"), Some(&"value1".to_string()));
|
||||
assert_eq!(loaded.get("test2"), Some(&"value2".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod extractor;
|
||||
pub mod download;
|
||||
pub mod postprocess;
|
||||
pub mod archive;
|
||||
pub mod auth;
|
||||
|
||||
// Re-export extractor types for library users
|
||||
pub use extractor::{
|
||||
@@ -48,6 +49,11 @@ pub use archive::{
|
||||
DownloadArchive, SqliteArchive, ArchiveError,
|
||||
};
|
||||
|
||||
// Re-export auth types for library users
|
||||
pub use auth::{
|
||||
load_cookies_from_file, parse_netscape_cookies,
|
||||
};
|
||||
|
||||
/// Version of the gallery-dl crate
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user