use std::io;
use std::process::ExitCode;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorClass {
UserInput,
Permission,
Transient,
Configuration,
Internal,
}
impl ErrorClass {
fn as_str(self) -> &'static str {
match self {
ErrorClass::UserInput => "USER_INPUT",
ErrorClass::Permission => "PERMISSION",
ErrorClass::Transient => "TRANSIENT",
ErrorClass::Configuration => "CONFIGURATION",
ErrorClass::Internal => "INTERNAL",
}
}
}
#[derive(Debug)]
pub struct ConfigError(pub String);
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug)]
pub struct UsageError(pub String);
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for UsageError {}
#[derive(Debug)]
pub struct FailOnTriggered {
pub threshold: droidsaw_common::Severity,
pub count: usize,
}
impl std::fmt::Display for FailOnTriggered {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} finding(s) at or above the --fail-on={:?} threshold",
self.count, self.threshold
)
}
}
impl std::error::Error for FailOnTriggered {}
pub fn classify(err: &anyhow::Error) -> ErrorClass {
for cause in err.chain() {
if cause.downcast_ref::<ConfigError>().is_some() {
return ErrorClass::Configuration;
}
if cause.downcast_ref::<UsageError>().is_some() {
return ErrorClass::UserInput;
}
if let Some(io_err) = cause.downcast_ref::<io::Error>() {
return classify_io(io_err);
}
if cause.downcast_ref::<regex::Error>().is_some() {
return ErrorClass::UserInput;
}
if cause.downcast_ref::<serde_json::Error>().is_some() {
return ErrorClass::Internal;
}
if let Some(apk_err) = cause.downcast_ref::<droidsaw_apk::ApkError>() {
return classify_apk(apk_err);
}
if let Some(hermes_err) = cause.downcast_ref::<droidsaw_hermes::error::HermesError>() {
return classify_hermes(hermes_err);
}
}
let msg = format!("{err:#}").to_lowercase();
if msg.contains("--min-severity must be") || msg.contains("--target must be") {
return ErrorClass::Configuration;
}
if msg.contains("no hermes") || msg.contains("no bytecode") {
return ErrorClass::UserInput;
}
if msg.contains("unrecognized input") || msg.contains("requires an apk") {
return ErrorClass::UserInput;
}
if msg.contains("invalid function id") || msg.contains("specify a function id") {
return ErrorClass::UserInput;
}
if msg.contains("rules path does not exist") || msg.contains("is not a directory") {
return ErrorClass::UserInput;
}
if msg.contains("permission denied") {
return ErrorClass::Permission;
}
ErrorClass::Internal
}
fn classify_io(err: &io::Error) -> ErrorClass {
use io::ErrorKind::*;
match err.kind() {
NotFound | InvalidInput | InvalidData | UnexpectedEof => ErrorClass::UserInput,
PermissionDenied => ErrorClass::Permission,
Interrupted | WouldBlock | TimedOut => ErrorClass::Transient,
_ => ErrorClass::Internal,
}
}
fn classify_apk(err: &droidsaw_apk::ApkError) -> ErrorClass {
use droidsaw_apk::ApkError;
match err {
ApkError::Truncated { .. }
| ApkError::BadMagic { .. }
| ApkError::Structural { .. }
| ApkError::QuotaExceeded { .. }
| ApkError::Zip(_) => ErrorClass::UserInput,
ApkError::Io(io_err) => classify_io(io_err),
ApkError::Contract { .. } | ApkError::Yara { .. } => ErrorClass::Internal,
ApkError::YaraRuleSourceRestricted { .. } => ErrorClass::UserInput,
_ => ErrorClass::Internal,
}
}
fn classify_hermes(err: &droidsaw_hermes::error::HermesError) -> ErrorClass {
use droidsaw_hermes::error::HermesError;
match err {
HermesError::HeaderTooSmall { .. }
| HermesError::InvalidMagic { .. }
| HermesError::UnsupportedVersion { .. }
| HermesError::SectionSizeOverflow { .. }
| HermesError::SectionExceedsBounds { .. }
| HermesError::SectionCursorOverflow { .. }
| HermesError::CountExceedsInput { .. }
| HermesError::ArithmeticOverflow { .. } => ErrorClass::UserInput,
HermesError::InvalidExceptionLayout { .. } | HermesError::Ssa(_) => ErrorClass::UserInput,
HermesError::Budget(_) => ErrorClass::UserInput,
_ => ErrorClass::Internal,
}
}
fn hint_for(class: ErrorClass, operation: &str) -> Option<&'static str> {
match (class, operation) {
(ErrorClass::UserInput, "info")
| (ErrorClass::UserInput, "manifest")
| (ErrorClass::UserInput, "signing")
| (ErrorClass::UserInput, "apk-info")
| (ErrorClass::UserInput, "strings")
| (ErrorClass::UserInput, "xrefs")
| (ErrorClass::UserInput, "audit")
| (ErrorClass::UserInput, "decompile")
| (ErrorClass::UserInput, "frida")
| (ErrorClass::UserInput, "trufflehog")
| (ErrorClass::UserInput, "sbom")
| (ErrorClass::UserInput, "semgrep")
| (ErrorClass::UserInput, "export") => {
Some("verify the path points to a readable APK/DEX/HBC file")
}
(ErrorClass::UserInput, "yara") => {
Some("pass --rules <.yar|.yara|dir>; the path must exist and be readable")
}
(ErrorClass::UserInput, "scan-corpus") => {
Some("pass one or more directories containing .apk files, or individual apks")
}
(ErrorClass::Permission, _) => Some("check file permissions on the target path"),
(ErrorClass::Configuration, "scan-corpus") => {
Some("--min-severity must be one of: critical, high, medium, low, info")
}
(ErrorClass::Configuration, "yara") => {
Some("--target must be one of: manifest, dex, resources, native, assets, all")
}
(ErrorClass::Transient, _) => Some("retry after a short delay"),
(ErrorClass::Internal, _) => Some("this is a bug in droidsaw; please file an issue"),
_ => None,
}
}
#[derive(Serialize)]
struct Envelope<'a> {
error: EnvelopeBody<'a>,
}
#[derive(Serialize)]
struct EnvelopeBody<'a> {
code: &'a str,
operation: &'a str,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<&'a str>,
}
pub fn emit(err: &anyhow::Error, operation: &str) -> ExitCode {
let class = classify(err);
let message = format!("{err:#}");
let hint = hint_for(class, operation);
let envelope = Envelope {
error: EnvelopeBody {
code: class.as_str(),
operation,
message,
hint,
},
};
match serde_json::to_string_pretty(&envelope) {
Ok(s) => println!("{s}"),
Err(_) => {
println!(
"{{\"error\":{{\"code\":\"INTERNAL\",\"operation\":\"{operation}\",\"message\":\"failed to serialize error envelope\"}}}}"
);
}
}
ExitCode::from(2)
}