Metadata-Version: 2.4
Name: javainspect
Version: 1.1.0
Summary: Read and edit JAR/Java bytecode: class parsing, instruction matchers, string deobfuscation, a verifiable bytecode assembler, and jar rewriting.
Author: Samael.Git
License: MIT
Keywords: java,jvm,bytecode,jar,class-file,disassembler,assembler,deobfuscation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Disassemblers
Classifier: Topic :: Security
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: javatools>=1.6.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Dynamic: license-file

# javainspect

Standalone JAR and Java bytecode inspection, extracted from XantheProtections.
Drop it into any application that needs to read `.jar` archives or `.class`
files without pulling in a scanner, detection rules, or ML.

It parses, normalizes, searches — and edits. Reading does not classify (no
checks, no scoring, no verdicts); editing can add or remove bytecode and
repackage a jar. Build your own logic on top.

**Only dependency:** [`javatools`](https://pypi.org/project/javatools/).

## Install

```bash
pip install ./libs/javainspect          # from this repo
pip install -e ./libs/javainspect       # editable, for development
```

Or vendor it: copy `src/javainspect/` into your project and `pip install javatools`.

## Quick start

```python
from javainspect import JarInspector

with JarInspector("plugin.jar") as jar:
    print(jar.manifest.get("Main-Class"))
    print(len(jar.class_entries()), "classes")

    for class_file in jar.iter_classes():
        for instruction in class_file.get_method_calls():
            info = instruction.get_method_info()
            if info and info[0] == "java/lang/Runtime":
                print(class_file.name, "->", info)

    print(jar.errors)  # classes that failed to parse
```

A single class file, on disk or in memory:

```python
from javainspect import parse_class, parse_class_bytes

class_file = parse_class("Main.class")
class_file = parse_class_bytes(open("Main.class", "rb").read())
```

## What's in it

### Archive layer — `javainspect.jar`

| API | Purpose |
| --- | --- |
| `JarInspector(path, limits=, deobfuscate=)` | Context-managed handle over a jar |
| `.entries()` / `.class_entries()` / `.resource_entries()` | Entry listing with sizes and CRCs |
| `.manifest` | `META-INF/MANIFEST.MF` main attributes as a dict |
| `.read(name)` / `.read_text(name)` | Size-capped raw reads |
| `.iter_classes()` / `.classes()` | Parsed `ClassFile` objects |
| `.parse_class(name)` | One class by archive name |
| `.map_classes(fn, workers=)` | Parse + analyze across a process pool |
| `.all_strings()` / `.class_names()` | Whole-archive aggregates |
| `.errors` | `ClassError` list from the last traversal |
| `.zipfile` | The underlying `ZipFile`, for zip-level work |
| `iter_jar_classes(path)` | One-liner over a whole jar |
| `parse_manifest(text)` | Manifest parsing on its own |

`JarLimits(max_entry_size, max_class_size)` bounds what gets read, so a hostile
archive can't zip-bomb the host process. Class entries named `pkg/Real.class/`
(the trailing-slash form used to dodge naive suffix filters) are recognized.
Malformed classes are collected in `.errors` instead of aborting the walk.

### Class model — `javainspect.parser`, `javainspect.instruction`

`ClassFile` → `Method` → `Instruction`, all frozen dataclasses.

- `ClassFile.get_method_calls()`, `.get_field_accesses()` — cached, class-wide
- `ClassFile.get_all_strings()` — literals plus low-noise decodes
- `ClassFile.get_deobfuscated_strings()` — literals plus *every* plausible decode (noisy, opt-in)
- `Method.instructions`, `.exception_handlers`, `.descriptor`
- `Instruction.get_method_info()` → `(owner, name, descriptor)`; also
  `.get_field_info()`, `.get_string_constant()`, `.get_long_constant()`,
  `.opcode_name`, and `is_*` predicates per opcode family

### Matchers — `javainspect.utils`

Predicates over instructions that take a plain string (substring match) or a
compiled regex:

```python
from javainspect import is_method_call, method_matches_all
import re

is_method_call(instr, "java/lang/Runtime", "exec", None)
is_method_call(instr, None, re.compile(r"^(download|fetch)\w*File$"), None)

method_matches_all(method, [               # all must co-occur in ONE method
    lambda i: is_method_call(i, "java/net/URL", "openStream", None),
    lambda i: is_method_call(i, "java/io/FileOutputStream", "<init>", None),
])
```

Plus navigation (`find_forward`, `find_backward`, `is_followed_by`,
`previous_instruction`, `next_instruction`), value recovery
(`get_array_store_values`, `get_string_array_literal`), descriptor parsing
(`get_return_type`, `get_argument_types`), and reflection helpers
(`uses_reflection_api`, `reflective_dangerous_targets`).

### String recovery — `javainspect.utils`, `javainspect.deobfuscator`

- `deobfuscate_string_candidates(s)` — high-confidence base64/hex decodes plus
  aggressive decodes that look meaningful. Safe to consume anywhere.
- `deobfuscate_string_candidates_deep(s)` — every plausible decode. Noisy, opt-in.
- `optimize_class(class_file)` — experimental in-place folding of constant
  arithmetic, `String.concat` chains, and `StringBuilder` append-spam. Also
  reachable as `JarInspector(..., deobfuscate=True)`.

### Library recognition — `javainspect.allowlist`

`known_library_match(class_file)` returns the name of a recognized bundled
library (bStats, ProtocolLib) or `None`. `KNOWN_LIBRARY_FINGERPRINTS` and
`KNOWN_LIBRARY_PACKAGE_PREFIXES` are plain dicts — extend them for whatever
your own application bundles.

## Type hierarchy and creation sites

Two cross-class questions, answered over a whole jar — *what does a method
derive from* and *where is a class created*:

```python
from javainspect import JarInspector, find_creators

with JarInspector("plugin.jar") as jar:
    h = jar.type_hierarchy()

    h.superclass("com.x.Dog")            # -> "com.x.Animal"
    h.interfaces("com.x.Dog")            # -> ("com.x.Named",)
    h.ancestors("com.x.Dog")             # transitive supers, nearest first
    h.subclasses("com.x.Animal")         # direct subclasses
    h.implementors("com.x.Named")        # classes implementing an interface
    h.subtypes("com.x.Animal")           # everything transitively below
    h.is_subtype("com.x.Dog", "com.x.Animal")   # True

    # What a method overrides / implements (which supertype declares it):
    h.method_origins("com.x.Dog", "sound", "()Ljava/lang/String;")  # -> ["com.x.Animal"]

    # Where is a type instantiated?  (every `new T` site -> class + method)
    for site in jar.instantiations().get("com.x.Dog", []):
        print(site.in_class, site.in_method, site.in_descriptor)
```

`method_origins` reports only supertypes **present in the jar**, nearest first —
an override of a JDK/library method (or a method the class introduces) comes
back empty. Constructors and static initializers are never inherited, so they're
always empty. Names are dotted, matching `ClassFile.name`; `find_creators`
accepts either dotted or internal (`com/x/Dog`) form. On the CLI:

```
javainspect hierarchy plugin.jar com.x.Dog     # extends/implements/ancestors/subtypes
javainspect hierarchy plugin.jar               # list every class's supertypes
javainspect creators  plugin.jar com.x.Dog     # every `new com.x.Dog` site
```

Individual instructions expose the raw signal too: `Instruction.is_new()` /
`get_new_type()` for allocations, and `ClassFile.super_name` /
`interface_names` for one class's own links.

### Resolving a method reference, and finding callers

If all you have is a symbolic call like `HookManager.hook()` (which is exactly
what an `invoke` instruction carries), resolve it to the class that *actually*
declares it — walking the hierarchy the way the JVM does when the method is
inherited:

```python
from javainspect import resolve_method, find_callers

# Child inherits hook() from Base -> resolves up to Base:
resolve_method(classes, "Child", "hook")          # -> MethodRef(owner="Base", name="hook", descriptor="()V")
resolve_method(classes, "Child", "hook", "()V")   # pass a descriptor to disambiguate overloads

# Who calls it? (matches the owner as written at each call site)
for site in find_callers(classes, "Base", "hook"):
    print(site.in_class, site.in_method, "->", site.target, f"({site.opcode})")

calls(classes)   # every call site in the set, as CallSite records
```

`resolve_method` returns `None` when no class **in the set** declares it — i.e.
it's inherited from a JDK/library type you don't have. `find_callers` matches
the *symbolic* owner named at the call site (a call to an inherited method names
the subclass, not the declarer), so combine the two to go from "who calls
`Child.hook`" to "where `hook` really lives". On the CLI:

```
javainspect resolve plugin.jar Child hook          # -> declared by Base
javainspect callers plugin.jar Base hook           # every call site
javainspect callers plugin.jar com.x.HookManager   # all calls to any method on it
```

## Editing bytecode and rewriting jars

The read side uses `javatools`, which can't write class files, so the edit side
(`javainspect.classfile`, `javainspect.transform`) is a self-contained
class-file writer. It is deliberately conservative so the output stays
**verifiable** by the JVM:

- **Append-only constant pool** — new constants are appended, existing indices
  never move, so every field, every other method, and every class attribute is
  copied through byte-for-byte.
- **Surgical `Code` rewrite** — only the edited method's bytecode, `max_stack`,
  exception table, `LineNumberTable`, `LocalVariableTable`, and — the part that
  makes or breaks verification — `StackMapTable` are rewritten, with all
  bytecode offsets shifted consistently.

A no-op read/write cycle reproduces the input **byte-for-byte**, and the test
suite loads an edited class through a JVM class loader (which forces the
verifier to check the rewritten `StackMapTable`).

### Inject / remove a log line

The headline transform mirrors the common ASM "add a log line to `onEnable`"
recipe — it prepends `Bukkit.getLogger().log(Level.INFO, message)` to
`onEnable()V` / `onPluginEnable()V`:

```python
from javainspect import transform_jar, inject_log_code, remove_log_code

# Inject into every class in a jar (in place or to a new path):
edits = transform_jar("plugin.jar", "plugin-tagged.jar",
                       lambda ed: inject_log_code(ed, "Loaded by javainspect"))
for e in edits:
    if e.changed:
        print("injected into", e.name, e.methods)

# Remove it again — the exact inverse; only strips a prefix that resolves to
# the injected getLogger/log shape, so ordinary code is never touched:
transform_jar("plugin-tagged.jar", "plugin-clean.jar", remove_log_code)
```

Editing one class file directly:

```python
from javainspect import ClassFileEditor, inject_log_code

editor = ClassFileEditor(open("Main.class", "rb").read())
if inject_log_code(editor, "hello"):          # returns edited method names
    open("Main.class", "wb").write(editor.serialize())
```

### Write your own bytecode — the assembler

You don't have to hand-pack opcodes. `javainspect.asm` lets you write
instructions the way `javap` shows them, and works out the constant-pool
entries and `max_stack` for you. Your `System.out.println` example, verbatim:

```python
from javainspect import ClassFileEditor, prepend_assembly

editor = ClassFileEditor(open("Main.class", "rb").read())
prepend_assembly(editor, '''
    getstatic java/lang/System.out Ljava/io/PrintStream;
    ldc "[Xanthe Protections] This Plugin is stupid"
    invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V
''')
open("Main.class", "wb").write(editor.serialize())
```

Text syntax: one instruction per line; member operands are
`owner/pkg/Class.member` with the descriptor as a separate token; `ldc` takes a
`"quoted string"` or an integer; `;` or `//` starts a comment (the trailing `;`
of a descriptor is safe). Same thing with the fluent `CodeBuilder` if you'd
rather stay in Python:

```python
from javainspect import ClassFileEditor, prepend_with

editor = ClassFileEditor(open("Main.class", "rb").read())
prepend_with(editor, lambda b: (
    b.getstatic("java/lang/System", "out", "Ljava/io/PrintStream;"),
    b.ldc("[Xanthe Protections] This Plugin is stupid"),
    b.invokevirtual("java/io/PrintStream", "println", "(Ljava/lang/String;)V"),
))
```

Across a whole jar, and its ready-made shorthand + inverse:

```python
from javainspect import transform_jar, inject_println, remove_println, prepend_assembly

transform_jar("plugin.jar", "out.jar", lambda ed: inject_println(ed, "[Xanthe] loaded"))
transform_jar("plugin.jar", "out.jar",
              lambda ed: prepend_assembly(ed, open("myinjection.txt").read()))
transform_jar("out.jar", "clean.jar", remove_println)   # exact inverse
```

`inject_println` uses `System.out`, so unlike `inject_log_code` (which needs a
Bukkit server for the `Bukkit.getLogger()` call to resolve) the line prints in a
plain JVM — handy for testing. See
[examples/inject_custom_example.py](examples/inject_custom_example.py) for a CLI
around all of this (`--println`, `--asm FILE`, `--builder`, `--remove-println`).

**The one rule:** an injected sequence must be **stack-neutral** — it has to
leave the operand stack exactly as it found it, because the method's original
code runs right after. The assembler simulates the stack from empty and raises
if a sequence would underflow; it has no labels or branches (injected control
flow would need stack-map frame synthesis the writer doesn't do).

Supported instructions: the common straight-line set — `getstatic`/`putstatic`/
`getfield`/`putfield`, `invokevirtual`/`invokespecial`/`invokestatic`/
`invokeinterface`, `new`/`anewarray`/`checkcast`/`instanceof`, `ldc`(+`ldc_class`),
`bipush`/`sipush`, `aload`/`iload`/`astore`/`istore`(+`_0.._3`), `dup`/`pop`/
`swap`/… and the returns. Emitting anything unmodelled raises rather than
guessing a wrong `max_stack`.

### Lowest level

If you want to skip the assembler, append constants on the pool
(`add_methodref`, `add_fieldref`, `add_string`, `add_integer`, `add_class`, …),
pack the bytes yourself, and hand them to `CodeAttribute.prepend(bytes,
min_max_stack)` (or `.remove_prefix(n)`):

```python
import struct
from javainspect import ClassFileEditor

editor = ClassFileEditor(open("Main.class", "rb").read())
pool = editor.pool
out = pool.add_fieldref("java/lang/System", "out", "Ljava/io/PrintStream;")
msg = pool.add_string("hi")
println = pool.add_methodref("java/io/PrintStream", "println", "(Ljava/lang/String;)V")

code_bytes = (struct.pack(">BH", 0xB2, out)      # getstatic System.out
              + struct.pack(">BH", 0x13, msg)    # ldc_w "hi"
              + struct.pack(">BH", 0xB6, println))  # invokevirtual println

method = editor.find_method("onEnable", "()V")
code = method.get_code(pool)
code.prepend(code_bytes, min_max_stack=2)   # you supply max_stack here
method.set_code(pool, code)
open("Main.class", "wb").write(editor.serialize())
```

**Scope and limits of the writer:**

- Edits insert or remove at the **start** of a method. Inserting straight-line,
  stack-neutral code there (like a log/telemetry call) needs no new stack-map
  frames and is the well-trodden path. Inserting code with its own branches, or
  editing mid-method, would need frame synthesis this does not do.
- `remove_prefix` refuses if any metadata offset falls inside the removed
  region (i.e. the removed code was branched to or referenced) — a guard
  against corrupting a method that isn't a clean injected prefix.
- Removal restores the **bytecode** exactly but leaves the appended constants
  in the pool (unused constants are harmless), so a round-tripped file is not
  byte-identical to the pre-injection original.
- `Code` sub-attributes that could carry their own bytecode offsets but aren't
  parsed (e.g. `RuntimeVisibleTypeAnnotations` on code — rare in plugins) are
  copied unshifted and surfaced in `ClassEdit.warnings` rather than silently
  corrupted.

### Inject anywhere — before every return

`prepend_*` inserts at method start. To insert before **every `return`** instead
(an `onDisable` cleanup line, telemetry on exit), use the before-return variants.
The rewriter fixes up branch offsets, `tableswitch`/`lookupswitch` padding, and
stack-map frames so the result verifies even in a method full of control flow:

```python
from javainspect import transform_jar, inject_println_before_return, prepend_assembly_before_return

transform_jar("plugin.jar", "out.jar",
              lambda ed: inject_println_before_return(ed, "[Xanthe] disabling"))
transform_jar("plugin.jar", "out.jar",
              lambda ed: prepend_assembly_before_return(ed, open("bye.txt").read()))
```

### Add whole new methods and fields

Synthesize a helper method (and fields), then call it from injected code:

```python
from javainspect import ClassFileEditor, add_static_method, prepend_assembly
from javainspect import ACC_PUBLIC, ACC_STATIC

editor = ClassFileEditor(open("Main.class", "rb").read())

add_static_method(editor, "xanthe$hello", "()V", '''
    getstatic java/lang/System.out Ljava/io/PrintStream;
    ldc "hello from an injected method"
    invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V
    return
''')
editor.add_field(ACC_PUBLIC | ACC_STATIC, "xantheFlag", "Z")

# call the new method from onEnable
prepend_assembly(editor, f"invokestatic {editor.this_class_name}.xanthe$hello ()V")
open("Main.class", "wb").write(editor.serialize())
```

`add_static_method` assembles a full method body (which must end in the return
matching the descriptor) and computes `max_stack`/`max_locals`. Use
`editor.this_class_name` to build the `invokestatic` back to it.

### Remove and rename classes and members

Remove a method or field from a class (class-local — like deleting it in source;
call sites become your responsibility):

```python
from javainspect import ClassFileEditor

editor = ClassFileEditor(open("Main.class", "rb").read())
editor.remove_method("removeMe", "()V")
editor.remove_field("unused", "I")
open("Main.class", "wb").write(editor.serialize())
```

Delete whole classes from a jar:

```python
from javainspect import remove_classes
remove_classes("plugin.jar", "out.jar", ["com/x/Debug", "com/x/OldThing"])
```

Rename classes across a **whole jar**, reference-correctly — every subclass,
field/method descriptor, and `new` that mentions the type is updated, and each
renamed `.class` entry is moved to its new path:

```python
from javainspect import rename_classes_in_jar

report = rename_classes_in_jar("plugin.jar", "out.jar", {
    "com/old/Foo": "com/new/Bar",
    "me/author/Secret": "me/author/a",   # e.g. obfuscate/deobfuscate names
})
print(report.edited_classes, "classes updated")
print(report.renamed_entries)            # [(old_path, new_path), ...]
print(report.string_literals)            # class names found in string constants
```

It works by rewriting only the type-bearing `Utf8` constants, so pool indices
(and therefore all bytecode) stay untouched — the output verifies and runs. Two
things it deliberately does **not** touch, and reports instead in
`report.string_literals` so you can handle them: class names that appear only as
runtime strings (`Class.forName("com.old.Foo")`) and generic `Signature`
attributes. It renames types, not members — patch `plugin.yml`/the manifest
separately if they name a renamed class.

### Edit the manifest and resources

For everything in the archive that isn't a class — `javainspect.jaredit`:

```python
from javainspect import patch_manifest, set_resources, remove_entries, rewrite_jar

patch_manifest("plugin.jar", "out.jar", {"Main-Class": "com.x.Loader",
                                         "Sealed": "true"})   # None value removes a key
set_resources("plugin.jar", "out.jar", {"plugin.yml": b"name: X\nmain: com.x.Loader\n"})
remove_entries("plugin.jar", "out.jar", ["unwanted.txt"])

# arbitrary per-entry edit:
rewrite_jar("plugin.jar", "out.jar",
            edit_entry=lambda name, data: data.replace(b"debug=false", b"debug=true")
                       if name == "config.txt" else None)
```

Manifest values are wrapped to the 72-byte line limit automatically, and per-entry
manifest sections are preserved.

### Validate the result

Editing bytecode is exacting, so check the output. `validate_*` re-decodes every
method and confirms all branch/switch/exception/stack-map offsets land on real
instruction boundaries — the corruption a bad edit produces. It's deterministic,
needs no JVM, and never runs the plugin's code:

```python
from javainspect import validate_jar, transform_jar, inject_println

problems = validate_jar("out.jar")          # {} means clean
if problems:
    for name, issues in problems.items():
        print(name, issues)

# or validate edited classes inline as you write them:
transform_jar("plugin.jar", "out.jar", lambda ed: inject_println(ed, "x"), validate=True)
```

This is a fast structural gate, not a full type-checker — it won't catch a
too-small `max_stack` (neither, reliably, does HotSpot's lazy verifier). To be
certain a class type-checks, actually run it; the test suite does exactly that.

## Command line

Installing the package puts a `javainspect` command on your path (or run
`python -m javainspect.cli`):

```
javainspect inspect plugin.jar                     # entries, manifest, entry methods
javainspect strings plugin.jar --deobf --min 6     # dump string constants
javainspect manifest plugin.jar --set Main-Class=com.x.Loader -o out.jar
javainspect inject-println plugin.jar "[Xanthe] loaded" -o out.jar
javainspect inject-println plugin.jar "bye" --before-return -o out.jar
javainspect asm plugin.jar myinjection.txt -o out.jar
javainspect remove-println out.jar -o clean.jar
javainspect validate out.jar
```

## Parallel analysis

`map_classes` spreads parsing across cores. `fn` and its return value must be
picklable, so use a module-level function:

```python
from javainspect import JarInspector, is_method_call

def find_exec(class_file, name):
    return [
        i.get_method_info()
        for i in class_file.get_method_calls()
        if is_method_call(i, "java/lang/Runtime", "exec", None)
    ]

with JarInspector("plugin.jar") as jar:
    for entry_name, hits in jar.map_classes(find_exec):
        if hits:
            print(entry_name, hits)
```

## Notes

- `Instruction.get_method_info()` returns `None` for `invokedynamic`, which has
  no owner class — its constant-pool entry resolves to a bootstrap-method index.
  Check `opcode == OP_invokedynamic` directly if you need those.
- Instruction offsets are original class-file byte offsets, not list positions.
  `optimize_class` preserves offsets on retained instructions and leaves the
  exception table alone.
- Methods without code (abstract/native) are kept with an empty instruction
  list so name-based analysis still sees them.
