diff --git a/README.md b/README.md index a3fc8ea..2b1063e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ A modern Java reverse engineering tool for the web. - simple hexadecimal viewer for binary files - a simple JS scripting API for doing various things - multi-pane workspace for viewing multiple files at once +- supports loading different mapping formats for remapping + - Tiny v1, Tiny v2, SRG/XSRC, CSRG/TSRG, TSRG v2, ProGuard - [shadcn/ui](https://ui.shadcn.com/) design and theming support ## Installation diff --git a/package.json b/package.json index 08e08e8..dbee4aa 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "comlink": "^4.4.2", "elkjs": "^0.10.0", "html-to-image": "1.11.11", + "java-remapper": "^1.0.6", "queueable": "^5.3.2", "svelte-modals": "^2.0.1", "tailwind-merge": "^3.3.1", diff --git a/src/lib/components/dialog/index.ts b/src/lib/components/dialog/index.ts index 0299024..f735e22 100644 --- a/src/lib/components/dialog/index.ts +++ b/src/lib/components/dialog/index.ts @@ -2,6 +2,7 @@ import AboutDialog from "./about.svelte"; import ClearDialog from "./clear.svelte"; import DeleteDialog from "./delete.svelte"; import LoadExternalDialog from "./load_external.svelte"; +import LoadMappings from "./load_mappings.svelte"; import ScriptDialog from "./script.svelte"; import ScriptDeleteDialog from "./script_delete.svelte"; import ScriptLoadDialog from "./script_load.svelte"; @@ -12,6 +13,7 @@ export { ClearDialog, DeleteDialog, LoadExternalDialog, + LoadMappings, ScriptDeleteDialog, ScriptDialog, ScriptLoadDialog, diff --git a/src/lib/components/dialog/load_mappings.svelte b/src/lib/components/dialog/load_mappings.svelte new file mode 100644 index 0000000..15c5df1 --- /dev/null +++ b/src/lib/components/dialog/load_mappings.svelte @@ -0,0 +1,91 @@ + + + open || close()}> + + + Load Mappings + Please select from the dropdown the mappings format. + +
+ + + +
+ + + +
+
diff --git a/src/lib/components/menu/menu.svelte b/src/lib/components/menu/menu.svelte index 3a7b9bd..032e51d 100644 --- a/src/lib/components/menu/menu.svelte +++ b/src/lib/components/menu/menu.svelte @@ -183,6 +183,9 @@ modals.open(LoadExternalDialog, { handler })} class="justify-between"> Add from URL + handler.loadMappings(entries)} disabled={entries.length === 0}> + Open Mappings + modals.open(ClearDialog, { handler })}> Clear all diff --git a/src/lib/components/ui/badge/badge.svelte b/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..f71bd00 --- /dev/null +++ b/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/src/lib/components/ui/badge/index.ts b/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/src/lib/event/handler.ts b/src/lib/event/handler.ts index 3cd2f39..e7cb71a 100644 --- a/src/lib/event/handler.ts +++ b/src/lib/event/handler.ts @@ -1,5 +1,7 @@ +import { LoadMappings } from "$lib/components/dialog"; import { disassembleEntry, type Disassembler } from "$lib/disasm"; import { error } from "$lib/log"; +import { detectMappingFormat, MappingFormat, parseMappings } from "java-remapper"; import { load as loadScript, type ProtoScript, @@ -31,25 +33,40 @@ import { recordTimed, remove as removeTask, } from "$lib/task"; -import { chunk, distribute, downloadBlob, partition, readFiles, timestampFile, truncate } from "$lib/utils"; +import { + chunk, + distribute, + downloadBlob, + fileToString, + partition, + readFiles, + timestampFile, + truncate, +} from "$lib/utils"; import { type ClassEntry, clear as clearWs, + entries, type Entry, EntryType, loadFile, loadRemote, type LoadResult, loadZip, + mapClass, + type MapClassResult, + MAPPINGS_EXTENSIONS, readDeferred, remove as removeWs, ZIP_EXTENSIONS, } from "$lib/workspace"; +import { analyzeBackground } from "$lib/workspace/analysis"; import { type Data, download } from "$lib/workspace/data"; import { Channel } from "queueable"; +import { modals } from "svelte-modals"; import { toast } from "svelte-sonner"; import { get } from "svelte/store"; -import type { EventHandler } from "./"; +import { type EventHandler } from "./"; // one hell of a file that responds to basically all essential actions as signalled by the UI @@ -129,6 +146,95 @@ export default { }); } }, + async loadMappings(entries: Entry[]): Promise { + if (entries.length === 0) return; + + const files = await readFiles( + Array.from(MAPPINGS_EXTENSIONS.values()) + .map((e) => `.${e}`) + .join(","), + false + ); + if (files.length === 0) { + return; + } + + const file = files[0]; + + try { + const content = await fileToString(file); + + const detectedFormat = detectMappingFormat(content); + + modals.open(LoadMappings, { detectedFormat, handler: this, content }); + } catch (e) { + error(`failed to read mappings file ${file.name}`, e); + toast.error("Error occurred", { + description: `Could not read mappings file ${file.name}, check the console.`, + }); + } + }, + async applyMappings(content: string, format: MappingFormat): Promise { + try { + const parsed = parseMappings(format, content); + + await recordProgress("applying mappings", null, async (task) => { + let completed = 0; + const oldToNewMap = new Map(); // Track old → new mapping + + for (const clazz of parsed.classes) { + const data = await mapClass(clazz, parsed); + + if (data.mapped) { + oldToNewMap.set(data.oldName, data.entry!!); + } + + completed++; + task.desc.set(`${parsed.classes.length} entries (${parsed.classes.length - completed} remaining)`); + task.progress?.set((completed / parsed.classes.length) * 100); + } + + entries.update(($entries) => { + oldToNewMap.forEach((value, key) => { + $entries.delete(key); + $entries.set(value.name, value); + }); + return $entries; + }); + + tabs.update(($tabs) => { + oldToNewMap.forEach((newEntry, oldName) => { + for (const [key, tab] of $tabs.entries()) { + if (tab.entry && tab.entry.name === oldName) { + $tabs.set(key, { + ...tab, + id: `${TabType.CODE}:${newEntry.name}`, + name: newEntry.shortName, + entry: newEntry, + }); + } + } + }); + return $tabs; + }); + + task.desc.set(`${parsed.classes.length} entries`); + + await analyzeBackground(); + + toast.success("Mapped classes", { + description: `Mapped ${parsed.classes.length} ${ + parsed.classes.length === 1 ? "entry" : "entries" + }.`, + }); + }); + } catch (e) { + error(`failed to apply mappings`, e); + toast.error("Error occurred", { + description: `Could not apply mappings file, check the console.`, + }); + } + }, async add(files?: File[]): Promise { if (!files) { files = await readFiles("", true); diff --git a/src/lib/event/index.ts b/src/lib/event/index.ts index 4f20ce4..55b83d7 100644 --- a/src/lib/event/index.ts +++ b/src/lib/event/index.ts @@ -1,4 +1,5 @@ import type { Disassembler } from "$lib/disasm"; +import type { MappingFormat } from "java-remapper"; import type { ProtoScript } from "$lib/script"; import type { Tab, TabDefinition, TabPosition, TabType } from "$lib/tab"; import type { Entry } from "$lib/workspace"; @@ -9,6 +10,8 @@ type Awaitable = T | PromiseLike; export interface EventHandler { load(): Awaitable; + loadMappings(entries: Entry[]): Awaitable; + applyMappings(content: string, format: MappingFormat): Awaitable; add(files?: File[]): Awaitable; addRemote(url: string): Awaitable; clear(): Awaitable; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2853f87..8dbfd2b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -187,6 +187,22 @@ export const readFiles = (pattern: string, multiple: boolean): Promise = }); }; +export const fileToString = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + resolve(reader.result as string); + }; + + reader.onerror = () => { + reject(reader.error); + }; + + reader.readAsText(file); // reads file as string (UTF-8 by default) + }); +}; + export const downloadUrl = (name: string, url: string) => { const link = document.createElement("a"); link.style.display = "none"; diff --git a/src/lib/workspace/index.ts b/src/lib/workspace/index.ts index 2b6da34..dccf2ee 100644 --- a/src/lib/workspace/index.ts +++ b/src/lib/workspace/index.ts @@ -2,13 +2,14 @@ import { warn } from "$lib/log"; import { analysisBackground } from "$lib/state"; import { record } from "$lib/task"; import { prettyMethodDesc } from "$lib/utils"; -import type { Member, Node } from "@run-slicer/asm"; -import type { UTF8Entry } from "@run-slicer/asm/pool"; +import { read, type Member, type Node } from "@run-slicer/asm"; +import { type UTF8Entry } from "@run-slicer/asm/pool"; import type { Zip } from "@run-slicer/zip"; +import { remap, type ClassMapping, type MappingSet } from "java-remapper"; import { derived, get, writable } from "svelte/store"; import { AnalysisState, analyze, analyzeBackground, analyzeSchedule } from "./analysis"; import { transform } from "./analysis/transform"; -import { type Data, fileData, memoryData, type Named, parseName, zipData } from "./data"; +import { fileData, memoryData, parseName, zipData, type Data, type Named } from "./data"; import { archiveDecoder } from "./encoding"; export const enum EntryType { @@ -129,7 +130,14 @@ export interface LoadResult { created: boolean; } +export interface MapClassResult { + mapped: boolean; + oldName: string; + entry?: ClassEntry; +} + export const ZIP_EXTENSIONS = new Set(["zip", "jar", "apk", "war", "ear", "jmod"]); +export const MAPPINGS_EXTENSIONS = new Set(["srg", "csrg", "tsrg", "tiny", "txt", "xsrg"]); const load0 = async (entries: Map, d: Data, parent?: Entry): Promise => { const name = parent ? `${parent.name}/${d.name}` : d.name; @@ -233,6 +241,35 @@ export const clear = () => { }); }; +export const mapClass = async (clazz: ClassMapping, mappings: MappingSet): Promise => { + const entries0 = get(entries); + + const fullName = clazz.obfuscatedName + ".class"; + + const entry = entries0.get(fullName) as ClassEntry; + if (!entry) return { mapped: false, oldName: fullName, entry: undefined }; + + const parsedNames = parseName(clazz.deobfuscatedName + ".class"); + + const entryBytes = await entry.data.bytes(); + const remappedData = await remap(entryBytes, mappings); + const newData = memoryData(parsedNames.name, remappedData); + const newNode = read(remappedData); + + return { + mapped: true, + oldName: fullName, + entry: { + ...entry, + ...parsedNames, + node: newNode, + parent: undefined, + state: AnalysisState.NONE, + data: newData, + } as ClassEntry, + }; +}; + // PWA file handler // @ts-ignore - experimental APIs if (window.launchQueue) { diff --git a/vite.config.ts b/vite.config.ts index f33236f..96aa59c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,45 @@ import { svelte } from "@sveltejs/vite-plugin-svelte"; import tailwindcss from "@tailwindcss/vite"; -import { resolve } from "path"; -import { defineConfig } from "vite"; +import { readFileSync } from "fs"; +import { basename, join, resolve } from "path"; +import { defineConfig, PluginOption } from "vite"; + +const wasmMiddleware: (files: { module: string; file: string }[]) => PluginOption = (files) => { + return { + name: "wasm-middleware", + configureServer(server) { + server.middlewares.use((req, res, next) => { + files.forEach((file) => { + if (req.url && req.url.endsWith(file.file)) { + const wasmPath = join(__dirname, "node_modules/" + file.module, basename(req.url)); + const wasmFile = readFileSync(wasmPath); + res.setHeader("Content-Type", "application/wasm"); + res.end(wasmFile); + return; + } + }); + next(); + }); + }, + }; +}; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [tailwindcss(), svelte()], + plugins: [ + tailwindcss(), + svelte(), + wasmMiddleware([ + { + file: "java-remapper.wasm", + module: "java-remapper", + }, + { + file: "jasm.wasm", + module: "@run-slicer/jasm", + }, + ]), + ], build: { sourcemap: "hidden", },