mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bbaa1723 | ||
|
|
7639bd83d5 | ||
|
|
a0c31a3b24 | ||
|
|
5a030a91ae | ||
|
|
3eeece75a4 | ||
|
|
5d6c487fc6 | ||
|
|
ec1868a069 | ||
|
|
7b32ec68fd | ||
|
|
eb9e4ee4be | ||
|
|
de4412c9d6 |
No files matched your search
@@ -0,0 +1,43 @@
|
||||
# Local transfer publication safety audit
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
The completed download moves an existing destination to a backup, checks that
|
||||
its pathname is unoccupied, then renames the prepared download into place.
|
||||
Another program can save to that pathname between the check and rename. The
|
||||
rename overwrites those bytes and successful cleanup deletes the original
|
||||
backup. Backup restoration and post-publication rollback have equivalent races.
|
||||
|
||||
Real filesystem regressions inject concurrent creation at the actual final
|
||||
publication and restoration boundary. Both lose concurrent contents on the
|
||||
baseline. An explicitly absent destination can also appear after validation and
|
||||
be incorrectly moved aside. These tests fail before the change.
|
||||
|
||||
## Contract and design
|
||||
|
||||
- Prepared data and an original backup remain private sibling files.
|
||||
- Publication and backup restoration use the same no-overwrite primitive.
|
||||
- On hardlink-capable filesystems, linking publishes complete bytes atomically.
|
||||
- On filesystems without that operation, exclusive open plus writes through the
|
||||
owned handle preserves compatibility without overwriting another destination.
|
||||
This fallback exposes partial contents during copying; it does not promise
|
||||
atomic visibility. It never deletes the destination pathname on failure.
|
||||
- Failed fallback copies retain complete prepared data and original backup, with
|
||||
their locations in the error. A concurrent replacement is never removed.
|
||||
- Successful publication is the commit boundary. Cancellation checked before it
|
||||
restores the original where possible. Cancellation arriving after publication
|
||||
does not attempt unsafe pathname-based rollback.
|
||||
- A validated absent target is distinct from an omitted validation callback.
|
||||
- This change does not claim to prevent another program writing through an
|
||||
already-open handle or provide durable power-loss transactions.
|
||||
|
||||
## Validation
|
||||
|
||||
The focused tests cover publication and restoration boundary races, absent
|
||||
validation, normal mode-preserving replacement, early/late cancellation,
|
||||
unsupported-hardlink fallback and write failure with concurrent replacement.
|
||||
Real unsupported-filesystem hardware has not been exercised; the fallback uses
|
||||
real files with hardlink capability failure injected.
|
||||
|
||||
Related audit themes: #3186 replacement attributes and #3213 interrupted recovery.
|
||||
These reports do not establish the cause of this separately reproduced defect.
|
||||
@@ -0,0 +1,73 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
// Publish a prepared regular file without replacing any destination entry.
|
||||
// Hardlinks make complete bytes visible atomically. FAT-like filesystems use an
|
||||
// exclusively created handle instead: partial contents can be visible there,
|
||||
// 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) {
|
||||
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) {
|
||||
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV"].includes(error?.code)) throw error;
|
||||
}
|
||||
|
||||
let input;
|
||||
let output;
|
||||
let failure;
|
||||
let publishedIdentity;
|
||||
try {
|
||||
input = preparedHandle ?? await fs.promises.open(source, "r");
|
||||
const stat = await input.stat();
|
||||
assertNotCancelled();
|
||||
output = await fs.promises.open(target, "wx", stat.mode & 0o7777);
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let position = 0;
|
||||
while (position < stat.size) {
|
||||
assertNotCancelled();
|
||||
const { bytesRead } = await input.read(buffer, 0, Math.min(buffer.length, stat.size - position), position);
|
||||
if (!bytesRead) throw new Error("Prepared local file ended before publication completed");
|
||||
let written = 0;
|
||||
while (written < bytesRead) {
|
||||
assertNotCancelled();
|
||||
const { bytesWritten } = await output.write(buffer, written, bytesRead - written, position + written);
|
||||
if (!bytesWritten) throw new Error("Local publication made no write progress");
|
||||
written += bytesWritten;
|
||||
}
|
||||
position += bytesRead;
|
||||
}
|
||||
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 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 };
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
for (const handle of [output, preparedHandle ? undefined : input]) {
|
||||
try { await handle?.close(); } catch (error) { failure ??= error; }
|
||||
}
|
||||
}
|
||||
if (failure) {
|
||||
// Once exclusive creation succeeded, leave the partial destination intact.
|
||||
// The caller must retain its complete prepared file and any original backup.
|
||||
if (output) failure.localPublicationIncomplete = true;
|
||||
throw failure;
|
||||
}
|
||||
return publishedIdentity;
|
||||
}
|
||||
|
||||
module.exports = { publishLocalFileExclusive };
|
||||
@@ -755,11 +755,10 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 3, true, "the backup should be restored after cancellation");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal rolls back a published file when cancellation wins the final rename", async (t) => {
|
||||
it("downloadSftpToLocal keeps the committed download when cancellation arrives after publication", async (t) => {
|
||||
const controller = new AbortController();
|
||||
const downloaded = Buffer.from("new-downloaded-content");
|
||||
const backend = {
|
||||
@@ -778,26 +777,27 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
const original = Buffer.from("existing-local-content");
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) controller.abort();
|
||||
const originalLink = fs.promises.link;
|
||||
let published = false;
|
||||
fs.promises.link = async (...args) => {
|
||||
await originalLink(...args);
|
||||
if (path.basename(args[1]) === path.basename(localPath) && String(args[0]).endsWith(".ready")) {
|
||||
published = true;
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
t.after(() => { fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
await assert.doesNotReject(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-final-rename-abort",
|
||||
remotePath: "/remote/file.bin",
|
||||
localPath,
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
/cancel|abort/i,
|
||||
);
|
||||
assert.equal(renameCalls >= 4, true, "the published file should be rolled back to the backup");
|
||||
assert.deepEqual(fs.readFileSync(localPath), original);
|
||||
assert.equal(published, true);
|
||||
assert.deepEqual(fs.readFileSync(localPath), downloaded);
|
||||
});
|
||||
|
||||
it("downloadSftpToLocal reports and preserves the backup when cancellation rollback fails", async (t) => {
|
||||
@@ -820,20 +820,20 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
const originalLink = fs.promises.link;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 4) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 2) backupPath = args[1];
|
||||
if (renameCalls === 3) controller.abort();
|
||||
if (path.basename(args[0]) === path.basename(localPath) && String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -843,7 +843,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, /Backup:/);
|
||||
assert.doesNotMatch(error.message, /^Transfer cancelled$/);
|
||||
return true;
|
||||
@@ -877,11 +877,6 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 3) {
|
||||
const error = new Error("injected backup restore failure");
|
||||
error.code = "EIO";
|
||||
throw error;
|
||||
}
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
@@ -889,7 +884,12 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
const originalLink = fs.promises.link;
|
||||
fs.promises.link = async (...args) => {
|
||||
if (String(args[0]).endsWith(".backup")) throw Object.assign(new Error("injected restore failure"), { code: "EIO" });
|
||||
return originalLink(...args);
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; fs.promises.link = originalLink; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -899,7 +899,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
abortSignal: controller.signal,
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Could not restore the original file/);
|
||||
assert.match(error.message, /Recovery files preserved/);
|
||||
assert.match(error.message, new RegExp(readyPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.match(error.message, new RegExp(backupPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
return true;
|
||||
|
||||
+145
-188
@@ -950,219 +950,151 @@ function stableLocalFileIdentity(statLike) {
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const assertNotCancelled = typeof options.assertNotCancelled === "function"
|
||||
? options.assertNotCancelled
|
||||
: () => {};
|
||||
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
|
||||
const assertNotCancelled = options.assertNotCancelled || (() => {});
|
||||
const token = crypto.randomUUID().replace(/-/g, "");
|
||||
const targetDir = path.dirname(targetPath);
|
||||
const targetBase = path.basename(targetPath);
|
||||
const readyPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.ready`);
|
||||
const backupPath = path.join(targetDir, `.${targetBase}.netcatty-${token}.backup`);
|
||||
let preparedPath = stagedPath;
|
||||
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
|
||||
const readyPath = `${base}.ready`;
|
||||
const backupPath = `${base}.backup`;
|
||||
const restoreProbePath = `${base}.restore-check`;
|
||||
let backedUp = false;
|
||||
let published = false;
|
||||
let publishedIdentity = null;
|
||||
let committed = false;
|
||||
let keepRecoveryFiles = false;
|
||||
let preparedHandle;
|
||||
let originalHandle;
|
||||
let restoreProbeCreated = false;
|
||||
let localMtimePrepared = false;
|
||||
try {
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(stagedPath, readyPath);
|
||||
} catch (err) {
|
||||
if (err?.code !== "EXDEV") throw err;
|
||||
await fs.promises.copyFile(stagedPath, readyPath);
|
||||
preparedPath = readyPath;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") throw error;
|
||||
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
|
||||
}
|
||||
if (preparedPath !== readyPath) preparedPath = readyPath;
|
||||
// Stamp the private prepared file before applying possibly unreadable
|
||||
// destination permissions. Publication carries these times to the target.
|
||||
const mtimeMs = Number(options.sourceSoftIdentity?.mtimeMs);
|
||||
if (Number.isFinite(mtimeMs) && mtimeMs >= 1000) {
|
||||
const when = new Date(Math.floor(mtimeMs / 1000) * 1000);
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(readyPath, when, when), 15_000, "Prepared destination utimes",
|
||||
).then(() => { localMtimePrepared = true; }).catch(() => {});
|
||||
}
|
||||
// Keep read access to our private bytes before destination permissions
|
||||
// can remove it; the no-hardlink fallback copies through this handle.
|
||||
preparedHandle = await fs.promises.open(readyPath, "r");
|
||||
let appliedMode = null;
|
||||
let targetStable = false;
|
||||
let expectedStableIdentity = null;
|
||||
let validatedTarget;
|
||||
let stable = false;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const validatedTarget = typeof options.validateTarget === "function"
|
||||
validatedTarget = typeof options.validateTarget === "function"
|
||||
? await options.validateTarget()
|
||||
: null;
|
||||
const existingMode = Number.isInteger(validatedTarget?.existingMode)
|
||||
: undefined;
|
||||
const mode = Number.isInteger(validatedTarget?.existingMode)
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode)
|
||||
? options.existingMode & 0o7777
|
||||
: null;
|
||||
if (existingMode !== null && existingMode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, existingMode);
|
||||
appliedMode = existingMode;
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
if (mode !== null && mode !== appliedMode) {
|
||||
await fs.promises.chmod(readyPath, mode);
|
||||
appliedMode = mode;
|
||||
continue;
|
||||
}
|
||||
expectedStableIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity
|
||||
? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":")
|
||||
: null);
|
||||
targetStable = true;
|
||||
stable = true;
|
||||
break;
|
||||
}
|
||||
if (!targetStable) {
|
||||
throw new Error("Local download target kept changing before replacement");
|
||||
}
|
||||
if (!stable) throw new Error("Local download target kept changing before replacement");
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
// Another process may have replaced the destination between validateTarget
|
||||
// and rename. Re-check the moved backup before publishing the download.
|
||||
if (backedUp && expectedStableIdentity) {
|
||||
let backupStat;
|
||||
const expectedAbsent = validatedTarget?.targetIdentity === "missing" || validatedTarget?.targetIdentity === null;
|
||||
const expectedIdentity = validatedTarget?.stableIdentity
|
||||
|| (validatedTarget?.targetIdentity ? String(validatedTarget.targetIdentity).split(":").slice(0, 3).join(":") : null);
|
||||
if (!expectedAbsent) {
|
||||
try {
|
||||
backupStat = await fs.promises.lstat(backupPath);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Local download target disappeared during replacement: ${targetPath}`,
|
||||
{ cause: err },
|
||||
);
|
||||
originalHandle = await fs.promises.open(targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
||||
// An unreadable original cannot use copy-based recovery. Verify the
|
||||
// non-overwriting alternative before moving its only visible name.
|
||||
try {
|
||||
await fs.promises.link(targetPath, restoreProbePath);
|
||||
restoreProbeCreated = true;
|
||||
await fs.promises.unlink(restoreProbePath);
|
||||
restoreProbeCreated = false;
|
||||
} catch (restoreError) {
|
||||
throw new Error("Cannot safely replace unreadable local destination: hardlink recovery unavailable", { cause: restoreError });
|
||||
}
|
||||
} else if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
if (!backupStat.isFile() || stableLocalFileIdentity(backupStat) !== expectedStableIdentity) {
|
||||
// If another writer already recreated targetPath, do not restore the
|
||||
// mismatched backup over it — leave both intact and fail closed.
|
||||
let targetOccupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
targetOccupied = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (targetOccupied) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
} catch (restoreErr) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after a concurrent replacement was detected. `
|
||||
+ `Backup: ${backupPath}; target: ${targetPath}`,
|
||||
{ cause: restoreErr },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
}
|
||||
assertNotCancelled();
|
||||
try {
|
||||
await fs.promises.rename(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
if (backedUp && expectedIdentity) {
|
||||
const stat = await fs.promises.lstat(backupPath);
|
||||
if (!stat.isFile() || stableLocalFileIdentity(stat) !== expectedIdentity) {
|
||||
throw new Error("Local download target changed during replacement");
|
||||
}
|
||||
}
|
||||
// After the backup move, a concurrent writer may recreate targetPath. Never
|
||||
// clobber that file: leave it in place, keep the original in backupPath for
|
||||
// recovery, and fail closed before publishing the download.
|
||||
if (backedUp) {
|
||||
let recreated = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
recreated = true;
|
||||
} catch (err) {
|
||||
if (err?.code !== "ENOENT") throw err;
|
||||
}
|
||||
if (recreated) {
|
||||
const conflict = new Error("Local download target changed during replacement");
|
||||
conflict.leaveConcurrentTarget = true;
|
||||
conflict.remoteBackupPath = backupPath;
|
||||
// Prevent the catch path from renaming backup over the concurrent file.
|
||||
backedUp = false;
|
||||
throw conflict;
|
||||
}
|
||||
}
|
||||
assertNotCancelled();
|
||||
// Capture identity of the ready file before publish so rollback can refuse
|
||||
// to clobber a concurrent replacement of the published target.
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
publishedIdentity = stableLocalFileIdentity(await fs.promises.lstat(readyPath));
|
||||
} catch {
|
||||
publishedIdentity = null;
|
||||
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error("Local download target changed during replacement", { cause: error });
|
||||
}
|
||||
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
|
||||
throw error;
|
||||
}
|
||||
await fs.promises.rename(readyPath, targetPath);
|
||||
published = true;
|
||||
assertNotCancelled();
|
||||
options.onCommit?.();
|
||||
// Publication is the commit boundary. A late cancel must not perform a
|
||||
// check-then-unlink rollback against a name another process may now own.
|
||||
committed = true;
|
||||
// Hand the published inode identity to the caller for descriptor-based
|
||||
// metadata stamping after publication.
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
|
||||
if (stagedPath !== readyPath) await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (err) {
|
||||
let restoreError = null;
|
||||
if (err?.leaveConcurrentTarget) {
|
||||
// Caller already decided not to touch a concurrent target/backup pair.
|
||||
} else if (backedUp) {
|
||||
let targetMatchesPublished = false;
|
||||
let targetMissing = false;
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(stagedPath).catch(() => {});
|
||||
} catch (error) {
|
||||
if (committed) throw error;
|
||||
if (backedUp && !keepRecoveryFiles) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
targetMatchesPublished = !!(
|
||||
published
|
||||
&& publishedIdentity
|
||||
&& stableLocalFileIdentity(targetStat) === publishedIdentity
|
||||
);
|
||||
} catch (statErr) {
|
||||
if (statErr?.code === "ENOENT") targetMissing = true;
|
||||
else restoreError = statErr;
|
||||
}
|
||||
if (!restoreError) {
|
||||
if (published && !targetMissing && !targetMatchesPublished) {
|
||||
// Concurrent writer replaced our published file — keep both.
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
if (published && targetMatchesPublished) {
|
||||
await fs.promises.unlink(targetPath).catch(() => {});
|
||||
}
|
||||
if (targetMissing || targetMatchesPublished || !published) {
|
||||
try {
|
||||
// Only restore when the path is free or still holds our publish.
|
||||
if (!published) {
|
||||
let occupied = false;
|
||||
try {
|
||||
await fs.promises.lstat(targetPath);
|
||||
occupied = true;
|
||||
} catch (occErr) {
|
||||
if (occErr?.code !== "ENOENT") throw occErr;
|
||||
}
|
||||
if (occupied) {
|
||||
err.leaveConcurrentTarget = true;
|
||||
err.remoteBackupPath = backupPath;
|
||||
backedUp = false;
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} else {
|
||||
await fs.promises.rename(backupPath, targetPath);
|
||||
backedUp = false;
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
restoreError = recoveryErr;
|
||||
}
|
||||
}
|
||||
// The pathname may have changed between open and rename. Never copy
|
||||
// an earlier inode over the original actually moved into the backup.
|
||||
let restoreHandle;
|
||||
if (originalHandle) {
|
||||
const [heldStat, backupStat] = await Promise.all([
|
||||
originalHandle.stat(), fs.promises.lstat(backupPath),
|
||||
]);
|
||||
if (stableLocalFileIdentity(heldStat) === stableLocalFileIdentity(backupStat)) restoreHandle = originalHandle;
|
||||
}
|
||||
}
|
||||
} else if (published) {
|
||||
try {
|
||||
const targetStat = await fs.promises.lstat(targetPath);
|
||||
if (publishedIdentity && stableLocalFileIdentity(targetStat) === publishedIdentity) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
if (recoveryErr?.code !== "ENOENT") restoreError = recoveryErr;
|
||||
await publishLocalFileExclusive(backupPath, targetPath, undefined, restoreHandle);
|
||||
await fs.promises.unlink(backupPath).catch(() => {});
|
||||
backedUp = false;
|
||||
} catch (restoreError) {
|
||||
keepRecoveryFiles = true;
|
||||
error.cause ??= restoreError;
|
||||
}
|
||||
}
|
||||
if (restoreError) {
|
||||
const recoveryFailure = new Error(
|
||||
`Could not restore the original file after replacement failed. `
|
||||
+ `Backup: ${backupPath}; prepared replacement: ${readyPath}; `
|
||||
+ `original staged path: ${stagedPath}; target: ${targetPath}`,
|
||||
{ cause: restoreError },
|
||||
if (keepRecoveryFiles) {
|
||||
const failure = new Error(
|
||||
`${error.message}. Recovery files preserved. Backup: ${backedUp ? backupPath : "none"}; `
|
||||
+ `prepared replacement: ${readyPath}; target: ${targetPath}`,
|
||||
{ cause: error },
|
||||
);
|
||||
recoveryFailure.recoveryFailed = true;
|
||||
throw recoveryFailure;
|
||||
failure.recoveryFailed = true;
|
||||
if (backedUp) failure.remoteBackupPath = backupPath;
|
||||
throw failure;
|
||||
}
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
throw err;
|
||||
throw error;
|
||||
} finally {
|
||||
await preparedHandle?.close().catch(() => {});
|
||||
await originalHandle?.close().catch(() => {});
|
||||
if (restoreProbeCreated) await fs.promises.unlink(restoreProbePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,11 +1139,30 @@ async function preserveTransferredDestinationMtime(transfer, options = {}) {
|
||||
: 15_000;
|
||||
|
||||
if (transfer.targetType === "local" && transfer.targetPath) {
|
||||
await awaitBestEffortBounded(
|
||||
fs.promises.utimes(transfer.targetPath, when, when),
|
||||
mtimeTimeoutMs,
|
||||
"Destination utimes",
|
||||
);
|
||||
if (transfer.localMtimePrepared) return;
|
||||
// Verify and stamp the same open file. A later pathname replacement
|
||||
// must never receive metadata belonging to this completed transfer.
|
||||
await awaitBestEffortBounded((async () => {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.promises.open(transfer.targetPath, "r");
|
||||
} catch (error) {
|
||||
if (error?.code !== "EACCES" && error?.code !== "EPERM") throw error;
|
||||
// O_WRONLY neither creates nor truncates a write-only destination.
|
||||
handle = await fs.promises.open(transfer.targetPath, fs.constants.O_WRONLY);
|
||||
}
|
||||
try {
|
||||
const currentStat = await handle.stat();
|
||||
const publishedIdentity = transfer.publishedLocalIdentity;
|
||||
const expectedIdentity = typeof publishedIdentity === "string"
|
||||
? publishedIdentity : stableLocalFileIdentity(publishedIdentity);
|
||||
if (!currentStat.isFile()
|
||||
|| (expectedIdentity && stableLocalFileIdentity(currentStat) !== expectedIdentity)) return;
|
||||
await handle.utimes(when, when);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
})(), mtimeTimeoutMs, "Destination utimes");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6082,6 +6033,7 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
} = await inspectLocalPromotionTarget(targetPath);
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
await promoteLocalTransfer(downloadTargetPath, promotionTargetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
existingMode,
|
||||
async validateTarget() {
|
||||
const latestTarget = await inspectLocalPromotionTarget(targetPath);
|
||||
@@ -6096,8 +6048,10 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
@@ -6185,11 +6139,14 @@ async function startTransferNow(event, payload, onProgress) {
|
||||
});
|
||||
if (transfer.resumable && transfer.stagedLocalPath) {
|
||||
await promoteLocalTransfer(transfer.stagedLocalPath, targetPath, {
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
assertNotCancelled() {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
},
|
||||
onCommit() {
|
||||
onCommit(publishedIdentity, localMtimePrepared) {
|
||||
transfer.completionCommitted = true;
|
||||
transfer.localMtimePrepared = localMtimePrepared;
|
||||
transfer.publishedLocalIdentity = publishedIdentity ?? null;
|
||||
},
|
||||
});
|
||||
transfer.stagedLocalPath = null;
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"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");
|
||||
|
||||
for (const restore of [false, true]) {
|
||||
test(`local ${restore ? "restore" : "publish"} never overwrites a last-moment concurrent file`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let injected = false;
|
||||
for (const method of ["rename", "link"]) {
|
||||
const original = fs.promises[method];
|
||||
t.after(() => { fs.promises[method] = original; });
|
||||
fs.promises[method] = async (...args) => {
|
||||
if (!injected && args[1] === target && String(args[0]).endsWith(restore ? ".backup" : ".ready")) {
|
||||
injected = true;
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
}
|
||||
return original.apply(fs.promises, args);
|
||||
};
|
||||
}
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target,
|
||||
restore ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}));
|
||||
assert.equal(injected, true);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const backup = fs.readdirSync(root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup, "retain original for recovery");
|
||||
assert.equal(fs.readFileSync(path.join(root, backup), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("an explicitly absent destination is never moved aside if another writer creates it", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-absent")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
validateTarget: async () => {
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
return { targetIdentity: "missing", existingMode: null };
|
||||
},
|
||||
}));
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
for (const failCopy of [false, true]) {
|
||||
test(`exclusive copy fallback ${failCopy ? "retains recovery files after a write failure" : "publishes complete bytes without hardlinks"}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-fallback")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(2 * 1024 * 1024 + 7, 173);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
if (failCopy) {
|
||||
const open = fs.promises.open;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
handle.write = async () => {
|
||||
// A replacement entry must survive even when the owned old handle fails.
|
||||
fs.unlinkSync(target);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
throw new Error("disk write failed");
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.open = open; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target), /Recovery files preserved/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
} else {
|
||||
await bridge._promoteLocalTransferForTests(staged, target);
|
||||
assert.deepEqual(fs.readFileSync(target), payload);
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("cancellation before publication restores the original destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-cancel")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled: () => { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const interruption of ["cancel", "replace", "restore-replace"]) {
|
||||
test(`fallback ${interruption} during copying preserves complete recovery files`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mid-copy")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(3 * 1024 * 1024, 42);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original");
|
||||
const link = fs.promises.link;
|
||||
const open = fs.promises.open;
|
||||
let cancelled = false;
|
||||
let writes = 0;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await open.apply(fs.promises, args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const write = handle.write.bind(handle);
|
||||
handle.write = async (...writeArgs) => {
|
||||
const result = await write(...writeArgs);
|
||||
if (++writes === 1) {
|
||||
if (interruption === "cancel") cancelled = true;
|
||||
else { fs.unlinkSync(target); fs.writeFileSync(target, "concurrent"); }
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
t.after(() => { fs.promises.link = link; fs.promises.open = open; });
|
||||
let committed = false;
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
...(interruption === "restore-replace" ? { validateTarget: async () => ({ stableIdentity: "wrong" }) } : {}),
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
}), /Recovery files preserved/);
|
||||
assert.equal(committed, false);
|
||||
if (interruption === "cancel") assert.equal(writes, 1);
|
||||
else assert.equal(fs.readFileSync(target, "utf8"), "concurrent");
|
||||
const names = fs.readdirSync(root);
|
||||
assert.deepEqual(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".ready")))), payload);
|
||||
assert.equal(fs.readFileSync(path.join(root, names.find(name => name.endsWith(".backup"))), "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("cancelled replacement restores original timestamps through the copy fallback", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restore-times")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original", { mode: 0o640 });
|
||||
const atime = new Date("2019-01-02T00:00:00Z");
|
||||
const mtime = new Date("2020-02-03T00:00:00Z");
|
||||
fs.utimesSync(target, atime, mtime);
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
await assert.rejects(() => bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (!fs.existsSync(target)) throw new Error("Transfer cancelled"); },
|
||||
}), /cancelled/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mtimeMs, mtime.getTime());
|
||||
assert.equal(stat.atimeMs, atime.getTime());
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
|
||||
for (const replaceAfterCommit of [false, true]) {
|
||||
test(`local publication prepares timestamps before restrictive permissions${replaceAfterCommit ? " and leaves post-commit replacement alone" : ""}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mtime")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
const transfer = {
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
};
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: 0,
|
||||
sourceSoftIdentity: transfer.sourceSoftIdentity,
|
||||
onCommit(identity) {
|
||||
transfer.publishedLocalIdentity = identity;
|
||||
transfer.localMtimePrepared = true;
|
||||
if (replaceAfterCommit) {
|
||||
fs.renameSync(target, path.join(root, "published"));
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
}
|
||||
},
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests(transfer);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), replaceAfterCommit ? 1_600_000_000 : 1_700_000_000);
|
||||
if (!replaceAfterCommit) assert.equal(stat.mode & 0o777, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("fallback close failure retains the original backup and prepared replacement", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-close")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === "wx") {
|
||||
const close = handle.close.bind(handle);
|
||||
handle.close = async () => { await close(); throw new Error("close failed"); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target), (error) => {
|
||||
assert.equal(error.recoveryFailed, true);
|
||||
assert.equal(fs.readFileSync(error.remoteBackupPath, "utf8"), "original");
|
||||
return true;
|
||||
});
|
||||
const ready = fs.readdirSync(root).find((name) => name.endsWith(".ready"));
|
||||
assert.equal(fs.readFileSync(path.join(root, ready), "utf8"), "download");
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`copy fallback publishes prepared bytes with destination mode ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-mode")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode, sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, mode);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "download");
|
||||
});
|
||||
}
|
||||
|
||||
test("direct local timestamp preservation supports a write-only target", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(Math.floor(stat.mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("write-only timestamp fallback leaves a later pathname replacement unchanged", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("stamp-writeonly-race")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target");
|
||||
const published = path.join(root, "published");
|
||||
fs.writeFileSync(target, "download");
|
||||
fs.chmodSync(target, 0o200);
|
||||
let replaced = false;
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
const handle = await open(...args);
|
||||
if (args[0] === target && args[1] === fs.constants.O_WRONLY) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => {
|
||||
fs.renameSync(target, published);
|
||||
fs.writeFileSync(target, "concurrent");
|
||||
fs.utimesSync(target, 1_600_000_000, 1_600_000_000);
|
||||
replaced = true;
|
||||
return utimes(...times);
|
||||
};
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await bridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath: target,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor(fs.statSync(target).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(Math.floor(fs.statSync(published).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("copy fallback refuses to move an unreadable existing destination", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("publish-restrictive")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
const payload = Buffer.alloc(1024 * 1024 + 3, 7);
|
||||
fs.writeFileSync(staged, payload);
|
||||
fs.writeFileSync(target, "original", { mode: 0o200 });
|
||||
const link = fs.promises.link;
|
||||
fs.promises.link = async () => { throw Object.assign(new Error("hardlinks unavailable"), { code: "ENOTSUP" }); };
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[0] === target && args[1] === "r") throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
return open(...args);
|
||||
});
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, { existingMode: 0o200 }), /hardlink recovery unavailable/);
|
||||
const stat = fs.statSync(target);
|
||||
assert.equal(stat.mode & 0o777, 0o200);
|
||||
assert.equal(stat.size, "original".length);
|
||||
fs.chmodSync(target, 0o600); // grant readback for verification
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
|
||||
for (const mode of [0o200, 0]) {
|
||||
test(`unreadable original remains at destination when safe restoration is unavailable: ${mode.toString(8)}`, async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-unreadable")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
fs.chmodSync(target, mode);
|
||||
const open = fs.promises.open;
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
if (args[1] === "r" && (args[0] === target || String(args[0]).endsWith(".backup"))) {
|
||||
throw Object.assign(new Error("read access denied"), { code: "EACCES" });
|
||||
}
|
||||
return open(...args);
|
||||
});
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) cancelled = true;
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
existingMode: mode,
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}));
|
||||
assert.equal(fs.existsSync(target), true, "unrestorable original must not be moved away");
|
||||
assert.equal(fs.statSync(target).mode & 0o777, mode);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
});
|
||||
}
|
||||
|
||||
test("copy recovery retains original read access after its permissions become restrictive", async (t) => {
|
||||
const root = fs.mkdtempSync(`${temp.getTempFilePath("restore-held-handle")}-`);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const staged = path.join(root, "staged");
|
||||
const target = path.join(root, "target");
|
||||
fs.writeFileSync(staged, "download");
|
||||
fs.writeFileSync(target, "original");
|
||||
let cancelled = false;
|
||||
const rename = fs.promises.rename;
|
||||
t.mock.method(fs.promises, "rename", async (...args) => {
|
||||
const result = await rename(...args);
|
||||
if (args[0] === target) {
|
||||
fs.chmodSync(args[1], 0);
|
||||
cancelled = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
t.mock.method(fs.promises, "link", async () => { throw Object.assign(new Error("unsupported"), { code: "ENOTSUP" }); });
|
||||
await assert.rejects(bridge._promoteLocalTransferForTests(staged, target, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
}), /Transfer cancelled/);
|
||||
assert.equal(fs.statSync(target).mode & 0o777, 0);
|
||||
fs.chmodSync(target, 0o600);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), "original");
|
||||
assert.deepEqual(fs.readdirSync(root), ["target"]);
|
||||
});
|
||||
@@ -12275,55 +12275,31 @@ test("local promotion does not clobber a target recreated after backup", async (
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
});
|
||||
|
||||
test("local promotion rollback does not clobber a concurrent post-publish target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-local-promo-postpub-"));
|
||||
t.after(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("local promotion commits before a late cancel and never deletes a concurrent replacement", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("local-promo-postpub")}-`);
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "download.staged");
|
||||
const targetPath = path.join(tempDir, "target.bin");
|
||||
const original = Buffer.from("original-target-content");
|
||||
const concurrent = Buffer.from("post-publish-concurrent");
|
||||
const downloaded = Buffer.from("downloaded-replacement");
|
||||
await fs.promises.writeFile(targetPath, original);
|
||||
await fs.promises.writeFile(stagedPath, downloaded);
|
||||
const originalStat = await fs.promises.lstat(targetPath);
|
||||
|
||||
await assert.rejects(
|
||||
() => transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
async validateTarget() {
|
||||
return {
|
||||
existingMode: null,
|
||||
stableIdentity: transferBridge._stableLocalFileIdentityForTests(originalStat),
|
||||
targetIdentity: [
|
||||
originalStat.dev,
|
||||
originalStat.ino,
|
||||
originalStat.size,
|
||||
originalStat.mtimeMs,
|
||||
originalStat.ctimeMs,
|
||||
].join(":"),
|
||||
};
|
||||
},
|
||||
assertNotCancelled() {
|
||||
// After ready is published onto targetPath, simulate concurrent replace + cancel.
|
||||
try {
|
||||
if (fs.readFileSync(targetPath).equals(downloaded)) {
|
||||
fs.writeFileSync(targetPath, concurrent);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err.message || err).includes("cancelled")) throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
/cancelled/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(await fs.promises.readFile(targetPath), concurrent);
|
||||
const backupName = (await fs.promises.readdir(tempDir)).find((name) => name.includes(".backup"));
|
||||
assert.ok(backupName, "original should remain in backup");
|
||||
assert.deepEqual(await fs.promises.readFile(path.join(tempDir, backupName)), original);
|
||||
await fs.promises.writeFile(targetPath, "original");
|
||||
await fs.promises.writeFile(stagedPath, "download");
|
||||
let cancelled = false;
|
||||
let committed = false;
|
||||
const link = fs.promises.link;
|
||||
t.after(() => { fs.promises.link = link; });
|
||||
fs.promises.link = async (...args) => {
|
||||
await link.apply(fs.promises, args);
|
||||
if (args[1] === targetPath && String(args[0]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(targetPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
cancelled = true;
|
||||
}
|
||||
};
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
assertNotCancelled() { if (cancelled) throw new Error("Transfer cancelled"); },
|
||||
onCommit() { committed = true; },
|
||||
});
|
||||
assert.equal(committed, true);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "concurrent");
|
||||
});
|
||||
|
||||
test("local promotion succeeds when backup still matches validated identity", async (t) => {
|
||||
@@ -12382,6 +12358,109 @@ test("preserveTransferredDestinationMtime stamps local targets from sourceSoftId
|
||||
assert.notEqual(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime skips a concurrently replaced local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-race-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("concurrent"));
|
||||
const before = await fs.promises.stat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
// Identity recorded at publication no longer matches the pathname's file.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(before.mtimeMs / 1000));
|
||||
|
||||
// A vanished destination is likewise left alone.
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath: path.join(tempDir, "missing.bin"),
|
||||
publishedLocalIdentity: "999999:999999:7",
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps the published local inode", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-inode-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
const publishedStat = await fs.promises.lstat(targetPath);
|
||||
const sourceMtimeMs = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
publishedLocalIdentity: transferBridge._stableLocalFileIdentityForTests(publishedStat),
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), Math.floor(sourceMtimeMs / 1000));
|
||||
});
|
||||
|
||||
test("local publication returns an identity usable for timestamp preservation", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-published-time-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const stagedPath = path.join(tempDir, "stage");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(stagedPath, "payload");
|
||||
let publishedLocalIdentity;
|
||||
await transferBridge._promoteLocalTransferForTests(stagedPath, targetPath, {
|
||||
onCommit(identity) { publishedLocalIdentity = identity; },
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("local timestamp preservation cannot stamp a replacement after identity verification", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-stamp-race-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
const oldPath = path.join(tempDir, "published");
|
||||
await fs.promises.writeFile(targetPath, "payload");
|
||||
const publishedLocalIdentity = transferBridge._stableLocalFileIdentityForTests(await fs.promises.stat(targetPath));
|
||||
let replaced = false;
|
||||
const replace = async () => {
|
||||
if (replaced) return;
|
||||
replaced = true;
|
||||
await fs.promises.rename(targetPath, oldPath);
|
||||
await fs.promises.writeFile(targetPath, "concurrent");
|
||||
await originalUtimes(targetPath, 1_600_000_000, 1_600_000_000);
|
||||
};
|
||||
const originalUtimes = fs.promises.utimes;
|
||||
const originalOpen = fs.promises.open;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (name === targetPath) await replace();
|
||||
return originalUtimes(name, ...args);
|
||||
});
|
||||
t.mock.method(fs.promises, "open", async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
if (args[0] === targetPath) {
|
||||
const utimes = handle.utimes.bind(handle);
|
||||
handle.utimes = async (...times) => { await replace(); return utimes(...times); };
|
||||
}
|
||||
return handle;
|
||||
});
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local", targetPath, publishedLocalIdentity,
|
||||
sourceSoftIdentity: { mtimeMs: 1_700_000_000_000 },
|
||||
});
|
||||
assert.equal(replaced, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
});
|
||||
|
||||
test("restoreRemoteUploadModeBestEffort times out hanging chmod", async () => {
|
||||
const hangingClient = {
|
||||
async chmod() {
|
||||
@@ -12757,3 +12836,54 @@ test("in-place isolated OPEN keeps path gate until late callback after channel e
|
||||
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
|
||||
assert.ok(eventLog.includes(`late-open-truncate:${targetPath}`) || eventLog.includes("close"));
|
||||
});
|
||||
|
||||
test("preserveTransferredDestinationMtime stamps a write-only local target", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-preserve-mtime-wo-"));
|
||||
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
|
||||
const targetPath = path.join(tempDir, "copied.bin");
|
||||
await fs.promises.writeFile(targetPath, Buffer.from("payload"));
|
||||
await fs.promises.chmod(targetPath, 0o200);
|
||||
const sourceMtimeMs = 1_700_000_000_000;
|
||||
|
||||
await transferBridge._preserveTransferredDestinationMtimeForTests({
|
||||
targetType: "local",
|
||||
targetPath,
|
||||
sourceSoftIdentity: { size: 7, mtimeMs: sourceMtimeMs },
|
||||
});
|
||||
|
||||
const after = await fs.promises.stat(targetPath);
|
||||
assert.equal(Math.floor(after.mtimeMs / 1000), 1_700_000_000);
|
||||
assert.equal(after.mode & 0o777, 0o200);
|
||||
});
|
||||
|
||||
for (const failureMode of ["rejection", "timeout"]) {
|
||||
test(`resumable local transfer retries a failed prepared-file timestamp: ${failureMode}`, async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-mtime-retry-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const sourcePath = path.join(tempDir, "source");
|
||||
const targetPath = path.join(tempDir, "target");
|
||||
await fs.promises.writeFile(sourcePath, "payload");
|
||||
await fs.promises.utimes(sourcePath, 1_600_000_000, 1_600_000_000);
|
||||
let preparationFailed = false;
|
||||
const utimes = fs.promises.utimes;
|
||||
t.mock.method(fs.promises, "utimes", async (name, ...args) => {
|
||||
if (String(name).endsWith(".ready")) {
|
||||
preparationFailed = true;
|
||||
if (failureMode === "timeout") return new Promise(() => {});
|
||||
throw Object.assign(new Error("transient metadata failure"), { code: "EIO" });
|
||||
}
|
||||
return utimes(name, ...args);
|
||||
});
|
||||
transferBridge.init({ sftpClients: new Map() });
|
||||
const result = await transferBridge.startTransfer({ sender: createSender() }, {
|
||||
transferId: "local-mtime-retry", sourcePath, targetPath,
|
||||
sourceType: "local", targetType: "local", totalBytes: 7, resumable: true,
|
||||
});
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(preparationFailed, true);
|
||||
assert.equal(Math.floor((await fs.promises.stat(targetPath)).mtimeMs / 1000), 1_600_000_000);
|
||||
assert.equal(await fs.promises.readFile(targetPath, "utf8"), "payload");
|
||||
});
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user