diff --git a/README.md b/README.md index 87740a4ac..970a17412 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ fuzz jobs from a few virtual machines to thousands of cores. ## Features * **Composable fuzzing workflows**: Open source allows users to onboard their own - fuzzers, [swap instrumentation](docs/custom-analysis.md), and manage seed inputs. + fuzzers, [swap instrumentation](docs/custom-analysis.md), and manage seed inputs. * **Built-in ensemble fuzzing**: By default, fuzzers work as a team to share strengths, swapping inputs of interest between fuzzing technologies. * **Programmatic triage and result de-duplication**: It provides unique flaw cases that diff --git a/docs/tasks.md b/docs/tasks.md index 0c35d20ff..b523da452 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -33,6 +33,7 @@ The current task types available are: * generic_crash_report: use a built-in debugging tool (debugapi or ptrace based) to rerun the crashing input, attempting to generate an informational report for each discovered crash +* dotnet_coverage: same as `coverage` but for dotnet Each type of task has a unique set of configuration options available, these include: @@ -70,4 +71,4 @@ implementation level details on the types of tasks available. ## Environment Variables -* `ONEFUZZ_TARGET_SETUP_PATH`: An environment variable set prior to launching target-specific setup scripts that defines the path to the setup container. \ No newline at end of file +* `ONEFUZZ_TARGET_SETUP_PATH`: An environment variable set prior to launching target-specific setup scripts that defines the path to the setup container. diff --git a/docs/webhook_events.md b/docs/webhook_events.md index fd4470287..34dcd8f47 100644 --- a/docs/webhook_events.md +++ b/docs/webhook_events.md @@ -565,7 +565,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -1204,7 +1205,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -2444,7 +2446,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -3159,7 +3162,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -3665,7 +3669,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -4114,7 +4119,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -4551,7 +4557,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -4987,7 +4994,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, @@ -6725,7 +6733,8 @@ If webhook is set to have Event Grid message format then the payload will look a "generic_merge", "generic_generator", "generic_crash_report", - "generic_regression" + "generic_regression", + "dotnet_coverage" ], "title": "TaskType" }, diff --git a/src/ApiService/ApiService/OneFuzzTypes/Enums.cs b/src/ApiService/ApiService/OneFuzzTypes/Enums.cs index c21a19905..1f5868caa 100644 --- a/src/ApiService/ApiService/OneFuzzTypes/Enums.cs +++ b/src/ApiService/ApiService/OneFuzzTypes/Enums.cs @@ -70,7 +70,8 @@ public enum TaskType { GenericMerge, GenericGenerator, GenericCrashReport, - GenericRegression + GenericRegression, + DotnetCoverage, } public enum Os { diff --git a/src/ApiService/ApiService/onefuzzlib/Defs.cs b/src/ApiService/ApiService/onefuzzlib/Defs.cs index 090dbcdc5..dc2f026e5 100644 --- a/src/ApiService/ApiService/onefuzzlib/Defs.cs +++ b/src/ApiService/ApiService/onefuzzlib/Defs.cs @@ -555,5 +555,42 @@ public static class Defs { ContainerPermission.Read | ContainerPermission.List ), }) - } }; + }, + { TaskType.DotnetCoverage , + new TaskDefinition( + Features: new[] { + TaskFeature.TargetExe, + TaskFeature.TargetEnv, + TaskFeature.TargetOptions, + TaskFeature.TargetTimeout, + TaskFeature.CoverageFilter, + TaskFeature.TargetMustUseInput, + }, + Vm: new VmDefinition(Compare: Compare.Equal, Value:1), + Containers: new [] { + new ContainerDefinition( + Type:ContainerType.Setup, + Compare: Compare.Equal, + Value:1, + Permissions: ContainerPermission.Read | ContainerPermission.List + ), + new ContainerDefinition( + Type:ContainerType.ReadonlyInputs, + Compare: Compare.AtLeast, + Value:1, + Permissions: ContainerPermission.Read | ContainerPermission.List + ), + new ContainerDefinition( + Type:ContainerType.Coverage, + Compare: Compare.Equal, + Value:1, + Permissions: + ContainerPermission.List | + ContainerPermission.Read | + ContainerPermission.Write + + )}, + MonitorQueue: ContainerType.ReadonlyInputs) + }, + }; } diff --git a/src/agent/onefuzz-task/src/local/cmd.rs b/src/agent/onefuzz-task/src/local/cmd.rs index 92c18a313..0b4b48e64 100644 --- a/src/agent/onefuzz-task/src/local/cmd.rs +++ b/src/agent/onefuzz-task/src/local/cmd.rs @@ -1,13 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#[cfg(any(target_os = "linux", target_os = "windows"))] -use crate::local::coverage; use crate::local::{ common::add_common_config, generic_analysis, generic_crash_report, generic_generator, libfuzzer, libfuzzer_crash_report, libfuzzer_fuzz, libfuzzer_merge, libfuzzer_regression, libfuzzer_test_input, radamsa, test_input, tui::TerminalUi, }; +#[cfg(any(target_os = "linux", target_os = "windows"))] +use crate::local::{coverage, dotnet_coverage}; use anyhow::{Context, Result}; use clap::{App, Arg, SubCommand}; use crossterm::tty::IsTty; @@ -23,6 +23,8 @@ enum Commands { Radamsa, #[cfg(any(target_os = "linux", target_os = "windows"))] Coverage, + #[cfg(any(target_os = "linux", target_os = "windows"))] + DotnetCoverage, LibfuzzerFuzz, LibfuzzerMerge, LibfuzzerCrashReport, @@ -59,6 +61,8 @@ pub async fn run(args: clap::ArgMatches<'static>) -> Result<()> { match command { #[cfg(any(target_os = "linux", target_os = "windows"))] Commands::Coverage => coverage::run(&sub_args, event_sender).await, + #[cfg(any(target_os = "linux", target_os = "windows"))] + Commands::DotnetCoverage => dotnet_coverage::run(&sub_args, event_sender).await, Commands::Radamsa => radamsa::run(&sub_args, event_sender).await, Commands::LibfuzzerCrashReport => { libfuzzer_crash_report::run(&sub_args, event_sender).await @@ -117,6 +121,8 @@ pub fn args(name: &str) -> App<'static, 'static> { let app = match subcommand { #[cfg(any(target_os = "linux", target_os = "windows"))] Commands::Coverage => coverage::args(subcommand.into()), + #[cfg(any(target_os = "linux", target_os = "windows"))] + Commands::DotnetCoverage => dotnet_coverage::args(subcommand.into()), Commands::Radamsa => radamsa::args(subcommand.into()), Commands::LibfuzzerCrashReport => libfuzzer_crash_report::args(subcommand.into()), Commands::LibfuzzerFuzz => libfuzzer_fuzz::args(subcommand.into()), diff --git a/src/agent/onefuzz-task/src/local/common.rs b/src/agent/onefuzz-task/src/local/common.rs index 7472e404c..930b3a7af 100644 --- a/src/agent/onefuzz-task/src/local/common.rs +++ b/src/agent/onefuzz-task/src/local/common.rs @@ -29,6 +29,8 @@ pub const CHECK_RETRY_COUNT: &str = "check_retry_count"; pub const DISABLE_CHECK_QUEUE: &str = "disable_check_queue"; pub const UNIQUE_REPORTS_DIR: &str = "unique_reports_dir"; pub const COVERAGE_DIR: &str = "coverage_dir"; +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub const DOTNET_COVERAGE_DIR: &str = "dotnet_coverage_dir"; pub const READONLY_INPUTS: &str = "readonly_inputs_dir"; pub const CHECK_ASAN_LOG: &str = "check_asan_log"; pub const TOOLS_DIR: &str = "tools_dir"; diff --git a/src/agent/onefuzz-task/src/local/dotnet_coverage.rs b/src/agent/onefuzz-task/src/local/dotnet_coverage.rs new file mode 100644 index 000000000..904f8014d --- /dev/null +++ b/src/agent/onefuzz-task/src/local/dotnet_coverage.rs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{ + local::common::{ + build_local_context, get_cmd_arg, get_cmd_env, get_cmd_exe, get_synced_dir, + get_synced_dirs, CmdType, SyncCountDirMonitor, UiEvent, COVERAGE_DIR, INPUTS_DIR, + READONLY_INPUTS, TARGET_ENV, TARGET_EXE, TARGET_OPTIONS, TARGET_TIMEOUT, + }, + tasks::{ + config::CommonConfig, + coverage::dotnet::{Config, DotnetCoverageTask}, + }, +}; + +use anyhow::Result; +use clap::{App, Arg, SubCommand}; +use flume::Sender; +use storage_queue::QueueClient; + +pub fn build_shared_args(local_job: bool) -> Vec> { + let mut args = vec![ + Arg::with_name(TARGET_EXE) + .long(TARGET_EXE) + .takes_value(true) + .required(true), + Arg::with_name(TARGET_ENV) + .long(TARGET_ENV) + .takes_value(true) + .multiple(true), + Arg::with_name(TARGET_OPTIONS) + .long(TARGET_OPTIONS) + .default_value("{input}") + .takes_value(true) + .value_delimiter(" ") + .help("Use a quoted string with space separation to denote multiple arguments"), + Arg::with_name(TARGET_TIMEOUT) + .takes_value(true) + .long(TARGET_TIMEOUT), + Arg::with_name(COVERAGE_DIR) + .takes_value(true) + .required(!local_job) + .long(COVERAGE_DIR), + ]; + if local_job { + args.push( + Arg::with_name(INPUTS_DIR) + .long(INPUTS_DIR) + .takes_value(true) + .required(true), + ) + } else { + args.push( + Arg::with_name(READONLY_INPUTS) + .takes_value(true) + .required(true) + .long(READONLY_INPUTS) + .multiple(true), + ) + } + args +} + +pub fn build_coverage_config( + args: &clap::ArgMatches<'_>, + local_job: bool, + input_queue: Option, + common: CommonConfig, + event_sender: Option>, +) -> Result { + let target_exe = get_cmd_exe(CmdType::Target, args)?.into(); + let target_env = get_cmd_env(CmdType::Target, args)?; + let target_options = get_cmd_arg(CmdType::Target, args); + let target_timeout = value_t!(args, TARGET_TIMEOUT, u64).ok(); + + let readonly_inputs = if local_job { + info!("Took inputs_dir"); + vec![ + get_synced_dir(INPUTS_DIR, common.job_id, common.task_id, args)? + .monitor_count(&event_sender)?, + ] + } else { + get_synced_dirs(READONLY_INPUTS, common.job_id, common.task_id, args)? + .into_iter() + .map(|sd| sd.monitor_count(&event_sender)) + .collect::>>()? + }; + + let coverage = get_synced_dir(COVERAGE_DIR, common.job_id, common.task_id, args)? + .monitor_count(&event_sender)?; + + let config = Config { + target_exe, + target_env, + target_options, + target_timeout, + input_queue, + readonly_inputs, + coverage, + common, + }; + + Ok(config) +} + +pub async fn run(args: &clap::ArgMatches<'_>, event_sender: Option>) -> Result<()> { + let context = build_local_context(args, true, event_sender.clone())?; + let config = build_coverage_config( + args, + false, + None, + context.common_config.clone(), + event_sender, + )?; + + let mut task = DotnetCoverageTask::new(config); + task.run().await +} + +pub fn args(name: &'static str) -> App<'static, 'static> { + SubCommand::with_name(name) + .about("execute a local-only coverage task") + .args(&build_shared_args(false)) +} diff --git a/src/agent/onefuzz-task/src/local/libfuzzer.rs b/src/agent/onefuzz-task/src/local/libfuzzer.rs index d4df0a4a4..c81d31489 100644 --- a/src/agent/onefuzz-task/src/local/libfuzzer.rs +++ b/src/agent/onefuzz-task/src/local/libfuzzer.rs @@ -1,14 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#[cfg(any(target_os = "linux", target_os = "windows"))] -use crate::{ - local::{ - common::COVERAGE_DIR, - coverage::{build_coverage_config, build_shared_args as build_coverage_args}, - }, - tasks::coverage::generic::CoverageTask, -}; use crate::{ local::{ common::{ @@ -28,6 +20,17 @@ use crate::{ report::libfuzzer_report::ReportTask, }, }; +#[cfg(any(target_os = "linux", target_os = "windows"))] +use crate::{ + local::{ + common::{COVERAGE_DIR, DOTNET_COVERAGE_DIR}, + coverage, + coverage::build_shared_args as build_coverage_args, + dotnet_coverage, + }, + tasks::coverage::dotnet::DotnetCoverageTask, + tasks::coverage::generic::CoverageTask, +}; use anyhow::Result; use clap::{App, SubCommand}; use flume::Sender; @@ -75,11 +78,33 @@ pub async fn run(args: &clap::ArgMatches<'_>, event_sender: Option &mut c.common, + #[cfg(any(target_os = "linux", target_os = "windows"))] + Config::DotnetCoverage(c) => &mut c.common, Config::LibFuzzerFuzz(c) => &mut c.common, Config::LibFuzzerMerge(c) => &mut c.common, Config::LibFuzzerReport(c) => &mut c.common, @@ -147,6 +153,8 @@ impl Config { match self { #[cfg(any(target_os = "linux", target_os = "windows"))] Config::Coverage(c) => &c.common, + #[cfg(any(target_os = "linux", target_os = "windows"))] + Config::DotnetCoverage(c) => &c.common, Config::LibFuzzerFuzz(c) => &c.common, Config::LibFuzzerMerge(c) => &c.common, Config::LibFuzzerReport(c) => &c.common, @@ -164,6 +172,8 @@ impl Config { let event_type = match self { #[cfg(any(target_os = "linux", target_os = "windows"))] Config::Coverage(_) => "coverage", + #[cfg(any(target_os = "linux", target_os = "windows"))] + Config::DotnetCoverage(_) => "dotnet_coverage", Config::LibFuzzerFuzz(_) => "libfuzzer_fuzz", Config::LibFuzzerMerge(_) => "libfuzzer_merge", Config::LibFuzzerReport(_) => "libfuzzer_crash_report", @@ -208,6 +218,12 @@ impl Config { match self { #[cfg(any(target_os = "linux", target_os = "windows"))] Config::Coverage(config) => coverage::generic::CoverageTask::new(config).run().await, + #[cfg(any(target_os = "linux", target_os = "windows"))] + Config::DotnetCoverage(config) => { + coverage::dotnet::DotnetCoverageTask::new(config) + .run() + .await + } Config::LibFuzzerFuzz(config) => { fuzz::libfuzzer_fuzz::LibFuzzerFuzzTask::new(config)? .run() diff --git a/src/agent/onefuzz-task/src/tasks/coverage/dotnet.rs b/src/agent/onefuzz-task/src/tasks/coverage/dotnet.rs new file mode 100644 index 000000000..9503f3cb4 --- /dev/null +++ b/src/agent/onefuzz-task/src/tasks/coverage/dotnet.rs @@ -0,0 +1,421 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use onefuzz::{ + expand::{Expand, PlaceHolder}, + monitor::DirectoryMonitor, + syncdir::SyncedDir, +}; +use reqwest::Url; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use std::{env, process::ExitStatus}; +use storage_queue::{Message, QueueClient}; +use tokio::{fs, process::Command, time::timeout}; +use tokio_stream::wrappers::ReadDirStream; +use uuid::Uuid; + +use crate::tasks::{ + config::CommonConfig, + coverage::COBERTURA_COVERAGE_FILE, + generic::input_poller::{CallbackImpl, InputPoller, Processor}, + heartbeat::{HeartbeatSender, TaskHeartbeatClient}, +}; + +const MAX_COVERAGE_RECORDING_ATTEMPTS: usize = 2; +const DEFAULT_TARGET_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Deserialize)] +pub struct Config { + pub target_exe: PathBuf, + pub target_env: HashMap, + pub target_options: Vec, + pub target_timeout: Option, + + pub input_queue: Option, + pub readonly_inputs: Vec, + pub coverage: SyncedDir, + + #[serde(flatten)] + pub common: CommonConfig, +} + +impl Config { + pub fn timeout(&self) -> Duration { + self.target_timeout + .map(Duration::from_secs) + .unwrap_or(DEFAULT_TARGET_TIMEOUT) + } +} + +pub struct DotnetCoverageTask { + config: Config, + poller: InputPoller, +} + +impl DotnetCoverageTask { + pub fn new(config: Config) -> Self { + let poller = InputPoller::new("dotnet_coverage"); + Self { config, poller } + } + + pub async fn run(&mut self) -> Result<()> { + info!("starting dotnet_coverage task"); + self.config.coverage.init_pull().await?; + + let dotnet_path = dotnet_path()?; + let dotnet_coverage_path = dotnet_coverage_path()?; + + let heartbeat = self.config.common.init_heartbeat(None).await?; + let mut context = TaskContext::new( + &self.config, + heartbeat, + dotnet_path, + dotnet_coverage_path.clone(), + ); + + if !context.uses_input() { + bail!("input is not specified on the command line or arguments for the target"); + } + + context.heartbeat.alive(); + + let coverage_local_path = self.config.coverage.local_path.canonicalize()?; + let intermediate_files_path = intermediate_coverage_files_path(&coverage_local_path)?; + fs::create_dir_all(&intermediate_files_path).await?; + let timeout = self.config.timeout(); + let coverage_dir = self.config.coverage.clone(); + let dotnet_coverage_path = dotnet_coverage_path; + + tokio::spawn(async move { + if let Err(e) = start_directory_monitor( + &intermediate_files_path, + &coverage_local_path, + timeout, + coverage_dir, + &dotnet_coverage_path, + ) + .await + { + error!("Directory monitor failed: {}", e); + } + }); + + for dir in &self.config.readonly_inputs { + debug!("recording coverage for {}", dir.local_path.display()); + + dir.init_pull().await?; + let dir_count = context.record_corpus(&dir.local_path).await?; + + info!( + "recorded coverage for {} inputs from {}", + dir_count, + dir.local_path.display() + ); + + context.heartbeat.alive(); + } + + context.heartbeat.alive(); + + if let Some(queue) = &self.config.input_queue { + info!("polling queue for new coverage inputs"); + + let callback = CallbackImpl::new(queue.clone(), context)?; + self.poller.run(callback).await?; + } + + Ok(()) + } +} + +async fn start_directory_monitor( + intermediate_files_path: &PathBuf, + coverage_local_path: &Path, + timeout: Duration, + coverage_dir: SyncedDir, + dotnet_coverage_path: &Path, +) -> Result<()> { + info!( + "Starting dotnet coverage intermediate file directory monitor on {}", + intermediate_files_path.to_string_lossy() + ); + let mut monitor = DirectoryMonitor::new(intermediate_files_path).await?; + debug!("Started directory monitor, waiting for files"); + while (monitor.next_file().await?).is_some() { + debug!("Found intermediate coverage file"); + save_and_sync_coverage( + coverage_local_path, + timeout, + coverage_dir.clone(), + dotnet_coverage_path, + ) + .await?; + info!("Updated and synced coverage"); + } + info!("Shut down directory monitor"); + + Ok(()) +} + +struct TaskContext<'a> { + config: &'a Config, + heartbeat: Option, + dotnet_path: PathBuf, + dotnet_coverage_path: PathBuf, +} + +impl<'a> TaskContext<'a> { + pub fn new( + config: &'a Config, + heartbeat: Option, + dotnet_path: PathBuf, + dotnet_coverage_path: PathBuf, + ) -> Self { + Self { + config, + heartbeat, + dotnet_path, + dotnet_coverage_path, + } + } + async fn record_corpus(&mut self, dir: &Path) -> Result { + use futures::stream::StreamExt; + + let mut corpus = fs::read_dir(dir) + .await + .map(ReadDirStream::new) + .with_context(|| format!("unable to read corpus directory: {}", dir.display()))?; + + let mut count = 0; + + while let Some(entry) = corpus.next().await { + match entry { + Ok(entry) => { + if entry.file_type().await?.is_file() { + self.record_input(&entry.path()).await?; + count += 1; + } else { + warn!("skipping non-file dir entry: {}", entry.path().display()); + } + } + Err(err) => { + error!("{:?}", err); + } + } + } + + Ok(count) + } + + async fn record_input(&mut self, input: &Path) -> Result<()> { + debug!("recording coverage for {}", input.display()); + let attempts = MAX_COVERAGE_RECORDING_ATTEMPTS; + + for attempt in 1..=attempts { + let result = self.try_record_input(input).await; + + if let Err(err) = &result { + // Recording failed, check if we can retry. + if attempt < attempts { + // We will retry, but warn to capture the error if we succeed. + warn!( + "error recording coverage for input = {}: {:?}", + input.display(), + err + ); + } else { + // Final attempt, do not retry. + return result.with_context(|| { + format_err!( + "failed to record coverage for input = {} after {} attempts", + input.display(), + attempts + ) + }); + } + } else { + // We successfully recorded the coverage for `input`, so stop. + break; + } + } + + Ok(()) + } + + async fn try_record_input(&self, input: &Path) -> Result<()> { + let mut cmd = self.command_for_input(input).await?; + let timeout = self.config.timeout(); + spawn_with_timeout(&mut cmd, timeout).await?; + Ok(()) + } + + async fn command_for_input(&self, input: &Path) -> Result { + let expand = Expand::new() + .machine_id() + .await? + .input_path(input) + .job_id(&self.config.common.job_id) + .setup_dir(&self.config.common.setup_dir) + .target_exe(&self.config.target_exe) + .target_options(&self.config.target_options) + .task_id(&self.config.common.task_id); + + let dotnet_coverage_path = &self.dotnet_coverage_path; + let dotnet_path = &self.dotnet_path; + let id = Uuid::new_v4(); + let output_file_path = + intermediate_coverage_files_path(self.config.coverage.local_path.as_path())? + .join(format!("{}.cobertura.xml", id)); + + let target_options = expand.evaluate(&self.config.target_options)?; + + let mut cmd = Command::new(dotnet_coverage_path); + cmd.arg("collect") + .args(["--output-format", "cobertura"]) + .args(["-o", &output_file_path.to_string_lossy()]) + .arg(format!( + "{} {} -- {}", + dotnet_path.to_string_lossy(), + self.config.target_exe.canonicalize()?.to_string_lossy(), + target_options.join(" ") + )); + + info!("{:?}", &cmd); + + for (k, v) in &self.config.target_env { + cmd.env(k, expand.evaluate_value(v)?); + } + + cmd.env_remove("RUST_LOG"); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + Ok(cmd) + } + + fn uses_input(&self) -> bool { + let input = PlaceHolder::Input.get_string(); + + for entry in &self.config.target_options { + if entry.contains(&input) { + return true; + } + } + for (k, v) in &self.config.target_env { + if k == &input || v.contains(&input) { + return true; + } + } + + false + } +} + +pub async fn save_and_sync_coverage( + coverage_local_path: &Path, + timeout: Duration, + coverage_dir: SyncedDir, + dotnet_coverage_path: &Path, +) -> Result<()> { + info!("Saving and syncing coverage"); + let mut cmd = command_for_merge(coverage_local_path, dotnet_coverage_path).await?; + spawn_with_timeout(&mut cmd, timeout).await?; + info!("Pushing coverage"); + coverage_dir.sync_push().await?; + + Ok(()) +} + +async fn command_for_merge( + coverage_local_path: &Path, + dotnet_coverage_path: &Path, +) -> Result { + let output_file = working_dir(coverage_local_path)?.join(COBERTURA_COVERAGE_FILE); + + let mut cmd = Command::new(dotnet_coverage_path); + cmd.arg("merge") + .args(["--output-format", "cobertura"]) + .args(["-o", &output_file.to_string_lossy()]) + .arg("-r") + .arg("--remove-input-files") + .arg("*.cobertura.xml") + .arg(COBERTURA_COVERAGE_FILE); // This lets us 'fold' any new coverage into the existing coverage file. + + cmd.current_dir(working_dir(coverage_local_path)?); + + info!("{:?}", &cmd); + info!("From: {:?}", working_dir(coverage_local_path)?); + + cmd.env_remove("RUST_LOG"); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + Ok(cmd) +} + +fn working_dir(coverage_local_path: &Path) -> Result { + Ok(coverage_local_path.canonicalize()?) +} + +fn intermediate_coverage_files_path(coverage_local_path: &Path) -> Result { + Ok(working_dir(coverage_local_path)?.join("intermediate-coverage-files")) +} + +async fn spawn_with_timeout( + cmd: &mut Command, + timeout_after: Duration, +) -> Result { + cmd.kill_on_drop(true); + timeout(timeout_after, cmd.spawn()?.wait()).await? +} + +fn dotnet_coverage_path() -> Result { + let tools_dir = env::var("ONEFUZZ_TOOLS")?; + #[cfg(target_os = "windows")] + let dotnet_coverage_executable = "dotnet-coverage.exe"; + #[cfg(not(target_os = "windows"))] + let dotnet_coverage_executable = "dotnet-coverage"; + let dotnet_coverage = Path::new(&tools_dir).join(dotnet_coverage_executable); + + Ok(dotnet_coverage) +} + +fn dotnet_path() -> Result { + let dotnet_root_dir = env::var("DOTNET_ROOT")?; + #[cfg(target_os = "windows")] + let dotnet_executable = "dotnet.exe"; + #[cfg(not(target_os = "windows"))] + let dotnet_executable = "dotnet"; + let dotnet = Path::new(&dotnet_root_dir).join(dotnet_executable); // The dotnet executable + + Ok(dotnet) +} + +#[async_trait] +impl<'a> Processor for TaskContext<'a> { + async fn process(&mut self, _url: Option, input: &Path) -> Result<()> { + self.heartbeat.alive(); + + self.record_input(input).await?; + let coverage_local_path = self.config.coverage.local_path.canonicalize()?; + + save_and_sync_coverage( + coverage_local_path.as_path(), + self.config.timeout(), + self.config.coverage.clone(), + &self.dotnet_coverage_path, + ) + .await?; + + Ok(()) + } +} diff --git a/src/agent/onefuzz-task/src/tasks/coverage/generic.rs b/src/agent/onefuzz-task/src/tasks/coverage/generic.rs index e6690c797..0c862375f 100644 --- a/src/agent/onefuzz-task/src/tasks/coverage/generic.rs +++ b/src/agent/onefuzz-task/src/tasks/coverage/generic.rs @@ -28,10 +28,11 @@ use crate::tasks::config::CommonConfig; use crate::tasks::generic::input_poller::{CallbackImpl, InputPoller, Processor}; use crate::tasks::heartbeat::{HeartbeatSender, TaskHeartbeatClient}; +use super::COBERTURA_COVERAGE_FILE; + const MAX_COVERAGE_RECORDING_ATTEMPTS: usize = 2; const COVERAGE_FILE: &str = "coverage.json"; const SOURCE_COVERAGE_FILE: &str = "source-coverage.json"; -const COBERTURA_COVERAGE_FILE: &str = "cobertura-coverage.xml"; const MODULE_CACHE_FILE: &str = "module-cache.json"; const DEFAULT_TARGET_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/src/agent/onefuzz-task/src/tasks/coverage/mod.rs b/src/agent/onefuzz-task/src/tasks/coverage/mod.rs index 87af96aae..3f1cc8d36 100644 --- a/src/agent/onefuzz-task/src/tasks/coverage/mod.rs +++ b/src/agent/onefuzz-task/src/tasks/coverage/mod.rs @@ -1,4 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +const COBERTURA_COVERAGE_FILE: &str = "cobertura-coverage.xml"; + +pub mod dotnet; pub mod generic; diff --git a/src/api-service/__app__/onefuzzlib/tasks/defs.py b/src/api-service/__app__/onefuzzlib/tasks/defs.py index ccad92b32..b2d674a8d 100644 --- a/src/api-service/__app__/onefuzzlib/tasks/defs.py +++ b/src/api-service/__app__/onefuzzlib/tasks/defs.py @@ -558,4 +558,40 @@ TASK_DEFINITIONS = { ), ], ), + TaskType.dotnet_coverage: TaskDefinition( + features=[ + TaskFeature.target_exe, + TaskFeature.target_env, + TaskFeature.target_options, + TaskFeature.target_timeout, + TaskFeature.coverage_filter, + TaskFeature.target_must_use_input, + ], + vm=VmDefinition(compare=Compare.Equal, value=1), + containers=[ + ContainerDefinition( + type=ContainerType.setup, + compare=Compare.Equal, + value=1, + permissions=[ContainerPermission.Read, ContainerPermission.List], + ), + ContainerDefinition( + type=ContainerType.readonly_inputs, + compare=Compare.AtLeast, + value=1, + permissions=[ContainerPermission.Read, ContainerPermission.List], + ), + ContainerDefinition( + type=ContainerType.coverage, + compare=Compare.Equal, + value=1, + permissions=[ + ContainerPermission.List, + ContainerPermission.Read, + ContainerPermission.Write, + ], + ), + ], + monitor_queue=ContainerType.readonly_inputs, + ), } diff --git a/src/pytypes/onefuzztypes/enums.py b/src/pytypes/onefuzztypes/enums.py index 98b4d0c4d..ed2ebe9bf 100644 --- a/src/pytypes/onefuzztypes/enums.py +++ b/src/pytypes/onefuzztypes/enums.py @@ -165,6 +165,8 @@ class TaskType(Enum): generic_crash_report = "generic_crash_report" generic_regression = "generic_regression" + dotnet_coverage = "dotnet_coverage" + class VmState(Enum): init = "init" diff --git a/src/runtime-tools/linux/setup.sh b/src/runtime-tools/linux/setup.sh index 2670a4025..8bae8e89a 100755 --- a/src/runtime-tools/linux/setup.sh +++ b/src/runtime-tools/linux/setup.sh @@ -14,6 +14,7 @@ MANAGED_SETUP="/onefuzz/bin/managed.sh" SCALESET_SETUP="/onefuzz/bin/scaleset-setup.sh" DOTNET_VERSION="6.0.300" export DOTNET_ROOT=/onefuzz/tools/dotnet +export DOTNET_CLI_HOME="$DOTNET_ROOT" export ONEFUZZ_ROOT=/onefuzz export LLVM_SYMBOLIZER_PATH=/onefuzz/bin/llvm-symbolizer @@ -128,23 +129,24 @@ if type apt > /dev/null 2> /dev/null; then # Install dotnet until sudo apt install -y curl libicu-dev; do - echo "apt failed, sleeping 10s then retrying" + logger "apt failed, sleeping 10s then retrying" sleep 10 done - echo "downloading dotnet install" - curl --retry 10 -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh > /dev/null + logger "downloading dotnet install" + curl --retry 10 -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh 2>&1 | logger -s -i -t 'onefuzz-curl-dotnet-install' chmod +x dotnet-install.sh - echo "running dotnet install" - . ./dotnet-install.sh --version "$DOTNET_VERSION" --install-dir "$DOTNET_ROOT" 2>&1 | logger -s -i -t 'onefuzz-dotnet-setup' + logger "running dotnet install" + /bin/bash ./dotnet-install.sh --version "$DOTNET_VERSION" --install-dir "$DOTNET_ROOT" 2>&1 | logger -s -i -t 'onefuzz-dotnet-setup' rm dotnet-install.sh - echo "install dotnet tools" + logger "install dotnet tools" pushd "$DOTNET_ROOT" - ./dotnet tool install dotnet-dump --tool-path /onefuzz/tools - ./dotnet tool install dotnet-coverage --tool-path /onefuzz/tools - ./dotnet tool install dotnet-sos --tool-path /onefuzz/tools + ls -lah 2>&1 | logger -s -i -t 'onefuzz-dotnet-tools' + "$DOTNET_ROOT"/dotnet tool install dotnet-dump --version 6.0.328102 --tool-path /onefuzz/tools 2>&1 | logger -s -i -t 'onefuzz-dotnet-tools' + "$DOTNET_ROOT"/dotnet tool install dotnet-coverage --version 17.3.6 --tool-path /onefuzz/tools 2>&1 | logger -s -i -t 'onefuzz-dotnet-tools' + "$DOTNET_ROOT"/dotnet tool install dotnet-sos --version 6.0.328102 --tool-path /onefuzz/tools 2>&1 | logger -s -i -t 'onefuzz-dotnet-tools' popd fi diff --git a/src/runtime-tools/win64/onefuzz.ps1 b/src/runtime-tools/win64/onefuzz.ps1 index 3b2d91d46..cdc02dfe5 100644 --- a/src/runtime-tools/win64/onefuzz.ps1 +++ b/src/runtime-tools/win64/onefuzz.ps1 @@ -181,9 +181,9 @@ function Install-Dotnet([string]$Version, [string]$InstallDir, [string]$ToolsDir log "Installing dotnet: done" log "Installing dotnet tools to ${ToolsDir}" Push-Location $InstallDir - ./dotnet.exe tool install dotnet-dump --tool-path $ToolsDir - ./dotnet.exe tool install dotnet-coverage --tool-path $ToolsDir - ./dotnet.exe tool install dotnet-sos --tool-path $ToolsDir + ./dotnet.exe tool install dotnet-dump --version 6.0.328102 --tool-path $ToolsDir + ./dotnet.exe tool install dotnet-coverage --version 17.3.6 --tool-path $ToolsDir + ./dotnet.exe tool install dotnet-sos --version 6.0.328102 --tool-path $ToolsDir Pop-Location log "Installing dotnet tools: done" }