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
+90
View File
@@ -0,0 +1,90 @@
# Local candidate only. Uses cached dependencies; never starts adb or an emulator.
param([string]$JobDirectory = '',[switch]$ResumeAfterBuild)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$tempRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetTempPath())
if ($ResumeAfterBuild -and -not $JobDirectory) { throw 'Resume requires the exact completed build directory' }
if (-not $JobDirectory) { $JobDirectory = Join-Path $tempRoot ('ucvl-android-104-' + [guid]::NewGuid().ToString('N')) }
$job = [IO.Path]::GetFullPath($JobDirectory)
if ([IO.Path]::GetDirectoryName($job) -ine $tempRoot -or [IO.Path]::GetFileName($job) -cnotmatch '^ucvl-android-104-[a-f0-9]{32}$' -or $job -match '[^\x00-\x7F]') {
throw 'A fresh scoped ASCII temporary directory is required'
}
if ((Test-Path -LiteralPath $job) -and -not $ResumeAfterBuild) { throw 'Candidate directory already exists' }
foreach ($name in @('universal','arm64-v8a','armeabi-v7a','x86','x86_64')) {
if (Test-Path -LiteralPath (Join-Path $repo "releases/UCVL-Zonghengjia-1.0.4-android-$name.apk")) { throw 'Signed 1.0.4 candidate already exists; do not replace it' }
}
$buildText = Get-Content -LiteralPath (Join-Path $repo 'V2rayNG/app/build.gradle.kts') -Raw
if ($buildText -notmatch 'versionCode = 10004' -or $buildText -notmatch 'versionName = "1.0.4"') { throw 'Source version mismatch' }
$project = Join-Path $job 'project'
$source = Join-Path $repo 'V2rayNG'
function Save-Json([string]$Name,$Value) {
[IO.File]::WriteAllText((Join-Path $job "evidence/$Name"),($Value | ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false))
}
$env:JAVA_HOME = Join-Path $repo '.tools/jdk-staging/jdk-21.0.12.1+1'
$env:ANDROID_HOME = Join-Path $repo '.tools/android-sdk'
if (-not $ResumeAfterBuild) {
$null = New-Item -ItemType Directory -Path $job,$project,(Join-Path $job 'evidence'),(Join-Path $job 'artifacts')
foreach ($name in @('gradlew','gradlew.bat','gradle.properties','settings.gradle.kts','build.gradle.kts','gradle')) {
Copy-Item -LiteralPath (Join-Path $source $name) -Destination $project -Recurse
}
$null = New-Item -ItemType Directory -Path (Join-Path $project 'app')
foreach ($name in @('src','libs','build.gradle.kts','proguard-rules.pro')) {
Copy-Item -LiteralPath (Join-Path $source "app/$name") -Destination (Join-Path $project 'app') -Recurse
}
$inputs = @(Get-ChildItem -LiteralPath $project -File -Recurse | Sort-Object FullName | ForEach-Object {
[ordered]@{Path=[IO.Path]::GetRelativePath($project,$_.FullName).Replace('\','/');Bytes=$_.Length;Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}
})
Save-Json 'build-inputs.json' $inputs
$inputsHash = (Get-FileHash -LiteralPath (Join-Path $job 'evidence/build-inputs.json')).Hash
$started = [DateTimeOffset]::Now
Push-Location $project
try {
& ./gradlew.bat --offline --no-daemon --max-workers=2 :app:testPlaystoreReleaseUnitTest :app:assemblePlaystoreRelease *> (Join-Path $job 'evidence/gradle.log')
$buildExit = $LASTEXITCODE
} finally { Pop-Location }
$suites = @(Get-ChildItem -LiteralPath (Join-Path $project 'app/build/test-results/testPlaystoreReleaseUnitTest') -Filter 'TEST-*.xml' -File -ErrorAction SilentlyContinue | ForEach-Object {
[xml]$xml = Get-Content -LiteralPath $_.FullName -Raw
[pscustomobject]@{Name=$xml.testsuite.name;Tests=[int]$xml.testsuite.tests;Failures=[int]$xml.testsuite.failures;Errors=[int]$xml.testsuite.errors;Skipped=[int]$xml.testsuite.skipped}
})
$tests = [ordered]@{StartedAt=$started.ToString('O');ExitCode=$buildExit;Offline=$true;Suites=$suites;BuildInputsSha256=$inputsHash;PhysicalDeviceTested=$false;EmulatorStarted=$false;Published=$false}
Save-Json 'tests.json' $tests
} else {
$tests = Get-Content -LiteralPath (Join-Path $job 'evidence/tests.json') -Raw | ConvertFrom-Json
$suites = $tests.Suites
$buildExit = $tests.ExitCode
$inputs = Get-Content -LiteralPath (Join-Path $job 'evidence/build-inputs.json') -Raw | ConvertFrom-Json
$inputsHash = (Get-FileHash -LiteralPath (Join-Path $job 'evidence/build-inputs.json')).Hash
if ($inputsHash -ne $tests.BuildInputsSha256) { throw 'Completed build input manifest changed' }
}
if ($buildExit -ne 0) { throw "Gradle failed ($buildExit); evidence retained at $job" }
if (($suites | Measure-Object Tests -Sum).Sum -ne 41 -or ($suites | Measure-Object Failures -Sum).Sum -ne 0 -or ($suites | Measure-Object Errors -Sum).Sum -ne 0 -or ($suites | Measure-Object Skipped -Sum).Sum -ne 0) {
throw 'Unexpected unit test coverage or failures'
}
foreach ($row in $inputs) {
if ((Get-FileHash -LiteralPath (Join-Path $project $row.Path)).Hash -ne $row.Sha256 -or (Get-FileHash -LiteralPath (Join-Path $source $row.Path)).Hash -ne $row.Sha256) {
throw "Input changed during build: $($row.Path)"
}
}
& (Join-Path $repo 'Sign-Candidate.ps1') -Version '1.0.4' -InputDirectory (Join-Path $project 'app/build/outputs/apk/playstore/release') *> (Join-Path $job 'evidence/signing.log')
$java = Join-Path $env:JAVA_HOME 'bin/java.exe'
$signer = Join-Path $env:ANDROID_HOME 'build-tools/37.0.0/lib/apksigner.jar'
$aapt = Join-Path $env:ANDROID_HOME 'build-tools/37.0.0/aapt.exe'
$expectedCertificate = '0f1a09870d6ee1e73b62138f99463e7066f4228ce28b7f16ff0aeccca59ea14f'
foreach ($version in @('1.0.2','1.0.3')) {
$verification = (& $java -jar $signer verify --verbose --print-certs (Join-Path $repo "releases/UCVL-Zonghengjia-$version-android-universal.apk") 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0 -or $verification -notmatch $expectedCertificate) { throw "Old $version signing identity differs" }
}
$packages = @(foreach ($file in Get-ChildItem -LiteralPath (Join-Path $repo 'releases') -Filter 'UCVL-Zonghengjia-1.0.4-android-*.apk' -File | Sort-Object Name) {
$verification = (& $java -jar $signer verify --verbose --print-certs $file.FullName 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0 -or $verification -notmatch $expectedCertificate -or $verification -notmatch 'v2\): true' -or $verification -notmatch 'v3\): true') { throw "Candidate signature mismatch: $($file.Name)" }
$copy = Join-Path $job "artifacts/$($file.Name)"
Copy-Item -LiteralPath $file.FullName -Destination $copy
$badging = (& $aapt dump badging $copy 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0 -or $badging -notmatch "package: name='cn.toplc.zonghengjia' versionCode='4010004' versionName='1.0.4'") { throw "Candidate manifest mismatch: $($file.Name)" }
[ordered]@{Name=$file.Name;Bytes=$file.Length;Sha256=(Get-FileHash -LiteralPath $copy).Hash;Version='1.0.4';VersionCode=4010004;SignerSha256=$expectedCertificate;V2=$true;V3=$true}
})
if ($packages.Count -ne 5) { throw 'Expected all five ABI/universal packages' }
Save-Json 'candidate.json' ([ordered]@{Version='1.0.4';VersionCode=4010004;BaseVersionCode=10004;JobDirectory=$job;BuiltAt=[DateTimeOffset]::Now.ToString('O');Packages=$packages;BuildInputsSha256=$inputsHash;UnitTests=41;AuthorizationLifecycleJvmTests=10;SameSignerAs=@('1.0.2','1.0.3');PhysicalDeviceTested=$false;AndroidServiceBroadcastTested=$false;StableReleaseAccepted=$false;Published=$false})
Write-Output "CANDIDATE_JOB=$job"
Write-Output 'CANDIDATE_BUILD_SIGN_VERIFY_PASSED'
+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)
+48
View File
@@ -0,0 +1,48 @@
param(
[string]$Runtime = '',
[ValidateSet('before','after','full')][string]$Phase = 'before'
)
$ErrorActionPreference = 'Stop'
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$source = Join-Path $repo 'V2rayNG'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
if (-not $Runtime) { $Runtime = Join-Path $tempRoot ('ucvl-auth-20260917-' + [guid]::NewGuid().ToString('N')) }
$Runtime = [IO.Path]::GetFullPath($Runtime)
if (-not $Runtime.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or (Split-Path $Runtime -Leaf) -notmatch '^ucvl-auth-20260917-[a-f0-9]{32}$') {
throw 'Only a dedicated temporary ASCII build directory is allowed'
}
New-Item -ItemType Directory -Path $Runtime -Force | Out-Null
foreach ($name in @('gradlew','gradlew.bat','gradle.properties','settings.gradle.kts','build.gradle.kts')) {
Copy-Item -LiteralPath (Join-Path $source $name) -Destination $Runtime -Force
}
Copy-Item -LiteralPath (Join-Path $source 'gradle') -Destination $Runtime -Recurse -Force
$appTarget = Join-Path $Runtime 'app'
New-Item -ItemType Directory -Path $appTarget -Force | Out-Null
foreach ($name in @('src','libs','build.gradle.kts','proguard-rules.pro')) {
Copy-Item -LiteralPath (Join-Path $source ('app/' + $name)) -Destination $appTarget -Recurse -Force
}
$env:JAVA_HOME = Join-Path $repo '.tools/jdk-staging/jdk-21.0.12.1+1'
$env:ANDROID_HOME = Join-Path $repo '.tools/android-sdk'
if (-not (Test-Path (Join-Path $env:JAVA_HOME 'bin/java.exe'))) { throw 'Expected JDK 21 is missing' }
$log = Join-Path $repo ('acceptance/android-auth-' + $Phase + '-20260917.log')
$started = Get-Date
Push-Location $Runtime
try {
$gradleArgs = @('--offline','--no-daemon','--max-workers=2',':app:testPlaystoreReleaseUnitTest')
if ($Phase -ne 'full') { $gradleArgs += @('--tests','com.v2ray.ang.ucvl.UcvlAuthorizationGuardTest') }
& .\gradlew.bat @gradleArgs *> $log
$testExit = $LASTEXITCODE
} finally { Pop-Location }
$suites = @()
$resultDir = Join-Path $Runtime 'app/build/test-results/testPlaystoreReleaseUnitTest'
if (Test-Path $resultDir) {
foreach ($file in Get-ChildItem $resultDir -Filter 'TEST-*.xml' -File) {
if ($file.LastWriteTime -lt $started) { continue }
[xml]$xml = Get-Content -LiteralPath $file.FullName -Raw
$suites += [ordered]@{name=$xml.testsuite.name; tests=[int]$xml.testsuite.tests; failures=[int]$xml.testsuite.failures; errors=[int]$xml.testsuite.errors; skipped=[int]$xml.testsuite.skipped}
}
}
$report = [ordered]@{phase=$Phase; started_at=$started.ToString('o'); runtime=$Runtime; offline=$true; exit_code=$testExit; suites=$suites; source_copied=$true; emulator_started=$false; device_installed=$false; apk_published=$false; network_modified=$false}
$report | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 (Join-Path $repo ('acceptance/android-auth-' + $Phase + '-20260917.json'))
$report | ConvertTo-Json -Depth 5
exit $testExit
+66
View File
@@ -0,0 +1,66 @@
"""Same-certificate upgrade test on our dedicated emulator; never a phone.
Seeds only a synthetic non-authenticating session. Does not alter host networking
or request a production account. Run after building/signing the test APK.
"""
import datetime
import hashlib
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
ADB = ROOT / ".tools/android-sdk/platform-tools/adb.exe"
PACKAGE = "cn.toplc.zonghengjia"
SERIAL = "emulator-5554"
def adb(*args, timeout=60):
return subprocess.run([str(ADB), "-s", SERIAL, *map(str, args)], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout,
check=True, creationflags=subprocess.CREATE_NO_WINDOW).stdout
def instrument(method):
result = adb("shell", "am", "instrument", "-w", "-r", "-e", "oldVersion", "1.0.2",
"-e", "newVersion", "1.0.3", "-e", "class",
"com.v2ray.ang.ucvl.UpgradeSmokeTest#" + method,
PACKAGE + ".test/androidx.test.runner.AndroidJUnitRunner", timeout=90)
if "OK (1 test)" not in result:
raise RuntimeError("Instrumentation failed: " + result[-3000:])
def main():
assert adb("shell", "getprop", "ro.kernel.qemu").strip() == "1", "Dedicated emulator only"
assert "ucvl_acceptance" in adb("emu", "avd", "name"), "Unexpected emulator image"
assert adb("shell", "getprop", "sys.boot_completed").strip() == "1", "Emulator has not booted"
old = ROOT / "releases/UCVL-Zonghengjia-1.0.2-android-universal.apk"
new = ROOT / "releases/UCVL-Zonghengjia-1.0.3-android-universal.apk"
test = ROOT / "releases/app-playstore-release-androidTest-1.0.3.apk"
assert all(path.is_file() for path in [old, new, test])
evidence = ROOT / "acceptance/upgrade-1.0.3.json"
report = {"timestamp": datetime.datetime.now().astimezone().isoformat(), "serial": SERIAL,
"old_version": "1.0.2", "new_version": "1.0.3", "passed": False,
"physical_device_tested": False, "host_network_modified": False,
"authenticated_vpn_connection_tested": False, "steps": []}
try:
adb("shell", "am", "force-stop", PACKAGE)
assert "Success" in adb("install", "-r", old)
assert "Success" in adb("install", "-r", test)
instrument("seedUpgrade")
report["steps"].append("seed_synthetic_session_on_1.0.2")
assert "Success" in adb("install", "-r", new)
instrument("verifyUpgrade")
report["steps"].append("cover_upgrade_preserves_encrypted_session_login_and_device_id")
instrument("loginVisibleAndEmptyFormIsSafe")
report["steps"].append("login_visible_empty_submission_safe_fixture_cleared")
report["apk_sha256"] = hashlib.sha256(new.read_bytes()).hexdigest()
report["passed"] = True
finally:
adb("shell", "am", "force-stop", PACKAGE)
evidence.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report))
if __name__ == "__main__":
main()