← pag

Commit 7c95d20

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.20 ║
4 +║ PAG - Pagan Linux Package Manager v3.3.20a ║
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, fnmatch
36 +import os, sys, json, copy, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
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.20"
58 +PAG_VERSION = "3.3.20a"
59 59 from urllib.error import URLError, HTTPError
60 60
61 61 # =============================================================================
@@ -226,16 +226,22 @@ def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
226 226 """Weryfikuje podpis i odczytuje fingerprint podpisującego.
227 227
228 228 Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
229 +
230 + FAIL-CLOSED: OK tylko gdy GPG zwrócił 0 ORAZ w statusie maszynowym jest
231 + linia VALIDSIG. Nie zakładamy sukcesu na podstawie samego kodu wyjścia –
232 + gdyby format --status-fd się zmienił, wolimy odrzucić podpis niż przyjąć
233 + niezaufany pakiet (wywołujący traktuje fp=None jako brak potwierdzenia).
229 234 """
230 235 env = os.environ.copy()
231 236 res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
232 237 capture_output=True, text=True, timeout=timeout, env=env)
233 - if res.returncode != 0:
234 - return False, None
235 - m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
238 + out = res.stdout or ""
239 + m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]{16,})", out)
236 240 if not m:
237 - m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
238 - return True, (m.group(1).upper() if m else None)
241 + m = re.search(r"\bVALIDSIG\s+([0-9A-Fa-f]{16,})", out)
242 + if res.returncode != 0 or not m:
243 + return False, None
244 + return True, m.group(1).upper()
239 245
240 246
241 247 # =============================================================================
@@ -355,6 +361,11 @@ T = {
355 361 "sec_conflict": "File conflict: {path} owned by {owner}",
356 362 "sec_audit": "{pkg} installed by {user}",
357 363 "sec_locked": "Another pag process is running",
364 + # Konfiguracja (/etc) – zachowanie zmian użytkownika
365 + "conf_pacnew": "Modified config kept; new version saved as {path}",
366 + "conf_pacsave": "Modified config kept as {path}",
367 + # Zależności wirtualne (provides)
368 + "provides_conflict": "Multiple providers for '{name}' ({providers}) – using '{chosen}'",
358 369 },
359 370 "pl": {
360 371 "root_required": "pag wymaga uprawnień root (sudo).",
@@ -462,6 +473,9 @@ T = {
462 473 "stats_cache": "Rozmiar cache",
463 474 "stats_history": "Transakcje",
464 475 "stats_last_update": "Ostatnia aktualizacja",
476 + "conf_pacnew": "Zmieniony plik konfiguracyjny zachowany; nowa wersja: {path}",
477 + "conf_pacsave": "Zmieniony plik konfiguracyjny zachowany jako {path}",
478 + "provides_conflict": "Wielu dostawców dla '{name}' ({providers}) – używam '{chosen}'",
465 479 },
466 480 }
467 481
@@ -492,8 +506,12 @@ def _load_lang_files() -> None:
492 506 with open(_p, "r", encoding="utf-8") as _fh:
493 507 _data = json.load(_fh)
494 508 if isinstance(_data, dict):
495 - T.setdefault(_code, {}).update(
496 - {str(k): str(v) for k, v in _data.items()})
509 + # Merge (nie replace): klucze nieobecne w pliku zachowują
510 + # wbudowane tłumaczenie. Puste wartości pomijamy, żeby
511 + # niekompletny/uszkodzony plik nie wyczyścił komunikatu.
512 + _clean = {str(k): str(v) for k, v in _data.items()
513 + if str(v).strip()}
514 + T.setdefault(_code, {}).update(_clean)
497 515 except (OSError, ValueError):
498 516 continue
499 517
@@ -593,6 +611,10 @@ DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
593 611 SHARED_PATHS = {
594 612 "/var", "/etc", "/home", "/root", "/tmp", "/run",
595 613 "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
614 + # /boot współdzielone: jądro + initramfs są wspólne dla wszystkich
615 + # deploymentów (inaczej każdy trzyma własną ~50-100 MB kopię). GRUB
616 + # wskazuje na /.deployments/<id>/boot → symlink do /boot.
617 + "/boot",
596 618 "/.deployments", "/.pag_staging",
597 619 }
598 620
@@ -603,6 +625,14 @@ def _is_shared_path(rel: str) -> bool:
603 625 return True
604 626 return False
605 627
628 +def _is_config_path(rel: str) -> bool:
629 + """Czy ścieżka to plik konfiguracyjny (/etc/...)?
630 +
631 + Dla takich plików stosujemy .pacnew/.pacsave (zachowanie zmian użytkownika)
632 + zamiast bezwarunkowego nadpisania/usunięcia."""
633 + p = rel.lstrip("/")
634 + return p == "etc" or p.startswith("etc/")
635 +
606 636 def _get_deployment_root() -> str:
607 637 """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
608 638 if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
@@ -663,7 +693,9 @@ def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
663 693 raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
664 694 else:
665 695 # Pierwszy deployment – tylko katalogi szkieletowe
666 - for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
696 + # /boot celowo POMINIĘTE – jest współdzielone (SHARED_PATHS); poniższa
697 + # pętla utworzy w deploymencie symlink boot → /boot.
698 + for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/opt"]:
667 699 if os.path.isdir(d):
668 700 dest = os.path.join(deploy_dir, d.lstrip("/"))
669 701 os.makedirs(dest, exist_ok=True)
@@ -778,17 +810,11 @@ def _db_init():
778 810 size INTEGER,
779 811 is_symlink INTEGER DEFAULT 0,
780 812 symlink_target TEXT,
781 - pre_existing INTEGER DEFAULT 0,
782 813 UNIQUE(path, package)
783 814 )
784 815 """)
785 816 db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
786 817 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")
792 818 db.execute("""
793 819 CREATE TABLE IF NOT EXISTS file_checksums (
794 820 path TEXT PRIMARY KEY,
@@ -807,12 +833,10 @@ def _db_record_files(pkg_name: str, files: List[dict]):
807 833 except sqlite3.OperationalError:
808 834 pass # transakcja już otwarta (implicit)
809 835 db.executemany(
810 - "INSERT OR REPLACE INTO files "
811 - "(path, package, sha256, size, is_symlink, symlink_target, pre_existing) "
812 - "VALUES (?,?,?,?,?,?,?)",
836 + "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
837 + "VALUES (?,?,?,?,?,?)",
813 838 [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
814 - f.get("is_symlink", 0), f.get("symlink_target", ""),
815 - 1 if f.get("pre_existing") else 0)
839 + f.get("is_symlink", 0), f.get("symlink_target", ""))
816 840 for f in files]
817 841 )
818 842 db.executemany(
@@ -827,39 +851,6 @@ def _db_get_package_files(pkg_name: str) -> List[str]:
827 851 "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
828 852 )]
829 853
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 -
863 854 def _db_get_file_owners(filepath: str) -> List[str]:
864 855 """Zwraca listę pakietów będących właścicielami pliku."""
865 856 with _db_session() as db:
@@ -876,6 +867,18 @@ def _db_get_all_file_checksums() -> Dict[str, str]:
876 867 with _db_session() as db:
877 868 return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
878 869
870 +def _db_get_package_checksums(pkg_name: str) -> Dict[str, str]:
871 + """Sumy SHA256 zapisane dla plików należących do pakietu.
872 +
873 + Używane do wykrycia, czy użytkownik zmodyfikował plik konfiguracyjny
874 + (porównanie z sumą z chwili instalacji) – serce mechanizmu .pacnew/.pacsave.
875 + """
876 + with _db_session() as db:
877 + return {r["path"]: r["sha256"] for r in db.execute(
878 + "SELECT fc.path AS path, fc.sha256 AS sha256 FROM file_checksums fc "
879 + "JOIN files f ON f.path = fc.path WHERE f.package=?", (pkg_name,))}
880 +
881 +
879 882 def _db_count_files() -> int:
880 883 with _db_session() as db:
881 884 return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
@@ -917,6 +920,43 @@ class DatabaseLock:
917 920 # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
918 921 # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
919 922
923 +
924 +class SelfUpdateLock:
925 + """Blokada podmiany binarki pag podczas self-update.
926 +
927 + Niezależna od DatabaseLock (inny plik) – cmd_self_update może zostać
928 + wywołane poza globalną blokadą (np. z zewnętrznego skryptu), a dwie
929 + równoległe aktualizacje pisałyby do tego samego `dst.new`; `os.replace`
930 + mógłby wtedy podmienić plik w trakcie wykonywania (uszkodzony pag).
931 + """
932 + def __init__(self):
933 + self._f = None
934 +
935 + def __enter__(self):
936 + lock_path = LOCK_FILE + ".self-update"
937 + os.makedirs(os.path.dirname(lock_path), exist_ok=True)
938 + self._f = open(lock_path, "w")
939 + try:
940 + fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
941 + except BlockingIOError:
942 + self._f.close()
943 + self._f = None
944 + print("❌ Inna aktualizacja pag jest w toku – spróbuj ponownie później.",
945 + file=sys.stderr)
946 + sys.exit(1)
947 + self._f.write(str(os.getpid()))
948 + self._f.flush()
949 + return self
950 +
951 + def __exit__(self, *args):
952 + if self._f:
953 + try:
954 + fcntl.flock(self._f, fcntl.LOCK_UN)
955 + except OSError:
956 + pass
957 + self._f.close()
958 + self._f = None
959 +
920 960 # =============================================================================
921 961 # POMOCNICZE
922 962 # =============================================================================
@@ -944,34 +984,6 @@ def _check_path_safety(name: str) -> bool:
944 984 return False
945 985
946 986
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 -
975 987 def _validate_pkg_name(name):
976 988 return bool(ALLOWED_PKG_RE.match(name))
977 989
@@ -1063,9 +1075,13 @@ def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool =
1063 1075 continue
1064 1076
1065 1077 # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
1066 - # `filter=` (inaczej DeprecationWarning, w 3.14+ błąd) – nasza ręczna
1067 - # walidacja powyżej już zabezpiecza ścieżki, więc 'fully_trusted'
1068 - # (pomija filtr Pythona i zachowuje SUID/SGID/sticky z preserve_perms).
1078 + # `filter=` (inaczej DeprecationWarning, a w 3.14+ błąd).
1079 + # UWAGA: 'fully_trusted' CELOWO pomija wbudowane filtry bezpieczeństwa
1080 + # Pythona – to nie przeoczenie. Nasza walidacja powyżej (path traversal,
1081 + # NUL/backslash, escape przez symlink, linki wychodzące poza dest) jest
1082 + # równoważna lub ostrzejsza, a 'fully_trusted' pozwala zachować bity
1083 + # SUID/SGID/sticky, które filtr 'data' by usunął (np. /usr/bin/sudo).
1084 + # SUID jest i tak zdejmowany przez _strip_suid() tuż po rozpakowaniu.
1069 1085 try:
1070 1086 if hasattr(tarfile, 'data_filter'):
1071 1087 # Python 3.12+
@@ -1080,11 +1096,15 @@ def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool =
1080 1096
1081 1097
1082 1098 def _sha256_file(path: str) -> str:
1083 - h = hashlib.sha256()
1099 + """SHA256 pliku. Na Python ≥3.11 używa hashlib.file_digest (pętla w C);
1100 + starsze wersje mają fallback z pętlą chunków."""
1084 1101 with open(path, "rb") as f:
1102 + if hasattr(hashlib, "file_digest"):
1103 + return hashlib.file_digest(f, "sha256").hexdigest()
1104 + h = hashlib.sha256()
1085 1105 for chunk in iter(lambda: f.read(65536), b""):
1086 1106 h.update(chunk)
1087 - return h.hexdigest()
1107 + return h.hexdigest()
1088 1108
1089 1109 def _split_version(v: str):
1090 1110 """Rozdziela wersję na (release_parts, prerelease_parts).
@@ -1269,6 +1289,26 @@ def _cache_ro_notice():
1269 1289 _cache_ro_notice_done = True
1270 1290 print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1271 1291
1292 +def _load_repo_cache(cp: str) -> Optional[list]:
1293 + """Wczytuje cache indeksu repo; zwraca None gdy brak albo uszkodzony.
1294 +
1295 + Partial write (crash w trakcie zapisu) mógł zostawić obcięty JSON. Zamiast
1296 + zwracać pustą listę pakietów (użytkownik myśli, że repo jest puste)
1297 + sygnalizujemy None – wywołujący ponowi pobranie albo pokaże ostrzeżenie."""
1298 + if not os.path.exists(cp):
1299 + return None
1300 + try:
1301 + with open(cp, "r", encoding="utf-8") as fh:
1302 + data = json.load(fh)
1303 + pkgs = data.get("packages")
1304 + if not isinstance(pkgs, list):
1305 + raise ValueError("brak listy 'packages'")
1306 + return pkgs
1307 + except (OSError, ValueError) as e:
1308 + print(f" ⚠ Uszkodzony cache indeksu {cp}: {e}", file=sys.stderr)
1309 + return None
1310 +
1311 +
1272 1312 def fetch_repo_index(repo_url, force=False):
1273 1313 cp = _repo_cache_path(repo_url)
1274 1314 ep = _repo_etag_path(repo_url)
@@ -1277,8 +1317,12 @@ def fetch_repo_index(repo_url, force=False):
1277 1317 if not force and os.path.exists(cp) and os.path.exists(tp):
1278 1318 try:
1279 1319 if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1280 - return json.load(open(cp)).get("packages",[])
1281 - except: pass
1320 + cached = _load_repo_cache(cp)
1321 + if cached is not None:
1322 + return cached
1323 + # uszkodzony cache – spróbuj odświeżyć z sieci
1324 + except (OSError, ValueError):
1325 + pass
1282 1326
1283 1327 # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1284 1328 # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
@@ -1290,11 +1334,9 @@ def fetch_repo_index(repo_url, force=False):
1290 1334 file=sys.stderr)
1291 1335 return None
1292 1336 _cache_ro_notice()
1293 - if os.path.exists(cp):
1294 - try:
1295 - return json.load(open(cp)).get("packages",[])
1296 - except Exception:
1297 - pass
1337 + cached = _load_repo_cache(cp)
1338 + if cached is not None:
1339 + return cached
1298 1340 return None
1299 1341
1300 1342 headers = {"User-Agent": "pag/3.0"}
@@ -1329,21 +1371,15 @@ def fetch_repo_index(repo_url, force=False):
1329 1371 open(tp,"w").write(str(time.time()))
1330 1372 except OSError:
1331 1373 pass
1332 - if os.path.exists(cp):
1333 - try:
1334 - return json.load(open(cp)).get("packages",[])
1335 - except Exception:
1336 - pass # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1374 + cached = _load_repo_cache(cp)
1375 + if cached is not None:
1376 + return cached
1377 + # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1337 1378 print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1338 1379 return None
1339 1380 except Exception as e:
1340 1381 print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1341 - if os.path.exists(cp):
1342 - try:
1343 - return json.load(open(cp)).get("packages",[])
1344 - except Exception:
1345 - pass
1346 - return None
1382 + return _load_repo_cache(cp)
1347 1383
1348 1384 # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1349 1385 # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
@@ -1380,12 +1416,7 @@ def fetch_repo_index(repo_url, force=False):
1380 1416 file=sys.stderr)
1381 1417 # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1382 1418 # to wciąż stare, zweryfikowane dane
1383 - if os.path.exists(cp):
1384 - try:
1385 - return json.load(open(cp)).get("packages",[])
1386 - except Exception:
1387 - pass
1388 - return None
1419 + return _load_repo_cache(cp)
1389 1420 finally:
1390 1421 for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1391 1422 try:
@@ -1695,8 +1726,7 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1695 1726 staging: str, journal: list, installed_files: list,
1696 1727 deploy_dir: str = "", backup_dir: str = "",
1697 1728 backup_journal: Optional[list] = None,
1698 - owned_paths: Optional[set] = None,
1699 - base_paths: Optional[set] = None) -> bool:
1729 + old_checksums: Optional[dict] = None) -> bool:
1700 1730 """
1701 1731 Instaluje pojedynczy plik (zwykły lub symlink).
1702 1732 Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
@@ -1707,9 +1737,9 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1707 1737 Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1708 1738 stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1709 1739
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"].
1740 + old_checksums: {ścieżka: sha256 z chwili instalacji}. Gdy podane i plik /etc
1741 + został zmodyfikowany przez użytkownika, nowa wersja ląduje jako .pacnew
1742 + (stary plik NIE jest ruszany) – wzorzec jak w pacmanie.
1713 1743 """
1714 1744 # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1715 1745 if deploy_dir and _is_shared_path("/" + rel):
@@ -1720,13 +1750,6 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1720 1750 dst_root = PAG_ROOT
1721 1751
1722 1752 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
1730 1753
1731 1754 # --- SYMLINK ---
1732 1755 if os.path.islink(src):
@@ -1760,7 +1783,6 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1760 1783 "size": len(link_target),
1761 1784 "is_symlink": True,
1762 1785 "symlink_target": link_target,
1763 - "pre_existing": pre_existing,
1764 1786 })
1765 1787 return True
1766 1788
@@ -1776,6 +1798,34 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1776 1798 if expected and file_sha and file_sha != expected:
1777 1799 return False
1778 1800
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.
1806 + 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"
1815 + 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
1826 + except OSError:
1827 + pass # nie udało się – kontynuuj normalną instalację
1828 +
1779 1829 # Utwórz katalog docelowy
1780 1830 os.makedirs(os.path.dirname(dst), exist_ok=True)
1781 1831
@@ -1807,13 +1857,13 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1807 1857 "sha256": file_sha,
1808 1858 "size": os.path.getsize(dst),
1809 1859 "is_symlink": False,
1810 - "pre_existing": pre_existing,
1811 1860 })
1812 1861 return True
1813 1862
1814 1863
1815 1864 def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1816 - backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1865 + backup_dir: str = "",
1866 + old_checksums: Optional[dict] = None) -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1817 1867 """
1818 1868 Rozpakowuje do staging area, potem atomowo przenosi pliki.
1819 1869 Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
@@ -1844,19 +1894,6 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1844 1894 sums_path = os.path.join(data_staging, "sums.json")
1845 1895 sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1846 1896
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 -
1860 1897 # Hook pre-install (przed przeniesieniem plików do systemu)
1861 1898 _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1862 1899
@@ -1889,7 +1926,7 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1889 1926 ok = _install_file(src, rel, data_staging, sums,
1890 1927 staging, journal, installed_files, deploy_dir,
1891 1928 backup_dir, backup_journal,
1892 - owned_paths=owned_paths, base_paths=base_paths)
1929 + old_checksums=old_checksums)
1893 1930 if not ok:
1894 1931 # Cofnij wszystkie operacje
1895 1932 _rollback_journal(journal, staging)
@@ -1969,17 +2006,10 @@ def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1969 2006
1970 2007 def _rollback_journal(journal: list, staging_path: str):
1971 2008 """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"}
1976 2009 for entry in reversed(journal):
1977 2010 op = entry[0]
1978 2011 if op == "file":
1979 2012 _, 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
1983 2013 try:
1984 2014 if os.path.exists(dst) or os.path.islink(dst):
1985 2015 _safe_rename(dst, src)
@@ -2011,13 +2041,12 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2011 2041 Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
2012 2042 Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
2013 2043 """
2014 - pkg_entries = _db_get_package_entries(pkg_name)
2044 + pkg_files = _db_get_package_files(pkg_name)
2045 + recorded = _db_get_package_checksums(pkg_name)
2015 2046 removed = []
2016 2047 skipped_shared = []
2017 - skipped_critical = []
2018 - skipped_base = []
2019 2048
2020 - for fpath, pre_existing in pkg_entries:
2049 + for fpath in pkg_files:
2021 2050 owners = _db_get_file_owners(fpath)
2022 2051 # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
2023 2052 other_owners = [o for o in owners if o != pkg_name and o in installed_db]
@@ -2027,20 +2056,26 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2027 2056 skipped_shared.append(fpath)
2028 2057 continue
2029 2058
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 -
2042 2059 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2043 2060 if os.path.isfile(full) or os.path.islink(full):
2061 + # .pacsave: zachowaj ZMIENIONY plik konfiguracyjny zamiast kasować
2062 + # (porównanie z sumą z chwili instalacji), wzorzec jak w pacmanie.
2063 + if not os.path.islink(full) and _is_config_path(fpath):
2064 + old_sha = recorded.get(fpath, "")
2065 + if old_sha:
2066 + try:
2067 + cur_sha = _sha256_file(full)
2068 + except OSError:
2069 + cur_sha = ""
2070 + if cur_sha and cur_sha != old_sha:
2071 + pacsave = full + ".pacsave"
2072 + try:
2073 + os.replace(full, pacsave)
2074 + print(f" ⚠ {_('conf_pacsave', path=pacsave)}")
2075 + removed.append(fpath)
2076 + continue
2077 + except OSError:
2078 + pass
2044 2079 os.remove(full)
2045 2080 removed.append(fpath)
2046 2081
@@ -2065,15 +2100,8 @@ def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str
2065 2100
2066 2101 if skipped_shared:
2067 2102 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]})")
2074 2103
2075 - return (len(removed) + len(skipped_shared) + len(skipped_base)
2076 - + len(skipped_critical)), removed
2104 + return len(removed) + len(skipped_shared), removed
2077 2105
2078 2106
2079 2107 def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
@@ -2095,23 +2123,21 @@ def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str
2095 2123 root = deploy_dir or PAG_ROOT
2096 2124 removed = []
2097 2125 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))
2102 2126 for fpath in stale:
2103 2127 owners = _db_get_file_owners(fpath)
2104 2128 other_owners = [o for o in owners if o != pkg_name and o in installed_db]
2105 2129 if other_owners:
2106 2130 # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
2107 2131 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
2114 2132 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
2115 2141 full = os.path.join(root, fpath.lstrip("/"))
2116 2142 if os.path.isfile(full) or os.path.islink(full):
2117 2143 try:
@@ -2148,10 +2174,6 @@ def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str
2148 2174 print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
2149 2175 if skipped:
2150 2176 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")
2155 2177
2156 2178 return len(removed), removed
2157 2179
@@ -2322,7 +2344,10 @@ def cmd_rollback():
2322 2344 save_json(INSTALLED_DB, last["snapshot"])
2323 2345
2324 2346 # Wycofaj fizyczne pliki (jeśli zapisano journal)
2325 - file_journal = last.get("file_journal", [])
2347 + # Deduplikacja: pakiet może zgłosić ten sam plik więcej niż raz (np. przez
2348 + # `provides` lub wspólną ścieżkę), a journal z historii mógł zostać zapisany
2349 + # przed dodaniem deduplikacji.
2350 + file_journal = list(dict.fromkeys(last.get("file_journal", [])))
2326 2351 upgrade_backups = last.get("upgrade_backups", {}) or {}
2327 2352 backup_root = last.get("upgrade_backup_root", "")
2328 2353
@@ -2342,9 +2367,6 @@ def cmd_rollback():
2342 2367 for fpath in reversed(file_journal):
2343 2368 if fpath in backed:
2344 2369 continue
2345 - if _is_critical_path(fpath):
2346 - print(f" 🛡 Rollback: zachowano plik krytyczny {fpath}")
2347 - continue
2348 2370 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2349 2371 if os.path.exists(full) or os.path.islink(full):
2350 2372 os.remove(full)
@@ -2375,11 +2397,9 @@ def cmd_rollback():
2375 2397 # INSTALACJA
2376 2398 # =============================================================================
2377 2399
2378 -def _install_local_pkg_files(paths, install_succeeded, backup_dir: str = ""):
2400 +def _install_local_pkg_files(paths, install_succeeded):
2379 2401 """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2380 2402 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.
2383 2403 Zwraca (failed_count, installed_files)."""
2384 2404 failed = 0
2385 2405 all_files = []
@@ -2403,7 +2423,7 @@ def _install_local_pkg_files(paths, install_succeeded, backup_dir: str = ""):
2403 2423 continue
2404 2424 pkg = PackageInfo(data, repo="local")
2405 2425 print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2406 - ok, files, _ = _atomic_install(p, pkg, backup_dir=backup_dir)
2426 + ok, files, _ = _atomic_install(p, pkg)
2407 2427 if ok:
2408 2428 install_succeeded(pkg, files)
2409 2429 all_files.extend(f["path"] for f in files)
@@ -2477,11 +2497,7 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2477 2497 "requires_so": getattr(pkg, "requires_so", None) or [],
2478 2498 }
2479 2499 world.add(pkg.name)
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)
2500 + failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2485 2501 save_json(INSTALLED_DB, installed_db)
2486 2502 save_world(world)
2487 2503 if failed_local:
@@ -2584,7 +2600,7 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2584 2600 if not _ask_confirm():
2585 2601 print(_("cancelled")); return 0
2586 2602
2587 - snapshot = json.loads(json.dumps(installed_db))
2603 + snapshot = copy.deepcopy(installed_db)
2588 2604 all_installed_files = []
2589 2605 failed = []
2590 2606 # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
@@ -2694,10 +2710,13 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2694 2710
2695 2711 # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2696 2712 old_files = _db_get_package_files(name) if name in installed_db else []
2713 + # Stare sumy SHA256 – do wykrycia zmian użytkownika w plikach /etc (.pacnew)
2714 + old_checksums = _db_get_package_checksums(name) if name in installed_db else None
2697 2715
2698 2716 # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2699 2717 ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2700 - backup_dir=backup_root)
2718 + backup_dir=backup_root,
2719 + old_checksums=old_checksums)
2701 2720 if ok:
2702 2721 installed_db[name] = {
2703 2722 "version": pkg.version, "release": pkg.release, "description": pkg.description,
@@ -2729,6 +2748,10 @@ def cmd_install(package_names, as_dep=False, upgrade=False):
2729 2748 failed.append(name)
2730 2749 break # PRZERWIJ – błąd instalacji
2731 2750
2751 + # Deduplikacja journalu – ten sam plik może trafić tu dwukrotnie (np. przez
2752 + # `provides`); rollback i historia nie potrzebują duplikatów.
2753 + all_installed_files = list(dict.fromkeys(all_installed_files))
2754 +
2732 2755 # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2733 2756 if failed:
2734 2757 _bar_end()
@@ -2809,10 +2832,6 @@ def _rollback_transaction(installed_db: dict, snapshot: dict,
2809 2832 for fpath in reversed(installed_files):
2810 2833 if fpath in backup_map:
2811 2834 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
2816 2835 full = os.path.join(root, fpath.lstrip("/"))
2817 2836 if os.path.isfile(full) or os.path.islink(full):
2818 2837 try:
@@ -2849,7 +2868,7 @@ def _rollback_transaction(installed_db: dict, snapshot: dict,
2849 2868 def cmd_remove(package_names):
2850 2869 installed_db = load_json(INSTALLED_DB)
2851 2870 world = load_world()
2852 - snapshot = json.loads(json.dumps(installed_db))
2871 + snapshot = copy.deepcopy(installed_db)
2853 2872 removed = []
2854 2873 removed_files = []
2855 2874
@@ -2996,6 +3015,13 @@ def cmd_self_update():
2996 3015 /usr/local/bin/pag): gdy pakiet instaluje /usr/bin/pag, a self-update
2997 3016 pisał do /usr/local/bin/pag, powstawały DWIE kopie o różnych wersjach
2998 3017 i zależnie od PATH `pag --version` pokazywał raz jedną, raz drugą."""
3018 + # Dedykowana blokada – patrz SelfUpdateLock. Chroni zapis dst.new oraz
3019 + # os.replace przed równoległą aktualizacją (np. cron + ręcznie).
3020 + with SelfUpdateLock():
3021 + return _self_update_impl()
3022 +
3023 +
3024 +def _self_update_impl():
2999 3025 repos = get_repos()
3000 3026 if not repos:
3001 3027 print("❌ Brak repozytoriów w konfiguracji.")
@@ -3350,14 +3376,16 @@ def cmd_verify(deep=False):
3350 3376 if not installed: print("Nothing to verify."); return
3351 3377 errors = []
3352 3378
3379 + # Wczytaj WSZYSTKIE sumy RAZ (nie w pętli!) – przy 50k plików wywołanie
3380 + # _db_get_all_file_checksums() per-plik dawało O(n²) i godziny zamiast sekund.
3381 + all_checksums = _db_get_all_file_checksums() if deep else {}
3353 3382 for name in installed:
3354 3383 for fpath in _db_get_package_files(name):
3355 3384 full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3356 3385 if not (os.path.exists(full) or os.path.islink(full)):
3357 3386 errors.append(f" ❌ {name}: missing {fpath}")
3358 3387 elif deep:
3359 - checksums = _db_get_all_file_checksums()
3360 - expected = checksums.get(fpath, "")
3388 + expected = all_checksums.get(fpath, "")
3361 3389 if expected:
3362 3390 actual = _sha256_file(full)
3363 3391 if actual != expected:
@@ -3607,6 +3635,35 @@ PROVIDES_MAP = {
3607 3635 "pkgconfig(libgobject-2.0)": "glib",
3608 3636 }
3609 3637
3638 +# Ostrzeżenia o wielu dostawcach tej samej wirtualnej nazwy – raz na proces.
3639 +_PROVIDES_WARNED = set()
3640 +# Cache indeksu provides dla danego obiektu repo: (repo_obj, {virtual: [pkg,...]}).
3641 +# Trzymamy referencję do repo, by uniknąć pomyłki przy ponownym użyciu id().
3642 +_provides_cache = (None, {})
3643 +
3644 +def _provides_index(repo: dict) -> dict:
3645 + """Buduje (i cache’uje) mapę wirtualna nazwa → lista dostawców w repo.
3646 +
3647 + Pozwala wybrać dostawcę DETERMINISTYCZNIE (posortowanego) zamiast zależeć
3648 + od kolejności wstawiania w repo.json, oraz ostrzec o konflikcie provides."""
3649 + global _provides_cache
3650 + cached_repo, idx = _provides_cache
3651 + if cached_repo is repo:
3652 + return idx
3653 + idx = {}
3654 + for _pn, _p in repo.items():
3655 + for _prov in (getattr(_p, "provides", None) or []):
3656 + idx.setdefault(_prov, []).append(_pn)
3657 + for _prov, _pns in idx.items():
3658 + if len(_pns) > 1 and _prov not in _PROVIDES_WARNED:
3659 + _PROVIDES_WARNED.add(_prov)
3660 + _sorted = sorted(_pns)
3661 + print(f" ⚠ {_('provides_conflict', name=_prov, providers=', '.join(_sorted), chosen=_sorted[0])}",
3662 + file=sys.stderr)
3663 + _provides_cache = (repo, idx)
3664 + return idx
3665 +
3666 +
3610 3667 def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3611 3668 """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3612 3669
@@ -3638,17 +3695,18 @@ def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -
3638 3695 if real in repo:
3639 3696 return real
3640 3697 # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3641 - for _pkg_name, _pkg in repo.items():
3642 - _provs = getattr(_pkg, "provides", None) or []
3643 - if name in _provs:
3644 - return _pkg_name
3698 + # Determinizm: przy wielu dostawcach wybieramy posortowanego pierwszego
3699 + # (i ostrzegamy raz), zamiast zależeć od kolejności w repo.json.
3700 + _idx = _provides_index(repo)
3701 + if name in _idx:
3702 + return min(_idx[name])
3645 3703 # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3646 3704 # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3647 3705 if installed:
3648 - for _pkg_name, _meta in installed.items():
3649 - _provs = _meta.get("provides") or [] if isinstance(_meta, dict) else []
3650 - if name in _provs:
3651 - return _pkg_name
3706 + _inst_cands = [pn for pn, meta in installed.items()
3707 + if isinstance(meta, dict) and name in (meta.get("provides") or [])]
3708 + if _inst_cands:
3709 + return min(_inst_cands)
3652 3710 clean = name
3653 3711 if name.startswith("pkgconfig(") and ")" in name:
3654 3712 clean = name.split("(", 1)[1].rstrip(")")
@@ -4092,7 +4150,13 @@ def _flatpak_do_install(app_id: str) -> int:
4092 4150 użytkownika roota (/root/.local/share/flatpak) – widocznej dla roota, ale
4093 4151 nie dla zalogowanego użytkownika. To dokładnie efekt „instaluje, ale nie
4094 4152 mam tego na PC”. Bez roota próbujemy instalacji systemowej (polkit),
4095 - a w razie niepowodzenia cofamy się do instalacji użytkownika (--user)."""
4153 + a w razie niepowodzenia cofamy się do instalacji użytkownika (--user).
4154 +
4155 + Zakres (scope) ma znaczenie: instalacja SYSTEMOWA (--system) jest widoczna
4156 + dla wszystkich użytkowników, a --user tylko dla bieżącego (dla roota:
4157 + /root/.local/... → niewidoczna dla sesji). `flatpak list` bez flag pokazuje
4158 + oba zakresy, więc systemowa instalacja roota jest widoczna też dla
4159 + zwykłego użytkownika."""
4096 4160 print(f" {_('flatpak_installing', app_id)}")
4097 4161 if os.geteuid() == 0:
4098 4162 result = subprocess.run(
@@ -4551,6 +4615,12 @@ def _update_grub_config():
4551 4615
4552 4616 deployments = _load_deployments()
4553 4617 root_dev = _detect_root_device()
4618 + if not root_dev:
4619 + print(" ⚠ Nie udało się wykryć partycji root – wpisy GRUB nie dostaną root=.",
4620 + file=sys.stderr)
4621 + print(" Ustaw PAG_ROOT_DEVICE=/dev/... (lub PAG_GRUB_ROOT) i powtórz.",
4622 + file=sys.stderr)
4623 + root_arg = f" root={root_dev}" if root_dev else ""
4554 4624
4555 4625 lines = [
4556 4626 "# =====================================================================",
@@ -4590,7 +4660,7 @@ def _update_grub_config():
4590 4660
4591 4661 lines.append(f"menuentry '{label}' {{")
4592 4662 if kernel_path:
4593 - lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
4663 + lines.append(f" linux {kernel_path}{root_arg} rw quiet")
4594 4664 else:
4595 4665 lines.append(f" # Brak jądra w tym deploymencie")
4596 4666 if initrd_line:
@@ -4599,9 +4669,18 @@ def _update_grub_config():
4599 4669 lines.append("")
4600 4670 entry_num += 1
4601 4671
4602 - # Wpis fallback: zwykły root (gdyby wszystko padło)
4672 + # Wpis fallback: zwykły root (gdyby wszystko padło).
4673 + # Uwaga: GRUB NIE rozwija globów – trzeba podać KONKRETNY plik jądra
4674 + # (wcześniejsze `linux /boot/vmlinuz-*` było niepoprawne).
4675 + fallback_kernels = sorted(
4676 + [f for f in os.listdir("/boot") if f.startswith("vmlinuz-")],
4677 + reverse=True
4678 + ) if os.path.isdir("/boot") else []
4603 4679 lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4604 - lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
4680 + if fallback_kernels:
4681 + lines.append(f" linux /boot/{fallback_kernels[0]}{root_arg} rw quiet")
4682 + else:
4683 + lines.append(" # Brak jądra w /boot")
4605 4684 lines.append(f" initrd /boot/initramfs.img")
4606 4685 lines.append("}")
4607 4686 lines.append("")
@@ -4614,8 +4693,59 @@ def _update_grub_config():
4614 4693 print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4615 4694
4616 4695
4696 +def _root_device_from_fstab(path: str = "/etc/fstab") -> str:
4697 + """Zwraca DEVICE z wpisu dla '/' w fstab (pomija komentarze)."""
4698 + try:
4699 + with open(path, "r", encoding="utf-8", errors="replace") as fh:
4700 + for line in fh:
4701 + line = line.split("#", 1)[0].strip()
4702 + if not line:
4703 + continue
4704 + parts = line.split()
4705 + if len(parts) >= 2 and parts[1] == "/":
4706 + return parts[0]
4707 + except OSError:
4708 + pass
4709 + return ""
4710 +
4711 +
4712 +def _in_chroot() -> bool:
4713 + """Heurystyka: czy działamy w chrocie (build ISO/IMG)?
4714 +
4715 + W chrocie /proc/1/root to root HOSTA – inny system plików niż nasz '/'.
4716 + Na zwykłym systemie PID 1 ma root równy '/'. PAGAN_ROOT ustawiają skrypty
4717 + budujące i jest dziedziczony przez chroot (dodatkowa wskazówka).
4718 + """
4719 + if os.environ.get("PAG_IN_CHROOT") == "1":
4720 + return True
4721 + if os.environ.get("PAGAN_ROOT"):
4722 + return True
4723 + try:
4724 + return os.stat("/").st_dev != os.stat("/proc/1/root").st_dev
4725 + except OSError:
4726 + return False
4727 +
4728 +
4617 4729 def _detect_root_device() -> str:
4618 - """Wykrywa device partycji root (np. /dev/sda1)."""
4730 + """Wykrywa urządzenie/identyfikator partycji root dla GRUB (root=...).
4731 +
4732 + Kolejność (od najbardziej wiarygodnego w danym kontekście):
4733 + 1) PAG_ROOT_DEVICE / PAG_GRUB_ROOT – jawny override (build ISO/IMG,
4734 + instalator); najpewniejszy, bo nie zgadujemy.
4735 + 2) /etc/fstab TARGETU – w chrocie findmnt '/' zwraca partycję HOSTA,
4736 + a fstab opisuje target (np. root=/dev/sda2 dla obrazu IMG).
4737 + 3) findmnt '/' – na żywym systemie (obsługuje UUID/LUKS/subvol).
4738 + Zwraca "" gdy nie da się ustalić – wtedy GRUB nie dostaje błędnego root=.
4739 + """
4740 + explicit = (os.environ.get("PAG_ROOT_DEVICE")
4741 + or os.environ.get("PAG_GRUB_ROOT") or "").strip()
4742 + if explicit:
4743 + return explicit
4744 +
4745 + fstab_dev = _root_device_from_fstab()
4746 + if _in_chroot() and fstab_dev:
4747 + return fstab_dev
4748 +
4619 4749 try:
4620 4750 result = subprocess.run(
4621 4751 ["findmnt", "-n", "-o", "SOURCE", "/"],
@@ -4625,7 +4755,10 @@ def _detect_root_device() -> str:
4625 4755 return result.stdout.strip()
4626 4756 except Exception:
4627 4757 pass
4628 - return "/dev/sda1" # fallback
4758 +
4759 + # Ostatnia deska: fstab targetu (lepsze niż hardkodowane /dev/sda1,
4760 + # które na sprzęcie z NVMe dawało niebootowalny system).
4761 + return fstab_dev
4629 4762
4630 4763
4631 4764 def cmd_grub_update():
@@ -4865,32 +4998,57 @@ def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
4865 4998 print()
4866 4999 return len(missing)
4867 5000
4868 -def _download_pkg(pkg):
5001 +def _download_pkg(pkg, attempts: int = 3):
4869 5002 url = f"{pkg.repo_url}/{pkg.filename}"
4870 5003 dest = os.path.join(PAG_CACHE, pkg.filename)
4871 5004 if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
4872 5005 _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
4873 5006 return dest
4874 - try:
4875 - req = Request(url, headers={"User-Agent":"pag/3.0"})
4876 - with urlopen(req, timeout=600) as resp:
4877 - total = int(resp.headers.get("Content-Length", 0))
4878 - bar = DownloadBar(pkg.filename, total)
4879 - with open(dest, "wb") as f:
4880 - while True:
4881 - chunk = resp.read(65536)
4882 - if not chunk:
4883 - break
4884 - f.write(chunk)
4885 - bar.update(len(chunk))
4886 - bar.close()
4887 - if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
4888 - os.remove(dest); return None
4889 - _download_pkg_sig(pkg, dest)
4890 - return dest
4891 - except Exception as e:
4892 - print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
4893 - return None
5007 + last_err = None
5008 + for attempt in range(1, attempts + 1):
5009 + try:
5010 + req = Request(url, headers={"User-Agent":"pag/3.0"})
5011 + with urlopen(req, timeout=600) as resp:
5012 + total = int(resp.headers.get("Content-Length", 0))
5013 + bar = DownloadBar(pkg.filename, total)
5014 + with open(dest, "wb") as f:
5015 + while True:
5016 + chunk = resp.read(65536)
5017 + if not chunk:
5018 + break
5019 + f.write(chunk)
5020 + bar.update(len(chunk))
5021 + bar.close()
5022 + if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
5023 + last_err = "SHA256 mismatch"
5024 + try:
5025 + os.remove(dest)
5026 + except OSError:
5027 + pass
5028 + if attempt < attempts:
5029 + print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5030 + f"({attempt}/{attempts - 1})...", file=sys.stderr)
5031 + time.sleep(attempt)
5032 + continue
5033 + return None
5034 + _download_pkg_sig(pkg, dest)
5035 + return dest
5036 + except Exception as e:
5037 + last_err = e
5038 + # Usuń częściowy plik – bez tego mógłby zostać użyty jako „cache”
5039 + # (gdy pakiet nie ma sha256) albo mylić kolejną próbę.
5040 + try:
5041 + if os.path.exists(dest):
5042 + os.remove(dest)
5043 + except OSError:
5044 + pass
5045 + if attempt < attempts:
5046 + print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5047 + f"({attempt}/{attempts - 1})...", file=sys.stderr)
5048 + time.sleep(attempt)
5049 + continue
5050 + print(f" ⚠ Błąd pobierania {pkg.filename}: {last_err}", file=sys.stderr)
5051 + return None
4894 5052
4895 5053 def _download_pkg_sig(pkg, dest):
4896 5054 """Zapewnia AKTUALNY podpis pakietu (.asc, fallback .sig) w cache.