mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 01:23:58 +08:00
457 lines
20 KiB
Python
457 lines
20 KiB
Python
"""Offline settings recovery and staging. Archived bytes are never an import policy."""
|
|
import base64
|
|
import binascii
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
|
|
PREFERENCES = frozenset({
|
|
"AlwaysOnLateral", "IsMetric", "RecordAudio", "RecordFront", "RecordFrontLock",
|
|
"ShowDebugInfo", "GsmMetered", "OpenpilotEnabledToggle",
|
|
})
|
|
# Keys removed from the registry. Saved copies are archived at startup instead of blocking it.
|
|
RETIRED_KEYS = frozenset({'ShareUsageStats'})
|
|
MAX_VALUE_BYTES = 32 * 1024 * 1024
|
|
MAX_SNAPSHOT_BYTES = 256 * 1024 * 1024
|
|
MAX_KEYS = 4096
|
|
OPERATIONAL_KEYS = frozenset({
|
|
'DongleId', 'HardwareSerial', 'GithubSshKeys', 'GithubUsername', 'SshEnabled',
|
|
'CellularDisabled', 'GsmApn', 'GsmMetered', 'GsmRoaming', 'NetworkMetered', 'BluetoothEnabled',
|
|
'ConnectProvider', 'PairingProvider', 'PairingEmail', 'PrimeType', 'AssistNowToken', 'SecOCKey',
|
|
})
|
|
|
|
|
|
class MigrationRequired(RuntimeError):
|
|
"""Existing state has not been qualified for this runtime."""
|
|
|
|
|
|
def canonical_json(value):
|
|
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8')
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _key(value):
|
|
if not isinstance(value, str) or not value or value in ('.', '..') or '/' in value or '\0' in value:
|
|
raise ValueError("Invalid settings key")
|
|
return value
|
|
|
|
|
|
def _hash(value, length=64):
|
|
if not isinstance(value, str) or re.fullmatch(f'[0-9a-f]{{{length}}}', value) is None:
|
|
raise ValueError("Invalid source digest")
|
|
|
|
|
|
def _private_dir(path):
|
|
path = Path(path)
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
info = path.lstat()
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
raise ValueError("Recovery storage must be a private, owned directory")
|
|
return path
|
|
|
|
|
|
def _fsync_dir(path):
|
|
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _write(path, data):
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, 'wb') as output:
|
|
output.write(data)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
|
|
|
|
def _atomic_write(path, data):
|
|
fd, temporary = tempfile.mkstemp(prefix='.pending-', dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'wb') as output:
|
|
output.write(data)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
os.replace(temporary, path)
|
|
_fsync_dir(path.parent)
|
|
finally:
|
|
Path(temporary).unlink(missing_ok=True)
|
|
|
|
|
|
@contextmanager
|
|
def _params_lock(namespace, *, read_only=False):
|
|
# Params writers use this same lock in the parent of the namespace symlink.
|
|
flags = os.O_RDONLY | os.O_NOFOLLOW if read_only else os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
|
|
try:
|
|
fd = os.open(namespace.parent / '.lock', flags, 0o600)
|
|
except FileNotFoundError:
|
|
if not read_only:
|
|
raise
|
|
yield
|
|
return
|
|
try:
|
|
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
raise ValueError("Invalid Params lock")
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _read_value(path):
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(fd, 'rb') as source:
|
|
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
|
raise ValueError("Settings namespace must contain only regular files")
|
|
value = source.read(MAX_VALUE_BYTES + 1)
|
|
if len(value) > MAX_VALUE_BYTES:
|
|
raise ValueError("Settings value exceeds recovery limit")
|
|
return value
|
|
|
|
|
|
def _read_namespace(namespace):
|
|
actual = namespace.resolve(strict=True)
|
|
values = {}
|
|
total = 0
|
|
for entry in sorted(actual.iterdir()):
|
|
key = _key(entry.name)
|
|
value = _read_value(entry)
|
|
total += len(value)
|
|
values[key] = value
|
|
if total > MAX_SNAPSHOT_BYTES or len(values) > MAX_KEYS:
|
|
raise ValueError("Settings namespace exceeds recovery limit")
|
|
return values
|
|
|
|
|
|
def _inventory(values):
|
|
return [{'key': key, 'sha256': digest(value), 'size': len(value)} for key, value in sorted(values.items())]
|
|
|
|
|
|
def _outside_params(namespace, storage):
|
|
if storage.resolve().is_relative_to(namespace.parent.resolve()) or storage.resolve().is_relative_to(namespace.resolve()):
|
|
raise ValueError("Recovery storage must be outside Params")
|
|
|
|
|
|
def _save_snapshot(values, storage):
|
|
inventory = _inventory(values)
|
|
snapshot_id = digest(canonical_json(inventory))
|
|
storage = _private_dir(storage)
|
|
destination = storage / snapshot_id
|
|
if destination.exists():
|
|
if load_snapshot(destination) != values:
|
|
raise ValueError("Existing recovery snapshot does not match")
|
|
return destination
|
|
temporary = Path(tempfile.mkdtemp(prefix='.pending-', dir=storage))
|
|
try:
|
|
data_dir = _private_dir(temporary / 'values')
|
|
for key, value in values.items():
|
|
_write(data_dir / key, value)
|
|
_write(temporary / 'manifest.json', canonical_json({'format': 'starpilot-raw-settings', 'version': 1, 'entries': inventory}))
|
|
_fsync_dir(data_dir)
|
|
_fsync_dir(temporary)
|
|
os.rename(temporary, destination)
|
|
_fsync_dir(storage)
|
|
finally:
|
|
if temporary.exists():
|
|
shutil.rmtree(temporary)
|
|
return destination
|
|
|
|
|
|
def snapshot_settings(namespace, storage):
|
|
"""Capture all raw values, including empty files, unknown keys and credentials.
|
|
|
|
Call with services stopped. The native Params lock coordinates cooperating
|
|
writers, but cannot coordinate an older runtime's separate settings cache.
|
|
"""
|
|
namespace, storage = Path(namespace).absolute(), Path(storage).absolute()
|
|
_outside_params(namespace, storage)
|
|
with _params_lock(namespace):
|
|
return _save_snapshot(_read_namespace(namespace), storage)
|
|
|
|
|
|
def load_snapshot(snapshot):
|
|
snapshot = Path(snapshot)
|
|
manifest = json.loads(_read_value(snapshot / 'manifest.json'))
|
|
if not isinstance(manifest, dict) or set(manifest) != {'format', 'version', 'entries'}:
|
|
raise ValueError("Invalid recovery snapshot fields")
|
|
if manifest['format'] != 'starpilot-raw-settings' or type(manifest['version']) is not int or manifest['version'] != 1:
|
|
raise ValueError("Unsupported recovery snapshot")
|
|
values = _read_namespace(snapshot / 'values')
|
|
inventory = _inventory(values)
|
|
if inventory != manifest['entries'] or digest(canonical_json(inventory)) != snapshot.name:
|
|
raise ValueError("Recovery snapshot integrity failure")
|
|
return values
|
|
|
|
|
|
def restore_snapshot(snapshot, namespace):
|
|
"""Restore exact bytes to a NEW, unused namespace; never overwrite live state."""
|
|
values = load_snapshot(snapshot)
|
|
namespace = Path(namespace).absolute()
|
|
namespace.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
with _params_lock(namespace):
|
|
if namespace.exists() or namespace.is_symlink():
|
|
raise FileExistsError("Recovery requires an unused destination namespace")
|
|
temporary = Path(tempfile.mkdtemp(prefix='.restored-', dir=namespace.parent))
|
|
try:
|
|
for key, value in values.items():
|
|
_write(temporary / key, value)
|
|
_fsync_dir(temporary)
|
|
namespace.symlink_to(temporary, target_is_directory=True)
|
|
_fsync_dir(namespace.parent)
|
|
except BaseException:
|
|
if not namespace.is_symlink():
|
|
shutil.rmtree(temporary)
|
|
raise
|
|
return namespace
|
|
|
|
|
|
def validate_bundle(bundle):
|
|
if not isinstance(bundle, dict) or set(bundle) != {'format', 'version', 'source', 'preferences', 'pending', 'omitted'}:
|
|
raise ValueError("Invalid settings bundle fields")
|
|
if bundle['format'] != 'starpilot-settings' or type(bundle['version']) is not int or bundle['version'] != 1:
|
|
raise ValueError("Unsupported settings bundle")
|
|
source = bundle['source']
|
|
required = {'revision', 'registry_sha256', 'snapshot_sha256'}
|
|
if not isinstance(source, dict) or not required <= source.keys() or source.keys() - required - {'cache_snapshot_sha256'}:
|
|
raise ValueError("Invalid settings source")
|
|
for key, value in source.items():
|
|
_hash(value, 40 if key == 'revision' else 64)
|
|
preferences = bundle['preferences']
|
|
if not isinstance(preferences, dict) or not preferences.keys() <= PREFERENCES or 'AlwaysOnLateral' not in preferences:
|
|
raise ValueError("Unreviewed active preference")
|
|
if any(type(value) is not bool for value in preferences.values()):
|
|
raise ValueError("Preferences require canonical booleans")
|
|
seen = set(preferences)
|
|
for group in ('pending', 'omitted'):
|
|
if not isinstance(bundle[group], list) or len(bundle[group]) > MAX_KEYS:
|
|
raise ValueError("Invalid settings disposition list")
|
|
for entry in bundle[group]:
|
|
expected = {'key', 'type', 'raw_base64', 'sha256'} if group == 'pending' else {'key', 'reason'}
|
|
if not isinstance(entry, dict) or set(entry) != expected:
|
|
raise ValueError("Invalid settings disposition fields")
|
|
key = _key(entry['key'])
|
|
if key in seen:
|
|
raise ValueError("Duplicate settings disposition")
|
|
seen.add(key)
|
|
if group == 'omitted':
|
|
if not isinstance(entry['reason'], str) or not entry['reason']:
|
|
raise ValueError("Missing settings omission reason")
|
|
continue
|
|
if not isinstance(entry['type'], str) or entry['type'] not in {'BOOL', 'INT', 'FLOAT', 'TIME', 'JSON', 'STRING'}:
|
|
raise ValueError("Invalid pending settings type")
|
|
if not isinstance(entry['raw_base64'], str) or len(entry['raw_base64']) > 4 * ((MAX_VALUE_BYTES + 2) // 3):
|
|
raise ValueError("Invalid pending settings encoding size")
|
|
try:
|
|
value = base64.b64decode(entry['raw_base64'], validate=True)
|
|
except (ValueError, binascii.Error) as error:
|
|
raise ValueError("Invalid pending settings encoding") from error
|
|
_hash(entry['sha256'])
|
|
if len(value) > MAX_VALUE_BYTES or digest(value) != entry['sha256']:
|
|
raise ValueError("Pending settings integrity failure")
|
|
if len(canonical_json(bundle)) > MAX_SNAPSHOT_BYTES:
|
|
raise ValueError("Settings bundle exceeds recovery limit")
|
|
|
|
|
|
def decode_bundle(encoded):
|
|
"""Parse serialized input without silently accepting duplicate JSON members."""
|
|
if not isinstance(encoded, bytes) or len(encoded) > MAX_SNAPSHOT_BYTES:
|
|
raise ValueError("Invalid settings bundle size or encoding")
|
|
|
|
def unique_object(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError("Duplicate settings JSON member")
|
|
result[key] = value
|
|
return result
|
|
|
|
bundle = json.loads(encoded, object_pairs_hook=unique_object)
|
|
validate_bundle(bundle)
|
|
return bundle
|
|
|
|
|
|
def stage_settings(bundle, storage, namespace):
|
|
"""Persist a reviewed-format bundle outside Params. Nothing is activated."""
|
|
validate_bundle(bundle)
|
|
_outside_params(Path(namespace).absolute(), Path(storage).absolute())
|
|
encoded = canonical_json(bundle)
|
|
destination = _private_dir(Path(storage) / 'staged') / f'{digest(encoded)}.json'
|
|
_atomic_write(destination, encoded)
|
|
return destination
|
|
|
|
|
|
def _migrate_first_start(params, namespace, storage, values, known, cache_keys, inspect_cache, *, retire_keys=None,
|
|
retire_action='archive incompatible reconstructible vehicle cache'):
|
|
actual = namespace.resolve(strict=True)
|
|
snapshot = _save_snapshot(values, storage / 'snapshots')
|
|
if load_snapshot(snapshot) != values:
|
|
raise MigrationRequired('Raw recovery snapshot readback failed')
|
|
prepared = dict(values)
|
|
actions = {}
|
|
for key, raw in values.items():
|
|
if retire_keys is not None:
|
|
if key in retire_keys:
|
|
prepared.pop(key)
|
|
actions[key] = ('archive incompatible reconstructible vehicle cache' if key in known else
|
|
'archive unknown saved key; retire without interpretation')
|
|
continue
|
|
if key not in known or key not in OPERATIONAL_KEYS:
|
|
prepared.pop(key)
|
|
actions[key] = 'archive prior software state; initialize fresh defaults'
|
|
elif getattr(params.get_type(key), 'name', None) == 'BOOL' and raw not in (b'0', b'1'):
|
|
prepared.pop(key)
|
|
actions[key] = 'archive invalid operational flag; initialize registry default'
|
|
report = {'format': 'starpilot-first-start-migration', 'version': 2,
|
|
'namespace': str(namespace), 'target': str(actual), 'snapshot': str(snapshot),
|
|
'status': 'prepared', 'actions': actions, 'attempted': [], 'written': []}
|
|
receipt = snapshot / 'migration.json'
|
|
_atomic_write(receipt, canonical_json(report))
|
|
try:
|
|
if namespace.resolve(strict=True) != actual or _read_namespace(namespace) != values:
|
|
raise MigrationRequired('Settings source changed before migration')
|
|
for key in sorted(actions):
|
|
if namespace.resolve(strict=True) != actual:
|
|
raise MigrationRequired('Settings namespace changed during migration')
|
|
report['attempted'].append(key)
|
|
_atomic_write(receipt, canonical_json(report))
|
|
if key in prepared:
|
|
_atomic_write(actual / key, prepared[key])
|
|
else:
|
|
(actual / key).unlink()
|
|
_fsync_dir(actual)
|
|
report['written'].append(key)
|
|
if namespace.resolve(strict=True) != actual or _read_namespace(namespace) != prepared:
|
|
raise MigrationRequired('Migrated settings readback differs from prepared batch')
|
|
report['status'] = 'migrated'
|
|
_atomic_write(receipt, canonical_json(report))
|
|
except Exception as error:
|
|
report.update(status='failed', error=type(error).__name__)
|
|
_atomic_write(receipt, canonical_json(report))
|
|
raise MigrationRequired(f'Settings migration interrupted; raw recovery snapshot: {snapshot}') from error
|
|
return prepared
|
|
|
|
|
|
def _dom_handoff(namespace):
|
|
path = namespace.parent / f'.starpilot-dom-handoff-{digest(str(namespace).encode())}.json'
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
except FileNotFoundError:
|
|
return None
|
|
except OSError as error:
|
|
raise MigrationRequired('Invalid Dom handoff marker') from error
|
|
try:
|
|
with os.fdopen(fd, 'rb') as source:
|
|
info = os.fstat(source.fileno())
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 4096:
|
|
raise ValueError('Invalid Dom handoff file')
|
|
raw = source.read(4097)
|
|
record = json.loads(raw)
|
|
if type(record) is not dict or set(record) != {'format', 'version', 'namespace', 'target', 'token'}:
|
|
raise ValueError('Invalid Dom handoff fields')
|
|
_hash(record['token'], 32)
|
|
expected = {'format': 'starpilot-dom-handoff', 'version': 1, 'namespace': str(namespace),
|
|
'target': str(namespace.resolve(strict=True)), 'token': record['token']}
|
|
if type(record['version']) is not int or raw != canonical_json(expected):
|
|
raise ValueError('Dom handoff does not bind this namespace')
|
|
return record['token']
|
|
except (OSError, ValueError, TypeError, KeyError) as error:
|
|
raise MigrationRequired('Invalid Dom handoff marker') from error
|
|
|
|
|
|
def prepare_manager_start(params, storage, *, dry_run=False, auto_migrate=False):
|
|
from openpilot.starpilot.schema_cache import CACHE_KEYS, inspect_cache
|
|
|
|
namespace, storage = Path(params.get_param_path()).absolute(), Path(storage).absolute()
|
|
_outside_params(namespace, storage)
|
|
marker_dir = storage / 'profiles' if dry_run else _private_dir(storage / 'profiles')
|
|
if dry_run and (marker_dir.exists() or marker_dir.is_symlink()):
|
|
info = marker_dir.lstat()
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
raise ValueError("Recovery storage must be a private, owned directory")
|
|
marker = marker_dir / f'{digest(str(namespace).encode())}.json'
|
|
with _params_lock(namespace, read_only=dry_run):
|
|
values = _read_namespace(namespace)
|
|
identity = {'version': 2, 'schema_epoch': 2, 'namespace': str(namespace), 'target': str(namespace.resolve())}
|
|
handoff = _dom_handoff(namespace)
|
|
consumed = None
|
|
try:
|
|
raw_identity = _read_value(marker)
|
|
saved_identity = json.loads(raw_identity)
|
|
candidate_identity = dict(identity)
|
|
if type(saved_identity) is dict and 'consumed_dom_token' in saved_identity:
|
|
_hash(saved_identity['consumed_dom_token'], 32)
|
|
candidate_identity['consumed_dom_token'] = saved_identity['consumed_dom_token']
|
|
initialized = raw_identity == canonical_json(candidate_identity)
|
|
if initialized:
|
|
consumed = candidate_identity.get('consumed_dom_token')
|
|
except (OSError, ValueError, TypeError):
|
|
initialized = False
|
|
foreign_handoff = handoff is not None and handoff != consumed
|
|
if consumed is not None:
|
|
identity['consumed_dom_token'] = consumed
|
|
known = {key.decode() if isinstance(key, bytes) else key for key in params.all_keys()}
|
|
if auto_migrate and not dry_run and (foreign_handoff or not initialized and values):
|
|
values = _migrate_first_start(params, namespace, storage, values, known, CACHE_KEYS, inspect_cache)
|
|
migrated = True
|
|
if foreign_handoff:
|
|
identity["consumed_dom_token"] = handoff
|
|
else:
|
|
migrated = False
|
|
unknown = set(values) - known
|
|
retired = unknown & RETIRED_KEYS
|
|
if auto_migrate and not dry_run and initialized and not foreign_handoff and retired:
|
|
values = _migrate_first_start(params, namespace, storage, values, known, CACHE_KEYS, inspect_cache,
|
|
retire_keys=retired, retire_action='archive setting removed from the registry')
|
|
unknown = set(values) - known
|
|
invalid_preferences = any(key in values and values[key] not in (b'0', b'1') for key in PREFERENCES)
|
|
incompatible_keys = {key for key in CACHE_KEYS if key in values and inspect_cache(key, values[key]).status != 'valid'}
|
|
invalid_booleans = any(getattr(params.get_type(key), 'name', None) == 'BOOL' and raw not in (b'0', b'1')
|
|
for key, raw in values.items() if key in known)
|
|
if (auto_migrate and not dry_run and initialized and not foreign_handoff and unknown and
|
|
not invalid_preferences and not invalid_booleans and not incompatible_keys and
|
|
'LocationFilterInitialState' not in values):
|
|
values = _migrate_first_start(params, namespace, storage, values, known, CACHE_KEYS, inspect_cache,
|
|
retire_keys=unknown)
|
|
unknown = set()
|
|
if (auto_migrate and not dry_run and initialized and incompatible_keys and
|
|
incompatible_keys <= {'CarParamsCache', 'CarParamsPersistent', 'CarParamsPrevRoute'} and
|
|
not unknown and not invalid_preferences and not invalid_booleans and 'LocationFilterInitialState' not in values):
|
|
values = _migrate_first_start(params, namespace, storage, values, known, CACHE_KEYS, inspect_cache,
|
|
retire_keys=incompatible_keys)
|
|
incompatible_keys = set()
|
|
incompatible_cache = bool(incompatible_keys)
|
|
# This retired cache has no compatible producer or conversion.
|
|
incompatible_cache |= 'LocationFilterInitialState' in values
|
|
unqualified = (foreign_handoff and not migrated or unknown or invalid_preferences or incompatible_cache or
|
|
(not initialized and not migrated and bool(set(values) - PREFERENCES)))
|
|
if unqualified:
|
|
if dry_run:
|
|
reasons = []
|
|
if foreign_handoff:
|
|
reasons.append('unconsumed Dom handoff')
|
|
if unknown:
|
|
reasons.append('unknown saved keys')
|
|
if invalid_preferences:
|
|
reasons.append('invalid saved preferences')
|
|
if incompatible_cache:
|
|
reasons.append('incompatible retained cache')
|
|
if not initialized and bool(set(values) - PREFERENCES):
|
|
reasons.append('unqualified existing namespace')
|
|
raise MigrationRequired('Settings require migration before startup: ' + ', '.join(reasons))
|
|
snapshot = _save_snapshot(values, storage / 'snapshots')
|
|
raise MigrationRequired(f"Settings require migration before startup; raw recovery snapshot: {snapshot}")
|
|
if (not initialized or foreign_handoff) and not dry_run:
|
|
_atomic_write(marker, canonical_json(identity))
|