Skip to content

Commit

Permalink
Optimize hidden checks (#1226)
Browse files Browse the repository at this point in the history
Co-authored-by: Steven Silvester <steven.silvester@ieee.org>
  • Loading branch information
vidartf and blink1073 authored Mar 6, 2023
1 parent e60b048 commit b5a6306
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 33 deletions.
2 changes: 1 addition & 1 deletion jupyter_server/base/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ def validate_absolute_path(self, root, absolute_path):
abs_path = super().validate_absolute_path(root, absolute_path)
abs_root = os.path.abspath(root)
assert abs_path is not None
if is_hidden(abs_path, abs_root) and not self.contents_manager.allow_hidden:
if not self.contents_manager.allow_hidden and is_hidden(abs_path, abs_root):
self.log.info(
"Refusing to serve hidden file, via 404 Error, use flag 'ContentsManager.allow_hidden' to enable"
)
Expand Down
2 changes: 1 addition & 1 deletion jupyter_server/files/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async def get(self, path, include_body=True):
self.check_xsrf_cookie()
cm = self.contents_manager

if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
self.log.info("Refusing to serve hidden file, via 404 Error")
raise web.HTTPError(404)

Expand Down
26 changes: 13 additions & 13 deletions jupyter_server/services/contents/filemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ def _base_model(self, path):

four_o_four = "file or directory does not exist: %r" % path

if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
raise web.HTTPError(404, four_o_four)

Expand Down Expand Up @@ -278,7 +278,7 @@ def _dir_model(self, path, content=True):

if not os.path.isdir(os_path):
raise web.HTTPError(404, four_o_four)
elif is_hidden(os_path, self.root_dir) and not self.allow_hidden:
elif not self.allow_hidden and is_hidden(os_path, self.root_dir):
self.log.info("Refusing to serve hidden directory %r, via 404 Error", os_path)
raise web.HTTPError(404, four_o_four)

Expand Down Expand Up @@ -414,7 +414,7 @@ def get(self, path, content=True, type=None, format=None):
if not self.exists(path):
raise web.HTTPError(404, four_o_four)

if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
raise web.HTTPError(404, four_o_four)

Expand All @@ -437,7 +437,7 @@ def get(self, path, content=True, type=None, format=None):

def _save_directory(self, os_path, model, path=""):
"""create a directory"""
if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
raise web.HTTPError(400, "Cannot create directory %r" % os_path)
if not os.path.exists(os_path):
with self.perm_to_403():
Expand All @@ -460,7 +460,7 @@ def save(self, model, path=""):

os_path = self._get_os_path(path)

if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
raise web.HTTPError(400, f"Cannot create file or directory {os_path!r}")

self.log.debug("Saving %s", os_path)
Expand Down Expand Up @@ -506,7 +506,7 @@ def delete_file(self, path):
os_path = self._get_os_path(path)
rm = os.unlink

if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}")

four_o_four = "file or directory does not exist: %r" % path
Expand Down Expand Up @@ -576,9 +576,9 @@ def rename_file(self, old_path, new_path):
new_os_path = self._get_os_path(new_path)
old_os_path = self._get_os_path(old_path)

if (
if not self.allow_hidden and (
is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir)
) and not self.allow_hidden:
):
raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}")

# Should we proceed with the move?
Expand Down Expand Up @@ -741,7 +741,7 @@ async def _dir_model(self, path, content=True):

if not os.path.isdir(os_path):
raise web.HTTPError(404, four_o_four)
elif is_hidden(os_path, self.root_dir) and not self.allow_hidden:
elif not self.allow_hidden and is_hidden(os_path, self.root_dir):
self.log.info("Refusing to serve hidden directory %r, via 404 Error", os_path)
raise web.HTTPError(404, four_o_four)

Expand Down Expand Up @@ -896,7 +896,7 @@ async def get(self, path, content=True, type=None, format=None):

async def _save_directory(self, os_path, model, path=""):
"""create a directory"""
if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
raise web.HTTPError(400, "Cannot create hidden directory %r" % os_path)
if not os.path.exists(os_path):
with self.perm_to_403():
Expand Down Expand Up @@ -961,7 +961,7 @@ async def delete_file(self, path):
os_path = self._get_os_path(path)
rm = os.unlink

if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
if not self.allow_hidden and is_hidden(os_path, self.root_dir):
raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}")

if not os.path.exists(os_path):
Expand Down Expand Up @@ -1035,9 +1035,9 @@ async def rename_file(self, old_path, new_path):
new_os_path = self._get_os_path(new_path)
old_os_path = self._get_os_path(old_path)

if (
if not self.allow_hidden and (
is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir)
) and not self.allow_hidden:
):
raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}")

# Should we proceed with the move?
Expand Down
10 changes: 5 additions & 5 deletions jupyter_server/services/contents/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ async def get(self, path=""):
raise web.HTTPError(400, "Content %r is invalid" % content_str)
content = int(content_str or "")

if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
raise web.HTTPError(404, f"file or directory {path!r} does not exist")

model = await ensure_async(
Expand All @@ -141,10 +141,10 @@ async def patch(self, path=""):
old_path = model.get("path")
if (
old_path
and not cm.allow_hidden
and (
await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path))
)
and not cm.allow_hidden
):
raise web.HTTPError(400, f"Cannot rename file or directory {path!r}")

Expand Down Expand Up @@ -252,10 +252,10 @@ async def put(self, path=""):
if model:
if model.get("copy_from"):
raise web.HTTPError(400, "Cannot copy with PUT, only POST")
if (
if not cm.allow_hidden and (
(model.get("path") and await ensure_async(cm.is_hidden(model.get("path"))))
or await ensure_async(cm.is_hidden(path))
) and not cm.allow_hidden:
):
raise web.HTTPError(400, f"Cannot create file or directory {path!r}")

exists = await ensure_async(self.contents_manager.file_exists(path))
Expand All @@ -275,7 +275,7 @@ async def delete(self, path=""):
"""delete a file in the given path"""
cm = self.contents_manager

if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
if not cm.allow_hidden and await ensure_async(cm.is_hidden(path)):
raise web.HTTPError(400, f"Cannot delete file or directory {path!r}")

self.log.warning("delete %s", path)
Expand Down
3 changes: 2 additions & 1 deletion tests/base/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ async def test_authenticated_file_handler(jp_serverapp, tmpdir):

handler = AuthenticatedFileHandler(app.web_app, request, path=str(tmpdir))
for key in list(handler.settings):
del handler.settings[key]
if key != "contents_manager":
del handler.settings[key]
handler.check_xsrf_cookie = MagicMock() # type:ignore
handler._jupyter_current_user = "foo" # type:ignore
with warnings.catch_warnings():
Expand Down
80 changes: 72 additions & 8 deletions tests/services/contents/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import warnings
from base64 import decodebytes, encodebytes
from unicodedata import normalize
from unittest.mock import patch

import pytest
import tornado
Expand Down Expand Up @@ -185,7 +186,6 @@ async def test_get_text_file_contents(jp_fetch, contents, path, name):
assert expected_http_error(e, 400)


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled retrieving hidden files on Windows")
async def test_get_404_hidden(jp_fetch, contents, contents_dir):
# Create text files
hidden_dir = contents_dir / ".hidden"
Expand Down Expand Up @@ -398,7 +398,6 @@ async def test_upload_txt(jp_fetch, contents, contents_dir, _check_created):
assert model["content"] == body


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled uploading hidden files on Windows")
async def test_upload_txt_hidden(jp_fetch, contents, contents_dir):
with pytest.raises(tornado.httpclient.HTTPClientError) as e:
body = "ünicode téxt"
Expand Down Expand Up @@ -551,7 +550,6 @@ async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created):
assert expected_http_error(e, 400)


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
async def test_copy_put_400_hidden(
jp_fetch,
contents,
Expand Down Expand Up @@ -598,7 +596,6 @@ async def test_copy_put_400_hidden(
assert expected_http_error(e, 400)


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
async def test_copy_400_hidden(
jp_fetch,
contents,
Expand Down Expand Up @@ -686,7 +683,7 @@ async def test_delete_dirs(jp_fetch, contents, folders):
assert model["content"] == []


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting non-empty dirs on Windows")
@pytest.mark.xfail(sys.platform == "win32", reason="Deleting non-empty dirs on Windows")
async def test_delete_non_empty_dir(jp_fetch, contents):
# Delete a folder
await jp_fetch("api", "contents", "å b", method="DELETE")
Expand All @@ -696,14 +693,12 @@ async def test_delete_non_empty_dir(jp_fetch, contents):
assert expected_http_error(e, 404)


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
async def test_delete_hidden_dir(jp_fetch, contents):
with pytest.raises(tornado.httpclient.HTTPClientError) as e:
await jp_fetch("api", "contents", ".hidden", method="DELETE")
assert expected_http_error(e, 400)


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
async def test_delete_hidden_file(jp_fetch, contents):
# Test deleting file in a hidden directory
with pytest.raises(tornado.httpclient.HTTPClientError) as e:
Expand Down Expand Up @@ -747,7 +742,6 @@ async def test_rename(jp_fetch, jp_base_url, contents, contents_dir):
assert "a.ipynb" not in nbnames


@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
async def test_rename_400_hidden(jp_fetch, jp_base_url, contents, contents_dir):
with pytest.raises(tornado.httpclient.HTTPClientError) as e:
old_path = ".hidden/old.txt"
Expand Down Expand Up @@ -1018,3 +1012,73 @@ async def test_trust(jp_fetch, contents):
allow_nonstandard_methods=True,
)
assert r.code == 201


@patch(
"jupyter_core.paths.is_hidden",
side_effect=AssertionError("Should not call is_hidden if not important"),
)
@patch(
"jupyter_server.services.contents.filemanager.is_hidden",
side_effect=AssertionError("Should not call is_hidden if not important"),
)
async def test_regression_is_hidden(m1, m2, jp_fetch, jp_serverapp, contents, _check_created):
# check that no is_hidden check runs if configured to allow hidden files
contents_dir = contents["contents_dir"]

hidden_dir = contents_dir / ".hidden"
hidden_dir.mkdir(parents=True, exist_ok=True)
txt = "visible text file in hidden dir"
txtname = hidden_dir.joinpath("visible.txt")
txtname.write_text(txt, encoding="utf-8")

# Our role here is to check that the side-effect never triggers
jp_serverapp.contents_manager.allow_hidden = True
r = await jp_fetch(
"api",
"contents",
".hidden",
)
assert r.code == 200

r = await jp_fetch(
"api",
"contents",
".hidden",
method="POST",
body=json.dumps(
{
"copy_from": ".hidden/visible.txt",
}
),
)
_check_created(r, str(contents_dir), ".hidden", "visible-Copy1.txt", type="file")

r = await jp_fetch(
"api",
"contents",
".hidden",
"visible-Copy1.txt",
method="DELETE",
)
assert r.code == 204

model = {
"content": "foo",
"format": "text",
"type": "file",
}
r = await jp_fetch(
"api", "contents", ".hidden", "new.txt", method="PUT", body=json.dumps(model)
)
_check_created(r, str(contents_dir), ".hidden", "new.txt", type="file")

# sanity check that is actually triggers when flag set to false
jp_serverapp.contents_manager.allow_hidden = False
with pytest.raises(tornado.httpclient.HTTPClientError) as e:
await jp_fetch(
"api",
"contents",
".hidden",
)
assert expected_http_error(e, 500)
42 changes: 40 additions & 2 deletions tests/services/contents/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,6 @@ async def test_403(jp_file_contents_manager_class, tmp_path):
assert e.status_code == 403


@pytest.mark.skipif(sys.platform.startswith("win"), reason="Can't test hidden files on Windows")
async def test_400(jp_file_contents_manager_class, tmp_path): # noqa
# Test Delete behavior
# Test delete of file in hidden directory
Expand Down Expand Up @@ -406,7 +405,6 @@ async def test_400(jp_file_contents_manager_class, tmp_path): # noqa
assert excinfo.value.status_code == 400


@pytest.mark.skipif(sys.platform.startswith("win"), reason="Can't test hidden files on Windows")
async def test_404(jp_file_contents_manager_class, tmp_path):
# Test visible file in hidden folder
with pytest.raises(HTTPError) as excinfo:
Expand Down Expand Up @@ -1010,3 +1008,43 @@ async def test_validate_notebook_model(jp_contents_manager):
cm.validate_notebook_model(model)
assert mock_validate_nb.call_count == 1
mock_validate_nb.reset_mock()


@patch(
"jupyter_core.paths.is_hidden",
side_effect=AssertionError("Should not call is_hidden if not important"),
)
@patch(
"jupyter_server.services.contents.filemanager.is_hidden",
side_effect=AssertionError("Should not call is_hidden if not important"),
)
async def test_regression_is_hidden(m1, m2, jp_contents_manager):
cm = jp_contents_manager
cm.allow_hidden = True
# Our role here is to check that the side-effect never triggers
dirname = "foo/.hidden_dir"
await make_populated_dir(cm, dirname)
await ensure_async(cm.get(dirname))
await check_populated_dir_files(cm, dirname)
await ensure_async(cm.get(path="/".join([dirname, "nb.ipynb"])))
await ensure_async(cm.get(path="/".join([dirname, "file.txt"])))
await ensure_async(cm.new(path="/".join([dirname, "nb2.ipynb"])))
await ensure_async(cm.new(path="/".join([dirname, "file2.txt"])))
await ensure_async(cm.new(path="/".join([dirname, "subdir"]), model={"type": "directory"}))
await ensure_async(
cm.copy(
from_path="/".join([dirname, "file.txt"]), to_path="/".join([dirname, "file-copy.txt"])
)
)
await ensure_async(
cm.rename_file(
old_path="/".join([dirname, "file-copy.txt"]),
new_path="/".join([dirname, "file-renamed.txt"]),
)
)
await ensure_async(cm.delete_file(path="/".join([dirname, "file-renamed.txt"])))

# sanity check that is actually triggers when flag set to false
cm.allow_hidden = False
with pytest.raises(AssertionError):
await ensure_async(cm.get(dirname))
Loading

0 comments on commit b5a6306

Please sign in to comment.