Raise warnings if there are unsafe file path elements rather than coercing file paths. Stitching still raises an error if your file name is unsafe

This commit is contained in:
Beth Probert 2026-03-09 13:54:43 +00:00
parent 2395234322
commit f67699fbc8
3 changed files with 153 additions and 93 deletions

View file

@ -78,13 +78,14 @@ def validate_command(cmd: list[str]) -> None:
# Check against forbidden commands (case-insensitive) # Check against forbidden commands (case-insensitive)
if element.lower() in FORBIDDEN_COMMANDS: if element.lower() in FORBIDDEN_COMMANDS:
raise StitcherValidationError( raise StitcherValidationError(
f"Invalid stiching command: Forbidden element '{element}' detected." f"Invalid stitching command: Forbidden element '{element}' detected."
) )
# Ensure characters are safe for a path component # Ensure characters are safe for a path component
if element != make_path_safe(element): _, error_raised = make_path_safe(element)
if error_raised:
raise StitcherValidationError( raise StitcherValidationError(
f"Invalid stiching command: Element '{element}' contains unsafe characters." f"Invalid stitching command: Element '{element}' contains unsafe characters."
) )
@ -154,7 +155,8 @@ class BaseStitcher:
:raises RuntimeError: if inputs are unsafe. :raises RuntimeError: if inputs are unsafe.
""" """
if self.images_dir != make_path_safe(self.images_dir): _, error_raised = make_path_safe(self.images_dir)
if error_raised:
raise StitcherValidationError( raise StitcherValidationError(
"Invalid directory path: Contains unsafe characters." "Invalid directory path: Contains unsafe characters."
) )

View file

@ -77,7 +77,7 @@ def requires_lock(
# Compiled regular expressions for unsafe characters # Compiled regular expressions for unsafe characters
# Matches anything that isn't a-z, A-Z, 0-9, _, ., -, :, /, \ # Matches anything that isn't a-z, A-Z, 0-9, _, ., -, :, /, \
_WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\\]") _WINDOWS_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-:/\ \\]")
# Matches anything that isn't a-z, A-Z, 0-9, _, ., -, \ # Matches anything that isn't a-z, A-Z, 0-9, _, ., -, \
_POSIX_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-/]") _POSIX_UNSAFE_PATTERN = re.compile(r"[^a-zA-Z0-9_.\-/]")
# Matches anything that isn't a-z, A-Z, 0-9, _, ., - # Matches anything that isn't a-z, A-Z, 0-9, _, ., -
@ -110,26 +110,21 @@ _WINDOWS_RESERVED_NAMES = {
} }
def make_path_safe(unsafe_path_string: str) -> str: def make_path_safe(unsafe_path_string: str) -> tuple[str, bool]:
"""Replace unsafe characters in a file path with underscores, preserving separators. """Check if a file path has any unsafe elements in it, such as
unsafe characters or reserved names for its OS.
This can be used to check that user inputs have not been concatenated into an The path is not coerced into a safe form because if we ask
unsafe filepath. for a file to be written to a location we shouldn't be changing
that location as we have no "consent" to write to this other location.
This ensures compatibility across platforms by preserving only valid characters,
including path separators (forward slash on POSIX, and forward
slash/backslash/colon on Windows).
Additionally, it sanitises each path component for Windows reserved names and
trailing dots/spaces to ensure maximum cross-platform compatibility.
:param unsafe_path_string: The original path string to sanitise. :param unsafe_path_string: The original path string to sanitise.
:returns: A version of the input string safe to use as a file path. :returns: The input string unchanged and a boolean indicating if
any unsafe features were found.
""" """
# Split by separators first to sanitise components independently # Split by separators first to sanitise components independently
components = re.split(r"([/\\])", unsafe_path_string) components = re.split(r"([/\\])", unsafe_path_string)
sanitised_components = []
unsafe_character_pattern = ( unsafe_character_pattern = (
_WINDOWS_UNSAFE_PATTERN _WINDOWS_UNSAFE_PATTERN
@ -137,40 +132,52 @@ def make_path_safe(unsafe_path_string: str) -> str:
else _POSIX_UNSAFE_PATTERN else _POSIX_UNSAFE_PATTERN
) )
# Ensure each warning only gets logged once per path
unsafe_relative_path = False
trailing_dots = False
trailing_whitespace = False
unsafe_characters = False
reserved_word = False
for i, component in enumerate(components): for i, component in enumerate(components):
if component in ("/", "\\"): # Skip separators and empty strings
sanitised_components.append(component) if component in ("/", "\\") or not component:
elif component in (".", ".."): continue
# Only allow '.' and '..' if they are the very first
# elements and the next element is a separator.
# This allows for relative paths like "./file" or "../file"
# but not "file./file" or "file/../file"
is_first = i == 0
next_to_sep = (i + 1 < len(components)) and (
components[i + 1] in ("/", "\\")
)
if is_first and next_to_sep: # 1. Check for relative paths in the wrong place
sanitised_components.append(component) if component in (".", "..") and not unsafe_relative_path:
else: is_first = (i == 0)
sanitised_components.append("_") is_second = (i == 2 and components[i-1] in ("/", "\\") and components[i-2] == "..")
elif component: if not (is_first or is_second):
# 1. Strip trailing dots and spaces LOGGER.warning(f"File path {unsafe_path_string} may be unsafe due to unexpected relative navigation.")
# 2. Apply unsafe character regex unsafe_relative_path = True
# 3. Check for reserved names continue
sanitised = component.rstrip(". ")
if not sanitised:
sanitised = "_"
else:
sanitised = unsafe_character_pattern.sub("_", sanitised)
sanitised_components.append( # 2. Check for trailing dots in path
_sanitise_reserved(sanitised, is_filename=False) if "." in component and i != len(components) - 1 and not trailing_dots:
) # Check for trailing dots in the file path before the file name
else: # e.g.: foo/bar./file is bad, but foo/bar.file.py is fine
sanitised_components.append(component) LOGGER.warning(f'File path {unsafe_path_string} may be unsafe due to trailing dots.')
trailing_dots = True
return "".join(sanitised_components) # Check for trailing spaces - Windows specific
if sys.platform.startswith("win") and component.endswith(" ") and not trailing_whitespace:
LOGGER.warning(f"{unsafe_path_string} contains unsafe trailing spaces.")
trailing_whitespace = True
# 3. Check for unsafe characters (Regex)
if unsafe_character_pattern.search(component) and not unsafe_characters:
LOGGER.warning(f"{unsafe_path_string} contains characters that may be unsafe on this platform.")
unsafe_characters = True
# 4. Check for Reserved Names (e.g., CON, PRN, LPT1)
# Assuming _sanitise_reserved returns a different string if it's a reserved name
if _sanitise_reserved(component, is_filename=False) != component and not reserved_word:
LOGGER.warning(f"{unsafe_path_string} contains a reserved system name and may cause issues.")
reserved_word = True
warning_raised = unsafe_relative_path or trailing_dots or trailing_whitespace or unsafe_characters or reserved_word
return unsafe_path_string, warning_raised
def make_name_safe(unsafe_name_string: str) -> str: def make_name_safe(unsafe_name_string: str) -> str:

View file

@ -1,15 +1,42 @@
"""Unit tests for utility functions.""" """Unit tests for utility functions."""
import sys import sys
import logging
import pytest import pytest
from openflexure_microscope_server.utilities import ( from openflexure_microscope_server.utilities import (
_WINDOWS_RESERVED_NAMES, _WINDOWS_RESERVED_NAMES,
make_name_safe, make_name_safe,
make_path_safe, make_path_safe
) )
PATHS_WITH_DOTS = {
"a./b/c.",
"path./to/file.",
"./path./to/file."
}
RESERVED_NAME_PATHS = {
"path/CON/file",
"path/NUL.txt/file",
"C:/AUX/test"
}
if sys.platform.startswith("win"):
UNSAFE_CHARACTER_PATHS = {
"C:/path/asterisk/data*.csv",
"data/files/read_me_first!.txt",
"logs/system<error>.log",
"projects/alpha#1/notes.txt",
}
else:
UNSAFE_CHARACTER_PATHS = {
"/var/log/app:backup.log",
"/etc/configs/network-settings\\v1",
"/tmp/file_with_@_symbol.txt",
"/home/user/script(1).py"
}
def test_make_name_safe_basic(): def test_make_name_safe_basic():
"""Test basic functionality of make_name_safe.""" """Test basic functionality of make_name_safe."""
@ -56,57 +83,81 @@ def test_make_name_safe_reserved_names_false_positives():
assert make_name_safe("CHILLI CON CARNE") == "chilli_con_carne" assert make_name_safe("CHILLI CON CARNE") == "chilli_con_carne"
def test_make_path_safe_basic(): def test_make_path_safe_basic(caplog):
"""Test basic functionality of make_path_safe.""" """Test basic functionality of make_path_safe."""
# Note: behavior depends on platform for separators # Note: behavior depends on platform for separators
if sys.platform.startswith("win"): with caplog.at_level(logging.WARNING):
assert make_path_safe("C:\\path\\to/file") == "C:\\path\\to/file" if sys.platform.startswith("win"):
else: assert make_path_safe("C:\\path\\to/file")[0] == "C:\\path\\to/file"
# On POSIX, \ is not a separator and might be replaced by _ if not in allowed pattern assert make_path_safe("C:\\a very long path\\to/file")[0] == "C:\\a very long path\\to/file"
# The regex for POSIX is [^a-zA-Z0-9_.\-/] else:
# And : is also not allowed assert make_path_safe("path/to/file")[0] == "path/to/file"
assert make_path_safe("path/to/file") == "path/to/file" assert make_path_safe("a very long path/to/file")[0] == "a very long path/to/file"
# No errors should be raised. These paths are safe.
assert len(caplog.records) == 0
def test_make_path_safe_reserved_in_components(): @pytest.mark.parametrize("path", UNSAFE_CHARACTER_PATHS)
def test_make_path_safe_unsafe_characters_in_components(caplog, path):
"""Test unsafe characters within path components."""
with caplog.at_level(logging.WARNING):
assert make_path_safe(path)[0] == path
assert f"{path} contains characters that may be unsafe on this platform."
@pytest.mark.parametrize("path", RESERVED_NAME_PATHS)
def test_make_path_safe_reserved_in_components(caplog, path):
"""Test reserved names within path components.""" """Test reserved names within path components."""
assert make_path_safe("path/CON/file") == "path/CON_/file" with caplog.at_level(logging.WARNING):
assert make_path_safe("path/NUL.txt/file") == "path/NUL.txt_/file" assert make_path_safe(path)[0] == path
assert f"{path} contains a reserved system name and may cause issues."
if sys.platform.startswith("win"):
assert make_path_safe("C:/AUX/test") == "C:/AUX_/test"
else:
assert make_path_safe("C:/AUX/test") == "C_/AUX_/test"
def test_make_path_safe_trailing_in_components(): def test_make_path_safe_trailing_space_in_components(caplog):
"""Test trailing chars in path components.""" """Test trailing spaces in path components."""
assert make_path_safe("path /to. /file ") == "path/to/file" with caplog.at_level(logging.WARNING):
assert make_path_safe("path./to /file.") == "path/to/file" # Test Windows - cannot have trailingspaces
if sys.platform.startswith("win"):
# Allow dots at the start of the path assert make_path_safe("path /to /file ")[0] == "path /to /file "
# As this is common for hidden files on POSIX and relative paths assert make_path_safe("path/to /file")[0] == "path/to /file"
# And is the configuration in the manual and simulation config # Check directories can have spaces in them but not at the end
# json files. # TODO - dont want underscores, want to keep interim spaces
assert make_path_safe("./path/to/file") == "./path/to/file" assert make_path_safe("path/a long path name /file")[0] == "path/a long path name /file"
assert make_path_safe("./path./to /file.") == "./path/to/file" assert len(caplog.records) == 3
assert make_path_safe("./openflexure/data/") == "./openflexure/data/" assert f"path /to /file contains unsafe trailing spaces." in caplog.messages
assert f"path/to /file contains unsafe trailing spaces." in caplog.messages
assert f"path/a long path name /file contains unsafe trailing spaces." in caplog.messages
else:
# Trailing spaces allows in POSIX systems
assert make_path_safe("path /to /file ")[0] == "path /to /file "
assert make_path_safe("path/to /file")[0] == "path/to /file"
# Check directories can have spaces in them but not at the end
assert make_path_safe("path/a long path name /file")[0] == "path/a long path name file"
# No warnings should be raised
assert len(caplog.records) == 0
def test_make_path_safe_separators(): def test_make_path_safe_relative(caplog):
"""Test that separators are preserved and components sanitised."""
assert make_path_safe("a/b\\c") == "a/b\\c"
assert make_path_safe("a./b /c.") == "a/b/c"
def test_make_path_safe_relative():
"""Test that relative path components are preserved.""" """Test that relative path components are preserved."""
# We only allow relative paths with one dot, and at the beginning of the path, # Relative imports ./foo, ../foo or ../../foo are allowed.
# to avoid issues with paths like "file./file" or "file../file. with caplog.at_level(logging.WARNING):
assert make_path_safe("./openflexure/data/") == "./openflexure/data/" assert make_path_safe("./openflexure/data/")[0] == "./openflexure/data/"
assert make_path_safe("../openflexure/data/") == "../openflexure/data/" assert make_path_safe("../openflexure/data/")[0] == "../openflexure/data/"
assert make_path_safe("../../openflexure/data/")[0] == "../../openflexure/data/"
assert make_path_safe(".")[0] == "."
assert make_path_safe("..")[0] == ".."
assert make_path_safe("path/./to/file") == "path/_/to/file" assert len(caplog.records) == 0
assert make_path_safe("path/../to/file") == "path/_/to/file"
assert make_path_safe(".") == "_" assert make_path_safe("a_bad/../relative_path")[0] == "a_bad/../relative_path"
assert make_path_safe("..") == "_" assert len(caplog.records) == 1
assert(f"File path a_bad/../relative_path may be unsafe due to unexpected relative navigation.")
@pytest.mark.parametrize("filepath", PATHS_WITH_DOTS)
def test_make_path_safe_dots_warning(caplog, filepath):
"""Test that a user is warned about trailing dots in their file paths. The paths should remain unchanged."""
with caplog.at_level(logging.WARNING):
assert make_path_safe(filepath)[0] == filepath
assert f"File path {filepath} may be unsafe due to trailing dots." in caplog.messages