publish.sh: replace the test-only eval hook with --files-only/--index-only
bit's review on PR #7 (comment on the PR, appended to this reasoning): run_test_hook() eval'd an environment variable in publish.sh, which runs with the release signing key and S3 credentials in its environment, in a public repo. "Never set by a real caller" was true, but it was still a documented, discoverable path from any env-var influence to arbitrary code execution at the worst possible moment -- unnecessary, which is the stronger reason to remove it than any claim about likelihood. The replacement is a feature rather than a seam: --files-only and --index-only split the two passes into separately invokable steps. With neither flag the default is unchanged (both passes, files first). This makes the ordering guarantee testable by construction -- run --files-only, assert the bucket state, run --index-only, assert again -- with no hook, no eval, and no test-only branch in production code. It is also independently useful: an interrupted publish can resume at the index step without re-uploading every APK, and a caller can stage files now and publish the index later. The APK-existence check is skipped entirely for a plain --index-only invocation, since that pass can never upload a *.apk. Re-tested end to end: offline 91 passed / 4 skipped / 0 failed (up from 83/ 3/0, mostly new flag-construction and stub assertions); live against fdroid-weblib-dev 104 passed / 0 skipped / 0 failed, bucket verified empty before and after. Added a live fault-injection test that --index-only alone against a prefix --files-only never touched really does publish an index naming an absent file -- the exact state the ordering guarantee exists to prevent -- contrasted with the default path, which never reaches it. Offline, removing the run_files/run_index gating (simulating the flags being ignored) was confirmed to fail 5 of the new assertions before being reverted. Left the versionCode granularity as minutes, per instruction: that is being settled separately on weblib-viewer#15 and is a one-constant change for whenever it lands, not something to pre-empt here. Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
+177
-56
@@ -5,8 +5,9 @@
|
||||
# a genuine bucket is out of scope for most of this file. What this asserts:
|
||||
#
|
||||
# * argument/credential validation, offline;
|
||||
# * `--dry-run` builds the exact two commands (files, then index) a real run
|
||||
# would use, in that order, offline;
|
||||
# * `--dry-run` builds the exact command(s) a real run would use for
|
||||
# whichever of --files-only/--index-only (or neither -> both, files then
|
||||
# index) is selected, offline;
|
||||
# * a real invocation against a closed local port fails on the network call
|
||||
# itself -- during the credential check specifically, before any upload is
|
||||
# attempted -- proving both that the constructed command is well-formed
|
||||
@@ -14,12 +15,19 @@
|
||||
# * a real invocation against a tiny local HEAD-only S3 stub (test_s3_stub.py)
|
||||
# proves the credential check is a genuine authenticated call (a wrong
|
||||
# bucket is rejected, not waved through because a variable was merely
|
||||
# non-empty) and that an already-published APK is refused before any
|
||||
# upload -- and, as a negative control, that a *non*-colliding APK does
|
||||
# NOT trip the same check (fdroid-repo-ci#6);
|
||||
# non-empty), that an already-published APK is refused before any upload
|
||||
# (and that --index-only alone never runs that check at all, since it
|
||||
# cannot upload an APK) -- and, as a negative control, that a
|
||||
# *non*-colliding APK does NOT trip the same check (fdroid-repo-ci#6);
|
||||
# * the opt-in live round trip against a real bucket, extended to prove the
|
||||
# two-pass ordering with real uploads (not just command construction) and
|
||||
# the APK-collision refusal against a real object.
|
||||
# two-pass ordering **by construction**: `--files-only` then `--index-only`
|
||||
# as two genuinely separate invocations, with the bucket state asserted in
|
||||
# between -- no synchronization hook, no `eval` of anything in this public
|
||||
# repo's production script -- plus the APK-collision refusal against a
|
||||
# real object, and a deliberate-misuse case proving `--index-only` alone
|
||||
# really does produce the exact state the ordering guarantee exists to
|
||||
# prevent (an index naming a file that is not there), which the *default*
|
||||
# path never reaches.
|
||||
set -euo pipefail
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
@@ -102,6 +110,33 @@ echo "--- --dry-run with no prefix omits the trailing slash entirely ---"
|
||||
out_noprefix=$(bash "$here/publish.sh" --dry-run "$work/repo" mybucket https://s3.example.invalid)
|
||||
assert_contains "no-prefix dry-run targets the bucket root" "$out_noprefix" "s3://mybucket "
|
||||
|
||||
echo "--- --files-only and --index-only each print exactly one command, and are mutually exclusive ---"
|
||||
out_files_only=$(bash "$here/publish.sh" --dry-run --files-only "$work/repo" mybucket https://s3.example.invalid myprefix)
|
||||
n_files_only=$(echo "$out_files_only" | grep -c 's3 sync' || true)
|
||||
if [ "$n_files_only" -eq 1 ]; then
|
||||
pass "--files-only prints exactly one sync command"
|
||||
else
|
||||
fail "--files-only prints exactly one sync command (got $n_files_only)"
|
||||
fi
|
||||
assert_contains "the --files-only command still excludes index-shaped names" "$out_files_only" "--exclude index"
|
||||
if echo "$out_files_only" | grep -q -- "--include"; then
|
||||
fail "--files-only has no --include (it did!)"
|
||||
else
|
||||
pass "--files-only has no --include"
|
||||
fi
|
||||
|
||||
out_index_only=$(bash "$here/publish.sh" --dry-run --index-only "$work/repo" mybucket https://s3.example.invalid myprefix)
|
||||
n_index_only=$(echo "$out_index_only" | grep -c 's3 sync' || true)
|
||||
if [ "$n_index_only" -eq 1 ]; then
|
||||
pass "--index-only prints exactly one sync command"
|
||||
else
|
||||
fail "--index-only prints exactly one sync command (got $n_index_only)"
|
||||
fi
|
||||
assert_contains "the --index-only command still includes index-shaped names" "$out_index_only" "--include index"
|
||||
|
||||
assert_failure "publish.sh refuses --files-only together with --index-only" \
|
||||
bash "$here/publish.sh" --dry-run --files-only --index-only "$work/repo" mybucket https://s3.example.invalid
|
||||
|
||||
echo "--- a real invocation fails on the network call, not on arguments -- and fails during credential verification, before any upload ---"
|
||||
# port 1 is a reserved, unassigned TCP port -- nothing listens there, so this
|
||||
# never leaves the loopback interface and needs no real network access.
|
||||
@@ -188,9 +223,29 @@ if [ "$have_python" = true ]; then
|
||||
# once it gets there -- the only thing this proves is that the collision
|
||||
# check itself did not false-positive and let the script proceed past it.
|
||||
assert_contains "with nothing pre-existing, publish.sh proceeds to pass 1" "$out" "pass 1/2"
|
||||
|
||||
echo "--- --index-only alone never runs the APK-collision check at all (it cannot upload one) ---"
|
||||
# Same colliding key pre-seeded as the refusal test above, but this time
|
||||
# asking only for the index pass: the check must not even run, since
|
||||
# index_only_filters can never select a *.apk, so there is nothing it
|
||||
# could refuse. Proceeding to "pass 2/2" (and failing there, since this
|
||||
# stub has no sync support) is exactly the proof: a version of publish.sh
|
||||
# that ran the check regardless of the flags would instead refuse here,
|
||||
# before ever printing "pass 2/2".
|
||||
start_stub mybucket "myprefix/de.example.app_1.apk"
|
||||
out=$(bash "$here/publish.sh" --index-only "$collide_dir" mybucket "http://127.0.0.1:$stub_port" myprefix 2>&1) && rc=0 || rc=$?
|
||||
kill "$stub_pid" 2>/dev/null || true
|
||||
wait "$stub_pid" 2>/dev/null || true
|
||||
assert_contains "--index-only proceeds straight to pass 2/2, skipping the APK check" "$out" "pass 2/2"
|
||||
if echo "$out" | grep -q "already exists"; then
|
||||
fail "--index-only must not run the APK-collision check (it refused anyway!)"
|
||||
else
|
||||
pass "--index-only does not run the APK-collision check"
|
||||
fi
|
||||
else
|
||||
skip "credential-check-is-real / APK-collision-refusal (no python3 available, even via with-nixpkgs.sh)"
|
||||
skip "negative control: non-colliding APK does not trip the check"
|
||||
skip "--index-only never runs the APK-collision check"
|
||||
fi
|
||||
|
||||
# --- live round trip, opt-in ------------------------------------------------
|
||||
@@ -223,63 +278,64 @@ if [ -n "${FDROID_CI_LIVE_BUCKET:-}" ] && [ -n "${FDROID_CI_LIVE_ENDPOINT:-}" ]
|
||||
rm -rf "$live_dir"
|
||||
}
|
||||
|
||||
echo "--- upload ordering: index must not exist while the file already does (the hard part -- see below) ---"
|
||||
object_exists() {
|
||||
# object_exists <key> -- a real head-object against the live bucket.
|
||||
bash "$here/with-nixpkgs.sh" awscli2 aws --endpoint-url "$FDROID_CI_LIVE_ENDPOINT" \
|
||||
--region "${LIVE_REGION:-us-east-1}" \
|
||||
s3api head-object --bucket "$FDROID_CI_LIVE_BUCKET" --key "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "--- upload ordering by construction: --files-only, assert, then --index-only, assert (the hard part -- see below) ---"
|
||||
# This is the one property the offline suite structurally cannot see: it
|
||||
# is about the order two *real* uploads land in, which only a real bucket
|
||||
# can answer. Racing the check in from outside against a real network call
|
||||
# is exactly the flakiness fdroid-repo-ci#5 warned off ("worth building the
|
||||
# check into the script's own output rather than trying to catch a race
|
||||
# from outside"), so instead this uses the synchronization hook publish.sh
|
||||
# exposes for tests (PUBLISH_SH_TEST_HOOK_BETWEEN_PASSES): a real HEAD
|
||||
# against the real bucket runs from *inside* publish.sh's own process,
|
||||
# at the one moment guaranteed to be after pass 1 (files) finished and
|
||||
# before pass 2 (index) started -- not a poll racing a background upload.
|
||||
order_result=$(mktemp)
|
||||
hook_script=$(mktemp)
|
||||
cat > "$hook_script" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
aws=(bash "$FDROID_HOOK_HERE/with-nixpkgs.sh" awscli2 aws \
|
||||
--endpoint-url "$FDROID_HOOK_ENDPOINT" --region "${FDROID_HOOK_REGION:-us-east-1}")
|
||||
file_present=0
|
||||
index_absent=0
|
||||
if "${aws[@]}" s3api head-object --bucket "$FDROID_HOOK_BUCKET" \
|
||||
--key "$FDROID_HOOK_STAMP/probe.txt" >/dev/null 2>&1; then
|
||||
file_present=1
|
||||
fi
|
||||
if ! "${aws[@]}" s3api head-object --bucket "$FDROID_HOOK_BUCKET" \
|
||||
--key "$FDROID_HOOK_STAMP/index-v2.json" >/dev/null 2>&1; then
|
||||
index_absent=1
|
||||
fi
|
||||
echo "file_present=$file_present index_absent=$index_absent" > "$FDROID_HOOK_RESULT_FILE"
|
||||
HOOK
|
||||
|
||||
FDROID_HOOK_HERE="$here" FDROID_HOOK_ENDPOINT="$FDROID_CI_LIVE_ENDPOINT" \
|
||||
FDROID_HOOK_REGION="${LIVE_REGION:-us-east-1}" FDROID_HOOK_BUCKET="$FDROID_CI_LIVE_BUCKET" \
|
||||
FDROID_HOOK_STAMP="$stamp" FDROID_HOOK_RESULT_FILE="$order_result" \
|
||||
PUBLISH_SH_TEST_HOOK_BETWEEN_PASSES="bash '$hook_script'" \
|
||||
bash "$here/publish.sh" "$live_dir" "$FDROID_CI_LIVE_BUCKET" \
|
||||
"$FDROID_CI_LIVE_ENDPOINT" "$stamp" >/tmp/publish_live_out.$$ 2>&1
|
||||
publish_rc=$?
|
||||
if [ "$publish_rc" -eq 0 ]; then
|
||||
pass "publish.sh uploaded to a real bucket"
|
||||
# can answer. Racing a check in from outside against one real invocation
|
||||
# doing both passes is exactly the flakiness fdroid-repo-ci#5 warned off
|
||||
# ("worth building the check into the script's own output rather than
|
||||
# trying to catch a race from outside") -- and, per review on #7, doing
|
||||
# that with an env-var-triggered `eval` synchronization hook inside
|
||||
# publish.sh was its own problem in a public script that runs with a
|
||||
# signing key and S3 credentials in its environment. --files-only and
|
||||
# --index-only remove the need for either: they are two genuinely separate
|
||||
# invocations, so the bucket can be inspected for real, in between, with
|
||||
# nothing racing and nothing evaluated.
|
||||
bash "$here/publish.sh" --files-only "$live_dir" "$FDROID_CI_LIVE_BUCKET" \
|
||||
"$FDROID_CI_LIVE_ENDPOINT" "$stamp" >/tmp/publish_live_out.$$ 2>&1
|
||||
files_only_rc=$?
|
||||
if [ "$files_only_rc" -eq 0 ]; then
|
||||
pass "--files-only uploads to a real bucket"
|
||||
else
|
||||
fail "publish.sh uploaded to a real bucket (exit $publish_rc)"
|
||||
fail "--files-only uploads to a real bucket (exit $files_only_rc)"
|
||||
cat /tmp/publish_live_out.$$ >&2
|
||||
fi
|
||||
rm -f /tmp/publish_live_out.$$
|
||||
|
||||
if [ -s "$order_result" ]; then
|
||||
order_line=$(cat "$order_result")
|
||||
if echo "$order_line" | grep -q "file_present=1 index_absent=1"; then
|
||||
pass "mid-publish (real bucket): the file was already there and the index was not ($order_line)"
|
||||
else
|
||||
fail "mid-publish (real bucket): the file was already there and the index was not (got: $order_line)"
|
||||
fi
|
||||
if object_exists "$stamp/probe.txt"; then
|
||||
pass "after --files-only alone: the file object exists"
|
||||
else
|
||||
fail "the between-passes hook never ran against the real bucket -- ordering was not actually observed"
|
||||
fail "after --files-only alone: the file object exists"
|
||||
fi
|
||||
if object_exists "$stamp/index-v2.json"; then
|
||||
fail "after --files-only alone: the index object must not exist yet (it does!)"
|
||||
else
|
||||
pass "after --files-only alone: the index object does not exist yet"
|
||||
fi
|
||||
|
||||
bash "$here/publish.sh" --index-only "$live_dir" "$FDROID_CI_LIVE_BUCKET" \
|
||||
"$FDROID_CI_LIVE_ENDPOINT" "$stamp" >/tmp/publish_live_out2.$$ 2>&1
|
||||
index_only_rc=$?
|
||||
if [ "$index_only_rc" -eq 0 ]; then
|
||||
pass "--index-only uploads to a real bucket"
|
||||
else
|
||||
fail "--index-only uploads to a real bucket (exit $index_only_rc)"
|
||||
cat /tmp/publish_live_out2.$$ >&2
|
||||
fi
|
||||
rm -f /tmp/publish_live_out2.$$
|
||||
|
||||
if object_exists "$stamp/index-v2.json"; then
|
||||
pass "after --index-only resumes: the index object now exists too"
|
||||
else
|
||||
fail "after --index-only resumes: the index object now exists too"
|
||||
fi
|
||||
rm -f "$order_result" "$hook_script"
|
||||
|
||||
got=$(bash "$here/with-nixpkgs.sh" awscli2 aws --endpoint-url "$FDROID_CI_LIVE_ENDPOINT" \
|
||||
s3 cp "s3://$FDROID_CI_LIVE_BUCKET/$stamp/probe.txt" - 2>/dev/null || true)
|
||||
@@ -296,6 +352,70 @@ HOOK
|
||||
fail "the uploaded index object (pass 2) reads back byte-identical"
|
||||
fi
|
||||
|
||||
echo "--- fault injection: --index-only alone (misuse) really does produce the dangerous state; the default never does ---"
|
||||
# The property that actually matters: --index-only in isolation, against a
|
||||
# prefix that has never had --files-only run against it, must produce
|
||||
# exactly the state the whole ordering guarantee exists to prevent -- an
|
||||
# index naming a file that 404s. If it did not, the two flags would not be
|
||||
# a faithful decomposition of the default and the "by construction" claim
|
||||
# above would be empty.
|
||||
danger_dir=$(mktemp -d)
|
||||
danger_stamp="live-danger-$(date -u +%s)-$$"
|
||||
printf 'publish.sh live probe %s\n' "$danger_stamp" > "$danger_dir/probe.txt"
|
||||
printf '{"probe":"%s"}\n' "$danger_stamp" > "$danger_dir/index-v2.json"
|
||||
|
||||
bash "$here/publish.sh" --index-only "$danger_dir" "$FDROID_CI_LIVE_BUCKET" \
|
||||
"$FDROID_CI_LIVE_ENDPOINT" "$danger_stamp" >/tmp/publish_danger.$$ 2>&1
|
||||
danger_rc=$?
|
||||
if [ "$danger_rc" -ne 0 ]; then
|
||||
fail "misuse setup: --index-only alone uploads to a real bucket (exit $danger_rc)"
|
||||
cat /tmp/publish_danger.$$ >&2
|
||||
fi
|
||||
rm -f /tmp/publish_danger.$$
|
||||
|
||||
if object_exists "$danger_stamp/index-v2.json"; then
|
||||
pass "misuse (--index-only alone) really does publish an index"
|
||||
else
|
||||
fail "misuse (--index-only alone) really does publish an index"
|
||||
fi
|
||||
if object_exists "$danger_stamp/probe.txt"; then
|
||||
fail "misuse (--index-only alone) must leave the file it names absent (it does not!)"
|
||||
else
|
||||
pass "misuse (--index-only alone) leaves an index naming a file that 404s -- exactly the state the ordering guarantee exists to prevent"
|
||||
fi
|
||||
|
||||
bash "$here/with-nixpkgs.sh" awscli2 aws --endpoint-url "$FDROID_CI_LIVE_ENDPOINT" \
|
||||
s3 rm --recursive "s3://$FDROID_CI_LIVE_BUCKET/$danger_stamp/" >/dev/null 2>&1 || true
|
||||
rm -rf "$danger_dir"
|
||||
|
||||
# Contrast: the default (neither flag, one invocation) must never reach
|
||||
# that state. There is deliberately no mid-flight observation point for a
|
||||
# single default invocation any more (that was the hook this PR removes),
|
||||
# so this is not re-proven by racing a check against it -- it holds by
|
||||
# construction instead: the default's first phase runs the *same command
|
||||
# array*, built the same way, as --files-only above, so its effect on the
|
||||
# bucket is identical -- files land, and only then does the index command
|
||||
# run. There is no code path through publish.sh that reaches "index
|
||||
# present, file absent" without --index-only having been invoked alone,
|
||||
# which is opt-in and never the default. What is left to check here is
|
||||
# only the default's own final state.
|
||||
safe_dir=$(mktemp -d)
|
||||
safe_stamp="live-safe-$(date -u +%s)-$$"
|
||||
printf 'publish.sh live probe %s\n' "$safe_stamp" > "$safe_dir/probe.txt"
|
||||
printf '{"probe":"%s"}\n' "$safe_stamp" > "$safe_dir/index-v2.json"
|
||||
bash "$here/publish.sh" "$safe_dir" "$FDROID_CI_LIVE_BUCKET" \
|
||||
"$FDROID_CI_LIVE_ENDPOINT" "$safe_stamp" >/tmp/publish_safe.$$ 2>&1
|
||||
safe_rc=$?
|
||||
rm -f /tmp/publish_safe.$$
|
||||
if [ "$safe_rc" -eq 0 ] && object_exists "$safe_stamp/probe.txt" && object_exists "$safe_stamp/index-v2.json"; then
|
||||
pass "the default path (neither flag) never leaves an index without its file: both are present after one call"
|
||||
else
|
||||
fail "the default path (neither flag) never leaves an index without its file"
|
||||
fi
|
||||
bash "$here/with-nixpkgs.sh" awscli2 aws --endpoint-url "$FDROID_CI_LIVE_ENDPOINT" \
|
||||
s3 rm --recursive "s3://$FDROID_CI_LIVE_BUCKET/$safe_stamp/" >/dev/null 2>&1 || true
|
||||
rm -rf "$safe_dir"
|
||||
|
||||
echo "--- APK-collision refusal against a real, already-published object ---"
|
||||
apk_stamp="live-apk-$(date -u +%s)-$$"
|
||||
apk_key="$apk_stamp/de.example.app_1.apk"
|
||||
@@ -332,7 +452,8 @@ HOOK
|
||||
cleanup_live
|
||||
else
|
||||
skip "live round trip (set FDROID_CI_LIVE_BUCKET, FDROID_CI_LIVE_ENDPOINT and AWS credentials)"
|
||||
skip "live upload ordering (file present, index absent mid-publish)"
|
||||
skip "live upload ordering by construction (--files-only then --index-only)"
|
||||
skip "live fault injection: --index-only misuse vs. the default"
|
||||
skip "live APK-collision refusal against a real object"
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user