diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 77c86a617..7e0bb4a64 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -212,13 +212,19 @@ install_bun() { # VERSION REASON — download, verify and publish Bun VERSION at TMP=$(mktemp -d "$STORE/.tmp.XXXXXX") || stop INSTALL_PREFLIGHT_FAILED "cannot create a temp dir in $STORE" "set GSTACK_STATE_ROOT to a writable directory" curl --retry 3 -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v$WANT/bun-$TARGET.zip" -o "$TMP/bun.zip" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not download bun-v$WANT/bun-$TARGET.zip" "check network access to github.com, then re-run"; } echo "$SHA $TMP/bun.zip" | sha256sum -c - >/dev/null 2>&1 || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "bun-$TARGET.zip SHA-256 mismatch (expected $SHA); nothing installed" "update gstack_bun_archive_sha256 in bin/gstack-bun-version.sh only after checking the release's SHASUMS256.txt"; } - mkdir "$TMP/pub" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$TMP/pub/bun" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $STORE" "install unzip, or set GSTACK_STATE_ROOT to a writable directory"; } + mkdir "$TMP/pub" && (cd "$TMP" && unzip -qo bun.zip) && install -m 0755 "$TMP/bun-$TARGET/bun" "$TMP/pub/bun" && ln -s bun "$TMP/pub/bunx" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not unpack bun into $STORE" "install unzip, or set GSTACK_STATE_ROOT to a writable directory"; } _got=$("$TMP/pub/bun" --version 2>/dev/null | head -1) [ "$_got" = "$WANT" ] || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "the downloaded bun reports $_got, expected $WANT; nothing installed" "re-run; report it if it repeats"; } - [ -e "$STORE/$WANT" ] && mv -T "$STORE/$WANT" "$TMP/replaced" - mv -T "$TMP/pub" "$STORE/$WANT" || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not publish $STORE/$WANT" "check $STORE, then re-run"; } + # Publish without a window: a new version directory is renamed into + # place; in an existing one each file is renamed over its predecessor. + if [ -d "$STORE/$WANT" ] && [ ! -L "$STORE/$WANT" ]; then + mv -fT "$TMP/pub/bun" "$STORE/$WANT/bun" && mv -fT "$TMP/pub/bunx" "$STORE/$WANT/bunx" + else + rm -f "$STORE/$WANT" && mv -T "$TMP/pub" "$STORE/$WANT" + fi || { rm -rf "$TMP"; stop INSTALL_PREFLIGHT_FAILED "could not publish $STORE/$WANT" "check $STORE, then re-run"; } rm -rf "$TMP" fi + [ -e "$STORE/$WANT/bunx" ] || [ -L "$STORE/$WANT/bunx" ] || ln -s bun "$STORE/$WANT/bunx" 2>/dev/null || true exec 8>&- say "bun $WANT installed at $STORE/$WANT/bun" } @@ -338,11 +344,13 @@ fi # The store, verdict directory, checkout and install default are baked in, so # a fresh shell needs no environment; rewritten when they change. Only a # missing file or one carrying the marker is replaced. +shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; } write_shim() { - local dir="$HOME/.local/bin" name tmp + local dir="$HOME/.local/bin" name tmp STORE_REAL [ "${GSTACK_BUN_SHIM:-1}" != 0 ] || return 0 [ -n "${CAPY_MACHINE_CONFIG:-}" ] || [ -d "$HOME/.capy/system" ] || return 0 - mkdir -p "$dir" || { say "could not create $dir; no bun shim written"; return 0; } + mkdir -p "$dir" "$STORE" || { say "could not create $dir or $STORE; no bun shim written"; return 0; } + STORE_REAL=$(cd -P "$STORE" && pwd -P) || return 0 exec 8>"$HOME/.gstack-runtime.lock" command -v flock >/dev/null 2>&1 && flock -w 60 8 for name in bun bunx; do @@ -352,57 +360,96 @@ write_shim() { fi tmp="$dir/.$name.gstack.$$" if [ "$name" = bunx ]; then - printf '#!/bin/sh\n%s\n# The Capy bunx shim: bun x through the bun shim beside it (docs/capy-install.md).\nexec %s x "$@"\n' "$GSTACK_BUN_SHIM_MARKER" "'$dir/bun'" > "$tmp" + printf '#!/bin/sh\n%s\n# The Capy bunx shim: bun x through the bun shim beside it (docs/capy-install.md).\nexec %s x "$@"\n' "$GSTACK_BUN_SHIM_MARKER" "$(shq "$dir/bun")" > "$tmp" else - cat > "$tmp" </dev/null 2>&1; then printf '%s' "\$1" | sha256sum | cut -d' ' -f1; else printf '%s' "\$1" | shasum -a 256 | cut -d' ' -f1; fi; } -root="" record="" chose="" rule="" -dir=\$(pwd -P 2>/dev/null) || dir="" -while [ -n "\$dir" ]; do - [ -e "\$dir/.git" ] && { root=\$dir; break; } - [ "\$dir" = / ] && break - dir=\${dir%/*}; [ -n "\$dir" ] || dir=/ +set -f +explain() { [ "${GSTACK_BUN_SHIM_EXPLAIN:-}" = 1 ] && echo "gstack bun shim: $*" >&2; return 0; } +sha() { if command -v sha256sum >/dev/null 2>&1; then printf '%s' "$1" | sha256sum | cut -d' ' -f1; else printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1; fi; } +is_shim() { head -c 512 "$1" 2>/dev/null | grep -aqF "$marker"; } +ge() { # A B: version A is at or above B (a prerelease sorts below its release) + case "$1" in *-*) pre=1 ;; *) pre=0 ;; esac + gifs=$IFS; IFS=.; set -- ${1%%[-+]*} $2; IFS=$gifs + [ $# -eq 6 ] || return 1 + for n in "$@"; do case "$n" in ''|*[!0-9]*) return 1 ;; esac; done + [ "$1" -ne "$4" ] && { [ "$1" -gt "$4" ]; return; } + [ "$2" -ne "$5" ] && { [ "$2" -gt "$5" ]; return; } + [ "$3" -ne "$6" ] && { [ "$3" -gt "$6" ]; return; } + [ "$pre" = 0 ] +} +in_store() { # VERSION: the store's VERSION/bun when VERSION is x.y.z and the file is a real binary inside the store + printf '%s\n' "$1" | grep -Eqx '[0-9]+\.[0-9]+\.[0-9]+' || return 1 + d=$(cd -P "$store/$1" 2>/dev/null && pwd -P) || return 1 + [ "$d" = "$store/$1" ] && [ -f "$d/bun" ] && [ ! -L "$d/bun" ] && [ -x "$d/bun" ] && ! is_shim "$d/bun" && printf '%s/bun' "$d" +} +root="" record="" chose="" rule="" pin="" +dir=$(pwd -P 2>/dev/null) || dir="" +while [ -n "$dir" ]; do + [ -e "$dir/.git" ] && { root=$dir; break; } + [ "$dir" = / ] && break + dir=${dir%/*}; [ -n "$dir" ] || dir=/ done -if [ -n "\$root" ]; then - key=\$root; [ "\$root" = "\$checkout" ] && key=none - record="\$verdicts/\$(sha "\$key").json" - if [ -f "\$record" ]; then - b=\$(sed -n 's/^ "bun_path": "\(.*\)",\$/\1/p' "\$record" | head -1) - [ -n "\$b" ] && [ -x "\$b" ] && chose=\$b rule=record - else - record="" pin="" - [ -f "\$root/.bun-version" ] && pin=\$(head -1 "\$root/.bun-version" | tr -d ' v\r') - [ -n "\$pin" ] || pin=\$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"bun@\([0-9][0-9.]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1) - [ -n "\$pin" ] || pin=\$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "\$root/package.json" 2>/dev/null | head -1) - if [ -n "\$pin" ] && [ -x "\$store/\$pin/bun" ]; then chose="\$store/\$pin/bun" rule=pin - else echo "gstack bun shim: \$root has no recorded Bun; run ${GSTACK_CAPY_WRAPPER_PATH:+bash }${GSTACK_CAPY_WRAPPER_PATH:-$ROOT/bin/gstack-capy-install} --check here" >&2 +key=${root:-none}; [ "$root" = "$checkout" ] && key=none +record="$verdicts/$(sha "$key").json" +if [ -f "$record" ]; then + b=$(sed -n 's/^ "bun_path": "\(.*\)",$/\1/p' "$record" | head -1) + [ -n "$b" ] && [ -f "$b" ] && [ -x "$b" ] && ! is_shim "$b" && chose=$b rule=record +else + record="" +fi +if [ -z "$chose" ] && [ "$key" != none ]; then + [ -f "$root/.bun-version" ] && pin=$(head -1 "$root/.bun-version" | tr -d ' v\r') + [ -n "$pin" ] || pin=$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"bun@\([^"]*\)".*/\1/p' "$root/package.json" 2>/dev/null | head -1) + [ -n "$pin" ] || pin=$(sed -n 's/.*"bun"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)".*/\1/p' "$root/package.json" 2>/dev/null | head -1) + if [ -n "$pin" ]; then + if b=$(in_store "$pin"); then chose=$b rule=pin + else echo "gstack bun shim: $root pins bun $pin, which is not in gstack's runtime store; run $fix here" >&2 fi + else + explain "$root has no recorded Bun and no exact pin; run $fix here to record one" fi fi -[ -n "\$chose" ] || { [ -x "\$store/\$default/bun" ] && chose="\$store/\$default/bun" rule=default; } -if [ -z "\$chose" ]; then - set -f; ifs=\$IFS; IFS=: - for d in \$PATH; do - [ -n "\$d" ] && [ -f "\$d/bun" ] && [ -x "\$d/bun" ] || continue - head -c 512 "\$d/bun" 2>/dev/null | grep -aqF "\$marker" && continue - chose="\$d/bun" rule=PATH; break +[ -n "$chose" ] || { b=$(in_store "$default") && chose=$b rule=default; } +if [ -z "$chose" ]; then + cands="BUN_INSTALL:${BUN_INSTALL:+$BUN_INSTALL/bin/bun} +home:${HOME:+$HOME/.bun/bin/bun}" + oifs=$IFS; IFS=: + for d in $PATH; do [ -n "$d" ] && cands="$cands +PATH:$d/bun"; done + IFS=$oifs + for want in "$minimum" "$floor"; do + nl=' +' + oifs=$IFS; IFS=$nl + for cand in $cands; do + r=${cand%%:*} f=${cand#*:} + [ -n "$f" ] && [ -f "$f" ] && [ -x "$f" ] && ! is_shim "$f" || continue + ge "$("$f" --version 2>/dev/null | head -1)" "$want" && { chose=$f rule=$r; break 2; } + done + IFS=$oifs done - IFS=\$ifs; set +f + IFS=$oifs fi -[ "\${GSTACK_BUN_SHIM_EXPLAIN:-}" = 1 ] && echo "gstack bun shim: project=\${root:-none} record=\${record:-none} chose=\${chose:-none} rule=\${rule:-none}" >&2 -[ -n "\$chose" ] || { echo "gstack bun shim: no bun found (store \$store, then PATH)" >&2; exit 127; } -exec "\$chose" "\$@" +explain "project=${root:-none} record=${record:-none} chose=${chose:-none} rule=${rule:-none}" +[ -n "$chose" ] || { echo "gstack bun shim: no bun at or above $floor found (store $store, \$BUN_INSTALL/bin, ~/.bun/bin, PATH)" >&2; exit 127; } +exec "$chose" "$@" EOF_SHIM + } > "$tmp" fi chmod 0755 "$tmp" && if cmp -s "$tmp" "$dir/$name"; then rm -f "$tmp"; else mv -f "$tmp" "$dir/$name"; fi done diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index c2f0ca33d..250bf4a7f 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -484,11 +484,26 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: one resoluti expect(r.stdout).toContain(`no installed Bun satisfies gstack's supported minimum 1.4.2 and the project pins bun >=1.4.0 (engines.bun, package.json:1); bun 1.4.3 (CI linux lane, ci.yml:7) (judged: bun 1.4.2 at ${bi}/bin/bun (BUN_INSTALL), bun 1.3.14 at ${f.stub}/bun (PATH)); installing bun 1.4.3`); expect(r.stdout).not.toContain(`bun 1.3.14 at ${f.stub}/bun does not satisfy`); expect(r.stdout).toContain(`bun 1.4.3 at ${store(f, '1.4.3')}/bun (the project's exact pin, from gstack's runtime store;`); + expect(fs.readlinkSync(path.join(store(f, '1.4.3'), 'bunx'))).toBe('bun'); expect(sh(`"${bi}/bin/bun" --version`).stdout.trim()).toBe('1.4.2'); const c = run(f, ['--check', '--for', 'review'], { BUN_INSTALL: bi }, project); expect(c.status, c.stdout + c.stderr).toBe(0); }); + test('a broken store entry is repaired in place: the version directory is never moved aside, bun is renamed over its predecessor and bunx is published', () => { + const f = fixture({ bun: '1.3.14' }); + withPins(f); + const dir = store(f, '1.4.3'); + realBun(path.join(dir, 'bun'), '1.4.0'); + const inode = fs.statSync(dir).ino; + const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '', STUB_CURL_ZIP: zipFor(f, '1.4.3') }, f.base); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.statSync(dir).ino).toBe(inode); + expect(sh(`"${dir}/bun" --version`).stdout.trim()).toBe('1.4.3'); + expect(fs.readlinkSync(path.join(dir, 'bunx'))).toBe('bun'); + expect(fs.readdirSync(path.dirname(dir)).filter(n => n.startsWith('.tmp'))).toEqual([]); + }); + test('drift: a deleted recorded Bun fails --check with BUN_RESOLUTION_DRIFT, and following the printed fix makes the next --check pass', () => { const f = fixture({ bun: '1.3.14' }); withPins(f); @@ -600,9 +615,78 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire const d = fresh(f, unrecordedPinned, 'bun --version'); expect([d.stdout.trim(), d.stderr]).toEqual(['1.4.2', '']); const unrecorded = repo(f, 'e'); + // Integration item 6: an unrecorded, unpinned repo falls through quietly; + // only GSTACK_BUN_SHIM_EXPLAIN=1 names the missing record. const e = fresh(f, unrecorded, 'bun --version'); - expect(e.stdout.trim()).toBe('1.4.3'); - expect(e.stderr).toBe(`gstack bun shim: ${unrecorded} has no recorded Bun; run ${f.root}/bin/gstack-capy-install --check here\n`); + expect([e.stdout.trim(), e.stderr]).toEqual(['1.4.3', '']); + expect(fresh(f, unrecorded, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }).stderr).toContain(`gstack bun shim: ${unrecorded} has no recorded Bun and no exact pin; run ${f.root}/bin/gstack-capy-install --check here to record one\n`); + }); + + // Integration item 3: a repo's pin never steers the shim outside the store. + test('the generated shim rejects a traversal pin, a store entry that is a symlink escape, and a marker-carrying store binary; none of them runs', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, 'state'); + const storeDir = path.join(state, 'runtimes/bun'); + realBun(path.join(storeDir, '1.4.3/bun'), '1.4.3'); + expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); + const pwned = path.join(f.base, 'pwned'); + const payload = (dir: string) => script(path.join(dir, 'bun'), [`touch "${pwned}"`, 'echo payload']); + const evilRepo = repo(f, 'repo'); + payload(path.join(evilRepo, 'payload')); + write(path.join(evilRepo, '.bun-version'), '../../../repo/payload\n'); + expect(fs.existsSync(path.join(storeDir, '../../../repo/payload/bun'))).toBe(true); + const t = fresh(f, evilRepo, 'bun --version'); + expect(t.stdout.trim()).toBe('1.4.3'); + expect(t.stderr).toContain(`${evilRepo} pins bun ../../../repo/payload, which is not in gstack's runtime store`); + payload(path.join(f.base, 'evil')); + fs.symlinkSync(path.join(f.base, 'evil'), path.join(storeDir, '1.4.9')); + const escape = fresh(f, repo(f, 'escape', '1.4.9'), 'bun --version'); + expect(escape.stdout.trim()).toBe('1.4.3'); + write(path.join(storeDir, '1.4.8/bun'), '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\necho loop\n', 0o755); + const marked = fresh(f, repo(f, 'marked', '1.4.8'), 'bun --version'); + expect(marked.stdout.trim()).toBe('1.4.3'); + expect(fs.existsSync(pwned)).toBe(false); + }); + + // Integration item 4: outside a project and inside the gstack checkout the + // shim reads the 'none' record, then falls back in the resolver's order. + test('empty store, Capy-style PATH with the image bun 1.3.14: an install that reused off-PATH ~/.bun/bin 1.4.2 is what a fresh shell runs outside a project and in the checkout; without the record ~/.bun/bin still wins over PATH', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + realBun(path.join(f.home, '.bun/bin/bun'), '1.4.2'); + const outside = path.join(f.base, 'outside'); + fs.mkdirSync(outside); + const r = run(f, ['--for', 'autoplan'], { BUN_INSTALL: '' }, outside); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(r.stdout).toContain(`bun 1.4.2 at ${f.home}/.bun/bin/bun (~/.bun/bin;`); + expect(fs.existsSync(path.join(f.home, '.gstack-state/runtimes/bun/1.4.3'))).toBe(false); + for (const dir of [outside, f.root]) { + const e = fresh(f, dir, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(e.stdout.trim(), e.stderr).toBe('1.4.2'); + expect(e.stderr).toContain(`chose=${f.home}/.bun/bin/bun rule=record`); + } + fs.rmSync(path.join(f.home, '.gstack-state/install-verdicts'), { recursive: true, force: true }); + const home = fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(home.stdout.trim()).toBe('1.4.2'); + expect(home.stderr).toContain(`record=none chose=${f.home}/.bun/bin/bun rule=home`); + fs.rmSync(path.join(f.home, '.bun'), { recursive: true, force: true }); + const image = fresh(f, outside, 'bun --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(image.stdout.trim()).toBe('1.3.14'); + expect(image.stderr).toContain(`chose=${f.stub}/bun rule=PATH`); + }); + + test('paths with a single quote are baked safely into both shims', () => { + const f = fixture({ bun: '1.3.14' }); + fs.mkdirSync(path.join(f.home, '.capy/system'), { recursive: true }); + const state = path.join(f.base, "it's state"); + realBun(path.join(state, 'runtimes/bun/1.4.3/bun'), '1.4.3'); + expect(run(f, ['--for', 'autoplan'], { GSTACK_STATE_ROOT: state }, f.base).status).toBe(0); + expect(sh(`sh -n "${f.home}/.local/bin/bun"`).status).toBe(0); + const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout.trim().split('\n')[0]).toBe('1.4.3'); + expect(r.stderr.split(`chose=${state}/runtimes/bun/1.4.3/bun rule=record`), r.stderr).toHaveLength(3); }); test('a foreign ~/.local/bin/bun or a dangling link is never touched; GSTACK_BUN_SHIM=0 writes nothing; a rerun rewrites only gstack\'s own file', () => {