#!/usr/bin/env bash # Permissões do armazenamento local (volume montado em /app/.data). # # Cada caso cria o próprio volume descartável com prefixo único e remove só os # volumes que criou. Nunca toca nos volumes do compose # (adaptador-planilhas-local-postgres, adaptador-planilhas-local-storage). # # ADAPTER_IMAGE=adaptador-planilhas:local bash .github/scripts/storage-permissions.sh set -euo pipefail # Git Bash no Windows converteria /app/... em caminho do Windows. export MSYS_NO_PATHCONV=1 IMAGE="${ADAPTER_IMAGE:-adaptador-planilhas:local}" PREFIX="adaptador-storage-test-$$-$RANDOM" CREATED=() CONTAINERS=() cleanup() { for c in "${CONTAINERS[@]:-}"; do [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true; done for v in "${CREATED[@]:-}"; do [ -n "$v" ] && docker volume rm "$v" >/dev/null 2>&1 || true; done } trap cleanup EXIT # Define VOL no shell atual: dentro de $(...) o registro em CREATED se perderia. new_volume() { VOL="$PREFIX-$1" docker volume create "$VOL" >/dev/null CREATED+=("$VOL") } # Mesmas restrições do serviço storage-init do docker-compose.yml. prepare() { docker run --rm --network none --user 0:0 \ --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \ --cap-add DAC_READ_SEARCH --cap-add SETUID --cap-add SETGID \ --security-opt no-new-privileges:true \ -v "$1:/app/.data" "$IMAGE" prepare-storage "${@:2}" } as_app() { local v="$1"; shift; docker run --rm --network none -v "$v:/app/.data" "$IMAGE" exec "$@"; } as_root() { local v="$1"; shift; docker run --rm --network none --user 0:0 -v "$v:/app/.data" "$IMAGE" exec "$@"; } # Estado do volume como root: dono, grupo, modo, tamanho e caminho, mais o hash dos arquivos. snapshot() { as_root "$1" sh -c 'cd /app/.data && find . -printf "%U:%G %m %s %p\n" | sort && find . -type f -exec sha256sum {} + | sort -k2' } expect_owner_mode() { local v="$1" path="$2" want="$3" local got got=$(as_root "$v" stat -c '%u:%g %a' "$path") [ "$got" = "$want" ] || { echo "ERRO: $path = '$got', esperado '$want'"; exit 1; } } fail() { echo "ERRO: $*"; exit 1; } echo "== imagem: $IMAGE ==" docker image inspect "$IMAGE" >/dev/null echo "== A. volume novo ==" new_volume a; VA="$VOL" # A1: sem storage-init, o volume vazio herda dono e modo da imagem. expect_owner_mode "$VA" /app/.data "1001:1001 750" expect_owner_mode "$VA" /app/.data/storage "1001:1001 750" as_app "$VA" sh -c ': > /app/.data/storage/ok && rm /app/.data/storage/ok' || fail "uid 1001 não escreve no volume novo" # A2: com storage-init, o mesmo resultado e a prova de escrita. prepare "$VA" | tee /tmp/storage-a.log grep -q "STORAGE_WRITABLE" /tmp/storage-a.log || fail "storage-init não confirmou escrita" echo "ok: volume novo 1001:1001 750, escrita pelo uid 1001" echo "== B. volume existente, criado antes da correção (raiz root:root 755) ==" new_volume b; VB="$VOL" as_root "$VB" sh -c ' chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage mkdir -p /app/.data/storage/ws-alpha/modelos printf "conteudo do modelo alpha\n" > /app/.data/storage/ws-alpha/modelos/modelo.xlsx chown -R 1001:1001 /app/.data/storage/ws-alpha printf "arquivo legado de root\n" > /app/.data/storage/legado.bin chmod 640 /app/.data/storage/legado.bin' # Antes da preparação o uid 1001 não escreve na raiz: é o defeito original. if as_app "$VB" sh -c ': > /app/.data/storage/x' 2>/dev/null; then fail "o cenário B deveria começar sem escrita"; fi prepare "$VB" 2>&1 | tee /tmp/storage-b.log grep -q "STORAGE_WRITABLE" /tmp/storage-b.log || fail "storage-init não confirmou escrita" grep -q "AVISO: 1 item" /tmp/storage-b.log || fail "o arquivo de root deveria ser só reportado" expect_owner_mode "$VB" /app/.data "1001:1001 750" expect_owner_mode "$VB" /app/.data/storage "1001:1001 750" # Conteúdo e dono dos arquivos existentes intactos; só os dois diretórios mudaram. expect_owner_mode "$VB" /app/.data/storage/legado.bin "0:0 640" expect_owner_mode "$VB" /app/.data/storage/ws-alpha/modelos/modelo.xlsx "1001:1001 644" test "$(as_root "$VB" cat /app/.data/storage/ws-alpha/modelos/modelo.xlsx)" = "conteudo do modelo alpha" \ || fail "conteúdo do modelo alterado" test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo legado alterado" as_app "$VB" sh -c ': > /app/.data/storage/novo && rm /app/.data/storage/novo' || fail "uid 1001 não escreve após preparar" echo "ok: volume existente preservado e liberado para o uid 1001" echo "== C. idempotência ==" s1=$(snapshot "$VB") prepare "$VB" > /tmp/storage-c1.log 2>&1 prepare "$VB" > /tmp/storage-c2.log 2>&1 s2=$(snapshot "$VB") [ "$s1" = "$s2" ] || { diff <(echo "$s1") <(echo "$s2") || true; fail "a preparação repetida alterou o volume"; } if grep -q -- "->" /tmp/storage-c2.log; then fail "a segunda execução não deveria alterar dono nem modo"; fi echo "ok: duas execuções extras, nenhum byte, dono ou modo alterado" echo "== C2. --fix-existing é limitado ao volume e preserva conteúdo ==" prepare "$VB" --fix-existing > /tmp/storage-c3.log 2>&1 expect_owner_mode "$VB" /app/.data/storage/legado.bin "1001:1001 640" test "$(as_root "$VB" cat /app/.data/storage/legado.bin)" = "arquivo legado de root" || fail "conteúdo alterado" if grep -q "AVISO" /tmp/storage-c3.log; then fail "ainda há item com outro dono"; fi echo "ok: dono ajustado só quando pedido, conteúdo intacto" echo "== D. web e worker compartilham o volume ==" new_volume d; VD="$VOL" prepare "$VD" >/dev/null # Um contêiner grava como a web grava, outro lê e apaga como o worker faz na limpeza. as_app "$VD" sh -c 'mkdir -p /app/.data/storage/ws/jobs && printf saida > /app/.data/storage/ws/jobs/out.csv' test "$(as_app "$VD" cat /app/.data/storage/ws/jobs/out.csv)" = "saida" || fail "outro contêiner não lê o arquivo" as_app "$VD" rm /app/.data/storage/ws/jobs/out.csv || fail "outro contêiner não apaga o arquivo" # O comando web real passa da verificação e responde liveness. WEB="$PREFIX-web" CONTAINERS+=("$WEB") docker run -d --name "$WEB" --network none -e ADAPTER_ENV=development -v "$VD:/app/.data" "$IMAGE" web >/dev/null ok="" for _ in $(seq 1 30); do if docker exec "$WEB" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi sleep 1 done [ -n "$ok" ] || { docker logs "$WEB"; fail "a web não subiu com o volume preparado"; } docker logs "$WEB" 2>&1 | grep -q STORAGE_NOT_WRITABLE && fail "a web reportou storage sem escrita" # O comando worker real passa da verificação de storage (falha depois, por falta de banco/Redis, o que não é deste teste). WORKER="$PREFIX-worker" CONTAINERS+=("$WORKER") docker run -d --name "$WORKER" --network none -e ADAPTER_ENV=development -e ADAPTER_JOB_MODE=worker \ -v "$VD:/app/.data" "$IMAGE" worker >/dev/null sleep 6 wlog=$(docker logs "$WORKER" 2>&1) docker rm -f "$WORKER" >/dev/null echo "$wlog" | grep -q STORAGE_NOT_WRITABLE && { echo "$wlog"; fail "o worker reportou storage sem escrita"; } echo "$wlog" | grep -q '\[worker\]\|DATABASE\|Redis\|ECONNREFUSED\|pglite\|migration' \ || { echo "$wlog"; fail "o worker não chegou ao código da aplicação"; } echo "ok: escrita, leitura e remoção entre contêineres; web e worker passam da verificação" echo "== E. usuário não privilegiado ==" test "$(docker exec "$WEB" id -u)" = "1001" || fail "a web não roda como 1001" test "$(docker exec "$WEB" sh -c 'grep CapEff /proc/1/status | cut -f2')" = "0000000000000000" || fail "a web tem capacidades" test "$(docker run --rm --network none "$IMAGE" exec id -u)" = "1001" || fail "o usuário padrão da imagem não é 1001" # prepare-storage recusa rodar sem root, então não serve de atalho para escalar. if docker run --rm --network none -v "$VD:/app/.data" "$IMAGE" prepare-storage >/dev/null 2>&1; then fail "prepare-storage aceitou rodar como 1001" fi docker rm -f "$WEB" >/dev/null test "$(docker image inspect "$IMAGE" --format "{{.Config.User}}")" = "adaptador" || fail "a imagem não declara USER adaptador" echo "ok: web e worker como 1001 sem capacidades; preparação só como root" echo "== F. falha segura com armazenamento sem escrita ==" new_volume f; VF="$VOL" as_root "$VF" sh -c 'chown 0:0 /app/.data /app/.data/storage && chmod 755 /app/.data /app/.data/storage' for proc in web worker; do set +e out=$(docker run --rm --network none -e ADAPTER_ENV=development -v "$VF:/app/.data" "$IMAGE" "$proc" 2>&1) code=$? set -e [ "$code" -ne 0 ] || fail "$proc subiu com storage sem escrita" echo "$out" | grep -q STORAGE_NOT_WRITABLE || { echo "$out"; fail "$proc não explicou a falha"; } echo " $proc: saiu com código $code e STORAGE_NOT_WRITABLE" done # Com Supabase configurado o volume local não é usado e a verificação não bloqueia. SUPA="$PREFIX-supabase" CONTAINERS+=("$SUPA") docker run -d --name "$SUPA" --network none -e ADAPTER_ENV=development \ -e NEXT_PUBLIC_SUPABASE_URL=https://exemplo.invalid -e SUPABASE_SERVICE_ROLE_KEY=x -e SUPABASE_STORAGE_BUCKET=x \ -v "$VF:/app/.data" "$IMAGE" web >/dev/null ok="" for _ in $(seq 1 30); do if docker exec "$SUPA" node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; then ok=1; break; fi sleep 1 done slog=$(docker logs "$SUPA" 2>&1) docker rm -f "$SUPA" >/dev/null [ -n "$ok" ] || { echo "$slog"; fail "a web não subiu com bucket configurado"; } echo "$slog" | grep -q STORAGE_NOT_WRITABLE && { echo "$slog"; fail "a verificação bloqueou com bucket configurado"; } echo "ok: falha explícita sem storage; sem bloqueio quando o bucket é o Supabase" echo "" echo "PERMISSÕES DO ARMAZENAMENTO: OK (A, B, C, D, E, F)"