CH-J Toolsupravljanje poslužiteljima preko SSH
Meni
Objavljen izvor

CH-J Tools

Pregledaj direktorije i datoteke za određeni programski izdanje.

Preuzmi ZIP izvora
CH-J / Server Manager 406 datoteke
core-20261024T135205Z-7c487d719de2 Dodano: · Izgrađeno:
transferManager.test.js Dodano: Datum nije zabilježen 31,0 KB · 466 linije
Oznaka Preuzmi datoteku
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 v8 = require("node:v8");
9const vm = require("node:vm");
10const fs = require("node:fs");
11const os = require("node:os");
12const path = require("node:path");
13const { EventEmitter } = require("node:events");
14const { TransferManager, summarize } = require("../src/main/files/transferManager");
16const sha = (data) => crypto.createHash("sha256").update(data).digest("hex");
17const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
18async 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); }
22function sftpError(error) {
23 const code = error.code === "ENOENT" ? 2 : error.code === "EACCES" ? 3 : 4;
24 return Object.assign(new Error(error.message), { code });
27class 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 }
117function 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; } };
141const job = (manager) => manager.list("fm").jobs[0];
143test("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});
160test("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});
178test("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});
202test("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 active
216 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});
220test("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});
241test("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});
258test("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});
284test("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});
300test("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});
319test("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});
340test("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 window
352 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});
369test("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});
389test("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});
405test("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});
427test("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});
444test("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 RSS
452 // also counted uncollected chunk garbage (npm test runs without --expose-gc) and the in-process fake server's
453 // "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-25633ade54e5bcb2dcac23d08549bf6e0f2ff0dd779a54d15bddfc78a49cf6f4e97

CH-J Proprietary Software License 1.14 · Izvor je osiguran pod CH-J Proprietary Software License 1.14. Njena dostupnost ne mijenja uvjete licenciranja niti daje dodatna prava.

Arhiv SHA-256476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6
Datoteke