| 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 |