diff --git a/.rgignore b/.rgignore new file mode 100644 index 000000000..3fd06b215 --- /dev/null +++ b/.rgignore @@ -0,0 +1 @@ +cargo-afl/AFLplusplus diff --git a/Cargo.lock b/Cargo.lock index 7273997a8..694c927ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,7 +19,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "afl" -version = "0.15.23" +version = "0.16.0-rc.1" dependencies = [ "arbitrary", "home", @@ -161,7 +161,7 @@ dependencies = [ [[package]] name = "cargo-afl" -version = "0.15.23" +version = "0.16.0-rc.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/afl/Cargo.toml b/afl/Cargo.toml index 1d4e7281c..446b1b695 100644 --- a/afl/Cargo.toml +++ b/afl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "afl" -version = "0.15.23" +version = "0.16.0-rc.1" readme = "README.md" license = "Apache-2.0" authors = [ diff --git a/cargo-afl/Cargo.toml b/cargo-afl/Cargo.toml index 2fcf9d878..0ba304130 100644 --- a/cargo-afl/Cargo.toml +++ b/cargo-afl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-afl" -version = "0.15.23" +version = "0.16.0-rc.1" readme = "README.md" license = "Apache-2.0" authors = [ diff --git a/cargo-afl/src/common.rs b/cargo-afl/src/common.rs index 53aaadaf6..047642e35 100644 --- a/cargo-afl/src/common.rs +++ b/cargo-afl/src/common.rs @@ -5,7 +5,10 @@ use std::env; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -fn xdg_base_dir() -> xdg::BaseDirectories { +/// Return the [`xdg::BaseDirectories`] used by afl.rs +/// +/// This function is public only for tests. Non-test code should use [`data_dir`], etc. +pub fn xdg_base_dir() -> xdg::BaseDirectories { xdg::BaseDirectories::with_prefix("afl.rs") } @@ -55,7 +58,20 @@ pub fn object_file_path() -> Result { afl_llvm_dir().map(|path| path.join("libafl-llvm-rt.o")) } -pub fn plugins_available() -> Result { +pub fn aflplusplus_dir() -> Result { + aflplusplus_dir_from_base_dir(&xdg_base_dir()) +} + +/// Construct the AFLplusplus directory from [`xdg::BaseDirectories`] +/// +/// This function exists only for tests. Non-test code should use [`aflplusplus_dir`]. +pub fn aflplusplus_dir_from_base_dir(base_dir: &xdg::BaseDirectories) -> Result { + base_dir + .create_data_directory("AFLplusplus") + .map_err(Into::into) +} + +pub fn plugins_installed() -> Result { let afl_llvm_dir = afl_llvm_dir()?; for result in afl_llvm_dir .read_dir() diff --git a/cargo-afl/src/config.rs b/cargo-afl/src/config.rs index 790dbe7f8..854a141fc 100644 --- a/cargo-afl/src/config.rs +++ b/cargo-afl/src/config.rs @@ -1,5 +1,3 @@ -#![deny(clippy::disallowed_macros, clippy::expect_used, clippy::unwrap_used)] - use anyhow::{Context, Result, bail, ensure}; use clap::Parser; use std::ffi::OsStr; @@ -9,6 +7,7 @@ use std::process::{Command, ExitStatus, Stdio}; use super::common; const AFL_SRC_PATH: &str = "AFLplusplus"; +const AFLPLUSPLUS_URL: &str = "https://github.com/AFLplusplus/AFLplusplus"; #[allow(clippy::struct_excessive_bools)] #[derive(Default, Parser)] @@ -20,19 +19,41 @@ pub struct Args { #[clap(long, help = "Build AFL++ for the default toolchain")] pub build: bool, - #[clap(long, help = "Rebuild AFL++ if it was already built")] + #[clap( + long, + help = "Rebuild AFL++ if it was already built. Note: AFL++ will be built without plugins \ + if `--plugins` is not passed." + )] pub force: bool, #[clap(long, help = "Enable building of LLVM plugins")] pub plugins: bool, + #[clap( + long, + help = "Update to instead of the latest stable version", + requires = "update" + )] + pub tag: Option, + + #[clap( + long, + help = "Update AFL++ to the latest stable version (preserving plugins, if applicable)" + )] + pub update: bool, + #[clap(long, help = "Show build output")] pub verbose: bool, } pub fn config(args: &Args) -> Result<()> { let object_file_path = common::object_file_path()?; - if !args.force && object_file_path.exists() && args.plugins == common::plugins_available()? { + + if !args.force + && !args.update + && object_file_path.exists() + && args.plugins == common::plugins_installed()? + { let version = common::afl_rustc_version()?; bail!( "AFL LLVM runtime was already built for Rust {version}; run `cargo afl config --build \ @@ -40,63 +61,168 @@ pub fn config(args: &Args) -> Result<()> { ); } - let afl_src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(AFL_SRC_PATH); - let afl_src_dir_str = &afl_src_dir.to_string_lossy(); + // smoelius: If updating and AFL++ was built with plugins before, build with plugins again. + let args = Args { + plugins: if args.update { + common::plugins_installed().is_ok_and(|is_true| is_true) + } else { + args.plugins + }, + tag: args.tag.clone(), + ..*args + }; + + let aflplusplus_dir = + common::aflplusplus_dir().with_context(|| "could not determine AFLplusplus directory")?; + + // smoelius: The AFLplusplus directory could be in one of three possible states: + // + // 1. Nonexistent + // 2. Initialized with a copy of the AFLplusplus submodule from afl.rs's source tree + // 3. Cloned from `AFLPLUSPLUS_URL` + // + // If we are not updating and the AFLplusplus directory is nonexistent: initialize the directory + // with a copy of the AFLplusplus submodule from afl.rs's source tree (the `else` case in the + // next `if` statement). + // + // If we are updating and the AFLplusplus directory is a copy of the AFLplusplus submodule from + // afl.rs's source tree: remove it and create a new directory by cloning AFL++ (the `else` case + // in `update_to_stable_or_tag`). + // + // Finally, if we are updating: check out either `origin/stable` or the tag that was passed. + if args.update { + let rev_prev = if is_repo(&aflplusplus_dir)? { + rev(&aflplusplus_dir).map(Some)? + } else { + None + }; + + update_to_stable_or_tag(&aflplusplus_dir, args.tag.as_deref())?; + + let rev_curr = rev(&aflplusplus_dir)?; + + if rev_prev == Some(rev_curr) && !args.force { + eprintln!("Nothing to do. Pass `--force` to force rebuilding."); + return Ok(()); + } + } else if !aflplusplus_dir.join(".git").try_exists()? { + copy_aflplusplus_submodule(&aflplusplus_dir)?; + } + + build_afl(&args, &aflplusplus_dir)?; + build_afl_llvm_runtime(&args, &aflplusplus_dir)?; + + if args.plugins { + copy_afl_llvm_plugins(&args, &aflplusplus_dir)?; + } - let tempdir = tempfile::tempdir().with_context(|| "could not create temporary directory")?; + let afl_dir = common::afl_dir()?; + let Some(afl_dir_parent) = afl_dir.parent() else { + bail!("could not get afl dir parent"); + }; + eprintln!("Artifacts written to {}", afl_dir_parent.display()); + + Ok(()) +} - if afl_src_dir.join(".git").is_dir() { +fn update_to_stable_or_tag(aflplusplus_dir: &Path, tag: Option<&str>) -> Result<()> { + if is_repo(aflplusplus_dir)? { let success = Command::new("git") - .args(["clone", afl_src_dir_str, &*tempdir.path().to_string_lossy()]) + .arg("fetch") + .current_dir(aflplusplus_dir) .status() .as_ref() .is_ok_and(ExitStatus::success); - ensure!(success, "could not run 'git'"); + ensure!(success, "could not run 'git fetch'"); } else { - let success = Command::new("cp") + remove_aflplusplus_dir(aflplusplus_dir).unwrap_or_default(); + let success = Command::new("git") .args([ - "-P", // preserve symlinks - "-R", // copy directories recursively - afl_src_dir_str, - &*tempdir.path().to_string_lossy(), + "clone", + AFLPLUSPLUS_URL, + &*aflplusplus_dir.to_string_lossy(), ]) .status() .as_ref() .is_ok_and(ExitStatus::success); - ensure!( - success, - "could not copy directory `{}`", - afl_src_dir.display() - ); + ensure!(success, "could not run 'git clone'"); } - let work_dir = tempdir.path().join(AFL_SRC_PATH); + let mut command = Command::new("git"); + command.arg("checkout"); + if let Some(tag) = tag { + command.arg(tag); + } else { + command.arg("origin/stable"); + } + command.current_dir(aflplusplus_dir); + let success = command.status().as_ref().is_ok_and(ExitStatus::success); + ensure!(success, "could not run 'git checkout'"); + + Ok(()) +} - build_afl(args, &work_dir)?; - build_afl_llvm_runtime(args, &work_dir)?; +fn remove_aflplusplus_dir(aflplusplus_dir: &Path) -> Result<()> { + std::fs::remove_dir_all(aflplusplus_dir).map_err(Into::into) +} - if args.plugins { - copy_afl_llvm_plugins(args, &work_dir)?; - } +fn copy_aflplusplus_submodule(aflplusplus_dir: &Path) -> Result<()> { + let afl_src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(AFL_SRC_PATH); + let afl_src_dir_str = &afl_src_dir.to_string_lossy(); - let afl_dir = common::afl_dir()?; - let Some(dir) = afl_dir.parent().map(Path::to_path_buf) else { - bail!("could not get afl dir parent"); + let Some(aflplusplus_dir_parent) = aflplusplus_dir.parent() else { + bail!("could not get AFLplusplus dir parent"); }; - eprintln!("Artifacts written to {}", dir.display()); + debug_assert_eq!(aflplusplus_dir_parent.join(AFL_SRC_PATH), aflplusplus_dir); + + let success = Command::new("cp") + .args([ + "-P", // preserve symlinks + "-R", // copy directories recursively + afl_src_dir_str, + &*aflplusplus_dir_parent.to_string_lossy(), + ]) + .status() + .as_ref() + .is_ok_and(ExitStatus::success); + ensure!( + success, + "could not copy directory `{}`", + afl_src_dir.display() + ); Ok(()) } +// smoelius: `dot_git` will refer to an ASCII text file if it was copied from the AFLplusplus +// submodule from afl.rs's source tree. +fn is_repo(aflplusplus_dir: &Path) -> Result { + let dot_git = aflplusplus_dir.join(".git"); + if dot_git.try_exists()? { + Ok(dot_git.is_dir()) + } else { + Ok(false) + } +} + +fn rev(dir: &Path) -> Result { + let mut command = Command::new("git"); + command.args(["rev-parse", "HEAD"]); + command.current_dir(dir); + let output = command + .output() + .with_context(|| "could not run `git rev-parse`")?; + ensure!(output.status.success(), "`git rev-parse` failed"); + String::from_utf8(output.stdout).map_err(Into::into) +} + fn build_afl(args: &Args, work_dir: &Path) -> Result<()> { // if you had already installed cargo-afl previously you **must** clean AFL++ - // smoelius: AFL++ is now copied to a temporary directory before being built. So `make clean` - // is no longer necessary. let afl_dir = common::afl_dir()?; let mut command = Command::new("make"); command .current_dir(work_dir) - .arg("install") + .args(["clean", "install"]) // skip the checks for the legacy x86 afl-gcc compiler .env("AFL_NO_X86", "1") .env("DESTDIR", afl_dir) @@ -118,7 +244,11 @@ fn build_afl(args: &Args, work_dir: &Path) -> Result<()> { } let success = command.status().as_ref().is_ok_and(ExitStatus::success); - ensure!(success, "could not run 'make install'"); + ensure!( + success, + "could not run 'make clean install' in {}", + work_dir.display() + ); Ok(()) } @@ -200,3 +330,141 @@ fn check_llvm_and_get_config() -> Result { Ok(llvm_config) } + +#[cfg(test)] +mod tests { + use super::{copy_aflplusplus_submodule, remove_aflplusplus_dir, update_to_stable_or_tag}; + use crate::{common, config::is_repo}; + use anyhow::Result; + use assert_cmd::cargo::CommandCargoExt; + use std::{path::Path, process::Command}; + use tempfile::tempdir; + + #[derive(Clone, Copy, Debug)] + enum State { + Nonexistent, + Submodule, + Tag(&'static str), + Stable, + } + + const TESTCASES: &[(State, State, &[&str])] = &[ + // smoelius: There is currently no way to update to the submodule. + // (State::Nonexistent, State::Submodule, &[]), + ( + State::Nonexistent, + State::Tag("v4.33c"), + &[ + #[cfg(not(target_os = "macos"))] + "Note: switching to 'v4.33c'.", + "HEAD is now at", + ], + ), + ( + State::Nonexistent, + State::Stable, + &[ + #[cfg(not(target_os = "macos"))] + "Note: switching to 'origin/stable'.", + "HEAD is now at", + ], + ), + ( + State::Submodule, + State::Tag("v4.33c"), + &[ + #[cfg(not(target_os = "macos"))] + "Note: switching to 'v4.33c'.", + "HEAD is now at", + ], + ), + ( + State::Submodule, + State::Stable, + &[ + #[cfg(not(target_os = "macos"))] + "Note: switching to 'origin/stable'.", + "HEAD is now at", + ], + ), + // smoelius: It should be possible to go from a tag to the stable version. + ( + State::Tag("v4.33c"), + State::Stable, + &["Previous HEAD position was", "HEAD is now at"], + ), + // smoelius: It should be possible to go from the stable version to a tag. + ( + State::Stable, + State::Tag("v4.33c"), + &["Previous HEAD position was", "HEAD is now at"], + ), + ]; + + #[test] + fn update() { + let mut base_dir = common::xdg_base_dir(); + + for &(before, after, line_prefixes) in TESTCASES { + eprintln!("{before:?} -> {after:?}"); + + let tempdir = tempdir().unwrap(); + + // smoelius: Based on https://github.com/whitequark/rust-xdg/issues/44, the recommended + // way of testing with a fake value of `XDG_DATA_HOME` seems to be manually overwriting + // the `data_home` field in `xdg::BaseDirectories`. + base_dir.data_home = Some(tempdir.path().to_path_buf()); + + let aflplusplus_dir = common::aflplusplus_dir_from_base_dir(&base_dir).unwrap(); + + assert!(aflplusplus_dir.starts_with(tempdir.path())); + + set_aflplusplus_dir_contents(before, &aflplusplus_dir).unwrap(); + + let mut command = Command::cargo_bin("cargo-afl").unwrap(); + command.args(["afl", "config", "--update"]); + command.env("XDG_DATA_HOME", tempdir.path()); + match after { + State::Nonexistent | State::Submodule => unreachable!(), + State::Tag(tag) => { + command.args(["--tag", tag]); + } + State::Stable => {} + } + let output = command.output().unwrap(); + assert!(output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + contains_expected_line_prefixes(&stderr, line_prefixes); + } + } + + fn set_aflplusplus_dir_contents(state: State, aflplusplus_dir: &Path) -> Result<()> { + let result = match state { + State::Nonexistent => remove_aflplusplus_dir(aflplusplus_dir), + State::Submodule => copy_aflplusplus_submodule(aflplusplus_dir), + State::Tag(tag) => update_to_stable_or_tag(aflplusplus_dir, Some(tag)), + State::Stable => update_to_stable_or_tag(aflplusplus_dir, None), + }; + // smoelius: Sanity. + assert!( + is_repo(aflplusplus_dir) + .is_ok_and(|value| value == matches!(state, State::Tag(_) | State::Stable)) + ); + result + } + + fn contains_expected_line_prefixes(stderr: &str, mut line_prefixes: &[&str]) { + for line in stderr.lines() { + if line_prefixes + .first() + .is_some_and(|prefix| line.starts_with(prefix)) + { + line_prefixes = &line_prefixes[1..]; + } + } + assert!( + line_prefixes.is_empty(), + "Could not find line prefix {line_prefixes:?}:\n```\n{stderr}```" + ); + } +} diff --git a/cargo-afl/src/main.rs b/cargo-afl/src/main.rs index ab9a04e15..5a0f93aaa 100644 --- a/cargo-afl/src/main.rs +++ b/cargo-afl/src/main.rs @@ -104,7 +104,7 @@ declare_afl_subcommand_enum! { Addseeds("Invoke afl-addseeds"), Analyze("Invoke afl-analyze"), Cmin("Invoke afl-cmin"), - Config("Build or rebuild AFL++", config::Args), + Config("Build, rebuild, or update AFL++", config::Args), Fuzz("Invoke afl-fuzz"), Gotcpu("Invoke afl-gotcpu"), Plot("Invoke afl-plot"), @@ -182,7 +182,7 @@ fn command_with_afl_version() -> clap::Command { (|| -> Option<()> { let afl_version = afl_version()?; - let with_plugins = common::plugins_available().ok()?; + let with_plugins = common::plugins_installed().ok()?; let subcmd = command.find_subcommand_mut("afl").unwrap(); let ver = format!( @@ -292,7 +292,7 @@ where environment_variables.insert("ASAN_OPTIONS", asan_options); environment_variables.insert("TSAN_OPTIONS", tsan_options); - let has_plugins = common::plugins_available().unwrap(); + let has_plugins = common::plugins_installed().unwrap(); if require_plugins || has_plugins { // Make sure we are on nightly for the -Z flags assert!( @@ -534,6 +534,16 @@ mod tests { } } + #[test] + fn tag_requires_update() { + let output = cargo_afl(&["config", "--tag", "v4.33c"]).output().unwrap(); + assert_failure(&output, None); + assert!(String::from_utf8(output.stderr).unwrap().contains( + "error: the following required arguments were not provided: + --update" + )); + } + fn cargo_afl>(args: &[T]) -> Command { let mut command = command(); command.arg("afl").args(args).env("NO_SUDO", "1"); diff --git a/clippy.toml b/clippy.toml index e2f44cbe0..57944a8de 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,3 +4,4 @@ disallowed-macros = [ "std::assert_ne", "std::panic", ] +doc-valid-idents = ["AFLplusplus"]