use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, anyhow, bail};
use clap::{Args, Subcommand};
use serde::Deserialize;
use serde_json::{Value, json};
#[derive(Subcommand)]
pub enum TriageCommands {
Promote(PromoteArgs),
}
#[derive(Args)]
pub struct PromoteArgs {
pub bundle: PathBuf,
#[arg(long, value_name = "PATH")]
pub input: Option<PathBuf>,
#[arg(long = "crate", value_name = "NAME")]
pub crate_override: Option<String>,
#[arg(long, value_name = "TARGET")]
pub target: Option<String>,
#[arg(long, value_name = "PATH")]
pub workspace: Option<PathBuf>,
#[arg(long)]
pub write: bool,
}
#[derive(Debug, Deserialize)]
struct BundleMetadata {
#[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
version: u32,
panic: PanicMeta,
input_hash: Option<String>,
#[serde(default)]
stages: Vec<StageDesc>,
}
#[derive(Debug, Deserialize)]
struct PanicMeta {
file: Option<String>,
line: Option<u32>,
col: Option<u32>,
message: String,
#[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
thread: String,
}
#[derive(Debug, Deserialize)]
struct StageDesc {
#[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
seq: usize,
name: String,
#[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
file: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KnownCrate {
Dex,
Hermes,
Apk,
Common,
}
impl KnownCrate {
fn module_prefix(self) -> &'static str {
match self {
KnownCrate::Dex => "droidsaw_dex",
KnownCrate::Hermes => "droidsaw_hermes",
KnownCrate::Apk => "droidsaw_apk",
KnownCrate::Common => "droidsaw_common",
}
}
fn crate_dir(self) -> &'static str {
match self {
KnownCrate::Dex => "droidsaw-dex",
KnownCrate::Hermes => "droidsaw-hermes",
KnownCrate::Apk => "droidsaw-apk",
KnownCrate::Common => "droidsaw-common",
}
}
fn from_crate_override(name: &str) -> Option<Self> {
match name {
"droidsaw-dex" | "droidsaw_dex" | "dex" => Some(KnownCrate::Dex),
"droidsaw-hermes" | "droidsaw_hermes" | "hermes" => Some(KnownCrate::Hermes),
"droidsaw-apk" | "droidsaw_apk" | "apk" => Some(KnownCrate::Apk),
"droidsaw-common" | "droidsaw_common" | "common" => Some(KnownCrate::Common),
_ => None,
}
}
}
fn infer_crate(backtrace: &str) -> Result<KnownCrate, InferError> {
let mut seen: Vec<KnownCrate> = Vec::new();
for line in backtrace.lines() {
for candidate in [
KnownCrate::Dex,
KnownCrate::Hermes,
KnownCrate::Apk,
KnownCrate::Common,
] {
if line.contains(candidate.module_prefix())
&& !seen.contains(&candidate)
{
seen.push(candidate);
}
}
}
let non_common: Vec<KnownCrate> =
seen.iter().copied().filter(|c| *c != KnownCrate::Common).collect();
match non_common.as_slice() {
[] => {
if seen.contains(&KnownCrate::Common) {
Err(InferError::Ambiguous {
reason: "backtrace only contains `droidsaw_common::*` frames \
(panic-hook infra); panic site is in common itself or \
outside the workspace — pass --crate to disambiguate"
.to_string(),
})
} else {
Err(InferError::Unknown {
reason: "no workspace crate frames (dex / hermes / apk / common) \
found in backtrace — pass --crate to override"
.to_string(),
})
}
}
[only] => Ok(*only),
_ => Err(InferError::Ambiguous {
reason: format!(
"multiple workspace crates in backtrace: {:?} — pass --crate to pick one",
non_common.iter().map(|c| c.crate_dir()).collect::<Vec<_>>(),
),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FuzzTarget(String);
impl FuzzTarget {
fn as_str(&self) -> &str {
&self.0
}
}
const TARGET_MAP: &[(KnownCrate, &str, &str)] = &[
(KnownCrate::Dex, "build_ssa", "fuzz_ssa"),
(KnownCrate::Dex, "SsaBody::build", "fuzz_ssa"),
(KnownCrate::Dex, "ssa::Builder", "fuzz_ssa"),
(KnownCrate::Dex, "::braun::", "fuzz_ssa"),
(KnownCrate::Dex, "Cfg::build", "fuzz_cfg"),
(KnownCrate::Dex, "cfg::Cfg::", "fuzz_cfg"),
(KnownCrate::Dex, "decode_insns", "fuzz_opcode_decode"),
(KnownCrate::Dex, "decode::decode", "fuzz_opcode_decode"),
(KnownCrate::Dex, "parse_code_item", "fuzz_opcode_decode"),
(KnownCrate::Dex, "DexFile::parse", "fuzz_parser"),
(KnownCrate::Dex, "parse_class_defs", "fuzz_parser"),
(KnownCrate::Dex, "parse_class_data", "fuzz_parser"),
(KnownCrate::Dex, "parse_strings", "fuzz_parser"),
(KnownCrate::Dex, "parse_type_lists", "fuzz_parser"),
(KnownCrate::Dex, "parser::", "fuzz_parser"),
(KnownCrate::Hermes, "build_ssa", "fuzz_ssa"),
(KnownCrate::Hermes, "ssa::build", "fuzz_ssa"),
(KnownCrate::Hermes, "Cfg::build", "fuzz_cfg"),
(KnownCrate::Hermes, "cfg::Cfg::", "fuzz_cfg"),
(KnownCrate::Hermes, "decode_function", "fuzz_opcode_decode"),
(KnownCrate::Hermes, "decode::decode", "fuzz_opcode_decode"),
(KnownCrate::Hermes, "HbcFile::parse", "fuzz_parser"),
(KnownCrate::Hermes, "parser::", "fuzz_parser"),
(KnownCrate::Apk, "BinaryXml::decode", "fuzz_binary_xml"),
(KnownCrate::Apk, "binary_xml::", "fuzz_binary_xml"),
(KnownCrate::Apk, "ResourceTable", "fuzz_arsc"),
(KnownCrate::Apk, "resources::", "fuzz_arsc"),
(KnownCrate::Apk, "parse_signing_block", "fuzz_signing_block"),
(KnownCrate::Apk, "signing::", "fuzz_signing_block"),
(KnownCrate::Apk, "Apk::parse", "fuzz_apk_parse"),
(KnownCrate::Apk, "apk::", "fuzz_apk_parse"),
];
fn infer_target(backtrace: &str, krate: KnownCrate) -> Result<FuzzTarget, InferError> {
for line in backtrace.lines() {
for (c, needle, target) in TARGET_MAP {
if *c == krate && line.contains(needle) {
return Ok(FuzzTarget((*target).to_string()));
}
}
}
Err(InferError::Unknown {
reason: format!(
"no frame in backtrace matched a known fuzz-target pattern for {} — \
pass --target to override",
krate.crate_dir()
),
})
}
#[derive(Debug)]
enum InferError {
Unknown { reason: String },
Ambiguous { reason: String },
}
impl std::fmt::Display for InferError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InferError::Unknown { reason } | InferError::Ambiguous { reason } => {
f.write_str(reason)
}
}
}
}
impl std::error::Error for InferError {}
fn guess_err_variant(krate: KnownCrate, meta: &BundleMetadata) -> VariantGuess {
let msg = meta.panic.message.to_ascii_lowercase();
let last_stage = meta.stages.last().map(|s| s.name.as_str()).unwrap_or("");
let guesses: Vec<&'static str> = match krate {
KnownCrate::Dex => {
let mut v: Vec<&'static str> = Vec::new();
if msg.contains("overflow") {
v.push("DexError::InvalidInstruction { .. }");
}
if msg.contains("slice index") || msg.contains("out of bounds") {
v.push("DexError::Truncated { .. }");
v.push("DexError::ScrollRead { .. }");
}
if msg.contains("allocation") || msg.contains("capacity") {
v.push("DexError::CountExceedsInput { .. }");
}
if v.is_empty() {
v.push("DexError::Truncated { .. }");
v.push("DexError::InvalidInstruction { .. }");
v.push("DexError::CountExceedsInput { .. }");
v.push("DexError::ScrollRead { .. }");
}
v
}
KnownCrate::Hermes => {
let mut v: Vec<&'static str> = Vec::new();
if msg.contains("unsupported bytecode version") {
v.push("HermesError::UnsupportedVersion { .. }");
}
if msg.contains("catch handler") || msg.contains("exception") {
v.push("HermesError::InvalidExceptionLayout { .. }");
}
if v.is_empty() {
v.push("HermesError::UnsupportedVersion { .. }");
v.push("HermesError::InvalidExceptionLayout { .. }");
}
v
}
KnownCrate::Apk => {
vec!["/* apk errors are message-typed — inspect err.to_string() */"]
}
KnownCrate::Common => vec!["/* common: pick the variant from the source location */"],
};
VariantGuess { candidates: guesses, last_stage: last_stage.to_string() }
}
struct VariantGuess {
candidates: Vec<&'static str>,
last_stage: String,
}
struct Plan {
bundle_dir: PathBuf,
input_hash: String,
input_source: PathBuf,
krate: KnownCrate,
target: FuzzTarget,
crate_root: PathBuf,
fixture_path: PathBuf,
test_fn_name: String,
test_skeleton: String,
review_banner: Vec<String>,
}
fn sanitize_identifier(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
fn build_skeleton(plan_krate: KnownCrate, plan_target: &FuzzTarget, plan: &SkeletonArgs<'_>) -> String {
let include_path = format!(
"fixtures/adversarial/{}/{}.bin",
plan_target.as_str(),
plan.input_hash
);
let driver_body = driver_body_for(plan_krate, plan_target);
let panic_site = match (&plan.panic_file, plan.panic_line, plan.panic_col) {
(Some(f), Some(l), Some(c)) => format!("{f}:{l}:{c}"),
(Some(f), Some(l), None) => format!("{f}:{l}"),
(Some(f), None, None) => f.clone(),
_ => "<unknown>".to_string(),
};
let stages_line = if plan.stage_names.is_empty() {
"<none>".to_string()
} else {
plan.stage_names.join(", ")
};
let candidates_block = plan
.variant_candidates
.iter()
.map(|c| format!("// {c}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"\
// Origin: diag bundle {bundle_rel}
// input_hash: {hash}
// panic_site: {site}
// panic_message: {msg}
// stages recorded (oldest→newest): {stages}
// last stage before panic: {last_stage}
//
// TODO(triage-promote): human review required. Tighten the match arm
// below to the specific typed `Err` variant. Suggested candidates:
{candidates}
#[test]
fn {test_fn}() {{
let data = include_bytes!(\"{include_path}\");
{driver}
}}
",
bundle_rel = plan.bundle_rel,
hash = plan.input_hash,
site = panic_site,
msg = plan.panic_message,
stages = stages_line,
last_stage = if plan.last_stage.is_empty() {
"<none>"
} else {
&plan.last_stage
},
candidates = candidates_block,
test_fn = plan.test_fn_name,
include_path = include_path,
driver = driver_body,
)
}
struct SkeletonArgs<'a> {
bundle_rel: String,
input_hash: String,
panic_file: Option<String>,
panic_line: Option<u32>,
panic_col: Option<u32>,
panic_message: String,
stage_names: Vec<String>,
last_stage: String,
variant_candidates: &'a [&'static str],
test_fn_name: String,
}
fn driver_body_for(krate: KnownCrate, target: &FuzzTarget) -> String {
let indent = " ";
match (krate, target.as_str()) {
(KnownCrate::Dex, "fuzz_parser") => format!(
"{indent}let err = droidsaw_dex::DexFile::parse(data, None)\n\
{indent} .expect_err(\"must surface typed Err, no panic\");\n\
{indent}let _ = err; // TODO: match specific DexError variant"
),
(KnownCrate::Dex, "fuzz_cfg") => format!(
"{indent}// Reuse `drive_cfg` helper already defined in tests/adversarial.rs.\n\
{indent}let err = drive_cfg(data).expect_err(\"must surface typed Err, no panic\");\n\
{indent}let _ = err; // TODO: match specific DexError variant"
),
(KnownCrate::Dex, "fuzz_ssa") => format!(
"{indent}// Reuse `drive_ssa` helper already defined in tests/adversarial.rs.\n\
{indent}let err = drive_ssa(data).expect_err(\"must surface typed Err, no panic\");\n\
{indent}let _ = err; // TODO: match specific DexError variant"
),
(KnownCrate::Dex, "fuzz_opcode_decode") => format!(
"{indent}// decode_insns takes a raw instruction stream (u16 units).\n\
{indent}let insns_size = (data.len() / 2) as u32;\n\
{indent}let result = droidsaw_dex::decode::decode_insns(data, 0, insns_size);\n\
{indent}// TODO: tighten to the specific DexError variant and drop _result\n\
{indent}let _ = result;"
),
(KnownCrate::Hermes, "fuzz_parser") => format!(
"{indent}let err = droidsaw_hermes::parser::HbcFile::parse(data, None)\n\
{indent} .expect_err(\"must surface typed Err, no panic\");\n\
{indent}let _ = err; // TODO: match specific HermesError variant"
),
(KnownCrate::Hermes, "fuzz_cfg") => format!(
"{indent}// Reuse `first_cfg_build_err` helper already defined in tests/adversarial.rs.\n\
{indent}let err = first_cfg_build_err(data)\n\
{indent} .expect(\"must surface typed Err, no panic\");\n\
{indent}let _ = err; // TODO: match specific HermesError variant"
),
(KnownCrate::Hermes, "fuzz_ssa") => format!(
"{indent}// TODO: add / reuse a `first_build_ssa_err` helper — mirrors\n\
{indent}// `first_cfg_build_err` but calls `build_ssa` after Cfg::build.\n\
{indent}let _ = data;"
),
(KnownCrate::Apk, "fuzz_binary_xml") => format!(
"{indent}let err = droidsaw_apk::binary_xml::BinaryXml::decode(data)\n\
{indent} .expect_err(\"must surface typed Err, no panic\");\n\
{indent}// TODO: tighten via err.to_string() assertions\n\
{indent}let _ = err;"
),
(KnownCrate::Apk, "fuzz_arsc") => format!(
"{indent}// TODO: drive the ARSC parser. The canonical entry point is\n\
{indent}// droidsaw_apk::resources::ResourceTable — confirm the exact\n\
{indent}// function name in the current crate.\n\
{indent}let _ = data;"
),
(KnownCrate::Apk, "fuzz_apk_parse") => format!(
"{indent}// TODO: drive Apk::parse — may require a ZIP-containered fixture.\n\
{indent}let _ = data;"
),
(KnownCrate::Apk, "fuzz_signing_block") => format!(
"{indent}// TODO: drive the signing block parser.\n\
{indent}let _ = data;"
),
_ => format!(
"{indent}// TODO(triage-promote): no driver template for ({k:?}, \"{t}\").\n\
{indent}// Add one in commands/triage.rs driver_body_for().\n\
{indent}let _ = data;",
k = krate,
t = target.as_str(),
),
}
}
fn plan_promotion(args: &PromoteArgs) -> anyhow::Result<Plan> {
let bundle_dir = args
.bundle
.canonicalize()
.with_context(|| format!("bundle dir does not exist: {}", args.bundle.display()))?;
if !bundle_dir.is_dir() {
bail!("bundle path is not a directory: {}", bundle_dir.display());
}
let metadata_path = bundle_dir.join("metadata.json");
let metadata_bytes = fs::read(&metadata_path)
.with_context(|| format!("missing metadata.json at {}", metadata_path.display()))?;
let metadata: BundleMetadata = serde_json::from_slice(&metadata_bytes)
.with_context(|| format!("metadata.json is not a valid BundleMetadata: {}", metadata_path.display()))?;
let backtrace_path = bundle_dir.join("backtrace.txt");
let backtrace = fs::read_to_string(&backtrace_path)
.with_context(|| format!("missing backtrace.txt at {}", backtrace_path.display()))?;
let krate = match args.crate_override.as_deref() {
Some(name) => KnownCrate::from_crate_override(name)
.ok_or_else(|| anyhow!("--crate must be one of: droidsaw-dex / droidsaw-hermes / droidsaw-apk / droidsaw-common; got `{name}`"))?,
None => infer_crate(&backtrace).map_err(|e| {
anyhow!(
"crate inference failed: {e}\n(pass --crate to override; see `--help`)"
)
})?,
};
let target = match args.target.as_deref() {
Some(t) => FuzzTarget(t.to_string()),
None => infer_target(&backtrace, krate).map_err(|e| {
anyhow!(
"fuzz-target inference failed: {e}\n(pass --target to override)"
)
})?,
};
let workspace = match args.workspace.clone() {
Some(w) => w.canonicalize().with_context(|| format!("workspace: {}", w.display()))?,
None => std::env::current_dir().context("cannot resolve current working directory")?,
};
let crate_root = workspace.join(krate.crate_dir());
if !crate_root.is_dir() {
bail!(
"crate directory not found: {} (pass --workspace <path> if running outside the workspace)",
crate_root.display()
);
}
let input_hash = metadata
.input_hash
.clone()
.ok_or_else(|| anyhow!("metadata.json has no input_hash — bundle pre-dates diag-wire or panic lost it; pass --crate and promote manually"))?;
let input_source = match args.input.clone() {
Some(p) => p,
None => {
let sibling = bundle_dir.join("input.bin");
if sibling.is_file() {
sibling
} else {
bail!(
"no --input path given and bundle has no `input.bin` — pass \
--input <path> to the original bytes that triggered the panic"
);
}
}
};
if !input_source.is_file() {
bail!("--input path is not a readable file: {}", input_source.display());
}
let fixture_dir = crate_root
.join("tests")
.join("fixtures")
.join("adversarial")
.join(target.as_str());
let fixture_path = fixture_dir.join(format!("{input_hash}.bin"));
let stage_names: Vec<String> = metadata.stages.iter().map(|s| s.name.clone()).collect();
let variant_guess = guess_err_variant(krate, &metadata);
let test_fn_name = format!(
"{target}_{hash}_triage",
target = target.as_str(),
hash = sanitize_identifier(&input_hash),
);
let bundle_rel = bundle_dir
.strip_prefix(&workspace)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| bundle_dir.to_string_lossy().into_owned());
let skeleton_args = SkeletonArgs {
bundle_rel,
input_hash: input_hash.clone(),
panic_file: metadata.panic.file.clone(),
panic_line: metadata.panic.line,
panic_col: metadata.panic.col,
panic_message: metadata.panic.message.clone(),
stage_names,
last_stage: variant_guess.last_stage.clone(),
variant_candidates: &variant_guess.candidates,
test_fn_name: test_fn_name.clone(),
};
let test_skeleton = build_skeleton(krate, &target, &skeleton_args);
let review_banner = vec![
"═══════════════════════════════════════════════════════════════".to_string(),
" REVIEW BEFORE COMMITTING".to_string(),
" Triage-promote is a scaffolding tool; crate / target / Err".to_string(),
" variant guesses are heuristics. Tighten the match arm in the".to_string(),
" emitted stub before merging.".to_string(),
"═══════════════════════════════════════════════════════════════".to_string(),
];
Ok(Plan {
bundle_dir,
input_hash,
input_source,
krate,
target,
crate_root,
fixture_path,
test_fn_name,
test_skeleton,
review_banner,
})
}
pub fn promote(args: PromoteArgs) -> anyhow::Result<Value> {
let plan = plan_promotion(&args)?;
execute_plan(&plan, args.write)
}
fn execute_plan(plan: &Plan, write: bool) -> anyhow::Result<Value> {
let input_bytes = fs::read(&plan.input_source)
.with_context(|| format!("reading input file: {}", plan.input_source.display()))?;
let mut written: Vec<String> = Vec::new();
if write {
if let Some(parent) = plan.fixture_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating fixture dir: {}", parent.display()))?;
}
fs::write(&plan.fixture_path, &input_bytes).with_context(|| {
format!("writing fixture: {}", plan.fixture_path.display())
})?;
written.push(plan.fixture_path.to_string_lossy().into_owned());
let adversarial_rs = plan.crate_root.join("tests").join("adversarial.rs");
append_skeleton_to_file(&adversarial_rs, &plan.test_skeleton)?;
written.push(adversarial_rs.to_string_lossy().into_owned());
} else {
for line in &plan.review_banner {
eprintln!("droidsaw: {line}");
}
eprintln!(
"droidsaw: dry-run (pass --write to execute)\n\
droidsaw: fixture target: {}\n\
droidsaw: would append #[test] {}() to: {}/tests/adversarial.rs",
plan.fixture_path.display(),
plan.test_fn_name,
plan.crate_root.display(),
);
}
Ok(json!({
"bundle": plan.bundle_dir.display().to_string(),
"input_hash": plan.input_hash,
"input_source": plan.input_source.display().to_string(),
"input_bytes": input_bytes.len(),
"crate": plan.krate.crate_dir(),
"target": plan.target.as_str(),
"fixture_path": plan.fixture_path.display().to_string(),
"test_fn_name": plan.test_fn_name,
"test_skeleton": plan.test_skeleton,
"written_files": written,
"wrote": write,
"_meta": {
"hint": if write {
"test stub appended — review the match arm and `cargo test -p <crate>` before committing"
} else {
"dry-run — re-run with --write to copy the fixture and append the test stub"
},
"review_banner": plan.review_banner,
"related": ["audit", "info", "decompile"],
}
}))
}
fn append_skeleton_to_file(path: &Path, skeleton: &str) -> anyhow::Result<()> {
let existing = match fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(anyhow!(e).context(format!("reading {}", path.display())));
}
};
let preamble = "\
//! Adversarial fixtures promoted from panic-hook crash bundles.
//!
//! Each test here was scaffolded by `droidsaw triage promote` from a
//! `droidsaw_common::diag` bundle. The match arms are heuristic on
//! emission — see the per-test TODO comment for what to tighten.
";
let mut buf = existing.unwrap_or_else(|| preamble.to_string());
if !buf.ends_with('\n') {
buf.push('\n');
}
buf.push('\n');
buf.push_str(skeleton);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating tests dir: {}", parent.display()))?;
}
fs::write(path, buf)
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn bt(frames: &[&str]) -> String {
frames
.iter()
.enumerate()
.map(|(i, f)| format!(" {i}: {f}"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn infer_crate_picks_dex_over_common_infra() {
let backtrace = bt(&[
"droidsaw_common::diag::init::{{closure}}",
"std::panicking::panic_with_hook",
"droidsaw_dex::ssa::Builder::build_ssa",
"droidsaw_dex::DexFile::parse",
]);
assert_eq!(infer_crate(&backtrace).unwrap(), KnownCrate::Dex);
}
#[test]
fn infer_crate_reports_ambiguity_when_multiple_crates_appear() {
let backtrace = bt(&[
"droidsaw_dex::ssa::Builder::build_ssa",
"droidsaw_hermes::parser::HbcFile::parse",
]);
let err = infer_crate(&backtrace).unwrap_err();
assert!(matches!(err, InferError::Ambiguous { .. }));
}
#[test]
fn infer_crate_rejects_bundles_with_only_diag_frames() {
let backtrace = bt(&[
"droidsaw_common::diag::init::{{closure}}",
"std::panicking::panic_with_hook",
"core::panicking::panic_fmt",
"some_user_binary::main",
]);
let err = infer_crate(&backtrace).unwrap_err();
assert!(matches!(err, InferError::Ambiguous { .. }));
}
#[test]
fn infer_crate_unknown_when_no_workspace_frames() {
let backtrace = bt(&[
"std::panicking::panic_with_hook",
"core::panicking::panic_fmt",
"main",
]);
let err = infer_crate(&backtrace).unwrap_err();
assert!(matches!(err, InferError::Unknown { .. }));
}
#[test]
fn infer_target_ssa_beats_parser() {
let backtrace = bt(&[
"droidsaw_dex::ssa::Builder::build_ssa",
"droidsaw_dex::cfg::Cfg::build",
"droidsaw_dex::parser::parse_class_defs",
]);
assert_eq!(
infer_target(&backtrace, KnownCrate::Dex).unwrap().as_str(),
"fuzz_ssa"
);
}
#[test]
fn infer_target_maps_hermes_cfg() {
let backtrace = bt(&[
"droidsaw_hermes::decompile::cfg::Cfg::build",
"droidsaw_hermes::parser::HbcFile::parse",
]);
assert_eq!(
infer_target(&backtrace, KnownCrate::Hermes).unwrap().as_str(),
"fuzz_cfg"
);
}
#[test]
fn infer_target_apk_binary_xml() {
let backtrace = bt(&["droidsaw_apk::binary_xml::BinaryXml::decode"]);
assert_eq!(
infer_target(&backtrace, KnownCrate::Apk).unwrap().as_str(),
"fuzz_binary_xml"
);
}
#[test]
fn sanitize_identifier_replaces_non_ident_chars() {
assert_eq!(sanitize_identifier("ab12"), "ab12");
assert_eq!(sanitize_identifier("ab-12"), "ab_12");
assert_eq!(sanitize_identifier("12ab"), "12ab");
}
}