Files
fdroidserver-ipfs/rehash.py
T
mrambossekandClaude Opus 4.7 917ddbe550
Build fdroidserver-ipfs image / build (push) Successful in 1m10s
fix: delete stale index-v2-<hash>.json files from prior rehash runs
Without this, every update left another renamed index lying in repo/
forever — pinning the repo to IPFS would keep re-pinning all of them.

Also strip a pre-existing -<hex12> suffix from the stem before computing
the new one, so re-running rehash standalone (without an intervening
fdroid update) is idempotent instead of producing index-v2-abc-def.json.

diff/*.json doesn't need the same treatment: fdroidserver's make_v2()
already wipes the whole diff/ directory at the start of every update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:54:10 +02:00

169 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Post-process `fdroid update` output for IPFS publishing.
For each repodir (repo, archive) discovered from config.yml:
- rename index-v2.json to index-v2-<sha256_last12>.json (cache-busts gateways)
- rename diff/<ts>.json to diff/<ts>-<sha256_last12>.json
- rewrite entry.json so name/ipfsCIDv1 point at the renamed files
- re-sign entry.jar via fdroidserver.signindex.sign_index
- regenerate entry.json.asc if GPG signing is configured
Run in the same working directory as `fdroid update` (where config.yml lives).
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path
# Make fdroidserver importable + invocable when this script is run outside the
# upstream image's ENTRYPOINT (which sources /etc/profile.d/bsenv.sh). The
# buildserver-style image places fdroidserver at $fdroidserver — typically
# /home/vagrant/fdroidserver. Both sys.path (for the import below) and PATH
# (for the `fdroid gpgsign` subprocess call) need it.
_FDS = os.environ.get('fdroidserver') or '/home/vagrant/fdroidserver'
if os.path.isdir(_FDS):
if _FDS not in sys.path:
sys.path.insert(0, _FDS)
if _FDS not in os.environ.get('PATH', '').split(os.pathsep):
os.environ['PATH'] = _FDS + os.pathsep + os.environ.get('PATH', '')
from fdroidserver import common, signindex
HASH_SUFFIX_LEN = 12
# Matches a trailing "-<12 lowercase hex>" right before the extension. Used to
# strip the prior suffix when re-rehashing the same file, so we don't accrete
# index-v2-abc-def-ghi.json from repeated runs.
_HASH_SUFFIX_RE = re.compile(r'-[0-9a-f]{12}$')
def sha256_last_chars(path: Path) -> str:
h = hashlib.sha256()
with path.open('rb') as fp:
for chunk in iter(lambda: fp.read(65536), b''):
h.update(chunk)
return h.hexdigest()[-HASH_SUFFIX_LEN:]
def write_entry_json(entry: dict, path: Path) -> None:
with path.open('w', encoding='utf-8') as fp:
json.dump(entry, fp, ensure_ascii=False, sort_keys=True)
def cleanup_stale_indexes(repodir: Path, keep_filename: str) -> None:
"""Remove leftover index-v2-<hash>.json files (and their .asc) from prior runs.
`fdroid update` always writes the fresh index back to the stable
`index-v2.json` name, so anything matching `index-v2-*.json` in this
directory is from a previous rehash. Skip `keep_filename` to make
re-running the script standalone safe.
"""
for stale in repodir.glob('index-v2-*.json'):
if stale.name == keep_filename:
continue
stale.unlink()
asc = stale.with_suffix(stale.suffix + '.asc')
if asc.exists():
asc.unlink()
def rehash_entry_file(repodir: Path, file_entry: dict) -> None:
"""Rename the file referenced by file_entry["name"] and update name/ipfsCIDv1 in place."""
old_rel = file_entry['name'].lstrip('/')
old_path = repodir / old_rel
suffix = sha256_last_chars(old_path)
base_stem = _HASH_SUFFIX_RE.sub('', old_path.stem)
new_path = old_path.with_name(f"{base_stem}-{suffix}{old_path.suffix}")
cid = common.calculate_IPFS_cid(str(old_path))
if new_path != old_path:
old_path.rename(new_path)
file_entry['name'] = '/' + new_path.relative_to(repodir).as_posix()
if cid:
file_entry['ipfsCIDv1'] = cid
def rehash_repodir(repodir_name: str) -> bool:
repodir = Path(repodir_name)
entry_json = repodir / 'entry.json'
if not entry_json.exists():
return False
with entry_json.open(encoding='utf-8') as fp:
entry = json.load(fp)
current_index = Path(entry['index']['name'].lstrip('/')).name
cleanup_stale_indexes(repodir, keep_filename=current_index)
rehash_entry_file(repodir, entry['index'])
for diff_entry in entry.get('diffs', {}).values():
rehash_entry_file(repodir, diff_entry)
write_entry_json(entry, entry_json)
signindex.sign_index(repodir_name, 'entry.json')
return True
def regenerate_gpg_sigs(repodirs: list) -> None:
"""Drop stale .asc files we just invalidated, then let `fdroid gpgsign` recreate them.
We detect "GPG is part of this pipeline" by the prior existence of entry.json.asc
rather than from config keys — matches whatever the user's pipeline actually does.
gpgsign only signs files missing a sibling .asc, so deleting the stale ones is
enough to trigger a fresh signature on the new filenames.
"""
pipeline_uses_gpg = any(
(Path(r) / 'entry.json.asc').exists() for r in repodirs
)
if not pipeline_uses_gpg:
return
for repodir_name in repodirs:
repodir = Path(repodir_name)
stale = [repodir / 'entry.json.asc']
stale += list(repodir.glob('index-v2*.json.asc'))
if (repodir / 'diff').is_dir():
stale += list((repodir / 'diff').glob('*.json.asc'))
for path in stale:
if path.exists():
path.unlink()
subprocess.run(['fdroid', 'gpgsign'], check=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
'repodirs', nargs='*',
help='Repo directories to process. Default: auto-discover from config.yml.',
)
args = parser.parse_args()
config = common.read_config()
signindex.config = config
repodirs = args.repodirs
if not repodirs:
repodirs = ['repo']
if config.get('archive_older', 0) != 0:
repodirs.append('archive')
processed = []
for repodir in repodirs:
if rehash_repodir(repodir):
processed.append(repodir)
regenerate_gpg_sigs(processed)
return 0
if __name__ == '__main__':
sys.exit(main())