Daniel Brötzmann pushed to branch sanitize-filenames at gajim / gajim
Commits:
-
f9a3eaad
by wurstsalat at 2022-08-26T17:38:27+02:00
-
cb93756a
by wurstsalat at 2022-08-26T19:55:04+02:00
4 changed files:
- gajim/common/helpers.py
- gajim/common/modules/chatstates.py
- gajim/common/preview_helpers.py
- + test/no_gui/test_sanitize_filename.py
Changes:
| ... | ... | @@ -37,7 +37,6 @@ |
| 37 | 37 | import re
|
| 38 | 38 | import os
|
| 39 | 39 | import subprocess
|
| 40 | -import base64
|
|
| 41 | 40 | import hashlib
|
| 42 | 41 | import shlex
|
| 43 | 42 | import socket
|
| ... | ... | @@ -58,10 +57,10 @@ |
| 58 | 57 | from datetime import timedelta
|
| 59 | 58 | from urllib.parse import unquote
|
| 60 | 59 | from urllib.parse import urlparse
|
| 61 | -from encodings.punycode import punycode_encode
|
|
| 62 | 60 | from functools import wraps
|
| 63 | 61 | from pathlib import Path
|
| 64 | 62 | from packaging.version import Version as V
|
| 63 | +import unicodedata
|
|
| 65 | 64 | |
| 66 | 65 | from nbxmpp.namespaces import Namespace
|
| 67 | 66 | from nbxmpp.const import Role
|
| ... | ... | @@ -358,26 +357,35 @@ def get_file_path_from_dnd_dropped_uri(uri: str) -> str: |
| 358 | 357 | |
| 359 | 358 | def sanitize_filename(filename: str) -> str:
|
| 360 | 359 | '''
|
| 361 | - Make sure the filename we will write does contain only acceptable and latin
|
|
| 362 | - characters, and is not too long (in that case hash it)
|
|
| 360 | + Sanitize filename of elements not allowed on Windows
|
|
| 361 | + https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
| 363 | 362 | '''
|
| 364 | - # 48 is the limit
|
|
| 365 | - if len(filename) > 48:
|
|
| 366 | - hash_ = hashlib.md5(filename.encode('utf-8'))
|
|
| 367 | - filename = base64.b64encode(hash_.digest()).decode('utf-8')
|
|
| 368 | - |
|
| 369 | - # make it latin chars only
|
|
| 370 | - filename = punycode_encode(filename).decode('utf-8')
|
|
| 371 | - filename = filename.replace('/', '_')
|
|
| 372 | - if os.name == 'nt':
|
|
| 373 | - filename = filename.replace('?', '_')\
|
|
| 374 | - .replace(':', '_')\
|
|
| 375 | - .replace('\\', '_')\
|
|
| 376 | - .replace('"', "'")\
|
|
| 377 | - .replace('|', '_')\
|
|
| 378 | - .replace('*', '_')\
|
|
| 379 | - .replace('<', '_')\
|
|
| 380 | - .replace('>', '_')
|
|
| 363 | + blacklist = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', '\0']
|
|
| 364 | + reserved_filenames = [
|
|
| 365 | + 'CON', 'PRN', 'AUX', 'NUL',
|
|
| 366 | + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
|
| 367 | + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
|
| 368 | + ]
|
|
| 369 | + filename = ''.join(char for char in filename if char not in blacklist)
|
|
| 370 | + |
|
| 371 | + filename = ''.join(char for char in filename if 31 < ord(char))
|
|
| 372 | + |
|
| 373 | + filename = unicodedata.normalize('NFKD', filename)
|
|
| 374 | + filename = filename.rstrip('. ')
|
|
| 375 | + filename = filename.strip()
|
|
| 376 | + |
|
| 377 | + if all([char == '.' for char in filename]):
|
|
| 378 | + filename = f'__{filename}'
|
|
| 379 | + if filename in reserved_filenames:
|
|
| 380 | + filename = f'__{filename}'
|
|
| 381 | + if len(filename) == 0:
|
|
| 382 | + filename = '__'
|
|
| 383 | + if len(filename) > 50:
|
|
| 384 | + # Many Filesystems have a limit on filename length
|
|
| 385 | + # Most have 255, some encrypted ones only 143
|
|
| 386 | + # We add around 50 chars for the hash,
|
|
| 387 | + # so the filename should not exceed 50
|
|
| 388 | + filename = filename[:50]
|
|
| 381 | 389 | |
| 382 | 390 | return filename
|
| 383 | 391 |
| ... | ... | @@ -253,6 +253,11 @@ def set_chatstate_delayed(self, |
| 253 | 253 | # Used when we go from Composing -> Active after deleting all text
|
| 254 | 254 | # from the Textview. We delay the Active state because maybe the
|
| 255 | 255 | # User starts writing again.
|
| 256 | + |
|
| 257 | + # Don’t send chatstates to ourself
|
|
| 258 | + if self._con.get_own_jid().bare_match(contact.jid):
|
|
| 259 | + return
|
|
| 260 | + |
|
| 256 | 261 | self.remove_delay_timeout(contact)
|
| 257 | 262 | self._delay_timeout_ids[contact.jid] = GLib.timeout_add_seconds(
|
| 258 | 263 | 2, self.set_chatstate, contact, state)
|
| ... | ... | @@ -45,6 +45,7 @@ |
| 45 | 45 | from cryptography.hazmat.primitives.ciphers import algorithms
|
| 46 | 46 | from cryptography.hazmat.primitives.ciphers.modes import GCM
|
| 47 | 47 | |
| 48 | +from gajim.common.helpers import sanitize_filename
|
|
| 48 | 49 | from gajim.common.i18n import _
|
| 49 | 50 | |
| 50 | 51 | log = logging.getLogger('gajim.c.preview_helpers')
|
| ... | ... | @@ -308,12 +309,7 @@ def get_image_paths(uri: str, |
| 308 | 309 | web_stem = path.stem
|
| 309 | 310 | extension = path.suffix
|
| 310 | 311 | |
| 311 | - if len(web_stem) > 90:
|
|
| 312 | - # Many Filesystems have a limit on filename length
|
|
| 313 | - # Most have 255, some encrypted ones only 143
|
|
| 314 | - # We add around 50 chars for the hash,
|
|
| 315 | - # so the filename should not exceed 90
|
|
| 316 | - web_stem = web_stem[:90]
|
|
| 312 | + web_stem = sanitize_filename(web_stem)
|
|
| 317 | 313 | |
| 318 | 314 | name_hash = hashlib.sha1(str(uri).encode()).hexdigest()
|
| 319 | 315 |
| 1 | +import unittest
|
|
| 2 | + |
|
| 3 | +from gajim.common.helpers import sanitize_filename
|
|
| 4 | + |
|
| 5 | + |
|
| 6 | +class SanitizeTest(unittest.TestCase):
|
|
| 7 | + '''Tests for the sanitize_filename function.'''
|
|
| 8 | + |
|
| 9 | + def test_invalid_chars(self):
|
|
| 10 | + '''Make sure invalid characters are removed in filenames.'''
|
|
| 11 | + self.assertEqual(sanitize_filename('A/B/C'), 'ABC')
|
|
| 12 | + self.assertEqual(sanitize_filename('A*C.d'), 'AC.d')
|
|
| 13 | + self.assertEqual(sanitize_filename('A?C.d'), 'AC.d')
|
|
| 14 | + |
|
| 15 | + def test_invalid_suffix(self):
|
|
| 16 | + '''Dots are not allowed at the end.'''
|
|
| 17 | + self.assertEqual(sanitize_filename('def.'), 'def')
|
|
| 18 | + self.assertEqual(sanitize_filename('def.ghi'), 'def.ghi')
|
|
| 19 | + self.assertTrue(sanitize_filename('X' * 1000 + '.').endswith('X'))
|
|
| 20 | + |
|
| 21 | + def test_reserved_words(self):
|
|
| 22 | + '''Make sure reserved Windows words are prefixed.'''
|
|
| 23 | + self.assertEqual(sanitize_filename('NUL'), '__NUL')
|
|
| 24 | + self.assertEqual(sanitize_filename('..'), '__')
|
|
| 25 | + |
|
| 26 | + def test_long_names(self):
|
|
| 27 | + '''Make sure long names are truncated.'''
|
|
| 28 | + self.assertEqual(len(sanitize_filename('X' * 300)), 50)
|
|
| 29 | + self.assertEqual(len(sanitize_filename(
|
|
| 30 | + '.'.join(['X' * 100, 'X' * 100, 'X' * 100]))), 50)
|
|
| 31 | + self.assertEqual(len(sanitize_filename(
|
|
| 32 | + '.'.join(['X' * 300, 'X' * 300, 'X' * 300]))), 50)
|
|
| 33 | + self.assertEqual(len(sanitize_filename('.' * 300 + '.txt')), 50)
|
|
| 34 | + |
|
| 35 | + def test_unicode_normalization(self):
|
|
| 36 | + '''Names should be NFKD normalized.'''
|
|
| 37 | + self.assertEqual(sanitize_filename('ў'), chr(1091) + chr(774))
|
|
| 38 | + |
|
| 39 | + |
|
| 40 | +if __name__ == '__main__':
|
|
| 41 | + unittest.main() |