diff --git a/README.md b/README.md
index aa6f785..5754261 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,14 @@ if the Proxy is down. Software that ignores proxy variables can use
connection profile yet. Obtain enrollment, member-set inputs, the Elder onion,
and its signer pin from the operator you intend to use.
+Prefer the prebuilt Rust client binary? One line installs it into
+`~/.local/bin` after verifying its published checksum; options and caveats are
+in [`rust/INSTALL.md`](rust/INSTALL.md):
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/dmarzzz/shade-tree-node/main/scripts/install.sh | sh
+```
+
## How it works

diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 4a13f16..fcfd7bb 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -182,6 +182,10 @@ Read only by the one-command droplet bring-up (not by any `shade-tree` process).
| `SHADE_TREE_REGISTRAR` | `0` | `1` = render + start `shade-tree-registrar.service` (the 402 registrar), publish it as an extra `HiddenServicePort SHADE_TREE_REGISTRAR_PORT` of an onion this box runs — the BOOTNODE onion (bootnode+gateway box; the bootnode advertises it in `/health`) or, T-FEAT-9, the GATEWAY onion (gateway-only box, `SHADE_TREE_BOOTNODE_ONION` set) — and make the heartbeat advertise it as signed `caps.pay`. Companions (all required with `1`): `SHADE_TREE_PAID_ACCESS_CONTRACT`, `SHADE_TREE_PAY_ASSET`, `SHADE_TREE_PAY_PRICES`, `SHADE_TREE_RPC_URL`, `paid` in `SHADE_TREE_ADMIT`; optional `SHADE_TREE_PAY_PROTOCOLS` (default `x402,mpp`; rendered into the registrar unit + both adverts), `SHADE_TREE_PAY_TO`, `SHADE_TREE_REGISTRAR_PORT`, `SHADE_TREE_PAY_CHAIN_ID`. The operator key is a secret → a 0600 drop-in, never a tunable (`docs/OPERATOR.md` "Selling access via 402"). |
| `SHADE_TREE_RENDER_ONLY` | (unset) | `
`: render the torrc + units under `/etc/…` and exit (no root, nothing installed); `--render ` is the same. |
+## Installer (`scripts/install.sh`)
+
+Read only by the one-line installer for the prebuilt Rust client, not by any `shade-tree` process: `SHADE_TREE_VERSION`, `SHADE_TREE_LIVE`, `SHADE_TREE_INSTALL_DIR`, `SHADE_TREE_FORCE`, `SHADE_TREE_TARGET`, `SHADE_TREE_LIBC`, `SHADE_TREE_RELEASE_BASE`. Defaults and meaning: `rust/INSTALL.md` "One-line install".
+
## Demo / test only
Not part of the core protocol; set only when running the demo page or the Sepolia integration script.
diff --git a/rust/INSTALL.md b/rust/INSTALL.md
index 6c960ac..bdf8450 100644
--- a/rust/INSTALL.md
+++ b/rust/INSTALL.md
@@ -5,12 +5,54 @@ Tagged releases publish two variants of the `shade-tree` binary:
| Variant | Includes | Published targets |
| --- | --- | --- |
| default | directory and receipt verification, selection, cache | Linux x86_64/aarch64 (GNU and musl), macOS x86_64/aarch64, Windows x86_64 |
-| `-live` | default features plus RLN proving and embedded Tor | Linux x86_64, macOS aarch64, Windows x86_64 |
+| `-live` | default features plus RLN proving and embedded Tor | Linux x86_64 (GNU), macOS aarch64, Windows x86_64 |
Every asset has a matching `.sha256` file. The `-live` builds embed the
repository's testnet RLN artifacts; review
[`../circuits/rln/ARTIFACTS.md`](../circuits/rln/ARTIFACTS.md) before use.
+## One-line install
+
+`scripts/install.sh` detects your OS and CPU, downloads the matching asset and
+its `.sha256` from the latest release, verifies the checksum, and only then
+places the binary in `~/.local/bin/shade-tree`. It needs `curl` and
+`sha256sum`, `shasum`, or `openssl`. It never uses sudo and never runs a byte
+it has not verified. It is configured through environment variables, so it
+behaves the same whether piped or saved and read first.
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/dmarzzz/shade-tree-node/main/scripts/install.sh | sh
+```
+
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `SHADE_TREE_VERSION` | latest release | Pin a tag, `v0.3.0` or `0.3.0` |
+| `SHADE_TREE_LIVE` | `0` | `1` installs the `-live` variant where the table above lists one; other targets are refused. Only `0` or `1` is accepted |
+| `SHADE_TREE_INSTALL_DIR` | `$HOME/.local/bin` | Destination directory, created if missing |
+| `SHADE_TREE_FORCE` | `0` | `1` replaces a symlink at the destination, which is how npm installs its own `shade-tree`. A regular file there is replaced without it, with a note |
+| `SHADE_TREE_TARGET` | detected | Skip detection, for example `x86_64-unknown-linux-musl` |
+| `SHADE_TREE_LIBC` | detected | `gnu` or `musl`; required when neither `ldd` nor the dynamic loader identifies the libc |
+| `SHADE_TREE_RELEASE_BASE` | GitHub Releases | `https://` only; `file://` and loopback `http://` are accepted for the selftest and local mirrors |
+
+Notes:
+
+- The npm CLI (`npm install --global git+...`) is also called `shade-tree`; it
+ keeps that name for the Proxy, `shade-tree run`, and the operator commands.
+ If both are on `PATH` the earlier directory wins; the installer lists every
+ other `shade-tree` it finds and says which one your shell will run. In the
+ examples below, the `identity` command belongs to the npm CLI; every other
+ `shade-tree` command is the Rust binary (`./target/release/shade-tree` from
+ source, `~/.local/bin/shade-tree` after this installer).
+- Live egress still needs the npm CLI once, to export `identity.json` at your
+ enrolled tier. Each Rust `egress` run picks its own slot, so until
+ [issue #75](https://github.com/dmarzzz/shade-tree-node/issues/75) is fixed
+ make at most one `egress` invocation per member secret per protocol epoch
+ (120 seconds by default), counting any attempt that may have reached a node.
+ `--slot-cursor` is best-effort and wraps at K; do not rely on it.
+- On Windows the installer runs from Git Bash or MSYS2 (x86_64) and installs
+ `shade-tree.exe`. PowerShell users download the asset and its `.sha256` by
+ hand and compare the digest with `Get-FileHash` as shown below.
+
## Download
Choose an asset from the [latest release](https://github.com/dmarzzz/shade-tree-node/releases/latest).
@@ -64,14 +106,18 @@ shade-tree verify-receipt receipt.json --onion
Live egress requires an identity and the membership set committed by the
gateway. Get the exact enrolled tier from the Grove operator. Generate the
-identity with the npm CLI at that tier, then use the live Rust build:
+identity with the npm CLI at that tier, then run the live Rust build by its
+path: the one-line install puts it in `~/.local/bin`, a source build in
+`./target/release`:
```bash
read -s SHADE_TREE_SECRET && export SHADE_TREE_SECRET
read -r SHADE_TREE_LIMIT && export SHADE_TREE_LIMIT
+# The identity command belongs to the npm CLI. If the Rust client shadows it on PATH,
+# call the npm CLI by path: node "$(npm root -g)/shade-tree-node/bin/shade-tree.mjs" ...
shade-tree identity --limit "$SHADE_TREE_LIMIT" --out identity.json
-./target/release/shade-tree egress \
+"${SHADE_TREE_INSTALL_DIR:-$HOME/.local/bin}/shade-tree" egress \
--directory directory.json \
--signer \
--identity identity.json \
diff --git a/scripts/install.selftest.mjs b/scripts/install.selftest.mjs
new file mode 100644
index 0000000..d3c9475
--- /dev/null
+++ b/scripts/install.selftest.mjs
@@ -0,0 +1,627 @@
+// Selftest for scripts/install.sh, the one-line installer for the prebuilt Rust `shade-tree`
+// binary (issue #64). Everything runs OFFLINE against a fake release tree served over file://
+// (and one loopback HTTP server, in a child process, for the "latest" redirect and HTTP error
+// classification); it never touches github.com.
+//
+// What is asserted:
+// 1. Every published default target (7) installs from the fake release when the host is
+// faked via shimmed `uname` and `ldd` (Linux gnu/musl, macOS, Git Bash on Windows), and
+// again when SHADE_TREE_TARGET overrides detection: exit 0, the installed file is
+// byte-identical to the asset, executable, named `shade-tree` (`shade-tree.exe` for the
+// Windows target), and the installer's temp dir is gone afterwards.
+// 2. SHADE_TREE_LIVE=1 installs the `-live` asset for the targets that publish one and fails
+// with a precise message for a target that does not; SHADE_TREE_LIVE=yes is refused.
+// 3. The checksum path FAILS CLOSED: a single flipped byte in the asset, a `.sha256` that names
+// a different file, a missing or garbage `.sha256`, or a missing asset each exit nonzero,
+// install nothing, leave no temp dir and no staging file behind.
+// 4. Destination preflight: a symlink to a file at the destination (how npm installs its own
+// `shade-tree`) is refused and left intact unless SHADE_TREE_FORCE=1; a symlink to a
+// directory is refused even when forced, with nothing dropped inside it; a regular file is
+// replaced; a directory is refused; an install dir with spaces yields quoted, runnable hints.
+// 5. "latest" is resolved from the release page redirect (302 -> /tag/vX.Y.Z), also with an
+// ambient http_proxy set (ignored for loopback) and via http://localhost; SHADE_TREE_VERSION
+// accepts `0.3.0` and `v0.3.0`; an HTTP 500 and a DNS failure are reported as such, not as
+// a missing asset.
+// 6. Refusals: cleartext non-loopback base and loopback lookalikes (user-info, prefix hosts,
+// bad ports) before any fetch; SHADE_TREE_TARGET outside the seven published triples
+// (including a smuggled "-live"); unsupported OS/arch; a Linux host whose libc cannot be
+// identified (unless SHADE_TREE_LIBC says); stray arguments; HOME unset without an install dir.
+// 7. Hints: PATH, next command, the npm-CLI name collision (also when the npm CLI sits behind
+// the install dir on PATH); the script never invokes sudo (checked statically outside
+// comments/strings and at runtime with a shim).
+//
+// node scripts/install.selftest.mjs
+//
+// Exit 0 = every check passed; nonzero = a check failed (prints which). Needs `sh` and `curl`
+// on PATH, which the installer itself needs anyway.
+
+import { spawn, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, existsSync, statSync, lstatSync, chmodSync, cpSync, symlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SCRIPT = join(HERE, "install.sh");
+
+let failures = 0;
+const ok = (cond, msg) => { if (cond) console.log(` ok ${msg}`); else { console.log(` FAIL ${msg}`); failures++; } };
+const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+// Mirrors .github/workflows/release.yml: 7 default targets, 3 of them also as `-live`.
+const VERSION = "0.3.0";
+const TARGETS = [
+ "x86_64-unknown-linux-gnu",
+ "aarch64-unknown-linux-gnu",
+ "x86_64-unknown-linux-musl",
+ "aarch64-unknown-linux-musl",
+ "x86_64-apple-darwin",
+ "aarch64-apple-darwin",
+ "x86_64-pc-windows-msvc",
+];
+const LIVE_TARGETS = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-pc-windows-msvc"];
+const ext = (target) => (target.includes("windows") ? ".exe" : "");
+const assetName = (target, live = false) => `shade-tree-${VERSION}-${target}${live ? "-live" : ""}${ext(target)}`;
+const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
+
+// A fake release: /download/v/{,.sha256}, exactly the GitHub layout, so the
+// installer has ONE code path. Each asset is a tiny distinct sh script so the native-target case
+// can prove it installed the right bytes by running them.
+function makeRelease(root) {
+ const dir = join(root, "download", `v${VERSION}`);
+ mkdirSync(dir, { recursive: true });
+ const assets = new Map();
+ for (const target of TARGETS) {
+ for (const live of LIVE_TARGETS.includes(target) ? [false, true] : [false]) {
+ const name = assetName(target, live);
+ const body = Buffer.from(`#!/bin/sh\necho "fake shade-tree ${target}${live ? " live" : ""}"\n`);
+ writeFileSync(join(dir, name), body);
+ // Same " " framing release.yml writes (two spaces, sha256sum style).
+ writeFileSync(join(dir, `${name}.sha256`), `${sha256(body)} ${name}\n`);
+ assets.set(name, body);
+ }
+ }
+ return assets;
+}
+
+// A shim dir prepended to PATH so `uname` (and `ldd`) answer for a pretend host. The installer
+// must resolve tools through PATH for this to work, which is also what a user's shell does.
+function shimHost(root, { sysname, machine, ldd = null }) {
+ const bin = join(root, "shim-bin");
+ mkdirSync(bin, { recursive: true });
+ writeFileSync(join(bin, "uname"), `#!/bin/sh\ncase "$1" in -m) echo "${machine}";; *) echo "${sysname}";; esac\n`);
+ chmodSync(join(bin, "uname"), 0o755);
+ if (ldd === "musl") writeFileSync(join(bin, "ldd"), `#!/bin/sh\necho "musl libc (x86_64)" >&2\nexit 1\n`);
+ if (ldd === "gnu") writeFileSync(join(bin, "ldd"), `#!/bin/sh\necho "ldd (GNU libc) 2.39"\n`);
+ if (ldd) chmodSync(join(bin, "ldd"), 0o755);
+ return bin;
+}
+
+// Minimal env: PATH + HOME + a private TMPDIR (so temp-dir cleanup is observable) + the
+// installer's own knobs. Nothing from the developer's shell leaks in.
+function runInstall(work, { env = {}, pathPrefix = null, timeout = 30_000, shell = "sh" } = {}) {
+ const tmp = join(work, "tmp");
+ const home = join(work, "home");
+ mkdirSync(tmp, { recursive: true });
+ mkdirSync(home, { recursive: true });
+ const r = spawnSync(shell, [SCRIPT], {
+ env: {
+ PATH: pathPrefix ? `${pathPrefix}:${process.env.PATH}` : process.env.PATH,
+ HOME: home,
+ TMPDIR: tmp,
+ ...env,
+ },
+ encoding: "utf8",
+ timeout,
+ });
+ return { status: r.status, signal: r.signal, error: r.error, stdout: r.stdout ?? "", stderr: r.stderr ?? "", out: (r.stdout ?? "") + (r.stderr ?? ""), tmp, home };
+}
+// An expected failure must be a real nonzero exit: a timeout (status null + SIGTERM) or a
+// spawn error would otherwise pass as "it refused".
+const failedCleanly = (r) => typeof r.status === "number" && r.status !== 0 && r.signal === null && !r.error;
+const tmpEmpty = (tmp) => readdirSync(tmp).length === 0;
+// No `.shade-tree.XXXXXX` staging leftovers next to the destination either.
+const noStage = (dir) => !existsSync(dir) || readdirSync(dir).every((f) => !f.startsWith(".shade-tree."));
+const fresh = (label) => mkdtempSync(join(tmpdir(), `shade-tree-install-${label}-`));
+
+// A loopback HTTP release server in its OWN process: the checks below drive the installer with
+// spawnSync, which blocks this process's event loop, so an in-process http.Server could never
+// answer curl. The child mimics GitHub's shape: /releases/latest -> 302 /releases/tag/,
+// /releases/download// -> bytes from the fake release tree; tag v5.0.0 answers 500.
+const SERVER_SRC = String.raw`
+import { createServer } from "node:http";
+import { readFileSync, existsSync } from "node:fs";
+import { join } from "node:path";
+const [root, tag] = process.argv.slice(2);
+const server = createServer((req, res) => {
+ const url = new URL(req.url, "http://127.0.0.1");
+ if (url.pathname === "/releases/latest") {
+ res.writeHead(302, { Location: "http://127.0.0.1:" + server.address().port + "/releases/tag/" + tag });
+ return res.end();
+ }
+ const m = url.pathname.match(/^\/releases\/download\/(v[^/]+)\/([^/]+)$/);
+ if (m && m[1] === "v5.0.0") { res.writeHead(500); return res.end("boom"); }
+ if (m) {
+ const p = join(root, "download", m[1], m[2]);
+ if (existsSync(p)) { res.writeHead(200); return res.end(readFileSync(p)); }
+ }
+ res.writeHead(404); res.end();
+});
+server.on("error", (e) => { process.stderr.write("listen failed: " + e.message + "\n"); process.exit(2); });
+server.listen(0, "127.0.0.1", () => { process.stdout.write(String(server.address().port) + "\n"); });
+`;
+function startReleaseServer(releaseRoot) {
+ const src = join(releaseRoot, "release-server.mjs");
+ writeFileSync(src, SERVER_SRC);
+ const child = spawn(process.execPath, [src, releaseRoot, `v${VERSION}`], { stdio: ["ignore", "pipe", "pipe"] });
+ const stop = () => { try { child.kill(); } catch { /* already gone */ } };
+ return new Promise((resolve, reject) => {
+ let buf = "";
+ let errText = "";
+ let settled = false;
+ let timer = null;
+ // Exactly one outcome: the startup timer is cleared on every path so it can never fire
+ // later and kill a server that started fine; a late exit after success is ignored.
+ const finish = (err, value) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (err) { stop(); reject(err); } else resolve(value);
+ };
+ timer = setTimeout(() => finish(new Error("release server did not report a port")), 10_000);
+ child.stdout.on("data", (chunk) => {
+ buf += chunk;
+ const nl = buf.indexOf("\n");
+ if (nl !== -1) finish(null, { port: Number(buf.slice(0, nl)), stop });
+ });
+ child.stderr.on("data", (chunk) => { errText += chunk; });
+ child.once("error", (e) => finish(new Error(`release server failed to start: ${e.message}`)));
+ child.once("exit", (code) => finish(new Error(`release server exited early (${code}): ${errText.trim()}`)));
+ });
+}
+// Only a sandbox that forbids listening is a reason to skip the loopback checks; anything
+// else (a syntax error in the server source, a crash) is a test failure, not coverage.
+const isSocketPolicyError = (e) => /\b(EPERM|EACCES)\b/.test(e.message);
+
+async function main() {
+ console.log("scripts/install.sh selftest\n");
+
+ ok(existsSync(SCRIPT), "scripts/install.sh exists");
+ const text = existsSync(SCRIPT) ? readFileSync(SCRIPT, "utf8") : "";
+ ok(text.startsWith("#!/bin/sh"), "shebang is #!/bin/sh (POSIX sh, not bash)");
+ // "never uses sudo" means never INVOKES it. The word is allowed in comments, the help text
+ // (a heredoc), and error strings, so strip those before looking for a bare sudo token. A
+ // runtime shim below double-checks by making any real call fail loudly.
+ const code = text
+ .replace(/cat <<'EOF'[\s\S]*?\nEOF\n/g, "")
+ .split("\n").filter((line) => !/^\s*#/.test(line)).join("\n")
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'[^']*'/g, "''");
+ ok(!/(^|[\s;&|(`])sudo(\s|$)/m.test(code), "the installer never invokes sudo (outside comments and messages)");
+ const syntax = spawnSync("sh", ["-n", SCRIPT], { encoding: "utf8" });
+ ok(syntax.status === 0, `sh -n parses the script${syntax.status === 0 ? "" : `: ${syntax.stderr}`}`);
+
+ const releaseRoot = fresh("release");
+ let server = null;
+ try {
+ const assets = makeRelease(releaseRoot);
+ const BASE = pathToFileURL(releaseRoot).href; // file:///.../release (+ /download/vX/asset)
+ const pinned = (extra = {}) => ({ SHADE_TREE_RELEASE_BASE: BASE, SHADE_TREE_VERSION: `v${VERSION}`, ...extra });
+
+ // --- 1. every target: detection through shimmed uname/ldd, then the override ------------
+ console.log("\n-- detection via shimmed uname/ldd, all 7 targets");
+ const HOSTS = [
+ { target: "x86_64-unknown-linux-gnu", sysname: "Linux", machine: "x86_64", ldd: "gnu" },
+ { target: "aarch64-unknown-linux-gnu", sysname: "Linux", machine: "aarch64", ldd: "gnu" },
+ { target: "x86_64-unknown-linux-musl", sysname: "Linux", machine: "amd64", ldd: "musl" },
+ { target: "aarch64-unknown-linux-musl", sysname: "Linux", machine: "arm64", ldd: "musl" },
+ { target: "x86_64-apple-darwin", sysname: "Darwin", machine: "x86_64" },
+ { target: "aarch64-apple-darwin", sysname: "Darwin", machine: "arm64" },
+ { target: "x86_64-pc-windows-msvc", sysname: "MINGW64_NT-10.0-22631", machine: "x86_64" },
+ { target: "x86_64-pc-windows-msvc", sysname: "MSYS_NT-10.0", machine: "x86_64" },
+ ];
+ for (const host of HOSTS) {
+ const work = fresh("detect");
+ const bin = shimHost(work, host);
+ const dest = join(work, "bin");
+ const r = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_INSTALL_DIR: dest }) });
+ const installed = join(dest, `shade-tree${ext(host.target)}`);
+ const label = `${host.sysname}/${host.machine}${host.ldd ? ` (ldd: ${host.ldd})` : ""} -> ${host.target}`;
+ ok(r.status === 0, `${label}: exit 0${r.status === 0 ? "" : `\n${r.out}`}`);
+ ok(existsSync(installed) && readFileSync(installed).equals(assets.get(assetName(host.target))), ` installed ${installed.split("/").pop()} == ${assetName(host.target)}`);
+ ok(existsSync(installed) && (statSync(installed).mode & 0o111) !== 0, " installed file is executable");
+ ok(tmpEmpty(r.tmp) && noStage(dest), " temp dir and staging file cleaned up");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ // Linux with no ldd and no loader on disk (this host is not Linux): no guess, ask.
+ const work = fresh("libc-unknown");
+ const bin = shimHost(work, { sysname: "Linux", machine: "x86_64" });
+ const dest = join(work, "bin");
+ const r = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_INSTALL_DIR: dest }) });
+ const decided = existsSync("/etc/alpine-release") || existsSync("/lib/ld-linux-x86-64.so.2") || existsSync("/lib64/ld-linux-x86-64.so.2");
+ if (decided) console.log(" skip unknown-libc refusal (this host really is Linux; a loader is present)");
+ else {
+ ok(failedCleanly(r) && !existsSync(join(dest, "shade-tree")), "Linux, libc undetectable: refused rather than guessing gnu");
+ ok(/SHADE_TREE_LIBC/.test(r.stderr), " error names SHADE_TREE_LIBC");
+ }
+ const r2 = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_LIBC: "musl" }) });
+ ok(r2.status === 0 && readFileSync(join(dest, "shade-tree")).equals(assets.get(assetName("x86_64-unknown-linux-musl"))), " SHADE_TREE_LIBC=musl resolves it");
+ const r3 = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_LIBC: "bionic" }) });
+ ok(failedCleanly(r3) && /gnu or musl/.test(r3.stderr), " SHADE_TREE_LIBC=bionic refused");
+ rmSync(work, { recursive: true, force: true });
+ }
+ console.log("\n-- SHADE_TREE_TARGET override, all 7 targets");
+ for (const target of TARGETS) {
+ const work = fresh("target");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ const installed = join(dest, `shade-tree${ext(target)}`);
+ ok(r.status === 0, `SHADE_TREE_TARGET=${target}: exit 0${r.status === 0 ? "" : `\n${r.out}`}`);
+ ok(existsSync(installed) && readFileSync(installed).equals(assets.get(assetName(target))), ` installed ${installed.split("/").pop()} == ${assetName(target)}`);
+ ok(tmpEmpty(r.tmp) && noStage(dest), " temp dir and staging file cleaned up");
+ rmSync(work, { recursive: true, force: true });
+ }
+ // The native target really runs: the fake binary is a sh script that names its target.
+ {
+ const native = process.platform === "darwin"
+ ? (process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin")
+ : process.platform === "linux"
+ ? (process.arch === "arm64" ? "aarch64-unknown-linux-" : "x86_64-unknown-linux-")
+ : null;
+ if (native) {
+ const work = fresh("native");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: BASE, SHADE_TREE_VERSION: VERSION, SHADE_TREE_INSTALL_DIR: dest } });
+ ok(r.status === 0, `native host detection (no SHADE_TREE_TARGET, version without v): exit 0${r.status === 0 ? "" : `\n${r.out}`}`);
+ const run = spawnSync(join(dest, "shade-tree"), [], { encoding: "utf8" });
+ ok(run.status === 0 && run.stdout.startsWith(`fake shade-tree ${native}`), ` installed binary runs and reports a ${native}* target (${run.stdout.trim() || run.stderr.trim()})`);
+ rmSync(work, { recursive: true, force: true });
+ } else console.log(" skip native-run check (unsupported host platform for the release binary)");
+ }
+
+ // --- 2. live variant ----------------------------------------------------------------------
+ console.log("\n-- SHADE_TREE_LIVE");
+ for (const target of LIVE_TARGETS) {
+ const work = fresh("live");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target, SHADE_TREE_LIVE: "1" }) });
+ const installed = join(dest, `shade-tree${ext(target)}`);
+ ok(r.status === 0 && existsSync(installed) && readFileSync(installed).equals(assets.get(assetName(target, true))), `live ${target}: installs ${assetName(target, true)}${r.status === 0 ? "" : `\n${r.out}`}`);
+ ok(/egress/.test(r.stdout) && /INSTALL\.md/.test(r.stdout) && /#75/.test(r.stdout) && !/shade-tree identity/.test(r.stdout), " next steps: egress, a pointer to INSTALL.md for identity.json, the #75 rule, and no ambiguous bare command");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const target = "aarch64-unknown-linux-gnu"; // published default-only
+ const work = fresh("nolive");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target, SHADE_TREE_LIVE: "1" }) });
+ ok(failedCleanly(r), `live ${target}: refused (no -live asset published)`);
+ ok(/-live/.test(r.stderr) && new RegExp(target).test(r.stderr), " error names the -live variant and the target");
+ ok(!existsSync(join(dest, "shade-tree")) && tmpEmpty(r.tmp) && noStage(dest), " nothing installed, nothing left behind");
+ rmSync(work, { recursive: true, force: true });
+ }
+ for (const bad of ["yes", "true", "2", " 1"]) {
+ const work = fresh("badlive");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target0(), SHADE_TREE_LIVE: bad }) });
+ ok(failedCleanly(r) && /SHADE_TREE_LIVE/.test(r.stderr) && !existsSync(join(dest, "shade-tree")), `SHADE_TREE_LIVE=${JSON.stringify(bad)}: refused, nothing installed`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ function target0() { return "x86_64-unknown-linux-gnu"; }
+
+ // --- 3. checksum path fails closed --------------------------------------------------------
+ console.log("\n-- checksum fails closed");
+ const target = target0();
+ const name = assetName(target);
+ const tamperCase = (label, mutate, expectRe) => {
+ const root = fresh(`tamper-${label.replace(/\W+/g, "-")}`);
+ cpSync(releaseRoot, root, { recursive: true });
+ mutate(join(root, "download", `v${VERSION}`));
+ const work = fresh("tamperrun");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: pathToFileURL(root).href, SHADE_TREE_VERSION: `v${VERSION}`, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target } });
+ ok(failedCleanly(r), `${label}: exit nonzero`);
+ ok(expectRe.test(r.stderr), ` stderr explains (${expectRe})${expectRe.test(r.stderr) ? "" : `: ${r.stderr.trim()}`}`);
+ ok(!existsSync(join(dest, "shade-tree")), " nothing installed");
+ ok(tmpEmpty(r.tmp) && noStage(dest), " temp dir and staging file cleaned up");
+ rmSync(root, { recursive: true, force: true });
+ rmSync(work, { recursive: true, force: true });
+ };
+ tamperCase("one flipped byte in the asset", (dir) => {
+ const p = join(dir, name);
+ const b = readFileSync(p);
+ b[b.length - 2] ^= 0x01;
+ writeFileSync(p, b);
+ }, /checksum mismatch/i);
+ tamperCase(".sha256 names a different file", (dir) => {
+ const p = join(dir, `${name}.sha256`);
+ writeFileSync(p, readFileSync(p, "utf8").replace(name, assetName("aarch64-apple-darwin")));
+ }, /names '.*', not/);
+ tamperCase("missing .sha256", (dir) => rmSync(join(dir, `${name}.sha256`)), /\.sha256 not found/);
+ tamperCase("missing asset", (dir) => rmSync(join(dir, name)), new RegExp(`download of ${esc(name)} failed`));
+ tamperCase("garbage .sha256 (no hex)", (dir) => writeFileSync(join(dir, `${name}.sha256`), "not a checksum line\n"), /malformed .*\.sha256/);
+ tamperCase("short hex in .sha256", (dir) => writeFileSync(join(dir, `${name}.sha256`), `${"ab".repeat(20)} ${name}\n`), /malformed .*\.sha256/);
+ // The two framings the parser deliberately accepts must still verify: uppercase hex
+ // (some tools print it) and sha256sum's binary-mode "*" marker.
+ const acceptCase = (label, mutate) => {
+ const root = fresh(`accept-${label.replace(/\W+/g, "-")}`);
+ cpSync(releaseRoot, root, { recursive: true });
+ mutate(join(root, "download", `v${VERSION}`));
+ const work = fresh("acceptrun");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: pathToFileURL(root).href, SHADE_TREE_VERSION: `v${VERSION}`, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target } });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")) && readFileSync(join(dest, "shade-tree")).equals(assets.get(name)), `${label}: still verifies and installs${r.status === 0 ? "" : `\n${r.out}`}`);
+ rmSync(root, { recursive: true, force: true });
+ rmSync(work, { recursive: true, force: true });
+ };
+ acceptCase("uppercase hex in .sha256", (dir) => writeFileSync(join(dir, `${name}.sha256`), `${sha256(assets.get(name)).toUpperCase()} ${name}\n`));
+ acceptCase("binary-mode '*file' in .sha256", (dir) => writeFileSync(join(dir, `${name}.sha256`), `${sha256(assets.get(name))} *${name}\n`));
+
+ // --- 4. destination preflight -------------------------------------------------------------
+ console.log("\n-- destination preflight");
+ {
+ const work = fresh("symlink");
+ const dest = join(work, "bin");
+ mkdirSync(dest, { recursive: true });
+ const npmCli = join(work, "npm-cli.mjs");
+ writeFileSync(npmCli, "// pretend npm CLI\n");
+ symlinkSync(npmCli, join(dest, "shade-tree"));
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(failedCleanly(r) && /symlink/.test(r.stderr) && /SHADE_TREE_FORCE/.test(r.stderr), "destination is a symlink (npm CLI): refused, SHADE_TREE_FORCE named");
+ ok(lstatSync(join(dest, "shade-tree")).isSymbolicLink() && existsSync(npmCli), " symlink and its target untouched");
+ ok(tmpEmpty(r.tmp) && noStage(dest), " nothing downloaded or staged");
+ const r2 = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target, SHADE_TREE_FORCE: "1" }) });
+ ok(r2.status === 0 && !lstatSync(join(dest, "shade-tree")).isSymbolicLink() && readFileSync(join(dest, "shade-tree")).equals(assets.get(name)), " SHADE_TREE_FORCE=1: symlink replaced by the binary");
+ ok(existsSync(npmCli), " the symlink's target file is not deleted");
+ const r3 = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target, SHADE_TREE_FORCE: "please" }) });
+ ok(failedCleanly(r3) && /SHADE_TREE_FORCE/.test(r3.stderr), " SHADE_TREE_FORCE=please refused");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("regular");
+ const dest = join(work, "bin");
+ mkdirSync(dest, { recursive: true });
+ writeFileSync(join(dest, "shade-tree"), "old build\n");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(r.status === 0 && readFileSync(join(dest, "shade-tree")).equals(assets.get(name)) && /will replace existing/.test(r.stdout), "destination is a regular file: replaced, with a note");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("dirdest");
+ const dest = join(work, "bin");
+ mkdirSync(join(dest, "shade-tree"), { recursive: true });
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(failedCleanly(r) && /not a regular file/.test(r.stderr) && statSync(join(dest, "shade-tree")).isDirectory(), "destination is a directory: refused, untouched");
+ rmSync(work, { recursive: true, force: true });
+ }
+ for (const force of ["0", "1"]) {
+ // A symlink to a DIRECTORY: `mv` would follow it and drop the binary inside, leaving the
+ // symlink in place and a false "installed". Must be refused even when forced.
+ const work = fresh("dirlink");
+ const dest = join(work, "bin");
+ const realDir = join(work, "real-dir");
+ mkdirSync(dest, { recursive: true });
+ mkdirSync(realDir, { recursive: true });
+ symlinkSync(realDir, join(dest, "shade-tree"));
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target, SHADE_TREE_FORCE: force }) });
+ ok(failedCleanly(r) && /symlink to a directory/.test(r.stderr), `destination is a symlink to a directory (FORCE=${force}): refused, exit nonzero`);
+ ok(lstatSync(join(dest, "shade-tree")).isSymbolicLink() && readdirSync(realDir).length === 0, " symlink intact, nothing installed inside its target");
+ ok(tmpEmpty(r.tmp) && noStage(dest), " no temp or staging residue");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ // An install dir with spaces: the printed next command must be quoted so it copy-pastes.
+ const work = fresh("spaces");
+ const dest = join(work, "my bin dir");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ const helpLine = r.stdout.split("\n").map((l) => l.trim()).find((l) => l.endsWith(" --help")) ?? "";
+ const run = spawnSync("sh", ["-c", helpLine], { encoding: "utf8" });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")), "install dir with spaces: installs");
+ ok(run.status === 0 && /fake shade-tree/.test(run.stdout), ` printed next command runs verbatim through sh -c (${helpLine.length ? "quoted" : "no --help line found"})`);
+ const pathLine = r.stdout.split("\n").map((l) => l.trim()).find((l) => l.startsWith("export PATH=")) ?? "";
+ const pathRun = spawnSync("sh", ["-c", `${pathLine}; command -v shade-tree`], { encoding: "utf8", env: { PATH: "/usr/bin:/bin" } });
+ ok(pathRun.status === 0 && pathRun.stdout.trim() === join(dest, "shade-tree"), " printed PATH hint runs verbatim and resolves the installed binary");
+ rmSync(work, { recursive: true, force: true });
+ }
+
+ // --- 5. latest via redirect, version forms, error classification (loopback http) ---------
+ console.log("\n-- latest via redirect, version forms, HTTP errors (loopback http)");
+ try {
+ server = await startReleaseServer(releaseRoot);
+ } catch (e) {
+ if (!isSocketPolicyError(e)) throw e;
+ console.log(` skip loopback HTTP checks (socket policy: ${e.message})`);
+ }
+ if (server) {
+ const httpBase = `http://127.0.0.1:${server.port}/releases`;
+ {
+ // An ambient http_proxy (unroutable here) must not capture loopback requests.
+ const work = fresh("latest");
+ const dest = join(work, "bin");
+ const proxyEnv = { http_proxy: "http://192.0.2.1:1", HTTP_PROXY: "http://192.0.2.1:1", ALL_PROXY: "http://192.0.2.1:1" };
+ const r = runInstall(work, { env: { ...proxyEnv, SHADE_TREE_RELEASE_BASE: httpBase, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }, timeout: 20_000 });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")) && readFileSync(join(dest, "shade-tree")).equals(assets.get(name)), `no SHADE_TREE_VERSION: latest resolved from the redirect and installed, with http_proxy set and ignored${r.status === 0 ? "" : `\n${r.out}`}`);
+ ok(new RegExp(`release: v${esc(VERSION)} \\(latest\\)`).test(r.stdout), " resolved tag printed");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("localhost");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: `http://localhost:${server.port}/releases`, SHADE_TREE_VERSION: VERSION, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }, timeout: 20_000 });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")), `http://localhost: accepted as loopback${r.status === 0 ? "" : `\n${r.out}`}`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("bare-version");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: httpBase, SHADE_TREE_VERSION: VERSION, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target } });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")), `SHADE_TREE_VERSION=${VERSION} (no v) accepted`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("bad-version");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: httpBase, SHADE_TREE_VERSION: "v9.9.9", SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target } });
+ ok(failedCleanly(r) && /not found/.test(r.stderr) && /SHADE_TREE_VERSION/.test(r.stderr), "unknown version (404): reported as not found, hints at SHADE_TREE_VERSION");
+ ok(!existsSync(join(dest, "shade-tree")) && tmpEmpty(r.tmp) && noStage(dest), " installs nothing, cleans up");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const work = fresh("http-500");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: httpBase, SHADE_TREE_VERSION: "v5.0.0", SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }, timeout: 60_000 });
+ ok(failedCleanly(r) && /HTTP error 500/.test(r.stderr) && !/not found/.test(r.stderr), "server error (500): reported as an HTTP error, not as a missing asset");
+ rmSync(work, { recursive: true, force: true });
+ }
+ server.stop();
+ server = null;
+ }
+ {
+ // .invalid never resolves (RFC 2606): a DNS failure must not read as "asset not found".
+ const work = fresh("dns");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_RELEASE_BASE: "https://releases.nonexistent.invalid/releases", SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }), timeout: 60_000 });
+ ok(failedCleanly(r) && /could not fetch/.test(r.stderr) && !/not found/.test(r.stderr), "DNS failure: reported as a fetch/network problem, not as a missing asset");
+ ok(tmpEmpty(r.tmp) && noStage(dest), " cleans up");
+ rmSync(work, { recursive: true, force: true });
+ }
+
+ // --- 6. refusals ---------------------------------------------------------------------------
+ console.log("\n-- refusals");
+ {
+ const work = fresh("cleartext");
+ const dest = join(work, "bin");
+ // TEST-NET-1 is unroutable: if the script wrongly tried to fetch, this would hang and time out.
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: "http://192.0.2.1/releases", SHADE_TREE_VERSION: `v${VERSION}`, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }, timeout: 15_000 });
+ ok(failedCleanly(r), "cleartext non-loopback base: refused without a fetch");
+ ok(/https/.test(r.stderr), " error says https is required");
+ rmSync(work, { recursive: true, force: true });
+ }
+ // Loopback lookalikes: user-info that makes curl connect elsewhere, hostnames that merely
+ // start with a loopback name, and malformed ports. All must fail before any fetch (the
+ // remote hosts are unroutable TEST-NET, so a wrongly attempted fetch would time out).
+ for (const base of [
+ "http://127.0.0.1:pw@192.0.2.1/releases",
+ "http://user@127.0.0.1/releases",
+ "http://[::1]@192.0.2.1/releases",
+ "http://127.0.0.1.192.0.2.1.nip.io/releases",
+ "http://localhost.example/releases",
+ "http://127.0.0.1:99999/releases",
+ "http://127.0.0.1:0/releases",
+ "http://127.0.0.1:/releases",
+ "http://[::1]:x/releases",
+ "http://127.0.0.10/releases",
+ ]) {
+ const work = fresh("lookalike");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: { SHADE_TREE_RELEASE_BASE: base, SHADE_TREE_VERSION: `v${VERSION}`, SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }, timeout: 15_000 });
+ ok(failedCleanly(r) && /https|port/.test(r.stderr) && !existsSync(join(dest, "shade-tree")) && tmpEmpty(r.tmp) && noStage(dest), `${base}: refused before any fetch`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ // Target allowlist: only the seven published triples, exactly; "-live" cannot be smuggled
+ // into the target to bypass SHADE_TREE_LIVE.
+ for (const bad of ["x86_64-unknown-linux-gnu-live", "X86_64-apple-darwin", "aarch64-linux-android", "x86_64-pc-windows-msvc.exe", "x86_64-unknown-linux-gnu "]) {
+ const work = fresh("badtarget");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: bad }) });
+ ok(failedCleanly(r) && /not a published target/.test(r.stderr) && !existsSync(join(dest, "shade-tree")) && tmpEmpty(r.tmp) && noStage(dest), `SHADE_TREE_TARGET=${JSON.stringify(bad)}: refused`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const r = spawnSync("sh", [SCRIPT, "-h"], { env: { PATH: process.env.PATH }, encoding: "utf8" });
+ ok(r.status === 0 && /SHADE_TREE_INSTALL_DIR/.test(r.stdout), "-h works with HOME unset");
+ const r2 = runInstall(fresh("nohome"), { env: pinned({ SHADE_TREE_TARGET: target }) });
+ // runInstall always sets HOME; emulate an unset HOME by passing an empty one.
+ const r3 = spawnSync("sh", [SCRIPT], { env: { PATH: process.env.PATH, HOME: "", SHADE_TREE_RELEASE_BASE: BASE, SHADE_TREE_VERSION: `v${VERSION}`, SHADE_TREE_TARGET: target }, encoding: "utf8" });
+ ok(r2.status === 0, " (control: same run with HOME set installs)");
+ ok(failedCleanly(r3) && /HOME is unset/.test(r3.stderr), " install with HOME unset and no SHADE_TREE_INSTALL_DIR: refused with a hint");
+ }
+ for (const host of [
+ { label: "unsupported OS (FreeBSD)", sysname: "FreeBSD", machine: "amd64", re: /unsupported OS/ },
+ { label: "unsupported arch (Linux i686)", sysname: "Linux", machine: "i686", re: /unsupported CPU/ },
+ { label: "Windows on aarch64 (no release)", sysname: "MINGW64_NT-10.0", machine: "aarch64", re: /Windows releases cover x86_64/ },
+ ]) {
+ const work = fresh("refuse");
+ const bin = shimHost(work, host);
+ const dest = join(work, "bin");
+ const r = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_INSTALL_DIR: dest }) });
+ ok(failedCleanly(r) && !existsSync(join(dest, "shade-tree")), `${host.label}: refused, nothing installed`);
+ ok(host.re.test(r.stderr) && /INSTALL\.md/.test(r.stderr), ` error explains (${host.re}) and points at rust/INSTALL.md`);
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ const r = spawnSync("sh", [SCRIPT, "--live"], { env: { PATH: process.env.PATH, HOME: tmpdir() }, encoding: "utf8" });
+ ok(failedCleanly(r) && /unknown argument/.test(r.stderr), "stray argument: refused with a pointer to the env knobs");
+ const h = spawnSync("sh", [SCRIPT, "-h"], { env: { PATH: process.env.PATH, HOME: tmpdir() }, encoding: "utf8" });
+ ok(h.status === 0 && /SHADE_TREE_RELEASE_BASE/.test(h.stdout) && /SHADE_TREE_FORCE/.test(h.stdout), "-h lists every knob");
+ }
+
+ // --- 7. hints --------------------------------------------------------------------------------
+ console.log("\n-- hints");
+ {
+ const work = fresh("hints");
+ const dest = join(work, "not-on-path");
+ const r = runInstall(work, { env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(r.status === 0 && /PATH/.test(r.stdout), "install dir not on PATH: PATH hint printed");
+ ok(/shade-tree' --help/.test(r.stdout), " next command printed, path quoted ('...shade-tree' --help)");
+ ok(new RegExp(esc(dest)).test(r.stdout), " installed path printed");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ // Default install dir is $HOME/.local/bin when SHADE_TREE_INSTALL_DIR is unset. A `sudo`
+ // shim sits first on PATH and leaves a marker if anything calls it.
+ const work = fresh("default-dir");
+ const bin = join(work, "shim-bin");
+ mkdirSync(bin, { recursive: true });
+ const marker = join(work, "sudo-was-called");
+ writeFileSync(join(bin, "sudo"), `#!/bin/sh\necho "sudo called: $*" > '${marker}'\nexit 99\n`);
+ chmodSync(join(bin, "sudo"), 0o755);
+ const r = runInstall(work, { pathPrefix: bin, env: pinned({ SHADE_TREE_TARGET: target }) });
+ ok(r.status === 0 && existsSync(join(r.home, ".local", "bin", "shade-tree")), "default install dir is $HOME/.local/bin");
+ ok(!existsSync(marker), " sudo shim on PATH was never called");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ // Another `shade-tree` earlier on PATH (the npm CLI has the same name): warn, do not fail.
+ const work = fresh("collision");
+ const other = join(work, "other-bin");
+ mkdirSync(other, { recursive: true });
+ writeFileSync(join(other, "shade-tree"), "#!/bin/sh\necho npm cli\n");
+ chmodSync(join(other, "shade-tree"), 0o755);
+ const dest = join(work, "bin");
+ const r = runInstall(work, { pathPrefix: other, env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")), "foreign shade-tree earlier on PATH: still installs");
+ ok(new RegExp(esc(other)).test(r.out) && /shadow/.test(r.out) && new RegExp(`run ${esc(join(other, "shade-tree"))} first`).test(r.out), " warns, names the other path, and says the other one wins");
+ // Same collision with the install dir FIRST on PATH: `command -v` would now report our own
+ // binary, so only a full PATH walk can still surface the npm CLI behind it.
+ const r2 = runInstall(work, { pathPrefix: `${dest}:${other}`, env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(r2.status === 0 && new RegExp(esc(join(other, "shade-tree"))).test(r2.out) && /run this Rust client first/.test(r2.out), " npm CLI BEHIND the install dir on PATH: still named, and told it is the one shadowed");
+ rmSync(work, { recursive: true, force: true });
+ }
+ {
+ // Stricter shells when available: dash is Debian/Ubuntu's /bin/sh (and ships on macOS).
+ const dash = spawnSync("sh", ["-c", "command -v dash"], { encoding: "utf8" }).stdout.trim();
+ if (dash) {
+ const work = fresh("dash");
+ const dest = join(work, "bin");
+ const r = runInstall(work, { shell: dash, env: pinned({ SHADE_TREE_INSTALL_DIR: dest, SHADE_TREE_TARGET: target }) });
+ ok(r.status === 0 && existsSync(join(dest, "shade-tree")), "runs under dash");
+ rmSync(work, { recursive: true, force: true });
+ } else console.log(" skip dash run (dash not on PATH)");
+ const shellcheck = spawnSync("sh", ["-c", "command -v shellcheck"], { encoding: "utf8" }).stdout.trim();
+ if (shellcheck) {
+ const r = spawnSync(shellcheck, ["-s", "sh", SCRIPT], { encoding: "utf8" });
+ ok(r.status === 0, `shellcheck -s sh clean${r.status === 0 ? "" : `\n${r.stdout}`}`);
+ } else console.log(" skip shellcheck (not on PATH)");
+ }
+ } finally {
+ if (server) server.stop();
+ rmSync(releaseRoot, { recursive: true, force: true });
+ }
+
+ console.log(`\n${failures ? "issues found" : "ready"}: ${failures} failure(s)`);
+ process.exit(failures ? 1 : 0);
+}
+
+await main();
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..2ca3a1c
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,393 @@
+#!/bin/sh
+# install.sh: one-line installer for the prebuilt Rust `shade-tree` client (issue #64).
+#
+# curl -fsSL https://raw.githubusercontent.com/dmarzzz/shade-tree-node/main/scripts/install.sh | sh
+#
+# What it does, in order: detect OS/arch, resolve the release tag, refuse to replace a symlink
+# at the destination (unless forced), download the matching release asset AND its .sha256,
+# verify the checksum, and only then place the binary in ~/.local/bin (or
+# $SHADE_TREE_INSTALL_DIR). It never uses sudo, never executes a byte it has not verified, and
+# never fetches a binary over cleartext from the network.
+#
+# Knobs (environment; `curl | sh` cannot take flags):
+# SHADE_TREE_VERSION release tag to install (`v0.3.0` or `0.3.0`); default: latest
+# SHADE_TREE_LIVE=1 install the `-live` variant (embedded Tor + RLN prover); only
+# some targets publish one, see rust/INSTALL.md
+# SHADE_TREE_INSTALL_DIR destination directory; default $HOME/.local/bin
+# SHADE_TREE_FORCE=1 replace a destination that is a symlink to a file (the npm
+# CLI's bin)
+# SHADE_TREE_TARGET skip detection; one of the seven published targets
+# SHADE_TREE_LIBC=gnu|musl choose the Linux libc when it cannot be detected
+# SHADE_TREE_RELEASE_BASE where releases live; default the GitHub Releases page. Must be
+# https://, or file:// / loopback http:// for tests and mirrors.
+#
+# Asset naming comes from .github/workflows/release.yml:
+# shade-tree--[-live][.exe] plus .sha256 (" ")
+#
+# POSIX sh only (dash, bash --posix, BusyBox ash, Git Bash): no arrays, no [[ ]], no
+# pipefail, no local.
+
+set -eu
+
+say() { printf '%s\n' "$*"; }
+die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
+# Single-quote a value for copy-paste into any POSIX shell (a path with spaces or quotes).
+shquote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
+
+usage() {
+ cat <<'EOF'
+usage: sh install.sh (or: curl -fsSL /scripts/install.sh | sh)
+
+Installs the prebuilt Rust `shade-tree` client from a GitHub Release into ~/.local/bin after
+verifying its sha256 against the published .sha256 asset. No sudo, ever.
+
+environment:
+ SHADE_TREE_VERSION=v0.3.0 pin a release (default: latest)
+ SHADE_TREE_LIVE=1 the -live variant (embedded Tor + prover), where published
+ SHADE_TREE_INSTALL_DIR=DIR destination (default: $HOME/.local/bin)
+ SHADE_TREE_FORCE=1 replace a destination that is a symlink to a file (npm's bin)
+ SHADE_TREE_TARGET=TRIPLE skip OS/arch detection (one of the seven published targets)
+ SHADE_TREE_LIBC=gnu|musl choose the Linux libc when it cannot be detected
+ SHADE_TREE_RELEASE_BASE=URL https:// release base (file:// or loopback http:// for tests)
+
+Prefer to inspect before running? Download the script, read it, then `sh install.sh`.
+Windows: works from Git Bash or MSYS2 (x86_64 only); see rust/INSTALL.md for PowerShell.
+EOF
+}
+
+case "${1:-}" in
+ -h|--help) usage; exit 0 ;;
+ "") ;;
+ *) die "unknown argument '$1' (this installer is configured through SHADE_TREE_* variables; -h for help)" ;;
+esac
+
+REPO_BASE_DEFAULT="https://github.com/dmarzzz/shade-tree-node/releases"
+BASE="${SHADE_TREE_RELEASE_BASE:-$REPO_BASE_DEFAULT}"
+LIVE="${SHADE_TREE_LIVE:-0}"
+FORCE="${SHADE_TREE_FORCE:-0}"
+if [ -n "${SHADE_TREE_INSTALL_DIR:-}" ]; then
+ INSTALL_DIR="$SHADE_TREE_INSTALL_DIR"
+else
+ [ -n "${HOME:-}" ] || die "HOME is unset; set SHADE_TREE_INSTALL_DIR to choose a destination"
+ INSTALL_DIR="$HOME/.local/bin"
+fi
+
+# Booleans are exactly 0 or 1: a typo such as SHADE_TREE_LIVE=yes must not quietly install the
+# other variant.
+case "$LIVE" in 0|1) ;; *) die "SHADE_TREE_LIVE must be 1 or 0 (got '$LIVE')" ;; esac
+case "$FORCE" in 0|1) ;; *) die "SHADE_TREE_FORCE must be 1 or 0 (got '$FORCE')" ;; esac
+
+# --- prerequisites -------------------------------------------------------------------------
+command -v curl >/dev/null 2>&1 || die "curl is required (install it with your package manager, then rerun)"
+command -v mktemp >/dev/null 2>&1 || die "mktemp is required"
+
+# Pick a sha256 tool once. Which one exists differs by platform: sha256sum (coreutils, BusyBox,
+# Git Bash), shasum (macOS, perl), openssl (almost everywhere). Each is only ever fed a path.
+if command -v sha256sum >/dev/null 2>&1; then SHA_TOOL=sha256sum
+elif command -v shasum >/dev/null 2>&1; then SHA_TOOL=shasum
+elif command -v openssl >/dev/null 2>&1; then SHA_TOOL=openssl
+else die "no sha256 tool found (need sha256sum, shasum, or openssl); refusing to install unverified"
+fi
+
+# Each tool prints the digest in a different frame: " " for the first two,
+# "SHA256()= " for openssl. Only the hex is returned.
+sha256_of() {
+ case "$SHA_TOOL" in
+ sha256sum) sha256sum "$1" | { read -r hex _; printf '%s' "$hex"; } ;;
+ shasum) shasum -a 256 "$1" | { read -r hex _; printf '%s' "$hex"; } ;;
+ openssl) openssl dgst -sha256 "$1" | sed 's/^.*= *//' | tr -d '\n' ;;
+ esac
+}
+
+# --- release base: refuse cleartext from the network before touching it -------------------
+# A binary fetched over plain http can be swapped in transit, and its .sha256 with it; the
+# checksum then proves nothing. file:// and loopback http:// exist so the selftest can run
+# offline against a fake release tree. The loopback authority is parsed strictly: no
+# user-info (curl would connect to whatever follows the "@"), the exact host, an optional
+# numeric port, nothing else.
+case "$BASE" in
+ https://*) PROTO="https" ;;
+ file://*) PROTO="file" ;;
+ http://*)
+ AUTHORITY="${BASE#http://}"
+ AUTHORITY="${AUTHORITY%%/*}"
+ case "$AUTHORITY" in
+ *@*) die "SHADE_TREE_RELEASE_BASE must use https (got '$BASE'); cleartext http is allowed only to 127.0.0.1, localhost, or [::1], with no user-info" ;;
+ esac
+ HOSTPART="$AUTHORITY"
+ PORTPART=""
+ case "$AUTHORITY" in
+ "[::1]") ;;
+ "[::1]:"*) HOSTPART="[::1]"; PORTPART="${AUTHORITY#\[::1\]:}" ;;
+ *:*) HOSTPART="${AUTHORITY%%:*}"; PORTPART="${AUTHORITY#*:}" ;;
+ esac
+ case "$HOSTPART" in
+ 127.0.0.1|localhost|"[::1]") ;;
+ *) die "SHADE_TREE_RELEASE_BASE must use https (got '$BASE'); cleartext http is allowed only to 127.0.0.1, localhost, or [::1]" ;;
+ esac
+ case "$AUTHORITY" in *:) die "malformed port in SHADE_TREE_RELEASE_BASE ('$AUTHORITY')" ;; esac
+ if [ -n "$PORTPART" ]; then
+ case "$PORTPART" in *[!0-9]*) die "malformed port in SHADE_TREE_RELEASE_BASE ('$AUTHORITY')" ;; esac
+ [ "$PORTPART" -ge 1 ] && [ "$PORTPART" -le 65535 ] || die "port out of range in SHADE_TREE_RELEASE_BASE ('$AUTHORITY')"
+ fi
+ PROTO=http ;;
+ *) die "SHADE_TREE_RELEASE_BASE must be an https:// URL (got '$BASE')" ;;
+esac
+BASE="${BASE%/}"
+
+# Every fetch: fail on HTTP errors (-f), follow only https redirects (GitHub serves assets
+# from objects.githubusercontent.com), allow exactly the scheme of the configured base, and
+# never send a loopback request through an ambient http_proxy.
+curl_get() {
+ if [ "$PROTO" = http ]; then
+ curl -fsSL --retry 2 --connect-timeout 20 --max-time 600 --proto "=$PROTO" --proto-redir "=https" --noproxy '*' "$@"
+ else
+ curl -fsSL --retry 2 --connect-timeout 20 --max-time 600 --proto "=$PROTO" --proto-redir "=https" "$@"
+ fi
+}
+curl_head() {
+ if [ "$PROTO" = http ]; then
+ curl -fsSI --connect-timeout 20 --max-time 60 --proto "=$PROTO" --noproxy '*' "$@"
+ else
+ curl -fsSI --connect-timeout 20 --max-time 60 --proto "=$PROTO" "$@"
+ fi
+}
+fetch() { curl_get -o "$2" "$1"; }
+# Turn a curl exit code into the remedy the user actually needs. 22 is the only "the server
+# answered and said no" code; everything else is transport, and "asset not found" would send
+# someone to check their version instead of their network.
+explain_curl() {
+ case "$1" in
+ 22) printf 'HTTP error %s' "$2" ;;
+ 37) printf 'file not found' ;;
+ 6) printf 'could not resolve host (DNS)' ;;
+ 7) printf 'could not connect' ;;
+ 28) printf 'timed out' ;;
+ 35|60) printf 'TLS handshake or certificate failure' ;;
+ *) printf 'curl exit %s' "$1" ;;
+ esac
+}
+
+# --- target ---------------------------------------------------------------------------------
+detect_libc() {
+ # Positive identification only. glibc's ldd says "GNU libc"; musl's says "musl libc" (on
+ # stderr, exit 1). Without ldd, the dynamic loader's file name is the next best witness.
+ # No guess otherwise: a GNU binary "installs fine" on a musl host and then fails to exec.
+ if command -v ldd >/dev/null 2>&1; then
+ case "$(ldd --version 2>&1 || true)" in
+ *musl*) printf musl; return ;;
+ *GNU*|*glibc*) printf gnu; return ;;
+ esac
+ fi
+ if [ -f /etc/alpine-release ] || ls /lib/ld-musl-*.so* >/dev/null 2>&1; then printf musl; return; fi
+ if ls /lib/ld-linux*.so* /lib64/ld-linux*.so* /lib/*/ld-linux*.so* >/dev/null 2>&1; then printf gnu; return; fi
+ printf ''
+}
+
+if [ -n "${SHADE_TREE_TARGET:-}" ]; then
+ TARGET="$SHADE_TREE_TARGET"
+ say "target: $TARGET (SHADE_TREE_TARGET)"
+else
+ OS="$(uname -s 2>/dev/null || echo unknown)"
+ ARCH="$(uname -m 2>/dev/null || echo unknown)"
+ case "$ARCH" in
+ x86_64|amd64) ARCH=x86_64 ;;
+ aarch64|arm64) ARCH=aarch64 ;;
+ *) die "unsupported CPU architecture '$ARCH' (releases cover x86_64 and aarch64); see rust/INSTALL.md to build from source" ;;
+ esac
+ case "$OS" in
+ Darwin) TARGET="$ARCH-apple-darwin" ;;
+ Linux)
+ LIBC="${SHADE_TREE_LIBC:-}"
+ [ -n "$LIBC" ] || LIBC="$(detect_libc)"
+ [ -n "$LIBC" ] || die "cannot tell glibc from musl on this Linux host (no ldd, no known loader); set SHADE_TREE_LIBC=gnu or SHADE_TREE_LIBC=musl"
+ case "$LIBC" in gnu|musl) ;; *) die "SHADE_TREE_LIBC must be gnu or musl (got '$LIBC')" ;; esac
+ TARGET="$ARCH-unknown-linux-$LIBC" ;;
+ MINGW*|MSYS*|CYGWIN*|Windows_NT)
+ # Git Bash / MSYS2 give a POSIX sh with curl and sha256sum; the asset is the MSVC .exe.
+ [ "$ARCH" = x86_64 ] || die "Windows releases cover x86_64 only (got '$ARCH'); see rust/INSTALL.md"
+ TARGET="x86_64-pc-windows-msvc" ;;
+ *) die "unsupported OS '$OS' (releases cover Linux, macOS, and Windows); see rust/INSTALL.md to build from source" ;;
+ esac
+ say "target: $TARGET (detected)"
+fi
+# The seven targets release.yml publishes. An exact allowlist means a typo, or a "-live"
+# smuggled into the target to dodge SHADE_TREE_LIVE, fails here instead of naming an asset
+# that happens to exist. A new target in release.yml needs a line here too.
+case "$TARGET" in
+ x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu|x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|x86_64-apple-darwin|aarch64-apple-darwin|x86_64-pc-windows-msvc) ;;
+ *) die "'$TARGET' is not a published target; choose one of x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc (SHADE_TREE_LIVE=1 selects the -live variant)" ;;
+esac
+case "$TARGET" in *windows*) EXT=.exe ;; *) EXT= ;; esac
+
+# --- version ---------------------------------------------------------------------------------
+if [ -n "${SHADE_TREE_VERSION:-}" ]; then
+ VERSION="${SHADE_TREE_VERSION#v}"
+ TAG="v$VERSION"
+ say "release: $TAG (SHADE_TREE_VERSION)"
+else
+ if [ "$PROTO" = file ]; then
+ die "SHADE_TREE_VERSION is required with a file:// release base (no 'latest' redirect to follow)"
+ fi
+ # GitHub answers /releases/latest with a 302 to /releases/tag/. Reading the Location
+ # header needs no API token and no JSON parser.
+ LOCATION="$(curl_head -o /dev/null -w '%{redirect_url}' "$BASE/latest" || true)"
+ TAG="${LOCATION##*/tag/}"
+ [ -n "$LOCATION" ] && [ "$TAG" != "$LOCATION" ] || die "could not resolve the latest release from $BASE/latest (set SHADE_TREE_VERSION to pin one)"
+ VERSION="${TAG#v}"
+ say "release: $TAG (latest)"
+fi
+case "$TAG" in
+ v[0-9]*) ;;
+ *) die "unexpected release tag '$TAG'" ;;
+esac
+
+# --- destination preflight (before any download) ------------------------------------------
+# The npm CLI is also installed as `shade-tree`, always as a symlink into node_modules, so a
+# symlink at the destination is refused unless forced. A symlink to a DIRECTORY is refused
+# outright: `mv` would follow it and drop the binary inside. A regular file there is replaced
+# with a note: the script cannot know who wrote it, and that is the only other shape npm never
+# produces. Anything else (a directory, a device) is refused.
+BIN_NAME="shade-tree$EXT"
+DEST="$INSTALL_DIR/$BIN_NAME"
+check_destination() {
+ if [ -L "$DEST" ]; then
+ [ ! -d "$DEST" ] || die "$DEST is a symlink to a directory; refusing to install through it. Remove it or choose another SHADE_TREE_INSTALL_DIR"
+ [ "$FORCE" = 1 ] || die "$DEST is a symlink (the npm CLI installs shade-tree that way); refusing to replace it. Use another SHADE_TREE_INSTALL_DIR, or SHADE_TREE_FORCE=1 to replace it anyway"
+ elif [ -e "$DEST" ] && [ ! -f "$DEST" ]; then
+ die "$DEST exists and is not a regular file; refusing to replace it"
+ fi
+}
+check_destination
+if [ -L "$DEST" ]; then say "note: will replace symlink $DEST (SHADE_TREE_FORCE=1)"
+elif [ -f "$DEST" ]; then say "note: will replace existing $DEST"
+fi
+mkdir -p "$INSTALL_DIR" || die "cannot create $INSTALL_DIR"
+[ -w "$INSTALL_DIR" ] || die "$INSTALL_DIR is not writable (choose another SHADE_TREE_INSTALL_DIR; this installer never uses sudo)"
+
+# --- asset ------------------------------------------------------------------------------------
+SUFFIX=
+[ "$LIVE" = 1 ] && SUFFIX=-live
+ASSET="shade-tree-$VERSION-$TARGET$SUFFIX$EXT"
+URL="$BASE/download/$TAG/$ASSET"
+say "asset: $ASSET"
+
+TMP="$(mktemp -d "${TMPDIR:-/tmp}/shade-tree-install.XXXXXX")"
+STAGE=""
+# Whatever happens next (checksum mismatch, Ctrl-C, a failed download), the partial files go,
+# including the staging file inside the destination directory once it exists.
+cleanup() { rm -rf "$TMP"; [ -n "$STAGE" ] && rm -f "$STAGE"; return 0; }
+trap cleanup EXIT
+trap 'exit 130' INT
+trap 'exit 143' TERM HUP
+
+# The .sha256 first: it is tiny, and a 404 there is the clearest signal that no such asset
+# exists for this target/version/variant, which for -live is the common case.
+if HTTP_CODE="$(curl_get -w '%{http_code}' -o "$TMP/$ASSET.sha256" "$URL.sha256" 2>/dev/null)"; then
+ :
+else
+ RC=$?
+ if [ "$RC" = 22 ] && [ "$HTTP_CODE" = 404 ] || [ "$RC" = 37 ]; then
+ if [ "$LIVE" = 1 ]; then
+ die "no -live asset for $TARGET in $TAG ($ASSET.sha256 not found). Live builds are published only for some targets; see rust/INSTALL.md, or unset SHADE_TREE_LIVE for the default build"
+ fi
+ die "release asset $ASSET.sha256 not found at $URL.sha256 (wrong SHADE_TREE_VERSION or SHADE_TREE_TARGET?)"
+ fi
+ die "could not fetch $URL.sha256: $(explain_curl "$RC" "$HTTP_CODE"). Check your network or SHADE_TREE_RELEASE_BASE and retry"
+fi
+fetch "$URL" "$TMP/$ASSET" || die "download of $ASSET failed ($URL)"
+
+# --- verify (the whole point) -------------------------------------------------------------
+# The .sha256 line is " " (release.yml frames it by hand so every target matches).
+# Both halves are checked: the hex against the bytes, and the file name against the asset we
+# asked for, so a .sha256 swapped in from another asset cannot vouch for this one.
+read -r EXPECTED EXPECTED_NAME _ < "$TMP/$ASSET.sha256" || true
+EXPECTED="$(printf '%s' "${EXPECTED:-}" | tr 'A-F' 'a-f')"
+EXPECTED_NAME="${EXPECTED_NAME#\*}"
+case "$EXPECTED" in
+ ????????????????????????????????????????????????????????????????) ;;
+ *) die "malformed $ASSET.sha256 (expected '<64 hex> '); refusing to install" ;;
+esac
+case "$EXPECTED" in *[!0-9a-f]*) die "malformed $ASSET.sha256 (non-hex digest); refusing to install" ;; esac
+[ "$EXPECTED_NAME" = "$ASSET" ] || die "$ASSET.sha256 names '$EXPECTED_NAME', not '$ASSET'; refusing to install"
+ACTUAL="$(sha256_of "$TMP/$ASSET" | tr 'A-F' 'a-f')"
+[ "$ACTUAL" = "$EXPECTED" ] || die "checksum mismatch for $ASSET (expected $EXPECTED, got $ACTUAL); refusing to install"
+say "verified: sha256 $ACTUAL"
+
+# --- install (only now does the file get an executable bit) --------------------------------
+# Stage in an exclusive temp file inside the destination directory (so the final rename is a
+# same-filesystem replace and a reader never sees a half-written binary), never at a name an
+# attacker could pre-create. The destination is re-checked right before the rename: the
+# download took time, and an approved file symlink is removed explicitly so `mv` replaces the
+# link itself rather than following it.
+STAGE="$(mktemp "$INSTALL_DIR/.shade-tree.XXXXXX")" || die "cannot create a staging file in $INSTALL_DIR"
+cp "$TMP/$ASSET" "$STAGE"
+chmod 0755 "$STAGE"
+check_destination
+if [ -L "$DEST" ]; then rm -f "$DEST" || die "cannot remove symlink $DEST"; fi
+mv -f "$STAGE" "$DEST"
+STAGE=""
+[ -f "$DEST" ] && [ ! -L "$DEST" ] && [ -x "$DEST" ] || die "$DEST is not the installed executable after the rename; refusing to report success"
+say "installed: $DEST ($TAG, $TARGET${SUFFIX:+, live})"
+
+# --- after-care -------------------------------------------------------------------------------
+QDEST="$(shquote "$DEST")"
+QDIR="$(shquote "$INSTALL_DIR")"
+
+# macOS only stamps a quarantine attribute on files saved by browsers, not by curl; mention
+# the fix anyway if it is there, since the checksum already vouched for the bytes.
+if command -v xattr >/dev/null 2>&1 && xattr -p com.apple.quarantine "$DEST" >/dev/null 2>&1; then
+ say "note: macOS quarantined the file; the checksum verified, so you can clear it with:"
+ say " xattr -d com.apple.quarantine $QDEST"
+fi
+
+# The npm CLI (`npm install --global git+...`) is ALSO called shade-tree: the Proxy, `run`, and
+# operator commands live there, the Rust binary is the verify/select/egress client. Walk the
+# whole PATH rather than asking `command -v`, which only reports the winner and would stay
+# silent about an npm CLI sitting BEHIND the directory we just installed into.
+OTHERS=""
+SAVED_IFS="$IFS"
+IFS=:
+set -f
+for d in $PATH; do
+ [ -n "$d" ] || continue
+ for cand in "$d/shade-tree" "$d/shade-tree.exe"; do
+ [ -x "$cand" ] && [ "$cand" != "$DEST" ] && OTHERS="$OTHERS $cand"
+ done
+done
+set +f
+IFS="$SAVED_IFS"
+if [ -n "$OTHERS" ]; then
+ FIRST="$(command -v shade-tree 2>/dev/null || true)"
+ say "warning: other shade-tree executables are on PATH:$OTHERS"
+ say " (likely the npm CLI, which keeps that name for the Proxy and operator commands)."
+ if [ "$FIRST" = "$DEST" ]; then
+ say " Your shell will run this Rust client first and shadow them; call the npm CLI by"
+ say " its full path when you need it."
+ else
+ say " Your shell will run $FIRST first and shadow $DEST; call the Rust client by its"
+ say " full path, or put $INSTALL_DIR earlier in PATH."
+ fi
+fi
+
+case ":$PATH:" in
+ *":$INSTALL_DIR:"*) ;;
+ *) say "note: $INSTALL_DIR is not on your PATH. Add it for this shell with:"
+ say " export PATH=$QDIR:\"\$PATH\"" ;;
+esac
+
+say ""
+say "next:"
+say " $QDEST --help"
+if [ "$LIVE" = 1 ]; then
+ say " # identity.json comes from the npm CLI at your enrolled tier; see rust/INSTALL.md"
+ say " # \"Examples\" for that step, then, with the operator's Elder onion and signer pin:"
+ say " $QDEST egress --bootnode-onion --signer \\"
+ say " --identity identity.json --members members.json --target api.ipify.org:443"
+ say " # Until issue #75 is fixed: at most ONE egress invocation per member secret per"
+ say " # protocol epoch (120 s by default), counting any attempt that may have reached a"
+ say " # node. The optional --slot-cursor is best-effort and wraps at K; do not rely on it."
+else
+ say " $QDEST verify-directory directory.json --signer "
+ say " (egress needs the -live build: SHADE_TREE_LIVE=1, where published; see rust/INSTALL.md)"
+fi