diff --git a/mtproxymax.sh b/mtproxymax.sh index b0ac44b..e6f9d0b 100644 --- a/mtproxymax.sh +++ b/mtproxymax.sh @@ -9598,14 +9598,20 @@ _iso_to_epoch() { [ -z "$ts" ] && { echo "0"; return; } # Strip sub-second precision only, keep Z for UTC (e.g. 2026-03-03T10:00:00.123456789Z -> 2026-03-03T10:00:00Z) local ts_clean="${ts%%.*}" - # Restore trailing Z if original had it - [[ "$ts" == *Z ]] && ts_clean="${ts_clean}Z" + # Restore trailing Z only if stripping the fraction removed it. Doing this + # unconditionally produced "...ZZ" for the stored format (which has no fractional + # seconds), and no date accepts that: GNU rejected it and then fell through to the + # busybox branch, which GNU cannot run either, so the function returned 0 — which + # callers read as "no expiry" and skip enforcement entirely. + [[ "$ts" == *Z && "$ts_clean" != *Z ]] && ts_clean="${ts_clean}Z" local epoch # GNU date: handles ISO 8601 with Z correctly epoch=$(date -d "${ts_clean}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } - # Busybox date: strip Z, use explicit format + # Busybox date: strip Z, use explicit format, and force UTC. Under `-D` busybox + # ignores a trailing Z entirely, so without TZ=UTC the value is parsed as local time + # and the epoch is out by the offset that timestamp's own date carries. local ts_bb="${ts_clean%Z}" - epoch=$(date -D '%Y-%m-%dT%H:%M:%S' -d "${ts_bb}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } + epoch=$(TZ=UTC date -D '%Y-%m-%dT%H:%M:%S' -d "${ts_bb}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } echo "0" } @@ -11426,11 +11432,11 @@ _iso_to_epoch() { local ts="$1" [ -z "$ts" ] && { echo "0"; return; } local ts_clean="${ts%%.*}" - [[ "$ts" == *Z ]] && ts_clean="${ts_clean}Z" + [[ "$ts" == *Z && "$ts_clean" != *Z ]] && ts_clean="${ts_clean}Z" local epoch epoch=$(date -d "${ts_clean}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } local ts_bb="${ts_clean%Z}" - epoch=$(date -D '%Y-%m-%dT%H:%M:%S' -d "${ts_bb}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } + epoch=$(TZ=UTC date -D '%Y-%m-%dT%H:%M:%S' -d "${ts_bb}" +%s 2>/dev/null) && [ "$epoch" -gt 0 ] 2>/dev/null && { echo "$epoch"; return; } echo "0" } diff --git a/tests/test_iso_to_epoch.sh b/tests/test_iso_to_epoch.sh new file mode 100644 index 0000000..2e784da --- /dev/null +++ b/tests/test_iso_to_epoch.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Regression tests for _iso_to_epoch(), which parses the ISO 8601 timestamps this project +# stores for secret expiry. +# +# The stored format is "%Y-%m-%dT%H:%M:%SZ" — no fractional seconds. Two independent +# defects made the function return a wrong value for exactly that format: +# +# 1. Z duplication (affects GNU/Linux, i.e. Debian and Ubuntu). +# local ts_clean="${ts%%.*}" +# [[ "$ts" == *Z ]] && ts_clean="${ts_clean}Z" +# For a value with no fractional part, `${ts%%.*}` is a no-op, so the Z is still +# present and a second one is appended: "2026-03-03T10:00:00ZZ". GNU date rejects +# that, `date -D` does not exist on GNU, and the function falls through to `echo 0`. +# A return of 0 is read by callers as "no expiry", so `secret_check_expiry` skips +# the secret entirely and an expired key stays enabled. +# +# 2. Local-time parsing (affects Alpine/busybox). +# The busybox branch strips the Z and then parses with no TZ, so a UTC value is +# reinterpreted as local time — busybox ignores the designator outright under `-D`. +# The error equals the offset that timestamp's own date carries, so on a DST zone it +# is an hour in winter and two in summer. +# +# Both are covered below: part A forces the busybox branch with a stub, part B uses the +# real date. Neither part alone would catch both defects. +set -o pipefail + +if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then + echo "SKIP: bash 4+ required (got ${BASH_VERSION:-unknown})" >&2 + exit 0 +fi + +REAL_DATE=$(command -v date) +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TEST_TMPDIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'mtp_iso_XXXXXX') + +# A date that models busybox's actual option surface: +# - plain `-d ` -> rejected outright +# - `-D FMT -d ` -> accepted, and any trailing Z is IGNORED, so the +# value is parsed in the ambient TZ +# Parsing itself is delegated to the real date, so the ambient TZ decides the answer. +FAKEBIN="$TEST_TMPDIR/bin" +mkdir -p "$FAKEBIN" +cat >"$FAKEBIN/date" </dev/null) && + { printf '%s\n' "\$_out"; exit 0; } + exec "$REAL_DATE" -D "\$_Dfmt" -d "\${_darg%Z}" "\$_outfmt" + fi + case "\$_darg" in + *Z) + echo "date: invalid date '\$_darg'" >&2 + exit 1 + ;; + esac + exec "$REAL_DATE" -d "\$_darg" "\$_outfmt" +fi +exec "$REAL_DATE" "\$@" +DATE_EOF +chmod +x "$FAKEBIN/date" + +# POSIX TZ string, so no tzdata is needed: XXX-2 is two hours EAST of Greenwich. +export TZ="XXX-2" + +MTPROXYMAX_SOURCE_ONLY=true source "$REPO_ROOT/mtproxymax.sh" +set +e +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +TESTS_RUN=0 +TESTS_FAILED=0 +assert_eq() { + local name="$1" want="$2" got="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ "$got" = "$want" ]; then + printf ' PASS %s\n' "$name" + else + printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +TRUE_EPOCH=1772532000 # 2026-03-03T10:00:00Z, verified with `date -u -d` + +echo "ISO-8601 epoch parsing tests" + +# ---- Part A: the busybox branch, forced via the stub ------------------------------- +PATH="$FAKEBIN:$PATH" +assert_eq "stub date is the one on PATH" "$FAKEBIN/date" "$(command -v date)" + +# The stored format: no fractional seconds. Broken on every platform. +assert_eq "A: stored-format timestamp parses correctly" \ + "$TRUE_EPOCH" "$(_iso_to_epoch '2026-03-03T10:00:00Z')" +# With sub-second precision, which busybox truncates. +assert_eq "A: sub-second precision does not shift the result" \ + "$TRUE_EPOCH" "$(_iso_to_epoch '2026-03-03T10:00:00.123456789Z')" + +# ---- Part B: the real date on this host -------------------------------------------- +PATH="${PATH#"$FAKEBIN:"}" +real_date_now=$(command -v date) +assert_eq "real date restored for part B" "yes" \ + "$([ "$real_date_now" != "$FAKEBIN/date" ] && echo yes || echo no)" + +# On GNU this is where the doubled Z surfaced; on busybox it is a UTC container, so the +# timezone defect does not apply here and the parse must still be exact. +assert_eq "B: stored-format timestamp parses correctly" \ + "$TRUE_EPOCH" "$(_iso_to_epoch '2026-03-03T10:00:00Z')" +assert_eq "B: sub-second precision does not shift the result" \ + "$TRUE_EPOCH" "$(_iso_to_epoch '2026-03-03T10:00:00.123456789Z')" + +# ---- both defects at once, on a value that must not parse --------------------------- +assert_eq "an unparseable value still reports 0" "0" "$(_iso_to_epoch 'not-a-timestamp')" +assert_eq "an empty value still reports 0" "0" "$(_iso_to_epoch '')" + +# The generated daemon carries its own copy inside a heredoc, so it cannot be exercised by +# sourcing; guard it at the source level. Both copies must force UTC for the busybox branch. +assert_eq "both copies force UTC on the busybox branch" "2" \ + "$(grep -c "TZ=UTC date -D '%Y-%m-%dT%H:%M:%S'" "$REPO_ROOT/mtproxymax.sh")" + +printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED" +[ "$TESTS_FAILED" -eq 0 ]