mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 13:50:31 +00:00
* fix: remove unwrap() from production code, improve error handling - Replace unwrap() with proper error handling in npm, mirror, validation - Add input validation to cargo registry (crate name + version) - Improve expect() messages with descriptive context in metrics, rate_limit - Remove unnecessary clone() in error.rs, docker.rs, npm.rs, dashboard_metrics - Add #![deny(clippy::unwrap_used)] to prevent future unwrap in prod code - Add let-else pattern for safer null checks in validation.rs * docs: update SECURITY.md — add 0.3.x to supported versions * security: forbid unsafe code at crate level Add #![forbid(unsafe_code)] to both lib.rs and main.rs. NORA has zero unsafe blocks — this prevents future additions without removing the forbid attribute (stronger than deny). * build: add rust-toolchain.toml, Dockerfile HEALTHCHECK - Pin toolchain to stable with clippy + rustfmt components - Add Docker HEALTHCHECK for standalone deployments (wget /health) * test: add Go proxy and Raw registry integration tests Go proxy tests: list, .info, .mod, @latest, path traversal, 404 Raw registry tests: upload/download, HEAD, 404, path traversal, overwrite, delete, binary data (10KB)
482 lines
14 KiB
Bash
Executable File
482 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# NORA E2E Smoke Test
|
|
# Starts NORA, runs real-world scenarios, verifies results.
|
|
# Exit code 0 = all passed, non-zero = failures.
|
|
|
|
NORA_BIN="${NORA_BIN:-./target/release/nora}"
|
|
PORT="${NORA_TEST_PORT:-14000}"
|
|
BASE="http://localhost:${PORT}"
|
|
STORAGE_DIR=$(mktemp -d)
|
|
PASSED=0
|
|
FAILED=0
|
|
NORA_PID=""
|
|
|
|
cleanup() {
|
|
[ -n "$NORA_PID" ] && kill "$NORA_PID" 2>/dev/null || true
|
|
rm -rf "$STORAGE_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
fail() {
|
|
echo " FAIL: $1"
|
|
FAILED=$((FAILED + 1))
|
|
}
|
|
|
|
pass() {
|
|
echo " PASS: $1"
|
|
PASSED=$((PASSED + 1))
|
|
}
|
|
|
|
check() {
|
|
local desc="$1"
|
|
shift
|
|
if "$@" >/dev/null 2>&1; then
|
|
pass "$desc"
|
|
else
|
|
fail "$desc"
|
|
fi
|
|
}
|
|
|
|
echo "=== NORA Smoke Test ==="
|
|
echo "Binary: $NORA_BIN"
|
|
echo "Port: $PORT"
|
|
echo "Storage: $STORAGE_DIR"
|
|
echo ""
|
|
|
|
# Start NORA
|
|
NORA_HOST=127.0.0.1 \
|
|
NORA_PORT=$PORT \
|
|
NORA_STORAGE_PATH="$STORAGE_DIR" \
|
|
NORA_RATE_LIMIT_ENABLED=false \
|
|
NORA_PUBLIC_URL="$BASE" \
|
|
"$NORA_BIN" serve &
|
|
NORA_PID=$!
|
|
|
|
# Wait for startup
|
|
for i in $(seq 1 20); do
|
|
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
|
sleep 0.5
|
|
done
|
|
|
|
echo "--- Health & Monitoring ---"
|
|
check "GET /health returns healthy" \
|
|
curl -sf "$BASE/health"
|
|
|
|
check "GET /ready returns 200" \
|
|
curl -sf "$BASE/ready"
|
|
|
|
check "GET /metrics returns prometheus" \
|
|
curl -sf "$BASE/metrics"
|
|
|
|
echo ""
|
|
echo "--- npm Proxy ---"
|
|
|
|
# Fetch metadata — triggers proxy cache
|
|
METADATA=$(curl -sf "$BASE/npm/chalk" 2>/dev/null || echo "{}")
|
|
|
|
check "npm metadata returns 200" \
|
|
curl -sf "$BASE/npm/chalk"
|
|
|
|
# URL rewriting check
|
|
TARBALL_URL=$(echo "$METADATA" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('versions',{}).get('5.4.1',{}).get('dist',{}).get('tarball',''))" 2>/dev/null || echo "")
|
|
if echo "$TARBALL_URL" | grep -q "localhost:${PORT}/npm"; then
|
|
pass "npm tarball URL rewritten to NORA"
|
|
else
|
|
fail "npm tarball URL not rewritten: $TARBALL_URL"
|
|
fi
|
|
|
|
# Fetch tarball
|
|
check "npm tarball download" \
|
|
curl -sf "$BASE/npm/chalk/-/chalk-5.4.1.tgz" -o /dev/null
|
|
|
|
# Scoped package
|
|
check "npm scoped package @babel/parser" \
|
|
curl -sf "$BASE/npm/@babel/parser"
|
|
|
|
# Publish
|
|
PUBLISH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \
|
|
"$BASE/npm/smoke-test-pkg")
|
|
if [ "$PUBLISH_RESULT" = "201" ]; then
|
|
pass "npm publish returns 201"
|
|
else
|
|
fail "npm publish returned $PUBLISH_RESULT"
|
|
fi
|
|
|
|
# Version immutability
|
|
DUPE_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \
|
|
"$BASE/npm/smoke-test-pkg")
|
|
if [ "$DUPE_RESULT" = "409" ]; then
|
|
pass "npm version immutability (409 on duplicate)"
|
|
else
|
|
fail "npm duplicate publish returned $DUPE_RESULT, expected 409"
|
|
fi
|
|
|
|
# Security: name mismatch
|
|
MISMATCH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name":"evil-pkg","versions":{"1.0.0":{}},"_attachments":{"a.tgz":{"data":"dGVzdA=="}}}' \
|
|
"$BASE/npm/lodash")
|
|
if [ "$MISMATCH_RESULT" = "400" ]; then
|
|
pass "npm name mismatch rejected (400)"
|
|
else
|
|
fail "npm name mismatch returned $MISMATCH_RESULT, expected 400"
|
|
fi
|
|
|
|
# Security: path traversal
|
|
TRAVERSAL_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"name":"test-pkg","versions":{"1.0.0":{}},"_attachments":{"../../etc/passwd":{"data":"dGVzdA=="}}}' \
|
|
"$BASE/npm/test-pkg")
|
|
if [ "$TRAVERSAL_RESULT" = "400" ]; then
|
|
pass "npm path traversal rejected (400)"
|
|
else
|
|
fail "npm path traversal returned $TRAVERSAL_RESULT, expected 400"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- Maven ---"
|
|
check "Maven proxy download" \
|
|
curl -sf "$BASE/maven2/org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom" -o /dev/null
|
|
|
|
echo ""
|
|
echo "--- PyPI ---"
|
|
check "PyPI simple index" \
|
|
curl -sf "$BASE/simple/"
|
|
|
|
check "PyPI package page" \
|
|
curl -sf "$BASE/simple/requests/"
|
|
|
|
echo ""
|
|
echo "--- Docker ---"
|
|
check "Docker v2 check" \
|
|
curl -sf "$BASE/v2/"
|
|
|
|
echo ""
|
|
echo "--- Raw ---"
|
|
echo "raw-test-data" | curl -sf -X PUT --data-binary @- "$BASE/raw/smoke/test.txt" >/dev/null 2>&1
|
|
check "Raw upload" \
|
|
curl -sf "$BASE/raw/smoke/test.txt" -o /dev/null
|
|
|
|
echo ""
|
|
echo "--- UI & API ---"
|
|
check "UI dashboard loads" \
|
|
curl -sf "$BASE/ui/"
|
|
|
|
check "OpenAPI docs" \
|
|
curl -sf "$BASE/api-docs" -o /dev/null
|
|
|
|
# Dashboard stats — check npm count > 0 after proxy fetches
|
|
sleep 1
|
|
STATS=$(curl -sf "$BASE/ui/api/stats" 2>/dev/null || echo "{}")
|
|
NPM_COUNT=$(echo "$STATS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('npm',0))" 2>/dev/null || echo "0")
|
|
if [ "$NPM_COUNT" -gt 0 ] 2>/dev/null; then
|
|
pass "Dashboard npm count > 0 (got $NPM_COUNT)"
|
|
else
|
|
fail "Dashboard npm count is $NPM_COUNT, expected > 0"
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- Docker Push/Pull + Digest Verify ---"
|
|
|
|
# Create a minimal Docker image, push, pull, verify digest
|
|
DOCKER_AVAILABLE=true
|
|
if ! docker info >/dev/null 2>&1; then
|
|
echo " SKIP: Docker daemon not available"
|
|
DOCKER_AVAILABLE=false
|
|
fi
|
|
|
|
if [ "$DOCKER_AVAILABLE" = true ]; then
|
|
DOCKER_IMG="localhost:${PORT}/smoke-test/hello:v1"
|
|
|
|
# Create tiny image from scratch
|
|
DOCKER_BUILD_DIR=$(mktemp -d)
|
|
echo "FROM scratch" > "$DOCKER_BUILD_DIR/Dockerfile"
|
|
echo "smoke-test" > "$DOCKER_BUILD_DIR/data.txt"
|
|
echo "COPY data.txt /data.txt" >> "$DOCKER_BUILD_DIR/Dockerfile"
|
|
|
|
if docker build -t "$DOCKER_IMG" "$DOCKER_BUILD_DIR" >/dev/null 2>&1; then
|
|
pass "docker build smoke image"
|
|
else
|
|
fail "docker build smoke image"
|
|
fi
|
|
rm -rf "$DOCKER_BUILD_DIR"
|
|
|
|
# Push
|
|
if docker push "$DOCKER_IMG" >/dev/null 2>&1; then
|
|
pass "docker push to NORA"
|
|
else
|
|
fail "docker push to NORA"
|
|
fi
|
|
|
|
# Get digest from registry
|
|
MANIFEST=$(curl -sf -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
|
|
"$BASE/v2/smoke-test/hello/manifests/v1" 2>/dev/null || echo "")
|
|
if [ -n "$MANIFEST" ] && echo "$MANIFEST" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
|
|
pass "docker manifest retrievable from NORA"
|
|
else
|
|
fail "docker manifest not retrievable"
|
|
fi
|
|
|
|
# Remove local image and pull back
|
|
docker rmi "$DOCKER_IMG" >/dev/null 2>&1 || true
|
|
if docker pull "$DOCKER_IMG" >/dev/null 2>&1; then
|
|
pass "docker pull from NORA"
|
|
else
|
|
fail "docker pull from NORA"
|
|
fi
|
|
|
|
# Verify digest matches: push digest == pull digest
|
|
PUSH_DIGEST=$(docker inspect "$DOCKER_IMG" --format='{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)
|
|
if [ -n "$PUSH_DIGEST" ] && echo "$PUSH_DIGEST" | grep -q "^sha256:"; then
|
|
pass "docker digest verified (${PUSH_DIGEST:0:20}...)"
|
|
else
|
|
fail "docker digest verification failed"
|
|
fi
|
|
|
|
# Cleanup
|
|
docker rmi "$DOCKER_IMG" >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- npm Install + Integrity Verify ---"
|
|
|
|
# Test real npm client against NORA (not just curl)
|
|
NPM_TEST_DIR=$(mktemp -d)
|
|
cd "$NPM_TEST_DIR"
|
|
|
|
# Create minimal package.json
|
|
cat > package.json << 'PKGJSON'
|
|
{
|
|
"name": "nora-smoke-test",
|
|
"version": "1.0.0",
|
|
"dependencies": {
|
|
"chalk": "5.4.1"
|
|
}
|
|
}
|
|
PKGJSON
|
|
|
|
# npm install using NORA as registry
|
|
if npm install --registry "$BASE/npm/" --prefer-online --no-audit --no-fund >/dev/null 2>&1; then
|
|
pass "npm install chalk via NORA registry"
|
|
else
|
|
fail "npm install chalk via NORA registry"
|
|
fi
|
|
|
|
# Verify package was installed
|
|
if [ -f "node_modules/chalk/package.json" ]; then
|
|
INSTALLED_VER=$(python3 -c "import json; print(json.load(open('node_modules/chalk/package.json'))['version'])" 2>/dev/null || echo "")
|
|
if [ "$INSTALLED_VER" = "5.4.1" ]; then
|
|
pass "npm installed correct version (5.4.1)"
|
|
else
|
|
fail "npm installed wrong version: $INSTALLED_VER"
|
|
fi
|
|
else
|
|
fail "npm node_modules/chalk not found"
|
|
fi
|
|
|
|
# Verify integrity: check that package-lock.json has sha512 integrity
|
|
if [ -f "package-lock.json" ]; then
|
|
INTEGRITY=$(python3 -c "
|
|
import json
|
|
lock = json.load(open('package-lock.json'))
|
|
pkgs = lock.get('packages', {})
|
|
chalk = pkgs.get('node_modules/chalk', pkgs.get('chalk', {}))
|
|
print(chalk.get('integrity', ''))
|
|
" 2>/dev/null || echo "")
|
|
if echo "$INTEGRITY" | grep -q "^sha512-"; then
|
|
pass "npm integrity hash present (sha512)"
|
|
else
|
|
fail "npm integrity hash missing: $INTEGRITY"
|
|
fi
|
|
else
|
|
fail "npm package-lock.json not created"
|
|
fi
|
|
|
|
cd /tmp
|
|
rm -rf "$NPM_TEST_DIR"
|
|
|
|
echo ""
|
|
echo "--- Upstream Timeout Handling ---"
|
|
|
|
# Verify that requesting a non-existent package from upstream returns 404 quickly (not hang)
|
|
TIMEOUT_START=$(date +%s)
|
|
TIMEOUT_RESULT=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 \
|
|
"$BASE/npm/@nora-smoke-test/nonexistent-package-xyz-12345")
|
|
TIMEOUT_END=$(date +%s)
|
|
TIMEOUT_DURATION=$((TIMEOUT_END - TIMEOUT_START))
|
|
|
|
if [ "$TIMEOUT_RESULT" = "404" ]; then
|
|
pass "upstream 404 returned correctly"
|
|
else
|
|
fail "upstream returned $TIMEOUT_RESULT, expected 404"
|
|
fi
|
|
|
|
if [ "$TIMEOUT_DURATION" -lt 10 ]; then
|
|
pass "upstream 404 returned in ${TIMEOUT_DURATION}s (< 10s)"
|
|
else
|
|
fail "upstream 404 took ${TIMEOUT_DURATION}s (too slow, retry may hang)"
|
|
fi
|
|
|
|
echo ""
|
|
# ============================================
|
|
# Go Proxy Tests
|
|
# ============================================
|
|
echo ""
|
|
echo "=== Go Proxy ==="
|
|
|
|
# Pre-seed a Go module for testing
|
|
GO_MODULE="example.com/testmod"
|
|
GO_VERSION="v1.0.0"
|
|
GO_STORAGE="$STORAGE_DIR/go"
|
|
mkdir -p "$GO_STORAGE/example.com/testmod/@v"
|
|
|
|
# Create .info file
|
|
echo '{"Version":"v1.0.0","Time":"2026-01-01T00:00:00Z"}' > "$GO_STORAGE/example.com/testmod/@v/v1.0.0.info"
|
|
|
|
# Create .mod file
|
|
echo 'module example.com/testmod
|
|
|
|
go 1.21' > "$GO_STORAGE/example.com/testmod/@v/v1.0.0.mod"
|
|
|
|
# Create list file
|
|
echo "v1.0.0" > "$GO_STORAGE/example.com/testmod/@v/list"
|
|
|
|
# Test: Go module list
|
|
check "Go list versions" \
|
|
curl -sf "$BASE/go/example.com/testmod/@v/list" -o /dev/null
|
|
|
|
# Test: Go module .info
|
|
INFO_RESULT=$(curl -sf "$BASE/go/example.com/testmod/@v/v1.0.0.info" 2>/dev/null)
|
|
if echo "$INFO_RESULT" | grep -q "v1.0.0"; then
|
|
pass "Go .info returns version"
|
|
else
|
|
fail "Go .info: $INFO_RESULT"
|
|
fi
|
|
|
|
# Test: Go module .mod
|
|
MOD_RESULT=$(curl -sf "$BASE/go/example.com/testmod/@v/v1.0.0.mod" 2>/dev/null)
|
|
if echo "$MOD_RESULT" | grep -q "module example.com/testmod"; then
|
|
pass "Go .mod returns module content"
|
|
else
|
|
fail "Go .mod: $MOD_RESULT"
|
|
fi
|
|
|
|
# Test: Go @latest (200 with upstream, 404 without — both valid)
|
|
LATEST_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/example.com/testmod/@latest")
|
|
if [ "$LATEST_CODE" = "200" ] || [ "$LATEST_CODE" = "404" ]; then
|
|
pass "Go @latest handled ($LATEST_CODE)"
|
|
else
|
|
fail "Go @latest returned $LATEST_CODE"
|
|
fi
|
|
|
|
# Test: Go path traversal rejection
|
|
TRAVERSAL_RESULT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/../../etc/passwd/@v/list")
|
|
if [ "$TRAVERSAL_RESULT" = "400" ] || [ "$TRAVERSAL_RESULT" = "404" ]; then
|
|
pass "Go path traversal rejected ($TRAVERSAL_RESULT)"
|
|
else
|
|
fail "Go path traversal returned $TRAVERSAL_RESULT"
|
|
fi
|
|
|
|
# Test: Go nonexistent module
|
|
NOTFOUND=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/go/nonexistent.com/pkg/@v/list")
|
|
if [ "$NOTFOUND" = "404" ]; then
|
|
pass "Go 404 on nonexistent module"
|
|
else
|
|
fail "Go nonexistent returned $NOTFOUND"
|
|
fi
|
|
|
|
# ============================================
|
|
# Raw Registry Extended Tests
|
|
# ============================================
|
|
echo ""
|
|
echo "=== Raw Registry (extended) ==="
|
|
|
|
# Test: Raw upload and download (basic — already exists, extend)
|
|
echo "integration-test-data-$(date +%s)" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/test.txt" >/dev/null 2>&1
|
|
check "Raw upload + download" \
|
|
curl -sf "$BASE/raw/integration/test.txt" -o /dev/null
|
|
|
|
# Test: Raw HEAD (check exists)
|
|
HEAD_RESULT=$(curl -sf -o /dev/null -w "%{http_code}" --head "$BASE/raw/integration/test.txt")
|
|
if [ "$HEAD_RESULT" = "200" ]; then
|
|
pass "Raw HEAD returns 200"
|
|
else
|
|
fail "Raw HEAD returned $HEAD_RESULT"
|
|
fi
|
|
|
|
# Test: Raw 404 on nonexistent
|
|
NOTFOUND=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/nonexistent/file.bin")
|
|
if [ "$NOTFOUND" = "404" ]; then
|
|
pass "Raw 404 on nonexistent file"
|
|
else
|
|
fail "Raw nonexistent returned $NOTFOUND"
|
|
fi
|
|
|
|
# Test: Raw path traversal
|
|
TRAVERSAL=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/../../../etc/passwd")
|
|
if [ "$TRAVERSAL" = "400" ] || [ "$TRAVERSAL" = "404" ]; then
|
|
pass "Raw path traversal rejected ($TRAVERSAL)"
|
|
else
|
|
fail "Raw path traversal returned $TRAVERSAL"
|
|
fi
|
|
|
|
# Test: Raw overwrite
|
|
echo "version-1" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1
|
|
echo "version-2" | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1
|
|
CONTENT=$(curl -sf "$BASE/raw/integration/overwrite.txt" 2>/dev/null)
|
|
if [ "$CONTENT" = "version-2" ]; then
|
|
pass "Raw overwrite works"
|
|
else
|
|
fail "Raw overwrite: got '$CONTENT'"
|
|
fi
|
|
|
|
# Test: Raw delete
|
|
curl -sf -X DELETE "$BASE/raw/integration/overwrite.txt" >/dev/null 2>&1
|
|
DELETE_CHECK=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/raw/integration/overwrite.txt")
|
|
if [ "$DELETE_CHECK" = "404" ]; then
|
|
pass "Raw delete works"
|
|
else
|
|
fail "Raw delete: file still returns $DELETE_CHECK"
|
|
fi
|
|
|
|
# Test: Raw binary data (not just text)
|
|
dd if=/dev/urandom bs=1024 count=10 2>/dev/null | curl -sf -X PUT --data-binary @- "$BASE/raw/integration/binary.bin" >/dev/null 2>&1
|
|
BIN_SIZE=$(curl -sf "$BASE/raw/integration/binary.bin" 2>/dev/null | wc -c)
|
|
if [ "$BIN_SIZE" -ge 10000 ]; then
|
|
pass "Raw binary upload/download (${BIN_SIZE} bytes)"
|
|
else
|
|
fail "Raw binary: expected ~10240, got $BIN_SIZE"
|
|
fi
|
|
echo "--- Mirror CLI ---"
|
|
# Create a minimal lockfile
|
|
LOCKFILE=$(mktemp)
|
|
cat > "$LOCKFILE" << 'EOF'
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"": { "name": "test" },
|
|
"node_modules/chalk": { "version": "5.4.1" }
|
|
}
|
|
}
|
|
EOF
|
|
MIRROR_RESULT=$("$NORA_BIN" mirror --registry "$BASE" npm --lockfile "$LOCKFILE" 2>&1)
|
|
if echo "$MIRROR_RESULT" | grep -q "Failed: 0"; then
|
|
pass "nora mirror npm --lockfile (0 failures)"
|
|
else
|
|
fail "nora mirror: $MIRROR_RESULT"
|
|
fi
|
|
rm -f "$LOCKFILE"
|
|
|
|
echo ""
|
|
echo "================================"
|
|
echo "Results: $PASSED passed, $FAILED failed"
|
|
echo "================================"
|
|
|
|
[ "$FAILED" -eq 0 ] && exit 0 || exit 1
|