110 lines
5.5 KiB
Python
110 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Offline, dependency-free installer. Review this file before running it."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
VERSION = '1.0.0b1'
|
|
|
|
|
|
def verify(source):
|
|
manifest = json.loads((source/'MANIFEST.sha256.json').read_text(encoding='utf-8'))
|
|
if manifest.get('version') != VERSION or not isinstance(manifest.get('files'), dict):
|
|
raise ValueError('Invalid release manifest')
|
|
files = manifest['files']
|
|
required = {'install.py','pyproject.toml','LICENSE','THIRD_PARTY_NOTICES.md',
|
|
'proxydoctor/__init__.py','proxydoctor/__main__.py','proxydoctor/cli.py'}
|
|
if not required <= set(files):
|
|
raise ValueError('Incomplete release manifest')
|
|
actual = {p.relative_to(source).as_posix() for p in (source/'proxydoctor').rglob('*.py')}
|
|
if actual != {name for name in files if name.startswith('proxydoctor/') and name.endswith('.py')}:
|
|
raise ValueError('Unlisted or missing package source')
|
|
for name, expected in files.items():
|
|
relative = PurePosixPath(name)
|
|
if relative.is_absolute() or '..' in relative.parts or '\\' in name:
|
|
raise ValueError('Unsafe manifest path')
|
|
path = source.joinpath(*relative.parts)
|
|
if any(part.is_symlink() for part in (path,*path.parents) if part != source.parent):
|
|
raise ValueError('Symlink in source path')
|
|
if not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
|
|
raise ValueError('Release integrity check failed')
|
|
return files
|
|
|
|
|
|
def install(source, prefix):
|
|
if sys.version_info < (3,10) or sys.platform != 'linux':
|
|
raise ValueError('Requires Linux and Python 3.10+')
|
|
files = verify(source)
|
|
prefix = prefix.expanduser().absolute()
|
|
if prefix.is_symlink() or os.path.lexists(prefix):
|
|
raise FileExistsError('Install destination already exists; choose a new version directory')
|
|
prefix.parent.mkdir(parents=True,exist_ok=True)
|
|
prefix.mkdir(mode=0o700)
|
|
owned = prefix.resolve()
|
|
try:
|
|
# --without-pip avoids ensurepip/network/package-manager dependencies.
|
|
subprocess.run([sys.executable,'-I','-B','-m','venv','--without-pip',str(prefix)],
|
|
check=True,timeout=45,capture_output=True)
|
|
python = prefix/'bin/python'
|
|
probe = subprocess.run([str(python),'-I','-B','-c',
|
|
'import json,sys,sysconfig;print(json.dumps([sys.prefix,sys.base_prefix,sysconfig.get_path("purelib")]))'],
|
|
check=True,capture_output=True,text=True,timeout=10)
|
|
active,base,library = json.loads(probe.stdout)
|
|
if active == base or Path(active).resolve() != owned:
|
|
raise ValueError('Virtual environment isolation check failed')
|
|
library = Path(library)
|
|
library.resolve().relative_to(owned)
|
|
for name in files:
|
|
if name.startswith('proxydoctor/') and name.endswith('.py'):
|
|
destination = library/name
|
|
destination.parent.mkdir(parents=True,exist_ok=True)
|
|
shutil.copyfile(source/name,destination)
|
|
notices = prefix/'share/proxydoctor'
|
|
notices.mkdir(parents=True)
|
|
for name in ('LICENSE','THIRD_PARTY_NOTICES.md','MANIFEST.sha256.json'):
|
|
shutil.copyfile(source/name,notices/name)
|
|
launcher = prefix/'bin/vpsmaster'
|
|
# execv avoids shebang/path whitespace issues without shell interpolation.
|
|
launcher.write_text('#!/bin/sh\nexec '+__import__('shlex').quote(str(python))+' -I -B -m proxydoctor "$@"\n',encoding='utf-8')
|
|
launcher.chmod(0o700)
|
|
legacy_launcher = prefix/'bin/proxydoctor'
|
|
legacy_launcher.write_bytes(launcher.read_bytes())
|
|
legacy_launcher.chmod(0o700)
|
|
subprocess.run([str(launcher),'--version'],check=True,timeout=10,capture_output=True)
|
|
(prefix/'proxydoctor-install.json').write_text(json.dumps({'brand':'VPSMaster','package':'proxydoctor','version':VERSION,'isolated':True}),encoding='utf-8')
|
|
return launcher
|
|
except BaseException:
|
|
# Only remove the new directory reserved by this invocation.
|
|
if not prefix.is_symlink() and prefix.resolve() == owned:
|
|
shutil.rmtree(prefix)
|
|
raise
|
|
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description='Offline isolated VPSMaster installer / VPSMaster 离线隔离安装')
|
|
parser.add_argument('--prefix',type=Path,default=Path.home()/'.local/share/vpsmaster'/VERSION)
|
|
parser.add_argument('--run',action='store_true',help='Install and run a read-only offline check')
|
|
args=parser.parse_args()
|
|
try:
|
|
launcher=install(Path(__file__).resolve().parent,args.prefix)
|
|
except KeyboardInterrupt:
|
|
print('Installation cancelled; new environment cleaned / 安装已取消',file=sys.stderr)
|
|
return 130
|
|
except (OSError,ValueError,subprocess.SubprocessError):
|
|
print('Installation failed. Check Python/venv availability, release checksum and destination. No system packages were installed. / 安装失败,请检查环境、校验和与目录。',file=sys.stderr)
|
|
return 2
|
|
print('Installed / 已安装:'+str(launcher),flush=True)
|
|
print('VPSMaster 1.0 public beta; not the final release / VPSMaster 1.0 公测版,非最终版本',flush=True)
|
|
if args.run:
|
|
return subprocess.call([str(launcher),'suite','--offline'])
|
|
return 0
|
|
|
|
|
|
if __name__=='__main__':
|
|
sys.exit(main())
|