返回 CodeWhale
install-safety.test.js
根目录 / scripts / release / install-safety.test.js
1 #!/usr/bin/env node
2
3 // Offline shell proofs. Every destination and download is a disposable fixture;
4 // curl, uname, and sudo are intercepted so no real installation or network runs.
5 const assert = require("node:assert/strict");
6 const { spawnSync } = require("node:child_process");
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 test = require("node:test");
12
13 const repo = path.resolve(__dirname, "../..");
14 const bytes = '#!/bin/sh\necho executed >> "$INSTALL_TEST_EXECUTED"\necho "codewhale 0.9.11"\n';
15
16 function executable(file, body) {
17 fs.writeFileSync(file, body, { mode: 0o755 });
18 }
19
20 function fixture(t, kind) {
21 const root = fs.mkdtempSync(path.join(os.tmpdir(), "cw-install-safety-"));
22 t.after(() => fs.rmSync(root, { recursive: true, force: true }));
23 const home = path.join(root, "home");
24 const bin = path.join(root, "tools");
25 const archive = path.join(root, "archive");
26 const assets = path.join(root, "assets");
27 for (const dir of [home, bin, archive, assets]) fs.mkdirSync(dir);
28 fs.copyFileSync(path.join(repo, "scripts/release/install.sh"), path.join(archive, "install.sh"));
29 for (const name of ["codewhale", "codew"]) {
30 executable(path.join(archive, name), bytes);
31 executable(path.join(assets, `${name}-macos-arm64`), bytes);
32 }
33 const hash = crypto.createHash("sha256").update(bytes).digest("hex");
34 fs.writeFileSync(path.join(assets, "codewhale-artifacts-sha256.txt"),
35 `${hash} codewhale-macos-arm64\n${hash} codew-macos-arm64\n`);
36 executable(path.join(bin, "uname"), '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo arm64 ;; esac\n');
37 executable(path.join(bin, "sudo"), '#!/bin/sh\necho sudo >> "$INSTALL_TEST_EXECUTED"\nexit 97\n');
38 executable(path.join(bin, "codewhale"), '#!/bin/sh\necho shadow >> "$INSTALL_TEST_EXECUTED"\nexit 98\n');
39 executable(path.join(bin, "curl"), `#!/bin/sh
40 url=""; output=""
41 while [ "$#" -gt 0 ]; do
42 case "$1" in
43 -o) shift; output="$1" ;;
44 http*) url="$1" ;;
45 esac
46 shift
47 done
48 printf '%s\n' "$url" >> "$INSTALL_TEST_DOWNLOADS"
49 asset="$(basename "$url")"
50 cp "$INSTALL_TEST_ASSETS/$asset" "$output"
51 `);
52 const destination = path.join(home, ".local", "bin");
53 const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}`,
54 CODEWHALE_VERSION: "v0.9.11", CODEWHALE_INSTALL_DIR: destination,
55 PREFIX: path.dirname(destination), INSTALL_TEST_ASSETS: assets,
56 INSTALL_TEST_EXECUTED: path.join(root, "executed"),
57 INSTALL_TEST_DOWNLOADS: path.join(root, "downloads") };
58 for (const key of ["CODEWHALE_RELEASE_BASE_URL", "DEEPSEEK_TUI_RELEASE_BASE_URL", "CODEWHALE_SKIP_GLIBC_CHECK", "DEEPSEEK_TUI_SKIP_GLIBC_CHECK", "DEEPSEEK_SKIP_GLIBC_CHECK", "TERMUX_VERSION"]) delete env[key];
59 function run() {
60 const script = kind === "website" ? path.join(repo, "web/public/install.sh") : path.join(archive, "install.sh");
61 return spawnSync(kind === "website" ? "sh" : "bash", [script], { env, encoding: "utf8", timeout: 10000 });
62 }
63 function prepare() { fs.mkdirSync(env.CODEWHALE_INSTALL_DIR, { recursive: true }); }
64 function untouched() { assert.equal(fs.existsSync(env.INSTALL_TEST_EXECUTED), false, "installers must not execute existing files or sudo"); }
65 return { root, home, bin, archive, assets, destination, env, run, prepare, untouched };
66 }
67
68 for (const kind of ["website", "archive"]) {
69 test(`${kind}: fresh installation verifies bytes, executable modes, and PATH shadowing`, t => {
70 const f = fixture(t, kind);
71 const result = f.run();
72 assert.equal(result.status, 0, result.stderr);
73 for (const name of ["codewhale", "codew"]) {
74 const installed = path.join(f.destination, name);
75 assert.equal(fs.readFileSync(installed, "utf8"), bytes);
76 assert.ok(fs.statSync(installed).mode & 0o111);
77 }
78 assert.match(result.stdout, /PATH selects/);
79 assert.ok(result.stdout.includes(`"${fs.realpathSync(f.destination)}/codewhale" update`), result.stdout);
80 if (kind === "website") {
81 const downloads = fs.readFileSync(f.env.INSTALL_TEST_DOWNLOADS, "utf8").trim().split("\n");
82 assert.equal(downloads.length, 3);
83 assert.ok(downloads.every(url => url.startsWith("https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.11/")));
84 }
85 f.untouched();
86 });
87
88 test(`${kind}: rerunning an identical installation preserves its files`, t => {
89 const f = fixture(t, kind);
90 assert.equal(f.run().status, 0);
91 const file = path.join(f.destination, "codewhale");
92 const before = fs.statSync(file);
93 const result = f.run();
94 assert.equal(result.status, 0, result.stderr);
95 assert.equal(fs.statSync(file).ino, before.ino);
96 assert.equal(fs.readFileSync(file, "utf8"), bytes);
97 f.untouched();
98 });
99
100 test(`${kind}: refuses a newer existing binary without downgrading or executing it`, t => {
101 const f = fixture(t, kind); f.prepare();
102 const primary = path.join(f.destination, "codewhale");
103 const newer = bytes.replace("0.9.11", "0.9.12");
104 executable(primary, newer);
105 const result = f.run();
106 assert.notEqual(result.status, 0);
107 assert.ok(result.stderr.includes(primary), result.stderr);
108 assert.match(result.stderr, /mktemp -d/);
109 assert.equal(fs.readFileSync(primary, "utf8"), newer);
110 assert.equal(fs.existsSync(path.join(f.destination, "codew")), false);
111 f.untouched();
112 });
113
114 for (const name of ["codew", "codewhale-tui"]) {
115 test(`${kind}: conflicting ${name} prevents the first install write`, t => {
116 const f = fixture(t, kind); f.prepare();
117 const file = path.join(f.destination, name);
118 executable(file, "unrelated bytes");
119 const result = f.run();
120 assert.notEqual(result.status, 0);
121 assert.ok(result.stderr.includes(file), result.stderr);
122 assert.equal(fs.readFileSync(file, "utf8"), "unrelated bytes");
123 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
124 f.untouched();
125 });
126 }
127
128 test(`${kind}: refuses a symlink destination and preserves its target`, t => {
129 const f = fixture(t, kind); f.prepare();
130 const target = path.join(f.root, "foreign");
131 executable(target, "unrelated bytes");
132 const alias = path.join(f.destination, "codew");
133 fs.symlinkSync(target, alias);
134 assert.notEqual(f.run().status, 0);
135 assert.ok(fs.lstatSync(alias).isSymbolicLink());
136 assert.equal(fs.readFileSync(target, "utf8"), "unrelated bytes");
137 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
138 f.untouched();
139 });
140
141 test(`${kind}: managed directories are refused without invoking sudo`, t => {
142 const f = fixture(t, kind);
143 f.env.PREFIX = path.join(f.home, ".cargo");
144 f.env.CODEWHALE_INSTALL_DIR = path.join(f.env.PREFIX, "bin");
145 const result = f.run();
146 assert.notEqual(result.status, 0);
147 assert.match(result.stderr, /managed\/system/);
148 assert.equal(fs.existsSync(path.join(f.env.CODEWHALE_INSTALL_DIR, "codewhale")), false);
149 f.untouched();
150 });
151
152 test(`${kind}: a destination created during publication is never overwritten`, t => {
153 const f = fixture(t, kind);
154 executable(path.join(f.bin, "ln"), `#!/bin/sh
155 destination="$2$(basename "$1")"
156 printf 'another writer' > "$destination"
157 exec /bin/ln "$@"
158 `);
159 assert.notEqual(f.run().status, 0);
160 assert.equal(fs.readFileSync(path.join(f.destination, "codewhale"), "utf8"), "another writer");
161 assert.equal(fs.existsSync(path.join(f.destination, "codew")), false);
162 assert.equal(fs.readdirSync(f.destination).some(name => name.startsWith(".codewhale-install.")), false);
163 f.untouched();
164 });
165
166 test(`${kind}: a failed second publication removes the command this run published`, t => {
167 const f = fixture(t, kind);
168 // Another writer claims codew between preflight and its publication.
169 executable(path.join(f.bin, "ln"), `#!/bin/sh
170 case "$(basename "$1")" in codew) printf 'another writer' > "$2codew" ;; esac
171 exec /bin/ln "$@"
172 `);
173 const result = f.run();
174 assert.notEqual(result.status, 0, result.stdout);
175 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false, "no half-installed pair is left behind");
176 assert.equal(fs.readFileSync(path.join(f.destination, "codew"), "utf8"), "another writer");
177 assert.match(result.stderr, /did not complete/);
178 assert.equal(fs.readdirSync(f.destination).some(name => name.startsWith(".codewhale-install.")), false);
179 f.untouched();
180 });
181
182 test(`${kind}: rollback never removes a command that was already installed`, t => {
183 const f = fixture(t, kind);
184 assert.equal(f.run().status, 0);
185 fs.unlinkSync(path.join(f.destination, "codew"));
186 const kept = fs.statSync(path.join(f.destination, "codewhale")).ino;
187 executable(path.join(f.bin, "ln"), `#!/bin/sh
188 case "$(basename "$1")" in codew) printf 'another writer' > "$2codew" ;; esac
189 exec /bin/ln "$@"
190 `);
191 assert.notEqual(f.run().status, 0);
192 assert.equal(fs.statSync(path.join(f.destination, "codewhale")).ino, kept);
193 f.untouched();
194 });
195
196 for (const collision of ["directory", "directory symlink"]) {
197 test(`${kind}: a raced ${collision} cannot redirect publication`, t => {
198 const f = fixture(t, kind);
199 const foreign = path.join(f.root, "foreign-directory");
200 fs.mkdirSync(foreign);
201 f.env.INSTALL_TEST_FOREIGN = foreign;
202 const create = collision === "directory"
203 ? 'mkdir "$destination"'
204 : '/bin/ln -s "$INSTALL_TEST_FOREIGN" "$destination"';
205 executable(path.join(f.bin, "ln"), `#!/bin/sh
206 destination="$2$(basename "$1")"
207 ${create}
208 exec /bin/ln "$@"
209 `);
210 const result = f.run();
211 assert.notEqual(result.status, 0, result.stdout);
212 assert.doesNotMatch(result.stdout, /Installed checksummed|Done\. Commands/);
213 const target = path.join(f.destination, "codewhale");
214 assert.equal(fs.lstatSync(target).isSymbolicLink(), collision === "directory symlink");
215 assert.deepEqual(fs.readdirSync(target), []);
216 assert.deepEqual(fs.readdirSync(foreign), []);
217 assert.equal(fs.existsSync(path.join(f.destination, "codew")), false);
218 assert.equal(fs.readdirSync(f.destination).some(name => name.startsWith(".codewhale-install.")), false);
219 f.untouched();
220 });
221 }
222 }
223
224 test("website: a checksum mismatch stops before any installation", t => {
225 const f = fixture(t, "website");
226 fs.writeFileSync(path.join(f.assets, "codew-macos-arm64"), "tampered");
227 const result = f.run();
228 assert.notEqual(result.status, 0);
229 assert.match(result.stderr, /checksum mismatch/);
230 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
231 f.untouched();
232 });
233
234 test("archive: a missing second binary stops before the first installation", t => {
235 const f = fixture(t, "archive");
236 fs.unlinkSync(path.join(f.archive, "codew"));
237 const result = f.run();
238 assert.notEqual(result.status, 0);
239 assert.match(result.stderr, /not found in archive/);
240 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
241 f.untouched();
242 });
243
244 test("website: Termux never downloads a Linux binary", t => {
245 const f = fixture(t, "website");
246 f.env.TERMUX_VERSION = "fixture";
247 const result = f.run();
248 assert.notEqual(result.status, 0);
249 assert.match(result.stderr, /Android\/Termux needs the Android/);
250 assert.equal(fs.existsSync(f.env.INSTALL_TEST_DOWNLOADS), false);
251 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
252 f.untouched();
253 });
254
255 function addHostPayload(f) {
256 const payloads = {
257 'codewhale-extension-host': '#!/bin/sh\nexit 0\n',
258 'codewhale-extension-host.LICENSES.txt': 'offline runtime notice fixture\n',
259 'codewhale-extension-host.relink-source.tar.gz': 'offline relink fixture\n',
260 'codewhale-extension-host.release.json': '{"fixture":"already qualified by release producer"}\n',
261 };
262 let manifest = fs.readFileSync(path.join(f.assets, 'codewhale-artifacts-sha256.txt'), 'utf8');
263 for (const [name, content] of Object.entries(payloads)) {
264 fs.writeFileSync(path.join(f.archive, name), content, { mode: name === 'codewhale-extension-host' ? 0o755 : 0o644 });
265 const asset = name === 'codewhale-extension-host.release.json' ? 'codewhale-extension-hosts.json' : name.replace('codewhale-extension-host', 'codewhale-extension-host-macos-arm64').replace('.LICENSES.txt', '-LICENSES.txt').replace('.relink-source.tar.gz', '-relink-source.tar.gz');
266 fs.writeFileSync(path.join(f.assets, asset), content);
267 manifest += `${crypto.createHash('sha256').update(content).digest('hex')} ${asset}\n`;
268 }
269 fs.writeFileSync(path.join(f.assets, 'codewhale-artifacts-sha256.txt'), manifest);
270 return payloads;
271 }
272 for (const kind of ['website', 'archive']) {
273 test(`${kind}: qualified companion presence stays inert until explicit installer choice`, t => {
274 const f = fixture(t, kind), payloads = addHostPayload(f);
275 f.env.CODEWHALE_INSTALL_COMPILED_HOST = '0';
276 assert.equal(f.run().status, 0);
277 assert.equal(fs.existsSync(path.join(f.destination, 'codewhale-extension-host')), false);
278 f.env.CODEWHALE_INSTALL_COMPILED_HOST = '1';
279 const result = f.run(); assert.equal(result.status, 0, result.stderr);
280 for (const [name, content] of Object.entries(payloads)) {
281 const file = path.join(f.destination, name);
282 assert.equal(fs.readFileSync(file, 'utf8'), content);
283 assert.equal(fs.statSync(file).mode & 0o777, name === 'codewhale-extension-host' ? 0o755 : 0o644);
284 }
285 f.untouched();
286 });
287 test(`${kind}: a conflicting companion prevents the first command publication`, t => {
288 const f = fixture(t, kind); addHostPayload(f); f.prepare(); f.env.CODEWHALE_INSTALL_COMPILED_HOST = '1';
289 const foreign = path.join(f.destination, 'codewhale-extension-host.LICENSES.txt'); fs.writeFileSync(foreign, 'foreign');
290 const result = f.run(); assert.notEqual(result.status, 0); assert.ok(result.stderr.includes(foreign), result.stderr);
291 assert.equal(fs.readFileSync(foreign, 'utf8'), 'foreign'); assert.equal(fs.existsSync(path.join(f.destination, 'codewhale')), false);
292 f.untouched();
293 });
294 test(`${kind}: late companion collision rolls back every new command and image`, t => {
295 const f = fixture(t, kind); addHostPayload(f); f.env.CODEWHALE_INSTALL_COMPILED_HOST = '1';
296 executable(path.join(f.bin, 'ln'), `#!/bin/sh\ncase "$(basename "$1")" in codewhale-extension-host.LICENSES.txt) printf 'another writer' > "$2codewhale-extension-host.LICENSES.txt" ;; esac\nexec /bin/ln "$@"\n`);
297 const result = f.run(); assert.notEqual(result.status, 0, result.stdout);
298 for (const name of ['codewhale', 'codew', 'codewhale-extension-host', 'codewhale-extension-host.release.json']) assert.equal(fs.existsSync(path.join(f.destination, name)), false, name);
299 assert.equal(fs.readFileSync(path.join(f.destination, 'codewhale-extension-host.LICENSES.txt'), 'utf8'), 'another writer');
300 f.untouched();
301 });
302 }
303
303 lines JAVASCRIPT