diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml index 2fce8e7..1f6ac30 100644 --- a/.github/workflows/auto-pr.yml +++ b/.github/workflows/auto-pr.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Create or update PR env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c7ea9..213e2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: name: ESLint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -37,8 +37,8 @@ jobs: name: TypeScript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -49,8 +49,8 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -62,14 +62,25 @@ jobs: runs-on: ubuntu-latest needs: [lint, typecheck, test] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm run build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist/ + + # The scripts the deploy keys are pinned to are the whole of what CI can do + # on the host, so they are checked with every change. + host-scripts: + name: Host deploy scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - run: | + shellcheck -S warning ops/host/miniapp-* ops/host/test-site-receive.sh + bash ops/host/test-site-receive.sh diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 023f6f6..146c5d9 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -14,9 +14,9 @@ jobs: name: Code Complexity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -35,9 +35,9 @@ jobs: name: Duplicate Code Detection runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e7bd8be..e4041d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,9 +26,9 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -37,35 +37,19 @@ jobs: - run: npm run build - # VPS1 (telegram.pezkuwichain.io) is currently running Zagros testnet - deployed manually - # Re-enable this step when testnet period is over - # - name: Deploy to telegram.pezkuwichain.io - # uses: appleboy/scp-action@v1.0.0 - # with: - # host: ${{ secrets.VPS1_HOST }} - # username: ${{ secrets.VPS1_USER }} - # key: ${{ secrets.VPS1_SSH_KEY }} - # source: 'dist/*' - # target: '/var/www/telegram.pezkuwichain.io' - # strip_components: 1 - - name: Deploy to telegram.pezkiwi.app - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'dist/*' - target: '/var/www/telegram.pezkiwi.app' - strip_components: 1 - - - name: Cleanup old assets on VPS - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - script: bash /opt/cleanup-miniapp.sh + env: + DEPLOY_KEY: ${{ secrets.MINIAPP_SITE_DEPLOY_KEY }} + # VPS3's host key, read from the host itself; any other key fails. + KNOWN_HOST: "217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ" + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$KNOWN_HOST" > "$RUNNER_TEMP/known_hosts" + # The key is pinned on the host to ops/host/miniapp-site-receive + # (authorized_keys restrict,command=): the site tar on stdin, then the + # cleanup of earlier deploys' assets; nothing else. + tar -C dist -cf - . | ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 deploy # Edge functions live in a Supabase volume shared with other projects, so they # are written through the ownership gate on the host rather than copied in @@ -77,7 +61,7 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # telegram-bot and ask are excluded on purpose: both Telegram bots and the # news.pex.mom assistant are served from the cloud project, not from here. @@ -90,51 +74,27 @@ jobs: --exclude='functions/ask' \ functions - - name: Copy to staging on VPS - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'functions.tgz' - target: '/opt/miniapp-deploy-staging' - - # The gate and its registry are versioned here, so the server copy is - # replaced from source control on every deploy rather than edited in - # place. They previously existed only at /usr/local/bin — one copy, no - # history, no review, gone with the server. The mechanism built to stop - # silent drift was itself drifting. - - name: Sync ownership gate to host - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'ops/supabase-deploy-functions,ops/functions-registry.json' - target: '/opt/miniapp-deploy-staging' - strip_components: 1 - + # The ownership gate and its registry guard every project's functions + # in the shared volume, so this deploy does not install them; root does, + # from ops/ here. What the deploy does is refuse to run when the host's + # copy is not the one in this repository. - name: Deploy through ownership gate - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - script: | - set -e - BASE=/opt/miniapp-deploy-staging - - # Install the gate before using it, so a change to the gate ships in - # the same deploy as the functions it validates. - install -m 755 "$BASE/supabase-deploy-functions" /usr/local/bin/supabase-deploy-functions - install -m 644 "$BASE/functions-registry.json" /opt/supabase-self-hosted/functions-registry.json - trap 'rm -rf "$BASE"' EXIT - rm -rf "$BASE/functions" - tar xzf "$BASE/functions.tgz" -C "$BASE" - supabase-deploy-functions \ - --project pezkuwi-telegram-miniapp \ - --src "$BASE/functions" \ - --restart + env: + DEPLOY_KEY: ${{ secrets.MINIAPP_FUNCTIONS_DEPLOY_KEY }} + # VPS3's host key, read from the host itself; any other key fails. + KNOWN_HOST: "217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ" + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$KNOWN_HOST" > "$RUNNER_TEMP/known_hosts" + # The key is pinned on the host to ops/host/miniapp-functions-ssh. + want="$(sha256sum ops/supabase-deploy-functions ops/functions-registry.json | awk '{print $1}')" + have="$(ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 gate-sha)" + if [ "$want" != "$have" ]; then + echo "::error::the host's ownership gate or registry is not the one in ops/; install them as root (ops/host/README.md), then re-run" + exit 1 + fi + ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 deploy < functions.tgz # The two Telegram bots and the news.pex.mom assistant run on the cloud # Supabase project, reached by webhook at @@ -147,7 +107,7 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Deploy telegram-bot and ask env: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c5804b4..755c3e7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,9 +16,9 @@ jobs: name: Dependency Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -41,10 +41,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: fail-on-severity: high deny-licenses: GPL-3.0, AGPL-3.0 @@ -53,12 +53,12 @@ jobs: name: Secret Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: TruffleHog Secret Scan - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2 with: path: ./ base: ${{ github.event.repository.default_branch }} diff --git a/ops/README.md b/ops/README.md index ed4b8d9..5fac4ec 100644 --- a/ops/README.md +++ b/ops/README.md @@ -62,11 +62,45 @@ server where nobody can see the change. ### Deployment -`.github/workflows/deploy.yml` copies both to the host on every deploy, so the -server copy is replaced from version control rather than edited in place. Before -this, the script existed only at `/usr/local/bin/supabase-deploy-functions` — one -copy, no history, no review, and gone with the server. The mechanism built to -stop silent drift was itself drifting. +The gate and its registry guard every project's functions in the shared +volume, so no project's CI installs them; that would let one project rewrite +the rule that protects the others. Root installs them from this directory: + +```bash +install -o root -g root -m 755 ops/supabase-deploy-functions /usr/local/bin/ +install -o root -g root -m 644 ops/functions-registry.json /opt/supabase-self-hosted/ +``` + +They are still versioned here, not edited in place on the server: before +2026-07 the script existed only at `/usr/local/bin` — one copy, no history, no +review. `deploy.yml` checks the host's copies against these files (`gate-sha`) +and refuses to deploy functions while they differ. + +## `host/`: what the deploy keys can do + +CI reaches vps3 as `miniapp-deploy`, with two keys. Neither opens a shell: each +is pinned in `authorized_keys` to one script here (`restrict,command=`). + +| key (secret) | forced command | input | +|---|---|---| +| `MINIAPP_SITE_DEPLOY_KEY` | `miniapp-site-receive` | site tar on stdin → `/var/www/telegram.pezkiwi.app`; removes earlier builds' assets after four hours, never the new build's | +| `MINIAPP_FUNCTIONS_DEPLOY_KEY` | `miniapp-functions-ssh` → sudo `miniapp-functions-deploy` | `gate-sha`, or `deploy` with functions.tgz on stdin | + +Install (root, on vps3): + +```bash +install -o root -g root -m 755 ops/host/miniapp-* /usr/local/sbin/ +useradd --system --create-home --shell /bin/bash miniapp-deploy +chown -R miniapp-deploy:miniapp-deploy /var/www/telegram.pezkiwi.app +# /etc/sudoers.d/miniapp-functions-deploy +miniapp-deploy ALL=(root) NOPASSWD: /usr/local/sbin/miniapp-functions-deploy deploy, /usr/local/sbin/miniapp-functions-deploy gate-sha +# ~miniapp-deploy/.ssh/authorized_keys +restrict,command="/usr/local/sbin/miniapp-site-receive" ssh-ed25519 ... miniapp-ci-site-deploy +restrict,command="/usr/local/sbin/miniapp-functions-ssh" ssh-ed25519 ... miniapp-ci-functions-deploy +``` + +`host/test-site-receive.sh` runs the site script against a scratch web root, +including what it must refuse; CI runs it with shellcheck. ## `apply-repo-settings.sh` diff --git a/ops/apply-repo-settings.sh b/ops/apply-repo-settings.sh index 811fa13..d7ae9d5 100755 --- a/ops/apply-repo-settings.sh +++ b/ops/apply-repo-settings.sh @@ -33,7 +33,7 @@ read -r -d '' PROTECTION <<'JSON' || true { "required_status_checks": { "strict": true, - "contexts": ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] + "contexts": ["Test", "ESLint", "TypeScript", "Build", "Secret Scan", "Host deploy scripts"] }, "enforce_admins": false, "required_pull_request_reviews": { @@ -65,7 +65,7 @@ import json, sys d = json.loads(sys.argv[1]) checks = d.get('required_status_checks') or {} rev = d.get('required_pull_request_reviews') or {} -expected_checks = ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] +expected_checks = ["Test", "ESLint", "TypeScript", "Build", "Secret Scan", "Host deploy scripts"] want = { 'required checks': (sorted(checks.get('contexts') or []), sorted(expected_checks)), 'strict': (checks.get('strict'), True), @@ -88,5 +88,5 @@ PY fi gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" --input - <<<"$PROTECTION" >/dev/null -ok "protected: 1 approval, 5 required checks, no force push, no deletion" +ok "protected: 1 approval, 6 required checks, no force push, no deletion" echo "✔ done — verify with: $0 --check" diff --git a/ops/host/miniapp-functions-deploy b/ops/host/miniapp-functions-deploy new file mode 100755 index 0000000..733aa74 --- /dev/null +++ b/ops/host/miniapp-functions-deploy @@ -0,0 +1,30 @@ +#!/bin/bash +# Deploys this project's edge functions into the shared Supabase volume, +# through the ownership gate (supabase-deploy-functions), from a functions.tgz +# on stdin. Installed root-owned at /usr/local/sbin/miniapp-functions-deploy; +# reached only through miniapp-functions-ssh. +# +# The gate and its registry guard every project's functions in that volume, +# so CI does not install them: root does, from ops/ in this repository, and +# CI checks with `gate-sha` that the host runs what the repository says. +set -euo pipefail + +GATE=/usr/local/bin/supabase-deploy-functions +REGISTRY=/opt/supabase-self-hosted/functions-registry.json + +case "${1:-}" in + gate-sha) + sha256sum "$GATE" "$REGISTRY" | awk '{print $1}' + ;; + deploy) + BASE=$(mktemp -d /opt/miniapp-deploy-staging.XXXXXX) + trap 'rm -rf "$BASE"' EXIT + tar -xzf - -C "$BASE" --no-same-owner + [ -d "$BASE/functions" ] || { echo "miniapp-functions-deploy: the bundle has no functions/" >&2; exit 1; } + "$GATE" --project pezkuwi-telegram-miniapp --src "$BASE/functions" --restart + ;; + *) + echo "usage: miniapp-functions-deploy gate-sha|deploy" >&2 + exit 2 + ;; +esac diff --git a/ops/host/miniapp-functions-ssh b/ops/host/miniapp-functions-ssh new file mode 100755 index 0000000..e262d48 --- /dev/null +++ b/ops/host/miniapp-functions-ssh @@ -0,0 +1,17 @@ +#!/bin/bash +# Forced command for the miniapp functions deploy key. Installed root-owned +# at /usr/local/sbin/miniapp-functions-ssh: +# +# restrict,command="/usr/local/sbin/miniapp-functions-ssh" ssh-ed25519 ... +# +# ssh miniapp-deploy@ gate-sha # sha256 of the installed gate and registry +# ssh miniapp-deploy@ deploy < functions.tgz +# +# miniapp-functions-deploy, which sudo lets this user run with exactly those +# arguments and nothing else, does the work. +set -euo pipefail + +case "${SSH_ORIGINAL_COMMAND:-}" in + deploy|gate-sha) exec sudo -n /usr/local/sbin/miniapp-functions-deploy "$SSH_ORIGINAL_COMMAND" ;; + *) echo "miniapp-functions-ssh: the commands are 'gate-sha' and 'deploy' (functions.tgz on stdin)" >&2; exit 2 ;; +esac diff --git a/ops/host/miniapp-site-receive b/ops/host/miniapp-site-receive new file mode 100755 index 0000000..d724694 --- /dev/null +++ b/ops/host/miniapp-site-receive @@ -0,0 +1,50 @@ +#!/bin/bash +# Forced command for the miniapp site deploy key. Installed root-owned at +# /usr/local/sbin/miniapp-site-receive; authorized_keys pins the key to it: +# +# restrict,command="/usr/local/sbin/miniapp-site-receive" ssh-ed25519 ... +# +# CI runs `ssh miniapp-deploy@ deploy < site.tar`. The key can do +# nothing else: no shell, no other command, no forwarding, one web root. +set -euo pipefail + +TARGET=/var/www/telegram.pezkiwi.app + +if [ "${SSH_ORIGINAL_COMMAND:-}" != "deploy" ]; then + echo "miniapp-site-receive: the only command is 'deploy', with the site tar on stdin" >&2 + exit 2 +fi + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +# GNU tar drops leading slashes and refuses members containing '..'. +tar -xf - -C "$STAGE" --no-same-owner --no-same-permissions + +if [ ! -f "$STAGE/index.html" ] || [ ! -d "$STAGE/assets" ]; then + echo "miniapp-site-receive: not a site build (index.html and assets/ are required)" >&2 + exit 1 +fi + +# -rt copies regular files and directories only: a symlink in the upload is +# skipped. --perms with --chmod sets the modes, so mktemp's 0700 never reaches +# a directory nginx reads. Assets first, index.html last. +rsync -rt --perms --chmod=D755,F644 --exclude=/index.html "$STAGE/" "$TARGET/" +install -m 644 "$STAGE/index.html" "$TARGET/index.html" + +# Earlier deploys' assets go once nothing can still ask for them: a file of +# this build is never removed, whatever its timestamp; a file that is not in +# this build stays for four hours, for tabs still open on the previous build. +# (The cleanup this replaces protected lazy chunks by their mtime and so +# removed live ones when a build was shipped more than two hours after it was +# made, which took seven pages down on 2026-10-09.) +removed=0 +while IFS= read -r -d '' f; do + name=${f#"$TARGET/"} + [ -e "$STAGE/$name" ] && continue + if [ -z "$(find "$f" -mmin -240)" ]; then + rm -f "$f" + removed=$((removed + 1)) + fi +done < <(find "$TARGET/assets" -type f -print0) +echo "miniapp-site-receive: deployed; removed $removed asset(s) of earlier builds" diff --git a/ops/host/test-site-receive.sh b/ops/host/test-site-receive.sh new file mode 100755 index 0000000..4d68f1b --- /dev/null +++ b/ops/host/test-site-receive.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# The rc/before variables are read inside the check expressions, which +# check() runs with eval; shellcheck cannot see into those strings. +# shellcheck disable=SC2034 +# Exercises miniapp-site-receive against a scratch web root: what it +# installs, with which modes, and what it must refuse. The script under test +# is copied with its web root pointed at the scratch directory. +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/web" "$T/tmp/stage" +export TMPDIR="$T/tmp/stage" # stage = $T/tmp/stage/tmp.X, so ../../.. is $T +sed -e "s#/var/www/telegram.pezkiwi.app#$T/web#" "$HERE/miniapp-site-receive" > "$T/receive" +chmod +x "$T/receive" + +pass=0; fail=0 +check () { if eval "$2"; then echo "ok $1"; pass=$((pass+1)); else echo "FAIL $1"; fail=$((fail+1)); fi; } +site () { mkdir -p "$1/assets"; echo "new" > "$1/index.html"; echo "x" > "$1/assets/index-NEW.js"; } +run () { ( cd "$2" && tar -cf - . ) | SSH_ORIGINAL_COMMAND="$1" "$T/receive" >/dev/null 2>&1; } + +mkdir -p "$T/web/assets"; echo old > "$T/web/assets/index-OLD.js"; echo "old" > "$T/web/index.html" + +site "$T/s1"; run deploy "$T/s1"; rc=$? +check "a valid build installs" '[ $rc = 0 ] && grep -q new "$T/web/index.html"' +check "recent chunks of the previous build stay" '[ -f "$T/web/assets/index-OLD.js" ]' +check "files are world-readable (644)" '[ "$(stat -c %a "$T/web/index.html")" = 644 ] && [ "$(stat -c %a "$T/web/assets/index-NEW.js")" = 644 ]' +check "directories are traversable (755)" '[ "$(stat -c %a "$T/web/assets")" = 755 ] && [ "$(stat -c %a "$T/web")" = 755 ]' + +before=$(cat "$T/web/index.html") +mkdir -p "$T/s2/assets"; echo x > "$T/s2/assets/a.js"; run deploy "$T/s2"; rc=$? +check "a tar without index.html is refused" '[ $rc != 0 ] && [ "$(cat "$T/web/index.html")" = "$before" ]' + +site "$T/s3"; run 'deploy; id' "$T/s3"; rc1=$?; run '' "$T/s3"; rc2=$? +check "any command but deploy is refused" '[ $rc1 = 2 ] && [ $rc2 = 2 ]' + +# The cleanup: a file of the new build stays however old it is (the old +# cleanup removed lazy chunks older than two hours); a file not in the build +# stays for four hours, then goes. +site "$T/c1"; echo chunk > "$T/c1/assets/Wallet-OLD.js"; touch -d '3 days ago' "$T/c1/assets/Wallet-OLD.js" +echo prev > "$T/web/assets/prev-recent.js"; echo gone > "$T/web/assets/prev-old.js"; touch -d '1 day ago' "$T/web/assets/prev-old.js" +run deploy "$T/c1"; rc=$? +check "an old file of the new build is kept" '[ $rc = 0 ] && [ -f "$T/web/assets/Wallet-OLD.js" ]' +check "a recent file of an earlier build is kept" '[ -f "$T/web/assets/prev-recent.js" ]' +check "an old file of an earlier build is removed" '[ ! -e "$T/web/assets/prev-old.js" ]' + +site "$T/s4"; ln -s /etc/passwd "$T/s4/assets/passwd.js"; run deploy "$T/s4"; rc=$? +check "a symlink in the upload is not installed" '[ $rc = 0 ] && [ ! -e "$T/web/assets/passwd.js" ] && [ ! -L "$T/web/assets/passwd.js" ]' + +site "$T/s5" +( cd "$T/s5" && tar -cf "$T/evil.tar" . && tar -rf "$T/evil.tar" --transform 's#^#../../../#' index.html ) 2>/dev/null +SSH_ORIGINAL_COMMAND=deploy "$T/receive" < "$T/evil.tar" >/dev/null 2>&1 +check "a ../ member cannot write outside the stage" '[ ! -e "$T/index.html" ] && [ ! -e "$T/tmp/index.html" ]' + +echo "RESULT: $pass passed, $fail failed" +[ "$fail" = 0 ]