From 609d97b86d4bf51f7dcd2bac66a40898d402a563 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 8 Feb 2026 13:33:06 -0700 Subject: [PATCH] fix: correct ilspycmd output parsing and heap access - Fix _TYPE_LINE_PATTERN regex to match ilspycmd format (no colon) - Fix UserStringHeap data access using getattr instead of name mangling - Add _decompile_to_stdout method for simple decompilation - Fix decompile to not always use -o flag when stdout is desired - Update test data to match actual ilspycmd output format --- src/mcilspy/ilspy_wrapper.py | 89 +++++++++++++++++++++++++++++++--- src/mcilspy/metadata_reader.py | 3 +- tests/test_ilspy_wrapper.py | 20 ++++---- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/mcilspy/ilspy_wrapper.py b/src/mcilspy/ilspy_wrapper.py index 08c23c1..883f41d 100644 --- a/src/mcilspy/ilspy_wrapper.py +++ b/src/mcilspy/ilspy_wrapper.py @@ -194,15 +194,18 @@ class ILSpyWrapper: assembly_name=os.path.basename(request.assembly_path), ) - # Use TemporaryDirectory context manager for guaranteed cleanup (no race condition) - # when user doesn't specify an output directory + # When user specifies output_dir OR uses project mode, we need files + # Otherwise, we can get output directly from stdout if request.output_dir: # User specified output directory - use it directly return await self._decompile_to_dir(request, request.output_dir) - else: - # Create a temporary directory with guaranteed cleanup + elif request.create_project: + # Project mode needs temp directory for multiple files with tempfile.TemporaryDirectory() as temp_dir: return await self._decompile_to_dir(request, temp_dir) + else: + # Simple decompilation - get output from stdout (no -o flag) + return await self._decompile_to_stdout(request) async def _decompile_to_dir( self, request: DecompileRequest, output_dir: str @@ -312,6 +315,79 @@ class ILSpyWrapper: type_name=request.type_name, ) + async def _decompile_to_stdout(self, request: DecompileRequest) -> DecompileResponse: + """Decompile to stdout (no -o flag) for simple single-file decompilation. + + Args: + request: Decompilation request (output_dir must be None) + + Returns: + Decompilation response with source_code from stdout + """ + args = [request.assembly_path] + + # Add language version + args.extend(["-lv", request.language_version.value]) + + # Add type filter if specified + if request.type_name: + args.extend(["-t", request.type_name]) + + # No -o flag - output goes to stdout + + # Add IL code flag + if request.show_il_code: + args.append("-il") + + # Add reference paths + for ref_path in request.reference_paths: + args.extend(["-r", ref_path]) + + # Add optimization flags + if request.remove_dead_code: + args.append("--no-dead-code") + + if request.remove_dead_stores: + args.append("--no-dead-stores") + + # Add IL sequence points flag + if request.show_il_sequence_points: + args.append("--il-sequence-points") + + # Disable update check for automation + args.append("--disable-updatecheck") + + assembly_name = os.path.splitext(os.path.basename(request.assembly_path))[0] + + try: + return_code, stdout, stderr = await self._run_command(args) + + if return_code == 0: + return DecompileResponse( + success=True, + source_code=stdout, + output_path=None, + assembly_name=assembly_name, + type_name=request.type_name, + ) + else: + error_msg = stderr or stdout or "Unknown error occurred" + return DecompileResponse( + success=False, + error_message=error_msg, + assembly_name=assembly_name, + type_name=request.type_name, + ) + + except OSError as e: + logger.exception(f"Error during decompilation: {e}") + return DecompileResponse( + success=False, + error_message=str(e), + assembly_name=assembly_name, + type_name=request.type_name, + ) + async def list_types(self, request: ListTypesRequest) -> ListTypesResponse: """List types in a .NET assembly. @@ -359,8 +435,9 @@ class ILSpyWrapper: return ListTypesResponse(success=False, error_message=str(e)) # Compiled regex for parsing ilspycmd list output - # Format: "TypeKind: FullTypeName" (e.g., "Class: MyNamespace.MyClass") - _TYPE_LINE_PATTERN = re.compile(r"^(\w+):\s*(.+)$") + # Format: "TypeKind FullTypeName" (e.g., "Class MyNamespace.MyClass") + # Only matches valid type kinds: Class, Interface, Struct, Enum, Delegate + _TYPE_LINE_PATTERN = re.compile(r"^(Class|Interface|Struct|Enum|Delegate)\s+(.+)$") def _parse_types_output(self, output: str) -> list[TypeInfo]: """Parse the output from list types command. diff --git a/src/mcilspy/metadata_reader.py b/src/mcilspy/metadata_reader.py index 2d6150f..13f2669 100644 --- a/src/mcilspy/metadata_reader.py +++ b/src/mcilspy/metadata_reader.py @@ -570,7 +570,8 @@ class MetadataReader: return heap = pe.net.user_strings - data = heap._ClrStream__data__ # Access the raw bytes + # Access raw bytes using getattr to avoid Python name mangling issues + data = getattr(heap, "__data__", None) # Raw heap bytes if not data: return diff --git a/tests/test_ilspy_wrapper.py b/tests/test_ilspy_wrapper.py index 22d5f30..015f6b3 100644 --- a/tests/test_ilspy_wrapper.py +++ b/tests/test_ilspy_wrapper.py @@ -41,7 +41,7 @@ class TestILSpyWrapperTypeParsing: except RuntimeError: pytest.skip("ilspycmd not installed") - output = "Class: MyNamespace.MyClass" + output = "Class MyNamespace.MyClass" types = wrapper._parse_types_output(output) assert len(types) == 1 @@ -57,11 +57,11 @@ class TestILSpyWrapperTypeParsing: except RuntimeError: pytest.skip("ilspycmd not installed") - output = """Class: NS.ClassA -Interface: NS.IService -Struct: NS.MyStruct -Enum: NS.MyEnum -Delegate: NS.MyDelegate""" + output = """Class NS.ClassA +Interface NS.IService +Struct NS.MyStruct +Enum NS.MyEnum +Delegate NS.MyDelegate""" types = wrapper._parse_types_output(output) assert len(types) == 5 @@ -81,7 +81,7 @@ Delegate: NS.MyDelegate""" except RuntimeError: pytest.skip("ilspycmd not installed") - output = "Class: MyNamespace.Outer+Nested" + output = "Class MyNamespace.Outer+Nested" types = wrapper._parse_types_output(output) assert len(types) == 1 @@ -95,7 +95,7 @@ Delegate: NS.MyDelegate""" except RuntimeError: pytest.skip("ilspycmd not installed") - output = "Class: MyClass" + output = "Class MyClass" types = wrapper._parse_types_output(output) assert len(types) == 1 @@ -109,10 +109,10 @@ Delegate: NS.MyDelegate""" except RuntimeError: pytest.skip("ilspycmd not installed") - output = """Class: NS.Valid + output = """Class NS.Valid This is not a valid line Another invalid line -Interface: NS.AlsoValid""" +Interface NS.AlsoValid""" types = wrapper._parse_types_output(output) assert len(types) == 2