144 lines
7.8 KiB
Python
144 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Publish a reviewed QSafe APK into the existing Argus catalog, with rollback and identity guards."""
|
|
import argparse
|
|
from contextlib import closing
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import sqlite3
|
|
import tempfile
|
|
import uuid
|
|
|
|
ROOT = Path('/mnt/data/argus').resolve()
|
|
SLUG, PACKAGE = 'qsafe', 'ru.kusoft.qsafe'
|
|
|
|
|
|
def digest(path):
|
|
h = hashlib.sha256()
|
|
with path.open('rb') as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def check(db):
|
|
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
|
raise RuntimeError('SQLite integrity check failed')
|
|
if db.execute('PRAGMA foreign_key_check').fetchall():
|
|
raise RuntimeError('SQLite foreign key check failed')
|
|
|
|
|
|
def publish(source, metadata):
|
|
version, code, size, sha = (metadata[k] for k in ['version', 'versionCode', 'size', 'sha256'])
|
|
if not re.fullmatch(r'\d+\.\d+\.\d+', version) or not isinstance(code, int) or code < 1 or not isinstance(size, int) or size < 1 or not re.fullmatch('[0-9a-f]{64}', sha):
|
|
raise ValueError('Invalid reviewed metadata')
|
|
if not source.is_file() or source.stat().st_size != size or digest(source) != sha:
|
|
raise ValueError('Reviewed APK does not match source')
|
|
with closing(sqlite3.connect(ROOT / 'argus.db', timeout=30)) as db:
|
|
db.execute('PRAGMA foreign_keys=ON')
|
|
check(db)
|
|
existing = db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone()
|
|
if existing and existing[1] != PACKAGE:
|
|
raise RuntimeError('Slug is owned by another package')
|
|
if db.execute('SELECT Slug FROM Apps WHERE AndroidPackageName=? AND Slug<>?', (PACKAGE, SLUG)).fetchall():
|
|
raise RuntimeError('Another slug owns this Android package')
|
|
if existing:
|
|
same = db.execute('SELECT Id,StoredRelativePath FROM Releases WHERE CatalogAppId=? AND Version=? AND AndroidVersionCode=? AND Sha256=?', (existing[0], version, code, sha)).fetchone()
|
|
if same:
|
|
file = (ROOT / 'Packages' / same[1]).resolve()
|
|
file.relative_to(ROOT / 'Packages')
|
|
if not file.is_file() or digest(file) != sha:
|
|
raise RuntimeError('Existing publication has an invalid artifact')
|
|
return {'alreadyPublished': True, 'slug': SLUG, 'releaseId': same[0], 'sha256': sha}
|
|
previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (existing[0],)).fetchone()[0]
|
|
if metadata.get('expectedPreviousVersionCode') != previous or (previous is not None and code <= previous):
|
|
raise RuntimeError('Catalog changed or versionCode did not increase')
|
|
elif metadata.get('expectedPreviousVersionCode') is not None:
|
|
raise RuntimeError('Expected previous release is missing')
|
|
stamp = datetime.now(timezone.utc)
|
|
now = stamp.isoformat(timespec='microseconds')
|
|
backup_dir = ROOT / 'backups' / ('qsafe-' + version + '-' + stamp.strftime('%Y%m%dT%H%M%S%fZ'))
|
|
backup_dir.mkdir(mode=0o700)
|
|
with closing(sqlite3.connect(backup_dir / 'argus.db')) as saved:
|
|
db.backup(saved)
|
|
check(saved)
|
|
if existing:
|
|
for relative, in db.execute('SELECT StoredRelativePath FROM Releases WHERE CatalogAppId=?', (existing[0],)):
|
|
old = (ROOT / 'Packages' / relative).resolve()
|
|
old.relative_to(ROOT / 'Packages')
|
|
if old.is_file():
|
|
dest = backup_dir / 'Packages' / relative
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(old, dest)
|
|
app_id = existing[0] if existing else str(uuid.uuid4()).upper()
|
|
release_id = str(uuid.uuid4()).upper()
|
|
package_dir = ROOT / 'Packages' / SLUG
|
|
if package_dir.is_symlink():
|
|
raise RuntimeError('Package directory may not be a symlink')
|
|
package_dir.mkdir(exist_ok=True)
|
|
package_dir.resolve().relative_to(ROOT / 'Packages')
|
|
relative = SLUG + '/' + stamp.strftime('%Y%m%d%H%M%S') + '-' + version + '-' + release_id.replace('-', '') + '.apk'
|
|
target = ROOT / 'Packages' / relative
|
|
with tempfile.NamedTemporaryFile(dir=package_dir, prefix='.upload-', delete=False) as output:
|
|
temp = Path(output.name)
|
|
with source.open('rb') as src:
|
|
shutil.copyfileobj(src, output)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
temp.chmod(0o644)
|
|
if digest(temp) != sha:
|
|
temp.unlink()
|
|
raise RuntimeError('Staging hash mismatch')
|
|
temp.replace(target)
|
|
|
|
def unrelated():
|
|
rows = [db.execute('SELECT * FROM Apps WHERE Id<>? ORDER BY Id', (app_id,)).fetchall(), db.execute('SELECT * FROM Releases WHERE CatalogAppId<>? ORDER BY Id', (app_id,)).fetchall()]
|
|
return hashlib.sha256(json.dumps(rows, ensure_ascii=False).encode()).hexdigest()
|
|
|
|
committed = False
|
|
try:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
if db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone() != existing:
|
|
raise RuntimeError('Application catalog changed during preparation')
|
|
before = unrelated()
|
|
if existing:
|
|
previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0]
|
|
if metadata.get('expectedPreviousVersionCode') != previous:
|
|
raise RuntimeError('Previous release changed during preparation')
|
|
db.execute('UPDATE Apps SET Name=?,Summary=?,Description=?,IsListed=1,UpdatedAt=? WHERE Id=?', ('QSafe', metadata['summary'], metadata['description'], now, app_id))
|
|
else:
|
|
db.execute('INSERT INTO Apps VALUES(?,?,?,?,?,NULL,NULL,?,1,?,?)', (app_id, SLUG, 'QSafe', metadata['summary'], metadata['description'], PACKAGE, now, now))
|
|
db.execute('INSERT INTO Releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', (release_id, app_id, version, 'stable', 'android', 'apk', code, source.name, relative, 'application/vnd.android.package-archive', size, sha, metadata['notes'], now))
|
|
if unrelated() != before:
|
|
raise RuntimeError('Unrelated catalog data changed')
|
|
if db.execute('SELECT COUNT(*) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0] != 1:
|
|
raise RuntimeError('Expected a single retained release')
|
|
if db.execute('PRAGMA foreign_key_check').fetchall():
|
|
raise RuntimeError('Foreign key check failed before commit')
|
|
db.commit()
|
|
committed = True
|
|
finally:
|
|
if not committed:
|
|
db.rollback()
|
|
target.unlink(missing_ok=True)
|
|
check(db)
|
|
report = {'slug': SLUG, 'version': version, 'versionCode': code, 'size': size, 'sha256': sha, 'releaseId': release_id,
|
|
'backup': str(backup_dir), 'integrity': 'ok', 'unrelatedCatalogRowsUnchanged': True,
|
|
'catalogUrl': 'https://argus.kusoft.xyz/?app=qsafe',
|
|
'manifestUrl': 'https://argus.kusoft.xyz/api/apps/qsafe/manifest?platform=android&channel=stable',
|
|
'downloadUrl': f'https://argus.kusoft.xyz/api/apps/qsafe/releases/{release_id}/download'}
|
|
(backup_dir / 'publication.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return report
|
|
|
|
|
|
if __name__ == '__main__':
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument('apk', type=Path)
|
|
p.add_argument('metadata', type=Path)
|
|
args = p.parse_args()
|
|
print(json.dumps(publish(args.apk, json.loads(args.metadata.read_text())), ensure_ascii=False))
|