mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 18:24:51 +00:00
152 lines
8.4 KiB
Python
152 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate and verify offline bundle resources. No network or executable probes."""
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def required(value, label):
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError(f'{label}: required nonempty string')
|
|
return value
|
|
|
|
|
|
def web_url(value, label):
|
|
value = required(value, label)
|
|
if urlparse(value).scheme != 'https' or not urlparse(value).netloc:
|
|
raise ValueError(f'{label}: invalid HTTPS URL')
|
|
return value
|
|
|
|
|
|
def local_path(root, reference):
|
|
required(reference, 'text reference')
|
|
if Path(reference).is_absolute():
|
|
raise ValueError(f'invalid reference: {reference}')
|
|
path = (root / reference).resolve()
|
|
if not path.is_relative_to(root.resolve()):
|
|
raise ValueError(f'invalid reference: {reference}')
|
|
return path
|
|
|
|
|
|
def generate(root):
|
|
vendor = root / 'Vendor'
|
|
specs = json.loads((vendor / 'dependency-metadata.json').read_text())
|
|
expected_manifests = {str(p.relative_to(vendor)) for p in vendor.glob('*/manifest.json')}
|
|
expected_manifests |= {str(p.relative_to(vendor)) for p in vendor.glob('rules/*/manifest.json')}
|
|
declared = [s['manifest'] for s in specs.values() if 'manifest' in s]
|
|
if set(declared) != expected_manifests or len(declared) != len(set(declared)):
|
|
raise ValueError(f'dependency manifest coverage mismatch: expected {sorted(expected_manifests)}, declared {declared}')
|
|
resources, entries, plugins, aliases = {}, [], [], set()
|
|
for identity, spec in specs.items():
|
|
try:
|
|
if 'package' in spec:
|
|
name = required(spec.get('name', spec['package']), 'name')
|
|
# shortcut: reads the current exact-pin block; use a YAML parser if its shape expands.
|
|
package = re.search(r'^ ' + re.escape(spec['package']) + r':\n((?: .*\n)+)',
|
|
(root / 'project.yml').read_text(), re.MULTILINE)
|
|
if not package:
|
|
raise ValueError('missing XcodeGen package')
|
|
fields = dict(re.findall(r'^ (url|exactVersion): ([^\n]+)$', package[1], re.MULTILINE))
|
|
version = required(fields.get('exactVersion'), 'exactVersion')
|
|
project = source = web_url(fields.get('url'), 'package URL')
|
|
distribution = f'{source}/releases/tag/{version}'
|
|
else:
|
|
manifest = json.loads(local_path(vendor, spec['manifest']).read_text())
|
|
name = required(spec.get('name', manifest.get('project')), 'name')
|
|
version = required(manifest.get('release', manifest.get('upstreamVersion')), 'version')
|
|
project = web_url(spec.get('projectURL'), 'projectURL')
|
|
source = web_url(spec.get('sourceURL', project), 'sourceURL')
|
|
distribution = web_url(manifest.get('url', manifest.get('upstreamURL')), 'distributionURL')
|
|
if manifest.get('bundleSubpath') == 'Helpers/Plugins':
|
|
names = manifest.get('pluginNames')
|
|
arguments = manifest.get('defaultArguments')
|
|
if not isinstance(names, list) or not names or not isinstance(arguments, list):
|
|
raise ValueError('missing pluginNames/defaultArguments')
|
|
for alias in names:
|
|
required(alias, 'plugin alias')
|
|
if alias in aliases:
|
|
raise ValueError(f'duplicate plugin alias: {alias}')
|
|
aliases.add(alias)
|
|
for argument in arguments:
|
|
required(argument, 'plugin argument')
|
|
plugins.append(dict(program=identity, names=names,
|
|
executableName=required(manifest.get('binary'), 'binary'),
|
|
signIdentifier=required(manifest.get('signIdentifier'), 'signIdentifier'),
|
|
defaultArguments=arguments))
|
|
licenses, notices = spec.get('licenses'), spec.get('notices')
|
|
if not isinstance(licenses, list) or not licenses or not isinstance(notices, list) or not notices:
|
|
raise ValueError('licenses/notices must be text reference lists')
|
|
references = list(licenses) + list(notices)
|
|
for reference in references:
|
|
path = local_path(vendor, reference)
|
|
content = path.read_bytes()
|
|
if not content.decode('utf-8').strip():
|
|
raise ValueError(f'empty text: {reference}')
|
|
if reference.endswith(('/THIRD-PARTY-NOTICES', '/NOTICE')) and version not in content.decode():
|
|
raise ValueError(f'notice does not match selected version {version}: {reference}')
|
|
resources[reference] = content
|
|
# Existing aggregate notices reference shared full texts and extra notices.
|
|
for link in re.findall(r'`(\.\./third-party-licenses/[^`]+\.(?:txt))`', content.decode()):
|
|
target = local_path(vendor, str(Path(reference).parent / link))
|
|
normalized = str(target.relative_to(vendor.resolve()))
|
|
if normalized not in references:
|
|
references.append(normalized)
|
|
notices = [*notices, normalized]
|
|
entries.append(dict(id=identity, name=name, projectURL=project, sourceURL=source,
|
|
distributionURL=distribution, version=version,
|
|
licenses=licenses, notices=notices))
|
|
except (ValueError, OSError, KeyError, TypeError) as error:
|
|
raise ValueError(f'{identity}: {error}') from error
|
|
resources['catalog.json'] = (json.dumps(dict(schemaVersion=1, dependencies=entries), indent=2) + '\n').encode()
|
|
lines = ['// Generated by Scripts/dependency-metadata.py. Do not edit.', 'import Foundation', '',
|
|
'extension ManagedPluginCatalog {', ' static let definitions: [ManagedPluginInfo] = [']
|
|
for plugin in plugins:
|
|
lines.append(' ManagedPluginInfo(')
|
|
pairs = list(plugin.items())
|
|
for index, (key, value) in enumerate(pairs):
|
|
suffix = '),' if index == len(pairs) - 1 else ','
|
|
lines.append(f' {key}: {json.dumps(value, ensure_ascii=False)}{suffix}')
|
|
lines += [' ]', '}', '']
|
|
return resources, '\n'.join(lines).encode()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument('--check', action='store_true')
|
|
parser.add_argument('--bundle', type=Path, help='verify actual Contents/Resources/ThirdParty')
|
|
args = parser.parse_args()
|
|
root = args.root.resolve()
|
|
resources, swift = generate(root)
|
|
if args.bundle:
|
|
resolved = root / 'ShadowsocksX-NG2.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved'
|
|
pins = json.loads(resolved.read_text())['pins']
|
|
package = next(entry for entry in json.loads(resources['catalog.json'])['dependencies']
|
|
if entry['id'] == 'AgeSwift')
|
|
if not any(pin.get('location') == package['sourceURL'] and
|
|
pin.get('state', {}).get('version') == package['version'] for pin in pins):
|
|
raise ValueError('AgeSwift: resolved package disagrees with selected source/version')
|
|
output = args.bundle or root / '.build/dependency-metadata/ThirdParty'
|
|
files = {output / path: data for path, data in resources.items()}
|
|
if not args.bundle:
|
|
files[root / 'Domain/Plugin/ManagedPluginDefinitions.generated.swift'] = swift
|
|
for path, data in files.items():
|
|
if args.check or args.bundle:
|
|
if not path.is_file() or path.read_bytes() != data:
|
|
raise ValueError(f'{"bundle" if args.bundle else "stale generated metadata"}: {path}')
|
|
elif not path.is_file() or path.read_bytes() != data:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
print(f'Dependency metadata: {len(resources)} resources verified' if args.check or args.bundle
|
|
else f'Dependency metadata: {len(resources)} resources generated')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except (ValueError, OSError, KeyError, TypeError) as error:
|
|
sys.exit(f'error: dependency metadata: {error}')
|