Skip to content

Commit

Permalink
Fixed #35604, Refs #35326 -- Made FileSystemStorage.exists() behaviou…
Browse files Browse the repository at this point in the history
…r independent from allow_overwrite.

Partially reverts 0b33a3a.

Storage.exists(name) was documented to "return False if
the name is available for a new file." but return True if
the file exists. This is ambiguous in the overwrite file
case. It will now always return whether the file exists.

Thank you to Natalia Bidart and Josh Schneier for the
review.
  • Loading branch information
sarahboyce committed Jul 24, 2024
1 parent 5559011 commit 8d6a20b
Show file tree
Hide file tree
Showing 5 changed files with 48 additions and 20 deletions.
10 changes: 7 additions & 3 deletions django/core/files/storage/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ def save(self, name, content, max_length=None):
validate_file_name(name, allow_relative_path=True)
return name

def is_name_available(self, name, max_length=None):
exceeds_max_length = max_length and len(name) > max_length
return not self.exists(name) and not exceeds_max_length

# These methods are part of the public API, with default implementations.

def get_valid_name(self, name):
Expand Down Expand Up @@ -82,11 +86,11 @@ def get_available_name(self, name, max_length=None):
validate_file_name(file_name)
file_ext = "".join(pathlib.PurePath(file_name).suffixes)
file_root = file_name.removesuffix(file_ext)
# If the filename already exists, generate an alternative filename
# until it doesn't exist.
# If the filename is not available, generate an alternative
# filename until one is available.
# Truncate original name if required, so the new filename does not
# exceed the max_length.
while self.exists(name) or (max_length and len(name) > max_length):
while not self.is_name_available(name, max_length=max_length):
# file_ext includes the dot.
name = os.path.join(
dir_name, self.get_alternative_name(file_root, file_ext)
Expand Down
19 changes: 11 additions & 8 deletions django/core/files/storage/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from urllib.parse import urljoin

from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.core.files import File, locks
from django.core.files.move import file_move_safe
from django.core.signals import setting_changed
Expand Down Expand Up @@ -192,14 +191,18 @@ def delete(self, name):
# concurrently.
pass

def exists(self, name):
try:
exists = os.path.lexists(self.path(name))
except SuspiciousFileOperation:
raise
def is_name_available(self, name, max_length=None):
if self._allow_overwrite:
return not (max_length and len(name) > max_length)
return super().is_name_available(name, max_length=max_length)

def get_alternative_name(self, file_root, file_ext):
if self._allow_overwrite:
return False
return exists
return f"{file_root}{file_ext}"
return super().get_alternative_name(file_root, file_ext)

def exists(self, name):
return os.path.lexists(self.path(name))

def listdir(self, path):
path = self.path(path)
Expand Down
3 changes: 1 addition & 2 deletions docs/ref/files/storage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ The ``Storage`` class
.. method:: exists(name)

Returns ``True`` if a file referenced by the given name already exists
in the storage system, or ``False`` if the name is available for a new
file.
in the storage system.

.. method:: get_accessed_time(name)

Expand Down
6 changes: 6 additions & 0 deletions tests/file_storage/test_generate_filename.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ def test_storage_dangerous_paths(self):
("", ""),
]
s = FileSystemStorage()
s_overwrite = FileSystemStorage(allow_overwrite=True)
msg = "Could not derive file name from '%s'"
for file_name, base_name in candidates:
with self.subTest(file_name=file_name):
with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
s.get_available_name(file_name)
with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
s_overwrite.get_available_name(file_name)
with self.assertRaisesMessage(SuspiciousFileOperation, msg % base_name):
s.generate_filename(file_name)

Expand All @@ -98,11 +101,14 @@ def test_storage_dangerous_paths_dir_name(self):
("\\tmp\\..\\path", "/tmp/.."),
]
s = FileSystemStorage()
s_overwrite = FileSystemStorage(allow_overwrite=True)
for file_name, path in candidates:
msg = "Detected path traversal attempt in '%s'" % path
with self.subTest(file_name=file_name):
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
s.get_available_name(file_name)
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
s_overwrite.get_available_name(file_name)
with self.assertRaisesMessage(SuspiciousFileOperation, msg):
s.generate_filename(file_name)

Expand Down
30 changes: 23 additions & 7 deletions tests/file_storage/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,18 @@ def test_file_access_options(self):
"""
Standard file access options are available, and work as expected.
"""
self.assertFalse(os.path.exists(os.path.join(self.temp_dir, "storage_test")))
self.assertFalse(self.storage.exists("storage_test"))
f = self.storage.open("storage_test", "w")
f.write("storage contents")
f.close()
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, "storage_test")))
self.assertTrue(self.storage.exists("storage_test"))

f = self.storage.open("storage_test", "r")
self.assertEqual(f.read(), "storage contents")
f.close()

self.storage.delete("storage_test")
self.assertFalse(os.path.exists(os.path.join(self.temp_dir, "storage_test")))
self.assertFalse(self.storage.exists("storage_test"))

def _test_file_time_getter(self, getter):
# Check for correct behavior under both USE_TZ=True and USE_TZ=False.
Expand Down Expand Up @@ -275,10 +275,10 @@ def test_file_save_with_path(self):
"""
Saving a pathname should create intermediate directories as necessary.
"""
self.assertFalse(os.path.exists(os.path.join(self.temp_dir, "path/to")))
self.assertFalse(self.storage.exists("path/to"))
self.storage.save("path/to/test.file", ContentFile("file saved with path"))

self.assertTrue(os.path.exists(os.path.join(self.temp_dir, "path/to")))
self.assertTrue(self.storage.exists("path/to"))
with self.storage.open("path/to/test.file") as f:
self.assertEqual(f.read(), b"file saved with path")

Expand Down Expand Up @@ -692,12 +692,12 @@ def test_save_overwrite_behavior(self):
stored_name_1 = self.storage.save(name, f_1)
try:
self.assertEqual(stored_name_1, name)
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, name)))
self.assertTrue(self.storage.exists(name))
with self.storage.open(name) as fp:
self.assertEqual(fp.read(), content_1)
stored_name_2 = self.storage.save(name, f_2)
self.assertEqual(stored_name_2, name)
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, name)))
self.assertTrue(self.storage.exists(name))
with self.storage.open(name) as fp:
self.assertEqual(fp.read(), content_2)
finally:
Expand Down Expand Up @@ -729,6 +729,22 @@ def test_save_overwrite_behavior_temp_file(self):
finally:
self.storage.delete(name)

def test_file_name_truncation(self):
name = "test_long_file_name.txt"
file = ContentFile(b"content")
stored_name = self.storage.save(name, file, max_length=10)
self.addCleanup(self.storage.delete, stored_name)
self.assertEqual(stored_name, "test_l.txt")
self.assertEqual(len(stored_name), 10)

def test_file_name_truncation_extension_too_long(self):
name = "file_name.longext"
file = ContentFile(b"content")
with self.assertRaisesMessage(
SuspiciousFileOperation, "Storage can not find an available filename"
):
self.storage.save(name, file, max_length=5)


class DiscardingFalseContentStorage(FileSystemStorage):
def _save(self, name, content):
Expand Down

0 comments on commit 8d6a20b

Please sign in to comment.