From a71f5fb4653611aac55ca3401768d1d3fc3a48e8 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:26:31 -0700 Subject: [PATCH 1/7] Give path exports the permissions open() would The atomic writers created their temp file with tempfile.mkstemp, which is always 0o600, and os.replace carried that onto the target: every to_html, write_image, write_images and facet export was owner-only, even when it replaced an existing 0o644 file. Create the temp file with 0o666 so the umask applies, and keep an existing target's mode. --- news/+export-file-permissions.bugfix.md | 4 +++ python/xy/export.py | 48 +++++++++++++++++++------ spec/api/export.md | 4 ++- tests/test_figure.py | 33 +++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 news/+export-file-permissions.bugfix.md diff --git a/news/+export-file-permissions.bugfix.md b/news/+export-file-permissions.bugfix.md new file mode 100644 index 000000000..37fe2ae94 --- /dev/null +++ b/news/+export-file-permissions.bugfix.md @@ -0,0 +1,4 @@ +Files written by `to_html(path)`, `write_image`, `write_images` and facet +exports now get the same permissions as any other new file (normally +`rw-r--r--`), and a file that already exists keeps its mode. They were created +owner-only (`rw-------`), so a web server or another user could not read them. diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec12..4cea5a5c1 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -10,7 +10,9 @@ import numbers import os import re as _re +import secrets import shutil +import stat import subprocess import sys import tempfile @@ -216,11 +218,44 @@ def _base64_chunks(blob: bytes) -> list[str]: ) +def _sibling_temp(target: Path, *, binary: bool) -> tuple[int, Path]: + """Create `...tmp` beside `target` for an atomic replace. + + The temp file gets the permissions `open(target, "w")` would have given + the export: `0o666` less the umask for a new file, or the existing file's + own mode. `tempfile.mkstemp` always creates `0o600`, and `os.replace` + carries that mode onto the target, so every export ended up owner-only + (unreadable by a web server or another user, even over a `0o644` file). + """ + try: + mode: int | None = stat.S_IMODE(os.stat(target).st_mode) & 0o777 + except OSError: + mode = None + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + if binary: + flags |= getattr(os, "O_BINARY", 0) + for _ in range(tempfile.TMP_MAX): + tmp_path = target.parent / f".{target.name}.{secrets.token_hex(4)}.tmp" + try: + fd = os.open(tmp_path, flags, 0o666) # the umask applies, as for open() + except FileExistsError: + continue + if mode is not None: + try: + os.chmod(tmp_path, mode) + except OSError: + os.close(fd) + with suppress(FileNotFoundError): + tmp_path.unlink() + raise + return fd, tmp_path + raise FileExistsError(f"no free temporary file name beside {str(target)!r}") + + 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) - fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp") - tmp_path = Path(tmp_name) + fd, tmp_path = _sibling_temp(target, binary=True) try: with os.fdopen(fd, "wb") as f: fd = -1 @@ -240,14 +275,7 @@ def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: def _atomic_write_text(path: str | PathLike[str], text: str) -> None: """Write text through a same-directory temp file, then replace atomically.""" target = Path(path) - parent = target.parent - fd, tmp_name = tempfile.mkstemp( - dir=parent, - prefix=f".{target.name}.", - suffix=".tmp", - text=True, - ) - tmp_path = Path(tmp_name) + fd, tmp_path = _sibling_temp(target, binary=False) try: with os.fdopen(fd, "w", encoding="utf-8") as f: fd = -1 diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d5..fb59c13e3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -228,7 +228,9 @@ 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: +reader never observes a partial image. The temp file is created with the mode a +plain `open(path, "w")` would give (`0o666` less the umask, or the existing +file's mode), so the replace does not leave an owner-only export. 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 diff --git a/tests/test_figure.py b/tests/test_figure.py index 0149b1222..9ac647009 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -7,6 +7,8 @@ import datetime as dt import html as _html import json +import os +import stat import warnings from pathlib import Path @@ -1253,6 +1255,37 @@ 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"]) +def test_path_exports_get_the_permissions_open_would_give(tmp_path: Path, name: str): + """The atomic temp file must not leak its owner-only mode onto the export: + a new file follows the umask like `open(path, "w")`, and an existing file + keeps its own mode.""" + fig = Figure(title="permissions").line([0.0, 1.0], [1.0, 2.0]) + + def export(target: Path) -> None: + if target.suffix == ".html": + fig.to_html(target) + else: + fig.write_image(target) + + previous = os.umask(0o022) + try: + created = tmp_path / name + export(created) + assert stat.S_IMODE(created.stat().st_mode) == 0o644 + + 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 not list(tmp_path.glob(".*.tmp")) + + 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"} From afb7dcf4efa8de490620e7386983826e0119b7b7 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:28:18 -0700 Subject: [PATCH 2/7] Name the news fragment after the pull request --- news/{+export-file-permissions.bugfix.md => 525.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/{+export-file-permissions.bugfix.md => 525.bugfix.md} (100%) diff --git a/news/+export-file-permissions.bugfix.md b/news/525.bugfix.md similarity index 100% rename from news/+export-file-permissions.bugfix.md rename to news/525.bugfix.md From c297c8893b3a759bd3b36c05005e494e8c4c564f Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:39:44 -0700 Subject: [PATCH 3/7] Keep the temp file private until it is written, then apply the mode Review follow-up. The temp file is created 0o600 by mkstemp again, so no other user can open it while the export is being written; after fsync the final mode is set through the descriptor (fchmod), right before the replace. A new file's mode is read from an empty probe created with 0o666, so a directory default ACL applies just as it does for open(). The test compares against a file made with open(), covers to_svg(path) and write_images, and checks the temp file is 0o600 while it is written. --- news/525.bugfix.md | 4 +-- python/xy/export.py | 69 +++++++++++++++++++++++++++----------------- spec/api/export.md | 11 +++---- tests/test_figure.py | 33 ++++++++++++++++----- 4 files changed, 77 insertions(+), 40 deletions(-) diff --git a/news/525.bugfix.md b/news/525.bugfix.md index 37fe2ae94..082ad2133 100644 --- a/news/525.bugfix.md +++ b/news/525.bugfix.md @@ -1,4 +1,4 @@ -Files written by `to_html(path)`, `write_image`, `write_images` and facet -exports now get the same permissions as any other new file (normally +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 a file that already exists keeps its mode. They were created owner-only (`rw-------`), so a web server or another user could not read them. diff --git a/python/xy/export.py b/python/xy/export.py index 4cea5a5c1..304387020 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -218,50 +218,59 @@ def _base64_chunks(blob: bytes) -> list[str]: ) -def _sibling_temp(target: Path, *, binary: bool) -> tuple[int, Path]: - """Create `...tmp` beside `target` for an atomic replace. - - The temp file gets the permissions `open(target, "w")` would have given - the export: `0o666` less the umask for a new file, or the existing file's - own mode. `tempfile.mkstemp` always creates `0o600`, and `os.replace` - carries that mode onto the target, so every export ended up owner-only - (unreadable by a web server or another user, even over a `0o644` file). +def _open_file_mode(target: Path) -> int: + """The permission bits `open(target, "w")` would leave on `target`. + + An existing file keeps its own bits (`open` never changes them; the kernel + clears setuid/setgid on write, hence the `0o777` mask). A new file gets + `0o666` filtered by whatever governs creation in that directory — the + umask, or a default ACL — which is read back from an empty probe file + rather than by flipping the process-wide umask. """ try: - mode: int | None = stat.S_IMODE(os.stat(target).st_mode) & 0o777 + return stat.S_IMODE(os.stat(target).st_mode) & 0o777 except OSError: - mode = None - flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) - if binary: - flags |= getattr(os, "O_BINARY", 0) + pass + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) for _ in range(tempfile.TMP_MAX): - tmp_path = target.parent / f".{target.name}.{secrets.token_hex(4)}.tmp" + probe = target.parent / f".{target.name}.{secrets.token_hex(4)}.mode" try: - fd = os.open(tmp_path, flags, 0o666) # the umask applies, as for open() + fd = os.open(probe, flags, 0o666) except FileExistsError: continue - if mode is not None: - try: - os.chmod(tmp_path, mode) - except OSError: - os.close(fd) - with suppress(FileNotFoundError): - tmp_path.unlink() - raise - return fd, tmp_path + 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 _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. + """ + if hasattr(os, "fchmod"): # POSIX; Windows has no owner-only mode to undo + os.fchmod(fd, _open_file_mode(target)) + + 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) - fd, tmp_path = _sibling_temp(target, binary=True) + fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp") + tmp_path = Path(tmp_name) try: with os.fdopen(fd, "wb") as f: fd = -1 f.write(data) f.flush() os.fsync(f.fileno()) + _publish_mode(f.fileno(), target) os.replace(tmp_path, target) except Exception: if fd != -1: @@ -275,13 +284,21 @@ def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: def _atomic_write_text(path: str | PathLike[str], text: str) -> None: """Write text through a same-directory temp file, then replace atomically.""" target = Path(path) - fd, tmp_path = _sibling_temp(target, binary=False) + parent = target.parent + fd, tmp_name = tempfile.mkstemp( + dir=parent, + prefix=f".{target.name}.", + suffix=".tmp", + text=True, + ) + tmp_path = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as f: fd = -1 f.write(text) f.flush() os.fsync(f.fileno()) + _publish_mode(f.fileno(), target) os.replace(tmp_path, target) except Exception: if fd != -1: diff --git a/spec/api/export.md b/spec/api/export.md index fb59c13e3..2471d3b0c 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -228,11 +228,12 @@ 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. The temp file is created with the mode a -plain `open(path, "w")` would give (`0o666` less the umask, or the existing -file's mode), so the replace does not leave an owner-only export. 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 +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. 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) diff --git a/tests/test_figure.py b/tests/test_figure.py index 9ac647009..b4d1db655 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -1256,24 +1256,42 @@ def fail_replace(src: Path, dst: Path) -> None: @pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") -@pytest.mark.parametrize("name", ["chart.html", "chart.png", "chart.svg", "chart.pdf"]) -def test_path_exports_get_the_permissions_open_would_give(tmp_path: Path, name: str): +@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 follows the umask like `open(path, "w")`, and an existing file - keeps its own mode.""" + 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 target.suffix == ".html": + 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) + 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) == 0o644 + assert stat.S_IMODE(created.stat().st_mode) == expected existing = tmp_path / f"shared-{name}" existing.write_bytes(b"old") @@ -1283,7 +1301,8 @@ def export(target: Path) -> None: assert existing.read_bytes() != b"old" finally: os.umask(previous) - assert not list(tmp_path.glob(".*.tmp")) + 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")) def test_figure_dom_slots_are_validated_before_export(): From 32de4f2d746cd2e1ceac412f0e88b9d9701b418b Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:49:14 -0700 Subject: [PATCH 4/7] Publish the export even when the mode probe cannot be created The probe is one more file in the target directory; if it cannot be made (no free inode), keep the finished export owner-only instead of failing it. --- python/xy/export.py | 12 ++++++++++-- tests/test_figure.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/python/xy/export.py b/python/xy/export.py index 304387020..593450cf7 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -254,9 +254,17 @@ def _publish_mode(fd: int, target: Path) -> None: 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 (the probe file cannot be + created, e.g. no free inode), the finished export is still published, just + owner-only as before. """ - if hasattr(os, "fchmod"): # POSIX; Windows has no owner-only mode to undo - os.fchmod(fd, _open_file_mode(target)) + if not hasattr(os, "fchmod"): # POSIX; Windows has no owner-only mode to undo + return + try: + mode = _open_file_mode(target) + except OSError: + return + os.fchmod(fd, mode) def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: diff --git a/tests/test_figure.py b/tests/test_figure.py index b4d1db655..34d96580a 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -1305,6 +1305,27 @@ def recording_fsync(fd: int) -> None: assert not list(tmp_path.glob(".*.tmp")) and not list(tmp_path.glob(".*.mode")) +@pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") +def test_export_still_lands_when_the_mode_probe_cannot_be_created( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """The mode probe is best effort: a directory that can take the export but + not one more file (no free inode) still gets the export, owner-only.""" + fig = Figure(title="probe").line([0.0, 1.0], [1.0, 2.0]) + real_open = export_module.os.open + + def no_probe(path, flags, mode=0o777, *args, **kwargs): + if str(path).endswith(".mode"): + raise OSError(28, "No space left on device") + return real_open(path, flags, mode, *args, **kwargs) + + monkeypatch.setattr(export_module.os, "open", no_probe) + target = tmp_path / "chart.html" + html = fig.to_html(target) + assert target.read_text(encoding="utf-8") == html + assert not list(tmp_path.glob(".*.tmp")) + + 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"} From e2d2187de039da57e49d0655d71fb7f84e49aac4 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:56:33 -0700 Subject: [PATCH 5/7] Also publish the export when fchmod is unsupported A filesystem without chmod (vfat, some FUSE/NFS mounts) now keeps the owner-only export instead of failing it. The fallback test covers both the probe and fchmod failures, uses errno constants, and asserts the 0o600 fallback. --- python/xy/export.py | 13 +++++-------- tests/test_figure.py | 33 ++++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/python/xy/export.py b/python/xy/export.py index 593450cf7..0bbb1f87b 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -254,17 +254,14 @@ def _publish_mode(fd: int, target: Path) -> None: 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 (the probe file cannot be - created, e.g. no free inode), the finished export is still published, just - owner-only as before. + Best effort: if the mode cannot be determined or applied (no free inode for + the probe, a filesystem without chmod), the finished export is still + published, just owner-only as before. """ if not hasattr(os, "fchmod"): # POSIX; Windows has no owner-only mode to undo return - try: - mode = _open_file_mode(target) - except OSError: - return - os.fchmod(fd, mode) + with suppress(OSError): + os.fchmod(fd, _open_file_mode(target)) def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: diff --git a/tests/test_figure.py b/tests/test_figure.py index 34d96580a..0e3de78c5 100644 --- a/tests/test_figure.py +++ b/tests/test_figure.py @@ -5,6 +5,7 @@ from __future__ import annotations import datetime as dt +import errno import html as _html import json import os @@ -1306,24 +1307,34 @@ def recording_fsync(fd: int) -> None: @pytest.mark.skipif(os.name != "posix", reason="POSIX permission bits") -def test_export_still_lands_when_the_mode_probe_cannot_be_created( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +@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 ): - """The mode probe is best effort: a directory that can take the export but - not one more file (no free inode) still gets the export, owner-only.""" + """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]) - real_open = export_module.os.open + 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(28, "No space left on device") - return real_open(path, flags, mode, *args, **kwargs) + 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) + 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 - assert not list(tmp_path.glob(".*.tmp")) + 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(): From 1fe2771d69c9e62d4a74432d3389c7a7b6e2dc28 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Tue, 27 Oct 2026 20:53:35 +0500 Subject: [PATCH 6/7] Preserve export access controls and skip POSIX modes on Windows --- news/525.bugfix.md | 6 +- python/xy/export.py | 54 ++++++++++----- spec/api/export.md | 11 ++- tests/test_export_permissions.py | 114 +++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 tests/test_export_permissions.py diff --git a/news/525.bugfix.md b/news/525.bugfix.md index 082ad2133..885fc6ad5 100644 --- a/news/525.bugfix.md +++ b/news/525.bugfix.md @@ -1,4 +1,6 @@ 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 a file that already exists keeps its mode. They were created -owner-only (`rw-------`), so a web server or another user could not read them. +`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. diff --git a/python/xy/export.py b/python/xy/export.py index 0bbb1f87b..53a904fae 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 +import errno import html as _html import json import math @@ -218,19 +219,8 @@ def _base64_chunks(blob: bytes) -> list[str]: ) -def _open_file_mode(target: Path) -> int: - """The permission bits `open(target, "w")` would leave on `target`. - - An existing file keeps its own bits (`open` never changes them; the kernel - clears setuid/setgid on write, hence the `0o777` mask). A new file gets - `0o666` filtered by whatever governs creation in that directory — the - umask, or a default ACL — which is read back from an empty probe file - rather than by flipping the process-wide umask. - """ - try: - return stat.S_IMODE(os.stat(target).st_mode) & 0o777 - except OSError: - pass +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): probe = target.parent / f".{target.name}.{secrets.token_hex(4)}.mode" @@ -247,6 +237,25 @@ def _open_file_mode(target: Path) -> int: 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" + try: + acl = os.getxattr(target, attribute) + except OSError as error: + if error.errno != errno.ENODATA: + raise + try: + os.removexattr(fd, attribute) + except OSError as error: + if error.errno != errno.ENODATA: + 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. @@ -255,13 +264,24 @@ def _publish_mode(fd: int, target: Path) -> None: 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), the finished export is still - published, just owner-only as before. + the probe, a filesystem without chmod, or an unpreservable group/ACL), + the export is still published, just owner-only as before. """ - if not hasattr(os, "fchmod"): # POSIX; Windows has no owner-only mode to undo + if os.name != "posix": # Windows fchmod changes the read-only attribute. return with suppress(OSError): - os.fchmod(fd, _open_file_mode(target)) + 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: diff --git a/spec/api/export.md b/spec/api/export.md index 2471d3b0c..26aabb750 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -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 @@ -232,7 +235,13 @@ reader never observes a partial image. The temp file stays owner-only while it 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. Failure mid-batch is not transactional: files already written stay +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. 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 diff --git a/tests/test_export_permissions.py b/tests/test_export_permissions.py new file mode 100644 index 000000000..17910ef55 --- /dev/null +++ b/tests/test_export_permissions.py @@ -0,0 +1,114 @@ +"""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(" Date: Tue, 27 Oct 2026 21:11:30 +0500 Subject: [PATCH 7/7] Keep export modes on filesystems without ACL support --- news/525.bugfix.md | 2 ++ python/xy/export.py | 5 +++-- spec/api/export.md | 4 +++- tests/test_export_permissions.py | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/news/525.bugfix.md b/news/525.bugfix.md index 885fc6ad5..d58a65725 100644 --- a/news/525.bugfix.md +++ b/news/525.bugfix.md @@ -4,3 +4,5 @@ and facet exports now get the same permissions as any other new file (normally 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. diff --git a/python/xy/export.py b/python/xy/export.py index 53a904fae..c8e5d1057 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -242,15 +242,16 @@ def _preserve_access_acl(fd: int, target: Path) -> None: 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) except OSError as error: - if error.errno != errno.ENODATA: + if error.errno not in absent_acl_errors: raise try: os.removexattr(fd, attribute) except OSError as error: - if error.errno != errno.ENODATA: + if error.errno not in absent_acl_errors: raise else: os.setxattr(fd, attribute, acl) diff --git a/spec/api/export.md b/spec/api/export.md index 26aabb750..4d37d2195 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -237,7 +237,9 @@ 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. If determining or +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 diff --git a/tests/test_export_permissions.py b/tests/test_export_permissions.py index 17910ef55..99568dc99 100644 --- a/tests/test_export_permissions.py +++ b/tests/test_export_permissions.py @@ -112,3 +112,39 @@ def reject_acl(*args): 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"))