v0.1.0: logo, i18n pt/en, CI com caches, testes e documentação
- Logo oficial (sabia.png) e conjunto de ícones do app desktop. - Interface em português e inglês: tradutor por observação do DOM, seletor de idioma e checagem de cobertura de tradução no CI. - CI/CD com caches (rust-cache, .deb do Tauri em cache) e pipeline de release multiplataforma (CLI em 5 alvos + app desktop via tauri-action), templates de issue/PR. - Suíte de testes de integração do sabia-core (scan e2e, handshake/crack 4-way e PMKID, pcap, trilha de auditoria) — 46 testes verdes. - Documentação completa pt/en: README, ROADMAP, SECURITY, CONTRIBUTING, CHANGELOG e docs/ (cli, interface, arquitetura), com enquadramento de uso autorizado. - Correções de clippy (-D warnings) e formatação em toda a árvore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df27974359
commit
791538c9d9
112 files changed
+15620
-494
No files matched your search
@@ -15,5 +15,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
sabia-core = { path = "../sabia-core" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ctrlc = "3"
|
||||
+395
-104
@@ -1,9 +1,9 @@
|
||||
mod web;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use sabia_core::{
|
||||
audit::AuditLog, capture::RawCapture, iface, netlink, pcapwriter::PcapWriter, scan::ScanTable,
|
||||
};
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use sabia_core::iface;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -16,47 +16,115 @@ use std::time::Duration;
|
||||
version
|
||||
)]
|
||||
struct Cli {
|
||||
/// Trilha de auditoria (padrão: .sabia-audit.log).
|
||||
#[arg(long, global = true, default_value = ".sabia-audit.log")]
|
||||
audit: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Lista interfaces de rede e mostra quais são Wi-Fi.
|
||||
/// Lista interfaces, canais e controla o modo monitor.
|
||||
Iface {
|
||||
#[command(subcommand)]
|
||||
action: IfaceAction,
|
||||
},
|
||||
/// Varre o ar na interface (já em modo monitor) e mostra APs/clientes vistos.
|
||||
/// Captura o ar e mostra redes, clientes e handshakes.
|
||||
Scan {
|
||||
iface: String,
|
||||
/// Canal fixo; sem isso, pula entre todos os canais suportados.
|
||||
/// Canal fixo; sem isso, percorre os canais suportados.
|
||||
#[arg(long)]
|
||||
channel: Option<u32>,
|
||||
/// Banda ao percorrer canais: 2.4, 5, 6 ou all.
|
||||
#[arg(long)]
|
||||
band: Option<String>,
|
||||
/// Tempo em cada canal (ms).
|
||||
#[arg(long, default_value_t = 350)]
|
||||
hop_ms: u64,
|
||||
/// Para a varredura depois de N segundos (padrão: até Ctrl+C).
|
||||
#[arg(long)]
|
||||
seconds: Option<u64>,
|
||||
/// Salva tudo que foi capturado em um .pcap (inclui handshakes).
|
||||
/// Salva os quadros capturados em um .pcap.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
/// Depois de capturar, tenta quebrar o primeiro handshake.
|
||||
#[arg(long, value_name = "DICIONARIO")]
|
||||
crack: Option<PathBuf>,
|
||||
/// Mostra apenas o JSON final.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Analisa um .pcap offline (redes, clientes e handshakes).
|
||||
Analyze {
|
||||
pcap: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Quebra um handshake de um .pcap por dicionário.
|
||||
Crack {
|
||||
pcap: PathBuf,
|
||||
/// Dicionário com uma senha por linha.
|
||||
#[arg(short, long)]
|
||||
wordlist: PathBuf,
|
||||
#[arg(long)]
|
||||
bssid: Option<String>,
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Envia desautenticação para um AP/estação (teste autorizado).
|
||||
Deauth {
|
||||
iface: String,
|
||||
#[arg(long)]
|
||||
bssid: String,
|
||||
/// Sem isso, o destino é broadcast.
|
||||
#[arg(long)]
|
||||
station: Option<String>,
|
||||
#[arg(long, default_value_t = 5)]
|
||||
count: u32,
|
||||
#[arg(long, default_value_t = 7)]
|
||||
reason: u16,
|
||||
/// Confirma que você tem autorização para testar esta rede.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Diagnóstico de privilégios, ferramentas e interfaces.
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Inspeciona a trilha de auditoria.
|
||||
Audit {
|
||||
#[command(subcommand)]
|
||||
action: AuditAction,
|
||||
},
|
||||
/// Verifica e aplica atualizações do próprio sabia.
|
||||
Update {
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
},
|
||||
/// Confere a integridade da trilha de auditoria.
|
||||
AuditVerify { path: PathBuf },
|
||||
/// Abre a interface gráfica (servidor local + navegador).
|
||||
Ui {
|
||||
#[arg(long, default_value = "4337")]
|
||||
port: u16,
|
||||
/// Não tenta abrir o navegador automaticamente.
|
||||
#[arg(long)]
|
||||
no_open: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum IfaceAction {
|
||||
List,
|
||||
/// Mostra os canais suportados pelo rádio.
|
||||
Channels {
|
||||
iface: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Liga/desliga modo monitor numa interface Wi-Fi.
|
||||
Monitor {
|
||||
iface: String,
|
||||
@@ -65,16 +133,23 @@ enum IfaceAction {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AuditAction {
|
||||
/// Confere a integridade da cadeia.
|
||||
Verify { path: Option<PathBuf> },
|
||||
/// Mostra as últimas entradas.
|
||||
Show {
|
||||
#[arg(long, default_value_t = 20)]
|
||||
limit: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, clap::ValueEnum)]
|
||||
enum OnOff {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
fn audit_path() -> PathBuf {
|
||||
PathBuf::from(".sabia-audit.log")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
@@ -84,6 +159,9 @@ fn main() {
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
let audit_path = cli.audit.clone();
|
||||
let engine = Engine::with_audit_path(&audit_path);
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Iface { action } => match action {
|
||||
IfaceAction::List => {
|
||||
@@ -94,17 +172,33 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
i.name,
|
||||
i.mac,
|
||||
tag,
|
||||
format!("{:?}", i.mode),
|
||||
format!("{:?}", i.mode).to_lowercase(),
|
||||
if i.up { "up" } else { "down" }
|
||||
);
|
||||
}
|
||||
}
|
||||
IfaceAction::Channels { iface: name, json } => {
|
||||
let channels = engine.channels(&name)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&channels)?);
|
||||
} else {
|
||||
for c in channels {
|
||||
println!(
|
||||
"canal {:<4} {:>5} MHz {:<6} {}{}",
|
||||
c.channel,
|
||||
c.freq_mhz,
|
||||
c.band.label(),
|
||||
if c.disabled { "desabilitado" } else { "ok" },
|
||||
c.max_power_dbm
|
||||
.map(|p| format!(" até {p:.1} dBm"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
IfaceAction::Monitor { iface: name, state } => {
|
||||
let enable = matches!(state, OnOff::On);
|
||||
iface::get(&name)?; // valida que é Wi-Fi antes de tocar nela
|
||||
netlink::set_monitor_mode(&name, enable)?;
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record("monitor_mode", &name, &format!("{{\"enable\": {enable}}}"))?;
|
||||
engine.set_monitor(&name, enable)?;
|
||||
println!(
|
||||
"{name}: modo monitor {}",
|
||||
if enable { "ativado" } else { "desativado" }
|
||||
@@ -115,104 +209,167 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
Cmd::Scan {
|
||||
iface: name,
|
||||
channel,
|
||||
band,
|
||||
hop_ms,
|
||||
seconds,
|
||||
out,
|
||||
crack,
|
||||
json,
|
||||
} => {
|
||||
let info = iface::get(&name)?;
|
||||
if info.mode != iface::Mode::Monitor {
|
||||
eprintln!("aviso: {name} não está em modo monitor (sabia iface monitor {name} on)");
|
||||
}
|
||||
|
||||
let mut log = AuditLog::open(audit_path())?;
|
||||
log.record(
|
||||
"scan_start",
|
||||
&name,
|
||||
&format!("{{\"channel\": {channel:?}}}"),
|
||||
)?;
|
||||
|
||||
let cap = RawCapture::open(&name)?;
|
||||
cap.set_read_timeout(Duration::from_millis(500))?;
|
||||
let options = ScanOptions {
|
||||
channel,
|
||||
band,
|
||||
hop_ms,
|
||||
pcap_out: out,
|
||||
duration_secs: seconds,
|
||||
};
|
||||
engine.start_scan_with(&name, options)?;
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let r = running.clone();
|
||||
ctrlc::set_handler(move || r.store(false, Ordering::SeqCst))
|
||||
.expect("falha ao instalar handler de Ctrl+C");
|
||||
let _ = ctrlc::set_handler(move || r.store(false, Ordering::SeqCst));
|
||||
|
||||
let mut table = ScanTable::new();
|
||||
let mut writer = match &out {
|
||||
Some(p) => Some(PcapWriter::create(p)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let channels: Vec<u32> = match channel {
|
||||
Some(c) => vec![c],
|
||||
None => netlink::supported_channels(&name).unwrap_or_default(),
|
||||
};
|
||||
let mut chan_idx = 0usize;
|
||||
let mut last_hop = std::time::Instant::now();
|
||||
|
||||
let deadline = seconds.map(|s| std::time::Instant::now() + Duration::from_secs(s));
|
||||
let mut buf = [0u8; 65535];
|
||||
|
||||
println!("capturando em {name}... (Ctrl+C para parar)");
|
||||
let interactive = std::io::stdout().is_terminal() && !json;
|
||||
while running.load(Ordering::SeqCst) {
|
||||
if let Some(d) = deadline {
|
||||
if std::time::Instant::now() >= d {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
let snapshot = match engine.scan_snapshot(&name) {
|
||||
Ok(s) => s,
|
||||
Err(_) => break,
|
||||
};
|
||||
if !snapshot.running {
|
||||
break;
|
||||
}
|
||||
if channel.is_none()
|
||||
&& !channels.is_empty()
|
||||
&& last_hop.elapsed() > Duration::from_millis(300)
|
||||
{
|
||||
chan_idx = (chan_idx + 1) % channels.len();
|
||||
let _ = netlink::set_channel(&name, channels[chan_idx]);
|
||||
last_hop = std::time::Instant::now();
|
||||
}
|
||||
|
||||
match cap.recv(&mut buf)? {
|
||||
Some(n) => {
|
||||
let frame = &buf[..n];
|
||||
let had_eapol = table.ingest(frame);
|
||||
if had_eapol {
|
||||
eprintln!("[!] handshake EAPOL visto");
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.write_frame(frame)?;
|
||||
}
|
||||
}
|
||||
None => continue, // timeout, sem quadro
|
||||
if interactive {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
}
|
||||
if let Some(w) = writer.as_mut() {
|
||||
w.flush()?;
|
||||
|
||||
engine.stop_scan(&name);
|
||||
let snapshot = engine.scan_snapshot(&name)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
||||
} else {
|
||||
if interactive {
|
||||
print!("\x1b[2J\x1b[H");
|
||||
}
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
|
||||
log.record(
|
||||
"scan_stop",
|
||||
&name,
|
||||
&format!("{{\"aps_found\": {}}}", table.access_points().len()),
|
||||
if let Some(wordlist) = crack {
|
||||
crack_first(&engine, &name, &wordlist)?;
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Analyze { pcap, json } => {
|
||||
let snapshot = engine.analyze_pcap(&pcap)?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&snapshot)?);
|
||||
} else {
|
||||
print_snapshot(&snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Crack {
|
||||
pcap,
|
||||
wordlist,
|
||||
bssid,
|
||||
station,
|
||||
json,
|
||||
} => {
|
||||
let mut last = 0u64;
|
||||
let report = sabia_core::crack::crack_pcap(
|
||||
&pcap,
|
||||
&wordlist,
|
||||
bssid.as_deref(),
|
||||
station.as_deref(),
|
||||
|tried| {
|
||||
if tried.saturating_sub(last) >= 4096 {
|
||||
eprint!("\r{tried} senhas testadas…");
|
||||
last = tried;
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<8} {:<8} {:<6} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEG"
|
||||
);
|
||||
for ap in table.access_points() {
|
||||
if !json {
|
||||
eprintln!();
|
||||
}
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else if let Some(password) = &report.password {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<8} {:<8} {:<6} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.unwrap_or_default(),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.last_signal_dbm
|
||||
.map(|s| format!("{s}dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms ({})",
|
||||
report.tried,
|
||||
report.elapsed_ms,
|
||||
report.method.as_deref().unwrap_or("?")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"senha não encontrada ({} tentativas em {} ms)",
|
||||
report.tried, report.elapsed_ms
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Deauth {
|
||||
iface: name,
|
||||
bssid,
|
||||
station,
|
||||
count,
|
||||
reason,
|
||||
yes,
|
||||
} => {
|
||||
if !yes {
|
||||
return Err(sabia_core::SabiaError::InvalidInput(
|
||||
"desautenticação derruba clientes; confirme com --yes apenas em rede própria ou com autorização".into(),
|
||||
));
|
||||
}
|
||||
let report = engine.deauth(&name, &bssid, station.as_deref(), count, reason)?;
|
||||
println!(
|
||||
"{} quadros enviados para {} via {} (motivo {})",
|
||||
report.frames_sent, report.station, report.iface, report.reason
|
||||
);
|
||||
}
|
||||
|
||||
Cmd::Doctor { json } => {
|
||||
let report = engine.doctor();
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_doctor(&report);
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Audit { action } => match action {
|
||||
AuditAction::Verify { path } => {
|
||||
let path = path.unwrap_or_else(|| audit_path.clone());
|
||||
if !path.exists() {
|
||||
println!(
|
||||
"{} não existe (nenhuma ação registrada ainda)",
|
||||
path.display()
|
||||
);
|
||||
} else {
|
||||
match sabia_core::audit::AuditLog::verify(&path)? {
|
||||
None => println!("cadeia de auditoria íntegra ({})", path.display()),
|
||||
Some(idx) => {
|
||||
println!("cadeia ROMPIDA na entrada {idx} ({})", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AuditAction::Show { limit } => {
|
||||
let entries = sabia_core::audit::AuditLog::read_entries(&audit_path, Some(limit))?;
|
||||
if entries.is_empty() {
|
||||
println!("nenhuma entrada em {}", audit_path.display());
|
||||
}
|
||||
for e in entries {
|
||||
println!(
|
||||
"{} {:<18} {:<18} {}",
|
||||
e.timestamp, e.action, e.target, e.detail
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Cmd::Update { apply } => {
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if apply {
|
||||
@@ -233,14 +390,148 @@ fn run(cli: Cli) -> sabia_core::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::AuditVerify { path } => match sabia_core::audit::AuditLog::verify(&path)? {
|
||||
None => println!("cadeia de auditoria íntegra"),
|
||||
Some(idx) => println!("cadeia ROMPIDA na entrada {idx}"),
|
||||
},
|
||||
|
||||
Cmd::Ui { port } => {
|
||||
web::serve(port)?;
|
||||
Cmd::Ui { port, no_open } => {
|
||||
web::serve(port, audit_path, !no_open)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn crack_first(engine: &Engine, iface: &str, wordlist: &PathBuf) -> sabia_core::Result<()> {
|
||||
let Some(hs) = engine
|
||||
.handshakes(Some(iface))
|
||||
.into_iter()
|
||||
.find(|h| h.crackable)
|
||||
else {
|
||||
println!("\nnenhum handshake quebrável capturado");
|
||||
return Ok(());
|
||||
};
|
||||
println!(
|
||||
"\nquebrando handshake de {} ({}) com {}…",
|
||||
hs.ssid.as_deref().unwrap_or("?"),
|
||||
hs.bssid,
|
||||
wordlist.display()
|
||||
);
|
||||
let id = engine.start_crack(iface, Some(&hs.bssid), Some(&hs.station), wordlist)?;
|
||||
loop {
|
||||
let job = engine.crack_job(&id).expect("job");
|
||||
if job.status == "running" {
|
||||
eprint!("\r{} senhas testadas…", job.tried);
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
continue;
|
||||
}
|
||||
eprintln!();
|
||||
match job.report {
|
||||
Some(report) => match report.password {
|
||||
Some(password) => println!(
|
||||
"SENHA ENCONTRADA: {password}\n{} tentativas em {} ms",
|
||||
report.tried, report.elapsed_ms
|
||||
),
|
||||
None => println!(
|
||||
"senha não encontrada ({} tentativas em {} ms)",
|
||||
report.tried, report.elapsed_ms
|
||||
),
|
||||
},
|
||||
None => println!("falha: {}", job.error.unwrap_or_default()),
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_snapshot(snapshot: &sabia_core::scan::ScanSnapshot) {
|
||||
println!(
|
||||
"{} {}{} {} quadros · {} APs · {} clientes · {} handshakes",
|
||||
snapshot.iface,
|
||||
if snapshot.running { "● " } else { "" },
|
||||
snapshot
|
||||
.channel
|
||||
.map(|c| format!("canal {c}"))
|
||||
.unwrap_or_default(),
|
||||
snapshot.stats.frames,
|
||||
snapshot.stats.aps,
|
||||
snapshot.stats.clients,
|
||||
snapshot.stats.handshakes
|
||||
);
|
||||
if !snapshot.access_points.is_empty() {
|
||||
println!(
|
||||
"\n{:<18} {:<24} {:<6} {:<8} {:<12} CLIENTES",
|
||||
"BSSID", "SSID", "CANAL", "SINAL", "SEGURANÇA"
|
||||
);
|
||||
for ap in &snapshot.access_points {
|
||||
println!(
|
||||
"{:<18} {:<24} {:<6} {:<8} {:<12} {}",
|
||||
ap.bssid,
|
||||
ap.ssid.clone().unwrap_or_else(|| "<oculta>".into()),
|
||||
ap.channel.map(|c| c.to_string()).unwrap_or_default(),
|
||||
ap.signal_dbm
|
||||
.map(|s| format!("{s} dBm"))
|
||||
.unwrap_or_default(),
|
||||
ap.security.clone().unwrap_or_default(),
|
||||
ap.clients.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
if !snapshot.handshakes.is_empty() {
|
||||
println!("\nHANDSHAKES");
|
||||
for hs in &snapshot.handshakes {
|
||||
println!(
|
||||
"{:<18} {:<18} {:<16} msgs {:<8} {}{}",
|
||||
hs.bssid,
|
||||
hs.station,
|
||||
hs.ssid.clone().unwrap_or_default(),
|
||||
hs.messages,
|
||||
hs.kind,
|
||||
if hs.crackable { " · quebrável" } else { "" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_doctor(report: &sabia_core::doctor::DoctorReport) {
|
||||
println!("Sabiá {} ({})", report.version, report.platform);
|
||||
println!(
|
||||
"root: {} · iw: {} · ip: {} · curl: {} · captura: {}",
|
||||
yes_no(report.root),
|
||||
yes_no(report.iw),
|
||||
yes_no(report.ip),
|
||||
yes_no(report.curl),
|
||||
report.capture_backend
|
||||
);
|
||||
println!(
|
||||
"auditoria: {} ({})",
|
||||
report.audit_path,
|
||||
if report.audit_ok {
|
||||
"íntegra"
|
||||
} else {
|
||||
"corrompida"
|
||||
}
|
||||
);
|
||||
if !report.interfaces.is_empty() {
|
||||
println!("\nINTERFACES");
|
||||
for i in &report.interfaces {
|
||||
println!(
|
||||
"{:<16} {:<18} {:<10} monitor: {:<3} driver: {}",
|
||||
i.name,
|
||||
i.mac,
|
||||
i.mode,
|
||||
yes_no(i.monitor_supported),
|
||||
i.driver.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
}
|
||||
if !report.notes.is_empty() {
|
||||
println!("\nAVISOS");
|
||||
for note in &report.notes {
|
||||
println!("- {note}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value {
|
||||
"sim"
|
||||
} else {
|
||||
"não"
|
||||
}
|
||||
}
|
||||
+350
-77
@@ -3,31 +3,32 @@
|
||||
//! núcleo por uma API JSON simples. A mesma página em `apps/gui/ui` é o
|
||||
//! que o app desktop (Tauri) carrega — só muda o transporte.
|
||||
|
||||
use sabia_core::engine::Engine;
|
||||
use sabia_core::engine::{Engine, ScanOptions};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../../../../apps/gui/ui/index.html");
|
||||
const APP_CSS: &str = include_str!("../../../../apps/gui/ui/app.css");
|
||||
const APP_JS: &str = include_str!("../../../../apps/gui/ui/app.js");
|
||||
const LOGO_SVG: &str = include_str!("../../../../apps/gui/ui/sabia-logo.svg");
|
||||
const LOGO_PNG: &[u8] = include_bytes!("../../../../apps/gui/ui/sabia.png");
|
||||
|
||||
pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
pub fn serve(port: u16, audit_path: std::path::PathBuf, open: bool) -> sabia_core::Result<()> {
|
||||
let token = gen_token();
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let engine = Arc::new(Engine::new());
|
||||
let engine = Arc::new(Engine::with_audit_path(audit_path));
|
||||
|
||||
println!("Sabiá UI em http://127.0.0.1:{port}/?token={token}");
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
if open {
|
||||
let _ = open_browser(&format!("http://127.0.0.1:{port}/?token={token}"));
|
||||
}
|
||||
|
||||
for stream in listener.incoming() {
|
||||
let stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(stream) = stream else { continue };
|
||||
let engine = engine.clone();
|
||||
let token = token.clone();
|
||||
thread::spawn(move || {
|
||||
@@ -38,36 +39,35 @@ pub fn serve(port: u16) -> sabia_core::Result<()> {
|
||||
}
|
||||
|
||||
fn gen_token() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("{nanos:x}")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open_browser(url: &str) -> std::io::Result<()> {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", url])
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
struct Request {
|
||||
@@ -75,6 +75,44 @@ struct Request {
|
||||
path: String,
|
||||
query: HashMap<String, String>,
|
||||
token: Option<String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
fn text(&self, key: &str) -> Option<String> {
|
||||
if !self.body.is_empty() {
|
||||
if let Ok(serde_json::Value::Object(map)) =
|
||||
serde_json::from_slice::<serde_json::Value>(&self.body)
|
||||
{
|
||||
if let Some(value) = map.get(key) {
|
||||
return match value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Null => None,
|
||||
other => Some(other.to_string()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
self.query.get(key).cloned()
|
||||
}
|
||||
|
||||
fn bool(&self, key: &str) -> bool {
|
||||
self.text(key)
|
||||
.map(|v| v == "true" || v == "1" || v == "on")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn u64(&self, key: &str) -> Option<u64> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
fn u32(&self, key: &str) -> Option<u32> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
|
||||
fn u16(&self, key: &str) -> Option<u16> {
|
||||
self.text(key).and_then(|v| v.parse().ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
@@ -85,37 +123,87 @@ fn parse_request(stream: &mut TcpStream) -> std::io::Result<Request> {
|
||||
let method = parts.next().unwrap_or("GET").to_string();
|
||||
let full_path = parts.next().unwrap_or("/").to_string();
|
||||
|
||||
let mut content_length = 0usize;
|
||||
let mut header_token = None;
|
||||
loop {
|
||||
let mut l = String::new();
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" {
|
||||
if reader.read_line(&mut l)? == 0 || l == "\r\n" || l == "\n" {
|
||||
break;
|
||||
}
|
||||
if let Some((name, value)) = l.split_once(':') {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let value = value.trim();
|
||||
match name.as_str() {
|
||||
"content-length" => content_length = value.parse().unwrap_or(0),
|
||||
"x-sabia-token" => header_token = Some(value.to_string()),
|
||||
"authorization" => {
|
||||
header_token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.map(|v| v.trim().to_string())
|
||||
.or_else(|| Some(value.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, query_str) = match full_path.split_once('?') {
|
||||
Some((p, q)) => (p.to_string(), q.to_string()),
|
||||
None => (full_path, String::new()),
|
||||
};
|
||||
let (path, query_str) = full_path
|
||||
.split_once('?')
|
||||
.map(|(p, q)| (p.to_string(), q.to_string()))
|
||||
.unwrap_or((full_path, String::new()));
|
||||
let mut query = HashMap::new();
|
||||
for pair in query_str.split('&') {
|
||||
if let Some((k, v)) = pair.split_once('=') {
|
||||
query.insert(urldecode(k), urldecode(v));
|
||||
} else if !pair.is_empty() {
|
||||
query.insert(urldecode(pair), String::new());
|
||||
}
|
||||
}
|
||||
let token = query.get("token").cloned();
|
||||
let token = query.get("token").cloned().or(header_token);
|
||||
|
||||
let mut body = vec![0u8; content_length.min(4 * 1024 * 1024)];
|
||||
if !body.is_empty() {
|
||||
reader.read_exact(&mut body)?;
|
||||
}
|
||||
|
||||
Ok(Request {
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
token,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn urldecode(s: &str) -> String {
|
||||
s.replace('+', " ")
|
||||
.replace("%3A", ":")
|
||||
.replace("%2F", "/")
|
||||
.replace("%20", " ")
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'+' => out.push(b' '),
|
||||
b'%' if i + 2 < bytes.len() => {
|
||||
if let (Some(hi), Some(lo)) = (hexval(bytes[i + 1]), hexval(bytes[i + 2])) {
|
||||
out.push((hi << 4) | lo);
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(b'%');
|
||||
}
|
||||
}
|
||||
b => out.push(b),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn hexval(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(
|
||||
@@ -137,7 +225,6 @@ fn handle(
|
||||
);
|
||||
}
|
||||
|
||||
// Assets estáticos: não carregam dado nenhum, então não exigem token.
|
||||
match req.path.as_str() {
|
||||
"/app.css" => return respond(&mut stream, 200, "text/css", APP_CSS.as_bytes()),
|
||||
"/app.js" => {
|
||||
@@ -148,9 +235,7 @@ fn handle(
|
||||
APP_JS.as_bytes(),
|
||||
)
|
||||
}
|
||||
"/sabia-logo.svg" => {
|
||||
return respond(&mut stream, 200, "image/svg+xml", LOGO_SVG.as_bytes())
|
||||
}
|
||||
"/sabia.png" => return respond(&mut stream, 200, "image/png", LOGO_PNG),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -162,65 +247,253 @@ fn handle(
|
||||
&mut stream,
|
||||
401,
|
||||
"application/json",
|
||||
b"{\"error\":\"token invalido\"}",
|
||||
b"{\"ok\":false,\"error\":\"token invalido\"}",
|
||||
);
|
||||
}
|
||||
|
||||
let body = api(engine, &req);
|
||||
respond(&mut stream, 200, "application/json", body.as_bytes())
|
||||
let (code, body) = api(engine, &req);
|
||||
respond(&mut stream, code, "application/json", body.as_bytes())
|
||||
}
|
||||
|
||||
fn respond(stream: &mut TcpStream, code: u16, ctype: &str, body: &[u8]) -> std::io::Result<()> {
|
||||
let status = match code {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
401 => "Unauthorized",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Error",
|
||||
};
|
||||
write!(stream, "HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len())?;
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {code} {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
)?;
|
||||
stream.write_all(body)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> String {
|
||||
fn ok<T: serde::Serialize>(value: &T) -> (u16, String) {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(body) => (200, body),
|
||||
Err(e) => err(500, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_json(value: serde_json::Value) -> (u16, String) {
|
||||
(200, value.to_string())
|
||||
}
|
||||
|
||||
fn err(code: u16, message: impl ToString) -> (u16, String) {
|
||||
(
|
||||
code,
|
||||
serde_json::json!({ "ok": false, "error": message.to_string() }).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnalyzeBody {
|
||||
pcap: String,
|
||||
}
|
||||
|
||||
fn api(engine: &Arc<Engine>, req: &Request) -> (u16, String) {
|
||||
match (req.method.as_str(), req.path.as_str()) {
|
||||
("GET", "/api/version") => format!("{{\"version\":{:?}}}", engine.version()),
|
||||
("GET", "/api/version") => ok_json(serde_json::json!({ "version": engine.version() })),
|
||||
("GET", "/api/doctor") => ok(&engine.doctor()),
|
||||
("GET", "/api/update/check") => match engine.update_check() {
|
||||
Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| "{}".into()),
|
||||
Err(e) => format!("{{\"error\":{:?}}}", e.to_string()),
|
||||
Ok(status) => ok(&status),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/ifaces") => {
|
||||
let list = engine.list_ifaces().unwrap_or_default();
|
||||
serde_json::to_string(&list).unwrap_or_else(|_| "[]".into())
|
||||
("POST", "/api/update/apply") => match engine.update_apply() {
|
||||
Ok(version) => ok_json(serde_json::json!({ "ok": true, "version": version })),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/ifaces") => match engine.list_ifaces() {
|
||||
Ok(list) => ok(&list),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/sessions") => ok(&engine.list_sessions()),
|
||||
("GET", "/api/channels") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.channels(&iface) {
|
||||
Ok(list) => ok(&list),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/monitor") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
let enable = req
|
||||
.query
|
||||
.get("enable")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
match engine.set_monitor(&name, enable) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.set_monitor(&iface, req.bool("enable")) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/channel") => {
|
||||
let (Some(iface), Some(channel)) = (req.text("iface"), req.u32("channel")) else {
|
||||
return err(400, "iface e channel obrigatórios");
|
||||
};
|
||||
match engine.set_channel(&iface, channel) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/start") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
match engine.start_scan(&name) {
|
||||
Ok(()) => "{\"ok\":true}".into(),
|
||||
Err(e) => format!("{{\"ok\":false,\"error\":{:?}}}", e.to_string()),
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
let options = ScanOptions {
|
||||
channel: req.u32("channel"),
|
||||
band: req.text("band"),
|
||||
hop_ms: req.u64("hop_ms").unwrap_or(350),
|
||||
pcap_out: req.text("pcap_out").map(Into::into),
|
||||
duration_secs: req.u64("seconds"),
|
||||
};
|
||||
match engine.start_scan_with(&iface, options) {
|
||||
Ok(()) => ok_json(serde_json::json!({ "ok": true })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/scan/stop") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
engine.stop_scan(&name);
|
||||
"{\"ok\":true}".into()
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
engine.stop_scan(&iface);
|
||||
ok_json(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
("GET", "/api/scan/snapshot") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
match engine.scan_snapshot(&iface) {
|
||||
Ok(snapshot) => ok(&snapshot),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/scan") => {
|
||||
let name = req.query.get("iface").cloned().unwrap_or_default();
|
||||
serde_json::to_string(&engine.scan_results(&name)).unwrap_or_else(|_| "[]".into())
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
ok(&engine.scan_results(&iface))
|
||||
}
|
||||
_ => "{\"error\":\"rota desconhecida\"}".into(),
|
||||
("GET", "/api/scan/clients") => {
|
||||
let Some(iface) = req.text("iface") else {
|
||||
return err(400, "iface obrigatória");
|
||||
};
|
||||
ok(&engine.scan_clients(&iface))
|
||||
}
|
||||
("GET", "/api/handshakes") => ok(&engine.handshakes(req.text("iface").as_deref())),
|
||||
("POST", "/api/handshakes/export") => {
|
||||
let (Some(iface), Some(bssid), Some(station), Some(out)) = (
|
||||
req.text("iface"),
|
||||
req.text("bssid"),
|
||||
req.text("station"),
|
||||
req.text("out"),
|
||||
) else {
|
||||
return err(400, "iface, bssid, station e out são obrigatórios");
|
||||
};
|
||||
match engine.export_handshake(&iface, &bssid, &station, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/handshakes/export-all") => {
|
||||
let (Some(iface), Some(out)) = (req.text("iface"), req.text("out")) else {
|
||||
return err(400, "iface e out são obrigatórios");
|
||||
};
|
||||
match engine.export_all_handshakes(&iface, &out) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/crack") => {
|
||||
let (Some(iface), Some(wordlist)) = (req.text("iface"), req.text("wordlist")) else {
|
||||
return err(400, "iface e wordlist são obrigatórios");
|
||||
};
|
||||
let bssid = req.text("bssid");
|
||||
let station = req.text("station");
|
||||
match engine.start_crack(&iface, bssid.as_deref(), station.as_deref(), &wordlist) {
|
||||
Ok(id) => ok_json(serde_json::json!({ "ok": true, "job": id })),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/crack") => {
|
||||
let Some(id) = req.text("id") else {
|
||||
return err(400, "id obrigatório");
|
||||
};
|
||||
match engine.crack_job(&id) {
|
||||
Some(job) => ok(&job),
|
||||
None => err(404, "job não encontrado"),
|
||||
}
|
||||
}
|
||||
("GET", "/api/cracks") => ok(&engine.crack_jobs()),
|
||||
("POST", "/api/deauth") => {
|
||||
if !req.bool("confirm") {
|
||||
return err(
|
||||
400,
|
||||
"confirme a autorização do teste (confirm=true) antes de desautenticar",
|
||||
);
|
||||
}
|
||||
let (Some(iface), Some(bssid)) = (req.text("iface"), req.text("bssid")) else {
|
||||
return err(400, "iface e bssid são obrigatórios");
|
||||
};
|
||||
let count = req.u32("count").unwrap_or(5);
|
||||
let reason = req.u16("reason").unwrap_or(7);
|
||||
match engine.deauth(
|
||||
&iface,
|
||||
&bssid,
|
||||
req.text("station").as_deref(),
|
||||
count,
|
||||
reason,
|
||||
) {
|
||||
Ok(report) => ok(&report),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("POST", "/api/analyze") => {
|
||||
let pcap = if !req.body.is_empty() {
|
||||
serde_json::from_slice::<AnalyzeBody>(&req.body)
|
||||
.ok()
|
||||
.map(|b| b.pcap)
|
||||
.or_else(|| req.text("pcap"))
|
||||
} else {
|
||||
req.text("pcap")
|
||||
};
|
||||
let Some(pcap) = pcap else {
|
||||
return err(400, "pcap obrigatório");
|
||||
};
|
||||
match engine.analyze_pcap(&pcap) {
|
||||
Ok(snapshot) => ok(&snapshot),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
("GET", "/api/audit/verify") => match engine.audit_verify() {
|
||||
Ok(None) => ok_json(serde_json::json!({ "ok": true, "broken_at": null })),
|
||||
Ok(Some(idx)) => ok_json(serde_json::json!({ "ok": false, "broken_at": idx })),
|
||||
Err(e) => err(400, e),
|
||||
},
|
||||
("GET", "/api/audit") => {
|
||||
let limit = req.u64("limit").map(|v| v as usize).or(Some(50));
|
||||
match engine.audit_entries(limit) {
|
||||
Ok(entries) => ok(&entries),
|
||||
Err(e) => err(400, e),
|
||||
}
|
||||
}
|
||||
_ => err(404, "rota desconhecida"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_url_components() {
|
||||
assert_eq!(urldecode("a%20b%2Fc+d"), "a b/c d");
|
||||
assert_eq!(urldecode("100%25"), "100%");
|
||||
assert_eq!(urldecode("caf%C3%A9"), "café");
|
||||
assert_eq!(urldecode("bad%2"), "bad%2");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user