Files
LangBot/src/langbot/pkg/box/sandbox_files.py
T

140 lines
6.1 KiB
Python

"""Bounded, link-free outbox operations executed inside the bound sandbox."""
import os
import textwrap
def outbox_script(
outbox_root: str,
query_key: str,
*,
operation: str,
max_file_bytes: int,
max_files: int,
max_total_bytes: int,
) -> str:
settings = {
'workspace': os.path.dirname(outbox_root),
'outbox': os.path.basename(outbox_root),
'query': query_key,
'operation': operation,
'max_file_bytes': max_file_bytes,
'max_files': max_files,
'max_total_bytes': max_total_bytes,
}
return f'settings = {settings!r}\n' + textwrap.dedent("""\
import base64, errno, json, os, stat
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
out = []
budget = {'entries': 512, 'directories': 64, 'bytes': 0}
def component(value):
if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240
or any(c in value for c in ('/', '\\\\', '\\x00'))):
raise RuntimeError('Unsafe sandbox outbox path component')
return value
def read_directory(fd, prefix='', depth=0):
if depth > 8:
return
with os.scandir(fd) as entries:
for entry in entries:
if len(out) >= settings['max_files']:
return
if budget['entries'] <= 0:
raise RuntimeError('Sandbox outbox exceeds the directory-entry limit')
budget['entries'] -= 1
name = component(entry.name)
relative = prefix + '/' + name if prefix else name
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
if budget['directories'] <= 0:
raise RuntimeError('Sandbox outbox exceeds the directory limit')
budget['directories'] -= 1
flags = directory_flags
elif entry.is_file(follow_symlinks=False):
flags = file_flags
else:
continue
try:
child_fd = os.open(name, flags, dir_fd=fd)
except OSError as exc:
if exc.errno in {errno.ENOENT, errno.ELOOP, errno.ENOTDIR}:
continue
raise
try:
metadata = os.fstat(child_fd)
if flags == directory_flags and stat.S_ISDIR(metadata.st_mode):
read_directory(child_fd, relative, depth + 1)
continue
if not stat.S_ISREG(metadata.st_mode):
continue
remaining = settings['max_total_bytes'] - budget['bytes']
if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining:
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
with os.fdopen(child_fd, 'rb', closefd=False) as source:
data = source.read(settings['max_file_bytes'] + 1)
if len(data) > settings['max_file_bytes'] or len(data) > remaining:
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')})
budget['bytes'] += len(data)
finally:
os.close(child_fd)
def clear_directory(fd, depth=0):
if depth > 16:
raise RuntimeError('Sandbox cleanup exceeded its depth limit')
with os.scandir(fd) as entries:
for entry in entries:
if budget['entries'] <= 0:
raise RuntimeError('Sandbox cleanup exceeded its inode limit')
budget['entries'] -= 1
name = component(entry.name)
try:
child_fd = os.open(name, directory_flags, dir_fd=fd)
except FileNotFoundError:
continue
except OSError as exc:
if exc.errno not in {errno.ELOOP, errno.ENOTDIR}:
raise
try:
os.unlink(name, dir_fd=fd)
except FileNotFoundError:
pass
else:
try:
clear_directory(child_fd, depth + 1)
finally:
os.close(child_fd)
try:
os.rmdir(name, dir_fd=fd)
except FileNotFoundError:
pass
try:
workspace_fd = os.open(settings['workspace'], directory_flags)
try:
outbox_fd = os.open(component(settings['outbox']), directory_flags, dir_fd=workspace_fd)
try:
query_fd = os.open(component(settings['query']), directory_flags, dir_fd=outbox_fd)
try:
if settings['operation'] == 'read':
read_directory(query_fd)
elif settings['operation'] == 'clear':
budget['entries'] = 4096
clear_directory(query_fd)
else:
raise RuntimeError('Unknown sandbox outbox operation')
finally:
os.close(query_fd)
finally:
os.close(outbox_fd)
finally:
os.close(workspace_fd)
except FileNotFoundError:
pass
print(json.dumps(out))
""")