From 05b109fc4499bb6daafbff4935c4787734c13a29 Mon Sep 17 00:00:00 2001 From: hahwul Date: Thu, 25 Jun 2026 00:17:47 +0900 Subject: [PATCH] feat(output): modernize terminal output with a badges + rules design Introduce a centralized presentation layer (src/printing/theme.rs) and restyle every human-readable command output to a cohesive "badges + rules" look: section headers with an accent bar + horizontal rule, dim-label / accent-value key-value rows, colored severity badges for scan & jwks, and a unified braille spinner + block-fill progress bar. - theme: section/subsection headers, kv rows, status badges (badge / badge_width), spinner / progress_bar helpers, shared glyph constants. - Logger & banner refreshed: dropped the "[..] [..]" bracket noise for a dim time + glyph line; compact dot-separated identity line in the banner. - utils log_* and spinner helpers now delegate to the theme. - Fix a latent alignment bug: width padding was applied to already-colored strings (the count includes ANSI escape bytes, so columns drift under a real TTY); helpers now pad the plain text and then apply color. Contracts preserved (verified): - --json output is byte-identical (the machine-readable path is untouched). - Severity::as_str() / its Serialize impl and the HTML report are unchanged. - stdout data lines (tokens, payloads, JSON, PEM) stay structurally intact and pipe-safe. - Color stays TTY / NO_COLOR gated via the `colored` crate. --- src/cmd/crack.rs | 122 ++++++++++++------------ src/cmd/decode.rs | 93 +++++++++++-------- src/cmd/encode.rs | 25 +++-- src/cmd/jwks.rs | 106 +++++++++++---------- src/cmd/payload.rs | 96 +++++++++++++------ src/cmd/scan.rs | 73 ++++++++++----- src/cmd/version.rs | 15 +-- src/lib.rs | 1 + src/printing/mod.rs | 49 +++++----- src/printing/theme.rs | 209 ++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 40 ++++---- 11 files changed, 572 insertions(+), 257 deletions(-) create mode 100644 src/printing/theme.rs diff --git a/src/cmd/crack.rs b/src/cmd/crack.rs index 8dcd2d1..2b2ab14 100644 --- a/src/cmd/crack.rs +++ b/src/cmd/crack.rs @@ -1,5 +1,5 @@ -use colored::Colorize; -use indicatif::{HumanDuration, MultiProgress, ProgressBar, ProgressStyle}; +use colored::{Color, Colorize}; +use indicatif::{HumanDuration, MultiProgress, ProgressBar}; use log::{error, info}; use rayon::prelude::*; use serde::Serialize; @@ -14,6 +14,7 @@ use zeroize::Zeroize; use crate::crack; use crate::jwt; +use crate::printing::theme; use crate::utils; /// Maps preset names to their corresponding character sets @@ -273,13 +274,7 @@ fn create_crack_progress_bar( if verbose { return None; } - let progress = multi.add(ProgressBar::new(total)); - progress.set_style( - ProgressStyle::default_bar() - .template("{spinner:.red} [{elapsed_precise}] Cracking.. [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}") - .expect("valid progress bar template") - .progress_chars("#>-") - ); + let progress = multi.add(theme::progress_bar(total, "Cracking")); Some(progress) } @@ -457,16 +452,20 @@ fn report_crack_results( }; let secret_label = if is_jwe { "Key" } else { "Secret" }; - eprintln!("\n {} {}", "✓".green(), label.bold()); + eprintln!( + "\n{}", + theme::status_line(theme::G_OK, Color::Green, label.bold()) + ); println!(); - println!(" {:<14}{}", secret_label.bold(), secret.bold()); + println!("{}", theme::kv(secret_label, secret.bold())); println!( - " {:<14}{} ({:.2} keys/sec)", - "Time".bold(), - HumanDuration(elapsed), - rate + "{}", + theme::kv( + "Time", + format!("{} ({:.2} keys/sec)", HumanDuration(elapsed), rate) + ) ); - println!(" {:<14}{}", "Token".bold(), utils::format_jwt_token(token)); + println!("{}", theme::kv("Token", utils::format_jwt_token(token))); } else { let label = if is_jwe { "Key not found" @@ -474,12 +473,18 @@ fn report_crack_results( "Secret not found" }; eprintln!( - "\n {} {} ({} keys in {}, {:.2} keys/sec)", - "✗".red(), - label.bold(), - attempts_total, - HumanDuration(elapsed), - rate + "\n{}", + theme::status_line( + theme::G_ERR, + Color::Red, + format!( + "{} ({} keys in {}, {:.2} keys/sec)", + label.bold(), + attempts_total, + HumanDuration(elapsed), + rate + ) + ) ); } } @@ -542,20 +547,9 @@ fn crack_dictionary( None }; // Show a spinner while loading/processing batches - let loading_pb = if let Some(ref multi) = multi { - let pb = multi.add(ProgressBar::new_spinner()); - pb.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.blue} {msg}") - .expect("valid spinner template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.set_message("Processing wordlist..."); - pb.enable_steady_tick(Duration::from_millis(100)); - Some(pb) - } else { - None - }; + let loading_pb = multi + .as_ref() + .map(|multi| multi.add(theme::spinner("Processing wordlist..."))); let found = Arc::new(Mutex::new(None::)); let found_flag = Arc::new(AtomicBool::new(false)); @@ -1017,19 +1011,9 @@ fn crack_target_field( let mut bytes_read: u64 = 0; let loading_pb = if emit_output { - let pb = multi.as_ref().map(|m| { - let pb = m.add(ProgressBar::new_spinner()); - pb.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.blue} {msg}") - .expect("valid spinner template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.set_message("Processing wordlist (targeted field)..."); - pb.enable_steady_tick(Duration::from_millis(100)); - pb - }); - pb + multi + .as_ref() + .map(|m| m.add(theme::spinner("Processing wordlist (targeted field)..."))) } else { None }; @@ -1116,27 +1100,37 @@ fn crack_target_field( if emit_output { if let Some(value) = found.lock().unwrap_or_else(|e| e.into_inner()).clone() { eprintln!( - "\n {} {}", - "✓".green(), - "Matching field value found".bold() + "\n{}", + theme::status_line( + theme::G_OK, + Color::Green, + "Matching field value found".bold() + ) ); println!(); - println!(" {:<14}{}", "Field".bold(), target_field.bold()); - println!(" {:<14}{}", "Value".bold(), value.bold()); + println!("{}", theme::kv("Field", target_field.bold())); + println!("{}", theme::kv("Value", value.bold())); println!( - " {:<14}{} ({:.2} attempts/sec)", - "Time".bold(), - HumanDuration(elapsed), - rate + "{}", + theme::kv( + "Time", + format!("{} ({:.2} attempts/sec)", HumanDuration(elapsed), rate) + ) ); } else { eprintln!( - "\n {} {} ({} attempts in {}, {:.2} attempts/sec)", - "✗".red(), - "No matching field value found".bold(), - attempts_total, - HumanDuration(elapsed), - rate + "\n{}", + theme::status_line( + theme::G_ERR, + Color::Red, + format!( + "{} ({} attempts in {}, {:.2} attempts/sec)", + "No matching field value found".bold(), + attempts_total, + HumanDuration(elapsed), + rate + ) + ) ); } } diff --git a/src/cmd/decode.rs b/src/cmd/decode.rs index cf9e1bb..18b4a6d 100644 --- a/src/cmd/decode.rs +++ b/src/cmd/decode.rs @@ -3,6 +3,7 @@ use colored::Colorize; use serde_json::Value; use crate::jwt; +use crate::printing::theme; use crate::utils; /// Helper function to format a Unix timestamp to a human-readable string. @@ -112,20 +113,22 @@ fn decode_jwt_token(token: &str) -> Result<()> { .and_then(|v| v.as_str()) .unwrap_or("JWT"); - println!(" {:<14}{}", "Algorithm".bold(), alg_str.cyan()); - println!(" {:<14}{}", "Type".bold(), typ); + println!("{}", theme::section_line("Decode")); + println!(); + println!("{}", theme::kv("Algorithm", alg_str.cyan())); + println!("{}", theme::kv("Type", typ)); - println!("\n {}", "Header".bold()); + println!("\n{}", theme::subsection_line("Header")); let header_json = serde_json::to_string_pretty(&decoded.header)?; - println!(" {}", header_json.replace('\n', "\n ")); + println!("{}{}", theme::INDENT, header_json.replace('\n', "\n ")); let mut claims_map: Value = decoded.claims.clone(); process_issued_at_claim(&decoded.claims, &mut claims_map); process_expiration_claim(&decoded.claims, &mut claims_map); - println!("\n {}", "Payload".bold()); + println!("\n{}", theme::subsection_line("Payload")); let payload_json = serde_json::to_string_pretty(&claims_map)?; - println!(" {}", payload_json.replace('\n', "\n ")); + println!("{}{}", theme::INDENT, payload_json.replace('\n', "\n ")); Ok(()) } @@ -157,57 +160,73 @@ fn decode_jwt_token_json(token: &str) -> Result { fn decode_jwe_token(token: &str) -> Result<()> { let decoded = jwt::decode_jwe(token)?; - println!(" {:<14}{}", "Key Mgmt".bold(), decoded.algorithm.cyan()); - println!(" {:<14}{}", "Encryption".bold(), decoded.encryption.cyan()); + println!("{}", theme::section_line("Decode · JWE")); + println!(); + println!("{}", theme::kv("Key Mgmt", decoded.algorithm.cyan())); + println!("{}", theme::kv("Encryption", decoded.encryption.cyan())); - println!("\n {}", "Header".bold()); + println!("\n{}", theme::subsection_line("Header")); let header_json = serde_json::to_string_pretty(&decoded.header)?; - println!(" {}", header_json.replace('\n', "\n ")); + println!("{}{}", theme::INDENT, header_json.replace('\n', "\n ")); + println!("\n{}", theme::subsection_line("Components")); println!( - "\n {:<18}{}", - "Encrypted Key".bold(), - if decoded.encrypted_key.is_empty() { - "(empty)".dimmed().to_string() - } else { - utils::format_base64_preview(&decoded.encrypted_key) - } + "{}", + theme::kv_line( + "Encrypted Key", + if decoded.encrypted_key.is_empty() { + "(empty)".dimmed().to_string() + } else { + utils::format_base64_preview(&decoded.encrypted_key) + }, + 18 + ) ); println!( - " {:<18}{}", - "IV".bold(), - if decoded.iv.is_empty() { - "(empty)".dimmed().to_string() - } else { - utils::format_base64_preview(&decoded.iv) - } + "{}", + theme::kv_line( + "IV", + if decoded.iv.is_empty() { + "(empty)".dimmed().to_string() + } else { + utils::format_base64_preview(&decoded.iv) + }, + 18 + ) ); println!( - " {:<18}{}", - "Ciphertext".bold(), - utils::format_base64_preview(&decoded.ciphertext) + "{}", + theme::kv_line( + "Ciphertext", + utils::format_base64_preview(&decoded.ciphertext), + 18 + ) ); println!( - " {:<18}{}", - "Auth Tag".bold(), - if decoded.tag.is_empty() { - "(empty)".dimmed().to_string() - } else { - utils::format_base64_preview(&decoded.tag) - } + "{}", + theme::kv_line( + "Auth Tag", + if decoded.tag.is_empty() { + "(empty)".dimmed().to_string() + } else { + utils::format_base64_preview(&decoded.tag) + }, + 18 + ) ); eprintln!( - "\n {}", + "\n{}{}", + theme::INDENT, "JWE payload is encrypted and cannot be decoded without the appropriate key".dimmed() ); // Check for misconfigurations let misconfigs = jwt::detect_jwe_misconfigurations(&decoded); if !misconfigs.is_empty() { - eprintln!("\n {}", "Security Issues Detected:".yellow().bold()); + eprintln!("\n{}", theme::subsection_line("Security Issues")); for issue in misconfigs { - eprintln!(" {}", issue); + eprintln!("{}{}", theme::INDENT, issue.yellow()); } } diff --git a/src/cmd/encode.rs b/src/cmd/encode.rs index d04f9f3..f1e4b99 100644 --- a/src/cmd/encode.rs +++ b/src/cmd/encode.rs @@ -6,6 +6,7 @@ use std::fs; use std::path::PathBuf; use crate::jwt; +use crate::printing::theme; use crate::utils; /// Options for encoding operations @@ -155,18 +156,20 @@ fn display_encoding_result( key_info: &str, headers: &[(String, String)], ) { - println!(" {:<14}{}", "Algorithm".bold(), algorithm.cyan()); - println!(" {:<14}{}", "Key".bold(), key_info); + println!("{}", theme::section_line("Encode")); + println!(); + println!("{}", theme::kv("Algorithm", algorithm.cyan())); + println!("{}", theme::kv("Key", key_info)); if !headers.is_empty() { - println!("\n {}", "Headers".bold()); + println!("\n{}", theme::subsection_line("Headers")); for (key, value) in headers { - println!(" {:<14}{}", key.to_string().dimmed(), value); + println!("{}", theme::kv(key, value)); } } - println!("\n {}", "Token".bold()); - println!(" {}", utils::format_jwt_token(token)); + println!("\n{}", theme::subsection_line("Token")); + println!("{}{}", theme::INDENT, utils::format_jwt_token(token)); } fn encode_json( @@ -250,11 +253,13 @@ fn encode_jwe(json_str: &str, secret: Option<&str>) -> Result<()> { let key = secret.unwrap_or("default_jwe_key"); let token = jwt::encode_jwe_demo(json_str, key)?; - println!(" {:<14}{}", "Key Mgmt".bold(), "dir".cyan()); - println!(" {:<14}{}", "Encryption".bold(), "A256GCM".cyan()); + println!("{}", theme::section_line("Encode · JWE")); + println!(); + println!("{}", theme::kv("Key Mgmt", "dir".cyan())); + println!("{}", theme::kv("Encryption", "A256GCM".cyan())); - println!("\n {}", "Token".bold()); - println!(" {}", token); + println!("\n{}", theme::subsection_line("Token")); + println!("{}{}", theme::INDENT, token); Ok(()) } diff --git a/src/cmd/jwks.rs b/src/cmd/jwks.rs index 4132545..2b57548 100644 --- a/src/cmd/jwks.rs +++ b/src/cmd/jwks.rs @@ -2,6 +2,7 @@ use colored::Colorize; use serde_json::Value; use std::path::PathBuf; +use crate::printing::theme; use crate::utils; use jwt_hack::jwks; @@ -176,22 +177,23 @@ pub fn execute_json(action: &super::JwksAction) -> anyhow::Result { pub fn execute_fetch(url: &str) { match jwks::fetch_jwks(url) { Ok(jwk_set) => { - println!("\n {}", "JWKS Endpoint".bold()); - println!(" {:<18}{}", "URL".dimmed(), url); - println!(" {:<18}{}", "Keys Found".dimmed(), jwk_set.keys.len()); + println!("{}", theme::section_line("JWKS Endpoint")); + println!(); + println!("{}", theme::kv_line("URL", url, 18)); + println!("{}", theme::kv_line("Keys Found", jwk_set.keys.len(), 18)); for (i, key) in jwk_set.keys.iter().enumerate() { - println!("\n {} {}", "Key".bold(), format!("#{}", i + 1).bold()); - println!(" {:<18}{}", "Type (kty)".dimmed(), key.kty); + println!("\n{}", theme::subsection_line(&format!("Key #{}", i + 1))); + println!("{}", theme::kv_line("Type (kty)", &key.kty, 18)); if let Some(kid) = &key.kid { - println!(" {:<18}{}", "Key ID (kid)".dimmed(), kid); + println!("{}", theme::kv_line("Key ID (kid)", kid, 18)); } if let Some(alg) = &key.alg { - println!(" {:<18}{}", "Algorithm".dimmed(), alg); + println!("{}", theme::kv_line("Algorithm", alg, 18)); } if let Some(use_) = &key.key_use { - println!(" {:<18}{}", "Use".dimmed(), use_); + println!("{}", theme::kv_line("Use", use_, 18)); } match key.kty.as_str() { @@ -208,47 +210,48 @@ pub fn execute_fetch(url: &str) { } else { n_str.to_string() }; - println!(" {:<18}{}", "Modulus (n)".dimmed(), n_display); + println!("{}", theme::kv_line("Modulus (n)", n_display, 18)); } if let Some(e) = &key.e { - println!(" {:<18}{}", "Exponent (e)".dimmed(), e); + println!("{}", theme::kv_line("Exponent (e)", e, 18)); } // Try to convert to PEM match jwks::jwk_rsa_to_pem(key) { Ok(pem) => { - println!(" {:<18}{}", "PEM".dimmed(), "OK (extractable)".green()); + println!( + "{}", + theme::kv_line("PEM", "OK (extractable)".green(), 18) + ); println!("\n{}", pem); } Err(e) => { println!( - " {:<18}{}", - "PEM".dimmed(), - format!("Error: {}", e).red() + "{}", + theme::kv_line("PEM", format!("Error: {}", e).red(), 18) ); } } } "EC" => { if let Some(crv) = &key.crv { - println!(" {:<18}{}", "Curve".dimmed(), crv); + println!("{}", theme::kv_line("Curve", crv, 18)); } if let Some(x) = &key.x { - println!(" {:<18}{}", "X".dimmed(), x); + println!("{}", theme::kv_line("X", x, 18)); } if let Some(y) = &key.y { - println!(" {:<18}{}", "Y".dimmed(), y); + println!("{}", theme::kv_line("Y", y, 18)); } } "oct" => { println!( - " {:<18}{}", - "Key".dimmed(), - "(symmetric key present)".yellow() + "{}", + theme::kv_line("Key", "(symmetric key present)".yellow(), 18) ); } _ => { - println!(" {:<18}(unknown key type)", "Details".dimmed()); + println!("{}", theme::kv_line("Details", "(unknown key type)", 18)); } } } @@ -283,9 +286,10 @@ pub fn execute_spoof( match jwks::generate_jwks_injection_payloads(token_str, url, algorithm) { Ok(result) => { - println!("\n {}", "JWKS Injection Attack".bold()); - println!(" {:<18}{}", "Algorithm".dimmed(), algorithm); - println!(" {:<18}{}", "Attacker URL".dimmed(), url); + println!("{}", theme::section_line("JWKS Injection Attack")); + println!(); + println!("{}", theme::kv_line("Algorithm", algorithm, 18)); + println!("{}", theme::kv_line("Attacker URL", url, 18)); // Save JWKS to file if output specified if let Some(output_path) = output { @@ -303,28 +307,28 @@ pub fn execute_spoof( } } - println!("\n {}", "Spoofed JWKS".bold()); + println!("\n{}", theme::subsection_line("Spoofed JWKS")); println!("{}", result.jwks_json); - println!("\n {}", "Private Key".bold()); + println!("\n{}", theme::subsection_line("Private Key")); println!("{}", result.private_key_pem); - println!("\n {}", "Injection Payloads".bold()); + println!("\n{}", theme::subsection_line("Injection Payloads")); for payload in &result.payloads { println!( - "\n {}", - format!( + "\n{}", + theme::subsection_line(&format!( "{} ({})", payload.header_type.to_uppercase(), payload.description - ) - .bold() + )) ); println!(" {}", payload.token); } println!( - "\n {}", + "\n{}{}", + theme::INDENT, "Host the JWKS JSON at the attacker URL and use the injection tokens.".yellow() ); } @@ -338,8 +342,9 @@ pub fn execute_spoof( // Simple spoof without injection match jwks::generate_spoofed_jwks(algorithm, kid, token) { Ok(spoofed) => { - println!("\n {}", "Spoofed JWKS".bold()); - println!(" {:<18}{}", "Algorithm".dimmed(), algorithm); + println!("{}", theme::section_line("Spoofed JWKS")); + println!(); + println!("{}", theme::kv_line("Algorithm", algorithm, 18)); // Save JWKS to file if output specified if let Some(output_path) = output { @@ -357,14 +362,14 @@ pub fn execute_spoof( } } - println!("\n {}", "JWKS (Public Key Set)".bold()); + println!("\n{}", theme::subsection_line("JWKS (Public Key Set)")); println!("{}", spoofed.jwks_json); - println!("\n {}", "Private Key (PEM)".bold()); + println!("\n{}", theme::subsection_line("Private Key (PEM)")); println!("{}", spoofed.private_key_pem); if let Some(signed_token) = &spoofed.signed_token { - println!("\n {}", "Signed Token".bold()); + println!("\n{}", theme::subsection_line("Signed Token")); println!(" {}", signed_token); } } @@ -407,8 +412,9 @@ pub fn execute_verify(token: &str, url: Option<&str>, jwks_file: Option<&PathBuf return; }; - println!("\n {}", "JWKS Verification".bold()); - println!(" {:<18}{}", "Keys".dimmed(), jwk_set.keys.len()); + println!("{}", theme::section_line("JWKS Verification")); + println!(); + println!("{}", theme::kv_line("Keys", jwk_set.keys.len(), 18)); match jwks::verify_with_jwks(token, &jwk_set) { Ok(results) => { @@ -417,9 +423,9 @@ pub fn execute_verify(token: &str, url: Option<&str>, jwks_file: Option<&PathBuf for result in &results { let status = if result.valid { any_valid = true; - "VALID".green().to_string() + theme::badge_width(theme::G_OK, "VALID", colored::Color::Green, 7) } else { - "INVALID".red().to_string() + theme::badge_width(theme::G_ERR, "INVALID", colored::Color::Red, 7) }; let alg_str = result.alg.as_deref().unwrap_or("(unspecified)").to_string(); @@ -488,8 +494,12 @@ pub fn execute_rotate(token: &str, keys_dir: Option<&PathBuf>, key_files: &[Path return; } - println!("\n {}", "Key Rotation Test".bold()); - println!(" {:<18}{}", "Keys to Test".dimmed(), all_key_paths.len()); + println!("{}", theme::section_line("Key Rotation Test")); + println!(); + println!( + "{}", + theme::kv_line("Keys to Test", all_key_paths.len(), 18) + ); match jwks::test_key_rotation(token, &all_key_paths) { Ok(results) => { @@ -498,9 +508,9 @@ pub fn execute_rotate(token: &str, keys_dir: Option<&PathBuf>, key_files: &[Path for result in &results { let status = if result.valid { valid_count += 1; - "VALID".green().to_string() + theme::badge_width(theme::G_OK, "VALID", colored::Color::Green, 7) } else { - "INVALID".red().to_string() + theme::badge_width(theme::G_ERR, "INVALID", colored::Color::Red, 7) }; println!("\n {} {}", status, result.key_file.bold()); @@ -510,9 +520,11 @@ pub fn execute_rotate(token: &str, keys_dir: Option<&PathBuf>, key_files: &[Path } } - println!("\n {}", "Summary".bold()); + println!("\n{}", theme::section_line("Summary")); + println!(); println!( - " {} of {} keys verified the token", + "{}{} of {} keys verified the token", + theme::INDENT, valid_count, results.len() ); diff --git a/src/cmd/payload.rs b/src/cmd/payload.rs index 52b9167..7f919b5 100644 --- a/src/cmd/payload.rs +++ b/src/cmd/payload.rs @@ -1,12 +1,12 @@ use anyhow::Result; use base64::{engine::general_purpose, Engine as _}; -use colored::Colorize; use log::info; use serde_json::json; use serde_json::Value; use std::collections::HashSet; use crate::jwt; +use crate::printing::theme; use crate::utils; /// Generates different JWT attack payloads based on the given token and parameters @@ -133,7 +133,10 @@ fn generate_payloads( if should_generate_all || targets.contains("alg_confusion") { if let Ok(payloads) = crate::payload::generate_alg_confusion_payload(token, None) { for payload in payloads { - println!("\n {}", "Algorithm Confusion (RS256->HS256)".bold()); + println!( + "\n{}", + theme::subsection_line("Algorithm Confusion (RS256->HS256)") + ); println!(" {payload}"); } } @@ -143,7 +146,7 @@ fn generate_payloads( if should_generate_all || targets.contains("kid_sql") { if let Ok(payloads) = crate::payload::generate_kid_sql_payload(token) { for payload in payloads { - println!("\n {}", "kid SQL Injection".bold()); + println!("\n{}", theme::subsection_line("kid SQL Injection")); println!(" {payload}"); } } @@ -153,7 +156,7 @@ fn generate_payloads( if should_generate_all || targets.contains("x5c") { if let Ok(payloads) = crate::payload::generate_x5c_payload(token) { for payload in payloads { - println!("\n {}", "x5c Header Injection".bold()); + println!("\n{}", theme::subsection_line("x5c Header Injection")); println!(" {payload}"); } } @@ -163,7 +166,7 @@ fn generate_payloads( if should_generate_all || targets.contains("cty") { if let Ok(payloads) = crate::payload::generate_cty_payload(token) { for payload in payloads { - println!("\n {}", "cty Header Manipulation".bold()); + println!("\n{}", theme::subsection_line("cty Header Manipulation")); println!(" {payload}"); } } @@ -173,7 +176,10 @@ fn generate_payloads( if should_generate_all || targets.contains("jwk_embed") { match crate::payload::generate_jwk_embed_payload(token) { Ok(payload) => { - println!("\n {}", "jwk Embedded Header (signed)".bold()); + println!( + "\n{}", + theme::subsection_line("jwk Embedded Header (signed)") + ); println!(" {payload}"); } Err(e) => { @@ -186,7 +192,7 @@ fn generate_payloads( if should_generate_all || targets.contains("kid_traversal") { if let Ok(payloads) = crate::payload::generate_kid_traversal_payload(token) { for payload in payloads { - println!("\n {}", "kid Path Traversal".bold()); + println!("\n{}", theme::subsection_line("kid Path Traversal")); println!(" {payload}"); } } @@ -196,7 +202,7 @@ fn generate_payloads( if should_generate_all || targets.contains("crit") { if let Ok(payloads) = crate::payload::generate_crit_payload(token) { for payload in payloads { - println!("\n {}", "crit Header Bypass".bold()); + println!("\n{}", theme::subsection_line("crit Header Bypass")); println!(" {payload}"); } } @@ -206,7 +212,7 @@ fn generate_payloads( if should_generate_all || targets.contains("b64") { if let Ok(payloads) = crate::payload::generate_b64_payload(token) { for payload in payloads { - println!("\n {}", "b64=false (RFC 7797)".bold()); + println!("\n{}", theme::subsection_line("b64=false (RFC 7797)")); println!(" {payload}"); } } @@ -216,7 +222,7 @@ fn generate_payloads( if should_generate_all || targets.contains("empty_sig") { if let Ok(payloads) = crate::payload::generate_empty_sig_payload(token) { for payload in payloads { - println!("\n {}", "Empty/Stripped Signature".bold()); + println!("\n{}", theme::subsection_line("Empty/Stripped Signature")); println!(" {payload}"); } } @@ -226,7 +232,10 @@ fn generate_payloads( if should_generate_all || targets.contains("x5c_signed") { match crate::payload::generate_x5c_signed_payload(token) { Ok(payload) => { - println!("\n {}", "x5c Self-signed Cert (signed)".bold()); + println!( + "\n{}", + theme::subsection_line("x5c Self-signed Cert (signed)") + ); println!(" {payload}"); } Err(e) => { @@ -239,7 +248,10 @@ fn generate_payloads( if should_generate_all || targets.contains("psychic") { if let Ok(payloads) = crate::payload::generate_psychic_signature_payload(token) { for payload in payloads { - println!("\n {}", "ECDSA Psychic Signature (CVE-2022-21449)".bold()); + println!( + "\n{}", + theme::subsection_line("ECDSA Psychic Signature (CVE-2022-21449)") + ); println!(" {payload}"); } } @@ -249,7 +261,7 @@ fn generate_payloads( if should_generate_all || targets.contains("typ_confusion") { if let Ok(payloads) = crate::payload::generate_typ_confusion_payload(token) { for payload in payloads { - println!("\n {}", "typ Confusion".bold()); + println!("\n{}", theme::subsection_line("typ Confusion")); println!(" {payload}"); } } @@ -259,7 +271,7 @@ fn generate_payloads( if should_generate_all || targets.contains("alg_edge") { if let Ok(payloads) = crate::payload::generate_alg_edge_payload(token) { for payload in payloads { - println!("\n {}", "alg Edge Value".bold()); + println!("\n{}", theme::subsection_line("alg Edge Value")); println!(" {payload}"); } } @@ -269,7 +281,7 @@ fn generate_payloads( if should_generate_all || targets.contains("ssrf") { if let Ok(payloads) = crate::payload::generate_jku_x5u_ssrf_payload(token) { for payload in payloads { - println!("\n {}", "jku/x5u SSRF Probe".bold()); + println!("\n{}", theme::subsection_line("jku/x5u SSRF Probe")); println!(" {payload}"); } } @@ -279,7 +291,10 @@ fn generate_payloads( if should_generate_all || targets.contains("zip") { if let Ok(payloads) = crate::payload::generate_zip_payload(token) { for payload in payloads { - println!("\n {}", "zip Variant / Decompression Bomb".bold()); + println!( + "\n{}", + theme::subsection_line("zip Variant / Decompression Bomb") + ); println!(" {payload}"); } } @@ -289,7 +304,7 @@ fn generate_payloads( if should_generate_all || targets.contains("kid_predictable") { if let Ok(payloads) = crate::payload::generate_kid_predictable_payload(token, None) { for payload in payloads { - println!("\n {}", "kid Predictable Path".bold()); + println!("\n{}", theme::subsection_line("kid Predictable Path")); println!(" {payload}"); } } @@ -299,7 +314,10 @@ fn generate_payloads( if should_generate_all || targets.contains("dup_key") { if let Ok(payloads) = crate::payload::generate_duplicate_key_payload(token) { for payload in payloads { - println!("\n {}", "Duplicate JSON Key (alg/typ/kid)".bold()); + println!( + "\n{}", + theme::subsection_line("Duplicate JSON Key (alg/typ/kid)") + ); println!(" {payload}"); } } @@ -309,7 +327,7 @@ fn generate_payloads( if should_generate_all || targets.contains("nested") { if let Ok(payloads) = crate::payload::generate_nested_jwt_payload(token) { for payload in payloads { - println!("\n {}", "Nested JWT (cty=JWT)".bold()); + println!("\n{}", theme::subsection_line("Nested JWT (cty=JWT)")); println!(" {payload}"); } } @@ -319,7 +337,10 @@ fn generate_payloads( if should_generate_all || targets.contains("jwk_embed_ec") { match crate::payload::generate_jwk_embed_ec_payload(token) { Ok(payload) => { - println!("\n {}", "jwk Embedded Header EC (signed ES256)".bold()); + println!( + "\n{}", + theme::subsection_line("jwk Embedded Header EC (signed ES256)") + ); println!(" {payload}"); } Err(e) => { @@ -332,7 +353,10 @@ fn generate_payloads( if should_generate_all || targets.contains("jws_json") { if let Ok(payloads) = crate::payload::generate_jws_json_payload(token) { for payload in payloads { - println!("\n {}", "JWS Flattened JSON Serialization".bold()); + println!( + "\n{}", + theme::subsection_line("JWS Flattened JSON Serialization") + ); println!(" {payload}"); } } @@ -342,7 +366,10 @@ fn generate_payloads( if should_generate_all || targets.contains("alg_family_swap") { if let Ok(payloads) = crate::payload::generate_alg_family_swap_payload(token) { for payload in payloads { - println!("\n {}", "alg Cross-family Swap (PS↔RS / ES family)".bold()); + println!( + "\n{}", + theme::subsection_line("alg Cross-family Swap (PS↔RS / ES family)") + ); println!(" {payload}"); } } @@ -352,7 +379,10 @@ fn generate_payloads( if should_generate_all || targets.contains("none_sig") { if let Ok(payloads) = crate::payload::generate_none_with_sig_payload(token) { for payload in payloads { - println!("\n {}", "alg=none + Non-empty Signature".bold()); + println!( + "\n{}", + theme::subsection_line("alg=none + Non-empty Signature") + ); println!(" {payload}"); } } @@ -362,7 +392,10 @@ fn generate_payloads( if should_generate_all || targets.contains("header_quirks") { if let Ok(payloads) = crate::payload::generate_header_quirks_payload(token) { for payload in payloads { - println!("\n {}", "Header Quirks (BOM / WS / Trailing Junk)".bold()); + println!( + "\n{}", + theme::subsection_line("Header Quirks (BOM / WS / Trailing Junk)") + ); println!(" {payload}"); } } @@ -372,7 +405,10 @@ fn generate_payloads( if should_generate_all || targets.contains("kid_wildcard") { if let Ok(payloads) = crate::payload::generate_kid_wildcard_payload(token) { for payload in payloads { - println!("\n {}", "kid Empty/Null/Wildcard Fallback".bold()); + println!( + "\n{}", + theme::subsection_line("kid Empty/Null/Wildcard Fallback") + ); println!(" {payload}"); } } @@ -395,7 +431,10 @@ fn generate_none_payloads(claims: &str, alg_value: &str) -> Result<()> { // Base64 encode the header for JWT format let encoded_header = general_purpose::URL_SAFE_NO_PAD.encode(header_json.as_bytes()); - println!("\n {}", format!("None Algorithm ({alg_value})").bold()); + println!( + "\n{}", + theme::subsection_line(&format!("None Algorithm ({alg_value})")) + ); println!(" {}.{}", encoded_header, claims); Ok(()) @@ -442,7 +481,10 @@ fn generate_url_payloads( for (i, payload) in payloads.iter().enumerate() { let label = payload_labels.get(i).unwrap_or(&"Bypass"); - println!("\n {}", format!("{label} ({key_type})").bold()); + println!( + "\n{}", + theme::subsection_line(&format!("{label} ({key_type})")) + ); println!(" {payload}"); } } diff --git a/src/cmd/scan.rs b/src/cmd/scan.rs index ad193b6..62ee4f5 100644 --- a/src/cmd/scan.rs +++ b/src/cmd/scan.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use crate::jwt; use crate::payload; +use crate::printing::theme; use crate::utils; /// Options for customizing the scan @@ -68,6 +69,24 @@ impl Severity { Severity::Info => colored::Color::Cyan, } } + + /// Render a colored status badge for terminal output. A non-vulnerable check + /// shows a green `PASS`; a vulnerable one shows a severity-specific glyph and + /// short label. This is presentation only — it never feeds the JSON/HTML + /// report (those use `as_str()`), so the machine-readable contract is intact. + fn badge(&self, vulnerable: bool) -> String { + if !vulnerable { + return theme::badge(theme::G_OK, "PASS", colored::Color::Green); + } + let (glyph, label) = match self { + Severity::Critical => ("▲", "CRIT"), + Severity::High => ("▲", "HIGH"), + Severity::Medium => ("◆", "MED"), + Severity::Low => ("■", "LOW"), + Severity::Info => (theme::G_INFO, "INFO"), + }; + theme::badge(glyph, label, self.color()) + } } impl Serialize for Severity { @@ -171,14 +190,17 @@ fn parse_header_only(token: &str) -> Result { fn run_scan(token: &str, options: &ScanOptions, report_path: Option<&PathBuf>) -> Result<()> { let report = scan_token(token, options, !options.skip_payloads)?; - println!(" {}", "Token".bold()); - println!(" {:<18}{}", "Algorithm".dimmed(), report.algorithm.cyan()); - println!(" {:<18}{}", "Type".dimmed(), report.typ); + println!("{}", theme::section_line("Scan")); + println!(); + println!( + "{}", + theme::kv_line("Algorithm", report.algorithm.cyan(), 18) + ); + println!("{}", theme::kv_line("Type", &report.typ, 18)); if let Some(err) = &report.strict_decode_error { println!( - " {:<18}{}", - "Strict decode".dimmed(), - format!("rejected: {err}").yellow() + "{}", + theme::kv_line("Strict decode", format!("rejected: {err}").yellow(), 18) ); } @@ -187,9 +209,10 @@ fn run_scan(token: &str, options: &ScanOptions, report_path: Option<&PathBuf>) - if !options.skip_payloads { if let Some(payloads) = &report.attack_payloads { if !payloads.is_empty() { - println!("\n {}", "Attack Payloads".bold()); + println!("\n{}", theme::section_line("Attack Payloads")); + println!(); for p in payloads { - println!(" {}", p); + println!("{}{}", theme::INDENT, p); } } } @@ -1274,7 +1297,8 @@ fn check_zip_header(decoded: &jwt::DecodedToken) -> Result /// Display scan results fn display_results(results: &[VulnerabilityResult]) { - println!("\n {}", "Results".bold()); + println!("\n{}", theme::section_line("Results")); + println!(); let mut vulnerable_count = 0; let mut critical_count = 0; @@ -1294,25 +1318,21 @@ fn display_results(results: &[VulnerabilityResult]) { } } - let status = if result.vulnerable { - "✗".red().to_string() - } else { - "✓".green().to_string() - }; - - let severity_str = result.severity.as_str().color(result.severity.color()); - + // Badge carries both status and severity. Pad the plain name *before* + // coloring so the columns line up under a real TTY. + let name_padded = format!("{:<22}", result.name); println!( - " {} {:<22} {:<12} {}", - status, - result.name.bold(), - severity_str, + "{}{} {} {}", + theme::INDENT, + result.severity.badge(result.vulnerable), + name_padded.bold(), result.details ); } // Summary - println!("\n {}", "Summary".bold()); + println!("\n{}", theme::section_line("Summary")); + println!(); if vulnerable_count > 0 { let mut parts = Vec::new(); if critical_count > 0 { @@ -1328,12 +1348,17 @@ fn display_results(results: &[VulnerabilityResult]) { parts.push(format!("{} low", low_count)); } println!( - " {} vulnerabilities found: {}", + "{}{} vulnerabilities found: {}", + theme::INDENT, vulnerable_count, parts.join(", ") ); } else { - println!(" {} No vulnerabilities detected", "✓".green()); + println!( + "{}{} No vulnerabilities detected", + theme::INDENT, + theme::G_OK.green() + ); } } diff --git a/src/cmd/version.rs b/src/cmd/version.rs index 7edb29f..b812022 100644 --- a/src/cmd/version.rs +++ b/src/cmd/version.rs @@ -1,16 +1,17 @@ -use crate::printing::VERSION; -use colored::Colorize; +use crate::printing::{theme, VERSION}; use serde_json::Value; /// Displays version information and other project details pub fn execute() { - println!(" {:<14}{}", "Version".dimmed(), VERSION); - println!(" {:<14}@hahwul", "Author".dimmed()); + println!("{}", theme::section_line("jwt-hack")); + println!(); + println!("{}", theme::kv("Version", VERSION)); + println!("{}", theme::kv("Author", "@hahwul")); println!( - " {:<14}https://github.com/hahwul/jwt-hack", - "Repository".dimmed() + "{}", + theme::kv("Repository", "https://github.com/hahwul/jwt-hack") ); - println!(" {:<14}MIT", "License".dimmed()); + println!("{}", theme::kv("License", "MIT")); } pub fn execute_json() -> anyhow::Result { diff --git a/src/lib.rs b/src/lib.rs index 961e76a..9da8ba3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,5 @@ pub mod config; pub mod jwe_attacks; pub mod jwks; pub mod jwt; +pub mod printing; pub mod utils; diff --git a/src/printing/mod.rs b/src/printing/mod.rs index dc8476b..0bc7012 100644 --- a/src/printing/mod.rs +++ b/src/printing/mod.rs @@ -1,3 +1,4 @@ +pub mod theme; pub mod version; use colored::Colorize; @@ -16,28 +17,31 @@ impl log::Log for PrettyLogger { fn log(&self, record: &Record) { if self.enabled(record.metadata()) { - let timestamp = chrono::Local::now().format("%H:%M:%S%.3f"); - let level_str = match record.level() { - Level::Error => format!("{}", "✗".red()), - Level::Warn => format!("{}", "⚠".yellow()), - Level::Info => format!("{}", "▸".cyan()), - Level::Debug => format!("{}", "●".dimmed()), - Level::Trace => format!("{}", "○".dimmed()), + // Dim, second-precision timestamp keeps the line scannable without the + // noisy `[..] [..]` brackets of the old format. + let timestamp = chrono::Local::now().format("%H:%M:%S"); + let glyph = match record.level() { + Level::Error => theme::G_ERR.red(), + Level::Warn => theme::G_WARN.yellow(), + Level::Info => theme::G_INFO.cyan(), + Level::Debug => theme::G_DEBUG.dimmed(), + Level::Trace => theme::G_TRACE.dimmed(), }; + // Only escalated levels tint the message body; info/trace stay plain + // so routine status output reads calmly. let message = match record.level() { - Level::Error => format!("{}", record.args().to_string().red()), - Level::Warn => format!("{}", record.args().to_string().yellow()), - Level::Info => format!("{}", record.args()), - Level::Debug => format!("{}", record.args().to_string().dimmed()), - Level::Trace => format!("{}", record.args()), + Level::Error => record.args().to_string().red(), + Level::Warn => record.args().to_string().yellow(), + Level::Debug => record.args().to_string().dimmed(), + Level::Info | Level::Trace => record.args().to_string().normal(), }; let _ = writeln!( std::io::stderr(), - "[{}] [{}] {}", + "{} {} {}", timestamp.to_string().dimmed(), - level_str, + glyph, message ); } @@ -63,15 +67,16 @@ pub fn banner() { \/_____/ \/_/ \/_/ \/_/ \/_/\/_/ \/_/\/_/ \/_____/ \/_/\/_/ "# .cyan() + .bold() ); + // Single, dot-separated identity line keeps the metadata compact and modern. eprintln!( - "{}{}{}", - " JSON Web Token Hack Toolkit - ".dimmed(), - VERSION.green(), - " by @hahwul".dimmed() - ); - eprintln!( - "{}\n", - " https://github.com/hahwul/jwt-hack".dimmed() + " {} {} {} {} {}", + "JSON Web Token Hack Toolkit".dimmed(), + "·".dimmed(), + VERSION.green().bold(), + "·".dimmed(), + "@hahwul".cyan() ); + eprintln!(" {}\n", "https://github.com/hahwul/jwt-hack".dimmed()); } diff --git a/src/printing/theme.rs b/src/printing/theme.rs new file mode 100644 index 0000000..aefabd9 --- /dev/null +++ b/src/printing/theme.rs @@ -0,0 +1,209 @@ +//! Centralized terminal presentation layer. +//! +//! Every subcommand renders its human-readable output through these helpers so +//! the visual language ("badges + rules") stays consistent. Color is applied via +//! the `colored` crate, which auto-disables on a non-TTY / when `NO_COLOR` is set, +//! so piped output degrades to plain text. Each status badge always carries a +//! short text label next to its glyph (e.g. `▲ CRIT` vs `▲ HIGH`, `● PASS`), so +//! statuses stay distinguishable by glyph + label even without color. +//! +//! These helpers only decorate *framing* (section titles, labels, badges, +//! spinners). They never wrap the actual data lines (tokens, JSON, PEM), so +//! `--json` output and piped data streams remain byte-faithful. + +use colored::{Color, Colorize}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::fmt::Display; +use std::time::Duration; + +/// Two-space indent used for all body lines under a section. +pub const INDENT: &str = " "; +/// Left accent glyph that prefixes every section/sub-section title. +const ACCENT_BAR: &str = "▎"; +/// Glyph used to draw the horizontal rule after a top-level section title. +const RULE_CHAR: &str = "─"; +/// Target column width a top-level section header (bar + title + rule) fills to. +const SECTION_WIDTH: usize = 46; + +/// Braille spinner frames shared by every spinner and progress bar. +pub const BRAILLE: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +// Status glyphs — kept in one place so the logger, the inline `log_*` helpers and +// the badge renderer all speak the same visual language. +pub const G_OK: &str = "✓"; +pub const G_INFO: &str = "▸"; +pub const G_WARN: &str = "⚠"; +pub const G_ERR: &str = "✗"; +pub const G_DEBUG: &str = "●"; +pub const G_TRACE: &str = "○"; + +/// Build a top-level section header: accent bar + UPPERCASED bold title + dim rule. +/// +/// e.g. `▎ DECODE ─────────────────────────────────` +pub fn section_line(title: &str) -> String { + let title_up = title.to_uppercase(); + // Visible columns consumed by "▎ " + title + " " before the rule begins. + let prefix_cols = 2 + title_up.chars().count() + 1; + let dashes = SECTION_WIDTH.saturating_sub(prefix_cols).max(3); + format!( + "{} {} {}", + ACCENT_BAR.cyan(), + title_up.bold(), + RULE_CHAR.repeat(dashes).dimmed() + ) +} + +/// Build a sub-section header: accent bar + bold title (no rule). +/// +/// e.g. `▎ Header` +pub fn subsection_line(title: &str) -> String { + format!("{} {}", ACCENT_BAR.cyan(), title.bold()) +} + +/// Build a key/value row: dim, left-padded label followed by the value. +/// +/// The label is padded *before* coloring so alignment is correct even when ANSI +/// escape sequences are present (padding a `ColoredString` would count the escape +/// bytes and silently break the columns under a real TTY). +pub fn kv_line(label: &str, value: impl Display, width: usize) -> String { + let padded = format!("{label: String { + kv_line(label, value, 14) +} + +/// Build a status badge padded to 4 columns: ` LABEL` rendered in `color`. +/// +/// 4 columns keeps the scan severity badges aligned (`PASS`, `CRIT`, `HIGH`, +/// `MED `, `LOW `). Callers with wider labels should use [`badge_width`]. +pub fn badge(glyph: &str, label: &str, color: Color) -> String { + badge_width(glyph, label, color, 4) +} + +/// Build a status badge padded to `width` columns: ` LABEL` in `color`. +/// +/// The label is padded *before* coloring (same ANSI-safety reason as [`kv_line`]). +/// Labels longer than `width` are never truncated — pass a `width` that fits the +/// widest label in a column so e.g. `VALID`/`INVALID` rows line up. +pub fn badge_width(glyph: &str, label: &str, color: Color, width: usize) -> String { + let padded = format!("{label: message` with only the glyph colored. +pub fn status_line(glyph: &str, color: Color, message: impl Display) -> String { + format!("{} {}", glyph.color(color), message) +} + +/// Create an indeterminate braille spinner with the shared style. +pub fn spinner(message: &str) -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .expect("valid spinner template") + .tick_strings(BRAILLE), + ); + pb.set_message(message.to_string()); + pb.enable_steady_tick(Duration::from_millis(80)); + pb +} + +/// Create a determinate progress bar with the shared "badges + rules" style. +/// +/// `prefix` is shown bold before the bar (e.g. `Cracking`); callers set the +/// message via `set_message` for a trailing detail such as the current rate. +pub fn progress_bar(total: u64, prefix: &str) -> ProgressBar { + let pb = ProgressBar::new(total); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{spinner:.cyan} {prefix:.bold} [{bar:28.cyan/dim}] {percent:>3}% {pos}/{len} {msg} {elapsed:>5}", + ) + .expect("valid progress bar template") + .progress_chars("█▓░") + .tick_strings(BRAILLE), + ); + pb.set_prefix(prefix.to_string()); + pb.enable_steady_tick(Duration::from_millis(80)); + pb +} + +#[cfg(test)] +mod tests { + use super::*; + use colored::control::{set_override, unset_override}; + use std::sync::Mutex; + + // `colored`'s override is a process-global flag. `cargo test` runs tests in + // parallel, so the color-forcing tests below must be serialized against each + // other or one test's restore clobbers another's window (observed as flaky + // "saw ANSI escapes" failures). This lock makes them mutually exclusive. + static COLOR_LOCK: Mutex<()> = Mutex::new(()); + + /// Run `body` with color forced off, then restore auto-detection. Holds the + /// shared lock for the whole window; recovers from a poisoned lock so one + /// failing assertion doesn't cascade into the others. + fn with_color_off(body: impl FnOnce()) { + let _guard = COLOR_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_override(false); + body(); + unset_override(); + } + + #[test] + fn section_line_fills_to_exact_width_uncolored() { + with_color_off(|| { + let line = section_line("decode"); + assert!(line.starts_with("▎ DECODE ")); + let dashes = line.chars().filter(|c| *c == '─').count(); + // Visible cols before the rule: "▎ " (2) + "DECODE" (6) + " " (1) = 9. + assert_eq!(dashes, SECTION_WIDTH - (2 + "DECODE".len() + 1)); + }); + } + + #[test] + fn section_line_rule_floors_at_three_for_long_titles() { + with_color_off(|| { + // A title longer than SECTION_WIDTH saturates the subtraction to 0, + // and the `.max(3)` floor keeps a minimal rule. + let long = "x".repeat(SECTION_WIDTH + 10); + let dashes = section_line(&long).chars().filter(|c| *c == '─').count(); + assert_eq!(dashes, 3); + }); + } + + #[test] + fn kv_line_pads_plain_label() { + with_color_off(|| { + // 14-wide padding: "Type" (4) + 10 spaces before the value. + assert_eq!(kv_line("Type", "JWT", 14), " Type JWT"); + }); + } + + #[test] + fn badge_pads_label_to_four() { + with_color_off(|| { + assert_eq!(badge(G_DEBUG, "MED", Color::Yellow), "● MED "); + }); + } + + #[test] + fn badge_width_pads_wider_labels() { + with_color_off(|| { + // VALID/INVALID share a 7-col column so jwks rows align. + assert_eq!(badge_width(G_OK, "VALID", Color::Green, 7), "✓ VALID "); + assert_eq!(badge_width(G_ERR, "INVALID", Color::Red, 7), "✗ INVALID"); + }); + } + + #[test] + fn subsection_has_accent_bar() { + with_color_off(|| { + assert_eq!(subsection_line("Header"), "▎ Header"); + }); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index f0e3fc5..2af954f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,32 +1,40 @@ -use colored::Colorize; +use colored::{Color, Colorize}; use std::fmt::Display; +use crate::printing::theme; + pub mod compression; /// Displays a success message with a green checkmark prefix pub fn log_success(message: T) { - eprintln!("{} {}", "✓".green(), message); + eprintln!("{}", theme::status_line(theme::G_OK, Color::Green, message)); } /// Displays an information message with a cyan arrow prefix pub fn log_info(message: T) { - eprintln!("{} {}", "▸".cyan(), message); + eprintln!( + "{}", + theme::status_line(theme::G_INFO, Color::Cyan, message) + ); } /// Displays a warning message with a yellow warning symbol prefix pub fn log_warning(message: T) { - eprintln!("{} {}", "⚠".yellow(), message); + eprintln!( + "{}", + theme::status_line(theme::G_WARN, Color::Yellow, message) + ); } /// Displays an error message with a red cross prefix pub fn log_error(message: T) { - eprintln!("{} {}", "✗".red(), message); + eprintln!("{}", theme::status_line(theme::G_ERR, Color::Red, message)); } /// Displays a debug message with a dimmed dot prefix for development purposes #[allow(dead_code)] pub fn log_debug(message: T) { - eprintln!("{} {}", "●".dimmed(), message); + eprintln!("{} {}", theme::G_DEBUG.dimmed(), message); } /// Returns a value formatted with color based on success status (green for success, red for failure) @@ -57,23 +65,17 @@ pub fn format_jwt_token(token: &str) -> String { } /// Creates an animated spinner with a custom color to indicate ongoing operations -pub fn start_progress_with_color(message: &str, color: &str) -> indicatif::ProgressBar { - let pb = indicatif::ProgressBar::new_spinner(); - let template = format!("{{spinner:.{color}}} {{msg}}"); - pb.set_style( - indicatif::ProgressStyle::default_spinner() - .template(&template) - .expect("valid spinner template") - .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]), - ); - pb.set_message(message.to_string()); - pb.enable_steady_tick(std::time::Duration::from_millis(100)); - pb +/// +/// Retained for API compatibility; the color argument is accepted but the shared +/// theme spinner uses the unified cyan accent so spinners look identical +/// everywhere. +pub fn start_progress_with_color(message: &str, _color: &str) -> indicatif::ProgressBar { + theme::spinner(message) } /// Creates an animated spinner to indicate ongoing operations with the specified message pub fn start_progress(message: &str) -> indicatif::ProgressBar { - start_progress_with_color(message, "blue") + theme::spinner(message) } /// Converts a duration into human-readable format (hours, minutes, seconds)