ซอร์สที่เผยแพร่
ดาวน์โหลด ZIP ของซอร์ส
CH-J Tools
เรียกดูไดเรกทอรีและไฟล์ของรุ่นแอปพลิเคชันที่ระบุ
CH-J / Server Manager
406 ไฟล์
core-20261024T135205Z-7c487d719de2
วันที่เพิ่ม: · วันที่บิลด์:
1
"use strict";3
// Transfer engine tests against a simulated SFTP server backed by a real temporary directory.4
// These are local unit/engine tests (no SSH); real servers are covered by filesIntegration.test.js.5
const test = require("node:test");6
const assert = require("node:assert/strict");7
const crypto = require("node:crypto");8
const v8 = require("node:v8");9
const vm = require("node:vm");10
const fs = require("node:fs");11
const os = require("node:os");12
const path = require("node:path");13
const { EventEmitter } = require("node:events");14
const { TransferManager, summarize } = require("../src/main/files/transferManager");16
const sha = (data) => crypto.createHash("sha256").update(data).digest("hex");17
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));18
async function until(predicate, timeout = 10000) {19
const started = Date.now();20
while (!predicate()) { if (Date.now() - started > timeout) throw new Error("Timed out waiting for condition"); await tick(5); }21
}22
function sftpError(error) {23
const code = error.code === "ENOENT" ? 2 : error.code === "EACCES" ? 3 : 4;24
return Object.assign(new Error(error.message), { code });25
}27
class FakeServer {28
constructor(root) {29
this.root = root; this.writes = []; this.writeDelay = 0; this.failAfterWrites = Infinity; this.channels = 0; this.helperCalls = [];30
this.connected = true; this.readOnly = new Set();31
}32
local(remote) { return path.join(this.root, remote); }33
attrs(stats) {34
return { size: stats.size, mtime: Math.floor(stats.mtimeMs / 1000), mode: stats.mode, isDirectory: () => stats.isDirectory(), isFile: () => stats.isFile(), isSymbolicLink: () => stats.isSymbolicLink() };35
}36
sftp() {37
const server = this; const handles = new Map(); let ended = false; this.channels += 1;38
const guard = (cb) => { if (ended || !server.connected) { cb(Object.assign(new Error("No response from server"), { code: 7 })); return false; } return true; };39
return {40
open(p, flags, attrs, cb) {41
if (typeof attrs === "function") { cb = attrs; attrs = {}; }42
if (!guard(cb)) return;43
if (flags !== "r" && server.readOnly.has(path.posix.dirname(p))) return cb(Object.assign(new Error("Permission denied"), { code: 3 }));44
fs.open(server.local(p), flags, attrs?.mode ?? 0o644, (error, fd) => {45
if (error) return cb(sftpError(error));46
const handle = Buffer.from(crypto.randomBytes(4)); handles.set(handle.toString("hex"), fd); cb(null, handle);47
});48
},49
read(handle, buf, off, len, pos, cb) { if (!guard(cb)) return; fs.read(handles.get(handle.toString("hex")), buf, off, len, pos, (error, n) => error ? cb(sftpError(error)) : cb(undefined, n, buf, pos)); },50
write(handle, buf, off, len, pos, cb) {51
if (!guard(cb)) return;52
server.writes.push(pos);53
if (server.writes.length > server.failAfterWrites) { server.connected = false; return cb(Object.assign(new Error("No response from server"), { code: 7 })); }54
setTimeout(() => {55
if (ended || !server.connected) return cb(Object.assign(new Error("No response from server"), { code: 7 }));56
fs.write(handles.get(handle.toString("hex")), buf, off, len, pos, (error) => error ? cb(sftpError(error)) : cb(undefined, off + len));57
}, server.writeDelay);58
},59
fstat(handle, cb) { if (!guard(cb)) return; fs.fstat(handles.get(handle.toString("hex")), (error, stats) => error ? cb(sftpError(error)) : cb(null, server.attrs(stats))); },60
fsetstat(handle, attrs, cb) { fs.ftruncate(handles.get(handle.toString("hex")), attrs.size, (error) => cb(error ? sftpError(error) : undefined)); },61
close(handle, cb) { const fd = handles.get(handle.toString("hex")); handles.delete(handle.toString("hex")); fs.close(fd, () => cb()); },62
stat(p, cb) { if (!guard(cb)) return; fs.stat(server.local(p), (error, stats) => error ? cb(sftpError(error)) : cb(null, server.attrs(stats))); },63
lstat(p, cb) { if (!guard(cb)) return; fs.lstat(server.local(p), (error, stats) => error ? cb(sftpError(error)) : cb(null, server.attrs(stats))); },64
unlink(p, cb) { if (!guard(cb)) return; fs.unlink(server.local(p), (error) => cb(error ? sftpError(error) : undefined)); },65
rename(from, to, cb) {66
if (!guard(cb)) return;67
if (fs.existsSync(server.local(to))) return cb(Object.assign(new Error("Failure"), { code: 4 }));68
fs.rename(server.local(from), server.local(to), (error) => cb(error ? sftpError(error) : undefined));69
},70
mkdir(p, attrs, cb) { if (typeof attrs === "function") cb = attrs; fs.mkdir(server.local(p), (error) => cb(error ? sftpError(error) : undefined)); },71
readdir(p, cb) {72
fs.readdir(server.local(p), (error, names) => error ? cb(sftpError(error)) : cb(null, names.map((filename) => ({ filename, attrs: server.attrs(fs.lstatSync(path.join(server.local(p), filename))) }))));73
},74
end() { ended = true; for (const fd of handles.values()) try { fs.closeSync(fd); } catch {} handles.clear(); }75
};76
}77
// Simulated fixed remote helper (the real Python helper is exercised on Linux by remoteEditorLinux/filesIntegration).78
helper(request, auth) {79
this.helperCalls.push({ operation: request.operation, sudo: auth.sudo === true, password: auth.sudoPassword, retry: request.retry });80
if (!this.connected) throw Object.assign(new Error("lost"), { code: "SESSION_NOT_CONNECTED" });81
const lite = (stats) => ({ dev: stats.dev, ino: stats.ino, size: stats.size, mtimeNs: String(stats.mtimeNs ?? Math.trunc(stats.mtimeMs * 1e6)), ctimeNs: String(Math.trunc(stats.ctimeMs * 1e6)), uid: stats.uid, gid: stats.gid, mode: stats.mode & 0o7777, nlink: stats.nlink });82
const workspace = "/home/user/ch-j-sm/transfers";83
if (request.operation === "transfer-workspace") { fs.mkdirSync(this.local(workspace), { recursive: true, mode: 0o700 }); return { path: workspace, uid: 1000 }; }84
if (request.operation === "stat") {85
const stats = fs.existsSync(this.local(request.path)) ? fs.lstatSync(this.local(request.path)) : null;86
return stats ? { exists: true, type: stats.isDirectory() ? "directory" : "file", resolvedPath: request.path, baseline: lite(stats) } : { exists: false, resolvedPath: request.path };87
}88
if (request.operation === "hash") { const data = fs.readFileSync(this.local(request.path)); return { hash: sha(data), size: data.length }; }89
if (request.operation === "transfer-remove") { const file = this.local(`${workspace}/${request.id}.part`); const existed = fs.existsSync(file); fs.rmSync(file, { force: true }); return { removed: existed }; }90
if (request.operation === "transfer-list") { const dir = this.local(workspace); return { items: fs.existsSync(dir) ? fs.readdirSync(dir).map((name) => ({ id: name.slice(0, 32), size: 1, mtime: 0 })) : [] }; }91
if (request.operation === "mkdir") { fs.mkdirSync(this.local(request.path), { recursive: true }); return { path: request.path }; }92
if (request.operation === "stage-out") {93
if (!auth.sudo) throw Object.assign(new Error("denied"), { code: "FILE_PERMISSION_DENIED" });94
const data = fs.readFileSync(this.local(request.path)); fs.mkdirSync(this.local(workspace), { recursive: true });95
fs.writeFileSync(this.local(`${workspace}/${request.id}.part`), data, { mode: 0o600 });96
return { path: `${workspace}/${request.id}.part`, size: data.length, hash: sha(data), mtimeNs: "1" };97
}98
if (request.operation === "publish") {99
const target = this.local(request.path);100
if (this.publishFailure) { const error = this.publishFailure; this.publishFailure = null; throw error; }101
const current = fs.existsSync(target) ? lite(fs.lstatSync(target)) : null;102
if (request.retry && current && request.baseline && JSON.stringify(current) !== JSON.stringify(request.baseline) && sha(fs.readFileSync(target)) === request.hash) return { status: "published", alreadyPublished: true, hash: request.hash, resolvedPath: request.path };103
if (current && !request.baseline && !request.overwrite) throw Object.assign(new Error("exists"), { code: "FILE_EXISTS" });104
if (request.baseline && JSON.stringify(current) !== JSON.stringify(request.baseline)) throw Object.assign(new Error("conflict"), { code: "FILE_CONFLICT" });105
const data = fs.readFileSync(this.local(request.source));106
if (sha(data) !== request.hash || data.length !== request.size) throw Object.assign(new Error("verify"), { code: "FILE_VERIFY_FAILED" });107
const mode = current ? current.mode : request.mode;108
fs.mkdirSync(path.dirname(target), { recursive: true });109
if (request.strategy === "rename") { fs.chmodSync(this.local(request.source), mode); fs.renameSync(this.local(request.source), target); }110
else { fs.writeFileSync(target + ".tmp", data, { mode }); fs.renameSync(target + ".tmp", target); fs.rmSync(this.local(request.source)); }111
return { status: "published", hash: request.hash, resolvedPath: request.path, created: !current, metadataPreserved: Boolean(current), sourceRemoved: true };112
}113
throw new Error(`Unexpected helper operation ${request.operation}`);114
}115
}117
function setup(t, { storageRoot, server: existing, selected = [], downloadDirectory, downloadPath, retryDelaysMs, stopGraceMs } = {}) {118
const root = existing?.root || fs.mkdtempSync(path.join(os.tmpdir(), "chj-transfer-remote-"));119
const local = fs.mkdtempSync(path.join(os.tmpdir(), "chj-transfer-local-"));120
const storage = storageRoot || fs.mkdtempSync(path.join(os.tmpdir(), "chj-transfer-state-"));121
t.after(() => { for (const dir of [local, storage, root]) fs.rmSync(dir, { recursive: true, force: true }); });122
const server = existing || new FakeServer(root);123
for (const dir of ["/srv", "/etc/nginx"]) fs.mkdirSync(server.local(dir), { recursive: true });124
const sessions = new EventEmitter();125
sessions.list = () => server.connected ? [{ sessionId: "s1", profileId: "p", host: "h", port: 22, username: "user", state: "connected" }] : [];126
sessions.openSftp = async () => { if (!server.connected) throw Object.assign(new Error("not connected"), { code: "SESSION_NOT_CONNECTED" }); return server.sftp(); };127
const files = {128
helper: async (_sessionId, request, options = {}) => server.helper(request, options),129
_withSftp: async (_sessionId, action) => { const sftp = await sessions.openSftp(); try { return await action(sftp); } finally { sftp.end(); } },130
privileges: async () => ({ mode: "sudo-password", username: "user", sudoInstalled: true })131
};132
const manager = new TransferManager({ sessionManager: sessions, remoteFileService: files, storageRoot: storage, logger: null, retryDelaysMs, stopGraceMs,133
selectUploadPath: async () => ({ canceled: false, paths: selected.map((name) => path.join(local, name)) }),134
selectDownloadDirectory: async () => ({ canceled: false, path: downloadDirectory || local }),135
selectDownloadPath: async (name) => ({ canceled: false, path: downloadPath || path.join(local, name) }) });136
t.after(() => manager.shutdown());137
const updates = [];138
manager.on("update", (update) => updates.push(update));139
return { server, manager, local, storage, sessions, updates, setSelected: (names) => { selected = names; } };140
}141
const job = (manager) => manager.list("fm").jobs[0];143
test("summary is byte based, keeps unknown sizes indeterminate and never shows a false 100 %", () => {144
const base = { speed: 0, eta: null };145
const summary = summarize([146
{ ...base, state: "completed", size: 100, transferred: 100 },147
{ ...base, state: "transferring", size: 900, transferred: 0, speed: 50, eta: 18 },148
{ ...base, state: "failed", size: 0, transferred: 0 },149
{ ...base, state: "cancelled", size: 1000, transferred: 10 }150
]);151
assert.equal(summary.percent, 10, "1 of 2 files done but only 10 % of the bytes");152
assert.equal(summary.total, 4); assert.equal(summary.completed, 1); assert.equal(summary.active, 1); assert.equal(summary.failed, 1); assert.equal(summary.cancelled, 1);153
assert.equal(summary.totalBytes, 1000); assert.equal(summary.eta, 18);154
assert.equal(summarize([{ ...base, state: "transferring", size: null, transferred: 5 }]).percent, null);155
assert.equal(summarize([{ ...base, state: "verifying", size: 10, transferred: 10 }]).percent, 99.9);156
assert.equal(summarize([{ ...base, state: "completed", size: 0, transferred: 0 }]).percent, 100);157
assert.equal(summarize([{ ...base, state: "transferring", size: 10, transferred: 1, speed: 0, eta: null }]).eta, null, "no ETA without a reliable rate");158
});160
test("upload of a regular file streams to a sibling partial file and publishes it", async (t) => {161
const f = setup(t, { selected: ["a.bin"] });162
const data = crypto.randomBytes(3 * 1024 * 1024 + 123);163
fs.writeFileSync(path.join(f.local, "a.bin"), data);164
const plan = await f.manager.planUpload("fm", "s1", "/srv");165
assert.equal(plan.files, 1); assert.equal(plan.mode, "direct"); assert.equal(plan.bytes, data.length);166
await f.manager.start("fm", plan.planId);167
await until(() => job(f.manager)?.state === "completed");168
assert.deepEqual(fs.readFileSync(f.server.local("/srv/a.bin")), data);169
assert.deepEqual(fs.readdirSync(f.server.local("/srv")), ["a.bin"], "no partial files left");170
const done = job(f.manager);171
assert.equal(done.percent, 100); assert.equal(done.transferred, data.length);172
assert.equal(done.verification.method, "sha256");173
assert.equal(f.server.helperCalls.find((call) => call.operation === "publish").sudo, false, "no sudo for a writable target");174
const seen = f.updates.flatMap((update) => update.jobs).map((entry) => entry.transferred);175
assert.deepEqual(seen, [...seen].sort((a, b) => a - b), "progress never goes backwards");176
});178
test("pause stops the data flow; resume continues from the confirmed offset", async (t) => {179
const f = setup(t, { selected: ["big.bin"] });180
const data = crypto.randomBytes(8 * 1024 * 1024);181
fs.writeFileSync(path.join(f.local, "big.bin"), data);182
f.server.writeDelay = 15;183
const plan = await f.manager.planUpload("fm", "s1", "/srv");184
await f.manager.start("fm", plan.planId);185
await until(() => job(f.manager)?.transferred >= 2 * 1024 * 1024);186
f.manager.pause("fm", "all");187
await until(() => job(f.manager).state === "paused");188
const paused = job(f.manager);189
const writesAtPause = f.server.writes.length;190
await tick(150);191
assert.equal(f.server.writes.length, writesAtPause, "no SFTP writes while paused");192
assert.ok(paused.transferred > 0 && paused.transferred < data.length);193
assert.equal(paused.transferred % (256 * 1024), 0, "offset is a confirmed chunk boundary");194
f.server.writeDelay = 0;195
f.manager.resume("fm", "all");196
await until(() => job(f.manager).state === "completed");197
assert.equal(Math.min(...f.server.writes.slice(writesAtPause)), paused.transferred, "the resumed run starts at the confirmed offset");198
assert.ok(f.server.writes.slice(writesAtPause).every((position) => position >= paused.transferred), "nothing before the offset is sent again");199
assert.deepEqual(fs.readFileSync(f.server.local("/srv/big.bin")), data);200
});202
test("a quick resume is not stopped later by the grace timer of the earlier pause", async (t) => {203
const f = setup(t, { selected: ["slow.bin"], stopGraceMs: 80 });204
fs.writeFileSync(path.join(f.local, "slow.bin"), crypto.randomBytes(4 * 1024 * 1024));205
f.server.writeDelay = 15;206
const plan = await f.manager.planUpload("fm", "s1", "/srv");207
await f.manager.start("fm", plan.planId);208
await until(() => job(f.manager)?.transferred > 0);209
f.manager.pause("fm", "all");210
await until(() => job(f.manager).state === "paused");211
const states = [];212
const set = f.manager._set.bind(f.manager);213
f.manager._set = (entry, state, error) => { states.push(state); return set(entry, state, error); };214
f.manager.resume("fm", "all");215
await tick(250); // well past the old pause's grace period while the new run is active216
await until(() => job(f.manager).state === "completed");217
assert.equal(states.includes("paused"), false, "the resumed run was never stopped by the old timer");218
});220
test("SSH loss interrupts with a safe offset; reconnect resumes and completes", async (t) => {221
const f = setup(t, { selected: ["c.bin"] });222
const data = crypto.randomBytes(4 * 1024 * 1024);223
fs.writeFileSync(path.join(f.local, "c.bin"), data);224
f.manager.updateSettings("fm", { autoRetry: true });225
const plan = await f.manager.planUpload("fm", "s1", "/srv");226
f.server.failAfterWrites = 6;227
await f.manager.start("fm", plan.planId);228
await until(() => ["interrupted", "reconnecting"].includes(job(f.manager).state));229
const interrupted = job(f.manager);230
assert.equal(interrupted.error.code, "SESSION_NOT_CONNECTED");231
assert.ok(interrupted.transferred <= 6 * 256 * 1024);232
assert.deepEqual(fs.readdirSync(f.server.local("/srv")).filter((name) => name === "c.bin"), [], "target not published from partial data");233
f.server.failAfterWrites = Infinity; f.server.connected = true;234
const offset = interrupted.transferred; const before = f.server.writes.length;235
f.sessions.emit("state", { state: "connected", profileId: "p", host: "h", port: 22, username: "user" });236
await until(() => job(f.manager).state === "completed");237
assert.equal(Math.min(...f.server.writes.slice(before)), offset);238
assert.deepEqual(fs.readFileSync(f.server.local("/srv/c.bin")), data);239
});241
test("automatic retries are bounded and end in a manual interrupted state", async (t) => {242
const f = setup(t, { selected: ["d.bin"], retryDelaysMs: [5, 10, 20] });243
fs.writeFileSync(path.join(f.local, "d.bin"), crypto.randomBytes(1024 * 1024));244
const plan = await f.manager.planUpload("fm", "s1", "/srv");245
const states = [];246
const set = f.manager._set.bind(f.manager);247
f.manager._set = (entry, state, error) => { states.push(state); return set(entry, state, error); };248
f.server.connected = false; // The server stays unreachable: retries must stop on their own.249
await f.manager.start("fm", plan.planId);250
await until(() => job(f.manager).state === "interrupted" && job(f.manager).attempts === 3);251
await tick(200);252
assert.equal(job(f.manager).state, "interrupted");253
assert.equal(job(f.manager).attempts, 3, "exactly the configured number of attempts");254
assert.equal(states.filter((state) => state === "reconnecting").length, 3);255
assert.equal(job(f.manager).actions.resume, true, "manual resume stays available");256
});258
test("application restart offers resume and continues the persisted job", async (t) => {259
const storage = fs.mkdtempSync(path.join(os.tmpdir(), "chj-transfer-state-"));260
const first = setup(t, { storageRoot: storage, selected: ["e.bin"] });261
const data = crypto.randomBytes(6 * 1024 * 1024);262
fs.writeFileSync(path.join(first.local, "e.bin"), data);263
first.server.writeDelay = 10;264
const plan = await first.manager.planUpload("fm", "s1", "/srv");265
await first.manager.start("fm", plan.planId);266
await until(() => job(first.manager).transferred >= 1024 * 1024);267
first.manager.shutdown();268
await until(() => !first.manager.runtime.get(job(first.manager).id)?.running);269
const persisted = JSON.parse(fs.readFileSync(path.join(storage, "transfers.json"), "utf8"));270
assert.equal(JSON.stringify(persisted).includes("password"), false, "no secrets in persisted state");271
const offset = persisted.jobs[0].transferred;272
assert.ok(offset > 0);273
first.server.writeDelay = 0;274
const second = setup(t, { storageRoot: storage, server: first.server });275
const restored = job(second.manager);276
assert.equal(restored.state, "interrupted"); assert.equal(restored.error.code, "APP_RESTARTED"); assert.equal(restored.actions.resume, true);277
const before = first.server.writes.length;278
second.manager.resume("fm", [restored.id]);279
await until(() => job(second.manager).state === "completed");280
assert.equal(Math.min(...first.server.writes.slice(before)), offset);281
assert.deepEqual(fs.readFileSync(first.server.local("/srv/e.bin")), data);282
});284
test("cancel stops the transfer, removes only its partial file and keeps the existing target", async (t) => {285
const f = setup(t, { selected: ["keep.conf"] });286
fs.writeFileSync(path.join(f.local, "keep.conf"), crypto.randomBytes(6 * 1024 * 1024));287
fs.writeFileSync(f.server.local("/srv/keep.conf"), "original");288
f.server.writeDelay = 15;289
const plan = await f.manager.planUpload("fm", "s1", "/srv");290
assert.equal(plan.conflictCount, 1);291
await assert.rejects(() => f.manager.start("fm", plan.planId), { code: "FILE_EXISTS" });292
await f.manager.start("fm", plan.planId, { conflict: "overwrite" });293
await until(() => job(f.manager).transferred > 0);294
await f.manager.cancel("fm", "all");295
await until(() => job(f.manager).state === "cancelled");296
assert.equal(fs.readFileSync(f.server.local("/srv/keep.conf"), "utf8"), "original");297
assert.deepEqual(fs.readdirSync(f.server.local("/srv")), ["keep.conf"]);298
});300
test("several files run concurrently within the limit with correct individual and total progress", async (t) => {301
const f = setup(t, { selected: ["dir"] });302
fs.mkdirSync(path.join(f.local, "dir/sub"), { recursive: true });303
const sizes = { "dir/one.bin": 1024 * 1024, "dir/two.bin": 2 * 1024 * 1024, "dir/sub/three.bin": 512 * 1024, "dir/empty.txt": 0 };304
for (const [name, size] of Object.entries(sizes)) fs.writeFileSync(path.join(f.local, name), crypto.randomBytes(size));305
f.manager.updateSettings("fm", { maxConcurrent: 2 });306
f.server.writeDelay = 5;307
const plan = await f.manager.planUpload("fm", "s1", "/srv");308
assert.equal(plan.files, 4); assert.equal(plan.directories, 2);309
let peak = 0;310
f.manager.on("update", (update) => { peak = Math.max(peak, update.summary.active); });311
await f.manager.start("fm", plan.planId);312
await until(() => f.manager.list("fm").jobs.every((entry) => entry.state === "completed"));313
assert.ok(peak <= 2, `at most 2 active, saw ${peak}`);314
const summary = f.manager.list("fm").summary;315
assert.equal(summary.percent, 100); assert.equal(summary.totalBytes, 3.5 * 1024 * 1024); assert.equal(summary.completed, 4);316
for (const [name] of Object.entries(sizes)) assert.deepEqual(fs.readFileSync(f.server.local(`/srv/${name}`)), fs.readFileSync(path.join(f.local, name)));317
});319
test("a source changed during an interruption is detected before resuming", async (t) => {320
const f = setup(t, { selected: ["s.bin"] });321
const file = path.join(f.local, "s.bin");322
fs.writeFileSync(file, crypto.randomBytes(4 * 1024 * 1024));323
f.server.writeDelay = 10;324
const plan = await f.manager.planUpload("fm", "s1", "/srv");325
await f.manager.start("fm", plan.planId);326
await until(() => job(f.manager).transferred >= 512 * 1024);327
f.manager.pause("fm", "all");328
await until(() => job(f.manager).state === "paused");329
fs.appendFileSync(file, "changed");330
f.manager.resume("fm", "all");331
await until(() => job(f.manager).state === "failed");332
assert.equal(job(f.manager).error.code, "FILE_SOURCE_CHANGED");333
assert.equal(job(f.manager).actions.retry, true);334
await f.manager.retry("fm", "all");335
f.server.writeDelay = 0;336
await until(() => job(f.manager).state === "completed");337
assert.deepEqual(fs.readFileSync(f.server.local("/srv/s.bin")), fs.readFileSync(file));338
});340
test("partial data that no longer matches the source is never continued", async (t) => {341
const f = setup(t, { selected: ["m.bin"] });342
fs.writeFileSync(path.join(f.local, "m.bin"), crypto.randomBytes(4 * 1024 * 1024));343
f.server.writeDelay = 10;344
const plan = await f.manager.planUpload("fm", "s1", "/srv");345
await f.manager.start("fm", plan.planId);346
await until(() => job(f.manager).transferred >= 1024 * 1024);347
f.manager.pause("fm", "all");348
await until(() => job(f.manager).state === "paused");349
const part = fs.readdirSync(f.server.local("/srv")).find((name) => name.includes(".chj-part-"));350
const corrupt = (position) => { const fd = fs.openSync(f.server.local(`/srv/${part}`), "r+"); fs.writeSync(fd, Buffer.from("corrupt"), 0, 7, position); fs.closeSync(fd); };351
corrupt(job(f.manager).transferred - 100); // inside the verified tail window352
f.manager.resume("fm", "all");353
await until(() => job(f.manager).state === "failed");354
assert.equal(job(f.manager).error.code, "FILE_RESUME_MISMATCH");355
assert.equal(fs.existsSync(f.server.local("/srv/m.bin")), false);356
// Damage before the tail window is caught by the end-to-end SHA-256 before publication.357
await f.manager.retry("fm", "all");358
await until(() => job(f.manager).transferred >= 2 * 1024 * 1024);359
f.manager.pause("fm", "all");360
await until(() => job(f.manager).state === "paused");361
corrupt(1000);362
f.server.writeDelay = 0;363
f.manager.resume("fm", "all");364
await until(() => job(f.manager).state === "failed");365
assert.equal(job(f.manager).error.code, "FILE_VERIFY_FAILED");366
assert.equal(fs.existsSync(f.server.local("/srv/m.bin")), false, "corrupted data is never published");367
});369
test("upload into a protected directory stages privately and publishes through sudo", async (t) => {370
const f = setup(t, { selected: ["nginx.conf"] });371
const data = Buffer.from("worker_processes auto;\n".repeat(5000));372
fs.writeFileSync(path.join(f.local, "nginx.conf"), data);373
fs.writeFileSync(f.server.local("/etc/nginx/nginx.conf"), "old");374
fs.chmodSync(f.server.local("/etc/nginx/nginx.conf"), 0o640);375
f.server.readOnly.add("/etc/nginx");376
const plan = await f.manager.planUpload("fm", "s1", "/etc/nginx");377
assert.equal(plan.mode, "privileged"); assert.equal(plan.requiresSudo, true); assert.equal(plan.sudoPasswordRequired, true);378
await f.manager.start("fm", plan.planId, { conflict: "overwrite", sudoPassword: "secret" });379
await until(() => job(f.manager).state === "completed");380
assert.deepEqual(fs.readFileSync(f.server.local("/etc/nginx/nginx.conf")), data);381
// Windows does not expose POSIX permission bits for this local fake server.382
if (process.platform !== "win32") assert.equal(fs.statSync(f.server.local("/etc/nginx/nginx.conf")).mode & 0o777, 0o640, "original mode kept");383
const publish = f.server.helperCalls.find((call) => call.operation === "publish");384
assert.equal(publish.sudo, true); assert.equal(publish.password, "secret");385
assert.deepEqual(fs.readdirSync(f.server.local("/home/user/ch-j-sm/transfers")), [], "staged copy removed");386
assert.equal(job(f.manager).mode, "privileged"); assert.equal(job(f.manager).finalize.state, "done");387
});389
test("lost confirmation of the privileged replacement is retried idempotently", async (t) => {390
const f = setup(t, { selected: ["site.conf"] });391
fs.writeFileSync(path.join(f.local, "site.conf"), "server {}\n");392
f.server.readOnly.add("/etc/nginx");393
const plan = await f.manager.planUpload("fm", "s1", "/etc/nginx");394
f.manager.updateSettings("fm", { autoRetry: false });395
f.server.publishFailure = Object.assign(new Error("lost"), { code: "FILE_RESULT_UNKNOWN" });396
await f.manager.start("fm", plan.planId, { sudoPassword: "secret" });397
await until(() => job(f.manager).state === "interrupted");398
assert.equal(job(f.manager).finalize.state, "unknown");399
f.manager.resume("fm", "all");400
await until(() => job(f.manager).state === "completed");401
assert.equal(f.server.helperCalls.filter((call) => call.operation === "publish").at(-1).retry, true);402
assert.equal(fs.readFileSync(f.server.local("/etc/nginx/site.conf"), "utf8"), "server {}\n");403
});405
test("download of a regular file, pause/resume and checksum verification", async (t) => {406
const f = setup(t);407
const data = crypto.randomBytes(5 * 1024 * 1024 + 7);408
fs.writeFileSync(f.server.local("/srv/remote.bin"), data);409
const plan = await f.manager.planDownload("fm", "s1", ["/srv/remote.bin"]);410
assert.equal(plan.files, 1);411
const server = f.server; const sftp = server.sftp.bind(server);412
let reads = 0;413
server.sftp = () => { const channel = sftp(); const read = channel.read; channel.read = (...args) => { reads += 1; setTimeout(() => read(...args), 8); }; return channel; };414
await f.manager.start("fm", plan.planId);415
await until(() => job(f.manager).transferred >= 1024 * 1024);416
f.manager.pause("fm", "all");417
await until(() => job(f.manager).state === "paused");418
const atPause = reads; await tick(100); assert.equal(reads, atPause, "no reads while paused");419
f.manager.resume("fm", "all");420
await until(() => job(f.manager).state === "completed");421
assert.deepEqual(fs.readFileSync(path.join(f.local, "remote.bin")), data);422
assert.equal(job(f.manager).verification.method, "sha256");423
assert.equal(job(f.manager).verification.matched, true);424
assert.deepEqual(fs.readdirSync(f.local), ["remote.bin"]);425
});427
test("privileged download stages the protected file and removes the staged copy", async (t) => {428
const f = setup(t);429
fs.writeFileSync(f.server.local("/etc/nginx/secret.conf"), "ssl_certificate_key /x;\n");430
const server = f.server; const sftp = server.sftp.bind(server);431
server.sftp = () => { const channel = sftp(); const open = channel.open; channel.open = (p, flags, attrs, cb) => p.startsWith("/etc/") ? (cb || attrs)(Object.assign(new Error("Permission denied"), { code: 3 })) : open(p, flags, attrs, cb); return channel; };432
const plan = await f.manager.planDownload("fm", "s1", ["/etc/nginx/secret.conf"]);433
await f.manager.start("fm", plan.planId);434
await until(() => job(f.manager).state === "interrupted");435
assert.equal(job(f.manager).authRequired, true);436
f.manager.resume("fm", "all", { sudo: true, sudoPassword: "secret" });437
await until(() => job(f.manager).state === "completed");438
assert.equal(fs.readFileSync(path.join(f.local, "secret.conf"), "utf8"), "ssl_certificate_key /x;\n");439
// POSIX modes are verified on macOS/Linux; Windows uses filesystem ACLs.440
if (process.platform !== "win32") assert.equal(fs.statSync(path.join(f.local, "secret.conf")).mode & 0o777, 0o600, "protected download stays private");441
assert.deepEqual(fs.readdirSync(f.server.local("/home/user/ch-j-sm/transfers")), []);442
});444
test("a large transfer keeps memory bounded by the pipeline window", async (t) => {445
const f = setup(t, { selected: ["large.bin"] });446
const file = path.join(f.local, "large.bin");447
const fd = fs.openSync(file, "w"); const block = crypto.randomBytes(4 * 1024 * 1024);448
for (let index = 0; index < 48; index += 1) fs.writeSync(fd, block);449
fs.closeSync(fd);450
const plan = await f.manager.planUpload("fm", "s1", "/srv");451
// Measure memory that is still retained after a forced GC, only while the client pipeline transfers data. Plain RSS452
// also counted uncollected chunk garbage (npm test runs without --expose-gc) and the in-process fake server's453
// "publish", which reads the whole file like the remote helper does on the server; both made the check flaky.454
v8.setFlagsFromString("--expose-gc");455
const gc = vm.runInNewContext("gc");456
const retained = () => { gc(); const usage = process.memoryUsage(); return usage.arrayBuffers + usage.external; };457
const baseline = retained(); let peak = baseline; let samples = 0;458
const sampler = setInterval(() => { if (job(f.manager)?.state === "transferring") { peak = Math.max(peak, retained()); samples += 1; } }, 25);459
await f.manager.start("fm", plan.planId);460
await until(() => job(f.manager).state === "completed", 60000);461
clearInterval(sampler);462
t.diagnostic(`192 MiB upload: retained buffer growth ${Math.round((peak - baseline) / 1048576)} MiB over ${samples} samples while transferring`);463
assert.ok(samples >= 3, "the transfer phase was sampled");464
assert.ok(peak - baseline < 64 * 1024 * 1024, "the file is never buffered in memory as a whole");465
assert.equal(fs.statSync(f.server.local("/srv/large.bin")).size, 192 * 1024 * 1024);466
});SHA-256
33ade54e5bcb2dcac23d08549bf6e0f2ff0dd779a54d15bddfc78a49cf6f4e97CH-J Proprietary Software License 1.14 · ซอร์สโค้ดนี้ให้ไว้ภายใต้ CH-J Proprietary Software License 1.14 การเปิดให้เข้าถึงไม่ได้เปลี่ยนแปลงเงื่อนไขของสัญญาอนุญาตหรือให้สิทธิ์เพิ่มเติม
SHA-256 ของไฟล์บีบอัด
476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6