CH-J Server Managerуправление серверами через SSH
Меню
Публикуемый источник

CH-J Server Manager

Просмотр каталогов и файлов для конкретного выпуска приложения.

Загрузка источника ZIP
CH-J / Server Manager 377 файлы
core-20261019T111616Z-160a00bfcddc Добавлено: · Построено:
transferManager.test.js Добавлено: Дата нерегистрации 30,2 KB · 458 Линии
Код Загрузить файл
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.
5const test = require("node:test");
6const assert = require("node:assert/strict");
7const crypto = require("node:crypto");
8const fs = require("node:fs");
9const os = require("node:os");
10const path = require("node:path");
11const { EventEmitter } = require("node:events");
12const { TransferManager, summarize } = require("../src/main/files/transferManager");
14const sha = (data) => crypto.createHash("sha256").update(data).digest("hex");
15const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
16async 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); }
20function sftpError(error) {
21 const code = error.code === "ENOENT" ? 2 : error.code === "EACCES" ? 3 : 4;
22 return Object.assign(new Error(error.message), { code });
25class 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 }
115function 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; } };
139const job = (manager) => manager.list("fm").jobs[0];
141test("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});
158test("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});
176test("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});
200test("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 active
214 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});
218test("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});
239test("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});
256test("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});
282test("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});
298test("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});
317test("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});
338test("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 window
350 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});
367test("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});
387test("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});
403test("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});
425test("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});
442test("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-256aa0818c25374646e6a8237708c787fb6dd1b61da10c1fa1d0e181a8cc12dbcd0

CH-J Proprietary Software License 1.14 · Источник представлен в CH-J Proprietary Software License 1.14. Его наличие не изменяет лицензионных условий и не предоставляет дополнительных прав.

Архив SHA-256a9605c0870a223b6a8542bb0705edd51d8b04f3069582653417a8397fabcc09a
Файлы