Repository: meltano/meltano

Issue:
Limit direct Werkzeug use to `meltano.api`
When `meltano.api` is removed as part of version 3, we also want to remove any direct usage of Werkzeug so that we are no longer directly depending on it, as this has been a significant maintenance burden in the past, and is currently (as of 2023-03-13) the source of [a security issue](https://github.com/meltano/meltano/security/dependabot/2).

In preparation for this, we'll need to remove all usage of Werkzeug outside of `meltano.api`. It is currently used in 2 places within `src/meltano/core/project.py`: calling `werkzeug.utils.secure_filename(state_id)` to create the path to the jobs directory, and to create the path to the jobs logs directory.

We cannot change the behaviour at those 2 locations without breaking backwards compatibility, so switching to an alternative path sanitization library is not an option unless it can do exactly what `werkzeug.utils.secure_filename` does. Our best bet is probably to write a small utility function that exhibits the same behaviour as `werkzeug.utils.secure_filename`, and use that instead.


Discussion:

Resolving patch:
diff --git a/src/meltano/core/project.py b/src/meltano/core/project.py
--- a/src/meltano/core/project.py
+++ b/src/meltano/core/project.py
@@ -14,7 +14,6 @@
 
 import fasteners
 from dotenv import dotenv_values
-from werkzeug.utils import secure_filename
 
 from meltano.core import yaml
 from meltano.core.behavior.versioned import Versioned
@@ -30,7 +29,7 @@
 from meltano.core.project_files import ProjectFiles
 from meltano.core.project_plugins_service import ProjectPluginsService
 from meltano.core.project_settings_service import ProjectSettingsService
-from meltano.core.utils import makedirs, truthy
+from meltano.core.utils import makedirs, sanitize_filename, truthy
 
 if sys.version_info >= (3, 8):
     from functools import cached_property
@@ -516,7 +515,7 @@ def job_dir(self, state_id, *joinpaths, make_dirs: bool = True):
             Resolved path to `elt` dir optionally joined to given paths.
         """
         return self.run_dir(
-            "elt", secure_filename(state_id), *joinpaths, make_dirs=make_dirs
+            "elt", sanitize_filename(state_id), *joinpaths, make_dirs=make_dirs
         )
 
     @makedirs
@@ -532,7 +531,7 @@ def job_logs_dir(self, state_id, *joinpaths, make_dirs: bool = True):
             Resolved path to `elt` dir optionally joined to given paths.
         """
         return self.logs_dir(
-            "elt", secure_filename(state_id), *joinpaths, make_dirs=make_dirs
+            "elt", sanitize_filename(state_id), *joinpaths, make_dirs=make_dirs
         )
 
     @makedirs
diff --git a/src/meltano/core/utils/__init__.py b/src/meltano/core/utils/__init__.py
--- a/src/meltano/core/utils/__init__.py
+++ b/src/meltano/core/utils/__init__.py
@@ -10,10 +10,12 @@
 import logging
 import math
 import os
+import platform
 import re
 import sys
 import traceback
 import typing as t
+import unicodedata
 from contextlib import suppress
 from copy import copy, deepcopy
 from datetime import date, datetime, time
@@ -807,3 +809,49 @@ def remove_suffix(string: str, suffix: str) -> str:
     elif string.endswith(suffix):
         return string[: -len(suffix)]
     return string
+
+
+_filename_restriction_pattern = re.compile(r"[^\w.-]")
+_reserved_windows_filenames = frozenset(
+    ("AUX", "COM1", "COM2", "COM3", "COM4", "CON", "LPT1", "LPT2", "LPT3", "NUL", "PRN")
+)
+_sanitize_filename_transformations = (
+    # Normalize unicode data in the string:
+    lambda x: unicodedata.normalize("NFKD", x),
+    # Limit the string to ASCII characters:
+    lambda x: x.encode("ascii", "ignore").decode("ascii"),
+    # Replace each path separator with a space:
+    lambda x: x.replace(os.path.sep, " "),
+    lambda x: x.replace(os.path.altsep, " ") if os.path.altsep else x,
+    # Replace each whitespace character with an underscore:
+    lambda x: "_".join(x.split()),
+    # Limit the string to alphanumeric characters, underscores, hyphens, and dots:
+    lambda x: _filename_restriction_pattern.sub("", x),
+    # Remove Remove illegal character combination `._` from front and back:
+    lambda x: x.strip("._"),
+    # Add a leading `_` if necessary to avoid conflict with reserved Windows filenames:
+    lambda x: f"_{x}"
+    if platform.system() == "Windows"
+    and x
+    and x.split(".")[0].upper() in _reserved_windows_filenames
+    else x,
+)
+
+
+def sanitize_filename(filename: str) -> str:
+    """Sanitize `filename` in a consistent cross-platform way.
+
+    Args:
+        filename: The name of the file to sanitize - not the full path.
+
+    Returns:
+        The provided filename after a series of santitization steps. It will
+        only contain ASCII characters. If necessary on Windows, the filename
+        will be prefixed by an underscore to avoid conflict with reserved
+        Windows file names.
+    """
+    return functools.reduce(
+        lambda x, y: y(x),  # noqa: WPS442
+        _sanitize_filename_transformations,
+        filename,
+    )


Test patch: