diff --git a/tests/modkit_tests.lua b/tests/modkit_tests.lua index 3a688793..90b795f9 100644 --- a/tests/modkit_tests.lua +++ b/tests/modkit_tests.lua @@ -636,6 +636,52 @@ local packed = io.open(cleanPkg, "rb") check(packed ~= nil, "pack writes the package") if packed then packed:close() end +-- Reproducible-build callers pin the informational pack timestamp through the +-- standard SOURCE_DATE_EPOCH contract. Two clean invocations over the same +-- input must then produce identical archive bytes and metadata. +local epoch = "1234567890" +local envPrefix = isWindows + and ('set "SOURCE_DATE_EPOCH=%s" && '):format(epoch) + or ("SOURCE_DATE_EPOCH=%s "):format(epoch) +local deterministicA = root .. "/declared-a.modpkg" +local deterministicB = root .. "/declared-b.modpkg" +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicA)) +check(code == 0, "SOURCE_DATE_EPOCH package A succeeds: " .. out) +out, code = run(envPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, deterministicB)) +check(code == 0, "SOURCE_DATE_EPOCH package B succeeds: " .. out) +local archiveA = assert(io.open(deterministicA, "rb")) +local bytesA = archiveA:read("*a") +archiveA:close() +local archiveB = assert(io.open(deterministicB, "rb")) +local bytesB = archiveB:read("*a") +archiveB:close() +check(bytesA == bytesB, "SOURCE_DATE_EPOCH makes package bytes reproducible") +local inspectPack = root .. "/inspect_pack.py" +write(inspectPack, [[ +import json, sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as archive: + meta = json.loads(archive.read(".modkit/pack.json")) +assert meta["packed_at"] == "2009-02-13T23:31:30Z", meta["packed_at"] +]]) +out, code = run(("%s %q %q"):format(python, inspectPack, deterministicA)) +check(code == 0, "pack metadata honors SOURCE_DATE_EPOCH: " .. out) +local invalidEpochPrefix = isWindows + and 'set "SOURCE_DATE_EPOCH=not-a-time" && ' + or "SOURCE_DATE_EPOCH=not-a-time " +local invalidEpochPkg = root .. "/declared-invalid-epoch.modpkg" +out, code = run(invalidEpochPrefix .. + ("%s tools/modkit.py pack %q -o %q --base fixture") + :format(python, declared, invalidEpochPkg)) +check(code == 2, "invalid SOURCE_DATE_EPOCH is a usage failure: " .. out) +check(out:find("SOURCE_DATE_EPOCH", 1, true) ~= nil, + "invalid source epoch names the failed contract") +check(io.open(invalidEpochPkg, "rb") == nil, + "invalid source epoch writes no package") + -- MK305 diffs shipped tables against the imported dataset; fake one under -- a scratch repo root so the check exercises the same on ROM-less machines local fake = root .. "/fakerepo" diff --git a/tools/modkit.py b/tools/modkit.py index 522b8008..4eaf7f8b 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -1085,6 +1085,20 @@ def cmd_lint(args, repo): # ---------------------------------------------------------------- pack +def pack_timestamp(): + raw = os.environ.get("SOURCE_DATE_EPOCH") + if raw is None: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), None + try: + epoch = int(raw, 10) + if epoch < 0: + raise ValueError("negative epoch") + stamp = datetime.fromtimestamp(epoch, timezone.utc) + except (ValueError, OverflowError, OSError): + return None, "SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp" + return stamp.strftime("%Y-%m-%dT%H:%M:%SZ"), None + + def cmd_pack(args, repo): mod_dir = resolve_mod_dir(repo, args.mod) if not mod_dir: @@ -1119,6 +1133,10 @@ def cmd_pack(args, repo): mod_id = manifest["id"] version = manifest.get("version", "0.0.0") out = args.output or f"{mod_id}-{version}.modpkg" + packed_at, timestamp_problem = pack_timestamp() + if timestamp_problem: + print(f"modkit: {timestamp_problem}") + return 2 files = mod_files(mod_dir) records = [] for rel in files: @@ -1127,8 +1145,7 @@ def cmd_pack(args, repo): "sha256": hashlib.sha256(body).hexdigest()}) pack_meta = { "modkit": MODKIT_VERSION, - "packed_at": datetime.now(timezone.utc) - .strftime("%Y-%m-%dT%H:%M:%SZ"), + "packed_at": packed_at, "id": mod_id, "version": version, "api": manifest.get("api", 1),