Skip to content
Merged
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
105 changes: 97 additions & 8 deletions electron/local-files.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,75 @@ function requireEntryName(name, what) {
return safeName;
}

// Asserts that `candidate` (already absolute) lies strictly inside `root`
// after normalisation on the *current* platform. `pathImpl` is injectable so
// the Windows rules can be exercised in tests on any OS. Traversal that
// survives normalisation ("..\\x" is a plain file name on POSIX but a parent
// reference on Windows) is caught here, as are absolute / drive-qualified
// names that path.join or path.resolve would let take over.
function assertWithinRoot(rootPath, candidate, pathImpl = path) {
if (typeof rootPath !== "string" || !rootPath.trim()) {
throw new LocalFileError("EINVAL", "A download folder is required");
}
const root = pathImpl.normalize(pathImpl.resolve(rootPath));
const target = pathImpl.normalize(pathImpl.resolve(candidate));
const rel = pathImpl.relative(root, target);
const escapes =
rel === "" ||
rel === ".." ||
rel.startsWith(`..${pathImpl.sep}`) ||
pathImpl.isAbsolute(rel) ||
// A different drive on Windows yields an absolute relative path; a UNC
// or drive-qualified segment must never survive either.
rel.split(pathImpl.sep).some((seg) => seg === ".." || seg === "");
if (escapes) {
throw new LocalFileError(
"EINVAL",
`"${pathImpl.basename(candidate)}" would be written outside the selected folder`,
);
}
return target;
}

// Resolve a deliberately selected linked root once, but never follow links
// supplied as descendants of a downloaded tree.
async function prepareDownloadPath(rootPath, candidate, directory = false) {
const target = assertWithinRoot(rootPath, normalizeLocalPath(candidate));
const selected = path.resolve(rootPath);
const realRoot = await fsp.realpath(selected);
if (!(await fsp.stat(realRoot)).isDirectory()) {
throw new LocalFileError("EINVAL", "The download root is not a directory");
}
const parts = path.relative(selected, target).split(path.sep);
let current = realRoot;
for (let i = 0; i < parts.length; i++) {
current = path.join(current, parts[i]);
const needsDirectory = directory || i < parts.length - 1;
if (needsDirectory) {
await fsp.mkdir(current).catch((error) => {
if (error.code !== "EEXIST") throw error;
});
}
const stat = await fsp.lstat(current).catch((error) => {
if (error.code === "ENOENT" && !needsDirectory) return null;
throw error;
});
if (stat?.isSymbolicLink()) {
throw new LocalFileError(
"EINVAL",
"Downloads cannot follow links inside the selected folder",
);
}
if (needsDirectory && !stat.isDirectory()) {
throw new LocalFileError(
"ENOTDIR",
"A download parent is not a directory",
);
}
}
return { root: realRoot, path: current };
}

async function pathExists(target) {
try {
await fsp.lstat(target);
Expand Down Expand Up @@ -610,8 +679,15 @@ async function downloadToLocal(
event,
options,
) {
const { transferId, origin, deviceId, body, destPath, expectedSize } =
options || {};
const {
transferId,
origin,
deviceId,
body,
destPath,
rootPath,
expectedSize,
} = options || {};
const overwrite = options?.overwrite === true;

if (!transferId || !destPath) {
Expand All @@ -623,7 +699,10 @@ async function downloadToLocal(
deviceId,
});

const absDest = normalizeLocalPath(destPath);
// The renderer builds destPath from remote names; never trust that it
// stayed inside the folder the user picked.
const destination = await prepareDownloadPath(rootPath, destPath);
const absDest = destination.path;
if (activeDestinations.has(absDest)) {
throw new LocalFileError(
"EBUSY",
Expand All @@ -632,7 +711,10 @@ async function downloadToLocal(
}
activeDestinations.add(absDest);

const partialPath = partialPathFor(absDest, transferId);
const partialPath = partialPathFor(
path.join(destination.root, path.basename(absDest)),
transferId,
);
const report = makeProgressReporter(event.sender, transferId);
const state = {
cancelled: false,
Expand All @@ -650,7 +732,6 @@ async function downloadToLocal(
`"${path.basename(absDest)}" already exists`,
);
}
await fsp.mkdir(path.dirname(absDest), { recursive: true });

request = createNetRequest(net, event, "POST", url);
for (const [key, value] of Object.entries(toHeaderMap(headers))) {
Expand Down Expand Up @@ -723,6 +804,7 @@ async function downloadToLocal(
request.end();
});

await prepareDownloadPath(destination.root, absDest);
await publishDownload(
partialPath,
absDest,
Expand Down Expand Up @@ -838,9 +920,15 @@ function createLocalFileHandlers({
return { trashed: targetPaths.length - failed.length, failed };
}),

[IPC.ENSURE_DIR]: wrap(async (_event, dirPath) => {
const target = normalizeLocalPath(dirPath);
await fsp.mkdir(target, { recursive: true });
[IPC.ENSURE_DIR]: wrap(async (_event, dirPath, rootPath) => {
let target = normalizeLocalPath(dirPath);
// Transfers pass the folder the user picked; the directory skeleton of
// a downloaded tree must stay inside it.
if (rootPath !== undefined) {
target = (await prepareDownloadPath(rootPath, target, true)).path;
} else {
await fsp.mkdir(target, { recursive: true });
}
return { path: target };
}),

Expand Down Expand Up @@ -915,6 +1003,7 @@ module.exports = {
createTargetResolver,
publishDownload,
defaultPublishFs,
assertWithinRoot,
walkPaths,
listDirectory,
};
3 changes: 2 additions & 1 deletion electron/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
rename: (oldPath, newName) =>
ipcRenderer.invoke("local-fs:rename", oldPath, newName),
trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths),
ensureDir: (dirPath) => ipcRenderer.invoke("local-fs:ensure-dir", dirPath),
ensureDir: (dirPath, rootPath) =>
ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath),
exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths),
walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths),
reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath),
Expand Down
Loading
Loading