Add shared managed lock broker
This commit is contained in:
@@ -14,6 +14,14 @@ _SPEC_PATH = (Path(__file__).resolve().parents[4] /
|
||||
_spec = importlib.util.spec_from_file_location('gitea_native_spec_support', _SPEC_PATH)
|
||||
_support = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_support)
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock.py'
|
||||
_lock_spec = importlib.util.spec_from_file_location('gitea_managed_lock', _LOCK_PATH)
|
||||
_locks = importlib.util.module_from_spec(_lock_spec)
|
||||
_lock_spec.loader.exec_module(_locks)
|
||||
_BROKER_PATH = Path(__file__).resolve().parents[5] / 'lib/managed_lock_broker.py'
|
||||
_broker_spec = importlib.util.spec_from_file_location('gitea_managed_lock_broker', _BROKER_PATH)
|
||||
_broker = importlib.util.module_from_spec(_broker_spec)
|
||||
_broker_spec.loader.exec_module(_broker)
|
||||
ValidationError = _support.ValidationError
|
||||
loads_json = _support.loads_json
|
||||
collect_pages = _support.collect_pages
|
||||
@@ -646,83 +654,158 @@ class ApiAdapter:
|
||||
return data
|
||||
|
||||
|
||||
def _validate_registered_source(plan, policy_path, deadline_monotonic):
|
||||
import math, os, stat, subprocess, time
|
||||
if type(deadline_monotonic) not in [int, float] or not math.isfinite(deadline_monotonic):
|
||||
fail('invalid or expired Issue lease')
|
||||
t = plan['target']
|
||||
info = _support.validate_cache_paths(policy_path, t['owner'], t['repository'], t['issue'])
|
||||
wt = Path(info['repository']) / 'worktrees' / f'issue-{t["issue"]}' / 'checkout'
|
||||
opened = []
|
||||
def close_opened():
|
||||
while opened:
|
||||
try: os.close(opened.pop())
|
||||
except OSError: pass
|
||||
def reject(message):
|
||||
close_opened()
|
||||
fail(message)
|
||||
def open_dir(path):
|
||||
parts = path.parts
|
||||
fd = os.open(parts[0], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
opened.append(fd)
|
||||
try:
|
||||
for part in parts[1:]:
|
||||
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=fd)
|
||||
opened.append(nxt); os.close(fd); opened.remove(fd); fd = nxt
|
||||
return fd
|
||||
except BaseException:
|
||||
close_opened(); raise
|
||||
try:
|
||||
worktrees_fd = open_dir(wt.parent.parent)
|
||||
issue_fd = os.open(wt.parent.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=worktrees_fd)
|
||||
opened.append(issue_fd)
|
||||
checkout_fd = os.open(wt.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=issue_fd)
|
||||
opened.append(checkout_fd)
|
||||
for directory_fd in [worktrees_fd, issue_fd, checkout_fd]:
|
||||
st = os.fstat(directory_fd)
|
||||
if (not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid()
|
||||
or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)):
|
||||
reject('unsafe existing worktree path')
|
||||
state_fd = os.open('state.json', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=issue_fd)
|
||||
opened.append(state_fd)
|
||||
git_pointer_fd = os.open('.git', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=checkout_fd)
|
||||
opened.append(git_pointer_fd)
|
||||
state_st = os.fstat(state_fd); git_st = os.fstat(git_pointer_fd)
|
||||
if not stat.S_ISREG(state_st.st_mode) or state_st.st_uid != os.getuid() or stat.S_IMODE(state_st.st_mode) != 0o600:
|
||||
reject('unsafe worktree state')
|
||||
if not stat.S_ISREG(git_st.st_mode) or git_st.st_uid != os.getuid():
|
||||
reject('invalid worktree Git pointer')
|
||||
chunks = []
|
||||
while chunk := os.read(state_fd, 65536):
|
||||
chunks.append(chunk)
|
||||
try:
|
||||
state = loads_json(b''.join(chunks).decode('utf-8'))
|
||||
except Exception:
|
||||
close_opened()
|
||||
raise
|
||||
os.lseek(git_pointer_fd, 0, os.SEEK_SET)
|
||||
pointer = os.read(git_pointer_fd, 4096).decode('utf-8').strip()
|
||||
if not pointer.startswith('gitdir: '):
|
||||
reject('invalid worktree Git pointer')
|
||||
worktree_git_fd = open_dir(Path(pointer[8:]))
|
||||
except OSError:
|
||||
close_opened()
|
||||
raise ValidationError('unsafe existing worktree path') from None
|
||||
|
||||
if (type(state) is not dict or state.get('schema') != 'gitea-agent.worktree/v1' or state.get('owner') != t['owner']
|
||||
or state.get('repository') != t['repository'] or type(state.get('issue')) is not int
|
||||
or state['issue'] != t['issue'] or state.get('created_by_role') != 'gitea-impl-agent'
|
||||
or state.get('mode') != 'detached'):
|
||||
reject('worktree registration identity mismatch')
|
||||
|
||||
git_binary = '/usr/bin/git'; binary_stat = Path(git_binary).lstat()
|
||||
if not stat.S_ISREG(binary_stat.st_mode) or binary_stat.st_uid != 0 or binary_stat.st_mode & 0o022:
|
||||
reject('untrusted system Git binary')
|
||||
env = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_OPTIONAL_LOCKS': '0'}
|
||||
git_dir_fd = open_dir(Path(info['repository_git']))
|
||||
def git(*args):
|
||||
try:
|
||||
process = subprocess.run([git_binary, '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null',
|
||||
'-c', 'safe.directory=/proc/self/fd/' + str(checkout_fd),
|
||||
'--git-dir=/proc/self/fd/' + str(worktree_git_fd),
|
||||
'--work-tree=/proc/self/fd/' + str(checkout_fd), *args], capture_output=True, text=True, env=env, timeout=15,
|
||||
pass_fds=(checkout_fd, worktree_git_fd, git_dir_fd))
|
||||
except Exception:
|
||||
close_opened()
|
||||
raise ValidationError('read-only Git check unavailable') from None
|
||||
if process.returncode or len(process.stdout) > 1000000:
|
||||
reject('read-only Git check failed')
|
||||
return process.stdout
|
||||
if git('rev-parse', 'HEAD').strip() != t['head']:
|
||||
reject('worktree HEAD mismatch')
|
||||
if git('status', '--porcelain=v1', '--untracked-files=all').strip():
|
||||
reject('existing worktree is not clean')
|
||||
if git('rev-parse', '--path-format=absolute', '--git-common-dir').strip() != info['repository_git']:
|
||||
reject('worktree common Git directory mismatch')
|
||||
records = git('--git-dir=/proc/self/fd/' + str(git_dir_fd), 'worktree', 'list', '--porcelain', '-z').split('\0\0')
|
||||
matches = [r.split('\0') for r in records if r.startswith('worktree ' + str(wt) + '\0')]
|
||||
if len(matches) != 1 or 'detached' not in matches[0] or not any(x == 'locked' or x.startswith('locked ') for x in matches[0]):
|
||||
reject('worktree detached/locked registration mismatch')
|
||||
close_opened()
|
||||
if time.monotonic() >= deadline_monotonic:
|
||||
fail('Issue lease expired during source checks')
|
||||
|
||||
|
||||
class HeldIssueLease:
|
||||
"""Validate a caller-owned Issue lock FD; never close/release the caller's FD."""
|
||||
def __init__(self, issue_fd, policy_path, deadline_monotonic):
|
||||
self.issue_fd = issue_fd
|
||||
self.policy_path = policy_path
|
||||
self.deadline_monotonic = deadline_monotonic
|
||||
self.issue_fd = issue_fd; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
|
||||
|
||||
def check(self, plan):
|
||||
import math, os, stat, subprocess, time
|
||||
import math, time
|
||||
validate_plan(plan)
|
||||
if (type(self.issue_fd) is not int or type(self.deadline_monotonic) not in [int, float]
|
||||
or not math.isfinite(self.deadline_monotonic) or time.monotonic() >= self.deadline_monotonic):
|
||||
fail('invalid or expired Issue lease')
|
||||
t = plan['target']
|
||||
info = _support.validate_cache_paths(self.policy_path, t['owner'], t['repository'], t['issue'])
|
||||
t = plan['target']; info = _support.validate_cache_paths(self.policy_path, t['owner'], t['repository'], t['issue'])
|
||||
lock = Path(info['locks']) / f'issue-{t["issue"]}.lock'
|
||||
try:
|
||||
current = os.fstat(self.issue_fd); disk = lock.lstat()
|
||||
if (not stat.S_ISREG(current.st_mode) or not stat.S_ISREG(disk.st_mode)
|
||||
or current.st_uid != os.getuid() or stat.S_IMODE(current.st_mode) != 0o600
|
||||
or current.st_nlink != 1 or (current.st_dev, current.st_ino) != (disk.st_dev, disk.st_ino)):
|
||||
fail('invalid Issue lock descriptor')
|
||||
# Linux fdinfo reports locks on this open file description. Do not
|
||||
# acquire/reacquire here: an opened but unheld FD is not a lease.
|
||||
records = [line.split() for line in Path(f'/proc/self/fdinfo/{self.issue_fd}').read_text().splitlines()]
|
||||
device_inode = f'{os.major(current.st_dev):02x}:{os.minor(current.st_dev):02x}:{current.st_ino}'
|
||||
if not any(len(row) == 9 and row[0] == 'lock:'
|
||||
and row[2:6] == ['FLOCK', 'ADVISORY', 'WRITE', str(os.getpid())]
|
||||
and row[6:] == [device_inode, '0', 'EOF'] for row in records):
|
||||
if not _locks.validate_held_flock(self.issue_fd, lock):
|
||||
fail('Issue lock is not already held by this process')
|
||||
except OSError:
|
||||
except (OSError, _locks.LockValidationError):
|
||||
raise ValidationError('Issue lock unavailable') from None
|
||||
wt = Path(info['repository']) / 'worktrees' / f'issue-{t["issue"]}' / 'checkout'
|
||||
for directory in [wt.parent.parent, wt.parent, wt]:
|
||||
st = directory.lstat()
|
||||
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||
fail('unsafe existing worktree path')
|
||||
state_file = wt.parent / 'state.json'; st = state_file.lstat()
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600:
|
||||
fail('unsafe worktree state')
|
||||
state = loads_json(state_file.read_text(encoding='utf-8'))
|
||||
if (type(state) is not dict or state.get('schema') != 'gitea-agent.worktree/v1' or state.get('owner') != t['owner']
|
||||
or state.get('repository') != t['repository'] or type(state.get('issue')) is not int
|
||||
or state['issue'] != t['issue'] or state.get('created_by_role') != 'gitea-impl-agent'
|
||||
or state.get('mode') != 'detached'):
|
||||
fail('worktree registration identity mismatch')
|
||||
st = (wt / '.git').lstat()
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid():
|
||||
fail('invalid worktree Git pointer')
|
||||
git_binary = '/usr/bin/git'
|
||||
binary_stat = Path(git_binary).lstat()
|
||||
if not stat.S_ISREG(binary_stat.st_mode) or binary_stat.st_uid != 0 or binary_stat.st_mode & 0o022:
|
||||
fail('untrusted system Git binary')
|
||||
env = {'PATH': '/usr/bin:/bin', 'GIT_CONFIG_GLOBAL': '/dev/null',
|
||||
'GIT_CONFIG_NOSYSTEM': '1', 'GIT_OPTIONAL_LOCKS': '0'}
|
||||
def git(*args):
|
||||
try:
|
||||
process = subprocess.run([git_binary, '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null',
|
||||
'-c', 'safe.directory=' + str(wt), *args],
|
||||
capture_output=True, text=True, env=env, timeout=15)
|
||||
except Exception:
|
||||
raise ValidationError('read-only Git check unavailable') from None
|
||||
if process.returncode or len(process.stdout) > 1000000:
|
||||
fail('read-only Git check failed')
|
||||
return process.stdout
|
||||
if git('-C', str(wt), 'rev-parse', 'HEAD').strip() != t['head']:
|
||||
fail('worktree HEAD mismatch')
|
||||
if git('-C', str(wt), 'status', '--porcelain=v1', '--untracked-files=all').strip():
|
||||
fail('existing worktree is not clean')
|
||||
if git('-C', str(wt), 'rev-parse', '--path-format=absolute', '--git-common-dir').strip() != info['repository_git']:
|
||||
fail('worktree common Git directory mismatch')
|
||||
records = git('--git-dir=' + info['repository_git'], 'worktree', 'list', '--porcelain', '-z').split('\0\0')
|
||||
matches = [r.split('\0') for r in records if r.startswith('worktree ' + str(wt) + '\0')]
|
||||
if len(matches) != 1 or 'detached' not in matches[0] or not any(x == 'locked' or x.startswith('locked ') for x in matches[0]):
|
||||
fail('worktree detached/locked registration mismatch')
|
||||
if time.monotonic() >= self.deadline_monotonic:
|
||||
fail('Issue lease expired during source checks')
|
||||
_validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
|
||||
|
||||
|
||||
class BrokerIssueLease:
|
||||
"""Validate an authenticated shared managed-lock broker Issue lease."""
|
||||
def __init__(self, handle_path, policy_path, deadline_monotonic):
|
||||
self.handle_path = handle_path; self.policy_path = policy_path; self.deadline_monotonic = deadline_monotonic
|
||||
|
||||
def check(self, plan):
|
||||
import math, time
|
||||
validate_plan(plan)
|
||||
if (type(self.deadline_monotonic) not in [int, float] or not math.isfinite(self.deadline_monotonic)
|
||||
or time.monotonic() >= self.deadline_monotonic):
|
||||
fail('invalid or expired Issue lease')
|
||||
t = plan['target']
|
||||
try:
|
||||
handle = _broker.read_handle_metadata(self.handle_path)
|
||||
status = _broker.request_handle(self.handle_path, 'status')
|
||||
except Exception:
|
||||
raise ValidationError('broker Issue lease unavailable') from None
|
||||
if (type(status) is not dict or status.get('status') not in ('held', 'repository-held')
|
||||
or status.get('owner') != t['owner'] or status.get('repository') != t['repository']
|
||||
or status.get('issue') != t['issue'] or status.get('role') != 'gitea-review-agent'
|
||||
or type(status.get('pid')) is not int or status.get('pid') != handle.get('pid')
|
||||
or type(handle.get('pid')) is not int or status.get('deadline') != handle.get('deadline')
|
||||
or type(status.get('deadline')) not in [int, float]
|
||||
or status.get('deadline') > self.deadline_monotonic or time.monotonic() >= status.get('deadline')):
|
||||
fail('broker Issue lease identity mismatch')
|
||||
_validate_registered_source(plan, self.policy_path, self.deadline_monotonic)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
||||
Reference in New Issue
Block a user