返回 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/Hmbown/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 for (const collision of ["directory", "directory symlink"]) {
167 test(`${kind}: a raced ${collision} cannot redirect publication`, t => {
168 const f = fixture(t, kind);
169 const foreign = path.join(f.root, "foreign-directory");
170 fs.mkdirSync(foreign);
171 f.env.INSTALL_TEST_FOREIGN = foreign;
172 const create = collision === "directory"
173 ? 'mkdir "$destination"'
174 : '/bin/ln -s "$INSTALL_TEST_FOREIGN" "$destination"';
175 executable(path.join(f.bin, "ln"), `#!/bin/sh
176 destination="$2$(basename "$1")"
177 ${create}
178 exec /bin/ln "$@"
179 `);
180 const result = f.run();
181 assert.notEqual(result.status, 0, result.stdout);
182 assert.doesNotMatch(result.stdout, /Installed checksummed|Done\. Commands/);
183 const target = path.join(f.destination, "codewhale");
184 assert.equal(fs.lstatSync(target).isSymbolicLink(), collision === "directory symlink");
185 assert.deepEqual(fs.readdirSync(target), []);
186 assert.deepEqual(fs.readdirSync(foreign), []);
187 assert.equal(fs.existsSync(path.join(f.destination, "codew")), false);
188 assert.equal(fs.readdirSync(f.destination).some(name => name.startsWith(".codewhale-install.")), false);
189 f.untouched();
190 });
191 }
192 }
193
194 test("website: a checksum mismatch stops before any installation", t => {
195 const f = fixture(t, "website");
196 fs.writeFileSync(path.join(f.assets, "codew-macos-arm64"), "tampered");
197 const result = f.run();
198 assert.notEqual(result.status, 0);
199 assert.match(result.stderr, /checksum mismatch/);
200 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
201 f.untouched();
202 });
203
204 test("archive: a missing second binary stops before the first installation", t => {
205 const f = fixture(t, "archive");
206 fs.unlinkSync(path.join(f.archive, "codew"));
207 const result = f.run();
208 assert.notEqual(result.status, 0);
209 assert.match(result.stderr, /not found in archive/);
210 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
211 f.untouched();
212 });
213
214 test("website: Termux never downloads a Linux binary", t => {
215 const f = fixture(t, "website");
216 f.env.TERMUX_VERSION = "fixture";
217 const result = f.run();
218 assert.notEqual(result.status, 0);
219 assert.match(result.stderr, /Android\/Termux needs the Android/);
220 assert.equal(fs.existsSync(f.env.INSTALL_TEST_DOWNLOADS), false);
221 assert.equal(fs.existsSync(path.join(f.destination, "codewhale")), false);
222 f.untouched();
223 });
224
224 lines JAVASCRIPT