Skip to content

Commit 394e5d6

Browse files
authored
Revert "[general] Write overwriting files in terms of .exists() (#1422)" (#1484)
* Revert "[general] Write overwriting files in terms of .exists() (#1422)" This reverts commit a531947. * [dropbox] Remove coercing to full path before getting name
1 parent af98a96 commit 394e5d6

File tree

10 files changed

+145
-39
lines changed

10 files changed

+145
-39
lines changed

storages/backends/azure_storage.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from storages.base import BaseStorage
2222
from storages.utils import clean_name
23+
from storages.utils import get_available_overwrite_name
2324
from storages.utils import safe_join
2425
from storages.utils import setting
2526
from storages.utils import to_bytes
@@ -240,13 +241,20 @@ def _get_valid_path(self, name):
240241
def _open(self, name, mode="rb"):
241242
return AzureStorageFile(name, mode, self)
242243

244+
def get_available_name(self, name, max_length=_AZURE_NAME_MAX_LEN):
245+
"""
246+
Returns a filename that's free on the target storage system, and
247+
available for new content to be written to.
248+
"""
249+
name = clean_name(name)
250+
if self.overwrite_files:
251+
return get_available_overwrite_name(name, max_length)
252+
return super().get_available_name(name, max_length)
253+
243254
def exists(self, name):
244255
if not name:
245256
return True
246257

247-
if self.overwrite_files:
248-
return False
249-
250258
blob_client = self.client.get_blob_client(self._get_valid_path(name))
251259
return blob_client.exists()
252260

storages/backends/dropbox.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dropbox.files import WriteMode
2020

2121
from storages.base import BaseStorage
22+
from storages.utils import get_available_overwrite_name
2223
from storages.utils import setting
2324

2425
_DEFAULT_TIMEOUT = 100
@@ -48,7 +49,7 @@ def _get_file(self):
4849
with BytesIO(response.content) as file_content:
4950
copyfileobj(file_content, self._file)
5051
else:
51-
# JIC the exception isn't catched by the dropbox client
52+
# JIC the exception isn't caught by the dropbox client
5253
raise DropboxStorageException(
5354
"Dropbox server returned a {} response when accessing {}".format(
5455
response.status_code, self.name
@@ -125,9 +126,6 @@ def delete(self, name):
125126
self.client.files_delete(self._full_path(name))
126127

127128
def exists(self, name):
128-
if self.write_mode == "overwrite":
129-
return False
130-
131129
try:
132130
return bool(self.client.files_get_metadata(self._full_path(name)))
133131
except ApiError:
@@ -194,5 +192,11 @@ def _chunked_upload(self, content, dest_path):
194192
)
195193
cursor.offset = content.tell()
196194

195+
def get_available_name(self, name, max_length=None):
196+
"""Overwrite existing file with the same name."""
197+
if self.write_mode == "overwrite":
198+
return get_available_overwrite_name(name, max_length)
199+
return super().get_available_name(name, max_length)
200+
197201

198202
DropBoxStorage = DropboxStorage

storages/backends/gcloud.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from storages.compress import CompressedFileMixin
1515
from storages.utils import check_location
1616
from storages.utils import clean_name
17+
from storages.utils import get_available_overwrite_name
1718
from storages.utils import safe_join
1819
from storages.utils import setting
1920
from storages.utils import to_bytes
@@ -241,9 +242,6 @@ def exists(self, name):
241242
except NotFound:
242243
return False
243244

244-
if self.file_overwrite:
245-
return False
246-
247245
name = self._normalize_name(clean_name(name))
248246
return bool(self.bucket.get_blob(name))
249247

@@ -334,3 +332,9 @@ def url(self, name, parameters=None):
334332
params[key] = value
335333

336334
return blob.generate_signed_url(**params)
335+
336+
def get_available_name(self, name, max_length=None):
337+
name = clean_name(name)
338+
if self.file_overwrite:
339+
return get_available_overwrite_name(name, max_length)
340+
return super().get_available_name(name, max_length)

storages/backends/s3.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from storages.utils import ReadBytesWrapper
2424
from storages.utils import check_location
2525
from storages.utils import clean_name
26+
from storages.utils import get_available_overwrite_name
2627
from storages.utils import is_seekable
2728
from storages.utils import lookup_env
2829
from storages.utils import safe_join
@@ -579,9 +580,6 @@ def delete(self, name):
579580
raise
580581

581582
def exists(self, name):
582-
if self.file_overwrite:
583-
return False
584-
585583
name = self._normalize_name(clean_name(name))
586584
try:
587585
self.connection.meta.client.head_object(Bucket=self.bucket_name, Key=name)
@@ -698,6 +696,13 @@ def url(self, name, parameters=None, expire=None, http_method=None):
698696
)
699697
return url
700698

699+
def get_available_name(self, name, max_length=None):
700+
"""Overwrite existing file with the same name."""
701+
name = clean_name(name)
702+
if self.file_overwrite:
703+
return get_available_overwrite_name(name, max_length)
704+
return super().get_available_name(name, max_length)
705+
701706

702707
class S3StaticStorage(S3Storage):
703708
"""Querystring auth must be disabled so that url() returns a consistent output."""

storages/utils.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from django.conf import settings
66
from django.core.exceptions import ImproperlyConfigured
7+
from django.core.exceptions import SuspiciousFileOperation
78
from django.core.files.utils import FileProxyMixin
89
from django.utils.encoding import force_bytes
910

@@ -119,6 +120,25 @@ def lookup_env(names):
119120
return value
120121

121122

123+
def get_available_overwrite_name(name, max_length):
124+
if max_length is None or len(name) <= max_length:
125+
return name
126+
127+
# Adapted from Django
128+
dir_name, file_name = os.path.split(name)
129+
file_root, file_ext = os.path.splitext(file_name)
130+
truncation = len(name) - max_length
131+
132+
file_root = file_root[:-truncation]
133+
if not file_root:
134+
raise SuspiciousFileOperation(
135+
'Storage tried to truncate away entire filename "%s". '
136+
"Please make sure that the corresponding file field "
137+
'allows sufficient "max_length".' % name
138+
)
139+
return os.path.join(dir_name, "{}{}".format(file_root, file_ext))
140+
141+
122142
def is_seekable(file_object):
123143
return not hasattr(file_object, "seekable") or file_object.seekable()
124144

tests/test_azure.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from datetime import timedelta
33
from unittest import mock
44

5+
import django
56
from azure.storage.blob import BlobProperties
7+
from django.core.exceptions import SuspiciousOperation
68
from django.core.files.base import ContentFile
79
from django.test import TestCase
810
from django.test import override_settings
@@ -68,6 +70,65 @@ def test_get_valid_path_idempotency(self):
6870
self.storage._get_valid_path(some_path),
6971
)
7072

73+
def test_get_available_name(self):
74+
self.storage.overwrite_files = False
75+
client_mock = mock.MagicMock()
76+
client_mock.exists.side_effect = [True, False]
77+
self.storage._client.get_blob_client.return_value = client_mock
78+
name = self.storage.get_available_name("foo.txt")
79+
self.assertTrue(name.startswith("foo_"))
80+
self.assertTrue(name.endswith(".txt"))
81+
self.assertTrue(len(name) > len("foo.txt"))
82+
self.assertEqual(client_mock.exists.call_count, 2)
83+
84+
def test_get_available_name_first(self):
85+
self.storage.overwrite_files = False
86+
client_mock = mock.MagicMock()
87+
client_mock.exists.return_value = False
88+
self.storage._client.get_blob_client.return_value = client_mock
89+
self.assertEqual(
90+
self.storage.get_available_name("foo bar baz.txt"), "foo bar baz.txt"
91+
)
92+
self.assertEqual(client_mock.exists.call_count, 1)
93+
94+
def test_get_available_name_max_len(self):
95+
self.storage.overwrite_files = False
96+
# if you wonder why this is, file-system
97+
# storage will raise when file name is too long as well,
98+
# the form should validate this
99+
client_mock = mock.MagicMock()
100+
client_mock.exists.side_effect = [True, False]
101+
self.storage._client.get_blob_client.return_value = client_mock
102+
self.assertRaises(ValueError, self.storage.get_available_name, "a" * 1025)
103+
name = self.storage.get_available_name(
104+
"a" * 1000, max_length=100
105+
) # max_len == 1024
106+
self.assertEqual(len(name), 100)
107+
self.assertTrue("_" in name)
108+
self.assertEqual(client_mock.exists.call_count, 2)
109+
110+
def test_get_available_invalid(self):
111+
self.storage.overwrite_files = False
112+
self.storage._client.exists.return_value = False
113+
if django.VERSION[:2] == (3, 0):
114+
# Django 2.2.21 added this security fix:
115+
# https://docs.djangoproject.com/en/3.2/releases/2.2.21/#cve-2021-31542-potential-directory-traversal-via-uploaded-files
116+
# It raises SuspiciousOperation before we get to our ValueError.
117+
# The fix wasn't applied to 3.0 (no longer in support), but was applied to
118+
# 3.1 & 3.2.
119+
self.assertRaises(ValueError, self.storage.get_available_name, "")
120+
self.assertRaises(ValueError, self.storage.get_available_name, "/")
121+
self.assertRaises(ValueError, self.storage.get_available_name, ".")
122+
self.assertRaises(ValueError, self.storage.get_available_name, "///")
123+
else:
124+
self.assertRaises(SuspiciousOperation, self.storage.get_available_name, "")
125+
self.assertRaises(SuspiciousOperation, self.storage.get_available_name, "/")
126+
self.assertRaises(SuspiciousOperation, self.storage.get_available_name, ".")
127+
self.assertRaises(
128+
SuspiciousOperation, self.storage.get_available_name, "///"
129+
)
130+
self.assertRaises(ValueError, self.storage.get_available_name, "...")
131+
71132
def test_url(self):
72133
blob_mock = mock.MagicMock()
73134
blob_mock.url = "https://ret_foo.blob.core.windows.net/test/some%20blob"
@@ -296,15 +357,11 @@ def test_storage_open_write(self):
296357
)
297358

298359
def test_storage_exists(self):
299-
overwrite_files = [True, False]
300-
for owf in overwrite_files:
301-
self.storage.overwrite_files = owf
302-
client_mock = mock.MagicMock()
303-
self.storage._client.get_blob_client.return_value = client_mock
304-
assert_ = self.assertFalse if owf else self.assertTrue
305-
call_count = 0 if owf else 1
306-
assert_(self.storage.exists("blob"))
307-
self.assertEqual(client_mock.exists.call_count, call_count)
360+
blob_name = "blob"
361+
client_mock = mock.MagicMock()
362+
self.storage._client.get_blob_client.return_value = client_mock
363+
self.assertTrue(self.storage.exists(blob_name))
364+
self.assertEqual(client_mock.exists.call_count, 1)
308365

309366
def test_delete_blob(self):
310367
self.storage.delete("name")

tests/test_dropbox.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,6 @@ def test_not_exists(self, *args):
9898
exists = self.storage.exists("bar")
9999
self.assertFalse(exists)
100100

101-
@mock.patch("dropbox.Dropbox.files_get_metadata", return_value=[FILE_METADATA_MOCK])
102-
def test_exists_overwrite_mode(self, *args):
103-
self.storage.write_mode = "overwrite"
104-
self.assertFalse(self.storage.exists("foo"))
105-
106101
@mock.patch("dropbox.Dropbox.files_list_folder", return_value=FILES_MOCK)
107102
def test_listdir(self, *args):
108103
dirs, files = self.storage.listdir("/")

tests/test_gcloud.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ def test_delete(self):
168168
)
169169

170170
def test_exists(self):
171-
self.storage.file_overwrite = False
172171
self.storage._bucket = mock.MagicMock()
173172
self.assertTrue(self.storage.exists(self.filename))
174173
self.storage._bucket.get_blob.assert_called_with(self.filename)
@@ -188,10 +187,6 @@ def test_exists_bucket(self):
188187
# exists('') should return True if the bucket exists
189188
self.assertTrue(self.storage.exists(""))
190189

191-
def test_exists_file_overwrite(self):
192-
self.storage.file_overwrite = True
193-
self.assertFalse(self.storage.exists(self.filename))
194-
195190
def test_listdir(self):
196191
file_names = ["some/path/1.txt", "2.txt", "other/path/3.txt", "4.txt"]
197192
subdir = ""

tests/test_s3.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -496,15 +496,13 @@ def test_storage_write_beyond_buffer_size(self):
496496
)
497497

498498
def test_storage_exists(self):
499-
self.storage.file_overwrite = False
500499
self.assertTrue(self.storage.exists("file.txt"))
501500
self.storage.connection.meta.client.head_object.assert_called_with(
502501
Bucket=self.storage.bucket_name,
503502
Key="file.txt",
504503
)
505504

506505
def test_storage_exists_false(self):
507-
self.storage.file_overwrite = False
508506
self.storage.connection.meta.client.head_object.side_effect = ClientError(
509507
{"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 404}},
510508
"HeadObject",
@@ -516,7 +514,6 @@ def test_storage_exists_false(self):
516514
)
517515

518516
def test_storage_exists_other_error_reraise(self):
519-
self.storage.file_overwrite = False
520517
self.storage.connection.meta.client.head_object.side_effect = ClientError(
521518
{"Error": {}, "ResponseMetadata": {"HTTPStatusCode": 403}},
522519
"HeadObject",
@@ -528,10 +525,6 @@ def test_storage_exists_other_error_reraise(self):
528525
cm.exception.response["ResponseMetadata"]["HTTPStatusCode"], 403
529526
)
530527

531-
def test_storage_exists_overwrite(self):
532-
self.storage.file_overwrite = True
533-
self.assertFalse(self.storage.exists("foo"))
534-
535528
def test_storage_delete(self):
536529
self.storage.delete("path/to/file.txt")
537530
self.storage.bucket.Object.assert_called_with("path/to/file.txt")

tests/test_utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import pathlib
55

66
from django.conf import settings
7+
from django.core.exceptions import SuspiciousFileOperation
78
from django.test import TestCase
89

910
from storages import utils
11+
from storages.utils import get_available_overwrite_name as gaon
1012

1113

1214
class SettingTest(TestCase):
@@ -116,6 +118,29 @@ def test_with_base_url_join_nothing(self):
116118
self.assertEqual(path, "base_url/")
117119

118120

121+
class TestGetAvailableOverwriteName(TestCase):
122+
def test_maxlength_is_none(self):
123+
name = "superlong/file/with/path.txt"
124+
self.assertEqual(gaon(name, None), name)
125+
126+
def test_maxlength_equals_name(self):
127+
name = "parent/child.txt"
128+
self.assertEqual(gaon(name, len(name)), name)
129+
130+
def test_maxlength_is_greater_than_name(self):
131+
name = "parent/child.txt"
132+
self.assertEqual(gaon(name, len(name) + 1), name)
133+
134+
def test_maxlength_less_than_name(self):
135+
name = "parent/child.txt"
136+
self.assertEqual(gaon(name, len(name) - 1), "parent/chil.txt")
137+
138+
def test_truncates_away_filename_raises(self):
139+
name = "parent/child.txt"
140+
with self.assertRaises(SuspiciousFileOperation):
141+
gaon(name, len(name) - 5)
142+
143+
119144
class TestReadBytesWrapper(TestCase):
120145
def test_with_bytes_file(self):
121146
file = io.BytesIO(b"abcd")

0 commit comments

Comments
 (0)