weblib/weblib-viewer#95: every version in the published index claimed it was added at the moment of the *last* publish, so an F-Droid client's version list showed five separate builds all added within seconds of each other. Measured on the live dev repo: index-v2.json gave versionCodes 365139/365516/365666/365710/366099 the added dates 05:50:06/08/10/12/13 on 2026-09-12, while the bucket's own Last-Modified for those objects says they were actually published on 2026-09-11 at 13:53, 20:09, 22:37, 23:23 and 2026-09-12 05:50. It is not an mtime problem, and --use-date-from-apk does not fix it. fdroidserver takes the date from common.KnownApks, whose __init__ loads it out of an already existing repo/index-v2.json (and archive/index-v2.json), and whose recordapk falls back to datetime.now() for any filename it has not seen; --use-date-from-apk only changes that fallback to the local file's mtime, which for a re-downloaded APK is when it was downloaded. KnownApks' own docstring says there is nowhere else to look: the index is "currently the only way to permanently store the 'updated' date of APKs". A CI job that rebuilds repo/ on a fresh runner -- which is exactly what weblib-viewer#32's retention scheme does when it re-downloads the previous APKs -- therefore starts with an empty KnownApks, and every build in it counts as new. So hand fdroid-update.sh the previously published index, optionally, as FDROID_PREVIOUS_INDEX -- a local path, because fetching it needs to know the bucket and per fdroid-repo-ci#1 that stays on the consumer's side; this script still needs no network. preserve_added.py stages it where KnownApks looks, and afterwards *checks* that every build present in both indexes really did keep its date, failing the run if not. That is not belt-and-braces: fdroidserver's own read_added_date_from_all_apks carries a TODO about reading these dates differently, so a future version could drop the behaviour this depends on, and a silent return to flattened history is the failure mode that took a human noticing to catch the first time. Set-but-missing is a hard failure rather than a shrug, and so is a file that is not a parseable index-v2.json (a truncated download, an S3 NoSuchKey error document): KnownApks has no error handling of its own and would abort mid-library with a traceback that never names the file, and a consumer whose fetch silently produced nothing would otherwise flatten its whole history on every publish while looking healthy. A first-ever publish expresses "there is no previous index" by leaving the variable unset. Verified end to end without publishing anything, against the five real APKs and a copy of the live dev index-v2.json pulled over HTTPS: - no previous index (today's behaviour), all five re-stamped: 366099 06:23:19, 365710 06:23:18, 365666 06:23:17, 365516 06:23:15, 365139 06:23:14 -- a five-second spread, all of it "now". - previous index minus 366099, i.e. the real "four retained plus one genuinely new" case: 365139/365516/365666/365710 keep 05:50:06/08/10/12 exactly, 366099 gets 06:24:01, and lastUpdated follows the new build. - a second run on a fresh repo/ with the APK mtimes deliberately touched to now: all five unchanged, "5 already-published build(s) kept their original 'added' timestamp, 0 new". test_preserve_added.py (12 assertions, stdlib only) covers the drift detection in both directions, including the negative control that a drifted date fails; test_fdroid_update.sh covers the shell contract. run_tests.sh: 123 passed, 4 skipped, 0 failed. A weblib-viewer change is needed too -- its workflows have to fetch last publish's index and set FDROID_PREVIOUS_INDEX -- and is a separate PR in that repo. Co-authored-by: bit <bit@das-labor.org>
190 lines
9.5 KiB
Bash
Executable File
190 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# End-to-end test of fdroid-update.sh: a throwaway index-signing keystore, a
|
|
# repo/ directory holding one already-signed fixture APK (from sign-apk.sh --
|
|
# binary-repo mode never builds or app-signs anything itself), a real
|
|
# `fdroid update` resolved through with-nixpkgs.sh/nixpkgs-pin.
|
|
#
|
|
# The fixture APK's AndroidManifest.xml is not real binary AXML -- building
|
|
# one needs aapt2, i.e. the Android SDK this repo deliberately avoids
|
|
# depending on. fdroidserver's own parser (androguard) rejects it and skips
|
|
# the app ("Failed to get APK information"), which is expected and asserted
|
|
# on below rather than hidden: what this test proves is the *indexing and
|
|
# index-signing* pipeline, which is what fdroid-update.sh exists for and
|
|
# what fdroid-repo-ci#1 asks to be verified -- "assert the index exists" --
|
|
# not that fdroidserver's manifest parser accepts a hand-built fixture.
|
|
set -euo pipefail
|
|
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
source "$here/test_lib.sh"
|
|
|
|
work=$(mktemp -d)
|
|
trap 'rm -rf "$work"' EXIT
|
|
cd "$work"
|
|
mkdir -p repo
|
|
|
|
echo "generating throwaway index keystore..."
|
|
bash "$here/with-nixpkgs.sh" jdk21 keytool -genkeypair \
|
|
-keystore index.jks -storepass idxpass123 -keypass idxpass123 \
|
|
-alias indexkey -keyalg RSA -keysize 2048 -validity 1 \
|
|
-dname "CN=Index, OU=Test, O=Test, L=Test, S=Test, C=US" >/dev/null 2>&1
|
|
assert_file_exists "throwaway index keystore was created" index.jks
|
|
|
|
echo "signing a fixture APK with sign-apk.sh, to sit in repo/..."
|
|
bash "$here/with-nixpkgs.sh" jdk21 keytool -genkeypair \
|
|
-keystore app.jks -storepass apppass123 -keypass apppass123 \
|
|
-alias appkey -keyalg RSA -keysize 2048 -validity 1 \
|
|
-dname "CN=App, OU=Test, O=Test, L=Test, S=Test, C=US" >/dev/null 2>&1
|
|
bash "$here/with-nixpkgs.sh" python3 python3 - "$work" <<'PYEOF'
|
|
import struct, sys, zipfile
|
|
|
|
def add_stored(zf, name, data, align=4):
|
|
info = zipfile.ZipInfo(name)
|
|
info.compress_type = zipfile.ZIP_STORED
|
|
header_len = 30 + len(name.encode())
|
|
start = zf.fp.tell() + header_len
|
|
pad = (-start) % align
|
|
if pad:
|
|
if pad < 4:
|
|
pad += align
|
|
extra_len = pad - 4
|
|
info.extra = struct.pack("<HH", 0xD935, extra_len) + b"\x00" * extra_len
|
|
zf.writestr(info, data)
|
|
|
|
work = sys.argv[1]
|
|
with zipfile.ZipFile(f"{work}/unsigned.apk", "w") as zf:
|
|
add_stored(zf, "AndroidManifest.xml", b"0123456789")
|
|
PYEOF
|
|
export APP_KS_PASS=apppass123
|
|
bash "$here/sign-apk.sh" unsigned.apk repo/com.example.testapp_1.apk app.jks appkey APP_KS_PASS \
|
|
--min-sdk-version 21 --max-sdk-version 34 >/dev/null
|
|
assert_file_exists "fixture APK signed and placed in repo/" repo/com.example.testapp_1.apk
|
|
|
|
echo "--- fault: no repo/ directory is refused ---"
|
|
assert_failure "fdroid-update.sh refuses a root with no repo/" \
|
|
bash "$here/fdroid-update.sh" "$work/does-not-have-repo" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- fault: missing password env var is refused ---"
|
|
assert_failure "fdroid-update.sh refuses when the password env var is unset" \
|
|
env -u IDX_KS_PASS bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
export IDX_KS_PASS=idxpass123
|
|
|
|
echo "--- fault: missing FDROID_REPO_URL is refused (fdroid-repo-ci#11) ---"
|
|
assert_failure "fdroid-update.sh refuses when FDROID_REPO_URL is unset" \
|
|
env -u FDROID_REPO_URL FDROID_REPO_NAME="weblib" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- fault: missing FDROID_REPO_NAME is refused (fdroid-repo-ci#11) ---"
|
|
assert_failure "fdroid-update.sh refuses when FDROID_REPO_NAME is unset" \
|
|
env -u FDROID_REPO_NAME FDROID_REPO_URL="https://example.invalid" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- fault: FDROID_REPO_URL not ending in /repo is refused by fdroidserver itself ---"
|
|
assert_failure "fdroid-update.sh (fdroidserver) refuses a repo_url without the /repo suffix" \
|
|
env FDROID_REPO_URL="https://fdroid-repo-ci-test.example.invalid" \
|
|
FDROID_REPO_NAME="fdroid-repo-ci test repo" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- revert: a real run, needs no network ---"
|
|
export FDROID_REPO_URL="https://fdroid-repo-ci-test.example.invalid/repo"
|
|
export FDROID_REPO_NAME="fdroid-repo-ci test repo"
|
|
export FDROID_REPO_DESCRIPTION="a throwaway repo built by test_fdroid_update.sh"
|
|
assert_success "fdroid-update.sh runs fdroid update successfully" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
assert_file_exists "index-v2.json exists and is non-empty" repo/index-v2.json
|
|
assert_file_exists "index-v1.jar (signed index) exists and is non-empty" repo/index-v1.jar
|
|
assert_file_exists "entry.jar (signed entry, apksigner v1) exists and is non-empty" repo/entry.jar
|
|
|
|
echo "--- index-v2.json is valid JSON, not just a non-empty file ---"
|
|
assert_success "index-v2.json parses as JSON" \
|
|
bash "$here/with-nixpkgs.sh" python3 python3 -c "import json; json.load(open('repo/index-v2.json'))"
|
|
|
|
echo "--- index-v2.json carries the repo metadata, not fdroidserver's placeholder ---"
|
|
assert_success "repo.address is FDROID_REPO_URL, not MyFirstFDroidRepo.org" \
|
|
bash "$here/with-nixpkgs.sh" python3 python3 -c "
|
|
import json
|
|
repo = json.load(open('repo/index-v2.json'))['repo']
|
|
assert repo['address'] == 'https://fdroid-repo-ci-test.example.invalid/repo', repo['address']
|
|
assert repo['name']['en-US'] == 'fdroid-repo-ci test repo', repo['name']
|
|
assert repo['description']['en-US'] == 'a throwaway repo built by test_fdroid_update.sh', repo['description']
|
|
assert 'MyFirstFDroidRepo' not in json.dumps(repo)
|
|
"
|
|
|
|
echo "--- index-v1.jar is a validly signed jar (its own signature, not apksigner's) ---"
|
|
assert_success "jarsigner -verify accepts the signed index" \
|
|
bash "$here/with-nixpkgs.sh" jdk21 jarsigner -verify repo/index-v1.jar
|
|
|
|
echo "--- config.yml never holds a literal password ---"
|
|
assert_failure "config.yml has no literal password (uses {env: ...} instead)" \
|
|
grep -q "idxpass123" "$work/config.yml"
|
|
|
|
echo "--- a value with an embedded single quote survives config.yml round-trip ---"
|
|
export FDROID_REPO_NAME="bit's test repo"
|
|
assert_success "fdroid-update.sh accepts a name with an embedded quote" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
assert_success "the quoted name round-trips through config.yml into index-v2.json" \
|
|
bash "$here/with-nixpkgs.sh" python3 python3 -c "
|
|
import json
|
|
repo = json.load(open('repo/index-v2.json'))['repo']
|
|
assert repo['name']['en-US'] == \"bit's test repo\", repo['name']
|
|
"
|
|
export FDROID_REPO_NAME="fdroid-repo-ci test repo"
|
|
|
|
# --- FDROID_PREVIOUS_INDEX (weblib/weblib-viewer#95) ---
|
|
#
|
|
# The preservation itself is measured by test_preserve_added.py (unit) and,
|
|
# end to end, against a copy of the live dev index -- not here: this suite's
|
|
# fixture APK is deliberately not parseable by androguard (see the top of
|
|
# this file), so `fdroid update` indexes zero packages and there is no
|
|
# `added` date to preserve. What IS testable here is the part that runs
|
|
# before fdroidserver is ever reached: the contract that a set-but-wrong
|
|
# FDROID_PREVIOUS_INDEX aborts loudly rather than silently flattening every
|
|
# already-published build's history on the next publish.
|
|
|
|
echo "--- fault: FDROID_PREVIOUS_INDEX pointing at a file that does not exist is refused ---"
|
|
assert_failure "fdroid-update.sh refuses a FDROID_PREVIOUS_INDEX that does not exist" \
|
|
env FDROID_PREVIOUS_INDEX="$work/never-fetched.json" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- fault: a truncated/garbage previous index is refused before fdroid update runs ---"
|
|
printf '%s' '{"packages": {"de.example' > "$work/truncated.json"
|
|
assert_failure "fdroid-update.sh refuses a previous index that is not valid JSON" \
|
|
env FDROID_PREVIOUS_INDEX="$work/truncated.json" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- fault: valid JSON that is not an index (e.g. an S3 error document) is refused ---"
|
|
printf '%s' '{"error": "NoSuchKey"}' > "$work/notanindex.json"
|
|
assert_failure "fdroid-update.sh refuses a previous index with no top-level packages" \
|
|
env FDROID_PREVIOUS_INDEX="$work/notanindex.json" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS
|
|
|
|
echo "--- a valid previous index is staged, survives the run, and is verified afterwards ---"
|
|
# An empty-but-well-formed index is exactly the shape a channel's
|
|
# first-ever publish would leave behind, and it proves the staged file does
|
|
# not confuse `fdroid update` -- which overwrites it -- while still being
|
|
# read back for the after-the-fact check.
|
|
printf '%s' '{"repo": {}, "packages": {}}' > "$work/prev-empty.json"
|
|
out="$work/prev-empty.log"
|
|
if env FDROID_PREVIOUS_INDEX="$work/prev-empty.json" \
|
|
bash "$here/fdroid-update.sh" "$work" index.jks indexkey IDX_KS_PASS >"$out" 2>&1; then
|
|
pass "fdroid-update.sh runs with FDROID_PREVIOUS_INDEX set"
|
|
else
|
|
fail "fdroid-update.sh runs with FDROID_PREVIOUS_INDEX set"
|
|
cat "$out" >&2
|
|
fi
|
|
assert_contains "the previous index was staged for fdroidserver to read" "$out" \
|
|
"fdroid update will carry the 'added' date of 0 known build(s)"
|
|
assert_contains "the result was checked, not merely hoped for" "$out" \
|
|
"already-published build(s) kept their original 'added' timestamp"
|
|
|
|
echo "--- and the staged copy never replaces the freshly generated index ---"
|
|
assert_success "index-v2.json after the run is the new one, not the staged previous one" \
|
|
bash "$here/with-nixpkgs.sh" python3 python3 -c "
|
|
import json
|
|
index = json.load(open('repo/index-v2.json'))
|
|
assert index['repo']['address'] == 'https://fdroid-repo-ci-test.example.invalid/repo', index['repo']
|
|
"
|
|
|
|
report_and_exit
|