next-version-code.sh: derive the candidate from the commit, not the build clock
fdroid-repo-ci#6's "Superseded" sections change the rule: the candidate is now the committer date of the commit being built, clamped on both sides, rather than whatever minute the runner happened to pick the job up in. Backwards: rebase, amend and cherry-pick can produce a commit older than one already published, so the clamp keys off the committer date, not the author date -- author date survives a rebase unchanged and would replay a stale, already-superseded number. Forwards: a commit's timestamp is attacker- and typo-controlled and the number only ever rises, so a bad one can never be walked back. A value placed just below the int32 ceiling would still publish and then no later build could ever exceed it -- the app becomes permanently un-updatable. TOLERANCE absorbs honest clock skew and the clamp warns loudly rather than rejecting, since rejecting would block a legitimate build over a few minutes of skew while buying nothing the clamp doesn't already cover. next_version_code.py now takes the committer timestamp as a third argument (Unix seconds, what `git log -1 --format=%ct` prints) instead of reading the system clock, keeping it pure and testable without a git checkout. next-version-code.sh, README.md and action.yml updated to match. Granularity stays minutes: concurrency is handled separately (a workflow-level concurrency group, plus publish.sh's refuse-on-existing), so it is now a pure headroom choice. Tests (test_next_version_code.py, test_next_version_code.sh): a normal commit yields its own minute, a commit older than the published maximum still yields published+1, a future-dated commit within tolerance passes through untouched while one beyond it is clamped and warns, a commit dated near the int32 ceiling does not produce a near-int32 result, and a dedicated property test checks the result always strictly exceeds every versionCode in the fixture across all of the above. Each was verified by injecting the corresponding fault (removing the backwards clamp, removing the forwards clamp, and ignoring the commit's date entirely) and confirming the relevant test failed, then reverting. 98 assertions offline (0 failed, 4 skipped -- pre-existing, live-bucket only), up from 91. Co-authored-by: bit <bit@das-labor.org>
This commit is contained in:
+150
-52
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline tests for next_version_code.py (fdroid-repo-ci#6).
|
||||
"""Offline tests for next_version_code.py (fdroid-repo-ci#6, including its
|
||||
"Superseded" sections: the candidate is derived from the COMMITTER date of
|
||||
the commit being built, clamped on both sides, not from the build clock).
|
||||
|
||||
Standard library only, no network, no nix -- like test_check_zip_alignment.py,
|
||||
this is the piece of the change that needs none of the pinned toolchain.
|
||||
@@ -12,15 +14,20 @@ itself part of the contract (README/issue #6: a caller does
|
||||
`versionCode=$(next-version-code.sh ...)`, so stray stdout output would be
|
||||
silently captured as part of the number).
|
||||
|
||||
"now" is pinned via the NEXT_VERSION_CODE_NOW testing seam (see the script's
|
||||
main()) so `candidate` is a fixed, known integer instead of whatever minute
|
||||
the suite happens to run in.
|
||||
Two clocks are pinned independently, deliberately kept distinct so a test
|
||||
can move either one without the other:
|
||||
|
||||
The property every case after the first two actually defends -- and the one
|
||||
a future refactor could quietly break while still "looking" correct -- is
|
||||
that the result is *strictly greater than every versionCode already in the
|
||||
fixture*, not merely equal to some formula. Each clamp case asserts that
|
||||
directly, not just the arithmetic.
|
||||
* the COMMIT's committer date -- the third CLI argument, exactly as a real
|
||||
caller passes `git log -1 --format=%ct`;
|
||||
* "now" -- the runner's clock, used only for the forwards clamp, pinned via
|
||||
the NEXT_VERSION_CODE_NOW testing seam (see the script's main()).
|
||||
|
||||
The property that actually matters -- and the one a future refactor could
|
||||
quietly break while still "looking" correct -- is that the result is
|
||||
*strictly greater than every versionCode already in the fixture*, not
|
||||
merely equal to some formula. Every clamp case below asserts that directly,
|
||||
not just the arithmetic, and test_property_result_exceeds_every_published_code
|
||||
checks it again across every scenario in one place.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
@@ -35,11 +42,21 @@ SCRIPT = os.path.join(HERE, "next_version_code.py")
|
||||
APP_ID = "de.example.app"
|
||||
|
||||
EPOCH = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
# An arbitrary, fixed "now": 100 minutes after the epoch this script uses,
|
||||
# so the expected candidate (100) is known without re-deriving the formula
|
||||
# under test.
|
||||
FIXED_NOW = (EPOCH + datetime.timedelta(minutes=100)).isoformat()
|
||||
CANDIDATE_AT_FIXED_NOW = 100
|
||||
TOLERANCE_MINUTES = 6 * 60 # must track next_version_code.py's TOLERANCE_MINUTES
|
||||
INT32_MAX = 2**31 - 1
|
||||
|
||||
# "Now" is pinned at minute 100 after the epoch throughout, unless a test
|
||||
# overrides it -- an arbitrary, fixed instant so expected candidates are
|
||||
# known integers instead of whatever minute the suite happens to run in.
|
||||
NOW_MINUTES = 100
|
||||
NOW_ISO = (EPOCH + datetime.timedelta(minutes=NOW_MINUTES)).isoformat()
|
||||
|
||||
|
||||
def minutes_to_unix_seconds(minutes):
|
||||
"""Convert "minutes since the 2026 epoch" (what the script's candidate
|
||||
is expressed in) to Unix seconds (what its CLI argument, and real
|
||||
`git log -1 --format=%ct`, actually take)."""
|
||||
return int((EPOCH + datetime.timedelta(minutes=minutes)).timestamp())
|
||||
|
||||
|
||||
def index_with_versions(application_id, version_codes):
|
||||
@@ -66,66 +83,65 @@ class NextVersionCodeTest(unittest.TestCase):
|
||||
json.dump(obj_or_text, f)
|
||||
return path
|
||||
|
||||
def _run(self, index_path, application_id=APP_ID, now=FIXED_NOW):
|
||||
def _run(self, index_path, committer_minutes, application_id=APP_ID,
|
||||
now_iso=NOW_ISO):
|
||||
env = dict(os.environ)
|
||||
if now is not None:
|
||||
env["NEXT_VERSION_CODE_NOW"] = now
|
||||
if now_iso is not None:
|
||||
env["NEXT_VERSION_CODE_NOW"] = now_iso
|
||||
else:
|
||||
env.pop("NEXT_VERSION_CODE_NOW", None)
|
||||
committer_ts = str(minutes_to_unix_seconds(committer_minutes))
|
||||
return subprocess.run(
|
||||
[sys.executable, SCRIPT, index_path, application_id],
|
||||
[sys.executable, SCRIPT, index_path, application_id, committer_ts],
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
|
||||
# -- no prior build: the first-ever publish for a channel -------------
|
||||
# -- a normal commit yields its own minute -----------------------------
|
||||
|
||||
def test_no_prior_build_yields_bare_candidate(self):
|
||||
def test_normal_commit_yields_its_own_minute(self):
|
||||
# Committer date deliberately different from "now" (250 vs 100), and
|
||||
# well inside tolerance, to prove the result tracks the COMMIT, not
|
||||
# the clock the script happens to run under.
|
||||
path = self._write_index("empty.json", {"packages": {}})
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=250)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, f"{CANDIDATE_AT_FIXED_NOW}\n")
|
||||
self.assertEqual(result.stdout, "250\n")
|
||||
self.assertEqual(result.stderr, "") # no warning: well within tolerance
|
||||
|
||||
def test_application_id_missing_entirely_is_not_an_error(self):
|
||||
path = self._write_index(
|
||||
"other-app.json", index_with_versions("some.other.app", [5]))
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, f"{CANDIDATE_AT_FIXED_NOW}\n")
|
||||
self.assertEqual(result.stdout, "100\n")
|
||||
|
||||
# -- the clamp: result must exceed every published code ----------------
|
||||
# -- backwards: a commit older than published still exceeds it ---------
|
||||
|
||||
def test_lower_prior_is_ignored_in_favour_of_the_clock(self):
|
||||
path = self._write_index("lower.json", index_with_versions(APP_ID, [1, 2, 3]))
|
||||
result = self._run(path)
|
||||
def test_commit_older_than_published_max_still_yields_published_plus_one(self):
|
||||
# The rebase/amend/cherry-pick case: committer date (10) is far
|
||||
# older than what is already published (500). Fault this catches if
|
||||
# removed: the script would print 10, a number every device that
|
||||
# saw 500 has already rejected as a downgrade.
|
||||
path = self._write_index("higher.json", index_with_versions(APP_ID, [500]))
|
||||
result = self._run(path, committer_minutes=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertEqual(got, CANDIDATE_AT_FIXED_NOW)
|
||||
self.assertGreater(got, 3)
|
||||
self.assertEqual(got, 501)
|
||||
self.assertGreater(got, 500)
|
||||
|
||||
def test_equal_prior_is_bumped_by_one(self):
|
||||
# published == candidate: the same-minute collision case. The clamp
|
||||
# must still move strictly past it.
|
||||
path = self._write_index(
|
||||
"equal.json", index_with_versions(APP_ID, [CANDIDATE_AT_FIXED_NOW]))
|
||||
result = self._run(path)
|
||||
# committer minute == published: the same-minute collision case.
|
||||
# The clamp must still move strictly past it.
|
||||
path = self._write_index("equal.json", index_with_versions(APP_ID, [100]))
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertGreater(got, CANDIDATE_AT_FIXED_NOW)
|
||||
self.assertEqual(got, CANDIDATE_AT_FIXED_NOW + 1)
|
||||
|
||||
def test_higher_prior_wins_the_clamp_clock_went_backwards(self):
|
||||
higher = CANDIDATE_AT_FIXED_NOW + 500
|
||||
path = self._write_index("higher.json", index_with_versions(APP_ID, [higher]))
|
||||
result = self._run(path)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertGreater(got, higher)
|
||||
self.assertEqual(got, higher + 1)
|
||||
self.assertEqual(got, 101)
|
||||
|
||||
def test_highest_of_several_versions_is_the_one_that_matters(self):
|
||||
path = self._write_index(
|
||||
"several.json", index_with_versions(APP_ID, [10, 999, 50]))
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertGreater(got, 999)
|
||||
@@ -138,23 +154,105 @@ class NextVersionCodeTest(unittest.TestCase):
|
||||
obj["packages"][APP_ID]["versions"]["nomanifest"] = {}
|
||||
obj["packages"][APP_ID]["versions"]["emptymanifest"] = {"manifest": {}}
|
||||
path = self._write_index("partial.json", obj)
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertGreater(got, 7)
|
||||
|
||||
# -- forwards: a future-dated commit is clamped, and warns -------------
|
||||
|
||||
def test_future_dated_commit_within_tolerance_is_not_clamped(self):
|
||||
# Negative control: honest clock skew, well inside TOLERANCE, must
|
||||
# pass through untouched and silently -- proves the forwards clamp
|
||||
# is not simply "always clamp to now".
|
||||
just_ahead = NOW_MINUTES + TOLERANCE_MINUTES - 1
|
||||
path = self._write_index("empty.json", {"packages": {}})
|
||||
result = self._run(path, committer_minutes=just_ahead)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout, f"{just_ahead}\n")
|
||||
self.assertEqual(result.stderr, "")
|
||||
|
||||
def test_future_dated_commit_beyond_tolerance_is_clamped_and_warns(self):
|
||||
# A commit dated far ahead of "now" (attacker- or typo-controlled,
|
||||
# per the module docstring) must be clamped to roughly now, not
|
||||
# trusted outright -- and the clamp must be visible, not silent.
|
||||
far_ahead = NOW_MINUTES + TOLERANCE_MINUTES + 100_000
|
||||
path = self._write_index("empty.json", {"packages": {}})
|
||||
result = self._run(path, committer_minutes=far_ahead)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
expected_ceiling = NOW_MINUTES + TOLERANCE_MINUTES
|
||||
self.assertEqual(got, expected_ceiling)
|
||||
self.assertLess(got, far_ahead) # actually clamped, not passed through
|
||||
self.assertIn("warning", result.stderr.lower())
|
||||
self.assertIn("clamping", result.stderr.lower())
|
||||
|
||||
def test_commit_dated_near_int32_does_not_produce_a_near_int32_result(self):
|
||||
# The unrecoverable failure mode from issue #6: a commit dated to
|
||||
# land just below the int32 ceiling must NOT be allowed through,
|
||||
# since that would publish a versionCode no later build could ever
|
||||
# exceed. This uses a real distant year (2999) via a raw Unix
|
||||
# timestamp, not a minutes-since-epoch shortcut, to exercise the
|
||||
# actual conversion path a malicious or badly-typo'd commit date
|
||||
# would take.
|
||||
year_2999 = datetime.datetime(2999, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
ts = str(int(year_2999.timestamp()))
|
||||
path = self._write_index("empty.json", {"packages": {}})
|
||||
env = dict(os.environ)
|
||||
env["NEXT_VERSION_CODE_NOW"] = NOW_ISO
|
||||
result = subprocess.run(
|
||||
[sys.executable, SCRIPT, path, APP_ID, ts],
|
||||
capture_output=True, text=True, env=env,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
self.assertLess(got, INT32_MAX - 1_000_000)
|
||||
# Tighter than "not overflowing": it must be near the clamp ceiling,
|
||||
# i.e. near *now*, not merely somewhere short of int32.
|
||||
self.assertEqual(got, NOW_MINUTES + TOLERANCE_MINUTES)
|
||||
self.assertIn("warning", result.stderr.lower())
|
||||
|
||||
# -- the property that actually matters ---------------------------------
|
||||
|
||||
def test_property_result_exceeds_every_published_code(self):
|
||||
"""Across a spread of scenarios -- ordinary, backwards, forwards,
|
||||
far-forwards -- the result must always exceed every versionCode
|
||||
already in the fixture. This is the property a future refactor
|
||||
could quietly break while every individual arithmetic case still
|
||||
happens to look right."""
|
||||
scenarios = [
|
||||
([], 5),
|
||||
([1, 2, 3], 5),
|
||||
([100], 100),
|
||||
([500], 10), # backwards
|
||||
([1], NOW_MINUTES + TOLERANCE_MINUTES - 1), # forwards, in tolerance
|
||||
([1], NOW_MINUTES + TOLERANCE_MINUTES + 100_000), # forwards, clamped
|
||||
]
|
||||
for published, committer_minutes in scenarios:
|
||||
with self.subTest(published=published, committer_minutes=committer_minutes):
|
||||
path = self._write_index(
|
||||
f"scenario-{committer_minutes}.json",
|
||||
index_with_versions(APP_ID, published) if published
|
||||
else {"packages": {}},
|
||||
)
|
||||
result = self._run(path, committer_minutes=committer_minutes)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
got = int(result.stdout)
|
||||
ceiling = max(published) if published else float("-inf")
|
||||
self.assertGreater(got, ceiling)
|
||||
|
||||
# -- failure modes: fail loudly, never guess ---------------------------
|
||||
|
||||
def test_unreadable_index_fails_loudly(self):
|
||||
missing = os.path.join(self.tmp.name, "does-not-exist.json")
|
||||
result = self._run(missing)
|
||||
result = self._run(missing, committer_minutes=100)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "") # never a guessed number on stdout
|
||||
self.assertIn("could not read the index", result.stderr)
|
||||
|
||||
def test_malformed_index_fails_loudly(self):
|
||||
path = self._write_index("malformed.json", "{not valid json")
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(result.stdout, "")
|
||||
self.assertIn("could not read the index", result.stderr)
|
||||
@@ -163,10 +261,10 @@ class NextVersionCodeTest(unittest.TestCase):
|
||||
|
||||
def test_stdout_is_only_the_integer(self):
|
||||
path = self._write_index("clean.json", {"packages": {}})
|
||||
result = self._run(path)
|
||||
result = self._run(path, committer_minutes=100)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
# Exactly one line, and it parses as an int with no surrounding text.
|
||||
self.assertEqual(int(result.stdout.strip()), CANDIDATE_AT_FIXED_NOW)
|
||||
self.assertEqual(int(result.stdout.strip()), 100)
|
||||
self.assertEqual(result.stdout.count("\n"), 1)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user