Compare commits

...
Author SHA1 Message Date
bincxz 3cf7c3fab4 fix(sftp): preserve ordinary cancellation before fallback output 2026-10-15 12:08:58 +08:00
bincxz cf4b293106 fix(sftp): tolerate permission refusal for optional metadata only 2026-10-15 11:56:42 +08:00
bincxz a7fa3a5375 test(sftp): inject mode failures through prepared file handles 2026-10-15 11:41:17 +08:00
bincxz c648edf22e fix(sftp): keep GVFS save fallback focused on writable data 2026-10-15 11:37:49 +08:00
bincxz 65defed89d Merge remote-tracking branch 'origin/main' into codex/cleanup-pr-3612 2026-10-15 11:32:17 +08:00
陈奇 6d0c10e8a2 fix(sftp): preserve unverified target after failed relabel 2026-10-12 04:07:36 +00:00
陈奇 8c0f3db756 fix(sftp): close prepared handle after stat failure 2026-10-12 02:43:55 +00:00
netcatty-bot 9ad9b1d204 fix: address Codex review on PR #3612 2026-10-09 09:03:55 +00:00
netcatty-bot 091eefd96f fix: address Codex review on PR #3612 2026-10-09 08:32:41 +00:00
netcatty-bot a973c4f549 fix: address Codex review on PR #3612 2026-10-09 07:51:06 +00:00
netcatty-bot 85e3177e66 fix: address Codex review on PR #3612 2026-10-09 07:17:20 +00:00
netcatty-bot fb1265f425 fix: address Codex review on PR #3612 2026-10-09 06:40:44 +00:00
netcatty-bot 04569c0d70 fix: address Codex review on PR #3612 2026-10-09 06:15:12 +00:00
netcatty-bot cea486cc4b fix: address Codex review on PR #3612 2026-10-09 05:43:21 +00:00
netcatty-bot a64f4188dd fix: address Codex review on PR #3612 2026-10-09 04:56:37 +00:00
netcatty-bot f306a584e0 fix: address Codex review on PR #3612 2026-10-09 04:14:02 +00:00
netcatty-bot f9d6dedd2a fix: address Codex review on PR #3612 2026-10-09 03:50:17 +00:00
netcatty-bot ceb068bda8 fix: address Codex review on PR #3612 2026-10-09 03:00:03 +00:00
netcatty-bot 2c579505ee fix: address Codex review on PR #3612 2026-10-09 02:28:59 +00:00
netcatty-bot bce08b998a fix: address Codex review on PR #3612 2026-10-09 02:00:47 +00:00
netcatty-bot c0a271fea5 fix: address Codex review on PR #3612 2026-10-09 01:22:54 +00:00
netcatty-bot 4b99034754 fix: address Codex review on PR #3612 2026-10-09 00:03:39 +00:00
netcatty-bot b7c6f01dc3 fix: address Codex review on PR #3612 2026-10-08 23:35:31 +00:00
netcatty-bot 9fa04d8772 fix: address Codex review on PR #3612 2026-10-08 22:55:59 +00:00
netcatty-bot 67c606d212 fix: address Codex review on PR #3612 2026-10-08 22:16:43 +00:00
netcatty-bot bf24c255f7 fix: address Codex review on PR #3612 2026-10-08 21:46:46 +00:00
netcatty-bot c8f13fe5d8 fix: address Codex review on PR #3612 2026-10-08 21:11:26 +00:00
netcatty-bot 9b1ac09810 fix: address Codex review on PR #3612 2026-10-08 20:44:38 +00:00
netcatty-bot ec45fd195c fix: address Codex review on PR #3612 2026-10-08 20:08:38 +00:00
netcatty-bot e10d624fd9 fix: address Codex review on PR #3612 2026-10-08 19:39:03 +00:00
netcatty-bot b84e577e1e fix: address Codex review on PR #3612 2026-10-08 19:11:26 +00:00
netcatty-bot 04cfd7bf6f fix: address Codex review on PR #3612 2026-10-08 18:45:10 +00:00
netcatty-bot 8efbae3088 fix: address Codex review on PR #3612 2026-10-08 18:16:33 +00:00
netcatty-bot 9c407ab01d fix: address Codex review on PR #3612 2026-10-08 17:53:38 +00:00
netcatty-bot 2e0edc594f fix: address Codex review on PR #3612 2026-10-08 17:20:35 +00:00
netcatty-bot 4040355c1d fix: address Codex review on PR #3612 2026-10-08 16:51:41 +00:00
netcatty-bot 5f7c48dac9 fix: address Codex review on PR #3612 2026-10-08 16:22:22 +00:00
netcatty-bot 3d73061b83 fix: address Codex review on PR #3612 2026-10-08 15:34:51 +00:00
netcatty-bot 57ce359def fix: address Codex review on PR #3612 2026-10-08 15:10:22 +00:00
netcatty-bot e36e23bce1 fix: address Codex review on PR #3612 2026-10-08 14:41:48 +00:00
netcatty-bot dfd7be5d8c fix: address Codex review on PR #3612 2026-10-08 14:14:06 +00:00
netcatty-bot 57f34b576c fix: address Codex review on PR #3612 2026-10-08 13:46:31 +00:00
netcatty-bot ab1a6deb80 fix: address Codex review on PR #3612 2026-10-08 13:01:04 +00:00
netcatty-bot 16a4cb3477 fix: address Codex review on PR #3612 2026-10-08 12:10:15 +00:00
netcatty-bot a5e41937b9 fix: address Codex review on PR #3612 2026-10-08 11:43:36 +00:00
netcatty-bot 1c7633d1a8 fix: address Codex review on PR #3612 2026-10-08 11:17:14 +00:00
netcatty-bot 3ff523f85f fix: address Codex review on PR #3612 2026-10-08 10:47:21 +00:00
netcatty-bot 13e5b668b7 fix(#3611): automated AI fix 2026-10-08 10:08:10 +00:00
4 changed files with 352 additions and 49 deletions

No files matched your search

+45 -15
View File
@@ -8,24 +8,27 @@ const fs = require("node:fs");
// but no pathname cleanup may remove a concurrent writer's replacement.
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
// verify an open destination handle before later metadata stamping.
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle, options = {}) {
assertNotCancelled();
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
if (!options.copyOnly) {
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
}
let input;
let output;
let failure;
let publishedIdentity;
let timestampsPreserved = true;
try {
input = preparedHandle ?? await fs.promises.open(source, "r");
const stat = await input.stat();
@@ -49,14 +52,31 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
assertNotCancelled();
// Restore metadata after writes (which may clear special permission bits),
// through the owned handle rather than a potentially replaced pathname.
await output.chmod(stat.mode & 0o7777);
await output.utimes(stat.atime, stat.mtime);
const mode = stat.mode & 0o7777;
if (((await output.stat()).mode & 0o7777) !== mode) {
try {
await output.chmod(mode);
} catch (error) {
// New files follow the mount's access policy (GVFS can synthesize mode
// bits). Replacements must still preserve the validated protection.
if (!isOptionalMetadataError(error) || options.preserveMode !== false) throw error;
}
if (options.preserveMode !== false && ((await output.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
try {
await output.utimes(stat.atime, stat.mtime);
} catch (error) {
if (!isOptionalMetadataError(error) || options.requireTimestamps !== false) throw error;
timestampsPreserved = false;
}
const ownedStat = await output.stat();
const targetStat = await fs.promises.lstat(target);
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
throw new Error("Local download target changed during replacement");
}
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size, timestampsPreserved };
} catch (error) {
failure = error;
} finally {
@@ -73,4 +93,14 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
return publishedIdentity;
}
module.exports = { publishLocalFileExclusive };
// Only optional chmod/utimes may ignore permission refusal. Never use this
// predicate for creation, data writes, close, or required permission changes.
function isOptionalMetadataError(error) {
return isMetadataUnsupportedError(error) || ["EPERM", "EACCES"].includes(error?.code);
}
function isMetadataUnsupportedError(error) {
return ["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(error?.code);
}
module.exports = { publishLocalFileExclusive, isMetadataUnsupportedError };
@@ -560,6 +560,54 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal cancels and clears the stage before fallback creates output", async (t) => {
const controller = new AbortController();
const payload = Buffer.from("complete-download-before-cancel");
const localPath = path.join(tmpDir, "cancel-before-fallback.bin");
fs.writeFileSync(localPath, "original");
let stagedPath;
t.after(() => { if (stagedPath) fs.rmSync(stagedPath, { force: true }); });
const events = [];
sftpClients.set("scp-pre-fallback-cancel", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: {
async stat() { return { type: "file", isDirectory: false, size: payload.length }; },
async downloadFile(_remotePath, stage) {
stagedPath = stage;
await fs.promises.writeFile(stage, payload);
return { fileSize: payload.length, transferred: payload.length };
},
},
});
for (const name of ["rename", "copyFile"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (args[0] === stagedPath && String(args[1]).endsWith(".ready")) {
if (name === "copyFile") controller.abort();
throw Object.assign(new Error("injected cross-device copy refusal"), {
code: name === "rename" ? "EXDEV" : "ENOTSUP",
});
}
return original.apply(fs.promises, args);
};
}
await assert.rejects(() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-pre-fallback-cancel", remotePath: "/remote/file.bin", localPath,
abortSignal: controller.signal,
onTransferEvent: (channel) => events.push(channel),
}), (error) => {
assert.match(error.message, /cancel|abort/i);
assert.doesNotMatch(error.message, /Recovery files preserved/);
return true;
});
assert.ok(stagedPath);
assert.equal(fs.existsSync(stagedPath), false);
assert.equal(fs.readFileSync(localPath, "utf8"), "original");
assert.ok(events.includes("netcatty:transfer:cancelled"));
assert.equal(events.includes("netcatty:transfer:error"), false);
});
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
skip: process.platform === "win32",
}, async (t) => {
@@ -582,16 +630,11 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async () => {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
});
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -630,18 +673,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
const original = Buffer.from("original-before-late-change");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-directory",
@@ -685,18 +724,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-same-mode-replacement",
@@ -986,3 +1021,17 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
});
});
function interceptPreparedChmod(t, callback) {
const originalOpen = fs.promises.open;
fs.promises.open = async (filePath, ...args) => {
const handle = await originalOpen.call(fs.promises, filePath, ...args);
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready") && args[0] === "r") {
const chmod = handle.chmod.bind(handle);
handle.chmod = (mode) => callback(chmod, mode);
}
return handle;
};
t.after(() => { fs.promises.open = originalOpen; });
}
+41 -6
View File
@@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) {
}
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs");
const assertNotCancelled = options.assertNotCancelled || (() => {});
const token = crypto.randomUUID().replace(/-/g, "");
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
@@ -1050,6 +1050,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
let backedUp = false;
let committed = false;
let keepRecoveryFiles = false;
let skipReadyCleanup = false;
let preparedHandle;
let originalHandle;
let restoreProbeCreated = false;
@@ -1060,7 +1061,28 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
await fs.promises.rename(stagedPath, readyPath);
} catch (error) {
if (error?.code !== "EXDEV") throw error;
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
try {
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
} catch (copyError) {
if (!["ENOTSUP", "EOPNOTSUPP"].includes(copyError?.code)) throw copyError;
// copyFile includes metadata operations that writable GVFS mounts may
// reject. Reuse the exclusive bounded read/write publication path.
try {
await publishLocalFileExclusive(stagedPath, readyPath, assertNotCancelled, undefined, {
copyOnly: true, preserveMode: false, requireTimestamps: false,
});
} catch (fallbackError) {
// Do not remove a name this fallback never owned: copyFile may
// have left a partial, or an exclusive open may have lost a race.
skipReadyCleanup = true;
const cancelledBeforeOutput = !fallbackError.localPublicationIncomplete
&& (fallbackError.code === "ABORT_ERR" || fallbackError.message === "Transfer cancelled");
// An early cancellation needs ordinary stage cleanup, not recovery.
// Once output exists, retain the complete stage as before.
keepRecoveryFiles = !cancelledBeforeOutput;
throw fallbackError;
}
}
}
// Stamp the private prepared file before applying possibly unreadable
// destination permissions. Publication carries these times to the target.
@@ -1085,7 +1107,17 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
? validatedTarget.existingMode & 0o7777
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
if (mode !== null && mode !== appliedMode) {
await fs.promises.chmod(readyPath, mode);
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
try {
await preparedHandle.chmod(mode);
} catch (error) {
if (!isMetadataUnsupportedError(error)
|| ((await preparedHandle.stat()).mode & 0o7777) !== mode) throw error;
}
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
appliedMode = mode;
continue;
}
@@ -1131,7 +1163,9 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
assertNotCancelled();
let publishedIdentity = null;
try {
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, {
preserveMode: appliedMode !== null, requireTimestamps: false,
}) ?? null;
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("Local download target changed during replacement", { cause: error });
@@ -1144,12 +1178,13 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
committed = true;
// Hand the published inode identity to the caller for descriptor-based
// metadata stamping after publication.
options.onCommit?.(publishedIdentity, localMtimePrepared);
options.onCommit?.(publishedIdentity, localMtimePrepared && publishedIdentity?.timestampsPreserved !== false);
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(stagedPath).catch(() => {});
} catch (error) {
if (committed) throw error;
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
if (backedUp && !keepRecoveryFiles) {
try {
// The pathname may have changed between open and rename. Never copy
@@ -1179,7 +1214,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
if (backedUp) failure.remoteBackupPath = backupPath;
throw failure;
}
await fs.promises.unlink(readyPath).catch(() => {});
if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {});
throw error;
} finally {
await preparedHandle?.close().catch(() => {});
@@ -0,0 +1,189 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const bridge = require("./transferBridge.cjs");
const temp = require("./tempDirBridge.cjs");
const unsupported = (code = "ENOTSUP") => Object.assign(new Error(`Injected ${code}`), { code });
// Real files and handles; only unavailable mount operations and virtual mode
// reporting are simulated. This is not a real Linux/GVFS acceptance test.
async function mountedDestination(t, options = {}) {
const root = fs.mkdtempSync(`${temp.getTempFilePath("gvfs-publish")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const stage = path.join(root, "stage.part");
const target = path.join(root, "target");
const payload = Buffer.alloc(2 * 1024 * 1024 + 17, 173);
fs.writeFileSync(stage, payload, { mode: 0o644 });
if (options.existingMode !== undefined) {
fs.writeFileSync(target, "original");
fs.chmodSync(target, options.existingMode);
}
const calls = [];
let cancelled = false;
for (const name of ["rename", "copyFile", "link", "open"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (name === "rename" && args[0] === stage) throw unsupported("EXDEV");
if (name === "copyFile") {
calls.push("copyFile");
if (options.readyCollision) fs.writeFileSync(args[1], "other writer");
if (options.cancelBeforeFallback) cancelled = true;
if (options.copyError !== false) throw unsupported(options.copyError || "ENOTSUP");
}
if (name === "link") throw unsupported();
const result = await original.apply(fs.promises, args);
if (name !== "open" || args[0] === stage) return result;
const created = args[1] === "wx";
const virtualMode = args[0] === target ? options.finalVirtualMode ?? options.virtualMode : options.virtualMode;
if (created && virtualMode !== undefined) fs.chmodSync(args[0], virtualMode);
result.chmod = async () => { calls.push("chmod"); throw unsupported(options.metadataError); };
result.utimes = async () => { calls.push("utimes"); throw unsupported(options.metadataError); };
if (created) {
const final = args[0] === target;
calls.push(final ? "final-open" : "ready-open");
const write = result.write.bind(result);
result.write = async (buffer, offset, length, position) => {
if (options.writeError && final) throw unsupported(options.writeError);
const written = await write(buffer, offset, Math.min(length, 32768), position);
if (options.cancel && !final) cancelled = true;
return written;
};
const close = result.close.bind(result);
result.close = async () => {
await close();
if (options.closeError === (final ? "final" : "ready")) throw unsupported("EIO");
};
}
return result;
};
}
const run = () => bridge._promoteLocalTransferForTests(stage, target, {
sourceSoftIdentity: { mtimeMs: 1700000000000 },
validateTarget: async () => ({
targetIdentity: options.existingMode === undefined ? "missing" : undefined,
existingMode: options.existingMode,
}),
assertNotCancelled: () => {
if (cancelled || (options.cancelBeforeFinal && !fs.existsSync(target))) throw unsupported("ABORT_ERR");
},
onCommit: (_identity, timestampsPreserved) => { calls.push(["commit", timestampsPreserved]); },
});
return { root, stage, target, payload, calls, run };
}
for (const [virtualMode, copyError, metadataError] of [[0o644, "ENOTSUP"], [0o700, "EOPNOTSUPP"], [0o700, "ENOTSUP", "EPERM"], [0o700, "ENOTSUP", "EACCES"]]) {
test(`new mounted destination accepts creation policy ${virtualMode.toString(8)} with ${metadataError || "ENOTSUP"} through both copy stages`, async (t) => {
const setup = await mountedDestination(t, { virtualMode, copyError, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
assert.ok(setup.calls.includes("ready-open"));
assert.ok(setup.calls.includes("final-open"));
assert.deepEqual(setup.calls.at(-1), ["commit", false]);
});
}
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`existing mounted file keeps matching virtual permissions: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o700, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o700);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
});
test(`refused chmod cannot broaden an existing destination: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o600, metadataError });
await assert.rejects(setup.run, { code: metadataError });
assert.equal(fs.readFileSync(setup.target, "utf8"), "original");
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o600);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const copyError of ["EACCES", "EPERM", "ENOSPC", "EIO", "EEXIST", "EINVAL"]) {
test(`copyFile ${copyError} remains a failure`, async (t) => {
const setup = await mountedDestination(t, { copyError });
await assert.rejects(setup.run, { code: copyError });
assert.equal(fs.existsSync(setup.target), false);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.includes("ready-open"), false);
});
}
for (const failure of [{ cancel: true }, { closeError: "ready" }, { closeError: "final" }, { writeError: "ENOSPC" }, { writeError: "EPERM" }, { writeError: "EACCES" }]) {
test(`copy failure preserves original and complete stage: ${JSON.stringify(failure)}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, ...failure });
await assert.rejects(setup.run, /Recovery files preserved/);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.equal(fs.readFileSync(backup ? path.join(setup.root, backup) : setup.target, "utf8"), "original");
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
test("failed copy followed by exclusive-open collision leaves that entry intact", async (t) => {
const setup = await mountedDestination(t, { readyCollision: true });
await assert.rejects(setup.run, /Recovery files preserved/);
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(fs.existsSync(setup.target), false);
});
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const metadataError of ["EPERM", "EACCES"]) {
test(`final copy cannot broaden an existing destination after chmod ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, {
existingMode: 0o644, virtualMode: 0o644, finalVirtualMode: 0o700, metadataError,
});
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
for (const readyCollision of [false, true]) {
test(`cancellation before fallback output remains cancellation, existing ready=${readyCollision}`, async (t) => {
const setup = await mountedDestination(t, { cancelBeforeFallback: true, readyCollision });
const originalLstat = fs.promises.lstat;
const originalUnlink = fs.promises.unlink;
t.after(() => { fs.promises.lstat = originalLstat; fs.promises.unlink = originalUnlink; });
for (const [name, original] of [["lstat", originalLstat], ["unlink", originalUnlink]]) {
fs.promises[name] = async (...args) => {
assert.equal(String(args[0]).endsWith(".ready"), false, "no destination probe/cleanup after early cancellation");
return original.apply(fs.promises, args);
};
}
await assert.rejects(setup.run, (error) => {
assert.equal(error.code, "ABORT_ERR");
assert.equal(Boolean(error.recoveryFailed), false);
return true;
});
assert.equal(setup.calls.includes("ready-open"), false);
assert.equal(fs.existsSync(setup.target), false);
if (readyCollision) {
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
} else assert.deepEqual(fs.readdirSync(setup.root), ["stage.part"]);
});
}