Skip to content

Commit 7822a51

Browse files
Baraff24fsbraun
andauthored
fix: add filename length safety check with random suffix (#1515)
* feat: add filename length safety check with random suffix * fix: correct base filename length for validation test * fix: update base filename length in validation test * test: enhance filename length validation and edge case handling Integration of the suggestion from the community and sourcery-ai * fix: update _ensure_safe_length to use settings for max length and random suffix * test: read max filename length and random suffix from settings * fix: set default values for max filename length and random suffix in _ensure_safe_length * Update filer/utils/files.py * Apply suggestions from code review --------- Co-authored-by: Fabian Braun <[email protected]>
1 parent 58b017d commit 7822a51

File tree

2 files changed

+146
-2
lines changed

2 files changed

+146
-2
lines changed

filer/utils/files.py

+33-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import mimetypes
22
import os
3+
import uuid
34

45
from django.http.multipartparser import ChunkIter, SkipFile, StopFutureHandlers, StopUpload, exhaust
56
from django.template.defaultfilters import slugify as slugify_django
@@ -121,6 +122,33 @@ def slugify(string):
121122
return slugify_django(force_str(string))
122123

123124

125+
def _ensure_safe_length(filename, max_length=155, random_suffix_length=16):
126+
"""
127+
Ensures that the filename does not exceed the maximum allowed length.
128+
If it does, the function truncates the filename and appends a random hexadecimal
129+
suffix of length `random_suffix_length` to ensure uniqueness and compliance with
130+
database constraints - even after markers for a thumbnail are added.
131+
132+
Parameters:
133+
filename (str): The filename to check.
134+
max_length (int): The maximum allowed length for the filename.
135+
random_suffix_length (int): The length of the random suffix to append.
136+
137+
Returns:
138+
str: The safe filename.
139+
140+
141+
Reference issue: https://github.com/django-cms/django-filer/issues/1270
142+
"""
143+
144+
if len(filename) <= max_length:
145+
return filename
146+
147+
keep_length = max_length - random_suffix_length
148+
random_suffix = uuid.uuid4().hex[:random_suffix_length]
149+
return filename[:keep_length] + random_suffix
150+
151+
124152
def get_valid_filename(s):
125153
"""
126154
like the regular get_valid_filename, but also slugifies away
@@ -131,6 +159,9 @@ def get_valid_filename(s):
131159
filename = slugify(filename)
132160
ext = slugify(ext)
133161
if ext:
134-
return "{}.{}".format(filename, ext)
162+
valid_filename = "{}.{}".format(filename, ext)
135163
else:
136-
return "{}".format(filename)
164+
valid_filename = "{}".format(filename)
165+
166+
# Ensure the filename meets the maximum length requirements.
167+
return _ensure_safe_length(valid_filename)

tests/test_files.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import string
2+
3+
from django.test import TestCase
4+
from filer.utils.files import get_valid_filename
5+
6+
7+
class GetValidFilenameTest(TestCase):
8+
9+
def setUp(self):
10+
"""
11+
Set up the test case by reading the configuration settings for the maximum filename length.
12+
"""
13+
self.max_length = 155
14+
self.random_suffix_length = 16
15+
16+
def test_short_filename_remains_unchanged(self):
17+
"""
18+
Test that a filename under the maximum length remains unchanged.
19+
"""
20+
original = "example.jpg"
21+
result = get_valid_filename(original)
22+
self.assertEqual(result, "example.jpg")
23+
24+
def test_long_filename_is_truncated_and_suffix_appended(self):
25+
"""
26+
Test that a filename longer than the maximum allowed length is truncated and a random
27+
hexadecimal suffix of length 16 is appended, resulting in exactly 255 characters.
28+
"""
29+
base = "a" * 300 # 300 characters base
30+
original = f"{base}.jpg"
31+
result = get_valid_filename(original)
32+
self.assertEqual(
33+
len(result),
34+
self.max_length,
35+
"Filename length should be exactly 255 characters."
36+
)
37+
# Verify that the last 16 characters form a valid hexadecimal string.
38+
random_suffix = result[-16:]
39+
valid_hex_chars = set(string.hexdigits)
40+
self.assertTrue(all(c in valid_hex_chars for c in random_suffix),
41+
"The suffix is not a valid hexadecimal string.")
42+
43+
def test_filename_with_extension_preserved(self):
44+
"""
45+
Test that the file extension is preserved (and slugified) after processing.
46+
"""
47+
original = "This is a test IMAGE.JPG"
48+
result = get_valid_filename(original)
49+
self.assertTrue(result.endswith(".jpg"),
50+
"File extension was not preserved correctly.")
51+
52+
def test_unicode_characters(self):
53+
"""
54+
Test that filenames with Unicode characters are handled correctly.
55+
"""
56+
original = "fiłęñâmé_üñîçødé.jpeg"
57+
result = get_valid_filename(original)
58+
self.assertTrue(result.endswith(".jpeg"),
59+
"File extension is not preserved for unicode filename.")
60+
# Verify that the resulting filename contains only allowed characters.
61+
allowed_chars = set(string.ascii_lowercase + string.digits + "._-")
62+
for char in result:
63+
self.assertIn(char, allowed_chars,
64+
f"Unexpected character '{char}' found in filename.")
65+
66+
def test_edge_case_exact_length(self):
67+
"""
68+
Test that a filename exactly at the maximum allowed length remains unchanged.
69+
"""
70+
extension = ".png"
71+
base_length = 155 - len(extension)
72+
base = "b" * base_length
73+
original = f"{base}{extension}"
74+
result = get_valid_filename(original)
75+
self.assertEqual(
76+
len(result),
77+
self.max_length,
78+
"Filename with length exactly 255 should remain unchanged."
79+
)
80+
self.assertEqual(result, original,
81+
"Filename with length exactly 255 should not be modified.")
82+
83+
def test_edge_case_filenames(self):
84+
"""
85+
Test filenames at various boundary conditions to ensure correct behavior.
86+
"""
87+
max_length = self.max_length
88+
random_suffix_length = self.random_suffix_length
89+
extension = ".jpg"
90+
91+
# Test case 1: Filename with length exactly max_length - 1.
92+
base_length = max_length - 1 - len(extension)
93+
base = "c" * base_length
94+
original = f"{base}{extension}"
95+
result = get_valid_filename(original)
96+
self.assertEqual(result, original,
97+
"Filename with length max_length-1 should remain unchanged.")
98+
99+
# Test case 2: Filename with length exactly equal to max_length - random_suffix_length.
100+
base_length = max_length - random_suffix_length - len(extension)
101+
base = "d" * base_length
102+
original = f"{base}{extension}"
103+
result = get_valid_filename(original)
104+
self.assertEqual(result, original,
105+
"Filename with length equal to max_length - random_suffix_length should remain unchanged.")
106+
107+
# Test case 3: Filename with length exactly equal to max_length - random_suffix_length - 1.
108+
base_length = max_length - random_suffix_length - 1 - len(extension)
109+
base = "e" * base_length
110+
original = f"{base}{extension}"
111+
result = get_valid_filename(original)
112+
self.assertEqual(result, original,
113+
"Filename with length equal to max_length - random_suffix_length - 1 should remain unchanged.")

0 commit comments

Comments
 (0)