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 ![Shade Tree reputation gate and network path](docs/post/fig/shade-tree-readme.svg) 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