diff --git a/.github/workflows/vm-native.yml b/.github/workflows/vm-native.yml
new file mode 100644
index 00000000..5dea081f
--- /dev/null
+++ b/.github/workflows/vm-native.yml
@@ -0,0 +1,63 @@
+name: Native VM analysis validation
+
+on:
+ push:
+ branches:
+ - feat/themida-vm-structural-20261015
+ pull_request:
+ paths:
+ - 'crates/angryier-cfg/**'
+ - 'crates/angryier-expr/**'
+ - 'crates/angryier-ir/**'
+ - 'crates/angryier-loader/**'
+ - 'crates/angryier-models/**'
+ - 'crates/angryier-cli/**'
+ - 'crates/angryier-runtime/**'
+ - '.github/workflows/vm-native.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ vm-native:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Toolchain
+ run: rustup show
+ - name: Rust cache
+ uses: Swatinem/rust-cache@v2
+ - name: Check VM crates
+ run: cargo check -p angryier-cfg -p angryier-expr -p angryier-ir -p angryier-loader -p angryier-models -p angryier-runtime -p angryier-cli --all-targets
+ - name: VM CFG tests
+ run: cargo test -p angryier-cfg
+ - name: VM expression tests
+ run: cargo test -p angryier-expr
+ - name: Symbolic/Concolic execution regression suite
+ run: cargo test -p angryier-execution
+ - name: AngryIR effect and trace regression suite
+ run: cargo test -p angryier-ir
+ - name: VM loader tests
+ run: cargo test -p angryier-loader
+ - name: Win32 API and synchronization tests
+ run: cargo test -p angryier-models
+ - name: Native runtime unit and fidelity tests
+ run: cargo test -p angryier-runtime --features "xed script" --lib
+ - name: VM runtime integration tests
+ run: cargo test -p angryier-runtime --features "xed script" --test lua_api
+ - name: Windows PE call-return fidelity regressions
+ run: cargo test -p angryier-runtime --features "xed script" --test pe_driver
+ - name: VM handler SSA materialization integration
+ run: cargo test -p angryier-runtime --features "xed script" --test vm_materialization
+ - name: CLI forensic command tests
+ run: cargo test -p angryier-cli
+ # Existing main has unrelated clippy warnings. Retain diagnostic output
+ # instead of hiding the compile/test result behind that baseline debt.
+ - name: Clippy analysis (baseline warnings reported)
+ run: cargo clippy -p angryier-cfg -p angryier-expr -p angryier-ir -p angryier-loader -p angryier-models -p angryier-runtime --lib
+ - name: Clippy forensic CLI
+ run: cargo clippy -p angryier-cli --bin angryier
diff --git a/crates/angryier-cfg/src/lib.rs b/crates/angryier-cfg/src/lib.rs
index 0555e6e3..01c200ee 100644
--- a/crates/angryier-cfg/src/lib.rs
+++ b/crates/angryier-cfg/src/lib.rs
@@ -18,6 +18,8 @@ use angryier_types::Address;
pub mod themida;
pub mod vpc;
+pub mod vm;
+pub mod xrefs;
/// How control leaves a basic block.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -867,7 +869,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts {
Some(angryier_arch::MemoryBase::InstructionPointer { .. }) => {
insn.address
.checked_add(u64::from(insn.length))
- .and_then(|end| end.checked_add(mem.displacement as u64))
+ .and_then(|end| end.checked_add_signed(mem.displacement))
.map(AbstractVal::Const)
}
Some(angryier_arch::MemoryBase::Register(b)) => {
@@ -876,7 +878,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts {
// &table[idx] — an address, not a target: Unknown.
match (facts.values.get(&b.parent).copied(), mem.index) {
(Some(AbstractVal::Const(c)), None) => {
- c.checked_add(mem.displacement as u64).map(AbstractVal::Const)
+ c.checked_add_signed(mem.displacement).map(AbstractVal::Const)
}
_ => None,
}
@@ -900,7 +902,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts {
Some(angryier_arch::MemoryIndex::Register(idx)) => {
insn.address
.checked_add(u64::from(insn.length))
- .and_then(|end| end.checked_add(mem.displacement as u64))
+ .and_then(|end| end.checked_add_signed(mem.displacement))
.map(|table| AbstractVal::TableEntry {
base: table,
index: idx.parent,
@@ -914,7 +916,7 @@ fn interpret_block(instructions: &[DecodedInstruction]) -> BlockFacts {
Some(angryier_arch::MemoryBase::Register(b)) => {
match (facts.values.get(&b.parent).copied(), mem.index) {
(Some(AbstractVal::Const(c)), Some(angryier_arch::MemoryIndex::Register(idx))) => {
- c.checked_add(mem.displacement as u64).map(|table| AbstractVal::TableEntry {
+ c.checked_add_signed(mem.displacement).map(|table| AbstractVal::TableEntry {
base: table,
index: idx.parent,
scale: mem.scale,
@@ -1058,11 +1060,11 @@ fn resolve_indirect_jump(
};
let table = match mem.base {
Some(angryier_arch::MemoryBase::InstructionPointer { .. }) => {
- insn_end.checked_add(mem.displacement as u64)?
+ insn_end.checked_add_signed(mem.displacement)?
}
Some(angryier_arch::MemoryBase::Register(b)) => {
match facts.values.get(&b.parent) {
- Some(AbstractVal::Const(c)) => c.checked_add(mem.displacement as u64)?,
+ Some(AbstractVal::Const(c)) => c.checked_add_signed(mem.displacement)?,
_ => return None,
}
}
@@ -2384,6 +2386,29 @@ mod tests {
assert_eq!(forest.depth(3), 0);
}
+ #[test]
+ fn negative_rip_relative_jump_table_displacement_resolves() -> Result<(), String> {
+ // The two qword targets sit BEFORE the dispatch, so the RIP-relative
+ // displacement is negative. A u64 cast used to reject this case.
+ let code = [
+ 0x18, 0x10, 0, 0, 0, 0, 0, 0, // 0x1000 -> 0x1018
+ 0x1a, 0x10, 0, 0, 0, 0, 0, 0, // 0x1008 -> 0x101a
+ 0xf0, 0x02, // cmp eax,2 @ 0x1010
+ 0xf1, 0x08, 0xe8, 0xff, 0xff, 0xff, // jmp [rip+eax*8-24], end=0x1018
+ 0x90, 0xc3, // nop; ret @ 0x1018
+ 0x90, 0xc3, // nop; ret @ 0x101a
+ ];
+ let cfg = recover(&TestDecoder, 0x1000, &code, 0x1010, identity_form)
+ .map_err(|error| format!("{error:?}"))?;
+ assert!(cfg.edges.iter().any(|edge| {
+ matches!(edge.kind, EdgeKind::TableDispatch { .. }) && edge.to == Some(0x1018)
+ }));
+ assert!(cfg.edges.iter().any(|edge| {
+ matches!(edge.kind, EdgeKind::TableDispatch { .. }) && edge.to == Some(0x101a)
+ }));
+ Ok(())
+ }
+
#[test]
fn jump_table_resolves_to_entries() -> Result<(), String> {
// cmp eax,2; jmp [rip + eax*8 + disp] where disp points 2 entries
diff --git a/crates/angryier-cfg/src/themida.rs b/crates/angryier-cfg/src/themida.rs
index 617df0e4..a0d81669 100644
--- a/crates/angryier-cfg/src/themida.rs
+++ b/crates/angryier-cfg/src/themida.rs
@@ -214,7 +214,26 @@ impl DispatchWindow<'_> {
}
use angryier_semantics_intel64::forms;
match insn.form_id {
- forms::MOV_R64_IMM64 | forms::MOV_R32_IMM32 | forms::LEA_R64_MEM => Def::Constant,
+ forms::MOV_R64_IMM64 | forms::MOV_R32_IMM32 => Def::Constant,
+ // LEA is a constant only with RIP/absolute addressing. A context-
+ // relative LEA is NOT a constant, even if its displacement is.
+ forms::LEA_R64_MEM => {
+ let mem = insn.operands.iter().find_map(|op| match op.kind {
+ OperandKind::AddressGeneration(mem) | OperandKind::Memory(mem) => Some(mem),
+ _ => None,
+ });
+ match mem.map(|m| (m.base, m.index)) {
+ Some((Some(MemoryBase::InstructionPointer { .. }), None))
+ | Some((None, None)) => Def::Constant,
+ Some((Some(MemoryBase::Register(base)), None)) => Def::Derived(base.parent),
+ Some((None, Some(MemoryIndex::Register(index)))) => {
+ Def::Derived(index.parent)
+ }
+ // Two register inputs cannot be represented by the single-
+ // source provenance relation. Retain uncertainty.
+ _ => Def::ClobberedByUnmodeled,
+ }
+ },
forms::MOV_R64_R64 | forms::MOV_R32_R32 | forms::MOVZX_R64_R32 | forms::MOVSX_R64_R32 => {
match register_source(insn) {
Some(src) => Def::From(src),
@@ -430,6 +449,58 @@ mod tests {
assert_eq!(site.chain_register, Some(RegisterId(0)));
}
+ #[test]
+ fn lea_context_relative_is_not_a_constant() -> Result<(), &'static str> {
+ // lea rbx,[r14+0x28]; jmp [rbx+0x38]
+ let block = [
+ insn(
+ 0x80,
+ forms::LEA_R64_MEM,
+ vec![
+ reg_op(0, 2, AccessKind::Write),
+ mem_op(1, Some(0xe), None, 1, 0x28),
+ ],
+ ),
+ insn(0x83, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]),
+ ];
+ let site = (DispatchWindow { instructions: &block }).classify().ok_or("site unavailable")?;
+ let (_, provenance) = site.base.ok_or("base unavailable")?;
+ assert!(matches!(
+ provenance,
+ BaseProvenance::DerivedFrom { source } if source == RegisterId(0xe)
+ ));
+ Ok(())
+ }
+
+ #[test]
+ fn lea_rip_relative_is_constant_but_two_registers_are_not() -> Result<(), &'static str> {
+ let rip = [
+ insn(
+ 0x90,
+ forms::LEA_R64_MEM,
+ vec![reg_op(0, 2, AccessKind::Write), rip_mem_op(-0x20)],
+ ),
+ insn(0x93, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]),
+ ];
+ let site = (DispatchWindow { instructions: &rip }).classify().ok_or("RIP site unavailable")?;
+ assert!(matches!(site.base.ok_or("RIP base unavailable")?.1, BaseProvenance::Constant));
+
+ let ambiguous = [
+ insn(
+ 0xa0,
+ forms::LEA_R64_MEM,
+ vec![
+ reg_op(0, 2, AccessKind::Write),
+ mem_op(1, Some(0xe), Some(0xd), 4, 0x28),
+ ],
+ ),
+ insn(0xa3, forms::JMP_INDIRECT_MEM64, vec![mem_op(0, Some(2), None, 1, 0x38)]),
+ ];
+ let site = (DispatchWindow { instructions: &ambiguous }).classify().ok_or("ambiguous site unavailable")?;
+ assert!(matches!(site.base.ok_or("ambiguous base unavailable")?.1, BaseProvenance::Clobbered));
+ Ok(())
+ }
+
#[test]
fn derived_base_keeps_source() {
// add rbx, 0x20 ; jmp [rbx+0x38] — context member pointer.
diff --git a/crates/angryier-cfg/src/vm.rs b/crates/angryier-cfg/src/vm.rs
new file mode 100644
index 00000000..337529ca
--- /dev/null
+++ b/crates/angryier-cfg/src/vm.rs
@@ -0,0 +1,458 @@
+//! Opt-in VM-dispatch reconnaissance and context-sensitive transition graphs.
+//!
+//! Ordinary CFGs are keyed only by native PC, which aliases distinct virtual
+//! instructions executed by the same VM handler. This module intentionally
+//! separates three levels of evidence:
+//!
+//! * `inspect_native_cfg`: structural candidates, NOT confirmed VM instructions;
+//! * `VmAnalysisSession`: observed/symbolically derived handler transitions,
+//! keyed by native PC, virtual PC, state context and executable image version;
+//! * `projected_edges`: an explicitly observational virtual CFG projection.
+//!
+//! The caller must identify handler boundaries and supply trustworthy VPC and
+//! context observations. Nothing here guesses a virtual PC from a jump target,
+//! fabricates unobserved edges, executes untrusted code or claims full coverage.
+//! The existing native CFG recovery remains unchanged.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use angryier_arch::RegisterId;
+use angryier_types::Address;
+
+use crate::themida::{BaseProvenance, DispatchKind, DispatchWindow};
+use crate::vpc::{VpcBlock, identify_vpc_candidates};
+use crate::{Cfg, EdgeKind};
+
+/// A register-only VPC hypothesis; scores are heuristic, not probabilities.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct VmVpcCandidate {
+ pub register: RegisterId,
+ pub confidence: u8,
+ pub table_base: Option
,
+}
+
+/// Evidence at one native indirect-dispatch site.
+#[derive(Clone, Debug)]
+pub struct VmDispatchFinding {
+ pub block_start: Address,
+ pub dispatch_pc: Address,
+ pub kind: DispatchKind,
+ pub base: Option<(RegisterId, BaseProvenance)>,
+ /// A candidate context register, not a proven VM_CONTEXT base.
+ pub context_origin: Option,
+ pub vpc_candidates: Vec,
+}
+
+/// A bounded static scan result. Indirect exits without recognized operands
+/// remain unresolved rather than being silently promoted to VM sites.
+#[derive(Clone, Debug, Default)]
+pub struct VmDispatchReport {
+ pub findings: Vec,
+ pub unresolved_indirect_blocks: usize,
+ pub unclassified_indirect_blocks: usize,
+}
+
+/// Inspect only unresolved native indirect-jump exits. This does not mutate
+/// the CFG or allow heuristic VM findings to become confirmed CFG edges.
+pub fn inspect_native_cfg(cfg: &Cfg) -> VmDispatchReport {
+ let blocks: Vec> = cfg
+ .blocks
+ .values()
+ .filter(|block| block.terminator == EdgeKind::IndirectJump)
+ .map(|block| VpcBlock {
+ native_pc: block.start,
+ instructions: &block.instructions,
+ })
+ .collect();
+
+ let mut candidate_map: BTreeMap> = BTreeMap::new();
+ for candidate in identify_vpc_candidates(&blocks) {
+ candidate_map
+ .entry(candidate.native_pc)
+ .or_default()
+ .push(VmVpcCandidate {
+ register: candidate.register,
+ confidence: candidate.confidence,
+ table_base: candidate.table_base,
+ });
+ }
+
+ let mut report = VmDispatchReport {
+ unresolved_indirect_blocks: blocks.len(),
+ ..VmDispatchReport::default()
+ };
+ for block in blocks {
+ let Some(site) = (DispatchWindow {
+ instructions: block.instructions,
+ })
+ .classify() else {
+ report.unclassified_indirect_blocks += 1;
+ continue;
+ };
+
+ let origin = match site.base {
+ Some((_, BaseProvenance::ContextRegister { source, .. }))
+ | Some((_, BaseProvenance::DerivedFrom { source })) => Some(source),
+ _ => None,
+ };
+ report.findings.push(VmDispatchFinding {
+ block_start: block.native_pc,
+ dispatch_pc: site.native_pc,
+ kind: site.kind,
+ base: site.base,
+ context_origin: origin,
+ vpc_candidates: candidate_map.remove(&block.native_pc).unwrap_or_default(),
+ });
+ }
+ report
+}
+
+/// Exact replay/capture identity. The caller hashes the relevant VM state
+/// and code bytes under a pinned interpretation and records their provenance.
+/// The digests are not computed or authenticated by this module.
+pub type VmDigest = [u8; 32];
+
+/// Context-sensitive handler location, disambiguating two virtual instructions
+/// executed at the same native PC, including different decryption/key states.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct VmNodeKey {
+ pub native_pc: Address,
+ pub virtual_pc: Address,
+ pub state_digest: VmDigest,
+ pub code_digest: VmDigest,
+}
+
+/// Where a particular transition came from. Even a symbolic result needs
+/// independent validation before being represented as semantic equivalence.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum VmTransitionSource {
+ ObservedTrace,
+ SymbolicCandidate,
+ ValidatedReplay,
+}
+
+/// One handler-boundary transition. An observation is not a proof that these
+/// are all possible successors; the full state and origin remain significant.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct VmTransition {
+ pub from: VmNodeKey,
+ pub to: VmNodeKey,
+ pub source: VmTransitionSource,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct VmAnalysisLimits {
+ pub max_nodes: usize,
+ pub max_edges: usize,
+}
+
+impl Default for VmAnalysisLimits {
+ fn default() -> Self {
+ Self {
+ max_nodes: 100_000,
+ max_edges: 200_000,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum VmAnalysisError {
+ NodeBudgetExceeded { required: usize, limit: usize },
+ EdgeBudgetExceeded { required: usize, limit: usize },
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct VmEdgeEvidence {
+ pub observations: u64,
+ pub sources: BTreeSet,
+}
+
+/// An aggregated observed edge in a VPC-aware graph.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct VmProjectedEdge {
+ pub from: (Address, Address),
+ pub to: (Address, Address),
+ pub observations: u64,
+}
+
+/// Accumulates explicit VM transition evidence with fail-closed admission.
+/// Budget rejection is atomic: an edge cannot leave orphan nodes behind.
+/// The graph is observational even when no budget was exceeded.
+pub struct VmAnalysisSession {
+ limits: VmAnalysisLimits,
+ nodes: BTreeSet,
+ edges: BTreeMap<(VmNodeKey, VmNodeKey), VmEdgeEvidence>,
+ attempted: u64,
+ rejected: u64,
+}
+
+impl VmAnalysisSession {
+ pub fn new(limits: VmAnalysisLimits) -> Self {
+ Self {
+ limits,
+ nodes: BTreeSet::new(),
+ edges: BTreeMap::new(),
+ attempted: 0,
+ rejected: 0,
+ }
+ }
+
+ /// Record evidence without modifying existing edges on admission failure.
+ pub fn observe(&mut self, transition: VmTransition) -> Result<(), VmAnalysisError> {
+ self.attempted = self.attempted.saturating_add(1);
+ let key = (transition.from, transition.to);
+ if let Some(edge) = self.edges.get_mut(&key) {
+ edge.observations = edge.observations.saturating_add(1);
+ edge.sources.insert(transition.source);
+ return Ok(());
+ }
+
+ let new_nodes = usize::from(!self.nodes.contains(&transition.from))
+ + usize::from(
+ transition.to != transition.from && !self.nodes.contains(&transition.to),
+ );
+ let required_nodes = self.nodes.len().saturating_add(new_nodes);
+ if required_nodes > self.limits.max_nodes {
+ self.rejected = self.rejected.saturating_add(1);
+ return Err(VmAnalysisError::NodeBudgetExceeded {
+ required: required_nodes,
+ limit: self.limits.max_nodes,
+ });
+ }
+ let required_edges = self.edges.len().saturating_add(1);
+ if required_edges > self.limits.max_edges {
+ self.rejected = self.rejected.saturating_add(1);
+ return Err(VmAnalysisError::EdgeBudgetExceeded {
+ required: required_edges,
+ limit: self.limits.max_edges,
+ });
+ }
+
+ self.nodes.insert(transition.from);
+ self.nodes.insert(transition.to);
+ self.edges.insert(
+ key,
+ VmEdgeEvidence {
+ observations: 1,
+ sources: BTreeSet::from([transition.source]),
+ },
+ );
+ Ok(())
+ }
+
+ pub fn nodes(&self) -> &BTreeSet {
+ &self.nodes
+ }
+
+ pub fn edges(&self) -> &BTreeMap<(VmNodeKey, VmNodeKey), VmEdgeEvidence> {
+ &self.edges
+ }
+
+ pub fn attempted(&self) -> u64 {
+ self.attempted
+ }
+
+ pub fn rejected(&self) -> u64 {
+ self.rejected
+ }
+
+ /// Context-collapsed view for a downstream decompiler. This is a union of
+ /// observed possibilities, not a complete CFG or an equivalence proof.
+ pub fn projected_edges(&self) -> Vec {
+ let mut edges: BTreeMap<
+ ((Address, Address), (Address, Address)),
+ u64,
+ > = BTreeMap::new();
+ for ((from, to), evidence) in &self.edges {
+ let location = (
+ (from.native_pc, from.virtual_pc),
+ (to.native_pc, to.virtual_pc),
+ );
+ let count = edges.entry(location).or_default();
+ *count = count.saturating_add(evidence.observations);
+ }
+ edges
+ .into_iter()
+ .map(|((from, to), observations)| VmProjectedEdge {
+ from,
+ to,
+ observations,
+ })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use angryier_arch::{
+ AccessKind, DecodedInstruction, InstructionModifiers, MemoryBase, MemoryOperand,
+ Operand, OperandKind, OperandVisibility, RegisterView,
+ };
+ use angryier_semantics_intel64::forms;
+ use crate::{BasicBlock, CfgEdge};
+
+ fn node(native_pc: u64, virtual_pc: u64, state: u8) -> VmNodeKey {
+ VmNodeKey {
+ native_pc,
+ virtual_pc,
+ state_digest: [state; 32],
+ code_digest: [0xb7; 32],
+ }
+ }
+
+ #[test]
+ fn native_pc_does_not_alias_distinct_virtual_pcs_or_contexts() {
+ let mut session = VmAnalysisSession::new(VmAnalysisLimits::default());
+ let x = node(0x1000, 0x2000, 1);
+ let y = node(0x1000, 0x2001, 1);
+ let z = node(0x1000, 0x2000, 2);
+ for (from, to) in [(x, y), (y, z), (z, x)] {
+ assert_eq!(session.observe(VmTransition {
+ from,
+ to,
+ source: VmTransitionSource::ObservedTrace,
+ }), Ok(()));
+ }
+ assert_eq!(session.nodes().len(), 3);
+ assert_eq!(session.edges().len(), 3);
+ // Projection collapses only context, not VPC, and stays observational.
+ assert_eq!(session.projected_edges().len(), 3);
+ }
+
+ #[test]
+ fn duplicate_evidence_is_coalesced_without_losing_sources() {
+ let mut session = VmAnalysisSession::new(VmAnalysisLimits::default());
+ let from = node(0x1000, 0x2000, 1);
+ let to = node(0x1100, 0x2005, 2);
+ for source in [
+ VmTransitionSource::ObservedTrace,
+ VmTransitionSource::SymbolicCandidate,
+ VmTransitionSource::ValidatedReplay,
+ ] {
+ assert_eq!(session.observe(VmTransition { from, to, source }), Ok(()));
+ }
+ let edge = &session.edges()[&(from, to)];
+ assert_eq!(edge.observations, 3);
+ assert_eq!(edge.sources.len(), 3);
+ assert_eq!(session.projected_edges()[0].observations, 3);
+ }
+
+ #[test]
+ fn budgets_reject_atomically_and_preserve_incompleteness() {
+ let mut session = VmAnalysisSession::new(VmAnalysisLimits {
+ max_nodes: 2,
+ max_edges: 1,
+ });
+ let x = node(0x1000, 0x2000, 1);
+ let y = node(0x1100, 0x2001, 1);
+ let z = node(0x1200, 0x2002, 1);
+ assert_eq!(session.observe(VmTransition {
+ from: x,
+ to: y,
+ source: VmTransitionSource::ObservedTrace,
+ }), Ok(()));
+ assert_eq!(
+ session.observe(VmTransition {
+ from: y,
+ to: z,
+ source: VmTransitionSource::ObservedTrace,
+ }),
+ Err(VmAnalysisError::NodeBudgetExceeded { required: 3, limit: 2 })
+ );
+ assert_eq!(
+ session.observe(VmTransition {
+ from: y,
+ to: x,
+ source: VmTransitionSource::ObservedTrace,
+ }),
+ Err(VmAnalysisError::EdgeBudgetExceeded { required: 2, limit: 1 })
+ );
+ assert_eq!(session.nodes().len(), 2);
+ assert_eq!(session.edges().len(), 1);
+ assert_eq!(session.rejected(), 2);
+ assert_eq!(session.attempted(), 3);
+ }
+
+ fn register(id: u32) -> RegisterView {
+ RegisterView::full(RegisterId(id), 64)
+ }
+
+ fn register_operand(index: u8, id: u32, access: AccessKind) -> Operand {
+ Operand {
+ index,
+ width_bits: 64,
+ access,
+ visibility: OperandVisibility::Explicit,
+ kind: OperandKind::Register(register(id)),
+ }
+ }
+
+ #[test]
+ fn structural_scan_separates_context_provenance_from_confirmed_vpc() {
+ // MOV RBX,R14 / JMP [RBX+38h]: context-table shape, not proof
+ // that R14 holds the VM context, and no VPC is identifiable here.
+ let memory = Operand {
+ index: 0,
+ width_bits: 64,
+ access: AccessKind::Read,
+ visibility: OperandVisibility::Explicit,
+ kind: OperandKind::Memory(MemoryOperand {
+ memory_index: 0,
+ address_width_bits: 64,
+ segment: None,
+ segment_base: None,
+ base: Some(MemoryBase::Register(register(3))),
+ index: None,
+ scale: 1,
+ displacement: 0x38,
+ displacement_width_bits: 32,
+ }),
+ };
+ let insns = vec![
+ DecodedInstruction {
+ address: 0x1000,
+ length: 3,
+ form_id: forms::MOV_R64_R64,
+ features: Vec::new(),
+ operands: vec![
+ register_operand(0, 3, AccessKind::Write),
+ register_operand(1, 14, AccessKind::Read),
+ ],
+ modifiers: InstructionModifiers::default(),
+ },
+ DecodedInstruction {
+ address: 0x1003,
+ length: 3,
+ form_id: forms::JMP_INDIRECT_MEM64,
+ features: Vec::new(),
+ operands: vec![memory],
+ modifiers: InstructionModifiers::default(),
+ },
+ ];
+ let cfg = Cfg {
+ entry: 0x1000,
+ blocks: BTreeMap::from([(
+ 0x1000,
+ BasicBlock {
+ start: 0x1000,
+ end: 0x1006,
+ instructions: insns,
+ terminator: EdgeKind::IndirectJump,
+ },
+ )]),
+ edges: vec![CfgEdge {
+ from: 0x1003,
+ to: None,
+ kind: EdgeKind::IndirectJump,
+ }],
+ };
+ let report = inspect_native_cfg(&cfg);
+ assert_eq!(report.unresolved_indirect_blocks, 1);
+ assert_eq!(report.unclassified_indirect_blocks, 0);
+ assert_eq!(report.findings.len(), 1);
+ assert_eq!(report.findings[0].context_origin, Some(RegisterId(14)));
+ assert_eq!(report.findings[0].kind, DispatchKind::ContextTable { disp: 0x38 });
+ assert!(report.findings[0].vpc_candidates.is_empty());
+ }
+}
diff --git a/crates/angryier-cfg/src/vpc.rs b/crates/angryier-cfg/src/vpc.rs
index 0e45edb9..dd6d792d 100644
--- a/crates/angryier-cfg/src/vpc.rs
+++ b/crates/angryier-cfg/src/vpc.rs
@@ -51,10 +51,9 @@
//! `r1` then `r2`). Register rotation is also something ordinary
//! register allocation produces, so this is the weakest signal.
//!
-//! This module is intentionally NOT wired into the crate module tree yet
-//! (`lib.rs` is owned elsewhere): it is self-contained and drop-in. The
-//! tests below activate once the module is declared; until then cargo does
-//! not compile this file at all.
+//! This module is compiled and exposed by `angryier-cfg`. Its candidates
+//! are consumed by the opt-in `vm::inspect_native_cfg` report; they are not
+//! automatically promoted to confirmed virtual-PC identities.
use std::collections::BTreeMap;
diff --git a/crates/angryier-cfg/src/xrefs.rs b/crates/angryier-cfg/src/xrefs.rs
new file mode 100644
index 00000000..244250ca
--- /dev/null
+++ b/crates/angryier-cfg/src/xrefs.rs
@@ -0,0 +1,258 @@
+//! Source-backed direct control-flow and RIP-relative cross-references.
+//!
+//! This is an index of *known* references, not an indirect-call solver.
+//! Every result carries its source instruction. Memory-indirect calls refer
+//! to their IAT/vtable SLOT rather than falsely claiming the call target.
+//! The index is deterministic and is derived from an existing bounded CFG.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use angryier_arch::{AccessKind, MemoryBase, OperandKind};
+use angryier_types::Address;
+
+use crate::{Cfg, EdgeKind};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum XrefKind {
+ Call,
+ Branch,
+ FallThrough,
+ TableDispatch,
+ ImportOrIndirectSlot,
+ AddressOf,
+ Read,
+ Write,
+ ReadWrite,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct Xref {
+ pub source: Address,
+ pub target: Address,
+ pub kind: XrefKind,
+ pub table_index: Option,
+}
+
+/// Both incoming and outgoing indexes reuse the same deduplicated evidence.
+#[derive(Clone, Debug, Default)]
+pub struct XrefIndex {
+ by_target: BTreeMap>,
+ by_source: BTreeMap>,
+ total: usize,
+}
+
+impl XrefIndex {
+ pub fn from_cfg(cfg: &Cfg) -> Self {
+ let mut records = BTreeSet::new();
+
+ for edge in &cfg.edges {
+ let Some(target) = edge.to else {
+ continue;
+ };
+ let (kind, table_index) = match edge.kind {
+ EdgeKind::Call => (XrefKind::Call, None),
+ EdgeKind::ConditionalTaken | EdgeKind::Unconditional => (XrefKind::Branch, None),
+ EdgeKind::FallThrough => (XrefKind::FallThrough, None),
+ EdgeKind::TableDispatch { index } => (XrefKind::TableDispatch, Some(index)),
+ EdgeKind::IndirectCall | EdgeKind::IndirectJump | EdgeKind::Return => continue,
+ };
+ records.insert(Xref {
+ source: edge.from,
+ target,
+ kind,
+ table_index,
+ });
+ }
+
+ // Literal RIP-relative reads, writes and pointer expressions are
+ // anchored to the bytes that actually reference them.
+ for block in cfg.blocks.values() {
+ for insn in &block.instructions {
+ for operand in &insn.operands {
+ let (memory, kind) = match operand.kind {
+ OperandKind::AddressGeneration(mem) => (mem, XrefKind::AddressOf),
+ OperandKind::Memory(mem) => {
+ if matches!(
+ insn.form_id,
+ angryier_semantics_intel64::forms::CALL_INDIRECT_MEM64
+ | angryier_semantics_intel64::forms::JMP_INDIRECT_MEM64
+ ) {
+ (mem, XrefKind::ImportOrIndirectSlot)
+ } else {
+ let kind = match operand.access {
+ AccessKind::Read | AccessKind::ConditionalRead => XrefKind::Read,
+ AccessKind::Write | AccessKind::ConditionalWrite => XrefKind::Write,
+ AccessKind::ReadWrite | AccessKind::ReadConditionalWrite
+ | AccessKind::ConditionalReadWrite => XrefKind::ReadWrite,
+ };
+ (mem, kind)
+ }
+ }
+ _ => continue,
+ };
+ if !matches!(memory.base, Some(MemoryBase::InstructionPointer { .. }))
+ || memory.index.is_some()
+ {
+ continue;
+ }
+ let Some(target) = insn
+ .address
+ .checked_add(u64::from(insn.length))
+ .and_then(|base| base.checked_add_signed(memory.displacement))
+ else {
+ continue;
+ };
+ records.insert(Xref {
+ source: insn.address,
+ target,
+ kind,
+ table_index: None,
+ });
+ }
+ }
+ }
+
+ let mut index = Self::default();
+ for record in records {
+ index.by_source.entry(record.source).or_default().push(record);
+ index.by_target.entry(record.target).or_default().push(record);
+ index.total += 1;
+ }
+ index
+ }
+
+ pub fn len(&self) -> usize {
+ self.total
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.total == 0
+ }
+
+ pub fn to(&self, target: Address) -> &[Xref] {
+ self.by_target.get(&target).map_or(&[], Vec::as_slice)
+ }
+
+ pub fn from(&self, source: Address) -> &[Xref] {
+ self.by_source.get(&source).map_or(&[], Vec::as_slice)
+ }
+
+ /// Only direct, statically identified call sites. Memory-indirect call
+ /// slots are intentionally excluded: resolving their targets requires an
+ /// import linker, concrete state or explicit evidence from elsewhere.
+ pub fn callers_of(&self, callee: Address) -> Vec {
+ self.to(callee)
+ .iter()
+ .filter(|record| record.kind == XrefKind::Call)
+ .map(|record| record.source)
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use angryier_arch::{
+ DecodedInstruction, InstructionModifiers, MemoryOperand, Operand,
+ OperandVisibility, RelativeBranchOperand,
+ };
+ use crate::{BasicBlock, CfgEdge};
+
+ fn rip_reference(addr: u64, disp: i64, form: u32, access: AccessKind) -> DecodedInstruction {
+ DecodedInstruction {
+ address: addr,
+ length: 6,
+ form_id: form,
+ features: Vec::new(),
+ operands: vec![Operand {
+ index: 0,
+ width_bits: 64,
+ access,
+ visibility: OperandVisibility::Explicit,
+ kind: OperandKind::Memory(MemoryOperand {
+ memory_index: 0,
+ address_width_bits: 64,
+ segment: None,
+ segment_base: None,
+ base: Some(MemoryBase::InstructionPointer { width_bits: 64 }),
+ index: None,
+ scale: 1,
+ displacement: disp,
+ displacement_width_bits: 32,
+ }),
+ }],
+ modifiers: InstructionModifiers::default(),
+ }
+ }
+
+ #[test]
+ fn exact_direct_caller_and_rip_iat_slot_are_distinct() {
+ let instructions = vec![
+ rip_reference(0x1000, -0x10, angryier_semantics_intel64::forms::CALL_INDIRECT_MEM64, AccessKind::Read),
+ DecodedInstruction {
+ address: 0x1006,
+ length: 5,
+ form_id: angryier_semantics_intel64::forms::CALL_REL32,
+ features: Vec::new(),
+ operands: vec![Operand {
+ index: 0,
+ width_bits: 32,
+ access: AccessKind::Read,
+ visibility: OperandVisibility::Explicit,
+ kind: OperandKind::RelativeBranch(RelativeBranchOperand {
+ displacement: 0x15,
+ displacement_width_bits: 32,
+ }),
+ }],
+ modifiers: InstructionModifiers::default(),
+ },
+ ];
+ let cfg = Cfg {
+ entry: 0x1000,
+ blocks: BTreeMap::from([(
+ 0x1000,
+ BasicBlock {
+ start: 0x1000,
+ end: 0x100b,
+ instructions,
+ terminator: EdgeKind::Call,
+ },
+ )]),
+ edges: vec![
+ CfgEdge { from: 0x1006, to: Some(0x1020), kind: EdgeKind::Call },
+ CfgEdge { from: 0x1006, to: Some(0x100b), kind: EdgeKind::FallThrough },
+ CfgEdge { from: 0x1006, to: Some(0x1020), kind: EdgeKind::Call }, // dedup
+ ],
+ };
+ let idx = XrefIndex::from_cfg(&cfg);
+ assert_eq!(idx.callers_of(0x1020), vec![0x1006]);
+ assert_eq!(idx.to(0x0ff6)[0].kind, XrefKind::ImportOrIndirectSlot);
+ assert!(idx.callers_of(0x0ff6).is_empty());
+ assert_eq!(idx.len(), 3);
+ }
+
+ #[test]
+ fn rip_relative_negative_displacement_and_conditional_access() {
+ let insn = rip_reference(
+ 0x140005000,
+ -0x20,
+ angryier_semantics_intel64::forms::MOV_R64_MEM64,
+ AccessKind::ConditionalRead,
+ );
+ let cfg = Cfg {
+ entry: 0x140005000,
+ blocks: BTreeMap::from([(
+ 0x140005000,
+ BasicBlock {
+ start: 0x140005000,
+ end: 0x140005006,
+ instructions: vec![insn],
+ terminator: EdgeKind::IndirectJump,
+ },
+ )]),
+ edges: Vec::new(),
+ };
+ let idx = XrefIndex::from_cfg(&cfg);
+ assert_eq!(idx.to(0x140004fe6)[0].kind, XrefKind::Read);
+ }
+}
diff --git a/crates/angryier-cli/Cargo.toml b/crates/angryier-cli/Cargo.toml
index 7e7571df..bc6deb53 100644
--- a/crates/angryier-cli/Cargo.toml
+++ b/crates/angryier-cli/Cargo.toml
@@ -15,14 +15,17 @@ angryier-runtime = { path = "../angryier-runtime", optional = true }
angryier-models = { path = "../angryier-models", optional = true }
angryier-types = { path = "../angryier-types", optional = true }
mlua = { version = "0.12", features = ["lua54", "vendored"], optional = true }
+angryier-loader = { path = "../angryier-loader", optional = true }
+sha2 = { version = "0.10", optional = true }
[features]
+inspect = ["dep:angryier-loader", "dep:sha2"]
# `run` ships by default: the binary is the product, and a bare
# `cargo build --release` must yield a run-capable `angryier` (incl. driver
# mode). This pulls mlua (vendored Lua 5.4), xed-sys (XED built from source)
# and the system libz3 via `angryier-runtime/script` -> `z3`; build the
# minimal rustc-only CLI with `cargo build -p angryier-cli --no-default-features`.
-default = ["run"]
+default = ["run", "inspect"]
run = ["dep:angryier", "dep:angryier-runtime", "dep:angryier-models", "dep:angryier-types", "dep:mlua", "angryier-runtime/xed", "angryier-runtime/script"]
[lints]
diff --git a/crates/angryier-cli/build.rs b/crates/angryier-cli/build.rs
new file mode 100644
index 00000000..45d60a6b
--- /dev/null
+++ b/crates/angryier-cli/build.rs
@@ -0,0 +1,31 @@
+//! Bind the compiled CLI to a precise source revision. No git requirement:
+//! exported source archives identify themselves explicitly as unknown.
+use std::process::Command;
+
+fn git(args: &[&str]) -> Option {
+ let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?;
+ let result = Command::new("git")
+ .arg("-C")
+ .arg(manifest)
+ .args(args)
+ .output()
+ .ok()?;
+ if !result.status.success() {
+ return None;
+ }
+ let value = String::from_utf8(result.stdout).ok()?;
+ let value = value.trim();
+ (!value.is_empty()).then(|| value.to_string())
+}
+
+fn main() {
+ println!("cargo:rerun-if-env-changed=ANGRYIER_BUILD_SHA");
+ println!("cargo:rerun-if-changed=../../.git/HEAD");
+ println!("cargo:rerun-if-changed=../../.git/packed-refs");
+ let source_sha = std::env::var("ANGRYIER_BUILD_SHA")
+ .ok()
+ .filter(|v| !v.is_empty())
+ .or_else(|| git(&["rev-parse", "HEAD"]))
+ .unwrap_or_else(|| "unknown".to_string());
+ println!("cargo:rustc-env=ANGRYIER_BUILD_SHA={source_sha}");
+}
diff --git a/crates/angryier-cli/src/inspection.rs b/crates/angryier-cli/src/inspection.rs
new file mode 100644
index 00000000..4b437cb6
--- /dev/null
+++ b/crates/angryier-cli/src/inspection.rs
@@ -0,0 +1,143 @@
+//! Read-only PE address and Windows minidump inspection CLI commands.
+//! These are deliberately independent from native XED/Z3 symbolic execution,
+//! so they can run even on machines without those native libraries.
+
+use angryier_loader::addresses::{PeAddressMap, PeLocation};
+use angryier_loader::minidump_file::MiniDumpFile;
+
+fn number(text: &str) -> Result {
+ let value = text.trim();
+ if let Some(hex) = value.strip_prefix("0x").or_else(|| value.strip_prefix("0X")) {
+ u64::from_str_radix(hex, 16).map_err(|_| format!("invalid hexadecimal address: {text}"))
+ } else {
+ value.parse::().map_err(|_| format!("invalid unsigned integer: {text}"))
+ }
+}
+
+fn length(text: Option<&String>, default: usize, max: usize) -> Result {
+ let val = match text {
+ Some(text) => usize::try_from(number(text)?).map_err(|_| "read length overflows usize".to_string())?,
+ None => default,
+ };
+ if val > max {
+ return Err(format!("requested length {val} exceeds maximum {max}"));
+ }
+ Ok(val)
+}
+
+fn hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect::>().join(" ")
+}
+
+pub fn pe_address(args: &[String]) -> Result {
+ if !(2..=3).contains(&args.len()) {
+ return Err("usage: angryier pe-address [length<=256]".to_string());
+ }
+ let file = std::fs::read(&args[0]).map_err(|error| format!("cannot open PE image: {error}"))?;
+ let mapping = PeAddressMap::parse(&file).map_err(|error| format!("invalid PE image: {error:?}"))?;
+ let va = number(&args[1])?;
+ let len = length(args.get(2), 0, 256)?;
+ let rva = mapping.va_to_rva(va).map_err(|error| format!("VA not in image: {error:?}"))?;
+ let location = mapping.locate_va(va).map_err(|error| format!("unmapped VA: {error:?}"))?;
+ let description = match location {
+ PeLocation::Header { file_offset } => format!("header file_offset=0x{file_offset:x}"),
+ PeLocation::Section { index, file_offset } => {
+ let section = &mapping.sections()[index];
+ format!("section={} file_offset=0x{file_offset:x}", section.name)
+ }
+ PeLocation::ZeroFill { index } => {
+ format!("section={} zero-fill (no original file bytes)", mapping.sections()[index].name)
+ }
+ };
+ let mut output = format!(
+ "image_base=0x{:x} va=0x{va:x} rva=0x{rva:x} {description}",
+ mapping.image_base()
+ );
+ if len != 0 {
+ let bytes = mapping.bytes_at_va(&file, va, len)
+ .map_err(|error| format!("cannot read {len} bytes at VA: {error:?}"))?;
+ output.push_str(&format!("\nbytes[{len}]={}", hex(bytes)));
+ }
+ Ok(output)
+}
+
+/// Resolve the PE import directory to source file/IAT VA evidence. Import
+/// names are *metadata*, not evidence that a kernel/user API was executed.
+pub fn pe_imports(args: &[String]) -> Result {
+ if !(1..=2).contains(&args.len()) {
+ return Err("usage: angryier pe-imports [substring]".to_string());
+ }
+ use angryier_loader::{ImageLoader, Pe32Loader, PeImportKind};
+ let bytes = std::fs::read(&args[0])
+ .map_err(|e| format!("cannot open PE: {e}"))?;
+ let map = PeAddressMap::parse(&bytes)
+ .map_err(|e| format!("PE address map: {e:?}"))?;
+ let image = Pe32Loader::new().load(&bytes)
+ .map_err(|e| format!("PE import parse: {e}"))?;
+ let imports = image.pe_imports().ok_or("not a PE image")?;
+ let filter = args.get(1).map(|s| s.to_ascii_lowercase());
+ let mut out = format!(
+ "image_base=0x{:x} imports={}\n", map.image_base(), imports.len()
+ );
+ let mut displayed = 0usize;
+ for entry in imports {
+ let symbol = match &entry.kind {
+ PeImportKind::Name(name) => name.clone(),
+ PeImportKind::Ordinal(ordinal) => format!("#{ordinal}"),
+ };
+ let qualified = format!("{}!{}", entry.dll, symbol);
+ if filter.as_ref().is_some_and(|query| !qualified.to_ascii_lowercase().contains(query)) {
+ continue;
+ }
+ let slot = map.rva_to_va(entry.iat_rva)
+ .map_err(|e| format!("IAT RVA overflow: {e:?}"))?;
+ let offset = match map.locate_rva(entry.iat_rva) {
+ Ok(PeLocation::Header { file_offset } | PeLocation::Section { file_offset, .. }) => {
+ format!("0x{file_offset:x}")
+ }
+ Ok(PeLocation::ZeroFill { .. }) => "no-file-byte".to_string(),
+ Err(_) => "unmapped".to_string(),
+ };
+ out.push_str(&format!("slot=0x{slot:x} rva=0x{:x} file_offset={offset} import={qualified}\n", entry.iat_rva));
+ displayed += 1;
+ if displayed > 100_000 {
+ return Err("too many imports to display safely".to_string());
+ }
+ }
+ out.push_str(&format!("shown={displayed}"));
+ Ok(out)
+}
+
+pub fn minidump_read(args: &[String]) -> Result {
+ if args.len() != 3 {
+ return Err("usage: angryier dump-read ".to_string());
+ }
+ let addr = number(&args[1])?;
+ let len = length(args.get(2), 0, 4096)?;
+ let mut dump = MiniDumpFile::open(&args[0])
+ .map_err(|error| format!("cannot inspect dump: {error:?}"))?;
+ let bytes = dump.read(addr, len)
+ .map_err(|error| format!("requested memory is unavailable in dump: {error:?}"))?;
+ Ok(format!(
+ "captured va=0x{addr:x} length={} regions={} file_bytes={}\n{}",
+ bytes.len(),
+ dump.regions().len(),
+ dump.file_size(),
+ hex(&bytes)
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_exact_hex_addresses_and_rejects_invalid_input() {
+ assert_eq!(number("0x140404e10"), Ok(0x140404e10));
+ assert_eq!(number("12345"), Ok(12345));
+ assert!(number("-1").is_err());
+ assert!(number("0xZZ").is_err());
+ assert_eq!(length(Some(&"0x100".to_string()), 16, 256), Ok(256));
+ assert!(length(Some(&"257".to_string()), 16, 256).is_err());
+ }
+}
diff --git a/crates/angryier-cli/src/main.rs b/crates/angryier-cli/src/main.rs
index 50483386..45ee7b47 100644
--- a/crates/angryier-cli/src/main.rs
+++ b/crates/angryier-cli/src/main.rs
@@ -5,6 +5,11 @@
//! This binary reports workspace/crate status. It parses arguments manually
//! from `std::env::args()` and uses no external dependencies.
+#[cfg(feature = "inspect")]
+mod inspection;
+#[cfg(feature = "inspect")]
+mod provenance;
+
/// Single version source: the workspace package version inherited via
/// `version.workspace = true` in Cargo.toml. `angry.version()` (the runtime
/// crate) derives from the same source, so the two cannot drift.
@@ -309,6 +314,11 @@ fn help_output() -> String {
run {run_desc}\n \
help Print this help message"
)
+ + if cfg!(feature = "inspect") {
+ "\n doctor [--show-paths] Report executable hash/build source identity\n pe-address [length] Strict VA/RVA/file-offset lookup\n pe-imports [substring] List exact IAT slot addresses and import names\n dump-read Read captured minidump bytes"
+ } else {
+ ""
+ }
}
/// Dispatch a parsed argument list to the appropriate subcommand.
@@ -350,6 +360,45 @@ fn run(args: &[String]) -> i32 {
1
}
}
+ Some("doctor") => {
+ #[cfg(feature = "inspect")]
+ {
+ match provenance::doctor(&args[2..]) {
+ Ok(report) => { println!("{report}"); 0 }
+ Err(error) => { eprintln!("error: {error}"); 1 }
+ }
+ }
+ #[cfg(not(feature = "inspect"))]
+ {
+ eprintln!("error: doctor requires --features inspect");
+ 1
+ }
+ }
+ Some("pe-address") | Some("pe-imports") | Some("dump-read") => {
+ #[cfg(feature = "inspect")]
+ {
+ let outcome = match args.get(1).map(String::as_str) {
+ Some("pe-address") => inspection::pe_address(&args[2..]),
+ Some("pe-imports") => inspection::pe_imports(&args[2..]),
+ _ => inspection::minidump_read(&args[2..]),
+ };
+ match outcome {
+ Ok(result) => {
+ println!("{result}");
+ 0
+ }
+ Err(error) => {
+ eprintln!("error: {error}");
+ 1
+ }
+ }
+ }
+ #[cfg(not(feature = "inspect"))]
+ {
+ eprintln!("error: forensic inspection requires --features inspect");
+ 1
+ }
+ }
Some("help") | Some("--help") | Some("-h") => {
println!("{}", help_output());
0
diff --git a/crates/angryier-cli/src/provenance.rs b/crates/angryier-cli/src/provenance.rs
new file mode 100644
index 00000000..eca96fcc
--- /dev/null
+++ b/crates/angryier-cli/src/provenance.rs
@@ -0,0 +1,91 @@
+//! Native run provenance and host-checkout comparison.
+//! Paths are masked by default: explicit --show-paths is needed for the
+//! directory strings; audit logs should normally use SHA-256 identities.
+
+use sha2::{Digest, Sha256};
+use std::io::Read;
+use std::process::Command;
+
+fn sha256_file(path: &std::path::Path) -> Result {
+ let mut reader = std::io::BufReader::new(
+ std::fs::File::open(path).map_err(|e| format!("binary open failed: {e}"))?,
+ );
+ let mut digest = Sha256::new();
+ let mut buffer = [0u8; 64 * 1024];
+ loop {
+ let n = reader.read(&mut buffer).map_err(|e| format!("binary read failed: {e}"))?;
+ if n == 0 {
+ break;
+ }
+ digest.update(&buffer[..n]);
+ }
+ Ok(format!("{:x}", digest.finalize()))
+}
+
+fn git_head(directory: &std::path::Path) -> Option {
+ let out = Command::new("git")
+ .arg("-C")
+ .arg(directory)
+ .args(["rev-parse", "--verify", "HEAD"])
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ let value = String::from_utf8(out.stdout).ok()?;
+ let value = value.trim();
+ (!value.is_empty()).then(|| value.to_string())
+}
+
+pub fn doctor(args: &[String]) -> Result {
+ let show_paths = match args {
+ [] => false,
+ [flag] if flag == "--show-paths" => true,
+ _ => return Err("usage: angryier doctor [--show-paths]".to_string()),
+ };
+ let binary = std::env::current_exe()
+ .and_then(std::fs::canonicalize)
+ .map_err(|e| format!("cannot resolve running executable: {e}"))?;
+ let binary_hash = sha256_file(&binary)?;
+ let cwd = std::env::current_dir().map_err(|e| format!("cannot access current directory: {e}"))?;
+ let cwd_sha = git_head(&cwd);
+ let compiled_sha = env!("ANGRYIER_BUILD_SHA");
+ let verdict = match cwd_sha.as_deref() {
+ Some(_) if compiled_sha == "unknown" => "unknown-compiled-revision",
+ Some(sha) if sha == compiled_sha => "matching-revisions",
+ Some(_) => "mismatched-checkout-and-binary",
+ None => "no-git-checkout-in-working-directory",
+ };
+
+ let mut out = format!(
+ "provenance_schema=1\npackage={}\ncompiled_revision={}\nworking_checkout_revision={}\nverdict={}\nbinary_sha256={}\nfeatures=run:{},inspect:{}",
+ env!("CARGO_PKG_VERSION"),
+ compiled_sha,
+ cwd_sha.as_deref().unwrap_or("unknown"),
+ verdict,
+ binary_hash,
+ cfg!(feature = "run"),
+ cfg!(feature = "inspect"),
+ );
+ if show_paths {
+ out.push_str(&format!("\nbinary_path={}\nworking_directory={}", binary.display(), cwd.display()));
+ } else {
+ out.push_str("\npaths=redacted (use --show-paths for local diagnosis)");
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn no_paths_in_default_diagnostics() -> Result<(), String> {
+ let out = doctor(&[])?;
+ assert!(out.contains("compiled_revision="));
+ assert!(out.contains("binary_sha256="));
+ assert!(out.contains("paths=redacted"));
+ assert!(!out.contains("binary_path="));
+ assert!(doctor(&["--invalid".to_string()]).is_err());
+ Ok(())
+ }
+}
diff --git a/crates/angryier-execution/src/lib.rs b/crates/angryier-execution/src/lib.rs
index 546263c5..59335d37 100644
--- a/crates/angryier-execution/src/lib.rs
+++ b/crates/angryier-execution/src/lib.rs
@@ -6,7 +6,7 @@ pub mod symbolic;
pub use interpreter::{ConcreteExecutionError, ConcreteInterpreter};
pub use symbolic::{
ConcolicBinding, ConcolicEvaluator, ConcolicImage, ConcolicSource, PathConstraint, SYMBOLIC_DEBT_SITE_CAP,
- SymbolBinding, SymbolicArena, SymbolicBlockSummary, SymbolicBranch, SymbolicDebtSite, SymbolicEvalError,
+ SymbolBinding, SymbolicArena, SymbolicBlockSummary, SymbolicBlockValueTrace, SymbolicRegisterWriteTrace, SymbolicBranch, SymbolicDebtSite, SymbolicEvalError,
SymbolicEvaluator, SymbolicSessionMemory, SymbolicStateSnapshot, bit_to_bool, constant_value, merge_snapshots,
};
diff --git a/crates/angryier-execution/src/symbolic.rs b/crates/angryier-execution/src/symbolic.rs
index a988c71c..ae0304da 100644
--- a/crates/angryier-execution/src/symbolic.rs
+++ b/crates/angryier-execution/src/symbolic.rs
@@ -81,7 +81,7 @@ pub struct SymbolicDebtSite {
/// Everything else about vectors is exact: whole-vector moves, loads, stores
/// and register traffic lower through the existing Concat/Extract/ZExt
/// machinery over a flat `BitVec(width_bits)` (the engine's `ExprOp::Concat`
-/// is binary little-endian, operands[0] = low bits, and `Extract` immediates
+/// takes (high, low), while AngryIR `IrPrimitive::Concat` takes (low, high); `Extract` immediates
/// encode `[start:u16 LE, width:u16 LE]`), and scalar bitvector primitives
/// applied to whole-vector values stay exact flat 128-bit arithmetic.
fn is_vector_debt_primitive(op: IrPrimitive) -> bool {
@@ -316,6 +316,30 @@ pub struct SymbolicBlockSummary {
pub jump_target: Option,
}
+/// Evaluated block values in this block's *local* SSA namespace.
+/// Each entry is indexed by IrValueId. Never reuse numeric IDs across
+/// different blocks, and never infer semantic equivalence from this table.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SymbolicBlockValueTrace {
+ pub summary: SymbolicBlockSummary,
+ pub values: Vec