Files
oh-my-pi/tests/test_permissions_e2e.py
T
can1357 dc2eb47297 security: added slot_uid-aware git env and workspace ownership handling
- Added optional slot_uid handling to push requests and git operations, validating IDs 1..65535.
- Passed slot-specific subprocess kwargs into git helpers, including safe.directory, HOME, user/group and umask.
- Injected slot-safe repo env helpers to scrub secrets, force git identity, and disable terminal prompts.
- Adjusted workspace and cache permissioning so slot users own workspaces with targeted chmod/chown behavior.
- Added tests for slot UID propagation, safe-directory env, and cross-slot push/retry behavior in unit and e2e suites.
2026-05-16 19:03:56 +02:00

336 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import platform
import shutil
import subprocess
import tempfile
from collections.abc import Iterator
from pathlib import Path
from typing import cast
import pytest
from robomp import host_tools
from robomp.db import Database
from robomp.github_backend import GitHubBackend
from robomp.github_client import IssueInfo, RepoInfo
from robomp.sandbox import LocalGitTransport, SandboxManager, Workspace
pytestmark = pytest.mark.skipif(
os.environ.get("ROBOMP_PERMISSION_E2E") != "1",
reason="set ROBOMP_PERMISSION_E2E=1 to run slot-permission e2e tests",
)
_SLOT_ONE = 2001
_SLOT_TWO = 2002
_SHARED_OMP_GID = 2000
_AUTHOR_NAME = "robomp-bot"
_AUTHOR_EMAIL = "robomp-bot@example.invalid"
_REPO = "octo/permission-e2e"
def _require_linux_root_toolchain() -> None:
if platform.system() != "Linux" or os.geteuid() != 0:
pytest.skip("slot permission e2e tests require Linux root so subprocesses can drop to omp-N UIDs")
missing = [cmd for cmd in ("git", "bun", "cargo", "python3") if shutil.which(cmd) is None]
if missing:
pytest.skip(f"slot permission e2e tests require tools on PATH: {', '.join(missing)}")
def _git(args: list[str], cwd: Path, *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env=env,
)
def _write_seed_repo(seed: Path) -> None:
(seed / "src").mkdir(parents=True)
(seed / "crates" / "core" / "src").mkdir(parents=True)
(seed / "package.json").write_text(
json.dumps(
{
"name": "permission-e2e",
"private": True,
"type": "module",
"scripts": {
"check": "bun run check:ts && cargo check --workspace",
"check:ts": "biome check src/index.ts",
"fix": "biome check --write --unsafe src/index.ts",
},
"devDependencies": {"@biomejs/biome": "^2.4.14"},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
(seed / ".gitignore").write_text("node_modules/\n", encoding="utf-8")
(seed / "src" / "index.ts").write_text("export const answer = 42;\n", encoding="utf-8")
(seed / "Cargo.toml").write_text(
'[workspace]\nmembers = ["crates/core"]\nresolver = "2"\n',
encoding="utf-8",
)
(seed / "rust-toolchain.toml").write_text(
'[toolchain]\nchannel = "stable"\nprofile = "minimal"\n',
encoding="utf-8",
)
(seed / "crates" / "core" / "Cargo.toml").write_text(
'[package]\nname = "permission-e2e-core"\nversion = "0.1.0"\nedition = "2021"\n\n[lib]\npath = "src/lib.rs"\n',
encoding="utf-8",
)
(seed / "crates" / "core" / "src" / "lib.rs").write_text(
"pub fn answer() -> u32 {\n 42\n}\n",
encoding="utf-8",
)
@pytest.fixture
def slot_tmp_path() -> Iterator[Path]:
root = Path(tempfile.mkdtemp(prefix="robomp-permission-e2e-", dir="/tmp"))
root.chmod(0o755)
try:
yield root
finally:
shutil.rmtree(root, ignore_errors=True)
def _share_tree_with_slots(path: Path) -> None:
for root, dirs, files in os.walk(path):
root_path = Path(root)
os.chown(root_path, 0, _SHARED_OMP_GID)
root_path.chmod(0o2770)
for dirname in dirs:
child = root_path / dirname
os.chown(child, 0, _SHARED_OMP_GID)
child.chmod(0o2770)
for filename in files:
child = root_path / filename
executable = child.stat().st_mode & 0o111
os.chown(child, 0, _SHARED_OMP_GID)
child.chmod(0o770 if executable else 0o660)
@pytest.fixture
def upstream_repo(slot_tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
upstream = slot_tmp_path / "upstream.git"
seed = slot_tmp_path / "seed"
seed.mkdir()
_write_seed_repo(seed)
_git(["init", "--initial-branch=main", "--bare", str(upstream)], cwd=slot_tmp_path)
_git(["init", "--initial-branch=main", str(seed)], cwd=slot_tmp_path)
_git(["-C", str(seed), "add", "."], cwd=slot_tmp_path)
commit_env = os.environ | {
"GIT_AUTHOR_NAME": "seed",
"GIT_AUTHOR_EMAIL": "seed@example.invalid",
"GIT_COMMITTER_NAME": "seed",
"GIT_COMMITTER_EMAIL": "seed@example.invalid",
}
_git(["-C", str(seed), "commit", "-m", "seed"], cwd=slot_tmp_path, env=commit_env)
_git(["-C", str(seed), "remote", "add", "origin", str(upstream)], cwd=slot_tmp_path)
_git(["-C", str(seed), "push", "origin", "main"], cwd=slot_tmp_path)
_share_tree_with_slots(upstream)
git_system_config = slot_tmp_path / "git-system.conf"
_git(["config", "--file", str(git_system_config), "--add", "safe.directory", str(upstream)], cwd=slot_tmp_path)
git_system_config.chmod(0o644)
monkeypatch.setenv("GIT_CONFIG_SYSTEM", str(git_system_config))
return upstream
@pytest.fixture
def tool_loop() -> Iterator[asyncio.AbstractEventLoop]:
loop = asyncio.new_event_loop()
try:
yield loop
finally:
loop.close()
def _ensure_workspace(
root: Path, upstream: Path, *, number: int, slot_uid: int, existing_branch: str | None = None
) -> Workspace:
manager = SandboxManager(root, transport=LocalGitTransport(token=None))
return manager.ensure_workspace(
repo=_REPO,
number=number,
title="permission e2e",
clone_url=str(upstream),
default_branch="main",
existing_branch=existing_branch,
author_name=_AUTHOR_NAME,
author_email=_AUTHOR_EMAIL,
slot_uid=slot_uid,
)
def _bindings(
*,
db: Database,
tool_loop: asyncio.AbstractEventLoop,
workspace: Workspace,
upstream: Path,
slot_uid: int,
) -> host_tools.ToolBindings:
repo = RepoInfo(full_name=_REPO, default_branch="main", clone_url=str(upstream), private=False)
issue = IssueInfo(
repo=_REPO,
number=workspace.issue_number,
title="permission e2e",
body="",
state="open",
author="human",
labels=(),
is_pull_request=False,
)
return host_tools.ToolBindings(
db=db,
github=cast(GitHubBackend, object()), # not used by these local-only host-tool paths
git_transport=LocalGitTransport(token=None),
repo=repo,
issue=issue,
workspace=workspace,
loop=tool_loop,
author_name=_AUTHOR_NAME,
author_email=_AUTHOR_EMAIL,
slot_uid=slot_uid,
)
def _run_ok(
bindings: host_tools.ToolBindings,
cmd: list[str] | tuple[str, ...],
*,
timeout: float = 180.0,
) -> subprocess.CompletedProcess[str]:
proc = host_tools._run_repo_command(bindings, cmd, timeout=timeout)
assert proc.returncode == 0, (
f"command failed as slot {bindings.slot_uid}: {' '.join(cmd)}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)
return proc
def _write_as_slot(bindings: host_tools.ToolBindings, relative_path: str, content: str) -> None:
_run_ok(
bindings,
[
"python3",
"-c",
(
"from pathlib import Path; "
"Path(__import__('sys').argv[1]).parent.mkdir(parents=True, exist_ok=True); "
"Path(__import__('sys').argv[1]).write_text(__import__('sys').argv[2], encoding='utf-8')"
),
relative_path,
content,
],
)
def _prepare_shared_cargo_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
cargo_home = tmp_path / "shared-cache" / "cargo"
cargo_target = tmp_path / "shared-cache" / "cargo-target"
for path in (cargo_home, cargo_target):
path.mkdir(parents=True)
os.chown(path, 0, _SHARED_OMP_GID)
path.chmod(0o2770)
monkeypatch.setenv("CARGO_HOME", str(cargo_home))
monkeypatch.setenv("CARGO_TARGET_DIR", str(cargo_target))
return cargo_target
def test_slot_workspace_runs_bun_biome_cargo_and_git_after_root_reentry(
slot_tmp_path: Path,
upstream_repo: Path,
db: Database,
tool_loop: asyncio.AbstractEventLoop,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_require_linux_root_toolchain()
cargo_target = _prepare_shared_cargo_cache(slot_tmp_path, monkeypatch)
workspaces = slot_tmp_path / "workspaces"
first = _ensure_workspace(workspaces, upstream_repo, number=101, slot_uid=_SLOT_ONE)
stale_bun_cache = first.root / ".omp-xdg" / "cache" / "bun-install" / "root-owned-stale"
stale_bun_cache.mkdir(parents=True, exist_ok=True)
stale_marker = stale_bun_cache / "marker.txt"
stale_marker.write_text("root-owned\n", encoding="utf-8")
stale_bun_cache.chmod(0o700)
stale_marker.chmod(0o600)
workspace = _ensure_workspace(
workspaces,
upstream_repo,
number=101,
slot_uid=_SLOT_ONE,
existing_branch=first.branch,
)
bindings = _bindings(db=db, tool_loop=tool_loop, workspace=workspace, upstream=upstream_repo, slot_uid=_SLOT_ONE)
_run_ok(bindings, ["bun", "install", "--no-progress"], timeout=300.0)
_run_ok(bindings, ["bun", "run", "check:ts"], timeout=180.0)
_run_ok(bindings, ["cargo", "check", "--workspace"], timeout=600.0)
host_tools._run_pre_publish_bun_check(bindings, {}, tool_name="gh_push_branch", stage="push")
runtime_env = host_tools._repo_command_env(bindings)
bun_cache = Path(runtime_env["BUN_INSTALL_CACHE_DIR"])
assert bun_cache.is_dir()
assert bun_cache.stat().st_uid == _SLOT_ONE
assert stale_marker.stat().st_uid == _SLOT_ONE
assert (cargo_target / "debug").is_dir()
assert (cargo_target / "debug").stat().st_gid == _SHARED_OMP_GID
_write_as_slot(bindings, "src/slot-generated.ts", "export const generatedBySlot = true;\n")
_run_ok(bindings, ["git", "add", "src/slot-generated.ts", "Cargo.lock", "bun.lock"])
_run_ok(bindings, ["git", "commit", "-m", "slot generated file"])
status = _run_ok(bindings, ["git", "status", "--porcelain", "--untracked-files=normal"])
assert status.stdout.strip() == ""
def test_git_pool_metadata_survives_root_push_and_retry_slot(
slot_tmp_path: Path,
upstream_repo: Path,
db: Database,
tool_loop: asyncio.AbstractEventLoop,
) -> None:
_require_linux_root_toolchain()
workspaces = slot_tmp_path / "workspaces"
first = _ensure_workspace(workspaces, upstream_repo, number=102, slot_uid=_SLOT_ONE)
first_bindings = _bindings(db=db, tool_loop=tool_loop, workspace=first, upstream=upstream_repo, slot_uid=_SLOT_ONE)
_write_as_slot(first_bindings, "src/first-slot.ts", "export const firstSlot = 1;\n")
_run_ok(first_bindings, ["git", "add", "src/first-slot.ts"])
_run_ok(first_bindings, ["git", "commit", "-m", "first slot commit"])
first_head = host_tools._guarded_push_branch(first_bindings, {}, "gh_push_branch", first.branch)
remote_head = _git(["--git-dir", str(upstream_repo), "rev-parse", first.branch], cwd=slot_tmp_path).stdout.strip()
assert remote_head == first_head
retry = _ensure_workspace(
workspaces,
upstream_repo,
number=102,
slot_uid=_SLOT_TWO,
existing_branch=first.branch,
)
retry_bindings = _bindings(db=db, tool_loop=tool_loop, workspace=retry, upstream=upstream_repo, slot_uid=_SLOT_TWO)
_run_ok(retry_bindings, ["git", "fsck", "--no-progress"], timeout=180.0)
_write_as_slot(retry_bindings, "src/retry-slot.ts", "export const retrySlot = 2;\n")
_run_ok(retry_bindings, ["git", "add", "src/retry-slot.ts"])
_run_ok(retry_bindings, ["git", "commit", "-m", "retry slot commit"])
retry_head = host_tools._guarded_push_branch(retry_bindings, {}, "gh_push_branch", retry.branch)
remote_retry_head = _git(
["--git-dir", str(upstream_repo), "rev-parse", retry.branch], cwd=slot_tmp_path
).stdout.strip()
assert remote_retry_head == retry_head
assert retry_head != first_head