"""Config bundle creator and extractor for ESPHome. A bundle is a self-contained .tar.gz archive containing a YAML config and every local file it depends on. Bundles can be created from a config and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` """ from __future__ import annotations from dataclasses import dataclass, field from enum import StrEnum import io import json import logging from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath import re import shutil import tarfile from typing import Any from esphome import const, yaml_util from esphome.const import ( BUNDLE_EXTENSION, CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_INCLUDES, CONF_INCLUDES_C, CONF_PATH, CONF_SOURCE, CONF_TYPE, ) from esphome.core import CORE, EsphomeError from esphome.util import filter_yaml_files _LOGGER = logging.getLogger(__name__) DOMAIN = "bundle" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB # Directories preserved across bundle extractions (build caches) _PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio") _BUNDLE_STAGING_DIR = ".bundle_staging" class ManifestKey(StrEnum): """Keys used in bundle manifest.json.""" MANIFEST_VERSION = "manifest_version" ESPHOME_VERSION = "esphome_version" CONFIG_FILENAME = "config_filename" CONFIG_DIR = "config_dir" FILES = "files" HAS_SECRETS = "has_secrets" # String prefixes that are never local file paths _NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<") # File extensions recognized when resolving relative path strings. # A relative string with one of these extensions is resolved against the # config directory and included if the file exists. _KNOWN_FILE_EXTENSIONS = frozenset( { # Fonts ".ttf", ".otf", ".woff", ".woff2", ".pcf", ".bdf", # Images ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg", ".ico", ".webp", # Certificates ".pem", ".crt", ".key", ".der", ".p12", ".pfx", # C/C++ includes ".h", ".hpp", ".c", ".cpp", ".ino", # Web assets ".css", ".js", ".html", } ) # Matches !secret references in YAML text. An optional surrounding # quote pair around the key is allowed and ignored: YAML treats # ``!secret 'foo'`` and ``!secret foo`` as the same key. This is # intentionally a simple regex scan rather than a YAML parse — it may # match inside comments or multi-line strings, which is the conservative # direction (include more secrets rather than fewer). _SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: """Scan YAML files for ``!secret `` references.""" keys: set[str] = set() for fpath in yaml_files: try: text = fpath.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue for match in _SECRET_RE.finditer(text): keys.add(match.group(1)) return keys @dataclass class BundleData: """Files components asked to include, keyed under DOMAIN in CORE.data.""" extra_files: list[Path] = field(default_factory=list) # Directories whose YAML files are scanned for !secret references but # never bundled, e.g. git package checkouts the builder re-fetches. secret_scan_dirs: set[Path] = field(default_factory=set) # Original config dir parsed from an extracted bundle's manifest.json, # kept in the path flavor of the machine the bundle was created on. # The checked flag makes the manifest lookup happen at most once per run; # CORE.data is cleared between runs. original_config_dir: PurePath | None = None original_config_dir_checked: bool = False def _get_data() -> BundleData: if DOMAIN not in CORE.data: CORE.data[DOMAIN] = BundleData() return CORE.data[DOMAIN] def add_bundle_file(path: Path) -> None: """Register a file that a bundle must include. Bundle discovery walks the validated config, so it only finds files the config names. Components call this during validation for files it cannot see, such as a file that is referenced from inside another file. A relative path is taken as relative to the config directory. Files outside the config directory are skipped when the bundle is built. """ _get_data().extra_files.append(CORE.relative_config_path(path)) def add_secret_scan_dir(path: Path) -> None: """Register a directory to scan for ``!secret`` references when bundling. The directory's files are not added to the bundle. Components call this for YAML the build consumes without bundling it — such as git-fetched packages, which the builder re-fetches — so the secrets those files reference are still shipped in the filtered secrets file. A relative path is taken as relative to the config directory. """ if not path.is_absolute(): path = CORE.relative_config_path(path) _get_data().secret_scan_dirs.add(path) def _secret_scan_yaml_files() -> list[Path]: """Return the YAML files inside registered secret-scan directories.""" return filter_yaml_files( f for scan_dir in _get_data().secret_scan_dirs for f in yaml_util.find_files(scan_dir, "*") ) # Windows paths start with a drive letter or contain backslashes; POSIX # paths do neither in practice, so this is how the flavor of a recorded # path string is recognized on any host. _WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") def _path_flavor(value: str) -> type[PurePath]: """Pick the pure path class matching the flavor ``value`` was written in.""" if "\\" in value or _WINDOWS_DRIVE_RE.match(value): return PureWindowsPath return PurePosixPath def _load_original_config_dir() -> PurePath | None: """Read the original config dir from an extracted bundle's manifest. Returns None when the current config dir is not an extracted bundle or the manifest does not record the original config dir. """ manifest_path = CORE.config_dir / MANIFEST_FILENAME try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except FileNotFoundError: # The common case: this config dir is not an extracted bundle. return None except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: # A manifest.json is present but unreadable or malformed. Say so # instead of letting it look identical to "not a bundle". _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err) return None if not isinstance(manifest, dict): return None # A manifest.json in the config dir does not have to be ours. Only trust # one that looks like a bundle manifest for exactly this config file. version = manifest.get(ManifestKey.MANIFEST_VERSION) if not isinstance(version, int) or version < 1: return None if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name: return None config_dir = manifest.get(ManifestKey.CONFIG_DIR) if not isinstance(config_dir, str) or not config_dir: return None return _path_flavor(config_dir)(config_dir) def remap_bundle_path(value: str) -> Path | None: """Remap an absolute path from the machine a bundle was created on. A bundled config may reference files by absolute path. The referenced files ship inside the bundle at their config-relative locations, but the YAML text is copied verbatim, so after extraction on another machine the absolute reference points at a path that only existed on the creating machine. The bundle manifest records that machine's config dir; when ``value`` names a path that lived under it, return the corresponding file next to the extracted config. ``value`` is the raw path string from the config. It is parsed with the original machine's path flavor, so a bundle created on Windows remaps on a POSIX build server and vice versa. Returns None when not compiling an extracted bundle, when ``value`` was not under the original config dir, or when the bundle does not contain the file. """ data = _get_data() if not data.original_config_dir_checked: data.original_config_dir_checked = True data.original_config_dir = _load_original_config_dir() original_dir = data.original_config_dir if original_dir is None: return None path = type(original_dir)(value) if not path.is_absolute(): return None try: rel = path.relative_to(original_dir) except ValueError: return None # relative_to is lexical, so ".." segments survive it. Refuse them: the # remapped file must land strictly inside the extracted config tree. if ".." in rel.parts: return None remapped = CORE.relative_config_path(Path(*rel.parts)) if not remapped.exists(): return None return remapped @dataclass class BundleFile: """A file to include in the bundle.""" path: str # Relative path inside the archive source: Path # Absolute path on disk @dataclass class BundleResult: """Result of creating a bundle.""" data: bytes manifest: dict[str, Any] files: list[BundleFile] @dataclass class BundleManifest: """Parsed and validated bundle manifest.""" manifest_version: int esphome_version: str config_filename: str files: list[str] has_secrets: bool config_dir: str | None = None class ConfigBundleCreator: """Creates a self-contained bundle from an ESPHome config.""" def __init__(self, config: dict[str, Any]) -> None: self._config = config self._config_dir = Path(CORE.config_dir).resolve() self._config_path = Path(CORE.config_path).resolve() self._files: list[BundleFile] = [] self._seen_paths: set[Path] = set() self._secrets_paths: set[Path] = set() def discover_files(self) -> list[BundleFile]: """Discover all files needed for the bundle.""" self._files = [] self._seen_paths = set() self._secrets_paths = set() # The main config file self._add_file(self._config_path) # Phase 1: YAML includes (tracked during config loading) self._discover_yaml_includes() # Phase 2: Component-referenced files from validated config self._discover_component_files() return list(self._files) def create_bundle(self) -> BundleResult: """Create the bundle archive.""" files = self.discover_files() # Determine which secret keys are actually referenced by the # bundled YAML files so we only ship those, not the entire # secrets.yaml which may contain secrets for other devices. yaml_sources = [ bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") ] yaml_sources.extend(_secret_scan_yaml_files()) used_secret_keys = _find_used_secret_keys(yaml_sources) filtered_secrets = self._build_filtered_secrets(used_secret_keys) has_secrets = bool(filtered_secrets) if has_secrets: _LOGGER.warning( "Bundle contains secrets (e.g. Wi-Fi passwords). " "Do not share it with untrusted parties." ) manifest = self._build_manifest(files, has_secrets=has_secrets) buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w:gz") as tar: # Add manifest first manifest_data = json.dumps(manifest, indent=2).encode("utf-8") _add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data) # Add filtered secrets files for rel_path, data in sorted(filtered_secrets.items()): _add_bytes_to_tar(tar, rel_path, data) # Add files in sorted order for determinism, skipping secrets # files which were already added above with filtered content for bf in sorted(files, key=lambda f: f.path): if bf.source in self._secrets_paths: continue self._add_to_tar(tar, bf) return BundleResult(data=buf.getvalue(), manifest=manifest, files=files) def _add_file(self, abs_path: Path) -> bool: """Add a file to the bundle. Returns False if already added.""" abs_path = abs_path.resolve() if abs_path in self._seen_paths: return False if not abs_path.is_file(): _LOGGER.warning("Bundle: skipping missing file %s", abs_path) return False rel_path = self._relative_to_config_dir(abs_path) if rel_path is None: _LOGGER.warning( "Bundle: skipping file outside config directory: %s", abs_path ) return False self._seen_paths.add(abs_path) self._files.append(BundleFile(path=rel_path, source=abs_path)) return True def _add_directory(self, abs_path: Path) -> None: """Recursively add all files in a directory.""" abs_path = abs_path.resolve() if not abs_path.is_dir(): _LOGGER.warning("Bundle: skipping missing directory %s", abs_path) return for child in sorted(abs_path.rglob("*")): if child.is_file() and "__pycache__" not in child.parts: self._add_file(child) def _relative_to_config_dir(self, abs_path: Path) -> str | None: """Get a path relative to the config directory. Returns None if outside. Always uses forward slashes for consistency in tar archives. """ try: return abs_path.relative_to(self._config_dir).as_posix() except ValueError: return None def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a fresh re-parse and force-loads every deferred ``IncludeFile`` so that *all* potentially-reachable includes are captured (even branches not selected by local substitutions). Bundles are meant to be compiled on another system where command-line substitution overrides may choose a different branch — e.g. ``!include network/${eth_model}/config.yaml`` must ship every candidate so the remote build can pick any one. """ discovered = yaml_util.discover_user_yaml_files(self._config_path) self._secrets_paths.update(discovered.secrets) # A !secret inside a file this re-parse does not reach (for example # a git-fetched package the builder re-fetches) still resolves # against the config-dir secrets.yaml at build time, so always # consider that file; filtering no-ops when no key matches. default_secrets = self._config_dir / yaml_util.SECRET_YAML if default_secrets.is_file(): self._secrets_paths.add(default_secrets.resolve()) config_resolved = self._config_path.resolve() for fpath in discovered.files: if fpath == config_resolved: continue # Already added as config self._add_file(fpath) def _discover_component_files(self) -> None: """Walk the validated config for file references. Uses a generic recursive walk to find file paths instead of hardcoding per-component knowledge about config dict formats. After validation, components typically resolve paths to absolute using CORE.relative_config_path() or cv.file_(). Relative paths with known file extensions are also resolved and checked. Core ESPHome concepts that use relative paths or directories are handled explicitly. Files the config does not name at all are registered by their component with add_bundle_file(). """ config = self._config # Generic walk: find all file paths in the validated config self._walk_config_for_files(config) # Files registered by components during validation for extra_file in _get_data().extra_files: self._add_file(extra_file) # --- Core ESPHome concepts needing explicit handling --- # esphome.includes / includes_c - can be relative paths and directories esphome_conf = config.get(CONF_ESPHOME, {}) for include_path in esphome_conf.get(CONF_INCLUDES, []): resolved = _resolve_include_path(include_path) if resolved is None: continue if resolved.is_dir(): self._add_directory(resolved) else: self._add_file(resolved) for include_path in esphome_conf.get(CONF_INCLUDES_C, []): resolved = _resolve_include_path(include_path) if resolved is not None: self._add_file(resolved) # external_components with source: local - directories for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []): source = ext_conf.get(CONF_SOURCE, {}) if not isinstance(source, dict): continue if source.get(CONF_TYPE) != "local": continue path = source.get(CONF_PATH) if not path: continue p = Path(path) if not p.is_absolute(): p = CORE.relative_config_path(p) self._add_directory(p) def _walk_config_for_files(self, obj: Any) -> None: """Recursively walk the config dict looking for file path references.""" if isinstance(obj, dict): for value in obj.values(): self._walk_config_for_files(value) elif isinstance(obj, (list, tuple)): for item in obj: self._walk_config_for_files(item) elif isinstance(obj, Path): if obj.is_absolute() and obj.is_file(): self._add_file(obj) elif isinstance(obj, str): self._check_string_path(obj) def _check_string_path(self, value: str) -> None: """Check if a string value is a local file reference.""" # Fast exits for strings that cannot be file paths if len(value) < 2 or "\n" in value: return if value.startswith(_NON_PATH_PREFIXES): return # File paths must contain a path separator or a dot (for extension) if "/" not in value and "\\" not in value and "." not in value: return p = Path(value) # Absolute path - check if it points to an existing file if p.is_absolute(): if p.is_file(): self._add_file(p) return # Relative path with a known file extension - likely a component # validator that forgot to resolve to absolute via cv.file_() or # CORE.relative_config_path(). Warn and try to resolve. if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS: _LOGGER.warning( "Bundle: non-absolute path in validated config: %s " "(component validator should return absolute paths)", value, ) resolved = CORE.relative_config_path(p) if resolved.is_file(): self._add_file(resolved) def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]: """Build filtered secrets files containing only the referenced keys. Returns a dict mapping relative archive path to YAML bytes. """ if not used_keys or not self._secrets_paths: return {} result: dict[str, bytes] = {} for secrets_path in self._secrets_paths: rel_path = self._relative_to_config_dir(secrets_path) if rel_path is None: continue try: all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False) except EsphomeError: _LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path) continue if not isinstance(all_secrets, dict): continue filtered = {k: v for k, v in all_secrets.items() if k in used_keys} if filtered: data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8") result[rel_path] = data return result def _build_manifest( self, files: list[BundleFile], *, has_secrets: bool ) -> dict[str, Any]: """Build the manifest.json content.""" return { ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, ManifestKey.ESPHOME_VERSION: const.__version__, ManifestKey.CONFIG_FILENAME: self._config_path.name, ManifestKey.CONFIG_DIR: str(self._config_dir), ManifestKey.FILES: [f.path for f in files], ManifestKey.HAS_SECRETS: has_secrets, } @staticmethod def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: """Add a BundleFile to the tar archive with deterministic metadata.""" with bf.source.open("rb") as f: _add_bytes_to_tar(tar, bf.path, f.read()) def extract_bundle( bundle_path: Path, target_dir: Path | None = None, ) -> Path: """Extract a bundle archive and return the path to the config YAML. Sanity checks reject path traversal, symlinks, absolute paths, and oversized archives to prevent accidental file overwrites or extraction outside the target directory. These are **not** a security boundary — bundles are assumed to come from the user's own machine or a trusted build pipeline. Args: bundle_path: Path to the .tar.gz bundle file. target_dir: Directory to extract into. If None, extracts next to the bundle file in a directory named after it. Returns: Absolute path to the extracted config YAML file. Raises: EsphomeError: If the bundle is invalid or extraction fails. """ bundle_path = bundle_path.resolve() if not bundle_path.is_file(): raise EsphomeError(f"Bundle file not found: {bundle_path}") if target_dir is None: target_dir = _default_target_dir(bundle_path) target_dir = target_dir.resolve() target_dir.mkdir(parents=True, exist_ok=True) # Read and validate the archive try: with tarfile.open(bundle_path, "r:gz") as tar: manifest = _read_manifest_from_tar(tar) _validate_tar_members(tar, target_dir) tar.extractall(path=target_dir, filter="data") except tarfile.TarError as err: raise EsphomeError(f"Failed to extract bundle: {err}") from err config_filename = manifest[ManifestKey.CONFIG_FILENAME] config_path = target_dir / config_filename if not config_path.is_file(): raise EsphomeError( f"Bundle manifest references config '{config_filename}' " f"but it was not found in the archive" ) return config_path def read_bundle_manifest(bundle_path: Path) -> BundleManifest: """Read and validate the manifest from a bundle without full extraction. Args: bundle_path: Path to the .tar.gz bundle file. Returns: Parsed BundleManifest. Raises: EsphomeError: If the manifest is missing, invalid, or version unsupported. """ try: with tarfile.open(bundle_path, "r:gz") as tar: manifest = _read_manifest_from_tar(tar) except tarfile.TarError as err: raise EsphomeError(f"Failed to read bundle: {err}") from err config_dir = manifest.get(ManifestKey.CONFIG_DIR) return BundleManifest( manifest_version=manifest[ManifestKey.MANIFEST_VERSION], esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), config_filename=manifest[ManifestKey.CONFIG_FILENAME], files=manifest.get(ManifestKey.FILES, []), has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), config_dir=config_dir if isinstance(config_dir, str) else None, ) def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]: """Read and validate manifest.json from an open tar archive.""" try: member = tar.getmember(MANIFEST_FILENAME) except KeyError: raise EsphomeError("Invalid bundle: missing manifest.json") from None f = tar.extractfile(member) if f is None: raise EsphomeError("Invalid bundle: manifest.json is not a regular file") if member.size > MAX_MANIFEST_SIZE: raise EsphomeError( f"Invalid bundle: manifest.json too large " f"({member.size} bytes, max {MAX_MANIFEST_SIZE})" ) try: manifest = json.loads(f.read()) except (json.JSONDecodeError, UnicodeDecodeError) as err: raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err # Version check version = manifest.get(ManifestKey.MANIFEST_VERSION) if version is None: raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'") if not isinstance(version, int) or version < 1: raise EsphomeError( f"Invalid bundle: manifest_version must be a positive integer, got {version!r}" ) if version > CURRENT_MANIFEST_VERSION: raise EsphomeError( f"Bundle manifest version {version} is newer than this ESPHome " f"version supports (max {CURRENT_MANIFEST_VERSION}). " f"Please upgrade ESPHome to compile this bundle." ) # Required fields if ManifestKey.CONFIG_FILENAME not in manifest: raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'") return manifest def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: """Sanity-check tar members to prevent mistakes and accidental overwrites. This is not a security boundary — bundles are created locally or come from a trusted build pipeline. The checks catch malformed archives and common mistakes (stray absolute paths, ``..`` components) that could silently overwrite unrelated files. """ total_size = 0 for member in tar.getmembers(): # Reject absolute paths (Unix and Windows) if member.name.startswith(("/", "\\")): raise EsphomeError( f"Invalid bundle: absolute path in archive: {member.name}" ) # Reject path traversal (split on both / and \ for cross-platform) parts = re.split(r"[/\\]", member.name) if ".." in parts: raise EsphomeError( f"Invalid bundle: path traversal in archive: {member.name}" ) # Reject symlinks if member.issym() or member.islnk(): raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}") # Ensure extraction stays within target_dir target_path = (target_dir / member.name).resolve() if not target_path.is_relative_to(target_dir): raise EsphomeError( f"Invalid bundle: file would extract outside target: {member.name}" ) # Track total decompressed size total_size += member.size if total_size > MAX_DECOMPRESSED_SIZE: raise EsphomeError( f"Invalid bundle: decompressed size exceeds " f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit" ) def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: """Add in-memory bytes to a tar archive with deterministic metadata.""" info = tarfile.TarInfo(name=name) info.size = len(data) info.mtime = 0 info.uid = 0 info.gid = 0 info.mode = 0o644 tar.addfile(info, io.BytesIO(data)) def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): return None # System include, not a local file p = Path(include_path) if not p.is_absolute(): p = CORE.relative_config_path(p) return p def _default_target_dir(bundle_path: Path) -> Path: """Compute the default extraction directory for a bundle.""" name = bundle_path.name if name.lower().endswith(BUNDLE_EXTENSION): name = name[: -len(BUNDLE_EXTENSION)] return bundle_path.parent / name def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None: """Move preserved build cache directories back into target_dir. If the bundle contained entries under a preserved directory name, the extracted copy is removed so the original cache always wins. """ for dirname, src in preserved.items(): dst = target_dir / dirname if dst.exists(): shutil.rmtree(dst) shutil.move(str(src), str(dst)) def prepare_bundle_for_compile( bundle_path: Path, target_dir: Path | None = None, ) -> Path: """Extract a bundle for compilation, preserving build caches. Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/ directories in the target if they already exist (for incremental builds). Args: bundle_path: Path to the .tar.gz bundle file. target_dir: Directory to extract into. Must be specified for build server use. Returns: Absolute path to the extracted config YAML file. """ bundle_path = bundle_path.resolve() if not bundle_path.is_file(): raise EsphomeError(f"Bundle file not found: {bundle_path}") if target_dir is None: target_dir = _default_target_dir(bundle_path) target_dir = target_dir.resolve() target_dir.mkdir(parents=True, exist_ok=True) preserved: dict[str, Path] = {} # Temporarily move preserved dirs out of the way staging = target_dir / _BUNDLE_STAGING_DIR for dirname in _PRESERVE_DIRS: src = target_dir / dirname if src.is_dir(): dst = staging / dirname dst.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src), str(dst)) preserved[dirname] = dst try: # Clean non-preserved content and extract fresh for item in target_dir.iterdir(): if item.name == _BUNDLE_STAGING_DIR: continue if item.is_dir(): shutil.rmtree(item) else: item.unlink() config_path = extract_bundle(bundle_path, target_dir) finally: # Restore preserved dirs (idempotent) and clean staging _restore_preserved_dirs(preserved, target_dir) if staging.is_dir(): shutil.rmtree(staging) return config_path