unidecompiler-plugin-jvm-class 0.1.1__tar.gz

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.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: unidecompiler-plugin-jvm-class
3
+ Version: 0.1.1
4
+ Summary: JVM class-file frontend plugin for unidecompiler
5
+ Author-email: Wker <1670133844@qq.com>
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/Wker666/unidecompiler
8
+ Project-URL: Repository, https://github.com/Wker666/unidecompiler
9
+ Project-URL: Issues, https://github.com/Wker666/unidecompiler/issues
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: unidecompiler<0.2.0,>=0.1.1
13
+ Requires-Dist: jawa<3,>=2.2.0
14
+
15
+ # unidecompiler-plugin-jvm-class
16
+
17
+ Frontend plugin for JVM `.class` files. It uses `jawa` to read class files and
18
+ submits neutral thin IR to `unidecompiler` for recovery and rendering.
19
+
20
+ Install with:
21
+
22
+ ```sh
23
+ python -m pip install unidecompiler-plugin-jvm-class
24
+ ```
25
+
26
+ The plugin is discovered automatically by compatible CLI and GUI hosts.
@@ -0,0 +1,12 @@
1
+ # unidecompiler-plugin-jvm-class
2
+
3
+ Frontend plugin for JVM `.class` files. It uses `jawa` to read class files and
4
+ submits neutral thin IR to `unidecompiler` for recovery and rendering.
5
+
6
+ Install with:
7
+
8
+ ```sh
9
+ python -m pip install unidecompiler-plugin-jvm-class
10
+ ```
11
+
12
+ The plugin is discovered automatically by compatible CLI and GUI hosts.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unidecompiler-plugin-jvm-class"
7
+ version = "0.1.1"
8
+ description = "JVM class-file frontend plugin for unidecompiler"
9
+ readme = "README.md"
10
+ license = "AGPL-3.0-or-later"
11
+ authors = [{ name = "Wker", email = "1670133844@qq.com" }]
12
+ requires-python = ">=3.11"
13
+ dependencies = ["unidecompiler>=0.1.1,<0.2.0", "jawa>=2.2.0,<3"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/Wker666/unidecompiler"
17
+ Repository = "https://github.com/Wker666/unidecompiler"
18
+ Issues = "https://github.com/Wker666/unidecompiler/issues"
19
+
20
+ [project.entry-points."unidecompiler.frontends"]
21
+ jvm-class = "unidecompiler_plugin_jvm_class.plugin:JavaClassFrontendPlugin"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """JVM .class frontend."""
2
+
@@ -0,0 +1,352 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from io import BytesIO
5
+ from typing import Protocol
6
+
7
+ from unidecompiler.plugins import FrontendDecodeError
8
+
9
+
10
+ CLASS_MAGIC = b"\xca\xfe\xba\xbe"
11
+
12
+
13
+ class ClassDecodeError(FrontendDecodeError):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class JavaInstruction:
19
+ offset: int
20
+ opcode: str
21
+ operands: str = ""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class JavaExceptionRegion:
26
+ start: int
27
+ end: int
28
+ target: int
29
+ exception_type: str | None = None
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class JavaMethodListing:
34
+ name: str
35
+ descriptor: str | None = None
36
+ is_static: bool = False
37
+ is_annotation_member: bool = False
38
+ annotation_default: object | None = None
39
+ instructions: tuple[JavaInstruction, ...] = ()
40
+ exception_regions: tuple[JavaExceptionRegion, ...] = ()
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class JavaClassFile:
45
+ major_version: int
46
+ minor_version: int
47
+ class_name: str | None = None
48
+ is_annotation: bool = False
49
+ methods: tuple[JavaMethodListing, ...] = ()
50
+ filename: str | None = None
51
+ decoder_id: str | None = None
52
+
53
+
54
+ class ClassFileDecoder(Protocol):
55
+ id: str
56
+
57
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
58
+ ...
59
+
60
+ def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
61
+ ...
62
+
63
+
64
+ def looks_like_class(data: bytes) -> bool:
65
+ return data.startswith(CLASS_MAGIC)
66
+
67
+
68
+ def decode_class_header(data: bytes, filename: str | None = None) -> JavaClassFile:
69
+ if len(data) < 8:
70
+ raise ClassDecodeError("truncated class file")
71
+ if not looks_like_class(data):
72
+ raise ClassDecodeError("missing class magic")
73
+ return JavaClassFile(
74
+ minor_version=int.from_bytes(data[4:6], "big"),
75
+ major_version=int.from_bytes(data[6:8], "big"),
76
+ filename=filename,
77
+ decoder_id="class-header-only",
78
+ )
79
+
80
+
81
+ class HeaderOnlyClassFileDecoder:
82
+ id = "class-header-only"
83
+
84
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
85
+ return looks_like_class(data)
86
+
87
+ def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
88
+ return decode_class_header(data, filename)
89
+
90
+
91
+ class JawaClassFileDecoder:
92
+ """JVM classfile decoder backed by the third-party ``jawa`` library."""
93
+
94
+ id = "jawa"
95
+
96
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
97
+ return looks_like_class(data) and _jawa_class_file_type() is not None
98
+
99
+ def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
100
+ class_file_type = _jawa_class_file_type()
101
+ if class_file_type is None:
102
+ raise ClassDecodeError("jawa is not installed")
103
+ if not looks_like_class(data):
104
+ raise ClassDecodeError("missing class magic")
105
+ try:
106
+ parsed = class_file_type(BytesIO(data))
107
+ except Exception as error: # pragma: no cover - jawa owns parse details.
108
+ raise ClassDecodeError(f"jawa failed to decode class: {error}") from error
109
+ class_name = _jawa_utf8(parsed.this.name)
110
+ return JavaClassFile(
111
+ minor_version=parsed.version.minor,
112
+ major_version=parsed.version.major,
113
+ class_name=class_name,
114
+ is_annotation=bool(parsed.access_flags.acc_annotation),
115
+ methods=tuple(_jawa_method_listing(parsed, method) for method in parsed.methods),
116
+ filename=filename,
117
+ decoder_id=self.id,
118
+ )
119
+
120
+
121
+ class PreferredClassFileDecoder:
122
+ id = "class-preferred"
123
+
124
+ def __init__(self, decoders: tuple[ClassFileDecoder, ...] | None = None) -> None:
125
+ self.decoders = decoders or (
126
+ JawaClassFileDecoder(),
127
+ HeaderOnlyClassFileDecoder(),
128
+ )
129
+
130
+ def can_decode(self, data: bytes, filename: str | None = None) -> bool:
131
+ return any(decoder.can_decode(data, filename) for decoder in self.decoders)
132
+
133
+ def decode(self, data: bytes, filename: str | None = None) -> JavaClassFile:
134
+ errors: list[str] = []
135
+ for decoder in self.decoders:
136
+ if not decoder.can_decode(data, filename):
137
+ continue
138
+ try:
139
+ return decoder.decode(data, filename)
140
+ except ClassDecodeError as error:
141
+ errors.append(f"{decoder.id}: {error}")
142
+ if errors:
143
+ raise ClassDecodeError("; ".join(errors))
144
+ raise ClassDecodeError("no JVM class decoder can decode this input")
145
+
146
+
147
+ def _jawa_class_file_type():
148
+ try:
149
+ from jawa.cf import ClassFile
150
+ except ImportError:
151
+ return None
152
+ return ClassFile
153
+
154
+
155
+ def _jawa_method_listing(class_file, method) -> JavaMethodListing:
156
+ raw_name = _jawa_utf8(method.name) or "<method>"
157
+ class_name = _jawa_utf8(class_file.this.name)
158
+ name = class_name.rsplit("/", 1)[-1] if raw_name == "<init>" and class_name else raw_name
159
+ code = method.code
160
+ instructions: tuple[JavaInstruction, ...] = ()
161
+ if code is not None:
162
+ instructions = tuple(
163
+ _jawa_instruction(class_file, instruction) for instruction in code.disassemble()
164
+ )
165
+ return JavaMethodListing(
166
+ name=name,
167
+ descriptor=_jawa_utf8(method.descriptor),
168
+ is_static=bool(method.access_flags.acc_static),
169
+ is_annotation_member=bool(class_file.access_flags.acc_annotation) and code is None,
170
+ annotation_default=_jawa_annotation_default(class_file, method),
171
+ instructions=instructions,
172
+ exception_regions=tuple(
173
+ JavaExceptionRegion(
174
+ start=entry.start_pc,
175
+ end=entry.end_pc,
176
+ target=entry.handler_pc,
177
+ exception_type=_jawa_exception_type(class_file, entry.catch_type),
178
+ )
179
+ for entry in (getattr(code, "exception_table", ()) if code is not None else ())
180
+ ),
181
+ )
182
+
183
+
184
+ def _jawa_exception_type(class_file, index: int) -> str | None:
185
+ if not index:
186
+ return None
187
+ try:
188
+ constant = class_file.constants.get(index)
189
+ name = _jawa_utf8(constant.name)
190
+ except Exception:
191
+ return None
192
+ return name.replace("/", ".") if name else None
193
+
194
+
195
+ def _jawa_annotation_default(class_file, method) -> object | None:
196
+ for attribute in method.attributes:
197
+ if _jawa_utf8(attribute.name) != "AnnotationDefault":
198
+ continue
199
+ info = getattr(attribute, "info", None)
200
+ if not isinstance(info, (bytes, bytearray)):
201
+ return "<annotation-default>"
202
+ value, _offset = _parse_jvm_element_value(class_file, bytes(info), 0)
203
+ return value
204
+ return None
205
+
206
+
207
+ def _parse_jvm_element_value(class_file, data: bytes, offset: int) -> tuple[object, int]:
208
+ if offset >= len(data):
209
+ return ("<truncated-annotation-default>", offset)
210
+ tag = chr(data[offset])
211
+ offset += 1
212
+ if tag in "BCDFIJSZs":
213
+ if offset + 2 > len(data):
214
+ return ("<truncated-annotation-constant>", offset)
215
+ index = int.from_bytes(data[offset : offset + 2], "big")
216
+ return (_jawa_constant_value(class_file, index), offset + 2)
217
+ if tag == "e":
218
+ if offset + 4 > len(data):
219
+ return ("<truncated-annotation-enum>", offset)
220
+ type_index = int.from_bytes(data[offset : offset + 2], "big")
221
+ const_index = int.from_bytes(data[offset + 2 : offset + 4], "big")
222
+ return (
223
+ f"{_jawa_constant_value(class_file, type_index)}.{_jawa_constant_value(class_file, const_index)}",
224
+ offset + 4,
225
+ )
226
+ if tag == "c":
227
+ if offset + 2 > len(data):
228
+ return ("<truncated-annotation-class>", offset)
229
+ index = int.from_bytes(data[offset : offset + 2], "big")
230
+ return (f"{_jawa_constant_value(class_file, index)}.class", offset + 2)
231
+ return (f"<annotation-default:{tag}>", len(data))
232
+
233
+
234
+ def _jawa_constant_value(class_file, index: int) -> object:
235
+ try:
236
+ constant = class_file.constants.get(index)
237
+ except Exception:
238
+ return f"#{index}"
239
+ if constant is None:
240
+ return f"#{index}"
241
+ value = getattr(constant, "value", None)
242
+ if value is not None:
243
+ return value
244
+ for attr in ("string", "name"):
245
+ nested = getattr(constant, attr, None)
246
+ text = _jawa_utf8(nested)
247
+ if text is not None:
248
+ return text
249
+ return f"#{index}"
250
+
251
+
252
+ def _jawa_instruction(class_file, instruction) -> JavaInstruction:
253
+ if instruction.mnemonic == "lookupswitch":
254
+ operands = _jawa_lookupswitch_operands(instruction)
255
+ if operands is not None:
256
+ return JavaInstruction(
257
+ offset=instruction.pos,
258
+ opcode=instruction.mnemonic,
259
+ operands=operands,
260
+ )
261
+ operands = tuple(
262
+ rendered
263
+ for rendered in (
264
+ _jawa_operand(class_file, instruction, operand) for operand in instruction.operands
265
+ )
266
+ if rendered
267
+ )
268
+ return JavaInstruction(
269
+ offset=instruction.pos,
270
+ opcode=instruction.mnemonic,
271
+ operands=", ".join(operands),
272
+ )
273
+
274
+
275
+ def _jawa_lookupswitch_operands(instruction) -> str | None:
276
+ operands = instruction.operands
277
+ if len(operands) != 2 or not isinstance(operands[0], dict):
278
+ return None
279
+ default_offset = getattr(operands[1], "value", None)
280
+ if not isinstance(default_offset, int):
281
+ return None
282
+ pairs: list[str] = [str(instruction.pos + default_offset)]
283
+ try:
284
+ cases = sorted(operands[0].items())
285
+ except TypeError:
286
+ return None
287
+ for value, relative_target in cases:
288
+ if not isinstance(value, int) or not isinstance(relative_target, int):
289
+ return None
290
+ pairs.extend((str(value), str(instruction.pos + relative_target)))
291
+ return ", ".join(pairs)
292
+
293
+
294
+ def _jawa_operand(class_file, instruction, operand) -> str:
295
+ op_type = getattr(getattr(operand, "op_type", None), "name", "")
296
+ value = getattr(operand, "value", None)
297
+ if op_type == "BRANCH" and isinstance(value, int):
298
+ return str(instruction.pos + value)
299
+ if op_type == "CONSTANT_INDEX" and isinstance(value, int):
300
+ return _jawa_constant_operand(class_file, value)
301
+ if op_type == "PADDING":
302
+ return ""
303
+ return str(value)
304
+
305
+
306
+ def _jawa_constant_operand(class_file, index: int) -> str:
307
+ try:
308
+ constant = class_file.constants.get(index)
309
+ except Exception:
310
+ return f"#{index}"
311
+ if constant is None:
312
+ return f"#{index}"
313
+
314
+ type_name = type(constant).__name__
315
+ if type_name == "String":
316
+ return f"#{index} // String {_jawa_utf8(constant.string)}"
317
+ if type_name in {"Integer", "Float", "Long", "Double"}:
318
+ return f"#{index} // {type_name.lower()} {constant.value}"
319
+ if type_name == "ConstantClass":
320
+ return f"#{index} // class {_jawa_utf8(constant.name)}"
321
+ if type_name in {
322
+ "FieldReference",
323
+ "MethodReference",
324
+ "InterfaceMethodReference",
325
+ "InterfaceMethodRef",
326
+ }:
327
+ owner = _jawa_utf8(constant.class_.name) or "<owner>"
328
+ name_and_type = constant.name_and_type
329
+ name = _jawa_utf8(name_and_type.name) or "<name>"
330
+ descriptor = _jawa_utf8(name_and_type.descriptor) or "()V"
331
+ label = {
332
+ "FieldReference": "Field",
333
+ "MethodReference": "Method",
334
+ "InterfaceMethodReference": "InterfaceMethod",
335
+ "InterfaceMethodRef": "InterfaceMethod",
336
+ }[type_name]
337
+ return f"#{index} // {label} {owner}.{name}:{descriptor}"
338
+ if type_name in {"InvokeDynamic", "Dynamic"} and hasattr(constant, "name_and_type"):
339
+ name_and_type = constant.name_and_type
340
+ name = _jawa_utf8(name_and_type.name) or "<name>"
341
+ descriptor = _jawa_utf8(name_and_type.descriptor) or "()V"
342
+ return f"#{index} // {type_name} {name}:{descriptor}"
343
+ return f"#{index}"
344
+
345
+
346
+ def _jawa_utf8(value) -> str | None:
347
+ if value is None:
348
+ return None
349
+ if isinstance(value, str):
350
+ return value
351
+ text = getattr(value, "value", None)
352
+ return text if isinstance(text, str) else None