From 036f392d613c9bd816ef498deed89f4ce97021ca Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Tue, 9 Jun 2026 21:58:38 -0300 Subject: [PATCH] feat(analysis): add string extraction and API support --- engines/ida66/extract.py | 81 ++++++++- .../ai/service/EmbeddingChunkBuilder.java | 56 +++++++ .../ai/service/EmbeddingService.java | 8 + .../controller/AnalysisController.java | 11 ++ .../analysis/model/dto/StringResponse.java | 12 ++ .../analysis/model/entity/StaticString.java | 35 ++++ .../repository/StaticStringRepository.java | 10 ++ .../analysis/service/AnalysisService.java | 26 +++ .../engine/model/AnalysisResult.java | 11 +- .../decompile/engine/model/StringEntry.java | 3 + .../service/AbstractIdaEngineService.java | 83 ++++++++- .../migration/V014__create_static_string.sql | 12 ++ .../service/AbstractIdaEngineServiceTest.java | 157 ++++++++++++++++++ 13 files changed, 502 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ai/decompile/analysis/model/dto/StringResponse.java create mode 100644 src/main/java/ai/decompile/analysis/model/entity/StaticString.java create mode 100644 src/main/java/ai/decompile/analysis/model/repository/StaticStringRepository.java create mode 100644 src/main/java/ai/decompile/engine/model/StringEntry.java create mode 100644 src/main/resources/db/migration/V014__create_static_string.sql create mode 100644 src/test/java/ai/decompile/engine/service/AbstractIdaEngineServiceTest.java diff --git a/engines/ida66/extract.py b/engines/ida66/extract.py index 580b0b8..0a05834 100644 --- a/engines/ida66/extract.py +++ b/engines/ida66/extract.py @@ -11,6 +11,7 @@ import idaapi import idautils OUTPUT = "output.json" +DEBUG_LOG = "extract_debug.log" # Cross-reference type constants (IDA SDK values) FL_CF = 16 # Call Far @@ -18,6 +19,34 @@ FL_CN = 17 # Call Near FL_JF = 18 # Jump Far FL_JN = 19 # Jump Near +# String type constants (may not be in idc namespace in all IDA versions) +try: + _ASCSTR_C = idc.ASCSTR_C + _ASCSTR_UNICODE = idc.ASCSTR_UNICODE + _ASCSTR_ULEN2 = idc.ASCSTR_ULEN2 + _ASCSTR_ULEN4 = idc.ASCSTR_ULEN4 + _ASCSTR_PASCAL = idc.ASCSTR_PASCAL + _ASCSTR_LEN2 = idc.ASCSTR_LEN2 + _ASCSTR_LEN4 = idc.ASCSTR_LEN4 +except AttributeError: + _ASCSTR_C = 0 + _ASCSTR_UNICODE = 4 + _ASCSTR_ULEN2 = 5 + _ASCSTR_ULEN4 = 6 + _ASCSTR_PASCAL = 1 + _ASCSTR_LEN2 = 2 + _ASCSTR_LEN4 = 3 + +_STRING_TYPE_NAMES = { + _ASCSTR_C: "ASCSTR_C", + _ASCSTR_UNICODE: "ASCSTR_UNICODE", + _ASCSTR_ULEN2: "ASCSTR_ULEN2", + _ASCSTR_ULEN4: "ASCSTR_ULEN4", + _ASCSTR_PASCAL: "ASCSTR_PASCAL", + _ASCSTR_LEN2: "ASCSTR_LEN2", + _ASCSTR_LEN4: "ASCSTR_LEN4", +} + def msg(text): idc.Message(text) @@ -25,6 +54,15 @@ def msg(text): sys.stderr.flush() +def dbg(text): + try: + with open(DEBUG_LOG, "a") as df: + df.write(text) + df.flush() + except: + pass + + def _json_string(s): """Escape a string for JSON — returns the quoted representation. Handles raw bytes in disassembly that aren't valid UTF-8.""" @@ -32,7 +70,7 @@ def _json_string(s): return json.dumps(s) try: return json.dumps(s) - except UnicodeDecodeError: + except (UnicodeDecodeError, UnicodeEncodeError): return json.dumps(s.decode('latin-1')) @@ -150,6 +188,43 @@ def extract(f): f.write("\n ],\n") f.flush() + # ---- strings ---- + f.write(" \"strings\": [\n") + f.flush() + try: + strings = list(idautils.Strings()) + except Exception as e: + dbg("idautils.Strings() failed: %s\n" % str(e)) + strings = [] + string_count = 0 + for i, s in enumerate(strings): + try: + str_value = str(s) + if len(str_value) < 2: + continue + str_type_id = idc.GetStringType(s.ea) + if str_type_id is None: + str_type_id = -1 + encoding = _STRING_TYPE_NAMES.get(str_type_id, "UNKNOWN") + ref_parts = [] + for xref in idautils.XrefsTo(s.ea): + func_name = idc.GetFunctionName(xref.frm) + if func_name: + ref_parts.append(func_name) + referenced_by = " ".join(ref_parts) + if string_count > 0: + f.write(",\n") + f.write( + " {\"address\": \"%s\", \"value\": %s, \"encoding\": \"%s\", \"referencedBy\": %s}" + % (_json_hex(s.ea), _json_string(str_value), encoding, _json_string(referenced_by)) + ) + string_count += 1 + except Exception as e: + dbg("String at %s failed: %s\n" % (_json_hex(s.ea), str(e))) + f.write("\n ],\n") + f.flush() + msg("[extract.py] Extraidas %d strings.\n" % string_count) + # ---- functions ---- f.write(" \"functions\": [\n") f.flush() @@ -212,6 +287,10 @@ def main(): except Exception as e: msg("[extract.py] ERRO na extracao: %s\n" % str(e)) msg(traceback.format_exc() + "\n") + try: + dbg("FATAL in extract(): %s\n%s\n" % (str(e), traceback.format_exc())) + except: + pass finally: f.close() diff --git a/src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java b/src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java index 47494fa..c9a7823 100644 --- a/src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java +++ b/src/main/java/ai/decompile/ai/service/EmbeddingChunkBuilder.java @@ -3,9 +3,11 @@ package ai.decompile.ai.service; import ai.decompile.ai.model.entity.EmbeddingChunk; import ai.decompile.analysis.model.entity.StaticAnalysis; import ai.decompile.analysis.model.entity.StaticFunction; +import ai.decompile.analysis.model.entity.StaticString; import ai.decompile.analysis.model.entity.StaticXref; import ai.decompile.analysis.model.repository.StaticLabelRepository; import ai.decompile.analysis.model.repository.StaticSegmentRepository; +import ai.decompile.analysis.model.repository.StaticStringRepository; import ai.decompile.analysis.model.repository.StaticXrefRepository; import ai.decompile.workspace.model.entity.Binary; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,11 +30,14 @@ public class EmbeddingChunkBuilder { private final StaticXrefRepository xrefRepository; private final StaticLabelRepository labelRepository; private final StaticSegmentRepository segmentRepository; + private final StaticStringRepository stringRepository; private final ObjectMapper objectMapper; @Value("${app.ai.embedding.chunk-max-chars:3000}") private int chunkMaxChars; + private static final int METADATA_TOP_STRINGS = 20; + public List buildFunctionChunks(List functions, Binary binary) { var graphContext = loadGraphContext(functions); @@ -81,6 +86,19 @@ public class EmbeddingChunkBuilder { } } + var strings = stringRepository.findByAnalysisIdOrderByAddressAsc(analysis.getId()); + if (!strings.isEmpty()) { + sb.append("Top strings:\n"); + strings.stream() + .limit(METADATA_TOP_STRINGS) + .forEach( + s -> + sb.append( + String.format( + " %s (%s): \"%s\"\n", + s.getAddress(), s.getEncoding(), AiUtils.truncate(s.getValue(), 200)))); + } + return EmbeddingChunk.builder() .id(UUID.randomUUID()) .binaryId(binary.getId()) @@ -94,6 +112,44 @@ public class EmbeddingChunkBuilder { .build(); } + public List buildStringChunks(List strings, Binary binary) { + var chunks = new ArrayList(); + for (var s : strings) { + var sb = new StringBuilder(); + sb.append(String.format("[STRING] at %s (%s)\n", s.getAddress(), s.getEncoding())); + if (Objects.nonNull(s.getReferencedBy()) && !s.getReferencedBy().isBlank()) { + sb.append("Referenced by: ").append(s.getReferencedBy()).append("\n"); + } + sb.append("\"").append(s.getValue()).append("\""); + var content = sb.toString(); + chunks.add( + EmbeddingChunk.builder() + .id(UUID.randomUUID()) + .binaryId(binary.getId()) + .chunkType("STRING") + .sourceType("STATIC_STRING") + .sourceId(s.getId()) + .content(content) + .metadata( + AiUtils.toJson( + objectMapper, + Map.of( + "address", + s.getAddress(), + "encoding", + s.getEncoding(), + "length", + s.getValue().length(), + "referenced_by", + Objects.nonNull(s.getReferencedBy()) ? s.getReferencedBy() : ""))) + .tokenCount(AiUtils.estimateTokens(content)) + .createdAt(java.time.Instant.now()) + .build()); + } + + return chunks; + } + private FunctionGraphContext loadGraphContext(List functions) { var nameToCallers = loadXrefNames(functions, xrefRepository::findByCalleeId, xr -> xr.getCaller().getName()); diff --git a/src/main/java/ai/decompile/ai/service/EmbeddingService.java b/src/main/java/ai/decompile/ai/service/EmbeddingService.java index 968a418..2c8e16f 100644 --- a/src/main/java/ai/decompile/ai/service/EmbeddingService.java +++ b/src/main/java/ai/decompile/ai/service/EmbeddingService.java @@ -4,6 +4,7 @@ import ai.decompile.ai.model.entity.EmbeddingChunk; import ai.decompile.ai.model.repository.EmbeddingChunkRepository; import ai.decompile.analysis.model.entity.StaticAnalysis; import ai.decompile.analysis.model.repository.StaticAnalysisRepository; +import ai.decompile.analysis.model.repository.StaticStringRepository; import ai.decompile.workspace.service.BinaryService; import java.util.ArrayList; import java.util.List; @@ -25,6 +26,7 @@ public class EmbeddingService { private final EmbeddingChunkBuilder chunkBuilder; private final EmbeddingChunkRepository embeddingChunkRepository; private final StaticAnalysisRepository analysisRepository; + private final StaticStringRepository stringRepository; private final BinaryService binaryService; @Value("${app.ai.embedding.batch-size:20}") @@ -53,6 +55,12 @@ public class EmbeddingService { var functionChunks = chunkBuilder.buildFunctionChunks(functions, binary); var allChunks = new ArrayList<>(functionChunks); allChunks.add(chunkBuilder.buildMetadataChunk(binary, analysis, functions.size())); + + var strings = stringRepository.findByAnalysisIdOrderByAddressAsc(analysis.getId()); + if (!strings.isEmpty()) { + allChunks.addAll(chunkBuilder.buildStringChunks(strings, binary)); + } + embedAndPersistChunks(allChunks); log.info("Embedding complete for binary {}: {} chunks", binaryId, allChunks.size()); diff --git a/src/main/java/ai/decompile/analysis/controller/AnalysisController.java b/src/main/java/ai/decompile/analysis/controller/AnalysisController.java index 91323b0..a6d67d2 100644 --- a/src/main/java/ai/decompile/analysis/controller/AnalysisController.java +++ b/src/main/java/ai/decompile/analysis/controller/AnalysisController.java @@ -3,6 +3,7 @@ package ai.decompile.analysis.controller; import ai.decompile.analysis.model.dto.AnalysisResponse; import ai.decompile.analysis.model.dto.FunctionSummaryResponse; import ai.decompile.analysis.model.dto.SegmentResponse; +import ai.decompile.analysis.model.dto.StringResponse; import ai.decompile.analysis.service.AnalysisService; import io.swagger.v3.oas.annotations.Operation; import java.util.List; @@ -58,4 +59,14 @@ public class AnalysisController { public List getSegments(@PathVariable UUID analysisId) { return analysisService.getSegments(analysisId); } + + @Operation( + summary = "List strings in an analysis", + description = "Retrieve all strings extracted during a static analysis.", + tags = {"Analysis"}, + operationId = "listStrings") + @GetMapping("/analysis/{analysisId}/strings") + public List getStrings(@PathVariable UUID analysisId) { + return analysisService.getStrings(analysisId); + } } diff --git a/src/main/java/ai/decompile/analysis/model/dto/StringResponse.java b/src/main/java/ai/decompile/analysis/model/dto/StringResponse.java new file mode 100644 index 0000000..63467be --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/StringResponse.java @@ -0,0 +1,12 @@ +package ai.decompile.analysis.model.dto; + +import ai.decompile.analysis.model.entity.StaticString; +import java.util.UUID; + +public record StringResponse( + UUID id, String address, String value, String encoding, String referencedBy) { + public static StringResponse from(StaticString s) { + return new StringResponse( + s.getId(), s.getAddress(), s.getValue(), s.getEncoding(), s.getReferencedBy()); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/entity/StaticString.java b/src/main/java/ai/decompile/analysis/model/entity/StaticString.java new file mode 100644 index 0000000..be9393a --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/StaticString.java @@ -0,0 +1,35 @@ +package ai.decompile.analysis.model.entity; + +import jakarta.persistence.*; +import java.util.UUID; +import lombok.*; + +@Entity +@Table(name = "static_string") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StaticString { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "analysis_id", nullable = false) + private StaticAnalysis analysis; + + @Column(nullable = false, length = 20) + private String address; + + @Column(nullable = false, columnDefinition = "TEXT") + private String value; + + @Column(nullable = false, length = 20) + private String encoding; + + @Column(name = "referenced_by", nullable = false, columnDefinition = "TEXT DEFAULT ''") + @Builder.Default + private String referencedBy = ""; +} diff --git a/src/main/java/ai/decompile/analysis/model/repository/StaticStringRepository.java b/src/main/java/ai/decompile/analysis/model/repository/StaticStringRepository.java new file mode 100644 index 0000000..bd5cd04 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/StaticStringRepository.java @@ -0,0 +1,10 @@ +package ai.decompile.analysis.model.repository; + +import ai.decompile.analysis.model.entity.StaticString; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StaticStringRepository extends JpaRepository { + List findByAnalysisIdOrderByAddressAsc(UUID analysisId); +} diff --git a/src/main/java/ai/decompile/analysis/service/AnalysisService.java b/src/main/java/ai/decompile/analysis/service/AnalysisService.java index ba525bb..44da36e 100644 --- a/src/main/java/ai/decompile/analysis/service/AnalysisService.java +++ b/src/main/java/ai/decompile/analysis/service/AnalysisService.java @@ -5,16 +5,19 @@ import ai.decompile.analysis.model.dto.FunctionDetailResponse; import ai.decompile.analysis.model.dto.FunctionSummaryResponse; import ai.decompile.analysis.model.dto.LabelResponse; import ai.decompile.analysis.model.dto.SegmentResponse; +import ai.decompile.analysis.model.dto.StringResponse; import ai.decompile.analysis.model.dto.XrefResponse; import ai.decompile.analysis.model.entity.StaticAnalysis; import ai.decompile.analysis.model.entity.StaticFunction; import ai.decompile.analysis.model.entity.StaticLabel; import ai.decompile.analysis.model.entity.StaticSegment; +import ai.decompile.analysis.model.entity.StaticString; import ai.decompile.analysis.model.entity.StaticXref; import ai.decompile.analysis.model.repository.StaticAnalysisRepository; import ai.decompile.analysis.model.repository.StaticFunctionRepository; import ai.decompile.analysis.model.repository.StaticLabelRepository; import ai.decompile.analysis.model.repository.StaticSegmentRepository; +import ai.decompile.analysis.model.repository.StaticStringRepository; import ai.decompile.analysis.model.repository.StaticXrefRepository; import ai.decompile.common.exception.NotFoundException; import ai.decompile.engine.model.AnalysisResult; @@ -41,6 +44,7 @@ public class AnalysisService { private final StaticXrefRepository xrefRepository; private final StaticLabelRepository labelRepository; private final StaticSegmentRepository segmentRepository; + private final StaticStringRepository stringRepository; @Transactional public AnalysisResponse saveResult( @@ -49,6 +53,7 @@ public class AnalysisService { var analysis = createAnalysis(binary, jobId, engine, result); saveSegments(analysis, result.segments()); + saveStrings(analysis, result.strings()); var nameToId = saveFunctions(analysis, result); saveXrefs(result, nameToId); @@ -96,6 +101,13 @@ public class AnalysisService { .toList(); } + public List getStrings(UUID analysisId) { + assertAnalysisExists(analysisId); + return stringRepository.findByAnalysisIdOrderByAddressAsc(analysisId).stream() + .map(StringResponse::from) + .toList(); + } + private void replaceExistingAnalysis(Binary binary, String engine) { analysisRepository .findByBinaryIdAndEngine(binary.getId(), engine) @@ -123,6 +135,20 @@ public class AnalysisService { } } + private void saveStrings( + StaticAnalysis analysis, List entries) { + for (var entry : entries) { + stringRepository.save( + StaticString.builder() + .analysis(analysis) + .address(entry.address()) + .value(entry.value()) + .encoding(entry.encoding()) + .referencedBy(entry.referencedBy()) + .build()); + } + } + private StaticAnalysis createAnalysis( Binary binary, UUID jobId, String engine, AnalysisResult result) { return analysisRepository.save( diff --git a/src/main/java/ai/decompile/engine/model/AnalysisResult.java b/src/main/java/ai/decompile/engine/model/AnalysisResult.java index 5c2b5d7..ec5e95e 100644 --- a/src/main/java/ai/decompile/engine/model/AnalysisResult.java +++ b/src/main/java/ai/decompile/engine/model/AnalysisResult.java @@ -3,4 +3,13 @@ package ai.decompile.engine.model; import java.util.List; public record AnalysisResult( - String entryPoint, List segments, List functions) {} + String entryPoint, + List segments, + List functions, + List strings) { + + public AnalysisResult( + String entryPoint, List segments, List functions) { + this(entryPoint, segments, functions, List.of()); + } +} diff --git a/src/main/java/ai/decompile/engine/model/StringEntry.java b/src/main/java/ai/decompile/engine/model/StringEntry.java new file mode 100644 index 0000000..924c337 --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/StringEntry.java @@ -0,0 +1,3 @@ +package ai.decompile.engine.model; + +public record StringEntry(String address, String value, String encoding, String referencedBy) {} diff --git a/src/main/java/ai/decompile/engine/service/AbstractIdaEngineService.java b/src/main/java/ai/decompile/engine/service/AbstractIdaEngineService.java index 026819d..50bf8e9 100644 --- a/src/main/java/ai/decompile/engine/service/AbstractIdaEngineService.java +++ b/src/main/java/ai/decompile/engine/service/AbstractIdaEngineService.java @@ -6,6 +6,7 @@ import ai.decompile.engine.model.AnalysisResult; import ai.decompile.engine.model.FunctionResult; import ai.decompile.engine.model.LabelEntry; import ai.decompile.engine.model.SegmentEntry; +import ai.decompile.engine.model.StringEntry; import ai.decompile.engine.model.XrefEntry; import com.github.dockerjava.api.model.Bind; import java.io.File; @@ -36,6 +37,11 @@ public abstract class AbstractIdaEngineService implements EngineService { private static final String KEY_SEGMENT_NAME = "\"name\": \""; private static final String KEY_SEGMENT_START = "\", \"start\": \""; private static final String KEY_SEGMENT_END = "\", \"end\": \""; + private static final String KEY_STRINGS_START = "\"strings\": ["; + private static final String KEY_STRING_ADDRESS = "\"address\": \""; + private static final String KEY_STRING_VALUE = "\", \"value\": \""; + private static final String KEY_STRING_ENCODING = "\", \"encoding\": \""; + private static final String KEY_STRING_REFERENCED_BY = ", \"referencedBy\": \""; protected final ContainerService containers; protected final EngineProperties engineProperties; @@ -144,6 +150,7 @@ public abstract class AbstractIdaEngineService implements EngineService { var entryPoint = parseEntryPoint(text); var segments = parseSegments(text); + var strings = parseStrings(text); var functions = new ArrayList(); @@ -161,7 +168,7 @@ public abstract class AbstractIdaEngineService implements EngineService { raw.length() > 500 ? raw.substring(0, 500) : raw); } - return new AnalysisResult(entryPoint, segments, functions); + return new AnalysisResult(entryPoint, segments, functions, strings); } private static String parseEntryPoint(String text) { @@ -333,6 +340,80 @@ public abstract class AbstractIdaEngineService implements EngineService { return new SegmentEntry(name, startAddress, endAddress); } + private static List parseStrings(String text) { + var strings = new ArrayList(); + var remainder = after(text, KEY_STRINGS_START); + if (Objects.isNull(remainder)) { + return strings; + } + + var stringsBlock = extractField(remainder, "\n ],\n"); + if (Objects.isNull(stringsBlock)) { + return strings; + } + + var parts = stringsBlock.split("},\\s*\\{"); + for (var part : parts) { + var string = parseStringEntry(part); + if (Objects.nonNull(string)) { + strings.add(string); + } + } + + return strings; + } + + private static StringEntry parseStringEntry(String part) { + var clean = part.replace("{", "").replace("}", "").trim(); + if (clean.isBlank()) { + return null; + } + + var addressRemainder = after(clean, KEY_STRING_ADDRESS); + if (Objects.isNull(addressRemainder)) { + return null; + } + + var address = extractField(addressRemainder, KEY_STRING_VALUE); + if (Objects.isNull(address)) { + return null; + } + + var valueRemainder = after(addressRemainder, KEY_STRING_VALUE); + if (Objects.isNull(valueRemainder)) { + return null; + } + + var value = extractField(valueRemainder, KEY_STRING_ENCODING); + if (Objects.isNull(value)) { + return null; + } + + var encodingRemainder = after(valueRemainder, KEY_STRING_ENCODING); + if (Objects.isNull(encodingRemainder)) { + return null; + } + + var encoding = extractField(encodingRemainder, "\""); + if (Objects.isNull(encoding)) { + return null; + } + + var afterEncoding = after(encodingRemainder, "\""); + var referencedBy = ""; + if (Objects.nonNull(afterEncoding)) { + var refRemainder = after(afterEncoding, KEY_STRING_REFERENCED_BY); + if (Objects.nonNull(refRemainder)) { + var ref = extractField(refRemainder, "\""); + if (Objects.nonNull(ref)) { + referencedBy = ref; + } + } + } + + return new StringEntry(address, value, encoding, referencedBy); + } + private static List parseXrefs(String raw) { if (Objects.isNull(raw) || raw.isBlank()) { return List.of(); diff --git a/src/main/resources/db/migration/V014__create_static_string.sql b/src/main/resources/db/migration/V014__create_static_string.sql new file mode 100644 index 0000000..4a527d2 --- /dev/null +++ b/src/main/resources/db/migration/V014__create_static_string.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS static_string +( + id UUID PRIMARY KEY, + analysis_id UUID NOT NULL REFERENCES static_analysis (id) ON DELETE CASCADE, + address VARCHAR(20) NOT NULL, + value TEXT NOT NULL, + encoding VARCHAR(20) NOT NULL, + referenced_by TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_static_string_analysis_id ON static_string (analysis_id); + diff --git a/src/test/java/ai/decompile/engine/service/AbstractIdaEngineServiceTest.java b/src/test/java/ai/decompile/engine/service/AbstractIdaEngineServiceTest.java new file mode 100644 index 0000000..abd4baa --- /dev/null +++ b/src/test/java/ai/decompile/engine/service/AbstractIdaEngineServiceTest.java @@ -0,0 +1,157 @@ +package ai.decompile.engine.service; + +import static org.junit.jupiter.api.Assertions.*; + +import ai.decompile.engine.model.AnalysisResult; +import org.junit.jupiter.api.Test; + +class AbstractIdaEngineServiceTest { + + @Test + void shouldParseStringsWhenPresent() { + var json = + """ + { + "entry_point": "0x00401000:start", + "segments": [], + "strings": [ + {"address": "0x00403000", "value": "Hello World", "encoding": "ASCSTR_C", "referencedBy": "sub_1000 sub_2500"}, + {"address": "0x00403010", "value": "path/to/file", "encoding": "ASCSTR_C", "referencedBy": ""}, + {"address": "0x00403020", "value": "test", "encoding": "UNKNOWN", "referencedBy": "sub_3000"} + ], + "functions": [] + } + """; + + var result = invokeParseOutput(json); + + assertNotNull(result); + assertEquals(3, result.strings().size()); + assertEquals("0x00403000", result.strings().get(0).address()); + assertEquals("Hello World", result.strings().get(0).value()); + assertEquals("ASCSTR_C", result.strings().get(0).encoding()); + assertEquals("sub_1000 sub_2500", result.strings().get(0).referencedBy()); + assertEquals("0x00403010", result.strings().get(1).address()); + assertEquals("path/to/file", result.strings().get(1).value()); + assertEquals("", result.strings().get(1).referencedBy()); + assertEquals("0x00403020", result.strings().get(2).address()); + assertEquals("test", result.strings().get(2).value()); + assertEquals("UNKNOWN", result.strings().get(2).encoding()); + assertEquals("sub_3000", result.strings().get(2).referencedBy()); + } + + @Test + void shouldReturnEmptyStringsWhenBlockAbsent() { + var json = + """ + { + "entry_point": "0x00401000:start", + "segments": [], + "functions": [] + } + """; + + var result = invokeParseOutput(json); + + assertNotNull(result); + assertTrue(result.strings().isEmpty()); + } + + @Test + void shouldReturnEmptyStringsWhenBlockEmpty() { + var json = + """ + { + "entry_point": "0x00401000:start", + "segments": [], + "strings": [], + "functions": [] + } + """; + + var result = invokeParseOutput(json); + + assertNotNull(result); + assertTrue(result.strings().isEmpty()); + } + + @Test + void shouldHandleSegmentsAndStringsTogether() { + var json = + """ + { + "entry_point": "0x00401000:start", + "segments": [ + {"name": ".text", "start": "0x00401000", "end": "0x00408000"} + ], + "strings": [ + {"address": "0x0040A000", "value": "error", "encoding": "ASCSTR_C", "referencedBy": ""} + ], + "functions": [] + } + """; + + var result = invokeParseOutput(json); + + assertNotNull(result); + assertEquals(1, result.segments().size()); + assertEquals(1, result.strings().size()); + assertEquals("error", result.strings().get(0).value()); + } + + @Test + void shouldDefaultReferencedByToEmptyWhenFieldMissing() { + var json = + """ + { + "entry_point": "0x00401000:start", + "segments": [], + "strings": [ + {"address": "0x00403000", "value": "legacy", "encoding": "ASCSTR_C"} + ], + "functions": [] + } + """; + + var result = invokeParseOutput(json); + + assertNotNull(result); + assertEquals(1, result.strings().size()); + assertEquals("legacy", result.strings().get(0).value()); + assertEquals("", result.strings().get(0).referencedBy()); + } + + private AnalysisResult invokeParseOutput(String json) { + try { + var method = AbstractIdaEngineService.class.getDeclaredMethod("parseOutput", String.class); + method.setAccessible(true); + + var dummyService = + new AbstractIdaEngineService(null, null) { + @Override + protected String getDockerfilePath() { + return null; + } + + @Override + protected String getConfigKey() { + return null; + } + + @Override + protected String getTempDirPrefix() { + return null; + } + + @Override + public ai.decompile.engine.model.EngineType supportedEngine() { + return ai.decompile.engine.model.EngineType.IDA66; + } + }; + + return (AnalysisResult) method.invoke(dummyService, json); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +}