From eb612a583dae0dbbf28fb322d5a414d57f3bc140 Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Sun, 9 Aug 2026 14:24:20 +0530 Subject: [PATCH 1/5] perf: stream plugin archive extraction natively --- src/lib/installPlugin.js | 277 +++--------------- .../pluginContext/src/android/Tee.java | 193 ++++++++++++ 2 files changed, 241 insertions(+), 229 deletions(-) diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index 9dd31ab78..cf933844a 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -8,7 +8,6 @@ import helpers from "utils/helpers"; import Url from "utils/Url"; import { isVersionGreater } from "utils/version"; import config from "./config"; -import InstallState from "./installState"; import { loadPluginWithTimeout } from "./loadPlugins"; /** @type {import("dialogs/loader").Loader} */ @@ -16,6 +15,24 @@ let loaderDialog; /** @type {Array<() => Promise>} */ let depsLoaders; +const PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function assertSafePluginId(id) { + if (!PLUGIN_ID_PATTERN.test(String(id || ""))) { + throw new Error("Invalid plugin id"); + } +} + +function extractPluginArchive(archiveUrl, pluginDir, manifest) { + return new Promise((resolve, reject) => { + cordova.exec(resolve, reject, "Tee", "extractPluginArchive", [ + archiveUrl, + pluginDir, + manifest, + ]); + }); +} + /** * Installs a plugin. * @param {string} id @@ -38,7 +55,9 @@ export default async function installPlugin( let pluginDir; let pluginUrl; - let state; + let archiveUrl; + let pluginWasInstalled = false; + let extractionComplete = false; try { if (!(await fsOperation(PLUGIN_DIR).exists())) { @@ -164,90 +183,22 @@ export default async function installPlugin( if (!pluginDir) { pluginJson.source = pluginUrl; id = pluginJson.id; - pluginDir = Url.join(PLUGIN_DIR, id); - } - - state = await InstallState.new(id); - - if (!(await fsOperation(pluginDir).exists())) { - await fsOperation(PLUGIN_DIR).createDirectory(id); - } - - // Track unsafe absolute entries to skip - const ignoredUnsafeEntries = new Set(); - - const files = Object.keys(zip.files); - const limit = 2; - - async function processFile(file) { - try { - const entry = zip.files[file]; - - let correctFile = file.replace(/\\/g, "/"); - const isDirEntry = entry.dir || correctFile.endsWith("/"); - - if (isUnsafeAbsolutePath(file)) { - ignoredUnsafeEntries.add(file); - return; - } - - correctFile = sanitizeZipPath(correctFile, isDirEntry); - if (!correctFile) return; - - const fileUrl = Url.join(pluginDir, correctFile); - - // Handle directory entries - if (isDirEntry) { - await createFileRecursive(pluginDir, correctFile, true); - return; - } - - // Ensure parent directory exists - const lastSlash = correctFile.lastIndexOf("/"); - if (lastSlash !== -1) { - const parentRel = correctFile.slice(0, lastSlash + 1); - await createFileRecursive(pluginDir, parentRel, true); - } - - if (!state.exists(correctFile)) { - await createFileRecursive(pluginDir, correctFile, false); - } - - let data = await entry.async("ArrayBuffer"); - - if (file === "plugin.json") { - data = JSON.stringify(pluginJson); - } - - if (!(await state.isUpdated(correctFile, data))) return; - - await fsOperation(fileUrl).writeFile(data); - } catch (error) { - console.error(`Error processing file ${file}:`, error); - } } - // Process in batches - for (let i = 0; i < files.length; i += limit) { - const batch = files.slice(i, i + limit); - await Promise.allSettled(batch.map(processFile)); - - // Allow UI thread to breathe - await new Promise((r) => setTimeout(r, 0)); - } - // Emit a non-blocking warning if any unsafe entries were skipped - if (!isDependency && ignoredUnsafeEntries.size) { - const sample = Array.from(ignoredUnsafeEntries).slice(0, 3).join(", "); - loaderDialog.setMessage( - `Skipped ${ignoredUnsafeEntries.size} unsafe archive entr${ - ignoredUnsafeEntries.size === 1 ? "y" : "ies" - } (e.g., ${sample})`, - ); - console.warn( - "Plugin installer: skipped unsafe absolute paths in archive:", - Array.from(ignoredUnsafeEntries), - ); - } + assertSafePluginId(id); + pluginDir = Url.join(PLUGIN_DIR, id); + pluginWasInstalled = await fsOperation(pluginDir).exists(); + archiveUrl = Url.join( + CACHE_STORAGE, + `.plugin-install-${helpers.uuid()}.zip`, + ); + await fsOperation(CACHE_STORAGE).createFile( + Url.basename(archiveUrl), + plugin, + ); + loaderDialog?.setMessage("Extracting plugin files..."); + await extractPluginArchive(archiveUrl, pluginDir, JSON.stringify(pluginJson)); + extractionComplete = true; if (isDependency) { depsLoaders.push(async () => { @@ -260,16 +211,17 @@ export default async function installPlugin( await loadPluginWithTimeout(id, true); } - await state.save(); - deleteRedundantFiles(pluginDir, state); } } catch (err) { try { - // Clear the install state if installation fails - if (state) await state.clear(); - - // Delete the plugin directory if it was created - if (pluginDir && (await fsOperation(pluginDir).exists())) { + // A failed extraction leaves the previous plugin untouched. If a brand + // new plugin fails after activation, remove that incomplete install. + if ( + extractionComplete && + !pluginWasInstalled && + pluginDir && + (await fsOperation(pluginDir).exists()) + ) { await fsOperation(pluginDir).delete(); } } catch (cleanupError) { @@ -277,116 +229,15 @@ export default async function installPlugin( } throw err; } finally { - if (!isDependency) { - loaderDialog.destroy(); - } - } -} - -/** - * Create directory recursively - * @param {string} parent - * @param {Array | string} dir - */ -async function createFileRecursive(parent, dir, shouldBeDirAtEnd) { - let wantDirEnd = !!shouldBeDirAtEnd; - /** @type {string[]} */ - let parts; - if (typeof dir === "string") { - if (dir.endsWith("/")) wantDirEnd = true; - dir = dir.replace(/\\/g, "/"); - parts = dir.split("/"); - } else { - parts = dir; - } - parts = parts.filter((d) => d); - const cd = parts.shift(); - if (!cd) return; - const newParent = Url.join(parent, cd); - - const isLast = parts.length === 0; - const needDir = !isLast || wantDirEnd; - if (!(await fsOperation(newParent).exists())) { - if (needDir) { - try { - await fsOperation(parent).createDirectory(cd); - } catch (e) { - // If another concurrent task created it, consider it fine - if (!(await fsOperation(newParent).exists())) throw e; - } - } else { + if (archiveUrl) { try { - await fsOperation(parent).createFile(cd); - } catch (e) { - if (!(await fsOperation(newParent).exists())) throw e; - } + await fsOperation(archiveUrl).delete(); + } catch (_) {} } - } - if (parts.length) { - await createFileRecursive(newParent, parts, wantDirEnd); - } -} - -/** - * Sanitize zip entry path to ensure it's relative and safe under pluginDir - * - Normalizes separators to '/' - * - Strips leading slashes and Windows drive prefixes (e.g., C:/) - * - Resolves '.' and '..' segments - * - Preserves trailing slash for directory entries - * @param {string} p - * @param {boolean} isDir - * @returns {string} sanitized relative path - */ -function sanitizeZipPath(p, isDir) { - if (!p) return ""; - let path = String(p); - // Normalize separators - path = path.replace(/\\/g, "/"); - // Remove URL-like scheme if present accidentally - path = path.replace(/^[a-zA-Z]+:\/\//, ""); - // Strip leading slashes - path = path.replace(/^\/+/, ""); - // Strip Windows drive letter, e.g., C:/ - path = path.replace(/^[A-Za-z]:\//, ""); - - const parts = path.split("/"); - const stack = []; - for (const part of parts) { - if (!part || part === ".") continue; - if (part === "..") { - if (stack.length) stack.pop(); - continue; + if (!isDependency) { + loaderDialog.destroy(); } - stack.push(part); - } - let safe = stack.join("/"); - if (isDir && safe && !safe.endsWith("/")) safe += "/"; - return safe; -} - -/** - * Detects unsafe absolute paths in zip entries that should be ignored. - * Treats leading '/' as absolute, Windows drive roots like 'C:/' as absolute, - * and common Android/Linux device roots like '/data', '/root', '/system'. - * @param {string} p - */ -function isUnsafeAbsolutePath(p) { - if (!p) return false; - const s = String(p); - if (/^[A-Za-z]:[\\\/]/.test(s)) return true; // Windows drive root - if (s.startsWith("//")) return true; // network path - if (s.startsWith("/")) { - return ( - s.startsWith("/data") || - s.startsWith("/system") || - s.startsWith("/vendor") || - s.startsWith("/storage") || - s.startsWith("/sdcard") || - s.startsWith("/root") || - true // any leading slash is unsafe - ); } - return false; } /** @@ -495,35 +346,3 @@ async function resolveDep(manifest) { return purchase; } } - -/** - * - * @param {string} dir - * @param {Array} files - */ -async function listFileRecursive(dir, files) { - for (const child of await fsOperation(dir).lsDir()) { - const fileUrl = Url.join(dir, child.name); - if (child.isDirectory) { - await listFileRecursive(fileUrl, files); - } else { - files.push(fileUrl); - } - } -} - -/** - * - * @param {Record} files - */ -async function deleteRedundantFiles(pluginDir, state) { - /** @type {string[]} */ - let files = []; - await listFileRecursive(pluginDir, files); - - for (const file of files) { - if (!state.exists(file.replace(`${pluginDir}/`, ""))) { - fsOperation(file).delete(); - } - } -} diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 39edb423c..27d651e37 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -15,13 +15,29 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import android.content.Context; +import android.net.Uri; import org.apache.cordova.*; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + //auth plugin import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; public class Tee extends CordovaPlugin { + private static final int MAX_ARCHIVE_ENTRIES = 4096; + private static final long MAX_ARCHIVE_BYTES = 100L * 1024 * 1024; + private static final long MAX_ENTRY_BYTES = 32L * 1024 * 1024; + private static final int BUFFER_SIZE = 32 * 1024; + // pluginId : token private /*static*/ final Map tokenStore = new ConcurrentHashMap<>(); @@ -45,6 +61,11 @@ public void initialize(CordovaInterface cordova, CordovaWebView webView) { public boolean execute(String action, JSONArray args, CallbackContext callback) throws JSONException { + if ("extractPluginArchive".equals(action)) { + extractPluginArchive(args.getString(0), args.getString(1), args.getString(2), callback); + return true; + } + if ("get_secret".equals(action)) { String token = args.getString(0); @@ -126,6 +147,178 @@ public boolean execute(String action, JSONArray args, CallbackContext callback) return false; } + /** + * Extract a downloaded archive in native code. The previous plugin is kept + * in place until all archive entries have been streamed to a sibling + * staging directory and the directory swap succeeds. + */ + private void extractPluginArchive( + final String archiveUri, + final String destinationUri, + final String manifest, + final CallbackContext callback + ) { + cordova.getThreadPool().execute(new Runnable() { + @Override + public void run() { + File staging = null; + File backup = null; + File destination = null; + try { + File archive = webView.getResourceApi().mapUriToFile(Uri.parse(archiveUri)); + destination = webView.getResourceApi().mapUriToFile(Uri.parse(destinationUri)); + if (archive == null || !archive.isFile()) { + throw new IOException("Plugin archive is unavailable"); + } + if (destination == null || destination.getParentFile() == null) { + throw new IOException("Plugin destination is unavailable"); + } + + File parent = destination.getParentFile().getCanonicalFile(); + destination = destination.getCanonicalFile(); + if (!destination.getParentFile().equals(parent)) { + throw new IOException("Invalid plugin destination"); + } + if (!parent.exists() && !parent.mkdirs()) { + throw new IOException("Unable to create plugin directory"); + } + + staging = new File( + parent, + "." + destination.getName() + ".install-" + UUID.randomUUID() + ); + if (!staging.mkdirs()) { + throw new IOException("Unable to create plugin staging directory"); + } + + extractArchive(archive, staging); + writeManifest(staging, manifest); + + if (destination.exists()) { + backup = new File( + parent, + "." + destination.getName() + ".backup-" + UUID.randomUUID() + ); + if (!destination.renameTo(backup)) { + throw new IOException("Unable to stage existing plugin"); + } + } + + if (!staging.renameTo(destination)) { + if (backup != null && backup.exists()) { + backup.renameTo(destination); + } + throw new IOException("Unable to activate plugin"); + } + staging = null; + + if (backup != null) { + deleteRecursively(backup); + } + callback.success(); + } catch (Exception error) { + callback.error(error.getMessage() == null ? "Plugin extraction failed" : error.getMessage()); + } finally { + if (staging != null) { + deleteRecursively(staging); + } + if (backup != null && backup.exists() && destination != null && !destination.exists()) { + backup.renameTo(destination); + } + } + } + }); + } + + private static void extractArchive(File archive, File destination) throws IOException { + String destinationPath = destination.getCanonicalPath() + File.separator; + int entryCount = 0; + long extractedBytes = 0; + boolean hasManifest = false; + byte[] buffer = new byte[BUFFER_SIZE]; + + try (ZipInputStream input = new ZipInputStream( + new BufferedInputStream(new FileInputStream(archive)) + )) { + ZipEntry entry; + while ((entry = input.getNextEntry()) != null) { + entryCount += 1; + if (entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Plugin archive contains too many files"); + } + + String name = entry.getName().replace('\\', '/'); + if (name.isEmpty() || name.startsWith("/") || name.matches("^[A-Za-z]:/.*") || name.indexOf('\0') >= 0) { + throw new IOException("Plugin archive contains an unsafe path"); + } + + File output = new File(destination, name).getCanonicalFile(); + if (!output.getPath().startsWith(destinationPath)) { + throw new IOException("Plugin archive attempts to write outside its directory"); + } + if ("plugin.json".equals(name)) { + hasManifest = true; + } + + if (entry.isDirectory()) { + if (!output.mkdirs() && !output.isDirectory()) { + throw new IOException("Unable to create plugin directory"); + } + input.closeEntry(); + continue; + } + + long declaredSize = entry.getSize(); + if (declaredSize > MAX_ENTRY_BYTES) { + throw new IOException("Plugin archive contains an oversized file"); + } + File outputParent = output.getParentFile(); + if (!outputParent.exists() && !outputParent.mkdirs()) { + throw new IOException("Unable to create plugin directory"); + } + + long entryBytes = 0; + try (BufferedOutputStream outputStream = new BufferedOutputStream( + new FileOutputStream(output) + )) { + int count; + while ((count = input.read(buffer)) != -1) { + entryBytes += count; + extractedBytes += count; + if (entryBytes > MAX_ENTRY_BYTES || extractedBytes > MAX_ARCHIVE_BYTES) { + throw new IOException("Plugin archive is too large"); + } + outputStream.write(buffer, 0, count); + } + } + input.closeEntry(); + } + } + + if (!hasManifest) { + throw new IOException("Plugin archive is missing plugin.json"); + } + } + + private static void writeManifest(File destination, String manifest) throws IOException { + try (FileOutputStream output = new FileOutputStream(new File(destination, "plugin.json"))) { + output.write(manifest.getBytes(StandardCharsets.UTF_8)); + } + } + + private static void deleteRecursively(File file) { + if (file == null || !file.exists()) return; + if (file.isDirectory()) { + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + file.delete(); + } + private String getPluginIdFromToken(String token) { for (Map.Entry entry : tokenStore.entrySet()) { From bbcf8bfc3a08ffc3128ca513d108e8a95ed201bd Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Tue, 11 Aug 2026 08:50:05 +0530 Subject: [PATCH 2/5] fix: harden native plugin extraction --- src/lib/installPlugin.js | 7 ++- .../pluginContext/src/android/Tee.java | 50 +++++++++++++++++-- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index cf933844a..ff3da2343 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -197,7 +197,11 @@ export default async function installPlugin( plugin, ); loaderDialog?.setMessage("Extracting plugin files..."); - await extractPluginArchive(archiveUrl, pluginDir, JSON.stringify(pluginJson)); + await extractPluginArchive( + archiveUrl, + pluginDir, + JSON.stringify(pluginJson), + ); extractionComplete = true; if (isDependency) { @@ -210,7 +214,6 @@ export default async function installPlugin( } await loadPluginWithTimeout(id, true); } - } } catch (err) { try { diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 27d651e37..762850914 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -10,8 +10,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.HashMap; -import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import android.content.Context; @@ -33,10 +31,11 @@ public class Tee extends CordovaPlugin { - private static final int MAX_ARCHIVE_ENTRIES = 4096; - private static final long MAX_ARCHIVE_BYTES = 100L * 1024 * 1024; - private static final long MAX_ENTRY_BYTES = 32L * 1024 * 1024; + private static final int MAX_ARCHIVE_ENTRIES = 16 * 1024; + private static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; + private static final long MAX_ENTRY_BYTES = 128L * 1024 * 1024; private static final int BUFFER_SIZE = 32 * 1024; + private static final Set activeExtractions = ConcurrentHashMap.newKeySet(); // pluginId : token private /*static*/ final Map tokenStore = new ConcurrentHashMap<>(); @@ -164,6 +163,7 @@ public void run() { File staging = null; File backup = null; File destination = null; + String destinationPath = null; try { File archive = webView.getResourceApi().mapUriToFile(Uri.parse(archiveUri)); destination = webView.getResourceApi().mapUriToFile(Uri.parse(destinationUri)); @@ -182,6 +182,12 @@ public void run() { if (!parent.exists() && !parent.mkdirs()) { throw new IOException("Unable to create plugin directory"); } + destinationPath = destination.getPath(); + if (!activeExtractions.add(destinationPath)) { + throw new IOException("Plugin installation is already in progress"); + } + + restoreInterruptedInstall(parent, destination); staging = new File( parent, @@ -225,6 +231,9 @@ public void run() { if (backup != null && backup.exists() && destination != null && !destination.exists()) { backup.renameTo(destination); } + if (destinationPath != null) { + activeExtractions.remove(destinationPath); + } } } }); @@ -306,6 +315,37 @@ private static void writeManifest(File destination, String manifest) throws IOEx } } + /** + * A directory rename cannot be made atomic with replacing an existing + * directory. If Android stops the app between the two renames, restore the + * most recent backup before beginning another installation. + */ + private static void restoreInterruptedInstall(File parent, File destination) throws IOException { + String backupPrefix = "." + destination.getName() + ".backup-"; + File[] children = parent.listFiles(); + if (children == null) return; + + File newestBackup = null; + for (File child : children) { + if (!child.isDirectory() || !child.getName().startsWith(backupPrefix)) continue; + if (newestBackup == null || child.lastModified() > newestBackup.lastModified()) { + newestBackup = child; + } + } + + if (!destination.exists() && newestBackup != null) { + if (!newestBackup.renameTo(destination)) { + throw new IOException("Unable to restore previous plugin installation"); + } + } + + for (File child : children) { + if (child.isDirectory() && child.getName().startsWith(backupPrefix)) { + deleteRecursively(child); + } + } + } + private static void deleteRecursively(File file) { if (file == null || !file.exists()) return; if (file.isDirectory()) { From a6f84d739eb876ee2ef8dd5412f1572654dac78e Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Tue, 11 Aug 2026 09:16:00 +0530 Subject: [PATCH 3/5] fix: normalize plugin archive manifest path --- src/plugins/pluginContext/src/android/Tee.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 762850914..985218ae6 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -241,6 +241,7 @@ public void run() { private static void extractArchive(File archive, File destination) throws IOException { String destinationPath = destination.getCanonicalPath() + File.separator; + File manifestFile = new File(destination, "plugin.json").getCanonicalFile(); int entryCount = 0; long extractedBytes = 0; boolean hasManifest = false; @@ -265,7 +266,9 @@ private static void extractArchive(File archive, File destination) throws IOExce if (!output.getPath().startsWith(destinationPath)) { throw new IOException("Plugin archive attempts to write outside its directory"); } - if ("plugin.json".equals(name)) { + // JSZip normalizes paths such as "./plugin.json", so compare + // the resolved safe path instead of the raw ZIP entry name. + if (!entry.isDirectory() && output.equals(manifestFile)) { hasManifest = true; } From 272ba567e7d64db9b3b73d408e9d57ef224cfe93 Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Tue, 11 Aug 2026 09:23:44 +0530 Subject: [PATCH 4/5] fix: read plugin archives through central directory --- .../pluginContext/src/android/Tee.java | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 985218ae6..57a143f7e 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -19,12 +19,13 @@ import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.File; -import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.Enumeration; import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; +import java.util.zip.ZipFile; //auth plugin import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; @@ -241,17 +242,14 @@ public void run() { private static void extractArchive(File archive, File destination) throws IOException { String destinationPath = destination.getCanonicalPath() + File.separator; - File manifestFile = new File(destination, "plugin.json").getCanonicalFile(); int entryCount = 0; long extractedBytes = 0; - boolean hasManifest = false; byte[] buffer = new byte[BUFFER_SIZE]; - try (ZipInputStream input = new ZipInputStream( - new BufferedInputStream(new FileInputStream(archive)) - )) { - ZipEntry entry; - while ((entry = input.getNextEntry()) != null) { + try (ZipFile zipFile = new ZipFile(archive)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); entryCount += 1; if (entryCount > MAX_ARCHIVE_ENTRIES) { throw new IOException("Plugin archive contains too many files"); @@ -266,17 +264,10 @@ private static void extractArchive(File archive, File destination) throws IOExce if (!output.getPath().startsWith(destinationPath)) { throw new IOException("Plugin archive attempts to write outside its directory"); } - // JSZip normalizes paths such as "./plugin.json", so compare - // the resolved safe path instead of the raw ZIP entry name. - if (!entry.isDirectory() && output.equals(manifestFile)) { - hasManifest = true; - } - if (entry.isDirectory()) { if (!output.mkdirs() && !output.isDirectory()) { throw new IOException("Unable to create plugin directory"); } - input.closeEntry(); continue; } @@ -290,9 +281,12 @@ private static void extractArchive(File archive, File destination) throws IOExce } long entryBytes = 0; - try (BufferedOutputStream outputStream = new BufferedOutputStream( - new FileOutputStream(output) - )) { + try ( + InputStream input = new BufferedInputStream(zipFile.getInputStream(entry)); + BufferedOutputStream outputStream = new BufferedOutputStream( + new FileOutputStream(output) + ) + ) { int count; while ((count = input.read(buffer)) != -1) { entryBytes += count; @@ -303,13 +297,9 @@ private static void extractArchive(File archive, File destination) throws IOExce outputStream.write(buffer, 0, count); } } - input.closeEntry(); } } - if (!hasManifest) { - throw new IOException("Plugin archive is missing plugin.json"); - } } private static void writeManifest(File destination, String manifest) throws IOException { From a06da60ee99d5d1ecf0225b8f5f3bee24d7d1814 Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Tue, 11 Aug 2026 09:28:41 +0530 Subject: [PATCH 5/5] perf: reduce native plugin extraction overhead --- .../pluginContext/src/android/Tee.java | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 57a143f7e..89a72abd8 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -16,8 +16,6 @@ import android.net.Uri; import org.apache.cordova.*; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -35,7 +33,7 @@ public class Tee extends CordovaPlugin { private static final int MAX_ARCHIVE_ENTRIES = 16 * 1024; private static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; private static final long MAX_ENTRY_BYTES = 128L * 1024 * 1024; - private static final int BUFFER_SIZE = 32 * 1024; + private static final int BUFFER_SIZE = 64 * 1024; private static final Set activeExtractions = ConcurrentHashMap.newKeySet(); // pluginId : token @@ -241,7 +239,6 @@ public void run() { } private static void extractArchive(File archive, File destination) throws IOException { - String destinationPath = destination.getCanonicalPath() + File.separator; int entryCount = 0; long extractedBytes = 0; byte[] buffer = new byte[BUFFER_SIZE]; @@ -255,15 +252,9 @@ private static void extractArchive(File archive, File destination) throws IOExce throw new IOException("Plugin archive contains too many files"); } - String name = entry.getName().replace('\\', '/'); - if (name.isEmpty() || name.startsWith("/") || name.matches("^[A-Za-z]:/.*") || name.indexOf('\0') >= 0) { - throw new IOException("Plugin archive contains an unsafe path"); - } - - File output = new File(destination, name).getCanonicalFile(); - if (!output.getPath().startsWith(destinationPath)) { - throw new IOException("Plugin archive attempts to write outside its directory"); - } + String name = normalizeArchivePath(entry.getName()); + if (name == null) continue; + File output = new File(destination, name); if (entry.isDirectory()) { if (!output.mkdirs() && !output.isDirectory()) { throw new IOException("Unable to create plugin directory"); @@ -282,10 +273,8 @@ private static void extractArchive(File archive, File destination) throws IOExce long entryBytes = 0; try ( - InputStream input = new BufferedInputStream(zipFile.getInputStream(entry)); - BufferedOutputStream outputStream = new BufferedOutputStream( - new FileOutputStream(output) - ) + InputStream input = zipFile.getInputStream(entry); + FileOutputStream outputStream = new FileOutputStream(output) ) { int count; while ((count = input.read(buffer)) != -1) { @@ -302,6 +291,30 @@ private static void extractArchive(File archive, File destination) throws IOExce } + /** + * The staging directory is newly created for each install, so rejecting + * absolute and parent paths is sufficient to keep every output below it. + * This avoids a canonical-path filesystem lookup for every archive entry. + */ + private static String normalizeArchivePath(String path) throws IOException { + if (path == null) throw new IOException("Plugin archive contains an unsafe path"); + String rawPath = path.replace('\\', '/'); + if (rawPath.startsWith("/") || rawPath.matches("^[A-Za-z]:($|/.*)") || rawPath.indexOf('\0') >= 0) { + throw new IOException("Plugin archive contains an unsafe path"); + } + + StringBuilder normalizedPath = new StringBuilder(rawPath.length()); + for (String segment : rawPath.split("/")) { + if (segment.isEmpty() || ".".equals(segment)) continue; + if ("..".equals(segment)) { + throw new IOException("Plugin archive attempts to write outside its directory"); + } + if (normalizedPath.length() > 0) normalizedPath.append('/'); + normalizedPath.append(segment); + } + return normalizedPath.length() == 0 ? null : normalizedPath.toString(); + } + private static void writeManifest(File destination, String manifest) throws IOException { try (FileOutputStream output = new FileOutputStream(new File(destination, "plugin.json"))) { output.write(manifest.getBytes(StandardCharsets.UTF_8));