use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::NOTHING};
use unjar::{Browser, CookieJar, Profile, Result, find_profile, profiles};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
Netscape,
Json,
Header,
}
#[derive(Debug, Parser)]
#[command(name = "unjar", version, about, args_conflicts_with_subcommands = true)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
export: Export,
}
#[derive(Debug, Subcommand)]
enum Command {
List,
}
#[derive(Debug, Args)]
struct Export {
#[arg(value_name = "DOMAIN")]
domains: Vec<String>,
#[arg(short, long)]
browser: Option<Browser>,
#[arg(short, long)]
profile: Option<String>,
#[arg(short, long, value_enum, default_value = "netscape")]
format: Format,
#[arg(short, long)]
output: Option<PathBuf>,
}
fn main() -> Result<()> {
if std::env::args_os().len() == 1 {
Cli::command().print_help()?;
println!();
return Ok(());
}
let cli = Cli::parse();
if matches!(cli.command, Some(Command::List)) {
print_profiles();
return Ok(());
}
export(cli.export)
}
fn print_profiles() {
let mut table = Table::new();
table.load_preset(NOTHING);
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(
["Browser", "Profile", "Default", "Path"]
.map(|heading| Cell::new(heading).add_attribute(Attribute::Bold)),
);
for profile in profiles() {
table.add_row([
Cell::new(profile.browser()).fg(Color::Cyan),
Cell::new(profile.id()).fg(Color::Magenta),
Cell::new(if profile.is_default() { "yes" } else { "" }).fg(Color::Green),
Cell::new(profile.path().display()),
]);
}
println!("{table}");
}
fn export(args: Export) -> Result<()> {
if args.domains.is_empty() {
return Err("missing domain; pass one or more domains, or `all`".into());
}
if args.domains.iter().any(|domain| domain == "list") {
return Err("`list` is a command and cannot be combined with export arguments".into());
}
let all = matches!(args.domains.as_slice(), [domain] if domain == "all");
if !all && args.domains.iter().any(|domain| domain == "all") {
return Err("`all` cannot be combined with domains".into());
}
if args.format == Format::Header && (all || args.domains.len() != 1) {
return Err("header format requires exactly one domain".into());
}
let profile = match &args.profile {
Some(selector) => Some(select_profile(args.browser, selector)?),
None => None,
};
let browser = args.browser.unwrap_or(Browser::Chrome);
let jar = match profile {
Some(profile) => profile.cookies()?,
None => browser.cookies()?,
};
let jar = if all {
jar
} else {
let domains: Vec<_> = args.domains.iter().map(String::as_str).collect();
jar.domains(&domains)
};
let out = format_jar(&jar, args.format);
match args.output {
Some(path) => {
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&path)?;
#[cfg(unix)]
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
file.write_all(out.as_bytes())?;
eprintln!("wrote {} cookie(s) to {}", jar.len(), path.display());
}
None => println!("{out}"),
}
Ok(())
}
fn select_profile(browser: Option<Browser>, selector: &str) -> Result<Profile> {
match browser {
Some(browser) => browser.find_profile(selector),
None => find_profile(selector),
}
}
fn format_jar(jar: &CookieJar, format: Format) -> String {
match format {
Format::Netscape => jar.to_netscape(),
Format::Json => jar.to_json(),
Format::Header => jar.to_header(),
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn list_is_a_subcommand() {
let cli = Cli::try_parse_from(["unjar", "list"]).unwrap();
assert!(matches!(cli.command, Some(Command::List)));
}
#[test]
fn export_arguments_conflict_with_list() {
for args in [
vec!["unjar", "x.com", "list"],
vec!["unjar", "-f", "json", "list"],
vec!["unjar", "-b", "chrome", "list"],
] {
let cli = Cli::try_parse_from(args).unwrap();
assert!(export(cli.export).unwrap_err().to_string().contains("`list` is a command"));
}
assert!(Cli::try_parse_from(["unjar", "list", "x.com"]).is_err());
}
#[test]
fn browser_and_profile_flags_are_independent() {
let cli = Cli::try_parse_from([
"unjar",
"example.com",
"--browser",
"chrome",
"--profile",
"Profile 1",
])
.unwrap();
assert_eq!(cli.export.domains, ["example.com"]);
assert_eq!(cli.export.browser, Some(Browser::Chrome));
assert_eq!(cli.export.profile.as_deref(), Some("Profile 1"));
}
#[test]
fn browser_is_optional() {
let cli = Cli::try_parse_from(["unjar", "example.com", "-p", "Work"]).unwrap();
assert_eq!(cli.export.browser, None);
assert_eq!(cli.export.profile.as_deref(), Some("Work"));
}
#[test]
fn multiple_domains_are_positional_arguments() {
let cli = Cli::try_parse_from(["unjar", "x.com", "t.co", "-p", "Work"]).unwrap();
assert_eq!(cli.export.domains, ["x.com", "t.co"]);
}
#[test]
fn options_can_surround_domains() {
for args in [
["unjar", "-f", "json", "x.com", "t.co"],
["unjar", "x.com", "-f", "json", "t.co"],
["unjar", "x.com", "t.co", "-f", "json"],
] {
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.export.domains, ["x.com", "t.co"]);
assert_eq!(cli.export.format, Format::Json);
}
}
#[test]
fn all_is_an_explicit_exclusive_target() {
let cli = Cli::try_parse_from(["unjar", "all", "-b", "firefox"]).unwrap();
assert_eq!(cli.export.domains, ["all"]);
let cli = Cli::try_parse_from(["unjar", "x.com", "all"]).unwrap();
assert!(export(cli.export).unwrap_err().to_string().contains("cannot be combined"));
}
#[test]
fn a_target_is_required_for_export() {
let cli = Cli::try_parse_from(["unjar", "-b", "chrome"]).unwrap();
assert!(export(cli.export).unwrap_err().to_string().contains("missing domain"));
}
#[test]
fn header_format_requires_one_domain() {
for args in
[vec!["unjar", "-f", "header", "all"], vec!["unjar", "-f", "header", "x.com", "t.co"]]
{
let cli = Cli::try_parse_from(args).unwrap();
assert!(export(cli.export).unwrap_err().to_string().contains("exactly one domain"));
}
}
}