208 lines
9.8 KiB
Python
208 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Separate-mounted synthetic volumes; no backend execution or installed data."""
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
|
|
import c01_views as views
|
|
import c01_supervisor as supervisor
|
|
from c01_children import Children, drop_credentials, process_group_path
|
|
|
|
assert os.geteuid() == 0 and os.environ.get('ARCHY_C01_VIEW_LOCATORS_FIXTURE') == '1'
|
|
assert Path(process_group_path(os.getpid())).parent.name.startswith('opencode-c01-view-locators-')
|
|
for directory in ('/run', '/var/lib/archipelago', '/var/lib/containers'):
|
|
rows = [line.split() for line in Path('/proc/self/mountinfo').read_text().splitlines()]
|
|
assert any(row[4] == directory and row[row.index('-') + 1] == 'tmpfs' for row in rows)
|
|
|
|
# Create NEW mounts after entering our own namespace. Systemd's outer tmpfs
|
|
# alone did not reproduce the detached pre-unshare mount's relative fd rendering.
|
|
outer = views.PrivateMounts()
|
|
volume = Path('/var/lib/archipelago/volume'); volume.mkdir()
|
|
assert views.LIBC.mount(b'tmpfs', os.fsencode(volume), b'tmpfs',
|
|
views.MS_NOSUID | views.MS_NODEV | views.MS_NOEXEC, b'size=8m,mode=700') == 0
|
|
os.chown(volume, 1000, 1000)
|
|
lnd = volume / 'lnd'; lnd.mkdir(mode=0o700); os.chown(lnd, 1000, 1000)
|
|
fds = []
|
|
def pin(path):
|
|
fd = views.pin_directory(path); fds.append(fd)
|
|
return views.PinnedSource.capture(fd, path)
|
|
source = pin(lnd)
|
|
volume_source = pin(volume)
|
|
before = (views.identity(source.fd), os.fstatvfs(source.fd).f_flag)
|
|
parent_namespace = views.namespace_identity()
|
|
targets = Path('/run/locator-targets'); targets.mkdir(mode=0o755)
|
|
|
|
|
|
def refused(action):
|
|
try: action()
|
|
except (OSError, ValueError): return
|
|
raise AssertionError('unexpected source admission')
|
|
|
|
|
|
def case(name, action):
|
|
owner = Children()
|
|
try:
|
|
def child(control):
|
|
try:
|
|
action()
|
|
control.send(b'PASS')
|
|
except BaseException:
|
|
traceback.print_exc(); raise
|
|
record = owner.spawn('application', child, keep=fds)
|
|
record.channel.settimeout(15)
|
|
assert record.channel.recv(16) == b'PASS', name
|
|
end = time.monotonic() + 5
|
|
while owner.children:
|
|
assert owner.poll() == []
|
|
assert time.monotonic() < end, name
|
|
time.sleep(0.005)
|
|
assert owner.results['application'] == 0
|
|
finally:
|
|
owner.request_stop()
|
|
end = time.monotonic() + 5
|
|
while owner.children:
|
|
owner.poll()
|
|
assert time.monotonic() < end, 'fixture retained owned child'
|
|
time.sleep(0.005)
|
|
owner.close()
|
|
print('PASS ' + name, flush=True)
|
|
|
|
|
|
def mapped_volume():
|
|
# A fork alone leaves the preparing namespace alive in the test parent.
|
|
# Framework's single-process probe destroys its old namespace at unshare.
|
|
# Give this child a sole-owner staging namespace and pin its own mount FD.
|
|
views.PrivateMounts()
|
|
local_fd = views.pin_directory(lnd)
|
|
detached = views.PinnedSource.capture(local_fd, lnd)
|
|
view = views.PrivateMounts()
|
|
rendered = os.readlink(f'/proc/self/fd/{local_fd}')
|
|
assert rendered == '/lnd', ('separate-volume reproducer did not detach the FD path', rendered)
|
|
destination = targets / 'readonly'; destination.mkdir()
|
|
view.idmapped_directory(detached, destination, 1000, 60002, readonly=True)
|
|
meta = destination.stat()
|
|
assert (meta.st_dev, meta.st_ino, meta.st_uid, meta.st_gid, meta.st_mode & 0o7777) == (
|
|
source.expected[0], source.expected[1], 60002, 60002, source.expected[4])
|
|
assert os.statvfs(destination).f_flag & (views.PROTECTIONS) == source.protections | os.ST_RDONLY
|
|
assert (views.identity(source.fd), os.fstatvfs(source.fd).f_flag) == before
|
|
os.close(local_fd)
|
|
case('separate-mounted-volume-relative-fd-rendering-uses-preunshare-authenticated-locator', mapped_volume)
|
|
|
|
|
|
def hidden():
|
|
view = views.PrivateMounts()
|
|
view.cover(lnd, 0o700)
|
|
destination = targets / 'hidden'; destination.mkdir()
|
|
refused(lambda: view.idmapped_directory(source, destination, 1000, 60002))
|
|
case('covered-source-refused-despite-retained-old-fd', hidden)
|
|
|
|
for name in ('renamed', 'replaced', 'unlinked', 'symlink'):
|
|
path = volume / name; path.mkdir(mode=0o700); os.chown(path, 1000, 1000)
|
|
selected = pin(path)
|
|
def mutate(name=name, path=path, selected=selected):
|
|
view = views.PrivateMounts()
|
|
if name == 'unlinked':
|
|
path.rmdir()
|
|
else:
|
|
path.rename(volume / (name + '-original'))
|
|
if name in ('replaced', 'unlinked'):
|
|
path.mkdir(mode=0o700); os.chown(path, 1000, 1000)
|
|
if name == 'symlink':
|
|
path.symlink_to(volume / (name + '-original'))
|
|
destination = targets / name; destination.mkdir()
|
|
refused(lambda: view.idmapped_directory(selected, destination, 1000, 60002))
|
|
assert destination.stat().st_uid == 0
|
|
case(name + '-source-refused-without-fd-name-fallback', mutate)
|
|
|
|
other = volume / 'other'; other.mkdir(mode=0o700); os.chown(other, 1000, 1000)
|
|
refused(lambda: views.PinnedSource.capture(source.fd, other))
|
|
link = volume / 'intermediate'; link.symlink_to(volume)
|
|
refused(lambda: views.PinnedSource.capture(source.fd, link / 'lnd'))
|
|
print('PASS capture-rejects-substituted-inode-and-intermediate-symlink', flush=True)
|
|
|
|
def late_capture():
|
|
view = views.PrivateMounts()
|
|
late = views.PinnedSource.capture(source.fd, lnd)
|
|
destination = targets / 'late'; destination.mkdir()
|
|
refused(lambda: view.idmapped_directory(late, destination, 1000, 60002))
|
|
case('locator-captured-after-namespace-change-refused', late_capture)
|
|
|
|
# Equal inode/UID/mode is insufficient if the reopened mount weakened flags.
|
|
alias = Path('/var/lib/archipelago/restricted-alias'); alias.mkdir()
|
|
assert views.LIBC.mount(os.fsencode(lnd), os.fsencode(alias), None, 4096, None) == 0
|
|
assert views.LIBC.mount(None, os.fsencode(alias), None, 4096 | 32 | 1 | 2 | 4 | 8, None) == 0
|
|
restricted = pin(alias)
|
|
restricted_flags = os.statvfs(alias).f_flag
|
|
def weakened():
|
|
view = views.PrivateMounts()
|
|
assert views.LIBC.mount(None, os.fsencode(alias), None, 4096 | 32 | 2 | 4 | 8, None) == 0
|
|
assert views.identity(restricted.fd) == restricted.expected
|
|
assert not os.statvfs(alias).f_flag & os.ST_RDONLY
|
|
destination = targets / 'weakened'; destination.mkdir()
|
|
refused(lambda: view.idmapped_directory(restricted, destination, 1000, 60002))
|
|
case('same-inode-reopened-mount-with-weaker-protections-refused', weakened)
|
|
assert os.statvfs(alias).f_flag == restricted_flags
|
|
|
|
def changed_mode():
|
|
view = views.PrivateMounts()
|
|
destination = targets / 'changed-mode'; destination.mkdir()
|
|
try:
|
|
lnd.chmod(0o750) # This is synthetic fixture data only.
|
|
refused(lambda: view.idmapped_directory(source, destination, 1000, 60002))
|
|
finally:
|
|
lnd.chmod(0o700)
|
|
case('pinned-source-mode-change-refused', changed_mode)
|
|
|
|
# Exercise actual Prepared.role_views callers with a synthetic, NEVER executed
|
|
# proxy file on a second separate mount. No old backend/proxy artifact is used.
|
|
prepared = supervisor.Prepared.__new__(supervisor.Prepared)
|
|
prepared.fds = []
|
|
prepared.value = {'runtime_uid': 1000, 'runtime_gid': 1000, 'runtime_data_dir': str(volume)}
|
|
prepared.backings, prepared.backing_sources = {}, {}
|
|
private = Path('/var/lib/containers/private'); private.mkdir(mode=0o700)
|
|
for name in ('manager', 'snapshot', 'authority'):
|
|
path = private / name; path.mkdir(mode=0o700); os.chown(path, 1000, 1000)
|
|
cap = pin(path)
|
|
prepared.backings[name], prepared.backing_sources[name] = cap.fd, cap
|
|
prepared.runtime, prepared.runtime_source = volume_source.fd, volume_source
|
|
payload = Path('/var/lib/containers/payload'); payload.mkdir()
|
|
assert views.LIBC.mount(b'tmpfs', os.fsencode(payload), b'tmpfs', 2 | 4, b'size=1m,mode=755') == 0
|
|
proxy = payload / 'proxy'; proxy.touch(mode=0o555)
|
|
prepared.proxy = os.open(proxy, os.O_PATH | os.O_CLOEXEC); fds.append(prepared.proxy)
|
|
prepared.proxy_source = views.PinnedSource.capture(prepared.proxy, proxy)
|
|
proxy_identity = views.identity(prepared.proxy)
|
|
def composed_role():
|
|
views.PrivateMounts()
|
|
# Capture mounts owned by this sole-process staging namespace; role_views
|
|
# will destroy it at its own unshare, like the failing real-volume probe.
|
|
for name, cap in list(prepared.backing_sources.items()):
|
|
local = cap.reopen()
|
|
prepared.backings[name] = local
|
|
prepared.backing_sources[name] = views.PinnedSource.capture(local, cap.locator)
|
|
prepared.runtime = prepared.runtime_source.reopen()
|
|
prepared.runtime_source = views.PinnedSource.capture(prepared.runtime, volume)
|
|
prepared.proxy = prepared.proxy_source.reopen()
|
|
prepared.proxy_source = views.PinnedSource.capture(prepared.proxy, proxy)
|
|
prepared.role_views(SimpleNamespace(pw_uid=60001, pw_gid=60001), 'application')
|
|
assert os.readlink(f'/proc/self/fd/{prepared.proxy}') == '/proxy'
|
|
drop_credentials(60001, 60001, os.getppid())
|
|
assert (supervisor.BASE / 'manager').stat().st_uid == 60001
|
|
assert (volume / 'lnd').stat().st_uid == 60001
|
|
alias = supervisor.BASE / 'bin/podman'
|
|
assert (alias.stat().st_dev, alias.stat().st_ino) == proxy_identity[:2]
|
|
assert os.statvfs(alias).f_flag & os.ST_RDONLY
|
|
refused(lambda: (private / 'authority').stat())
|
|
case('prepared-role-views-and-proxy-alias-use-captured-separate-mount-locators', composed_role)
|
|
|
|
assert views.namespace_identity() == parent_namespace
|
|
assert (views.identity(source.fd), os.fstatvfs(source.fd).f_flag) == before
|
|
assert views.identity(prepared.proxy) == proxy_identity
|
|
assert not Path(f'/proc/self/task/{os.getpid()}/children').read_text().strip()
|
|
for fd in fds: os.close(fd)
|
|
print('PASS parent-mount-flags-metadata-and-lifecycle-preserved', flush=True)
|
|
# No recursive cleanup: private mounts die with this disposable process/unit.
|