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:
Lucas RayanandClaude Opus 5.5 committed 2026-10-23 12:46:19 -03:00
1 parent df27974359
commit 791538c9d9
112 files changed
+15620 -494

No files matched your search

+835 -58
View File
@@ -1,26 +1,116 @@
//! Ponte única entre o núcleo e as duas interfaces (servidor local da CLI e
//! o app desktop Tauri): ambos chamam exatamente os mesmos métodos, então
//! qualquer coisa que funcione em um funciona idêntico no outro.
//!
//! Sessões de captura, jobs de quebra, exportação de handshakes e deauth
//! ficam todos aqui — a CLI e a GUI não reimplementam nada.
use crate::audit::AuditLog;
use crate::error::Result;
use crate::iface::{self, Interface};
use crate::netlink;
use crate::scan::{AccessPoint, ScanTable};
use crate::crack::{self, CrackReport};
use crate::doctor::{self, DoctorReport};
use crate::error::{Result, SabiaError};
use crate::handshake::Handshake;
use crate::iface::{self, Interface, Mode};
use crate::netlink::{self, ChannelInfo};
use crate::pcapreader::PcapReader;
use crate::pcapwriter::PcapWriter;
use crate::scan::{AccessPoint, Client, ScanSnapshot, ScanTable};
use crate::update::{self, UpdateStatus};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanOptions {
/// Canal fixo; sem ele, percorre os canais suportados.
pub channel: Option<u32>,
/// `2.4`, `5`, `6` ou `all` (usado quando `channel` é `None`).
pub band: Option<String>,
/// Tempo em cada canal ao pular.
pub hop_ms: u64,
/// Salva os quadros capturados neste `.pcap`.
pub pcap_out: Option<PathBuf>,
/// Encerra sozinho depois de N segundos.
pub duration_secs: Option<u64>,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
channel: None,
band: None,
hop_ms: 350,
pcap_out: None,
duration_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub iface: String,
pub running: bool,
pub channel: Option<u32>,
pub channels: Vec<u32>,
pub frames: u64,
pub aps: usize,
pub clients: usize,
pub handshakes: usize,
pub started_unix: u64,
pub pcap_out: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CrackJobInfo {
pub id: String,
pub iface: String,
pub bssid: Option<String>,
pub station: Option<String>,
pub status: String,
pub tried: u64,
pub started_unix: u64,
pub finished_unix: Option<u64>,
pub report: Option<CrackReport>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportReport {
pub path: String,
pub frames: usize,
pub handshakes: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DeauthReport {
pub iface: String,
pub bssid: String,
pub station: String,
pub reason: u16,
pub frames_sent: usize,
}
struct Session {
table: Arc<Mutex<ScanTable>>,
running: Arc<AtomicBool>,
channel: Arc<Mutex<Option<u32>>>,
channels: Vec<u32>,
started_unix: u64,
pcap_out: Option<PathBuf>,
error: Arc<Mutex<Option<String>>>,
handle: Option<std::thread::JoinHandle<()>>,
}
pub struct Engine {
sessions: Mutex<HashMap<String, Session>>,
jobs: Mutex<HashMap<String, Arc<Mutex<CrackJobInfo>>>>,
job_seq: AtomicU64,
audit_path: PathBuf,
audit_lock: Arc<Mutex<()>>,
}
impl Default for Engine {
@@ -31,9 +121,16 @@ impl Default for Engine {
impl Engine {
pub fn new() -> Self {
Self::with_audit_path(PathBuf::from(".sabia-audit.log"))
}
pub fn with_audit_path(path: impl Into<PathBuf>) -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
audit_path: PathBuf::from(".sabia-audit.log"),
jobs: Mutex::new(HashMap::new()),
job_seq: AtomicU64::new(0),
audit_path: path.into(),
audit_lock: Arc::new(Mutex::new(())),
}
}
@@ -41,91 +138,597 @@ impl Engine {
env!("CARGO_PKG_VERSION")
}
pub fn audit_path(&self) -> &Path {
&self.audit_path
}
pub fn list_ifaces(&self) -> Result<Vec<Interface>> {
iface::list_wireless()
}
pub fn doctor(&self) -> DoctorReport {
doctor::diagnose(self.version(), &self.audit_path)
}
pub fn set_monitor(&self, name: &str, enable: bool) -> Result<()> {
netlink::set_monitor_mode(name, enable)?;
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("monitor_mode", name, &format!("{{\"enable\":{enable}}}"));
iface::get(name)?;
if !enable {
self.stop_scan(name);
}
netlink::set_monitor_mode(name, enable)?;
self.log(
"monitor_mode",
name,
serde_json::json!({ "enable": enable }),
);
Ok(())
}
#[cfg(target_os = "linux")]
pub fn start_scan(&self, name: &str) -> Result<()> {
use crate::capture::RawCapture;
use std::thread;
use std::time::Duration;
pub fn channels(&self, name: &str) -> Result<Vec<ChannelInfo>> {
netlink::channels(name)
}
let mut sessions = self.sessions.lock().unwrap();
if sessions.contains_key(name) {
return Ok(());
pub fn set_channel(&self, name: &str, channel: u32) -> Result<()> {
netlink::set_channel(name, channel)?;
if let Some(session) = self.sessions.lock().unwrap().get(name) {
*session.channel.lock().unwrap() = Some(channel);
}
self.log(
"set_channel",
name,
serde_json::json!({ "channel": channel }),
);
Ok(())
}
pub fn start_scan(&self, name: &str) -> Result<()> {
self.start_scan_with(name, ScanOptions::default())
}
#[cfg(target_os = "linux")]
pub fn start_scan_with(&self, name: &str, options: ScanOptions) -> Result<()> {
use crate::capture::RawCapture;
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de capturar"
)));
}
let cap = RawCapture::open(name)?;
cap.set_read_timeout(Duration::from_millis(200))?;
let writer = match options.pcap_out.as_ref() {
Some(path) => Some(PcapWriter::create(path)?),
None => None,
};
let channels = match options.channel {
Some(channel) => vec![channel],
None => netlink::channel_plan(name, options.band.as_deref()).unwrap_or_default(),
};
let hop = Duration::from_millis(options.hop_ms.clamp(100, 5_000));
// Substitui uma sessão anterior (se houver) só depois que a nova
// captura abriu, para não derrubar a antiga se algo falhar.
self.stop_scan(name);
let table = Arc::new(Mutex::new(ScanTable::new()));
let running = Arc::new(AtomicBool::new(true));
let channel = Arc::new(Mutex::new(None));
let error = Arc::new(Mutex::new(None));
let started_unix = unix_now();
let pcap_out = options.pcap_out.clone();
let t = table.clone();
let r = running.clone();
let ch = channel.clone();
let err_slot = error.clone();
let iface_name = name.to_string();
thread::spawn(move || {
let cap = match RawCapture::open(&iface_name) {
Ok(c) => c,
Err(e) => {
eprintln!("falha ao abrir captura em {iface_name}: {e}");
return;
}
};
let _ = cap.set_read_timeout(Duration::from_millis(500));
let channels = netlink::supported_channels(&iface_name).unwrap_or_default();
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let deadline = options
.duration_secs
.map(|secs| Instant::now() + Duration::from_secs(secs.max(1)));
let thread_channels = channels.clone();
let handle = std::thread::spawn(move || {
let channels = thread_channels;
let mut writer = writer;
let mut idx = 0usize;
let mut last_hop = std::time::Instant::now();
let mut last_hop = Instant::now();
let mut buf = [0u8; 65535];
while r.load(Ordering::SeqCst) {
if !channels.is_empty() && last_hop.elapsed() > Duration::from_millis(300) {
idx = (idx + 1) % channels.len();
let _ = netlink::set_channel(&iface_name, channels[idx]);
last_hop = std::time::Instant::now();
if !channels.is_empty() {
let first = channels[0];
if netlink::set_channel(&iface_name, first).is_ok() {
*ch.lock().unwrap() = Some(first);
}
if let Ok(Some(n)) = cap.recv(&mut buf) {
let mut table = t.lock().unwrap();
table.ingest(&buf[..n]);
}
while r.load(Ordering::SeqCst) {
if deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
if channels.len() > 1 && last_hop.elapsed() >= hop {
idx = (idx + 1) % channels.len();
let next = channels[idx];
if netlink::set_channel(&iface_name, next).is_ok() {
*ch.lock().unwrap() = Some(next);
}
last_hop = Instant::now();
}
match cap.recv(&mut buf) {
Ok(Some(n)) => {
let frame = &buf[..n];
t.lock().unwrap().ingest(frame);
if let Some(w) = writer.as_mut() {
if let Err(e) = w.write_frame(frame) {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
Ok(None) => {}
Err(e) => {
*err_slot.lock().unwrap() = Some(e.to_string());
break;
}
}
}
if let Some(w) = writer.as_mut() {
let _ = w.flush();
}
r.store(false, Ordering::SeqCst);
let stats = t.lock().unwrap().stats();
append_audit(
&audit_path,
&audit_lock,
"scan_stop",
&iface_name,
&serde_json::json!({
"aps": stats.aps,
"clients": stats.clients,
"handshakes": stats.handshakes,
"frames": stats.frames,
}),
);
});
self.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table,
running,
channel,
channels,
started_unix,
pcap_out,
error,
handle: Some(handle),
},
);
self.log(
"scan_start",
name,
serde_json::json!({ "options": options }),
);
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn start_scan_with(&self, _name: &str, _options: ScanOptions) -> Result<()> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let handle = {
let mut sessions = self.sessions.lock().unwrap();
match sessions.get_mut(name) {
Some(session) => {
session.running.store(false, Ordering::SeqCst);
session.handle.take()
}
None => None,
}
};
if let Some(handle) = handle {
let _ = handle.join();
}
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out: Vec<SessionInfo> = sessions
.iter()
.map(|(name, session)| {
let stats = session.table.lock().unwrap().stats();
SessionInfo {
iface: name.clone(),
running: session.running.load(Ordering::SeqCst),
channel: *session.channel.lock().unwrap(),
channels: session.channels.clone(),
frames: stats.frames,
aps: stats.aps,
clients: stats.clients,
handshakes: stats.handshakes,
started_unix: session.started_unix,
pcap_out: session.pcap_out.as_ref().map(|p| p.display().to_string()),
error: session.error.lock().unwrap().clone(),
}
})
.collect();
out.sort_by(|a, b| a.iface.cmp(&b.iface));
out
}
pub fn scan_snapshot(&self, name: &str) -> Result<ScanSnapshot> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
let snapshot = ScanSnapshot {
iface: name.to_string(),
running: session.running.load(Ordering::SeqCst),
channel: *session.channel.lock().unwrap(),
stats: table.stats(),
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
};
drop(table);
drop(sessions);
Ok(snapshot)
}
/// Compatibilidade com a API antiga: só os APs da sessão.
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().access_points())
.unwrap_or_default()
}
pub fn scan_clients(&self, name: &str) -> Vec<Client> {
self.sessions
.lock()
.unwrap()
.get(name)
.map(|s| s.table.lock().unwrap().clients())
.unwrap_or_default()
}
/// Analisa um `.pcap` offline e devolve o mesmo snapshot da captura ao
/// vivo (sem sessão e sem tocar no rádio).
pub fn analyze_pcap(&self, path: impl AsRef<Path>) -> Result<ScanSnapshot> {
let path = path.as_ref();
let mut reader = PcapReader::open(path)?;
if !reader.supports_80211() {
return Err(SabiaError::InvalidPcap(
"link-type não é 802.11 nem radiotap".into(),
));
}
let mut table = ScanTable::new();
while let Some(frame) = reader.next_frame()? {
table.ingest(&frame.data);
}
Ok(ScanSnapshot {
iface: path.display().to_string(),
running: false,
channel: None,
stats: table.stats(),
access_points: table.access_points(),
clients: table.clients(),
handshakes: table.handshakes(),
})
}
pub fn handshakes(&self, name: Option<&str>) -> Vec<crate::handshake::HandshakeInfo> {
let sessions = self.sessions.lock().unwrap();
let mut out = Vec::new();
for (iface_name, session) in sessions.iter() {
if name.is_some_and(|want| want != iface_name) {
continue;
}
out.extend(session.table.lock().unwrap().handshakes());
}
out.sort_by_key(|h| std::cmp::Reverse(h.last_seen_unix));
out
}
pub fn export_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
out: impl AsRef<Path>,
) -> Result<ExportReport> {
let hs = self
.session_handshake(name, bssid, station)?
.ok_or_else(|| SabiaError::InvalidInput("handshake não encontrado".into()))?;
let frames = hs.raw_frames();
if frames.is_empty() {
return Err(SabiaError::InvalidInput(
"handshake sem quadros crus (captura antiga)".into(),
));
}
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
for frame in &frames {
writer.write_frame(frame)?;
}
writer.flush()?;
self.log(
"export_handshake",
name,
serde_json::json!({ "bssid": bssid, "station": station, "path": path, "frames": frames.len() }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames: frames.len(),
handshakes: 1,
})
}
pub fn export_all_handshakes(&self, name: &str, out: impl AsRef<Path>) -> Result<ExportReport> {
let entries = {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let entries = session.table.lock().unwrap().handshake_entries();
entries
};
let path = out.as_ref();
let mut writer = PcapWriter::create(path)?;
let mut frames = 0usize;
let mut handshakes = 0usize;
for hs in &entries {
let raw = hs.raw_frames();
if raw.is_empty() {
continue;
}
for frame in &raw {
writer.write_frame(frame)?;
frames += 1;
}
handshakes += 1;
}
writer.flush()?;
if handshakes == 0 {
return Err(SabiaError::InvalidInput(
"nenhum handshake com quadros crus para exportar".into(),
));
}
self.log(
"export_handshakes",
name,
serde_json::json!({ "path": path, "frames": frames, "handshakes": handshakes }),
);
Ok(ExportReport {
path: path.display().to_string(),
frames,
handshakes,
})
}
pub fn start_crack(
&self,
name: &str,
bssid: Option<&str>,
station: Option<&str>,
wordlist: impl AsRef<Path>,
) -> Result<String> {
let wordlist = wordlist.as_ref();
if !wordlist.is_file() {
return Err(SabiaError::InvalidInput(format!(
"dicionário não encontrado: {}",
wordlist.display()
)));
}
let table = {
let sessions = self.sessions.lock().unwrap();
sessions.get(name).map(|s| s.table.clone()).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?
};
let hs = {
let table = table.lock().unwrap();
match (bssid, station) {
(Some(b), Some(s)) => table.handshake(b, s).filter(|h| h.is_crackable()).cloned(),
(Some(b), None) => table.first_crackable(Some(b), None).cloned(),
(None, Some(s)) => table.first_crackable(None, Some(s)).cloned(),
(None, None) => table.first_crackable(None, None).cloned(),
}
}
.ok_or_else(|| {
SabiaError::InvalidInput("nenhum handshake quebrável para os filtros informados".into())
})?;
let id = format!("crack-{}", self.job_seq.fetch_add(1, Ordering::SeqCst) + 1);
let job = Arc::new(Mutex::new(CrackJobInfo {
id: id.clone(),
iface: name.to_string(),
bssid: Some(hs.bssid.clone()),
station: Some(hs.station.clone()),
status: "running".into(),
tried: 0,
started_unix: unix_now(),
finished_unix: None,
report: None,
error: None,
}));
self.jobs.lock().unwrap().insert(id.clone(), job.clone());
let audit_path = self.audit_path.clone();
let audit_lock = self.audit_lock.clone();
let iface_name = name.to_string();
let wordlist_path = wordlist.to_path_buf();
let job_thread = job.clone();
let hs_bssid = hs.bssid.clone();
let hs_station = hs.station.clone();
let thread_wordlist = wordlist_path.clone();
let thread_bssid = hs_bssid.clone();
let thread_station = hs_station.clone();
std::thread::spawn(move || {
let progress_job = job_thread.clone();
let result = crack::crack_handshake(&hs, &thread_wordlist, move |tried| {
if let Ok(mut j) = progress_job.lock() {
j.tried = tried;
}
});
let mut guard = job_thread.lock().unwrap();
guard.finished_unix = Some(unix_now());
match result {
Ok(report) => {
guard.status = "done".into();
guard.tried = report.tried;
if let Some(password) = report.password.as_deref() {
table.lock().unwrap().set_password(
&thread_bssid,
&thread_station,
password,
);
append_audit(
&audit_path,
&audit_lock,
"crack_success",
&iface_name,
&serde_json::json!({
"bssid": thread_bssid,
"station": thread_station,
"tried": report.tried,
"elapsed_ms": report.elapsed_ms,
}),
);
}
guard.report = Some(report);
}
Err(e) => {
guard.status = "error".into();
guard.error = Some(e.to_string());
}
}
});
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_start", name, "{}");
self.log(
"crack_start",
name,
serde_json::json!({ "job": id, "bssid": hs_bssid, "station": hs_station, "wordlist": wordlist_path }),
);
Ok(id)
}
pub fn crack_job(&self, id: &str) -> Option<CrackJobInfo> {
self.jobs
.lock()
.unwrap()
.get(id)
.map(|j| j.lock().unwrap().clone())
}
pub fn crack_jobs(&self) -> Vec<CrackJobInfo> {
let jobs = self.jobs.lock().unwrap();
let mut out: Vec<_> = jobs.values().map(|j| j.lock().unwrap().clone()).collect();
out.sort_by(|a, b| {
b.started_unix
.cmp(&a.started_unix)
.then_with(|| a.id.cmp(&b.id))
});
out
}
#[cfg(target_os = "linux")]
pub fn deauth(
&self,
name: &str,
bssid: &str,
station: Option<&str>,
count: u32,
reason: u16,
) -> Result<DeauthReport> {
use crate::inject::{self, RawInjector};
if !(1..=256).contains(&count) {
return Err(SabiaError::InvalidInput(
"quantidade de quadros deve ficar entre 1 e 256".into(),
));
}
sessions.insert(name.to_string(), Session { table, running });
Ok(())
let info = iface::get(name)?;
if info.mode == Mode::Managed {
return Err(SabiaError::InvalidInput(format!(
"{name} está em modo managed; ative o modo monitor antes de injetar"
)));
}
let ap = parse_mac(bssid)?;
let dest = match station {
Some(s) => parse_mac(s)?,
None => inject::BROADCAST,
};
let reason = inject::Reason::from_u16(reason);
let frame = inject::deauth_frame(ap, dest, reason);
let injector = RawInjector::open(name)?;
let mut frames_sent = 0usize;
for _ in 0..count {
injector.send(&frame)?;
frames_sent += 1;
std::thread::sleep(Duration::from_millis(10));
}
let report = DeauthReport {
iface: name.to_string(),
bssid: bssid.to_string(),
station: station.unwrap_or("ff:ff:ff:ff:ff:ff").to_string(),
reason,
frames_sent,
};
self.log(
"deauth",
name,
serde_json::json!({
"bssid": bssid,
"station": report.station,
"reason": reason,
"frames": frames_sent,
}),
);
Ok(report)
}
/// Captura crua (AF_PACKET) só existe no Linux (ver `crate::capture`).
/// Em macOS/Windows o app compila e roda, mas a captura ainda não tem
/// backend nativo — próximo passo natural do projeto.
#[cfg(not(target_os = "linux"))]
pub fn start_scan(&self, _name: &str) -> Result<()> {
Err(crate::error::SabiaError::UnsupportedPlatform)
pub fn deauth(
&self,
_name: &str,
_bssid: &str,
_station: Option<&str>,
_count: u32,
_reason: u16,
) -> Result<DeauthReport> {
Err(SabiaError::UnsupportedPlatform)
}
pub fn stop_scan(&self, name: &str) {
let mut sessions = self.sessions.lock().unwrap();
if let Some(s) = sessions.remove(name) {
s.running.store(false, Ordering::SeqCst);
if let Ok(mut log) = AuditLog::open(&self.audit_path) {
let _ = log.record("scan_stop", name, "{}");
}
pub fn audit_verify(&self) -> Result<Option<usize>> {
if !self.audit_path.exists() {
return Ok(None);
}
AuditLog::verify(&self.audit_path)
}
pub fn scan_results(&self, name: &str) -> Vec<AccessPoint> {
let sessions = self.sessions.lock().unwrap();
match sessions.get(name) {
Some(s) => s.table.lock().unwrap().access_points(),
None => Vec::new(),
pub fn audit_entries(&self, limit: Option<usize>) -> Result<Vec<crate::audit::AuditEntry>> {
if !self.audit_path.exists() {
return Ok(Vec::new());
}
AuditLog::read_entries(&self.audit_path, limit)
}
pub fn update_check(&self) -> Result<UpdateStatus> {
@@ -136,4 +739,178 @@ impl Engine {
let exe = std::env::current_exe()?;
update::apply(&exe)
}
fn session_handshake(
&self,
name: &str,
bssid: &str,
station: &str,
) -> Result<Option<Handshake>> {
let sessions = self.sessions.lock().unwrap();
let session = sessions.get(name).ok_or_else(|| {
SabiaError::InvalidInput(format!("nenhuma sessão de captura em {name}"))
})?;
let table = session.table.lock().unwrap();
Ok(table.handshake(bssid, station).cloned())
}
fn log(&self, action: &str, target: &str, details: serde_json::Value) {
append_audit(&self.audit_path, &self.audit_lock, action, target, &details);
}
}
fn append_audit(
path: &Path,
lock: &Arc<Mutex<()>>,
action: &str,
target: &str,
details: &serde_json::Value,
) {
let _guard = lock.lock().unwrap();
let Ok(mut log) = AuditLog::open(path) else {
return;
};
let text = details.to_string();
let _ = log.record(action, target, &text);
}
fn parse_mac(text: &str) -> Result<[u8; 6]> {
crate::dot11::parse_mac(text)
.ok_or_else(|| SabiaError::InvalidInput(format!("MAC inválido: {text}")))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radiotap::Band;
use crate::testutil::{
ap, beacon_frame, frame_with_eapol, handshake_frames, radiotap_wrap, sta,
};
use tempfile::TempDir;
fn seed_session(engine: &Engine, name: &str) -> Arc<Mutex<ScanTable>> {
let mut table = ScanTable::new();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
table.ingest(&beacon);
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
table.ingest(&radiotap_wrap(&f1, 2437, -40));
table.ingest(&radiotap_wrap(&f2, 2437, -41));
assert_eq!(table.stats().handshakes, 1);
let table = Arc::new(Mutex::new(table));
engine.sessions.lock().unwrap().insert(
name.to_string(),
Session {
table: table.clone(),
running: Arc::new(AtomicBool::new(false)),
channel: Arc::new(Mutex::new(Some(6))),
channels: vec![6],
started_unix: 1,
pcap_out: None,
error: Arc::new(Mutex::new(None)),
handle: None,
},
);
table
}
#[test]
fn analyzes_pcap_and_snapshot() {
let dir = TempDir::new().unwrap();
let pcap = dir.path().join("capture.pcap");
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
let table = seed_session(&engine, "wlan-test");
let hs = {
let table = table.lock().unwrap();
table
.handshake("00:11:22:33:44:55", "66:77:88:99:aa:bb")
.cloned()
.unwrap()
};
let mut writer = PcapWriter::create(&pcap).unwrap();
let beacon = radiotap_wrap(&beacon_frame(ap(), "RedeWPA", 6, &[2]), 2437, -40);
writer.write_frame(&beacon).unwrap();
for frame in hs.raw_frames() {
writer.write_frame(&frame).unwrap();
}
writer.flush().unwrap();
let snapshot = engine.analyze_pcap(&pcap).unwrap();
assert_eq!(snapshot.access_points.len(), 1);
assert_eq!(snapshot.access_points[0].ssid.as_deref(), Some("RedeWPA"));
assert_eq!(snapshot.access_points[0].band, Some(Band::B2G4));
assert_eq!(snapshot.handshakes.len(), 1);
assert!(snapshot.handshakes[0].crackable);
assert_eq!(snapshot.stats.frames, 3);
}
#[test]
fn exports_handshake_and_cracks_it() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
seed_session(&engine, "wlan-test");
let export = dir.path().join("hs.pcap");
let report = engine
.export_handshake(
"wlan-test",
"00:11:22:33:44:55",
"66:77:88:99:aa:bb",
&export,
)
.unwrap();
assert_eq!(report.frames, 2);
assert!(export.is_file());
let wordlist = dir.path().join("words.txt");
std::fs::write(&wordlist, "errada\noutra\npassword\nnunca\n").unwrap();
let id = engine
.start_crack(
"wlan-test",
Some("00:11:22:33:44:55"),
Some("66:77:88:99:aa:bb"),
&wordlist,
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let job = engine.crack_job(&id).unwrap();
if job.status != "running" {
assert_eq!(job.status, "done");
assert_eq!(
job.report.as_ref().and_then(|r| r.password.as_deref()),
Some("password")
);
break;
}
assert!(Instant::now() < deadline, "job não terminou");
std::thread::sleep(Duration::from_millis(20));
}
let hs = engine.handshakes(Some("wlan-test"));
assert_eq!(hs[0].password.as_deref(), Some("password"));
}
#[test]
fn errors_are_clear_for_missing_inputs() {
let dir = TempDir::new().unwrap();
let engine = Engine::with_audit_path(dir.path().join("audit.log"));
assert!(engine.scan_snapshot("nope").is_err());
assert!(engine
.start_crack("nope", None, None, dir.path().join("nada.txt"))
.is_err());
assert!(engine.crack_job("nope").is_none());
assert!(engine.audit_entries(Some(10)).unwrap().is_empty());
}
}