feat: scaffold v0.1 foundation

This commit is contained in:
hermes 2026-07-28 19:26:47 +00:00
commit 170ca32f58
34 changed files with 6175 additions and 5 deletions

249
src/error.rs Normal file
View file

@ -0,0 +1,249 @@
//! Typed failures and coarse process status allocation.
use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
/// Stable machine-readable error taxonomy available in the foundation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
/// Command-line syntax or validation failed.
InvalidArguments,
/// The caller's Sample path does not exist.
SampleNotFound,
/// The caller's Sample cannot be read.
SampleUnreadable,
/// The opened Sample is not a regular file.
InvalidSampleType,
/// The Sample exceeded the selected byte ceiling.
SampleTooLarge,
/// Source metadata changed while snapshotting.
SampleChanged,
/// The Analysis Store lacks the required free-space reserve.
InsufficientStoreSpace,
/// Ghidra could not recognize a Target Specification.
UnsupportedTarget,
/// Ghidra found multiple Target Specification candidates.
AmbiguousTarget,
/// Ghidra is not installed.
GhidraMissing,
/// The installed Ghidra version is incompatible.
GhidraIncompatible,
/// Java is not installed.
JavaMissing,
/// The installed Java version is incompatible.
JavaIncompatible,
/// A later integration layer has not connected execution yet.
InternalNotImplemented,
/// Rust/Java request or response validation failed.
ProtocolViolation,
/// The child exceeded a native or watchdog timeout.
Timeout,
/// Native auto-analysis timed out.
AnalysisTimeout,
/// Ghidra auto-analysis failed.
AnalysisFailed,
/// Native Function decompilation timed out.
DecompileTimeout,
/// Ghidra Function decompilation failed.
DecompilationFailed,
/// The caller interrupted the operation.
Interrupted,
/// The adapter could not serialize a bounded response.
ResultTooLarge,
/// The success descriptor cannot fit the requested inline budget.
InlineBudgetTooSmall,
/// Store-wide cleanup lacks explicit confirmation.
ConfirmationRequired,
/// Another process holds the selected Analysis lock.
AnalysisBusy,
/// Stored Analysis validation failed and recovery did not succeed.
CorruptAnalysis,
/// A cleanup transaction requires a recoverable retry.
CleanupIncomplete,
/// The resolved Analysis Store path is invalid.
InvalidStorePath,
/// A Function Selector resolved to no Function.
FunctionNotFound,
/// A Function Selector resolved to multiple Functions.
FunctionSelectorAmbiguous,
/// An Address is not a Function entry.
FunctionEntryRequired,
/// Ghidra does not contain the requested address space.
AddressSpaceNotFound,
/// Installation diagnosis found an unusable component.
DoctorFailed,
/// A general runtime or integration failure.
Internal,
}
impl ErrorCode {
/// Authoritative coarse status mapping from ADR 0002.
#[must_use]
pub const fn exit_status(self) -> ExitStatus {
match self {
Self::InvalidArguments => ExitStatus::InvalidInvocation,
Self::Timeout | Self::AnalysisTimeout | Self::DecompileTimeout => ExitStatus::Timeout,
Self::Interrupted => ExitStatus::Interrupted,
_ => ExitStatus::RuntimeFailure,
}
}
}
/// Stable public error body.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ErrorBody {
/// Stable machine identifier.
pub code: ErrorCode,
/// Concise context that is not a control-flow interface.
pub message: String,
/// Whether the same logical request might later succeed unchanged.
pub retryable: bool,
/// Code-specific structured context, always an object.
pub details: BTreeMap<String, Value>,
}
/// Versioned failure document.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ErrorEnvelope {
/// Public schema major version.
pub schema_version: u32,
/// Always `error`.
pub kind: ErrorKind,
/// Typed failure body.
pub error: ErrorBody,
}
/// Error envelope discriminator.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
/// A failed invocation.
Error,
}
/// Internal error paired with its public document and status class.
#[derive(Clone, Debug, Error)]
#[error("{body_message}")]
pub struct AppError {
body_message: String,
envelope: ErrorEnvelope,
status: ExitStatus,
}
impl AppError {
/// Constructs a typed failure with empty structured details.
#[must_use]
pub fn new(code: ErrorCode, message: impl Into<String>, retryable: bool) -> Self {
let message = message.into();
let status = code.exit_status();
Self {
body_message: message.clone(),
envelope: ErrorEnvelope {
schema_version: 1,
kind: ErrorKind::Error,
error: ErrorBody {
code,
message,
retryable,
details: BTreeMap::new(),
},
},
status,
}
}
/// Clear failure used until the store/process/Java execution layer is attached.
#[must_use]
pub fn execution_not_implemented(operation: &str) -> Self {
let mut error = Self::new(
ErrorCode::InternalNotImplemented,
format!("operation '{operation}' is not connected to the Ghidra execution layer"),
false,
);
error
.envelope
.error
.details
.insert("operation".to_owned(), Value::String(operation.to_owned()));
error
}
/// Constructs an invalid-invocation failure from clap context.
#[must_use]
pub fn invalid_arguments(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidArguments, message, false)
}
/// Adds one typed detail field.
#[must_use]
pub fn with_detail(mut self, key: impl Into<String>, value: Value) -> Self {
self.envelope.error.details.insert(key.into(), value);
self
}
/// Returns the public error document.
#[must_use]
pub const fn envelope(&self) -> &ErrorEnvelope {
&self.envelope
}
/// Returns the coarse process status.
#[must_use]
pub const fn status(&self) -> ExitStatus {
self.status
}
}
/// Accepted coarse process statuses from ADR 0002.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(i32)]
pub enum ExitStatus {
/// Successful operation.
Success = 0,
/// Runtime failure.
RuntimeFailure = 1,
/// Invalid invocation or arguments.
InvalidInvocation = 2,
/// Native phase or child watchdog timeout.
Timeout = 124,
/// User interruption.
Interrupted = 130,
}
impl ExitStatus {
/// Integer process exit code.
#[must_use]
pub const fn code(self) -> i32 {
self as i32
}
}
#[cfg(test)]
mod tests {
use super::{ErrorCode, ExitStatus};
#[test]
fn detailed_errors_map_to_only_accepted_coarse_statuses() {
assert_eq!(
ErrorCode::InvalidArguments.exit_status(),
ExitStatus::InvalidInvocation
);
assert_eq!(
ErrorCode::AnalysisTimeout.exit_status(),
ExitStatus::Timeout
);
assert_eq!(
ErrorCode::Interrupted.exit_status(),
ExitStatus::Interrupted
);
assert_eq!(
ErrorCode::ProtocolViolation.exit_status(),
ExitStatus::RuntimeFailure
);
}
}