Initial commit: QSafe Android 1.1.0 and Windows 1.0.4
This commit is contained in:
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
stage="$(cd "$(dirname "$0")" && pwd)"
|
||||
mountpoint -q /mnt/data || { echo 'External disk is not mounted'; exit 1; }
|
||||
findmnt -n -o OPTIONS /mnt/data | tr ',' '\n' | grep -qx rw
|
||||
test -f "$stage/token.sha256"
|
||||
if ! id qsafe >/dev/null 2>&1; then useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin qsafe; fi
|
||||
install -d -o root -g root -m 755 /opt/qsafe
|
||||
install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/data
|
||||
install -d -o root -g qsafe -m 750 /etc/qsafe
|
||||
install -d -o root -g root -m 700 /var/backups/qsafe /var/backups/qsafe-install
|
||||
install -m 644 "$stage/server.py" "$stage/backup.py" /opt/qsafe/
|
||||
if test -f /etc/qsafe/token.sha256; then
|
||||
cmp -s "$stage/token.sha256" /etc/qsafe/token.sha256 || { echo 'Existing connection key differs; refusing to rotate it'; exit 1; }
|
||||
else
|
||||
install -o root -g qsafe -m 640 "$stage/token.sha256" /etc/qsafe/token.sha256
|
||||
fi
|
||||
cat > /etc/systemd/system/qsafe.service <<'EOF'
|
||||
[Unit]
|
||||
Description=QSafe encrypted vault synchronization
|
||||
After=network.target
|
||||
RequiresMountsFor=/mnt/data/qsafe/data
|
||||
[Service]
|
||||
Type=simple
|
||||
User=qsafe
|
||||
Group=qsafe
|
||||
UMask=0077
|
||||
ExecStart=/usr/bin/python3 /opt/qsafe/server.py --data /mnt/data/qsafe/data --token-hash-file /etc/qsafe/token.sha256
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/mnt/data/qsafe/data
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
MemoryMax=256M
|
||||
TasksMax=48
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
cat > /etc/systemd/system/qsafe-backup.service <<'EOF'
|
||||
[Unit]
|
||||
Description=QSafe verified encrypted backup to SD card
|
||||
RequiresMountsFor=/mnt/data/qsafe/data
|
||||
[Service]
|
||||
Type=oneshot
|
||||
UMask=0077
|
||||
ExecStart=/usr/bin/python3 /opt/qsafe/backup.py /mnt/data/qsafe/data/vault.sqlite3 /var/backups/qsafe
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/backups/qsafe /mnt/data/qsafe/data
|
||||
EOF
|
||||
cat > /etc/systemd/system/qsafe-backup.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Daily QSafe encrypted backup
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:25:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
: "${QSAFE_LAN_IP:?Set the Pi private IPv4; public vault endpoints are forbidden}"
|
||||
python3 "$stage/lan-only.py" --ip "$QSAFE_LAN_IP"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now qsafe.service qsafe-backup.timer
|
||||
systemctl restart qsafe.service
|
||||
for i in $(seq 1 20); do
|
||||
if curl -fsS http://127.0.0.1:18792/health; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
systemctl start qsafe-backup.service
|
||||
systemctl is-active qsafe.service caddy.service qsafe-backup.timer
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate only QSafe to a private-IP TLS listener; preserve Argus updates and vault data."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def run(*args):
|
||||
subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--ip', required=True)
|
||||
p.add_argument('--port', type=int, default=18443)
|
||||
a = p.parse_args()
|
||||
ip = ipaddress.IPv4Address(a.ip)
|
||||
if not any(ip in n for n in map(ipaddress.IPv4Network, ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'])) or not 1024 <= a.port <= 65535:
|
||||
raise SystemExit('A private LAN IPv4 and unprivileged TLS port are required')
|
||||
links = json.loads(subprocess.check_output(['ip', '-j', '-4', 'address', 'show']))
|
||||
match = [(link['ifname'], address['prefixlen']) for link in links for address in link.get('addr_info', []) if address.get('local') == str(ip)]
|
||||
if len(match) != 1 or not match[0][0].startswith(('wl', 'eth', 'en')):
|
||||
raise SystemExit('IP must belong to a physical LAN interface')
|
||||
subnet = ipaddress.IPv4Network(f'{ip}/{match[0][1]}', strict=False)
|
||||
if subnet.prefixlen < 16:
|
||||
raise SystemExit('LAN prefix is too broad')
|
||||
config = Path('/etc/caddy/Caddyfile')
|
||||
site = Path('/etc/caddy/sites/qsafe-lan.caddy')
|
||||
original = config.read_text()
|
||||
if original.count('# BEGIN QSafe') == 0 and original.count('# END QSafe') == 0:
|
||||
needle = 'argus.kusoft.xyz {\n'
|
||||
if original.count(needle) != 1: raise SystemExit('Expected Argus site; no change made')
|
||||
original = original.replace(needle, needle + '\t# BEGIN QSafe\n\t# END QSafe\n', 1)
|
||||
if original.count('# BEGIN QSafe\n') != 1 or original.count('# END QSafe\n') != 1:
|
||||
raise SystemExit('Expected exactly one owned QSafe block; no change made')
|
||||
start = original.index('\t# BEGIN QSafe')
|
||||
end = original.index('\n', original.index('# END QSafe'))
|
||||
replacement = '''\t# BEGIN QSafe
|
||||
\t@qsafe_disabled path /qsafe /qsafe/*
|
||||
\thandle @qsafe_disabled {
|
||||
\t\trespond 404
|
||||
\t}
|
||||
\t# END QSafe'''
|
||||
backup = Path('/var/backups/qsafe-lan') / time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
|
||||
backup.mkdir(parents=True, mode=0o700)
|
||||
shutil.copy2(config, backup / 'Caddyfile')
|
||||
if site.exists(): shutil.copy2(site, backup / 'qsafe-lan.caddy')
|
||||
tls_dir = Path('/etc/caddy/qsafe-lan')
|
||||
tls_dir.mkdir(mode=0o750, exist_ok=True)
|
||||
shutil.chown(tls_dir, user='root', group='caddy'); os.chmod(tls_dir, 0o750)
|
||||
cert, key = tls_dir / 'lan.crt', tls_dir / 'lan.key'
|
||||
if cert.exists() != key.exists(): raise SystemExit('Incomplete existing TLS identity; no change made')
|
||||
if not cert.exists():
|
||||
run('openssl', 'req', '-x509', '-newkey', 'rsa:3072', '-sha256', '-nodes', '-days', '3650', '-subj', '/CN=QSafe LAN', '-addext', f'subjectAltName=IP:{ip}', '-addext', 'extendedKeyUsage=serverAuth', '-keyout', str(key), '-out', str(cert))
|
||||
run('openssl', 'x509', '-in', str(cert), '-noout', '-checkip', str(ip))
|
||||
for file in (cert, key):
|
||||
shutil.chown(file, user='root', group='caddy'); os.chmod(file, 0o640)
|
||||
lan = f'''# QSafe: private LAN listener, no ACME or public hostname.
|
||||
https://{ip}:{a.port} {{
|
||||
bind {ip}
|
||||
tls {cert} {key}
|
||||
@foreign not remote_ip {subnet}
|
||||
respond @foreign 403
|
||||
handle_path /qsafe/* {{
|
||||
request_body {{
|
||||
max_size 32MB
|
||||
}}
|
||||
reverse_proxy 127.0.0.1:18792
|
||||
}}
|
||||
handle {{
|
||||
respond 404
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
try:
|
||||
config.write_text(original[:start] + replacement + original[end:])
|
||||
site.write_text(lan)
|
||||
run('caddy', 'validate', '--config', str(config), '--adapter', 'caddyfile')
|
||||
run('systemctl', 'reload', 'caddy')
|
||||
except BaseException:
|
||||
shutil.copy2(backup / 'Caddyfile', config)
|
||||
if (backup / 'qsafe-lan.caddy').exists(): shutil.copy2(backup / 'qsafe-lan.caddy', site)
|
||||
else: site.unlink(missing_ok=True)
|
||||
raise
|
||||
der = subprocess.check_output(['openssl', 'x509', '-in', str(cert), '-outform', 'DER'])
|
||||
print(json.dumps({'url': f'https://{ip}:{a.port}/qsafe/', 'homeSubnet': str(subnet), 'certificateSha256': hashlib.sha256(der).hexdigest(), 'publicVaultRoute': 'disabled', 'argusUpdates': 'unchanged', 'backup': str(backup)}))
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/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))
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
stage="$(cd "$(dirname "$0")" && pwd)"
|
||||
test ! -e /mnt/data/qsafe/qa/vault.sqlite3 || { echo 'QA vault already exists'; exit 1; }
|
||||
install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/qa
|
||||
install -o root -g qsafe -m 640 "$stage/qa-token.sha256" /etc/qsafe/qa-token.sha256
|
||||
systemd-run --unit=qsafe-qa --property=User=qsafe --property=Group=qsafe --property=UMask=0077 \
|
||||
/usr/bin/python3 /opt/qsafe/server.py --port 18793 --data /mnt/data/qsafe/qa --token-hash-file /etc/qsafe/qa-token.sha256
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path('/etc/caddy/sites/qsafe-lan.caddy')
|
||||
t = p.read_text()
|
||||
if '# BEGIN QSafe QA' in t:
|
||||
raise SystemExit('QA route already present')
|
||||
needle = ' handle_path /qsafe/* {\n'
|
||||
block = '''\t# BEGIN QSafe QA
|
||||
\thandle_path /qsafe-qa/* {
|
||||
\t\treverse_proxy 127.0.0.1:18793
|
||||
\t}
|
||||
\t# END QSafe QA
|
||||
'''
|
||||
assert t.count(needle) == 1, 'LAN-only QSafe site must be installed first'
|
||||
p.write_text(t.replace(needle, block + needle, 1))
|
||||
PY
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null
|
||||
systemctl reload caddy
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path('/etc/caddy/sites/qsafe-lan.caddy')
|
||||
t = p.read_text()
|
||||
start = t.find('\t# BEGIN QSafe QA\n')
|
||||
endmarker = '\t# END QSafe QA\n'
|
||||
end = t.find(endmarker, start)
|
||||
if start >= 0 and end >= 0:
|
||||
p.write_text(t[:start] + t[end + len(endmarker):])
|
||||
PY
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null
|
||||
systemctl reload caddy
|
||||
systemctl stop qsafe-qa
|
||||
rm -f /etc/qsafe/qa-token.sha256
|
||||
@@ -0,0 +1,44 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import publish_argus as publisher
|
||||
|
||||
|
||||
class PublicationTests(unittest.TestCase):
|
||||
def test_catalog_retains_latest_with_backup_and_other_apps_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
publisher.ROOT = root
|
||||
(root / 'backups').mkdir()
|
||||
(root / 'Packages' / 'qsafe').mkdir(parents=True)
|
||||
old = root / 'Packages/qsafe/old.apk'
|
||||
old.write_bytes(b'old synthetic APK')
|
||||
with sqlite3.connect(root / 'argus.db') as db:
|
||||
db.executescript('''
|
||||
CREATE TABLE Apps (Id TEXT PRIMARY KEY, Slug TEXT, Name TEXT, Summary TEXT, Description TEXT, Icon TEXT, Extra TEXT, AndroidPackageName TEXT, IsListed INTEGER, CreatedAt TEXT, UpdatedAt TEXT);
|
||||
CREATE TABLE Releases (Id TEXT PRIMARY KEY, CatalogAppId TEXT REFERENCES Apps(Id), Version TEXT, Channel TEXT, Platform TEXT, PackageKind TEXT, AndroidVersionCode INTEGER, OriginalFileName TEXT, StoredRelativePath TEXT, ContentType TEXT, PackageSizeBytes INTEGER, Sha256 TEXT, Notes TEXT, PublishedAt TEXT);
|
||||
CREATE TRIGGER Argus_RetainLatestAfterInsert AFTER INSERT ON Releases BEGIN
|
||||
DELETE FROM Releases WHERE CatalogAppId=NEW.CatalogAppId AND rowid NOT IN (SELECT rowid FROM Releases WHERE CatalogAppId=NEW.CatalogAppId ORDER BY AndroidVersionCode DESC LIMIT 1);
|
||||
END;
|
||||
INSERT INTO Apps VALUES ('qsafe-id','qsafe','QSafe','old','old',NULL,NULL,'ru.kusoft.qsafe',1,'old','old');
|
||||
INSERT INTO Apps VALUES ('other-id','other','Other','keep','keep',NULL,NULL,'other.package',1,'old','old');
|
||||
''')
|
||||
db.execute('INSERT INTO Releases VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ('old-release', 'qsafe-id', '1.0.0', 'stable', 'android', 'apk', 3, 'old.apk', 'qsafe/old.apk', 'application/vnd.android.package-archive', old.stat().st_size, hashlib.sha256(old.read_bytes()).hexdigest(), 'old', 'old'))
|
||||
new = root / 'QSafe-1.0.1.apk'
|
||||
new.write_bytes(b'new synthetic APK')
|
||||
metadata = dict(version='1.0.1', versionCode=4, size=new.stat().st_size, sha256=hashlib.sha256(new.read_bytes()).hexdigest(), expectedPreviousVersionCode=3, summary='new', description='new', notes='LAN only vault; Argus updates')
|
||||
result = publisher.publish(new, metadata)
|
||||
self.assertTrue(result['unrelatedCatalogRowsUnchanged'])
|
||||
self.assertEqual(b'old synthetic APK', old.read_bytes())
|
||||
with sqlite3.connect(root / 'argus.db') as db:
|
||||
self.assertEqual(1, db.execute("SELECT COUNT(*) FROM Releases WHERE CatalogAppId='qsafe-id'").fetchone()[0])
|
||||
self.assertEqual(('keep', 'keep'), db.execute("SELECT Summary,Description FROM Apps WHERE Id='other-id'").fetchone())
|
||||
self.assertTrue(publisher.publish(new, metadata)['alreadyPublished'])
|
||||
self.assertEqual(b'old synthetic APK', (Path(result['backup']) / 'Packages/qsafe/old.apk').read_bytes())
|
||||
wrong = dict(metadata, version='1.0.2', versionCode=5)
|
||||
with self.assertRaises(RuntimeError): publisher.publish(new, wrong)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in New Issue
Block a user