Compare commits

...
5 changed files with 450 additions and 22 deletions

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
/**
* asarSafeFs - Local filesystem reads that bypass Electron's asar interception.
*
* Electron patches node:fs so that ANY local path whose segment ends with
* .asar (case-insensitive) is treated as an asar archive instead of reaching
* the real filesystem (electron/lib/node/asar-fs-wrapper.ts, asarRe = /\.asar/i).
* Uploading a real file named app.asar then fails before any network I/O with
* the archive-root error `ENOENT, not found in <path>` (the two spaces come
* from the empty inner filePath), while the identical bytes named app.bin
* upload fine (#3450).
*
* The official switch is process.noAsar = true, but it must only be flipped
* around the exact fs call that resolves the path, because:
* - stat / open resolve asar-ness synchronously at call time, and fd-level
* operations (fs.read, handle.stat, fs.fstat) on the returned real fd are
* not intercepted again;
* - fs.createReadStream resolves asar-ness lazily when the stream opens the
* fd, so a synchronous toggle around the call is not enough — open the fd
* with a scoped toggle and hand the stream `{ fd }` instead;
* - ssh2 fastPut resolves asar-ness through the synchronous `fs.open` inside
* fastXfer, so a scoped toggle around the fastPut call covers it.
* The toggle is always restored synchronously, so concurrent require() from
* app.asar and other asar-aware fs users are unaffected.
*/
const fs = require("node:fs");
function withNoAsar(fn) {
const previous = process.noAsar;
process.noAsar = true;
try {
return fn();
} finally {
process.noAsar = previous;
}
}
/** stat() a real local file, ignoring any .asar-looking path segment. */
function statLocal(filePath, options) {
return withNoAsar(() => fs.promises.stat(filePath, options));
}
/** Open a real local file handle (fd reads afterwards are not intercepted). */
function openLocal(filePath, flags = "r") {
return withNoAsar(() => fs.promises.open(filePath, flags));
}
/**
* Read stream for a real local file. The fd is opened under a scoped noAsar
* toggle (fs.openSync) because fs.createReadStream resolves asar-ness lazily
* when the stream opens the fd. The stream auto-closes the fd.
*/
function createLocalReadStream(filePath, options = {}) {
return withNoAsar(() => {
const fd = fs.openSync(filePath, "r");
try {
return fs.createReadStream(filePath, { ...options, fd, autoClose: true });
} catch (error) {
try { fs.closeSync(fd); } catch { /* ignore */ }
throw error;
}
});
}
/**
* Promise-returning variant. The fd is opened asynchronously via openLocal
* (fs.promises.open) so a slow or unresponsive filesystem (SMB/NFS, removable
* media) never blocks the main thread on fs.openSync, and open failures reject
* instead of throwing synchronously. Fd-level reads are not intercepted again,
* so asar-ness stays bypassed for the whole stream lifetime.
*/
async function openLocalReadStream(filePath, options = {}) {
const handle = await openLocal(filePath, "r");
try {
return fs.createReadStream(filePath, { ...options, fd: handle, autoClose: true });
} catch (error) {
// createReadStream throws synchronously on invalid options; the fd must
// not leak since the stream was never constructed.
try { handle.close(); } catch { /* ignore */ }
throw error;
}
}
/**
* ssh2 fastPut for a real local file. fastXfer performs its local fs.open
* synchronously inside the call, so the scoped toggle covers the one patched
* call it makes; subsequent reads go through the real fd.
*/
function fastPutLocal(sftp, localPath, remotePath, options, callback) {
return withNoAsar(() => sftp.fastPut(localPath, remotePath, options, callback));
}
module.exports = {
withNoAsar,
statLocal,
openLocal,
createLocalReadStream,
openLocalReadStream,
fastPutLocal,
};
+166
View File
@@ -0,0 +1,166 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
withNoAsar,
statLocal,
openLocal,
createLocalReadStream,
openLocalReadStream,
fastPutLocal,
} = require("./asarSafeFs.cjs");
test("withNoAsar restores the previous flag synchronously, also on throw", () => {
assert.equal(process.noAsar, undefined);
const value = withNoAsar(() => process.noAsar);
assert.equal(value, true);
assert.equal(process.noAsar, undefined);
const seen = withNoAsar(() => {
const inner = process.noAsar;
assert.equal(inner, true);
return 42;
});
assert.equal(seen, 42);
assert.throws(() => withNoAsar(() => {
throw new Error("boom");
}), /boom/);
assert.equal(process.noAsar, undefined);
});
test("statLocal and openLocal read real files whose name ends with .asar", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-asar-"));
try {
const payload = "real-bytes-not-an-archive";
const filePath = path.join(dir, "app.asar");
fs.writeFileSync(filePath, payload);
const st = await statLocal(filePath);
assert.equal(st.isFile(), true);
assert.equal(st.size, payload.length);
const handle = await openLocal(filePath, "r");
try {
const hst = await handle.stat();
assert.equal(hst.size, payload.length);
const buf = Buffer.alloc(payload.length);
await handle.read(buf, 0, payload.length, 0);
assert.equal(buf.toString(), payload);
} finally {
await handle.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("createLocalReadStream streams real .asar-named files and closes its fd", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-asar-"));
try {
const payload = "stream-me";
const filePath = path.join(dir, "real.ASAR");
fs.writeFileSync(filePath, payload);
const chunks = [];
for await (const chunk of createLocalReadStream(filePath)) {
chunks.push(Buffer.from(chunk));
}
assert.equal(Buffer.concat(chunks).toString(), payload);
assert.equal(process.noAsar, undefined);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("openLocalReadStream streams real .asar-named files and closes its fd", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-asar-"));
try {
const payload = "open-me-async";
const filePath = path.join(dir, "app.asar");
fs.writeFileSync(filePath, payload);
const chunks = [];
const stream = await openLocalReadStream(filePath);
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
assert.equal(Buffer.concat(chunks).toString(), payload);
assert.equal(process.noAsar, undefined);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("openLocalReadStream opens its fd asynchronously, never via fs.openSync", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-asar-"));
try {
const filePath = path.join(dir, "app.asar");
fs.writeFileSync(filePath, "no-blocking-open");
const origOpenSync = fs.openSync;
let openSyncCalled = false;
fs.openSync = (...args) => {
openSyncCalled = true;
return origOpenSync(...args);
};
try {
const chunks = [];
const stream = await openLocalReadStream(filePath);
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
assert.equal(Buffer.concat(chunks).toString(), "no-blocking-open");
assert.equal(openSyncCalled, false);
} finally {
fs.openSync = origOpenSync;
}
assert.equal(process.noAsar, undefined);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("open errors reject asynchronously and never leak the toggle", async () => {
const missing = path.join(os.tmpdir(), `netcatty-asar-missing-${Date.now()}.asar`);
await assert.rejects(() => openLocalReadStream(missing), /ENOENT/);
assert.equal(process.noAsar, undefined);
});
test("createLocalReadStream throws synchronously for missing files without leaking the toggle", () => {
const missing = path.join(os.tmpdir(), `netcatty-asar-missing-${Date.now()}.asar`);
assert.throws(() => createLocalReadStream(missing), /ENOENT/);
assert.equal(process.noAsar, undefined);
});
test("fastPutLocal calls fastPut synchronously with the given args and restores the flag", () => {
const calls = [];
const fakeSftp = {
fastPut(localPath, remotePath, options, callback) {
calls.push({ localPath, remotePath, options, noAsarDuringCall: process.noAsar });
callback(null);
},
};
const done = fastPutLocal(fakeSftp, "/tmp/in.asar", "/remote/out", { chunkSize: 1 }, () => {});
assert.ok(typeof done?.then === "function" || done === undefined);
assert.equal(calls.length, 1);
assert.equal(calls[0].localPath, "/tmp/in.asar");
assert.equal(calls[0].remotePath, "/remote/out");
assert.deepEqual(calls[0].options, { chunkSize: 1 });
assert.equal(calls[0].noAsarDuringCall, true);
assert.equal(process.noAsar, undefined);
});
test("fastPutLocal restores the flag when fastPut throws synchronously", () => {
const fakeSftp = {
fastPut() {
assert.equal(process.noAsar, true);
throw new Error("channel gone");
},
};
assert.throws(() => fastPutLocal(fakeSftp, "/tmp/in.asar", "/remote/out", {}, () => {}), /channel gone/);
assert.equal(process.noAsar, undefined);
});
+71 -4
View File
@@ -11,6 +11,7 @@
const fs = require("node:fs");
const path = require("node:path");
const { statLocal, openLocalReadStream } = require("../asarSafeFs.cjs");
const { executeBoundedSshCommand, terminateSshExecStream } = require("../boundedSshExec.cjs");
const { invalidateSshTransport } = require("../sshTransportInvalidation.cjs");
const {
@@ -384,7 +385,8 @@ function createScpBackend(deps = {}) {
async function uploadFile(localPath, remotePath, options = {}) {
// Always use a fresh size for the SCP wire header so a shrinking/growing
// file between enqueue and open cannot desync the remote scp -t peer.
const st = await fsModule.promises.stat(localPath);
// asarSafeFs: real *.asar sources must not stat/read as archives (#3450).
const st = await statLocal(localPath);
const hasProvidedReadStream = typeof options.openReadStream === "function";
const fileSize = hasProvidedReadStream ? Number(options.fileSize) : st.size;
if (!Number.isSafeInteger(fileSize) || fileSize < 0) {
@@ -438,13 +440,78 @@ function createScpBackend(deps = {}) {
// Arm the final ACK listener before the trailing NUL so a fast remote cannot
// race past waitForAck and hang the upload. Race it with the stream so a
// mid-stream remote error/cancel does not leave an unhandled rejection.
const isCancelled = () => transfer?.cancelled || signal?.aborted;
const finalAck = waitForAck(stream, transfer, signal);
// Attach a rejection handler immediately: if the local open below rejects
// (or the remote stream closes while it is pending), finalAck would
// otherwise reject before Promise.all starts awaiting, surfacing as a
// process-level unhandledRejection for an ordinary failed upload. The
// rejection is also forwarded to the pending-open race below so a remote
// disconnect settles a stalled local open instead of leaving the upload
// waiting until the (possibly unresponsive) local filesystem answers.
let onFinalAckFailure = null;
void finalAck.catch((ackError) => {
const notify = onFinalAckFailure;
onFinalAckFailure = null;
if (typeof notify === "function") notify(ackError);
});
let activeReadStream = null;
let activeReadCompletion = Promise.resolve();
// Open the local source asynchronously (asarSafeFs) so a slow or
// unresponsive filesystem never blocks the main thread; open failures
// reject into the outer catch, which aborts the scp stream. Race the
// open against cancellation so a stalled open (e.g. an unresponsive
// NFS/SMB mount) cannot keep the transfer stuck after the user cancels:
// the race rejects immediately, and a file descriptor that only arrives
// after cancellation is destroyed (closing its fd) instead of leaking.
// The race also settles on an early finalAck rejection (remote close).
const openedReadStream = await new Promise((resolve, reject) => {
const openPromise = hasProvidedReadStream
? Promise.resolve().then(() => options.openReadStream())
: openLocalReadStream(localPath, { highWaterMark: 256 * 1024 });
let settled = false;
let poll = null;
const settle = (fn, value) => {
if (settled) return;
settled = true;
onFinalAckFailure = null;
if (poll) {
clearInterval(poll);
poll = null;
}
fn(value);
};
openPromise.then(
(result) => {
const readStream = result?.stream || result;
if (isCancelled()) {
try { readStream?.destroy?.(); } catch { /* ignore */ }
settle(reject, new Error("Transfer cancelled"));
return;
}
if (settled) {
// The race already settled (e.g. via remote ACK failure); the
// late-arriving descriptor must be destroyed instead of leaking.
try { readStream?.destroy?.(); } catch { /* ignore */ }
return;
}
settle(resolve, result);
},
(err) => settle(reject, err),
);
const cancel = (err) => settle(reject, err || new Error("Transfer cancelled"));
onFinalAckFailure = (ackError) => cancel(ackError);
if (isCancelled()) {
cancel();
return;
}
// Poll the cancel flag so aborts work even while the open is pending.
poll = setInterval(() => {
if (isCancelled()) cancel();
}, 25);
if (typeof poll.unref === "function") poll.unref();
});
const streamDone = new Promise((resolve, reject) => {
const openedReadStream = hasProvidedReadStream
? options.openReadStream()
: fsModule.createReadStream(localPath, { highWaterMark: 256 * 1024 });
const readStream = openedReadStream?.stream || openedReadStream;
activeReadCompletion = Promise.resolve(openedReadStream?.completed);
activeReadStream = readStream;
+109 -14
View File
@@ -16,6 +16,12 @@ const {
resolveEncodingForRequest,
} = require("./sftpBridge.cjs");
const { isScpModeClient, getScpBackendForClient } = require("./sftpBridge/scpBackend.cjs");
const {
statLocal,
openLocal,
openLocalReadStream,
fastPutLocal,
} = require("./asarSafeFs.cjs");
const { executeBoundedSshCommand } = require("./boundedSshExec.cjs");
const { openBoundedSftpChannel } = require("./boundedSftpOpen.cjs");
const {
@@ -730,14 +736,96 @@ async function hashReadable(readable, options = {}) {
const EMPTY_SHA256_HEX = crypto.createHash("sha256").update("").digest("hex");
function hashLocalPrefix(filePath, bytes, options) {
if (!Number.isFinite(bytes) || bytes < 0) return Promise.resolve(null);
if (bytes === 0) return Promise.resolve(EMPTY_SHA256_HEX);
return hashReadable(fs.createReadStream(filePath, { start: 0, end: bytes - 1 }), options);
function localStreamCancellationError() {
const error = new Error("Transfer cancelled");
error.code = "ABORT_ERR";
return error;
}
function hashLocalFile(filePath, options = {}) {
return hashReadable(fs.createReadStream(filePath), options);
// Local fingerprint reads go through asarSafeFs, whose fd open is asynchronous
// and can stay pending for a long time on an unresponsive filesystem (NFS/SMB,
// removable media). Race that open against the cancellation signal so aborting
// the transfer rejects immediately instead of waiting for the open to settle;
// a stream that only arrives after cancellation is destroyed (closing its fd)
// instead of leaking. hashReadable() only covers the post-open stream lifetime.
function raceLocalStreamOpenAgainstAbort(openPromise, signal) {
if (!signal) return openPromise;
return new Promise((resolve, reject) => {
let settled = false;
const destroyLateStream = (stream) => {
try { stream?.destroy?.(); } catch { /* ignore */ }
};
const onAbort = () => {
if (settled) return;
settled = true;
if (lateStream) destroyLateStream(lateStream);
reject(localStreamCancellationError());
};
let lateStream = null;
signal.addEventListener?.("abort", onAbort, { once: true });
// AbortSignal does not replay its event: if the signal was already aborted
// (e.g. while a preceding statLocal() was pending), the listener above will
// never fire. Reject immediately so a stalled open cannot leave the request
// pending; a stream that arrives later is still destroyed via lateStream.
if (signal.aborted) {
settled = true;
signal.removeEventListener?.("abort", onAbort);
reject(localStreamCancellationError());
// Still observe the pending open: destroy a late-resolving stream so its
// fd is closed, and swallow its rejection so it cannot surface as a
// process-level unhandledRejection.
openPromise.then(
(stream) => { destroyLateStream(stream); },
() => { /* already rejected with the cancellation error */ },
);
return;
}
openPromise.then(
(stream) => {
lateStream = stream;
if (settled) {
destroyLateStream(stream);
return;
}
if (signal.aborted) {
settled = true;
destroyLateStream(stream);
signal.removeEventListener?.("abort", onAbort);
reject(localStreamCancellationError());
return;
}
settled = true;
signal.removeEventListener?.("abort", onAbort);
resolve(stream);
},
(error) => {
if (settled) return;
settled = true;
signal.removeEventListener?.("abort", onAbort);
reject(error);
},
);
});
}
// Local source reads go through asarSafeFs so real files named *.asar are not
// mistaken for asar archives by Electron's fs wrapper (#3450).
async function hashLocalPrefix(filePath, bytes, options) {
if (!Number.isFinite(bytes) || bytes < 0) return Promise.resolve(null);
if (bytes === 0) return Promise.resolve(EMPTY_SHA256_HEX);
const stream = await raceLocalStreamOpenAgainstAbort(
openLocalReadStream(filePath, { start: 0, end: bytes - 1 }),
options?.signal,
);
return hashReadable(stream, options);
}
async function hashLocalFile(filePath, options = {}) {
const stream = await raceLocalStreamOpenAgainstAbort(
openLocalReadStream(filePath),
options.signal,
);
return hashReadable(stream, options);
}
async function hashRemotePrefixViaSshCommand(client, remotePath, bytes, options = {}) {
@@ -2204,7 +2292,9 @@ async function uploadViaFastPut(localPath, remotePath, sftp, fileSize, transfer,
return;
}
sftp.fastPut(localPath, remotePath, {
// ssh2 resolves asar-ness via fs.open inside fastXfer (synchronous), so a
// scoped noAsar toggle makes real *.asar files readable (#3450).
fastPutLocal(sftp, localPath, remotePath, {
chunkSize: TRANSFER_CHUNK_SIZE,
concurrency: UPLOAD_TRANSFER_CONCURRENCY,
step: (transferred, _chunk, total) => {
@@ -2274,8 +2364,11 @@ async function uploadFile(
transfer,
encoding,
signal: transfer.signal,
openReadStream: () => {
const stream = fs.createReadStream(localPath, { highWaterMark: 256 * 1024 });
openReadStream: async () => {
// asarSafeFs: real *.asar sources must not be read as archives (#3450).
// Open the fd asynchronously (fs.promises.open) so a slow or
// unresponsive filesystem never blocks the main thread on fs.openSync.
const stream = await openLocalReadStream(localPath, { highWaterMark: 256 * 1024 });
return { stream, completed: Promise.resolve() };
},
onProgress: (transferred, total) => sendProgress(transferred, total || fileSize),
@@ -2291,7 +2384,7 @@ async function uploadFile(
transfer.pauseSupported = Boolean(transfer.resumable);
const originalLocalPath = localPath;
const initialSource = (transfer.resumable || !transfer.sourceIsOwnedTemp)
? await fs.promises.stat(originalLocalPath)
? await statLocal(originalLocalPath)
: null;
/** @type {Error | null} */
@@ -2307,7 +2400,7 @@ async function uploadFile(
const finishSuccessfulUpload = async () => {
if (initialSource) {
const latestSource = await fs.promises.stat(originalLocalPath);
const latestSource = await statLocal(originalLocalPath);
// Soft size + mtime/ino (no full-file re-hash). Do not claim separate
// content proof — same-size rewrites that bump mtime still fail closed.
// ignoreCtime: macOS xattr/Spotlight noise must not abort a true match.
@@ -4435,7 +4528,8 @@ async function uploadFileConcurrent(
try {
if (transfer.cancelled) throw new Error("Transfer cancelled");
try {
localHandle = await fs.promises.open(localPath, "r");
// asarSafeFs: real *.asar sources must not be opened as archives (#3450).
localHandle = await openLocal(localPath, "r");
} catch (error) {
failed = true;
noTransferFallback = true;
@@ -5510,7 +5604,7 @@ async function startTransferNow(event, payload, onProgress) {
const SOFT_RESUME_SAMPLE_BYTES = 256 * 1024;
const readSourceSoftIdentity = async () => {
if (sourceType === "local") {
const st = await fs.promises.stat(sourcePath);
const st = await statLocal(sourcePath);
const sampleBytes = Math.min(SOFT_RESUME_SAMPLE_BYTES, Math.max(0, st.size));
const sample = sampleBytes > 0
? await hashLocalPrefix(sourcePath, sampleBytes, { signal: transfer.signal })
@@ -5778,7 +5872,8 @@ async function startTransferNow(event, payload, onProgress) {
if (!hasExplicitTotal) {
if (sourceType === 'local') {
const stat = await fs.promises.stat(sourcePath);
// asarSafeFs: local *.asar sources stat as archive roots otherwise (#3450).
const stat = await statLocal(sourcePath);
fileSize = stat.size;
} else if (sourceType === 'sftp') {
const client = sftpClients.get(sourceSftpId);
+4 -4
View File
@@ -608,7 +608,7 @@ test("SCP upload streams the live local path without a pre-hash digest", async (
uploadSourcePath = sourcePath;
// May still receive a plain file stream helper; must not require a digest.
if (typeof options.openReadStream === "function") {
const opened = options.openReadStream();
const opened = await options.openReadStream();
const chunks = [];
for await (const chunk of opened.stream) chunks.push(chunk);
remoteFiles.set(remotePath, Buffer.concat(chunks));
@@ -670,7 +670,7 @@ test("SCP staged streaming preserves executable mode without a local snapshot",
uploadedSourcePath = sourcePath;
uploadedMode = (await fs.promises.stat(sourcePath)).mode & 0o777;
assert.equal(options.fileSize, payload.length);
const opened = options.openReadStream();
const opened = await options.openReadStream();
const chunks = [];
for await (const chunk of opened.stream) chunks.push(chunk);
remoteFiles.set(remotePath, Buffer.concat(chunks));
@@ -744,7 +744,7 @@ test("SCP staged upload rechecks a deleted destination after mode setup", async
};
},
async uploadFile(_sourcePath, remotePath, options) {
const opened = options.openReadStream();
const opened = await options.openReadStream();
const chunks = [];
for await (const chunk of opened.stream) chunks.push(chunk);
remoteFiles.set(remotePath, Buffer.concat(chunks));
@@ -813,7 +813,7 @@ test("SCP staged upload does not promote when AbortSignal cancels mid-stream", a
return { type: "file", isDirectory: false, size: remoteFiles.get(remotePath).length };
},
async uploadFile(_sourcePath, remotePath, options) {
const opened = options.openReadStream();
const opened = await options.openReadStream();
const chunks = [];
let first = true;
for await (const chunk of opened.stream) {