diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d61c03..8351c2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,8 +31,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Test package notices - run: node --test --test-timeout=60000 scripts/package/collect-notices.test.mjs + - name: Test package resources + run: node --test --test-timeout=60000 scripts/package/*.test.mjs - name: Check formatting run: cargo fmt --all --check - name: Lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94d504b..6b04c27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,8 +30,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Test package notices - run: node --test --test-timeout=60000 scripts/package/collect-notices.test.mjs + - name: Test package resources + run: node --test --test-timeout=60000 scripts/package/*.test.mjs - name: Check formatting run: cargo fmt --all --check - name: Lint diff --git a/README.md b/README.md index c6800b8..eeda596 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Java Support for Lithe -> **Viva 预览版 Java 支持(1.1.3)**:清单已迁到当前契约 0,后端使用 Core +> **Viva 预览版 Java 支持(1.2.0)**:清单已迁到当前契约 0,后端使用 Core > `65cf82343c5e315aae2e335b2e8f0217b703e89a` 中的 Rust SDK 0.4.0(尚无对应发布标签)。 > 本轮验证含第三方 JAR 依赖的普通 Maven 工程:JDT 补全、Maven 编译、main 运行和断点调试,包含变量读取、继续和退出。 > 需要同时包含 Lithe 客户端的动作注册解码、动作刷新与首次调试加载修复;未修改这些行为的旧 preview 不能直接使用本包。 @@ -24,10 +24,27 @@ > SDK. Maven and Gradle import settings come from the Maven and Gradle plugins through a function interface that > Lithe Core relays between plugins. -1.1.3 面向包含主仓库 [#1884](https://github.com/1lck/Lithe-IDEA/pull/1884) 的配套 Viva preview(客户端修订 `607f00967d70a7017d49805183d0d2e6ffdf1c0d` 或更新版本)。API / 能力与传输版本以 `plugin.json`、`sdk.lock.json` +1.2.0 面向包含主仓库 [#1884](https://github.com/1lck/Lithe-IDEA/pull/1884) 的配套 Viva preview(客户端修订 `607f00967d70a7017d49805183d0d2e6ffdf1c0d` 或更新版本)。API / 能力与传输版本以 `plugin.json`、`sdk.lock.json` 和固定 Core 中的[插件资料](https://github.com/Lithe-IDEA/Lithe-core-rust/tree/65cf82343c5e315aae2e335b2e8f0217b703e89a/shared/plugins)为准。 `MIGRATION.md` 保留迁移历史,不能用其中旧宿主、旧 API 数字推断当前兼容性。 +## 二进制依赖源码 + +1.2.0 新增 `jdt:` 只读类内容提供者;配套 Viva 客户端需包含 [Lithe #1906](https://github.com/1lck/Lithe-IDEA/pull/1906) 的虚拟文档语言/URI +同步和过期内容导航取消修复。较旧的兼容 Viva 仍可使用此前的 Maven/Gradle 工作流, +但不保证打开反编译类后继续导航。新能力不增加 API、权限或运行时下载。 + +类型和方法定义复用 JDT 的源码附加与反编译结果。没有源码的类也可只读打开,并继续 +跳到依赖的其他类;方法定位使用 JDT 返回的行号。每份内容最多 1 MiB,插件不持有额外 +源码缓存,关闭标签或工作区沿用宿主资源回收。缺失依赖的成员不能保证解析。 + +针对残缺旧库,构建阶段修正锁定 JDT 1.61.0 对缺失内部类的遍历,避免整个外部类为空; +无法恢复缺失的内部类本身。修改后的 JDT core JAR 不再具有 Eclipse JAR 签名,随包包含 +修改的 EPL 源码和完整哈希清单;原生 JVM 签名不变。来源、验证和移除条件见 +[补丁说明](third_party/jdtls/patches/README.md)。安装包和下载缓存运行时保持只读。 + +`.jsp` 仍按 HTML 打开;Java 补全、跳转和 JSP 源码断点不属于此次新增能力。 + ## 当前 Viva 验证入口 先用 `scripts/build-package.sh --arch arm64` 与 `scripts/archive-package.sh` 构建真实插件包。 diff --git a/backend/src/class_content.rs b/backend/src/class_content.rs new file mode 100644 index 0000000..c3a8e3d --- /dev/null +++ b/backend/src/class_content.rs @@ -0,0 +1,267 @@ +//! Read-only binary sources owned by JDT LS. Only the plugin understands the +//! `jdt:` scheme; the host owns tabs, cancellation and subscription cleanup. + +use crate::settings_session::{SettingsSession, REQUEST_METHOD, SERVER_ID}; +use lithe_plugin_sdk::{ + BackendHandle, Context, FunctionError, PluginError, SdkError, Subscription, Value, + WorkspaceContext, +}; +use serde_json::json; +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, PoisonError}; +use url::Url; + +pub const CONTENT_FUNCTION: &str = "java.classFileContents"; +const REGISTER_METHOD: &str = "lithe/host/documents/registerContentProvider"; +const CONTENT_METHOD: &str = "java/classFileContents"; +const CONTENT_TIMEOUT_MS: u64 = 25_000; +const MAX_URI_BYTES: usize = 16 * 1024; +const MAX_CONTENT_BYTES: usize = 1024 * 1024; +pub const REGISTRATION_FAILED: &str = "java.content_registration_failed"; + +struct Registration { + generation: u64, + subscription: Subscription, +} + +/// Bindings belong to workspace generations, not individual JDT processes. +/// A stopped process leaves its binding in place; reads fail until JDT restarts. +pub struct ClassContentSession { + handle: BackendHandle, + registrations: Mutex>, +} +impl ClassContentSession { + pub fn new(handle: BackendHandle) -> Self { + Self { + handle, + registrations: Mutex::new(HashMap::new()), + } + } + + /// Serialize registration mutations, but never fetch class text while holding + /// this gate. The host records registrations without calling their callback. + pub fn sync(&self, settings: &SettingsSession) -> Result { + // Serialize the snapshot too: a delayed old context list must not + // dispose a binding that a newer synchronization already registered. + let mut bindings = self + .registrations + .lock() + .unwrap_or_else(PoisonError::into_inner); + let host = self.handle.host(); + let listed = host.request("lithe/host/workspace/listContexts", json!({}))?; + let contexts = listed + .get("contexts") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut seen = HashSet::new(); + let mut failures = 0; + for value in contexts { + let context = WorkspaceContext::from_value(&value)?; + let id = context.context_id().to_owned(); + seen.insert(id.clone()); + if bindings + .get(&id) + .is_some_and(|b| b.generation == context.generation()) + { + continue; + } + if !settings.server_is_running(&context) { + continue; + } + if self.bind(&mut bindings, &context).is_err() { + failures += 1; + } + } + let gone: Vec<_> = bindings + .keys() + .filter(|id| !seen.contains(*id)) + .cloned() + .collect(); + for id in gone { + if let Some(old) = bindings.remove(&id) { + if old.subscription.dispose().is_err() { + failures += 1; + } + } + } + Ok(failures) + } + + /// Complete the registration before returning a valid JDT launch plan. + /// This prevents a fast first definition from beating the state callback. + pub fn ensure(&self, context: &WorkspaceContext) -> Result<(), SdkError> { + let mut bindings = self + .registrations + .lock() + .unwrap_or_else(PoisonError::into_inner); + self.bind(&mut bindings, context) + } + + fn bind( + &self, + bindings: &mut HashMap, + context: &WorkspaceContext, + ) -> Result<(), SdkError> { + let id = context.context_id(); + if bindings + .get(id) + .is_some_and(|b| b.generation == context.generation()) + { + return Ok(()); + } + if let Some(old) = bindings.remove(id) { + old.subscription.dispose()?; + } + let subscription = self.handle.host().subscribe( + REGISTER_METHOD, + json!({"context":context.to_value(),"scheme":"jdt","function":CONTENT_FUNCTION}), + )?; + bindings.insert( + id.to_owned(), + Registration { + generation: context.generation(), + subscription, + }, + ); + Ok(()) + } + + /// One restricted diagnostic per failed synchronization boundary, no paths + /// or upstream responses. Expected context disposal remains a successful no-op. + pub fn sync_reporting(&self, settings: &SettingsSession, report: &dyn Fn(&str, u64)) { + match self.sync(settings) { + Ok(0) => {} + Ok(count) => report(REGISTRATION_FAILED, count), + Err(_) => report(REGISTRATION_FAILED, 1), + } + } +} + +fn failure(code: &str, message: &str) -> FunctionError { + PluginError::new(code, message).into() +} +fn validated_uri(params: &Value) -> Result<&str, FunctionError> { + let uri = params + .as_object() + .filter(|p| p.len() == 1) + .and_then(|p| p.get("uri")) + .and_then(Value::as_str) + .ok_or_else(|| { + failure( + "java.class_content.invalid_params", + "Invalid Java class content request", + ) + })?; + if uri.len() > MAX_URI_BYTES + || uri.chars().any(char::is_control) + || !Url::parse(uri).is_ok_and(|u| { + u.scheme() == "jdt" + && !u.path().is_empty() + && u.username().is_empty() + && u.password().is_none() + }) + { + return Err(failure( + "java.class_content.invalid_params", + "Invalid Java class content request", + )); + } + Ok(uri) +} +fn content_result(answer: Value) -> Result { + let content = match answer { + Value::Object(mut object) => object.remove("value"), + _ => None, + }; + let Some(Value::String(content)) = content else { + return Err(failure( + "java.class_content.invalid_result", + "Java class source is unavailable", + )); + }; + if content.trim().is_empty() { + return Err(failure( + "java.class_content.empty_result", + "Java class source is unavailable", + )); + } + if content.len() > MAX_CONTENT_BYTES { + return Err(failure( + "java.class_content.too_large", + "Java class source exceeds the content limit", + )); + } + Ok(Value::String(content)) +} + +/// Reuse JDT's own decompiler and source attachment resolution. Keep the exact +/// opaque URI, including its query encoding, and hold no text cache or files. +pub fn read( + settings: &SettingsSession, + params: Value, + context: &Context, +) -> Result { + context.check_cancelled()?; + let uri = validated_uri(¶ms)?; + let workspace = context.workspace_context().ok_or_else(|| { + failure( + "java.class_content.context_missing", + "A workspace context is required", + ) + })?; + let generation = settings + .running_server_generation(workspace) + .ok_or_else(|| { + failure( + "java.class_content.unavailable", + "The Java language server is unavailable", + ) + })?; + let answer=context.host().request(REQUEST_METHOD,json!({"context":workspace.to_value(),"serverID":SERVER_ID,"method":CONTENT_METHOD,"params":{"uri":uri},"timeoutMilliseconds":CONTENT_TIMEOUT_MS})); + context.check_cancelled()?; + let answer = answer.map_err(|_| { + failure( + "java.class_content.unavailable", + "Java class source is unavailable", + ) + })?; + if settings.running_server_generation(workspace) != Some(generation) { + return Err(failure( + "java.class_content.unavailable", + "The Java language server changed during the request", + )); + } + content_result(answer) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn class_identity_is_validated_but_never_reencoded() { + let uri = "jdt://contents/lib.jar/pkg/Type.java?=project/lib%5C/lib.jar%3Cpkg(Type.class"; + let input = json!({"uri":uri}); + assert_eq!(validated_uri(&input).unwrap(), uri); + for input in [ + json!({"uri":"file:///private.java"}), + json!({"uri":"https://example.com"}), + json!({"uri":"jdt://contents/a\n"}), + json!({"uri":uri,"extra":true}), + json!({"uri":"jdt:"}), + json!({"uri":format!("jdt://contents/{}","x".repeat(MAX_URI_BYTES))}), + ] { + assert!(validated_uri(&input).is_err()); + } + } + #[test] + fn source_result_requires_bounded_text() { + assert_eq!( + content_result(json!({"value":"class Type {}"})).unwrap(), + json!("class Type {}") + ); + assert!(content_result(json!({"value":null})).is_err()); + assert!(content_result(json!({"value":""})).is_err()); + assert!(content_result(json!({"value":"x".repeat(MAX_CONTENT_BYTES+1)})).is_err()); + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 92df99c..5827f75 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -47,6 +47,7 @@ //! `settings_session::SESSION_CATEGORIES`. The backend never writes to //! stderr, and a fact carries no path, source or tool output. +pub mod class_content; pub mod launch; pub mod plugin_functions; pub mod run; @@ -130,11 +131,18 @@ pub fn backend() -> Result { let session = Arc::new(SettingsSession::new(Arc::new(HostServices::new( backend.handle(), )))); + let content_session = Arc::new(class_content::ClassContentSession::new(backend.handle())); + backend.function(class_content::CONTENT_FUNCTION, { + let session = Arc::clone(&session); + move |params, context| class_content::read(&session, params, context) + })?; + backend.suppress_success_diagnostics(class_content::CONTENT_FUNCTION)?; let run_session = Arc::new(RunSession::new(backend.handle(), Arc::clone(&session))); let handle = backend.handle(); backend.on_host_notification(LANGUAGE_SERVER_STATE_NOTIFICATION, { let run_session = Arc::clone(&run_session); let session = Arc::clone(&session); + let content_session = Arc::clone(&content_session); move |params| { // An error from `degraded` only means the arguments were invalid, // and the session passes controlled categories and counts. @@ -144,6 +152,7 @@ pub fn backend() -> Result { .degraded(NOTIFICATION_OPERATION, code, Some(count)); }; session.did_change_state(¶ms, &report); + content_session.sync_reporting(&session, &report); run_session.sync_contexts_reporting(&report); Ok(()) } @@ -152,8 +161,25 @@ pub fn backend() -> Result { let session = Arc::clone(&session); move |params, context| settings_changed(&session, params, context) })?; - backend.function(LAUNCH_FUNCTION_ID, move |params, context| { - launch::resolve_launch_function(&session, params, context) + backend.function(LAUNCH_FUNCTION_ID, { + let session = Arc::clone(&session); + let content_session = Arc::clone(&content_session); + move |params, context| { + let plan = launch::resolve_launch_function(&session, params, context)?; + if let Some(workspace) = context.workspace_context() { + if content_session.ensure(workspace).is_err() { + // The old language workflows remain available if a host + // refuses this optional contribution; diagnose the loss. + let _ = context.diagnostics().degraded( + LAUNCH_FUNCTION_ID, + class_content::REGISTRATION_FAILED, + Some(1), + ); + } + } + context.check_cancelled()?; + Ok(plan) + } })?; // The run session registers the editor actions of every workspace context @@ -178,7 +204,10 @@ pub fn backend() -> Result { backend.on_contexts_changed({ let run_session = Arc::clone(&run_session); let report = Arc::clone(&report); + let content_session = Arc::clone(&content_session); + let session = Arc::clone(&session); move |_params| { + content_session.sync_reporting(&session, &*report); run_session.sync_contexts_reporting(&*report); Ok(()) } diff --git a/backend/tests/session.rs b/backend/tests/session.rs index b7deb79..705807c 100644 --- a/backend/tests/session.rs +++ b/backend/tests/session.rs @@ -265,3 +265,15 @@ fn initialize_without_lithe_logging_is_refused() { "closing stdin without exit is a failure exit" ); } + +#[test] +fn binary_content_callback_is_available_before_provider_registration() { + let mut host = Host::start(); + let initialized = host.initialize(); + let exit = host.close(); + assert_eq!(exit, 1); + assert!( + initialized.to_string().contains("java.classFileContents"), + "{initialized}" + ); +} diff --git a/docs/releases/v1.2.0.md b/docs/releases/v1.2.0.md new file mode 100644 index 0000000..aa92897 --- /dev/null +++ b/docs/releases/v1.2.0.md @@ -0,0 +1,51 @@ +## 中文 + +Java Support 1.2.0 为配套 Viva 预览版新增无源码依赖类导航。本版本是预发布,插件 ZIP 不带发布者签名。 + +### 下载与安装 + +选择 macOS Apple Silicon(`darwin-arm64`)、macOS Intel(`darwin-x64`)或 Windows x64(`windows-x64`)ZIP,在 Viva 插件设置中从文件安装。Maven/Gradle 插件沿用 Maven 0.0.1、Gradle 1.0.0。 + +原有项目工作流仍需包含 [Lithe #1884](https://github.com/1lck/Lithe-IDEA/pull/1884) 的兼容 Viva 与 Core #29;新增的虚拟类间导航还需 [Lithe #1906](https://github.com/1lck/Lithe-IDEA/pull/1906) 的虚拟文档语言/URI 与过期导航取消修复。契约、API、能力版本仍为 0,没有增加权限或外部工具要求。 + +### 更新 + +- 可只读打开依赖 JAR 中没有源码的类,跳到类型和方法定义,并从类视图继续跳转其他类。 +- 修复缺少内部类文件时,现有外部类也无法打开的问题;缺失类本身无法恢复。 +- 内容请求有超时、取消与大小限制,插件不新增源码缓存或后台轮询,安装目录保持只读。 + +### 兼容性与已知限制 + +- 缺失的第三方依赖可能使部分成员无法解析;不能把两个独立 class 文件视为完整可编译工程。 +- JSP 当前仍按 HTML 打开,没有 JSP 内 Java 补全、导航或源码断点支持。 +- 构建期修改的 JDT core JAR 已移除失效的 Eclipse JAR 签名,随包附带修改的 EPL-2.0 源码和补丁哈希;原生 JVM 签名不变。 +- 本版不宣称整体内存或响应速度下降。原生窗口、完整日志/取消分支、JUnit/TestNG 与 Spring/MyBatis 等范围仍见 README;Java 5/7 源码、长 module-path、JavaCC、多版本 JAR 和部分旧泛型限制继续保留。 + +[对比 1.1.3 与 1.2.0](https://github.com/Lithe-IDEA/lithe-plugin-java/compare/v1.1.3...v1.2.0) + +--- + +## English + +Java Support 1.2.0 adds navigation into binary dependencies for the matching Viva preview. This prerelease uses unsigned plugin ZIPs. + +### Download and installation + +Install the macOS Apple Silicon (`darwin-arm64`), macOS Intel (`darwin-x64`), or Windows x64 (`windows-x64`) ZIP from a file in Viva's plugin settings. Continue using Maven 0.0.1 and Gradle 1.0.0. + +Existing project workflows still require compatible Viva including [Lithe #1884](https://github.com/1lck/Lithe-IDEA/pull/1884) and Core #29. Further navigation from virtual class views requires [Lithe #1906](https://github.com/1lck/Lithe-IDEA/pull/1906), which fixes virtual-document language/URI synchronization and obsolete-navigation cancellation. Contract, API and capability versions remain 0, without additional permissions or external tools. + +### Changes + +- Open dependency classes without attached sources read-only, navigate to type and method definitions, and continue to other classes. +- Prevent missing inner-class files from making the available outer class unreadable; missing class bodies cannot be recovered. +- Content requests are bounded and cancellable, without a new plugin source cache or background polling; installed packages remain read-only. + +### Compatibility and known limitations + +- Missing third-party dependencies can leave some members unresolved; two standalone class files do not form a complete buildable project. +- JSP still opens as HTML, without Java completion, navigation or JSP-source breakpoints. +- The build-modified JDT core JAR removes its invalid Eclipse JAR signature and includes modified EPL-2.0 source and patch hashes; native JVM signatures are unchanged. +- This release claims no overall memory or responsiveness improvement. README retains native-window, logging/cancellation, JUnit/TestNG and Spring/MyBatis acceptance limits, plus Java 5/7, long module-path, JavaCC, multi-release JAR and older generic-code limitations. + +[Compare 1.1.3 and 1.2.0](https://github.com/Lithe-IDEA/lithe-plugin-java/compare/v1.1.3...v1.2.0) diff --git a/io.github.lithe-idea.java-support/plugin.json b/io.github.lithe-idea.java-support/plugin.json index 8384dba..9c1b44f 100644 --- a/io.github.lithe-idea.java-support/plugin.json +++ b/io.github.lithe-idea.java-support/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 0, "id": "io.github.lithe-idea.java-support", "displayName": "Java Support", - "version": "1.1.3", + "version": "1.2.0", "pluginAPIVersion": 0, "requires": {"backend": 0, "debugAdapters": 0, "languages": 0, "languageServers": 0, "pluginFunctions": 0, "settings": 0, "projectGenerators": 0, "toolDefinitions": 0}, "requiresTools": ["java"], diff --git a/scripts/assemble-package.sh b/scripts/assemble-package.sh index 849f5eb..f77f0d0 100755 --- a/scripts/assemble-package.sh +++ b/scripts/assemble-package.sh @@ -162,3 +162,6 @@ fi # Notices are build inputs, copied alongside the binary from locked sources. node "$ROOT_DIR/scripts/package/collect-notices.mjs" "$OUTPUT_DIR" >&2 + +# Compile the locked upstream decompiler fix only in package staging. +node "$ROOT_DIR/scripts/package/patch-jdt-decompiler.mjs" "$OUTPUT_DIR" >&2 diff --git a/scripts/package/build-windows.mjs b/scripts/package/build-windows.mjs index 5c722e8..c5c9d60 100644 --- a/scripts/package/build-windows.mjs +++ b/scripts/package/build-windows.mjs @@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { collectNotices, verifyNotices } from './collect-notices.mjs'; +import {patch,verifyPatch} from './patch-jdt-decompiler.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); if (process.platform !== 'darwin') throw new Error('Assemble this development package on macOS.'); @@ -89,6 +90,8 @@ try { await cp(resolve(backend), join(packageRoot, manifest.backend.launch.path)); collectNotices(packageRoot); verifyNotices(packageRoot); + patch(packageRoot); + verifyPatch(packageRoot); const target = join(artifacts, `Lithe-Java-Support-${manifest.version}-windows-x64.zip`); execFileSync('/usr/bin/ditto', ['-c', '-k', '--keepParent', packageRoot, target], { timeout: 120_000 }); await writeFile(`${target}.sha256`, `${await digest(target)} ${target.split('/').at(-1)}\n`); diff --git a/scripts/package/patch-jdt-decompiler.mjs b/scripts/package/patch-jdt-decompiler.mjs new file mode 100644 index 0000000..20f8913 --- /dev/null +++ b/scripts/package/patch-jdt-decompiler.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Build-time only: the verified upstream staging tree is read-only input. +// Compile the narrowly patched EPL-2.0 integration against its exact original +// JDT distribution; package the changed source and hashes beside its notices. +import {execFileSync} from 'node:child_process'; +import {createHash} from 'node:crypto'; +import {readFileSync,writeFileSync,readdirSync,mkdtempSync,mkdirSync,rmSync,cpSync} from 'node:fs'; +import {resolve,join,dirname} from 'node:path'; +import {tmpdir} from 'node:os'; +import {fileURLToPath} from 'node:url'; + +const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..'); +const source=join(root,'third_party/jdtls/patches/FernFlowerDecompiler.java'); +const spec=JSON.parse(readFileSync(join(root,'third_party/jdtls/patches/manifest.json'),'utf8')); +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +const run=(exe,args,options={})=>execFileSync(exe,args,{timeout:120000,maxBuffer:8*1024*1024,...options}); +const classPrefix='org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler'; +const signature=name=>/^META-INF\/[^/]+\.(SF|RSA|DSA|EC)$/i.test(name); +function coreJar(packageRoot){ + const plugins=join(packageRoot,'language-server/plugins'); + const jars=readdirSync(plugins).filter(n=>/^org\.eclipse\.jdt\.ls\.core_1\.61\.0\..*\.jar$/.test(n)); + if(jars.length!==1)throw Error('The pinned JDT 1.61 core bundle is required'); + return join(plugins,jars[0]); +} +function entries(jar){return run('/usr/bin/unzip',['-Z1',jar],{encoding:'utf8'}).trim().split('\n');} + +export function verifyPatch(packageRoot,io={}){ + const directory=join(packageRoot,'licenses/jdt-decompiler'); + const meta=JSON.parse(readFileSync(join(directory,'patch.json'),'utf8')); + if(Object.keys(spec).some(key=>JSON.stringify(meta[key])!==JSON.stringify(spec[key])) || hash(readFileSync(source))!==spec.sourceSHA256 || hash(readFileSync(join(directory,'FernFlowerDecompiler.java')))!==spec.sourceSHA256)throw Error('Decompiler patch source identity mismatch'); + const jdk=JSON.parse(readFileSync(join(root,'third_party/jdk/manifest.json'),'utf8')); + if(meta.compiler?.version!==jdk.version || jdk.platforms[meta.compiler?.platform]?.sha256!==meta.compiler?.archiveSHA256)throw Error('Decompiler compiler identity mismatch'); + const jar=coreJar(packageRoot); + if(hash(readFileSync(jar))!==meta.patchedCoreJarSHA256)throw Error('Decompiler patch bundle changed'); + if((io.entries??entries)(jar).some(signature))throw Error('Modified JDT bundle retains upstream signature entries'); + if(meta.upstreamSignaturesRemoved!==true)throw Error('Missing upstream signature removal declaration'); + if(!meta.classes || JSON.stringify(meta.classes.map(item=>item.path).sort())!==JSON.stringify(spec.classEntries))throw Error('Missing patched decompiler classes'); + for(const item of meta.classes){ + if(!item.path.startsWith(classPrefix) || !item.path.endsWith('.class') || item.path.includes('..'))throw Error('Invalid patch class entry'); + if(hash((io.readEntry??((jar,path)=>run('/usr/bin/unzip',['-p',jar,path])))(jar,item.path))!==item.sha256)throw Error('Patched decompiler class changed'); + } +} +export function patch(packageRoot){ + packageRoot=resolve(packageRoot); + const jar=coreJar(packageRoot); + if(hash(readFileSync(source))!==spec.sourceSHA256 || hash(readFileSync(jar))!==spec.originalCoreJarSHA256)throw Error('Decompiler patch inputs do not match the lock'); + // Always use the pinned host-architecture JDK; the output is Java bytecode + // shared by macOS and Windows, even when packaging another architecture. + const jdk=run(join(root,'scripts/prepare-jdk.sh'),[],{encoding:'utf8',env:{...process.env,LITHE_JDK_TARGET_ARCH:process.arch==='arm64'?'arm64':'x86_64'}}).trim(); + const stage=mkdtempSync(join(tmpdir(),'lithe-jdt-decompiler-')); + try{ + const classes=join(stage,'classes');mkdirSync(classes); + run(join(jdk,'bin/javac'),['--release','21','-g:none','-classpath',join(dirname(jar),'*'),'-d',classes,source]); + const original=entries(jar); + const folder=join(classes,'org/eclipse/jdt/ls/core/internal/decompiler'); + const generated=readdirSync(folder).filter(n=>n.endsWith('.class')).sort().map(n=>classPrefix.substring(0,classPrefix.lastIndexOf('/')+1)+n); + if(JSON.stringify(generated)!==JSON.stringify(spec.classEntries) || generated.some(n=>!original.includes(n)))throw Error('Unexpected decompiler class layout'); + const unpack=join(stage,'bundle');mkdirSync(unpack);run('/usr/bin/unzip',['-q',jar,'-d',unpack]); + for(const name of original.filter(signature))rmSync(join(unpack,name)); + const manifestFile=join(unpack,'META-INF/MANIFEST.MF'); + const sections=readFileSync(manifestFile,'utf8').split(/\r?\n\r?\n/).filter(s=>s.trim()); + for(const section of sections.slice(1)){ + const fields=section.split(/\r?\n/).filter(l=>l&&!l.startsWith(' ')); + if(fields.some(l=>!l.startsWith('Name:')&&!/^SHA-\d+-Digest:/.test(l)))throw Error('Unexpected signed manifest section'); + } + const mainManifest=join(stage,'MANIFEST.MF');writeFileSync(mainManifest,sections[0]+'\r\n\r\n');rmSync(manifestFile); + cpSync(classes,unpack,{recursive:true}); + const target=join(stage,'patched.jar');run(join(jdk,'bin/jar'),['--create','--file',target,'--manifest',mainManifest,'-C',unpack,'.']); + const directory=join(packageRoot,'licenses/jdt-decompiler');mkdirSync(directory,{recursive:true});cpSync(source,join(directory,'FernFlowerDecompiler.java')); + const jdkSpec=JSON.parse(readFileSync(join(root,'third_party/jdk/manifest.json'),'utf8')); + const compilerPlatform=process.arch==='arm64'?'macos-aarch64':'macos-x86_64'; + const metadata={...spec,compiler:{version:jdkSpec.version,platform:compilerPlatform,archiveSHA256:jdkSpec.platforms[compilerPlatform].sha256},patchedCoreJarSHA256:hash(readFileSync(target)),upstreamSignaturesRemoved:true,classes:generated.map(path=>({path,sha256:hash(readFileSync(join(classes,path)))}))}; + // The package staging directory is writable build output. Never patch an + // installed bundle or the downloaded/cache copy of the upstream distribution. + cpSync(target,jar);writeFileSync(join(directory,'patch.json'),JSON.stringify(metadata,null,2)+'\n'); + verifyPatch(packageRoot); + } finally{rmSync(stage,{recursive:true,force:true});} +} +if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){ + if(process.argv[2]==='--verify')verifyPatch(resolve(process.argv[3]));else patch(resolve(process.argv[2])); +} diff --git a/scripts/package/patch-jdt-decompiler.test.mjs b/scripts/package/patch-jdt-decompiler.test.mjs new file mode 100644 index 0000000..b4dcbbe --- /dev/null +++ b/scripts/package/patch-jdt-decompiler.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync,mkdirSync,readFileSync,writeFileSync,cpSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createHash} from 'node:crypto'; +import {verifyPatch} from './patch-jdt-decompiler.mjs'; +const root=fileURLToPath(new URL('../../',import.meta.url)); +const hash=value=>createHash('sha256').update(value).digest('hex'); +function fixture(t){ + const pkg=mkdtempSync(join(tmpdir(),'jdt-patch-test-'));t.after(()=>rmSync(pkg,{recursive:true,force:true})); + const notices=join(pkg,'licenses/jdt-decompiler');mkdirSync(notices,{recursive:true});mkdirSync(join(pkg,'language-server/plugins'),{recursive:true}); + const spec=JSON.parse(readFileSync(join(root,'third_party/jdtls/patches/manifest.json'),'utf8')); + const jdk=JSON.parse(readFileSync(join(root,'third_party/jdk/manifest.json'),'utf8')); + const jar=join(pkg,'language-server/plugins/org.eclipse.jdt.ls.core_1.61.0.test.jar');writeFileSync(jar,'locked generated jar'); + cpSync(join(root,'third_party/jdtls/patches/FernFlowerDecompiler.java'),join(notices,'FernFlowerDecompiler.java')); + const meta={...spec,compiler:{version:jdk.version,platform:'macos-aarch64',archiveSHA256:jdk.platforms['macos-aarch64'].sha256},patchedCoreJarSHA256:hash(readFileSync(jar)),upstreamSignaturesRemoved:true,classes:spec.classEntries.map(path=>({path,sha256:hash(path)}))}; + const manifest=join(notices,'patch.json');const save=()=>writeFileSync(manifest,JSON.stringify(meta));save(); + return {pkg,jar,meta,manifest,save,notices,io:{entries:()=>spec.classEntries,readEntry:(_,path)=>Buffer.from(path)}}; +} +test('verifies the complete pinned patch and compiler identities without running tools',t=>{const f=fixture(t);verifyPatch(f.pkg,f.io);}); +test('rejects changes to the generated jar or packaged source',t=>{ + const f=fixture(t);writeFileSync(f.jar,'modified');assert.throws(()=>verifyPatch(f.pkg,f.io),/bundle changed/); + const g=fixture(t);writeFileSync(join(g.notices,'FernFlowerDecompiler.java'),'modified');assert.throws(()=>verifyPatch(g.pkg,g.io),/source identity/); +}); +test('rejects missing classes, altered class bytes and stale signing entries',t=>{ + const f=fixture(t);f.meta.classes.pop();f.save();assert.throws(()=>verifyPatch(f.pkg,f.io),/Missing patched/); + const h=fixture(t);h.meta.upstreamSignaturesRemoved=false;h.save();assert.throws(()=>verifyPatch(h.pkg,h.io),/signature removal/); + const g=fixture(t);assert.throws(()=>verifyPatch(g.pkg,{...g.io,readEntry:()=>Buffer.from('modified')}),/class changed/); + assert.throws(()=>verifyPatch(g.pkg,{...g.io,entries:()=>['META-INF/ECLIPSE_.SF']}),/signature entries/); +}); +test('rejects an unpinned upstream revision or compiler',t=>{ + const f=fixture(t);f.meta.upstreamRevision='other';f.save();assert.throws(()=>verifyPatch(f.pkg,f.io),/source identity/); + const g=fixture(t);g.meta.compiler.archiveSHA256='0'.repeat(64);g.save();assert.throws(()=>verifyPatch(g.pkg,g.io),/compiler identity/); +}); diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh index 8bd8323..48c6b50 100755 --- a/scripts/verify-package.sh +++ b/scripts/verify-package.sh @@ -118,4 +118,6 @@ for runtime_arch in "${runtime_architectures[@]}"; do fail "$runtime/lib/server/libjvm.dylib lost its upstream code signature" done +node "$ROOT_DIR/scripts/package/patch-jdt-decompiler.mjs" --verify "$PACKAGE" + print -u2 -- "Verified Java Support package $PACKAGE ($ARCH)" diff --git a/third_party/jdtls/patches/FernFlowerDecompiler.java b/third_party/jdtls/patches/FernFlowerDecompiler.java new file mode 100644 index 0000000..1790f04 --- /dev/null +++ b/third_party/jdtls/patches/FernFlowerDecompiler.java @@ -0,0 +1,377 @@ +/******************************************************************************* +* Copyright (c) 2023 Microsoft Corporation and others. +* All rights reserved. This program and the accompanying materials +* are made available under the terms of the Eclipse Public License 2.0 +* which accompanies this distribution, and is available at +* https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Microsoft Corporation - initial API and implementation +*******************************************************************************/ + +package org.eclipse.jdt.ls.core.internal.decompiler; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.jar.Manifest; + +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jdt.core.IClassFile; +import org.eclipse.jdt.core.IOrdinaryClassFile; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.ls.core.internal.DecompilerResult; +import org.eclipse.jdt.ls.core.internal.JDTUtils; +import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin; +import org.eclipse.jdt.ls.core.internal.StatusFactory; +import org.jetbrains.java.decompiler.main.decompiler.BaseDecompiler; +import org.jetbrains.java.decompiler.main.extern.IBytecodeProvider; +import org.jetbrains.java.decompiler.main.extern.IFernflowerLogger; +import org.jetbrains.java.decompiler.main.extern.IFernflowerLogger.Severity; +import org.jetbrains.java.decompiler.main.extern.IFernflowerPreferences; +import org.jetbrains.java.decompiler.main.extern.IResultSaver; + +public class FernFlowerDecompiler extends DecompilerImpl { + public static final String DECOMPILER_HEADER = "// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\n"; + + public static boolean isDecompiledContents(String contents) { + return contents != null && contents.startsWith(DECOMPILER_HEADER); + } + + @Override + protected DecompilerResult decompileContent(URI uri, IProgressMonitor monitor) throws CoreException { + try { + return getContent(new BytecodeProvider(uri), monitor); + } catch (IOException e) { + throw new CoreException(StatusFactory.newErrorStatus("Failed to decompile class file: " + uri, e)); + } + } + + @Override + protected DecompilerResult decompileContent(IClassFile classFile, IProgressMonitor monitor) throws CoreException { + try { + return getContent(new BytecodeProvider(classFile), monitor); + } catch (IOException e) { + throw new CoreException(StatusFactory.newErrorStatus("Failed to decompile class file: " + classFile, e)); + } + } + + @Override + protected DecompilerType getDecompilerType() { + return DecompilerType.FERNFLOWER; + } + + private DecompilerResult getContent(BytecodeProvider provider, IProgressMonitor monitor) throws CoreException { + Map decompilerOptions = new HashMap<>(); + decompilerOptions.put(IFernflowerPreferences.HIDE_DEFAULT_CONSTRUCTOR, "0"); + decompilerOptions.put(IFernflowerPreferences.IGNORE_INVALID_BYTECODE, "1"); + decompilerOptions.put(IFernflowerPreferences.REMOVE_SYNTHETIC, "1"); + decompilerOptions.put(IFernflowerPreferences.REMOVE_BRIDGE, "1"); + decompilerOptions.put(IFernflowerPreferences.DECOMPILE_GENERIC_SIGNATURES, "1"); + decompilerOptions.put(IFernflowerPreferences.DECOMPILE_INNER, "1"); + decompilerOptions.put(IFernflowerPreferences.DECOMPILE_ENUM, "1"); + decompilerOptions.put(IFernflowerPreferences.LOG_LEVEL, Severity.ERROR.name()); + decompilerOptions.put(IFernflowerPreferences.ASCII_STRING_CHARACTERS, "0"); + decompilerOptions.put(IFernflowerPreferences.BYTECODE_SOURCE_MAPPING, "1"); + if (Boolean.getBoolean("jdt.ls.debug")) { + decompilerOptions.put(IFernflowerPreferences.DUMP_ORIGINAL_LINES, "1"); + } + ResultSaver resultSaver = new ResultSaver(); + BaseDecompiler fernflower = new BaseDecompiler(provider, resultSaver, decompilerOptions, new IFernflowerLogger() { + @Override + public void writeMessage(String message, Severity severity) { + if (severity.ordinal() >= Severity.ERROR.ordinal()) { + JavaLanguageServerPlugin.logError(message); + } + } + + @Override + public void writeMessage(String message, Severity severity, Throwable t) { + if (severity.ordinal() >= Severity.ERROR.ordinal()) { + JavaLanguageServerPlugin.logException(message, t); + } + } + }); + // Add all sources (outer + inner classes) to the decompiler + for (File file : provider.getAllClassFiles()) { + fernflower.addSource(file); + } + fernflower.decompileContext(); + final String decompiledCode = DECOMPILER_HEADER + resultSaver.content; + + final Map> originalLineMappings = new TreeMap<>(); + final Map> decompiledLineMappings = new TreeMap<>(); + if (resultSaver.originalLinesMapping != null) { + for (int i = 0; i + 1 < resultSaver.originalLinesMapping.length; i = i + 2) { + int srcLine = resultSaver.originalLinesMapping[i]; + int decompiledLine = resultSaver.originalLinesMapping[i + 1] + 1; // add one extra line for the header + Set decompiled = originalLineMappings.computeIfAbsent(srcLine, k -> new TreeSet<>()); + decompiled.add(decompiledLine); + Set src = decompiledLineMappings.computeIfAbsent(decompiledLine, k -> new TreeSet<>()); + src.add(srcLine); + } + } else { + JavaLanguageServerPlugin.logInfo("Line mappings not available for decompiled content - decompilation may have failed or line mapping was disabled"); + } + + List originals = new ArrayList<>(originalLineMappings.size() * 2); + for (Entry> entry: originalLineMappings.entrySet()) { + int original = entry.getKey(); + for (int value : entry.getValue()) { + originals.add(original); + originals.add(value); + } + } + + List decompiles = new ArrayList<>(decompiledLineMappings.size() * 2); + for (Entry> entry: decompiledLineMappings.entrySet()) { + int decompiled = entry.getKey(); + for (int value : entry.getValue()) { + decompiles.add(decompiled); + decompiles.add(value); + } + } + + return new DecompilerResult(decompiledCode, + originals.stream().mapToInt(Integer::intValue).toArray(), + decompiles.stream().mapToInt(Integer::intValue).toArray()); + } + + static class ResultSaver implements IResultSaver { + private String content; + private int[] originalLinesMapping; + + @Override + public void closeArchive(String arg0, String arg1) { + } + + @Override + public void copyEntry(String arg0, String arg1, String arg2, String arg3) { + } + + @Override + public void copyFile(String arg0, String arg1, String arg2) { + } + + @Override + public void createArchive(String arg0, String arg1, Manifest arg2) { + } + + @Override + public void saveClassEntry(String arg0, String arg1, String arg2, String arg3, String arg4) { + } + + @Override + public void saveClassFile(String filename, String qualifiedName, String entryName, String content, int[] originalLinesMapping) { + this.content = content; + this.originalLinesMapping = originalLinesMapping; + } + + @Override + public void saveDirEntry(String arg0, String arg1, String arg2) { + } + + @Override + public void saveFolder(String arg0) { + } + } + + static class BytecodeProvider implements IBytecodeProvider { + private Map bytecodeMap = new HashMap<>(); + private List classFiles = new ArrayList<>(); + + public BytecodeProvider(URI uri) throws CoreException, IOException { + collectClassFiles(uri); + } + + public BytecodeProvider(IClassFile classFile) throws CoreException, IOException { + collectClassFiles(classFile); + } + + public List getAllClassFiles() { + return classFiles; + } + + @Override + public byte[] getBytecode(String externalPath, String internalPath) throws IOException { + byte[] bytes = bytecodeMap.get(externalPath); + if (bytes == null) { + throw new IOException("Class file not found: " + externalPath + + " (available: " + bytecodeMap.keySet() + ")"); + } + return bytes; + } + + //----------------------------------------------------------- + // Collect all class files from a URI - File-based detection + //----------------------------------------------------------- + private void collectClassFiles(URI uri) throws CoreException, IOException { + IClassFile classFile = JDTUtils.resolveClassFile(uri); + if (classFile != null) { + collectClassFiles(classFile); + return; + } + + // File-based approach + java.nio.file.Path classPath = Paths.get(uri); + if (!Files.exists(classPath)) { + throw new IOException("Class file does not exist: " + classPath); + } + + File file = classPath.toFile(); + + // 1. Find the top-level class file + File topLevelFile = findTopLevelClassFile(file); + + // 2. Scan all matching classes (top-level + inner classes) + Set allClassFiles = new LinkedHashSet<>(); + scanClassFiles(topLevelFile, allClassFiles); + + // 3. Process all classes found + for (File classFileToProcess : allClassFiles) { + processClassFile(classFileToProcess); + } + } + + private File findTopLevelClassFile(File file) throws IOException { + String fileName = file.getName(); + if (!fileName.endsWith(".class")) { + return file; + } + + String baseName = fileName.substring(0, fileName.length() - 6); + int dollarIndex = baseName.indexOf('$'); + + if (dollarIndex > 0) { + // Check if this might be an inner class + String topLevelBaseName = baseName.substring(0, dollarIndex); + File parentDir = file.getParentFile(); + if (parentDir != null) { + File topLevelFile = new File(parentDir, topLevelBaseName + ".class"); + if (topLevelFile.exists()) { + // Found the parent class, so this is indeed an inner class + return topLevelFile; + } + // Parent class doesn't exist - treat this as a top-level class + // (e.g., a class with $ in its name, though uncommon) + } + } + + return file; + } + + private void scanClassFiles(File topLevelFile, Set result) { + // Add the top-level class + result.add(topLevelFile); + + // Find all inner classes + String fileName = topLevelFile.getName(); + if (fileName.endsWith(".class")) { + String baseName = fileName.substring(0, fileName.length() - 6); + String mask = baseName + "$"; + + File parentDir = topLevelFile.getParentFile(); + if (parentDir != null && parentDir.isDirectory()) { + File[] innerClasses = parentDir.listFiles((dir, name) -> + name.startsWith(mask) && name.endsWith(".class")); + if (innerClasses != null) { + Arrays.stream(innerClasses).forEach(result::add); + } + } + } + } + + private void processClassFile(File file) throws IOException { + String path = file.getPath(); + classFiles.add(file); + bytecodeMap.put(path, Files.readAllBytes(file.toPath())); + } + + //----------------------------------------------------------- + // Collect all class files from an IClassFile + //----------------------------------------------------------- + private void collectClassFiles(IClassFile classFile) throws CoreException { + // 1. Find the top-level class file + IClassFile topLevelClassFile = findTopLevelClassFile(classFile); + + // 2. Scan all matching IClassFile (top-level + inner classes) + Set allClassFiles = new LinkedHashSet<>(); + scanClassFiles(topLevelClassFile, allClassFiles); + + // 3. Process all classes found + for (IClassFile cf : allClassFiles) { + processClassFile(cf); + } + } + + private IClassFile findTopLevelClassFile(IClassFile classFile) throws CoreException { + IClassFile topLevelClassFile = classFile; + if (classFile instanceof IOrdinaryClassFile ordinaryClassFile) { + IType type = ordinaryClassFile.getType(); + // Navigate up to the top-level type + IType topLevelType = type; + while (topLevelType.getDeclaringType() != null) { + topLevelType = topLevelType.getDeclaringType(); + } + // Get the class file for the top-level type + if (topLevelType != type) { + topLevelClassFile = topLevelType.getClassFile(); + } + } + return topLevelClassFile; + } + + private void scanClassFiles(IClassFile classFile, Set result) throws CoreException { + // A standalone outer class may reference a member class that was + // not supplied. Keep decompiling the available bytecode instead of + // abandoning the entire outer type and its source mappings. + if (!classFile.exists()) { + JavaLanguageServerPlugin.log(new Status(IStatus.WARNING, + JavaLanguageServerPlugin.PLUGIN_ID, + "Nested class file is unavailable; showing available class content.")); + return; + } + if (classFile instanceof IOrdinaryClassFile ordinaryClassFile) { + if (!result.add(classFile)) { + return; + } + + // Collect declared member types (inner classes/interfaces) + IType type = ordinaryClassFile.getType(); + IType[] innerTypes = type.getTypes(); + for (IType innerType : innerTypes) { + IClassFile innerClassFile = innerType.getClassFile(); + if (innerClassFile != null) { + // Recursive call to handle nested inner classes + scanClassFiles(innerClassFile, result); + } + } + } + } + + private void processClassFile(IClassFile classFile) throws CoreException { + File file = new File(classFile.getElementName()).getAbsoluteFile(); + byte[] classBytes = classFile.getBytes(); + classFiles.add(file); + bytecodeMap.put(file.getPath(), classBytes); + } + + } +} diff --git a/third_party/jdtls/patches/README.md b/third_party/jdtls/patches/README.md new file mode 100644 index 0000000..b10504f --- /dev/null +++ b/third_party/jdtls/patches/README.md @@ -0,0 +1,39 @@ +# JDT LS missing-inner-class patch + +The locked JDT LS 1.61.0 FernFlower integration aborts decompilation of an +available outer class when an entry in its InnerClasses attribute is absent. +This occurs with partial legacy libraries. The patch skips only nonexistent +class files during the integration's recursive collection. FernFlower, JDT's +Java model, source attachments and source/bytecode mappings remain upstream. +Missing class bodies are not reconstructed. + +`manifest.json` fixes the upstream revision, original source and bundle hashes, +modified source hash and complete replacement class set. The original source is +Eclipse EPL-2.0; its copyright header is retained. The modified source is shipped +in `licenses/jdt-decompiler/FernFlowerDecompiler.java` with patch metadata and the +existing EPL license. The upstream source URL is recorded in the manifest. + +`scripts/package/patch-jdt-decompiler.mjs` runs only while building a package. +It uses the manifest-pinned Temurin host JDK, compiles against the original +verified distribution and modifies only fresh package staging. Temporary compiler +output is invocation-owned and removed on success or failure; download caches, +installed plugins and application bundles remain read-only. Neither temporary +output nor assembled packages may be shared between worktrees. + +Because the core JDT bundle's bytes change, its Eclipse JAR signature and signed +entry digests are removed before repacking. OSGi manifest attributes are retained; +unrecognized signed manifest attributes fail packaging. The rebuilt JAR is **not +Eclipse-signed**. This does not change native JVM signatures or claim a plugin +publisher signature. Packaged metadata records compiler identity, original and +patched JAR hashes, modified source hash, signature removal and every replacement +class hash. Package verification checks these identities before distribution. + +Run `node --test --test-timeout=60000 scripts/package/*.test.mjs`, build the +package, and run `scripts/verify-package.sh` for the target architecture. In the +paired Viva client, `TestJavaPluginBinaryNavigation` uses its own generated JAR, +including an outer class whose nested class is deliberately omitted, and verifies +class/method locations and navigation between decompiled views. Private customer +class files are never included in the source or public test artifacts. + +Remove this patch when a pinned upstream release fixes partial inner-class +collection, after the same regression passes with its unmodified distribution. diff --git a/third_party/jdtls/patches/manifest.json b/third_party/jdtls/patches/manifest.json new file mode 100644 index 0000000..e75ecf8 --- /dev/null +++ b/third_party/jdtls/patches/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "jdtls-1.61.0-missing-inner-class-1", + "upstreamRevision": "08eafe6ff60c7159ef88571d47b6a9ef82fef94e", + "upstreamSource": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/blob/08eafe6ff60c7159ef88571d47b6a9ef82fef94e/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler.java", + "upstreamSourceSHA256": "63ceebf6a377b0b1805586d44d6bf13d7ba5ccecaf92879d85d2e4f708730008", + "sourceSHA256": "f188e0f7097c6b87d03fcca472c71b1b09faf44b0b91c51204e8677d493f28ae", + "originalCoreJarSHA256": "b382afc38a89e41959a56aee532ae567c7f804c3e4d4f13444114468789ec032", + "classEntries": [ + "org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler$1.class", + "org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler$BytecodeProvider.class", + "org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler$ResultSaver.class", + "org/eclipse/jdt/ls/core/internal/decompiler/FernFlowerDecompiler.class" + ] +}