Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 52 additions & 230 deletions src/lib/installPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,31 @@ 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} */
let loaderDialog;
/** @type {Array<() => Promise<void>>} */
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
Expand All @@ -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())) {
Expand Down Expand Up @@ -164,90 +183,26 @@ 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovered update misclassified as fresh

When an interrupted update leaves only a backup, pluginWasInstalled is recorded as false before native recovery restores that backup. If the subsequent plugin load fails, the catch block treats the recovered update as a fresh installation and deletes the activated directory after its backup has been removed, leaving the plugin uninstalled.

Knowledge Base Used: Plugin System

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 () => {
Expand All @@ -259,134 +214,33 @@ 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) {
console.error("Cleanup failed:", cleanupError);
}
throw err;
} finally {
if (!isDependency) {
loaderDialog.destroy();
}
}
}

/**
* Create directory recursively
* @param {string} parent
* @param {Array<string> | 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) {
if (archiveUrl) {
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 {
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;
}

/**
Expand Down Expand Up @@ -495,35 +349,3 @@ async function resolveDep(manifest) {
return purchase;
}
}

/**
*
* @param {string} dir
* @param {Array<string>} 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<string, boolean>} 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();
}
}
}
Loading