diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8351c2a..e099980 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,3 +81,48 @@ jobs: path: dist/ retention-days: 7 if-no-files-found: error + + linux: + name: Build and run Linux ${{ matrix.arch }} package + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + arch: x64 + - os: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install packaging tools + run: sudo apt-get update && sudo apt-get install -y zsh zip unzip + - name: Restore checksum-verified downloads + uses: actions/cache@v4 + with: + path: | + .artifacts/jdtls-downloads + .artifacts/jdk-downloads + key: linux-${{ matrix.arch }}-${{ hashFiles('third_party/*/manifest.json') }} + - name: Test the native backend + run: cargo test --workspace --locked + - name: Build and verify the archive + run: node scripts/package/build-linux.mjs "${{ matrix.arch }}" + - name: Start the packaged backend and language server + run: node scripts/package/smoke-linux.mjs dist/*-linux-${{ matrix.arch }}.zip + - uses: actions/upload-artifact@v4 + with: + name: java-linux-${{ matrix.arch }} + path: | + dist/*-linux-*.zip + dist/*-linux-*.zip.sha256 + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b04c27..c2d061c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release -# Tags must match plugin.json. Build unsigned macOS arm64/x64 and Windows -# x64 packages from the same source revision, then upload a draft prerelease. +# Tags must match plugin.json. Build macOS arm64/x64, Windows x64 and Linux +# ARM64/x64 packages from the same revision, then upload a draft prerelease. # The maintainer verifies the downloaded assets before publishing the draft. # Contract 0 currently requires the matching Viva preview, not stable Lithe. on: @@ -69,7 +69,7 @@ jobs: release: name: Publish ${{ github.ref_name }} - needs: [test, windows] + needs: [test, windows, linux] runs-on: macos-26 timeout-minutes: 60 permissions: @@ -118,6 +118,11 @@ jobs: archive=$(node scripts/package/build-windows.mjs .artifacts/windows-backend/lithe-java-backend.exe) mkdir -p dist cp "$archive" "$archive.sha256" dist/ + - uses: actions/download-artifact@v4 + with: + pattern: java-linux-* + merge-multiple: true + path: dist - name: Archive and publish env: GH_TOKEN: ${{ github.token }} @@ -137,8 +142,53 @@ jobs: echo '### SHA-256' echo '```' sed 's# dist/# #' "$checksums" - cat dist/*windows-x64.zip.sha256 + cat dist/*windows-x64.zip.sha256 dist/*linux-*.zip.sha256 echo '```' } >> "$RUNNER_TEMP/notes.md" gh release create "$GITHUB_REF_NAME" dist/*.zip dist/*.zip.sha256 --verify-tag --draft --prerelease \ --title "Java Support $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md" + + linux: + name: Build and run Linux ${{ matrix.arch }} package + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + arch: x64 + - os: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install packaging tools + run: sudo apt-get update && sudo apt-get install -y zsh zip unzip + - name: Restore checksum-verified downloads + uses: actions/cache@v4 + with: + path: | + .artifacts/jdtls-downloads + .artifacts/jdk-downloads + key: linux-${{ matrix.arch }}-${{ hashFiles('third_party/*/manifest.json') }} + - name: Test the native backend + run: cargo test --workspace --locked + - name: Build and verify the archive + run: node scripts/package/build-linux.mjs "${{ matrix.arch }}" + - name: Start the packaged backend and language server + run: node scripts/package/smoke-linux.mjs dist/*-linux-${{ matrix.arch }}.zip + - uses: actions/upload-artifact@v4 + with: + name: java-linux-${{ matrix.arch }} + path: | + dist/*-linux-*.zip + dist/*-linux-*.zip.sha256 + if-no-files-found: error + retention-days: 7 diff --git a/README.md b/README.md index eeda596..25daeb7 100644 --- a/README.md +++ b/README.md @@ -245,12 +245,12 @@ fixtures/ # 与主仓库共享的契约样例(JSON) third_party/ # JDT LS 与 JDK 的来源地址和 SHA-256 sdk.lock.json # 后端链接的 Rust SDK:标签、提交、crate 归档的 SHA-256 scripts/ # 组装、校验、归档插件包,检查 SDK 锁 -.github/workflows/ci.yml # PR:格式、lint、测试、SDK 锁,以及 macOS 上的打包任务(构建并归档两个架构) +.github/workflows/ci.yml # PR:格式、lint、测试、SDK 锁,以及 macOS 双架构打包和 Linux 双架构原生验证 .github/workflows/release.yml # 推送 v<版本> 标签:测试后打包并发布 GitHub Release(预发布) MIGRATION.md # 主仓库路径到本仓库的映射、待宿主接口清单、主仓库删除清单 ``` -Rust 部分需要 Rust 工具链和 Cargo;打包脚本需要 macOS、Node.js 22、`zsh`、`curl`、`ditto`,不需要 Xcode 或 Swift 工具链。 +Rust 部分需要 Rust 工具链和 Cargo;macOS 打包脚本需要 macOS、Node.js 22、`zsh`、`curl`、`ditto`,不需要 Xcode 或 Swift 工具链。 打包脚本会用 `cargo build --release --locked -p lithe-java-backend --target ` 编译后端,所以还要安装两个 Apple target: `rustup target add aarch64-apple-darwin x86_64-apple-darwin`(`--arch arm64` 用前者,`x86_64` 用后者,`universal` 两个都编译再用 `lipo -create` 合并)。 @@ -291,7 +291,7 @@ node scripts/package/build-windows.mjs /path/to/lithe-java-backend.exe ## 发布新版本 构建和发布由现有工作流执行,正式发布前按 Lithe 的版本规则决定版本并核验兼容宿主。 -当前依赖 Viva preview,只能作为预发布。推送匹配版本的标签后,工作流为 macOS arm64/x64 与 Windows x64 生成 ZIP 和 SHA-256,并创建草稿预发布;维护者下载核对后再公开草稿。发布说明必须位于 `docs/releases/v<版本>.md`,缺失则终止发布。 +当前依赖 Viva preview,只能作为预发布。推送匹配版本的标签后,工作流为 macOS arm64/x64、Windows x64 与 Linux ARM64/x64 生成 ZIP 和 SHA-256,并创建草稿预发布;维护者下载核对后再公开草稿。发布说明必须位于 `docs/releases/v<版本>.md`,缺失则终止发布。 不得覆盖已发布 ZIP,也不得将本地隔离试验的 JDT/JDK 资源当作生产升级:本轮生产仍固定 JDT LS 1.61 和 Temurin 21。 ## 许可 @@ -301,3 +301,7 @@ node scripts/package/build-windows.mjs /path/to/lithe-java-backend.exe ### 随包许可 两端构建都会复制自身 LICENSE/NOTICE,并用 Cargo 锁定的非 dev 构建图收集 Rust 依赖的许可,写入 licenses/rust 和不含本机路径的哈希清单。SDK 沿其 Git 检出查找共享许可;crate 未携带主许可时,只接受 third_party/rust-notices 中与 crate VCS 修订及 SHA-256 匹配的固定文本。tree-sitter 内嵌的 Unicode 许可一并保留。缺少许可或来源不匹配会终止打包;不在运行期下载,不跨工作树共享生成目录。 + +## Linux ARM64 / x64 + +Linux 构建、资源归属与原生验证入口见 [Linux 构建与验证](docs/linux-packaging.md)。 diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md new file mode 100644 index 0000000..a21d72b --- /dev/null +++ b/docs/feature-matrix.md @@ -0,0 +1,10 @@ +# 功能验证矩阵 + +## Linux 安装包 + +| 能力 | Linux ARM64 | Linux x64 | 验证入口 | +| --- | --- | --- | --- | +| 构建与安装包 | 已实现;[原生验证结果](https://github.com/Lithe-IDEA/lithe-plugin-java/pull/15/checks) | 已实现;[原生验证结果](https://github.com/Lithe-IDEA/lithe-plugin-java/pull/15/checks) | `.github/workflows/ci.yml`、[资源说明](linux-packaging.md) | +| 真实工具/语言服务器流程 | [原生验证结果](https://github.com/Lithe-IDEA/lithe-plugin-java/pull/15/checks) | [原生验证结果](https://github.com/Lithe-IDEA/lithe-plugin-java/pull/15/checks) | `scripts/package/` 和现有集成测试 | + +跨架构本地包检查通过不等于原生工作流验收。既有平台验证记录保持不变。 diff --git a/docs/linux-packaging.md b/docs/linux-packaging.md new file mode 100644 index 0000000..4befcab --- /dev/null +++ b/docs/linux-packaging.md @@ -0,0 +1,13 @@ +# Linux 构建与验证 + +`node scripts/package/build-linux.mjs arm64|x64` 在对应 Linux runner 上编译 Rust 后端,校验其 ELF 架构,再从固定 SHA-256 的 Temurin JDK、JDT LS、Lombok、Java Debug 和 Java Test 输入组装包。ARM64 使用 `config_linux_arm`,x64 使用 `config_linux`。JDK 上游符号链接仅在构建时展开,插件 ZIP 不含链接。完整许可证与反编译补丁校验沿用已有流程。归档解压后再次检查清单、JDK/后端/VM 架构、执行权限和配置。 + +`node scripts/package/smoke-linux.mjs ` 在原生 runner 上运行解压后的后端握手与 `java.resolveLaunch`,启动包内 Java 和 JDT LS,执行 initialize/shutdown,并对比整个安装目录的文件内容与权限。状态目录和 Equinox 配置副本在临时用户目录;结束时清理进程组和目录。 + +Linux 产物面向 GNU/glibc 环境,原生 CI 使用 Ubuntu 24.04;本包不面向 musl/Alpine。 + +## 资源与验证边界 + +所有下载、解压、编译与归档都属于本插件仓库的构建阶段。`dist/` 为输出包;`.artifacts/`、`.cache/`、`target/` 或工具自身缓存为本地构建状态,不跨工作树共享可变状态。校验过的下载以固定来源与摘要识别,提取结果不作为可信下载源。运行时只读取已安装插件,缓存、日志、索引和工作区状态归用户可写目录。Linux 不使用 macOS 代码签名或 Sparkle;本次不改变 macOS 包签名与更新基线。 + +原生 ARM64 和 x64 验证由 `.github/workflows/ci.yml` 执行;本机的交叉编译与归档检查不能替代原生运行结果。只有目标平台 CI 成功后才能宣称该平台通过。发布新版本后,使用实际 Release 附件的 SHA-256 更新 Store,保留所有旧版本与既有平台。 diff --git a/docs/releases/v1.3.0.md b/docs/releases/v1.3.0.md new file mode 100644 index 0000000..b4a293e --- /dev/null +++ b/docs/releases/v1.3.0.md @@ -0,0 +1,13 @@ +# Java Support 1.3.0 + +## 中文 + +- 新增 Linux ARM64 和 x64 安装包,兼容现有插件契约 v0 与 Lithe 桌面预览版。 +- 保留已支持平台;安装包在构建时验证,运行时状态保存在用户目录。 + +--- + +## English + +- Add Linux ARM64 and x64 packages for the existing plugin contract v0 and Lithe desktop preview. +- Keep existing platforms; verify release archives at build time and keep runtime state in user directories. diff --git a/io.github.lithe-idea.java-support/plugin.json b/io.github.lithe-idea.java-support/plugin.json index 9c1b44f..e2ff51a 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.2.0", + "version": "1.3.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/package/build-linux.mjs b/scripts/package/build-linux.mjs new file mode 100644 index 0000000..edb8867 --- /dev/null +++ b/scripts/package/build-linux.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Linux packages use fixed, verified JDK/JDT inputs and a native backend from +// this source checkout. All writes are build outputs, never installed files. +import {execFileSync} from 'node:child_process'; +import {chmodSync,cpSync,mkdirSync,readFileSync,rmSync,mkdtempSync,readdirSync,lstatSync,writeFileSync} from 'node:fs'; +import {join,resolve} from 'node:path'; +import {architectures,root,prepareJDK,digest,verifyELF,verifyBinary} from './linux-runtime.mjs'; +import {collectNotices,verifyNotices} from './collect-notices.mjs'; +import {patch,verifyPatch} from './patch-jdt-decompiler.mjs'; +const arch=process.argv[2];const target=architectures[arch]; +if(!target)throw Error('Usage: node scripts/package/build-linux.mjs arm64|x64 [native-backend]'); +const run=(exe,args,options={})=>execFileSync(exe,args,{cwd:root,timeout:600000,stdio:['ignore','pipe','inherit'],...options}); +let backend=process.argv[3]; +if(!backend){ + if(process.platform!=='linux' || process.arch!==(arch==='x64'?'x64':'arm64'))throw Error('A native Linux runner or a matching backend binary is required'); + run('cargo',['build','--release','--locked','-p','lithe-java-backend']); + backend=join(root,'target/release/lithe-java-backend'); +} +backend=resolve(backend);verifyELF(readFileSync(backend),arch); +const runtime=await prepareJDK(arch); +const jdt=run(join(root,'scripts/prepare-jdtls.sh'),[],{encoding:'utf8',env:{...process.env,LITHE_ARCH:target.runtime}}).trim(); +const id='io.github.lithe-idea.java-support'; +const manifest=JSON.parse(readFileSync(join(root,id,'plugin.json'))); +const staging=mkdtempSync(join(root,'.artifacts',`linux-${arch}-`)); +const pkg=join(staging,id); +function verify(directory){ + const actual=JSON.parse(readFileSync(join(directory,'plugin.json'))); + if(JSON.stringify(actual)!==JSON.stringify(manifest))throw Error('Packaged manifest changed'); + for(const name of ['LICENSE','NOTICE'])if(!readFileSync(join(directory,name)).equals(readFileSync(join(root,name))))throw Error(`Missing or modified ${name}`); + for(const path of [manifest.backend.launch.path,`runtime/${target.runtime}/bin/java`,`runtime/${target.runtime}/lib/server/libjvm.so`]){ + verifyBinary(join(directory,path),arch,{executable:!path.endsWith('/libjvm.so')}); + } + if(!readFileSync(join(directory,'language-server/configuration',target.runtime,'config.ini')).equals(readFileSync(join(jdt,target.configuration,'config.ini'))))throw Error('Wrong Equinox platform configuration'); + const walk=path=>{for(const name of readdirSync(path)){const file=join(path,name);const s=lstatSync(file);if(s.isSymbolicLink())throw Error(`Package link: ${file}`);if(s.isDirectory())walk(file);}};walk(directory); + verifyNotices(directory);verifyPatch(directory); +} +try{ + mkdirSync(pkg); + cpSync(join(root,id,'plugin.json'),join(pkg,'plugin.json')); + for(const name of ['icons','templates'])cpSync(join(root,id,name),join(pkg,name),{recursive:true}); + mkdirSync(join(pkg,'runtime'));cpSync(runtime,join(pkg,'runtime',target.runtime),{recursive:true}); + const language=join(pkg,'language-server');mkdirSync(language); + for(const name of ['plugins','features','lombok','java-debug','java-test','manifest.json','LICENSE-EPL-2.0.txt'])cpSync(join(jdt,name),join(language,name),{recursive:true}); + mkdirSync(join(language,'configuration',target.runtime),{recursive:true}); + cpSync(join(jdt,target.configuration,'config.ini'),join(language,'configuration',target.runtime,'config.ini')); + mkdirSync(join(pkg,'bin'));cpSync(backend,join(pkg,manifest.backend.launch.path));chmodSync(join(pkg,manifest.backend.launch.path),0o755); + collectNotices(pkg);patch(pkg);verify(pkg); + const dist=join(root,'dist');mkdirSync(dist,{recursive:true}); + const asset=join(dist,`Lithe-Java-Support-${manifest.version}-linux-${arch}.zip`);rmSync(asset,{force:true}); + run('zip',['-qrXy',asset,id],{cwd:staging,timeout:120000}); + const check=join(staging,'extracted');mkdirSync(check); + run('unzip',['-q',asset,'-d',check],{timeout:120000}); + if(readdirSync(check).join()!==id)throw Error('Archive must contain only the plugin directory'); + verify(join(check,id)); + writeFileSync(`${asset}.sha256`,`${await digest(asset)} ${asset.split('/').at(-1)}\n`); + console.log(asset); +}finally{rmSync(staging,{recursive:true,force:true});} diff --git a/scripts/package/json-value.mjs b/scripts/package/json-value.mjs new file mode 100644 index 0000000..48b3a34 --- /dev/null +++ b/scripts/package/json-value.mjs @@ -0,0 +1,6 @@ +// Portable replacement for build-time plutil extraction on Linux. +import {readFileSync} from 'node:fs'; +let value=JSON.parse(readFileSync(process.argv[2],'utf8')); +for(const part of process.argv[3].split('.'))value=value?.[part]; +if(value===undefined || value===null || typeof value==='object')process.exit(1); +process.stdout.write(String(value)+'\n'); diff --git a/scripts/package/linux-runtime.mjs b/scripts/package/linux-runtime.mjs new file mode 100644 index 0000000..6f793cc --- /dev/null +++ b/scripts/package/linux-runtime.mjs @@ -0,0 +1,63 @@ +// Build-time Linux runtime preparation. Downloaded inputs are immutable and +// keyed by SHA-256; extracted runtimes belong only to this checkout. +import {createHash} from 'node:crypto'; +import {createReadStream,createWriteStream,readFileSync,mkdirSync,existsSync,rmSync,mkdtempSync,renameSync,statSync} from 'node:fs'; +import {join,dirname,resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {execFileSync} from 'node:child_process'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; +export const root=resolve(dirname(fileURLToPath(import.meta.url)),'../..'); +export const architectures={arm64:{runtime:'arm64',jdk:'linux-aarch64',configuration:'config_linux_arm',machine:183},x64:{runtime:'x86_64',jdk:'linux-x86_64',configuration:'config_linux',machine:62}}; +export function verifyELF(bytes,arch){ + const target=architectures[arch]; + if(!target)throw Error(`Unsupported Linux architecture: ${arch}`); + if(bytes.length<64 || !bytes.subarray(0,6).equals(Buffer.from([127,69,76,70,2,1])))throw Error('An ELF64 little-endian binary is required'); + if(bytes.readUInt16LE(18)!==target.machine)throw Error(`ELF architecture differs from Linux ${arch}`); + if(![2,3].includes(bytes.readUInt16LE(16)))throw Error('An ELF executable or shared object is required'); +} +// Shared libraries are mapped by the loader and need no execute permission. +export function verifyBinary(path,arch,{executable=false}={}){ + verifyELF(readFileSync(path),arch); + if(executable && !(statSync(path).mode & 0o111))throw Error(`Executable permission missing: ${path}`); +} +export async function digest(path){ + const hash=createHash('sha256');for await(const bytes of createReadStream(path))hash.update(bytes);return hash.digest('hex'); +} +export function copyRuntime(source,destination){ + // Both BSD and GNU cp resolve the JDK's legal/ links with -RL. Node's + // recursive copy can retain links, which become dangling after staging ends. + execFileSync('cp',['-RL',source,destination],{timeout:120000}); +} +export async function prepareJDK(arch){ + const target=architectures[arch];if(!target)throw Error(`Unsupported Linux architecture: ${arch}`); + const manifest=JSON.parse(readFileSync(join(root,'third_party/jdk/manifest.json'))); + const input=manifest.platforms[target.jdk];if(!input)throw Error(`Missing pinned JDK: ${target.jdk}`); + const cache=join(root,'.artifacts/jdk-downloads');mkdirSync(cache,{recursive:true}); + const archive=join(cache,`jdk-${target.jdk}-${input.sha256}.tar.gz`); + if(!existsSync(archive) || await digest(archive)!==input.sha256){ + const temporary=`${archive}.${process.pid}.download`; + try{ + const response=await fetch(input.url,{signal:AbortSignal.timeout(180000)}); + if(!response.ok)throw Error(`JDK download failed: HTTP ${response.status}`); + await pipeline(Readable.fromWeb(response.body),createWriteStream(temporary,{flags:'wx'})); + if(await digest(temporary)!==input.sha256)throw Error('Linux JDK SHA-256 mismatch'); + renameSync(temporary,archive); + }finally{rmSync(temporary,{force:true});} + } + const output=join(root,'.artifacts',`jdk-${target.jdk}-${input.sha256}`); + // Re-extract the checksum-verified input: no mutable runtime tree is trusted. + const staging=mkdtempSync(join(cache,'extract-')); + try{ + execFileSync('tar',['-xzf',archive,'-C',staging],{timeout:120000}); + const runtime=join(staging,input.jdkRoot); + for(const path of ['bin/java','lib/server/libjvm.so'])verifyELF(readFileSync(join(runtime,path)),arch); + if(!/^JAVA_VERSION="21\./m.test(readFileSync(join(runtime,'release'),'utf8')))throw Error('The runtime must be Java 21'); + rmSync(output,{recursive:true,force:true}); + // The plugin contract forbids links. Dereference upstream JDK links only + // during packaging; runtime code never modifies these installed resources. + copyRuntime(runtime,output); + return output; + }finally{rmSync(staging,{recursive:true,force:true});} +} +if(process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url))console.log(await prepareJDK(process.argv[2])); diff --git a/scripts/package/linux.test.mjs b/scripts/package/linux.test.mjs new file mode 100644 index 0000000..b469135 --- /dev/null +++ b/scripts/package/linux.test.mjs @@ -0,0 +1,33 @@ +// Offline checks of the platform boundary; real archives run in Linux CI. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {verifyELF,verifyBinary,copyRuntime} from './linux-runtime.mjs'; +for (const [arch,machine] of [['arm64',183],['x64',62]]) { + test(`accepts only Linux ${arch} ELF64 little-endian executables`,()=>{ + const bytes=Buffer.alloc(64);bytes.set([127,69,76,70,2,1]);bytes.writeUInt16LE(2,16);bytes.writeUInt16LE(machine,18); + verifyELF(bytes,arch); + assert.throws(()=>verifyELF(bytes,arch==='arm64'?'x64':'arm64'),/architecture/); + bytes[4]=1;assert.throws(()=>verifyELF(bytes,arch),/ELF64/); + assert.throws(()=>verifyELF(Buffer.from('MZ'),arch),/ELF64/); + }); +} + +import {mkdtempSync,writeFileSync,chmodSync,rmSync,mkdirSync,symlinkSync,lstatSync,readFileSync,statSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +test('allows read-only ELF shared libraries but requires execute permission on launchers',t=>{ + const stage=mkdtempSync(join(tmpdir(),'linux-elf-'));t.after(()=>rmSync(stage,{recursive:true,force:true})); + const file=join(stage,'libjvm.so');const bytes=Buffer.alloc(64);bytes.set([127,69,76,70,2,1]);bytes.writeUInt16LE(3,16);bytes.writeUInt16LE(183,18);writeFileSync(file,bytes);chmodSync(file,0o644); + verifyBinary(file,'arm64');assert.throws(()=>verifyBinary(file,'arm64',{executable:true}),/Executable permission/); + chmodSync(file,0o755);verifyBinary(file,'arm64',{executable:true}); +}); + +test('runtime copy contains regular license files after extraction staging is removed',t=>{ + const stage=mkdtempSync(join(tmpdir(),'linux-jdk-copy-'));t.after(()=>rmSync(stage,{recursive:true,force:true})); + const source=join(stage,'source');mkdirSync(source);writeFileSync(join(source,'LICENSE'),'pinned license'); + symlinkSync('LICENSE',join(source,'LICENSE-link'));writeFileSync(join(source,'java'),'ELF fixture');chmodSync(join(source,'java'),0o755); + const output=join(stage,'output');copyRuntime(source,output);rmSync(source,{recursive:true,force:true}); + assert.equal(lstatSync(join(output,'LICENSE-link')).isSymbolicLink(),false); + assert.equal(readFileSync(join(output,'LICENSE-link'),'utf8'),'pinned license'); + assert.ok(statSync(join(output,'java')).mode & 0o111); +}); diff --git a/scripts/package/lsp-client.mjs b/scripts/package/lsp-client.mjs new file mode 100644 index 0000000..fafe9e3 --- /dev/null +++ b/scripts/package/lsp-client.mjs @@ -0,0 +1,43 @@ +// Bounded integration-test client. Each child owns a process group; cleanup +// runs after success, protocol failure, and request timeout. +import {spawn} from 'node:child_process'; +export class Client { + constructor(executable,args=[],options={}){ + this.next=1;this.pending=new Map();this.buffer=Buffer.alloc(0);this.stderr=''; + this.child=spawn(executable,args,{...options,detached:true,stdio:['pipe','pipe','pipe']}); + this.exited=new Promise(resolve=>{this.child.once('exit',(code,signal)=>{this.fail(Error(`Process exited: ${code}/${signal}: ${this.stderr}`));resolve();});this.child.once('error',error=>{this.fail(error);resolve();});}); + this.child.stdin.on('error',error=>this.fail(error)); + this.child.stderr.on('data',bytes=>{this.stderr=(this.stderr+bytes).slice(-65536);}); + this.child.stdout.on('data',bytes=>{ + try{ + this.buffer=Buffer.concat([this.buffer,bytes]);if(this.buffer.length>16*1024*1024)throw Error('Protocol frame exceeds 16 MiB'); + while(true){ + const end=this.buffer.indexOf('\r\n\r\n');if(end<0)return; + const length=Number(/Content-Length: (\d+)/i.exec(this.buffer.subarray(0,end).toString())?.[1]); + if(!Number.isSafeInteger(length)||length>16*1024*1024)throw Error('Invalid protocol length'); + if(this.buffer.length({})):null; + this.send({id:message.id,result}); + }else if(message.id!==undefined){const item=this.pending.get(message.id);if(item){this.pending.delete(message.id);clearTimeout(item.timer);message.error?item.reject(Error(JSON.stringify(message.error))):item.resolve(message.result);}} + } + }catch(error){this.fail(error);} + }); + } + fail(error){this.failure=error;for(const item of this.pending.values()){clearTimeout(item.timer);item.reject(error);}this.pending.clear();} + send(message){const body=JSON.stringify({jsonrpc:'2.0',...message});this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);} + notify(method,params){this.send({method,params});} + request(method,params,timeout=60000){ + if(this.failure)return Promise.reject(this.failure); + const id=this.next++;return new Promise((resolve,reject)=>{ + const timer=setTimeout(()=>{this.pending.delete(id);reject(Error(`${method} timed out: ${this.stderr}`));},timeout); + this.pending.set(id,{resolve,reject,timer});this.send({id,method,params}); + }); + } + async close(){ + this.fail(Error('Client closed')); + if(this.child.pid){try{process.kill(-this.child.pid,'SIGKILL');}catch(error){if(error.code!=='ESRCH')throw error;}} + let timer;try{await Promise.race([this.exited,new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Process cleanup timed out')),5000);})]);}finally{clearTimeout(timer);} + } +} diff --git a/scripts/package/patch-jdt-decompiler.mjs b/scripts/package/patch-jdt-decompiler.mjs index 20f8913..377d41b 100644 --- a/scripts/package/patch-jdt-decompiler.mjs +++ b/scripts/package/patch-jdt-decompiler.mjs @@ -46,7 +46,9 @@ export function patch(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 jdk=process.platform==='linux' + ? run(process.execPath,[join(root,'scripts/package/linux-runtime.mjs'),process.arch==='arm64'?'arm64':'x64'],{encoding:'utf8',timeout:300000}).trim() + : 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); @@ -68,7 +70,7 @@ export function patch(packageRoot){ 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 compilerPlatform=`${process.platform==='linux'?'linux':'macos'}-${process.arch==='arm64'?'aarch64':'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. diff --git a/scripts/package/smoke-linux.mjs b/scripts/package/smoke-linux.mjs new file mode 100644 index 0000000..866e2f2 --- /dev/null +++ b/scripts/package/smoke-linux.mjs @@ -0,0 +1,40 @@ +// Native integration test of the extracted archive and its read-only boundary. +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {mkdtempSync,mkdirSync,rmSync,readFileSync,readdirSync,lstatSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; +import {createHash} from 'node:crypto'; +import {Client} from './lsp-client.mjs'; +const archive=resolve(process.argv[2]); +assert.equal(process.platform,'linux'); +const stage=mkdtempSync(join(tmpdir(),'lithe-java-linux-'));let backend,server; +const inventory=root=>{ + const entries=[];const walk=(path,relative)=>{const s=lstatSync(path);assert.ok(!s.isSymbolicLink());entries.push([relative,s.mode,s.isFile()?createHash('sha256').update(readFileSync(path)).digest('hex'):null]);if(s.isDirectory())for(const name of readdirSync(path).sort())walk(join(path,name),`${relative}/${name}`);};walk(root,'');return entries; +}; +try{ + execFileSync('unzip',['-q',archive,'-d',stage],{timeout:120000}); + const pkg=join(stage,'io.github.lithe-idea.java-support');const before=inventory(pkg); + const workspace=join(stage,'workspace');mkdirSync(workspace);writeFileSync(join(workspace,'Hello.java'),'class Hello { String value; }\n'); + const params={serverID:'java',platform:{os:'linux',arch:process.arch==='arm64'?'arm64':'x86_64'},settings:{logLevel:'WARNING'}}; + for(const [key,name] of Object.entries({stateDirectory:'state',workspaceStateDirectory:'workspace-state',logDirectory:'logs',temporaryDirectory:'tmp'})){params[key]=join(stage,name);mkdirSync(params[key]);} + backend=new Client(join(pkg,'bin/lithe-java-backend')); + const initialized=await backend.request('initialize',{rootUri:pathToFileURL(workspace).href,capabilities:{experimental:{litheFunctions:{versions:[0]},litheLogging:{versions:[0]}}}}); + assert.ok(initialized.capabilities.experimental.litheFunctions); + backend.notify('initialized',{}); + const launch=await backend.request('lithe/function/invoke',{functionID:'java.resolveLaunch',operationID:'linux-smoke',params}); + assert.equal(launch.executable.kind,'packaged'); + assert.ok(launch.writablePaths.every(path=>!path.startsWith(pkg+'/'))); + const runtime=join(pkg,launch.executable.path); + execFileSync(runtime,['-version'],{timeout:15000}); + server=new Client(runtime,launch.arguments,{env:{...process.env,...launch.environment}}); + const result=await server.request('initialize',{processId:null,rootUri:pathToFileURL(workspace).href,workspaceFolders:[{uri:pathToFileURL(workspace).href,name:'workspace'}],capabilities:{},initializationOptions:launch.initializationOptions},120000); + assert.ok(result.capabilities.definitionProvider);server.notify('initialized',{}); + await server.request('shutdown',null,30000);server.notify('exit',null);await server.close();server=undefined; + await backend.close();backend=undefined; + assert.deepEqual(inventory(pkg),before,'LSP startup must not change installed package files or permissions'); + console.log(`Verified Linux ${process.arch}: backend handshake, launch resolution, JDK, JDT LS initialize/shutdown and immutable package`); +}finally{ + try{if(server)await server.close();}finally{try{if(backend)await backend.close();}finally{rmSync(stage,{recursive:true,force:true});}} +} diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index b6020b4..f888fef 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -8,7 +8,7 @@ OUTPUT_DIR="${LITHE_JDTLS_ROOT:-$ROOT_DIR/.artifacts/jdtls}" CACHE_DIR="$ROOT_DIR/.artifacts/jdtls-downloads" manifest_value() { - /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST" + node "$ROOT_DIR/scripts/package/json-value.mjs" "$MANIFEST" "$1" } archive_url="$(manifest_value archiveURL)" @@ -37,7 +37,7 @@ java_debug_server_version="$(manifest_value javaDebugServerVersion)" java_test_extension_version="$(manifest_value javaTestExtensionVersion)" java_test_plugin_version="$(manifest_value javaTestPluginVersion)" minimum_java_version="$(manifest_value minimumJavaVersion)" -bundled_jdk_version="$(/usr/bin/plutil -extract version raw -o - "$ROOT_DIR/third_party/jdk/manifest.json")" +bundled_jdk_version="$(node "$ROOT_DIR/scripts/package/json-value.mjs" "$ROOT_DIR/third_party/jdk/manifest.json" version)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" @@ -134,6 +134,7 @@ validate_output() { exit 1 } done + [[ -f "$OUTPUT_DIR/config_linux/config.ini" && -f "$OUTPUT_DIR/config_linux_arm/config.ini" ]] || { print -u2 -- "JDTLS Linux configurations are missing: $OUTPUT_DIR"; exit 1; } [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; } [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; } [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } @@ -218,7 +219,7 @@ mkdir -p "$OUTPUT_DIR/java-test/extensions" "$OUTPUT_DIR/java-test/runner" # already installed" at every start. : > "$OUTPUT_DIR/$java_test_bundle_list" java_test_bundle_index=0 -while java_test_bundle="$(/usr/bin/plutil -extract "contributes.javaExtensions.$java_test_bundle_index" raw -o - "$java_test_extraction/extension/package.json" 2>/dev/null)"; do +while java_test_bundle="$(node "$ROOT_DIR/scripts/package/json-value.mjs" "$java_test_extraction/extension/package.json" "contributes.javaExtensions.$java_test_bundle_index" 2>/dev/null)"; do (( java_test_bundle_index += 1 )) [[ -e "$OUTPUT_DIR/plugins/${java_test_bundle:t}" ]] && continue cp "$java_test_extraction/extension/${java_test_bundle#./}" "$OUTPUT_DIR/java-test/extensions/${java_test_bundle:t}" diff --git a/third_party/jdk/manifest.json b/third_party/jdk/manifest.json index 3b2eec6..bf8cf5d 100644 --- a/third_party/jdk/manifest.json +++ b/third_party/jdk/manifest.json @@ -21,6 +21,16 @@ "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12.1_1.zip", "sha256": "f9d6e191ab098c0d416e7d588a24420a8621cd2f4720dab2459b8b7b2d2d8b4e", "jdkRoot": "jdk-21.0.12.1+1" + }, + "linux-aarch64": { + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.12.1_1.tar.gz", + "sha256": "23e37e026f12f3e706f18938ff611db3032d075b09d0879a25d06718c773e223", + "jdkRoot": "jdk-21.0.12.1+1" + }, + "linux-x86_64": { + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz", + "sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94", + "jdkRoot": "jdk-21.0.12.1+1" } } }