mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-09-22 02:45:20 -04:00
On Windows, creating symbolic links requires either an elevated shell or Developer Mode. This introduces a runtime check to verify if symlinks can be created. Tests that rely on symlink creation are now skipped if this check fails, preventing unnecessary test failures in environments without the required permissions.
1729 lines
76 KiB
Python
1729 lines
76 KiB
Python
#!/usr/bin/python3 -OO
|
|
# Copyright 2007-2026 by The SABnzbd-Team (sabnzbd.org)
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version 2
|
|
# of the License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
|
|
"""
|
|
tests.test_filesystem - Testing functions in filesystem.py
|
|
"""
|
|
|
|
import datetime
|
|
import errno
|
|
import io
|
|
import pickle
|
|
import stat
|
|
import sys
|
|
import os
|
|
import shutil
|
|
import time
|
|
import unicodedata
|
|
from pathlib import Path
|
|
import tempfile
|
|
from random import choice, randint
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from pyfakefs.helpers import set_uid
|
|
|
|
from tests.testhelper import SAB_DATA_DIR
|
|
|
|
import sabnzbd
|
|
import sabnzbd.cfg
|
|
from sabnzbd import cfg
|
|
import sabnzbd.filesystem as filesystem
|
|
from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX, JOB_ADMIN
|
|
|
|
# Set the global uid for fake filesystems to a non-root user;
|
|
# by default this depends on the user running pytest.
|
|
global_uid = 1000
|
|
set_uid(global_uid)
|
|
|
|
|
|
def _symlinks_available() -> bool:
|
|
"""On Windows, creating a symlink needs an elevated shell or Developer Mode.
|
|
Probe it once, so tests that need real symlinks skip instead of erroring."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
try:
|
|
os.symlink(tmp, os.path.join(tmp, "link"), target_is_directory=True)
|
|
return True
|
|
except (OSError, NotImplementedError, AttributeError):
|
|
return False
|
|
|
|
|
|
needs_symlinks = pytest.mark.skipif(
|
|
not _symlinks_available(), reason="Creating symlinks requires elevation or Developer Mode"
|
|
)
|
|
|
|
|
|
class TestFileFolderNameSanitizer:
|
|
def test_empty(self):
|
|
assert filesystem.sanitize_filename(None) is None
|
|
assert filesystem.sanitize_foldername(None) is None
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_colon_handling_windows(self):
|
|
assert filesystem.sanitize_filename("test:aftertest") == "test_aftertest"
|
|
assert filesystem.sanitize_filename(":") == "_"
|
|
assert filesystem.sanitize_filename("test:") == "test_"
|
|
assert filesystem.sanitize_filename("test: ") == "test_"
|
|
# They should act the same
|
|
assert filesystem.sanitize_filename("test:aftertest") == filesystem.sanitize_foldername("test:aftertest")
|
|
|
|
@pytest.mark.platform("macos")
|
|
def test_colon_handling_macos(self):
|
|
assert filesystem.sanitize_filename("test:aftertest") == "test_aftertest"
|
|
assert filesystem.sanitize_filename(":aftertest") == "_aftertest"
|
|
assert filesystem.sanitize_filename("::aftertest") == "__aftertest"
|
|
assert filesystem.sanitize_filename(":after:test") == "_after_test"
|
|
# Empty after sanitising with macos colon handling
|
|
assert filesystem.sanitize_filename(":") == "_"
|
|
assert filesystem.sanitize_filename("test:") == "test_"
|
|
assert filesystem.sanitize_filename("test: ") == "test_"
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_colon_handling_other(self):
|
|
assert filesystem.sanitize_filename("test:aftertest") == "test:aftertest"
|
|
assert filesystem.sanitize_filename(":") == ":"
|
|
assert filesystem.sanitize_filename("test:") == "test:"
|
|
assert filesystem.sanitize_filename("test: ") == "test:"
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_win_devices_on_win(self):
|
|
assert filesystem.sanitize_filename(None) is None
|
|
assert filesystem.sanitize_filename("aux.txt") == "_aux.txt"
|
|
assert filesystem.sanitize_filename("txt.aux") == "txt.aux"
|
|
assert filesystem.sanitize_filename("$mft") == "Smft"
|
|
assert filesystem.sanitize_filename("a$mft") == "a$mft"
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_win_devices_not_win(self):
|
|
# Linux and macOS are the same for this
|
|
assert filesystem.sanitize_filename(None) is None
|
|
assert filesystem.sanitize_filename("aux.txt") == "aux.txt"
|
|
assert filesystem.sanitize_filename("txt.aux") == "txt.aux"
|
|
assert filesystem.sanitize_filename("$mft") == "$mft"
|
|
assert filesystem.sanitize_filename("a$mft") == "a$mft"
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_file_illegal_chars_win32(self):
|
|
assert filesystem.sanitize_filename("test" + filesystem.CH_ILLEGAL_WIN + "aftertest") == (
|
|
"test" + (len(filesystem.CH_ILLEGAL_WIN) * "_") + "aftertest"
|
|
)
|
|
assert (
|
|
filesystem.sanitize_filename("test" + chr(0) + chr(1) + chr(15) + chr(31) + "aftertest")
|
|
== "test____aftertest"
|
|
)
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_folder_illegal_chars_win32(self):
|
|
assert (
|
|
filesystem.sanitize_foldername("test" + chr(0) + chr(9) + chr(13) + chr(31) + "aftertest")
|
|
== "test____aftertest"
|
|
)
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_file_illegal_chars_linux(self):
|
|
assert filesystem.sanitize_filename("test/aftertest") == "test_aftertest"
|
|
assert filesystem.sanitize_filename("/test") == "_test"
|
|
assert filesystem.sanitize_filename("test/") == "test_"
|
|
assert filesystem.sanitize_filename(r"/test\/aftertest/") == r"_test\_aftertest_"
|
|
assert filesystem.sanitize_filename("/") == "_"
|
|
assert filesystem.sanitize_filename("///") == "___"
|
|
assert filesystem.sanitize_filename("../") == ".._"
|
|
assert filesystem.sanitize_filename("../test") == ".._test"
|
|
|
|
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
|
@pytest.mark.platform()
|
|
def test_file_allow_subdirs(self, platform):
|
|
"""Par2 uses "/" to separate sub-directories, no matter which platform created the set"""
|
|
assert filesystem.sanitize_filename("sub/test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
|
|
assert filesystem.sanitize_filename("sub/deeper/test.rar", allow_subdirs=True) == os.path.join(
|
|
"sub", "deeper", "test.rar"
|
|
)
|
|
# No sub-directory at all, or nothing but separators
|
|
assert filesystem.sanitize_filename("test.rar", allow_subdirs=True) == "test.rar"
|
|
assert filesystem.sanitize_filename("a//b.rar", allow_subdirs=True) == os.path.join("a", "b.rar")
|
|
assert filesystem.sanitize_filename("sub/./test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
|
|
# Every part is sanitized on its own, chr(0) is illegal on all platforms
|
|
assert filesystem.sanitize_filename("sub" + chr(0) + "1/test" + chr(0) + "2.rar", allow_subdirs=True) == (
|
|
os.path.join("sub_1", "test_2.rar")
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"hostile_name",
|
|
[
|
|
"/test.rar",
|
|
"//test.rar",
|
|
"../test.rar",
|
|
"../../../../../../etc/shadow",
|
|
"sub/../../test.rar",
|
|
"sub/../../../sub/test.rar",
|
|
"./../test.rar",
|
|
"../..",
|
|
"../",
|
|
"/",
|
|
"//",
|
|
"/../",
|
|
"...",
|
|
"....",
|
|
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
|
|
" .. /test.rar",
|
|
" .. / .. /etc/shadow",
|
|
"sub/ .. / .. /test.rar",
|
|
"\t..\t/test.rar",
|
|
"\xa0../test.rar",
|
|
" .. ",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
|
@pytest.mark.platform()
|
|
def test_file_allow_subdirs_cannot_escape(self, platform, hostile_name):
|
|
"""Whatever the par2 claims, the result has to stay inside the folder it is used in.
|
|
Joining it onto any base directory must never point above that base."""
|
|
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
|
|
|
|
assert result, "an empty result would resolve to the base directory itself"
|
|
assert not os.path.isabs(result)
|
|
assert os.pardir not in result.split(os.sep)
|
|
|
|
# The real test: it cannot climb out of whatever it gets joined to
|
|
base = os.path.join(os.sep + "downloads", "incomplete", "job")
|
|
resolved = os.path.normpath(os.path.join(base, result))
|
|
assert resolved.startswith(base + os.sep), "%s escaped to %s" % (hostile_name, resolved)
|
|
|
|
@pytest.mark.parametrize(
|
|
"hostile_name",
|
|
[
|
|
JOB_ADMIN,
|
|
JOB_ADMIN + "/__verified__",
|
|
JOB_ADMIN.lower() + "/__verified__",
|
|
"sub/" + JOB_ADMIN + "/__verified__",
|
|
JOB_ADMIN + "/deeper/__verified__",
|
|
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
|
|
" " + JOB_ADMIN + " /__verified__",
|
|
"\t" + JOB_ADMIN + "\t/__verified__",
|
|
"sub/ " + JOB_ADMIN.lower() + " /__verified__",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
|
@pytest.mark.platform()
|
|
def test_file_allow_subdirs_cannot_enter_admin(self, platform, hostile_name):
|
|
"""The admin folder is pickle-loaded, so a par2 name must never point into it"""
|
|
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
|
|
|
|
assert result, "an empty result would resolve to the base directory itself"
|
|
assert JOB_ADMIN.lower() not in result.lower().split(os.sep)
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_folder_illegal_chars_linux(self):
|
|
assert filesystem.sanitize_foldername('test"aftertest') == "test_aftertest"
|
|
assert filesystem.sanitize_foldername("test:") == "test_"
|
|
assert filesystem.sanitize_foldername("test<>?*|aftertest") == "test<>?*|aftertest"
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_legal_chars_linux(self):
|
|
# Illegal on Windows but not on Linux, unless sanitize_safe is active.
|
|
# Don't bother with '/' which is illegal in filenames on all platforms.
|
|
char_ill = filesystem.CH_ILLEGAL_WIN.replace("/", "")
|
|
assert filesystem.sanitize_filename("test" + char_ill + "aftertest") == ("test" + char_ill + "aftertest")
|
|
for char in char_ill:
|
|
# Try at start, middle, and end of a filename.
|
|
assert filesystem.sanitize_filename("test" + char * 2 + "aftertest") == ("test" + char * 2 + "aftertest")
|
|
assert filesystem.sanitize_filename("test" + char * 2) == ("test" + char * 2).strip()
|
|
assert filesystem.sanitize_filename(char * 2 + "test") == (char * 2 + "test").strip()
|
|
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.config({"sanitize_safe": True})
|
|
def test_sanitize_safe_linux(self):
|
|
# Set sanitize_safe to on, simulating Windows-style restrictions.
|
|
assert filesystem.sanitize_filename("test" + filesystem.CH_ILLEGAL_WIN + "aftertest") == (
|
|
"test" + (len(filesystem.CH_ILLEGAL_WIN) * "_") + "aftertest"
|
|
)
|
|
for index in range(0, len(filesystem.CH_ILLEGAL_WIN)):
|
|
char_ill = filesystem.CH_ILLEGAL_WIN[index]
|
|
assert filesystem.sanitize_filename("test" + char_ill * 2 + "aftertest") == ("test__aftertest")
|
|
# Illegal chars that also get caught by strip() never make it far
|
|
# enough to be replaced by their legal equivalents if they appear
|
|
# on either end of the filename.
|
|
if char_ill.strip():
|
|
assert filesystem.sanitize_filename("test" + char_ill * 2) == "test__"
|
|
assert filesystem.sanitize_filename(char_ill * 2 + "test") == "__test"
|
|
|
|
def test_nfc_normalization_filename(self):
|
|
"""sanitize_filename must normalize Unicode to NFC (fixes issues #1633 and #2858).
|
|
|
|
macOS decomposes Unicode to NFD when returning filenames from the filesystem.
|
|
par2 files, yEnc headers, and NZBs typically carry NFC. Without normalization,
|
|
visually identical filenames compare unequal, causing double-unpacking and
|
|
inconsistent sort paths when %fn (disk) and %title (parsed) are combined.
|
|
"""
|
|
# NFD: 'e' + U+0300 (combining grave), 'o' + U+0308 (combining diaeresis)
|
|
nfd_name = "fre\u0300nch_german_demo\u0308.mkv"
|
|
# NFC: precomposed è (U+00E8) and ö (U+00F6)
|
|
nfc_name = "frènch_german_demö.mkv"
|
|
|
|
assert nfd_name != nfc_name, "pre-condition: NFD and NFC byte representations differ"
|
|
result = filesystem.sanitize_filename(nfd_name)
|
|
assert result == nfc_name
|
|
assert unicodedata.is_normalized("NFC", result)
|
|
|
|
# NFC input must pass through unchanged (idempotent)
|
|
assert filesystem.sanitize_filename(nfc_name) == nfc_name
|
|
|
|
def test_nfc_normalization_foldername(self):
|
|
"""sanitize_foldername must normalize Unicode to NFC (fixes issues #1633 and #2858)."""
|
|
nfd_folder = "Mo\u0308vie" # NFD: 'o' + U+0308 (combining diaeresis)
|
|
nfc_folder = "Möwie" # NFC: precomposed ö (U+00F6)
|
|
# Correct expected NFC for "Mo" + combining-diaeresis + "vie"
|
|
nfc_folder = "M\u00f6vie"
|
|
|
|
assert nfd_folder != nfc_folder, "pre-condition: NFD and NFC differ"
|
|
result = filesystem.sanitize_foldername(nfd_folder)
|
|
assert result == nfc_folder
|
|
assert unicodedata.is_normalized("NFC", result)
|
|
|
|
# NFC input must pass through unchanged (idempotent)
|
|
assert filesystem.sanitize_foldername(nfc_folder) == nfc_folder
|
|
|
|
def test_filename_dot(self):
|
|
# All dots should survive in filenames
|
|
assert filesystem.sanitize_filename(".test") == ".test"
|
|
assert filesystem.sanitize_filename("..test") == "..test"
|
|
assert filesystem.sanitize_filename("test.") == "test."
|
|
assert filesystem.sanitize_filename("test..") == "test.."
|
|
assert filesystem.sanitize_filename("test.aftertest") == "test.aftertest"
|
|
assert filesystem.sanitize_filename("test..aftertest") == "test..aftertest"
|
|
assert filesystem.sanitize_filename("test.aftertest.") == "test.aftertest."
|
|
assert filesystem.sanitize_filename("test.aftertest..") == "test.aftertest.."
|
|
|
|
def test_foldername_dot(self):
|
|
# Dot should be removed from the end of directory names only
|
|
assert filesystem.sanitize_foldername(".test") == ".test"
|
|
assert filesystem.sanitize_foldername("..test") == "..test"
|
|
assert filesystem.sanitize_foldername("test.") == "test"
|
|
assert filesystem.sanitize_foldername("test..") == "test"
|
|
assert filesystem.sanitize_foldername("test.aftertest") == "test.aftertest"
|
|
assert filesystem.sanitize_foldername("test..aftertest") == "test..aftertest"
|
|
assert filesystem.sanitize_foldername("test.aftertest.") == "test.aftertest"
|
|
assert filesystem.sanitize_foldername("test.aftertest..") == "test.aftertest"
|
|
assert filesystem.sanitize_foldername("test. aftertest. . . .") == "test. aftertest"
|
|
assert filesystem.sanitize_foldername("/test/this.") == "_test_this"
|
|
assert filesystem.sanitize_foldername("/test./this.") == "_test._this"
|
|
assert filesystem.sanitize_foldername("/test. /this . ") == "_test. _this"
|
|
|
|
def test_long_foldername(self):
|
|
# Note: some filesystem can handle up to 255 UTF chars (which is more than 255 bytes) in the foldername,
|
|
# but we stay on the safe side: max DEF_FILE_MAX bytes
|
|
assert len(filesystem.sanitize_foldername("test" * 100)) == DEF_FOLDER_MAX
|
|
assert len(filesystem.sanitize_foldername("a" * DEF_FOLDER_MAX)) == DEF_FOLDER_MAX
|
|
assert len(filesystem.sanitize_foldername("a" * (DEF_FOLDER_MAX + 1))) == DEF_FOLDER_MAX
|
|
|
|
# Adapted from filename tests
|
|
|
|
# PART 1: Base cases: Nothing should happen:
|
|
# normal filename
|
|
name = "a" * 200
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert sanitizedname == name
|
|
|
|
# Unicode / UTF8 is OK ... as total filename length is not too long
|
|
name = "BASE" + "你" * 50 + "blabla"
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert sanitizedname == name
|
|
|
|
# PART 2: base truncating
|
|
name = "BASE" + "a" * 300
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert len(sanitizedname) <= DEF_FOLDER_MAX
|
|
assert sanitizedname.startswith("BASEaaaaaaaaaaaaaaa")
|
|
|
|
# PART 3: more exotic cases
|
|
|
|
# insert NON-ASCII chars, which should stay in place because overall length is no problem
|
|
name = "aaaa" + 10 * chr(188) + 10 * chr(222) + "bbbb"
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert sanitizedname == name
|
|
|
|
# insert NON-ASCII chars
|
|
name = "aaaa" + 200 * chr(188) + 200 * chr(222)
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert (
|
|
sanitizedname
|
|
== "aaaa¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼"
|
|
)
|
|
|
|
# Unicode / UTF8 ... total filename length might be too long for certain filesystems
|
|
name = "BASE" + "你" * 200
|
|
sanitizedname = filesystem.sanitize_foldername(name)
|
|
assert sanitizedname.startswith("BASE")
|
|
assert sanitizedname.endswith("你")
|
|
|
|
def test_filename_empty_result(self):
|
|
# Nothing remains after sanitizing the filename
|
|
assert filesystem.sanitize_filename("\n") == "unknown"
|
|
assert filesystem.sanitize_filename("\r\n") == "unknown"
|
|
assert filesystem.sanitize_filename("\n\r") == "unknown"
|
|
assert filesystem.sanitize_filename("\t\t\t") == "unknown"
|
|
assert filesystem.sanitize_filename(" ") == "unknown"
|
|
assert filesystem.sanitize_filename(" ") == "unknown"
|
|
|
|
def test_foldername_empty_result(self):
|
|
# Nothing remains after sanitizing the foldername
|
|
assert filesystem.sanitize_foldername("\n") == "unknown"
|
|
assert filesystem.sanitize_foldername("\r\n") == "unknown"
|
|
assert filesystem.sanitize_foldername("\n\r") == "unknown"
|
|
assert filesystem.sanitize_foldername("\t\t\t") == "unknown"
|
|
assert filesystem.sanitize_foldername(" ") == "unknown"
|
|
assert filesystem.sanitize_foldername(" ") == "unknown"
|
|
assert filesystem.sanitize_foldername(" . .") == "unknown"
|
|
|
|
def test_filename_too_long(self):
|
|
# Note: some filesystem can handle up to 255 UTF chars (which is more than 255 bytes) in the filename,
|
|
# but we stay on the safe side: max DEF_FILE_MAX bytes
|
|
|
|
# PART 1: Base cases: Nothing should happen:
|
|
|
|
# normal filename
|
|
name = "a" * 200 + ".ext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname == name
|
|
|
|
# Unicode / UTF8 is OK ... as total filename length is not too long
|
|
name = "BASE" + "你" * 50 + "blabla.ext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname == name
|
|
|
|
# filename with very long extension, but total filename is no problem, so no change
|
|
name = "hello.ext" + "e" * 200
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname == name # no change
|
|
|
|
# PART 2: base truncating
|
|
|
|
name = "BASE" + "a" * 300 + ".mylongext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert len(sanitizedname) <= DEF_FILE_MAX
|
|
assert sanitizedname.startswith("BASEaaaaaaaaaaaaaaa")
|
|
assert sanitizedname.endswith(".mylongext")
|
|
|
|
# too long filename, so truncate keeping the start of name and ext should stay the same
|
|
name = "BASE" + "a" * 200 + ".EXT" + "e" * 200
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert len(sanitizedname) <= DEF_FILE_MAX
|
|
newname, newext = os.path.splitext(sanitizedname)
|
|
assert newname.startswith("BASEaaaaa")
|
|
assert newext.startswith(".EXTeeeee")
|
|
|
|
# PART 3: more exotic cases
|
|
|
|
# insert NON-ASCII chars, which should stay in place because overall length is no problem
|
|
name = "aaaa" + 10 * chr(188) + 10 * chr(222) + "bbbb.ext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname == name
|
|
|
|
# insert NON-ASCII chars
|
|
name = "aaaa" + 200 * chr(188) + 200 * chr(222) + "bbbb.ext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert (
|
|
sanitizedname
|
|
== "aaaa¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼¼.ext"
|
|
)
|
|
|
|
# Unicode / UTF8 ... total filename length might be too long for certain filesystems
|
|
name = "BASE" + "你" * 200 + ".ext"
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname.startswith("BASE")
|
|
assert sanitizedname.endswith(".ext")
|
|
|
|
# Linux / POSIX: a hidden file (no extension), with size 200, so do not truncate at all
|
|
name = "." + "a" * 200
|
|
sanitizedname = filesystem.sanitize_filename(name)
|
|
assert sanitizedname == name # no change
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
# Disable randomisation of directory listings
|
|
"shuffle_listdir_results": False,
|
|
}
|
|
)
|
|
class TestSanitizeFiles:
|
|
def test_sanitize_files_input(self):
|
|
assert [] == filesystem.sanitize_files(folder=None)
|
|
assert [] == filesystem.sanitize_files(filelist=None)
|
|
assert [] == filesystem.sanitize_files(folder=None, filelist=None)
|
|
|
|
@pytest.mark.config({"sanitize_safe": True})
|
|
def test_sanitize_files(self, fake_fs):
|
|
# The very specific tests of sanitize_filename() are above
|
|
# Here we just want to see that sanitize_files() works as expected
|
|
input_list = [r"c:\test\con.man", r"c:\test\foo:bar"]
|
|
output_list = [r"c:\test\_con.man", r"c:\test\foo_bar"]
|
|
|
|
# Test both the "folder" and "filelist" based calls
|
|
for kwargs in ({"folder": r"c:\test"}, {"filelist": input_list}):
|
|
# Create source files
|
|
for file in input_list:
|
|
fake_fs.create_file(file)
|
|
|
|
assert output_list == filesystem.sanitize_files(**kwargs)
|
|
|
|
# Make sure the old ones are gone
|
|
for file in input_list:
|
|
assert not os.path.exists(file)
|
|
|
|
# Make sure the new ones are there
|
|
for file in output_list:
|
|
assert os.path.exists(file)
|
|
os.remove(file)
|
|
assert not os.path.exists(file)
|
|
|
|
|
|
class TestSameDirectory:
|
|
def test_nothing_in_common_win_paths(self):
|
|
assert 0 == filesystem.same_directory("C:\\", "D:\\")
|
|
assert 0 == filesystem.same_directory("C:\\", "/home/test")
|
|
|
|
def test_nothing_in_common_unix_paths(self):
|
|
assert 0 == filesystem.same_directory("/home/", "/data/test")
|
|
assert 0 == filesystem.same_directory("/test/home/test", "/home/")
|
|
assert 0 == filesystem.same_directory("/test/../home", "/test")
|
|
assert 0 == filesystem.same_directory("/test/./test", "/test")
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.platform("linux")
|
|
def test_posix_fun(self):
|
|
assert 1 == filesystem.same_directory("/test", "/test")
|
|
# IEEE 1003.1-2017 par. 4.13 for details
|
|
assert 0 == filesystem.same_directory("/test", "//test")
|
|
assert 1 == filesystem.same_directory("/test", "///test")
|
|
assert 1 == filesystem.same_directory("/test", "/test/")
|
|
assert 1 == filesystem.same_directory("/test", "/test//")
|
|
assert 1 == filesystem.same_directory("/test", "/test///")
|
|
|
|
def test_same(self):
|
|
assert 1 == filesystem.same_directory("/home/123", "/home/123")
|
|
assert 1 == filesystem.same_directory("/test/../test", "/test")
|
|
assert 1 == filesystem.same_directory("test/../test", "test")
|
|
assert 1 == filesystem.same_directory("/test/./test", "/test/test")
|
|
assert 1 == filesystem.same_directory("./test", "test")
|
|
|
|
def test_subfolder(self):
|
|
assert 2 == filesystem.same_directory("/home/test123", "/home/test123/sub")
|
|
assert 2 == filesystem.same_directory("/test", "/test/./test")
|
|
assert 2 == filesystem.same_directory("/home/../test", "/test/./test")
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Relies on os.sep so should only run on Windows")
|
|
def test_windows(self):
|
|
assert 1 == filesystem.same_directory("D:\\", "D:\\")
|
|
assert 2 == filesystem.same_directory("\\\\?\\C:\\", "\\\\?\\C:\\Users\\")
|
|
assert 1 == filesystem.same_directory("/HOME/123", "/home/123")
|
|
assert 1 == filesystem.same_directory("D:\\", "d:\\")
|
|
assert 2 == filesystem.same_directory("\\\\?\\c:\\", "\\\\?\\C:\\Users\\")
|
|
|
|
def test_looks_likesubfolder_but_isnt(self):
|
|
assert 0 == filesystem.same_directory("/mnt/sabnzbd", "/mnt/sabnzbd-data")
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith(("win", "darwin")), reason="Requires a case-sensitive filesystem")
|
|
@pytest.mark.platform("linux")
|
|
def test_capitalization_linux(self):
|
|
assert 2 == filesystem.same_directory("/home/test123", "/home/test123/sub")
|
|
assert 0 == filesystem.same_directory("/test", "/Test")
|
|
assert 0 == filesystem.same_directory("tesT", "Test")
|
|
assert 0 == filesystem.same_directory("/test/../Home", "/home")
|
|
|
|
|
|
class TestPointsIntoAdminDir:
|
|
def test_by_name(self, tmp_path):
|
|
base = str(tmp_path)
|
|
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN), base)
|
|
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN, "__verified__"), base)
|
|
assert filesystem.points_into_admin_dir(os.path.join(base, "sub", JOB_ADMIN, "__verified__"), base)
|
|
|
|
def test_regular_names_are_left_alone(self, tmp_path):
|
|
base = str(tmp_path)
|
|
assert not filesystem.points_into_admin_dir(os.path.join(base, "testfile.rar"), base)
|
|
assert not filesystem.points_into_admin_dir(os.path.join(base, "sub", "testfile.rar"), base)
|
|
# Only a full part counts, not a name that merely starts with it
|
|
assert not filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN + "-data", "testfile.rar"), base)
|
|
|
|
@needs_symlinks
|
|
def test_link_cannot_hide_it(self, tmp_path):
|
|
"""On Windows an NTFS 8.3 alias ("__ADMI~1") points at the admin folder under a
|
|
different name, exactly like a link does here, so the name cannot be trusted"""
|
|
base = str(tmp_path)
|
|
admin_dir = os.path.join(base, JOB_ADMIN)
|
|
os.mkdir(admin_dir)
|
|
linkname = os.path.join(base, "notadmin")
|
|
os.symlink(admin_dir, linkname)
|
|
|
|
assert filesystem.points_into_admin_dir(linkname, base)
|
|
assert filesystem.points_into_admin_dir(os.path.join(linkname, "__verified__"), base)
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="NTFS 8.3 aliases only exist on Windows")
|
|
def test_ntfs_8dot3_alias_cannot_hide_it(self, tmp_path):
|
|
"""The real thing the link above stands in for: NTFS keeps an 8.3 alias for every
|
|
long name, so "__ADMI~1" reaches the admin folder without ever spelling it out"""
|
|
import win32api
|
|
|
|
base = str(tmp_path)
|
|
admin_dir = os.path.join(base, JOB_ADMIN)
|
|
os.mkdir(admin_dir)
|
|
|
|
# Ask the filesystem for the alias instead of assuming what it generated
|
|
alias = os.path.basename(win32api.GetShortPathName(admin_dir))
|
|
if alias.lower() == JOB_ADMIN.lower():
|
|
pytest.skip("8.3 name creation is disabled on this volume")
|
|
|
|
assert filesystem.points_into_admin_dir(os.path.join(base, alias), base)
|
|
assert filesystem.points_into_admin_dir(os.path.join(base, alias, "__verified__"), base)
|
|
|
|
# And the rename that the alias was meant to sneak through has to fail
|
|
filename = os.path.join(base, "myfile.txt")
|
|
Path(filename).touch()
|
|
with pytest.raises(OSError):
|
|
filesystem.renamer(filename, os.path.join(base, alias, "__verified__"), create_local_directories=True)
|
|
assert os.path.isfile(filename)
|
|
assert not os.listdir(admin_dir)
|
|
|
|
|
|
class TestPointsOutside:
|
|
def test_inside(self, tmp_path):
|
|
base = str(tmp_path)
|
|
assert not filesystem.points_outside(base, os.path.join(base, "file.bin"))
|
|
assert not filesystem.points_outside(base, os.path.join(base, "sub", "file.bin"))
|
|
|
|
def test_outside(self, tmp_path):
|
|
base = str(tmp_path)
|
|
assert filesystem.points_outside(base, os.path.join(base, os.pardir, "file.bin"))
|
|
assert filesystem.points_outside(base, os.path.join(base, "sub", os.pardir, os.pardir, "file.bin"))
|
|
|
|
@needs_symlinks
|
|
def test_root_reached_through_a_link_is_fine(self, tmp_path):
|
|
"""The download and complete folder are allowed to be a link"""
|
|
base = str(tmp_path)
|
|
real = os.path.join(base, "real")
|
|
os.mkdir(real)
|
|
link = os.path.join(base, "link")
|
|
os.symlink(real, link)
|
|
|
|
assert not filesystem.points_outside(link, os.path.join(link, "file.bin"))
|
|
assert not filesystem.points_outside(link, os.path.join(real, "file.bin"))
|
|
assert not filesystem.points_outside(real, os.path.join(link, "file.bin"))
|
|
assert filesystem.points_outside(link, os.path.join(link, os.pardir, "file.bin"))
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
|
|
)
|
|
def test_link_inside_the_root_cannot_redirect(self, tmp_path):
|
|
base = str(tmp_path)
|
|
root = os.path.join(base, "root")
|
|
os.mkdir(root)
|
|
os.symlink(base, os.path.join(root, "up"))
|
|
os.symlink(".", os.path.join(root, "pivot"))
|
|
|
|
assert filesystem.points_outside(root, os.path.join(root, "up", "file.bin"))
|
|
assert filesystem.points_outside(root, os.path.join(root, "pivot", os.pardir, "file.bin"))
|
|
assert not filesystem.points_outside(root, os.path.join(root, "pivot", "file.bin"))
|
|
|
|
|
|
class TestMoveToPath:
|
|
@needs_symlinks
|
|
def test_link_in_a_parent_cannot_redirect(self, tmp_path):
|
|
"""A linked directory in the path redirects the move just like a linked leaf"""
|
|
base = str(tmp_path)
|
|
root = os.path.join(base, "complete")
|
|
outside = os.path.join(base, "outside")
|
|
os.makedirs(root)
|
|
os.makedirs(outside)
|
|
os.symlink(outside, os.path.join(root, "sub"))
|
|
|
|
source = os.path.join(base, "source.bin")
|
|
Path(source).touch()
|
|
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
|
|
|
|
assert not ok
|
|
assert not new_path
|
|
assert os.path.isfile(source)
|
|
assert not os.listdir(outside)
|
|
|
|
def test_move_inside_the_root_still_works(self, tmp_path):
|
|
base = str(tmp_path)
|
|
root = os.path.join(base, "complete")
|
|
os.makedirs(root)
|
|
source = os.path.join(base, "source.bin")
|
|
Path(source).touch()
|
|
|
|
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
|
|
|
|
assert ok
|
|
assert os.path.isfile(new_path)
|
|
assert not os.path.isfile(source)
|
|
|
|
|
|
class TestFirstExistingPath:
|
|
def test_existing_path(self, tmp_path):
|
|
assert filesystem.first_existing_path(str(tmp_path)) == str(tmp_path)
|
|
|
|
def test_walks_up_to_existing_parent(self, tmp_path):
|
|
assert filesystem.first_existing_path(str(tmp_path / "not" / "created" / "yet")) == str(tmp_path)
|
|
|
|
|
|
class TestSameDevice:
|
|
def test_same_path(self, tmp_path):
|
|
assert filesystem.same_device(str(tmp_path), str(tmp_path)) is True
|
|
|
|
def test_sibling_folders(self, tmp_path):
|
|
download_dir = tmp_path / "download"
|
|
complete_dir = tmp_path / "complete"
|
|
download_dir.mkdir()
|
|
complete_dir.mkdir()
|
|
assert filesystem.same_device(str(download_dir), str(complete_dir)) is True
|
|
|
|
def test_folders_not_created_yet(self, tmp_path):
|
|
"""Falls back on the first existing parent, so both resolve to tmp_path"""
|
|
assert filesystem.same_device(str(tmp_path / "download"), str(tmp_path / "complete" / "sub")) is True
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Relies on /proc being its own filesystem")
|
|
def test_separate_devices(self, tmp_path):
|
|
assert filesystem.same_device(str(tmp_path), "/proc") is False
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows only has drive letters")
|
|
def test_different_drives_win(self):
|
|
assert filesystem.same_device("C:\\downloads", "D:\\complete") is False
|
|
|
|
def test_undeterminable_path(self, tmp_path):
|
|
"""Fall back on separate devices, so the caller keeps reserving space for a copy"""
|
|
with mock.patch("os.stat", side_effect=OSError):
|
|
assert filesystem.same_device(str(tmp_path / "a"), str(tmp_path / "b")) is False
|
|
|
|
|
|
class TestClipLongPath:
|
|
def test_empty(self):
|
|
assert filesystem.clip_path(None) is None
|
|
assert filesystem.long_path(None) is None
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_clip_path_win(self):
|
|
assert filesystem.clip_path(r"\\?\UNC\test") == r"\\test"
|
|
assert filesystem.clip_path(r"\\?\F:\test") == r"F:\test"
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_nothing_to_clip_win(self):
|
|
assert filesystem.clip_path(r"\\test") == r"\\test"
|
|
assert filesystem.clip_path(r"F:\test") == r"F:\test"
|
|
assert filesystem.clip_path("/test/dir") == "/test/dir"
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_clip_path_non_win(self):
|
|
# Shouldn't have any effect on platforms other than Windows
|
|
assert filesystem.clip_path(r"\\?\UNC\test") == r"\\?\UNC\test"
|
|
assert filesystem.clip_path(r"\\?\F:\test") == r"\\?\F:\test"
|
|
assert filesystem.clip_path(r"\\test") == r"\\test"
|
|
assert filesystem.clip_path(r"F:\test") == r"F:\test"
|
|
assert filesystem.clip_path("/test/dir") == "/test/dir"
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_long_path_win(self):
|
|
assert filesystem.long_path(r"\\test") == r"\\?\UNC\test"
|
|
assert filesystem.long_path(r"F:\test") == r"\\?\F:\test"
|
|
|
|
@pytest.mark.platform("win32")
|
|
def test_nothing_to_lenghten_win(self):
|
|
assert filesystem.long_path(r"\\?\UNC\test") == r"\\?\UNC\test"
|
|
assert filesystem.long_path(r"\\?\F:\test") == r"\\?\F:\test"
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_long_path_non_win(self):
|
|
# Shouldn't have any effect on platforms other than Windows
|
|
assert filesystem.long_path(r"\\?\UNC\test") == r"\\?\UNC\test"
|
|
assert filesystem.long_path(r"\\?\F:\test") == r"\\?\F:\test"
|
|
assert filesystem.long_path(r"\\test") == r"\\test"
|
|
assert filesystem.long_path(r"F:\test") == r"F:\test"
|
|
assert filesystem.long_path("/test/dir") == "/test/dir"
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"path_separator": "/",
|
|
"is_case_sensitive": True,
|
|
"create_dirs": ["/media/test/dir", "/mnt/TEST/DIR"],
|
|
}
|
|
)
|
|
class TestCheckMountLinux:
|
|
def test_bare_mountpoint_linux(self, fake_fs):
|
|
assert filesystem.mount_is_available("/media") is True
|
|
assert filesystem.mount_is_available("/media/") is True
|
|
assert filesystem.mount_is_available("/mnt") is True
|
|
assert filesystem.mount_is_available("/mnt/") is True
|
|
|
|
def test_existing_dir_linux(self, fake_fs):
|
|
assert filesystem.mount_is_available("/media/test") is True
|
|
assert filesystem.mount_is_available("/media/test/dir/") is True
|
|
assert filesystem.mount_is_available("/media/test/DIR/") is True
|
|
assert filesystem.mount_is_available("/mnt/TEST") is True
|
|
assert filesystem.mount_is_available("/mnt/TEST/dir/") is True
|
|
assert filesystem.mount_is_available("/mnt/TEST/DIR/") is True
|
|
|
|
def test_dir_nonexistent_linux(self, fake_fs, sleepless):
|
|
# Filesystem is case-sensitive on this platform
|
|
assert filesystem.mount_is_available("/media/TEST") is False # Issue #1457
|
|
assert filesystem.mount_is_available("/media/TesT/") is False
|
|
assert filesystem.mount_is_available("/mnt/TeSt/DIR") is False
|
|
assert filesystem.mount_is_available("/mnt/test/DiR/") is False
|
|
|
|
def test_dir_outsider_linux(self, fake_fs):
|
|
# Outside of /media and /mnt
|
|
assert filesystem.mount_is_available("/test/that/") is True
|
|
# Root directory
|
|
assert filesystem.mount_is_available("/") is True
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.platform("macos")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"create_dirs": ["/Volumes/test/dir"],
|
|
}
|
|
)
|
|
class TestCheckMountMacOS:
|
|
def test_bare_mountpoint_macos(self, fake_fs):
|
|
assert filesystem.mount_is_available("/Volumes") is True
|
|
assert filesystem.mount_is_available("/Volumes/") is True
|
|
|
|
def test_existing_dir_macos(self, fake_fs):
|
|
assert filesystem.mount_is_available("/Volumes/test") is True
|
|
assert filesystem.mount_is_available("/Volumes/test/dir/") is True
|
|
# Filesystem is set case-insensitive for this platform
|
|
assert filesystem.mount_is_available("/VOLUMES/test") is True
|
|
assert filesystem.mount_is_available("/volumes/Test/dir/") is True
|
|
|
|
def test_dir_nonexistent_macos(self, fake_fs, sleepless):
|
|
# Within /Volumes
|
|
assert filesystem.mount_is_available("/Volumes/nosuchdir") is False # Issue #1457
|
|
assert filesystem.mount_is_available("/Volumes/noSuchDir/") is False
|
|
assert filesystem.mount_is_available("/Volumes/nosuchDIR/subdir") is False
|
|
assert filesystem.mount_is_available("/Volumes/NOsuchdir/subdir/") is False
|
|
|
|
def test_dir_outsider_macos(self, fake_fs):
|
|
# Outside of /Volumes
|
|
assert filesystem.mount_is_available("/test/that/") is True
|
|
# Root directory
|
|
assert filesystem.mount_is_available("/") is True
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"create_dirs": [r"F:\test\dir"],
|
|
}
|
|
)
|
|
class TestCheckMountWin:
|
|
def test_existing_dir_win(self, fake_fs):
|
|
assert filesystem.mount_is_available("F:\\test") is True
|
|
assert filesystem.mount_is_available("F:\\test\\dir\\") is True
|
|
# Filesystem and drive letters are case-insensitive on this platform
|
|
assert filesystem.mount_is_available("f:\\Test") is True
|
|
assert filesystem.mount_is_available("f:\\test\\DIR\\") is True
|
|
|
|
def test_bare_mountpoint_win(self, fake_fs, sleepless):
|
|
assert filesystem.mount_is_available("F:\\") is True
|
|
assert filesystem.mount_is_available("Z:\\") is False
|
|
|
|
def test_dir_nonexistent_win(self, fake_fs):
|
|
# The existence of the drive letter is what really matters
|
|
assert filesystem.mount_is_available("F:\\NoSuchDir") is True
|
|
assert filesystem.mount_is_available("F:\\NoSuchDir\\") is True
|
|
assert filesystem.mount_is_available("F:\\NOsuchdir\\subdir") is True
|
|
assert filesystem.mount_is_available("F:\\nosuchDIR\\subdir\\") is True
|
|
|
|
def test_dir_on_nonexistent_drive_win(self, fake_fs, sleepless):
|
|
# Non-existent drive-letter
|
|
assert filesystem.mount_is_available("H:\\NoSuchDir") is False
|
|
assert filesystem.mount_is_available("E:\\NoSuchDir\\") is False
|
|
assert filesystem.mount_is_available("L:\\NOsuchdir\\subdir") is False
|
|
assert filesystem.mount_is_available("L:\\nosuchDIR\\subdir\\") is False
|
|
|
|
def test_dir_outsider_win(self, fake_fs):
|
|
# Outside the local filesystem
|
|
assert filesystem.mount_is_available("//test/that/") is True
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"path_separator": "/",
|
|
"is_case_sensitive": True,
|
|
}
|
|
)
|
|
class TestListdirFull:
|
|
def test_nonexistent_dir(self):
|
|
assert filesystem.listdir_full("/foo/bar") == []
|
|
|
|
def test_no_exceptions(self, fake_fs):
|
|
test_files = (
|
|
"/test/dir/file1.ext",
|
|
"/test/dir/file2",
|
|
"/test/dir/sub/sub/sub/dir/file3.ext",
|
|
)
|
|
for file in test_files:
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
# List our fake directory structure
|
|
results_subdir = filesystem.listdir_full("/test/dir")
|
|
assert len(results_subdir) == 3
|
|
for entry in test_files:
|
|
assert (entry in results_subdir) is True
|
|
|
|
# List the same directory again, this time using its parent as the function argument.
|
|
# Results should be identical, since there's nothing in /test but that one subdirectory
|
|
results_parent = filesystem.listdir_full("/test")
|
|
# Don't make assumptions about the sorting of the lists of results
|
|
results_parent.sort()
|
|
results_subdir.sort()
|
|
assert results_parent == results_subdir
|
|
|
|
# List that subsubsub-directory; no sorting required for a single result
|
|
assert filesystem.listdir_full("/test/dir/sub/sub") == ["/test/dir/sub/sub/sub/dir/file3.ext"]
|
|
|
|
# Test non-recursive version
|
|
assert filesystem.listdir_full(r"/test", recursive=False) == []
|
|
assert filesystem.listdir_full(r"/test/dir/sub", recursive=False) == []
|
|
assert len(filesystem.listdir_full(r"/test/dir", recursive=False)) == 2
|
|
|
|
def test_exception_appledouble(self, fake_fs):
|
|
# Anything below a .AppleDouble directory should be omitted
|
|
test_file = "/foo/bar/.AppleDouble/Oooooo.ps"
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file) is True
|
|
assert filesystem.listdir_full("/foo") == []
|
|
assert filesystem.listdir_full("/foo/bar") == []
|
|
assert filesystem.listdir_full("/foo/bar/.AppleDouble") == []
|
|
assert filesystem.listdir_full("/foo", recursive=False) == []
|
|
assert filesystem.listdir_full("/foo/bar", recursive=False) == []
|
|
assert filesystem.listdir_full("/foo/bar/.AppleDouble", recursive=False) == []
|
|
|
|
def test_exception_dsstore(self, fake_fs):
|
|
# Anything below a .DS_Store directory should be omitted
|
|
for file in (
|
|
"/some/FILE",
|
|
"/some/.DS_Store/oh.NO",
|
|
"/some/.DS_Store/subdir/The.End",
|
|
):
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
assert filesystem.listdir_full("/some") == ["/some/FILE"]
|
|
assert filesystem.listdir_full("/some/.DS_Store/") == []
|
|
assert filesystem.listdir_full("/some/.DS_Store/subdir") == []
|
|
assert filesystem.listdir_full("/some", recursive=False) == ["/some/FILE"]
|
|
assert filesystem.listdir_full("/some/.DS_Store/", recursive=False) == []
|
|
assert filesystem.listdir_full("/some/.DS_Store/subdir", recursive=False) == []
|
|
|
|
def test_exception_resource_files(self, fake_fs):
|
|
for file in (
|
|
"/rsc/base_file",
|
|
"/rsc/._base_file",
|
|
"/rsc/not._base_file",
|
|
):
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
assert sorted(filesystem.listdir_full("/rsc")) == ["/rsc/base_file", "/rsc/not._base_file"]
|
|
|
|
def test_invalid_file_argument(self, fake_fs):
|
|
# This is obviously not intended use; the function expects a directory
|
|
# as its argument, not a file. Test anyway.
|
|
test_file = "/dev/sleepy"
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file) is True
|
|
assert filesystem.listdir_full(test_file) == []
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
class TestListdirFullWin:
|
|
def test_nonexistent_dir(self):
|
|
assert filesystem.listdir_full(r"F:\foo\bar") == []
|
|
|
|
def test_no_exceptions(self, fake_fs):
|
|
test_files = (
|
|
r"f:\test\dir\file1.ext",
|
|
r"f:\test\dir\file2",
|
|
r"f:\test\dir\sub\sub\sub\dir\file3.ext",
|
|
)
|
|
for file in test_files:
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
# List our fake directory structure
|
|
results_subdir = filesystem.listdir_full(r"f:\test\dir")
|
|
assert len(results_subdir) == 3
|
|
for entry in test_files:
|
|
assert (entry in results_subdir) is True
|
|
|
|
# List the same directory again, this time using its parent as the function argument.
|
|
# Results should be identical, since there's nothing in /test but that one subdirectory
|
|
results_parent = filesystem.listdir_full(r"f:\test")
|
|
# Don't make assumptions about the sorting of the lists of results
|
|
results_parent.sort()
|
|
results_subdir.sort()
|
|
assert results_parent == results_subdir
|
|
|
|
# List that subsubsub-directory; no sorting required for a single result
|
|
assert filesystem.listdir_full(r"F:\test\dir\SUB\sub")[0].lower() == r"f:\test\dir\sub\sub\sub\dir\file3.ext"
|
|
|
|
# Test non-recursive version
|
|
assert filesystem.listdir_full(r"f:\test", recursive=False) == []
|
|
assert filesystem.listdir_full(r"F:\test\dir\SUB", recursive=False) == []
|
|
assert len(filesystem.listdir_full(r"f:\test\dir", recursive=False)) == 2
|
|
|
|
def test_exception_appledouble(self, fake_fs):
|
|
# Anything below a .AppleDouble directory should be omitted
|
|
test_file = r"f:\foo\bar\.AppleDouble\Oooooo.ps"
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file) is True
|
|
assert filesystem.listdir_full(r"f:\foo") == []
|
|
assert filesystem.listdir_full(r"f:\foo\bar") == []
|
|
assert filesystem.listdir_full(r"F:\foo\bar\.AppleDouble") == []
|
|
assert filesystem.listdir_full(r"f:\foo", recursive=False) == []
|
|
assert filesystem.listdir_full(r"f:\foo\bar", recursive=False) == []
|
|
assert filesystem.listdir_full(r"F:\foo\bar\.AppleDouble", recursive=False) == []
|
|
|
|
def test_exception_dsstore(self, fake_fs):
|
|
# Anything below a .DS_Store directory should be omitted
|
|
for file in (
|
|
r"f:\some\FILE",
|
|
r"f:\some\.DS_Store\oh.NO",
|
|
r"f:\some\.DS_Store\subdir\The.End",
|
|
):
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
assert filesystem.listdir_full(r"f:\some") == [r"f:\some\FILE"]
|
|
assert filesystem.listdir_full(r"f:\some\.DS_Store") == []
|
|
assert filesystem.listdir_full(r"f:\some\.DS_Store\subdir") == []
|
|
assert filesystem.listdir_full(r"f:\some", recursive=True) == [r"f:\some\FILE"]
|
|
assert filesystem.listdir_full(r"f:\some\.DS_Store", recursive=True) == []
|
|
assert filesystem.listdir_full(r"f:\some\.DS_Store\subdir", recursive=True) == []
|
|
|
|
def test_exception_resource_files(self, fake_fs):
|
|
for file in (
|
|
r"f:\rsc\base_file",
|
|
r"f:\rsc\._base_file",
|
|
r"f:\rsc\not._base_file",
|
|
):
|
|
fake_fs.create_file(file)
|
|
assert os.path.exists(file) is True
|
|
assert sorted(filesystem.listdir_full(r"f:\rsc")) == [r"f:\rsc\base_file", r"f:\rsc\not._base_file"]
|
|
|
|
def test_invalid_file_argument(self, fake_fs):
|
|
# This is obviously not intended use; the function expects a directory
|
|
# as its argument, not a file. Test anyway.
|
|
test_file = r"f:\dev\sleepy"
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file) is True
|
|
assert filesystem.listdir_full(test_file) == []
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"path_separator": "/",
|
|
"is_case_sensitive": True,
|
|
}
|
|
)
|
|
class TestGetUniqueDirFilename:
|
|
# Reduce the waiting time when the function calls check_mount()
|
|
@pytest.mark.config({"wait_ext_drive": 1})
|
|
def test_nonexistent_dir(self, fake_fs, sleepless):
|
|
# Absolute path
|
|
assert filesystem.get_unique_dir("/foo/bar", n=0, create_dir=False) == "/foo/bar"
|
|
# Absolute path in a location that matters to check_mount
|
|
assert filesystem.get_unique_dir("/mnt/foo/bar", n=0, create_dir=False) == "/mnt/foo/bar"
|
|
# Relative path
|
|
if fake_fs.cwd != "/":
|
|
os.chdir("/")
|
|
assert filesystem.get_unique_dir("foo/bar", n=0, create_dir=False) == "foo/bar"
|
|
|
|
def test_nonexistent_dir_without_permission(self, fake_fs):
|
|
some_dir = "/foo/bar"
|
|
fake_fs.create_dir(some_dir)
|
|
|
|
# Remove write permission from the directory.
|
|
os.chmod(some_dir, 0o500)
|
|
|
|
assert filesystem.get_unique_dir(os.path.join(some_dir, "nonexistent"), create_dir=True) is False
|
|
|
|
def test_creating_dir(self, fake_fs):
|
|
# First call also creates the directory for us
|
|
assert filesystem.get_unique_dir("/foo/bar", n=0, create_dir=True) == "/foo/bar"
|
|
# Verify creation of the path
|
|
assert os.path.exists("/foo/bar") is True
|
|
# Directories from previous loops get in the way
|
|
for dir_n in range(1, 11): # Go high enough for double digits
|
|
assert filesystem.get_unique_dir("/foo/bar", n=0, create_dir=True) == "/foo/bar." + str(dir_n)
|
|
assert os.path.exists("/foo/bar." + str(dir_n)) is True
|
|
# Explicitly set parameter n
|
|
assert filesystem.get_unique_dir("/foo/bar", n=666, create_dir=True) == "/foo/bar.666"
|
|
assert os.path.exists("/foo/bar.666") is True
|
|
|
|
def test_nonexistent_file(self, fake_fs):
|
|
assert filesystem.get_unique_filename("/dir/file.name") == "/dir/file.name"
|
|
# Relative path
|
|
assert filesystem.get_unique_filename("dir/file.name") == "dir/file.name"
|
|
|
|
def test_existing_file(self, fake_fs):
|
|
test_file = "/dir/file.name"
|
|
max_obstruct = 11 # High enough for double digits
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file)
|
|
# Create obstructions
|
|
for n in range(1, max_obstruct):
|
|
file_n = "/dir/file." + str(n) + ".name"
|
|
fake_fs.create_file(file_n)
|
|
assert os.path.exists(file_n)
|
|
assert filesystem.get_unique_filename(test_file) == "/dir/file." + str(max_obstruct) + ".name"
|
|
|
|
def test_existing_file_without_extension(self, fake_fs):
|
|
test_file = "/some/filename"
|
|
# Create obstructions
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file)
|
|
first_filename = filesystem.get_unique_filename(test_file)
|
|
assert first_filename == "/some/filename.1"
|
|
fake_fs.create_file(first_filename)
|
|
assert filesystem.get_unique_filename(test_file) == "/some/filename.2"
|
|
|
|
def test_dangling_link_is_taken(self, tmp_path):
|
|
"""A link whose target is missing still occupies the name, and handing it out would
|
|
write through it to wherever it points"""
|
|
base = str(tmp_path)
|
|
test_file = os.path.join(base, "file.name")
|
|
Path(test_file).touch()
|
|
os.symlink(os.path.join(base, "does_not_exist"), os.path.join(base, "file.1.name"))
|
|
|
|
assert filesystem.get_unique_filename(test_file) == os.path.join(base, "file.2.name")
|
|
|
|
|
|
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows specific tests")
|
|
class TestGetUniqueDirFilenameWin:
|
|
def test_nonexistent_dir(self, fake_fs, sleepless):
|
|
# Absolute path
|
|
assert filesystem.get_unique_dir(r"C:\No\Such\Dir", n=0, create_dir=False).lower() == r"c:\no\such\dir"
|
|
# Relative path
|
|
assert filesystem.get_unique_dir(r"foo\bar", n=0, create_dir=False).lower() == r"foo\bar"
|
|
|
|
def test_creating_dir(self, fake_fs):
|
|
# First call also creates the directory for us
|
|
assert filesystem.get_unique_dir(r"C:\foo\BAR", n=0, create_dir=True).lower() == r"c:\foo\bar"
|
|
# Verify creation of the path
|
|
assert os.path.exists(r"c:\foo\bar") is True
|
|
# Directories from previous loops get in the way
|
|
for dir_n in range(1, 11): # Go high enough for double digits
|
|
assert filesystem.get_unique_dir(r"c:\foo\bar", n=0, create_dir=True) == r"c:\foo\bar." + str(dir_n)
|
|
assert os.path.exists(r"c:\foo\bar." + str(dir_n)) is True
|
|
# Explicitly set parameter n
|
|
assert filesystem.get_unique_dir(r"c:\Foo\Bar", n=666, create_dir=True).lower() == r"c:\foo\bar.666"
|
|
assert os.path.exists(r"c:\foo\bar.666") is True
|
|
|
|
def test_nonexistent_file(self, fake_fs):
|
|
assert filesystem.get_unique_filename(r"C:\DIR\file.name").lower() == r"c:\dir\file.name"
|
|
# Relative path
|
|
assert filesystem.get_unique_filename(r"DIR\file.name").lower() == r"dir\file.name"
|
|
|
|
def test_existing_file(self, fake_fs):
|
|
test_file = r"C:\dir\file.name"
|
|
max_obstruct = 11 # High enough for double digits
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file)
|
|
# Create obstructions
|
|
for n in range(1, max_obstruct):
|
|
file_n = r"C:\dir\file." + str(n) + ".name"
|
|
fake_fs.create_file(file_n)
|
|
assert os.path.exists(file_n)
|
|
assert filesystem.get_unique_filename(test_file).lower() == r"c:\dir\file." + str(max_obstruct) + ".name"
|
|
|
|
def test_existing_file_without_extension(self, fake_fs):
|
|
test_file = r"c:\some\filename"
|
|
# Create obstructions
|
|
fake_fs.create_file(test_file)
|
|
assert os.path.exists(test_file)
|
|
assert filesystem.get_unique_filename(test_file).lower() == r"c:\some\filename.1"
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
class TestCreateAllDirsWin:
|
|
def test_create_all_dirs(self, fake_fs):
|
|
self.directory = fake_fs.create_dir(r"C:\Downloads")
|
|
# Also test for no crash when folder already exists
|
|
for folder in (r"C:\Downloads", r"C:\Downloads\Show\Test", r"C:\Downloads\Show\Test2", r"C:\Downloads\Show"):
|
|
assert filesystem.create_all_dirs(folder) == folder
|
|
assert os.path.exists(folder)
|
|
|
|
|
|
class PermissionCheckerHelper:
|
|
@staticmethod
|
|
def assert_dir_perms(path, expected_perms):
|
|
assert stat.filemode(os.stat(path).st_mode) == "d" + stat.filemode(expected_perms)[1:]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"path_separator": "/",
|
|
"is_case_sensitive": True,
|
|
}
|
|
)
|
|
class TestCreateAllDirs(PermissionCheckerHelper):
|
|
def test_basic_folder_creation(self, fake_fs):
|
|
fake_fs.create_dir("/test_base")
|
|
# Also test for no crash when folder already exists
|
|
for folder in ("/test_base", "/test_base/show/season 1/episode 1", "/test_base/show"):
|
|
assert filesystem.create_all_dirs(folder) == folder
|
|
assert os.path.exists(folder)
|
|
|
|
@pytest.mark.config({"permissions": "0777"})
|
|
def test_permissions_777(self, fake_fs):
|
|
self._permissions_runner(fake_fs, "/test_base777")
|
|
self._permissions_runner(fake_fs, "/test_base777_nomask", apply_permissions=False)
|
|
|
|
@pytest.mark.config({"permissions": "0770"})
|
|
def test_permissions_770(self, fake_fs):
|
|
self._permissions_runner(fake_fs, "/test_base770")
|
|
self._permissions_runner(fake_fs, "/test_base770_nomask", apply_permissions=False)
|
|
|
|
@pytest.mark.config({"permissions": "0600"})
|
|
def test_permissions_600(self, fake_fs):
|
|
with pytest.raises(OSError): # pyfakefs checks fake permissions now...
|
|
self._permissions_runner(fake_fs, "/test_base600")
|
|
self._permissions_runner(fake_fs, "/test_base600_nomask", apply_permissions=False)
|
|
|
|
@pytest.mark.config({"permissions": "0450"})
|
|
def test_permissions_450(self, fake_fs):
|
|
with pytest.raises(OSError):
|
|
self._permissions_runner(fake_fs, "/test_base450", perms_base="0450")
|
|
|
|
def test_no_permissions(self, fake_fs):
|
|
self._permissions_runner(fake_fs, "/test_base_perm700", perms_base="0700")
|
|
self._permissions_runner(fake_fs, "/test_base_perm750", perms_base="0750")
|
|
with pytest.raises(OSError): # pyfakefs checks fake permissions now...
|
|
self._permissions_runner(fake_fs, "/test_base_perm600", perms_base="0600")
|
|
self._permissions_runner(fake_fs, "/test_base_perm777", perms_base="0777")
|
|
|
|
def _permissions_runner(
|
|
self,
|
|
fs,
|
|
test_base,
|
|
perms_base="0700",
|
|
apply_permissions=True,
|
|
):
|
|
# Create base directory and set the base permissions
|
|
perms_base_int = int(perms_base, 8)
|
|
fs.create_dir(test_base, perms_base_int, apply_umask=False)
|
|
assert os.path.exists(test_base) is True
|
|
self.assert_dir_perms(test_base, perms_base_int)
|
|
|
|
# Create directories with permissions
|
|
new_dir = os.path.join(test_base, "se 1", "ep1")
|
|
filesystem.create_all_dirs(new_dir, apply_permissions=apply_permissions)
|
|
|
|
# If permissions needed to be set, verify the new folder has the
|
|
# right permissions and verify the base didn't change
|
|
if apply_permissions and cfg.permissions():
|
|
perms_test_int = int(cfg.permissions(), 8)
|
|
else:
|
|
# Get the current permissions, since os.mkdir masks that out
|
|
perms_test_int = int("0777", 8) & ~sabnzbd.ORG_UMASK
|
|
self.assert_dir_perms(new_dir, perms_test_int)
|
|
self.assert_dir_perms(test_base, perms_base_int)
|
|
|
|
|
|
@pytest.mark.platform("win32")
|
|
class TestSetPermissionsWin:
|
|
def test_win32(self):
|
|
# Should not do or return anything on Windows
|
|
assert filesystem.set_permissions(r"F:\who\cares", recursive=False) is None
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform.startswith("win"), reason="Non-Windows tests")
|
|
@pytest.mark.fake_fs(
|
|
{
|
|
"path_separator": "/",
|
|
"is_case_sensitive": True,
|
|
"umask": int("0022", 8), # rwxr-xr-x
|
|
}
|
|
)
|
|
class TestSetPermissions(PermissionCheckerHelper):
|
|
def _runner(self, perms_before_test, fs):
|
|
"""
|
|
Generic test runner for permissions testing. The permissions are set per test
|
|
via the relevant sab config option; the filesystem parameter in setUp().
|
|
Note that the umask set in the environment before starting the program
|
|
also affects the results if sabnzbd.cfg.permissions isn't set.
|
|
|
|
Arguments:
|
|
str perms_test: permissions for test objects, chmod style "0755".
|
|
"""
|
|
# We expect the cfg.permissions to be applied, or the original to be kept if none are set
|
|
perms_before_test = int(perms_before_test, 8)
|
|
if sabnzbd.cfg.permissions():
|
|
perms_after_test = int(sabnzbd.cfg.permissions(), 8)
|
|
else:
|
|
perms_after_test = perms_before_test
|
|
|
|
# Setup and verify fake dir
|
|
test_dir = "/test"
|
|
fs.create_dir(test_dir, perms_before_test, apply_umask=False)
|
|
assert os.path.exists(test_dir) is True
|
|
self.assert_dir_perms(test_dir, perms_before_test)
|
|
|
|
# Setup and verify fake files
|
|
for file in (
|
|
"foobar",
|
|
"file.ext",
|
|
"sub/dir/.nzb",
|
|
"another/sub/dir/WithSome.File",
|
|
):
|
|
file = os.path.join(test_dir, file)
|
|
basefolder = os.path.dirname(file)
|
|
|
|
# Create the folder, so it has the expected permissions
|
|
if not os.path.exists(basefolder):
|
|
try:
|
|
fs.create_dir(basefolder, perms_before_test, apply_umask=False)
|
|
except PermissionError:
|
|
set_uid(0)
|
|
fs.create_file(file, perms_before_test, apply_umask=False)
|
|
assert os.path.exists(basefolder) is True
|
|
self.assert_dir_perms(basefolder, perms_before_test)
|
|
|
|
# Add a random one of the forbidden bits
|
|
file_perms_before_test = perms_before_test | choice(
|
|
(stat.S_ISUID, stat.S_ISGID, stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH)
|
|
)
|
|
|
|
# Then, create the file
|
|
try:
|
|
fs.create_file(file, file_perms_before_test, apply_umask=False)
|
|
except PermissionError:
|
|
set_uid(0)
|
|
fs.create_file(file, file_perms_before_test, apply_umask=False)
|
|
|
|
assert os.path.exists(file) is True
|
|
assert stat.filemode(os.stat(file).st_mode)[1:] == stat.filemode(file_perms_before_test)[1:]
|
|
|
|
# Set permissions, recursive by default
|
|
filesystem.set_permissions(test_dir)
|
|
|
|
# Check the results
|
|
for root, dirs, files in os.walk(test_dir):
|
|
for directory in [os.path.join(root, d) for d in dirs]:
|
|
# Permissions on directories should now match perms_after
|
|
self.assert_dir_perms(directory, perms_after_test)
|
|
for file in [os.path.join(root, f) for f in files]:
|
|
# Files also shouldn't have any executable or special bits set
|
|
assert (
|
|
stat.filemode(os.stat(file).st_mode)[1:]
|
|
== stat.filemode(
|
|
perms_after_test & ~(stat.S_ISUID | stat.S_ISGID | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
)[1:]
|
|
)
|
|
|
|
# Cleanup
|
|
set_uid(0)
|
|
fs.remove_object(test_dir)
|
|
assert os.path.exists(test_dir) is False
|
|
set_uid(global_uid)
|
|
|
|
@pytest.mark.platform("linux")
|
|
def test_empty_permissions_setting(self, fake_fs):
|
|
# World writable directory
|
|
self._runner("0777", fake_fs)
|
|
self._runner("0450", fake_fs)
|
|
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.config({"permissions": "0760"})
|
|
def test_dir0777_permissions0760_setting(self, fake_fs):
|
|
# World-writable directory, permissions 760
|
|
self._runner("0777", fake_fs)
|
|
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.config({"permissions": "0617"})
|
|
def test_dir0450_permissions0617_setting(self, fake_fs):
|
|
# Insufficient base access
|
|
self._runner("0450", fake_fs)
|
|
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.config({"permissions": "2455"})
|
|
def test_dir0444_permissions2455_setting(self, fake_fs):
|
|
# Insufficient access, permissions with setgid (should be stripped)
|
|
self._runner("0444", fake_fs)
|
|
|
|
@pytest.mark.platform("linux")
|
|
@pytest.mark.config({"permissions": "4755"})
|
|
def test_dir1755_permissions4755_setting(self, fake_fs):
|
|
# Sticky bit on directory, permissions with setuid (should be stripped)
|
|
self._runner("1755", fake_fs)
|
|
|
|
|
|
class TestRenamer:
|
|
# test filesystem.renamer() for different scenario's
|
|
def test_renamer(self, fake_fs):
|
|
# First of all, create a working directory (with a random name)
|
|
dirname = os.path.join(SAB_DATA_DIR, "testdir" + str(randint(10000, 99999)))
|
|
fake_fs.create_dir(dirname)
|
|
|
|
# base case: rename file within directory
|
|
filename = os.path.join(dirname, "myfile.txt")
|
|
Path(filename).touch() # create file
|
|
newfilename = os.path.join(dirname, "newfile.txt")
|
|
assert newfilename == filesystem.renamer(filename, newfilename)
|
|
assert not os.path.isfile(filename)
|
|
assert os.path.isfile(newfilename)
|
|
|
|
# standard behaviour: renaming (moving) into an exiting other directory *is* allowed
|
|
filename = os.path.join(dirname, "myfile.txt")
|
|
Path(filename).touch() # create file
|
|
sameleveldirname = os.path.join(SAB_DATA_DIR, "othertestdir" + str(randint(10000, 99999)))
|
|
os.mkdir(sameleveldirname)
|
|
newfilename = os.path.join(sameleveldirname, "newfile.txt")
|
|
assert newfilename == filesystem.renamer(filename, newfilename)
|
|
assert not os.path.isfile(filename)
|
|
assert os.path.isfile(newfilename)
|
|
shutil.rmtree(sameleveldirname)
|
|
|
|
# Default: renaming into a non-existing subdirectory not allowed
|
|
Path(filename).touch() # create file
|
|
newfilename = os.path.join(dirname, "nonexistingsubdir", "newfile.txt")
|
|
try:
|
|
# Should fail
|
|
filesystem.renamer(filename, newfilename)
|
|
except Exception:
|
|
pass
|
|
assert os.path.isfile(filename)
|
|
assert not os.path.isfile(newfilename)
|
|
|
|
# Creation of subdirectory is allowed if create_local_directories=True
|
|
Path(filename).touch()
|
|
newfilename = os.path.join(dirname, "newsubdir", "newfile.txt")
|
|
try:
|
|
filesystem.renamer(filename, newfilename, create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert not os.path.isfile(filename)
|
|
assert os.path.isfile(newfilename)
|
|
|
|
# Creation of subdirectory plus deeper sudbdir is allowed if create_local_directories=True
|
|
Path(filename).touch()
|
|
newfilename = os.path.join(dirname, "newsubdir", "deepersubdir", "newfile.txt")
|
|
try:
|
|
filesystem.renamer(filename, newfilename, create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert not os.path.isfile(filename)
|
|
assert os.path.isfile(newfilename)
|
|
|
|
# ... escaping the directory plus subdir creation is not allowed
|
|
Path(filename).touch()
|
|
newfilename = os.path.join(dirname, "..", "newsubdir", "newfile.txt")
|
|
try:
|
|
filesystem.renamer(filename, newfilename, create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert os.path.isfile(filename)
|
|
assert not os.path.isfile(newfilename)
|
|
|
|
# ... renaming into the admin folder is not allowed either
|
|
admin_dir = os.path.join(dirname, JOB_ADMIN)
|
|
os.mkdir(admin_dir)
|
|
Path(filename).touch()
|
|
newfilename = os.path.join(admin_dir, "__verified__")
|
|
try:
|
|
filesystem.renamer(filename, newfilename, create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert os.path.isfile(filename)
|
|
assert not os.path.isfile(newfilename)
|
|
|
|
# ... nor is naming the admin folder itself: a move into an existing directory
|
|
# keeps the old basename, so this would end up inside the admin folder as well
|
|
Path(filename).touch()
|
|
try:
|
|
filesystem.renamer(filename, admin_dir, create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert os.path.isfile(filename)
|
|
assert not os.listdir(admin_dir)
|
|
|
|
# ... and not under another name that resolves to it, such as a link. On Windows
|
|
# an NTFS 8.3 alias ("__ADMI~1") reaches the admin folder the very same way.
|
|
linkname = os.path.join(dirname, "notadmin")
|
|
os.symlink(admin_dir, linkname)
|
|
Path(filename).touch()
|
|
try:
|
|
filesystem.renamer(filename, os.path.join(linkname, "__verified__"), create_local_directories=True)
|
|
except Exception:
|
|
pass
|
|
assert os.path.isfile(filename)
|
|
assert not os.listdir(admin_dir)
|
|
os.remove(linkname)
|
|
|
|
# Cleanup working directory
|
|
shutil.rmtree(dirname)
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
|
|
)
|
|
def test_link_cannot_redirect_rename(self, tmp_path):
|
|
"""The filesystem resolves a link before it handles "..", so "pivot/.." lands one
|
|
level higher than normalizing the path on its own suggests"""
|
|
base = str(tmp_path)
|
|
dirname = os.path.join(base, "job")
|
|
os.mkdir(dirname)
|
|
os.symlink(".", os.path.join(dirname, "pivot"))
|
|
|
|
filename = os.path.join(dirname, "myfile.txt")
|
|
Path(filename).touch()
|
|
escaped = os.path.join(base, "escaped.bin")
|
|
with pytest.raises(OSError):
|
|
filesystem.renamer(
|
|
filename, os.path.join(dirname, "pivot", "..", "escaped.bin"), create_local_directories=True
|
|
)
|
|
assert os.path.isfile(filename)
|
|
assert not os.path.exists(escaped)
|
|
|
|
# A link that leaves the directory outright is no stepping stone either
|
|
os.symlink(base, os.path.join(dirname, "outside"))
|
|
with pytest.raises(OSError):
|
|
filesystem.renamer(filename, os.path.join(dirname, "outside", "escaped.bin"), create_local_directories=True)
|
|
assert os.path.isfile(filename)
|
|
assert not os.path.exists(escaped)
|
|
|
|
|
|
class TestRestrictedUnpickler:
|
|
def test_round_trip(self, tmp_path):
|
|
data = {"a": 1, "s": {1, 2}, "when": datetime.datetime(2024, 1, 1), "t": time.gmtime(0), "st": os.stat(".")}
|
|
filesystem.save_data(data, "d", str(tmp_path))
|
|
assert filesystem.load_data("d", str(tmp_path), remove=False) == data
|
|
|
|
def test_rejects_code_execution_gadget(self):
|
|
class Evil:
|
|
def __reduce__(self):
|
|
return (os.system, ("echo pwned",))
|
|
|
|
with pytest.raises(pickle.UnpicklingError):
|
|
filesystem.RestrictedUnpickler(io.BytesIO(pickle.dumps(Evil()))).load()
|
|
|
|
def test_rejects_non_allowlisted_sabnzbd_class(self):
|
|
# kronos.ForkedScheduler has a __del__ that runs os.kill; referenced by name, rejected pre-import
|
|
def named_global(module, name):
|
|
return (
|
|
b"\x80\x04\x8c"
|
|
+ bytes([len(module)])
|
|
+ module.encode()
|
|
+ b"\x8c"
|
|
+ bytes([len(name)])
|
|
+ name.encode()
|
|
+ b"\x93."
|
|
)
|
|
|
|
with pytest.raises(pickle.UnpicklingError):
|
|
filesystem.RestrictedUnpickler(io.BytesIO(named_global("sabnzbd.utils.kronos", "ForkedScheduler"))).load()
|
|
|
|
def test_loads_legacy_3_0_rss_pickle(self):
|
|
path = os.path.join(SAB_DATA_DIR, "test_3_0_0_data_format")
|
|
data = filesystem.load_data("rss_data.sab", path, remove=False)
|
|
assert isinstance(data, dict) and data
|
|
feed_jobs = next(iter(data.values()))
|
|
assert isinstance(feed_jobs, dict) and feed_jobs
|
|
|
|
|
|
class TestUnwantedExtensions:
|
|
# Only test lowercase extensions without a leading dot: the unwanted_extensions
|
|
# setting is sanitized accordingly in interface.saveSwitches() before saving.
|
|
test_extensions = "iso, cmd, bat, sh, re:r[0-9]{2}, sab*"
|
|
# Test parameters as (filename, result) tuples, with result given for blacklist mode
|
|
test_params = [
|
|
("ubuntu.iso", True),
|
|
("par2.cmd", True),
|
|
("freedos.BAT", True),
|
|
("Debian.installer.SH", True),
|
|
("FREEBSD.ISO", True),
|
|
("par2.CmD", True),
|
|
("freedos.baT", True),
|
|
("Debian.Installer.sh", True),
|
|
("ubuntu.torrent", False),
|
|
("par2.cmd.notcmd", False),
|
|
("freedos.tab", False),
|
|
(".SH.hs", False),
|
|
("regexp.r0611", False),
|
|
("regexp.007", False),
|
|
("regexp.A01", False),
|
|
("regexp.r9", False),
|
|
("regexp.r2d2", False),
|
|
("regexp.r2d", False),
|
|
("regexp.r00", True),
|
|
("regexp.R42", True),
|
|
("test.sabnzbd", True),
|
|
("pass.sab", True),
|
|
("fail.sb", False),
|
|
("No_Extension", False),
|
|
("r42", False),
|
|
(480, False),
|
|
(None, False),
|
|
("", False),
|
|
([], False),
|
|
]
|
|
|
|
@pytest.mark.config({"unwanted_extensions_mode": 0, "unwanted_extensions": test_extensions})
|
|
def test_has_unwanted_extension_blacklist_mode(self):
|
|
for filename, result in self.test_params:
|
|
assert filesystem.has_unwanted_extension(filename) is result
|
|
|
|
@pytest.mark.config({"unwanted_extensions_mode": 1, "unwanted_extensions": test_extensions})
|
|
def test_has_unwanted_extension_whitelist_mode(self):
|
|
for filename, result in self.test_params:
|
|
if filesystem.get_ext(filename):
|
|
assert filesystem.has_unwanted_extension(filename) is not result
|
|
else:
|
|
# missing extension is never considered unwanted
|
|
assert filesystem.has_unwanted_extension(filename) is False
|
|
|
|
@pytest.mark.config({"unwanted_extensions_mode": 0, "unwanted_extensions": ""})
|
|
def test_has_unwanted_extension_empty_blacklist(self):
|
|
for filename, result in self.test_params:
|
|
assert filesystem.has_unwanted_extension(filename) is False
|
|
|
|
@pytest.mark.config({"unwanted_extensions_mode": 1, "unwanted_extensions": ""})
|
|
def test_has_unwanted_extension_empty_whitelist(self):
|
|
for filename, result in self.test_params:
|
|
if filesystem.get_ext(filename):
|
|
assert filesystem.has_unwanted_extension(filename) is True
|
|
else:
|
|
# missing extension is never considered unwanted
|
|
assert filesystem.has_unwanted_extension(filename) is False
|
|
|
|
|
|
class TestOtherFileSystemFunctions:
|
|
def test_directory_is_writable(self):
|
|
# very basic test of directory_is_writable()
|
|
# let's test on the tempdir provided by the OS:
|
|
assert filesystem.directory_is_writable(tempfile.gettempdir())
|
|
|
|
def test_filesystem_capabilities(self):
|
|
# test the filesystem is capable of long and unicode filenames
|
|
# any modern filesystem (ext3, ext4, ntfs, modern FAT) should succeed
|
|
assert filesystem.check_filesystem_capabilities(tempfile.gettempdir())
|
|
|
|
def test_directory_is_writable_with_file_survives_lost_test_file(self, monkeypatch):
|
|
# Regression test: losing the temporary test file before cleanup (e.g. a
|
|
# concurrent writability check removed it) must NOT be reported as "not
|
|
# writable". This used to surface as a false "is not writable at all.
|
|
# This blocks downloads." warning at startup.
|
|
test_dir = tempfile.gettempdir()
|
|
real_remove = os.remove
|
|
leftover = os.path.join(test_dir, "sab_test.txt")
|
|
|
|
def raise_enoent(path):
|
|
raise FileNotFoundError("simulated race: test file already removed")
|
|
|
|
monkeypatch.setattr(os, "remove", raise_enoent)
|
|
try:
|
|
assert filesystem.directory_is_writable_with_file(test_dir, "sab_test.txt") is True
|
|
finally:
|
|
# os.remove was patched out, so the test file was left behind
|
|
if os.path.exists(leftover):
|
|
real_remove(leftover)
|
|
|
|
def test_directory_is_writable_with_file_reports_write_failure(self, monkeypatch):
|
|
# A genuine failure to create/write the file must still return False
|
|
def raise_permission(*args, **kwargs):
|
|
raise PermissionError("simulated read-only filesystem")
|
|
|
|
monkeypatch.setattr("builtins.open", raise_permission)
|
|
assert filesystem.directory_is_writable_with_file(tempfile.gettempdir(), "sab_test.txt") is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"name, ext_to_remove, output",
|
|
[
|
|
("Test.nzb", (".nzb",), "Test"),
|
|
("Test.nzb.nzb.nzb.nzb.nzb", (".nzb",), "Test"),
|
|
("Test.not", (".nzb",), "Test.not"),
|
|
("No.par2.Test.par2.nzb", (".nzb", ".par2"), "No.par2.Test"),
|
|
],
|
|
)
|
|
def test_strip_extensions(self, name, ext_to_remove, output):
|
|
assert filesystem.strip_extensions(name, ext_to_remove) == output
|
|
|
|
@pytest.mark.parametrize(
|
|
"file_name, clean_file_name",
|
|
[
|
|
("my_awesome_nzb_file.pAr2.nZb", "my_awesome_nzb_file"),
|
|
("my_awesome_nzb_file.....pAr2.nZb", "my_awesome_nzb_file"),
|
|
("my_awesome_nzb_file....par2..", "my_awesome_nzb_file"),
|
|
(" my_awesome_nzb_file .pAr.nZb", "my_awesome_nzb_file"),
|
|
("with.extension.and.period.par2.", "with.extension.and.period"),
|
|
("nothing.in.here", "nothing.in.here"),
|
|
(" just.space ", "just.space"),
|
|
("http://test.par2 ", "http://test.par2"),
|
|
],
|
|
)
|
|
def test_create_work_name(self, file_name, clean_file_name):
|
|
# Only test stuff specific for create_work_name
|
|
# The sanitizing is already tested in tests for sanitize_foldername
|
|
assert filesystem.create_work_name(file_name) == clean_file_name
|
|
|
|
|
|
class TestOutOfSpace:
|
|
"""A full filesystem and an exhausted quota are the same thing to a user, and both
|
|
are fixed by freeing space rather than by retrying the write."""
|
|
|
|
@staticmethod
|
|
def error(code, winerror=None):
|
|
err = OSError(code, "test")
|
|
if winerror is not None:
|
|
err.winerror = winerror
|
|
return err
|
|
|
|
def test_a_full_filesystem(self):
|
|
assert sabnzbd.filesystem.out_of_space(self.error(errno.ENOSPC)) is True
|
|
|
|
def test_an_exhausted_quota(self):
|
|
assert sabnzbd.filesystem.out_of_space(self.error(errno.EDQUOT)) is True
|
|
|
|
@pytest.mark.parametrize("code", [errno.EACCES, errno.ENOENT, errno.EIO, errno.EROFS])
|
|
def test_other_errors_are_not_out_of_space(self, code):
|
|
"""These need a person, not more free space, so they must not be reported as a
|
|
full disk"""
|
|
assert sabnzbd.filesystem.out_of_space(self.error(code)) is False
|
|
|
|
@pytest.mark.parametrize("winerror", [39, 112, 1295])
|
|
def test_the_windows_codes(self, winerror):
|
|
"""Windows says it several ways and only some map onto an errno"""
|
|
original = sabnzbd.WINDOWS
|
|
sabnzbd.WINDOWS = True
|
|
try:
|
|
assert sabnzbd.filesystem.out_of_space(self.error(errno.EINVAL, winerror)) is True
|
|
finally:
|
|
sabnzbd.WINDOWS = original
|
|
|
|
def test_an_unrelated_windows_code(self):
|
|
original = sabnzbd.WINDOWS
|
|
sabnzbd.WINDOWS = True
|
|
try:
|
|
# 5 is ERROR_ACCESS_DENIED
|
|
assert sabnzbd.filesystem.out_of_space(self.error(errno.EINVAL, 5)) is False
|
|
finally:
|
|
sabnzbd.WINDOWS = original
|