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/5] 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/5] 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/5] 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/5] 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/5] 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():