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: