diff --git a/scripts/taler-monitoring/README.md b/scripts/taler-monitoring/README.md index abb55d3..878d106 100644 --- a/scripts/taler-monitoring/README.md +++ b/scripts/taler-monitoring/README.md @@ -65,6 +65,9 @@ Each check prints **global** and **grouped** ids: - **OK** green · **INFO** blue/cyan · **WARN** yellow (never red) - **ERROR** red · **BLOCKER** magenta · **PROG** cyan bar - Progress: auto estimate per phase, or `PROGRESS_TOTAL=N` / `PROGRESS_OFF=1` +- **QR (urls):** harvest `taler://` / `payto://` (+ app store `data-qr-url`) from landings and + `demo-withdraw.json` / `auto-account.json` → validate form → `qrencode` PNG → `zbarimg` + decode must match. Requires packages **`qrencode`** + **`zbar-tools`**. ## Commands diff --git a/scripts/taler-monitoring/TESTS.md b/scripts/taler-monitoring/TESTS.md index b238c3c..7bd51c1 100644 --- a/scripts/taler-monitoring/TESTS.md +++ b/scripts/taler-monitoring/TESTS.md @@ -15,7 +15,7 @@ Every check line has a **global** run number and a **grouped** id: | Area | Phase script | Groups (examples) | |------|--------------|-------------------| -| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` | +| **www** | `check_urls.sh` | `exchange` `perf` `stats` `bank` `merchant` `paivana` `landing` `qr` | | **inside** | `check_inside.sh` | `ssh` `bank` `exchange` `merchant` `caddy` `load` | | **versions** | `check_versions.sh` | `outside` `inside` `compare` | | **sanity** | `check_sanity.sh` | `bank` `exchange` `merchant` | @@ -54,6 +54,9 @@ Numbering follows **executed** checks (early skip may shift later NN inside the | **www.merchant-** | `/config` currency + currencies alt_unit_names; listed exchanges alt; webui/intro; **`/terms`**, **`/privacy`** | | **www.paivana-** | local GOA paywall front (redirect to template) | | **www.landing-** | own-stack intro links; static assets (`qrcode.min.js` hard; `og-goa-shop.png` hard only GOA/local); **demo-withdraw.json** GOA-only; shop-pay soft; cross-links local | +| **www.qr-** | QR payloads: harvest `taler://` / `payto://` / app `data-qr-url` from landings + mint JSON; **form** check; **qrencode → zbarimg** exact roundtrip; optional static QR images. Needs `qrencode` + `zbar-tools`. Skip: `QR_CHECK=0` | + +**QR rule:** `taler://withdraw/HOST/taler-integration/UUID` (no default `:443`/`:80`); `taler://pay/` / `pay-template/`; `payto://…` shape OK; decoded PNG must equal payload. Form errors on withdraw/pay/payto are **ERROR** on local/GOA. **Legal docs rule:** HTTP 200, non-empty body, not plain `not configured`, not merchant API JSON `code:21`. Local stack may require content needle (terms/privacy/FADP/GOA…). diff --git a/scripts/taler-monitoring/check_qr_payloads.py b/scripts/taler-monitoring/check_qr_payloads.py new file mode 100755 index 0000000..7cd109d --- /dev/null +++ b/scripts/taler-monitoring/check_qr_payloads.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Collect + validate QR-related payloads for taler-monitoring (www.qr-*). + +Sources (CLI paths / URLs handled by caller writing local files): + - landing HTML (href, data-qr-url, data-qr-taler, raw text) + - demo-withdraw.json / auto-account.json (taler_withdraw_uri / qr_payload) + - optional: already-decoded lines from zbarimg (kind=decoded) + +Stdout TSV: source\\tkind\\turi\\tstatus\\tdetail + kind: withdraw | pay | pay-template | refund | payto | https | other | empty + status: ok | bad + +Exit 0 always (caller tallies ok/bad lines). stderr for hard parse errors only. +""" +from __future__ import annotations + +import json +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlparse + +# Schemes we care about for wallet / settlement QRs +TALER_RE = re.compile(r"taler://[^\s\"'<>]+", re.I) +PAYTO_RE = re.compile(r"payto://[^\s\"'<>]+", re.I) +HTTPS_RE = re.compile(r"https://[^\s\"'<>]+", re.I) + +WITHDRAW_RE = re.compile( + r"^taler://withdraw/([^/]+)/taler-integration/([0-9a-fA-F-]+)/?$" +) +# order pay / template — host may include path segments after +PAY_RE = re.compile(r"^taler://pay/([^/?#]+)/", re.I) +PAY_TEMPLATE_RE = re.compile(r"^taler://pay-template/([^/?#]+)/", re.I) +REFUND_RE = re.compile(r"^taler://refund/", re.I) +PAYTO_OK_RE = re.compile(r"^payto://[a-z0-9][a-z0-9+.-]*/", re.I) + + +def looks_like_real_uri(uri: str) -> bool: + """Drop JS regex fragments, docs ellipses, incomplete placeholders.""" + if not uri or len(uri) < 12: + return False + if any(c in uri for c in "[]{}<>\n\r\t"): + return False + if "…" in uri or "..." in uri: + return False + # incomplete withdraw stubs in docs + if re.match(r"^taler://withdraw/?$", uri, re.I): + return False + if re.match(r"^taler://pay/?$", uri, re.I): + return False + # trailing junk from HTML/JS + if uri.endswith((")", "(", "\\", "^", "*", "+", "?")): + return False + if re.search(r"[\\^$*+?]", uri): + return False + return True + + +def strip_default_port_host(host: str) -> str | None: + if not host or host.startswith(":"): + return None + if ":" in host: + h, _, p = host.rpartition(":") + if not h or not p.isdigit(): + return None + if p in ("443", "80"): + return None # must be stripped for wallets + return host + + +def classify_and_validate(uri: str) -> tuple[str, str, str]: + """Return (kind, status, detail).""" + uri = (uri or "").strip() + if not uri: + return "empty", "bad", "empty" + # strip accidental trailing punctuation from HTML scrape + uri = uri.rstrip(").,;]") + low = uri.lower() + + if low.startswith("taler://withdraw/"): + m = WITHDRAW_RE.match(uri) + if not m: + return "withdraw", "bad", "shape need taler://withdraw/HOST/taler-integration/UUID" + host = m.group(1) + if strip_default_port_host(host) is None: + return "withdraw", "bad", "host/port invalid or default :443/:80 not stripped" + return "withdraw", "ok", f"host={host} id={m.group(2)[:12]}…" + + if low.startswith("taler://pay-template/"): + m = PAY_TEMPLATE_RE.match(uri) + if not m: + return "pay-template", "bad", "need taler://pay-template/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay-template", "bad", "host/port invalid or default port not stripped" + return "pay-template", "ok", f"host={host}" + + if low.startswith("taler://pay/"): + m = PAY_RE.match(uri) + if not m: + return "pay", "bad", "need taler://pay/HOST/…" + host = m.group(1) + if strip_default_port_host(host) is None: + return "pay", "bad", "host/port invalid or default port not stripped" + if "instances" not in uri and "/orders/" not in uri: + # still often valid private order URIs + return "pay", "ok", f"host={host}" + return "pay", "ok", f"host={host}" + + if low.startswith("taler://refund/"): + if not REFUND_RE.match(uri): + return "refund", "bad", "bad refund URI" + return "refund", "ok", "refund" + + if low.startswith("taler://"): + return "other", "ok", "other taler:// (accepted)" + + if low.startswith("payto://"): + if not PAYTO_OK_RE.match(uri): + return "payto", "bad", "need payto://method/…" + # wallet pay QR must not be payto for GOA demo-withdraw (checked elsewhere) + return "payto", "ok", uri.split("?", 1)[0][:72] + + if low.startswith("https://") or low.startswith("http://"): + p = urlparse(uri) + if p.scheme not in ("http", "https") or not p.netloc: + return "https", "bad", "bad URL" + return "https", "ok", p.netloc + (p.path or "/")[:40] + + return "other", "bad", "unsupported scheme" + + +class LinkHarvester(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.uris: list[tuple[str, str]] = [] # (source_hint, uri) + + def handle_starttag(self, tag, attrs): + ad = {k.lower(): (v or "") for k, v in attrs} + for key in ("href", "src", "data-qr-url", "data-qr-taler", "data-uri", "data-payto"): + if key in ad and ad[key]: + self.uris.append((f"attr:{key}", ad[key].strip())) + for k, v in ad.items(): + if k.startswith("data-") and v and ( + "taler://" in v.lower() or "payto://" in v.lower() or k.endswith("qr-url") + ): + self.uris.append((f"attr:{k}", v.strip())) + + def handle_data(self, data): + if not data or "://" not in data: + return + for m in TALER_RE.finditer(data): + self.uris.append(("text", m.group(0))) + for m in PAYTO_RE.finditer(data): + self.uris.append(("text", m.group(0))) + + +def harvest_html(path: Path, source: str) -> list[tuple[str, str, str]]: + raw = path.read_text(encoding="utf-8", errors="replace") + h = LinkHarvester() + try: + h.feed(raw) + except Exception: + pass + out: list[tuple[str, str, str]] = [] + for hint, u in h.uris: + lu = u.lower() + if lu.startswith(("taler://", "payto://", "https://", "http://")): + if looks_like_real_uri(u): + out.append((f"{source}:{hint}", u, "html")) + # raster image paths that look like QR assets (not .js) + elif re.search(r"\.(png|jpe?g|gif)(\?|$)", lu) and "qr" in lu: + out.append((f"{source}:imgpath", u, "imgpath")) + # Prefer attribute/text harvest; light regex only for complete-looking URIs in pre/code + for m in TALER_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and ( + WITHDRAW_RE.match(u) + or PAY_RE.match(u) + or PAY_TEMPLATE_RE.match(u) + or u.lower().startswith("taler://refund/") + ): + out.append((f"{source}:re", u, "html")) + for m in PAYTO_RE.finditer(raw): + u = m.group(0).rstrip(").,;]'\"") + if looks_like_real_uri(u) and PAYTO_OK_RE.match(u): + out.append((f"{source}:re", u, "html")) + return out + + +def harvest_json(path: Path, source: str) -> list[tuple[str, str, str]]: + try: + d = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except Exception as e: + return [(source, "", f"json-error:{e}")] + out: list[tuple[str, str, str]] = [] + if not isinstance(d, dict): + return out + for key in ( + "taler_withdraw_uri", + "qr_payload", + "taler_pay_uri", + "payto_uri", + "payto", + "uri", + ): + v = d.get(key) + if isinstance(v, str) and v.strip(): + out.append((f"{source}:{key}", v.strip(), "json")) + # nested + for k, v in d.items(): + if isinstance(v, str) and ( + v.startswith("taler://") or v.startswith("payto://") + ): + out.append((f"{source}:{k}", v.strip(), "json")) + return out + + +def main(argv: list[str]) -> int: + # args: pairs of source_label path + if len(argv) < 3 or len(argv) % 2 == 0: + print( + "usage: check_qr_payloads.py LABEL path [LABEL path ...]", + file=sys.stderr, + ) + return 2 + seen: set[str] = set() + rows: list[tuple[str, str, str, str, str]] = [] + i = 1 + while i < len(argv): + label, path_s = argv[i], argv[i + 1] + i += 2 + path = Path(path_s) + if not path.is_file(): + rows.append((label, "empty", "", "bad", f"missing file {path_s}")) + continue + if path.suffix.lower() in (".html", ".htm") or "html" in path.name: + items = harvest_html(path, label) + elif path.suffix.lower() == ".json" or path.name.endswith(".json"): + items = harvest_json(path, label) + else: + # plain text: one URI per line or raw + text = path.read_text(encoding="utf-8", errors="replace").strip() + items = [] + for line in text.splitlines(): + line = line.strip() + if line: + items.append((label, line, "text")) + if not items and text: + items.append((label, text, "text")) + + for src, uri, _origin in items: + if not uri: + continue + # skip pure fragment / relative without scheme for validate + if uri.startswith("#") or uri == "/": + continue + lu = uri.lower() + if lu.startswith("/") or "imgpath" in src: + continue + if not looks_like_real_uri(uri): + continue + # https: app-store / wallet / explicit data-qr-* only + if lu.startswith("http"): + if "data-qr" not in src and not any( + x in lu + for x in ( + "play.google", + "apps.apple", + "f-droid", + "wallet.taler", + "taler.net/wallet", + ) + ): + continue + if uri in seen: + continue + seen.add(uri) + kind, status, detail = classify_and_validate(uri) + rows.append((src, kind, uri, status, detail)) + + for src, kind, uri, status, detail in rows: + # TSV — uri may contain tabs rarely; replace + safe = uri.replace("\t", " ").replace("\n", " ") + print(f"{src}\t{kind}\t{safe}\t{status}\t{detail}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/taler-monitoring/check_urls.sh b/scripts/taler-monitoring/check_urls.sh index 55ed1b8..0ae5700 100755 --- a/scripts/taler-monitoring/check_urls.sh +++ b/scripts/taler-monitoring/check_urls.sh @@ -1193,4 +1193,232 @@ else warn "landing merchant shop assets" "${_ma}/2 present (soft)" fi +# --------------------------------------------------------------------------- +# QR payloads — form + encode/decode roundtrip (qrencode + zbarimg) +# Collect taler:// + payto:// (+ app-store https from data-qr-url) from landings +# and bank mint JSON; re-encode PNG; decode; require exact payload match. +# Env: QR_CHECK=0 to skip; QR_ECC=M (default) for qrencode -l +# --------------------------------------------------------------------------- +if [ "${QR_CHECK:-1}" = "1" ] && [ "${CHECK_LANDING:-1}" = "1" ]; then + set_group qr + section "www · QR payloads (form + encode/decode · taler:// payto://)" + + QR_ECC="${QR_ECC:-M}" + qr_dir="$tmp/qr-check" + mkdir -p "$qr_dir" + _qr_tools=1 + if ! command -v qrencode >/dev/null 2>&1; then + warn "qr tools" "qrencode missing — form checks only (apt install qrencode)" + _qr_tools=0 + fi + if ! command -v zbarimg >/dev/null 2>&1; then + warn "qr tools" "zbarimg missing — form checks only (apt install zbar-tools)" + _qr_tools=0 + fi + if [ "$_qr_tools" = "1" ]; then + ok "qr tools" "qrencode + zbarimg" + fi + + # Fetch sources (HTML landings + mint JSON when present) + _qr_args=() + for pair in \ + "bank-intro|$BANK_PUBLIC/intro/" \ + "merchant-intro|$MERCHANT_PUBLIC/intro/" \ + "exchange-intro|$EXCHANGE_PUBLIC/intro/" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.html" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + for pair in \ + "demo-withdraw|$BANK_PUBLIC/intro/demo-withdraw.json" \ + "auto-account|$BANK_PUBLIC/intro/auto-account.json" + do + _qn="${pair%%|*}" + _qu="${pair#*|}" + _qf="$qr_dir/${_qn}.json" + _qc=$(http_body "$_qu" "$_qf" 2>/dev/null || echo 000) + if [ "$_qc" = "200" ] && [ -s "$_qf" ]; then + _qr_args+=("$_qn" "$_qf") + fi + done + + if [ "${#_qr_args[@]}" -eq 0 ]; then + warn "qr sources" "no landing HTML / mint JSON fetched — skip payload checks" + else + _qr_tsv="$qr_dir/payloads.tsv" + if ! python3 "$ROOT/check_qr_payloads.py" "${_qr_args[@]}" >"$_qr_tsv" 2>"$qr_dir/harvest.err"; then + warn "qr harvest" "collector failed · $(tr '\n' ' ' <"$qr_dir/harvest.err" | head -c 160)" + fi + + _qr_n=0 + _qr_form_ok=0 + _qr_form_bad=0 + _qr_rt_ok=0 + _qr_rt_bad=0 + _qr_taler=0 + _qr_payto=0 + _qr_bad_sample="" + _qr_i=0 + + while IFS=$'\t' read -r src kind uri status detail || [ -n "${src:-}" ]; do + [ -n "${src:-}" ] || continue + _qr_n=$((_qr_n + 1)) + case "$kind" in + withdraw|pay|pay-template|refund|other) _qr_taler=$((_qr_taler + 1)) ;; + payto) _qr_payto=$((_qr_payto + 1)) ;; + esac + + if [ "$status" = "ok" ]; then + _qr_form_ok=$((_qr_form_ok + 1)) + else + _qr_form_bad=$((_qr_form_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }form/${kind} ${uri:0:56}" + # taler:// and payto:// form errors are hard on local/GOA + case "$kind" in + withdraw|pay|pay-template|payto) + if [ "${LOCAL_STACK:-1}" = "1" ] || [ "${EXPECT_CURRENCY:-}" = "GOA" ]; then + fail "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + else + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + fi + ;; + *) + warn "qr form ${kind}" "${src} · ${detail} · ${uri:0:80}" + ;; + esac + continue + fi + + # Encode → PNG → zbarimg (exact match) + if [ "$_qr_tools" = "1" ] && [ -n "$uri" ]; then + _qr_i=$((_qr_i + 1)) + _png="$qr_dir/p-${_qr_i}.png" + # ECC: H for wallet-style long URIs, L for long https store links, else QR_ECC + _ecc="$QR_ECC" + case "$kind" in + https) _ecc=L ;; + withdraw|pay|pay-template) _ecc=M ;; + payto) _ecc=M ;; + esac + if ! qrencode -l "$_ecc" -s 4 -m 2 -o "$_png" "$uri" 2>/dev/null; then + _qr_rt_bad=$((_qr_rt_bad + 1)) + fail "qr encode ${kind}" "qrencode failed · ${uri:0:72}" + continue + fi + _got=$(zbarimg --raw -q "$_png" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ "$_got" = "$uri" ]; then + _qr_rt_ok=$((_qr_rt_ok + 1)) + else + _qr_rt_bad=$((_qr_rt_bad + 1)) + _qr_bad_sample="${_qr_bad_sample}${_qr_bad_sample:+; }decode/${kind}" + fail "qr decode ${kind}" "zbar mismatch · want ${uri:0:48}… · got ${_got:0:48}" + fi + fi + done <"$_qr_tsv" + + # Download any obvious static QR images from bank intro and decode (soft) + if [ "$_qr_tools" = "1" ] && [ -f "$qr_dir/bank-intro.html" ]; then + python3 - "$qr_dir/bank-intro.html" "$BANK_PUBLIC" "$qr_dir" <<'PY' >"$qr_dir/img-urls.txt" 2>/dev/null || true +import re, sys +from urllib.parse import urljoin +html = open(sys.argv[1], encoding="utf-8", errors="replace").read() +base = sys.argv[2].rstrip("/") + "/" +out = sys.argv[3] +seen = set() +for m in re.finditer(r'''(?:src|href)=["']([^"']+\.(?:png|jpe?g|gif|svg))["']''', html, re.I): + u = m.group(1) + if "qr" not in u.lower() and "withdraw" not in u.lower(): + continue + if u.startswith("data:"): + continue + absu = urljoin(base + "intro/", u) + if absu in seen: + continue + seen.add(absu) + print(absu) +PY + _img_n=0 + _img_ok=0 + while read -r _img_url; do + [ -n "$_img_url" ] || continue + _img_n=$((_img_n + 1)) + _imgf="$qr_dir/img-${_img_n}.bin" + _ic=$(http_body "$_img_url" "$_imgf" 2>/dev/null || echo 000) + if [ "$_ic" != "200" ] || [ ! -s "$_imgf" ]; then + warn "qr image fetch" "HTTP ${_ic} · ${_img_url}" + continue + fi + # zbarimg needs image format; skip non-raster + if ! file "$_imgf" 2>/dev/null | grep -qiE 'PNG|JPEG|PBM|PGM|PPM|GIF'; then + info "qr image skip" "not raster · ${_img_url}" + continue + fi + _dec=$(zbarimg --raw -q "$_imgf" 2>/dev/null | head -n1 | tr -d '\r' || true) + if [ -z "$_dec" ]; then + warn "qr image decode" "no barcode · ${_img_url}" + continue + fi + _img_ok=$((_img_ok + 1)) + # validate decoded payload form + printf '%s\n' "$_dec" >"$qr_dir/img-decoded.txt" + _line=$(python3 "$ROOT/check_qr_payloads.py" "img" "$qr_dir/img-decoded.txt" 2>/dev/null | head -1 || true) + if [ -n "$_line" ]; then + _st=$(printf '%s' "$_line" | cut -f4) + _kd=$(printf '%s' "$_line" | cut -f2) + if [ "$_st" = "ok" ]; then + ok "qr image ${_kd}" "${_dec:0:64}" + else + case "$_kd" in + withdraw|pay|pay-template|payto) + fail "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + *) + warn "qr image ${_kd}" "bad form · ${_dec:0:72}" + ;; + esac + fi + else + ok "qr image decode" "${_dec:0:64}" + fi + done <"$qr_dir/img-urls.txt" + if [ "$_img_n" -gt 0 ]; then + info "qr images" "fetched ${_img_n} · decoded ${_img_ok}" + fi + fi + + # Summaries + if [ "$_qr_n" -eq 0 ]; then + warn "qr payloads" "none found in landings/mint JSON" + else + if [ "$_qr_form_bad" -eq 0 ]; then + ok "qr form" "${_qr_form_ok}/${_qr_n} ok · taler=${_qr_taler} payto=${_qr_payto}" + else + if [ "${LOCAL_STACK:-1}" = "1" ]; then + fail "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad${_qr_bad_sample:+ · $_qr_bad_sample}" + else + warn "qr form" "${_qr_form_ok}/${_qr_n} ok · ${_qr_form_bad} bad" + fi + fi + if [ "$_qr_tools" = "1" ]; then + if [ "$_qr_rt_bad" -eq 0 ] && [ "$_qr_rt_ok" -gt 0 ]; then + ok "qr encode/decode" "${_qr_rt_ok} roundtrips (ecc default ${QR_ECC})" + elif [ "$_qr_rt_ok" -eq 0 ] && [ "$_qr_rt_bad" -eq 0 ]; then + info "qr encode/decode" "no payloads to encode" + else + fail "qr encode/decode" "${_qr_rt_ok} ok · ${_qr_rt_bad} fail" + fi + fi + fi + fi +else + if [ "${QR_CHECK:-1}" != "1" ]; then + info "qr checks" "skipped (QR_CHECK=0)" + fi +fi + summary diff --git a/scripts/taler-monitoring/taler-monitoring.sh b/scripts/taler-monitoring/taler-monitoring.sh index 04af0b9..4eb90b2 100755 --- a/scripts/taler-monitoring/taler-monitoring.sh +++ b/scripts/taler-monitoring/taler-monitoring.sh @@ -59,6 +59,8 @@ Env (same meaning): NO_COLOR=1 / CLICOLOR=0 disable green/yellow/red tags (default: coloured) SKIP_SSH=1 NO_COLOR=1 PERF_WARN_MS PERF_FAIL_MS (urls latency; default 8000 / 20000) + QR_CHECK=0 skip QR form + qrencode/zbarimg (urls phase) + QR_ECC=M qrencode ECC level (default M) KOOPA_SSH KOOPA_SSH_FALLBACKS (default koopa → koopa-external) METRICS_LOAD=0 skip host/container RAM/CPU probes (e2e/ladder/inside) EOF @@ -224,7 +226,7 @@ if [ "${PROGRESS_TOTAL:-0}" = "0" ] || [ -z "${PROGRESS_TOTAL:-}" ]; then _pt=0 for p in "${PHASES[@]}"; do case "$p" in - urls) _pt=$((_pt + 55)) ;; + urls) _pt=$((_pt + 70)) ;; inside) _pt=$((_pt + 25)) ;; versions) _pt=$((_pt + 20)) ;; sanity) _pt=$((_pt + 30)) ;;