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.