Skip to content

Commit a1752db

Browse files
authored
Merge branch 'Acode-Foundation:main' into ai-test
2 parents 373f3d5 + a15ec2d commit a1752db

5 files changed

Lines changed: 205 additions & 36 deletions

File tree

src/lib/installPlugin.js

Lines changed: 137 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -173,21 +173,49 @@ export default async function installPlugin(
173173
await fsOperation(PLUGIN_DIR).createDirectory(id);
174174
}
175175

176+
// Track unsafe absolute entries to skip
177+
const ignoredUnsafeEntries = new Set();
178+
176179
const promises = Object.keys(zip.files).map(async (file) => {
177180
try {
178181
let correctFile = file;
179182
if (/\\/.test(correctFile)) {
180183
correctFile = correctFile.replace(/\\/g, "/");
181184
}
182185

186+
// Determine if the zip entry is a directory from JSZip metadata
187+
const isDirEntry = !!zip.files[file].dir || /\/$/.test(correctFile);
188+
189+
// If the original path is absolute or otherwise unsafe, skip it and warn later
190+
console.log(
191+
`Skipping unsafe path: ${file} : ${isUnsafeAbsolutePath(file)}`,
192+
);
193+
if (isUnsafeAbsolutePath(file)) {
194+
ignoredUnsafeEntries.add(file);
195+
return;
196+
}
197+
198+
// Sanitize path so it cannot escape pluginDir or start with '/'
199+
correctFile = sanitizeZipPath(correctFile, isDirEntry);
200+
if (!correctFile) return; // nothing to do
183201
const fileUrl = Url.join(pluginDir, correctFile);
184202

185-
if (!state.exists(correctFile)) {
186-
await createFileRecursive(pluginDir, correctFile);
203+
// Always ensure directories exist for dir entries
204+
if (isDirEntry) {
205+
await createFileRecursive(pluginDir, correctFile, true);
206+
return;
207+
}
208+
209+
// For files, ensure parent directory exists even if state claims it exists
210+
const lastSlash = correctFile.lastIndexOf("/");
211+
if (lastSlash >= 0) {
212+
const parentRel = correctFile.slice(0, lastSlash + 1);
213+
await createFileRecursive(pluginDir, parentRel, true);
187214
}
188215

189-
// Skip directories
190-
if (correctFile.endsWith("/")) return;
216+
if (!state.exists(correctFile)) {
217+
await createFileRecursive(pluginDir, correctFile, false);
218+
}
191219

192220
let data = await zip.files[file].async("ArrayBuffer");
193221

@@ -206,6 +234,20 @@ export default async function installPlugin(
206234
// Wait for all files to be processed
207235
await Promise.allSettled(promises);
208236

237+
// Emit a non-blocking warning if any unsafe entries were skipped
238+
if (!isDependency && ignoredUnsafeEntries.size) {
239+
const sample = Array.from(ignoredUnsafeEntries).slice(0, 3).join(", ");
240+
loaderDialog.setMessage(
241+
`Skipped ${ignoredUnsafeEntries.size} unsafe archive entr${
242+
ignoredUnsafeEntries.size === 1 ? "y" : "ies"
243+
} (e.g., ${sample})`,
244+
);
245+
console.warn(
246+
"Plugin installer: skipped unsafe absolute paths in archive:",
247+
Array.from(ignoredUnsafeEntries),
248+
);
249+
}
250+
209251
if (isDependency) {
210252
depsLoaders.push(async () => {
211253
await loadPlugin(id, true);
@@ -245,28 +287,105 @@ export default async function installPlugin(
245287
* @param {string} parent
246288
* @param {Array<string> | string} dir
247289
*/
248-
async function createFileRecursive(parent, dir) {
249-
let isDir = false;
290+
async function createFileRecursive(parent, dir, shouldBeDirAtEnd) {
291+
let wantDirEnd = !!shouldBeDirAtEnd;
292+
/** @type {string[]} */
293+
let parts;
250294
if (typeof dir === "string") {
251-
if (dir.endsWith("/")) {
252-
isDir = true;
253-
dir = dir.slice(0, -1);
254-
}
255-
dir = dir.split("/");
295+
if (dir.endsWith("/")) wantDirEnd = true;
296+
dir = dir.replace(/\\/g, "/");
297+
parts = dir.split("/");
298+
} else {
299+
parts = dir;
256300
}
257-
dir = dir.filter((d) => d);
258-
const cd = dir.shift();
301+
parts = parts.filter((d) => d);
302+
const cd = parts.shift();
303+
if (!cd) return;
259304
const newParent = Url.join(parent, cd);
305+
306+
const isLast = parts.length === 0;
307+
const needDir = !isLast || wantDirEnd;
260308
if (!(await fsOperation(newParent).exists())) {
261-
if (dir.length || isDir) {
262-
await fsOperation(parent).createDirectory(cd);
309+
if (needDir) {
310+
try {
311+
await fsOperation(parent).createDirectory(cd);
312+
} catch (e) {
313+
// If another concurrent task created it, consider it fine
314+
if (!(await fsOperation(newParent).exists())) throw e;
315+
}
263316
} else {
264-
await fsOperation(parent).createFile(cd);
317+
try {
318+
await fsOperation(parent).createFile(cd);
319+
} catch (e) {
320+
if (!(await fsOperation(newParent).exists())) throw e;
321+
}
322+
}
323+
}
324+
if (parts.length) {
325+
await createFileRecursive(newParent, parts, wantDirEnd);
326+
}
327+
}
328+
329+
/**
330+
* Sanitize zip entry path to ensure it's relative and safe under pluginDir
331+
* - Normalizes separators to '/'
332+
* - Strips leading slashes and Windows drive prefixes (e.g., C:/)
333+
* - Resolves '.' and '..' segments
334+
* - Preserves trailing slash for directory entries
335+
* @param {string} p
336+
* @param {boolean} isDir
337+
* @returns {string} sanitized relative path
338+
*/
339+
function sanitizeZipPath(p, isDir) {
340+
if (!p) return "";
341+
let path = String(p);
342+
// Normalize separators
343+
path = path.replace(/\\/g, "/");
344+
// Remove URL-like scheme if present accidentally
345+
path = path.replace(/^[a-zA-Z]+:\/\//, "");
346+
// Strip leading slashes
347+
path = path.replace(/^\/+/, "");
348+
// Strip Windows drive letter, e.g., C:/
349+
path = path.replace(/^[A-Za-z]:\//, "");
350+
351+
const parts = path.split("/");
352+
const stack = [];
353+
for (const part of parts) {
354+
if (!part || part === ".") continue;
355+
if (part === "..") {
356+
if (stack.length) stack.pop();
357+
continue;
265358
}
359+
stack.push(part);
266360
}
267-
if (dir.length) {
268-
await createFileRecursive(newParent, dir);
361+
let safe = stack.join("/");
362+
if (isDir && safe && !safe.endsWith("/")) safe += "/";
363+
return safe;
364+
}
365+
366+
/**
367+
* Detects unsafe absolute paths in zip entries that should be ignored.
368+
* Treats leading '/' as absolute, Windows drive roots like 'C:/' as absolute,
369+
* and common Android/Linux device roots like '/data', '/root', '/system'.
370+
* @param {string} p
371+
*/
372+
function isUnsafeAbsolutePath(p) {
373+
if (!p) return false;
374+
const s = String(p);
375+
if (/^[A-Za-z]:[\\\/]/.test(s)) return true; // Windows drive root
376+
if (s.startsWith("//")) return true; // network path
377+
if (s.startsWith("/")) {
378+
return (
379+
s.startsWith("/data") ||
380+
s.startsWith("/system") ||
381+
s.startsWith("/vendor") ||
382+
s.startsWith("/storage") ||
383+
s.startsWith("/sdcard") ||
384+
s.startsWith("/root") ||
385+
true // any leading slash is unsafe
386+
);
269387
}
388+
return false;
270389
}
271390

272391
/**

src/lib/installState.js

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,28 @@ export default class InstallState {
2626

2727
state.storeUrl = Url.join(INSTALL_STATE_STORAGE, state.id);
2828
if (await fsOperation(state.storeUrl).exists()) {
29-
state.store = JSON.parse(
30-
await fsOperation(state.storeUrl).readFile("utf-8"),
31-
);
29+
let raw = "{}";
30+
try {
31+
raw = await fsOperation(state.storeUrl).readFile("utf-8");
32+
state.store = JSON.parse(raw);
33+
} catch (err) {
34+
console.error(
35+
"InstallState: Failed to parse state file, deleting:",
36+
err,
37+
);
38+
// Delete corrupted state file to avoid parse errors such as 'Unexpected end of JSON'
39+
state.store = {};
40+
try {
41+
await fsOperation(state.storeUrl).delete();
42+
// Recreate a fresh empty file to keep invariant
43+
await fsOperation(INSTALL_STATE_STORAGE).createFile(state.id);
44+
} catch (writeErr) {
45+
console.error(
46+
"InstallState: Failed to recreate state file:",
47+
writeErr,
48+
);
49+
}
50+
}
3251

3352
const patchedStore = {};
3453
for (const [key, value] of Object.entries(state.store)) {
@@ -101,7 +120,19 @@ export default class InstallState {
101120
try {
102121
this.store = {};
103122
this.updatedStore = {};
104-
await fsOperation(this.storeUrl).writeFile("{}");
123+
// Delete the state file entirely to avoid corrupted/partial JSON issues
124+
if (await fsOperation(this.storeUrl).exists()) {
125+
try {
126+
await fsOperation(this.storeUrl).delete();
127+
} catch (delErr) {
128+
console.error(
129+
"InstallState: Failed to delete state file during clear:",
130+
delErr,
131+
);
132+
// As a fallback, overwrite with a valid empty JSON
133+
await fsOperation(this.storeUrl).writeFile("{}");
134+
}
135+
}
105136
} catch (error) {
106137
console.error("Failed to clear install state:", error);
107138
}

src/lib/openFile.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,21 @@ export default async function openFile(file, options = {}) {
386386

387387
const binData = await fs.readFile();
388388

389-
// Detect encoding if not explicitly provided
389+
// Determine encoding: if explicit provided use it, otherwise
390+
// if settings.defaultFileEncoding === 'auto' then detect; else use the default as-is
390391
let detectedEncoding = file.encoding || encoding;
391392
if (!detectedEncoding) {
392-
try {
393-
detectedEncoding = await detectEncoding(binData);
394-
} catch (error) {
395-
console.warn("Encoding detection failed, using default:", error);
396-
detectedEncoding = appSettings.value.defaultFileEncoding;
393+
const defaultSetting = appSettings.value.defaultFileEncoding;
394+
if (defaultSetting === "auto") {
395+
try {
396+
detectedEncoding = await detectEncoding(binData);
397+
if (detectedEncoding === "auto") detectedEncoding = "UTF-8";
398+
} catch (error) {
399+
console.warn("Encoding detection failed, using UTF-8:", error);
400+
detectedEncoding = "UTF-8";
401+
}
402+
} else {
403+
detectedEncoding = defaultSetting || "UTF-8";
397404
}
398405
}
399406

src/settings/appSettings.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,15 @@ export default function otherSettings() {
190190
key: "defaultFileEncoding",
191191
text: strings["default file encoding"],
192192
value: values.defaultFileEncoding,
193-
valueText: (value) => getEncoding(value).label,
194-
select: Object.keys(encodings).map((id) => {
195-
const encoding = encodings[id];
196-
return [id, encoding.label];
197-
}),
193+
valueText: (value) =>
194+
value === "auto" ? strings.auto || "Auto" : getEncoding(value).label,
195+
select: [
196+
["auto", strings.auto || "Auto"],
197+
...Object.keys(encodings).map((id) => {
198+
const encoding = encodings[id];
199+
return [id, encoding.label];
200+
}),
201+
],
198202
},
199203
];
200204

src/utils/encodings.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ function isValidUTF8(bytes) {
9191

9292
export async function detectEncoding(buffer) {
9393
if (!buffer || buffer.byteLength === 0) {
94-
return settings.value.defaultFileEncoding || "UTF-8";
94+
const def = settings.value.defaultFileEncoding;
95+
return def === "auto" ? "UTF-8" : def || "UTF-8";
9596
}
9697

9798
const bytes = new Uint8Array(buffer);
@@ -115,7 +116,9 @@ export async function detectEncoding(buffer) {
115116
const encodings = [
116117
...new Set([
117118
"UTF-8",
118-
settings.value.defaultFileEncoding || "UTF-8",
119+
settings.value.defaultFileEncoding === "auto"
120+
? "UTF-8"
121+
: settings.value.defaultFileEncoding || "UTF-8",
119122
"windows-1252",
120123
"ISO-8859-1",
121124
]),
@@ -144,7 +147,8 @@ export async function detectEncoding(buffer) {
144147
}
145148
}
146149

147-
return settings.value.defaultFileEncoding || "UTF-8";
150+
const def = settings.value.defaultFileEncoding;
151+
return def === "auto" ? "UTF-8" : def || "UTF-8";
148152
}
149153

150154
/**
@@ -165,6 +169,8 @@ export async function decode(buffer, charset) {
165169
charset = settings.value.defaultFileEncoding;
166170
}
167171

172+
if (charset === "auto") charset = "UTF-8";
173+
168174
charset = getEncoding(charset).name;
169175
const text = await execDecode(buffer, charset);
170176

@@ -186,6 +192,8 @@ export function encode(text, charset) {
186192
charset = settings.value.defaultFileEncoding;
187193
}
188194

195+
if (charset === "auto") charset = "UTF-8";
196+
189197
charset = getEncoding(charset).name;
190198
return execEncode(text, charset);
191199
}

0 commit comments

Comments
 (0)