diff --git a/Cargo.lock b/Cargo.lock index a085d9f..4a19a24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,56 @@ dependencies = [ "generic-array", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -145,6 +195,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cobs" version = "0.3.0" @@ -154,6 +244,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -229,6 +325,12 @@ dependencies = [ "wasip2", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -259,6 +361,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -307,6 +415,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -530,6 +644,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "smitebot" +version = "0.0.0" +dependencies = [ + "clap", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -602,6 +733,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index e3a107e..df8686f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "smite", + "smitebot", "smite-ir", "smite-ir-mutator", "smite-nyx-sys", diff --git a/README.md b/README.md index 89bcbd5..f0b429c 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ firefox ./$TARGET-$SCENARIO-coverage-report/html/index.html ``` smite/ # Core Rust library (runners, scenarios, noise protocol, BOLT messages) +smitebot/ # Automation CLI (doctor and upcoming campaign orchestration commands) smite-ir/ # IR types, generators, and mutators for structured fuzzing programs smite-ir-mutator/ # AFL++ custom mutator cdylib for IR programs smite-nyx-sys/ # Nyx FFI bindings diff --git a/smitebot/Cargo.toml b/smitebot/Cargo.toml new file mode 100644 index 0000000..9428476 --- /dev/null +++ b/smitebot/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "smitebot" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/smitebot/README.md b/smitebot/README.md new file mode 100644 index 0000000..775cccb --- /dev/null +++ b/smitebot/README.md @@ -0,0 +1,58 @@ +# smitebot + +`smitebot` is the Smite automation CLI. It is intended to orchestrate common fuzzing workflows and reduce manual setup/operations. + +## Install + +Install `smitebot` once from this repository: + +```bash +cargo install --path smitebot +``` + +After install, run it directly: + +```bash +smitebot doctor +smitebot doctor --json +``` + +## Commands + +### smitebot doctor + +`smitebot doctor` validates host prerequisites before running Smite campaigns. + +```bash +smitebot doctor +smitebot doctor --json +smitebot doctor --aflpp-path ~/AFLplusplus --smite-dir . +``` + +## Checks + +- `x86_64` architecture +- CPU virtualization enabled (`vmx` or `svm`) +- `/dev/kvm` is present and openable +- Docker daemon is reachable (`docker version`) +- Required host tools (`bash`, `python3`) +- AFL++ tools (`afl-fuzz`, `afl-cmin`, `afl-tmin`, `afl-whatsup`) available on `PATH` or under `--aflpp-path` +- Nyx packer `hget` exists under AFL++ +- `libnyx.so` is found on `LD_LIBRARY_PATH` or under `--aflpp-path` +- VMware backdoor is enabled +- Required Smite scripts are present and executable +- Required workload Dockerfiles are present + +## JSON output + +By default, output is in a human readable format. The `--json` flag changes output to structured JSON: + +```json +{ + "checks": [ + { "name": "x86_64 architecture", "passed": true }, + { "name": "Docker daemon reachable", "passed": false, "reason": "docker version: exit status: 1" } + ], + "overall": false +} +``` diff --git a/smitebot/src/commands/doctor.rs b/smitebot/src/commands/doctor.rs new file mode 100644 index 0000000..4d5ed29 --- /dev/null +++ b/smitebot/src/commands/doctor.rs @@ -0,0 +1,529 @@ +//! Host prerequisite checks for Smite fuzzing campaigns. +//! The output is intentionally stable so CI and operators can diff or parse it. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use clap::Args; +use serde::Serialize; + +use crate::utils::file_ops::{expand_tilde, find_in_path, is_executable}; + +/// AFL++ binaries required for campaign execution and corpus minimization. +const AFL_TOOLS: &[&str] = &["afl-fuzz", "afl-cmin", "afl-tmin", "afl-whatsup"]; + +/// Host tools required by Smite helper scripts. +const HOST_TOOLS: &[&str] = &["bash", "python", "python3"]; + +/// Repository scripts required by doctor and upcoming orchestration commands. +const REQUIRED_SCRIPTS: &[&str] = &[ + "setup-nyx.sh", + "coverage-report.sh", + "symbolize-crash.sh", + "enable-vmware-backdoor.sh", +]; + +/// Workload Dockerfiles required for normal and coverage image builds. +const REQUIRED_DOCKERFILES: &[&str] = &[ + "workloads/lnd/Dockerfile", + "workloads/lnd/Dockerfile.coverage", + "workloads/ldk/Dockerfile", + "workloads/ldk/Dockerfile.coverage", + "workloads/cln/Dockerfile", + "workloads/cln/Dockerfile.coverage", + "workloads/eclair/Dockerfile", + "workloads/eclair/Dockerfile.coverage", +]; + +/// Command handler for `smitebot doctor`. +pub struct DoctorCommand; + +/// CLI arguments for `smitebot doctor`. +#[derive(Debug, Args)] +pub struct DoctorArgs { + /// Emit machine-readable JSON output. + #[arg(long)] + json: bool, + /// Path to AFL++ source tree (used to verify Nyx packer binaries). + #[arg(long)] + aflpp_path: Option, + /// Path to smite repository root. + #[arg(long, default_value = ".")] + smite_dir: PathBuf, +} + +/// A single prerequisite check and its outcome. +#[derive(Debug, Serialize)] +struct DoctorCheck { + name: String, + passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +impl DoctorCheck { + /// Creates a report entry from a named doctor check result. + fn new(name: impl Into, result: Result<(), CheckFailure>) -> Self { + Self { + name: name.into(), + passed: result.is_ok(), + reason: result.err(), + } + } +} + +/// Aggregate report for human output or JSON serialization. +#[derive(Debug, Serialize)] +struct DoctorReport { + overall: bool, + checks: Vec, +} + +#[derive(Debug, thiserror::Error)] +enum CheckFailure { + #[error("unsupported architecture: {0}")] + UnsupportedArchitecture(String), + #[error("neither vmx nor svm flag found in /proc/cpuinfo")] + MissingCpuVirtualization, + #[error("{} not found", .0.display())] + MissingPath(PathBuf), + #[error("{} not executable", .0.display())] + NotExecutable(PathBuf), + #[error("{}: {error}", path.display())] + Io { + path: PathBuf, + #[source] + error: io::Error, + }, + #[error("{tool} not found on PATH{detail}")] + ToolNotFound { tool: String, detail: String }, + #[error("{command}: {detail}")] + Command { command: String, detail: String }, + #[error("unable to infer AFL++ root; pass --aflpp-path or ensure afl-fuzz is on PATH")] + MissingAflppRoot, + #[error("libnyx.so not found in LD_LIBRARY_PATH or --aflpp-path")] + LibnyxNotFound, + #[error("backdoor disabled; run ./scripts/enable-vmware-backdoor.sh to enable")] + VMwareBackdoorDisabled, +} + +impl CheckFailure { + /// Creates an I/O failure associated with a filesystem path. + fn io(path: &Path, error: io::Error) -> Self { + Self::Io { + path: path.to_path_buf(), + error, + } + } +} + +impl Serialize for CheckFailure { + fn serialize(&self, serializer: S) -> Result { + // Keep JSON schema simple: serialize as the user-facing string. + serializer.serialize_str(&self.to_string()) + } +} + +impl DoctorCommand { + /// Runs all doctor checks and prints either human-readable or JSON output. + pub fn execute(args: &DoctorArgs) -> bool { + let aflpp_path = args.aflpp_path.as_deref().map(expand_tilde); + let smite_dir = expand_tilde(&args.smite_dir); + let aflpp_root = resolve_aflpp_root(aflpp_path.as_deref()); + let aflpp_root = aflpp_root.as_deref(); + + // Keep a predictable order for operator readability and stable JSON output. + let mut checks = vec![ + DoctorCheck::new("x86_64 architecture", check_architecture()), + DoctorCheck::new( + "CPU virtualization enabled (vmx/svm)", + check_cpu_virtualization_enabled(), + ), + DoctorCheck::new("/dev/kvm accessible", check_kvm_access()), + DoctorCheck::new("Docker daemon reachable", check_docker_daemon()), + DoctorCheck::new("AFL++ Nyx packer hget", check_nyx_hget(aflpp_root)), + DoctorCheck::new("libnyx.so locatable", check_libnyx(aflpp_root)), + DoctorCheck::new("VMware backdoor enabled", check_vmware_backdoor_enabled()), + ]; + + for &tool in AFL_TOOLS { + checks.push(DoctorCheck::new(tool, require_tool(tool, aflpp_root))); + } + + for &tool in HOST_TOOLS { + checks.push(DoctorCheck::new(tool, require_tool(tool, None))); + } + + for script in REQUIRED_SCRIPTS { + let path = smite_dir.join("scripts").join(script); + checks.push(DoctorCheck::new( + format!("script executable: scripts/{script}"), + require_executable(&path), + )); + } + + for dockerfile in REQUIRED_DOCKERFILES { + let path = smite_dir.join(dockerfile); + checks.push(DoctorCheck::new( + format!("dockerfile present: {dockerfile}"), + require_exists(&path), + )); + } + + let overall = checks.iter().all(|check| check.passed); + let report = DoctorReport { overall, checks }; + + if args.json { + // JSON output is used by CI or external tooling to surface failures. + let json = + serde_json::to_string_pretty(&report).expect("DoctorReport is always serializable"); + println!("{json}"); + } else { + print_human_report(&report); + } + + report.overall + } +} + +/// Prints a compact checklist report intended for interactive terminal use. +fn print_human_report(report: &DoctorReport) { + for check in &report.checks { + match &check.reason { + None => println!("[ok] {}", check.name), + Some(reason) => println!("[fail] {}: {reason}", check.name), + } + } + + let total = report.checks.len(); + if report.overall { + println!("\nsmitebot doctor: all {total} checks passed"); + } else { + let failed = report.checks.iter().filter(|check| !check.passed).count(); + println!("\nsmitebot doctor: {failed} of {total} checks failed"); + } +} + +/// Verifies that the host architecture is supported by Nyx mode. +fn check_architecture() -> Result<(), CheckFailure> { + let arch = std::env::consts::ARCH; + if arch == "x86_64" { + Ok(()) + } else { + Err(CheckFailure::UnsupportedArchitecture(arch.to_string())) + } +} + +/// Checks for CPU virtualization flags required by KVM acceleration. +fn check_cpu_virtualization_enabled() -> Result<(), CheckFailure> { + let path = Path::new("/proc/cpuinfo"); + let cpuinfo = fs::read_to_string(path).map_err(|e| CheckFailure::io(path, e))?; + + let has_flag = cpuinfo + .split_whitespace() + .any(|flag| flag == "vmx" || flag == "svm"); + + if has_flag { + Ok(()) + } else { + Err(CheckFailure::MissingCpuVirtualization) + } +} + +/// Verifies that `/dev/kvm` exists and is openable by the current user. +fn check_kvm_access() -> Result<(), CheckFailure> { + let path = Path::new("/dev/kvm"); + require_exists(path)?; + fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| CheckFailure::io(path, e))?; + Ok(()) +} + +/// Checks that the Docker CLI can reach a running Docker daemon. +fn check_docker_daemon() -> Result<(), CheckFailure> { + require_tool("docker", None)?; + + let output = Command::new("docker") + .args(["version", "--format", "{{.Server.Version}}"]) + .output() + .map_err(|e| CheckFailure::Command { + command: "docker version".to_string(), + detail: e.to_string(), + })?; + + if output.status.success() { + Ok(()) + } else { + Err(CheckFailure::Command { + command: "docker version".to_string(), + detail: command_failure_detail(&output), + }) + } +} + +/// Resolves the AFL++ root from `--aflpp-path`, falling back to the parent of `afl-fuzz`. +fn resolve_aflpp_root(aflpp_path: Option<&Path>) -> Option { + aflpp_path.map(Path::to_path_buf).or_else(|| { + find_in_path("afl-fuzz") + .and_then(|afl_fuzz_path| afl_fuzz_path.parent().map(Path::to_path_buf)) + }) +} + +/// Verifies that AFL++ Nyx packer produced an executable `hget` helper. +fn check_nyx_hget(aflpp_root: Option<&Path>) -> Result<(), CheckFailure> { + let root = aflpp_root.ok_or(CheckFailure::MissingAflppRoot)?; + + require_executable(&root.join("nyx_mode/packer/packer/linux_x86_64-userspace/bin64/hget")) +} + +/// Checks whether `libnyx.so` is discoverable by the runtime or under the AFL++ root. +fn check_libnyx(aflpp_root: Option<&Path>) -> Result<(), CheckFailure> { + let in_ld_library_path = std::env::var_os("LD_LIBRARY_PATH").is_some_and(|path_var| { + std::env::split_paths(&path_var).any(|dir| dir.join("libnyx.so").exists()) + }); + + let in_aflpp_path = aflpp_root.is_some_and(|root| root.join("libnyx.so").exists()); + + if in_ld_library_path || in_aflpp_path { + Ok(()) + } else { + Err(CheckFailure::LibnyxNotFound) + } +} + +/// Checks whether the KVM `VMware` backdoor needed by Nyx is enabled. +fn check_vmware_backdoor_enabled() -> Result<(), CheckFailure> { + let path = Path::new("/sys/module/kvm/parameters/enable_vmware_backdoor"); + let contents = fs::read_to_string(path).map_err(|e| CheckFailure::io(path, e))?; + + if contents.trim().eq_ignore_ascii_case("y") { + Ok(()) + } else { + Err(CheckFailure::VMwareBackdoorDisabled) + } +} + +/// Returns success only when the path exists. +fn require_exists(path: &Path) -> Result<(), CheckFailure> { + if path.exists() { + Ok(()) + } else { + Err(CheckFailure::MissingPath(path.to_path_buf())) + } +} + +/// Returns success only when the path exists and has an executable bit set. +fn require_executable(path: &Path) -> Result<(), CheckFailure> { + require_exists(path)?; + if is_executable(path) { + Ok(()) + } else { + Err(CheckFailure::NotExecutable(path.to_path_buf())) + } +} + +/// Returns success when a tool is executable on PATH or under the AFL++ root. +fn require_tool(tool: &str, aflpp_root: Option<&Path>) -> Result<(), CheckFailure> { + let found_on_path = find_in_path(tool).is_some(); + let found_in_aflpp = aflpp_root + .map(|root| root.join(tool)) + .is_some_and(|candidate| candidate.is_file() && is_executable(&candidate)); + + if found_on_path || found_in_aflpp { + Ok(()) + } else { + Err(CheckFailure::ToolNotFound { + tool: tool.to_string(), + detail: aflpp_root.map_or_else(String::new, |root| { + format!(" or at {}", root.join(tool).display()) + }), + }) + } +} + +/// Builds a useful failure detail from a completed command. +fn command_failure_detail(output: &Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = stderr.trim(); + if detail.is_empty() { + let detail = stdout.trim(); + if detail.is_empty() { + output.status.to_string() + } else { + format!("{} ({detail})", output.status) + } + } else { + format!("{} ({})", output.status, detail) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_failure_display_is_user_visible_for_all_variants() { + let cases = vec![ + ( + CheckFailure::UnsupportedArchitecture("aarch64".to_string()), + "unsupported architecture: aarch64".to_string(), + ), + ( + CheckFailure::MissingCpuVirtualization, + "neither vmx nor svm flag found in /proc/cpuinfo".to_string(), + ), + ( + CheckFailure::MissingPath(PathBuf::from("/tmp/missing")), + "/tmp/missing not found".to_string(), + ), + ( + CheckFailure::NotExecutable(PathBuf::from("/tmp/tool")), + "/tmp/tool not executable".to_string(), + ), + ( + CheckFailure::io( + Path::new("/tmp/kvm"), + io::Error::new(io::ErrorKind::PermissionDenied, "denied"), + ), + "/tmp/kvm: denied".to_string(), + ), + ( + CheckFailure::ToolNotFound { + tool: "afl-fuzz".to_string(), + detail: " or at /opt/AFLplusplus/afl-fuzz".to_string(), + }, + "afl-fuzz not found on PATH or at /opt/AFLplusplus/afl-fuzz".to_string(), + ), + ( + CheckFailure::Command { + command: "docker version".to_string(), + detail: "daemon unavailable".to_string(), + }, + "docker version: daemon unavailable".to_string(), + ), + ( + CheckFailure::MissingAflppRoot, + "unable to infer AFL++ root; pass --aflpp-path or ensure afl-fuzz is on PATH" + .to_string(), + ), + ( + CheckFailure::LibnyxNotFound, + "libnyx.so not found in LD_LIBRARY_PATH or --aflpp-path".to_string(), + ), + ( + CheckFailure::VMwareBackdoorDisabled, + "backdoor disabled; run ./scripts/enable-vmware-backdoor.sh to enable".to_string(), + ), + ]; + + for (failure, expected) in cases { + assert_eq!(failure.to_string(), expected); + } + } + + #[test] + fn require_exists_reports_missing_path() { + let path = Path::new("/definitely/not/a/smitebot/path"); + let err = require_exists(path).unwrap_err(); + assert_eq!(err.to_string(), "/definitely/not/a/smitebot/path not found"); + } + + #[test] + fn require_executable_rejects_non_executable_file() { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("tool"); + fs::write(&path, "#!/bin/sh\n").unwrap(); + + let err = require_executable(&path).unwrap_err(); + assert_eq!( + err.to_string(), + format!("{} not executable", path.display()) + ); + } + + #[test] + fn require_tool_finds_executable_under_aflpp_root() { + use std::os::unix::fs::PermissionsExt; + + let tempdir = tempfile::tempdir().unwrap(); + let tool_path = tempdir.path().join("afl-fuzz"); + fs::write(&tool_path, "#!/bin/sh\n").unwrap(); + // Force executable permissions so the test doesn't depend on umask defaults. + let mut perms = fs::metadata(&tool_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&tool_path, perms).unwrap(); + + assert!(require_tool("afl-fuzz", Some(tempdir.path())).is_ok()); + } + + #[test] + fn doctor_report_json_is_machine_readable() { + let report = DoctorReport { + overall: false, + checks: vec![ + DoctorCheck::new("check-a", Ok(())), + DoctorCheck::new("check-b", Err(CheckFailure::MissingCpuVirtualization)), + ], + }; + + let json = serde_json::to_string(&report).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["overall"], false); + assert_eq!(parsed["checks"][0]["name"], "check-a"); + assert!(parsed["checks"][0].get("reason").is_none()); + assert_eq!(parsed["checks"][1]["passed"], false); + assert_eq!( + parsed["checks"][1]["reason"], + "neither vmx nor svm flag found in /proc/cpuinfo" + ); + } + + #[test] + fn resolve_aflpp_root_prefers_explicit_path() { + let root = PathBuf::from("/opt/AFLplusplus"); + assert_eq!(resolve_aflpp_root(Some(root.as_path())), Some(root)); + } + + #[test] + fn command_failure_detail_prefers_stderr() { + let output = output_with("stdout msg", "stderr msg"); + assert_eq!( + command_failure_detail(&output), + "exit status: 1 (stderr msg)" + ); + } + + #[test] + fn command_failure_detail_uses_stdout_if_stderr_empty() { + let output = output_with("stdout msg", ""); + assert_eq!( + command_failure_detail(&output), + "exit status: 1 (stdout msg)" + ); + } + + #[test] + fn command_failure_detail_handles_no_output() { + let output = output_with("", ""); + assert_eq!(command_failure_detail(&output), "exit status: 1"); + } + + fn output_with(stdout: &str, stderr: &str) -> std::process::Output { + use std::os::unix::process::ExitStatusExt; + use std::process::ExitStatus; + + // Build a synthetic Output so tests don't rely on executing external commands. + std::process::Output { + status: ExitStatus::from_raw(1 << 8), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } +} diff --git a/smitebot/src/commands/mod.rs b/smitebot/src/commands/mod.rs new file mode 100644 index 0000000..85483bb --- /dev/null +++ b/smitebot/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod doctor; + +pub use doctor::{DoctorArgs, DoctorCommand}; diff --git a/smitebot/src/main.rs b/smitebot/src/main.rs new file mode 100644 index 0000000..2e03762 --- /dev/null +++ b/smitebot/src/main.rs @@ -0,0 +1,38 @@ +//! `smitebot` command-line interface. + +mod commands; +mod utils; + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +use commands::{DoctorArgs, DoctorCommand}; + +#[derive(Debug, Parser)] +#[command(name = "smitebot", version, about = "Smite campaign manager")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + /// Validate host prerequisites for running Smite campaigns. + Doctor(DoctorArgs), +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + if run(cli) { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +fn run(cli: Cli) -> bool { + match cli.command { + Commands::Doctor(args) => DoctorCommand::execute(&args), + } +} diff --git a/smitebot/src/utils/file_ops.rs b/smitebot/src/utils/file_ops.rs new file mode 100644 index 0000000..3620ad6 --- /dev/null +++ b/smitebot/src/utils/file_ops.rs @@ -0,0 +1,120 @@ +//! Small filesystem helpers shared across smitebot commands. + +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Returns true if `path` exists and has at least one executable bit set. +pub fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) +} + +/// Finds an executable named `tool` on the process `PATH`. +pub fn find_in_path(tool: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + find_in_path_with_path(tool, &path_var) +} + +/// Finds an executable tool using an explicit PATH value, mainly for tests. +pub fn find_in_path_with_path(tool: &str, path_var: &OsStr) -> Option { + std::env::split_paths(path_var) + .map(|dir| dir.join(tool)) + .find(|candidate| candidate.is_file() && is_executable(candidate)) +} + +/// Expands `~` and `~/...` paths using `$HOME`; if `$HOME` is unavailable, returns unchanged. +pub fn expand_tilde(path: &Path) -> PathBuf { + let Some(home) = std::env::var_os("HOME") else { + return path.to_path_buf(); + }; + expand_tilde_with_home(path, home.as_ref()) +} + +/// Expands `~` and `~/...` paths using the supplied home directory. +pub fn expand_tilde_with_home(path: &Path, home: &OsStr) -> PathBuf { + let Some(path_str) = path.to_str() else { + return path.to_path_buf(); + }; + + if path_str == "~" { + return PathBuf::from(home); + } + + if let Some(rest) = path_str.strip_prefix("~/") { + return PathBuf::from(home).join(rest); + } + + path.to_path_buf() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + #[test] + fn find_in_path_with_path_finds_existing_executable() { + use std::os::unix::fs::PermissionsExt; + + let tempdir = tempfile::tempdir().unwrap(); + let binary_path = tempdir.path().join("afl-fuzz"); + fs::write(&binary_path, "#!/bin/sh\n").unwrap(); + // Ensure the test binary is executable regardless of umask defaults. + let mut perms = fs::metadata(&binary_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&binary_path, perms).unwrap(); + + let path_value = OsString::from(tempdir.path()); + let found = find_in_path_with_path("afl-fuzz", &path_value).unwrap(); + assert_eq!(found, binary_path); + } + + #[test] + fn find_in_path_with_path_ignores_non_executable_file() { + let tempdir = tempfile::tempdir().unwrap(); + let binary_path = tempdir.path().join("afl-fuzz"); + fs::write(&binary_path, "#!/bin/sh\n").unwrap(); + + let path_value = OsString::from(tempdir.path()); + let found = find_in_path_with_path("afl-fuzz", &path_value); + assert!(found.is_none()); + } + + #[test] + fn is_executable_detects_permission_bits() { + use std::os::unix::fs::PermissionsExt; + + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("tool"); + fs::write(&path, "#!/bin/sh\n").unwrap(); + + assert!(!is_executable(&path)); + + // Flip the executable bit explicitly to validate the permission check. + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + + assert!(is_executable(&path)); + } + + #[test] + fn expand_tilde_uses_supplied_home() { + let home = OsStr::new("/home/alice"); + assert_eq!( + expand_tilde_with_home(Path::new("~/AFLplusplus"), home), + PathBuf::from("/home/alice/AFLplusplus") + ); + } + + #[test] + fn expand_tilde_leaves_plain_paths_unchanged() { + let path = Path::new("/tmp/AFLplusplus"); + assert_eq!( + expand_tilde_with_home(path, OsStr::new("/home/alice")), + path + ); + } +} diff --git a/smitebot/src/utils/mod.rs b/smitebot/src/utils/mod.rs new file mode 100644 index 0000000..7bcd25f --- /dev/null +++ b/smitebot/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod file_ops; diff --git a/workloads/cln/Dockerfile b/workloads/cln/Dockerfile index c2b451e..29007cc 100644 --- a/workloads/cln/Dockerfile +++ b/workloads/cln/Dockerfile @@ -87,6 +87,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN set -eu; for f in smite-scenarios/src/bin/cln_*.rs; do \ TARGET_MAP_SIZE=$(cat /tmp/total-map-size) \ diff --git a/workloads/cln/Dockerfile.coverage b/workloads/cln/Dockerfile.coverage index 55300fb..fff9ad6 100644 --- a/workloads/cln/Dockerfile.coverage +++ b/workloads/cln/Dockerfile.coverage @@ -84,6 +84,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN cargo build -p smite-scenarios --bin cln_${SCENARIO} --release diff --git a/workloads/eclair/Dockerfile b/workloads/eclair/Dockerfile index 90f9186..92a7c4a 100644 --- a/workloads/eclair/Dockerfile +++ b/workloads/eclair/Dockerfile @@ -67,6 +67,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN set -eu; for f in smite-scenarios/src/bin/eclair_*.rs; do \ TARGET_MAP_SIZE=$(cat /tmp/eclair-edge-count.txt) \ diff --git a/workloads/eclair/Dockerfile.coverage b/workloads/eclair/Dockerfile.coverage index ff9b83d..0cb50c3 100644 --- a/workloads/eclair/Dockerfile.coverage +++ b/workloads/eclair/Dockerfile.coverage @@ -54,6 +54,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN cargo build -p smite-scenarios --bin eclair_${SCENARIO} --release diff --git a/workloads/ldk/Dockerfile b/workloads/ldk/Dockerfile index b711f7d..1292fe3 100644 --- a/workloads/ldk/Dockerfile +++ b/workloads/ldk/Dockerfile @@ -67,6 +67,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN set -eu; for f in smite-scenarios/src/bin/ldk_*.rs; do \ TARGET_PATH=/ldk-wrapper/target/release/ldk-node-wrapper \ diff --git a/workloads/ldk/Dockerfile.coverage b/workloads/ldk/Dockerfile.coverage index 529d5aa..99c6d0a 100644 --- a/workloads/ldk/Dockerfile.coverage +++ b/workloads/ldk/Dockerfile.coverage @@ -55,6 +55,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ ENV RUSTFLAGS="" RUN cargo build -p smite-scenarios --bin ldk_${SCENARIO} --release diff --git a/workloads/lnd/Dockerfile b/workloads/lnd/Dockerfile index ea432d0..5d052f0 100644 --- a/workloads/lnd/Dockerfile +++ b/workloads/lnd/Dockerfile @@ -68,6 +68,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN set -eu; for f in smite-scenarios/src/bin/lnd_*.rs; do \ TARGET_PATH=/lnd/cmd/lnd/lnd \ diff --git a/workloads/lnd/Dockerfile.coverage b/workloads/lnd/Dockerfile.coverage index e7d5070..588d72f 100644 --- a/workloads/lnd/Dockerfile.coverage +++ b/workloads/lnd/Dockerfile.coverage @@ -54,6 +54,7 @@ COPY smite/ smite/ COPY smite-ir/ smite-ir/ COPY smite-ir-mutator/ smite-ir-mutator/ COPY smite-nyx-sys/ smite-nyx-sys/ +COPY smitebot/ smitebot/ COPY smite-scenarios/ smite-scenarios/ RUN cargo build -p smite-scenarios --bin lnd_${SCENARIO} --release