Skip to content
8 changes: 8 additions & 0 deletions news/525.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Files written by `to_html(path)`, `to_svg(path)`, `write_image`, `write_images`
and facet exports now get the same permissions as any other new file (normally
`rw-r--r--`), and an existing POSIX file keeps its ordinary permission bits
and group when possible.
Linux replacements also retain the existing access ACL; failed permission
adjustments leave the export private. Exports were previously created owner-only (`rw-------`), so a web server or another user could not read them.

Filesystems without ACL support also retain ordinary permission bits on replacement.
71 changes: 71 additions & 0 deletions python/xy/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
from __future__ import annotations

import base64
import errno
import html as _html
import json
import math
import numbers
import os
import re as _re
import secrets
import shutil
import stat
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -216,6 +219,72 @@ def _base64_chunks(blob: bytes) -> list[str]:
)


def _new_file_mode(target: Path) -> int:
"""Read new-file permissions without changing the process-wide umask."""
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
for _ in range(tempfile.TMP_MAX):
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
probe = target.parent / f".{target.name}.{secrets.token_hex(4)}.mode"
try:
fd = os.open(probe, flags, 0o666)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
except FileExistsError:
continue
try:
return stat.S_IMODE(os.fstat(fd).st_mode)
finally:
os.close(fd)
with suppress(FileNotFoundError):
probe.unlink()
raise FileExistsError(f"no free temporary file name beside {str(target)!r}")


def _preserve_access_acl(fd: int, target: Path) -> None:
"""Keep a Linux replacement from gaining the directory's inherited ACL."""
if not sys.platform.startswith("linux"):
return
attribute = "system.posix_acl_access"
absent_acl_errors = (errno.ENODATA, errno.ENOTSUP, errno.EOPNOTSUPP)
try:
acl = os.getxattr(target, attribute)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
except OSError as error:
if error.errno not in absent_acl_errors:
raise
try:
os.removexattr(fd, attribute)
except OSError as error:
if error.errno not in absent_acl_errors:
raise
else:
os.setxattr(fd, attribute, acl)


def _publish_mode(fd: int, target: Path) -> None:
"""Give the finished temp file the mode `open()` would have given the export.

`tempfile.mkstemp` creates `0o600` so nothing can read the file while it is
being written, but `os.replace` then carries that mode onto the target and
every export ended up owner-only. The final mode is applied through the
descriptor, after the data is flushed and immediately before the replace.
Best effort: if the mode cannot be determined or applied (no free inode for
the probe, a filesystem without chmod, or an unpreservable group/ACL),
the export is still published, just owner-only as before.
"""
if os.name != "posix": # Windows fchmod changes the read-only attribute.
return
with suppress(OSError):
try:
original = target.stat()
except FileNotFoundError:
mode = _new_file_mode(target)
else:
# Preserve the access principals before enabling their bits. A
# failed group/ACL operation leaves the mkstemp file private.
if os.fstat(fd).st_gid != original.st_gid:
os.fchown(fd, -1, original.st_gid)
_preserve_access_acl(fd, target)
mode = stat.S_IMODE(original.st_mode) & 0o777
os.fchmod(fd, mode)


def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None:
"""Write bytes through a same-directory temp file, then replace atomically."""
target = Path(path)
Expand All @@ -227,6 +296,7 @@ def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None:
f.write(data)
f.flush()
os.fsync(f.fileno())
_publish_mode(f.fileno(), target)
os.replace(tmp_path, target)
except Exception:
if fd != -1:
Expand Down Expand Up @@ -254,6 +324,7 @@ def _atomic_write_text(path: str | PathLike[str], text: str) -> None:
f.write(text)
f.flush()
os.fsync(f.fileno())
_publish_mode(f.fileno(), target)
os.replace(tmp_path, target)
except Exception:
if fd != -1:
Expand Down
20 changes: 17 additions & 3 deletions spec/api/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ configured name with no entry). With no `export` spec the menu falls back to
There is no Reflex-side export API. `python/reflex_xy/` renders without a kernel in
the browser; export runs on the composed chart object in Python.

All path writers, including HTML/SVG and facet exports, use the atomic-write
and permission contract in [§8](#8-batch-export).

## 2. Formats and backends

`IMAGE_FORMATS = ("png", "jpeg", "webp", "svg", "pdf")`, with `jpg` aliased to
Expand Down Expand Up @@ -228,9 +231,20 @@ Two properties are the point of the API:
browser at all.

Writes are atomic per file (same-directory temp file, fsync, `os.replace`), so a
reader never observes a partial image. Failure mid-batch is not transactional:
files already written stay on disk. The return value is the list of written
byte strings, in input order.
reader never observes a partial image. The temp file stays owner-only while it
Comment thread
FarhanAliRaza marked this conversation as resolved.
Resolved
is written; just before the replace it takes the mode a plain `open(path, "w")`
would leave (an existing file's permission bits, or for a new file `0o666`
filtered by the umask or the directory's default ACL), so exports are not left
owner-only. POSIX replacements preserve the existing group before enabling its
permission bits; on Linux they also retain the existing access ACL instead of
gaining the directory's inherited named-user/group entries. Missing ACLs and
unsupported ACL operations (`ENODATA`, `ENOTSUP`/`EOPNOTSUPP`) count as no ACL;
these filesystems still receive their ordinary permission bits. If determining or
applying the group, ACL, or mode fails, the export is still published with its
private temporary-file permissions. Special setuid/setgid/sticky bits are not
restored. Windows retains the previous atomic-write behavior without applying
POSIX modes. Failure mid-batch is not transactional: files already written stay
on disk. The return value is the list of written byte strings, in input order.
## 9. What styling survives which export path

XY has five styling mechanisms (`spec/api/styling.md` § The five ways to style)
Expand Down
150 changes: 150 additions & 0 deletions tests/test_export_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Atomic export permissions must not widen access across platforms or groups."""

from __future__ import annotations

import errno
import os
import stat
import struct
import sys
from types import SimpleNamespace

import pytest

from xy import export as export_module


def test_publish_mode_skips_non_posix_even_when_fchmod_exists(tmp_path, monkeypatch):
target = tmp_path / "chart.html"
target.write_text("original")

def reject(*args):
pytest.fail("non-POSIX export attempted to set POSIX permissions")

simulated_windows = SimpleNamespace(**{**vars(os), "name": "nt", "fchmod": reject})
monkeypatch.setattr(export_module, "os", simulated_windows)
export_module._publish_mode(-1, target)
assert target.read_text() == "original"


@pytest.mark.skipif(os.name != "posix", reason="POSIX group ownership")
@pytest.mark.parametrize("writer", ["bytes", "text"])
@pytest.mark.parametrize("deny_group_change", [False, True])
def test_existing_export_preserves_group_or_stays_private(
tmp_path, monkeypatch, writer, deny_group_change
):
target = tmp_path / "chart.html"
target.write_text("old")
original_group = target.stat().st_gid
other_groups = set(os.getgroups()) - {original_group}
if not other_groups:
pytest.skip("requires membership in a second group")
restricted_group = min(other_groups)
os.chown(target, -1, restricted_group)
target.chmod(0o640)
if deny_group_change:

def reject_group_change(fd, uid, gid):
assert uid == -1 and gid == restricted_group
raise OSError(errno.EPERM, "cannot preserve group")

monkeypatch.setattr(export_module.os, "fchown", reject_group_change)
if writer == "bytes":
export_module._atomic_write_bytes(target, b"new chart")
else:
export_module._atomic_write_text(target, "new chart")
assert target.read_bytes() == b"new chart"
published = target.stat()
if deny_group_change:
assert stat.S_IMODE(published.st_mode) == 0o600
else:
assert published.st_gid == restricted_group
assert stat.S_IMODE(published.st_mode) == 0o640
assert not list(tmp_path.glob(".*.tmp"))


@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux POSIX ACL xattrs")
@pytest.mark.parametrize("existing_acl", [False, True])
def test_replacement_does_not_inherit_new_named_user_access(tmp_path, existing_acl):
def acl_for(user):
entries = [
(1, 6, 0xFFFFFFFF),
(2, 4, user),
(4, 0, 0xFFFFFFFF),
(16, 4, 0xFFFFFFFF),
(32, 0, 0xFFFFFFFF),
]
return struct.pack("<I", 2) + b"".join(struct.pack("<HHI", *entry) for entry in entries)

try:
os.setxattr(tmp_path, "system.posix_acl_default", acl_for(12345))
except OSError as error:
if error.errno in (errno.ENOTSUP, errno.EOPNOTSUPP):
pytest.skip("filesystem does not support POSIX ACLs")
raise
target = tmp_path / "chart.html"
target.write_text("old")
os.removexattr(target, "system.posix_acl_access")
target.chmod(0o640)
if existing_acl:
os.setxattr(target, "system.posix_acl_access", acl_for(12346))
export_module._atomic_write_text(target, "new")
assert target.read_text() == "new"
if existing_acl:
assert os.getxattr(target, "system.posix_acl_access") == acl_for(12346)
else:
with pytest.raises(OSError) as missing:
os.getxattr(target, "system.posix_acl_access")
assert missing.value.errno == errno.ENODATA


@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux POSIX ACL xattrs")
def test_unreadable_existing_acl_leaves_export_private(tmp_path, monkeypatch):
target = tmp_path / "chart.html"
target.write_text("old")
target.chmod(0o640)

def reject_acl(*args):
raise OSError(errno.EPERM, "cannot inspect access ACL")

monkeypatch.setattr(export_module.os, "getxattr", reject_acl)
export_module._atomic_write_text(target, "new")
assert target.read_text() == "new"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert not list(tmp_path.glob(".*.tmp"))


@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux POSIX ACL xattrs")
@pytest.mark.parametrize("writer", ["bytes", "text"])
@pytest.mark.parametrize(
"read_error, remove_error, expected_mode",
[
(errno.ENOTSUP, errno.ENOTSUP, 0o640),
(errno.EOPNOTSUPP, errno.EOPNOTSUPP, 0o640),
(errno.ENODATA, errno.ENOTSUP, 0o640),
(errno.ENOTSUP, errno.ENODATA, 0o640),
(errno.ENOTSUP, errno.EPERM, 0o600),
],
)
def test_unsupported_acl_keeps_mode_but_denied_acl_stays_private(
tmp_path, monkeypatch, writer, read_error, remove_error, expected_mode
):
target = tmp_path / "chart.html"
target.write_text("old")
target.chmod(0o640)

def no_acl(*args):
raise OSError(read_error, "cannot read access ACL")

def no_acl_removal(*args):
raise OSError(remove_error, "cannot remove access ACL")

monkeypatch.setattr(export_module.os, "getxattr", no_acl)
monkeypatch.setattr(export_module.os, "removexattr", no_acl_removal)
if writer == "bytes":
export_module._atomic_write_bytes(target, b"new")
else:
export_module._atomic_write_text(target, "new")
assert target.read_bytes() == b"new"
assert stat.S_IMODE(target.stat().st_mode) == expected_mode
assert not list(tmp_path.glob(".*.tmp"))
84 changes: 84 additions & 0 deletions tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
from __future__ import annotations

import datetime as dt
import errno
import html as _html
import json
import os
import stat
import warnings
from pathlib import Path

Expand Down Expand Up @@ -1253,6 +1256,87 @@ def fail_replace(src: Path, dst: Path) -> None:
assert not list(tmp_path.glob(".chart.html.*.tmp"))


@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits")
@pytest.mark.parametrize("name", ["chart.html", "chart.png", "chart.svg", "chart.pdf", "batch.png"])
def test_path_exports_get_the_permissions_open_would_give(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, name: str
):
"""The atomic temp file must not leak its owner-only mode onto the export:
a new file gets the mode `open(path, "w")` gives beside it, and an existing
file keeps its own mode. The temp file stays private until it is complete."""
fig = Figure(title="permissions").line([0.0, 1.0], [1.0, 2.0])

def export(target: Path) -> None:
if name.startswith("batch"):
export_module.write_images([fig], [target])
elif target.suffix == ".html":
fig.to_html(target)
elif target.suffix == ".svg":
fig.to_svg(target)
else:
fig.write_image(target)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

written_modes: list[int] = []
real_fsync = export_module.os.fsync

def recording_fsync(fd: int) -> None:
written_modes.append(stat.S_IMODE(os.fstat(fd).st_mode))
real_fsync(fd)

monkeypatch.setattr(export_module.os, "fsync", recording_fsync)
previous = os.umask(0o022)
try:
reference = tmp_path / "reference"
reference.open("w").close()
expected = stat.S_IMODE(reference.stat().st_mode)

created = tmp_path / name
export(created)
assert stat.S_IMODE(created.stat().st_mode) == expected

existing = tmp_path / f"shared-{name}"
existing.write_bytes(b"old")
existing.chmod(0o664)
export(existing)
assert stat.S_IMODE(existing.stat().st_mode) == 0o664
assert existing.read_bytes() != b"old"
finally:
os.umask(previous)
assert written_modes and all(mode == 0o600 for mode in written_modes)
assert not list(tmp_path.glob(".*.tmp")) and not list(tmp_path.glob(".*.mode"))


@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits")
@pytest.mark.parametrize("failure", ["probe", "fchmod"])
def test_export_still_lands_when_the_mode_cannot_be_applied(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str
):
"""Applying the open()-style mode is best effort: a directory that cannot
take one more file (no free inode), or a filesystem without chmod, still
gets the finished export, owner-only as before."""
fig = Figure(title="probe").line([0.0, 1.0], [1.0, 2.0])
if failure == "probe":
real_open = export_module.os.open

def no_probe(path, flags, mode=0o777, *args, **kwargs):
if str(path).endswith(".mode"):
raise OSError(errno.ENOSPC, os.strerror(errno.ENOSPC))
return real_open(path, flags, mode, *args, **kwargs)

monkeypatch.setattr(export_module.os, "open", no_probe)
else:

def no_fchmod(fd, mode):
raise OSError(errno.EPERM, os.strerror(errno.EPERM))

monkeypatch.setattr(export_module.os, "fchmod", no_fchmod)
target = tmp_path / "chart.html"
html = fig.to_html(target)
assert target.read_text(encoding="utf-8") == html
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert not list(tmp_path.glob(".*.tmp")) and not list(tmp_path.glob(".*.mode"))


def test_figure_dom_slots_are_validated_before_export():
fig = Figure().line([0.0, 1.0], [1.0, 2.0])
fig.class_names = {"legend": "ok", "legnd": "typo"}
Expand Down
Loading