"""Same-directory writes. The destination name is replaced only after the bytes are durable."""
import errno
import os
import stat
from collections import namedtuple
from eb_staged_rebuild.errors import Refused
# Most native first. A filesystem may expose one ACL through several names
# (a native NFSv4 model, the generic NFSv4 model, a POSIX projection); only
# the most native listed name is written and every listed name is verified.
ACL_ATTRIBUTES = (
"system.gpfs_nfs4_acl",
"system.nfs4_acl",
"system.posix_acl_access",
)
_NO_DATA = {errno.ENODATA, getattr(errno, "ENOATTR", errno.ENODATA)}
_NOT_PROVIDED = _NO_DATA | {errno.ENOTSUP, errno.EOPNOTSUPP}
# NFSv4-family names only. EINVAL on an unlisted probe is suppressed solely
# when the same inspection also lists a POSIX access ACL that is read below.
_NFS4_FAMILY = frozenset({"system.gpfs_nfs4_acl", "system.nfs4_acl"})
_POSIX_ACCESS = "system.posix_acl_access"
Metadata = namedtuple("Metadata", "info acls")
[docs]
def fsync_dir(path):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
[docs]
def file_mode(path):
"""Mode for a new file: the existing file's mode, otherwise 0644 masked by umask."""
if os.path.lexists(path) and not os.path.islink(path):
return stat.S_IMODE(os.lstat(path).st_mode)
umask = os.umask(0)
os.umask(umask)
return 0o644 & ~umask
def _identity(info):
return (info.st_dev, info.st_ino, info.st_uid, info.st_gid, info.st_mode, info.st_ctime_ns)
def _listed_acls(fd):
names = set()
for name in os.listxattr(fd):
if name in ACL_ATTRIBUTES:
names.add(name)
elif name.startswith("system."):
raise Refused(f"unsupported access-control attribute {name}")
return names
def _read_all_acls(fd):
listed = _listed_acls(fd)
acls = {}
for name in ACL_ATTRIBUTES:
try:
acls[name] = os.getxattr(fd, name)
except OSError as error:
if name in listed:
if error.errno in _NO_DATA:
raise Refused(f"{name} was listed but is no longer readable") from error
raise
if error.errno in _NOT_PROVIDED:
continue
# Narrow compatibility: unlisted NFSv4-family EINVAL only when
# this inspection lists POSIX access (read below; missing POSIX
# bytes fail closed). Never suppress EINVAL for POSIX itself,
# listed NFSv4 names, or denied/other errors.
if (
error.errno == errno.EINVAL
and name in _NFS4_FAMILY
and _POSIX_ACCESS in listed
):
continue
raise
if _POSIX_ACCESS in listed and _POSIX_ACCESS not in acls:
raise Refused(f"{_POSIX_ACCESS} was listed but is no longer readable")
return acls
[docs]
def read_acls(fd):
"""Read listed and virtual ACLs through one regular-file descriptor.
GPFS can expose system.nfs4_acl through getxattr without listing it.
Every known ACL name is probed; denied inspection and metadata changes
refuse replacement. Two complete passes protect the snapshot within
one ctime tick.
"""
before = os.fstat(fd)
acls = _read_all_acls(fd)
if _read_all_acls(fd) != acls or _identity(os.fstat(fd)) != _identity(before):
raise Refused("access metadata changed during inspection")
return acls
def _same_metadata(left, right):
return _identity(left.info) == _identity(right.info) and left.acls == right.acls
def _restore_metadata(fd, metadata, mode):
"""Copy ownership, mode and ACL bytes, then read them back from ``fd``.
Ownership and mode come first: chown can clear set-id bits and chmod can
rewrite an NFSv4 ACL or a POSIX mask, so neither may follow the ACL.
"""
if metadata is None:
os.fchmod(fd, mode)
return
os.fchown(fd, metadata.info.st_uid, metadata.info.st_gid)
os.fchmod(fd, mode)
primary = next((name for name in ACL_ATTRIBUTES if name in metadata.acls), None)
# A mode-derived virtual ACL can already match the required policy.
# Writing it can materialize an additional native ACL on GPFS.
if primary is not None and read_acls(fd) != metadata.acls:
os.setxattr(fd, primary, metadata.acls[primary])
# Applying the required ACL can replace inherited virtual views.
for _ in ACL_ATTRIBUTES:
present = read_acls(fd)
extra = [name for name in ACL_ATTRIBUTES if name in present and name not in metadata.acls]
if not extra:
break
# Removing a native ACL can also remove the views derived from it.
os.removexattr(fd, extra[0])
restored = os.fstat(fd)
if (restored.st_uid, restored.st_gid, stat.S_IMODE(restored.st_mode)) != (
metadata.info.st_uid, metadata.info.st_gid, mode
):
raise Refused(
"ownership or mode did not round-trip: expected "
f"{metadata.info.st_uid}:{metadata.info.st_gid} {mode:o}, found "
f"{restored.st_uid}:{restored.st_gid} {stat.S_IMODE(restored.st_mode):o}"
)
if read_acls(fd) != metadata.acls:
raise Refused("access-control attributes did not round-trip")
def _discard(tmp, created):
try:
current = os.lstat(tmp)
if (current.st_dev, current.st_ino) == (created.st_dev, created.st_ino):
os.unlink(tmp)
except OSError:
pass
[docs]
def atomic_write(path, data, faults, step, mode=None, metadata_from=None):
"""Replace a destination only after its bytes and access metadata are durable.
Existing files retain their ownership, mode and listed ACL attributes
(POSIX access, generic NFSv4 and GPFS native NFSv4), including the
absence of an ACL. ``metadata_from`` selects a prepared permission
policy instead. New files without a metadata source inherit the
directory's default ACL. Metadata that cannot be inspected, restored
byte for byte, or that changes before the rename leaves the destination
unchanged.
Fault points ``<step>.write``, ``<step>.fsync`` and ``<step>.rename``
precede those operations. The destination is never unlinked first.
"""
if faults is None:
from eb_staged_rebuild.faults import FaultInjector
faults = FaultInjector()
if isinstance(data, str):
data = data.encode("utf-8")
path = os.fspath(path)
parent = os.path.dirname(path) or "."
os.makedirs(parent, exist_ok=True)
if os.path.islink(path):
raise Refused(f"refusing to replace a symlink: {path}")
metadata_path = metadata_from
if metadata_path is None and os.path.lexists(path):
metadata_path = path
try:
metadata = read_metadata(metadata_path) if metadata_path is not None else None
except (OSError, Refused) as error:
raise Refused(f"cannot read access metadata for {metadata_path}: {error}") from error
if mode is None:
mode = stat.S_IMODE(metadata.info.st_mode) if metadata is not None else file_mode(path)
tmp = os.path.join(parent, f".{os.path.basename(path)}.new")
try:
stale = os.lstat(tmp)
except FileNotFoundError:
stale = None
if stale is not None:
# A privileged writer hands its temporary file to the metadata owner
# before a crash can interrupt it.
owners = {os.geteuid()} | ({metadata.info.st_uid} if metadata is not None else set())
if not stat.S_ISREG(stale.st_mode) or stale.st_uid not in owners or stale.st_nlink != 1:
raise Refused(f"temporary file must be an owned regular file with one link: {tmp}")
os.unlink(tmp)
faults.reach(f"{step}.write")
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
created = os.fstat(fd)
try:
try:
view = memoryview(data)
while view:
written = os.write(fd, view)
view = view[written:]
try:
_restore_metadata(fd, metadata, mode)
except (OSError, Refused) as error:
raise Refused(f"cannot preserve access metadata for {path}: {error}") from error
faults.reach(f"{step}.fsync")
os.fsync(fd)
finally:
os.close(fd)
if metadata is not None:
try:
current = read_metadata(metadata_path)
except (OSError, Refused) as error:
raise Refused(f"cannot recheck access metadata for {metadata_path}: {error}") from error
if not _same_metadata(current, metadata):
raise Refused(f"access metadata of {metadata_path} changed before {path} was replaced")
faults.reach(f"{step}.rename")
os.replace(tmp, path)
except Exception:
_discard(tmp, created)
raise
fsync_dir(parent)
[docs]
def read_bytes(path):
with open(path, "rb") as handle:
return handle.read()
[docs]
def sha256_bytes(data):
import hashlib
return hashlib.sha256(data).hexdigest()