chore: initial project skeleton

Acauã: Network Detection & Threat Hunting, heuristic analysis over a
rolling 7-day window using deterministic math (time series, graph
theory, information theory) instead of opaque ML models. Third
product in the Ciberfauna ecosystem, alongside Guará and Sabiá.

Follows the ecosystem's established conventions: OpenWolf project
memory, bilingual PT-BR/EN docs and UI, Gitea Actions CI/CD on the
same self-hosted runners, Conventional Commits, SemVer, logo and
productName handling, MIT OR Apache-2.0 licensing.

Architecture decision (made with the project owner after a dedicated
consultation, not the original blueprint's suggested stack): Rust
throughout, not Python/FastAPI/React. Most of the statistics (Bowley
skew, MADM, Shannon entropy, PCR, Z-score, novel-edge detection,
pivot chains) reduces to SQL over an embedded DuckDB/Parquet store;
only the spectral analysis and graph centrality need host-language
code, and both have workable Rust crates. Documented in full, including
six corrections to the original blueprint's math (Rayleigh/Schuster
instead of a degenerate Lomb-Scargle over bare timestamps, an explicit
sentinel for Bowley skew's Q3=Q1 case, Jensen-Shannon instead of
unbounded KL, a dedicated long-connection detector, JA4 vs. JA4+
licensing caution, deterministic per-alert evidence), in
docs/architecture.md.

Skeleton only: crates/acaua-core has the four detection pillars,
fusion, storage, zeek log parsing and the updater as typed stubs that
compile and return a clear "skeleton" status rather than real
detection logic — that is separate, future work. crates/acaua-cli has
serve/analyze/doctor/update. apps/gui is a minimal Tauri shell. Also
already includes the one-click desktop self-update mechanism shared
with Guará and Sabiá (AppImage/.deb/Windows installer), even with no
release published yet, for consistency across the ecosystem.

No Gitea repository exists for this project yet; this is a local-only
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
This commit is contained in:
Lucas RayanandClaude Sonnet 5 committed 2026-10-31 10:20:03 -03:00
commit 1b974bb90d
95 files changed
+12016

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
[package]
name = "acaua-core"
description = "Acauã: deterministic network threat hunting core"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
rust-version.workspace = true
[features]
default = []
duckdb = ["dep:duckdb"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "1"
time = { version = "0.3", features = ["formatting", "macros"] }
duckdb = { version = "1", features = ["bundled"], optional = true }
[dev-dependencies]
tempfile = "3"
+61
View File
@@ -0,0 +1,61 @@
use serde::Serialize;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: &'static str,
pub ok: bool,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub checks: Vec<Check>,
pub status: &'static str,
}
fn command_available(name: &str, arg: &str) -> Check {
match Command::new(name).arg(arg).output() {
Ok(output) if output.status.success() => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: true,
detail: format!("{name} is available"),
},
Ok(_) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: false,
detail: format!("{name} returned an error"),
},
Err(error) => Check {
name: if name == "zeek" { "zeek" } else { "runtime" },
ok: false,
detail: format!("{name} not found: {error}"),
},
}
}
pub fn run() -> DoctorReport {
let checks = vec![
command_available("zeek", "--version"),
command_available("curl", "--version"),
command_available("tar", "--version"),
Check {
name: "duckdb",
ok: cfg!(feature = "duckdb"),
detail: if cfg!(feature = "duckdb") {
"bundled DuckDB feature is enabled".to_string()
} else {
"bundled DuckDB feature is disabled in this build".to_string()
},
},
Check {
name: "research",
ok: true,
detail: "research workspace is isolated from the product".to_string(),
},
];
DoctorReport {
checks,
status: "skeleton",
}
}
+21
View File
@@ -0,0 +1,21 @@
use std::io;
#[derive(Debug, thiserror::Error)]
pub enum AcauaError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("command failed: {cmd}: {detail}")]
CommandFailed { cmd: String, detail: String },
#[error("feature not implemented yet: {0}")]
NotImplemented(&'static str),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, AcauaError>;
+25
View File
@@ -0,0 +1,25 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Alert {
pub id: String,
pub pillar: u8,
pub score: f64,
pub summary: String,
pub evidence: serde_json::Value,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FusionReport {
pub alerts: Vec<Alert>,
pub status: &'static str,
}
pub fn fuse(_scores: &[f64]) -> Result<FusionReport> {
Ok(FusionReport {
alerts: Vec::new(),
status: "skeleton",
})
}
+12
View File
@@ -0,0 +1,12 @@
pub mod doctor;
pub mod error;
pub mod fusion;
pub mod pillar1;
pub mod pillar2;
pub mod pillar3;
pub mod pillar4;
pub mod storage;
pub mod update;
pub mod zeek;
pub use error::{AcauaError, Result};
+24
View File
@@ -0,0 +1,24 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BeaconingInput {
pub timestamps: Vec<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BeaconingScore {
pub score: f64,
pub observations: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &BeaconingInput) -> Result<BeaconingScore> {
Ok(BeaconingScore {
score: 0.0,
observations: input.timestamps.len(),
method: "rayleigh-schuster-stub",
status: "skeleton",
})
}
+33
View File
@@ -0,0 +1,33 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionInput {
pub durations_secs: Vec<f64>,
pub bytes: Vec<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionScore {
pub score: f64,
pub observations: usize,
pub long_connections: usize,
pub bowley_sentinel: Option<f64>,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &SessionInput) -> Result<SessionScore> {
Ok(SessionScore {
score: 0.0,
observations: input.durations_secs.len(),
long_connections: input
.durations_secs
.iter()
.filter(|duration| **duration >= 300.0)
.count(),
bowley_sentinel: None,
method: "long-connection-stub",
status: "skeleton",
})
}
+30
View File
@@ -0,0 +1,30 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProtocolInput {
pub events: usize,
pub protocols: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProtocolScore {
pub score: f64,
pub observations: usize,
pub distinct_protocols: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &ProtocolInput) -> Result<ProtocolScore> {
let mut protocols = input.protocols.clone();
protocols.sort();
protocols.dedup();
Ok(ProtocolScore {
score: 0.0,
observations: input.events,
distinct_protocols: protocols.len(),
method: "jensen-shannon-stub",
status: "skeleton",
})
}
+26
View File
@@ -0,0 +1,26 @@
use crate::error::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FusionInput {
pub beaconing: f64,
pub sessions: f64,
pub protocol: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct FusionScore {
pub score: f64,
pub inputs: usize,
pub method: &'static str,
pub status: &'static str,
}
pub fn score(input: &FusionInput) -> Result<FusionScore> {
Ok(FusionScore {
score: 0.0,
inputs: [input.beaconing, input.sessions, input.protocol].len(),
method: "deterministic-fusion-stub",
status: "skeleton",
})
}
+49
View File
@@ -0,0 +1,49 @@
use crate::error::Result;
use serde::Serialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct DatasetSummary {
pub records: u64,
pub partitions: usize,
pub ttl_days: u32,
pub backend: &'static str,
pub status: &'static str,
}
impl DatasetSummary {
pub fn empty() -> Self {
Self {
records: 0,
partitions: 0,
ttl_days: 7,
backend: "duckdb-parquet",
status: "skeleton",
}
}
}
#[derive(Debug, Clone)]
pub struct Storage {
root: PathBuf,
}
impl Storage {
pub fn open(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn summary(&self) -> Result<DatasetSummary> {
Ok(DatasetSummary::empty())
}
pub fn prune_expired(&self) -> Result<usize> {
Ok(0)
}
}
+594
View File
@@ -0,0 +1,594 @@
use crate::error::{AcauaError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/acaua/releases/latest";
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/acaua/releases";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
}
fn curl_text(url: &str) -> Result<String> {
let output = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", url])
.output()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !output.status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn curl_download(url: &str, destination: &Path) -> Result<()> {
let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: acaua-updater", "-o"])
.arg(destination)
.arg(url)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: format!("curl {url}"),
detail: "download failed".to_string(),
});
}
Ok(())
}
fn asset_suffix_for_platform() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"windows"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else {
"aarch64"
};
let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("-{os}-{arch}.{extension}")
}
pub fn check(current_version: &str) -> Result<UpdateStatus> {
let body = match curl_text(LATEST_RELEASE_URL) {
Ok(body) => body,
Err(_) => {
return Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: None,
update_available: false,
});
}
};
let release: Release = serde_json::from_str(&body)?;
let latest = release.tag_name.trim_start_matches('v').to_string();
let update_available = latest != current_version;
Ok(UpdateStatus {
current_version: current_version.to_string(),
latest_version: Some(latest),
update_available,
})
}
pub fn apply(current_exe: &Path) -> Result<String> {
let is_cli = current_exe
.file_stem()
.map(|stem| stem == "acaua")
.unwrap_or(false);
if !is_cli {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic updates only replace the acaua CLI; download the desktop installer from {RELEASES_PAGE_URL}"
),
});
}
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = asset_suffix_for_platform();
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(&suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending in `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|candidate| candidate.name == format!("{}.sha256", asset.name));
let temp_dir = std::env::temp_dir().join("acaua-update");
fs::create_dir_all(&temp_dir)?;
let archive_path = temp_dir.join(&asset.name);
curl_download(&asset.browser_download_url, &archive_path)?;
if let Some(checksum_asset) = checksum_asset {
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&archive_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash = format!("{:x}", hasher.finalize());
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch; update discarded".to_string(),
});
}
}
let extract_dir = temp_dir.join("extracted");
let _ = fs::remove_dir_all(&extract_dir);
fs::create_dir_all(&extract_dir)?;
let status = Command::new("tar")
.arg("xf")
.arg(&archive_path)
.arg("-C")
.arg(&extract_dir)
.status()
.map_err(|error| AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: error.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "tar xf".to_string(),
detail: "extraction failed".to_string(),
});
}
let new_binary = find_binary(&extract_dir)?;
let backup = current_exe.with_extension("bak");
let _ = fs::remove_file(&backup);
fs::rename(current_exe, &backup)?;
fs::copy(&new_binary, current_exe)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(current_exe)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(current_exe, permissions)?;
}
Ok(release.tag_name)
}
fn find_binary(directory: &Path) -> Result<PathBuf> {
fn is_binary(name: &OsStr) -> bool {
name == "acaua" || name == "acaua.exe"
}
for entry in fs::read_dir(directory)? {
let entry = entry?;
if is_binary(&entry.file_name()) {
return Ok(entry.path());
}
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
for inner in fs::read_dir(entry.path())? {
let inner = inner?;
if is_binary(&inner.file_name()) {
return Ok(inner.path());
}
}
}
}
Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "binary not found in the downloaded package".to_string(),
})
}
/// Desktop GUI installation type detected at runtime.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DesktopInstall {
/// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var).
AppImage(PathBuf),
/// Linux Debian package installed via dpkg: `current_exe` is reliable here.
Deb,
/// Windows NSIS installer: downloaded setup executable run with `/S`
/// (silent). Does not relaunch the app on its own — see the comment on
/// the `WindowsInstaller` apply path.
WindowsInstaller,
}
/// Detects the desktop installation type given an optional `APPIMAGE` environment
/// variable value and the current executable path.
pub fn detect_desktop_install_from(
appimage_env: Option<&std::ffi::OsStr>,
current_exe: &Path,
) -> Result<DesktopInstall> {
#[cfg(windows)]
{
let _ = (appimage_env, current_exe);
Ok(DesktopInstall::WindowsInstaller)
}
#[cfg(target_os = "linux")]
{
if let Some(val) = appimage_env {
let path = PathBuf::from(val);
if path.is_file() {
return Ok(DesktopInstall::AppImage(path));
}
}
if current_exe.is_file()
&& current_exe
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("appimage"))
.unwrap_or(false)
{
return Ok(DesktopInstall::AppImage(current_exe.to_path_buf()));
}
Ok(DesktopInstall::Deb)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
let _ = (appimage_env, current_exe);
Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"automatic self-update is not supported on this platform; download manually from {RELEASES_PAGE_URL}"
),
})
}
}
/// Detects the desktop installation type using the live environment.
pub fn detect_desktop_install() -> Result<DesktopInstall> {
let exe = std::env::current_exe()?;
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe)
}
/// Returns the expected release asset suffix for a desktop installation and CPU architecture.
pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> {
match install {
DesktopInstall::AppImage(_) => match arch {
"x86_64" => Ok("_amd64.AppImage"),
"aarch64" => Ok("_aarch64.AppImage"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for AppImage: {other}"),
}),
},
DesktopInstall::Deb => match arch {
"x86_64" => Ok("_amd64.deb"),
"aarch64" => Ok("_arm64.deb"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for deb package: {other}"),
}),
},
DesktopInstall::WindowsInstaller => match arch {
"x86_64" => Ok("_x64-setup.exe"),
other => Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("unsupported architecture for Windows installer: {other}"),
}),
},
}
}
/// Returns the expected release asset suffix for the current architecture.
pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> {
gui_asset_suffix_for(install, std::env::consts::ARCH)
}
/// Downloads, verifies, and installs the latest desktop GUI release in place.
pub fn apply_desktop(current_exe: &Path) -> Result<String> {
apply_desktop_with_progress(current_exe, |_| {})
}
/// Downloads, verifies, and installs the latest desktop GUI release in place,
/// notifying the progress callback of phase transitions (`"downloading"`,
/// `"verifying"`, `"installing"`, `"restarting"`).
pub fn apply_desktop_with_progress<F>(current_exe: &Path, on_progress: F) -> Result<String>
where
F: Fn(&str),
{
let install =
detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?;
let body = curl_text(LATEST_RELEASE_URL)?;
let release: Release = serde_json::from_str(&body)?;
let suffix = gui_asset_suffix(&install)?;
let asset = release
.assets
.iter()
.find(|asset| asset.name.ends_with(suffix))
.ok_or_else(|| AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!(
"no asset ending with `{suffix}` in release {}",
release.tag_name
),
})?;
let checksum_asset = release
.assets
.iter()
.find(|asset| asset.name == format!("{}.sha256", asset.name));
let tmp_dir = std::env::temp_dir().join("acaua-desktop-update");
fs::create_dir_all(&tmp_dir)?;
let download_path = tmp_dir.join(&asset.name);
on_progress("downloading");
curl_download(&asset.browser_download_url, &download_path)?;
if let Some(checksum_asset) = checksum_asset {
on_progress("verifying");
let expected = curl_text(&checksum_asset.browser_download_url)?;
let expected_hash = expected
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let data = fs::read(&download_path)?;
let mut hasher = Sha256::new();
hasher.update(&data);
let actual_hash: String = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
if actual_hash != expected_hash {
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "SHA-256 checksum mismatch — update discarded".to_string(),
});
}
}
on_progress("installing");
match install {
DesktopInstall::AppImage(target_appimage) => {
#[cfg(target_os = "linux")]
{
let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new("."));
let temp_name = format!(
".{}.tmp-{}",
target_appimage
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("appimage"),
std::process::id()
);
let temp_file = parent_dir.join(temp_name);
fs::copy(&download_path, &temp_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&temp_file)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&temp_file, perms)?;
}
fs::rename(&temp_file, &target_appimage)?;
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&target_appimage)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch AppImage: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&target_appimage, &download_path);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "AppImage updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::Deb => {
#[cfg(target_os = "linux")]
{
let status = Command::new("dpkg")
.args(["-i"])
.arg(&download_path)
.status()
.map_err(|e| AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: e.to_string(),
})?;
if !status.success() {
return Err(AcauaError::CommandFailed {
cmd: "dpkg -i".to_string(),
detail: format!("dpkg -i failed with exit code {:?}", status.code()),
});
}
let _ = fs::remove_file(&download_path);
on_progress("restarting");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(current_exe)
.args(std::env::args().skip(1))
.exec();
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: format!("failed to relaunch updated app: {err}"),
});
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = (&download_path, current_exe);
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "deb updates are only supported on Linux".to_string(),
});
}
}
DesktopInstall::WindowsInstaller => {
#[cfg(windows)]
{
// `/S` is the one NSIS silent-install switch with a documented,
// stable meaning. There is no standard NSIS or Tauri flag that
// makes a silent install relaunch the app afterwards — that was
// claimed in an earlier draft of this code and could not be
// verified against NSIS's or Tauri's documentation; passing an
// invented flag to a stock NSIS installer is silently ignored,
// not harmful, but also not a relaunch mechanism. Spawning
// detached and exiting immediately (instead of waiting on the
// child) also avoids a self-deadlock: if the installer's script
// waits for this process to close before it can overwrite the
// running .exe, blocking here on the installer's exit status
// would hang forever. The practical effect: the update installs
// silently, but the app does not reopen itself — the user needs
// to start it again. This needs real-world confirmation on
// Windows before relying on it; if testing shows the bundled
// NSIS template does support an explicit relaunch flag or step,
// wire it in here instead of this comment.
on_progress("restarting");
Command::new(&download_path)
.arg("/S")
.spawn()
.map_err(|e| AcauaError::CommandFailed {
cmd: "installer".to_string(),
detail: format!("failed to launch installer: {e}"),
})?;
std::process::exit(0);
}
#[cfg(not(windows))]
{
let _ = &download_path;
return Err(AcauaError::CommandFailed {
cmd: "update".to_string(),
detail: "Windows installer updates are only supported on Windows".to_string(),
});
}
}
}
#[allow(unreachable_code)]
Ok(release.tag_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_refuses_to_overwrite_the_desktop_app() {
let error = apply(Path::new("/opt/acaua/acaua-gui")).unwrap_err();
assert!(error.to_string().contains("CLI"), "{error}");
assert!(error.to_string().contains(RELEASES_PAGE_URL), "{error}");
}
#[test]
fn detect_desktop_install_variants() {
#[cfg(target_os = "linux")]
{
let temp_appimage =
std::env::temp_dir().join(format!("test-acaua-{}.AppImage", std::process::id()));
let _ = fs::write(&temp_appimage, b"dummy appimage");
let res = detect_desktop_install_from(
Some(temp_appimage.as_os_str()),
Path::new("/usr/bin/acaua-gui"),
)
.unwrap();
assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone()));
let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap();
assert_eq!(
res_fallback,
DesktopInstall::AppImage(temp_appimage.clone())
);
let _ = fs::remove_file(&temp_appimage);
let res_deb =
detect_desktop_install_from(None, Path::new("/usr/bin/acaua-gui")).unwrap();
assert_eq!(res_deb, DesktopInstall::Deb);
}
#[cfg(windows)]
{
let res = detect_desktop_install_from(
None,
Path::new("C:/Program Files/Acaua/acaua-gui.exe"),
)
.unwrap();
assert_eq!(res, DesktopInstall::WindowsInstaller);
}
}
#[test]
fn gui_asset_suffix_matches_known_release_naming() {
let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Acaua.AppImage"));
assert_eq!(
gui_asset_suffix_for(&appimage, "x86_64").unwrap(),
"_amd64.AppImage"
);
assert_eq!(
gui_asset_suffix_for(&appimage, "aarch64").unwrap(),
"_aarch64.AppImage"
);
let deb = DesktopInstall::Deb;
assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb");
assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb");
let win = DesktopInstall::WindowsInstaller;
assert_eq!(
gui_asset_suffix_for(&win, "x86_64").unwrap(),
"_x64-setup.exe"
);
assert!(gui_asset_suffix_for(&appimage, "mips").is_err());
assert!(gui_asset_suffix_for(&deb, "riscv64").is_err());
assert!(gui_asset_suffix_for(&win, "arm").is_err());
}
}
+59
View File
@@ -0,0 +1,59 @@
use crate::error::{AcauaError, Result};
use crate::fusion::Alert;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZeekLogKind {
Conn,
Dns,
Ssl,
SmbMapping,
DceRpc,
Rdp,
Ssh,
Kerberos,
Ntlm,
Unknown,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnalysisReport {
pub directory: String,
pub logs_seen: usize,
pub records: u64,
pub alerts: Vec<Alert>,
pub status: &'static str,
}
pub fn analyze_dir(directory: impl AsRef<Path>) -> Result<AnalysisReport> {
let directory = directory.as_ref();
if !directory.is_dir() {
return Err(AcauaError::InvalidInput(format!(
"{} is not a directory",
directory.display()
)));
}
Ok(AnalysisReport {
directory: directory.display().to_string(),
logs_seen: 0,
records: 0,
alerts: Vec::new(),
status: "skeleton",
})
}
pub fn classify_log(name: &str) -> ZeekLogKind {
match name {
"conn.log" => ZeekLogKind::Conn,
"dns.log" => ZeekLogKind::Dns,
"ssl.log" => ZeekLogKind::Ssl,
"smb_mapping.log" => ZeekLogKind::SmbMapping,
"dce_rpc.log" => ZeekLogKind::DceRpc,
"rdp.log" => ZeekLogKind::Rdp,
"ssh.log" => ZeekLogKind::Ssh,
"kerberos.log" => ZeekLogKind::Kerberos,
"ntlm.log" => ZeekLogKind::Ntlm,
_ => ZeekLogKind::Unknown,
}
}
+70
View File
@@ -0,0 +1,70 @@
use acaua_core::{doctor, fusion, pillar1, pillar2, pillar3, pillar4, storage, zeek, AcauaError};
#[test]
fn pillar1_returns_a_skeleton_score() {
let input = pillar1::BeaconingInput {
timestamps: vec![1.0, 2.0, 3.0],
};
let score = pillar1::score(&input).unwrap();
assert_eq!(score.observations, 3);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar2_counts_long_connections_without_panicking() {
let input = pillar2::SessionInput {
durations_secs: vec![1.0, 400.0],
bytes: vec![10, 20],
};
let score = pillar2::score(&input).unwrap();
assert_eq!(score.long_connections, 1);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar3_handles_an_empty_protocol_list() {
let score = pillar3::score(&pillar3::ProtocolInput::default()).unwrap();
assert_eq!(score.distinct_protocols, 0);
assert_eq!(score.status, "skeleton");
}
#[test]
fn pillar4_fuses_three_inputs() {
let score = pillar4::score(&pillar4::FusionInput::default()).unwrap();
assert_eq!(score.inputs, 3);
assert_eq!(score.status, "skeleton");
}
#[test]
fn fusion_returns_no_alerts_yet() {
let report = fusion::fuse(&[0.1, 0.2]).unwrap();
assert!(report.alerts.is_empty());
assert_eq!(report.status, "skeleton");
}
#[test]
fn storage_reports_the_reserved_backend() {
let store = storage::Storage::open(std::env::temp_dir());
let summary = store.summary().unwrap();
assert_eq!(summary.ttl_days, 7);
assert_eq!(summary.records, 0);
}
#[test]
fn zeek_rejects_a_missing_directory() {
let error = zeek::analyze_dir("/definitely/not/a/zeek/directory").unwrap_err();
assert!(matches!(error, AcauaError::InvalidInput(_)));
}
#[test]
fn zeek_classifies_known_logs() {
assert_eq!(zeek::classify_log("dns.log"), zeek::ZeekLogKind::Dns);
assert_eq!(zeek::classify_log("other.log"), zeek::ZeekLogKind::Unknown);
}
#[test]
fn doctor_reports_checks() {
let report = doctor::run();
assert!(!report.checks.is_empty());
assert_eq!(report.status, "skeleton");
}