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:
2026-02-16 10:00:55 +01:00
parent af93966260
commit 724df70a9c
2 changed files with 300 additions and 0 deletions

294
src/auth/cookies.rs Normal file
View 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()));
}
}

View File

@@ -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");