From eb612a583dae0dbbf28fb322d5a414d57f3bc140 Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Sun, 9 Aug 2026 14:24:20 +0530 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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)); From ca5df689a35f24f33903e543941bfd6dc963119f Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Sun, 16 Aug 2026 09:43:25 +0530 Subject: [PATCH 6/8] fix: prevent recovered-update misclassification and staging dir accumulation installPlugin.js: Move pluginWasInstalled check to after extractPluginArchive so that native recovery (restoreInterruptedInstall) has already run before the flag is recorded. Previously, a recovered update could be misclassified as a fresh install, causing the error handler to delete the restored directory. Tee.java: Expand restoreInterruptedInstall to also clean up orphaned .install-* staging directories alongside .backup-* directories, preventing indefinite storage accumulation from interrupted extractions. --- src/lib/installPlugin.js | 2 +- src/plugins/pluginContext/src/android/Tee.java | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index ff3da2343..d1b3fd8a1 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -187,7 +187,6 @@ export default async function installPlugin( assertSafePluginId(id); pluginDir = Url.join(PLUGIN_DIR, id); - pluginWasInstalled = await fsOperation(pluginDir).exists(); archiveUrl = Url.join( CACHE_STORAGE, `.plugin-install-${helpers.uuid()}.zip`, @@ -203,6 +202,7 @@ export default async function installPlugin( JSON.stringify(pluginJson), ); extractionComplete = true; + pluginWasInstalled = await fsOperation(pluginDir).exists(); if (isDependency) { depsLoaders.push(async () => { diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 89a72abd8..b3f0972fb 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -328,6 +328,7 @@ private static void writeManifest(File destination, String manifest) throws IOEx */ private static void restoreInterruptedInstall(File parent, File destination) throws IOException { String backupPrefix = "." + destination.getName() + ".backup-"; + String stagingPrefix = "." + destination.getName() + ".install-"; File[] children = parent.listFiles(); if (children == null) return; @@ -346,7 +347,9 @@ private static void restoreInterruptedInstall(File parent, File destination) thr } for (File child : children) { - if (child.isDirectory() && child.getName().startsWith(backupPrefix)) { + if (!child.isDirectory()) continue; + String name = child.getName(); + if (name.startsWith(backupPrefix) || name.startsWith(stagingPrefix)) { deleteRecursively(child); } } From 2550723e269a2e9be3054111809cb692505ed11d Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Wed, 19 Aug 2026 08:02:21 +0530 Subject: [PATCH 7/8] fix: restore fresh-install cleanup and fix staged-dir accumulation - installPlugin.js: Move pluginWasInstalled check back to before extraction to restore correct fresh-install cleanup, overriding it with the native recovered flag from extractPluginArchive when native backup recovery is detected. - Tee.java: Make restoreInterruptedInstall return a boolean to indicate backup recovery to the JS layer. Broaden orphan cleanup to remove abandoned staging and backup directories for ANY plugin, skipping only active extractions. This fixes indefinite staging dir accumulation without requiring the user to re-download the same plugin. --- src/lib/installPlugin.js | 7 +++-- .../pluginContext/src/android/Tee.java | 27 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index d1b3fd8a1..bd7e6d926 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -187,6 +187,7 @@ export default async function installPlugin( assertSafePluginId(id); pluginDir = Url.join(PLUGIN_DIR, id); + pluginWasInstalled = await fsOperation(pluginDir).exists(); archiveUrl = Url.join( CACHE_STORAGE, `.plugin-install-${helpers.uuid()}.zip`, @@ -196,13 +197,15 @@ export default async function installPlugin( plugin, ); loaderDialog?.setMessage("Extracting plugin files..."); - await extractPluginArchive( + const extractResult = await extractPluginArchive( archiveUrl, pluginDir, JSON.stringify(pluginJson), ); extractionComplete = true; - pluginWasInstalled = await fsOperation(pluginDir).exists(); + if (extractResult?.recovered) { + pluginWasInstalled = true; + } if (isDependency) { depsLoaders.push(async () => { diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index b3f0972fb..2d124aa1c 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -35,6 +35,8 @@ public class Tee extends CordovaPlugin { private static final long MAX_ENTRY_BYTES = 128L * 1024 * 1024; private static final int BUFFER_SIZE = 64 * 1024; private static final Set activeExtractions = ConcurrentHashMap.newKeySet(); + private static final java.util.regex.Pattern ORPHAN_DIR_PATTERN = + java.util.regex.Pattern.compile("^\\..+\\.(install|backup)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); // pluginId : token private /*static*/ final Map tokenStore = new ConcurrentHashMap<>(); @@ -186,7 +188,7 @@ public void run() { throw new IOException("Plugin installation is already in progress"); } - restoreInterruptedInstall(parent, destination); + boolean recovered = restoreInterruptedInstall(parent, destination); staging = new File( parent, @@ -220,7 +222,9 @@ public void run() { if (backup != null) { deleteRecursively(backup); } - callback.success(); + JSONObject result = new JSONObject(); + result.put("recovered", recovered); + callback.success(result); } catch (Exception error) { callback.error(error.getMessage() == null ? "Plugin extraction failed" : error.getMessage()); } finally { @@ -326,11 +330,11 @@ private static void writeManifest(File destination, String manifest) throws IOEx * 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 { + private static boolean restoreInterruptedInstall(File parent, File destination) throws IOException { + boolean recovered = false; String backupPrefix = "." + destination.getName() + ".backup-"; - String stagingPrefix = "." + destination.getName() + ".install-"; File[] children = parent.listFiles(); - if (children == null) return; + if (children == null) return false; File newestBackup = null; for (File child : children) { @@ -344,15 +348,24 @@ private static void restoreInterruptedInstall(File parent, File destination) thr if (!newestBackup.renameTo(destination)) { throw new IOException("Unable to restore previous plugin installation"); } + recovered = true; } for (File child : children) { if (!child.isDirectory()) continue; String name = child.getName(); - if (name.startsWith(backupPrefix) || name.startsWith(stagingPrefix)) { - deleteRecursively(child); + if (!ORPHAN_DIR_PATTERN.matcher(name).matches()) continue; + int typeIdx = name.lastIndexOf(".install-"); + if (typeIdx < 0) typeIdx = name.lastIndexOf(".backup-"); + if (typeIdx <= 1) continue; + String ownerName = name.substring(1, typeIdx); + String ownerPath = new File(parent, ownerName).getPath(); + if (activeExtractions.contains(ownerPath) && !ownerPath.equals(destination.getPath())) { + continue; } + deleteRecursively(child); } + return recovered; } private static void deleteRecursively(File file) { From d12d0fff0df17513e6e72882b20b48dc692cbff1 Mon Sep 17 00:00:00 2001 From: mesanjeetk Date: Wed, 19 Aug 2026 08:14:16 +0530 Subject: [PATCH 8/8] fix: restore cross-plugin backups during global sweep Tee.java: The global sweep for orphaned staging and backup directories could permanently delete another plugin's only recoverable backup if its own update was interrupted. This refactors the sweep to first identify and restore the newest backup for any plugin missing its destination directory, and strictly protects any remaining inactive backups whose destinations are missing. --- .../pluginContext/src/android/Tee.java | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/plugins/pluginContext/src/android/Tee.java b/src/plugins/pluginContext/src/android/Tee.java index 2d124aa1c..a599c1dcc 100644 --- a/src/plugins/pluginContext/src/android/Tee.java +++ b/src/plugins/pluginContext/src/android/Tee.java @@ -332,39 +332,70 @@ private static void writeManifest(File destination, String manifest) throws IOEx */ private static boolean restoreInterruptedInstall(File parent, File destination) throws IOException { boolean recovered = false; - String backupPrefix = "." + destination.getName() + ".backup-"; File[] children = parent.listFiles(); if (children == null) return false; - File newestBackup = null; + java.util.Map newestBackups = new java.util.HashMap<>(); for (File child : children) { - if (!child.isDirectory() || !child.getName().startsWith(backupPrefix)) continue; - if (newestBackup == null || child.lastModified() > newestBackup.lastModified()) { - newestBackup = child; + if (!child.isDirectory()) continue; + String name = child.getName(); + if (!ORPHAN_DIR_PATTERN.matcher(name).matches()) continue; + int typeIdx = name.lastIndexOf(".backup-"); + if (typeIdx <= 1) continue; + + String ownerName = name.substring(1, typeIdx); + File ownerDest = new File(parent, ownerName); + if (ownerDest.exists()) continue; + + String ownerPath = ownerDest.getPath(); + if (activeExtractions.contains(ownerPath) && !ownerPath.equals(destination.getPath())) { + continue; + } + + File currentBest = newestBackups.get(ownerName); + if (currentBest == null || child.lastModified() > currentBest.lastModified()) { + newestBackups.put(ownerName, child); } } - if (!destination.exists() && newestBackup != null) { - if (!newestBackup.renameTo(destination)) { - throw new IOException("Unable to restore previous plugin installation"); + for (java.util.Map.Entry entry : newestBackups.entrySet()) { + String ownerName = entry.getKey(); + File backup = entry.getValue(); + File ownerDest = new File(parent, ownerName); + + if (!backup.renameTo(ownerDest)) { + if (ownerDest.equals(destination)) { + throw new IOException("Unable to restore previous plugin installation"); + } + } else if (ownerDest.equals(destination)) { + recovered = true; } - recovered = true; } for (File child : children) { - if (!child.isDirectory()) continue; + if (!child.exists() || !child.isDirectory()) continue; String name = child.getName(); if (!ORPHAN_DIR_PATTERN.matcher(name).matches()) continue; int typeIdx = name.lastIndexOf(".install-"); - if (typeIdx < 0) typeIdx = name.lastIndexOf(".backup-"); + boolean isBackup = typeIdx < 0; + if (isBackup) typeIdx = name.lastIndexOf(".backup-"); if (typeIdx <= 1) continue; + String ownerName = name.substring(1, typeIdx); - String ownerPath = new File(parent, ownerName).getPath(); + File ownerDest = new File(parent, ownerName); + String ownerPath = ownerDest.getPath(); + if (activeExtractions.contains(ownerPath) && !ownerPath.equals(destination.getPath())) { continue; } + + if (isBackup && !ownerDest.exists()) { + continue; + } + deleteRecursively(child); } + return recovered; }