Skip to content

Commit df624c0

Browse files
krassowskiZsailer
andauthored
Fix writing on remote file systems with attribute cache (#1574)
Co-authored-by: Zachary Sailer <[email protected]>
1 parent c5c452c commit df624c0

File tree

3 files changed

+77
-1
lines changed

3 files changed

+77
-1
lines changed

.github/workflows/python-tests.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,24 @@ jobs:
129129
run: |
130130
hatch run test:nowarn || hatch -v run test:nowarn --lf
131131
132+
test_filesystems:
133+
name: Test remote file systems
134+
runs-on: ubuntu-latest
135+
timeout-minutes: 20
136+
steps:
137+
- uses: actions/checkout@v5
138+
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
139+
- name: Create NFS file system
140+
run: |
141+
sudo apt-get install -y nfs-kernel-server
142+
mkdir /tmp/nfs_source /tmp/nfs_mount
143+
echo "/tmp/nfs_source localhost(rw)" | sudo bash -c 'cat - > /etc/exports'
144+
sudo exportfs -a
145+
sudo mount -t nfs -o acregmin=60 localhost:/tmp/nfs_source /tmp/nfs_mount
146+
- name: Run the tests
147+
run: |
148+
hatch run test:nowarn -k test_atomic_writing_permission_cache
149+
132150
make_sdist:
133151
name: Make SDist
134152
runs-on: ubuntu-latest

jupyter_server/services/contents/fileio.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,22 @@ def copy2_safe(src, dst, log=None):
3939
4040
like shutil.copy2, but log errors in copystat instead of raising
4141
"""
42+
is_writable = os.access(src, os.W_OK)
43+
44+
if not is_writable:
45+
# attempt to refresh the attribute cache (used by remote file systems)
46+
# rather than raising a permission error before any operation that could
47+
# refresh the attribute cache is allowed to take place.
48+
fd = os.open(src, os.O_RDONLY)
49+
try:
50+
os.fsync(fd)
51+
finally:
52+
os.close(fd)
53+
# re-try
54+
is_writable = os.access(src, os.W_OK)
55+
4256
# if src file is not writable, avoid creating a back-up
43-
if not os.access(src, os.W_OK):
57+
if not is_writable:
4458
if log:
4559
log.debug("Source file, %s, is not writable", src)
4660
raise PermissionError(errno.EACCES, f"File is not writable: {src}")

tests/services/contents/test_fileio.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import contextlib
12
import json
23
import logging
34
import os
5+
import pathlib
46
import stat
57
import sys
8+
import tempfile
69

710
import pytest
811
from nbformat import validate
@@ -156,6 +159,47 @@ def test_atomic_writing_in_readonly_dir(tmp_path):
156159
assert mode == 0o500
157160

158161

162+
@contextlib.contextmanager
163+
def tmp_dir(tmp_root: pathlib.Path):
164+
"""Thin wrapper around `TemporaryDirectory` adopting it to `pathlib.Path`s"""
165+
# we need to append `/` if we want to get a sub-directory
166+
prefix = str(tmp_root) + "/"
167+
with tempfile.TemporaryDirectory(prefix=prefix) as temp_path:
168+
yield pathlib.Path(temp_path)
169+
170+
171+
@pytest.mark.skipif(
172+
not pathlib.Path("/tmp/nfs_mount").exists(), reason="requires a local NFS mount"
173+
)
174+
def test_atomic_writing_permission_cache():
175+
remote_source = pathlib.Path("/tmp/nfs_source")
176+
local_mount = pathlib.Path("/tmp/nfs_mount")
177+
178+
with tmp_dir(tmp_root=local_mount) as local_mount_path:
179+
f = local_mount_path / "file.txt"
180+
181+
# write initial content
182+
f.write_text("original content")
183+
184+
# make the file non-writable
185+
f.chmod(0o500)
186+
187+
# attempt write, should fail due to NFS attribute cache
188+
with pytest.raises(PermissionError):
189+
with atomic_writing(str(f)) as ff:
190+
ff.write("new content")
191+
192+
source_path = remote_source / local_mount_path.name / "file.txt"
193+
194+
# make it readable by modifying attributes at source
195+
source_path.chmod(0o700)
196+
197+
with atomic_writing(str(f)) as ff:
198+
ff.write("new content")
199+
200+
assert f.read_text() == "new content"
201+
202+
159203
@pytest.mark.skipif(os.name == "nt", reason="test fails on Windows")
160204
def test_file_manager_mixin(tmp_path):
161205
mixin = FileManagerMixin()

0 commit comments

Comments
 (0)