← pag

Commit 9618e51

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.20a ║
4 +║ PAG - Pagan Linux Package Manager v3.3.21 ║
5 5 ║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6 6 ╚══════════════════════════════════════════════════════════════════════════════╝
7 7
@@ -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.20a"
58 +PAG_VERSION = "3.3.21"
59 59 from urllib.error import URLError, HTTPError
60 60
61 61 # =============================================================================
@@ -633,6 +633,21 @@ def _is_config_path(rel: str) -> bool:
633 633 p = rel.lstrip("/")
634 634 return p == "etc" or p.startswith("etc/")
635 635
636 +# Wrażliwa konfiguracja auth/systemowa – NIGDY nie nadpisujemy jej po cichu.
637 +# Nadpisanie /etc/pam.d/system-* przez pakiet (np. shadow) potrafi zablokować
638 +# logowanie/sudo („account validation failure”); dla tych ścieżek zawsze robimy
639 +# .pacnew, gdy treść na dysku różni się od tej z pakietu.
640 +_SENSITIVE_CONFIG_RE = re.compile(
641 + r'^(pam\.d/|security/|sudoers$|sudoers\.d/|shadow$|gshadow$|passwd$|group$|'
642 + r'login\.defs$|nsswitch\.conf$|default/useradd$)')
643 +
644 +def _is_sensitive_config(rel: str) -> bool:
645 + """Czy rel to wrażliwy plik /etc (PAM, sudoers, shadow, login.defs…)?"""
646 + p = rel.lstrip("/")
647 + if not p.startswith("etc/"):
648 + return False
649 + return bool(_SENSITIVE_CONFIG_RE.match(p[4:]))
650 +
636 651 def _get_deployment_root() -> str:
637 652 """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
638 653 if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
@@ -780,19 +795,32 @@ def ensure_dirs():
780 795 from contextlib import contextmanager
781 796
782 797 @contextmanager
783 -def _db_session():
784 - """Context manager – gwarantuje zamknięcie połączenia."""
785 - conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
786 - conn.execute("PRAGMA journal_mode=WAL")
787 - conn.execute("PRAGMA synchronous=NORMAL")
798 +def _db_session(readonly: Optional[bool] = None):
799 + """Context manager – gwarantuje zamknięcie połączenia.
800 +
801 + Gdy katalog bazy nie jest zapisywalny (np. komenda read-only uruchomiona
802 + jako zwykły user), otwieramy połączenie w trybie read-only. Inaczej
803 + `PRAGMA journal_mode=WAL` próbuje pisać i kończy się błędem
804 + „attempt to write a readonly database” zamiast zwrócić wynik.
805 + """
806 + if readonly is None:
807 + readonly = not os.access(os.path.dirname(FILES_DB_SQL) or ".", os.W_OK)
808 + if readonly:
809 + conn = sqlite3.connect(f"file:{FILES_DB_SQL}?mode=ro", uri=True, timeout=15)
810 + else:
811 + conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
812 + conn.execute("PRAGMA journal_mode=WAL")
813 + conn.execute("PRAGMA synchronous=NORMAL")
788 814 conn.execute("PRAGMA foreign_keys=ON")
789 815 conn.execute("PRAGMA busy_timeout=15000")
790 816 conn.row_factory = sqlite3.Row
791 817 try:
792 818 yield conn
793 - conn.commit()
819 + if not readonly:
820 + conn.commit()
794 821 except Exception:
795 - conn.rollback()
822 + if not readonly:
823 + conn.rollback()
796 824 raise
797 825 finally:
798 826 conn.close()
@@ -1798,33 +1826,37 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1798 1826 if expected and file_sha and file_sha != expected:
1799 1827 return False
1800 1828
1801 - # --- .pacnew: NIE nadpisuj zmienionego pliku konfiguracyjnego ---
1802 - # Warunki: plik śledzony (znamy starą sumę), leży w /etc, użytkownik go
1803 - # zmodyfikował (hash na dysku != zapisanej sumy) i nowa wersja też się
1804 - # różni od starej. Wtedy nową treść zapisujemy jako `<plik>.pacnew`,
1805 - # a plik użytkownika zostaje nietknięty.
1829 + # --- .pacnew: NIE nadpisuj pliku konfiguracyjnego, którego nie wolno zgubić ---
1830 + # Robimy `<plik>.pacnew`, gdy:
1831 + # a) plik jest śledzony i użytkownik go zmodyfikował (hash != zapisanej sumy),
1832 + # b) to wrażliwa konfiguracja (/etc/pam.d, /etc/security, sudoers, shadow…),
1833 + # a treść na dysku różni się od tej z pakietu – chroni auth przed
1834 + # cichym nadpisaniem przez `install`/`upgrade` (np. przez shadow).
1835 + # NIE robimy .pacnew, jeśli plik na dysku jest identyczny z nowym albo był
1836 + # niezmieniony od instalacji (wtedy nadpisanie jest bezpieczne).
1806 1837 old_sha = (old_checksums or {}).get("/" + rel, "")
1807 - if (old_sha and file_sha and file_sha != old_sha and _is_config_path(rel)
1808 - and os.path.isfile(dst) and not os.path.islink(dst)):
1809 - try:
1810 - cur_sha = _sha256_file(dst)
1811 - except OSError:
1812 - cur_sha = ""
1813 - if cur_sha and cur_sha != old_sha:
1814 - pacnew = dst + ".pacnew"
1838 + if file_sha and _is_config_path(rel) and os.path.isfile(dst) and not os.path.islink(dst):
1839 + tracked_modified = bool(old_sha) and file_sha != old_sha
1840 + if tracked_modified or _is_sensitive_config(rel):
1815 1841 try:
1816 - _safe_rename(src, pacnew)
1817 - try:
1818 - os.chown(pacnew, 0, 0)
1819 - except (OSError, PermissionError):
1820 - pass
1821 - # Journal (do cofnięcia przy nieudanej transakcji) – ale NIE
1822 - # zapisujemy .pacnew w bazie plików: to artefakt użytkownika.
1823 - journal.append(("file", src, pacnew))
1824 - print(f" ⚠ {_('conf_pacnew', path=pacnew)}")
1825 - return True
1842 + cur_sha = _sha256_file(dst)
1826 1843 except OSError:
1827 - pass # nie udało się – kontynuuj normalną instalację
1844 + cur_sha = ""
1845 + if cur_sha and cur_sha != file_sha and not (old_sha and cur_sha == old_sha):
1846 + pacnew = dst + ".pacnew"
1847 + try:
1848 + _safe_rename(src, pacnew)
1849 + try:
1850 + os.chown(pacnew, 0, 0)
1851 + except (OSError, PermissionError):
1852 + pass
1853 + # Journal (do cofnięcia przy nieudanej transakcji) – ale NIE
1854 + # zapisujemy .pacnew w bazie plików: to artefakt użytkownika.
1855 + journal.append(("file", src, pacnew))
1856 + print(f" ⚠ {_('conf_pacnew', path=pacnew)}")
1857 + return True
1858 + except OSError:
1859 + pass # nie udało się – kontynuuj normalną instalację
1828 1860
1829 1861 # Utwórz katalog docelowy
1830 1862 os.makedirs(os.path.dirname(dst), exist_ok=True)
@@ -2045,6 +2077,7 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2045 2077 recorded = _db_get_package_checksums(pkg_name)
2046 2078 removed = []
2047 2079 skipped_shared = []
2080 + skipped_sensitive = []
2048 2081
2049 2082 for fpath in pkg_files:
2050 2083 owners = _db_get_file_owners(fpath)
@@ -2058,6 +2091,12 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2058 2091
2059 2092 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2060 2093 if os.path.isfile(full) or os.path.islink(full):
2094 + # Wrażliwej konfiguracji systemowej (/etc/pam.d, sudoers, shadow…)
2095 + # NIE kasujemy przy `remove` – jej brak potrafi zablokować logowanie
2096 + # i sudo (np. usunięcie system-auth/system-account). Zostaje wpis w bazie.
2097 + if _is_sensitive_config(fpath):
2098 + skipped_sensitive.append(fpath)
2099 + continue
2061 2100 # .pacsave: zachowaj ZMIENIONY plik konfiguracyjny zamiast kasować
2062 2101 # (porównanie z sumą z chwili instalacji), wzorzec jak w pacmanie.
2063 2102 if not os.path.islink(full) and _is_config_path(fpath):
@@ -2100,8 +2139,10 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2100 2139
2101 2140 if skipped_shared:
2102 2141 print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
2142 + if skipped_sensitive:
2143 + print(f" ⚠ {len(skipped_sensitive)} wrażliwych plików systemowych zachowanych")
2103 2144
2104 - return len(removed) + len(skipped_shared), removed
2145 + return len(removed) + len(skipped_shared) + len(skipped_sensitive), removed
2105 2146
2106 2147
2107 2148 def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
@@ -2130,27 +2171,33 @@ def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str
2130 2171 # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
2131 2172 skipped += 1
2132 2173 else:
2133 - # Gdy nowa wersja pliku /etc trafiła do .pacnew, plik użytkownika
2134 - # MUSI zostać – nie jest „przestarzały”. Zachowujemy też wpis w bazie
2135 - # (stara suma), by kolejny upgrade dalej wykrywał zmiany użytkownika.
2136 - if _is_config_path(fpath):
2137 - _cfg_full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2138 - if os.path.lexists(_cfg_full + ".pacnew"):
2139 - skipped += 1
2140 - continue
2141 - full = os.path.join(root, fpath.lstrip("/"))
2142 - if os.path.isfile(full) or os.path.islink(full):
2143 - try:
2144 - if backup_dir and backup_journal is not None:
2145 - backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
2146 - os.makedirs(os.path.dirname(backup_path), exist_ok=True)
2147 - os.replace(full, backup_path) # przenieś do backupu (rollback)
2148 - backup_journal.append((backup_path, fpath))
2149 - else:
2150 - os.remove(full)
2151 - removed.append(fpath)
2152 - except OSError:
2153 - pass
2174 + # Wrażliwych plików systemowych NIE kasujemy, gdy nowy pakiet ich już
2175 + # nie dostarcza (np. shadow przestaje pakować system-*). Ich brak
2176 + # blokuje login/sudo; usuwamy więc tylko wpis w bazie.
2177 + if _is_sensitive_config(fpath):
2178 + skipped += 1
2179 + else:
2180 + # Gdy nowa wersja pliku /etc trafiła do .pacnew, plik użytkownika
2181 + # MUSI zostać – nie jest „przestarzały”. Zachowujemy też wpis w bazie
2182 + # (stara suma), by kolejny upgrade dalej wykrywał zmiany użytkownika.
2183 + if _is_config_path(fpath):
2184 + _cfg_full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2185 + if os.path.lexists(_cfg_full + ".pacnew"):
2186 + skipped += 1
2187 + continue
2188 + full = os.path.join(root, fpath.lstrip("/"))
2189 + if os.path.isfile(full) or os.path.islink(full):
2190 + try:
2191 + if backup_dir and backup_journal is not None:
2192 + backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
2193 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
2194 + os.replace(full, backup_path) # przenieś do backupu (rollback)
2195 + backup_journal.append((backup_path, fpath))
2196 + else:
2197 + os.remove(full)
2198 + removed.append(fpath)
2199 + except OSError:
2200 + pass
2154 2201 # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
2155 2202 with _db_session() as db:
2156 2203 db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))