From febb4ebdb28b69f7d1f024bb8c459abde688f686 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:45:03 +0000 Subject: [PATCH] Isolate Model Runtime Caches By User --- .../models/runtime_bridge/runtime.py | 38 +++++++- .../tests/test_persistent_runtime_cache.py | 95 ++++++++++++++++++- prebuilt.json | 2 +- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/openpilot/starpilot/models/runtime_bridge/runtime.py b/openpilot/starpilot/models/runtime_bridge/runtime.py index a601069e1f..4a60856b48 100644 --- a/openpilot/starpilot/models/runtime_bridge/runtime.py +++ b/openpilot/starpilot/models/runtime_bridge/runtime.py @@ -1,5 +1,7 @@ """Load existing bank and stock artifacts with one explicitly selected runtime.""" import hashlib +import os +import stat from pathlib import Path from typing import TYPE_CHECKING, cast @@ -20,6 +22,40 @@ _selection = None _protection = None +def _inference_cache_root(role): + if role not in ('driving', 'monitoring'): + raise ValueError('Unsupported model runtime role') + root = Path('/data/starpilot-model-runtime') + leaf = root / LEGACY_REVISION / role / MANIFEST_SHA256 + uid = os.geteuid() + trusted_parent = True + for current in (*reversed(root.parents), root, root / LEGACY_REVISION, + root / LEGACY_REVISION / role, leaf, leaf / 'xdg'): + try: + status = current.lstat() + except FileNotFoundError: + break + if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode): + raise ValueError('Invalid runtime cache directory') + if current in root.parents: + trusted_parent = trusted_parent and status.st_uid in (0, uid) and not stat.S_IMODE(status.st_mode) & 0o022 + if current.is_relative_to(root) and status.st_uid != uid: + if uid == 0: + raise ValueError('Root ownership mismatch requires an explicit private runtime cache') + if not trusted_parent: + raise ValueError('Untrusted parent for per-user runtime cache') + sibling = root.with_name(root.name + '-uid' + str(uid)) + try: + owner = sibling.lstat() + except FileNotFoundError: + return sibling + if (not stat.S_ISDIR(owner.st_mode) or owner.st_uid != uid or + stat.S_IMODE(owner.st_mode) & 0o022): + raise ValueError('Invalid per-user runtime cache root') + return sibling + return root + + def prepare(role): global _selection if _selection is not None and _selection.get('purpose') == 'compiler': @@ -32,7 +68,7 @@ def prepare(role): raise RuntimeError('A model child cannot change its execution role') return _selection _verify_policy() - cache = Path('/data/starpilot-model-runtime') + cache = _inference_cache_root(role) _selection = activate(BUNDLE, MANIFEST_SHA256, LEGACY_REVISION, role, cache) return _selection diff --git a/openpilot/starpilot/models/tests/test_persistent_runtime_cache.py b/openpilot/starpilot/models/tests/test_persistent_runtime_cache.py index 7103ae8db6..5c1decc8ee 100644 --- a/openpilot/starpilot/models/tests/test_persistent_runtime_cache.py +++ b/openpilot/starpilot/models/tests/test_persistent_runtime_cache.py @@ -1,5 +1,8 @@ """Persistent startup cache selection; not GPU or road qualification.""" -from pathlib import Path +import copy +import stat +from types import SimpleNamespace +from pathlib import Path, PurePosixPath from unittest.mock import patch import pytest @@ -13,6 +16,7 @@ def test_inference_cache_survives_reboot_without_changing_role_isolation(role): with (patch("openpilot.common.hardware.AGNOS", True), patch.object(runtime, "_selection", None), patch.object(runtime, "_verify_policy") as verify, + patch.object(runtime, "_inference_cache_root", return_value=Path("/data/starpilot-model-runtime")), patch.object(runtime, "activate", return_value=selected) as activate): assert runtime.prepare(role) is selected verify.assert_called_once() @@ -27,3 +31,92 @@ def test_host_does_not_create_device_cache(): patch.object(runtime, "_selection", None), patch.object(runtime, "activate", side_effect=AssertionError("must not touch device cache"))): assert runtime.prepare("driving") is None + + +class CachePath(PurePosixPath): + entries = {} + + def lstat(self): + try: + uid, mode = self.entries[str(self)] + except KeyError: + raise FileNotFoundError(str(self)) from None + if isinstance(uid, BaseException): + raise uid + return SimpleNamespace(st_uid=uid, st_mode=mode) + + +@pytest.fixture +def cache_metadata(): + root = CachePath("/data/starpilot-model-runtime") + revision = root / runtime.LEGACY_REVISION + CachePath.entries = {"/": (0, stat.S_IFDIR | 0o755), "/data": (1000, stat.S_IFDIR | 0o755)} + for role in ("driving", "monitoring"): + leaf = revision / role / runtime.MANIFEST_SHA256 + for path in (root, revision, revision / role, leaf, leaf / "xdg"): + CachePath.entries[str(path)] = (1000, stat.S_IFDIR | 0o700) + def route(role="monitoring", uid=1000): + with patch.object(runtime, "Path", CachePath), patch.object(runtime.os, "geteuid", return_value=uid): + return runtime._inference_cache_root(role) + yield root, revision, route + CachePath.entries = {} + + +@pytest.mark.parametrize("role", ["driving", "monitoring"]) +def test_owned_hot_cache_keeps_canonical_path_without_writes(cache_metadata, role): + root, _, route = cache_metadata + before = copy.deepcopy(CachePath.entries) + assert route(role) == root + assert CachePath.entries == before + + +def test_foreign_monitoring_cache_uses_only_fixed_uid_sibling(cache_metadata): + root, revision, route = cache_metadata + CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700) + before = copy.deepcopy(CachePath.entries) + assert route() == root.with_name(root.name + "-uid1000") + assert route("driving") == root + assert CachePath.entries == before + + +def test_root_mismatch_requires_explicit_private_cache(cache_metadata): + _, _, route = cache_metadata + before = copy.deepcopy(CachePath.entries) + with pytest.raises(ValueError, match="explicit private"): + route(uid=0) + assert CachePath.entries == before + + +@pytest.mark.parametrize("uid,mode", [(2000, 0o755), (0, 0o777), (1000, 0o775)]) +def test_uid_fallback_rejects_untrusted_parent(cache_metadata, uid, mode): + _, revision, route = cache_metadata + CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700) + CachePath.entries["/data"] = (uid, stat.S_IFDIR | mode) + with pytest.raises(ValueError, match="Untrusted parent"): + route() + + +@pytest.mark.parametrize("mode", [stat.S_IFLNK | 0o777, stat.S_IFREG | 0o600]) +def test_cache_route_rejects_symlink_or_nondirectory(cache_metadata, mode): + _, revision, route = cache_metadata + CachePath.entries[str(revision / "monitoring")] = (1000, mode) + with pytest.raises(ValueError, match="Invalid runtime cache directory"): + route() + + +@pytest.mark.parametrize("error", [PermissionError("denied"), OSError("unknown metadata")]) +def test_cache_metadata_errors_propagate_without_fallback(cache_metadata, error): + root, _, route = cache_metadata + CachePath.entries[str(root)] = (error, 0) + with pytest.raises(type(error)): + route() + + +@pytest.mark.parametrize("uid,mode", [(0, stat.S_IFDIR | 0o700), (1000, stat.S_IFDIR | 0o777), + (1000, stat.S_IFLNK | 0o777)]) +def test_existing_uid_sibling_must_be_private_and_owned(cache_metadata, uid, mode): + root, revision, route = cache_metadata + CachePath.entries[str(revision / "monitoring")] = (0, stat.S_IFDIR | 0o700) + CachePath.entries[str(root.with_name(root.name + "-uid1000"))] = (uid, mode) + with pytest.raises(ValueError, match="Invalid per-user"): + route() diff --git a/prebuilt.json b/prebuilt.json index ba47db1e7a..228b14dd43 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1 +1 @@ -{"protected_inventory_sha256": "58286887a983750a4bc8792358871756b56daa7125675133f3405f16821314d6", "schema_version": 1} +{"protected_inventory_sha256": "8e7227036605dc335abd5d53efb4b4eef019a46e826d8260330e20ef381d5872", "schema_version": 1}