Archive reviewed candidate; not production acceptance (2026-09-24)
Android client acceptance / test-and-build (push) Has been cancelled

This commit is contained in:
2026-09-24 06:50:56 +00:00
parent 261dde435b
commit c0737d5665
35 changed files with 67736 additions and 18 deletions
+136
View File
@@ -0,0 +1,136 @@
"""Bind isolated APKs to their exact build inputs and reviewed GPL source. No network or devices."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import zipfile
REPO = Path(__file__).resolve().parents[1]
VERSION = '1.0.4'
LOCKED_BINARY_INPUTS = {
'app/libs/libv2ray.aar': '3d43b9344723e9c0625527de4f2bea0ee02c21180224e5ecab3384af143ca6d0',
'app/libs/quickie-foss-1.14.0.aar': '4d90e9cb37e07b57eaa3a839be2abdc422c0ce9ac8e969720a2a71c968cd771f',
'app/libs/Toasty-1.5.2.aar': '57866e731ebe3ef82328942cd4d96cf940b2406c9440f33857f69777027f36b5',
'app/libs/arm64-v8a/libhev-socks5-tunnel.so': '4eb0b2353f4fb6d45f43cca39fe72197ce9df8c8f5ba5a82feac87abceb9ea7a',
'app/libs/armeabi-v7a/libhev-socks5-tunnel.so': '816c48bab1785fcdaaf7fc2f49dc6b0733cf11e68b9379640d22d6a539f306ff',
'app/libs/x86/libhev-socks5-tunnel.so': '6e42da45387d76630a8ffd52ea45cd5738395da39df31dba1a48806fa79d3572',
'app/libs/x86_64/libhev-socks5-tunnel.so': 'c61a54f38f61feb73b465d2245fe6e4746c2efba7e9024de8de025ce3fc9206e',
}
def digest(path):
with Path(path).open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def save(path, value):
with Path(path).open('x', encoding='utf-8', newline='\n') as stream:
json.dump(value, stream, indent=2, ensure_ascii=False)
stream.write('\n')
def main(job):
job = Path(job).resolve()
if not re.fullmatch(r'ucvl-android-104-[a-f0-9]{32}', job.name):
raise RuntimeError('Unexpected candidate job')
candidate = json.loads((job / 'evidence/candidate.json').read_text(encoding='utf-8'))
if candidate['Version'] != VERSION or candidate['VersionCode'] != 4010004 or candidate['Published']:
raise RuntimeError('Unexpected candidate contract')
inputs_path = job / 'evidence/build-inputs.json'
if digest(inputs_path) != candidate['BuildInputsSha256'].lower():
raise RuntimeError('Build input manifest changed')
inputs = json.loads(inputs_path.read_text(encoding='utf-8'))
public = job / 'public-source-final'
matched = 0
binary_inputs = []
for row in inputs:
name, expected = row['Path'], row['Sha256'].lower()
if digest(job / 'project' / name) != expected or digest(REPO / 'V2rayNG' / name) != expected:
raise RuntimeError('Build/source drift: ' + name)
if name in LOCKED_BINARY_INPUTS:
if expected != LOCKED_BINARY_INPUTS[name]:
raise RuntimeError('Dependency differs from public restoration lock: ' + name)
binary_inputs.append({'path': name, 'sha256': expected})
else:
if digest(public / 'V2rayNG' / name) != expected:
raise RuntimeError('GPL snapshot does not match compiled input: ' + name)
matched += 1
if len(binary_inputs) != len(LOCKED_BINARY_INPUTS):
raise RuntimeError('Missing locked binary dependencies')
inventory = []
forbidden = {'.jks', '.keystore', '.p12', '.pfx', '.pem', '.apk', '.aar', '.so', '.idsig', '.log'}
for path in sorted(public.rglob('*')):
if not path.is_file():
continue
name = path.relative_to(public).as_posix()
if path.is_symlink() or name.casefold().startswith('zero3-helper/') or any(part.casefold() in {'.git', '.tools', 'build', 'releases', '.gradle'} for part in path.relative_to(public).parts):
raise RuntimeError('Private/generated directory exported: ' + name)
if path.suffix.casefold() in forbidden or path.name in {'signing.properties', 'local.properties'}:
raise RuntimeError('Private/binary build input exported: ' + name)
if re.search(rb'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----', path.read_bytes()):
raise RuntimeError('Private key marker exported: ' + name)
inventory.append({'path': name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
required = ['LICENSE', 'README.md', 'README-UPSTREAM.md', 'SOURCE-PROVENANCE.json', 'Restore-Dependencies.py',
'V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuard.kt',
'V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuardTest.kt']
if any(not (public / name).is_file() for name in required):
raise RuntimeError('GPL source snapshot incomplete')
output = REPO / '.tools/candidates' / ('android-1.0.4-20260924-' + job.name.rsplit('-', 1)[1][:8] + '-final')
output.mkdir(parents=True, exist_ok=False)
packages = []
for package in candidate['Packages']:
signed = job / 'artifacts' / package['Name']
if digest(signed) != package['Sha256'].lower():
raise RuntimeError('Signed APK changed')
unsigned = job / 'project/app/build/outputs/apk/playstore/release' / package['Name']
with zipfile.ZipFile(unsigned) as before, zipfile.ZipFile(signed) as after:
added = set(after.namelist()) - set(before.namelist())
if before.testzip() is not None or after.testzip() is not None or set(before.namelist()) - set(after.namelist()):
raise RuntimeError('Signed APK removed or corrupted compiled entries')
if added != {'META-INF/MANIFEST.MF', 'META-INF/UCVL-ZON.RSA', 'META-INF/UCVL-ZON.SF'}:
raise RuntimeError('Unexpected entries added during signing')
for name in before.namelist():
if before.read(name) != after.read(name):
raise RuntimeError('APK entry modified after build: ' + name)
dex = {name: hashlib.sha256(after.read(name)).hexdigest() for name in after.namelist() if re.fullmatch(r'classes\d*\.dex', name)}
shutil.copy2(signed, output / signed.name)
packages.append({**package, 'DexSha256': dex, 'AllUnsignedZipEntriesPreserved': True, 'AddedSigningMetadata': sorted(added)})
save(output / 'android-1.0.4-source-inventory.json', inventory)
source_archive = output / 'UCVL-Zonghengjia-1.0.4-source.zip'
with zipfile.ZipFile(source_archive, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
for row in inventory:
archive.write(public / row['path'], 'UCVL-Zonghengjia-1.0.4-source/' + row['path'])
with zipfile.ZipFile(source_archive) as archive:
if archive.testzip() is not None or len(archive.namelist()) != len(inventory):
raise RuntimeError('Source archive verification failed')
report = {
'Version': VERSION, 'BaseVersionCode': 10004, 'ApkVersionCode': 4010004,
'Packages': packages, 'SourceArchive': source_archive.name, 'SourceArchiveSha256': digest(source_archive),
'SourceInventorySha256': digest(output / 'android-1.0.4-source-inventory.json'), 'SourceFiles': len(inventory),
'CompiledPublicSourceInputsMatched': matched, 'LockedRestorableBinaryInputs': binary_inputs,
'BuildInputManifestSha256': digest(inputs_path), 'UnitTests': 41, 'AuthorizationLifecycleJvmTests': 10,
'OriginalCompanySignerMatches102And103': True, 'PrivateZero3Excluded': True,
'PhysicalDeviceTested': False, 'AndroidServiceBroadcastTested': False, 'EmulatorStarted': False,
'StableReleaseAccepted': False, 'Published': False,
'RemainingGates': ['Physical Android VPN permission and data path', 'Real Service/broadcast lifecycle for replaced login',
'Wi-Fi/mobile transitions, sleep/wake and recovery', 'Authenticated sustained business session'],
}
save(output / 'android-1.0.4-candidate.json', report)
shutil.copytree(job / 'evidence', output / 'local-evidence')
shutil.copytree(job / 'source-evidence-final', output / 'source-export-evidence')
with (output / 'SHA256SUMS.txt').open('x', encoding='ascii', newline='\n') as stream:
for path in sorted(output.iterdir()):
if path.is_file() and path.name != 'SHA256SUMS.txt':
stream.write(digest(path) + ' ' + path.name + '\n')
print(json.dumps({'output': str(output), 'source_files': len(inventory), 'source_sha256': digest(source_archive),
'public_compiled_inputs_matched': matched, 'packages': packages}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('job')
main(parser.parse_args().job)