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))