Pin Ghidra 12.2 and keep clients on the server's release

The server's Ghidra project is now built with Ghidra 12.2 (JDK 25); a project saved by a newer Ghidra cannot be opened by an older one. /api/ghidra-project announces the release (url, sha256, dir, jdk); td ghidra download upgrades a td-installed Ghidra to it and refuses with instructions for a user-configured older one; td decompile/disasm fall back to the server's export while the local Ghidra is older than the pin. The unpacked project is handed to the local user (Ghidra opens a project only for its recorded owner). The rodecomp server image ships Ghidra 12.2 and Temurin JDK 25.
This commit is contained in:
ragnarok-clanker 2026-10-28 20:04:10 +01:00
commit 0481ebbd6b
10 changed files with 110 additions and 26 deletions

View file

@ -5,9 +5,9 @@ set -eu
HOST="${1:?usage: install.sh <public-hostname>}" HOST="${1:?usage: install.sh <public-hostname>}"
NODE_VERSION=v22.21.0 NODE_VERSION=v22.21.0
GHIDRA_VERSION=11.2.1 GHIDRA_VERSION=12.2
GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip
GHIDRA_SHA256=ce4db5117da0fbaf8f33863fec4f40902f754f06b68945a59fb1c0f9b1bc461c GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67
SRC=$(cd "$(dirname "$0")/.." && pwd) SRC=$(cd "$(dirname "$0")/.." && pwd)
APP=/opt/togetherdecomp APP=/opt/togetherdecomp
DATA=/var/lib/togetherdecomp DATA=/var/lib/togetherdecomp
@ -31,7 +31,7 @@ fi
echo "== ghidra $GHIDRA_VERSION (server-side project and pseudocode)" echo "== ghidra $GHIDRA_VERSION (server-side project and pseudocode)"
# The JDK, not just the runtime: Ghidra compiles its .java scripts. # The JDK, not just the runtime: Ghidra compiles its .java scripts.
apt-get install -y -q openjdk-21-jdk-headless unzip >/dev/null apt-get install -y -q openjdk-25-jdk-headless unzip >/dev/null
GHIDRA_HOME="/opt/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" GHIDRA_HOME="/opt/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC"
if [ ! -x "$GHIDRA_HOME/support/analyzeHeadless" ]; then if [ ! -x "$GHIDRA_HOME/support/analyzeHeadless" ]; then
tmp=$(mktemp -d) tmp=$(mktemp -d)

View file

@ -1,7 +1,18 @@
FROM docker:29-cli AS docker-cli FROM docker:29-cli AS docker-cli
FROM eclipse-temurin:25-jdk AS jdk
FROM node:24-bookworm-slim AS ghidra
ARG GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip
ARG GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates unzip && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "$GHIDRA_URL" -o /tmp/ghidra.zip && echo "$GHIDRA_SHA256 /tmp/ghidra.zip" | sha256sum -c - && mkdir -p /opt/ghidra && unzip -q /tmp/ghidra.zip -d /opt/ghidra && rm /tmp/ghidra.zip
FROM node:24-bookworm-slim FROM node:24-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends git binutils ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends git binutils ca-certificates fontconfig && rm -rf /var/lib/apt/lists/*
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=jdk /opt/java/openjdk /opt/java/openjdk
COPY --from=ghidra /opt/ghidra /opt/ghidra
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH=/opt/java/openjdk/bin:$PATH
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts RUN npm ci --omit=dev --ignore-scripts

View file

@ -296,7 +296,7 @@ connect your AI with: td connect --workspace "${ws.dir}"`);
} }
case 'config': { case 'config': {
// td config show settings (token hidden) // td config show settings (token hidden)
// td config <key> <value> set one, e.g. `td config ghidraHome D:\ghidra_11.2.1_PUBLIC` // td config <key> <value> set one, e.g. `td config ghidraHome D:\ghidra_12.2_PUBLIC`
const [key, value] = args; const [key, value] = args;
const SETTABLE = ['ghidraHome', 'server']; const SETTABLE = ['ghidraHome', 'server'];
if (!key) return print({ ...ws.config, token: ws.config.token ? '(set)' : null }); if (!key) return print({ ...ws.config, token: ws.config.token ? '(set)' : null });
@ -311,7 +311,7 @@ connect your AI with: td connect --workspace "${ws.dir}"`);
if (args[0] === 'sync') return void (await ghidra.syncGhidraProject(ws), print('symbols applied')); if (args[0] === 'sync') return void (await ghidra.syncGhidraProject(ws), print('symbols applied'));
if (args[0] === 'download') { if (args[0] === 'download') {
const r = await ghidra.downloadGhidraProject(ws, { force: values.force }); const r = await ghidra.downloadGhidraProject(ws, { force: values.force });
return print(r.updated ? `Ghidra project of ${r.info.version} downloaded${ws.config.ghidraHome ? '' : ' (open it with Ghidra 11.2.1, or run td ghidra install)'}` : 'the Ghidra project is up to date'); return print(r.updated ? `Ghidra project of ${r.info.version} downloaded${ws.config.ghidraHome ? '' : ` (open it with Ghidra ${r.info.ghidra}, or run td ghidra install)`}` : 'the Ghidra project is up to date');
} }
throw new Error('usage: td ghidra install|download|build|sync'); throw new Error('usage: td ghidra install|download|build|sync');
case 'decompile': case 'decompile':

View file

@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url';
import { run } from '../shared/build.js'; import { run } from '../shared/build.js';
import { hex } from '../shared/annotations.js'; import { hex } from '../shared/annotations.js';
import { loadToolchainLock } from '../shared/project.js'; import { loadToolchainLock } from '../shared/project.js';
import { GHIDRA_RELEASE, ghidraAtLeast, ghidraVersionAt } from '../shared/ghidra.js';
import { Api } from './api.js'; import { Api } from './api.js';
const SCRIPT_NAMES = ['ApplySymbols.java', 'DecompileAt.java', 'DisassembleAt.java', 'ExportFunctions.java', 'ExportOriginals.java']; const SCRIPT_NAMES = ['ApplySymbols.java', 'DecompileAt.java', 'DisassembleAt.java', 'ExportFunctions.java', 'ExportOriginals.java'];
@ -55,26 +56,46 @@ async function runHeadless(ws, args, timeoutMs) {
return r.output; return r.output;
} }
/** Downloads and unpacks the Ghidra release pinned in data/toolchain.lock.json. */ /**
export async function installGhidra(ws) { * Downloads and unpacks a Ghidra release: the one the server builds its project with (so the
const lock = loadToolchainLock(ws.dir).ghidra; * downloaded project opens), else the one pinned in data/toolchain.lock.json, else td's own pin.
if (!lock?.url || !lock?.sha256) throw new Error('data/toolchain.lock.json has no ghidra { url, sha256, dir } entry'); */
export async function installGhidra(ws, release = null) {
const lock = release?.url ? release : loadToolchainLock(ws.dir).ghidra?.url ? loadToolchainLock(ws.dir).ghidra : GHIDRA_RELEASE;
if (!lock?.url || !lock?.sha256) throw new Error('no Ghidra release to install (data/toolchain.lock.json has no ghidra { url, sha256, dir } entry)');
const toolsDir = path.join(ws.tdDir, 'tools'); const toolsDir = path.join(ws.tdDir, 'tools');
fs.mkdirSync(toolsDir, { recursive: true }); fs.mkdirSync(toolsDir, { recursive: true });
const zip = path.join(toolsDir, path.basename(new URL(lock.url).pathname)); const zip = path.join(toolsDir, path.basename(new URL(lock.url).pathname));
if (!fs.existsSync(zip)) { if (!fs.existsSync(zip)) {
console.log(`downloading ${lock.url}`); console.error(`downloading ${lock.url}`);
await new Api('').download(lock.url, zip, lock.sha256); await new Api('').download(lock.url, zip, lock.sha256);
} }
// Windows' own tar.exe (bsdtar) unpacks zip files; a GNU tar earlier on PATH (Git Bash) would not. // Windows' own tar.exe (bsdtar) unpacks zip files; a GNU tar earlier on PATH (Git Bash) would not.
const winTar = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'); const winTar = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe');
const r = os.platform() === 'win32' ? await run(winTar, ['-xf', zip, '-C', toolsDir], { timeoutMs: 600_000 }) : await run('unzip', ['-q', '-o', zip, '-d', toolsDir], { timeoutMs: 600_000 }); const r = os.platform() === 'win32' ? await run(winTar, ['-xf', zip, '-C', toolsDir], { timeoutMs: 600_000 }) : await run('unzip', ['-q', '-o', zip, '-d', toolsDir], { timeoutMs: 600_000 });
if (r.code !== 0) throw new Error(`could not unpack Ghidra: ${r.output}`); if (r.code !== 0) throw new Error(`could not unpack Ghidra: ${r.output}`);
fs.rmSync(zip, { force: true });
ws.config.ghidraHome = path.join(toolsDir, lock.dir); ws.config.ghidraHome = path.join(toolsDir, lock.dir);
ws.save(); ws.save();
return ws.config.ghidraHome; return ws.config.ghidraHome;
} }
/**
* A local Ghidra older than the one the server's project was saved with cannot open it. Upgrades
* the Ghidra td installed itself; a Ghidra the user configured is reported, not replaced.
*/
export async function ensureGhidraFor(ws, release) {
if (!release?.version || !ws.config.ghidraHome) return null;
const local = ghidraVersionAt(ws.config.ghidraHome);
if (local && ghidraAtLeast(local, release.version)) return null;
const managed = path.resolve(ws.config.ghidraHome).startsWith(path.resolve(path.join(ws.tdDir, 'tools')));
if (!managed || !release.url) {
throw new Error(`the server's Ghidra project needs Ghidra ${release.version}${release.jdk ? ` (JDK ${release.jdk})` : ''}, but ${ws.config.ghidraHome} is ${local ?? 'unknown'}: install it and run \`td config ghidraHome <dir>\`, or run \`td ghidra install\``);
}
console.error(`upgrading Ghidra ${local ?? '?'} -> ${release.version} (the server's project needs it)`);
return installGhidra(ws, release);
}
function writeSymbolFile(ws) { function writeSymbolFile(ws) {
const file = path.join(ws.tdDir, 'ghidra-symbols.tsv'); const file = path.join(ws.tdDir, 'ghidra-symbols.tsv');
const lines = []; const lines = [];
@ -127,6 +148,7 @@ export function disassemble(ws, addr) {
export async function downloadGhidraProject(ws, { force = false } = {}) { export async function downloadGhidraProject(ws, { force = false } = {}) {
const client = new Api(ws.config.server, ws.config.token); const client = new Api(ws.config.server, ws.config.token);
const info = await client.get('/api/ghidra-project'); const info = await client.get('/api/ghidra-project');
await ensureGhidraFor(ws, info.release ?? (info.ghidra ? { version: info.ghidra } : null));
if (!force && ws.config.ghidraPackage === info.sha256 && fs.existsSync(path.join(ws.tdDir, 'ghidra', `${PROJECT_NAME}.gpr`))) { if (!force && ws.config.ghidraPackage === info.sha256 && fs.existsSync(path.join(ws.tdDir, 'ghidra', `${PROJECT_NAME}.gpr`))) {
return { updated: false, info }; return { updated: false, info };
} }
@ -140,7 +162,22 @@ export async function downloadGhidraProject(ws, { force = false } = {}) {
const r = await run(tar, ['-xzf', tgz, '-C', projDir], { timeoutMs: 1800_000 }); const r = await run(tar, ['-xzf', tgz, '-C', projDir], { timeoutMs: 1800_000 });
if (r.code !== 0) throw new Error(`could not unpack the Ghidra project: ${r.output}`); if (r.code !== 0) throw new Error(`could not unpack the Ghidra project: ${r.output}`);
fs.rmSync(tgz, { force: true }); fs.rmSync(tgz, { force: true });
takeProjectOwnership(projDir);
ws.config.ghidraPackage = info.sha256; ws.config.ghidraPackage = info.sha256;
ws.save(); ws.save();
return { updated: true, info }; return { updated: true, info };
} }
/**
* Ghidra opens a project only for the user recorded as its owner (its user.name, spaces removed).
* The server packs its project under its own account, so the unpacked copy is handed to whoever
* runs td.
*/
function takeProjectOwnership(projDir) {
const prp = path.join(projDir, `${PROJECT_NAME}.rep`, 'project.prp');
if (!fs.existsSync(prp)) return;
const user = os.userInfo().username.replace(/\s/g, '').replace(/[<>&"]/g, '');
const text = fs.readFileSync(prp, 'utf8');
const owned = text.replace(/(<STATE NAME="OWNER" TYPE="string" VALUE=")[^"]*(" \/>)/, (_, a, b) => a + user + b);
if (owned !== text) fs.writeFileSync(prp, owned);
}

View file

@ -240,10 +240,13 @@ async function remoteCheck(ws, targets, { wait = true, explicit = false } = {})
/** /**
* The original function as text. A local Ghidra (td ghidra install/build) answers locally; * The original function as text. A local Ghidra (td ghidra install/build) answers locally;
* otherwise the server's Ghidra export does. * otherwise, or when it is older than the release td pins (it cannot open the server's project),
* the server's Ghidra export does.
*/ */
export async function original(ws, query, kind) { export async function original(ws, query, kind) {
if (ws.config.ghidraHome) { const { ghidraVersionAt, ghidraAtLeast, GHIDRA_RELEASE } = await import('../shared/ghidra.js');
const local = ws.config.ghidraHome ? ghidraVersionAt(ws.config.ghidraHome) : null;
if (local && ghidraAtLeast(local, GHIDRA_RELEASE.version)) {
const { decompile, disassemble } = await import('./ghidra.js'); const { decompile, disassemble } = await import('./ghidra.js');
const s = ws.findSymbol(query); const s = ws.findSymbol(query);
const addr = s?.addr ?? (/^0x[0-9a-f]+$/i.test(query) ? Number.parseInt(query, 16) : null); const addr = s?.addr ?? (/^0x[0-9a-f]+$/i.test(query) ? Number.parseInt(query, 16) : null);

View file

@ -31,6 +31,7 @@ import { PEImage } from '../shared/pe.js';
import { loadProject, loadToolchainLock } from '../shared/project.js'; import { loadProject, loadToolchainLock } from '../shared/project.js';
import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js'; import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js';
import { hex } from '../shared/annotations.js'; import { hex } from '../shared/annotations.js';
import { GHIDRA_RELEASE } from '../shared/ghidra.js';
// TD_WEB_DIR points through the release symlink in production, so new pages are live without a restart. // TD_WEB_DIR points through the release symlink in production, so new pages are live without a restart.
const WEB_DIR = process.env.TD_WEB_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); const WEB_DIR = process.env.TD_WEB_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'web');
@ -1011,7 +1012,8 @@ export function createApp(ctx) {
auth(req); auth(req);
const info = ctx.ghidra.info(); const info = ctx.ghidra.info();
if (!info) throw new HttpError(404, 'no Ghidra project has been built on this server yet'); if (!info) throw new HttpError(404, 'no Ghidra project has been built on this server yet');
return { ...info, download: `${config.publicUrl}/api/ghidra-project/download` }; const ghidra = GHIDRA_RELEASE.version === info.ghidra ? GHIDRA_RELEASE : { version: info.ghidra };
return { ...info, release: ghidra, download: `${config.publicUrl}/api/ghidra-project/download` };
}); });
router.on('GET', '/api/ghidra-project/download', ({ req, res }) => { router.on('GET', '/api/ghidra-project/download', ({ req, res }) => {

View file

@ -13,6 +13,7 @@ import { Accounts } from './accounts.js';
import { agentGuidance, setAgentGuidance, resetAgentGuidance } from './guidance.js'; import { agentGuidance, setAgentGuidance, resetAgentGuidance } from './guidance.js';
import { productivity, formatProductivity } from './stats.js'; import { productivity, formatProductivity } from './stats.js';
import { PEImage } from '../shared/pe.js'; import { PEImage } from '../shared/pe.js';
import { GHIDRA_RELEASE } from '../shared/ghidra.js';
const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'template'); const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'template');
@ -323,9 +324,9 @@ async function main() {
} }
if (cmd === 'ghidra' && (sub === 'build' || sub === 'refresh')) { if (cmd === 'ghidra' && (sub === 'build' || sub === 'refresh')) {
// The server's Ghidra project (needs ghidraHome in server.json and a JDK 21). // The server's Ghidra project (needs ghidraHome in server.json and the JDK that Ghidra needs).
const ctx = openServer(dataDir); const ctx = openServer(dataDir);
if (!ctx.ghidra.enabled) throw new Error('set ghidraHome in server.json to a Ghidra 11.2.1 installation'); if (!ctx.ghidra.enabled) throw new Error(`set ghidraHome in server.json to a Ghidra ${GHIDRA_RELEASE.version} installation`);
ctx.store.setMeta('ghidra.commit', ctx.repo.head()); ctx.store.setMeta('ghidra.commit', ctx.repo.head());
const info = sub === 'build' ? await ctx.ghidra.build() : await ctx.ghidra.refresh(); const info = sub === 'build' ? await ctx.ghidra.build() : await ctx.ghidra.refresh();
console.log(JSON.stringify(info, null, 2)); console.log(JSON.stringify(info, null, 2));

View file

@ -12,6 +12,7 @@ import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { run } from '../shared/build.js'; import { run } from '../shared/build.js';
import { hex } from '../shared/annotations.js'; import { hex } from '../shared/annotations.js';
import { GHIDRA_RELEASE, ghidraVersionAt } from '../shared/ghidra.js';
const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'ghidra_scripts'); const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'ghidra_scripts');
const PROJECT = 'td'; const PROJECT = 'td';
@ -47,9 +48,8 @@ export class ServerGhidra {
async headless(args, timeoutMs) { async headless(args, timeoutMs) {
fs.mkdirSync(this.projectDir, { recursive: true }); fs.mkdirSync(this.projectDir, { recursive: true });
const exe = path.join(this.config.ghidraHome, 'support', 'analyzeHeadless'); const exe = path.join(this.config.ghidraHome, 'support', 'analyzeHeadless');
// Ghidra 11.2 is made for JDK 21; the system default may be newer. // Ghidra 12.2 needs JDK 25; `ghidraJavaHome` overrides a different default JDK.
const jdk21 = '/usr/lib/jvm/java-21-openjdk-amd64'; const javaHome = this.config.ghidraJavaHome ?? process.env.JAVA_HOME;
const javaHome = this.config.ghidraJavaHome ?? (fs.existsSync(jdk21) ? jdk21 : process.env.JAVA_HOME);
const env = { ...process.env, MAXMEM: this.config.ghidraMaxMem ?? '6G', ...(javaHome ? { JAVA_HOME: javaHome, PATH: `${javaHome}/bin:${process.env.PATH}` } : {}) }; const env = { ...process.env, MAXMEM: this.config.ghidraMaxMem ?? '6G', ...(javaHome ? { JAVA_HOME: javaHome, PATH: `${javaHome}/bin:${process.env.PATH}` } : {}) };
const r = await run(exe, [this.projectDir, PROJECT, ...args, '-scriptPath', SCRIPTS_DIR], { timeoutMs, env }); const r = await run(exe, [this.projectDir, PROJECT, ...args, '-scriptPath', SCRIPTS_DIR], { timeoutMs, env });
if (r.code !== 0) throw new Error(`Ghidra failed (exit ${r.code}): ${r.output.slice(-2000)}`); if (r.code !== 0) throw new Error(`Ghidra failed (exit ${r.code}): ${r.output.slice(-2000)}`);
@ -101,7 +101,8 @@ export class ServerGhidra {
const old = this.info(); const old = this.info();
fs.renameSync(tmp, path.join(this.dir, name)); fs.renameSync(tmp, path.join(this.dir, name));
const sha256 = await sha256File(path.join(this.dir, name)); const sha256 = await sha256File(path.join(this.dir, name));
const info = { version: v.id, file: name, sha256, size: fs.statSync(path.join(this.dir, name)).size, builtAt: Date.now(), commit: head, functions: n, ghidra: this.config.ghidraVersion ?? '11.2.1' }; const ghidra = ghidraVersionAt(this.config.ghidraHome) ?? this.config.ghidraVersion ?? GHIDRA_RELEASE.version;
const info = { version: v.id, file: name, sha256, size: fs.statSync(path.join(this.dir, name)).size, builtAt: Date.now(), commit: head, functions: n, ghidra };
fs.writeFileSync(path.join(this.dir, 'package.json'), JSON.stringify(info, null, 2)); fs.writeFileSync(path.join(this.dir, 'package.json'), JSON.stringify(info, null, 2));
if (old?.file && old.file !== name) fs.rmSync(path.join(this.dir, old.file), { force: true }); if (old?.file && old.file !== name) fs.rmSync(path.join(this.dir, old.file), { force: true });
this.store.setMeta('ghidra.refreshedAt', Date.now()); this.store.setMeta('ghidra.refreshedAt', Date.now());

29
src/shared/ghidra.js Normal file
View file

@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
// The Ghidra release the server builds its project with. A project saved by a newer Ghidra cannot
// be opened by an older one, so clients that download it need the same release.
export const GHIDRA_RELEASE = {
version: '12.2',
url: 'https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip',
sha256: '2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67',
dir: 'ghidra_12.2_PUBLIC',
jdk: '25',
};
/** The application.version of the Ghidra installed at `home`, or null. */
export function ghidraVersionAt(home) {
if (!home) return null;
try {
const props = fs.readFileSync(path.join(home, 'Ghidra', 'application.properties'), 'utf8');
return props.match(/^application\.version=(.+)$/m)?.[1].trim() ?? null;
} catch {
return null;
}
}
/** True when Ghidra version `a` is at least `b` (dotted numeric versions). */
export function ghidraAtLeast(a, b) {
const key = (v) => String(v ?? '0').split('.').map((x) => Number.parseInt(x, 10) || 0).concat([0, 0, 0]).slice(0, 3).reduce((n, x) => n * 1000 + x, 0);
return key(a) >= key(b);
}

View file

@ -5,10 +5,10 @@
"clSha256": null "clSha256": null
}, },
"ghidra": { "ghidra": {
"version": "11.2.1", "version": "12.2",
"url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip", "url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip",
"sha256": "ce4db5117da0fbaf8f33863fec4f40902f754f06b68945a59fb1c0f9b1bc461c", "sha256": "2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67",
"dir": "ghidra_11.2.1_PUBLIC", "dir": "ghidra_12.2_PUBLIC",
"jdk": "21" "jdk": "25"
} }
} }