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