Files
2026-09-30 20:30:56 +03:00

1441 lines
53 KiB
Python

from contextlib import contextmanager
import ctypes
from dataclasses import dataclass, field
from enum import Enum
import errno
import hashlib
import hmac
import os
import posixpath
import re
import secrets
import stat
import sys
import tempfile
import threading
try:
import fcntl
except ImportError: # pragma: no cover - non-Linux import portability
fcntl = None
MAX_MANAGED_ROOTS = 8
MAX_MANAGED_ROOT_ID_BYTES = 64
MAX_MANAGED_ROOT_PATH_BYTES = 4096
MAX_RELATIVE_PATH_BYTES = 4096
MAX_COMPONENT_BYTES = 255
MAX_PATH_DEPTH = 32
MAX_LISTING_ENTRIES = 1000
MAX_LISTING_BYTES = 1024 * 1024
MAX_FILE_BYTES = 64 * 1024 * 1024
MAX_RESULT_FILE_BYTES = 256 * 1024 * 1024
RUNTIME_LOG_ROOT_ID = 'runtime-logs'
RUNTIME_LOG_ROOT_PATH = '/data/runtime-linux/logs'
RUNTIME_KEYCHECK_ROOT_ID = 'runtime-keychecks'
RUNTIME_KEYCHECK_ROOT_PATH = '/data/runtime-linux/keychecks'
RUNTIME_RESULT_ROOT_ID = 'runtime-results'
RUNTIME_RESULT_ROOT_PATH = '/data/runtime-linux/results'
MANAGED_DATA_ROOT = '/data/managed-files'
_PREDEFINED_READ_ONLY_ROOTS = {
RUNTIME_KEYCHECK_ROOT_ID: RUNTIME_KEYCHECK_ROOT_PATH,
RUNTIME_LOG_ROOT_ID: RUNTIME_LOG_ROOT_PATH,
RUNTIME_RESULT_ROOT_ID: RUNTIME_RESULT_ROOT_PATH,
}
_ROOT_ID = re.compile(r'^[a-z][a-z0-9-]{0,63}$')
_DRIVE_PATH = re.compile(r'^/?[A-Za-z]:')
_DRIVE_COMPONENT = re.compile(r'^[A-Za-z]:')
_HASH = re.compile(r'^[0-9a-f]{64}$')
_RESULT_PROJECTION_FILE = re.compile(
r'^(?:found_secrets|scan_results)(?:\.g[0-9]{6})?\.jsonl$',
)
_TEMPORARY_PREFIX = '.truf-managed-file-'
_TEMPORARY_ATTEMPTS = 16
_READ_CHUNK_BYTES = 64 * 1024
_RENAME_NOREPLACE = 1
_PERMISSION_KEYS = frozenset({'list', 'read', 'create_replace', 'delete'})
_LIMIT_BOUNDS = {
'max_relative_path_bytes': MAX_RELATIVE_PATH_BYTES,
'max_component_bytes': MAX_COMPONENT_BYTES,
'max_path_depth': MAX_PATH_DEPTH,
'max_listing_entries': MAX_LISTING_ENTRIES,
'max_listing_bytes': MAX_LISTING_BYTES,
'max_file_bytes': MAX_FILE_BYTES,
}
class ManagedFileConfigurationError(ValueError):
def __init__(self, category, field):
self.category = category
self.field = tuple(field)
super().__init__('managed file root configuration is invalid')
class ManagedFileAccessError(RuntimeError):
def __init__(self, category):
self.category = category
super().__init__('managed file access failed')
class ManagedFileOperation(str, Enum):
LIST = 'list'
READ = 'read'
CREATE_REPLACE = 'create-replace'
DELETE = 'delete'
@dataclass(frozen=True)
class ManagedFilePermissions:
allow_list: bool
allow_read: bool
allow_create_replace: bool
allow_delete: bool
def allows(self, operation):
if operation is ManagedFileOperation.LIST:
return self.allow_list
if operation is ManagedFileOperation.READ:
return self.allow_read
if operation is ManagedFileOperation.CREATE_REPLACE:
return self.allow_create_replace
if operation is ManagedFileOperation.DELETE:
return self.allow_delete
return False
@dataclass(frozen=True)
class ManagedFileLimits:
max_relative_path_bytes: int
max_component_bytes: int
max_path_depth: int
max_listing_entries: int
max_listing_bytes: int
max_file_bytes: int
@dataclass(frozen=True)
class ManagedFileRoot:
root_id: str
absolute_path: str = field(repr=False)
permissions: ManagedFilePermissions
limits: ManagedFileLimits
@dataclass(frozen=True)
class ManagedFileRootRegistry:
roots: tuple[ManagedFileRoot, ...] = ()
def get(self, root_id):
if type(root_id) is not str:
return None
return next((root for root in self.roots if root.root_id == root_id), None)
def root_ids(self):
return tuple(root.root_id for root in self.roots)
@dataclass(frozen=True)
class ManagedFileIdentity:
sha256: str
byte_count: int
@dataclass(frozen=True)
class ManagedFileDirectoryEntry:
name: str
kind: str
byte_count: int | None
@dataclass(frozen=True)
class ManagedFileListing:
entries: tuple[ManagedFileDirectoryEntry, ...]
name_bytes: int
class ManagedFileSnapshot:
__slots__ = ('_handle', '_release', '_lock')
def __init__(self, handle, release):
self._handle = handle
self._release = release
self._lock = threading.Lock()
def chunks(self):
try:
while True:
with self._lock:
handle = self._handle
chunk = None if handle is None else handle.read(_READ_CHUNK_BYTES)
if not chunk:
break
yield chunk
finally:
self.close()
def close(self):
with self._lock:
handle = self._handle
release = self._release
self._handle = None
self._release = None
if handle is not None:
try:
handle.close()
except OSError:
pass
if release is not None:
release(self)
def __repr__(self):
return '<ManagedFileSnapshot>'
@dataclass(frozen=True)
class ManagedFileDownload:
identity: ManagedFileIdentity
content: bytes | None = field(repr=False, default=None)
snapshot: ManagedFileSnapshot | None = field(repr=False, default=None)
def __post_init__(self):
if (self.content is None) == (self.snapshot is None):
raise ValueError('managed file download requires one body source')
@dataclass(frozen=True)
class ManagedFileMutation:
before: ManagedFileIdentity | None
after: ManagedFileIdentity | None
written: bool
class ManagedFileOpenedTarget:
__slots__ = ('_descriptor', '_details')
def __init__(self, descriptor, details):
self._descriptor = descriptor
self._details = details
def fileno(self):
if self._descriptor is None:
raise ManagedFileAccessError('closed')
return self._descriptor
@property
def details(self):
return self._details
def _close(self):
descriptor = self._descriptor
self._descriptor = None
if descriptor is not None:
try:
os.close(descriptor)
except OSError:
pass
def __repr__(self):
return '<ManagedFileOpenedTarget>'
def _error(category, *field):
raise ManagedFileConfigurationError(category, ('root',) + field)
def _absolute_root_path(value):
if type(value) is not str:
_error('type', 'path')
try:
encoded = value.encode('utf-8')
except UnicodeEncodeError:
_error('bounds', 'path')
if (
not value or len(encoded) > MAX_MANAGED_ROOT_PATH_BYTES
or '\\' in value or '\x00' in value or _DRIVE_PATH.match(value)
or not value.startswith('/') or value == '/' or value.endswith('/')
or '//' in value
):
_error('deployment_path', 'path')
components = value.split('/')[1:]
if any(
component in ('', '.', '..')
or len(component.encode('utf-8')) > MAX_COMPONENT_BYTES
for component in components
) or posixpath.normpath(value) != value:
_error('deployment_path', 'path')
return value
def _permissions(value):
if type(value) is not dict:
_error('type', 'permissions')
if set(value) != _PERMISSION_KEYS:
category = 'unknown_key' if set(value) - _PERMISSION_KEYS else 'schema'
_error(category, 'permissions')
if any(type(value[name]) is not bool for name in _PERMISSION_KEYS):
_error('type', 'permissions')
return ManagedFilePermissions(
allow_list=value['list'],
allow_read=value['read'],
allow_create_replace=value['create_replace'],
allow_delete=value['delete'],
)
def _limits(value, root_id):
if type(value) is not dict:
_error('type', 'limits')
expected = set(_LIMIT_BOUNDS)
if set(value) != expected:
category = 'unknown_key' if set(value) - expected else 'schema'
_error(category, 'limits')
for name, maximum in _LIMIT_BOUNDS.items():
if name == 'max_file_bytes' and root_id == RUNTIME_RESULT_ROOT_ID:
maximum = MAX_RESULT_FILE_BYTES
if type(value[name]) is not int or not 1 <= value[name] <= maximum:
_error('bounds', 'limits', name)
if (
value['max_component_bytes'] > value['max_relative_path_bytes']
or value['max_listing_bytes'] < value['max_component_bytes']
):
_error('bounds', 'limits')
return ManagedFileLimits(**{name: value[name] for name in _LIMIT_BOUNDS})
def _allowed_root(root_id, path, permissions):
predefined_path = _PREDEFINED_READ_ONLY_ROOTS.get(root_id)
predefined_id = next((
candidate_id for candidate_id, candidate_path
in _PREDEFINED_READ_ONLY_ROOTS.items()
if candidate_path == path
), None)
if predefined_path is not None or predefined_id is not None:
if predefined_path != path or permissions != ManagedFilePermissions(
allow_list=True,
allow_read=True,
allow_create_replace=False,
allow_delete=False,
):
_error('deployment_path', 'path')
return
parent, name = posixpath.split(path)
if parent != MANAGED_DATA_ROOT or not name:
_error('deployment_path', 'path')
def normalize_managed_file_roots(value):
if type(value) is not dict:
raise ManagedFileConfigurationError('type', ('root',))
if len(value) > MAX_MANAGED_ROOTS:
raise ManagedFileConfigurationError('bounds', ('root',))
if any(type(root_id) is not str for root_id in value):
_error('bounds', 'id')
normalized = {}
roots = []
paths = set()
for root_id in sorted(value):
if (
type(root_id) is not str or not _ROOT_ID.fullmatch(root_id)
or len(root_id.encode('utf-8')) > MAX_MANAGED_ROOT_ID_BYTES
):
_error('bounds', 'id')
raw = value[root_id]
if type(raw) is not dict:
_error('type')
expected = {'path', 'permissions', 'limits'}
if set(raw) != expected:
category = 'unknown_key' if set(raw) - expected else 'schema'
_error(category)
path = _absolute_root_path(raw['path'])
permissions = _permissions(raw['permissions'])
limits = _limits(raw['limits'], root_id)
_allowed_root(root_id, path, permissions)
if path in paths:
_error('deployment_path', 'path')
paths.add(path)
root = ManagedFileRoot(root_id, path, permissions, limits)
roots.append(root)
normalized[root_id] = {
'path': path,
'permissions': {
'list': permissions.allow_list,
'read': permissions.allow_read,
'create_replace': permissions.allow_create_replace,
'delete': permissions.allow_delete,
},
'limits': {name: getattr(limits, name) for name in _LIMIT_BOUNDS},
}
return normalized, ManagedFileRootRegistry(tuple(roots))
def managed_file_root_registry_from_config(config):
if type(config) is not dict:
raise ManagedFileConfigurationError('type', ('root',))
supervisor = config.get('supervisor', {})
if type(supervisor) is not dict:
raise ManagedFileConfigurationError('type', ('root',))
worker_api = supervisor.get('worker_api', {})
if type(worker_api) is not dict:
raise ManagedFileConfigurationError('type', ('root',))
admin = worker_api.get('admin', {})
if admin is None:
admin = {}
if type(admin) is not dict:
raise ManagedFileConfigurationError('type', ('root',))
return normalize_managed_file_roots(admin.get('managed_file_roots', {}))[1]
def parse_managed_relative_path(value, limits):
if type(value) is not str or not isinstance(limits, ManagedFileLimits):
raise ManagedFileAccessError('invalid_path')
try:
encoded = value.encode('utf-8')
except UnicodeEncodeError:
raise ManagedFileAccessError('invalid_path') from None
if (
not value or len(encoded) > limits.max_relative_path_bytes
or value.startswith('/') or '\\' in value or '\x00' in value
or _DRIVE_PATH.match(value)
):
raise ManagedFileAccessError('invalid_path')
components = value.split('/')
if (
len(components) > limits.max_path_depth
or any(
component in ('', '.', '..')
or _DRIVE_COMPONENT.match(component)
or component.startswith(_TEMPORARY_PREFIX)
or len(component.encode('utf-8')) > limits.max_component_bytes
for component in components
)
):
raise ManagedFileAccessError('invalid_path')
return tuple(components)
def _close_descriptor(descriptor):
if descriptor is None:
return
try:
os.close(descriptor)
except OSError:
pass
def _descriptor_flags():
required = (
'O_PATH', 'O_DIRECTORY', 'O_NOFOLLOW', 'O_CLOEXEC',
'O_NONBLOCK', 'O_NOCTTY', 'O_CREAT', 'O_EXCL',
)
if (
not sys.platform.startswith('linux')
or os.open not in getattr(os, 'supports_dir_fd', ())
or any(type(getattr(os, name, None)) is not int for name in required)
):
raise ManagedFileAccessError('filesystem_unavailable')
return {
'directory': (
os.O_PATH | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
),
'list': os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
'inspect': os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC,
'read': os.O_RDONLY | os.O_NONBLOCK | os.O_NOCTTY | os.O_CLOEXEC,
'temporary': (
os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
),
}
def _filesystem_error_category(exc):
if exc.errno == errno.ENOENT:
return 'not_found'
if exc.errno in {
errno.ELOOP, errno.ENOTDIR, errno.EISDIR, errno.ENXIO,
errno.ENODEV, errno.EMLINK,
getattr(errno, 'EOPNOTSUPP', -1),
getattr(errno, 'ENOTSUP', -1),
}:
return 'unsafe_target'
return 'filesystem_unavailable'
def _file_revision(details):
return (
details.st_dev,
details.st_ino,
stat.S_IFMT(details.st_mode),
details.st_nlink,
details.st_size,
getattr(details, 'st_mtime_ns', None),
getattr(details, 'st_ctime_ns', None),
)
def _regular_single_link(details):
return stat.S_ISREG(details.st_mode) and details.st_nlink == 1
def _valid_hash(value):
if type(value) is not str or not _HASH.fullmatch(value):
raise ManagedFileAccessError('invalid_hash')
return value
def _rename_noreplace(source, target, directory_descriptor):
try:
function = ctypes.CDLL(None, use_errno=True).renameat2
function.argtypes = (
ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p,
ctypes.c_uint,
)
function.restype = ctypes.c_int
ctypes.set_errno(0)
result = function(
directory_descriptor, source.encode('utf-8'),
directory_descriptor, target.encode('utf-8'),
_RENAME_NOREPLACE,
)
except (AttributeError, OSError, TypeError, ValueError):
raise ManagedFileAccessError('filesystem_unavailable') from None
if result == 0:
return
error_number = ctypes.get_errno()
if error_number == errno.EEXIST:
raise ManagedFileAccessError('hash_conflict')
raise ManagedFileAccessError('filesystem_unavailable')
class ManagedFileTraversal:
def __init__(self, registry):
if not isinstance(registry, ManagedFileRootRegistry):
raise ValueError('managed file root registry is invalid')
if any(not isinstance(root, ManagedFileRoot) for root in registry.roots):
raise ValueError('managed file root registry is invalid')
root_ids = tuple(root.root_id for root in registry.roots)
try:
roots_are_valid = all(
_ROOT_ID.fullmatch(root.root_id)
and _absolute_root_path(root.absolute_path) == root.absolute_path
and isinstance(root.permissions, ManagedFilePermissions)
and isinstance(root.limits, ManagedFileLimits)
for root in registry.roots
)
except (AttributeError, ManagedFileConfigurationError, TypeError):
roots_are_valid = False
if not roots_are_valid or len(root_ids) != len(set(root_ids)):
raise ValueError('managed file root registry is invalid')
self._registry = registry
self._lock = threading.Lock()
self._mutation_lock = threading.Lock()
self._snapshot_gate = threading.BoundedSemaphore(1)
self._snapshots = set()
self._closed = False
self._roots = {}
self._flags = None
if not registry.roots:
return
self._flags = _descriptor_flags()
try:
for root in registry.roots:
self._roots[root.root_id] = self._open_root(root.absolute_path)
except BaseException:
descriptors = tuple(self._roots.values())
self._roots.clear()
self._closed = True
for descriptor in descriptors:
_close_descriptor(descriptor)
raise
def _open_root(self, absolute_path):
current = None
try:
current = os.open('/', self._flags['directory'])
if not stat.S_ISDIR(os.fstat(current).st_mode):
raise ManagedFileAccessError('root_unavailable')
for component in absolute_path.split('/')[1:]:
child = None
try:
child = os.open(
component, self._flags['directory'], dir_fd=current,
)
if not stat.S_ISDIR(os.fstat(child).st_mode):
raise ManagedFileAccessError('root_unavailable')
except BaseException:
_close_descriptor(child)
raise
previous = current
current = child
child = None
_close_descriptor(previous)
descriptor = current
current = None
return descriptor
except OSError:
raise ManagedFileAccessError('root_unavailable') from None
finally:
_close_descriptor(current)
def close(self):
with self._lock:
if self._closed:
return
self._closed = True
descriptors = tuple(self._roots.values())
snapshots = tuple(self._snapshots)
self._roots.clear()
self._snapshots.clear()
for descriptor in descriptors:
_close_descriptor(descriptor)
for snapshot in snapshots:
snapshot.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def _root_for_operation(self, root_id, operation):
root = self._registry.get(root_id)
if root is None:
raise ManagedFileAccessError('unknown_root')
if not root.permissions.allows(operation):
raise ManagedFileAccessError('operation_not_allowed')
return root
def _duplicate_root(self, root_id):
with self._lock:
if self._closed:
raise ManagedFileAccessError('closed')
descriptor = self._roots.get(root_id)
if descriptor is None:
raise ManagedFileAccessError('root_unavailable')
try:
return os.dup(descriptor)
except OSError:
raise ManagedFileAccessError('root_unavailable') from None
@staticmethod
def _relative_target_allowed(root_id, components, target_kind):
if root_id != RUNTIME_RESULT_ROOT_ID:
return True
if target_kind == 'directory':
return not components
return (
len(components) == 1
and _RESULT_PROJECTION_FILE.fullmatch(components[0]) is not None
)
def _require_relative_target(self, root_id, components, target_kind):
if not self._relative_target_allowed(root_id, components, target_kind):
raise ManagedFileAccessError('not_found')
def _snapshot_closed(self, snapshot):
with self._lock:
self._snapshots.discard(snapshot)
self._snapshot_gate.release()
def _new_snapshot_file(self, root_id):
root_descriptor = self._duplicate_root(root_id)
try:
return tempfile.TemporaryFile(
mode='w+b', dir=f'/proc/self/fd/{root_descriptor}',
)
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
finally:
_close_descriptor(root_descriptor)
@staticmethod
def _write_snapshot(handle, payload):
view = memoryview(payload)
written = 0
while written < len(view):
try:
count = handle.write(view[written:])
except InterruptedError:
continue
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
if not count:
raise ManagedFileAccessError('filesystem_unavailable')
written += count
def _snapshot_descriptor(self, root, descriptor):
handle = None
try:
before = os.fstat(descriptor)
if not _regular_single_link(before):
raise ManagedFileAccessError('unsafe_target')
if before.st_size > root.limits.max_file_bytes:
raise ManagedFileAccessError('limit_exceeded')
handle = self._new_snapshot_file(root.root_id)
os.lseek(descriptor, 0, os.SEEK_SET)
digest = hashlib.sha256()
byte_count = 0
while True:
remaining = root.limits.max_file_bytes - byte_count
if remaining < 0:
raise ManagedFileAccessError('limit_exceeded')
try:
chunk = os.read(
descriptor, min(_READ_CHUNK_BYTES, remaining + 1),
)
except InterruptedError:
continue
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
if not chunk:
break
byte_count += len(chunk)
if byte_count > root.limits.max_file_bytes:
raise ManagedFileAccessError('limit_exceeded')
digest.update(chunk)
self._write_snapshot(handle, chunk)
after = os.fstat(descriptor)
if (
not _regular_single_link(after)
or _file_revision(before) != _file_revision(after)
or byte_count != before.st_size
):
raise ManagedFileAccessError('concurrent_change')
handle.flush()
handle.seek(0)
with self._lock:
if self._closed:
raise ManagedFileAccessError('closed')
snapshot = ManagedFileSnapshot(handle, self._snapshot_closed)
self._snapshots.add(snapshot)
handle = None
return ManagedFileIdentity(digest.hexdigest(), byte_count), snapshot
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
finally:
if handle is not None:
try:
handle.close()
except OSError:
pass
def _open_relative(self, root, components, target_kind):
current = self._duplicate_root(root.root_id)
leaf = None
try:
for component in components[:-1]:
child = None
try:
child = os.open(
component, self._flags['directory'], dir_fd=current,
)
if not stat.S_ISDIR(os.fstat(child).st_mode):
raise ManagedFileAccessError('unsafe_target')
except BaseException:
_close_descriptor(child)
raise
previous = current
current = child
child = None
_close_descriptor(previous)
leaf_name = components[-1] if components else '.'
if target_kind == 'directory':
leaf = os.open(leaf_name, self._flags['list'], dir_fd=current)
details = os.fstat(leaf)
safe = stat.S_ISDIR(details.st_mode)
else:
leaf, details = self._open_file_at(current, leaf_name)
safe = True
if not safe:
raise ManagedFileAccessError('unsafe_target')
target = ManagedFileOpenedTarget(leaf, details)
leaf = None
return target
except OSError as exc:
raise ManagedFileAccessError(_filesystem_error_category(exc)) from None
finally:
_close_descriptor(leaf)
_close_descriptor(current)
def _open_file_at(self, directory_descriptor, leaf_name):
inspected = readable = None
try:
inspected = os.open(
leaf_name, self._flags['inspect'], dir_fd=directory_descriptor,
)
inspected_details = os.fstat(inspected)
if not _regular_single_link(inspected_details):
raise ManagedFileAccessError('unsafe_target')
try:
readable = os.open(
f'/proc/self/fd/{inspected}', self._flags['read'],
)
readable_details = os.fstat(readable)
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
if (
not _regular_single_link(readable_details)
or readable_details.st_dev != inspected_details.st_dev
or readable_details.st_ino != inspected_details.st_ino
):
raise ManagedFileAccessError('unsafe_target')
result = readable
readable = None
return result, readable_details
finally:
_close_descriptor(readable)
_close_descriptor(inspected)
@contextmanager
def _opened_parent(self, root, components):
current = operational = None
try:
current = self._duplicate_root(root.root_id)
for component in components[:-1]:
child = None
try:
child = os.open(
component, self._flags['directory'], dir_fd=current,
)
if not stat.S_ISDIR(os.fstat(child).st_mode):
raise ManagedFileAccessError('unsafe_target')
except BaseException:
_close_descriptor(child)
raise
previous = current
current = child
child = None
_close_descriptor(previous)
anchored = os.fstat(current)
operational = os.open('.', self._flags['list'], dir_fd=current)
opened = os.fstat(operational)
if (
not stat.S_ISDIR(opened.st_mode)
or opened.st_dev != anchored.st_dev
or opened.st_ino != anchored.st_ino
):
raise ManagedFileAccessError('unsafe_target')
descriptor = operational
operational = None
try:
yield descriptor, components[-1]
finally:
_close_descriptor(descriptor)
except OSError as exc:
raise ManagedFileAccessError(_filesystem_error_category(exc)) from None
finally:
_close_descriptor(operational)
_close_descriptor(current)
def _read_descriptor(self, descriptor, max_bytes, *, include_content):
payload = bytearray() if include_content else None
try:
before = os.fstat(descriptor)
if not _regular_single_link(before):
raise ManagedFileAccessError('unsafe_target')
if before.st_size > max_bytes:
raise ManagedFileAccessError('limit_exceeded')
try:
os.lseek(descriptor, 0, os.SEEK_SET)
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
digest = hashlib.sha256()
byte_count = 0
while True:
remaining = max_bytes - byte_count
if remaining < 0:
raise ManagedFileAccessError('limit_exceeded')
try:
chunk = os.read(
descriptor, min(_READ_CHUNK_BYTES, remaining + 1),
)
except InterruptedError:
continue
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
if not chunk:
break
byte_count += len(chunk)
if byte_count > max_bytes:
raise ManagedFileAccessError('limit_exceeded')
digest.update(chunk)
if payload is not None:
payload.extend(chunk)
after = os.fstat(descriptor)
if (
not _regular_single_link(after)
or _file_revision(before) != _file_revision(after)
or byte_count != before.st_size
):
raise ManagedFileAccessError('concurrent_change')
identity = ManagedFileIdentity(digest.hexdigest(), byte_count)
content = bytes(payload) if payload is not None else None
if payload is not None:
payload.clear()
return identity, before, content
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
except BaseException:
if payload is not None:
payload.clear()
raise
def _read_file_at(self, directory_descriptor, leaf_name, max_bytes, *, content):
descriptor = None
try:
descriptor, details = self._open_file_at(
directory_descriptor, leaf_name,
)
identity, stable, payload = self._read_descriptor(
descriptor, max_bytes, include_content=content,
)
if _file_revision(details) != _file_revision(stable):
raise ManagedFileAccessError('concurrent_change')
return identity, stable, payload
except OSError as exc:
raise ManagedFileAccessError(_filesystem_error_category(exc)) from None
finally:
_close_descriptor(descriptor)
def _revalidate_named_file(self, directory_descriptor, leaf_name, expected):
descriptor = None
try:
descriptor = os.open(
leaf_name, self._flags['inspect'], dir_fd=directory_descriptor,
)
current = os.fstat(descriptor)
if (
not _regular_single_link(current)
or _file_revision(current) != _file_revision(expected)
):
raise ManagedFileAccessError('concurrent_change')
return current
except ManagedFileAccessError:
raise
except OSError:
raise ManagedFileAccessError('concurrent_change') from None
finally:
_close_descriptor(descriptor)
def _new_temporary_file(self, directory_descriptor):
for _ in range(_TEMPORARY_ATTEMPTS):
name = f'{_TEMPORARY_PREFIX}{secrets.token_hex(12)}.tmp'
try:
descriptor = os.open(
name, self._flags['temporary'], 0o600,
dir_fd=directory_descriptor,
)
return name, descriptor
except FileExistsError:
continue
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
raise ManagedFileAccessError('filesystem_unavailable')
def _stage_temporary_file(self, directory_descriptor, payload, max_bytes):
name = descriptor = view = None
try:
name, descriptor = self._new_temporary_file(directory_descriptor)
os.fchmod(descriptor, 0o600)
details = os.fstat(descriptor)
if (
not _regular_single_link(details)
or details.st_uid != os.geteuid()
or stat.S_IMODE(details.st_mode) != 0o600
):
raise ManagedFileAccessError('unsafe_target')
view = memoryview(payload)
written = 0
while written < len(view):
try:
count = os.write(descriptor, view[written:])
except InterruptedError:
continue
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
if count <= 0:
raise ManagedFileAccessError('filesystem_unavailable')
written += count
try:
os.fsync(descriptor)
except OSError:
raise ManagedFileAccessError('filesystem_unavailable') from None
identity, stable, _ = self._read_descriptor(
descriptor, max_bytes, include_content=False,
)
if (
identity.byte_count != len(payload)
or stable.st_uid != os.geteuid()
or stat.S_IMODE(stable.st_mode) != 0o600
):
raise ManagedFileAccessError('unsafe_target')
return name, descriptor, identity, stable
except OSError:
failure = ManagedFileAccessError('filesystem_unavailable')
self._release_unpublished_temporary(
directory_descriptor, name, descriptor, failure,
)
descriptor = None
raise failure from None
except BaseException as failure:
self._release_unpublished_temporary(
directory_descriptor, name, descriptor, failure,
)
descriptor = None
raise
finally:
if view is not None:
view.release()
payload = None
def _release_unpublished_temporary(
self, directory_descriptor, name, descriptor, failure=None):
cleanup_failure = close_failure = None
if name is not None:
try:
self._cleanup_temporary_file(directory_descriptor, name)
except BaseException as exc:
cleanup_failure = exc
try:
_close_descriptor(descriptor)
except BaseException as exc:
close_failure = exc
if failure is not None:
if isinstance(failure, Exception):
if cleanup_failure is not None:
raise cleanup_failure
if close_failure is not None:
raise close_failure
return
if cleanup_failure is not None:
raise cleanup_failure
if close_failure is not None:
raise close_failure
@staticmethod
def _cleanup_temporary_file(directory_descriptor, name):
try:
os.unlink(name, dir_fd=directory_descriptor)
except FileNotFoundError:
pass
except OSError:
raise ManagedFileAccessError('durability_uncertain') from None
try:
os.fsync(directory_descriptor)
except OSError:
raise ManagedFileAccessError('durability_uncertain') from None
@staticmethod
def _fsync_directory(directory_descriptor):
try:
os.fsync(directory_descriptor)
except OSError:
raise ManagedFileAccessError('durability_uncertain') from None
@staticmethod
def _lock_mutation_directory(directory_descriptor):
if fcntl is None:
raise ManagedFileAccessError('filesystem_unavailable')
while True:
try:
fcntl.flock(directory_descriptor, fcntl.LOCK_EX)
return
except InterruptedError:
continue
except OSError:
raise ManagedFileAccessError(
'filesystem_unavailable',
) from None
@staticmethod
def _unlock_mutation_directory(directory_descriptor):
if fcntl is None:
return
try:
fcntl.flock(directory_descriptor, fcntl.LOCK_UN)
except OSError:
pass
@contextmanager
def _locked_parent(self, root, components):
with self._opened_parent(root, components) as parent:
directory_descriptor, _ = parent
self._lock_mutation_directory(directory_descriptor)
try:
yield parent
finally:
self._unlock_mutation_directory(directory_descriptor)
def _publish_temporary(
self, directory_descriptor, temporary_name, leaf_name, *, create):
failure = None
try:
if create:
_rename_noreplace(
temporary_name, leaf_name, directory_descriptor,
)
else:
try:
os.replace(
temporary_name, leaf_name,
src_dir_fd=directory_descriptor,
dst_dir_fd=directory_descriptor,
)
except OSError:
raise ManagedFileAccessError(
'concurrent_change',
) from None
except BaseException as exc:
failure = exc
raise
finally:
try:
self._fsync_directory(directory_descriptor)
except BaseException as sync_failure:
if (
failure is None
or isinstance(failure, Exception)
and not isinstance(sync_failure, Exception)
):
raise
def _verify_private_file(
self, directory_descriptor, leaf_name, max_bytes,
expected_identity, expected_inode):
identity, details, _ = self._read_file_at(
directory_descriptor, leaf_name, max_bytes, content=False,
)
if (
identity != expected_identity
or details.st_dev != expected_inode.st_dev
or details.st_ino != expected_inode.st_ino
or details.st_uid != os.geteuid()
or stat.S_IMODE(details.st_mode) != 0o600
):
raise ManagedFileAccessError('concurrent_change')
return details
@contextmanager
def _opened_target(self, root, components, target_kind):
target = self._open_relative(root, components, target_kind)
try:
yield target
finally:
target._close()
def open_list_directory(self, root_id, relative_path=None):
root = self._root_for_operation(root_id, ManagedFileOperation.LIST)
components = (
() if relative_path is None
else parse_managed_relative_path(relative_path, root.limits)
)
self._require_relative_target(root.root_id, components, 'directory')
return self._opened_target(root, components, 'directory')
def open_read_file(self, root_id, relative_path):
root = self._root_for_operation(root_id, ManagedFileOperation.READ)
components = parse_managed_relative_path(relative_path, root.limits)
self._require_relative_target(root.root_id, components, 'file')
return self._opened_target(root, components, 'file')
def list_directory(self, root_id, relative_path=None):
root = self._root_for_operation(root_id, ManagedFileOperation.LIST)
components = (
() if relative_path is None
else parse_managed_relative_path(relative_path, root.limits)
)
self._require_relative_target(root.root_id, components, 'directory')
entries = []
name_bytes = scanned = 0
try:
with self._opened_target(root, components, 'directory') as opened:
try:
iterator = os.scandir(opened.fileno())
with iterator:
for item in iterator:
scanned += 1
if scanned > root.limits.max_listing_entries:
raise ManagedFileAccessError('limit_exceeded')
name = item.name
if type(name) is not str or name.startswith(
_TEMPORARY_PREFIX):
continue
if not self._relative_target_allowed(
root.root_id, (*components, name), 'file'):
continue
try:
parse_managed_relative_path(
'/'.join((*components, name)), root.limits,
)
encoded = name.encode('utf-8')
details = os.stat(
name, dir_fd=opened.fileno(),
follow_symlinks=False,
)
except ManagedFileAccessError:
continue
except FileNotFoundError:
continue
except OSError:
raise ManagedFileAccessError(
'filesystem_unavailable',
) from None
if stat.S_ISDIR(details.st_mode):
kind = 'directory'
byte_count = None
elif (
_regular_single_link(details)
and details.st_size <= root.limits.max_file_bytes
):
kind = 'file'
byte_count = details.st_size
else:
continue
if not self._relative_target_allowed(
root.root_id, (*components, name), kind):
continue
name_bytes += len(encoded)
if name_bytes > root.limits.max_listing_bytes:
raise ManagedFileAccessError('limit_exceeded')
entries.append(ManagedFileDirectoryEntry(
name, kind, byte_count,
))
except OSError:
raise ManagedFileAccessError(
'filesystem_unavailable',
) from None
entries.sort(key=lambda entry: entry.name.encode('utf-8'))
return ManagedFileListing(tuple(entries), name_bytes)
except BaseException:
entries.clear()
raise
def download_file(self, root_id, relative_path):
root = self._root_for_operation(root_id, ManagedFileOperation.READ)
components = parse_managed_relative_path(relative_path, root.limits)
self._require_relative_target(root.root_id, components, 'file')
if root.root_id == RUNTIME_RESULT_ROOT_ID:
if not self._snapshot_gate.acquire(blocking=False):
raise ManagedFileAccessError('download_busy')
try:
for attempt in range(2):
try:
with self._opened_target(root, components, 'file') as opened:
identity, snapshot = self._snapshot_descriptor(
root, opened.fileno(),
)
return ManagedFileDownload(
identity, snapshot=snapshot,
)
except ManagedFileAccessError as exc:
if attempt or exc.category not in (
'concurrent_change', 'unsafe_target'):
raise
except BaseException:
self._snapshot_gate.release()
raise
raise ManagedFileAccessError('concurrent_change')
for attempt in range(2):
try:
with self._opened_target(root, components, 'file') as opened:
identity, _, content = self._read_descriptor(
opened.fileno(), root.limits.max_file_bytes,
include_content=True,
)
return ManagedFileDownload(identity, content)
except ManagedFileAccessError as exc:
if attempt or exc.category not in ('concurrent_change', 'unsafe_target'):
raise
raise ManagedFileAccessError('concurrent_change')
def mutation_file_identity(
self, root_id, relative_path, operation, *, require_private_sha256=None):
if operation not in (
ManagedFileOperation.CREATE_REPLACE, ManagedFileOperation.DELETE,
):
raise ManagedFileAccessError('operation_not_allowed')
root = self._root_for_operation(root_id, operation)
components = parse_managed_relative_path(relative_path, root.limits)
with self._opened_parent(root, components) as parent:
directory_descriptor, leaf_name = parent
try:
try:
identity, details, _ = self._read_file_at(
directory_descriptor, leaf_name,
root.limits.max_file_bytes, content=False,
)
except ManagedFileAccessError as exc:
if exc.category != 'not_found':
raise
self._fsync_directory(directory_descriptor)
try:
self._read_file_at(
directory_descriptor, leaf_name,
root.limits.max_file_bytes, content=False,
)
except ManagedFileAccessError as repeated:
if repeated.category == 'not_found':
raise exc
raise
raise ManagedFileAccessError('concurrent_change')
# Replay may follow a crash after namespace publication. A
# successful directory fsync turns the observed state into
# durable evidence before terminal operation reconciliation.
self._fsync_directory(directory_descriptor)
self._revalidate_named_file(
directory_descriptor, leaf_name, details,
)
if (
require_private_sha256 is not None
and hmac.compare_digest(identity.sha256, require_private_sha256)
and (
details.st_uid != os.geteuid()
or stat.S_IMODE(details.st_mode) != 0o600
)
):
raise ManagedFileAccessError('unsafe_target')
except ManagedFileAccessError:
raise
return identity
def _create_replace_file_payload(
self, root_id, relative_path, payload_box, *, expected_sha256):
root = self._root_for_operation(
root_id, ManagedFileOperation.CREATE_REPLACE,
)
components = parse_managed_relative_path(relative_path, root.limits)
if len(payload_box) != 1 or type(payload_box[0]) is not bytes:
raise ManagedFileAccessError('invalid_content')
if len(payload_box[0]) > root.limits.max_file_bytes:
raise ManagedFileAccessError('limit_exceeded')
if expected_sha256 is not None:
expected_sha256 = _valid_hash(expected_sha256)
proposed = ManagedFileIdentity(
hashlib.sha256(payload_box[0]).hexdigest(), len(payload_box[0]),
)
with self._mutation_lock, self._locked_parent(root, components) as parent:
directory_descriptor, leaf_name = parent
before = before_details = None
if expected_sha256 is not None:
before, before_details, _ = self._read_file_at(
directory_descriptor, leaf_name,
root.limits.max_file_bytes, content=False,
)
if not hmac.compare_digest(before.sha256, expected_sha256):
raise ManagedFileAccessError('hash_conflict')
if before == proposed:
self._revalidate_named_file(
directory_descriptor, leaf_name, before_details,
)
return ManagedFileMutation(before, before, False)
temporary_name = temporary_descriptor = None
published = False
failure = None
try:
(
temporary_name, temporary_descriptor,
staged_identity, staged_details,
) = self._stage_temporary_file(
directory_descriptor, payload_box[0],
root.limits.max_file_bytes,
)
if staged_identity != proposed:
raise ManagedFileAccessError('concurrent_change')
if expected_sha256 is not None:
self._revalidate_named_file(
directory_descriptor, leaf_name, before_details,
)
self._publish_temporary(
directory_descriptor, temporary_name, leaf_name,
create=expected_sha256 is None,
)
published = True
try:
verified = self._verify_private_file(
directory_descriptor, leaf_name,
root.limits.max_file_bytes, proposed, staged_details,
)
self._revalidate_named_file(
directory_descriptor, leaf_name, verified,
)
except ManagedFileAccessError as exc:
if exc.category == 'concurrent_change':
raise
raise ManagedFileAccessError(
'durability_uncertain',
) from None
return ManagedFileMutation(before, proposed, True)
except BaseException as exc:
failure = exc
raise
finally:
if temporary_name is not None and not published:
self._release_unpublished_temporary(
directory_descriptor, temporary_name,
temporary_descriptor, failure,
)
temporary_descriptor = None
try:
_close_descriptor(temporary_descriptor)
except BaseException:
if failure is None:
raise
def create_replace_file(
self, root_id, relative_path, payload, *, expected_sha256):
payload_box = [payload]
payload = None
try:
return self._create_replace_file_payload(
root_id, relative_path, payload_box,
expected_sha256=expected_sha256,
)
finally:
payload_box.clear()
payload = payload_box = None
def delete_file(self, root_id, relative_path, *, expected_sha256):
root = self._root_for_operation(root_id, ManagedFileOperation.DELETE)
components = parse_managed_relative_path(relative_path, root.limits)
expected_sha256 = _valid_hash(expected_sha256)
with self._mutation_lock, self._locked_parent(root, components) as parent:
directory_descriptor, leaf_name = parent
before, before_details, _ = self._read_file_at(
directory_descriptor, leaf_name,
root.limits.max_file_bytes, content=False,
)
if not hmac.compare_digest(before.sha256, expected_sha256):
raise ManagedFileAccessError('hash_conflict')
self._revalidate_named_file(
directory_descriptor, leaf_name, before_details,
)
failure = None
try:
try:
os.unlink(leaf_name, dir_fd=directory_descriptor)
except OSError:
raise ManagedFileAccessError(
'concurrent_change',
) from None
except BaseException as exc:
failure = exc
raise
finally:
try:
self._fsync_directory(directory_descriptor)
except BaseException as sync_failure:
if (
failure is None
or isinstance(failure, Exception)
and not isinstance(sync_failure, Exception)
):
raise
return ManagedFileMutation(before, None, True)