use crate::builder::DalfoxBuilder;
use crate::error::DalfoxError;
use crate::types::{DalfoxFinding, DalfoxJsonEnvelope, DalfoxResult};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DalfoxOutputFormat {
Json,
Jsonl,
}
pub struct DalfoxRunner {
config: DalfoxBuilder,
}
impl DalfoxRunner {
pub(crate) fn new(config: DalfoxBuilder) -> Self {
Self { config }
}
pub fn binary_path(&self) -> &str {
self.config.binary_path.as_deref().unwrap_or("dalfox")
}
pub fn is_available(&self) -> bool {
let output = match std::process::Command::new(self.binary_path())
.arg("-V")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
{
Ok(output) if output.status.success() => output,
_ => return false,
};
let version = version_text_from_output(&output);
matches!(dalfox_major_version(&version), Some(major) if major >= 3)
}
pub async fn version(&self) -> Result<String, DalfoxError> {
let output = Command::new(self.binary_path())
.arg("-V")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| map_spawn_error(e, self.binary_path()))?;
if !output.status.success() {
return Err(DalfoxError::BinaryNotFound {
path: self.binary_path().to_string(),
});
}
let version = version_text_from_output(&output);
match dalfox_major_version(&version) {
Some(major) if major >= 3 => Ok(version),
Some(major) => Err(DalfoxError::BinaryNotFound {
path: format!(
"{} (dalfox major version {major} < 3; install with `cargo install dalfox --locked`)",
self.binary_path()
),
}),
None => Err(DalfoxError::BinaryNotFound {
path: format!(
"{} (could not parse dalfox version from `-V` output: {version})",
self.binary_path()
),
}),
}
}
fn map_spawn_result(
&self,
result: Result<tokio::process::Child, std::io::Error>,
) -> Result<tokio::process::Child, DalfoxError> {
result.map_err(|e| map_spawn_error(e, self.binary_path()))
}
fn append_common_args(&self, args: &mut Vec<String>, format: DalfoxOutputFormat) {
args.push("--format".into());
args.push(
match format {
DalfoxOutputFormat::Json => "json",
DalfoxOutputFormat::Jsonl => "jsonl",
}
.into(),
);
if let Some(cookie) = &self.config.cookie {
args.push("--cookies".into());
args.push(cookie.clone());
}
if let Some(raw_file) = &self.config.cookie_from_raw {
args.push("--cookie-from-raw".into());
args.push(raw_file.clone());
}
if let Some(ua) = &self.config.user_agent {
args.push("--user-agent".into());
args.push(ua.clone());
}
if let Some(proxy) = &self.config.proxy {
args.push("--proxy".into());
args.push(proxy.clone());
}
if let Some(timeout) = self.config.request_timeout_secs {
args.push("--timeout".into());
args.push(timeout.to_string());
}
if let Some(delay) = self.config.delay_ms {
args.push("--delay".into());
args.push(delay.to_string());
}
if let Some(workers) = self.config.workers {
args.push("--workers".into());
args.push(workers.to_string());
}
if let Some(rate_limit) = self.config.rate_limit {
args.push("--rate-limit".into());
args.push(rate_limit.to_string());
}
if let Some(scan_timeout) = self.config.scan_timeout_secs {
args.push("--scan-timeout".into());
args.push(scan_timeout.to_string());
}
if let Some(retries) = self.config.retries {
args.push("--retries".into());
args.push(retries.to_string());
}
if let Some(insecure) = self.config.insecure {
if insecure {
args.push("--insecure".into());
} else {
args.push("--insecure=false".into());
}
}
if self.config.skip_mining_all {
args.push("--skip-mining".into());
}
if self.config.skip_mining_dom {
args.push("--skip-mining-dom".into());
}
if self.config.skip_mining_dict {
args.push("--skip-mining-dict".into());
}
if self.config.only_discovery {
args.push("--only-discovery".into());
}
if self.config.only_custom_payload {
args.push("--only-custom-payload".into());
}
if self.config.follow_redirects {
args.push("--follow-redirects".into());
}
if self.config.waf_evasion {
args.push("--waf-evasion".into());
}
if self.config.debug_mode {
args.push("--debug".into());
}
if self.config.silence {
args.push("--silence".into());
}
for p in &self.config.params {
args.push("--param".into());
args.push(p.clone());
}
if let Some(dict) = &self.config.mining_dict {
args.push("--mining-dict-word".into());
args.push(dict.clone());
}
if let Some(m) = &self.config.method {
args.push("--method".into());
args.push(m.clone());
}
if let Some(d) = &self.config.data {
args.push("--data".into());
args.push(d.clone());
}
if let Some(codes) = &self.config.ignore_return_codes {
args.push("--ignore-return".into());
args.push(codes.clone());
}
if let Some(url) = &self.config.blind_callback {
args.push("--blind".into());
args.push(url.clone());
}
if let Some(rp) = &self.config.remote_payloads {
args.push("--remote-payloads".into());
args.push(rp.clone());
}
if let Some(rw) = &self.config.remote_wordlists {
args.push("--remote-wordlists".into());
args.push(rw.clone());
}
if let Some(val) = &self.config.custom_alert_value {
args.push("--custom-alert-value".into());
args.push(val.clone());
}
if let Some(poc_filter) = &self.config.only_poc {
args.push("--only-poc".into());
args.push(poc_filter.clone());
}
if let Some(pt) = &self.config.poc_type {
args.push("--poc-type".into());
args.push(pt.clone());
}
if let Some(out) = &self.config.output_file {
args.push("--output".into());
args.push(out.clone());
}
for header in &self.config.custom_headers {
args.push("--headers".into());
args.push(header.clone());
}
for payload in &self.config.payloads {
args.push("--custom-payload".into());
args.push(payload.clone());
}
}
fn build_scan_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["scan".to_string()];
self.append_common_args(&mut args, format);
args
}
fn build_file_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["file".to_string()];
self.append_common_args(&mut args, format);
args
}
fn build_pipe_args(&self, format: DalfoxOutputFormat) -> Vec<String> {
let mut args = vec!["pipe".to_string()];
self.append_common_args(&mut args, format);
args
}
fn command_from_args(&self, args: &[String]) -> Command {
let mut cmd = Command::new(self.binary_path());
cmd.args(args);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.kill_on_drop(true);
cmd
}
fn build_scan_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_scan_args(format))
}
fn build_file_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_file_args(format))
}
fn build_pipe_command(&self, format: DalfoxOutputFormat) -> Command {
self.command_from_args(&self.build_pipe_args(format))
}
async fn parse_json_document(
&self,
mut child: tokio::process::Child,
) -> Result<DalfoxResult, DalfoxError> {
let start = std::time::Instant::now();
let stdout = child.stdout.take().ok_or_else(|| {
DalfoxError::ExecutionFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"failed to capture stdout from dalfox process",
))
})?;
let stderr_handle = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(stderr) = stderr_handle {
let mut reader = BufReader::new(stderr);
let _ = tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut buf).await;
}
buf
});
let mut stdout_buf = String::new();
let mut reader = BufReader::new(stdout);
reader
.read_to_string(&mut stdout_buf)
.await
.map_err(DalfoxError::ExecutionFailed)?;
let mut findings = Vec::new();
let mut parse_errors = Vec::new();
let mut meta = None;
let mut params = None;
let trimmed = stdout_buf.trim();
if !trimmed.is_empty() {
match serde_json::from_str::<DalfoxJsonEnvelope>(trimmed) {
Ok(envelope) => {
findings = envelope.findings;
meta = envelope.meta;
params = envelope.params;
}
Err(err) => {
tracing::warn!(
output = %trimmed,
error = %err,
"failed to parse dalfox JSON envelope"
);
parse_errors.push(format!("{err}: {trimmed}"));
}
}
}
let status = child.wait().await?;
let stderr_output = stderr_task.await.unwrap_or_default();
let exit_code = status.code();
let envelope_failure =
if findings.is_empty() && !parse_errors.is_empty() && !trimmed.is_empty() {
Some(parse_errors.join("; "))
} else {
None
};
if let Some(err) = check_scan_process_failure(
status.success(),
exit_code,
&stdout_buf,
&stderr_output,
findings.len(),
envelope_failure.as_deref(),
) {
return Err(err);
}
Ok(DalfoxResult {
findings,
parse_errors,
stderr_output,
exit_code,
scan_duration: Some(start.elapsed()),
meta,
params,
})
}
async fn parse_jsonl_stream<F>(
&self,
mut child: tokio::process::Child,
mut on_finding: Option<F>,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let start = std::time::Instant::now();
let stdout = child.stdout.take().ok_or_else(|| {
DalfoxError::ExecutionFailed(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"failed to capture stdout from dalfox process",
))
})?;
let stderr_handle = child.stderr.take();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
if let Some(stderr) = stderr_handle {
let mut reader = BufReader::new(stderr);
let _ = tokio::io::AsyncReadExt::read_to_string(&mut reader, &mut buf).await;
}
buf
});
let mut reader = BufReader::new(stdout).lines();
let mut findings = Vec::new();
let mut parse_errors = Vec::new();
let mut stdout_buf = String::new();
loop {
match reader.next_line().await {
Ok(Some(line)) => {
stdout_buf.push_str(&line);
stdout_buf.push('\n');
match parse_jsonl_line(&line) {
JsonlLineOutcome::Skipped | JsonlLineOutcome::EnvelopeSkipped => {}
JsonlLineOutcome::Finding(finding) => {
if let Some(ref mut cb) = on_finding {
cb(&finding);
}
findings.push(*finding);
}
JsonlLineOutcome::ParseError { message, raw_line } => {
tracing::warn!(
line = %raw_line,
error = %message,
"failed to parse dalfox JSONL finding line"
);
parse_errors.push(format!("{message}: {raw_line}"));
}
}
}
Ok(None) => break,
Err(err) => return Err(DalfoxError::ExecutionFailed(err)),
}
}
let status = child.wait().await?;
let stderr_output = stderr_task.await.unwrap_or_default();
let exit_code = status.code();
if let Some(err) = check_scan_process_failure(
status.success(),
exit_code,
&stdout_buf,
&stderr_output,
findings.len(),
None,
) {
return Err(err);
}
Ok(DalfoxResult {
findings,
parse_errors,
stderr_output,
exit_code,
scan_duration: Some(start.elapsed()),
meta: None,
params: None,
})
}
async fn execute_scan(
&self,
child: tokio::process::Child,
) -> Result<DalfoxResult, DalfoxError> {
if let Some(deadline_secs) = self.config.scan_deadline_secs {
let deadline = std::time::Duration::from_secs(deadline_secs);
match tokio::time::timeout(deadline, self.parse_json_document(child)).await {
Ok(result) => result,
Err(_) => Err(DalfoxError::ScanDeadlineExceeded { deadline_secs }),
}
} else {
self.parse_json_document(child).await
}
}
async fn execute_scan_streaming<F>(
&self,
child: tokio::process::Child,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
if let Some(deadline_secs) = self.config.scan_deadline_secs {
let deadline = std::time::Duration::from_secs(deadline_secs);
match tokio::time::timeout(deadline, self.parse_jsonl_stream(child, Some(on_finding)))
.await
{
Ok(result) => result,
Err(_) => Err(DalfoxError::ScanDeadlineExceeded { deadline_secs }),
}
} else {
self.parse_jsonl_stream(child, Some(on_finding)).await
}
}
pub async fn scan_url(&self, target_url: &str) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Json);
cmd.arg(target_url);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_url_streaming<F>(
&self,
target_url: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Jsonl);
cmd.arg(target_url);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
pub async fn scan_file_raw(&self, file_path: &str) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_file_command(DalfoxOutputFormat::Json);
cmd.arg(file_path);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_file_raw_streaming<F>(
&self,
file_path: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_file_command(DalfoxOutputFormat::Jsonl);
cmd.arg(file_path);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
pub async fn scan_pipe(&self, urls: Vec<String>) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_pipe_command(DalfoxOutputFormat::Json);
cmd.stdin(Stdio::piped());
let mut child = self.map_spawn_result(cmd.spawn())?;
if let Some(mut stdin) = child.stdin.take() {
let stream_data = urls.join("\n") + "\n";
stdin.write_all(stream_data.as_bytes()).await?;
stdin.flush().await?;
}
self.execute_scan(child).await
}
pub async fn scan_pipe_streaming<F>(
&self,
urls: Vec<String>,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_pipe_command(DalfoxOutputFormat::Jsonl);
cmd.stdin(Stdio::piped());
let mut child = self.map_spawn_result(cmd.spawn())?;
if let Some(mut stdin) = child.stdin.take() {
let stream_data = urls.join("\n") + "\n";
stdin.write_all(stream_data.as_bytes()).await?;
stdin.flush().await?;
}
self.execute_scan_streaming(child, on_finding).await
}
pub async fn scan_sxss(
&self,
inject_url: &str,
trigger_url: &str,
) -> Result<DalfoxResult, DalfoxError> {
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Json);
cmd.arg("--sxss")
.arg("--sxss-url")
.arg(trigger_url)
.arg(inject_url);
self.execute_scan(self.map_spawn_result(cmd.spawn())?).await
}
pub async fn scan_sxss_streaming<F>(
&self,
inject_url: &str,
trigger_url: &str,
on_finding: F,
) -> Result<DalfoxResult, DalfoxError>
where
F: FnMut(&DalfoxFinding),
{
let mut cmd = self.build_scan_command(DalfoxOutputFormat::Jsonl);
cmd.arg("--sxss")
.arg("--sxss-url")
.arg(trigger_url)
.arg(inject_url);
self.execute_scan_streaming(self.map_spawn_result(cmd.spawn())?, on_finding)
.await
}
#[doc(hidden)]
pub fn scan_url_argv(&self, target_url: &str) -> Vec<String> {
let mut args = self.build_scan_args(DalfoxOutputFormat::Json);
args.push(target_url.to_string());
args
}
#[doc(hidden)]
pub fn scan_file_argv(&self, file_path: &str) -> Vec<String> {
let mut args = self.build_file_args(DalfoxOutputFormat::Json);
args.push(file_path.to_string());
args
}
#[doc(hidden)]
pub fn scan_pipe_argv(&self) -> Vec<String> {
self.build_pipe_args(DalfoxOutputFormat::Json)
}
#[doc(hidden)]
pub fn scan_sxss_argv(&self, inject_url: &str, trigger_url: &str) -> Vec<String> {
let mut args = self.build_scan_args(DalfoxOutputFormat::Json);
args.push("--sxss".into());
args.push("--sxss-url".into());
args.push(trigger_url.to_string());
args.push(inject_url.to_string());
args
}
#[doc(hidden)]
pub fn scan_url_streaming_argv(&self, target_url: &str) -> Vec<String> {
let mut args = self.build_scan_args(DalfoxOutputFormat::Jsonl);
args.push(target_url.to_string());
args
}
}
const PROCESS_FAILED_STDOUT_FALLBACK_BYTES: usize = 4096;
fn version_text_from_output(output: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
#[doc(hidden)]
pub fn dalfox_major_version(version_str: &str) -> Option<u32> {
for token in version_str.split_whitespace() {
let trimmed = token.trim_start_matches(['v', 'V']);
if let Some((major, _)) = trimmed.split_once('.') {
if let Ok(parsed) = major.parse::<u32>() {
return Some(parsed);
}
}
if let Ok(parsed) = trimmed.parse::<u32>() {
return Some(parsed);
}
}
None
}
#[doc(hidden)]
pub fn detect_structured_error_json(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Some(diag) = structured_error_from_json_text(trimmed) {
return Some(diag);
}
if trimmed.contains('\n') {
for candidate in trimmed.lines().map(str::trim) {
if candidate.starts_with('{') {
if let Some(diag) = structured_error_from_json_text(candidate) {
return Some(diag);
}
}
}
}
None
}
fn structured_error_from_json_text(text: &str) -> Option<String> {
let value = serde_json::from_str::<serde_json::Value>(text).ok()?;
if value.get("error").and_then(serde_json::Value::as_bool) != Some(true) {
return None;
}
let code = value
.get("code")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let diag = match (code.is_empty(), message.is_empty()) {
(false, false) => format!("[{code}] {message}"),
(false, true) => code.to_string(),
(true, false) => message.to_string(),
(true, true) => text.to_string(),
};
Some(diag)
}
#[doc(hidden)]
pub fn check_scan_process_failure(
success: bool,
exit_code: Option<i32>,
stdout: &str,
stderr: &str,
findings_count: usize,
envelope_parse_failure: Option<&str>,
) -> Option<DalfoxError> {
if let Some(diag) =
detect_structured_error_json(stdout).or_else(|| detect_structured_error_json(stderr))
{
return Some(DalfoxError::ProcessFailed {
status: exit_code.unwrap_or(-1),
stderr: diag,
});
}
if let Some(diag) = envelope_parse_failure.filter(|_| findings_count == 0) {
return Some(DalfoxError::ProcessFailed {
status: exit_code.unwrap_or(-1),
stderr: diag.to_string(),
});
}
if !success {
let mut diag = process_failed_diagnostics(stderr, stdout);
if findings_count > 0 {
diag = format!("parsed {findings_count} finding(s) before non-zero exit: {diag}");
}
return Some(DalfoxError::ProcessFailed {
status: exit_code.unwrap_or(-1),
stderr: diag,
});
}
None
}
fn process_failed_diagnostics(stderr: &str, stdout: &str) -> String {
if !stderr.trim().is_empty() {
stderr.to_string()
} else {
truncate_utf8_prefix(stdout, PROCESS_FAILED_STDOUT_FALLBACK_BYTES)
}
}
fn truncate_utf8_prefix(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
}
fn is_json_envelope_line(trimmed: &str) -> bool {
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return false;
};
let Some(obj) = value.as_object() else {
return false;
};
if obj.get("findings").is_some_and(|v| v.is_array()) {
return true;
}
if obj.contains_key("type") && obj.contains_key("param") {
return false;
}
obj.contains_key("meta") || obj.contains_key("params")
}
#[doc(hidden)]
#[derive(Debug, Clone)]
pub enum JsonlLineOutcome {
Skipped,
EnvelopeSkipped,
Finding(Box<DalfoxFinding>),
ParseError {
message: String,
raw_line: String,
},
}
#[doc(hidden)]
pub fn parse_jsonl_line(line: &str) -> JsonlLineOutcome {
let trimmed = line.trim();
if trimmed.is_empty() {
return JsonlLineOutcome::Skipped;
}
if !trimmed.starts_with('{') {
return JsonlLineOutcome::Skipped;
}
match serde_json::from_str::<DalfoxFinding>(trimmed) {
Ok(finding) => JsonlLineOutcome::Finding(Box::new(finding)),
Err(err) => {
if is_json_envelope_line(trimmed) {
JsonlLineOutcome::EnvelopeSkipped
} else {
JsonlLineOutcome::ParseError {
message: err.to_string(),
raw_line: trimmed.to_string(),
}
}
}
}
}
fn map_spawn_error(err: std::io::Error, path: &str) -> DalfoxError {
if err.kind() == std::io::ErrorKind::NotFound {
DalfoxError::BinaryNotFound {
path: path.to_string(),
}
} else {
DalfoxError::ExecutionFailed(err)
}
}
impl DalfoxResult {
pub fn findings_by_severity(&self, severity: &crate::types::Severity) -> Vec<&DalfoxFinding> {
self.findings
.iter()
.filter(|f| &f.severity == severity)
.collect()
}
pub fn findings_by_type(&self, event_type: &crate::types::EventType) -> Vec<&DalfoxFinding> {
self.findings
.iter()
.filter(|f| &f.event_type == event_type)
.collect()
}
pub fn verified_findings(&self) -> Vec<&DalfoxFinding> {
self.findings_by_type(&crate::types::EventType::Verified)
}
pub fn high_severity_findings(&self) -> Vec<&DalfoxFinding> {
self.findings_by_severity(&crate::types::Severity::High)
}
pub fn has_verified_findings(&self) -> bool {
self.findings
.iter()
.any(|f| f.event_type == crate::types::EventType::Verified)
}
pub fn has_parse_errors(&self) -> bool {
!self.parse_errors.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Dalfox;
#[test]
fn builder_creates_runner() {
let runner = Dalfox::builder()
.request_timeout(30)
.scan_deadline(300)
.workers(10)
.cookie("session=abc")
.waf_evasion(true)
.build();
let _ = runner;
}
#[test]
fn builder_with_custom_binary() {
let runner = Dalfox::builder()
.binary_path("/usr/local/bin/dalfox")
.build();
assert_eq!(runner.binary_path(), "/usr/local/bin/dalfox");
}
#[test]
fn builder_with_all_new_flags() {
let runner = Dalfox::builder()
.cookie_from_raw("/path/to/burp.txt")
.only_discovery(true)
.only_custom_payload(true)
.follow_redirects(true)
.debug(true)
.silence(false)
.ignore_return("302,403,404")
.custom_alert_value("document.domain")
.only_poc("v,r,a")
.poc_type("curl")
.output_file("/tmp/dalfox-out.json")
.build();
let _ = runner;
}
#[test]
fn builder_with_headers_and_payloads() {
let payload_file = std::env::temp_dir().join("dalfox_rs_test_payloads.txt");
std::fs::write(&payload_file, "<script>alert(1)</script>\n").expect("write payload file");
let runner = Dalfox::builder()
.header("X-Custom: value")
.header("Authorization: Bearer token")
.payload(payload_file.to_string_lossy())
.build();
let _ = runner;
let _ = std::fs::remove_file(payload_file);
}
#[test]
fn backward_compat_timeout_sets_both() {
let builder = DalfoxBuilder::new().timeout(30);
assert_eq!(builder.request_timeout_secs, Some(30));
assert_eq!(builder.scan_deadline_secs, Some(30));
}
#[test]
fn binary_path_defaults_to_dalfox() {
let runner = Dalfox::builder().build();
assert_eq!(runner.binary_path(), "dalfox");
}
#[test]
fn result_filtering_helpers() {
let result = DalfoxResult::default();
assert!(result.verified_findings().is_empty());
assert!(result.high_severity_findings().is_empty());
assert!(!result.has_verified_findings());
assert!(!result.has_parse_errors());
}
#[test]
fn param_splits_comma_list_into_repeated_flags() {
let runner = Dalfox::builder().param("id,q,lang").build();
let args = runner.scan_url_argv("http://example.com/");
let param_positions: Vec<_> = args
.iter()
.enumerate()
.filter(|(_, a)| *a == "--param")
.map(|(i, _)| i)
.collect();
assert_eq!(param_positions.len(), 3);
assert_eq!(args[param_positions[0] + 1], "id");
assert_eq!(args[param_positions[1] + 1], "q");
assert_eq!(args[param_positions[2] + 1], "lang");
}
#[test]
fn is_json_envelope_line_detects_meta_and_findings_keys() {
assert!(super::is_json_envelope_line(
r#"{"meta":{"dalfox_version":"3.1.2"}}"#
));
assert!(super::is_json_envelope_line(r#"{"findings":[]}"#));
assert!(super::is_json_envelope_line(
r#"{"meta":{"dalfox_version":"3.1.2"},"params":[{"name":"q"}]}"#
));
assert!(!super::is_json_envelope_line(
r#"{"type":"V","method":"GET","param":"q","payload":"x","cwe":"CWE-79","severity":"High"}"#
));
}
#[test]
fn is_json_envelope_line_does_not_skip_finding_with_meta_key() {
assert!(!super::is_json_envelope_line(
r#"{"type":"V","method":"GET","param":"q","payload":"x","cwe":"CWE-79","severity":"High","meta":{"note":"not envelope"}}"#
));
}
#[test]
fn is_json_envelope_line_non_array_findings_is_not_envelope() {
assert!(!super::is_json_envelope_line(
r#"{"findings":"not-an-array"}"#
));
}
#[test]
fn is_json_envelope_line_params_without_finding_shape_is_envelope() {
assert!(super::is_json_envelope_line(r#"{"params":[{"name":"q"}]}"#));
}
#[test]
fn process_failed_diagnostics_prefers_stderr() {
let stderr = "dalfox error: connection refused";
let stdout = r#"{"error":true,"message":"boom"}"#;
assert_eq!(
super::process_failed_diagnostics(stderr, stdout),
stderr.to_string()
);
}
#[test]
fn process_failed_diagnostics_falls_back_to_truncated_stdout_when_stderr_empty() {
let stdout = r#"{"error":true,"message":"silenced failure"}"#;
let diag = super::process_failed_diagnostics("", stdout);
assert!(
diag.contains("silenced failure"),
"empty stderr must fall back to stdout diagnostics: {diag}"
);
}
#[test]
fn process_failed_diagnostics_truncates_large_stdout() {
let stdout = "x".repeat(5000);
let diag = super::process_failed_diagnostics("", &stdout);
assert!(diag.ends_with("..."));
assert!(diag.len() < stdout.len());
}
#[test]
fn truncate_utf8_prefix_respects_char_boundary_at_cutoff() {
let prefix = "é".repeat(1500);
let stdout = format!("{prefix}tail");
let cutoff = 2999;
assert!(
!stdout.is_char_boundary(cutoff),
"test setup must land cutoff mid UTF-8 character"
);
assert!(std::str::from_utf8(&stdout.as_bytes()[..cutoff]).is_err());
let diag = super::truncate_utf8_prefix(&stdout, cutoff);
assert!(diag.ends_with("..."));
assert!(std::str::from_utf8(diag.as_bytes()).is_ok());
assert_eq!(diag.len(), cutoff - 1 + 3);
}
#[test]
fn scan_url_puts_subcommand_before_flags_and_target_last() {
let runner = Dalfox::builder().workers(5).cookie("a=b").build();
let args = runner.scan_url_argv("http://example.com/?q=1");
let scan_pos = args
.iter()
.position(|a| a == "scan")
.expect("scan subcommand");
let format_pos = args
.iter()
.position(|a| a == "--format")
.expect("--format flag");
assert!(scan_pos < format_pos);
assert!(args.contains(&"--workers".to_string()));
assert!(args.contains(&"--cookies".to_string()));
assert_eq!(
args.last().map(String::as_str),
Some("http://example.com/?q=1")
);
assert!(!args.iter().any(|a| a == "url"));
}
#[tokio::test]
async fn version_against_live_dalfox_when_available() {
let runner = Dalfox::builder().build();
if !runner.is_available() {
return;
}
let version = runner.version().await.expect("version from live dalfox");
assert!(version.contains("dalfox"));
if let Some(major) = super::dalfox_major_version(&version) {
assert!(major >= 3, "live dalfox must be v3+, got {version}");
}
}
#[test]
fn dalfox_major_version_parses_v3_output() {
assert_eq!(super::dalfox_major_version("dalfox 3.1.2"), Some(3));
assert_eq!(super::dalfox_major_version("v3.2.0"), Some(3));
assert_eq!(super::dalfox_major_version("unknown"), None);
assert_eq!(super::dalfox_major_version("dalfox 2.9.1"), Some(2));
}
#[test]
fn detect_structured_error_json_from_fixture() {
let raw = include_str!("../tests/fixtures/error_structured.json");
let diag = super::detect_structured_error_json(raw).expect("error JSON");
assert!(diag.contains("NO_TARGETS"), "diag: {diag}");
assert!(diag.contains("no targets"), "diag: {diag}");
}
#[test]
fn is_available_rejects_unparseable_version_output() {
let runner = Dalfox::builder().binary_path("/usr/bin/true").build();
assert!(
!runner.is_available(),
"/usr/bin/true succeeds but emits no parseable dalfox version"
);
}
#[tokio::test]
async fn version_rejects_unparseable_version_output() {
let runner = Dalfox::builder().binary_path("/usr/bin/true").build();
let err = runner
.version()
.await
.expect_err("unparseable -V output must not pass version()");
assert!(
matches!(err, DalfoxError::BinaryNotFound { .. }),
"expected BinaryNotFound, got {err:?}"
);
}
#[test]
fn detect_structured_error_json_pretty_printed_fixture() {
let raw = include_str!("../tests/fixtures/error_structured_pretty.json");
let diag = super::detect_structured_error_json(raw).expect("pretty error JSON");
assert!(diag.contains("NO_TARGETS"), "diag: {diag}");
assert!(diag.contains("no targets"), "diag: {diag}");
}
#[test]
fn check_scan_process_failure_pretty_error_on_exit_zero() {
let raw = include_str!("../tests/fixtures/error_structured_pretty.json");
let err = super::check_scan_process_failure(true, Some(0), raw, "", 0, None)
.expect("pretty structured error must fail even on exit 0");
let DalfoxError::ProcessFailed { status, stderr } = err else {
panic!("expected ProcessFailed");
};
assert_eq!(status, 0);
assert!(stderr.contains("NO_TARGETS"));
}
#[test]
fn detect_structured_error_json_accepts_compact_true() {
let diag = super::detect_structured_error_json(r#"{"error":true,"message":"boom"}"#)
.expect("compact error JSON");
assert_eq!(diag, "boom");
}
#[test]
fn check_scan_process_failure_structured_error_on_exit_zero() {
let raw = include_str!("../tests/fixtures/error_structured.json");
let err = super::check_scan_process_failure(true, Some(0), raw, "", 0, None)
.expect("structured error must fail even on exit 0");
let DalfoxError::ProcessFailed { status, stderr } = err else {
panic!("expected ProcessFailed");
};
assert_eq!(status, 0);
assert!(stderr.contains("NO_TARGETS"));
}
#[test]
fn check_scan_process_failure_non_zero_exit_with_findings() {
let err = super::check_scan_process_failure(false, Some(2), "", "boom", 3, None)
.expect("non-zero exit must fail");
let DalfoxError::ProcessFailed { status, stderr } = err else {
panic!("expected ProcessFailed");
};
assert_eq!(status, 2);
assert!(stderr.contains("parsed 3 finding(s)"));
assert!(stderr.contains("boom"));
}
#[test]
fn priority_flags_emit_expected_argv() {
let runner = Dalfox::builder()
.rate_limit(20)
.scan_timeout(120)
.retries(2)
.insecure(false)
.build();
let args = runner.scan_url_argv("http://example.com/");
assert!(args
.windows(2)
.any(|w| w[0] == "--rate-limit" && w[1] == "20"));
assert!(args
.windows(2)
.any(|w| w[0] == "--scan-timeout" && w[1] == "120"));
assert!(args.windows(2).any(|w| w[0] == "--retries" && w[1] == "2"));
assert!(args.contains(&"--insecure=false".to_string()));
}
#[test]
fn scan_pipe_argv_uses_pipe_subcommand_and_json_format() {
let runner = Dalfox::builder().build();
let args = runner.scan_pipe_argv();
assert_eq!(args.first().map(String::as_str), Some("pipe"));
assert!(args
.windows(2)
.any(|w| w[0] == "--format" && w[1] == "json"));
}
#[test]
fn scan_sxss_argv_orders_flags_before_inject_url() {
let runner = Dalfox::builder().build();
let args = runner.scan_sxss_argv("http://inject.example/", "http://trigger.example/");
let sxss_pos = args.iter().position(|a| a == "--sxss").expect("--sxss");
let url_pos = args
.iter()
.position(|a| a == "--sxss-url")
.expect("--sxss-url");
assert!(sxss_pos < url_pos);
assert_eq!(
args.last().map(String::as_str),
Some("http://inject.example/")
);
assert!(args.contains(&"http://trigger.example/".to_string()));
}
#[test]
fn scan_url_streaming_argv_uses_jsonl_format() {
let runner = Dalfox::builder().build();
let args = runner.scan_url_streaming_argv("http://example.com/");
assert!(args
.windows(2)
.any(|w| w[0] == "--format" && w[1] == "jsonl"));
}
}