hotflow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hotflow/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
3
+
4
+ """Probabilistic control-flow analysis for ELF binaries."""
5
+
6
+ from hotflow.analyzer import analyze_binary
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["analyze_binary", "__version__"]
hotflow/analyzer.py ADDED
@@ -0,0 +1,86 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
3
+
4
+ """End-to-end analysis orchestration for binary functions."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import fnmatch
9
+ from collections.abc import Iterator
10
+ from pathlib import Path
11
+
12
+ from hotflow.binary import ElfBinary, LoadedFunction
13
+ from hotflow.cfg import build_cfg
14
+ from hotflow.disassembly import disassemble
15
+ from hotflow.frequency import solve_frequencies
16
+ from hotflow.loops import analyze_loops
17
+ from hotflow.metrics import FunctionAnalysis, analyze_metrics
18
+ from hotflow.probability import assign_probabilities
19
+
20
+
21
+ def _defined_function_names(elf: ElfBinary) -> list[str]:
22
+ return [
23
+ sym.name
24
+ for sym in elf.list_symbols(function_only=True)
25
+ if sym.section_name is not None
26
+ ]
27
+
28
+
29
+ def _select_function_names(names: list[str], patterns: list[str]) -> list[str]:
30
+ """Select exact names or shell globs in pattern order without duplicates."""
31
+ selected: list[str] = []
32
+ seen: set[str] = set()
33
+ for pattern in patterns:
34
+ for name in names:
35
+ if name not in seen and (
36
+ name == pattern or fnmatch.fnmatchcase(name, pattern)
37
+ ):
38
+ selected.append(name)
39
+ seen.add(name)
40
+ return selected
41
+
42
+
43
+ def _analyze_loaded(loaded: LoadedFunction, model_name: str) -> FunctionAnalysis:
44
+ instructions = disassemble(
45
+ loaded.code_bytes,
46
+ base_address=loaded.address,
47
+ mode_64=loaded.is_64bit,
48
+ relocations=loaded.relocations,
49
+ )
50
+
51
+ cfg = build_cfg(
52
+ loaded.address,
53
+ loaded.size,
54
+ instructions,
55
+ )
56
+ loops = analyze_loops(cfg)
57
+ assign_probabilities(cfg, loops, model_name)
58
+ solve_frequencies(cfg)
59
+
60
+ return analyze_metrics(
61
+ symbol=loaded.symbol.name,
62
+ address=loaded.address,
63
+ size=loaded.size,
64
+ architecture=loaded.architecture,
65
+ section_name=loaded.section_name,
66
+ model_name=model_name,
67
+ cfg=cfg,
68
+ loops=loops,
69
+ )
70
+
71
+
72
+ def analyze_binary(
73
+ binary_path: str | Path,
74
+ patterns: list[str] | None = None,
75
+ model_name: str = "heuristic",
76
+ ) -> Iterator[FunctionAnalysis]:
77
+ """Analyze exact symbol names or shell globs, opening the ELF once."""
78
+ with ElfBinary(binary_path) as elf:
79
+ available = _defined_function_names(elf)
80
+ names = (
81
+ available
82
+ if patterns is None
83
+ else _select_function_names(available, patterns)
84
+ )
85
+ for name in names:
86
+ yield _analyze_loaded(elf.load_function(name), model_name)
hotflow/binary.py ADDED
@@ -0,0 +1,294 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
3
+
4
+ """ELF binary loading, symbol extraction, and section mapping."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from bisect import bisect_right
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from elftools.elf.elffile import ELFFile
13
+ from elftools.elf.relocation import RelocationSection
14
+ from elftools.elf.sections import SymbolTableSection
15
+
16
+
17
+ class BinaryError(Exception):
18
+ """Base exception for binary loading and symbol lookup errors."""
19
+
20
+
21
+ class SymbolNotFoundError(BinaryError):
22
+ """Raised when a requested symbol is not found in the binary."""
23
+
24
+
25
+ @dataclass
26
+ class SymbolInfo:
27
+ """An extracted ELF symbol."""
28
+
29
+ name: str
30
+ address: int
31
+ size: int
32
+ section_name: str | None
33
+ section_index: int | None
34
+ binding: str
35
+ is_function: bool
36
+
37
+
38
+ @dataclass
39
+ class RelocationInfo:
40
+ """Relocation entry in a relocatable ELF file."""
41
+
42
+ offset: int
43
+ symbol_name: str | None
44
+
45
+
46
+ @dataclass
47
+ class LoadedFunction:
48
+ """A function loaded from an ELF binary ready for disassembly."""
49
+
50
+ symbol: SymbolInfo
51
+ address: int
52
+ size: int
53
+ code_bytes: bytes
54
+ architecture: str
55
+ is_64bit: bool
56
+ section_name: str | None = None
57
+ relocations: dict[int, RelocationInfo] = field(default_factory=dict)
58
+
59
+
60
+ class ElfBinary:
61
+ """Parser and extractor for ELF binaries."""
62
+
63
+ def __init__(self, file_path: str | Path) -> None:
64
+ self._path = Path(file_path)
65
+ self._file = self._path.open("rb")
66
+ try:
67
+ self.elf = ELFFile(self._file)
68
+ except Exception as error:
69
+ self._file.close()
70
+ raise BinaryError(f"Failed to parse ELF file: {error}") from error
71
+
72
+ self.arch = self.elf.get_machine_arch()
73
+ self.is_64bit = self.elf.elfclass == 64
74
+
75
+ self._symbol_tables: list[SymbolTableSection] | None = None
76
+ self._symbols: list[SymbolInfo] | None = None
77
+ self._symbols_by_name: dict[str, list[SymbolInfo]] | None = None
78
+ self._section_symbol_addrs: dict[int, list[int]] | None = None
79
+ self._relocations_by_section: dict[int, dict[int, RelocationInfo]] = {}
80
+
81
+ def __enter__(self) -> ElfBinary:
82
+ return self
83
+
84
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
85
+ self.close()
86
+
87
+ def close(self) -> None:
88
+ self._file.close()
89
+
90
+ def _get_symbol_tables(self) -> list[SymbolTableSection]:
91
+ if self._symbol_tables is None:
92
+ tables = []
93
+ symtab = self.elf.get_section_by_name(".symtab")
94
+ if isinstance(symtab, SymbolTableSection):
95
+ tables.append(symtab)
96
+ dynsym = self.elf.get_section_by_name(".dynsym")
97
+ if isinstance(dynsym, SymbolTableSection):
98
+ tables.append(dynsym)
99
+ self._symbol_tables = tables
100
+ return self._symbol_tables
101
+
102
+ def _ensure_symbol_index(self) -> None:
103
+ if self._symbols is not None:
104
+ return
105
+ symbols: list[SymbolInfo] = []
106
+ by_name: dict[str, list[SymbolInfo]] = {}
107
+ section_sym_addrs: dict[int, list[int]] = {}
108
+ seen: set[tuple[str, int, int | None]] = set()
109
+ num_sections = self.elf.num_sections()
110
+ section_names: dict[int, str] = {}
111
+
112
+ for table in self._get_symbol_tables():
113
+ for sym in table.iter_symbols():
114
+ name = sym.name
115
+ if not name:
116
+ continue
117
+ st_value = sym["st_value"]
118
+ sec_idx = sym["st_shndx"]
119
+ sec_idx_int = sec_idx if isinstance(sec_idx, int) and 0 <= sec_idx < num_sections else None
120
+ key = (name, st_value, sec_idx_int)
121
+ if key in seen:
122
+ continue
123
+ seen.add(key)
124
+
125
+ if sec_idx_int is not None:
126
+ if sec_idx_int not in section_names:
127
+ sec = self.elf.get_section(sec_idx_int)
128
+ section_names[sec_idx_int] = sec.name if sec else ""
129
+ sec_name = section_names[sec_idx_int] or None
130
+ section_sym_addrs.setdefault(sec_idx_int, []).append(st_value)
131
+ else:
132
+ sec_name = None
133
+
134
+ st_type = sym["st_info"]["type"]
135
+ is_func = st_type in ("STT_FUNC", "STT_GNU_IFUNC")
136
+ sym_info = SymbolInfo(
137
+ name=name,
138
+ address=st_value,
139
+ size=sym["st_size"],
140
+ section_name=sec_name,
141
+ section_index=sec_idx_int,
142
+ binding=sym["st_info"]["bind"],
143
+ is_function=is_func,
144
+ )
145
+ symbols.append(sym_info)
146
+ by_name.setdefault(name, []).append(sym_info)
147
+
148
+ for sec_idx_k in section_sym_addrs:
149
+ section_sym_addrs[sec_idx_k].sort()
150
+
151
+ self._symbols = symbols
152
+ self._symbols_by_name = by_name
153
+ self._section_symbol_addrs = section_sym_addrs
154
+
155
+ def list_symbols(self, function_only: bool = False) -> list[SymbolInfo]:
156
+ """List all symbols or function symbols in the ELF."""
157
+ self._ensure_symbol_index()
158
+ seen = set()
159
+ result = []
160
+ for sym in self._symbols or []:
161
+ if function_only and not sym.is_function:
162
+ continue
163
+ if sym.name in seen:
164
+ continue
165
+ seen.add(sym.name)
166
+ result.append(sym)
167
+ return result
168
+
169
+ def find_symbol(self, symbol_name: str) -> SymbolInfo:
170
+ """Find a symbol by exact name."""
171
+ self._ensure_symbol_index()
172
+ candidates = (self._symbols_by_name or {}).get(symbol_name)
173
+ if not candidates:
174
+ raise SymbolNotFoundError(
175
+ f"Symbol '{symbol_name}' not found in binary '{self._path}'"
176
+ )
177
+ func_candidates = [c for c in candidates if c.is_function]
178
+ if func_candidates:
179
+ return max(
180
+ func_candidates,
181
+ key=lambda s: (
182
+ s.section_name is not None,
183
+ s.size > 0,
184
+ s.binding == "STB_GLOBAL",
185
+ ),
186
+ )
187
+ return candidates[0]
188
+
189
+ def _infer_symbol_size(self, symbol: SymbolInfo, section) -> int:
190
+ """Infer size of a symbol when st_size is 0."""
191
+ sec_size = section["sh_size"]
192
+ sec_addr = section["sh_addr"]
193
+ sym_addr = symbol.address
194
+
195
+ min_next_addr = None
196
+ if symbol.section_index is not None and self._section_symbol_addrs:
197
+ addrs = self._section_symbol_addrs.get(symbol.section_index)
198
+ if addrs:
199
+ idx = bisect_right(addrs, sym_addr)
200
+ if idx < len(addrs):
201
+ min_next_addr = addrs[idx]
202
+
203
+ is_relocatable = self.elf["e_type"] == "ET_REL"
204
+ if is_relocatable:
205
+ offset = sym_addr
206
+ if min_next_addr is not None and min_next_addr <= sec_size:
207
+ return min_next_addr - offset
208
+ return sec_size - offset
209
+ else:
210
+ if min_next_addr is not None and min_next_addr <= (sec_addr + sec_size):
211
+ return min_next_addr - sym_addr
212
+ return (sec_addr + sec_size) - sym_addr
213
+
214
+ def _get_relocations_for_section(self, section_index: int) -> dict[int, RelocationInfo]:
215
+ """Extract relocations targeting a specific section."""
216
+ if section_index in self._relocations_by_section:
217
+ return self._relocations_by_section[section_index]
218
+
219
+ relocs: dict[int, RelocationInfo] = {}
220
+ for section in self.elf.iter_sections():
221
+ if isinstance(section, RelocationSection) and section["sh_info"] == section_index:
222
+ symtab = self.elf.get_section(section["sh_link"])
223
+ for rel in section.iter_relocations():
224
+ sym_name = None
225
+ if symtab and rel["r_info_sym"] < symtab.num_symbols():
226
+ target_sym = symtab.get_symbol(rel["r_info_sym"])
227
+ sym_name = target_sym.name
228
+
229
+ relocs[rel["r_offset"]] = RelocationInfo(
230
+ offset=rel["r_offset"],
231
+ symbol_name=sym_name,
232
+ )
233
+ self._relocations_by_section[section_index] = relocs
234
+ return relocs
235
+
236
+ def load_function(self, symbol_name: str) -> LoadedFunction:
237
+ """Extract function bytes and metadata for analysis."""
238
+ symbol = self.find_symbol(symbol_name)
239
+
240
+ if symbol.section_index is None:
241
+ raise BinaryError(f"Symbol '{symbol_name}' has no defined section")
242
+
243
+ section = self.elf.get_section(symbol.section_index)
244
+ if section is None:
245
+ raise BinaryError(
246
+ f"Section index {symbol.section_index} for symbol '{symbol_name}' not found"
247
+ )
248
+
249
+ is_relocatable = self.elf["e_type"] == "ET_REL"
250
+ sec_data = section.data()
251
+ sec_addr = section["sh_addr"]
252
+
253
+ if is_relocatable:
254
+ func_offset = symbol.address
255
+ actual_addr = symbol.address
256
+ else:
257
+ func_offset = symbol.address - sec_addr
258
+ actual_addr = symbol.address
259
+
260
+ if func_offset < 0 or func_offset >= len(sec_data):
261
+ raise BinaryError(
262
+ f"Symbol '{symbol_name}' offset {func_offset} is outside section '{section.name}' (size {len(sec_data)})"
263
+ )
264
+
265
+ func_size = symbol.size
266
+ if func_size == 0:
267
+ func_size = self._infer_symbol_size(symbol, section)
268
+
269
+ if func_size <= 0:
270
+ raise BinaryError(f"Unable to determine size for symbol '{symbol_name}'")
271
+
272
+ code_bytes = sec_data[func_offset : func_offset + func_size]
273
+ relocations = self._get_relocations_for_section(symbol.section_index)
274
+
275
+ # Adjust relocations relative to function address/offset
276
+ func_relocs: dict[int, RelocationInfo] = {}
277
+ for r_offset, r_info in relocations.items():
278
+ if is_relocatable:
279
+ if func_offset <= r_offset < func_offset + func_size:
280
+ func_relocs[r_offset] = r_info
281
+ else:
282
+ if actual_addr <= r_offset < actual_addr + func_size:
283
+ func_relocs[r_offset] = r_info
284
+
285
+ return LoadedFunction(
286
+ symbol=symbol,
287
+ address=actual_addr,
288
+ size=len(code_bytes),
289
+ code_bytes=code_bytes,
290
+ architecture=self.arch,
291
+ is_64bit=self.is_64bit,
292
+ section_name=section.name,
293
+ relocations=func_relocs,
294
+ )
hotflow/cfg.py ADDED
@@ -0,0 +1,255 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
3
+
4
+ """Control-Flow Graph (CFG) representation and construction."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ import networkx as nx
11
+
12
+ from hotflow.disassembly import Instruction, InstructionCategory
13
+
14
+
15
+ class EdgeKind(str, Enum):
16
+ """Types of control-flow edges."""
17
+
18
+ FALLTHROUGH = "fallthrough"
19
+ CONDITIONAL_TAKEN = "conditional_taken"
20
+ UNCONDITIONAL = "unconditional"
21
+ BACKEDGE = "backedge"
22
+
23
+
24
+ @dataclass
25
+ class Edge:
26
+ """An edge between basic blocks in the CFG."""
27
+
28
+ src: int
29
+ dst: int
30
+ kind: EdgeKind
31
+ probability: float = 0.0
32
+ probability_source: str = ""
33
+ confidence: str = "medium"
34
+ reason: str = ""
35
+ frequency: float = 0.0
36
+
37
+
38
+ @dataclass
39
+ class BasicBlock:
40
+ """A basic block of linear machine instructions."""
41
+
42
+ address: int
43
+ size: int
44
+ instructions: list[Instruction] = field(default_factory=list)
45
+ flags: set[str] = field(default_factory=set)
46
+ frequency: float = 0.0
47
+ reach_probability: float = 0.0
48
+
49
+ @property
50
+ def last_instruction(self) -> Instruction | None:
51
+ return self.instructions[-1] if self.instructions else None
52
+
53
+ @property
54
+ def instruction_count(self) -> int:
55
+ return len(self.instructions)
56
+
57
+ @property
58
+ def branch_count(self) -> int:
59
+ return sum(1 for insn in self.instructions if insn.is_branch)
60
+
61
+ @property
62
+ def call_count(self) -> int:
63
+ return sum(1 for insn in self.instructions if insn.is_call)
64
+
65
+ @property
66
+ def return_count(self) -> int:
67
+ return sum(1 for insn in self.instructions if insn.is_return)
68
+
69
+ @property
70
+ def is_exit(self) -> bool:
71
+ return "exit" in self.flags
72
+
73
+ @property
74
+ def is_trap(self) -> bool:
75
+ return "trap" in self.flags or "noreturn" in self.flags
76
+
77
+
78
+ class CFG:
79
+ """Control-Flow Graph for a function."""
80
+
81
+ def __init__(self, entry_address: int) -> None:
82
+ self.entry_address = entry_address
83
+ self.graph: nx.DiGraph[int] = nx.DiGraph()
84
+
85
+ def add_block(self, block: BasicBlock) -> None:
86
+ self.graph.add_node(block.address, block=block)
87
+
88
+ def add_edge(self, edge: Edge) -> None:
89
+ self.graph.add_edge(edge.src, edge.dst, edge=edge)
90
+
91
+ def get_block(self, address: int) -> BasicBlock | None:
92
+ data = self.graph.nodes.get(address)
93
+ return data["block"] if data else None
94
+
95
+ @property
96
+ def blocks(self) -> dict[int, BasicBlock]:
97
+ return {addr: data["block"] for addr, data in self.graph.nodes(data=True)}
98
+
99
+ @property
100
+ def edges(self) -> list[Edge]:
101
+ return [data["edge"] for _, _, data in self.graph.edges(data=True)]
102
+
103
+ def get_outgoing_edges(self, block_address: int) -> list[Edge]:
104
+ return [
105
+ data["edge"]
106
+ for _, _, data in self.graph.out_edges(block_address, data=True)
107
+ ]
108
+
109
+ def get_edge(self, src: int, dst: int) -> Edge | None:
110
+ data = self.graph.get_edge_data(src, dst)
111
+ return data["edge"] if data else None
112
+
113
+ @property
114
+ def exit_blocks(self) -> list[BasicBlock]:
115
+ return [
116
+ data["block"]
117
+ for _, data in self.graph.nodes(data=True)
118
+ if data["block"].is_exit or self.graph.out_degree(data["block"].address) == 0
119
+ ]
120
+
121
+ @property
122
+ def reachable_blocks(self) -> set[int]:
123
+ if not self.graph.has_node(self.entry_address):
124
+ return set()
125
+ return {self.entry_address} | nx.descendants(self.graph, self.entry_address)
126
+
127
+ @property
128
+ def cyclomatic_complexity(self) -> int:
129
+ """Calculate McCabe cyclomatic complexity: E - V + 2P."""
130
+ v = self.graph.number_of_nodes()
131
+ e = self.graph.number_of_edges()
132
+ if v == 0:
133
+ return 0
134
+ return max(1, e - v + 2)
135
+
136
+
137
+ def build_cfg(
138
+ function_address: int,
139
+ function_size: int,
140
+ instructions: list[Instruction],
141
+ ) -> CFG:
142
+ """Build a control-flow graph from disassembled instructions."""
143
+ if not instructions:
144
+ return CFG(function_address)
145
+
146
+ function_end = function_address + function_size
147
+ entry_address = instructions[0].address
148
+ cfg = CFG(entry_address)
149
+ address_to_index = {
150
+ instruction.address: index
151
+ for index, instruction in enumerate(instructions)
152
+ }
153
+ leaders = {entry_address}
154
+
155
+ for index, instruction in enumerate(instructions):
156
+ if instruction.is_branch:
157
+ if (
158
+ instruction.target is not None
159
+ and function_address <= instruction.target < function_end
160
+ and instruction.target in address_to_index
161
+ ):
162
+ leaders.add(instruction.target)
163
+ if index + 1 < len(instructions):
164
+ leaders.add(instructions[index + 1].address)
165
+ elif (instruction.is_return or instruction.is_trap) and index + 1 < len(instructions):
166
+ leaders.add(instructions[index + 1].address)
167
+
168
+ leader_indexes = sorted(address_to_index[address] for address in leaders)
169
+ for position, start in enumerate(leader_indexes):
170
+ end = (
171
+ leader_indexes[position + 1]
172
+ if position + 1 < len(leader_indexes)
173
+ else len(instructions)
174
+ )
175
+ block_instructions = instructions[start:end]
176
+ block = BasicBlock(
177
+ address=block_instructions[0].address,
178
+ size=sum(instruction.size for instruction in block_instructions),
179
+ instructions=block_instructions,
180
+ )
181
+ if block.address == entry_address:
182
+ block.flags.add("entry")
183
+ cfg.add_block(block)
184
+
185
+ block_addresses = sorted(cfg.blocks)
186
+ for index, address in enumerate(block_addresses):
187
+ block = cfg.blocks[address]
188
+ last_instruction = block.last_instruction
189
+ if last_instruction is None:
190
+ continue
191
+ next_address = (
192
+ block_addresses[index + 1]
193
+ if index + 1 < len(block_addresses)
194
+ else None
195
+ )
196
+
197
+ if last_instruction.is_conditional:
198
+ if (
199
+ last_instruction.target is not None
200
+ and last_instruction.target in cfg.blocks
201
+ ):
202
+ cfg.add_edge(
203
+ Edge(
204
+ src=block.address,
205
+ dst=last_instruction.target,
206
+ kind=EdgeKind.CONDITIONAL_TAKEN,
207
+ )
208
+ )
209
+ fallthrough = last_instruction.address + last_instruction.size
210
+ if fallthrough in cfg.blocks:
211
+ cfg.add_edge(
212
+ Edge(
213
+ src=block.address,
214
+ dst=fallthrough,
215
+ kind=EdgeKind.FALLTHROUGH,
216
+ )
217
+ )
218
+ elif last_instruction.is_unconditional:
219
+ if (
220
+ last_instruction.target is not None
221
+ and last_instruction.target in cfg.blocks
222
+ ):
223
+ cfg.add_edge(
224
+ Edge(
225
+ src=block.address,
226
+ dst=last_instruction.target,
227
+ kind=EdgeKind.UNCONDITIONAL,
228
+ )
229
+ )
230
+ else:
231
+ block.flags.add("exit")
232
+ elif last_instruction.is_return:
233
+ block.flags.add("exit")
234
+ elif last_instruction.is_trap:
235
+ block.flags.update(("trap", "exit"))
236
+ elif last_instruction.category == InstructionCategory.INDIRECT_BRANCH:
237
+ block.flags.update(("indirect", "exit"))
238
+ elif (
239
+ next_address is not None
240
+ and last_instruction.address + last_instruction.size == next_address
241
+ ):
242
+ cfg.add_edge(
243
+ Edge(
244
+ src=block.address,
245
+ dst=next_address,
246
+ kind=EdgeKind.FALLTHROUGH,
247
+ )
248
+ )
249
+ else:
250
+ block.flags.add("exit")
251
+
252
+ if not cfg.get_outgoing_edges(block.address):
253
+ block.flags.add("exit")
254
+
255
+ return cfg