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
This commit is contained in:
Ryan Malloy 2026-02-08 13:33:06 -07:00
parent 3c21b9d640
commit 609d97b86d
3 changed files with 95 additions and 17 deletions

View File

@ -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.

View File

@ -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

View File

@ -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