From 17622453794f2278c9892f78304e83657189386f Mon Sep 17 00:00:00 2001 From: ashish-kumar-dash Date: Mon, 24 Aug 2026 22:12:20 +0530 Subject: [PATCH] feat(smitebot): implement reproduce command --- smitebot/README.md | 13 ++ smitebot/src/commands.rs | 2 + smitebot/src/commands/reproduce.rs | 190 +++++++++++++++++++++++++++++ smitebot/src/commands/start.rs | 20 +-- smitebot/src/main.rs | 8 +- smitebot/src/utils.rs | 21 ++++ 6 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 smitebot/src/commands/reproduce.rs diff --git a/smitebot/README.md b/smitebot/README.md index ccd26f7f..b0646946 100644 --- a/smitebot/README.md +++ b/smitebot/README.md @@ -244,3 +244,16 @@ smitebot corpus minimize [-i ]... [-o ] [--aflpp- - `-i, --input `: One input directory to minimize. If multiple `-i` flags are present, all specified directories are merged before minimizing. If omitted, the campaign's runner queues are merged and minimized. - `-o, --output `: output directory; defaults to `~/.smitebot/runs//corpus-min/` - `--aflpp-path `: AFL++ source tree, overriding the `aflpp_path` stored in `state.json` (useful when the checkout has moved) + +### smitebot reproduce + +Replays a single input against a campaign's target and streams the target's output to the terminal; `reproduce` interprets nothing — read the logs to see what happened. Resolves the image and target from the campaign's `state.json`; no live campaign required. + +```bash +smitebot reproduce -i +``` + +- ``: directory name under `~/.smitebot/runs` +- `-i, --input `: input file to replay, e.g. a crash from a runner's `crashes/` directory + +If the image's digest no longer matches the one recorded for the campaign (e.g. it was rebuilt under the same tag), a warning is logged. Reproduction under Nyx is not yet supported. diff --git a/smitebot/src/commands.rs b/smitebot/src/commands.rs index 0568618e..8575bb63 100644 --- a/smitebot/src/commands.rs +++ b/smitebot/src/commands.rs @@ -4,6 +4,7 @@ pub mod config; pub mod corpus; pub mod doctor; pub mod print_ir; +pub mod reproduce; pub mod start; pub mod status; pub mod stop; @@ -14,6 +15,7 @@ pub use config::{ConfigArgs, ConfigCommand}; pub use corpus::{CorpusArgs, CorpusCommand}; pub use doctor::{DoctorArgs, DoctorCommand}; pub use print_ir::{PrintIrArgs, PrintIrCommand}; +pub use reproduce::{ReproduceArgs, ReproduceCommand}; pub use start::{StartArgs, StartCommand}; pub use status::{StatusArgs, StatusCommand}; pub use stop::{StopArgs, StopCommand}; diff --git a/smitebot/src/commands/reproduce.rs b/smitebot/src/commands/reproduce.rs new file mode 100644 index 00000000..116de4bc --- /dev/null +++ b/smitebot/src/commands/reproduce.rs @@ -0,0 +1,190 @@ +//! Crash reproduction: replay a single input against the campaign's target in +//! local Docker. +//! +//! Runs the campaign's Docker image once with `SMITE_INPUT` pointed at the given +//! input (the same `LocalRunner` path `scripts/coverage-report.sh` uses), streams +//! the target's output to the terminal, and interprets nothing — the logs are the +//! deliverable. Nyx-mode reproduction is a later phase. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use clap::Args; + +use crate::config::Target; +use crate::state::CampaignState; +use crate::utils::docker_image_id; + +/// Command handler for `smitebot reproduce`. +pub struct ReproduceCommand; + +/// CLI arguments for `smitebot reproduce`. +#[derive(Debug, Args)] +pub struct ReproduceArgs { + /// Campaign ID whose Docker image and target to reproduce against. + campaign_id: String, + /// Input file (e.g. a crash from a runner's `crashes/`) to replay. + #[arg(short = 'i', long)] + input: PathBuf, +} + +impl ReproduceCommand { + /// Replays `input` against the campaign's target in Docker. + /// + /// Returns `true` once the container has run to completion regardless of its + /// exit status (the target's output, not the exit code, is the result), and + /// `false` only on an operational failure: unknown campaign, missing input, + /// missing image, or a Docker spawn error. + pub fn execute(args: &ReproduceArgs) -> bool { + let Some(runs_dir) = CampaignState::runs_dir() else { + log::error!("unable to determine home directory"); + return false; + }; + + let state_path = runs_dir.join(&args.campaign_id).join("state.json"); + let state = match CampaignState::load(&state_path) { + Ok(s) => s, + Err(e) => { + log::error!("{e}"); + log::error!( + "campaign '{}' not found; list campaigns with: ls {}", + args.campaign_id, + runs_dir.display() + ); + return false; + } + }; + + // Resolve to an absolute path: Docker `-v` requires one, and this also + // rejects a missing input up front. Mount the parent directory and pass + // the file's basename via SMITE_INPUT so colons in AFL crash names (e.g. + // `id:000000,sig:06,...`) never appear in the bind-mount spec. + let input = match fs::canonicalize(&args.input) { + Ok(p) => p, + Err(e) => { + log::error!("input file not found: {} ({e})", args.input.display()); + return false; + } + }; + if !input.is_file() { + log::error!("input is not a regular file: {}", input.display()); + return false; + } + // canonicalize yields an absolute path ending in the file's name. + let parent = input.parent().expect("canonical input path has a parent"); + let basename = input + .file_name() + .expect("canonical input path has a file name") + .to_string_lossy(); + + let Some(image_id) = docker_image_id(&state.image) else { + log::error!( + "Docker image '{}' not found; build it with: smitebot build --target {} --scenario {}", + state.image, + state.target, + state.scenario + ); + return false; + }; + // A campaign's image can be rebuilt under the same tag during development, + // so a matching name is not a matching image. Warn on a digest mismatch and + // point at the campaign's smite commit for an exact rebuild. + if image_id != state.image_digest { + log::warn!( + "Docker image '{}' digest {} does not match the campaign's {}; results may differ.", + state.image, + image_id, + state.image_digest, + ); + } + + log::info!( + "reproducing {} against {} (image {})", + input.display(), + state.target, + state.image + ); + let run_args = docker_run_args(&state.image, state.target, parent, &basename); + match Command::new("docker").args(&run_args).status() { + Ok(_) => true, + Err(e) => { + log::error!("failed to run docker: {e}"); + false + } + } + } +} + +/// Builds the `docker run` argument list for a single reproduction. +/// +/// Mirrors `scripts/coverage-report.sh`: mount the input's parent directory +/// read-only at `/corpus` and point `SMITE_INPUT` at `/corpus/`; the +/// per-target entrypoint is `/-scenario`. +fn docker_run_args(image: &str, target: Target, parent: &Path, basename: &str) -> Vec { + // Corpus paths under ~/.smitebot are UTF-8 in practice; `display()`'s lossy + // conversion is acceptable for v1. + vec![ + "run".to_string(), + "--rm".to_string(), + "-v".to_string(), + format!("{}:/corpus:ro", parent.display()), + "-e".to_string(), + format!("SMITE_INPUT=/corpus/{basename}"), + image.to_string(), + format!("/{target}-scenario"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_run_args_mounts_parent_and_passes_basename() { + // A colon-laden AFL crash name must stay inside SMITE_INPUT, never in the + // bind-mount spec (which is delimited by colons). + let parent = PathBuf::from("/home/u/.smitebot/runs/c/crashes"); + let args = docker_run_args( + "smite-lnd-encrypted_bytes", + Target::Lnd, + &parent, + "id:000000,sig:06,src:000000", + ); + assert!(args.contains(&"/home/u/.smitebot/runs/c/crashes:/corpus:ro".to_string())); + assert!(args.contains(&"SMITE_INPUT=/corpus/id:000000,sig:06,src:000000".to_string())); + } + + #[test] + fn docker_run_args_entrypoint_is_per_target() { + let parent = PathBuf::from("/tmp"); + let args = docker_run_args("img", Target::Eclair, &parent, "in"); + assert_eq!(args.last().unwrap(), "/eclair-scenario"); + } + + #[test] + fn docker_run_args_places_every_flag_before_the_image() { + // docker run [flags] — every flag must precede the image, + // and the per-target entrypoint is the final argument. + let parent = PathBuf::from("/tmp"); + let args = docker_run_args("myimage", Target::Ldk, &parent, "in"); + let image_pos = args.iter().position(|a| a == "myimage").unwrap(); + for flag in ["--rm", "-v", "-e"] { + let pos = args + .iter() + .position(|a| a == flag) + .unwrap_or_else(|| panic!("{flag} missing from args")); + assert!(pos < image_pos, "{flag} must appear before the image"); + } + assert_eq!(args.last().unwrap(), "/ldk-scenario"); + } + + #[test] + fn docker_run_args_handles_spaces_in_parent_path() { + // Spaces in the corpus path must survive intact in the bind-mount spec; + // args go direct to Command (not a shell), so no quoting is needed. + let parent = PathBuf::from("/home/u/my campaigns/crashes"); + let args = docker_run_args("img", Target::Lnd, &parent, "crash"); + assert!(args.contains(&"/home/u/my campaigns/crashes:/corpus:ro".to_string())); + } +} diff --git a/smitebot/src/commands/start.rs b/smitebot/src/commands/start.rs index a6fd4029..86d81f19 100644 --- a/smitebot/src/commands/start.rs +++ b/smitebot/src/commands/start.rs @@ -16,7 +16,7 @@ use crate::commands::build::{BuildInputs, run_build}; use crate::config::CampaignConfig; use crate::state::{CampaignState, RunnerState, Status}; use crate::tmux; -use crate::utils::{setup_nyx, shell_quote}; +use crate::utils::{command_stdout, docker_image_id, setup_nyx, shell_quote}; /// How long to wait for `fuzzer_stats` before treating alive runners as started. /// @@ -592,15 +592,6 @@ fn build_ir_mutator(smite_dir: &Path) -> bool { } /// Runs a command and returns its trimmed stdout on success. -fn command_stdout(cmd: &mut Command) -> Option { - let output = cmd.output().ok()?; - if output.status.success() { - Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } else { - None - } -} - /// Returns the smite repository git hash, or `None` if not a git repo. fn smite_git_hash(smite_dir: &Path) -> Option { command_stdout( @@ -611,15 +602,6 @@ fn smite_git_hash(smite_dir: &Path) -> Option { ) } -/// Returns the Docker image ID hash for a locally built image. -fn docker_image_id(image: &str) -> Option { - command_stdout( - Command::new("docker") - .args(["inspect", "--format={{.Id}}"]) - .arg(image), - ) -} - #[cfg(test)] mod tests { use std::fs; diff --git a/smitebot/src/main.rs b/smitebot/src/main.rs index 298d715b..e448009d 100644 --- a/smitebot/src/main.rs +++ b/smitebot/src/main.rs @@ -14,8 +14,9 @@ use clap::{Parser, Subcommand}; use commands::{ BenchExecArgs, BenchExecCommand, BuildArgs, BuildCommand, ConfigArgs, ConfigCommand, - CorpusArgs, CorpusCommand, DoctorArgs, DoctorCommand, PrintIrArgs, PrintIrCommand, StartArgs, - StartCommand, StatusArgs, StatusCommand, StopArgs, StopCommand, + CorpusArgs, CorpusCommand, DoctorArgs, DoctorCommand, PrintIrArgs, PrintIrCommand, + ReproduceArgs, ReproduceCommand, StartArgs, StartCommand, StatusArgs, StatusCommand, StopArgs, + StopCommand, }; #[derive(Debug, Parser)] @@ -39,6 +40,8 @@ enum Commands { Doctor(DoctorArgs), /// Decode a fuzzer input and print it as readable IR. PrintIr(PrintIrArgs), + /// Replay a single input against a campaign's target in Docker. + Reproduce(ReproduceArgs), /// Launch a fuzzing campaign. Start(StartArgs), /// Report the status of a campaign. @@ -58,6 +61,7 @@ fn main() -> ExitCode { Commands::Corpus(args) => CorpusCommand::execute(&args), Commands::Doctor(args) => DoctorCommand::execute(&args), Commands::PrintIr(args) => PrintIrCommand::execute(&args), + Commands::Reproduce(args) => ReproduceCommand::execute(&args), Commands::Start(args) => StartCommand::execute(&args), Commands::Status(args) => StatusCommand::execute(&args), Commands::Stop(args) => StopCommand::execute(&args), diff --git a/smitebot/src/utils.rs b/smitebot/src/utils.rs index 71c9d3d1..d5adae1e 100644 --- a/smitebot/src/utils.rs +++ b/smitebot/src/utils.rs @@ -107,6 +107,27 @@ pub fn pin_to_cpu(cpu: usize) -> Result<(), String> { Ok(()) } +/// Runs `cmd` and returns its trimmed stdout, or `None` if it fails to spawn or +/// exits unsuccessfully. +pub fn command_stdout(cmd: &mut Command) -> Option { + let output = cmd.output().ok()?; + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } +} + +/// Returns a local Docker image's ID (content digest), or `None` if the image is +/// not present. The value matches the `image_digest` recorded in `state.json`. +pub fn docker_image_id(image: &str) -> Option { + command_stdout( + Command::new("docker") + .args(["inspect", "--format={{.Id}}"]) + .arg(image), + ) +} + #[cfg(test)] mod tests { use super::*;