CH-J Server Managerspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Server Manager

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J Proprietary Software License 1.14

Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

11,3 KB · 192 řádkůStáhnout soubor
1"use strict";
3const test = require("node:test");
4const assert = require("node:assert/strict");
5const crypto = require("node:crypto");
6const { RemoteFileService } = require("../src/main/files/remoteFileService");
8const hash = (data) => crypto.createHash("sha256").update(data).digest("hex");
9function fixture() {
10 let content = Buffer.from("original");
11 let baseline = { uid: 0, gid: 33, mode: 0o6754, mtimeNs: "1000000000", hash: hash(content), xattrs: { "user.test": "dGVzdA==" } };
12 let finalError, uploadError, cleanupError;
13 const calls = [], copies = new Map(), journals = new Map();
14 const manager = {
15 list: () => [{ sessionId: "s", state: "connected", host: "server", port: 22, username: "user", profileId: "p" }],
16 openSftp: async () => ({
17 writeFile(path, data, options, callback) {
18 calls.push({ op: "upload", path, options });
19 if (copies.has(path)) return callback(new Error("Exists"));
20 if (uploadError) { copies.set(path, data.subarray(0, 2)); return callback(uploadError); }
21 copies.set(path, Buffer.from(data)); callback(null);
22 }, end() {}
23 }),
24 async remoteEditor(_session, request, authorization) {
25 calls.push({ ...request, authorization });
26 const path = "/srv/custom-home/ch-j-sm/." + request.id + ".tmp";
27 if (request.operation === "read") return { data: content.toString("base64"), baseline: { ...baseline } };
28 if (request.operation === "prepare") {
29 journals.set(request.id, request);
30 return { temporary: path, recoveryId: request.id, uid: 1001 };
31 }
32 if (request.operation === "finalize") {
33 if (finalError) throw finalError;
34 assert.equal(hash(copies.get(path)), request.hash);
35 if (request.baseline.hash !== baseline.hash) { const e = new Error("Conflict"); e.code = "FILE_CONFLICT"; throw e; }
36 content = copies.get(path); baseline = { ...baseline, hash: request.hash };
37 return { status: "saved", hash: request.hash, baseline: { ...baseline } };
38 }
39 if (request.operation === "cleanup") {
40 if (cleanupError) throw cleanupError;
41 copies.delete(path); journals.delete(request.id); return { status: "confirmed" };
42 }
43 if (request.operation === "list") return { items: [...journals.keys()].map((recoveryId) => ({ recoveryId })) };
44 if (request.operation === "inspect") return { data: copies.get(path).toString("base64"), hash: hash(copies.get(path)), complete: true, status: "recovery-available", recoveryId: request.id };
45 throw new Error("Unexpected operation");
46 }
47 };
48 return { service: new RemoteFileService({ sessionManager: manager }), manager, calls, copies, journals,
49 get content() { return content.toString(); }, get baseline() { return baseline; },
50 set finalError(value) { finalError = value; }, set uploadError(value) { uploadError = value; }, set cleanupError(value) { cleanupError = value; },
51 change() { content = Buffer.from("external change"); baseline = { ...baseline, hash: hash(content) }; } };
54test("editor captures a baseline, stages privately in actual home and finalizes once before cleanup", async () => {
55 const f = fixture();
56 const opened = await f.service.readText("s", "/etc/test config");
57 assert.equal(opened.text, "original");
58 assert.equal(opened.metadata.uid, 0);
59 const result = await f.service.writeText("s", "/etc/test config", "modified", { editId: opened.editId, sudo: true, sudoPassword: "secret" });
60 assert.equal(result.status, "saved");
61 assert.equal(f.content, "modified");
62 assert.deepEqual(f.calls.map((call) => call.operation || call.op), ["read", "prepare", "upload", "finalize", "cleanup"]);
63 const upload = f.calls[2];
64 assert.match(upload.path, /^\/srv\/custom-home\/ch-j-sm\/\.[a-f0-9]{32}\.tmp$/);
65 assert.deepEqual(upload.options, { flag: "wx", mode: 0o600 });
66 assert.equal(f.calls[3].baseline.mode, 0o6754);
67 assert.equal(f.calls[3].baseline.gid, 33);
68 assert.equal(f.calls[3].authorization.sudoPassword, "secret");
69 assert.equal(f.copies.size, 0);
70});
72for (const code of ["FILE_SUDO_FAILED", "FILE_PERMISSION_DENIED", "FILE_CONFLICT", "FILE_REMOTE_IO_FAILED", "FILE_NOT_FOUND", "FILE_METADATA_UNSUPPORTED"]) {
73 test(code + " preserves original and complete recovery copy", async () => {
74 const f = fixture(); await f.service.readText("s", "/etc/config");
75 f.finalError = Object.assign(new Error("failure"), { code });
76 const result = await f.service.saveText("s", "/etc/config", "modified");
77 assert.equal(result.ok, false); assert.equal(result.error.code, code);
78 assert.equal(f.content, "original"); assert.equal([...f.copies.values()][0].toString(), "modified");
79 assert.equal(f.calls.some((c) => c.operation === "cleanup"), false);
80 assert.equal((await f.service.listRecovery("s")).items.length, 1);
81 assert.equal((await f.service.readRecovery("s", result.error.recoveryId)).text, "modified");
82 });
85test("upload failure leaves target intact, partial copy discoverable and never finalizes", async () => {
86 const f = fixture(); await f.service.readText("s", "/var/www/config");
87 f.uploadError = new Error("Disk full");
88 const result = await f.service.saveText("s", "/var/www/config", "modified");
89 assert.equal(result.error.code, "FILE_UPLOAD_FAILED"); assert.equal(f.content, "original");
90 assert.equal(f.calls.some((c) => c.operation === "finalize"), false);
91 assert.equal(f.copies.size, 1);
92});
94test("stalled SFTP upload times out without finalization and closes late channels", async (t) => {
95 t.mock.timers.enable({ apis: ["setTimeout"] });
96 const f = fixture(); await f.service.readText("s", "/etc/config");
97 let finishOpen, ended = 0;
98 f.manager.openSftp = () => new Promise((resolve) => { finishOpen = resolve; });
99 const saving = f.service.saveText("s", "/etc/config", "modified");
100 await new Promise((resolve) => setImmediate(resolve));
101 t.mock.timers.tick(60000);
102 const result = await saving;
103 assert.equal(result.error.code, "FILE_UPLOAD_TIMEOUT"); assert.equal(f.content, "original");
104 assert.equal(f.calls.some((c) => c.operation === "finalize"), false);
105 finishOpen({ end() { ended++; } });
106 await new Promise((resolve) => setImmediate(resolve));
107 assert.equal(ended, 1);
108});
110test("lost final confirmation retains recovery and prevents automatic repeat, including after reconnect", async () => {
111 const f = fixture(); await f.service.readText("s", "/etc/config");
112 f.finalError = Object.assign(new Error("disconnected"), { code: "FILE_RESULT_UNKNOWN" });
113 const result = await f.service.saveText("s", "/etc/config", "modified");
114 assert.equal(result.error.status, "unknown");
115 assert.equal((await f.service.saveText("s", "/etc/config", "modified")).error.code, "FILE_RESULT_UNKNOWN");
116 assert.equal(f.calls.filter((c) => c.operation === "finalize").length, 1);
117 const reconnected = new RemoteFileService({ sessionManager: f.manager });
118 assert.equal((await reconnected.listRecovery("s")).items[0].recoveryId, result.error.recoveryId);
119 assert.equal((await reconnected.readRecovery("s", result.error.recoveryId)).text, "modified");
120});
122test("unexpected remote failures remain unknown and never enable automatic repetition", async () => {
123 const f = fixture(); await f.service.readText("s", "/etc/config");
124 f.finalError = Object.assign(new Error("Unexpected exit"), { code: "FILE_UNEXPECTED_EXIT" });
125 const result = await f.service.saveText("s", "/etc/config", "modified");
126 assert.equal(result.error.code, "FILE_RESULT_UNKNOWN"); assert.equal(result.error.status, "unknown");
127 assert.equal(f.copies.size, 1);
128});
130test("simultaneous saves of one document are rejected while the first upload is pending", async () => {
131 const f = fixture(); const opened = await f.service.readText("s", "/etc/config");
132 let finishUpload;
133 const open = f.manager.openSftp;
134 f.manager.openSftp = async () => {
135 const sftp = await open(); const write = sftp.writeFile;
136 sftp.writeFile = (...args) => { finishUpload = () => write(...args); };
137 return sftp;
138 };
139 const first = f.service.writeText("s", "/etc/config", "first", { editId: opened.editId });
140 await new Promise((resolve) => setImmediate(resolve));
141 const second = await f.service.saveText("s", "/etc/config", "second", { editId: opened.editId });
142 assert.equal(second.ok, false); assert.equal(f.calls.filter((c) => c.operation === "prepare").length, 1);
143 finishUpload(); await first;
144 assert.equal(f.content, "first");
145});
147test("editor preserves a trailing space in a filename", async () => {
148 const f = fixture(); await f.service.readText("s", "/etc/config ");
149 await f.service.writeText("s", "/etc/config ", "modified");
150 assert.equal(f.calls.find((c) => c.operation === "finalize").path, "/etc/config ");
151});
153test("confirmed save stays successful when cleanup cannot be confirmed", async () => {
154 const f = fixture(); await f.service.readText("s", "/etc/config"); f.cleanupError = new Error("disconnected");
155 const saved = await f.service.writeText("s", "/etc/config", "modified");
156 assert.equal(saved.status, "saved"); assert.equal(saved.recoveryAvailable, true); assert.equal(f.copies.size, 1);
157});
159test("independent editor baselines detect concurrent changes and temporary names never collide", async () => {
160 const f = fixture(); const one = await f.service.readText("s", "/etc/config"); const two = await f.service.readText("s", "/etc/config");
161 await f.service.writeText("s", "/etc/config", "first", { editId: one.editId });
162 const result = await f.service.saveText("s", "/etc/config", "second", { editId: two.editId });
163 assert.equal(result.error.code, "FILE_CONFLICT"); assert.equal(f.content, "first");
164 const paths = f.calls.filter((c) => c.op === "upload").map((c) => c.path);
165 assert.equal(new Set(paths).size, 2);
166});
168test("legacy calls cannot silently choose a baseline when multiple documents share a path", async () => {
169 const f = fixture(); const one = await f.service.readText("s", "/etc/config"); const two = await f.service.readText("s", "/etc/config");
170 const ambiguous = await f.service.saveText("s", "/etc/config", "modified");
171 assert.equal(ambiguous.error.code, "FILE_EDIT_ID_REQUIRED"); assert.equal(f.content, "original");
172 f.service.closeText(one.editId);
173 const result = await f.service.writeText("s", "/etc/config", "modified");
174 assert.equal(result.editId, two.editId);
175});
177test("editor rejects traversal and cross-plugin/cross-server baseline reuse", async () => {
178 const f = fixture();
179 await assert.rejects(() => f.service.readText("s", "/etc/../etc/passwd"), /traversal/);
180 const opened = await f.service.readText("s", "/etc/config", {}, "plugin-a");
181 await assert.rejects(() => f.service.writeText("s", "/etc/config", "x", { editId: opened.editId }, "plugin-b"), /baseline/);
182 f.manager.list = () => [{ sessionId: "s", state: "connected", host: "other", username: "user" }];
183 await assert.rejects(() => f.service.writeText("s", "/etc/config", "x", { editId: opened.editId }, "plugin-a"), /baseline/);
184});
186test("ordinary writable saves work with legacy arguments after opening; document handles can be released", async () => {
187 const f = fixture(); const opened = await f.service.readText("s", "/home/user/config");
188 await f.service.writeText("s", "/home/user/config", "modified");
189 assert.deepEqual(f.calls.find((c) => c.operation === "finalize").authorization, {});
190 assert.equal(f.service.closeText(opened.editId).closed, true);
191 await assert.rejects(() => f.service.writeText("s", "/home/user/config", "x"), /baseline/);
192});

SHA-256: b4ca996f0b9f8e892d8e18d6e4189ba6b56534f1a8b9de135d64a894ae389ec9

SHA-256 archivu: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0