diff --git a/src/launchpad/artifacts/android/aab.py b/src/launchpad/artifacts/android/aab.py index 332c191b..a2869d28 100644 --- a/src/launchpad/artifacts/android/aab.py +++ b/src/launchpad/artifacts/android/aab.py @@ -19,7 +19,7 @@ from launchpad.utils.logging import get_logger from ..artifact import AndroidArtifact -from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path +from ..providers.zip_provider import UnsafePathError, ZipProvider from .apk import APK from .manifest.manifest import AndroidManifest from .manifest.proto_xml import ProtoXmlUtils @@ -156,11 +156,8 @@ def get_app_icon(self) -> bytes | None: logger.info("No icon path found in manifest") return None - base_dir = self._extract_dir / "base" - if not is_safe_path(base_dir, icon_path_str): - raise UnsafePathError(f"Unsafe icon path in manifest: {icon_path_str}") - - icon_path = base_dir / icon_path_str + base_dir = self._extract_dir.child("base") + icon_path = base_dir.resolve(icon_path_str) if not icon_path.exists(): logger.info(f"Icon not found in AAB: {icon_path_str}") @@ -171,7 +168,7 @@ def get_app_icon(self) -> bytes | None: try: proto_res_tables = self.get_resource_tables() - proto_xml_drawable_parser = ProtoXmlDrawableParser(self._extract_dir / "base", proto_res_tables) + proto_xml_drawable_parser = ProtoXmlDrawableParser(self._extract_dir.child("base"), proto_res_tables) icon = proto_xml_drawable_parser.render_from_path(icon_path) if icon: diff --git a/src/launchpad/artifacts/android/apk.py b/src/launchpad/artifacts/android/apk.py index 436180f8..948f732f 100644 --- a/src/launchpad/artifacts/android/apk.py +++ b/src/launchpad/artifacts/android/apk.py @@ -18,7 +18,8 @@ from ...parsers.android.dex.types import ClassDefinition from ...utils.logging import get_logger from ..artifact import AndroidArtifact -from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path +from ..providers.safe_directory import SafeDirectory +from ..providers.zip_provider import UnsafePathError, ZipProvider from .manifest.axml import AxmlUtils from .manifest.manifest import AndroidManifest from .resources.binary import BinaryResourceTable @@ -109,7 +110,7 @@ def get_class_definitions(self) -> list[ClassDefinition]: return self._class_definitions - def get_extract_path(self) -> Path: + def get_extract_path(self) -> SafeDirectory: return self._extract_dir def get_apksigner_certs(self) -> str: @@ -128,10 +129,7 @@ def get_app_icon(self) -> bytes | None: logger.info("No icon path found in manifest") return None - if not is_safe_path(self._extract_dir, icon_path_str): - raise UnsafePathError(f"Unsafe icon path in manifest: {icon_path_str}") - - icon_path = self._extract_dir / icon_path_str + icon_path = self._extract_dir.resolve(icon_path_str) if not icon_path.exists(): logger.info(f"Icon not found in APK: {icon_path_str}") diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index 608daa31..fcf5666e 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -17,7 +17,8 @@ from launchpad.utils.logging import get_logger from ..artifact import AppleArtifact -from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path +from ..providers.safe_directory import SafeDirectory +from ..providers.zip_provider import ZipProvider logger = get_logger(__name__) @@ -74,7 +75,7 @@ def __init__(self, path: Path) -> None: self._binary_uuid_cache: dict[Path, str] = {} self._lief_cache: dict[Path, lief.MachO.FatBinary] = {} - def get_extract_dir(self) -> Path: + def get_extract_dir(self) -> SafeDirectory: return self._extract_dir @sentry_sdk.trace @@ -127,16 +128,15 @@ def get_app_icon(self) -> bytes | None: logger.warning("No icon files found in CFBundleIconFiles") return None - app_bundle_path = self.get_app_bundle_path() + app_bundle = SafeDirectory(self.get_app_bundle_path()) for icon_name in icon_info.primary_icon_files: - if not is_safe_path(app_bundle_path, icon_name): - raise UnsafePathError(f"Unsafe icon name in plist: {icon_name}") + app_bundle.resolve(icon_name) # iOS lists base names without extensions or resolution modifiers (@2x, @3x, ~ipad) # Search for files matching the base name with any suffix # e.g., "AppIcon60x60" matches "AppIcon60x60@2x.png" or "AppIcon60x60.png" - matching_files = list(app_bundle_path.glob(f"{icon_name}*.png")) + matching_files = list(app_bundle.glob(f"{icon_name}*.png")) if not matching_files: continue diff --git a/src/launchpad/artifacts/providers/exceptions.py b/src/launchpad/artifacts/providers/exceptions.py new file mode 100644 index 00000000..04bba3e4 --- /dev/null +++ b/src/launchpad/artifacts/providers/exceptions.py @@ -0,0 +1,10 @@ +class UnreasonableZipError(ValueError): + """Raised when a zip file exceeds reasonable limits.""" + + pass + + +class UnsafePathError(ValueError): + """Raised when a zip file contains unsafe path entries that could lead to path traversal attacks.""" + + pass diff --git a/src/launchpad/artifacts/providers/safe_directory.py b/src/launchpad/artifacts/providers/safe_directory.py new file mode 100644 index 00000000..03d30e66 --- /dev/null +++ b/src/launchpad/artifacts/providers/safe_directory.py @@ -0,0 +1,90 @@ +import os + +from pathlib import Path +from typing import Generator + +from .exceptions import UnsafePathError + + +class SafeDirectory: + """A directory wrapper that validates untrusted paths stay within bounds. + + Use .resolve(untrusted) for attacker-controlled input (manifest values, + plist entries, zip member names). Trusted Path operations (glob, rglob, + iterdir, /, etc.) are delegated directly. + """ + + def __init__(self, base: Path) -> None: + self._base = base.resolve() + + @property + def path(self) -> Path: + return self._base + + def resolve(self, untrusted: str) -> Path: + """Resolve an untrusted path within this directory. + + Raises UnsafePathError if the resolved path escapes the base. + """ + try: + target = (self._base / untrusted).resolve() + except RuntimeError: + raise UnsafePathError(f"Path traversal attempt: {untrusted}") + if not target.is_relative_to(self._base): + raise UnsafePathError(f"Path traversal attempt: {untrusted}") + return target + + def child(self, untrusted: str) -> "SafeDirectory": + """Return a new SafeDirectory scoped to a validated subdirectory.""" + return SafeDirectory(self.resolve(untrusted)) + + # -- Trusted Path delegations -- + + def __truediv__(self, other: str | os.PathLike[str]) -> Path: + return self._base / other + + def __str__(self) -> str: + return str(self._base) + + def __repr__(self) -> str: + return f"SafeDirectory({self._base})" + + def __fspath__(self) -> str: + return str(self._base) + + def glob(self, pattern: str) -> Generator[Path, None, None]: + return self._base.glob(pattern) + + def rglob(self, pattern: str) -> Generator[Path, None, None]: + return self._base.rglob(pattern) + + def iterdir(self) -> Generator[Path, None, None]: + return self._base.iterdir() + + def exists(self) -> bool: + return self._base.exists() + + def is_dir(self) -> bool: + return self._base.is_dir() + + def is_file(self) -> bool: + return self._base.is_file() + + def relative_to(self, other: str | os.PathLike[str]) -> Path: + return self._base.relative_to(other) + + @property + def name(self) -> str: + return self._base.name + + @property + def stem(self) -> str: + return self._base.stem + + @property + def suffix(self) -> str: + return self._base.suffix + + @property + def parent(self) -> Path: + return self._base.parent diff --git a/src/launchpad/artifacts/providers/zip_provider.py b/src/launchpad/artifacts/providers/zip_provider.py index 8ae8d72a..599859a4 100644 --- a/src/launchpad/artifacts/providers/zip_provider.py +++ b/src/launchpad/artifacts/providers/zip_provider.py @@ -6,24 +6,15 @@ from launchpad.utils.file_utils import cleanup_directory, create_temp_directory from launchpad.utils.logging import get_logger +from .exceptions import UnreasonableZipError, UnsafePathError +from .safe_directory import SafeDirectory + logger = get_logger(__name__) DEFAULT_MAX_FILE_COUNT = 100000 DEFAULT_MAX_UNCOMPRESSED_SIZE = 10 * 1024 * 1024 * 1024 -class UnreasonableZipError(ValueError): - """Raised when a zip file exceeds reasonable limits.""" - - pass - - -class UnsafePathError(ValueError): - """Raised when a zip file contains unsafe path entries that could lead to path traversal attacks.""" - - pass - - def check_reasonable_zip( zf: zipfile.ZipFile, max_file_count: int = DEFAULT_MAX_FILE_COUNT, @@ -80,13 +71,13 @@ def __init__(self, path: Path) -> None: self.path = path self._temp_dirs: List[Path] = [] - def extract_to_temp_directory(self) -> Path: + def extract_to_temp_directory(self) -> SafeDirectory: """Extract the zip contents to a temporary directory. Creates a temporary directory and extracts the zip contents to it. A new temporary directory is created for each call to this method. Returns: - Path to the temporary directory containing extracted files + SafeDirectory wrapping the temporary directory containing extracted files """ temp_dir = create_temp_directory("zip-extract-") self._temp_dirs.append(temp_dir) @@ -94,7 +85,7 @@ def extract_to_temp_directory(self) -> Path: self._safe_extract(str(self.path), str(temp_dir)) logger.debug(f"Extracted zip contents to {temp_dir}") - return temp_dir + return SafeDirectory(temp_dir) def _safe_extract(self, zip_path: str, extract_path: str): """Extract the zip contents to a temporary directory, ensuring that the paths are safe from path traversal attacks. diff --git a/src/launchpad/parsers/android/icon/binary_xml_drawable_parser.py b/src/launchpad/parsers/android/icon/binary_xml_drawable_parser.py index 2a9f075c..ff71b1f2 100644 --- a/src/launchpad/parsers/android/icon/binary_xml_drawable_parser.py +++ b/src/launchpad/parsers/android/icon/binary_xml_drawable_parser.py @@ -1,9 +1,8 @@ from __future__ import annotations -from pathlib import Path - from launchpad.artifacts.android.manifest.axml import AxmlUtils from launchpad.artifacts.android.resources.binary import BinaryResourceTable +from launchpad.artifacts.providers.safe_directory import SafeDirectory from launchpad.parsers.android.binary.android_binary_parser import AndroidBinaryParser from launchpad.parsers.android.binary.types import XmlNode from launchpad.utils.logging import get_logger @@ -16,7 +15,7 @@ class BinaryXmlDrawableParser(IconParser): def __init__( self, - extract_dir: Path, + extract_dir: SafeDirectory, binary_res_tables: list[BinaryResourceTable], ) -> None: super().__init__(extract_dir) diff --git a/src/launchpad/parsers/android/icon/icon_parser.py b/src/launchpad/parsers/android/icon/icon_parser.py index 153b6e1d..004bd218 100644 --- a/src/launchpad/parsers/android/icon/icon_parser.py +++ b/src/launchpad/parsers/android/icon/icon_parser.py @@ -10,7 +10,8 @@ from PIL import Image, ImageDraw -from launchpad.artifacts.providers.zip_provider import UnsafePathError, is_safe_path +from launchpad.artifacts.providers.safe_directory import SafeDirectory +from launchpad.artifacts.providers.zip_provider import UnsafePathError from launchpad.parsers.android.binary.types import XmlNode from launchpad.utils.logging import get_logger @@ -57,8 +58,8 @@ class GradientInfo: class IconParser: - def __init__(self, extract_dir: Path) -> None: - self.extract_dir = extract_dir + def __init__(self, extract_dir: SafeDirectory) -> None: + self._safe_dir = extract_dir def _get_attr_value(self, attributes: list, name: str, required: bool = False) -> str | None: raise NotImplementedError @@ -461,23 +462,19 @@ def _interpolate_gradient_color( return None def _find_file(self, filename: str) -> Path | None: - if not is_safe_path(self.extract_dir, filename): - raise UnsafePathError(f"Unsafe file path in drawable: {filename}") - - # Try exact match first - exact_path = self.extract_dir / filename + exact_path = self._safe_dir.resolve(filename) if exact_path.exists(): return exact_path # Try with res/ prefix if not filename.startswith("res/"): - res_path = self.extract_dir / "res" / filename + res_path = self._safe_dir.resolve("res/" + filename) if res_path.exists(): return res_path # Search recursively (last resort) filename_lower = filename.lower() - for file_path in self.extract_dir.rglob("*"): + for file_path in self._safe_dir.rglob("*"): if file_path.is_file() and str(file_path).lower().endswith(filename_lower): return file_path diff --git a/src/launchpad/parsers/android/icon/proto_xml_drawable_parser.py b/src/launchpad/parsers/android/icon/proto_xml_drawable_parser.py index 4559b31f..3d661930 100644 --- a/src/launchpad/parsers/android/icon/proto_xml_drawable_parser.py +++ b/src/launchpad/parsers/android/icon/proto_xml_drawable_parser.py @@ -1,7 +1,5 @@ from __future__ import annotations -from pathlib import Path - from launchpad.artifacts.android.manifest.proto_xml import ProtoXmlUtils from launchpad.artifacts.android.resources.proto import ProtobufResourceTable from launchpad.artifacts.android.resources.protos.Resources_pb2 import ( # type: ignore[attr-defined] @@ -13,6 +11,7 @@ from launchpad.artifacts.android.resources.protos.Resources_pb2 import ( XmlNode as PbXmlNode, # type: ignore[attr-defined] ) +from launchpad.artifacts.providers.safe_directory import SafeDirectory from launchpad.parsers.android.binary.types import ( NodeType, TypedValue, @@ -27,7 +26,7 @@ class ProtoXmlDrawableParser(IconParser): - def __init__(self, extract_dir: Path, proto_res_tables: list[ProtobufResourceTable]) -> None: + def __init__(self, extract_dir: SafeDirectory, proto_res_tables: list[ProtobufResourceTable]) -> None: super().__init__(extract_dir) self.proto_res_tables = proto_res_tables diff --git a/src/launchpad/size/analyzers/android.py b/src/launchpad/size/analyzers/android.py index 24f57126..09656c4b 100644 --- a/src/launchpad/size/analyzers/android.py +++ b/src/launchpad/size/analyzers/android.py @@ -276,7 +276,7 @@ def _get_hermes_reports(self, apks: list[APK]) -> dict[str, HermesReport]: all_reports: dict[str, HermesReport] = {} for apk in apks: extract_path = apk.get_extract_path() - apk_reports = make_hermes_reports(extract_path) + apk_reports = make_hermes_reports(extract_path.path) for relative_path, report in apk_reports.items(): if relative_path in all_reports: logger.warning(f"Duplicate Hermes report key found: {relative_path}, overwriting") diff --git a/tests/unit/artifacts/apple/test_zipped_xcarchive.py b/tests/unit/artifacts/apple/test_zipped_xcarchive.py index c3b1d6f7..97d10a0f 100644 --- a/tests/unit/artifacts/apple/test_zipped_xcarchive.py +++ b/tests/unit/artifacts/apple/test_zipped_xcarchive.py @@ -5,6 +5,7 @@ from unittest.mock import patch from launchpad.artifacts.apple.zipped_xcarchive import ZippedXCArchive +from launchpad.artifacts.providers.safe_directory import SafeDirectory class TestZippedXCArchive: @@ -12,7 +13,7 @@ class TestZippedXCArchive: def test_top_level_asset_catalog_parsing(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) + tmpdir_path = Path(tmpdir).resolve() xcarchive_dir = tmpdir_path / "Test.xcarchive" parsed_assets_dir = xcarchive_dir / "ParsedAssets" / "Products" / "Applications" / "Test.app" @@ -36,7 +37,7 @@ def test_top_level_asset_catalog_parsing(self) -> None: with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): archive = ZippedXCArchive(Path("dummy")) - archive._extract_dir = tmpdir_path + archive._extract_dir = SafeDirectory(tmpdir_path) with patch.object( archive, @@ -56,7 +57,7 @@ def test_top_level_asset_catalog_parsing(self) -> None: def test_nested_bundle_asset_catalog_parsing(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) + tmpdir_path = Path(tmpdir).resolve() xcarchive_dir = tmpdir_path / "Test.xcarchive" parsed_assets_dir = xcarchive_dir / "ParsedAssets" / "Products" / "Applications" / "Test.app" @@ -81,7 +82,7 @@ def test_nested_bundle_asset_catalog_parsing(self) -> None: with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): archive = ZippedXCArchive(Path("dummy")) - archive._extract_dir = tmpdir_path + archive._extract_dir = SafeDirectory(tmpdir_path) with patch.object( archive, @@ -102,7 +103,7 @@ def test_nested_bundle_asset_catalog_parsing(self) -> None: def test_framework_bundle_asset_catalog_parsing(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: - tmpdir_path = Path(tmpdir) + tmpdir_path = Path(tmpdir).resolve() xcarchive_dir = tmpdir_path / "Test.xcarchive" parsed_assets_dir = xcarchive_dir / "ParsedAssets" / "Products" / "Applications" / "Test.app" @@ -127,7 +128,7 @@ def test_framework_bundle_asset_catalog_parsing(self) -> None: with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): archive = ZippedXCArchive(Path("dummy")) - archive._extract_dir = tmpdir_path + archive._extract_dir = SafeDirectory(tmpdir_path) with patch.object( archive, diff --git a/tests/unit/artifacts/providers/test_zip_provider.py b/tests/unit/artifacts/providers/test_zip_provider.py index 458c9ec8..cd92b76c 100644 --- a/tests/unit/artifacts/providers/test_zip_provider.py +++ b/tests/unit/artifacts/providers/test_zip_provider.py @@ -5,6 +5,7 @@ import pytest +from launchpad.artifacts.providers.safe_directory import SafeDirectory from launchpad.artifacts.providers.zip_provider import ( UnreasonableZipError, UnsafePathError, @@ -32,38 +33,37 @@ def test_init(self, hackernews_xcarchive: Path) -> None: def test_extract_to_temp_directory_ios(self, hackernews_xcarchive: Path) -> None: provider = ZipProvider(hackernews_xcarchive) - temp_dir = provider.extract_to_temp_directory() + safe_dir = provider.extract_to_temp_directory() - assert temp_dir.exists() - assert temp_dir.is_dir() + assert isinstance(safe_dir, SafeDirectory) + assert safe_dir.exists() + assert safe_dir.is_dir() assert len(provider._temp_dirs) == 1 - assert provider._temp_dirs[0] == temp_dir - extracted_files = list(temp_dir.rglob("*")) + extracted_files = list(safe_dir.rglob("*")) assert len(extracted_files) > 0 def test_extract_to_temp_directory_android(self, zipped_apk: Path) -> None: provider = ZipProvider(zipped_apk) - temp_dir = provider.extract_to_temp_directory() + safe_dir = provider.extract_to_temp_directory() - assert temp_dir.exists() - assert temp_dir.is_dir() + assert isinstance(safe_dir, SafeDirectory) + assert safe_dir.exists() + assert safe_dir.is_dir() assert len(provider._temp_dirs) == 1 - extracted_files = list(temp_dir.rglob("*")) + extracted_files = list(safe_dir.rglob("*")) assert len(extracted_files) > 0 def test_multiple_extractions(self, hackernews_xcarchive: Path) -> None: provider = ZipProvider(hackernews_xcarchive) - temp_dir1 = provider.extract_to_temp_directory() - temp_dir2 = provider.extract_to_temp_directory() + safe_dir1 = provider.extract_to_temp_directory() + safe_dir2 = provider.extract_to_temp_directory() - assert temp_dir1 != temp_dir2 + assert safe_dir1.path != safe_dir2.path assert len(provider._temp_dirs) == 2 - assert temp_dir1 in provider._temp_dirs - assert temp_dir2 in provider._temp_dirs - assert temp_dir1.exists() - assert temp_dir2.exists() + assert safe_dir1.exists() + assert safe_dir2.exists() def test_safe_extract_blocks_traversal(self, malicious_zip: Path) -> None: provider = ZipProvider(malicious_zip) @@ -109,6 +109,69 @@ def test_absolute_paths(self) -> None: assert not is_safe_path(base_dir, "/tmp/other/file.txt") +class TestSafeDirectory: + def test_resolve_valid_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + assert safe_dir.resolve("file.txt") == Path(tmpdir).resolve() / "file.txt" + assert safe_dir.resolve("subdir/file.txt") == Path(tmpdir).resolve() / "subdir" / "file.txt" + + def test_resolve_rejects_traversal(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + with pytest.raises(UnsafePathError): + safe_dir.resolve("../file.txt") + with pytest.raises(UnsafePathError): + safe_dir.resolve("../../etc/passwd") + with pytest.raises(UnsafePathError): + safe_dir.resolve("subdir/../../file.txt") + + def test_resolve_rejects_absolute_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + with pytest.raises(UnsafePathError): + safe_dir.resolve("/etc/passwd") + + def test_child_creates_scoped_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + (base / "sub").mkdir() + safe_dir = SafeDirectory(base) + child = safe_dir.child("sub") + assert child.path == base.resolve() / "sub" + with pytest.raises(UnsafePathError): + child.resolve("../../etc/passwd") + + def test_path_property(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + assert safe_dir.path == Path(tmpdir).resolve() + + def test_truediv_delegates_to_path(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + result = safe_dir / "subdir" / "file.txt" + assert result == Path(tmpdir).resolve() / "subdir" / "file.txt" + assert isinstance(result, Path) + + def test_glob_and_rglob(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + (base / "a.txt").write_text("a") + (base / "sub").mkdir() + (base / "sub" / "b.txt").write_text("b") + safe_dir = SafeDirectory(base) + assert len(list(safe_dir.glob("*.txt"))) == 1 + assert len(list(safe_dir.rglob("*.txt"))) == 2 + + def test_fspath(self) -> None: + import os + + with tempfile.TemporaryDirectory() as tmpdir: + safe_dir = SafeDirectory(Path(tmpdir)) + assert os.fspath(safe_dir) == str(Path(tmpdir).resolve()) + + class TestCheckReasonableZip: def test_reasonable_zip_passes(self, hackernews_xcarchive: Path) -> None: with zipfile.ZipFile(hackernews_xcarchive, "r") as zf: @@ -137,10 +200,10 @@ def test_extract_zstd_zip(self) -> None: try: provider = ZipProvider(temp_path) - temp_dir = provider.extract_to_temp_directory() + safe_dir = provider.extract_to_temp_directory() - assert temp_dir.exists() - assert (temp_dir / "test.txt").exists() - assert (temp_dir / "test.txt").read_text() == "content" + assert safe_dir.exists() + assert (safe_dir / "test.txt").exists() + assert (safe_dir / "test.txt").read_text() == "content" finally: temp_path.unlink(missing_ok=True) diff --git a/tests/unit/parsers/android/icon/test_icon_parser.py b/tests/unit/parsers/android/icon/test_icon_parser.py index 6d50ca96..e1722a29 100644 --- a/tests/unit/parsers/android/icon/test_icon_parser.py +++ b/tests/unit/parsers/android/icon/test_icon_parser.py @@ -4,6 +4,7 @@ import pytest +from launchpad.artifacts.providers.safe_directory import SafeDirectory from launchpad.artifacts.providers.zip_provider import UnsafePathError from launchpad.parsers.android.icon.icon_parser import IconParser @@ -11,8 +12,7 @@ class TestIconParserFindFile: def test_find_file_rejects_path_traversal(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: - extract_dir = Path(tmpdir) - parser = IconParser(extract_dir) + parser = IconParser(SafeDirectory(Path(tmpdir))) with pytest.raises(UnsafePathError): parser._find_file("../../etc/passwd")