├── .npmrc ├── src ├── vite-env.d.ts ├── App.css ├── main.tsx ├── ballistaInfo.ts ├── CertDialog.tsx ├── styles.css ├── connection.ts └── App.tsx ├── src-tauri ├── build.rs ├── icons │ ├── icon.png │ ├── 32x32.icns │ └── favicon.ico ├── .gitignore ├── test-resources │ ├── RSA.RSA │ ├── tampered-sf.jar │ ├── valid-signed.jar │ ├── signing-keys.jceks │ ├── tampered-app-class.jar │ ├── MANIFEST.MF │ └── RSA.SF ├── tauri.conf.json ├── Cargo.toml └── src │ ├── errors.rs │ ├── main.rs │ ├── connection.rs │ ├── webstart.rs │ └── verify.rs ├── .vscode └── extensions.json ├── tsconfig.node.json ├── .gitignore ├── index.html ├── tsconfig.json ├── vite.config.ts ├── package.json ├── README.md ├── .github └── workflows │ └── build-ballista.yml └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/icons/32x32.icns -------------------------------------------------------------------------------- /src-tauri/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/icons/favicon.ico -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/test-resources/RSA.RSA: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/test-resources/RSA.RSA -------------------------------------------------------------------------------- /src-tauri/test-resources/tampered-sf.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/test-resources/tampered-sf.jar -------------------------------------------------------------------------------- /src-tauri/test-resources/valid-signed.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/test-resources/valid-signed.jar -------------------------------------------------------------------------------- /src-tauri/test-resources/signing-keys.jceks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/test-resources/signing-keys.jceks -------------------------------------------------------------------------------- /src-tauri/test-resources/tampered-app-class.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kayyagari/ballista/HEAD/src-tauri/test-resources/tampered-app-class.jar -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .logo.vite:hover { 2 | filter: drop-shadow(0 0 2em #747bff); 3 | } 4 | 5 | .logo.react:hover { 6 | filter: drop-shadow(0 0 2em #61dafb); 7 | } 8 | 9 | /*.layout {*/ 10 | /* align-items: flex-start;*/ 11 | /*}*/ -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./styles.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/ballistaInfo.ts: -------------------------------------------------------------------------------- 1 | import {invoke} from "@tauri-apps/api/core"; 2 | 3 | export interface BallistaInfo { 4 | ballista_version: string 5 | } 6 | 7 | export async function requestBallistaInfo() { 8 | console.log("requesting ballista info"); 9 | const jsonArr: string = await invoke("get_ballista_info"); 10 | return JSON.parse(jsonArr) 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tauri + React + TS 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/test-resources/MANIFEST.MF: -------------------------------------------------------------------------------- 1 | Manifest-Version: 1.0 2 | Application-Name: Catapult Test Jar 3 | Built-By: dbugger 4 | Created-By: Apache Maven 3.6.0 5 | Build-Jdk: 1.8.0_352 6 | url: 7 | authors: Sereen Systems: Kiran Ayyagari 8 | 9 | Name: com/sereen/catapult/App.class 10 | SHA-256-Digest: YD7chnl2dQvq+IPXfOPOw/82gctW0ZDXrqlVTprcPIs= 11 | 12 | Name: META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.propert 13 | ies 14 | SHA-256-Digest: EuvP5v5Pd2IOFjVJhMixzxIKy2baBE6a+hOWhtAyA/s= 15 | 16 | Name: META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.xml 17 | SHA-256-Digest: hYrjJTvk33E2hMAm3jQFv94npqhurT1xC/89tZnhrpM= 18 | 19 | Name: log4j.properties 20 | SHA-256-Digest: qDNFTmmOPAopORClhI9oAJiLlPQLgoBBmz2MTWVTq34= 21 | 22 | -------------------------------------------------------------------------------- /src-tauri/test-resources/RSA.SF: -------------------------------------------------------------------------------- 1 | Signature-Version: 1.0 2 | SHA-256-Digest-Manifest-Main-Attributes: SrvXwDOQW2uH7eiPwlfR+ZwyjWW9A 3 | bEfM7dU3f4rDKo= 4 | SHA-256-Digest-Manifest: VncmygtfITJAO9mhhNipU9kWkFhAMqFErwtkfZsGXBc= 5 | Created-By: 1.8.0_352 (Azul Systems, Inc.) 6 | 7 | Name: com/sereen/catapult/App.class 8 | SHA-256-Digest: MGAQ6snGyZKVKzAcSfzmq6+4KnwYK3lXBHl25PRKPMU= 9 | 10 | Name: META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.propert 11 | ies 12 | SHA-256-Digest: lEBFiKk6dpR0QEag30N+lOIQKOnGT17wKb8e/YNbWv4= 13 | 14 | Name: META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.xml 15 | SHA-256-Digest: GUlGP/Ve5YYCc4jxXqE5XHpWLeLJshKzu2k8m9ulumE= 16 | 17 | Name: log4j.properties 18 | SHA-256-Digest: WZrTZ8yDNvEiIP9ZT1eLvyzRwwvQayYN5m8SY9QKQ4Q= 19 | 20 | -------------------------------------------------------------------------------- /src/CertDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Modal} from "antd"; 3 | 4 | export interface UntrustedCert { 5 | der?: string, 6 | subject?: string, 7 | issuer?: string, 8 | expires_on?: string 9 | } 10 | 11 | const CertDialog: React.FC<{cert: UntrustedCert, trustAndLaunch: any, abortLaunch: any}> = ({cert, trustAndLaunch, abortLaunch}) => { 12 | if(cert.der !== undefined) { 13 | return ( 15 |

Subject: {cert.subject}

16 |

Issued By: {cert.issuer}

17 |

Expires On: {cert.expires_on}

18 |

Do you trust this certificate?

19 |
) 20 | } 21 | return <> 22 | } 23 | 24 | export default CertDialog; -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "npm run dev", 4 | "beforeBuildCommand": "npm run build", 5 | "frontendDist": "../dist", 6 | "devUrl": "http://localhost:1420" 7 | }, 8 | "bundle": { 9 | "active": true, 10 | "targets": "all", 11 | "publisher": "Kiran Ayyagari", 12 | "icon": [ 13 | "icons/32x32.icns", 14 | "icons/favicon.ico", 15 | "icons/icon.png" 16 | ] 17 | }, 18 | "productName": "Ballista", 19 | "mainBinaryName": "Ballista", 20 | "identifier": "sereen.io", 21 | "plugins": {}, 22 | "app": { 23 | "withGlobalTauri": false, 24 | "trayIcon": { 25 | "iconPath": "icons/icon.png", 26 | "title": "Ballista" 27 | }, 28 | "security": { 29 | "csp": null 30 | }, 31 | "windows": [ 32 | { 33 | "fullscreen": false, 34 | "resizable": true, 35 | "title": "Ballista", 36 | "width": 900, 37 | "height": 600, 38 | "useHttpsScheme": true 39 | } 40 | ] 41 | } 42 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(async () => ({ 6 | plugins: [react()], 7 | 8 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 9 | // prevent vite from obscuring rust errors 10 | clearScreen: false, 11 | // tauri expects a fixed port, fail if that port is not available 12 | server: { 13 | port: 1420, 14 | strictPort: true, 15 | }, 16 | // to make use of `TAURI_DEBUG` and other env variables 17 | // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand 18 | envPrefix: ["VITE_", "TAURI_"], 19 | build: { 20 | // Tauri supports es2021 21 | target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13", 22 | // don't minify for debug builds 23 | minify: !process.env.TAURI_DEBUG ? "esbuild" : false, 24 | // produce sourcemaps for debug builds 25 | sourcemap: !!process.env.TAURI_DEBUG, 26 | }, 27 | })); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ballista", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri" 11 | }, 12 | "engines": { 13 | "node": ">=22.0.0" 14 | }, 15 | "dependencies": { 16 | "@ant-design/icons": "^6.0.0", 17 | "@tauri-apps/api": "~2.8.0", 18 | "@tauri-apps/plugin-clipboard-manager": "2.3.0", 19 | "@tauri-apps/plugin-dialog": "2.3.3", 20 | "@tauri-apps/plugin-fs": "2.4.2", 21 | "@tauri-apps/plugin-global-shortcut": "2.3.0", 22 | "@tauri-apps/plugin-http": "2.5.2", 23 | "@tauri-apps/plugin-notification": "2.3.1", 24 | "@tauri-apps/plugin-os": "2.3.1", 25 | "@tauri-apps/plugin-process": "2.3.0", 26 | "@tauri-apps/plugin-shell": "2.3.0", 27 | "antd": "^5.27.1", 28 | "react": "^19.1.1", 29 | "react-dom": "^19.1.1" 30 | }, 31 | "devDependencies": { 32 | "@tauri-apps/cli": "^2.8.1", 33 | "@types/node": "^22.17.2", 34 | "@types/react": "^19.1.11", 35 | "@types/react-dom": "^19.1.7", 36 | "@vitejs/plugin-react": "^5.0.1", 37 | "typescript": "^5.9.2", 38 | "vite": "^7.1.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ballista" 3 | version = "0.6.0" 4 | description = "A Mirth Connect Admin Client Launcher" 5 | authors = ["kiran@sereen.io"] 6 | homepage = "https://sereen.io" 7 | license = "MPL-2.0" 8 | repository = "https://github.com/kayyagari/ballista" 9 | edition = "2021" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [build-dependencies] 14 | tauri-build = { version = "2.4.0", features = [] } 15 | 16 | [dependencies] 17 | tauri = { version = "2.8.2", features = ["tray-icon"] } 18 | tauri-runtime-wry = "2.8.0" 19 | serde = { version = "1.0", features = ["derive", "rc"] } 20 | serde_json = "1.0" 21 | # the above are added and required by Tauri 22 | roxmltree = "0.18.1" 23 | reqwest = { version = "0.12.23", features = ["blocking", "native-tls-vendored"] } 24 | anyhow = "1.0.99" 25 | sha2 = "0.10.9" 26 | hex = "0.4.3" 27 | uuid = {version = "1.18.0", features = ["v4", "fast-rng"] } 28 | home = "0.5.11" 29 | zip = {version = "2.4.2", feature = ["deflate"] } 30 | rustc-hash = "2.1.1" 31 | openssl = { version = "0.10.73", features = ["vendored"] } 32 | openssl-probe = "0.1.6" 33 | asn1-rs = "0.7.1" 34 | tauri-plugin-shell = "2.3.0" 35 | tauri-plugin-http = "2.5.2" 36 | tauri-plugin-os = "2.3.1" 37 | tauri-plugin-fs = "2.4.2" 38 | tauri-plugin-process = "2.3.0" 39 | tauri-plugin-clipboard-manager = "2.3.0" 40 | tauri-plugin-dialog = "2.3.3" 41 | tauri-plugin-notification = "2.3.1" 42 | 43 | [dependencies.fix-path-env] 44 | git = "https://github.com/tauri-apps/fix-path-env-rs" 45 | branch = "dev" 46 | 47 | [features] 48 | # this feature is used for production builds or when `devPath` points to the filesystem 49 | # DO NOT REMOVE!! 50 | custom-protocol = ["tauri/custom-protocol"] 51 | 52 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 53 | tauri-plugin-global-shortcut = "2.3.0" 54 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, Avenir, Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | line-height: 24px; 5 | font-weight: 400; 6 | 7 | color: #0f0f0f; 8 | background-color: #f6f6f6; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | .container { 18 | margin: 0; 19 | padding-top: 10vh; 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | text-align: center; 24 | } 25 | 26 | .logo { 27 | height: 6em; 28 | padding: 1.5em; 29 | will-change: filter; 30 | transition: 0.75s; 31 | } 32 | 33 | .logo.tauri:hover { 34 | filter: drop-shadow(0 0 2em #24c8db); 35 | } 36 | 37 | .row { 38 | display: flex; 39 | justify-content: center; 40 | } 41 | 42 | a { 43 | font-weight: 500; 44 | color: #646cff; 45 | text-decoration: inherit; 46 | } 47 | 48 | a:hover { 49 | color: #535bf2; 50 | } 51 | 52 | h1 { 53 | text-align: center; 54 | } 55 | 56 | input, 57 | button { 58 | border-radius: 8px; 59 | border: 1px solid transparent; 60 | padding: 0.6em 1.2em; 61 | font-size: 1em; 62 | font-weight: 500; 63 | font-family: inherit; 64 | color: #0f0f0f; 65 | background-color: #ffffff; 66 | transition: border-color 0.25s; 67 | box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); 68 | } 69 | 70 | button { 71 | cursor: pointer; 72 | } 73 | 74 | button:hover { 75 | border-color: #396cd8; 76 | } 77 | button:active { 78 | border-color: #396cd8; 79 | background-color: #e8e8e8; 80 | } 81 | 82 | input, 83 | button { 84 | outline: none; 85 | } 86 | 87 | #greet-input { 88 | margin-right: 5px; 89 | } 90 | 91 | @media (prefers-color-scheme: dark) { 92 | :root { 93 | color: #f6f6f6; 94 | background-color: #2f2f2f; 95 | } 96 | 97 | a:hover { 98 | color: #24c8db; 99 | } 100 | 101 | input, 102 | button { 103 | color: #ffffff; 104 | background-color: #0f0f0f98; 105 | } 106 | button:active { 107 | background-color: #0f0f0f69; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ballista 2 | A lean and simple launcher for Mirth Connect Admin Client. 3 | 4 | ## How To Use 5 | 1. Go to releases and download a suitable installer for your OS platform 6 | 2. Create a new connection or if you are using MirthConnect Admin Launcher then import existing connections from `/data/connections.json` 7 | 3. Select a connection from the list of connections on the left hand side 8 | 4. Adjust the `Java Home` field's value if necessary (please note that Ballista assumes JRE version 8 or higher was already installed on the local machine) 9 | 4. Click on `Open` 10 | 11 | ## Known Issues 12 | Ballista cannot open MC Admin Client for version 3.10.1 due to the bug in MC server https://github.com/nextgenhealthcare/connect/issues/4432. This bug in MC server was fixed in version 3.11.0. 13 | 14 | ## Compiling 15 | 16 | These compilation instructions are written for users not familiar with Rust and Tauri who just want to build and use Ballista. 17 | 18 | You should generally follow the Tauri Getting started guide: https://tauri.app/v1/guides/getting-started/prerequisites 19 | 20 | A good reference for how to run builds is the file .github/workflows/build-ballista.yml . If you can replicate the same steps the build pipeline does, then you should have good builds! 21 | 22 | ### MacOS 23 | 24 | 1. Open the project in VS Code. Let VS code install the suggested plugins. 25 | 1. Install Rust `brew install rust` 26 | 1. Run `npm install` 27 | 1. Run `npm run tauri build` 28 | 1. A DMG will be built at `./src-tauri/target/release/bundle/dmg/Ballista_0.1.0_aarch64.dmg` 29 | 1. Install the app as usual. An installation to `~/Applications` instead of `/Applications` is best for development. 30 | 31 | ### Linux 32 | 33 | Should be very similar to MacOS. 34 | 35 | ### Windows 36 | 37 | Please make a PR if you use Windows and know how to compile the app! 38 | 39 | ___Follow the instructions at___: https://tauri.app/v1/guides/getting-started/prerequisites/#setting-up-windows 40 | 41 | Follow the openssl instructions at: https://docs.rs/crate/openssl/0.9.24 *EXCEPT* you have to use different commands to set env vars in PowerShell: 42 | ``` 43 | $env:OPENSSL_DIR='C:\Program Files\OpenSSL-Win64\' 44 | $env:OPENSSL_INCLUDE_DIR='C:\Program Files\OpenSSL-Win64\include' 45 | $env:OPENSSL_LIB_DIR='C:\Program Files\OpenSSL-Win64\lib' 46 | $env:OPENSSL_NO_VENDOR=1 47 | Get-ChildItem Env 48 | ``` 49 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/core"; 2 | import {DataNode} from "antd/es/tree"; 3 | 4 | export const DEFAULT_GROUP_NAME: string = "Default"; 5 | export interface Connection { 6 | address: string, 7 | heapSize: string, 8 | icon: string, 9 | id: string, 10 | javaHome: string, 11 | name: string, 12 | username: string, 13 | password: string, 14 | verify: boolean, 15 | group: string, 16 | notes: string, 17 | donotcache: boolean, 18 | 19 | // the below properties are transient and are used only in the UI 20 | nodeId: string, 21 | parentId: string, 22 | } 23 | 24 | export async function loadConnections() { 25 | console.log("loading connections"); 26 | const jsonArr: string = await invoke("load_connections"); 27 | let data = JSON.parse(jsonArr); 28 | //data.sort(connectionSorter); 29 | return data; 30 | } 31 | 32 | export function orderConnections(data: Connection[]) { 33 | let groupConnMap: any = {}; 34 | let prevGroup = null; 35 | for(let i =0; i < data.length; i++) { 36 | let con = data[i]; 37 | let conArr = groupConnMap[con.group]; 38 | if(conArr === undefined || conArr === null) { 39 | conArr = new Array(); 40 | groupConnMap[con.group] = conArr; 41 | } 42 | conArr.push(con); 43 | if(prevGroup != null && prevGroup !== con.group) { 44 | groupConnMap[prevGroup].sort(connectionSorter); 45 | } 46 | prevGroup = con.group; 47 | } 48 | if(prevGroup != null) { 49 | groupConnMap[prevGroup].sort(connectionSorter); 50 | } 51 | 52 | let groupNames = Object.keys(groupConnMap).filter((val) => val != DEFAULT_GROUP_NAME).sort(); 53 | if(groupConnMap[DEFAULT_GROUP_NAME]) { 54 | groupNames.unshift(DEFAULT_GROUP_NAME); 55 | } 56 | 57 | return {groupNames, groupConnMap}; 58 | } 59 | export function connectionSorter(c1: Connection, c2: Connection) { 60 | let n1 = c1.name.toLowerCase(); 61 | let n2 = c2.name.toLowerCase(); 62 | if(n1 > n2) { 63 | return 1; 64 | } 65 | else if(n1 < n2) { 66 | return -1; 67 | } 68 | 69 | return 0; 70 | } 71 | 72 | export function searchText(token: string, c: Connection) { 73 | token = token.toLowerCase(); 74 | for(const [key, val] of Object.entries(c)) { 75 | if(key == 'id') { 76 | continue; 77 | } 78 | if((typeof val == 'string') && val.toLowerCase().indexOf(token) > -1) { 79 | return true; 80 | } 81 | } 82 | return false; 83 | } -------------------------------------------------------------------------------- /.github/workflows/build-ballista.yml: -------------------------------------------------------------------------------- 1 | name: Build on every push and every pull-request 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - '*.md' 7 | push: 8 | paths-ignore: 9 | - '*.md' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | release: 14 | permissions: 15 | contents: write 16 | strategy: 17 | fail-fast: false 18 | max-parallel: 3 19 | matrix: 20 | platform: [macos-latest, ubuntu-latest, windows-latest] 21 | runs-on: ${{ matrix.platform }} 22 | 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v5 26 | 27 | - name: Install dependencies (ubuntu only) 28 | if: startsWith(matrix.platform, 'ubuntu') 29 | # You can remove libayatana-appindicator3-dev if you don't use the system tray feature. 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev 33 | 34 | - name: Rust setup 35 | uses: dtolnay/rust-toolchain@stable 36 | 37 | - name: Rust cache 38 | uses: swatinem/rust-cache@v2 39 | with: 40 | workspaces: './src-tauri -> target' 41 | 42 | - name: Sync node version and setup cache 43 | uses: actions/setup-node@v4 44 | with: 45 | node-version: 24 46 | cache: 'npm' 47 | 48 | - name: Install frontend dependencies 49 | run: npm install 50 | 51 | - name: Build the app and release 52 | if: ${{ github.ref == 'refs/heads/master' }} 53 | uses: tauri-apps/tauri-action@v0 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | tagName: ${{ github.ref_name }} 58 | releaseName: 'App Name v__VERSION__' 59 | releaseBody: 'See the assets to download and install this version.' 60 | releaseDraft: true 61 | prerelease: false 62 | 63 | - name: Build the app 64 | if: ${{ github.ref != 'refs/heads/master' }} 65 | uses: tauri-apps/tauri-action@v0 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | releaseDraft: false 70 | prerelease: false 71 | 72 | - name: Upload Artifact 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: build-${{ matrix.platform }} 76 | path: | 77 | src-tauri/target/release/bundle/nsis/*.exe 78 | src-tauri/target/release/bundle/msi/*.msi 79 | src-tauri/target/release/bundle/appimage/*.AppImage 80 | src-tauri/target/release/bundle/deb/*.deb 81 | src-tauri/target/release/bundle/rpm/*.rpm 82 | src-tauri/target/release/bundle/dmg/*.dmg 83 | src-tauri/target/release/bundle/macos/*.app 84 | -------------------------------------------------------------------------------- /src-tauri/src/errors.rs: -------------------------------------------------------------------------------- 1 | use openssl::error::ErrorStack; 2 | use openssl::x509::{X509NameRef, X509}; 3 | use rustc_hash::FxHashMap; 4 | use serde_json::{Number, Value}; 5 | use std::collections::VecDeque; 6 | use std::fmt::{Display, Formatter}; 7 | use std::io::Error; 8 | use zip::result::ZipError; 9 | 10 | #[derive(Debug)] 11 | pub struct VerificationError { 12 | pub(crate) cert: Option, 13 | pub(crate) msg: String, 14 | } 15 | 16 | impl VerificationError { 17 | pub fn to_json(&self) -> String { 18 | let mut obj = FxHashMap::default(); 19 | obj.insert("msg", Value::String(self.msg.clone())); 20 | obj.insert("code", Value::Number(Number::from(1))); 21 | if let Some(ref cert) = self.cert { 22 | let mut cert_details = serde_json::Map::new(); 23 | let der = cert.to_der().expect("failed to der encode the certificate"); 24 | let der = openssl::base64::encode_block(der.as_slice()); 25 | cert_details.insert("der".to_string(), Value::String(der)); 26 | let subject = format_name(cert.subject_name()); 27 | cert_details.insert("subject".to_string(), Value::String(subject)); 28 | 29 | let issuer = format_name(cert.issuer_name()); 30 | cert_details.insert("issuer".to_string(), Value::String(issuer)); 31 | 32 | let expires_on = cert.not_after().to_string(); 33 | cert_details.insert("expires_on".to_string(), Value::String(expires_on)); 34 | 35 | obj.insert("cert", Value::Object(cert_details)); 36 | } 37 | 38 | let json = serde_json::to_string(&obj).expect("failed to serialize VerificationError"); 39 | json 40 | } 41 | } 42 | 43 | impl Display for VerificationError { 44 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 45 | write!(f, "{}", self.msg) 46 | } 47 | } 48 | 49 | impl From for VerificationError { 50 | fn from(value: Error) -> Self { 51 | VerificationError { 52 | cert: None, 53 | msg: value.to_string(), 54 | } 55 | } 56 | } 57 | 58 | impl From for VerificationError { 59 | fn from(value: ZipError) -> Self { 60 | VerificationError { 61 | cert: None, 62 | msg: value.to_string(), 63 | } 64 | } 65 | } 66 | 67 | impl From for VerificationError { 68 | fn from(value: anyhow::Error) -> Self { 69 | VerificationError { 70 | cert: None, 71 | msg: value.to_string(), 72 | } 73 | } 74 | } 75 | 76 | impl From for VerificationError { 77 | fn from(value: ErrorStack) -> Self { 78 | VerificationError { 79 | cert: None, 80 | msg: value.to_string(), 81 | } 82 | } 83 | } 84 | 85 | fn format_name(name: &X509NameRef) -> String { 86 | let mut parts = VecDeque::new(); 87 | let mut formatted_name = String::with_capacity(128); 88 | for e in name.entries() { 89 | let p = format!( 90 | "{}={}", 91 | e.object().nid().short_name().unwrap(), 92 | e.data().as_utf8().unwrap().to_string() 93 | ); 94 | parts.push_front(p); 95 | } 96 | 97 | let last_part = parts.pop_back(); 98 | for p in parts { 99 | formatted_name.push_str(&p); 100 | formatted_name.push(','); 101 | } 102 | 103 | formatted_name.push_str(&last_part.unwrap()); 104 | 105 | formatted_name 106 | } 107 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | use std::fs; 5 | use std::path::PathBuf; 6 | use std::process::exit; 7 | use std::sync::Arc; 8 | 9 | use serde_json::Number; 10 | use tauri::State; 11 | 12 | use crate::connection::{ConnectionEntry, ConnectionStore}; 13 | use crate::webstart::{WebStartCache, WebstartFile}; 14 | 15 | mod connection; 16 | mod errors; 17 | mod verify; 18 | mod webstart; 19 | 20 | const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 21 | 22 | #[tauri::command] 23 | async fn get_ballista_info() -> String { 24 | let mut obj = serde_json::Map::new(); 25 | obj.insert( 26 | "ballista_version".to_string(), 27 | serde_json::Value::String(String::from(APP_VERSION)), 28 | ); 29 | return serde_json::to_string(&obj).unwrap(); 30 | } 31 | 32 | #[tauri::command(rename_all = "snake_case")] 33 | fn launch(id: &str, cs: State, wc: State) -> String { 34 | let ce = cs.get(id); 35 | if let Some(ce) = ce { 36 | let mut ws = wc.get(&ce.address); 37 | if let None = ws { 38 | let tmp = WebstartFile::load(&ce.address, &cs.cache_dir, ce.donotcache); 39 | if let Err(e) = tmp { 40 | let msg = e.to_string(); 41 | println!("{}", msg); 42 | return create_json_resp(-1, &msg); 43 | } 44 | 45 | ws = Some(Arc::new(tmp.unwrap())); 46 | } 47 | let ws = ws.unwrap(); 48 | if ce.verify { 49 | let verification_status = ws.verify(cs.get_cert_store().as_ref()); 50 | if let Err(e) = verification_status { 51 | let resp = e.to_json(); 52 | println!("{}", resp); 53 | return resp; 54 | } 55 | } 56 | let r = ws.run(ce); 57 | if let Err(e) = r { 58 | let msg = e.to_string(); 59 | println!("{}", msg); 60 | return create_json_resp(-1, &msg); 61 | } 62 | } 63 | 64 | String::from("{\"code\": 0}") 65 | } 66 | 67 | #[tauri::command] 68 | fn load_connections(cs: State) -> String { 69 | cs.to_json_array_string() 70 | } 71 | 72 | #[tauri::command] 73 | fn save(ce: &str, cs: State) -> String { 74 | let ce: serde_json::Result = serde_json::from_str(ce); 75 | //println!("received connection data {:?}", ce); 76 | let r = cs.save(ce.expect("failed to deserialize the given ConnectionEntry")); 77 | if let Err(e) = r { 78 | return e.to_string(); 79 | } 80 | 81 | r.unwrap() 82 | } 83 | 84 | #[tauri::command] 85 | fn delete(id: &str, cs: State) -> String { 86 | let r = cs.delete(id); 87 | if let Err(e) = r { 88 | return e.to_string(); 89 | } 90 | String::from("success") 91 | } 92 | 93 | #[tauri::command(rename_all = "snake_case")] 94 | fn import(file_path: &str, cs: State) -> String { 95 | let r = cs.import(file_path); 96 | if let Err(e) = r { 97 | let msg = e.to_string(); 98 | println!("{}", msg); 99 | return msg; 100 | } 101 | 102 | r.unwrap() 103 | } 104 | 105 | #[tauri::command(rename_all = "snake_case")] 106 | fn trust_cert(cert: &str, cs: State) -> String { 107 | let r = cs.add_trusted_cert(cert); 108 | if let Err(e) = r { 109 | return e.to_string(); 110 | } 111 | String::from("success") 112 | } 113 | 114 | fn main() { 115 | let env_fix = fix_path_env::fix_vars(&["JAVA_HOME", "PATH"]); 116 | if let Err(_e) = env_fix { 117 | println!("failed to read JAVA_HOME and PATH environment variables"); 118 | } 119 | 120 | let hd = home::home_dir().expect("unable to find the path to home directory"); 121 | // <= 0.2.0 migrate to a new app specific location 122 | let bd = hd.join(".ballista"); 123 | let r = fs::create_dir(&bd); 124 | if let Ok(_) = r { 125 | move_file(hd.join("catapult-data.json"), bd.join("ballista-data.json")); 126 | move_file( 127 | hd.join("catapult-trusted-certs.json"), 128 | bd.join("ballista-trusted-certs.json"), 129 | ); 130 | } 131 | 132 | let cs = ConnectionStore::init(bd); 133 | if let Err(e) = cs { 134 | println!("failed to initialize ConnectionStore: {}", e.to_string()); 135 | exit(1); 136 | } 137 | 138 | let wc = WebStartCache::init(); 139 | tauri::Builder::default() 140 | .plugin(tauri_plugin_notification::init()) 141 | .plugin(tauri_plugin_dialog::init()) 142 | .plugin(tauri_plugin_clipboard_manager::init()) 143 | .plugin(tauri_plugin_process::init()) 144 | .plugin(tauri_plugin_fs::init()) 145 | .plugin(tauri_plugin_os::init()) 146 | .plugin(tauri_plugin_global_shortcut::Builder::new().build()) 147 | .plugin(tauri_plugin_http::init()) 148 | .plugin(tauri_plugin_shell::init()) 149 | .manage(cs.unwrap()) 150 | .manage(wc) 151 | .invoke_handler(tauri::generate_handler![ 152 | launch, 153 | import, 154 | delete, 155 | save, 156 | load_connections, 157 | trust_cert, 158 | get_ballista_info 159 | ]) 160 | .run(tauri::generate_context!()) 161 | .expect("error while running tauri application"); 162 | } 163 | 164 | fn create_json_resp(code: i32, msg: &str) -> String { 165 | let mut obj = serde_json::Map::new(); 166 | obj.insert( 167 | "code".to_string(), 168 | serde_json::Value::Number(Number::from(code)), 169 | ); 170 | obj.insert( 171 | "msg".to_string(), 172 | serde_json::Value::String(String::from(msg)), 173 | ); 174 | serde_json::to_string(&obj).unwrap() 175 | } 176 | 177 | fn move_file(old: PathBuf, new: PathBuf) { 178 | if old.exists() && !new.exists() { 179 | let r = fs::rename(&old, &new); 180 | if let Err(e) = r { 181 | println!( 182 | "failed to move the file from {:?} to {:?} : {}", 183 | old, 184 | new, 185 | e.to_string() 186 | ); 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src-tauri/src/connection.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use home::env::Env; 3 | use home::env::OS_ENV; 4 | use openssl::x509::store::{X509Store, X509StoreBuilder}; 5 | use openssl::x509::X509; 6 | use rustc_hash::FxHashMap; 7 | use serde::{Deserialize, Serialize}; 8 | use sha2::{Digest, Sha256}; 9 | use std::collections::HashMap; 10 | use std::fs; 11 | use std::fs::{File, OpenOptions}; 12 | use std::io::Write; 13 | use std::ops::Deref; 14 | use std::path::PathBuf; 15 | use std::process::Command; 16 | use std::sync::{Arc, Mutex}; 17 | use uuid::Uuid; 18 | 19 | #[derive(Debug, Serialize, Deserialize)] 20 | pub struct ConnectionEntry { 21 | pub address: String, 22 | #[serde(rename = "heapSize")] 23 | pub heap_size: String, 24 | pub icon: String, 25 | pub id: String, 26 | #[serde(rename = "javaHome")] 27 | pub java_home: String, 28 | #[serde(rename = "javaArgs")] 29 | pub java_args: Option, 30 | pub name: String, 31 | pub username: Option, 32 | pub password: Option, 33 | #[serde(default = "get_verify")] 34 | pub verify: bool, 35 | #[serde(default = "get_default_group")] 36 | pub group: String, 37 | #[serde(default = "get_default_notes")] 38 | pub notes: String, 39 | #[serde(default = "get_default_donotcache")] 40 | pub donotcache: bool, 41 | } 42 | 43 | pub struct ConnectionStore { 44 | con_cache: Mutex>>, 45 | con_location: PathBuf, 46 | pub cache_dir: PathBuf, 47 | cert_store: Mutex>, 48 | trusted_certs_location: PathBuf, 49 | } 50 | 51 | impl Default for ConnectionEntry { 52 | fn default() -> Self { 53 | let empty_str = String::from(""); 54 | ConnectionEntry { 55 | address: empty_str.clone(), 56 | heap_size: String::from("512m"), 57 | icon: empty_str.clone(), 58 | id: Uuid::new_v4().to_string(), 59 | java_home: find_java_home(), 60 | java_args: Option::from(empty_str.clone()), 61 | name: empty_str.clone(), 62 | username: None, 63 | password: None, 64 | verify: true, 65 | group: get_default_group(), 66 | notes: get_default_notes(), 67 | donotcache: get_default_donotcache(), 68 | } 69 | } 70 | } 71 | 72 | impl ConnectionStore { 73 | pub fn init(data_dir_path: PathBuf) -> Result { 74 | let con_location = data_dir_path.join("ballista-data.json"); 75 | let mut con_location_file = File::open(&con_location); 76 | if let Err(_e) = con_location_file { 77 | con_location_file = File::create(&con_location); 78 | } 79 | let con_location_file = con_location_file?; 80 | 81 | let mut cache = HashMap::new(); 82 | let data: serde_json::Result> = 83 | serde_json::from_reader(con_location_file); 84 | if let Ok(data) = data { 85 | for (id, ce) in data { 86 | cache.insert(id, Arc::new(ce)); 87 | } 88 | } else { 89 | println!("{}", data.err().unwrap().to_string()); 90 | } 91 | 92 | let trusted_certs_location = data_dir_path.join("ballista-trusted-certs.json"); 93 | let certs = parse_trusted_certs(&trusted_certs_location); 94 | let cert_store = create_cert_store(certs); 95 | // if let Err(e) = trusted_certs_location_file { 96 | // trusted_certs_location_file = File::create(&trusted_certs_location); 97 | // } 98 | // let trusted_certs_location_file = trusted_certs_location_file?; 99 | 100 | let cache_dir = data_dir_path.join("cache"); 101 | if !cache_dir.exists() { 102 | fs::create_dir(&cache_dir)?; 103 | } 104 | 105 | Ok(ConnectionStore { 106 | con_location, 107 | con_cache: Mutex::new(cache), 108 | cert_store: Mutex::new(Arc::new(cert_store)), 109 | trusted_certs_location, 110 | cache_dir 111 | }) 112 | } 113 | 114 | pub fn to_json_array_string(&self) -> String { 115 | let mut sb = String::with_capacity(1024); 116 | let len = self.con_cache.lock().unwrap().len(); 117 | sb.push('['); 118 | for (pos, ce) in self.con_cache.lock().unwrap().values().enumerate() { 119 | let c = serde_json::to_string(ce).expect("failed to serialize ConnectionEntry"); 120 | sb.push_str(c.as_str()); 121 | if pos + 1 < len { 122 | sb.push(','); 123 | } 124 | } 125 | sb.push(']'); 126 | 127 | sb 128 | } 129 | 130 | pub fn get(&self, id: &str) -> Option> { 131 | let cs = self.con_cache.lock().unwrap(); 132 | let val = cs.get(id); 133 | if let Some(val) = val { 134 | return Some(Arc::clone(val)); 135 | } 136 | None 137 | } 138 | 139 | pub fn save(&self, mut ce: ConnectionEntry) -> Result { 140 | if ce.id.is_empty() { 141 | ce.id = uuid::Uuid::new_v4().to_string(); 142 | } 143 | 144 | let mut jh = ce.java_home.trim().to_string(); 145 | if jh.is_empty() { 146 | jh = find_java_home(); 147 | } 148 | ce.java_home = jh; 149 | 150 | if let Some(ref username) = ce.username { 151 | let username = username.trim(); 152 | if username.is_empty() { 153 | ce.username = None; 154 | } 155 | } 156 | 157 | if let Some(ref password) = ce.password { 158 | let password = password.trim(); 159 | if password.is_empty() { 160 | ce.password = None; 161 | } 162 | } 163 | 164 | let data = serde_json::to_string(&ce)?; 165 | self.con_cache 166 | .lock() 167 | .unwrap() 168 | .insert(ce.id.clone(), Arc::new(ce)); 169 | self.write_connections_to_disk()?; 170 | Ok(data) 171 | } 172 | 173 | pub fn delete(&self, id: &str) -> Result<(), Error> { 174 | self.con_cache.lock().unwrap().remove(id); 175 | self.write_connections_to_disk()?; 176 | Ok(()) 177 | } 178 | 179 | pub fn import(&self, file_path: &str) -> Result { 180 | let f = File::open(file_path)?; 181 | let data: Vec = serde_json::from_reader(f)?; 182 | let mut count = 0; 183 | let java_home = find_java_home(); 184 | for mut ce in data { 185 | ce.java_home = java_home.clone(); 186 | self.con_cache 187 | .lock() 188 | .unwrap() 189 | .insert(ce.id.clone(), Arc::new(ce)); 190 | count = count + 1; 191 | } 192 | 193 | self.write_connections_to_disk()?; 194 | Ok(format!("imported {} connections", count)) 195 | } 196 | 197 | pub fn add_trusted_cert(&self, cert_der: &str) -> Result<(), Error> { 198 | let mut certs = parse_trusted_certs(&self.trusted_certs_location); 199 | let mut hasher = Sha256::new(); 200 | hasher.update(cert_der); 201 | let hash = hasher.finalize(); 202 | let hash = hex::encode(&hash); 203 | 204 | let cert_der = openssl::base64::decode_block(cert_der)?; 205 | let cert = X509::from_der(cert_der.as_slice())?; 206 | if let None = certs.get(&hash) { 207 | certs.insert(hash, cert); 208 | } 209 | 210 | let mut der_certs = FxHashMap::default(); 211 | for (key, c) in &certs { 212 | let der = c.to_der()?; 213 | let der = openssl::base64::encode_block(der.as_slice()); 214 | der_certs.insert(key.to_string(), der); 215 | } 216 | let val = serde_json::to_string_pretty(&der_certs)?; 217 | let mut f = OpenOptions::new() 218 | .append(false) 219 | .create(true) 220 | .write(true) 221 | .truncate(true) 222 | .open(&self.trusted_certs_location)?; 223 | f.write_all(val.as_bytes())?; 224 | 225 | let new_store = create_cert_store(certs); 226 | *self.cert_store.lock().unwrap() = Arc::new(new_store); 227 | Ok(()) 228 | } 229 | 230 | pub fn get_cert_store(&self) -> Arc { 231 | let t = self.cert_store.lock().unwrap(); 232 | t.clone() 233 | } 234 | 235 | fn write_connections_to_disk(&self) -> Result<(), Error> { 236 | let c = self.con_cache.lock().unwrap(); 237 | let val = serde_json::to_string_pretty(c.deref())?; 238 | let f = OpenOptions::new() 239 | .append(false) 240 | .create(true) 241 | .write(true) 242 | .truncate(true) 243 | .open(&self.con_location); 244 | if let Err(e) = f { 245 | println!("unable to open file for writing: {}", e.to_string()); 246 | return Err(Error::new(e)); 247 | } 248 | f.unwrap().write_all(val.as_bytes())?; 249 | Ok(()) 250 | } 251 | } 252 | 253 | pub fn find_java_home() -> String { 254 | let mut java_home = String::from(""); 255 | if let Some(jh) = OS_ENV.var_os("JAVA_HOME") { 256 | java_home = String::from(jh.to_str().unwrap()); 257 | println!("JAVA_HOME is set to {}", java_home); 258 | } 259 | 260 | if java_home.is_empty() { 261 | let out = Command::new("/usr/libexec/java_home") 262 | .args(["-v", "1.8"]) 263 | .output(); 264 | if let Ok(out) = out { 265 | if out.status.success() { 266 | java_home = String::from_utf8(out.stdout) 267 | .expect("failed to create UTF-8 string from OsStr"); 268 | println!("/usr/libexec/java_home -v 1.8 returned {}", java_home); 269 | } 270 | } 271 | } 272 | java_home 273 | } 274 | 275 | fn parse_trusted_certs(trusted_certs_location: &PathBuf) -> FxHashMap { 276 | let mut certs = FxHashMap::default(); 277 | let trusted_certs_location_file = File::open(trusted_certs_location); 278 | if let Ok(trusted_certs_location_file) = trusted_certs_location_file { 279 | let cert_map: serde_json::Result> = 280 | serde_json::from_reader(trusted_certs_location_file); 281 | if let Ok(cert_map) = cert_map { 282 | for (key, der_data) in cert_map { 283 | let der_data = openssl::base64::decode_block(&der_data); 284 | if let Ok(der_data) = der_data { 285 | let c = X509::from_der(der_data.as_slice()); 286 | if let Ok(c) = c { 287 | certs.insert(key, c); 288 | } else { 289 | println!( 290 | "failed to parse cert from DER data with key {} {:?}", 291 | key, 292 | c.err() 293 | ); 294 | } 295 | } else { 296 | println!( 297 | "invalid base64 encoded data with key {} {:?}", 298 | key, 299 | der_data.err() 300 | ); 301 | } 302 | } 303 | } else { 304 | println!( 305 | "failed to parse trusted certificates JSON file {:?} {:?}", 306 | trusted_certs_location, 307 | cert_map.err() 308 | ); 309 | } 310 | } 311 | 312 | println!("found {} trusted certificates", certs.len()); 313 | certs 314 | } 315 | 316 | fn create_cert_store(certs: FxHashMap) -> X509Store { 317 | if !openssl_probe::has_ssl_cert_env_vars() { 318 | println!("probing and setting OpenSSL environment variables"); 319 | openssl_probe::init_ssl_cert_env_vars(); 320 | } 321 | let mut cert_store_builder = 322 | X509StoreBuilder::new().expect("unable to created X509 store builder"); 323 | cert_store_builder 324 | .set_default_paths() 325 | .expect("failed to load system default trusted certs"); 326 | for (_, c) in certs { 327 | cert_store_builder 328 | .add_cert(c) 329 | .expect("failed to add a cert to the in-memory store"); 330 | } 331 | 332 | cert_store_builder.build() 333 | } 334 | 335 | fn get_verify() -> bool { 336 | //println!("getting default value for verify attribute"); 337 | true 338 | } 339 | 340 | fn get_default_group() -> String { 341 | String::from("Default") 342 | } 343 | 344 | fn get_default_notes() -> String { 345 | String::from("") 346 | } 347 | 348 | fn get_default_donotcache() -> bool { 349 | false 350 | } 351 | -------------------------------------------------------------------------------- /src-tauri/src/webstart.rs: -------------------------------------------------------------------------------- 1 | use std::{env, io}; 2 | use std::fs::File; 3 | use std::io::{BufReader, Read}; 4 | use std::path::{Path, PathBuf}; 5 | use std::process::{Command, Stdio}; 6 | use std::sync::{Arc, Mutex}; 7 | use std::time::SystemTime; 8 | 9 | use anyhow::Error; 10 | use hex::encode; 11 | use openssl::x509::store::X509StoreRef; 12 | use reqwest::blocking::{Client, ClientBuilder}; 13 | use reqwest::Url; 14 | use roxmltree::Node; 15 | use rustc_hash::FxHashMap; 16 | use sha2::{Digest, Sha256}; 17 | 18 | use crate::connection::ConnectionEntry; 19 | use crate::errors::VerificationError; 20 | use crate::verify::verify_jar; 21 | 22 | #[derive(Debug)] 23 | pub struct WebstartFile { 24 | url: String, 25 | main_class: String, 26 | args: Vec, 27 | j2ses: Option>, 28 | //jars: Vec, 29 | jar_dir: PathBuf, 30 | loaded_at: SystemTime, 31 | } 32 | 33 | /// from jnlp -> resources -> j2se 34 | #[derive(Debug)] 35 | pub struct J2se { 36 | java_vm_args: Option, 37 | version: String, 38 | } 39 | 40 | pub struct WebStartCache { 41 | cache: Mutex>>, 42 | } 43 | 44 | impl WebStartCache { 45 | pub fn init() -> Self { 46 | let cache = Mutex::new(FxHashMap::default()); 47 | WebStartCache { cache } 48 | } 49 | 50 | pub fn put(&mut self, wf: Arc) { 51 | self.cache.lock().unwrap().insert(wf.url.clone(), wf); 52 | } 53 | 54 | pub fn get(&self, url: &str) -> Option> { 55 | let cache = self.cache.lock().unwrap(); 56 | let wf = cache.get(url); 57 | if let Some(wf) = wf { 58 | let now = SystemTime::now(); 59 | let elapsed = now 60 | .duration_since(wf.loaded_at) 61 | .expect("failed to calculate the duration"); 62 | if elapsed.as_secs() < 120 { 63 | return Some(Arc::clone(wf)); 64 | } 65 | } 66 | None 67 | } 68 | } 69 | 70 | impl WebstartFile { 71 | pub fn load(base_url: &str, cache_dir: &PathBuf, donotcache: bool) -> Result { 72 | let (base_url, host) = normalize_url(base_url)?; 73 | let webstart = format!("{}/webstart.jnlp", base_url); // base_url will never contain a / at the end after normalization 74 | let cb = ClientBuilder::default() 75 | // in certain network environments client is failing with error message "connection closed before message completed" 76 | // disabling the pooling resolved the issue 77 | .pool_max_idle_per_host(0) 78 | // accept any cert presented by the MC server 79 | .danger_accept_invalid_certs(true); 80 | let client = cb.build()?; 81 | 82 | let r = client.get(&webstart).send()?; 83 | let data = r.text()?; 84 | //TODO VERY NOISY, is there a log level lower than debug? 85 | //println!("Got response from MC as: {:?}", data); 86 | let doc = roxmltree::Document::parse(&data)?; 87 | 88 | let root = doc.root(); 89 | let main_class_node = get_node(&root, "application-desc").ok_or(Error::msg( 90 | "Got something from MC that was not an application-desc node in a JNLP XML", 91 | ))?; 92 | let main_class = main_class_node 93 | .attribute("main-class") 94 | .ok_or(Error::msg("missing main-class attribute"))? 95 | .to_string(); 96 | let args = get_client_args(&main_class_node); 97 | 98 | let resources_node = get_node(&root, "resources"); 99 | 100 | let mut version = "default"; 101 | if let Some(jnlp_node) = get_node(&root, "jnlp") { 102 | if let Some(v) = jnlp_node.attribute("version") { 103 | version = v; 104 | } 105 | } 106 | 107 | let jar_dir = cache_dir.join(host).join(version); 108 | if donotcache && jar_dir.exists() { 109 | println!("removing directory {:?}", jar_dir); 110 | std::fs::remove_dir_all(&jar_dir)?; 111 | } 112 | 113 | let dir_path = jar_dir.as_path(); 114 | if !jar_dir.exists() { 115 | println!("creating directory {:?}", jar_dir); 116 | std::fs::create_dir_all(dir_path)?; 117 | } 118 | 119 | let mut j2ses = None; 120 | if let Some(resources_node) = resources_node { 121 | j2ses = get_j2ses(&resources_node); 122 | download_jars(&resources_node, &client, dir_path, &base_url)?; 123 | } 124 | 125 | let loaded_at = SystemTime::now(); 126 | let ws = WebstartFile { 127 | url: base_url.to_string(), 128 | main_class, 129 | jar_dir, 130 | args, 131 | loaded_at, 132 | j2ses, 133 | }; 134 | 135 | Ok(ws) 136 | } 137 | 138 | pub fn run(&self, ce: Arc) -> Result<(), Error> { 139 | let itr = self.jar_dir.read_dir()?; 140 | let mut classpath = String::with_capacity(1152); 141 | let mut classpath_suffix = String::with_capacity(1024); 142 | for e in itr { 143 | let e = e?; 144 | if e.metadata().unwrap().is_dir() { 145 | continue; 146 | } 147 | let file_path = e.path(); 148 | let file_name = file_path.file_name().unwrap(); 149 | let file_path = file_path.as_os_str(); 150 | let file_path = file_path.to_str().unwrap(); 151 | 152 | //In Windows the CP separator is ';' and literally every other OS is ':' 153 | let classpath_separator = if cfg!(windows) { ';' } else { ':' }; 154 | 155 | //println!("{}", file_path); 156 | // MirthConnect's own jars contain some overridden classes 157 | // of the dependent libraries and hence must be loaded first 158 | // https://forums.mirthproject.io/forum/mirth-connect/support/15524-using-com-mirth-connect-client-core-client 159 | //TODO this should probably build the classpath objects as an ordered set, then do a .join(classpath_separator) 160 | if file_name.to_str().unwrap().starts_with("mirth") { 161 | classpath.push_str(file_path); 162 | classpath.push(classpath_separator); 163 | } else { 164 | classpath_suffix.push_str(file_path); 165 | classpath_suffix.push(classpath_separator); 166 | } 167 | } 168 | 169 | classpath.push_str(&classpath_suffix); 170 | 171 | //println!("class path: {}", classpath); 172 | let mut cmd; 173 | let java_home = ce.java_home.trim(); 174 | if java_home.is_empty() { 175 | cmd = Command::new("java") 176 | } else { 177 | cmd = Command::new(format!("{}/bin/java", java_home)); 178 | } 179 | 180 | println!("using java from: {:?}", cmd.get_program().to_str()); 181 | 182 | if let Some(ref vm_args) = self.j2ses { 183 | for va in vm_args { 184 | // if there are VM args for java version >= 1.9 185 | // then set the JDK_JAVA_OPTIONS environment variable 186 | // this will be ignored by java version <= 1.8 187 | if va.version.contains("1.9") { 188 | if let Some(java_vm_args) = &va.java_vm_args { 189 | println!("setting JDK_JAVA_OPTIONS environment variable with the java-vm-args given for version {} in JNLP file", va.version); 190 | cmd.env("JDK_JAVA_OPTIONS", java_vm_args); 191 | } 192 | } 193 | } 194 | } 195 | 196 | let heap = ce.heap_size.trim(); 197 | if !heap.is_empty() { 198 | cmd.arg(format!("-Xmx{}", heap)); 199 | } 200 | 201 | if let Some(args) = ce.java_args.as_deref() { 202 | // Should probably do some sanitization here... 203 | cmd.args(args.trim().lines()); 204 | } 205 | 206 | cmd.arg("-cp") 207 | .arg(classpath) 208 | .arg(&self.main_class) 209 | .args(&self.args); 210 | 211 | if let Some(ref username) = ce.username { 212 | cmd.arg(username); 213 | if let Some(ref password) = ce.password { 214 | cmd.arg(password); 215 | } 216 | } 217 | 218 | // inherit the parent's IO handles so that child's sysout and syserr messages can be seen on the terminal 219 | cmd.stdout(Stdio::inherit()); 220 | cmd.stderr(Stdio::inherit()); 221 | //TODO noisy, should be a debug logger 222 | //println!("Executing with: {:?}", cmd); 223 | cmd.spawn()?; 224 | Ok(()) 225 | } 226 | 227 | pub fn verify(&self, cert_store: &X509StoreRef) -> Result<(), VerificationError> { 228 | let mut jar_files = Vec::with_capacity(128); 229 | let itr = self 230 | .jar_dir 231 | .read_dir() 232 | .expect("failed to read the jar files directory"); 233 | for e in itr { 234 | let e = e.expect("failed to list a directory entry"); 235 | let file_path = e.path(); 236 | jar_files.push(file_path); 237 | } 238 | 239 | jar_files.sort_unstable(); 240 | println!("{:?}", jar_files); 241 | 242 | for jf in jar_files { 243 | let file_path = jf.as_os_str(); 244 | let file_path = file_path.to_str().unwrap(); 245 | verify_jar(file_path, cert_store)?; 246 | } 247 | Ok(()) 248 | } 249 | } 250 | 251 | fn download_jars( 252 | resources_node: &Node, 253 | client: &Client, 254 | dir_path: &Path, 255 | base_url: &str, 256 | ) -> Result<(), Error> { 257 | for n in resources_node.children() { 258 | let jar = n.has_tag_name("jar"); 259 | let extension = n.has_tag_name("extension"); 260 | 261 | if !jar && !extension { 262 | continue; 263 | } 264 | 265 | let href = n.attribute("href").unwrap(); 266 | let hash_in_jnlp = n.attribute("sha256"); 267 | let url = format!("{}/{}", base_url, href); 268 | 269 | if jar { 270 | let file_name = get_file_name_from_path(href); 271 | let jar_file_path = dir_path.join(file_name); 272 | if has_file_changed(&jar_file_path, hash_in_jnlp)? { 273 | //println!("downloading file {}", file_name); 274 | let mut resp = client.get(url).send()?; 275 | let mut f = File::create(&jar_file_path)?; 276 | resp.copy_to(&mut f)?; 277 | } 278 | else { 279 | //println!("file {} is cached", file_name); 280 | } 281 | } else if extension { 282 | let r = client.get(url).send()?; 283 | let data = r.text()?; 284 | let doc = roxmltree::Document::parse(&data)?; 285 | let root = doc.root(); 286 | let resources_node = get_node(&root, "resources"); 287 | let ext_base_url = format!("{}/webstart/extensions", base_url); 288 | if let Some(resources_node) = resources_node { 289 | download_jars(&resources_node, client, dir_path, &ext_base_url)?; 290 | } 291 | } 292 | } 293 | 294 | Ok(()) 295 | } 296 | 297 | fn get_file_name_from_path(p: &str) -> &str { 298 | let mut itr = p.rsplit_terminator("/"); 299 | itr.next().unwrap() 300 | } 301 | 302 | fn get_client_args(root: &Node) -> Vec { 303 | let mut args = Vec::new(); 304 | for n in root.descendants() { 305 | if n.has_tag_name("argument") { 306 | args.push(n.text().unwrap().to_string()); 307 | } 308 | } 309 | args 310 | } 311 | 312 | fn get_j2ses(resources: &Node) -> Option> { 313 | let mut j2ses = Vec::new(); 314 | for n in resources.descendants() { 315 | if n.has_tag_name("j2se") { 316 | // only consider those that have java-vm-args and version 317 | if let Some(java_vm_args) = n.attribute("java-vm-args") { 318 | if let Some(version) = n.attribute("version") { 319 | let java_vm_args = Some(java_vm_args.to_string()); 320 | let j2se = J2se { 321 | java_vm_args, 322 | version: version.to_string(), 323 | }; 324 | j2ses.push(j2se); 325 | } 326 | } 327 | } 328 | } 329 | if !j2ses.is_empty() { 330 | return Some(j2ses); 331 | } 332 | None 333 | } 334 | 335 | fn get_node<'a>(root: &'a Node, tag_name: &str) -> Option> { 336 | root.descendants().find(|n| { 337 | if n.has_tag_name(tag_name) { 338 | return true; 339 | } 340 | return false; 341 | }) 342 | } 343 | 344 | fn normalize_url(u: &str) -> Result<(String, String), Error> { 345 | let parsed_url = Url::parse(u)?; 346 | let mut reconstructed_url = String::with_capacity(u.len()); 347 | reconstructed_url.push_str(parsed_url.scheme()); 348 | reconstructed_url.push_str("://"); 349 | let host = parsed_url.host_str().map_or("", |h| h); 350 | reconstructed_url.push_str(host); 351 | let port = parsed_url 352 | .port() 353 | .map_or("".to_string(), |p| format!(":{}", p)); 354 | reconstructed_url.push_str(&port); 355 | reconstructed_url.push('/'); 356 | let mut path_parts = parsed_url.path().split_terminator("/"); 357 | for pp in path_parts { 358 | if !pp.is_empty() { 359 | reconstructed_url.push_str(pp); 360 | reconstructed_url.push('/'); 361 | } 362 | } 363 | 364 | reconstructed_url.pop(); // remove the trailing / 365 | let host = format!("{}{}", host, port).replace(":", "_"); 366 | Ok((reconstructed_url, host)) 367 | } 368 | 369 | fn has_file_changed(jar_file_path: &Path, hash_in_jnlp: Option<&str>) -> Result { 370 | if let Some(hash_in_jnlp) = hash_in_jnlp { 371 | let mut hasher = Sha256::new(); 372 | if jar_file_path.exists() { 373 | let jar_file = File::open(&jar_file_path)?; 374 | let mut reader = BufReader::new(&jar_file); 375 | let mut buf = [0; 2048]; 376 | while let Ok(count) = reader.read(&mut buf) { 377 | if count <= 0 { 378 | break; 379 | } 380 | hasher.update(&buf[..count]); 381 | } 382 | let val = hasher.finalize(); 383 | let val = openssl::base64::encode_block(val.as_slice()); 384 | return Ok(hash_in_jnlp != &val); 385 | } 386 | } 387 | 388 | Ok(true) 389 | } 390 | #[cfg(test)] 391 | mod tests { 392 | use crate::webstart::normalize_url; 393 | use anyhow::Error; 394 | 395 | #[test] 396 | pub fn test_normalize_url() -> Result<(), Error> { 397 | let candidates = [ 398 | ("https://localhost:8443", "https://localhost:8443"), 399 | ("https://localhost:8443/", "https://localhost:8443"), 400 | ("https://localhost:8443//", "https://localhost:8443"), 401 | ( 402 | "https://localhost:8443//a///bv", 403 | "https://localhost:8443/a/bv", 404 | ), 405 | ]; 406 | 407 | for (src, expected) in candidates { 408 | let (reconstructed_url, _host) = normalize_url(src)?; 409 | assert_eq!(expected, &reconstructed_url); 410 | } 411 | Ok(()) 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src-tauri/src/verify.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::{read_to_string, Read}; 3 | use std::iter::Peekable; 4 | 5 | use asn1_rs::{Any, DerSequence, FromDer, Sequence, Set}; 6 | use openssl::cms::CMSOptions; 7 | use openssl::md::MdRef; 8 | use std::str::Chars; 9 | 10 | use openssl::x509::store::X509StoreRef; 11 | use openssl::x509::X509; 12 | use rustc_hash::FxHashMap; 13 | 14 | use crate::errors::VerificationError; 15 | use zip::read::ZipFile; 16 | 17 | const DIGEST_KEY_SUFFIX: &'static str = "-Digest"; 18 | const DIGEST_MANIFEST_SUFFIX: &'static str = "-Digest-Manifest"; 19 | 20 | /// https://docs.oracle.com/en/java/javase/17/docs/specs/jar/jar.html#jar-manifest 21 | #[derive(Debug)] 22 | pub struct Manifest { 23 | file_name: String, 24 | digest_alg_name: Option, 25 | main_attribs: FxHashMap, 26 | name_digests: FxHashMap, 27 | } 28 | 29 | /// https://datatracker.ietf.org/doc/html/rfc5652 30 | #[derive(Debug, DerSequence)] 31 | pub struct ContentInfo<'a> { 32 | content_type: Any<'a>, 33 | #[tag_explicit(0)] 34 | signed_data: SignedData<'a>, 35 | } 36 | 37 | #[derive(Debug, DerSequence)] 38 | pub struct SignedData<'a> { 39 | version: i32, 40 | digest_algorithms: Set<'a>, 41 | encap_content_info: Sequence<'a>, 42 | #[tag_implicit(0)] 43 | #[optional] 44 | certificates: Option>, 45 | #[tag_implicit(1)] 46 | #[optional] 47 | crls: Option>, 48 | } 49 | 50 | impl Manifest { 51 | pub fn parse(file_name: &str, r: R) -> Result 52 | where 53 | R: Read, 54 | { 55 | let data = read_to_string(r)?; 56 | let mut buf = data.chars().peekable(); 57 | let mut main_attribs = FxHashMap::default(); 58 | let mut name_digests = FxHashMap::default(); 59 | 60 | let mut digest_alg_name = None; 61 | 62 | loop { 63 | let l = Manifest::read_line(&mut buf); 64 | if let None = l { 65 | break; 66 | } 67 | 68 | let kv = Manifest::get_key_val(&l); 69 | if let None = kv { 70 | continue; 71 | } 72 | 73 | let (k, v) = kv.unwrap(); 74 | if k == "Name" { 75 | loop { 76 | // until XXX-Digest key is found 77 | let next_line = Manifest::read_line(&mut buf); 78 | if let None = next_line { 79 | break; 80 | } 81 | let next_kv = Manifest::get_key_val(&next_line); 82 | if let None = next_kv { 83 | break; // a newline encountered and there is no XXX-Digest value for this Name 84 | } 85 | let (key, value) = next_kv.unwrap(); 86 | if key.ends_with(DIGEST_KEY_SUFFIX) { 87 | let alg = key.replace(DIGEST_KEY_SUFFIX, ""); 88 | let digest = value.trim().to_string(); 89 | let class_entry = v.trim().to_string(); 90 | name_digests.insert(class_entry, (alg, digest)); 91 | break; 92 | } 93 | } 94 | } else { 95 | if k.ends_with(DIGEST_MANIFEST_SUFFIX) { 96 | digest_alg_name = Some(k.replace(DIGEST_MANIFEST_SUFFIX, "")); 97 | } 98 | main_attribs.insert(k.to_string(), v.trim().to_string()); 99 | } 100 | } 101 | 102 | Ok(Manifest { 103 | file_name: file_name.to_string(), 104 | main_attribs, 105 | name_digests, 106 | digest_alg_name, 107 | }) 108 | } 109 | 110 | fn read_line(buf: &mut Peekable) -> Option { 111 | let mut line = String::with_capacity(128); 112 | let space = &' '; 113 | loop { 114 | let char = buf.next(); 115 | if let None = char { 116 | return None; 117 | } 118 | let char = char.unwrap(); 119 | match char { 120 | '\n' => { 121 | let next = buf.peek(); 122 | if let Some(c) = next { 123 | if c != space { 124 | break; 125 | } 126 | 127 | if c == space { 128 | buf.next(); // consume the space and continue 129 | } 130 | } 131 | } 132 | '\r' => { 133 | let next = buf.peek(); 134 | if let Some(c) = next { 135 | if c == &'\n' { 136 | continue; 137 | } 138 | 139 | if c != space { 140 | break; 141 | } 142 | 143 | if c == space { 144 | buf.next(); // consume the space and continue 145 | } 146 | } 147 | } 148 | _ => { 149 | line.push(char); 150 | } 151 | } 152 | } 153 | Some(line) 154 | } 155 | 156 | fn get_key_val(line: &Option) -> Option<(&str, &str)> { 157 | if let None = line { 158 | return None; 159 | } 160 | 161 | let line = line.as_ref().unwrap(); 162 | if line.is_empty() { 163 | return None; 164 | } 165 | 166 | let mut tokens = line.splitn(2, ":"); 167 | let k = tokens.next().or(Some("")); 168 | let v = tokens.next().or(Some("")); 169 | 170 | Some((k.unwrap(), v.unwrap())) 171 | } 172 | } 173 | 174 | pub fn verify_jar(file_path: &str, cert_store: &X509StoreRef) -> Result<(), VerificationError> { 175 | let f = File::open(file_path)?; 176 | let mut za = zip::ZipArchive::new(f)?; 177 | 178 | let mut signatures = Vec::new(); 179 | const META_INF_PREFIX_PATH: &'static str = "META-INF/"; 180 | const DOT_SF_SUFFIX: &'static str = ".SF"; 181 | const MC_SIGNATURE_FILE: &'static str = "META-INF/SERVER.SF"; 182 | 183 | { 184 | // give preference to the MC's signature file, if exists 185 | if let Ok(_) = za.by_name(MC_SIGNATURE_FILE) { 186 | signatures.push((MC_SIGNATURE_FILE.to_string(), "SERVER".to_string())); 187 | } 188 | } 189 | if signatures.is_empty() { 190 | for name in za.file_names() { 191 | //println!("{}", name); 192 | if name.starts_with(META_INF_PREFIX_PATH) && name.ends_with(DOT_SF_SUFFIX) { 193 | let sf_block_prefix = name 194 | .replace(META_INF_PREFIX_PATH, "") 195 | .replace(DOT_SF_SUFFIX, ""); 196 | signatures.push((name.to_string(), sf_block_prefix)); 197 | } 198 | } 199 | } 200 | 201 | //println!("{:?}", signatures); 202 | let manifest_buf; 203 | { 204 | let mut manifest_entry_file = za.by_name("META-INF/MANIFEST.MF")?; 205 | manifest_buf = read_file(&mut manifest_entry_file)?; 206 | } 207 | 208 | let manifest = Manifest::parse("MANIFEST.MF", manifest_buf.as_slice())?; 209 | //println!("{:?}", manifest); 210 | 211 | if signatures.is_empty() { 212 | return Err(VerificationError { 213 | cert: None, 214 | msg: format!("{} is not signed", file_path), 215 | }); 216 | } 217 | 218 | for (sf_name, sb_prefix) in signatures { 219 | let mut sigblock: Option<(&str, Vec)> = None; 220 | for suffix in ["RSA", "DSA", "EC"] { 221 | let entry = za.by_name(&format!("META-INF/{}.{}", sb_prefix, suffix)); 222 | if let Ok(mut entry) = entry { 223 | let entry = read_file(&mut entry)?; 224 | sigblock = Some((suffix, entry)); 225 | break; 226 | } 227 | } 228 | 229 | if let Some((_sig_alg_name, sigblock)) = sigblock { 230 | let sigmanifest_buf; 231 | { 232 | let mut sigmanifest_entry = za.by_name(&sf_name)?; 233 | sigmanifest_buf = read_file(&mut sigmanifest_entry)?; 234 | } 235 | let sigmanifest = Manifest::parse(&sf_name, sigmanifest_buf.as_slice())?; 236 | 237 | let sigblock = sigblock.as_slice(); 238 | let cert = extract_cert(sigblock)?; 239 | 240 | // https://docs.oracle.com/en/java/javase/20/docs/specs/man/jarsigner.html 241 | // #1 Verify the signature of the .SF file. 242 | println!("verifying {} of {}", sf_name, file_path); 243 | let mut cms_info = openssl::cms::CmsContentInfo::from_der(sigblock)?; 244 | let r = cms_info.verify( 245 | None, 246 | Some(cert_store), 247 | Some(sigmanifest_buf.as_slice()), 248 | None, 249 | CMSOptions::empty(), 250 | ); 251 | if let Err(e) = r { 252 | let msg = e.to_string(); 253 | println!("verification error: {}", msg); 254 | if !msg.contains("certificate purpose") { // could be <[unsupported|unsuitable] certificate purpose> 255 | // FIXME find a better way to tell OpenSSL to not check the certificate extensions 256 | if msg.contains("cms_signerinfo_verify_cert") { 257 | return Err(VerificationError { cert, msg }); 258 | } 259 | return Err(VerificationError { cert: None, msg }); 260 | } 261 | } 262 | 263 | // #2 Verify the digest listed in each entry in the .SF file with each corresponding section in the manifest. 264 | if let None = sigmanifest.digest_alg_name { 265 | return Err(VerificationError { 266 | cert: None, 267 | msg: String::from("missing XXX-Digest-Manifest attribute"), 268 | }); 269 | } 270 | 271 | let sig_digest_alg_name = sigmanifest.digest_alg_name.unwrap(); 272 | let key = format!("{}{}", sig_digest_alg_name, DIGEST_MANIFEST_SUFFIX); 273 | let sf_manifest_digest = sigmanifest.main_attribs.get(&key); 274 | if let None = sf_manifest_digest { 275 | return Err(VerificationError { 276 | cert: None, 277 | msg: format!("attribute {} not found in {}", key, sf_name), 278 | }); 279 | } 280 | let sf_manifest_digest = sf_manifest_digest.unwrap(); 281 | 282 | let digest_ref = get_digest_ref(&sig_digest_alg_name); 283 | if let None = digest_ref { 284 | return Err(VerificationError { 285 | cert: None, 286 | msg: format!("unsupported digest algorithm {}", sig_digest_alg_name), 287 | }); 288 | } 289 | let digest_ref = digest_ref.unwrap(); 290 | 291 | let mut computed_digest_output = [0; 32]; 292 | 293 | // verify that the digests are same 294 | let mut ctx = openssl::md_ctx::MdCtx::new().unwrap(); 295 | ctx.digest_init(digest_ref)?; 296 | ctx.digest_update(manifest_buf.as_slice())?; 297 | ctx.digest_final(&mut computed_digest_output)?; 298 | let computed_manifest_digest = openssl::base64::encode_block(&computed_digest_output); 299 | if &computed_manifest_digest != sf_manifest_digest { 300 | return Err(VerificationError { 301 | cert: None, 302 | msg: format!("mismatch in manifest digests of {}", file_path), 303 | }); 304 | } 305 | 306 | // #3 Read each file in the JAR file that has an entry in the .SF file. While reading, compute the file's digest and 307 | // compare the result with the digest for this file in the manifest section. The digests should be the same or verification fails. 308 | let mut buf: Vec = Vec::with_capacity(512); 309 | for (jar_entry_name, (jar_entry_digest_alg, _jar_entry_digest)) in 310 | &sigmanifest.name_digests 311 | { 312 | let mut ctx = openssl::md_ctx::MdCtx::new().unwrap(); 313 | ctx.digest_init(digest_ref)?; 314 | let f = za.by_name(jar_entry_name); 315 | if let Err(ref e) = f { 316 | println!( 317 | "entry {} not found in {} {}", 318 | jar_entry_name, 319 | file_path, 320 | e.to_string() 321 | ); 322 | continue; 323 | } 324 | let mut f = f.unwrap(); 325 | if f.is_dir() { 326 | println!( 327 | "entry {} of {} is a directory, skipping digest check", 328 | jar_entry_name, file_path 329 | ); 330 | continue; 331 | } 332 | f.read_to_end(&mut buf)?; 333 | ctx.digest_update(buf.as_slice())?; 334 | ctx.digest_final(&mut computed_digest_output)?; 335 | 336 | let computed_digest = openssl::base64::encode_block(&computed_digest_output); 337 | let (_m_alg, m_digest) = manifest 338 | .name_digests 339 | .get(jar_entry_name) 340 | .expect("missing MANIFEST entry"); // safe to unwrap 341 | //println!("comparing digests [{} === {}] for {}", m_digest, computed_digest, jar_entry_name); 342 | if m_digest != &computed_digest { 343 | let msg = format!( 344 | "{} digest mismatch(manifest={} != computed={}) for {} in {}", 345 | jar_entry_digest_alg, m_digest, computed_digest, jar_entry_name, file_path 346 | ); 347 | return Err(VerificationError { cert: None, msg }); 348 | } 349 | buf.clear(); 350 | } 351 | println!("verified"); 352 | } 353 | } 354 | Ok(()) 355 | } 356 | 357 | fn get_digest_ref(name: &str) -> Option<&MdRef> { 358 | use openssl::md::Md; 359 | match name { 360 | "SHA-256" => Some(Md::sha256()), 361 | "SHA-384" => Some(Md::sha384()), 362 | "SHA-512" => Some(Md::sha512()), 363 | _ => None, 364 | } 365 | } 366 | fn extract_cert(sigblock: &[u8]) -> Result, anyhow::Error> { 367 | let (_, ci) = ContentInfo::from_der(sigblock).unwrap(); 368 | //println!("{:?}", ci); 369 | if let Some(cert_set) = ci.signed_data.certificates { 370 | let cert = X509::from_der(cert_set.content.as_ref())?; 371 | return Ok(Some(cert)); 372 | } 373 | Ok(None) 374 | } 375 | 376 | fn read_file(zf: &mut ZipFile) -> Result, anyhow::Error> { 377 | let mut buf = Vec::with_capacity(512); 378 | zf.read_to_end(&mut buf)?; 379 | Ok(buf) 380 | } 381 | 382 | #[cfg(test)] 383 | mod tests { 384 | use openssl::ssl::SslFiletype; 385 | use openssl::x509::store::{HashDir, X509Lookup, X509StoreBuilder}; 386 | use std::collections::VecDeque; 387 | // use asn1::{ParseResult, SimpleAsn1Writable, WriteBuf, Writer}; 388 | use super::*; 389 | 390 | #[test] 391 | pub fn test_parse_manifest() { 392 | let file_name = "MANIFEST.MF"; 393 | let f = File::open("test-resources/MANIFEST.MF").unwrap(); 394 | let m = Manifest::parse(file_name, f).expect("failed to parse the manifest file"); 395 | assert_eq!(file_name, m.file_name); 396 | assert_eq!(None, m.digest_alg_name); 397 | 398 | let mut main_attribs = FxHashMap::default(); 399 | main_attribs.insert("Created-By", "Apache Maven 3.6.0"); 400 | main_attribs.insert("Application-Name", "Catapult Test Jar"); 401 | main_attribs.insert("Build-Jdk", "1.8.0_352"); 402 | main_attribs.insert("Built-By", "dbugger"); 403 | main_attribs.insert("url", ""); 404 | main_attribs.insert("authors", "Sereen Systems: Kiran Ayyagari"); 405 | main_attribs.insert("Manifest-Version", "1.0"); 406 | 407 | for (k, v) in main_attribs { 408 | assert_eq!(Some(&String::from(v)), m.main_attribs.get(k)); 409 | } 410 | 411 | let mut name_digests = FxHashMap::default(); 412 | name_digests.insert( 413 | "META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.xml", 414 | ("SHA-256", "hYrjJTvk33E2hMAm3jQFv94npqhurT1xC/89tZnhrpM="), 415 | ); 416 | name_digests.insert( 417 | "log4j.properties", 418 | ("SHA-256", "qDNFTmmOPAopORClhI9oAJiLlPQLgoBBmz2MTWVTq34="), 419 | ); 420 | name_digests.insert( 421 | "META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.properties", 422 | ("SHA-256", "EuvP5v5Pd2IOFjVJhMixzxIKy2baBE6a+hOWhtAyA/s="), 423 | ); 424 | name_digests.insert( 425 | "com/sereen/catapult/App.class", 426 | ("SHA-256", "YD7chnl2dQvq+IPXfOPOw/82gctW0ZDXrqlVTprcPIs="), 427 | ); 428 | 429 | for (k, (alg, digest)) in name_digests { 430 | assert_eq!( 431 | Some(&(String::from(alg), String::from(digest))), 432 | m.name_digests.get(k) 433 | ); 434 | } 435 | } 436 | 437 | #[test] 438 | pub fn test_parse_signature_file() { 439 | let file_name = "RSA.SF"; 440 | let f = File::open("test-resources/RSA.SF").unwrap(); 441 | let m = Manifest::parse(file_name, f).expect("failed to parse the signature file"); 442 | assert_eq!(file_name, m.file_name); 443 | assert_eq!(Some(String::from("SHA-256")), m.digest_alg_name); 444 | 445 | let mut main_attribs = FxHashMap::default(); 446 | main_attribs.insert("Signature-Version", "1.0"); 447 | main_attribs.insert( 448 | "SHA-256-Digest-Manifest-Main-Attributes", 449 | "SrvXwDOQW2uH7eiPwlfR+ZwyjWW9AbEfM7dU3f4rDKo=", 450 | ); 451 | main_attribs.insert( 452 | "SHA-256-Digest-Manifest", 453 | "VncmygtfITJAO9mhhNipU9kWkFhAMqFErwtkfZsGXBc=", 454 | ); 455 | main_attribs.insert("Created-By", "1.8.0_352 (Azul Systems, Inc.)"); 456 | 457 | for (k, v) in main_attribs { 458 | assert_eq!(Some(&String::from(v)), m.main_attribs.get(k)); 459 | } 460 | 461 | let mut name_digests = FxHashMap::default(); 462 | name_digests.insert( 463 | "META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.xml", 464 | ("SHA-256", "GUlGP/Ve5YYCc4jxXqE5XHpWLeLJshKzu2k8m9ulumE="), 465 | ); 466 | name_digests.insert( 467 | "log4j.properties", 468 | ("SHA-256", "WZrTZ8yDNvEiIP9ZT1eLvyzRwwvQayYN5m8SY9QKQ4Q="), 469 | ); 470 | name_digests.insert( 471 | "META-INF/maven/com.sereen.catapult/catapult-test-jar/pom.properties", 472 | ("SHA-256", "lEBFiKk6dpR0QEag30N+lOIQKOnGT17wKb8e/YNbWv4="), 473 | ); 474 | name_digests.insert( 475 | "com/sereen/catapult/App.class", 476 | ("SHA-256", "MGAQ6snGyZKVKzAcSfzmq6+4KnwYK3lXBHl25PRKPMU="), 477 | ); 478 | 479 | for (k, (alg, digest)) in name_digests { 480 | assert_eq!( 481 | Some(&(String::from(alg), String::from(digest))), 482 | m.name_digests.get(k) 483 | ); 484 | } 485 | } 486 | 487 | #[test] 488 | pub fn test_parse_content_info() { 489 | let mut f = File::open("test-resources/RSA.RSA").unwrap(); 490 | let mut buf = Vec::with_capacity(512); 491 | let r = f.read_to_end(&mut buf).unwrap(); 492 | let (_, ci) = ContentInfo::from_der(buf.as_slice()).unwrap(); 493 | println!("{:?}", ci); 494 | let cert = X509::from_der(ci.signed_data.certificates.unwrap().content.as_ref()); 495 | println!("{:?}", cert); 496 | } 497 | 498 | #[test] 499 | pub fn test_verify() { 500 | let jar_file = "test-resources/valid-signed.jar"; 501 | let mut xb = X509StoreBuilder::new().unwrap(); 502 | let store = xb.build(); 503 | let r = verify_jar(jar_file, store.as_ref()); 504 | println!("{:?}", r); 505 | assert!(r.is_err()); 506 | let ve = r.err().unwrap(); 507 | println!("{}", ve.to_json()); 508 | let cert = ve.cert.unwrap(); 509 | let mut xb = X509StoreBuilder::new().unwrap(); 510 | xb.add_cert(cert).unwrap(); 511 | let store = xb.build(); 512 | let r = verify_jar(jar_file, store.as_ref()); 513 | println!("{:?}", r); 514 | assert!(r.is_ok()); 515 | } 516 | 517 | #[test] 518 | fn test_verify_failures() { 519 | let files = [ 520 | "test-resources/tampered-app-class.jar", 521 | "test-resources/tampered-sf.jar", 522 | ]; 523 | let mut xb = X509StoreBuilder::new().unwrap(); 524 | let store = xb.build(); 525 | for f in files { 526 | let r = verify_jar(f, store.as_ref()); 527 | assert!(r.is_err()); 528 | } 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from "react"; 2 | import { invoke } from "@tauri-apps/api/core"; 3 | import { open, confirm } from '@tauri-apps/plugin-dialog'; 4 | import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; 5 | import "./App.css"; 6 | import './CertDialog' 7 | import { 8 | Avatar, 9 | Button, 10 | Col, 11 | Divider, 12 | Input, 13 | Layout, 14 | List, 15 | Menu, 16 | MenuProps, 17 | Row, 18 | theme, 19 | Modal, 20 | Checkbox, Spin, notification, Tree, Select, Space, InputRef, ConfigProvider 21 | } from "antd"; 22 | import type { DataNode } from 'antd/es/tree'; 23 | import { 24 | ApiOutlined, 25 | CarryOutOutlined, 26 | EyeInvisibleOutlined, 27 | EyeTwoTone, 28 | PlusOutlined, 29 | SettingOutlined 30 | } from "@ant-design/icons"; 31 | import CertDialog, {UntrustedCert} from "./CertDialog"; 32 | import {NotificationPlacement} from "antd/es/notification/interface"; 33 | import { 34 | Connection, 35 | connectionSorter, 36 | DEFAULT_GROUP_NAME, 37 | loadConnections, 38 | orderConnections, 39 | searchText 40 | } from './connection'; 41 | import Search from "antd/es/input/Search"; 42 | import {BallistaInfo, requestBallistaInfo} from "./ballistaInfo"; 43 | import TextArea from "antd/es/input/TextArea"; 44 | const appWindow = getCurrentWebviewWindow() 45 | 46 | const { Content, Sider } = Layout; 47 | 48 | type MenuItem = Required['items'][number]; 49 | 50 | function getItem( 51 | label: React.ReactNode, 52 | key: React.Key, 53 | icon?: React.ReactNode, 54 | children?: MenuItem[], 55 | ): MenuItem { 56 | return { 57 | key, 58 | icon, 59 | children, 60 | label, 61 | } as MenuItem; 62 | } 63 | 64 | const items: MenuItem[] = [ 65 | getItem('', 'settings', , [ 66 | getItem('Import', 'import')]) 67 | ]; 68 | 69 | const Context = React.createContext({ name: 'Default' }); 70 | 71 | function App() { 72 | const { 73 | token: { colorBgContainer }, 74 | } = theme.useToken(); 75 | 76 | const { defaultAlgorithm, darkAlgorithm } = theme; 77 | const [isDarkMode, setIsDarkMode] = useState(false); 78 | appWindow.theme().then(t => setIsDarkMode(t == 'dark')); 79 | appWindow.onThemeChanged(({ payload: theme }) => { 80 | setIsDarkMode(theme == 'dark'); 81 | }); 82 | 83 | const [api, contextHolder] = notification.useNotification(); 84 | const openNotification = (placement: NotificationPlacement, msg: string) => { 85 | api.info({ 86 | message: `Error`, 87 | description: {({ name }) => `${msg}`}, 88 | placement 89 | }); 90 | }; 91 | const [data, setData] = useState([]); 92 | 93 | const [treeData, setTreeData] = useState([]); 94 | const [expandedKeys, setExpandedKeys] = useState([]); 95 | const [autoExpandParent, setAutoExpandParent] = useState(false); 96 | const [searchVal, setSearchVal] = useState(""); 97 | const [selectedTreeNodeKey, setSelectedTreeNodeKey] = useState([]); 98 | 99 | const [newGroupName, setNewGroupName] = useState(""); 100 | const [groupNames, setGroupNames] = useState([DEFAULT_GROUP_NAME]); 101 | const groupInputRef = useRef(null); // for the group name selection 102 | 103 | const [ballistaInfo, setBallistaInfo] = useState(); 104 | 105 | const emptyConnection: Connection = { 106 | address: "", 107 | heapSize: "", 108 | icon: "", 109 | id: "", 110 | javaHome: "", 111 | name: "", 112 | username: "", 113 | password: "", 114 | verify: true, 115 | group: "Default", 116 | donotcache: false, 117 | notes: "", 118 | nodeId: "", 119 | parentId: "" 120 | }; 121 | 122 | const [cc, setCc] = useState({...emptyConnection}); 123 | 124 | const [dirty, setDirty] = useState(false); 125 | 126 | const [cert, setCert] = useState({ 127 | der: undefined, 128 | subject: undefined, 129 | issuer: undefined, 130 | expires_on: undefined 131 | }); 132 | const [loading, setLoading] = useState(false); 133 | 134 | useEffect(() => {requestBallistaInfo().then(data => { 135 | setBallistaInfo(data) 136 | appWindow.setTitle(`Ballista - ${data.ballista_version}`) 137 | })}, []) 138 | 139 | useEffect(() => {loadConnections().then(d => { 140 | setData(d); 141 | createTreeNodes(d); 142 | if(d.length > 0) { 143 | // also gather the group names for once 144 | let titles = new Array(); 145 | for(let i = 0; i < d.length; i++) { 146 | let g = d[i].group; 147 | if(titles.indexOf(g) == -1) { 148 | titles.push(g); 149 | } 150 | } 151 | titles.sort(); 152 | setGroupNames(titles); 153 | } 154 | })}, []) 155 | async function importConnections(e: any) { 156 | const selected = await open({ 157 | multiple: false, 158 | filters: [{ 159 | name: '', 160 | extensions: ['json'] 161 | }] 162 | }); 163 | if (selected !== null) { 164 | await invoke("import", {file_path: selected}); 165 | loadConnections().then(d => { 166 | setData(d); 167 | createTreeNodes(d); 168 | }) 169 | } 170 | } 171 | 172 | async function launch() { 173 | setLoading(true); 174 | try { 175 | let resp: string = await invoke("launch", { id: cc.id }); 176 | let result: any = JSON.parse(resp); 177 | if(result.code == 1) { 178 | setCert(result.cert); 179 | } 180 | setLoading(false); 181 | if(result.code == -1) { 182 | openNotification('topRight', result.msg); 183 | } 184 | } 185 | catch (e) { 186 | setLoading(false); 187 | } 188 | } 189 | 190 | async function trustAndLaunch() { 191 | await invoke("trust_cert", { cert: cert.der }); 192 | setCert({}); 193 | launch(); 194 | } 195 | function abortLaunch() { 196 | setCert({}); 197 | } 198 | function createNew() { 199 | setCc({...emptyConnection}) 200 | setSelectedTreeNodeKey([]); 201 | setDirty(false); 202 | } 203 | 204 | function createTreeNodes(d: Connection[]) { 205 | let tobeExpanded: string[] = []; 206 | if(d.length == 0) { 207 | setTreeData([]); 208 | return tobeExpanded; 209 | } 210 | let orderedConMap = orderConnections(d); 211 | let nodes: DataNode[] = []; 212 | for(let i = 0; i < orderedConMap.groupNames.length; i++) { 213 | let name = orderedConMap.groupNames[i]; 214 | let groupedCons = orderedConMap.groupConnMap[name]; 215 | let conNodes: DataNode[] = []; 216 | let parentId = i.toString(); 217 | tobeExpanded.push(parentId + "-0"); 218 | for(let j = 0; j < groupedCons.length; j++) { 219 | let c = groupedCons[j]; 220 | c.parentId = parentId; 221 | let nodeId = parentId + "-" + j.toString(); 222 | c.nodeId = nodeId; 223 | let node = { 224 | title: c.name, 225 | key: nodeId, 226 | con: c, 227 | icon: 228 | }; 229 | conNodes.push(node); 230 | } 231 | 232 | let groupNode = { 233 | title: name, 234 | key: parentId, 235 | icon: , 236 | children: conNodes 237 | }; 238 | nodes.push(groupNode); 239 | } 240 | setTreeData(nodes); 241 | if(nodes.length > 0) { 242 | let firstParent = nodes[0]; 243 | if(firstParent.children && firstParent.children.length > 0) { 244 | let child = firstParent.children[0]; 245 | let selectedKeys = [child.key]; 246 | setSelectedTreeNodeKey(selectedKeys); 247 | setExpandedKeys(selectedKeys); 248 | setAutoExpandParent(true); 249 | onTreeNodeSelect(selectedKeys, {node: child}); 250 | } 251 | } 252 | return tobeExpanded; 253 | } 254 | 255 | const handleMenuClick = ({ key, domEvent }: any) => { 256 | if (key == 'import') { 257 | importConnections(domEvent); 258 | } 259 | }; 260 | 261 | function updateName(e: any) { 262 | setCc({ 263 | ...cc, 264 | name: e.target.value 265 | }) 266 | setDirty(true); 267 | } 268 | 269 | function updateUrl(e: any) { 270 | setCc({ 271 | ...cc, 272 | address: e.target.value 273 | }) 274 | setDirty(true); 275 | } 276 | function updateUsername(e: any) { 277 | setCc({ 278 | ...cc, 279 | username : e.target.value 280 | }) 281 | setDirty(true); 282 | } 283 | 284 | function updatePassword(e: any) { 285 | setCc({ 286 | ...cc, 287 | password: e.target.value 288 | }) 289 | setDirty(true); 290 | } 291 | function updateJavaHome(e: any) { 292 | setCc({ 293 | ...cc, 294 | javaHome: e.target.value 295 | }) 296 | setDirty(true); 297 | } 298 | 299 | function updateHeapSize(e: any) { 300 | setCc({ 301 | ...cc, 302 | heapSize: e.target.value 303 | }) 304 | setDirty(true); 305 | } 306 | 307 | function updateVerify(e: any) { 308 | setCc({ 309 | ...cc, 310 | verify: e.target.checked 311 | }) 312 | setDirty(true); 313 | } 314 | 315 | function updateDonotcache(e: any) { 316 | setCc({ 317 | ...cc, 318 | donotcache: e.target.checked 319 | }) 320 | setDirty(true); 321 | } 322 | 323 | function updateNotes(e: any) { 324 | setCc({ 325 | ...cc, 326 | notes: e.target.value 327 | }) 328 | setDirty(true); 329 | } 330 | 331 | function updateGroup(name: string) { 332 | setCc({ 333 | ...cc, 334 | group: name 335 | }) 336 | setDirty(true); 337 | } 338 | async function deleteConnection() { 339 | const confirmed = await confirm('Do you want to delete connection ' + cc.name + '?', { title: '', kind: 'warning' }); 340 | if(confirmed) { 341 | const resp = await invoke("delete", {id: cc.id}); 342 | if(resp == "success") { 343 | let i = -1; 344 | let pos = -1; 345 | let tmp = data.filter(c => { 346 | i++; 347 | if(c.id == cc.id) { 348 | pos = i; 349 | } 350 | return c.id !== cc.id; 351 | }); 352 | setData(tmp); 353 | // it is easier to search again rather than updating the tree 354 | // this is clearly inefficient and needs to be fixed 355 | searchConnections(searchVal, tmp); 356 | } 357 | } 358 | } 359 | 360 | async function saveConnection() { 361 | if(cc.group.trim().length == 0) { 362 | cc.group = DEFAULT_GROUP_NAME; 363 | } 364 | 365 | let saveResult: string = await invoke("save", {ce: JSON.stringify(cc)}); 366 | try { 367 | let savedCon = JSON.parse(saveResult); 368 | setDirty(false); 369 | let tmp = data.filter(c => c.id !== savedCon.id); 370 | tmp.push(savedCon); 371 | setData(tmp); 372 | createTreeNodes(tmp); 373 | let selectedKey = [savedCon.nodeId]; 374 | setSelectedTreeNodeKey(selectedKey); 375 | setExpandedKeys(selectedKey); 376 | setAutoExpandParent(true); 377 | setCc({...savedCon}); 378 | } 379 | catch(e) { 380 | //TODO handle it 381 | } 382 | } 383 | 384 | const onTreeNodeSelect = async (selectedKeys: React.Key[], info: any) => { 385 | if(dirty) { 386 | setDirty(false); 387 | } 388 | setSelectedTreeNodeKey(selectedKeys); 389 | if(info.node.con) { 390 | setCc(info.node.con); 391 | } 392 | }; 393 | 394 | const setValAndSearch = (e: React.ChangeEvent) => { 395 | let {value} = e.target; 396 | value = value.trim(); 397 | setSearchVal(value); 398 | searchConnections(value, data); 399 | } 400 | 401 | const searchConnections = (value: string, connections: Connection[]) => { 402 | console.log("search value " + searchVal); 403 | if(value.length == 0) { 404 | createTreeNodes(connections); 405 | return; 406 | } 407 | if(value.length < 2) { 408 | return; 409 | } 410 | let filteredCons = connections.filter((c) => searchText(value, c)); 411 | let tobeExpanded: string[] = createTreeNodes(filteredCons); 412 | if(tobeExpanded.length > 0) { 413 | setExpandedKeys(tobeExpanded as React.Key[]); 414 | setAutoExpandParent(true); 415 | } 416 | else { 417 | setExpandedKeys([]); 418 | setAutoExpandParent(false); 419 | } 420 | }; 421 | 422 | const updateNewGroupName = (event: React.ChangeEvent) => { 423 | setNewGroupName(event.target.value); 424 | }; 425 | const addNewGroup = (e: React.MouseEvent) => { 426 | e.preventDefault(); 427 | let tmp = newGroupName.trim().toLowerCase(); 428 | if(tmp.length == 0) { 429 | return; 430 | } 431 | let exists = false; 432 | for(let i = 0; i < groupNames.length; i++) { 433 | if(groupNames[i].toLowerCase() == tmp) { 434 | exists = true; 435 | } 436 | } 437 | if(!exists) { 438 | let tmp = [...groupNames, newGroupName] 439 | tmp.sort(); 440 | setGroupNames(tmp); 441 | } 442 | setNewGroupName(""); 443 | setTimeout(() => { 444 | groupInputRef.current?.focus(); 445 | }, 0); 446 | }; 447 | 448 | const onExpand = (newExpandedKeys: React.Key[]) => { 449 | setExpandedKeys(newExpandedKeys); 450 | setAutoExpandParent(false); 451 | }; 452 | 453 | return ( 454 | 455 | 456 | {contextHolder} 457 | 458 | 459 | 460 |
461 | 462 | 471 |
472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | {/*
*/} 482 | 483 |
484 | 485 | Name: 486 | 487 | 488 | 489 | 490 | 491 | URL: 492 | 493 | 494 | 495 | 496 | 497 | 498 | Username: 499 | 500 | 501 | 502 | 503 | 504 | Password: 505 | 506 | (visible ? : )} 508 | onChange={updatePassword} /> 509 | 510 | 511 | 512 | Java Home: 513 | 514 | 515 | 516 | 517 | 518 | Max Memory: 519 | 520 | 521 | 522 | 523 | Verify JAR files 524 | 525 | 526 | 527 | Group: 528 | 529 | 545 |