mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf7c3fab4 | ||
|
|
cf4b293106 | ||
|
|
a7fa3a5375 | ||
|
|
c648edf22e | ||
|
|
65defed89d | ||
|
|
6d0c10e8a2 | ||
|
|
8c0f3db756 | ||
|
|
9ad9b1d204 | ||
|
|
091eefd96f | ||
|
|
a973c4f549 | ||
|
|
85e3177e66 | ||
|
|
fb1265f425 | ||
|
|
04569c0d70 | ||
|
|
cea486cc4b | ||
|
|
a64f4188dd | ||
|
|
f306a584e0 | ||
|
|
f9d6dedd2a | ||
|
|
ceb068bda8 | ||
|
|
2c579505ee | ||
|
|
bce08b998a | ||
|
|
c0a271fea5 | ||
|
|
4b99034754 | ||
|
|
b7c6f01dc3 | ||
|
|
9fa04d8772 | ||
|
|
67c606d212 | ||
|
|
bf24c255f7 | ||
|
|
c8f13fe5d8 | ||
|
|
9b1ac09810 | ||
|
|
ec45fd195c | ||
|
|
e10d624fd9 | ||
|
|
b84e577e1e | ||
|
|
04cfd7bf6f | ||
|
|
8efbae3088 | ||
|
|
9c407ab01d | ||
|
|
2e0edc594f | ||
|
|
4040355c1d | ||
|
|
5f7c48dac9 | ||
|
|
3d73061b83 | ||
|
|
57ce359def | ||
|
|
e36e23bce1 | ||
|
|
dfd7be5d8c | ||
|
|
57f34b576c | ||
|
|
ab1a6deb80 | ||
|
|
16a4cb3477 | ||
|
|
a5e41937b9 | ||
|
|
1c7633d1a8 | ||
|
|
3ff523f85f | ||
|
|
13e5b668b7 |
No files matched your search
@@ -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; });
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user