diff --git a/jupyter_server/services/contents/filecheckpoints.py b/jupyter_server/services/contents/filecheckpoints.py index 7af1235066..a6079ddcb3 100644 --- a/jupyter_server/services/contents/filecheckpoints.py +++ b/jupyter_server/services/contents/filecheckpoints.py @@ -4,6 +4,7 @@ import os import shutil +import tempfile from anyio.to_thread import run_sync from jupyter_core.utils import ensure_dir_exists @@ -111,6 +112,10 @@ def checkpoint_path(self, checkpoint_id, path): filename = f"{basename}-{checkpoint_id}{ext}" os_path = self._get_os_path(path=parent) cp_dir = os.path.join(os_path, self.checkpoint_dir) + # If parent directory isn't writable, use system temp + if not os.access(os.path.dirname(cp_dir), os.W_OK): + rel = os.path.relpath(os_path, start=self.root_dir) + cp_dir = os.path.join(tempfile.gettempdir(), "jupyter_checkpoints", rel) with self.perm_to_403(): ensure_dir_exists(cp_dir) cp_path = os.path.join(cp_dir, filename) diff --git a/jupyter_server/services/contents/fileio.py b/jupyter_server/services/contents/fileio.py index 5799b57497..85a2e8f2f3 100644 --- a/jupyter_server/services/contents/fileio.py +++ b/jupyter_server/services/contents/fileio.py @@ -101,6 +101,21 @@ def atomic_writing(path, text=True, encoding="utf-8", log=None, **kwargs): if os.path.islink(path): path = os.path.join(os.path.dirname(path), os.readlink(path)) + # Fall back to direct write for existing file in a non-writable dir + dirpath = os.path.dirname(path) or os.getcwd() + if os.path.isfile(path) and not os.access(dirpath, os.W_OK) and os.access(path, os.W_OK): + mode = "w" if text else "wb" + # direct open on the target file + if text: + fileobj = open(path, mode, encoding=encoding, **kwargs) # noqa: SIM115 + else: + fileobj = open(path, mode, **kwargs) # noqa: SIM115 + try: + yield fileobj + finally: + fileobj.close() + return + tmp_path = path_to_intermediate(path) if os.path.isfile(path): diff --git a/tests/services/contents/test_fileio.py b/tests/services/contents/test_fileio.py index 09fc92cf25..eeb4fa0832 100644 --- a/tests/services/contents/test_fileio.py +++ b/tests/services/contents/test_fileio.py @@ -136,6 +136,26 @@ def test_path_to_invalid(tmpdir): assert path_to_invalid(tmpdir) == str(tmpdir) + ".invalid" +@pytest.mark.skipif(sys.platform.startswith("win"), reason="requires POSIX directory perms") +def test_atomic_writing_in_readonly_dir(tmp_path): + # Setup: non-writable dir but a writable file inside + nonw = tmp_path / "nonwritable" + nonw.mkdir() + f = nonw / "file.txt" + f.write_text("original content") + os.chmod(str(nonw), 0o500) + os.chmod(str(f), 0o700) + + # direct write fallback succeeds + with atomic_writing(str(f)) as ff: + ff.write("new content") + assert f.read_text() == "new content" + + # dir perms unchanged + mode = stat.S_IMODE(os.stat(str(nonw)).st_mode) + assert mode == 0o500 + + @pytest.mark.skipif(os.name == "nt", reason="test fails on Windows") def test_file_manager_mixin(tmp_path): mixin = FileManagerMixin()