Repository navigation
Give path exports the permissions open() would #525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
FarhanAliRaza
merged 8 commits into
reflex-dev:main
from
breken-ai:fix/export-file-permissions
Oct 28, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a71f5fb
Give path exports the permissions open() would
breken-ai afb7dcf
Name the news fragment after the pull request
breken-ai c297c88
Keep the temp file private until it is written, then apply the mode
breken-ai 32de4f2
Publish the export even when the mode probe cannot be created
breken-ai e2d2187
Also publish the export when fchmod is unsupported
breken-ai a67e2b6
Merge branch 'main' into fix/export-file-permissions
FarhanAliRaza 1fe2771
Preserve export access controls and skip POSIX modes on Windows
FarhanAliRaza fb2f448
Keep export modes on filesystems without ACL support
FarhanAliRaza File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.