← pag

Commit 723789f

0
plików
+0
dodanych
-0
usuniętych
@@ -1,7 +1,7 @@
1 1 #!/usr/bin/env python3
2 2 """
3 3 ╔══════════════════════════════════════════════════════════════════════════════╗
4 -║ PAG - Pagan Linux Package Manager v3.3.19 ║
4 +║ PAG - Pagan Linux Package Manager v3.3.20 ║
5 5 ║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6 6 ╚══════════════════════════════════════════════════════════════════════════════╝
7 7
@@ -33,7 +33,7 @@ MODEL ZAUFANIA / BEZPIECZEŃSTWO:
33 33 - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34 34 """
35 35
36 -import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
36 +import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib, fnmatch
37 37
38 38 # Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39 39 # pag ships, otherwise urlopen() fails with "unable to get local issuer
@@ -55,7 +55,7 @@ import threading, itertools
55 55 import uuid # serialNumber SBOM (CycloneDX)
56 56
57 57 # Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
58 -PAG_VERSION = "3.3.19"
58 +PAG_VERSION = "3.3.20"
59 59 from urllib.error import URLError, HTTPError
60 60
61 61 # =============================================================================
@@ -479,8 +479,12 @@ def _load_lang_files() -> None:
479 479 _env = os.environ.get("PAG_LANG_DIR")
480 480 if _env:
481 481 dirs.append(_env)
482 + # __file__ bywa niedostępne, gdy moduł jest exec/frozen (np. harness
483 + # instalacyjny) – liczymy wtedy od ścieżki programu, zamiast wywalać
484 + # NameError przy samym imporcie.
485 + _self = globals().get("__file__") or sys.argv[0] or "pag"
482 486 dirs += ["/etc/pag/lang", "/usr/share/pag/lang",
483 - os.path.join(os.path.dirname(os.path.abspath(__file__)), "pag-lang")]
487 + os.path.join(os.path.dirname(os.path.abspath(_self)), "pag-lang")]
484 488 for _d in dirs:
485 489 for _code in list(T.keys()) + ["pl", "en", "de"]:
486 490 _p = os.path.join(_d, f"{_code}.json")
@@ -774,11 +778,17 @@ def _db_init():
774 778 size INTEGER,
775 779 is_symlink INTEGER DEFAULT 0,
776 780 symlink_target TEXT,
781 + pre_existing INTEGER DEFAULT 0,
777 782 UNIQUE(path, package)
778 783 )
779 784 """)
780 785 db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
781 786 db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
787 + # Migracja starszych baz – bez tej kolumny nie da się odróżnić plików
788 + # bazowych (rootfs/ISO) od tych dostarczonych przez pag.
789 + cols = {r[1] for r in db.execute("PRAGMA table_info(files)")}
790 + if "pre_existing" not in cols:
791 + db.execute("ALTER TABLE files ADD COLUMN pre_existing INTEGER DEFAULT 0")
782 792 db.execute("""
783 793 CREATE TABLE IF NOT EXISTS file_checksums (
784 794 path TEXT PRIMARY KEY,
@@ -797,10 +807,12 @@ def _db_record_files(pkg_name: str, files: List[dict]):
797 807 except sqlite3.OperationalError:
798 808 pass # transakcja już otwarta (implicit)
799 809 db.executemany(
800 - "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
801 - "VALUES (?,?,?,?,?,?)",
810 + "INSERT OR REPLACE INTO files "
811 + "(path, package, sha256, size, is_symlink, symlink_target, pre_existing) "
812 + "VALUES (?,?,?,?,?,?,?)",
802 813 [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
803 - f.get("is_symlink", 0), f.get("symlink_target", ""))
814 + f.get("is_symlink", 0), f.get("symlink_target", ""),
815 + 1 if f.get("pre_existing") else 0)
804 816 for f in files]
805 817 )
806 818 db.executemany(
@@ -815,6 +827,39 @@ def _db_get_package_files(pkg_name: str) -> List[str]:
815 827 "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
816 828 )]
817 829
830 +
831 +def _db_get_package_entries(pkg_name: str) -> List[Tuple[str, int]]:
832 + """Zwraca [(path, pre_existing), ...] dla pakietu.
833 + pre_existing=1 → plik istniał w systemie PRZED instalacją pag (baza/rootfs)
834 + i nie może zostać skasowany przy usuwaniu pakietu."""
835 + with _db_session() as db:
836 + return [(r["path"], r["pre_existing"] or 0) for r in db.execute(
837 + "SELECT path, pre_existing FROM files WHERE package=?", (pkg_name,)
838 + )]
839 +
840 +
841 +def _db_lookup_paths(paths) -> Dict[str, int]:
842 + """Dla podanych ścieżek zwraca {path: pre_existing} tylko dla tych, które
843 + należą już do jakiegoś pakietu pag. Jedno (kilka) zapytań zamiast skanowania
844 + całej bazy przy każdej instalacji."""
845 + paths = list(paths)
846 + result: Dict[str, int] = {}
847 + if not paths:
848 + return result
849 + chunk = 500 # poniżej limitu zmiennych SQLite
850 + with _db_session() as db:
851 + for i in range(0, len(paths), chunk):
852 + part = paths[i:i + chunk]
853 + marks = ",".join("?" * len(part))
854 + for row in db.execute(
855 + f"SELECT DISTINCT path, pre_existing FROM files WHERE path IN ({marks})",
856 + part,
857 + ):
858 + # Jeśli choć jeden właściciel oznaczył plik jako bazowy – bazowy.
859 + result[row["path"]] = max(result.get(row["path"], 0),
860 + row["pre_existing"] or 0)
861 + return result
862 +
818 863 def _db_get_file_owners(filepath: str) -> List[str]:
819 864 """Zwraca listę pakietów będących właścicielami pliku."""
820 865 with _db_session() as db:
@@ -899,6 +944,34 @@ def _check_path_safety(name: str) -> bool:
899 944 return False
900 945
901 946
947 +# Ścieżki krytyczne dla działania systemu (logowanie, init, glibc/NSS).
948 +# pag NIGDY ich nie skasuje – nawet gdy baza pakietów jest niespójna – ani nie
949 +# zostawi systemu bez nich po rollbacku. Zapobiega powtórce incydentu, w którym
950 +# rollback/usuwanie skasowało libpam.so.0, systemd oraz su/passwd (padł sudo/login).
951 +CRITICAL_PATTERNS = (
952 + "/etc/passwd", "/etc/shadow", "/etc/group", "/etc/gshadow", "/etc/nsswitch.conf",
953 + "/etc/pam.d", "/etc/pam.d/*",
954 + "/usr/bin/su", "/usr/bin/passwd", "/usr/bin/login", "/usr/bin/sudo",
955 + "/usr/bin/sudoedit", "/usr/sbin/visudo", "/usr/bin/systemctl",
956 + "/usr/lib*/libpam*.so*", "/lib*/libpam*.so*",
957 + "/usr/lib*/libnss_*.so*", "/lib*/libnss_*.so*",
958 + "/usr/lib*/libc.so*", "/lib*/libc.so*",
959 + "/usr/lib*/libc-*.so*", "/lib*/libc-*.so*",
960 + "/usr/lib*/libcrypt.so*", "/lib*/libcrypt.so*",
961 + "/usr/lib*/ld-linux*.so*", "/lib*/ld-linux*.so*",
962 + "/usr/lib*/libsystemd.so*", "/lib*/libsystemd.so*",
963 + "/lib/systemd/systemd", "/usr/lib/systemd/systemd",
964 + "/sbin/init", "/usr/sbin/init",
965 +)
966 +
967 +def _is_critical_path(path: str) -> bool:
968 + """True, gdy ścieżki nie wolno usunąć (ochrona przed zbrickowaniem systemu)."""
969 + if not path:
970 + return False
971 + norm = "/" + path.lstrip("/")
972 + return any(fnmatch.fnmatch(norm, pat) for pat in CRITICAL_PATTERNS)
973 +
974 +
902 975 def _validate_pkg_name(name):
903 976 return bool(ALLOWED_PKG_RE.match(name))
904 977
@@ -1621,7 +1694,9 @@ def _safe_rename(src: str, dst: str) -> bool:
1621 1694 def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1622 1695 staging: str, journal: list, installed_files: list,
1623 1696 deploy_dir: str = "", backup_dir: str = "",
1624 - backup_journal: Optional[list] = None) -> bool:
1697 + backup_journal: Optional[list] = None,
1698 + owned_paths: Optional[set] = None,
1699 + base_paths: Optional[set] = None) -> bool:
1625 1700 """
1626 1701 Instaluje pojedynczy plik (zwykły lub symlink).
1627 1702 Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
@@ -1631,6 +1706,10 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1631 1706
1632 1707 Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1633 1708 stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1709 +
1710 + `owned_paths` – ścieżki należące już do pag; `base_paths` – ścieżki oznaczone
1711 + jako bazowe. Pozwalają rozpoznać plik bazowy, którego pag (nawet po adopcji)
1712 + nie może później skasować – flaga trafia do installed_files["pre_existing"].
1634 1713 """
1635 1714 # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1636 1715 if deploy_dir and _is_shared_path("/" + rel):
@@ -1641,6 +1720,13 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1641 1720 dst_root = PAG_ROOT
1642 1721
1643 1722 dst = os.path.join(dst_root, rel)
1723 + rel_abs = "/" + rel
1724 + _owned = owned_paths or set()
1725 + _base = base_paths or set()
1726 + # Plik bazowy: istnieje na dysku i nie został dostarczony przez pag
1727 + # (lub został już wcześniej oznaczony jako bazowy przy poprzedniej wersji).
1728 + _had = os.path.lexists(dst)
1729 + pre_existing = 1 if (_had and (rel_abs not in _owned or rel_abs in _base)) else 0
1644 1730
1645 1731 # --- SYMLINK ---
1646 1732 if os.path.islink(src):
@@ -1674,6 +1760,7 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1674 1760 "size": len(link_target),
1675 1761 "is_symlink": True,
1676 1762 "symlink_target": link_target,
1763 + "pre_existing": pre_existing,
1677 1764 })
1678 1765 return True
1679 1766
@@ -1720,6 +1807,7 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1720 1807 "sha256": file_sha,
1721 1808 "size": os.path.getsize(dst),
1722 1809 "is_symlink": False,
1810 + "pre_existing": pre_existing,
1723 1811 })
1724 1812 return True
1725 1813
@@ -1756,6 +1844,19 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1756 1844 sums_path = os.path.join(data_staging, "sums.json")
1757 1845 sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1758 1846
1847 + # Zbierz ścieżki plików pakietu i jednym zapytaniem sprawdź, które są już
1848 + # własnością pag oraz które oznaczono jako bazowe – potrzebne, by
1849 + # rozpoznać plik bazowy (rootfs/ISO) nadpisywany przez pakiet i zapisać to
1850 + # w bazie, żeby `pag remove`/rollback go później nie skasował.
1851 + _file_rels = []
1852 + for _r, _dirs, _fs in os.walk(data_staging):
1853 + for _f in _fs:
1854 + if _f != "sums.json":
1855 + _file_rels.append(os.path.relpath(os.path.join(_r, _f), data_staging))
1856 + _meta = _db_lookup_paths("/" + r for r in _file_rels)
1857 + owned_paths = set(_meta)
1858 + base_paths = {p for p, v in _meta.items() if v}
1859 +
1759 1860 # Hook pre-install (przed przeniesieniem plików do systemu)
1760 1861 _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1761 1862
@@ -1787,7 +1888,8 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1787 1888
1788 1889 ok = _install_file(src, rel, data_staging, sums,
1789 1890 staging, journal, installed_files, deploy_dir,
1790 - backup_dir, backup_journal)
1891 + backup_dir, backup_journal,
1892 + owned_paths=owned_paths, base_paths=base_paths)
1791 1893 if not ok:
1792 1894 # Cofnij wszystkie operacje
1793 1895 _rollback_journal(journal, staging)
@@ -1867,10 +1969,17 @@ def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1867 1969
1868 1970 def _rollback_journal(journal: list, staging_path: str):
1869 1971 """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1972 + # Cele, które mają backup – dla nich cofnięcie jest bezpieczne (zostaną
1973 + # przywrócone z backupu). Plików krytycznych bez backupu NIE ruszamy,
1974 + # żeby nie zostawić systemu bez sudo/pam/systemd.
1975 + backed_dsts = {e[2] for e in journal if e[0] == "backup"}
1870 1976 for entry in reversed(journal):
1871 1977 op = entry[0]
1872 1978 if op == "file":
1873 1979 _, src, dst = entry
1980 + if _is_critical_path(dst) and dst not in backed_dsts:
1981 + print(f" 🛡 Rollback: zachowano plik krytyczny {dst}")
1982 + continue
1874 1983 try:
1875 1984 if os.path.exists(dst) or os.path.islink(dst):
1876 1985 _safe_rename(dst, src)
@@ -1902,11 +2011,13 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
1902 2011 Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1903 2012 Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1904 2013 """
1905 - pkg_files = _db_get_package_files(pkg_name)
2014 + pkg_entries = _db_get_package_entries(pkg_name)
1906 2015 removed = []
1907 2016 skipped_shared = []
2017 + skipped_critical = []
2018 + skipped_base = []
1908 2019
1909 - for fpath in pkg_files:
2020 + for fpath, pre_existing in pkg_entries:
1910 2021 owners = _db_get_file_owners(fpath)
1911 2022 # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1912 2023 other_owners = [o for o in owners if o != pkg_name and o in installed_db]
@@ -1916,6 +2027,18 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
1916 2027 skipped_shared.append(fpath)
1917 2028 continue
1918 2029
2030 + # Plik bazowy (istniał przed instalacją pag – rootfs/ISO). Pakiet go
2031 + # nadpisał, ale nie jest jego właścicielem: NIE kasujemy go.
2032 + if pre_existing:
2033 + skipped_base.append(fpath)
2034 + continue
2035 +
2036 + # Nigdy nie kasuj plików krytycznych (sudo/pam/systemd/init/glibc),
2037 + # nawet jeśli baza mówi, że należą do usuwanego pakietu.
2038 + if _is_critical_path(fpath):
2039 + skipped_critical.append(fpath)
2040 + continue
2041 +
1919 2042 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1920 2043 if os.path.isfile(full) or os.path.islink(full):
1921 2044 os.remove(full)
@@ -1942,8 +2065,15 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
1942 2065
1943 2066 if skipped_shared:
1944 2067 print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
2068 + if skipped_base:
2069 + print(f" 🛡 {len(skipped_base)} plików BAZOWYCH zachowanych "
2070 + f"(istniały przed instalacją pag, np. {skipped_base[0]})")
2071 + if skipped_critical:
2072 + print(f" 🛡 {len(skipped_critical)} plików KRYTYCZNYCH zachowanych "
2073 + f"(np. {skipped_critical[0]})")
1945 2074
1946 - return len(removed) + len(skipped_shared), removed
2075 + return (len(removed) + len(skipped_shared) + len(skipped_base)
2076 + + len(skipped_critical)), removed
1947 2077
1948 2078
1949 2079 def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
@@ -1965,12 +2095,22 @@ def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str
1965 2095 root = deploy_dir or PAG_ROOT
1966 2096 removed = []
1967 2097 skipped = 0
2098 + skipped_critical = 0
2099 + skipped_base = 0
2100 + # Flagi bazowe starej wersji (wiersze nieusuniętych martwych plików).
2101 + pre_map = dict(_db_get_package_entries(pkg_name))
1968 2102 for fpath in stale:
1969 2103 owners = _db_get_file_owners(fpath)
1970 2104 other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1971 2105 if other_owners:
1972 2106 # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1973 2107 skipped += 1
2108 + elif pre_map.get(fpath):
2109 + # Plik bazowy (rootfs/ISO) – zachowaj na dysku, usuń tylko wpis w DB
2110 + skipped_base += 1
2111 + elif _is_critical_path(fpath):
2112 + # Plik krytyczny – zachowaj na dysku, usuń tylko wpis w DB
2113 + skipped_critical += 1
1974 2114 else:
1975 2115 full = os.path.join(root, fpath.lstrip("/"))
1976 2116 if os.path.isfile(full) or os.path.islink(full):
@@ -2008,12 +2148,21 @@ def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str
2008 2148 print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
2009 2149 if skipped:
2010 2150 print(f" ⚠ {skipped} plików współdzielonych zachowanych")
2151 + if skipped_base:
2152 + print(f" 🛡 {skipped_base} plików BAZOWYCH zachowanych")
2153 + if skipped_critical:
2154 + print(f" 🛡 {skipped_critical} plików KRYTYCZNYCH zachowanych")
2011 2155
2012 2156 return len(removed), removed
2013 2157
2014 2158
2015 -def _new_upgrade_backup_root() -> str:
2016 - """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
2159 +def _new_transaction_backup_root() -> str:
2160 + """Katalog na backupy NADPISYWANYCH plików dla bieżącej transakcji.
2161 +
2162 + Tworzony dla KAŻDEJ transakcji, nie tylko upgrade: „świeża” instalacja
2163 + pakietu też potrafi nadpisać pliki spoza bazy pag (baza rootfs/ISO).
2164 + Gdy transakcja padnie, rollback MUSI mieć co przywrócić – inaczej kasuje
2165 + te pliki (tak zniknął m.in. libpam.so.0 i przestał działać sudo)."""
2017 2166 txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
2018 2167 root = os.path.join(STAGING_DIR, "backups", txn)
2019 2168 os.makedirs(root, exist_ok=True)
@@ -2193,6 +2342,9 @@ def cmd_rollback():
2193 2342 for fpath in reversed(file_journal):
2194 2343 if fpath in backed:
2195 2344 continue
2345 + if _is_critical_path(fpath):
2346 + print(f" 🛡 Rollback: zachowano plik krytyczny {fpath}")
2347 + continue
2196 2348 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2197 2349 if os.path.exists(full) or os.path.islink(full):
2198 2350 os.remove(full)
@@ -2223,9 +2375,11 @@ def cmd_rollback():
2223 2375 # INSTALACJA
2224 2376 # =============================================================================
2225 2377
2226 -def _install_local_pkg_files(paths, install_succeeded):
2378 +def _install_local_pkg_files(paths, install_succeeded, backup_dir: str = ""):
2227 2379 """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2228 2380 Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2381 + `backup_dir` pozwala cofnąć nadpisanie plików bazowych (rootfs/ISO)
2382 + – bez niego nieudana instalacja lokalna kasowała nadpisane pliki.
2229 2383 Zwraca (failed_count, installed_files)."""
2230 2384 failed = 0
2231 2385 all_files = []
@@ -2249,7 +2403,7 @@ def _install_local_pkg_files(paths, install_succeeded):
2249 2403 continue
2250 2404 pkg = PackageInfo(data, repo="local")
2251 2405 print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2252 - ok, files, _ = _atomic_install(p, pkg)
2406 + ok, files, _ = _atomic_install(p, pkg, backup_dir=backup_dir)
2253 2407 if ok:
2254 2408 install_succeeded(pkg, files)
2255 2409 all_files.extend(f["path"] for f in files)
@@ -2323,7 +2477,11 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2323 2477 "requires_so": getattr(pkg, "requires_so", None) or [],
2324 2478 }
2325 2479 world.add(pkg.name)
2326 - failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2480 + # Backup nadpisywanych plików bazowych – nawet dla lokalnych .pag.
2481 + local_backup_root = _new_transaction_backup_root()
2482 + failed_local, _fl = _install_local_pkg_files(local_files, _ok,
2483 + backup_dir=local_backup_root)
2484 + shutil.rmtree(local_backup_root, ignore_errors=True)
2327 2485 save_json(INSTALLED_DB, installed_db)
2328 2486 save_world(world)
2329 2487 if failed_local:
@@ -2431,11 +2589,13 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2431 2589 failed = []
2432 2590 # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2433 2591 stale_candidates = []
2434 - # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2592 + # Katalog backupów nadpisywanych plików – dla poprawnego rollbacku.
2593 + # Dla KAŻDEJ transakcji: instalacja „nowego” pakietu także nadpisuje pliki
2594 + # spoza bazy pag (baza rootfs/ISO), a rollback bez backupu by je skasował.
2435 2595 backup_root = ""
2436 2596 all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2437 - if upgrade and to_install:
2438 - backup_root = _new_upgrade_backup_root()
2597 + if to_install:
2598 + backup_root = _new_transaction_backup_root()
2439 2599
2440 2600 # --- Dziennik transakcji (dla pełnej atomowości) ---
2441 2601 # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
@@ -2649,6 +2809,10 @@ def _rollback_transaction(installed_db: dict, snapshot: dict,
2649 2809 for fpath in reversed(installed_files):
2650 2810 if fpath in backup_map:
2651 2811 continue
2812 + # Nie kasuj plików krytycznych – bezpieczniej zostawić niż zbrickować system
2813 + if _is_critical_path(fpath):
2814 + print(f" 🛡 Rollback: zachowano plik krytyczny {fpath}")
2815 + continue
2652 2816 full = os.path.join(root, fpath.lstrip("/"))
2653 2817 if os.path.isfile(full) or os.path.islink(full):
2654 2818 try: