Compare commits

...
6 changed files with 854 additions and 266 deletions

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.
+73
View File
@@ -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
View File
@@ -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"]);
});
+177 -47
View File
@@ -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");
});
}