Commit 629bc9a
0
plików
+0
dodanych
-0
usuniętych
@@ -1,5534 +1,5548 @@
1
-#!/usr/bin/env python3
2
-"""
3
-╔══════════════════════════════════════════════════════════════════════════════╗
4
-║ PAG - Pagan Linux Package Manager v3.3.21 ║
5
-║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6
-╚══════════════════════════════════════════════════════════════════════════════╝
7
-
8
-KLUCZOWE CECHY:
9
- - Atomowa instalacja przez staging (tmpdir → rename) – brak pół-instalacji
10
- - Bezpieczne usuwanie – sprawdza czy plik nie jest współdzielony
11
- - SQLite dla bazy plików – miliony plików bez problemu
12
- - GPG: weryfikacja repo.json + podpisy pakietów + pinning fingerprintu
13
- - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
14
- - Głęboka weryfikacja SHA256 per-plik
15
- - Pełny rollback – cofa fizyczne pliki
16
- - Blokada flock – tylko jedna instancja
17
- - Transakcje z migawkami + rejestr wykonanych hooków
18
- - Cache HTTP (ETag/If-Modified-Since)
19
- - Wielojęzyczność (i18n) – PL, EN
20
-
21
-FORMAT PAKIETU (.pag):
22
- ├── data.tar.xz – pliki + sums.json (SHA256 per plik)
23
- ├── metadata.json – nazwa, wersja, zależności
24
- └── hooks/ – pre-install, post-install, pre-remove, post-remove
25
-
26
-MODEL ZAUFANIA / BEZPIECZEŃSTWO:
27
- - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
28
- klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
29
- - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
30
- Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
31
- /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
32
- instalujesz kod, któremu ufasz.
33
- - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34
-"""
35
-
36
-import os, sys, json, copy, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
37
-
38
-# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39
-# pag ships, otherwise urlopen() fails with "unable to get local issuer
40
-# certificate" (no default capath/cafile is resolved in the chroot).
41
-for _cafile in (
42
- "/etc/ssl/certs/ca-certificates.crt",
43
- "/etc/ssl/cert.pem",
44
-):
45
- if os.path.isfile(_cafile):
46
- os.environ["SSL_CERT_FILE"] = _cafile
47
- break
48
-
49
-from pathlib import Path
50
-from datetime import datetime, timezone
51
-from typing import Dict, List, Optional, Tuple, Set
52
-from concurrent.futures import ThreadPoolExecutor, as_completed
53
-from urllib.request import urlopen, Request
54
-import threading, itertools
55
-import uuid # serialNumber SBOM (CycloneDX)
56
-
57
-# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
58
-PAG_VERSION = "3.3.21"
59
-from urllib.error import URLError, HTTPError
60
-
61
-# =============================================================================
62
-# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
63
-# =============================================================================
64
-
65
-class ProgressBar:
66
- """Czysty Python progress bar — działa z TTY i bez."""
67
- def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
68
- self.total = max(total, 1)
69
- self.desc = desc
70
- self.unit = unit
71
- self.width = width
72
- self.n = 0
73
- self.start = time.time()
74
- self.tty = sys.stderr.isatty()
75
- self._last_line_len = 0
76
-
77
- def update(self, n: Optional[int] = None, suffix: str = ""):
78
- if n is not None:
79
- self.n = n
80
- else:
81
- self.n += 1
82
- pct = self.n / self.total * 100
83
- elapsed = time.time() - self.start
84
- speed = self.n / elapsed if elapsed > 0 else 0
85
- if self.n >= self.total:
86
- eta_str = "done"
87
- elif speed > 0:
88
- eta = (self.total - self.n) / speed
89
- eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
90
- else:
91
- eta_str = "?..."
92
- bar_len = int(self.width * pct / 100)
93
- bar = "█" * bar_len + "░" * (self.width - bar_len)
94
- line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
95
- if self.tty:
96
- # Overwrite current line
97
- clear = " " * max(0, self._last_line_len - len(line))
98
- print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
99
- self._last_line_len = len(line)
100
- else:
101
- # Print milestone lines only (every 10% or when done)
102
- if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
103
- print(line, file=sys.stderr)
104
-
105
- def close(self):
106
- if self.tty:
107
- print(file=sys.stderr)
108
- self._last_line_len = 0
109
-
110
- def __enter__(self):
111
- return self
112
-
113
- def __exit__(self, *args):
114
- self.close()
115
-
116
-
117
-class DownloadBar:
118
- """Pasek postępu pobierania — na podstawie Content-Length."""
119
- def __init__(self, filename: str, total_bytes: int):
120
- self.filename = filename
121
- self.total = total_bytes
122
- self.downloaded = 0
123
- self.start = time.time()
124
- self.tty = sys.stderr.isatty()
125
- self._last_len = 0
126
-
127
- def update(self, chunk_size: int):
128
- self.downloaded += chunk_size
129
- if self.total <= 0:
130
- return
131
- pct = self.downloaded / self.total * 100
132
- elapsed = time.time() - self.start
133
- speed = self.downloaded / elapsed if elapsed > 0 else 0
134
- if speed > 0:
135
- eta = (self.total - self.downloaded) / speed
136
- eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
137
- else:
138
- eta_str = "?..."
139
- bar_len = 25
140
- filled = int(bar_len * pct / 100)
141
- bar = "█" * filled + "░" * (bar_len - filled)
142
- sz = self._fmt_size(self.total)
143
- spd = self._fmt_size(int(speed))
144
- line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
145
- if self.tty:
146
- clear = " " * max(0, self._last_len - len(line))
147
- print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
148
- self._last_len = len(line)
149
-
150
- def close(self):
151
- if self.tty and self.total > 0:
152
- print(file=sys.stderr)
153
-
154
- @staticmethod
155
- def _fmt_size(n: int) -> str:
156
- for unit in ("B", "KB", "MB", "GB"):
157
- if n < 1024:
158
- return f"{n:.1f} {unit}"
159
- n /= 1024
160
- return f"{n:.1f} TB"
161
-
162
-# =============================================================================
163
-# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
164
-# =============================================================================
165
-
166
-GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
167
-GPG_HOME = "/etc/pag/gpg" # izolowany keyring (działa z keyboxd GPG 2.4+)
168
-
169
-def _ssl_ok() -> bool:
170
- """Czy Python ma działający moduł ssl (HTTPS w urllib).
171
-
172
- Bez niego urllib nie zna schematu https i każda operacja sieciowa kończy
173
- się „unknown url type: https” – myląco, bo wygląda jak błąd adresu.
174
- """
175
- try:
176
- import ssl # noqa: F401
177
- return True
178
- except Exception:
179
- return False
180
-
181
-
182
-def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
183
- """
184
- Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
185
- gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
186
- Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
187
- (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
188
- """
189
- env = kwargs.pop("env", None) or os.environ.copy()
190
- env["LC_ALL"] = "C"
191
- env["GNUPGHOME"] = GPG_HOME
192
- try:
193
- return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
194
- except FileNotFoundError:
195
- # GPG nie jest dostępne – zwróć błąd z komunikatem
196
- # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
197
- _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
198
- _msg = f"GPG binary not found ({GPG_BINARY})"
199
- return subprocess.CompletedProcess(
200
- [GPG_BINARY, *args], 127,
201
- stdout=("" if _text else b""),
202
- stderr=(_msg if _text else _msg.encode()),
203
- )
204
- except subprocess.TimeoutExpired:
205
- return subprocess.CompletedProcess(
206
- [GPG_BINARY, *args], 124,
207
- stdout=b"", stderr=b"GPG operation timed out"
208
- )
209
-
210
-def _load_trust_db() -> dict:
211
- """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
212
- try:
213
- with open(TRUST_DB) as f:
214
- return json.load(f)
215
- except (FileNotFoundError, json.JSONDecodeError):
216
- return {}
217
-
218
-
219
-def _save_trust_db(db: dict):
220
- os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
221
- with open(TRUST_DB, "w") as f:
222
- json.dump(db, f, indent=2)
223
-
224
-
225
-def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
226
- """Weryfikuje podpis i odczytuje fingerprint podpisującego.
227
-
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).
234
- """
235
- env = os.environ.copy()
236
- res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
237
- capture_output=True, text=True, timeout=timeout, env=env)
238
- out = res.stdout or ""
239
- m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]{16,})", out)
240
- if not m:
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()
245
-
246
-
247
-# =============================================================================
248
-# i18n – WIELOJĘZYCZNOŚĆ
249
-# =============================================================================
250
-
251
-LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
252
-COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
253
-
254
-def _c(code: str, text: str) -> str:
255
- """Dodaje kody ANSI jeśli kolor jest włączony."""
256
- if not COLOR:
257
- return text
258
- colors = {
259
- "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
260
- "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
261
- "reset": "\033[0m",
262
- }
263
- return f"{colors.get(code,'')}{text}{colors['reset']}"
264
-
265
-T = {
266
- "en": {
267
- "root_required": "pag requires root privileges (sudo).",
268
- "db_locked": "Another pag instance is running.",
269
- "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
270
- "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
271
- "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
272
- "all_installed": "All packages are already installed.",
273
- "to_install": "To install: {} packages ({:.2f} MB)",
274
- "new": "NEW",
275
- "continue_q": "Continue? [Y/n] ",
276
- "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
277
- "cancelled": "Cancelled.",
278
- "not_found": "not found in repos",
279
- "pkg_not_found": "Package not found: {} (not in any repo)",
280
- "not_found_hint": "Check the spelling or run 'pag search <query>'.",
281
- "downloading": "Downloading",
282
- "download_fail": "download failed",
283
- "gpg_fail": "GPG verification failed",
284
- "sha256_mismatch": "SHA256 mismatch",
285
- "install_failed": "installation failed",
286
- "installed": "Installed {} packages.",
287
- "rollback_restored": "Restored previous state from snapshot.",
288
- "rollback_files": "Rolled back {} files.",
289
- "no_history": "No transaction history.",
290
- "pinned_list": "Pinned packages ({}):",
291
- "no_pinned": "No pinned packages.",
292
- "pinned_to": "pinned to",
293
- "unpinned": "unpinned.",
294
- "not_pinned": "was not pinned.",
295
- "repo_added": "Added repository: {}",
296
- "repo_exists": "Repository already exists: {}",
297
- "updated_done": "Index refresh complete. {} packages cached.",
298
- "indexes_refreshed": "Indexes refreshed.",
299
- "updates_available": "⚠ {} packages have updates – run: pag update",
300
- "upgrading": "Upgrading: {} packages",
301
- "all_up_to_date": "All packages are up to date.",
302
- "removing": "Removing",
303
- "orphans_found": "Orphaned dependencies ({}): {}",
304
- "flatpak_missing": "Flatpak is not installed.",
305
- "flatpak_adding": "Adding Flathub remote...",
306
- "flatpak_searching": "Searching Flathub for '{}'...",
307
- "flatpak_found": "Found {} results:",
308
- "flatpak_not_found": "not found on Flathub",
309
- "flatpak_install_prompt": "Install {}? [Y/n] ",
310
- "flatpak_installing": "Installing {}...",
311
- "flatpak_installed": "Flatpak {} installed.",
312
- "flatpak_removed": "Flatpak {} removed.",
313
- "flatpak_not_installed": "Flatpak {} is not installed.",
314
- "flatpak_info_id": "ID",
315
- "flatpak_info_version": "Version",
316
- "flatpak_info_branch": "Branch",
317
- "flatpak_info_origin": "Origin",
318
- "flatpak_info_size": "Installed size",
319
- "flatpak_info_desc": "Description",
320
- "flatpak_updated": "Flatpaks updated.",
321
- "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
322
- "flatpak_scope": "Installation",
323
- "flatpak_menu_hint": "Installed and verified, but this session may not show it in the menu: Flatpak exports are missing from XDG_DATA_DIRS.",
324
- "flatpak_menu_fix": "Add /var/lib/flatpak/exports/share to XDG_DATA_DIRS (and .../exports/bin to PATH) in your session, then re-login – PaganDE does this in session/pagande-session.",
325
- "key_imported": "Key imported successfully.",
326
- "key_removed": "Key removed: {}",
327
- "no_keys": "No trusted GPG keys.",
328
- "gpg_missing": "GNUPG MISSING – install gnupg and retry",
329
- "ssl_broken": "Python has no working ssl module (HTTPS impossible) – fix the python/openssl packages (e.g. sudo pag install -f python).",
330
- "key_add_failed": "Key import failed (gpg error) – key was NOT added.",
331
- "verify_ok": "All {} files intact.",
332
- "verify_errors": "{} problems found:",
333
- "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
334
- "deployments_list": "Deployments ({}):",
335
- "no_deployments": "No deployments.",
336
- "active_deployment": "ACTIVE",
337
- "deploy_rollback_ok": "Switched to deployment: {}",
338
- "deploy_rollback_fail": "No previous deployment.",
339
- "deploy_cleanup_ok": "Removed {} old deployments.",
340
- "deploy_cleanup_none": "No deployments to clean (minimum {}).",
341
- "why_explicit": "explicitly installed",
342
- "why_dependency": "dependency of",
343
- "why_not_installed": "not installed",
344
- "autoremove_ok": "Removed {} orphaned packages.",
345
- "autoremove_none": "No orphaned packages.",
346
- "downloaded": "Downloaded {} to cache ({:.2f} MB).",
347
- "provides_mapped": "{} → {} (provides)",
348
- "stats_title": "PAG Statistics",
349
- "stats_packages": "Installed packages",
350
- "stats_files": "Tracked files",
351
- "stats_size": "Total size",
352
- "stats_cache": "Cache size",
353
- "stats_history": "Transactions",
354
- "stats_last_update": "Last update",
355
- # Komunikaty bezpieczeństwa (baza EN; PL w tabeli "pl" jako sec_*_pl)
356
- "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
357
- "sec_suid": "SUID stripped from {path}",
358
- "sec_https": "HTTPS required for repos",
359
- "sec_badname": "Invalid package name: {name}",
360
- "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
361
- "sec_conflict": "File conflict: {path} owned by {owner}",
362
- "sec_audit": "{pkg} installed by {user}",
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}'",
369
- },
370
- "pl": {
371
- "root_required": "pag wymaga uprawnień root (sudo).",
372
- "db_locked": "Inna instancja pag jest uruchomiona.",
373
- "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
374
- "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
375
- "cache_ro": "Cache repozytoriów jest tylko-do-odczytu ({cache}) – używam lokalnego indeksu (może być nieaktualny).\n Odśwież jako root: sudo pag sync",
376
- "all_installed": "Wszystkie pakiety są już zainstalowane.",
377
- "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
378
- "new": "NOWY",
379
- "continue_q": "Kontynuować? [T/n] ",
380
- "no_tty": "Brak terminala (EOF) – anuluję.",
381
- "cancelled": "Anulowano.",
382
- "not_found": "brak w repozytoriach",
383
- "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
384
- "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
385
- "downloading": "Pobieranie",
386
- "download_fail": "błąd pobierania",
387
- "gpg_fail": "błąd weryfikacji GPG",
388
- "sha256_mismatch": "niezgodność SHA256",
389
- "install_failed": "błąd instalacji",
390
- "installed": "Zainstalowano {} pakietów.",
391
- "rollback_restored": "Przywrócono poprzedni stan z migawki.",
392
- "rollback_files": "Wycofano {} plików.",
393
- "no_history": "Brak historii transakcji.",
394
- "pinned_list": "Przypięte pakiety ({}):",
395
- "no_pinned": "Brak przypiętych pakietów.",
396
- "pinned_to": "przypięty do",
397
- "unpinned": "odpięty.",
398
- "not_pinned": "nie był przypięty.",
399
- "repo_added": "Dodano repozytorium: {}",
400
- "repo_exists": "Repozytorium już istnieje: {}",
401
- "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
402
- "indexes_refreshed": "Indeksy odświeżone.",
403
- "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
404
- "upgrading": "Aktualizacje: {} pakietów",
405
- "all_up_to_date": "Wszystkie pakiety są aktualne.",
406
- "removing": "Usuwanie",
407
- "orphans_found": "Osierocone zależności ({}): {}",
408
- "flatpak_missing": "Flatpak nie jest zainstalowany.",
409
- "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
410
- "flatpak_searching": "Szukam '{}' we Flathub...",
411
- "flatpak_found": "Znaleziono {} wyników:",
412
- "flatpak_not_found": "nie znaleziono we Flathub",
413
- "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
414
- "flatpak_installing": "Instalowanie {}...",
415
- "flatpak_installed": "Flatpak {} zainstalowany.",
416
- "flatpak_removed": "Flatpak {} usunięty.",
417
- "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
418
- "flatpak_info_id": "ID",
419
- "flatpak_info_version": "Wersja",
420
- "flatpak_info_branch": "Gałąź",
421
- "flatpak_info_origin": "Źródło",
422
- "flatpak_info_size": "Rozmiar",
423
- "flatpak_info_desc": "Opis",
424
- "flatpak_updated": "Flapaki zaktualizowane.",
425
- "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
426
- "flatpak_scope": "Instalacja",
427
- "flatpak_menu_hint": "Zainstalowano i zweryfikowano, ale sesja może jej nie pokazać w menu: brak eksportów Flatpaka w XDG_DATA_DIRS.",
428
- "flatpak_menu_fix": "Dodaj /var/lib/flatpak/exports/share do XDG_DATA_DIRS (a .../exports/bin do PATH) w sesji i zaloguj się ponownie – PaganDE robi to w session/pagande-session.",
429
- "key_imported": "Klucz zaimportowany pomyślnie.",
430
- "key_removed": "Klucz usunięty: {}",
431
- "no_keys": "Brak zaufanych kluczy GPG.",
432
- "gpg_missing": "BRAK GNUPG – zainstaluj gnupg i spróbuj ponownie",
433
- "ssl_broken": "Python bez działającego modułu ssl (HTTPS niemożliwe) – napraw pakiety python/openssl (np. sudo pag install -f python).",
434
- "key_add_failed": "Import klucza nie powiódł się (błąd gpg) – klucz NIE został dodany.",
435
- "verify_ok": "Wszystkie {} plików sprawne.",
436
- "verify_errors": "Znaleziono {} problemów:",
437
- "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
438
- "deployments_list": "Deploymenty ({}):",
439
- "no_deployments": "Brak deploymentów.",
440
- "active_deployment": "AKTYWNY",
441
- "deploy_rollback_ok": "Przełączono na deployment: {}",
442
- "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
443
- "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
444
- "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
445
- "why_explicit": "zainstalowany jawnie",
446
- "why_dependency": "zależność od",
447
- "why_not_installed": "niezainstalowany",
448
- "autoremove_ok": "Usunięto {} osieroconych pakietów.",
449
- "autoremove_none": "Brak osieroconych pakietów.",
450
- "downloaded": "Pobrano {} do cache ({:.2f} MB).",
451
- "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
452
- "sec_suid": "SUID stripped from {path}",
453
- "sec_https": "HTTPS required for repos",
454
- "sec_badname": "Invalid package name: {name}",
455
- "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
456
- "sec_conflict": "File conflict: {path} owned by {owner}",
457
- "sec_audit": "{pkg} installed by {user}",
458
- "sec_locked": "Another pag process is running",
459
- "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
460
- "sec_suid_pl": "SUID usuniety z {path}",
461
- "sec_https_pl": "Repozytorium wymaga HTTPS",
462
- "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
463
- "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
464
- "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
465
- "sec_audit_pl": "{pkg} zainstalowany przez {user}",
466
- "sec_locked_pl": "Inny proces pag juz dziala",
467
-
468
- "provides_mapped": "{} → {} (provides)",
469
- "stats_title": "Statystyki PAG",
470
- "stats_packages": "Zainstalowane pakiety",
471
- "stats_files": "Śledzone pliki",
472
- "stats_size": "Całkowity rozmiar",
473
- "stats_cache": "Rozmiar cache",
474
- "stats_history": "Transakcje",
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}'",
479
- },
480
-}
481
-
482
-# ── Tłumaczenia z PLIKÓW (nadpisują/rozszerzają wbudowane PL/EN) ─────────────
483
-# Kolejność: PAG_LANG_DIR (env) → /etc/pag/lang → /usr/share/pag/lang →
484
-# ./pag-lang obok binarki (dev). Brak plików NIE jest błędem – zostaje
485
-# wbudowany słownik T (fallback), więc pag zawsze działa.
486
-# Przykład pliku (pag-lang/pl.json): {"app_title": "...", "usage": "..."}.
487
-def _load_lang_files() -> None:
488
- # PAG_LANG_NO_FILES=1 → pomiń pliki (używane przy eksporcie --lang-extract,
489
- # żeby wyeksportować CZYSTE wbudowane słowniki, bez starych nadpisań).
490
- if os.environ.get("PAG_LANG_NO_FILES") == "1":
491
- return
492
- dirs = []
493
- _env = os.environ.get("PAG_LANG_DIR")
494
- if _env:
495
- dirs.append(_env)
496
- # __file__ bywa niedostępne, gdy moduł jest exec/frozen (np. harness
497
- # instalacyjny) – liczymy wtedy od ścieżki programu, zamiast wywalać
498
- # NameError przy samym imporcie.
499
- _self = globals().get("__file__") or sys.argv[0] or "pag"
500
- dirs += ["/etc/pag/lang", "/usr/share/pag/lang",
501
- os.path.join(os.path.dirname(os.path.abspath(_self)), "pag-lang")]
502
- for _d in dirs:
503
- for _code in list(T.keys()) + ["pl", "en", "de"]:
504
- _p = os.path.join(_d, f"{_code}.json")
505
- try:
506
- with open(_p, "r", encoding="utf-8") as _fh:
507
- _data = json.load(_fh)
508
- if isinstance(_data, dict):
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)
515
- except (OSError, ValueError):
516
- continue
517
-
518
-
519
-_load_lang_files()
520
-
521
-def _(key: str, *args, **kwargs) -> str:
522
- """Tłumaczy klucz i formatuje argumenty.
523
-
524
- Dla LANG=pl preferuje wariant „<key>_pl” (np. komunikaty bezpieczeństwa
525
- mają krótkie wersje PL obok bazy EN), potem zwykły klucz, potem EN/klicz.
526
- """
527
- if LANG == "pl":
528
- _pl = T.get("pl", {})
529
- msg = _pl.get(key + "_pl") or _pl.get(key) or T["en"].get(key, key)
530
- else:
531
- msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
532
- if args or kwargs:
533
- return msg.format(*args, **kwargs)
534
- return msg
535
-
536
-
537
-def _ask_confirm() -> bool:
538
- """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
539
-
540
- EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
541
- panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
542
- (inaczej input() rzuca EOFError i pag pada tracebackiem).
543
- Enter → tak (domyślne Y/n).
544
- """
545
- if os.environ.get("PAG_YES", "") == "1":
546
- print(_("continue_q") + " t (--yes)")
547
- return True
548
- try:
549
- ans = input(_("continue_q")).strip().lower()
550
- except (EOFError, KeyboardInterrupt):
551
- print(f"\n ⚠ {_('no_tty')}")
552
- return False
553
- return not ans or ans in ("t", "y")
554
-
555
-
556
-# =============================================================================
557
-# ŚCIEŻKI
558
-# =============================================================================
559
-PAG_ROOT = os.environ.get("PAG_ROOT", "/")
560
-PAG_DB = "/var/lib/pag"
561
-PAG_CACHE = "/var/cache/pag"
562
-PAG_CONF = "/etc/pag"
563
-REPO_CACHE = "/var/cache/pag/repos"
564
-REPOS_CONF = "/etc/pag/repos.conf"
565
-REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
566
-INSTALLED_DB = "/var/lib/pag/installed.json"
567
-FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
568
-WORLD_FILE = "/var/lib/pag/world"
569
-PINNED_FILE = "/var/lib/pag/pinned.json"
570
-HISTORY_FILE = "/var/lib/pag/history.json"
571
-LOCK_FILE = "/var/lib/pag/pag.lock"
572
-STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
573
-PKG_EXT = ".pag"
574
-REPO_CACHE_TTL = 3600
575
-MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
576
-ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
577
-
578
-# Bezpieczeństwo / audyt
579
-AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
580
-TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
581
-HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
582
-
583
-# =============================================================================
584
-# IMMUTABLE OS – DEPLOYMENTY
585
-# =============================================================================
586
-# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
587
-# /var, /etc, /home są współdzielone między deploymentami.
588
-#
589
-# STRUKTURA:
590
-# /.deployments/
591
-# active → 20260723T120000 (symlink do aktywnego)
592
-# 20260723T120000/
593
-# usr/ bin/ lib/ lib64/ ... (pełny system)
594
-# var → /var (symlink do współdzielonego)
595
-# etc → /etc
596
-# home → /home
597
-# ...
598
-#
599
-# Jak to działa:
600
-# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
601
-# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
602
-# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
603
-# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
604
-# =============================================================================
605
-
606
-DEPLOYMENTS_DIR = "/.deployments"
607
-ACTIVE_LINK = "/.deployments/active"
608
-DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
609
-
610
-# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
611
-SHARED_PATHS = {
612
- "/var", "/etc", "/home", "/root", "/tmp", "/run",
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",
618
- "/.deployments", "/.pag_staging",
619
-}
620
-
621
-def _is_shared_path(rel: str) -> bool:
622
- """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
623
- for sp in SHARED_PATHS:
624
- if rel == sp or rel.startswith(sp + "/"):
625
- return True
626
- return False
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
-
636
-# Wrażliwa konfiguracja auth/systemowa – NIGDY nie nadpisujemy jej po cichu.
637
-# Nadpisanie /etc/pam.d/system-* przez pakiet (np. shadow) potrafi zablokować
638
-# logowanie/sudo („account validation failure”); dla tych ścieżek zawsze robimy
639
-# .pacnew, gdy treść na dysku różni się od tej z pakietu.
640
-_SENSITIVE_CONFIG_RE = re.compile(
641
- r'^(pam\.d/|security/|sudoers$|sudoers\.d/|shadow$|gshadow$|passwd$|group$|'
642
- r'login\.defs$|nsswitch\.conf$|default/useradd$)')
643
-
644
-def _is_sensitive_config(rel: str) -> bool:
645
- """Czy rel to wrażliwy plik /etc (PAM, sudoers, shadow, login.defs…)?"""
646
- p = rel.lstrip("/")
647
- if not p.startswith("etc/"):
648
- return False
649
- return bool(_SENSITIVE_CONFIG_RE.match(p[4:]))
650
-
651
-def _get_deployment_root() -> str:
652
- """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
653
- if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
654
- return PAG_ROOT
655
- if os.path.islink(ACTIVE_LINK):
656
- return os.readlink(ACTIVE_LINK)
657
- if os.path.isdir(ACTIVE_LINK):
658
- return ACTIVE_LINK
659
- # Brak deploymentów – użyj /
660
- return PAG_ROOT
661
-
662
-def _load_deployments() -> List[dict]:
663
- """Wczytuje historię deploymentów."""
664
- if not os.path.exists(DEPLOYMENTS_DB):
665
- return []
666
- try:
667
- return json.load(open(DEPLOYMENTS_DB))
668
- except Exception:
669
- return []
670
-
671
-def _save_deployments(deployments: List[dict]):
672
- os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
673
- json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
674
-
675
-def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
676
- """
677
- Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
678
- Zwraca (deployment_dir, deployment_id).
679
- """
680
- deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
681
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
682
- os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
683
-
684
- active = _get_deployment_root()
685
-
686
- if os.path.isdir(active) and active != PAG_ROOT:
687
- # Trójstopniowa strategia kopiowania deploymentu:
688
- # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
689
- # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
690
- # 3. zwykłe cp (ostateczność) → pełna kopia
691
- print(f" ⚡ Kopiowanie aktywnego deploymentu...")
692
- copied = False
693
- for method, cmd, label in [
694
- ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
695
- ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
696
- ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
697
- ]:
698
- try:
699
- subprocess.run(cmd, check=True, timeout=600, capture_output=True)
700
- print(f" ✅ Deployment: {deploy_id} ({label})")
701
- copied = True
702
- break
703
- except subprocess.CalledProcessError:
704
- if method == "copy":
705
- raise # ostatnia deska – niech leci wyjątek
706
- continue
707
- if not copied:
708
- raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
709
- else:
710
- # Pierwszy deployment – tylko katalogi szkieletowe
711
- # /boot celowo POMINIĘTE – jest współdzielone (SHARED_PATHS); poniższa
712
- # pętla utworzy w deploymencie symlink boot → /boot.
713
- for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/opt"]:
714
- if os.path.isdir(d):
715
- dest = os.path.join(deploy_dir, d.lstrip("/"))
716
- os.makedirs(dest, exist_ok=True)
717
- print(f" ✅ Pierwszy deployment: {deploy_id}")
718
-
719
- # Utwórz symlinki do współdzielonych katalogów
720
- for sp in SHARED_PATHS:
721
- link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
722
- if not os.path.lexists(link_dst) and os.path.isdir(sp):
723
- os.symlink(sp, link_dst)
724
-
725
- # Zapisz w bazie deploymentów
726
- deployments = _load_deployments()
727
- deployments.append({
728
- "id": deploy_id,
729
- "action": action,
730
- "packages": pkg_names,
731
- "timestamp": datetime.now().isoformat(),
732
- "active": True,
733
- })
734
- # Oznacz poprzednie jako nieaktywne
735
- for d in deployments[:-1]:
736
- d["active"] = False
737
- _save_deployments(deployments)
738
-
739
- return deploy_dir, deploy_id
740
-
741
-def _switch_deployment(deploy_dir: str) -> bool:
742
- """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
743
- tmp_link = ACTIVE_LINK + ".new"
744
- if os.path.lexists(tmp_link):
745
- os.remove(tmp_link)
746
- os.symlink(deploy_dir, tmp_link)
747
- os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
748
- return True
749
-
750
-DEFAULT_REPOS = [
751
- "https://repo.paganlinux.eu/stable/",
752
-]
753
-
754
-# =============================================================================
755
-# INICJALIZACJA
756
-# =============================================================================
757
-
758
-def ensure_dirs():
759
- for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
760
- os.makedirs(d, exist_ok=True)
761
- for f, default in [
762
- (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
763
- (INSTALLED_DB, "{}"),
764
- (PINNED_FILE, "{}"),
765
- (HISTORY_FILE, "[]"),
766
- ]:
767
- if not os.path.exists(f):
768
- with open(f, "w") as fh: fh.write(default)
769
- if not os.path.exists(WORLD_FILE):
770
- Path(WORLD_FILE).touch()
771
- if not os.path.exists(GPG_HOME):
772
- os.makedirs(GPG_HOME, exist_ok=True)
773
- os.chmod(GPG_HOME, 0o700)
774
- _gpg_run("--list-keys", capture_output=True)
775
- # Inicjalizuj SQLite
776
- _db_init()
777
- # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
778
- if os.path.isdir(STAGING_DIR):
779
- for entry in os.listdir(STAGING_DIR):
780
- if entry == "backups":
781
- continue # backupy starych wersji – potrzebne do `pag rollback`
782
- path = os.path.join(STAGING_DIR, entry)
783
- try:
784
- if os.path.isfile(path) or os.path.islink(path):
785
- os.unlink(path)
786
- elif os.path.isdir(path):
787
- shutil.rmtree(path, ignore_errors=True)
788
- except OSError:
789
- pass
790
-
791
-# =============================================================================
792
-# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
793
-# =============================================================================
794
-
795
-from contextlib import contextmanager
796
-
797
-@contextmanager
798
-def _db_session(readonly: Optional[bool] = None):
799
- """Context manager – gwarantuje zamknięcie połączenia.
800
-
801
- Gdy katalog bazy nie jest zapisywalny (np. komenda read-only uruchomiona
802
- jako zwykły user), otwieramy połączenie w trybie read-only. Inaczej
803
- `PRAGMA journal_mode=WAL` próbuje pisać i kończy się błędem
804
- „attempt to write a readonly database” zamiast zwrócić wynik.
805
- """
806
- if readonly is None:
807
- readonly = not os.access(os.path.dirname(FILES_DB_SQL) or ".", os.W_OK)
808
- if readonly:
809
- conn = sqlite3.connect(f"file:{FILES_DB_SQL}?mode=ro", uri=True, timeout=15)
810
- else:
811
- conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
812
- conn.execute("PRAGMA journal_mode=WAL")
813
- conn.execute("PRAGMA synchronous=NORMAL")
814
- conn.execute("PRAGMA foreign_keys=ON")
815
- conn.execute("PRAGMA busy_timeout=15000")
816
- conn.row_factory = sqlite3.Row
817
- try:
818
- yield conn
819
- if not readonly:
820
- conn.commit()
821
- except Exception:
822
- if not readonly:
823
- conn.rollback()
824
- raise
825
- finally:
826
- conn.close()
827
-
828
-
829
-def _db_init():
830
- """Tworzy tabele SQLite jeśli nie istnieją."""
831
- with _db_session() as db:
832
- db.execute("""
833
- CREATE TABLE IF NOT EXISTS files (
834
- id INTEGER PRIMARY KEY AUTOINCREMENT,
835
- path TEXT NOT NULL,
836
- package TEXT NOT NULL,
837
- sha256 TEXT,
838
- size INTEGER,
839
- is_symlink INTEGER DEFAULT 0,
840
- symlink_target TEXT,
841
- UNIQUE(path, package)
842
- )
843
- """)
844
- db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
845
- db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
846
- db.execute("""
847
- CREATE TABLE IF NOT EXISTS file_checksums (
848
- path TEXT PRIMARY KEY,
849
- sha256 TEXT NOT NULL,
850
- installed_at TEXT
851
- )
852
- """)
853
- db.commit()
854
-
855
-def _db_record_files(pkg_name: str, files: List[dict]):
856
- """Zapisuje pliki do SQLite (obsługuje symlinki)."""
857
- with _db_session() as db:
858
- # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
859
- try:
860
- db.execute("BEGIN IMMEDIATE")
861
- except sqlite3.OperationalError:
862
- pass # transakcja już otwarta (implicit)
863
- db.executemany(
864
- "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
865
- "VALUES (?,?,?,?,?,?)",
866
- [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
867
- f.get("is_symlink", 0), f.get("symlink_target", ""))
868
- for f in files]
869
- )
870
- db.executemany(
871
- "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
872
- [(f["path"], f.get("sha256",""), datetime.now().isoformat())
873
- for f in files if f.get("sha256")]
874
- )
875
-
876
-def _db_get_package_files(pkg_name: str) -> List[str]:
877
- with _db_session() as db:
878
- return [r["path"] for r in db.execute(
879
- "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
880
- )]
881
-
882
-def _db_get_file_owners(filepath: str) -> List[str]:
883
- """Zwraca listę pakietów będących właścicielami pliku."""
884
- with _db_session() as db:
885
- return [r["package"] for r in db.execute(
886
- "SELECT package FROM files WHERE path=?", (filepath,)
887
- )]
888
-
889
-def _db_remove_package_files(pkg_name: str):
890
- with _db_session() as db:
891
- db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
892
- db.commit()
893
-
894
-def _db_get_all_file_checksums() -> Dict[str, str]:
895
- with _db_session() as db:
896
- return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
897
-
898
-def _db_get_package_checksums(pkg_name: str) -> Dict[str, str]:
899
- """Sumy SHA256 zapisane dla plików należących do pakietu.
900
-
901
- Używane do wykrycia, czy użytkownik zmodyfikował plik konfiguracyjny
902
- (porównanie z sumą z chwili instalacji) – serce mechanizmu .pacnew/.pacsave.
903
- """
904
- with _db_session() as db:
905
- return {r["path"]: r["sha256"] for r in db.execute(
906
- "SELECT fc.path AS path, fc.sha256 AS sha256 FROM file_checksums fc "
907
- "JOIN files f ON f.path = fc.path WHERE f.package=?", (pkg_name,))}
908
-
909
-
910
-def _db_count_files() -> int:
911
- with _db_session() as db:
912
- return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
913
-
914
-# =============================================================================
915
-# BLOKADA
916
-# =============================================================================
917
-
918
-class DatabaseLock:
919
- """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
920
- ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
921
- śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
922
- i pag odmawiał działania na zawsze („baza zablokowana”).
923
- """
924
- def __init__(self):
925
- self._f = None
926
- def __enter__(self):
927
- os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
928
- self._f = open(LOCK_FILE, "w")
929
- try:
930
- # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
931
- fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
932
- except BlockingIOError:
933
- print(f"❌ {_('db_locked')}", file=sys.stderr)
934
- print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
935
- sys.exit(1)
936
- self._f.write(str(os.getpid()))
937
- self._f.flush()
938
- return self
939
- def __exit__(self, *args):
940
- if self._f:
941
- try:
942
- fcntl.flock(self._f, fcntl.LOCK_UN)
943
- except OSError:
944
- pass
945
- self._f.close()
946
- self._f = None
947
- # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
948
- # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
949
- # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
950
-
951
-
952
-class SelfUpdateLock:
953
- """Blokada podmiany binarki pag podczas self-update.
954
-
955
- Niezależna od DatabaseLock (inny plik) – cmd_self_update może zostać
956
- wywołane poza globalną blokadą (np. z zewnętrznego skryptu), a dwie
957
- równoległe aktualizacje pisałyby do tego samego `dst.new`; `os.replace`
958
- mógłby wtedy podmienić plik w trakcie wykonywania (uszkodzony pag).
959
- """
960
- def __init__(self):
961
- self._f = None
962
-
963
- def __enter__(self):
964
- lock_path = LOCK_FILE + ".self-update"
965
- os.makedirs(os.path.dirname(lock_path), exist_ok=True)
966
- self._f = open(lock_path, "w")
967
- try:
968
- fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
969
- except BlockingIOError:
970
- self._f.close()
971
- self._f = None
972
- print("❌ Inna aktualizacja pag jest w toku – spróbuj ponownie później.",
973
- file=sys.stderr)
974
- sys.exit(1)
975
- self._f.write(str(os.getpid()))
976
- self._f.flush()
977
- return self
978
-
979
- def __exit__(self, *args):
980
- if self._f:
981
- try:
982
- fcntl.flock(self._f, fcntl.LOCK_UN)
983
- except OSError:
984
- pass
985
- self._f.close()
986
- self._f = None
987
-
988
-# =============================================================================
989
-# POMOCNICZE
990
-# =============================================================================
991
-
992
-
993
-_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
994
- "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
995
- # Pliki wewnętrzne paczki .pkg.tar.xz
996
- "metadata.json", "data.tar.xz", "hooks/",
997
- "sums.json")
998
-
999
-def _check_path_safety(name: str) -> bool:
1000
- # Normalizuj – usuń leading ./
1001
- if name.startswith("./"):
1002
- name = name[2:]
1003
- if name in (".", ""):
1004
- return True
1005
- # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
1006
- # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
1007
- norm = name.lstrip("/")
1008
- for prefix in _ALLOWED_PREFIXES:
1009
- p = prefix.lstrip("/").rstrip("/")
1010
- if norm == p or norm.startswith(p + "/"):
1011
- return True
1012
- return False
1013
-
1014
-
1015
-def _validate_pkg_name(name):
1016
- return bool(ALLOWED_PKG_RE.match(name))
1017
-
1018
-
1019
-
1020
-def _audit(msg):
1021
- from datetime import datetime, timezone
1022
- os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
1023
- with open(AUDIT_LOG, "a") as f:
1024
- f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
1025
-
1026
-def _strip_suid(path):
1027
- try:
1028
- st = os.stat(path)
1029
- if st.st_mode & 0o4000:
1030
- os.chmod(path, st.st_mode & ~0o4000)
1031
- print(f" {_("sec_suid", path=path)}")
1032
- except OSError:
1033
- pass
1034
-
1035
-def _check_downgrade(pkg_name, new_ver, installed_db):
1036
- if pkg_name in installed_db:
1037
- old = installed_db[pkg_name].get("version", "0")
1038
- if new_ver < old:
1039
- print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
1040
- return False
1041
- return True
1042
-
1043
-def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
1044
- """
1045
- Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
1046
-
1047
- Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
1048
- oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
1049
- zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
1050
-
1051
- Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
1052
- prostym sprawdzaniu stringa:
1053
- - Blokuje ścieżki absolutne i z '..' (path traversal)
1054
- - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
1055
- - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
1056
- rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
1057
- - Zachowuje oryginalne uprawnienia plików
1058
- """
1059
- dest_real = os.path.realpath(dest)
1060
- os.makedirs(dest_real, exist_ok=True)
1061
-
1062
- def _target_within(path: str) -> bool:
1063
- try:
1064
- return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
1065
- except ValueError:
1066
- # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
1067
- return False
1068
-
1069
- for member in tar.getmembers():
1070
- name = member.name
1071
-
1072
- # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
1073
- if name.startswith('/'):
1074
- continue
1075
- if '..' in name.split('/'):
1076
- continue
1077
- # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
1078
- if '\x00' in name or '\\' in name:
1079
- continue
1080
- if not _check_path_safety(name):
1081
- print(f" BLOCKED: {name}")
1082
- continue
1083
-
1084
- target = os.path.join(dest, name)
1085
-
1086
- # --- Ochrona na podstawie finalnej ścieżki ---
1087
- # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
1088
- # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
1089
- if not _target_within(target):
1090
- print(f" BLOCKED (escape): {name}")
1091
- continue
1092
-
1093
- # --- Ochrona dla symlinków i hardlinków ---
1094
- if member.issym() or member.islnk():
1095
- link = member.linkname
1096
- # Szybkie odrzucenie linków absolutnych / z '..'
1097
- if link.startswith('/') or '..' in link.split('/'):
1098
- continue
1099
- # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
1100
- link_target = os.path.join(os.path.dirname(target), link)
1101
- if not _target_within(link_target):
1102
- print(f" BLOCKED (link escape): {name} -> {link}")
1103
- continue
1104
-
1105
- # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
1106
- # `filter=` (inaczej DeprecationWarning, a w 3.14+ błąd).
1107
- # UWAGA: 'fully_trusted' CELOWO pomija wbudowane filtry bezpieczeństwa
1108
- # Pythona – to nie przeoczenie. Nasza walidacja powyżej (path traversal,
1109
- # NUL/backslash, escape przez symlink, linki wychodzące poza dest) jest
1110
- # równoważna lub ostrzejsza, a 'fully_trusted' pozwala zachować bity
1111
- # SUID/SGID/sticky, które filtr 'data' by usunął (np. /usr/bin/sudo).
1112
- # SUID jest i tak zdejmowany przez _strip_suid() tuż po rozpakowaniu.
1113
- try:
1114
- if hasattr(tarfile, 'data_filter'):
1115
- # Python 3.12+
1116
- tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
1117
- filter='fully_trusted')
1118
- else:
1119
- tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
1120
- except Exception as e:
1121
- print(f" ⚠ Nie rozpakowano {name}: {e}")
1122
- continue
1123
- _strip_suid(target)
1124
-
1125
-
1126
-def _sha256_file(path: str) -> str:
1127
- """SHA256 pliku. Na Python ≥3.11 używa hashlib.file_digest (pętla w C);
1128
- starsze wersje mają fallback z pętlą chunków."""
1129
- with open(path, "rb") as f:
1130
- if hasattr(hashlib, "file_digest"):
1131
- return hashlib.file_digest(f, "sha256").hexdigest()
1132
- h = hashlib.sha256()
1133
- for chunk in iter(lambda: f.read(65536), b""):
1134
- h.update(chunk)
1135
- return h.hexdigest()
1136
-
1137
-def _split_version(v: str):
1138
- """Rozdziela wersję na (release_parts, prerelease_parts).
1139
-
1140
- Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
1141
- """
1142
- v = v.strip().lower().lstrip("v")
1143
- # build metadata po '+' jest ignorowane przy porównywaniu (semver)
1144
- v = v.split("+", 1)[0]
1145
- # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
1146
- if "-" in v:
1147
- rel, pre = v.split("-", 1)
1148
- elif "_" in v:
1149
- rel, pre = v.split("_", 1)
1150
- else:
1151
- rel, pre = v, ""
1152
- nums = []
1153
- for part in rel.split("."):
1154
- m = re.match(r"(\d+)", part)
1155
- nums.append(int(m.group(1)) if m else 0)
1156
- pre_parts = [p for p in pre.split(".") if p]
1157
- return nums, pre_parts
1158
-
1159
-
1160
-def _cmp_pre(a, b):
1161
- """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
1162
- for i in range(max(len(a), len(b))):
1163
- if i >= len(a):
1164
- return -1 # krótszy prerelease jest niższy
1165
- if i >= len(b):
1166
- return 1
1167
- ia, ib = a[i], b[i]
1168
- if ia == ib:
1169
- continue
1170
- na, nb = ia.isdigit(), ib.isdigit()
1171
- if na and nb:
1172
- return 1 if int(ia) > int(ib) else -1
1173
- if na != nb:
1174
- return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
1175
- return 1 if ia > ib else -1
1176
- return 0
1177
-
1178
-
1179
-def _cmp_version(a: str, b: str) -> int:
1180
- """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
1181
- a_rel, a_pre = _split_version(a)
1182
- b_rel, b_pre = _split_version(b)
1183
- # Porównaj część release (brakujące komponenty traktuj jako 0)
1184
- for i in range(max(len(a_rel), len(b_rel))):
1185
- xa = a_rel[i] if i < len(a_rel) else 0
1186
- xb = b_rel[i] if i < len(b_rel) else 0
1187
- if xa != xb:
1188
- return 1 if xa > xb else -1
1189
- # Część release równa → decyduje prerelease.
1190
- # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
1191
- if not a_pre and not b_pre:
1192
- return 0
1193
- if not a_pre:
1194
- return 1
1195
- if not b_pre:
1196
- return -1
1197
- return _cmp_pre(a_pre, b_pre)
1198
-
1199
-
1200
-def _version_newer(a: str, b: str) -> bool:
1201
- """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1202
- try:
1203
- return _cmp_version(a, b) > 0
1204
- except Exception:
1205
- return a != b
1206
-
1207
-def load_json(path):
1208
- try:
1209
- with open(path) as f:
1210
- return json.load(f)
1211
- except (FileNotFoundError, json.JSONDecodeError):
1212
- return {}
1213
-
1214
-def save_json(path, data):
1215
- with open(path, "w") as f:
1216
- json.dump(data, f, indent=2)
1217
-
1218
-class PackageInfo:
1219
- __slots__ = ("name","version","release","description","dependencies",
1220
- "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1221
- "provides_so","requires_so")
1222
- def __init__(self, d, repo=""):
1223
- self.name = d.get("name","?")
1224
- self.version = d.get("version","0")
1225
- self.release = d.get("release", 1)
1226
- self.description = d.get("description","")
1227
- self.dependencies = d.get("dependencies", d.get("depends", []))
1228
- self.size_bytes = d.get("size",0)
1229
- self.sha256 = d.get("sha256","")
1230
- self.gpg_fp = d.get("gpg_fingerprint","")
1231
- self.repo_url = repo
1232
- self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1233
- self.provides = d.get("provides", []) or []
1234
- self.license = d.get("license", []) or []
1235
- self.provides_so = d.get("provides_so", []) or []
1236
- self.requires_so = d.get("requires_so", []) or []
1237
-
1238
-# =============================================================================
1239
-# REPOZYTORIA (cache, ETag, GPG)
1240
-# =============================================================================
1241
-
1242
-def _parse_repos_config():
1243
- """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1244
-
1245
- Format linii: <url> [fingerprint]
1246
- Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1247
- podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1248
- pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1249
-
1250
- Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1251
- wygodne dodawanie repo bez dotykania głównego repos.conf
1252
- (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1253
- """
1254
- entries = []
1255
-
1256
- def _read_lines(path):
1257
- if not os.path.exists(path):
1258
- return
1259
- for line in open(path):
1260
- line = line.strip()
1261
- if not line or line.startswith("#"):
1262
- continue
1263
- parts = line.split()
1264
- url = parts[0].rstrip("/")
1265
- fp = parts[1].lower() if len(parts) > 1 else ""
1266
- entries.append({"url": url, "fingerprint": fp or None})
1267
-
1268
- # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1269
- _read_lines(REPOS_CONF)
1270
- # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1271
- if os.path.isdir(REPOS_DIR):
1272
- for drop in sorted(os.listdir(REPOS_DIR)):
1273
- if drop.endswith(".conf"):
1274
- _read_lines(os.path.join(REPOS_DIR, drop))
1275
-
1276
- # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1277
- seen, unique = set(), []
1278
- for e in entries:
1279
- if e["url"] not in seen:
1280
- seen.add(e["url"])
1281
- unique.append(e)
1282
-
1283
- if not unique:
1284
- for url in DEFAULT_REPOS:
1285
- unique.append({"url": url, "fingerprint": None})
1286
- return unique
1287
-
1288
-
1289
-def get_repos():
1290
- return [e["url"] for e in _parse_repos_config()]
1291
-
1292
-
1293
-def _repo_pinned_fp(repo_url):
1294
- """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1295
- by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1296
- if by_url.get(repo_url):
1297
- return by_url[repo_url]
1298
- db = _load_trust_db()
1299
- fp = db.get(repo_url)
1300
- return fp.lower() if fp else None
1301
-
1302
-def _repo_cache_path(url):
1303
- return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1304
-
1305
-def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1306
-def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1307
-
1308
-# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1309
-# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1310
-# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1311
-_cache_ro_notice_done = False
1312
-
1313
-def _cache_ro_notice():
1314
- global _cache_ro_notice_done
1315
- if _cache_ro_notice_done:
1316
- return
1317
- _cache_ro_notice_done = True
1318
- print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1319
-
1320
-def _load_repo_cache(cp: str) -> Optional[list]:
1321
- """Wczytuje cache indeksu repo; zwraca None gdy brak albo uszkodzony.
1322
-
1323
- Partial write (crash w trakcie zapisu) mógł zostawić obcięty JSON. Zamiast
1324
- zwracać pustą listę pakietów (użytkownik myśli, że repo jest puste)
1325
- sygnalizujemy None – wywołujący ponowi pobranie albo pokaże ostrzeżenie."""
1326
- if not os.path.exists(cp):
1327
- return None
1328
- try:
1329
- with open(cp, "r", encoding="utf-8") as fh:
1330
- data = json.load(fh)
1331
- pkgs = data.get("packages")
1332
- if not isinstance(pkgs, list):
1333
- raise ValueError("brak listy 'packages'")
1334
- return pkgs
1335
- except (OSError, ValueError) as e:
1336
- print(f" ⚠ Uszkodzony cache indeksu {cp}: {e}", file=sys.stderr)
1337
- return None
1338
-
1339
-
1340
-def fetch_repo_index(repo_url, force=False):
1341
- cp = _repo_cache_path(repo_url)
1342
- ep = _repo_etag_path(repo_url)
1343
- tp = _repo_ts_path(repo_url)
1344
-
1345
- if not force and os.path.exists(cp) and os.path.exists(tp):
1346
- try:
1347
- if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1348
- cached = _load_repo_cache(cp)
1349
- if cached is not None:
1350
- return cached
1351
- # uszkodzony cache – spróbuj odświeżyć z sieci
1352
- except (OSError, ValueError):
1353
- pass
1354
-
1355
- # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1356
- # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1357
- # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1358
- # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1359
- if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1360
- if force:
1361
- print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1362
- file=sys.stderr)
1363
- return None
1364
- _cache_ro_notice()
1365
- cached = _load_repo_cache(cp)
1366
- if cached is not None:
1367
- return cached
1368
- return None
1369
-
1370
- headers = {"User-Agent": "pag/3.0"}
1371
- if os.path.exists(tp) and not force:
1372
- try:
1373
- lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1374
- # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1375
- try:
1376
- old_locale = locale.setlocale(locale.LC_TIME)
1377
- locale.setlocale(locale.LC_TIME, 'C')
1378
- headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1379
- locale.setlocale(locale.LC_TIME, old_locale)
1380
- except (locale.Error, ValueError):
1381
- # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1382
- headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1383
- except: pass
1384
- if os.path.exists(ep) and not force:
1385
- try: headers["If-None-Match"] = open(ep).read().strip()
1386
- except: pass
1387
-
1388
- # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1389
- try:
1390
- req = Request(f"{repo_url}/repo.json", headers=headers)
1391
- with urlopen(req, timeout=30) as resp:
1392
- etag = resp.headers.get("ETag","")
1393
- raw = resp.read()
1394
- data = json.loads(raw.decode())
1395
- except HTTPError as e:
1396
- if e.code == 304:
1397
- # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1398
- try:
1399
- open(tp,"w").write(str(time.time()))
1400
- except OSError:
1401
- pass
1402
- cached = _load_repo_cache(cp)
1403
- if cached is not None:
1404
- return cached
1405
- # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1406
- print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1407
- return None
1408
- except Exception as e:
1409
- print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1410
- return _load_repo_cache(cp)
1411
-
1412
- # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1413
- # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1414
- # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1415
- # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1416
- # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1417
- tmp_path = cp + ".tmp"
1418
- try:
1419
- with open(tmp_path, "wb") as f:
1420
- f.write(raw)
1421
- if not _verify_repo_sig(repo_url, tmp_path):
1422
- return None # weryfikacja nie powiodła się – stary cache zostaje
1423
- os.replace(tmp_path, cp)
1424
- # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1425
- for _ext in (".asc", ".sig"):
1426
- if os.path.exists(tmp_path + _ext):
1427
- try:
1428
- os.replace(tmp_path + _ext, cp + _ext)
1429
- except OSError:
1430
- pass
1431
- break
1432
- if etag:
1433
- try:
1434
- open(ep,"w").write(etag)
1435
- except OSError:
1436
- pass
1437
- try:
1438
- open(tp,"w").write(str(time.time()))
1439
- except OSError:
1440
- pass
1441
- return data.get("packages",[])
1442
- except OSError as e:
1443
- print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1444
- file=sys.stderr)
1445
- # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1446
- # to wciąż stare, zweryfikowane dane
1447
- return _load_repo_cache(cp)
1448
- finally:
1449
- for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1450
- try:
1451
- os.unlink(_p)
1452
- except OSError:
1453
- pass
1454
-
1455
-def _verify_repo_sig(repo_url, cache_path) -> bool:
1456
- """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1457
-
1458
- FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1459
- Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1460
-
1461
- Model zaufania (TOFU + pinning):
1462
- - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1463
- a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1464
- To świadomy kompromis wygody i bezpieczeństwa.
1465
- - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1466
- Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1467
- pag key-trust <repo_url> (po weryfikacji nowego klucza)
1468
- """
1469
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
1470
-
1471
- if not os.path.exists(GPG_HOME):
1472
- if insecure:
1473
- return True # brak GPG home – tryb insecure, akceptuj
1474
- print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1475
- print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1476
- os.remove(cache_path)
1477
- return False
1478
-
1479
- sig_path = cache_path + ".sig"
1480
- # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1481
- sig_data = None
1482
- sig_ext = ""
1483
- for ext in (".asc", ".sig"):
1484
- try:
1485
- req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1486
- with urlopen(req, timeout=15) as resp:
1487
- sig_data = resp.read()
1488
- sig_ext = ext
1489
- break
1490
- except Exception:
1491
- continue
1492
- if not sig_data:
1493
- if insecure:
1494
- return True # tryb insecure – akceptuj bez podpisu
1495
- print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1496
- print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1497
- os.remove(cache_path)
1498
- return False
1499
- sig_path = cache_path + sig_ext
1500
- with open(sig_path, "wb") as f:
1501
- f.write(sig_data)
1502
-
1503
- ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1504
- if not ok:
1505
- # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1506
- # jak apt) – gdy w keyringu brakuje klucza (No public key).
1507
- res = _gpg_run("--verify", sig_path, cache_path,
1508
- capture_output=True, text=True, timeout=30)
1509
- _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1510
- if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1511
- try:
1512
- with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1513
- keydata = r.read()
1514
- with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1515
- tmp.write(keydata)
1516
- tmp.flush()
1517
- _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1518
- os.unlink(tmp.name)
1519
- print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1520
- ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1521
- except Exception:
1522
- pass
1523
- if not ok:
1524
- if insecure:
1525
- print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1526
- return True
1527
- os.remove(cache_path)
1528
- if not shutil.which(GPG_BINARY):
1529
- print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1530
- print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1531
- else:
1532
- print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1533
- return False
1534
-
1535
- # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1536
- pinned = _repo_pinned_fp(repo_url)
1537
- if pinned:
1538
- if not fingerprint:
1539
- if insecure:
1540
- print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1541
- return True
1542
- os.remove(cache_path)
1543
- print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1544
- print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1545
- return False
1546
- if fingerprint != pinned.upper():
1547
- if insecure:
1548
- print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1549
- return True
1550
- os.remove(cache_path)
1551
- print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1552
- print(f" Oczekiwany: {pinned}")
1553
- print(f" Otrzymany: {fingerprint}")
1554
- print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1555
- return False
1556
- return True
1557
-
1558
- if fingerprint:
1559
- # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1560
- db = _load_trust_db()
1561
- if db.get(repo_url) != fingerprint:
1562
- _save_trust_db({**db, repo_url: fingerprint})
1563
- print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1564
- print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1565
- print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1566
- return True
1567
-
1568
-def fetch_all_packages(force=False):
1569
- all_pkgs = {}
1570
- for repo_url in get_repos():
1571
- pkgs = fetch_repo_index(repo_url, force)
1572
- if pkgs:
1573
- for pdata in pkgs:
1574
- name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1575
- pkg = PackageInfo(pdata, repo_url)
1576
- if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1577
- all_pkgs[name] = pkg
1578
- return all_pkgs
1579
-
1580
-# =============================================================================
1581
-# GPG
1582
-# =============================================================================
1583
-
1584
-def _verify_pkg_gpg(pkg_path, repo_url=None):
1585
- """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1586
-
1587
- FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1588
- Zwraca (passed: bool, message: str).
1589
- """
1590
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
1591
- # Brak gnupg = weryfikacja niemożliwa. Bez tej gałęzi użytkownik dostawał
1592
- # mylące „NIEPRAWIDŁOWY PODPIS GPG”, mimo że paczka i podpis są w porządku.
1593
- if not shutil.which(GPG_BINARY):
1594
- if insecure:
1595
- return True, "(gpg missing – PAG_INSECURE)"
1596
- return False, _("gpg_missing")
1597
- sig_path = pkg_path + ".sig"
1598
- if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1599
- sig_path = pkg_path + ".asc"
1600
-
1601
- if not os.path.exists(sig_path):
1602
- if insecure:
1603
- return True, "(no signature – PAG_INSECURE)"
1604
- return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1605
-
1606
- ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1607
- if not ok:
1608
- if insecure:
1609
- return True, "(invalid signature – PAG_INSECURE)"
1610
- return False, "NIEPRAWIDŁOWY PODPIS GPG"
1611
-
1612
- # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1613
- if repo_url:
1614
- pinned = _repo_pinned_fp(repo_url)
1615
- if pinned and fp and fp != pinned.upper():
1616
- if insecure:
1617
- return True, "(pkg signer mismatch – PAG_INSECURE)"
1618
- return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1619
-
1620
- return True, "GPG verified"
1621
-
1622
-def cmd_key_add(source):
1623
- ensure_dirs()
1624
- if not shutil.which(GPG_BINARY):
1625
- print(f"❌ {_('gpg_missing')}"); return 1
1626
- if source.startswith("http"):
1627
- try:
1628
- with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1629
- keydata = resp.read()
1630
- with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1631
- tmp.write(keydata); tmp.flush()
1632
- res = _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1633
- os.unlink(tmp.name)
1634
- if res.returncode != 0:
1635
- print(f"❌ {_('key_add_failed')}"); return 1
1636
- except Exception as e:
1637
- print(f"❌ Download error: {e}"); return 1
1638
- else:
1639
- res = _gpg_run("--import", source, capture_output=True, timeout=30)
1640
- if res.returncode != 0:
1641
- print(f"❌ {_('key_add_failed')}"); return 1
1642
- print(f"✅ {_('key_imported')}")
1643
-
1644
-def cmd_key_list():
1645
- if not shutil.which(GPG_BINARY):
1646
- print(f"❌ {_('gpg_missing')}"); return
1647
- if not os.path.exists(GPG_HOME):
1648
- print(_("no_keys")); return
1649
- result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1650
- capture_output=True, text=True, timeout=30)
1651
- if result.returncode != 0:
1652
- print(f"❌ {_('gpg_missing')}"); return
1653
- print(result.stdout or _("no_keys"))
1654
-
1655
-def cmd_key_remove(key_id):
1656
- _gpg_run("--batch", "--yes", "--delete-key", key_id,
1657
- capture_output=True, timeout=30)
1658
- print(f"✅ {_('key_removed', key_id)}")
1659
-
1660
-def _repo_signer_fp(repo_url):
1661
- """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1662
- repo_url = repo_url.rstrip("/")
1663
- try:
1664
- with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1665
- data = r.read()
1666
- except Exception:
1667
- return None
1668
- sig = None
1669
- sig_ext = ".asc"
1670
- for ext in (".asc", ".sig"):
1671
- try:
1672
- with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1673
- sig = r.read()
1674
- sig_ext = ext
1675
- break
1676
- except Exception:
1677
- continue
1678
- if not sig:
1679
- return None
1680
- with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1681
- tf.write(data); tf.flush()
1682
- data_path = tf.name
1683
- sig_path = data_path + sig_ext
1684
- try:
1685
- with open(sig_path, "wb") as f:
1686
- f.write(sig)
1687
- ok, fp = _gpg_verify_fp(sig_path, data_path)
1688
- finally:
1689
- for p in (data_path, sig_path):
1690
- try: os.unlink(p)
1691
- except OSError: pass
1692
- return fp if ok else None
1693
-
1694
-
1695
-def cmd_key_trust(repo_url):
1696
- """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1697
- repo_url = repo_url.rstrip("/")
1698
- print(f"🔐 Przypinam klucz repo {repo_url}...")
1699
- fp = _repo_signer_fp(repo_url)
1700
- if not fp:
1701
- print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1702
- print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1703
- return 1
1704
- db = _load_trust_db()
1705
- _save_trust_db({**db, repo_url: fp})
1706
- print(f" ✅ Przypięto {fp} dla {repo_url}")
1707
- print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1708
- return 0
1709
-
1710
-
1711
-def cmd_key_untrust(repo_url):
1712
- """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1713
- repo_url = repo_url.rstrip("/")
1714
- db = _load_trust_db()
1715
- if repo_url not in db:
1716
- print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1717
- return 0
1718
- del db[repo_url]
1719
- _save_trust_db(db)
1720
- print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1721
- return 0
1722
-
1723
-
1724
-def cmd_key_trusted():
1725
- """Listuje przypięte fingerprinty repozytoriów."""
1726
- db = _load_trust_db()
1727
- if not db:
1728
- print(_("no_keys"))
1729
- return
1730
- for url, fp in sorted(db.items()):
1731
- print(f" {url}\n {fp}")
1732
-
1733
-# =============================================================================
1734
-# ATOMOWA INSTALACJA (STAGING)
1735
-# =============================================================================
1736
-
1737
-def _safe_rename(src: str, dst: str) -> bool:
1738
- """
1739
- Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1740
- systemach plików (EXDEV), kopiuje + usuwa źródło.
1741
- """
1742
- try:
1743
- os.rename(src, dst)
1744
- return True
1745
- except OSError as e:
1746
- if e.errno == 18: # EXDEV – cross-device link
1747
- shutil.copy2(src, dst)
1748
- os.remove(src)
1749
- return True
1750
- raise
1751
-
1752
-
1753
-def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1754
- staging: str, journal: list, installed_files: list,
1755
- deploy_dir: str = "", backup_dir: str = "",
1756
- backup_journal: Optional[list] = None,
1757
- old_checksums: Optional[dict] = None) -> bool:
1758
- """
1759
- Instaluje pojedynczy plik (zwykły lub symlink).
1760
- Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1761
-
1762
- Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1763
- do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1764
-
1765
- Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1766
- stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1767
-
1768
- old_checksums: {ścieżka: sha256 z chwili instalacji}. Gdy podane i plik /etc
1769
- został zmodyfikowany przez użytkownika, nowa wersja ląduje jako .pacnew
1770
- (stary plik NIE jest ruszany) – wzorzec jak w pacmanie.
1771
- """
1772
- # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1773
- if deploy_dir and _is_shared_path("/" + rel):
1774
- dst_root = PAG_ROOT
1775
- elif deploy_dir:
1776
- dst_root = deploy_dir
1777
- else:
1778
- dst_root = PAG_ROOT
1779
-
1780
- dst = os.path.join(dst_root, rel)
1781
-
1782
- # --- SYMLINK ---
1783
- if os.path.islink(src):
1784
- link_target = os.readlink(src)
1785
- # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1786
- expected = sums.get("/" + rel, "")
1787
- if expected:
1788
- link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1789
- if expected and link_hash != expected:
1790
- return False
1791
-
1792
- os.makedirs(os.path.dirname(dst), exist_ok=True)
1793
- # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1794
- if backup_dir and backup_journal is not None and os.path.lexists(dst):
1795
- try:
1796
- backup_path = os.path.join(backup_dir, rel)
1797
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1798
- os.replace(dst, backup_path)
1799
- backup_journal.append((backup_path, "/" + rel))
1800
- journal.append(("backup", backup_path, dst))
1801
- except OSError:
1802
- pass
1803
- # Jeśli docelowy symlink już istnieje, usuń go
1804
- if os.path.islink(dst) or os.path.exists(dst):
1805
- os.remove(dst)
1806
- os.symlink(link_target, dst)
1807
- journal.append(("symlink", "", dst))
1808
- installed_files.append({
1809
- "path": "/" + rel,
1810
- "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1811
- "size": len(link_target),
1812
- "is_symlink": True,
1813
- "symlink_target": link_target,
1814
- })
1815
- return True
1816
-
1817
- # --- ZWYKŁY PLIK ---
1818
- # Oblicz SHA256
1819
- try:
1820
- file_sha = _sha256_file(src)
1821
- except Exception:
1822
- file_sha = ""
1823
-
1824
- # Weryfikuj sums.json
1825
- expected = sums.get("/" + rel, "")
1826
- if expected and file_sha and file_sha != expected:
1827
- return False
1828
-
1829
- # --- .pacnew: NIE nadpisuj pliku konfiguracyjnego, którego nie wolno zgubić ---
1830
- # Robimy `<plik>.pacnew`, gdy:
1831
- # a) plik jest śledzony i użytkownik go zmodyfikował (hash != zapisanej sumy),
1832
- # b) to wrażliwa konfiguracja (/etc/pam.d, /etc/security, sudoers, shadow…),
1833
- # a treść na dysku różni się od tej z pakietu – chroni auth przed
1834
- # cichym nadpisaniem przez `install`/`upgrade` (np. przez shadow).
1835
- # NIE robimy .pacnew, jeśli plik na dysku jest identyczny z nowym albo był
1836
- # niezmieniony od instalacji (wtedy nadpisanie jest bezpieczne).
1837
- old_sha = (old_checksums or {}).get("/" + rel, "")
1838
- if file_sha and _is_config_path(rel) and os.path.isfile(dst) and not os.path.islink(dst):
1839
- tracked_modified = bool(old_sha) and file_sha != old_sha
1840
- if tracked_modified or _is_sensitive_config(rel):
1841
- try:
1842
- cur_sha = _sha256_file(dst)
1843
- except OSError:
1844
- cur_sha = ""
1845
- if cur_sha and cur_sha != file_sha and not (old_sha and cur_sha == old_sha):
1846
- pacnew = dst + ".pacnew"
1847
- try:
1848
- _safe_rename(src, pacnew)
1849
- try:
1850
- os.chown(pacnew, 0, 0)
1851
- except (OSError, PermissionError):
1852
- pass
1853
- # Journal (do cofnięcia przy nieudanej transakcji) – ale NIE
1854
- # zapisujemy .pacnew w bazie plików: to artefakt użytkownika.
1855
- journal.append(("file", src, pacnew))
1856
- print(f" ⚠ {_('conf_pacnew', path=pacnew)}")
1857
- return True
1858
- except OSError:
1859
- pass # nie udało się – kontynuuj normalną instalację
1860
-
1861
- # Utwórz katalog docelowy
1862
- os.makedirs(os.path.dirname(dst), exist_ok=True)
1863
-
1864
- # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1865
- if backup_dir and backup_journal is not None and os.path.lexists(dst):
1866
- try:
1867
- backup_path = os.path.join(backup_dir, rel)
1868
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1869
- os.replace(dst, backup_path)
1870
- backup_journal.append((backup_path, "/" + rel))
1871
- journal.append(("backup", backup_path, dst))
1872
- except OSError:
1873
- pass
1874
-
1875
- # Atomowe przeniesienie (z fallbackiem dla cross-device).
1876
- # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1877
- _safe_rename(src, dst)
1878
-
1879
- # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1880
- try:
1881
- os.chown(dst, 0, 0)
1882
- except (OSError, PermissionError):
1883
- # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1884
- pass
1885
-
1886
- journal.append(("file", src, dst))
1887
- installed_files.append({
1888
- "path": "/" + rel,
1889
- "sha256": file_sha,
1890
- "size": os.path.getsize(dst),
1891
- "is_symlink": False,
1892
- })
1893
- return True
1894
-
1895
-
1896
-def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1897
- backup_dir: str = "",
1898
- old_checksums: Optional[dict] = None) -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1899
- """
1900
- Rozpakowuje do staging area, potem atomowo przenosi pliki.
1901
- Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1902
- Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1903
- """
1904
- staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1905
- journal = []
1906
- installed_files = []
1907
- backup_journal: List[Tuple[str, str]] = []
1908
-
1909
- try:
1910
- # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1911
- with tarfile.open(pkg_path, "r:xz") as tf:
1912
- _safe_extractall(tf, staging)
1913
-
1914
- data_tar = os.path.join(staging, "data.tar.xz")
1915
- if not os.path.exists(data_tar):
1916
- shutil.rmtree(staging, ignore_errors=True)
1917
- return False, [], backup_journal
1918
-
1919
- # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1920
- data_staging = os.path.join(staging, "data")
1921
- os.makedirs(data_staging, exist_ok=True)
1922
- with tarfile.open(data_tar, "r:xz") as tf:
1923
- _safe_extractall(tf, data_staging)
1924
-
1925
- # Wczytaj sums.json
1926
- sums_path = os.path.join(data_staging, "sums.json")
1927
- sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1928
-
1929
- # Hook pre-install (przed przeniesieniem plików do systemu)
1930
- _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1931
-
1932
- # Przenieś pliki: staging/data/* → /
1933
- for root, dirs, files in os.walk(data_staging):
1934
- # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1935
- # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1936
- # puste katalogi z data.tar.xz ginęły przy instalacji.
1937
- for d in dirs:
1938
- src_dir = os.path.join(root, d)
1939
- rel_dir = os.path.relpath(src_dir, data_staging)
1940
- if deploy_dir and _is_shared_path("/" + rel_dir):
1941
- dst_root = PAG_ROOT
1942
- elif deploy_dir:
1943
- dst_root = deploy_dir
1944
- else:
1945
- dst_root = PAG_ROOT
1946
- dst_dir = os.path.join(dst_root, rel_dir)
1947
- if not os.path.isdir(dst_dir):
1948
- try:
1949
- os.makedirs(dst_dir, exist_ok=True)
1950
- except OSError:
1951
- pass
1952
- for fname in files:
1953
- if fname == "sums.json":
1954
- continue
1955
- src = os.path.join(root, fname)
1956
- rel = os.path.relpath(src, data_staging)
1957
-
1958
- ok = _install_file(src, rel, data_staging, sums,
1959
- staging, journal, installed_files, deploy_dir,
1960
- backup_dir, backup_journal,
1961
- old_checksums=old_checksums)
1962
- if not ok:
1963
- # Cofnij wszystkie operacje
1964
- _rollback_journal(journal, staging)
1965
- return False, [], backup_journal
1966
-
1967
- # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1968
- # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1969
- # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1970
- # nie jest zainstalowane.
1971
- _icon_dirs = set()
1972
- for f in installed_files:
1973
- fp = f.get("path", "") or ""
1974
- if fp.startswith("/usr/share/icons/"):
1975
- _rest = fp[len("/usr/share/icons/"):]
1976
- _theme = _rest.split("/", 1)[0]
1977
- if _theme:
1978
- _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1979
- if _icon_dirs:
1980
- try:
1981
- subprocess.run(["gtk-update-icon-cache", "--version"],
1982
- capture_output=True, timeout=10)
1983
- for _d in sorted(_icon_dirs):
1984
- if os.path.isdir(_d):
1985
- subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
1986
- capture_output=True, timeout=300)
1987
- except Exception:
1988
- pass
1989
-
1990
- # Uruchom hooki post-install
1991
- hooks_dir = os.path.join(staging, "hooks")
1992
- _run_hook(hooks_dir, "post-install", pkg)
1993
-
1994
- # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1995
- try:
1996
- if os.path.isdir(hooks_dir):
1997
- persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1998
- shutil.rmtree(persisted, ignore_errors=True)
1999
- shutil.copytree(hooks_dir, persisted)
2000
- except Exception:
2001
- pass
2002
-
2003
- # Zapisz do SQLite
2004
- _db_record_files(pkg.name, installed_files)
2005
-
2006
- shutil.rmtree(staging, ignore_errors=True)
2007
- return True, installed_files, backup_journal
2008
-
2009
- except Exception as e:
2010
- _rollback_journal(journal, staging)
2011
- return False, [], backup_journal
2012
-
2013
-
2014
-def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
2015
- """Odświeża cache ld.so po udanej instalacji pakietów."""
2016
- ldconfig = shutil.which("ldconfig")
2017
- if not ldconfig:
2018
- print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
2019
- file=sys.stderr)
2020
- return False
2021
-
2022
- target_root = deploy_dir or PAG_ROOT
2023
- command = [ldconfig]
2024
- if target_root != "/":
2025
- command.extend(["-r", target_root])
2026
-
2027
- try:
2028
- subprocess.run(command, check=True, timeout=60,
2029
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
2030
- text=True)
2031
- return True
2032
- except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
2033
- detail = getattr(exc, "stderr", None) or str(exc)
2034
- print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
2035
- file=sys.stderr)
2036
- return False
2037
-
2038
-
2039
-def _rollback_journal(journal: list, staging_path: str):
2040
- """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
2041
- for entry in reversed(journal):
2042
- op = entry[0]
2043
- if op == "file":
2044
- _, src, dst = entry
2045
- try:
2046
- if os.path.exists(dst) or os.path.islink(dst):
2047
- _safe_rename(dst, src)
2048
- except Exception:
2049
- pass
2050
- elif op == "symlink":
2051
- _, _, dst = entry
2052
- try:
2053
- if os.path.islink(dst) or os.path.exists(dst):
2054
- os.remove(dst)
2055
- except Exception:
2056
- pass
2057
- elif op == "backup":
2058
- # Przywróć starą wersję pliku z backupu (upgrade)
2059
- _, bpath, dst = entry
2060
- try:
2061
- if os.path.lexists(bpath):
2062
- os.replace(bpath, dst)
2063
- except Exception:
2064
- pass
2065
- shutil.rmtree(staging_path, ignore_errors=True)
2066
-
2067
-# =============================================================================
2068
-# BEZPIECZNE USUWANIE
2069
-# =============================================================================
2070
-
2071
-def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
2072
- """
2073
- Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
2074
- Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
2075
- """
2076
- pkg_files = _db_get_package_files(pkg_name)
2077
- recorded = _db_get_package_checksums(pkg_name)
2078
- removed = []
2079
- skipped_shared = []
2080
- skipped_sensitive = []
2081
-
2082
- for fpath in pkg_files:
2083
- owners = _db_get_file_owners(fpath)
2084
- # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
2085
- other_owners = [o for o in owners if o != pkg_name and o in installed_db]
2086
-
2087
- if other_owners:
2088
- # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
2089
- skipped_shared.append(fpath)
2090
- continue
2091
-
2092
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2093
- if os.path.isfile(full) or os.path.islink(full):
2094
- # Wrażliwej konfiguracji systemowej (/etc/pam.d, sudoers, shadow…)
2095
- # NIE kasujemy przy `remove` – jej brak potrafi zablokować logowanie
2096
- # i sudo (np. usunięcie system-auth/system-account). Zostaje wpis w bazie.
2097
- if _is_sensitive_config(fpath):
2098
- skipped_sensitive.append(fpath)
2099
- continue
2100
- # .pacsave: zachowaj ZMIENIONY plik konfiguracyjny zamiast kasować
2101
- # (porównanie z sumą z chwili instalacji), wzorzec jak w pacmanie.
2102
- if not os.path.islink(full) and _is_config_path(fpath):
2103
- old_sha = recorded.get(fpath, "")
2104
- if old_sha:
2105
- try:
2106
- cur_sha = _sha256_file(full)
2107
- except OSError:
2108
- cur_sha = ""
2109
- if cur_sha and cur_sha != old_sha:
2110
- pacsave = full + ".pacsave"
2111
- try:
2112
- os.replace(full, pacsave)
2113
- print(f" ⚠ {_('conf_pacsave', path=pacsave)}")
2114
- removed.append(fpath)
2115
- continue
2116
- except OSError:
2117
- pass
2118
- os.remove(full)
2119
- removed.append(fpath)
2120
-
2121
- # Usuń puste katalogi (od najgłębszych)
2122
- dirs = set()
2123
- for fpath in removed + skipped_shared:
2124
- parent = os.path.dirname(fpath)
2125
- while parent and parent != "/":
2126
- dirs.add(parent)
2127
- parent = os.path.dirname(parent)
2128
-
2129
- for d in sorted(dirs, key=len, reverse=True):
2130
- full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2131
- if os.path.isdir(full_d):
2132
- try:
2133
- os.rmdir(full_d)
2134
- except OSError:
2135
- pass # nie jest pusty – OK
2136
-
2137
- # Usuń z SQLite
2138
- _db_remove_package_files(pkg_name)
2139
-
2140
- if skipped_shared:
2141
- print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
2142
- if skipped_sensitive:
2143
- print(f" ⚠ {len(skipped_sensitive)} wrażliwych plików systemowych zachowanych")
2144
-
2145
- return len(removed) + len(skipped_shared) + len(skipped_sensitive), removed
2146
-
2147
-
2148
-def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
2149
- installed_db: dict, deploy_dir: str = "",
2150
- backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
2151
- """
2152
- Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
2153
-
2154
- - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
2155
- (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
2156
- - Sprząta puste katalogi i wpisy SQLite starej wersji.
2157
- Zwraca (liczba usuniętych, [usunięte ścieżki]).
2158
- """
2159
- new_set = set(new_paths)
2160
- stale = [f for f in old_files if f not in new_set]
2161
- if not stale:
2162
- return 0, []
2163
-
2164
- root = deploy_dir or PAG_ROOT
2165
- removed = []
2166
- skipped = 0
2167
- for fpath in stale:
2168
- owners = _db_get_file_owners(fpath)
2169
- other_owners = [o for o in owners if o != pkg_name and o in installed_db]
2170
- if other_owners:
2171
- # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
2172
- skipped += 1
2173
- else:
2174
- # Wrażliwych plików systemowych NIE kasujemy, gdy nowy pakiet ich już
2175
- # nie dostarcza (np. shadow przestaje pakować system-*). Ich brak
2176
- # blokuje login/sudo; usuwamy więc tylko wpis w bazie.
2177
- if _is_sensitive_config(fpath):
2178
- skipped += 1
2179
- else:
2180
- # Gdy nowa wersja pliku /etc trafiła do .pacnew, plik użytkownika
2181
- # MUSI zostać – nie jest „przestarzały”. Zachowujemy też wpis w bazie
2182
- # (stara suma), by kolejny upgrade dalej wykrywał zmiany użytkownika.
2183
- if _is_config_path(fpath):
2184
- _cfg_full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2185
- if os.path.lexists(_cfg_full + ".pacnew"):
2186
- skipped += 1
2187
- continue
2188
- full = os.path.join(root, fpath.lstrip("/"))
2189
- if os.path.isfile(full) or os.path.islink(full):
2190
- try:
2191
- if backup_dir and backup_journal is not None:
2192
- backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
2193
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
2194
- os.replace(full, backup_path) # przenieś do backupu (rollback)
2195
- backup_journal.append((backup_path, fpath))
2196
- else:
2197
- os.remove(full)
2198
- removed.append(fpath)
2199
- except OSError:
2200
- pass
2201
- # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
2202
- with _db_session() as db:
2203
- db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
2204
-
2205
- # Usuń puste katalogi (od najgłębszych)
2206
- dirs = set()
2207
- for fpath in removed:
2208
- parent = os.path.dirname(fpath)
2209
- while parent and parent != "/":
2210
- dirs.add(parent)
2211
- parent = os.path.dirname(parent)
2212
- for d in sorted(dirs, key=len, reverse=True):
2213
- full_d = os.path.join(root, d.lstrip("/"))
2214
- if os.path.isdir(full_d):
2215
- try:
2216
- os.rmdir(full_d)
2217
- except OSError:
2218
- pass # nie jest pusty – OK
2219
-
2220
- if removed:
2221
- print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
2222
- if skipped:
2223
- print(f" ⚠ {skipped} plików współdzielonych zachowanych")
2224
-
2225
- return len(removed), removed
2226
-
2227
-
2228
-def _new_transaction_backup_root() -> str:
2229
- """Katalog na backupy NADPISYWANYCH plików dla bieżącej transakcji.
2230
-
2231
- Tworzony dla KAŻDEJ transakcji, nie tylko upgrade: „świeża” instalacja
2232
- pakietu też potrafi nadpisać pliki spoza bazy pag (baza rootfs/ISO).
2233
- Gdy transakcja padnie, rollback MUSI mieć co przywrócić – inaczej kasuje
2234
- te pliki (tak zniknął m.in. libpam.so.0 i przestał działać sudo)."""
2235
- txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
2236
- root = os.path.join(STAGING_DIR, "backups", txn)
2237
- os.makedirs(root, exist_ok=True)
2238
- return root
2239
-
2240
-
2241
-def _purge_old_backups(keep_root: str = ""):
2242
- """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
2243
- base = os.path.join(STAGING_DIR, "backups")
2244
- if not os.path.isdir(base):
2245
- return
2246
- for entry in os.listdir(base):
2247
- p = os.path.join(base, entry)
2248
- if p != keep_root and os.path.isdir(p):
2249
- shutil.rmtree(p, ignore_errors=True)
2250
-
2251
-# =============================================================================
2252
-# HOOKI
2253
-# =============================================================================
2254
-# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
2255
-# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
2256
-# Aby ograniczyć ryzyko:
2257
-# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
2258
-# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
2259
-# - każde uruchomienie jest logowane do /var/log/pag/audit.log
2260
-# - hook ma wersjonowane API (PKG_HOOK_API)
2261
-# =============================================================================
2262
-
2263
-# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
2264
-_HOOKS_RUN: List[str] = []
2265
-
2266
-
2267
-def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
2268
- """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
2269
- return {
2270
- "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
2271
- "HOME": "/root",
2272
- "LANG": "C.UTF-8",
2273
- "LC_ALL": "C.UTF-8",
2274
- "PKG_NAME": pkg.name,
2275
- "PKG_VERSION": pkg.version,
2276
- "PKG_ACTION": hook_name,
2277
- "PKG_HOOK_API": HOOK_API_VERSION,
2278
- }
2279
-
2280
-
2281
-def _hook_timeout() -> int:
2282
- try:
2283
- return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
2284
- except Exception:
2285
- return 60
2286
-
2287
-
2288
-def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
2289
- """Uruchamia skrypt hooka jeśli istnieje.
2290
-
2291
- Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
2292
- pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
2293
- ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
2294
- """
2295
- hook_path = os.path.join(hooks_dir, hook_name)
2296
- if not os.path.exists(hook_path):
2297
- return False
2298
-
2299
- if os.environ.get("PAG_NO_HOOKS", "") == "1":
2300
- print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
2301
- _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
2302
- return False
2303
-
2304
- os.chmod(hook_path, 0o755)
2305
- env = _hook_env(pkg, hook_name)
2306
- tag = f"{hook_name} {pkg.name}-{pkg.version}"
2307
- try:
2308
- result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2309
- check=False, capture_output=True, text=True,
2310
- cwd="/")
2311
- _HOOKS_RUN.append(tag)
2312
- if result.returncode != 0:
2313
- print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2314
- if result.stderr:
2315
- print(f" {result.stderr.strip()[-200:]}")
2316
- _audit(f"hook FAIL {tag} rc={result.returncode}")
2317
- else:
2318
- _audit(f"hook OK {tag}")
2319
- return True
2320
- except subprocess.TimeoutExpired:
2321
- print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2322
- _audit(f"hook TIMEOUT {tag}")
2323
- return False
2324
- except Exception as e:
2325
- print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2326
- _audit(f"hook ERROR {tag}: {e}")
2327
- return False
2328
-
2329
-# =============================================================================
2330
-# TRANSAKCJE I ROLLBACK
2331
-# =============================================================================
2332
-
2333
-def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2334
- upgrade_backups=None, upgrade_backup_root=""):
2335
- history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2336
- # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2337
- # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2338
- executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2339
- _HOOKS_RUN.clear()
2340
- entry = {
2341
- "action": action, "packages": packages, "success": success,
2342
- "timestamp": datetime.now().isoformat(),
2343
- "snapshot": snapshot,
2344
- "file_journal": file_journal, # lista plików do wycofania
2345
- "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2346
- }
2347
- if upgrade_backups:
2348
- entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2349
- entry["upgrade_backup_root"] = upgrade_backup_root
2350
- history.append(entry)
2351
- if len(history) > 50:
2352
- history = history[-50:]
2353
- save_json(HISTORY_FILE, history)
2354
-
2355
-def cmd_history():
2356
- if not os.path.exists(HISTORY_FILE):
2357
- print(_("no_history")); return
2358
- history = load_json(HISTORY_FILE)
2359
- if not history:
2360
- print(_("no_history")); return
2361
- print(f"Ostatnie transakcje ({len(history)}):")
2362
- for i, e in enumerate(reversed(history), 1):
2363
- icon = "✅" if e["success"] else "❌"
2364
- pkgs = ", ".join(e["packages"][:5])
2365
- if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2366
- print(f" {i}. {icon} {e['action']}: {pkgs}")
2367
- print(f" {e['timestamp']}")
2368
-
2369
-def cmd_rollback():
2370
- if not os.path.exists(HISTORY_FILE):
2371
- print(_("no_history")); return 1
2372
- history = load_json(HISTORY_FILE)
2373
- if not history:
2374
- print(_("no_history")); return 1
2375
-
2376
- last = None
2377
- for e in reversed(history):
2378
- if e["success"] and e.get("snapshot"):
2379
- last = e; break
2380
-
2381
- if not last:
2382
- print("❌ No snapshot to restore."); return 1
2383
-
2384
- print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2385
- print(f" Packages: {', '.join(last['packages'][:10])}")
2386
-
2387
- if not _ask_confirm():
2388
- return 0
2389
-
2390
- # Przywróć installed.json
2391
- save_json(INSTALLED_DB, last["snapshot"])
2392
-
2393
- # Wycofaj fizyczne pliki (jeśli zapisano journal)
2394
- # Deduplikacja: pakiet może zgłosić ten sam plik więcej niż raz (np. przez
2395
- # `provides` lub wspólną ścieżkę), a journal z historii mógł zostać zapisany
2396
- # przed dodaniem deduplikacji.
2397
- file_journal = list(dict.fromkeys(last.get("file_journal", [])))
2398
- upgrade_backups = last.get("upgrade_backups", {}) or {}
2399
- backup_root = last.get("upgrade_backup_root", "")
2400
-
2401
- # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2402
- for dst, bpath in upgrade_backups.items():
2403
- full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2404
- if bpath and os.path.lexists(bpath):
2405
- try:
2406
- os.makedirs(os.path.dirname(full), exist_ok=True)
2407
- os.replace(bpath, full)
2408
- except OSError:
2409
- pass
2410
-
2411
- # Usuń nowe pliki (które nie miały poprzedniej wersji)
2412
- backed = set(upgrade_backups)
2413
- if file_journal:
2414
- for fpath in reversed(file_journal):
2415
- if fpath in backed:
2416
- continue
2417
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2418
- if os.path.exists(full) or os.path.islink(full):
2419
- os.remove(full)
2420
- print(f" {_('rollback_files', len(file_journal))}")
2421
-
2422
- # Sprzątanie pustych katalogów + katalogu backupów
2423
- dirs = set()
2424
- for fpath in file_journal:
2425
- parent = os.path.dirname(fpath)
2426
- while parent and parent != "/":
2427
- dirs.add(parent)
2428
- parent = os.path.dirname(parent)
2429
- for d in sorted(dirs, key=len, reverse=True):
2430
- full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2431
- if os.path.isdir(full_d):
2432
- try:
2433
- os.rmdir(full_d)
2434
- except OSError:
2435
- pass
2436
- if backup_root:
2437
- shutil.rmtree(backup_root, ignore_errors=True)
2438
-
2439
- print(f"✅ {_('rollback_restored')}")
2440
- _record_transaction("rollback", last["packages"], True, None)
2441
- return 0
2442
-
2443
-# =============================================================================
2444
-# INSTALACJA
2445
-# =============================================================================
2446
-
2447
-def _install_local_pkg_files(paths, install_succeeded):
2448
- """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2449
- Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2450
- Zwraca (failed_count, installed_files)."""
2451
- failed = 0
2452
- all_files = []
2453
- for p in paths:
2454
- p = os.path.abspath(p)
2455
- if not os.path.isfile(p):
2456
- print(f" ❌ Nie znaleziono pakietu: {p}")
2457
- failed += 1
2458
- continue
2459
- try:
2460
- with tarfile.open(p, "r:xz") as tf:
2461
- meta = tf.extractfile("metadata.json")
2462
- if meta is None:
2463
- print(f" ❌ {p}: brak metadata.json")
2464
- failed += 1
2465
- continue
2466
- data = json.loads(meta.read())
2467
- except Exception as e:
2468
- print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2469
- failed += 1
2470
- continue
2471
- pkg = PackageInfo(data, repo="local")
2472
- print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2473
- ok, files, _ = _atomic_install(p, pkg)
2474
- if ok:
2475
- install_succeeded(pkg, files)
2476
- all_files.extend(f["path"] for f in files)
2477
- print("✅")
2478
- else:
2479
- print("❌")
2480
- failed += 1
2481
- return failed, all_files
2482
-
2483
-
2484
-def _preflight_disk(total_bytes: int) -> bool:
2485
- """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2486
-
2487
- Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2488
- miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2489
- (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2490
- """
2491
- target = PAG_ROOT or "/"
2492
- try:
2493
- st = os.statvfs(target)
2494
- free = st.f_bavail * st.f_frsize
2495
- except OSError:
2496
- return True # nie da się sprawdzić – nie blokuj
2497
- need_mb = total_bytes // 1048576
2498
- free_mb = free // 1048576
2499
- if free < total_bytes:
2500
- print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2501
- f"wolne {free_mb} MB ({target})")
2502
- return False
2503
- if free < total_bytes * 3:
2504
- print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2505
- f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2506
- # Wykryj mount read-only (test zapisu w stagingu)
2507
- try:
2508
- probe = os.path.join(STAGING_DIR, ".pag-probe")
2509
- with open(probe, "w") as f:
2510
- f.write("x")
2511
- os.remove(probe)
2512
- except OSError:
2513
- print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2514
- return False
2515
- return True
2516
-
2517
-
2518
-def cmd_install(package_names, as_dep=False, upgrade=False):
2519
- ensure_dirs()
2520
- installed_db = load_json(INSTALLED_DB)
2521
- world = load_world()
2522
- pinned = load_json(PINNED_FILE)
2523
-
2524
- # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2525
- # nie wymaga repozytorium ani GPG.
2526
- local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2527
- (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2528
- if local_files:
2529
- _local_need = sum(
2530
- os.path.getsize(os.path.abspath(p))
2531
- for p in local_files if os.path.isfile(os.path.abspath(p))
2532
- )
2533
- if not _preflight_disk(_local_need):
2534
- return 1
2535
-
2536
- def _ok(pkg, files):
2537
- installed_db[pkg.name] = {
2538
- "version": pkg.version, "release": pkg.release, "description": pkg.description,
2539
- "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2540
- "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2541
- "repo": "local",
2542
- "provides": getattr(pkg, "provides", None) or [],
2543
- "provides_so": getattr(pkg, "provides_so", None) or [],
2544
- "requires_so": getattr(pkg, "requires_so", None) or [],
2545
- }
2546
- world.add(pkg.name)
2547
- failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2548
- save_json(INSTALLED_DB, installed_db)
2549
- save_world(world)
2550
- if failed_local:
2551
- return 1
2552
- _refresh_dynamic_linker_cache()
2553
- package_names = [n for n in package_names if n not in
2554
- [os.path.abspath(x) for x in local_files] and
2555
- n not in local_files]
2556
- to_install = []
2557
- if not package_names:
2558
- return 0
2559
- # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2560
-
2561
- repo_pkgs = fetch_all_packages()
2562
-
2563
- if not repo_pkgs:
2564
- print(f"❌ {_('no_index')}"); return 1
2565
-
2566
- for name in list(package_names):
2567
- if name in pinned:
2568
- print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2569
- package_names.remove(name)
2570
-
2571
- to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2572
-
2573
- # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2574
- # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2575
- # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2576
- not_found = []
2577
- for n in package_names:
2578
- real = _resolve_provides(n, repo_pkgs, installed_db)
2579
- if real not in repo_pkgs and real not in installed_db \
2580
- and not os.path.exists(os.path.abspath(n)):
2581
- not_found.append(n)
2582
- if not_found:
2583
- print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2584
- print(f" {_('not_found_hint')}")
2585
- return 1
2586
-
2587
- # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2588
- # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2589
- if upgrade:
2590
- # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2591
- # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2592
- # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2593
- # _version_newer.
2594
- upgrade_targets = [
2595
- name for name in package_names
2596
- if name in repo_pkgs
2597
- and name in installed_db
2598
- and name not in pinned
2599
- ]
2600
- for name in upgrade_targets:
2601
- if name not in to_install:
2602
- to_install.append(name)
2603
-
2604
- if not to_install and not missing_deps:
2605
- print(f"✅ {_('all_installed')}"); return 0
2606
-
2607
- # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2608
- fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2609
-
2610
- if fatal_missing > 0:
2611
- print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2612
- print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2613
- return 1
2614
-
2615
- so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2616
- if so_missing > 0:
2617
- print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2618
- return 1
2619
-
2620
- if not to_install:
2621
- print(f"✅ {_('all_installed')}"); return 0
2622
-
2623
- MAX_MB = MAX_PKG_SIZE // 1048576
2624
- for n in to_install:
2625
- if not _validate_pkg_name(n):
2626
- print(f" {_("sec_badname", name=n)}")
2627
- return 1
2628
- sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2629
- if sz > MAX_PKG_SIZE:
2630
- mb = sz // 1048576
2631
- print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2632
- return 1
2633
- total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2634
- if not _preflight_disk(total_size):
2635
- return 1
2636
- print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2637
- for name in to_install:
2638
- p = repo_pkgs.get(name)
2639
- if p:
2640
- if name in installed_db:
2641
- marker = " [upgrade]" if upgrade else ""
2642
- else:
2643
- marker = f" [{_('new')}]"
2644
- print(f" {name}-{p.version}{marker}")
2645
-
2646
- if not as_dep and not upgrade:
2647
- if not _ask_confirm():
2648
- print(_("cancelled")); return 0
2649
-
2650
- snapshot = copy.deepcopy(installed_db)
2651
- all_installed_files = []
2652
- failed = []
2653
- # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2654
- stale_candidates = []
2655
- # Katalog backupów nadpisywanych plików – dla poprawnego rollbacku.
2656
- # Dla KAŻDEJ transakcji: instalacja „nowego” pakietu także nadpisuje pliki
2657
- # spoza bazy pag (baza rootfs/ISO), a rollback bez backupu by je skasował.
2658
- backup_root = ""
2659
- all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2660
- if to_install:
2661
- backup_root = _new_transaction_backup_root()
2662
-
2663
- # --- Dziennik transakcji (dla pełnej atomowości) ---
2664
- # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2665
- # w tej transakcji przez _rollback_transaction().
2666
- transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2667
-
2668
- # --- Tryb immutable: utwórz nowy deployment ---
2669
- immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2670
- deploy_dir = ""
2671
- deploy_id = ""
2672
- if immutable:
2673
- print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2674
- deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2675
- target_root = deploy_dir
2676
- else:
2677
- target_root = ""
2678
-
2679
- # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2680
- to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2681
- if len(to_download) > 1:
2682
- print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2683
- downloaded = _download_packages_parallel(to_download)
2684
- else:
2685
- downloaded = {}
2686
-
2687
- # --- Faza 2: Instalacja – JEDNA nadpisywana linia postępu (jak przy
2688
- # pobieraniu), bez ściany tekstu na każdy pakiet. W trybie
2689
- # nieinteraktywnym (logi, netinstall instalatora) wypisujemy linię na
2690
- # pakiet – tam to pożądane do logu.
2691
- t0 = time.time()
2692
- stderr_tty = sys.stderr.isatty()
2693
- stdout_tty = sys.stdout.isatty()
2694
- _bar_last = 0
2695
-
2696
- def _bar_draw(idx: int, name: str) -> None:
2697
- nonlocal _bar_last
2698
- n = len(to_install)
2699
- pct = (idx - 1) / n * 100.0
2700
- fl = int(25 * pct / 100)
2701
- pbar = "█" * fl + "░" * (25 - fl)
2702
- eta_s = ""
2703
- if idx > 1:
2704
- avg = (time.time() - t0) / (idx - 1)
2705
- rem = avg * (n - idx + 1)
2706
- eta_s = f" ~{rem:.0f}s" if rem < 60 else f" ~{rem/60:.1f}m"
2707
- line = f" 📦 [{pbar}] {idx}/{n} ({pct:.0f}%) {name}{eta_s}"
2708
- if stderr_tty:
2709
- clear = " " * max(0, _bar_last - len(line))
2710
- sys.stderr.write(f"\r{line}{clear}")
2711
- sys.stderr.flush()
2712
- _bar_last = len(line)
2713
- else:
2714
- print(line, file=sys.stderr, flush=True)
2715
-
2716
- def _bar_end() -> None:
2717
- nonlocal _bar_last
2718
- if stderr_tty and _bar_last:
2719
- sys.stderr.write("\r" + " " * _bar_last + "\r")
2720
- sys.stderr.flush()
2721
- _bar_last = 0
2722
-
2723
- _pkg_i = 0
2724
- for name in to_install:
2725
- pkg = repo_pkgs.get(name)
2726
- if not pkg:
2727
- _bar_end()
2728
- print(f" ❌ {name}: {_('not_found')}")
2729
- failed.append(name)
2730
- break
2731
-
2732
- _pkg_i += 1
2733
- _bar_draw(_pkg_i, f"{name}-{pkg.version}")
2734
-
2735
- # Pobierz (z cache fazy 1 lub bezpośrednio)
2736
- pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2737
- if not pkg_path:
2738
- _bar_end()
2739
- print(f" ❌ {name}: {_('download_fail')}")
2740
- failed.append(name)
2741
- break # przerwij transakcję
2742
-
2743
- # GPG
2744
- gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2745
- if not gpg_ok:
2746
- _bar_end()
2747
- print(f" ❌ {name}: {_('gpg_fail')}: {gpg_msg[:60]}")
2748
- failed.append(name)
2749
- break # PRZERWIJ – niezaufany pakiet
2750
-
2751
- # SHA256 całego pakietu
2752
- if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2753
- _bar_end()
2754
- print(f" ❌ {name}: {_('sha256_mismatch')}")
2755
- failed.append(name)
2756
- break # PRZERWIJ – uszkodzony pakiet
2757
-
2758
- # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2759
- old_files = _db_get_package_files(name) if name in installed_db else []
2760
- # Stare sumy SHA256 – do wykrycia zmian użytkownika w plikach /etc (.pacnew)
2761
- old_checksums = _db_get_package_checksums(name) if name in installed_db else None
2762
-
2763
- # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2764
- ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2765
- backup_dir=backup_root,
2766
- old_checksums=old_checksums)
2767
- if ok:
2768
- installed_db[name] = {
2769
- "version": pkg.version, "release": pkg.release, "description": pkg.description,
2770
- "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2771
- "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2772
- "repo": pkg.repo_url,
2773
- "provides": getattr(pkg, "provides", None) or [],
2774
- "provides_so": getattr(pkg, "provides_so", None) or [],
2775
- "requires_so": getattr(pkg, "requires_so", None) or [],
2776
- }
2777
- if not as_dep and name in package_names:
2778
- world.add(name)
2779
- if not stdout_tty:
2780
- print(f" ✓ {name}-{pkg.version}", flush=True)
2781
- all_installed_files.extend(f["path"] for f in files)
2782
- all_backups.extend(backup_j)
2783
-
2784
- # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2785
- # których nie ma już w nowej wersji.
2786
- if upgrade and old_files:
2787
- stale_candidates.append((name, old_files, [f["path"] for f in files]))
2788
-
2789
- # Po instalacji kernela – przebuduj initramfs
2790
- if _is_kernel_package(name):
2791
- _rebuild_initramfs(deploy_dir)
2792
- else:
2793
- _bar_end()
2794
- print(f" ❌ {name}: {_('install_failed')}")
2795
- failed.append(name)
2796
- break # PRZERWIJ – błąd instalacji
2797
-
2798
- # Deduplikacja journalu – ten sam plik może trafić tu dwukrotnie (np. przez
2799
- # `provides`); rollback i historia nie potrzebują duplikatów.
2800
- all_installed_files = list(dict.fromkeys(all_installed_files))
2801
-
2802
- # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2803
- if failed:
2804
- _bar_end()
2805
- print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2806
- _rollback_transaction(installed_db, snapshot, all_installed_files,
2807
- deploy_dir, immutable, backups=all_backups)
2808
- if backup_root:
2809
- shutil.rmtree(backup_root, ignore_errors=True)
2810
- _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2811
- return 1
2812
-
2813
- # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2814
- # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2815
- for pkg_name, old_files, new_paths in stale_candidates:
2816
- _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2817
- backup_root, all_backups)
2818
-
2819
- save_json(INSTALLED_DB, installed_db)
2820
- save_world(world)
2821
- _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2822
- file_journal=all_installed_files,
2823
- upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2824
- upgrade_backup_root=backup_root)
2825
-
2826
- # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2827
- if backup_root:
2828
- _purge_old_backups(keep_root=backup_root)
2829
-
2830
- _bar_end()
2831
-
2832
- # --- Tryb immutable: przełącz na nowy deployment ---
2833
- if immutable and not failed:
2834
- _refresh_dynamic_linker_cache(deploy_dir)
2835
- print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2836
- _switch_deployment(deploy_dir)
2837
- print(f" ✅ Aktywny deployment: {deploy_id}")
2838
- _update_grub_config()
2839
- cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2840
- print(f" 💡 Restart wymagany do przeładowania systemu.")
2841
- else:
2842
- _refresh_dynamic_linker_cache()
2843
- # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2844
- # trafiły do realnego systemu (nie do deploymentu).
2845
- _process_triggers(all_installed_files)
2846
-
2847
- print(f"\n✅ {_('installed', len(to_install))} ({(time.time()-t0):.0f}s)")
2848
- return 0
2849
-
2850
-
2851
-def _rollback_transaction(installed_db: dict, snapshot: dict,
2852
- installed_files: List[str],
2853
- deploy_dir: str, is_immutable: bool,
2854
- backups: Optional[List[Tuple[str, str]]] = None):
2855
- """
2856
- Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2857
- Przywraca installed_db do stanu sprzed transakcji.
2858
- Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2859
- Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2860
- """
2861
- # Przywróć installed_db
2862
- installed_db.clear()
2863
- installed_db.update(snapshot)
2864
-
2865
- root = deploy_dir if is_immutable else PAG_ROOT
2866
- backup_map = {dst: src for src, dst in (backups or [])}
2867
-
2868
- # Przywróć stare wersje z backupów (upgrade)
2869
- for dst, bpath in backup_map.items():
2870
- full = os.path.join(root, dst.lstrip("/"))
2871
- if os.path.lexists(bpath):
2872
- try:
2873
- os.makedirs(os.path.dirname(full), exist_ok=True)
2874
- os.replace(bpath, full)
2875
- except OSError:
2876
- pass
2877
-
2878
- # Usuń nowe pliki (które nie miały poprzedniej wersji)
2879
- for fpath in reversed(installed_files):
2880
- if fpath in backup_map:
2881
- continue
2882
- full = os.path.join(root, fpath.lstrip("/"))
2883
- if os.path.isfile(full) or os.path.islink(full):
2884
- try:
2885
- os.remove(full)
2886
- except OSError:
2887
- pass
2888
-
2889
- # Wyczyść puste katalogi
2890
- dirs_to_check = set()
2891
- for fpath in installed_files:
2892
- parent = os.path.dirname(fpath)
2893
- while parent and parent != "/":
2894
- dirs_to_check.add(parent)
2895
- parent = os.path.dirname(parent)
2896
- for d in sorted(dirs_to_check, key=len, reverse=True):
2897
- full_d = os.path.join(root, d.lstrip("/"))
2898
- if os.path.isdir(full_d):
2899
- try:
2900
- os.rmdir(full_d)
2901
- except OSError:
2902
- pass
2903
-
2904
- # W trybie immutable: usuń nieudany deployment
2905
- if is_immutable and deploy_dir:
2906
- shutil.rmtree(deploy_dir, ignore_errors=True)
2907
-
2908
- save_json(INSTALLED_DB, snapshot)
2909
-
2910
-
2911
-# =============================================================================
2912
-# USUWANIE
2913
-# =============================================================================
2914
-
2915
-def cmd_remove(package_names):
2916
- installed_db = load_json(INSTALLED_DB)
2917
- world = load_world()
2918
- snapshot = copy.deepcopy(installed_db)
2919
- removed = []
2920
- removed_files = []
2921
-
2922
- total = len(package_names)
2923
- for i, name in enumerate(package_names, 1):
2924
- if name not in installed_db:
2925
- print(f" ⚠ {name}: not installed"); continue
2926
-
2927
- # Pasek postępu
2928
- pct = (i - 1) / total * 100
2929
- filled = int(25 * pct / 100)
2930
- print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2931
-
2932
- print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2933
-
2934
- # Pre-remove hook (jeśli dostępny w staging)
2935
- _run_hook_for_installed(name, "pre-remove")
2936
-
2937
- count, rm_files = _safe_remove_files(name, installed_db)
2938
- del installed_db[name]
2939
- world.discard(name)
2940
- removed.append(name)
2941
- removed_files.extend(rm_files)
2942
- print(f"✅ ({count} files)")
2943
-
2944
- # Post-remove hook + sprzątanie zapisanych hooków
2945
- _run_hook_for_installed(name, "post-remove")
2946
- shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2947
-
2948
- save_json(INSTALLED_DB, installed_db)
2949
- save_world(world)
2950
- _record_transaction("remove", removed, True, snapshot)
2951
-
2952
- print(file=sys.stderr) # wyczyść linię paska postępu
2953
-
2954
- if not removed: return 0
2955
- print(f"\n✅ Removed {len(removed)}.")
2956
- _process_triggers(removed_files)
2957
-
2958
- orphans = _find_orphans(installed_db, world)
2959
- if orphans:
2960
- print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2961
- print(" 'pag remove-orphans' to clean up.")
2962
- return 0
2963
-
2964
-def _run_hook_for_installed(pkg_name, hook_name):
2965
- """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2966
- hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2967
- if os.path.isdir(hook_dir):
2968
- ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2969
- _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2970
-
2971
-
2972
-# =============================================================================
2973
-# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2974
-# =============================================================================
2975
-# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2976
-# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2977
-# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2978
-# każdym pakiecie z osobna.
2979
-
2980
-TRIGGERS_DIR = PAG_CONF + "/triggers"
2981
-
2982
-DEFAULT_TRIGGERS = [
2983
- {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2984
- "run": "fc-cache -fs"},
2985
- {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
2986
- "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
2987
- {"name": "desktop-database", "paths": ["/usr/share/applications/"],
2988
- "run": "update-desktop-database -q /usr/share/applications"},
2989
- {"name": "mime-database", "paths": ["/usr/share/mime/"],
2990
- "run": "update-mime-database /usr/share/mime"},
2991
-]
2992
-
2993
-def _load_triggers() -> List[dict]:
2994
- """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
2995
- out = []
2996
- for t in DEFAULT_TRIGGERS:
2997
- bin_name = t["run"].split()[0]
2998
- if shutil.which(bin_name):
2999
- out.append(dict(t))
3000
- if os.path.isdir(TRIGGERS_DIR):
3001
- for fn in sorted(os.listdir(TRIGGERS_DIR)):
3002
- if not fn.endswith(".json"):
3003
- continue
3004
- try:
3005
- with open(os.path.join(TRIGGERS_DIR, fn)) as f:
3006
- data = json.load(f)
3007
- except (OSError, json.JSONDecodeError):
3008
- continue
3009
- if isinstance(data, dict):
3010
- data = [data]
3011
- for t in data:
3012
- if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
3013
- out.append(t)
3014
- return out
3015
-
3016
-def _process_triggers(touched_paths: List[str]):
3017
- """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
3018
- if not touched_paths:
3019
- return
3020
- if os.environ.get("PAG_NO_HOOKS", "") == "1":
3021
- return
3022
- import shlex as _shlex
3023
- matched = []
3024
- for trig in _load_triggers():
3025
- if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
3026
- matched.append(trig)
3027
- for trig in matched:
3028
- run = trig["run"]
3029
- print(f" ⚡ Trigger: {trig['name']} ({run})")
3030
- try:
3031
- r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
3032
- _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
3033
- if r.returncode != 0:
3034
- print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
3035
- except subprocess.TimeoutExpired:
3036
- print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
3037
- _audit(f"TRIGGER {trig['name']} TIMEOUT")
3038
- except Exception as e:
3039
- print(f" ⚠ trigger {trig['name']}: {e}")
3040
-
3041
-# =============================================================================
3042
-# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
3043
-# =============================================================================
3044
-
3045
-def _cleanup_tmp_files(*paths):
3046
- """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
3047
- for p in paths:
3048
- try:
3049
- if os.path.isfile(p):
3050
- os.remove(p)
3051
- except OSError:
3052
- pass
3053
-
3054
-
3055
-def cmd_self_update():
3056
- """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
3057
-
3058
- Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
3059
- kontrola składni (compile) → backup → atomowe os.replace.
3060
-
3061
- Podmieniamy plik, z którego pag ZOSTAŁ URUCHOMIONY (a nie hardkodowane
3062
- /usr/local/bin/pag): gdy pakiet instaluje /usr/bin/pag, a self-update
3063
- pisał do /usr/local/bin/pag, powstawały DWIE kopie o różnych wersjach
3064
- i zależnie od PATH `pag --version` pokazywał raz jedną, raz drugą."""
3065
- # Dedykowana blokada – patrz SelfUpdateLock. Chroni zapis dst.new oraz
3066
- # os.replace przed równoległą aktualizacją (np. cron + ręcznie).
3067
- with SelfUpdateLock():
3068
- return _self_update_impl()
3069
-
3070
-
3071
-def _self_update_impl():
3072
- repos = get_repos()
3073
- if not repos:
3074
- print("❌ Brak repozytoriów w konfiguracji.")
3075
- return 1
3076
- # Aktualizuj bieżący plik (Python ≥3.9 ustawia __file__ jako ścieżkę
3077
- # bezwzględną). Poza /usr|/usr/local (np. uruchomienie z checkoutu)
3078
- # nie nadpisujemy niczego – wracamy do domyślnej lokalizacji instalacji.
3079
- dst = "/usr/local/bin/pag"
3080
- try:
3081
- cand = os.path.realpath(__file__)
3082
- if cand.startswith(("/usr/", "/usr/local/")):
3083
- dst = cand
3084
- except Exception:
3085
- pass
3086
- base = repos[0]
3087
- dst_new = dst + ".new"
3088
- dst_bak = dst + ".bak"
3089
- print(f"🔄 Sprawdzam aktualizację pag z {base}...")
3090
- try:
3091
- with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3092
- data = r.read()
3093
- with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3094
- sig = r.read()
3095
- except Exception as e:
3096
- print(f" ❌ Nie można pobrać pag: {e}")
3097
- return 1
3098
-
3099
- # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
3100
- with open(dst_new, "wb") as f:
3101
- f.write(data)
3102
- with open(dst_new + ".asc", "wb") as f:
3103
- f.write(sig)
3104
-
3105
- # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
3106
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
3107
- ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
3108
- if not ok:
3109
- # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
3110
- res = _gpg_run("--verify", dst_new + ".asc", dst_new,
3111
- capture_output=True, text=True)
3112
- _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
3113
- if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
3114
- try:
3115
- with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3116
- keydata = r.read()
3117
- with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
3118
- tmp.write(keydata); tmp.flush()
3119
- _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
3120
- os.unlink(tmp.name)
3121
- print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
3122
- ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
3123
- except Exception:
3124
- pass
3125
- if not ok:
3126
- if insecure:
3127
- print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
3128
- else:
3129
- print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
3130
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
3131
- return 1
3132
- # Sprawdź fingerprint względem przypiętego klucza repo
3133
- pinned = _repo_pinned_fp(base)
3134
- if pinned:
3135
- if not fp:
3136
- print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
3137
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
3138
- return 1
3139
- if fp != pinned.upper():
3140
- if insecure:
3141
- print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
3142
- else:
3143
- print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
3144
- print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
3145
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
3146
- return 1
3147
-
3148
- # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
3149
- try:
3150
- with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
3151
- sha = r.read().decode().strip().split()[0]
3152
- if sha:
3153
- actual = hashlib.sha256(data).hexdigest()
3154
- if actual.lower() != sha.lower():
3155
- print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
3156
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
3157
- return 1
3158
- print(" ✅ SHA256 zgodny")
3159
- except Exception:
3160
- # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
3161
- pass
3162
-
3163
- # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
3164
- try:
3165
- compile(data, "pag", "exec")
3166
- except SyntaxError as e:
3167
- print(f" ❌ Błąd składni w nowym pag: {e}")
3168
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
3169
- return 1
3170
-
3171
- m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
3172
- or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
3173
- new_ver = m.group(1).decode() if m else "?"
3174
- print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
3175
-
3176
- # --- 4. Backup + atomowa podmiana ---
3177
- if os.path.exists(dst):
3178
- shutil.copy2(dst, dst_bak)
3179
- os.chmod(dst_new, 0o755)
3180
- os.replace(dst_new, dst) # atomowe na tym samym FS
3181
- try:
3182
- if os.path.exists(dst_new + ".asc"):
3183
- os.remove(dst_new + ".asc")
3184
- except OSError:
3185
- pass
3186
- print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
3187
- # Ostrzeż, gdy w PATH pierwsza jest INNA kopia `pag` – inaczej
3188
- # `pag --version` dalej pokaże starą wersję i wygląda to jak brak update.
3189
- other = shutil.which("pag")
3190
- if other and os.path.realpath(other) != os.path.realpath(dst):
3191
- print(f" ⚠ W PATH pierwszy jest inny pag: {other} (zaktualizowano {dst})")
3192
- print(f" Usuń starą kopię: sudo rm {other}")
3193
- print(" Uruchom ponownie pag, aby użyć nowej wersji.")
3194
- return 0
3195
-
3196
-
3197
-def _candidate_newer(rp, inst):
3198
- """Czy pakiet z repo jest nowszy od zainstalowanego.
3199
- Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
3200
- po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
3201
- widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
3202
- traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
3203
- reinstalacji/zmiany wersji."""
3204
- rv = getattr(rp, "version", "0")
3205
- iv = inst.get("version", "0")
3206
- if _version_newer(rv, iv):
3207
- return True
3208
- if rv != iv:
3209
- return False
3210
- rr = int(getattr(rp, "release", 1) or 1)
3211
- ir = int(inst.get("release", 1) or 1)
3212
- return rr > ir
3213
-
3214
-
3215
-def _pending_updates() -> List[str]:
3216
- """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
3217
- installed = load_json(INSTALLED_DB)
3218
- pinned = load_json(PINNED_FILE)
3219
- repo = fetch_all_packages()
3220
- if not repo:
3221
- return []
3222
- return [n for n, i in installed.items()
3223
- if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
3224
-
3225
-def cmd_update(do_upgrade: bool = False):
3226
- """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
3227
-
3228
- sync → tylko odświeżenie indeksów + info: „jest X pakietów do
3229
- zaktualizowania – wpisz: pag update".
3230
- update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
3231
- Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
3232
- przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
3233
- """
3234
- force = True
3235
- print("🔄 Refreshing indexes...")
3236
- for repo_url in get_repos():
3237
- pkgs = fetch_repo_index(repo_url, force=force)
3238
- cp = _repo_cache_path(repo_url)
3239
- has_sig = os.path.exists(cp + ".sig")
3240
- print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
3241
- print(f"✅ {_('indexes_refreshed')}")
3242
-
3243
- # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
3244
- try:
3245
- for r in get_repos():
3246
- cp = _repo_cache_path(r)
3247
- if os.path.exists(cp):
3248
- d = json.load(open(cp))
3249
- rv = d.get("pag_version", "")
3250
- if rv and rv != PAG_VERSION:
3251
- print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
3252
- except Exception:
3253
- pass
3254
-
3255
- # Raport: pakiety do aktualizacji
3256
- pending = _pending_updates()
3257
- if not pending:
3258
- print(f"✅ {_('all_up_to_date')}")
3259
- return 0
3260
- print(f"{_('updates_available', len(pending))}")
3261
- installed = load_json(INSTALLED_DB)
3262
- repo = fetch_all_packages()
3263
- for n in pending:
3264
- print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
3265
- if not do_upgrade:
3266
- return 0 # sync: tylko informacja
3267
- if not _ask_confirm():
3268
- return 0
3269
- return cmd_install(pending, upgrade=True)
3270
-
3271
-def _initramfs_stale() -> bool:
3272
- """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
3273
- try:
3274
- kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
3275
- if not kernels:
3276
- return False
3277
- newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
3278
- initrd = "/boot/initramfs.img"
3279
- return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
3280
- except Exception:
3281
- return False
3282
-
3283
-def cmd_upgrade():
3284
- """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
3285
- rc = cmd_update(do_upgrade=True)
3286
- if rc != 0:
3287
- return rc
3288
- # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
3289
- if _initramfs_stale():
3290
- print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
3291
- _rebuild_initramfs()
3292
- try:
3293
- if _load_deployments():
3294
- _update_grub_config()
3295
- except Exception:
3296
- pass
3297
- return 0
3298
-
3299
-def cmd_list(installed_only=False):
3300
- if installed_only:
3301
- db = load_json(INSTALLED_DB)
3302
- pinned = load_json(PINNED_FILE)
3303
- if not db: print("No packages installed."); return
3304
- print(f"Installed ({len(db)}):")
3305
- for n, i in sorted(db.items()):
3306
- pin = " 📌" if n in pinned else ""
3307
- print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
3308
- else:
3309
- pkgs = fetch_all_packages()
3310
- installed = load_json(INSTALLED_DB)
3311
- pinned = load_json(PINNED_FILE)
3312
- print(f"Available ({len(pkgs)}):")
3313
- for n, p in sorted(pkgs.items()):
3314
- m = "✓" if n in installed else " "
3315
- extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
3316
- if n in pinned: extra += " 📌"
3317
- print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
3318
-
3319
-def cmd_search(query):
3320
- pkgs = fetch_all_packages()
3321
- results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
3322
- if not results: print(f"❌ No results for: {query}"); return
3323
- installed = load_json(INSTALLED_DB)
3324
- print(f"Results for '{query}' ({len(results)}):")
3325
- for n,p in sorted(results):
3326
- print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
3327
- print(f" {p.description}")
3328
-
3329
-
3330
-def _smart_search(query: str) -> int:
3331
- """
3332
- Inteligentne wyszukiwanie: repo PaganOS + Flathub.
3333
- Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
3334
- Pokazuje dostępne źródła i sugeruje komendy instalacji.
3335
- """
3336
- # 1. Repo PaganOS
3337
- try:
3338
- pkgs = fetch_all_packages()
3339
- except Exception:
3340
- pkgs = {}
3341
- repo_lower = [(n, p) for n, p in pkgs.items()
3342
- if query.lower() in n.lower() or query.lower() in p.description.lower()]
3343
-
3344
- # 2. Flathub (jeśli dostępny)
3345
- flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
3346
-
3347
- if not repo_lower and not flat:
3348
- print(f"\n ❌ '{query}' — nie znaleziono.")
3349
- print(f" Repo PaganOS: pag search {query}")
3350
- if _check_flatpak(quiet=True):
3351
- print(f" Flathub: pag flatpak search {query}")
3352
- print(f" Dodaj repo: pag repo-add <url>")
3353
- return 1
3354
-
3355
- installed = load_json(INSTALLED_DB)
3356
-
3357
- # ── Repo PaganOS ──
3358
- if repo_lower:
3359
- exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
3360
- show = (exact or repo_lower)[:6]
3361
- print(f"\n 📦 PaganOS — '{query}':")
3362
- for n, p in sorted(show):
3363
- mark = "✓" if n in installed else " "
3364
- desc = p.description[:70] if len(p.description) > 75 else p.description
3365
- print(f" [{mark}] {n}-{p.version}")
3366
- if desc:
3367
- print(f" {desc}")
3368
- if len(repo_lower) > 6:
3369
- print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
3370
-
3371
- # ── Flathub ──
3372
- if flat:
3373
- print(f"\n 📦 Flathub — '{query}':")
3374
- for r in flat[:5]:
3375
- mark = "✓" if r.get("installed") else " "
3376
- name = r.get("name") or r.get("application", "?")
3377
- desc = (r.get("description") or "")[:65]
3378
- print(f" [{mark}] {name}")
3379
- if desc:
3380
- print(f" {desc}")
3381
- if len(flat) > 5:
3382
- print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3383
-
3384
- # ── Sugestie instalacji ──
3385
- print()
3386
- if repo_lower:
3387
- best = sorted(repo_lower, key=lambda x: (x[0].lower() != query.lower(), -len(x[1].name if hasattr(x[1], 'name') else 0)))[0][0]
3388
- if best in installed:
3389
- print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3390
- else:
3391
- print(f" 💡 sudo pag install {best}")
3392
- if flat:
3393
- best_fp = flat[0].get("application") or flat[0].get("name", query)
3394
- print(f" 💡 pag flatpak install {best_fp}")
3395
-
3396
- return 0
3397
-
3398
-def cmd_info(name):
3399
- pkgs = fetch_all_packages()
3400
- p = pkgs.get(name)
3401
- info = load_json(INSTALLED_DB).get(name)
3402
- if not p and not info: print(f"❌ '{name}' not found."); return 1
3403
- print(f"📦 {name}")
3404
- if p:
3405
- print(f" Version (repo): {p.version}")
3406
- print(f" Description: {p.description}")
3407
- print(f" Size: {p.size_bytes/1048576:.1f} MB")
3408
- print(f" SHA256: {p.sha256[:32]}...")
3409
- print(f" GPG: {p.gpg_fp or 'none'}")
3410
- print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3411
- if info:
3412
- print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3413
-
3414
-def cmd_files(name):
3415
- if name not in load_json(INSTALLED_DB):
3416
- print(f"❌ '{name}' not installed."); return 1
3417
- files = _db_get_package_files(name)
3418
- print(f"Files in {name} ({len(files)}):")
3419
- for f in sorted(files): print(f" {f}")
3420
-
3421
-def cmd_verify(deep=False):
3422
- installed = load_json(INSTALLED_DB)
3423
- if not installed: print("Nothing to verify."); return
3424
- errors = []
3425
-
3426
- # Wczytaj WSZYSTKIE sumy RAZ (nie w pętli!) – przy 50k plików wywołanie
3427
- # _db_get_all_file_checksums() per-plik dawało O(n²) i godziny zamiast sekund.
3428
- all_checksums = _db_get_all_file_checksums() if deep else {}
3429
- for name in installed:
3430
- for fpath in _db_get_package_files(name):
3431
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3432
- if not (os.path.exists(full) or os.path.islink(full)):
3433
- errors.append(f" ❌ {name}: missing {fpath}")
3434
- elif deep:
3435
- expected = all_checksums.get(fpath, "")
3436
- if expected:
3437
- actual = _sha256_file(full)
3438
- if actual != expected:
3439
- errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3440
-
3441
- if errors:
3442
- print(f"❌ {_('verify_errors', len(errors))}")
3443
- for e in errors[:50]: print(e)
3444
- return 1
3445
- total = _db_count_files()
3446
- print(f"✅ {_('verify_ok', total)}")
3447
-
3448
-# =============================================================================
3449
-# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3450
-# =============================================================================
3451
-
3452
-def cmd_pin(name, version=""):
3453
- pinned = load_json(PINNED_FILE)
3454
- if version:
3455
- pinned[name] = version
3456
- else:
3457
- info = load_json(INSTALLED_DB).get(name, {})
3458
- pinned[name] = info.get("version", "?")
3459
- save_json(PINNED_FILE, pinned)
3460
- print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3461
-
3462
-def cmd_unpin(name):
3463
- pinned = load_json(PINNED_FILE)
3464
- if name in pinned:
3465
- del pinned[name]; save_json(PINNED_FILE, pinned)
3466
- print(f"🔓 {name} {_('unpinned')}")
3467
- else:
3468
- print(f"⚠ {name} {_('not_pinned')}")
3469
-
3470
-def cmd_pinned():
3471
- pinned = load_json(PINNED_FILE)
3472
- if not pinned: print(_("no_pinned")); return
3473
- print(_("pinned_list", len(pinned)))
3474
- for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3475
-
3476
-def cmd_clean():
3477
- if os.path.isdir(PAG_CACHE):
3478
- count = size = 0
3479
- for f in os.listdir(PAG_CACHE):
3480
- fp = os.path.join(PAG_CACHE, f)
3481
- if os.path.isfile(fp):
3482
- size += os.path.getsize(fp); os.remove(fp); count += 1
3483
- print(f"✅ {_('cache_cleared', count, size/1048576)}")
3484
-
3485
-def cmd_remove_orphans():
3486
- installed = load_json(INSTALLED_DB)
3487
- world = load_world()
3488
- orphans = _find_orphans(installed, world)
3489
- if not orphans: print("✅ No orphans."); return
3490
- print(f"Orphans ({len(orphans)}):")
3491
- for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3492
- if not _ask_confirm():
3493
- return
3494
- cmd_remove(list(orphans))
3495
-
3496
-
3497
-# =============================================================================
3498
-# PROVIDES – PAKIETY WIRTUALNE
3499
-# =============================================================================
3500
-
3501
-PROVIDES_MAP = {
3502
- "pkgconfig(glib-2.0)": "glib",
3503
- "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3504
- "pkgconfig(gtk+-3.0)": "gtk",
3505
- "pkgconfig(gtk4)": "gtk",
3506
- "pkgconfig(zlib)": "zlib",
3507
- "pkgconfig(libffi)": "libffi",
3508
- "pkgconfig(expat)": "expat",
3509
- "pkgconfig(libsystemd)": "systemd",
3510
- "pkgconfig(dbus-1)": "dbus",
3511
- "pkgconfig(mount)": "util-linux",
3512
- "pkgconfig(blkid)": "util-linux",
3513
- "pkgconfig(libcap)": "libcap",
3514
- "pkgconfig(liblzma)": "xz",
3515
- "pkgconfig(libzstd)": "zstd",
3516
- "pkgconfig(bzip2)": "bzip2",
3517
- "pkgconfig(libcurl)": "curl",
3518
- "pkgconfig(openssl)": "openssl",
3519
- "pkgconfig(libpcre2-8)": "pcre2",
3520
- "pkgconfig(libxml-2.0)": "libxml2",
3521
- "pkgconfig(libxslt)": "libxslt",
3522
- "pkgconfig(freetype2)": "freetype",
3523
- "pkgconfig(fontconfig)": "fontconfig",
3524
- "pkgconfig(harfbuzz)": "harfbuzz",
3525
- "pkgconfig(cairo)": "cairo",
3526
- "pkgconfig(pango)": "pango",
3527
- "pkgconfig(xt)": "xorg-libxt",
3528
- "pkgconfig(xmu)": "xorg-libxmu",
3529
- "pkgconfig(ice)": "xorg-libice",
3530
- "pkgconfig(sm)": "xorg-libsm",
3531
- "pkgconfig(x11)": "xorg-libx11",
3532
- "pkgconfig(xext)": "xorg-libxext",
3533
- "pkgconfig(xrandr)": "xorg-libxrandr",
3534
- "pkgconfig(xfixes)": "xorg-libxfixes",
3535
- "pkgconfig(xcursor)": "xorg-libxcursor",
3536
- "pkgconfig(xinerama)": "xorg-libxinerama",
3537
- "pkgconfig(xrender)": "xorg-libxrender",
3538
- "pkgconfig(xau)": "xorg-libxau",
3539
- "pkgconfig(xcb)": "xorg-libxcb",
3540
- "pkgconfig(xdamage)": "xorg-libxdamage",
3541
- "pkgconfig(xcomposite)": "xorg-libxcomposite",
3542
- "pkgconfig(xft)": "xorg-libxft",
3543
- "pkgconfig(xss)": "xorg-libxss",
3544
- "pkgconfig(libsoup-3.0)": "libsoup3",
3545
- "pkgconfig(libsoup-2.4)": "libsoup2",
3546
- "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3547
- "pkgconfig(libpng)": "libpng",
3548
- "pkgconfig(libjpeg)": "libjpeg-turbo",
3549
- "pkgconfig(libtiff-4)": "libtiff",
3550
- "pkgconfig(ffi)": "libffi",
3551
- # ── system / baza ──
3552
- "pkgconfig(libcrypto)": "openssl",
3553
- "pkgconfig(libssl)": "openssl",
3554
- "pkgconfig(libudev)": "systemd",
3555
- "pkgconfig(libmount)": "util-linux",
3556
- "pkgconfig(libblkid)": "util-linux",
3557
- "pkgconfig(uuid)": "util-linux",
3558
- "pkgconfig(libexpat)": "expat",
3559
- "pkgconfig(libpcre)": "pcre",
3560
- "pkgconfig(ncursesw)": "ncurses",
3561
- "pkgconfig(tinfo)": "ncurses",
3562
- "pkgconfig(panel)": "ncurses",
3563
- "pkgconfig(readline)": "readline",
3564
- "pkgconfig(libseccomp)": "libseccomp",
3565
- "pkgconfig(pam)": "linux-pam",
3566
- "pkgconfig(libxcrypt)": "libxcrypt",
3567
- "pkgconfig(libcrypt)": "libxcrypt",
3568
- "pkgconfig(libnsl)": "libnsl",
3569
- "pkgconfig(liblz4)": "lz4",
3570
- "pkgconfig(libevent)": "libevent",
3571
- "pkgconfig(libarchive)": "libarchive",
3572
- "pkgconfig(sqlite3)": "sqlite",
3573
- "pkgconfig(libpq)": "postgresql",
3574
- "pkgconfig(mysqlclient)": "mariadb",
3575
- "pkgconfig(json-c)": "json-c",
3576
- "pkgconfig(json-glib-1.0)": "json-glib",
3577
- "pkgconfig(libunistring)": "libunistring",
3578
- "pkgconfig(libidn2)": "libidn2",
3579
- "pkgconfig(libpsl)": "libpsl",
3580
- "pkgconfig(icu-uc)": "icu",
3581
- "pkgconfig(icu-i18n)": "icu",
3582
- "pkgconfig(icu-io)": "icu",
3583
- "pkgconfig(gnutls)": "gnutls",
3584
- "pkgconfig(nettle)": "nettle",
3585
- "pkgconfig(hogweed)": "nettle",
3586
- "pkgconfig(libgcrypt)": "libgcrypt",
3587
- "pkgconfig(libgpg-error)": "libgpg-error",
3588
- "pkgconfig(libassuan)": "libassuan",
3589
- "pkgconfig(libusb-1.0)": "libusb",
3590
- "pkgconfig(libusb)": "libusb",
3591
- "pkgconfig(libgudev-1.0)": "libgudev",
3592
- "pkgconfig(gudev-1.0)": "libgudev",
3593
- "pkgconfig(polkit-gobject-1)": "polkit",
3594
- "pkgconfig(polkit-agent-1)": "polkit",
3595
- "pkgconfig(libpciaccess)": "libpciaccess",
3596
- "pkgconfig(pixman-1)": "pixman",
3597
- "pkgconfig(libdrm)": "libdrm",
3598
- "pkgconfig(libva)": "libva",
3599
- "pkgconfig(libva-drm)": "libva",
3600
- "pkgconfig(libva-x11)": "libva",
3601
- "pkgconfig(libva-wayland)": "libva",
3602
- "pkgconfig(vdpau)": "libvdpau",
3603
- "pkgconfig(libvdpau)": "libvdpau",
3604
- "pkgconfig(libinput)": "libinput",
3605
- "pkgconfig(libevdev)": "libevdev",
3606
- "pkgconfig(mtdev)": "mtdev",
3607
- # ── grafika / GL / multimedia ──
3608
- "pkgconfig(gbm)": "mesa",
3609
- "pkgconfig(gl)": "libglvnd",
3610
- "pkgconfig(egl)": "libglvnd",
3611
- "pkgconfig(glesv2)": "libglvnd",
3612
- "pkgconfig(glx)": "libglvnd",
3613
- "pkgconfig(vulkan)": "vulkan-loader",
3614
- "pkgconfig(libxkbcommon)": "libxkbcommon",
3615
- "pkgconfig(xkbcommon)": "libxkbcommon",
3616
- "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3617
- "pkgconfig(xcb)": "xorg-libxcb",
3618
- "pkgconfig(xcb-util)": "xcb-util",
3619
- "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3620
- "pkgconfig(xcb-icccm)": "xcb-util-wm",
3621
- "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3622
- "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3623
- "pkgconfig(xcb-image)": "xcb-util-image",
3624
- "pkgconfig(xcb-errors)": "xcb-util-errors",
3625
- "pkgconfig(wayland-client)": "wayland",
3626
- "pkgconfig(wayland-server)": "wayland",
3627
- "pkgconfig(wayland-cursor)": "wayland",
3628
- "pkgconfig(wayland-egl)": "wayland",
3629
- "pkgconfig(wayland-protocols)": "wayland-protocols",
3630
- "pkgconfig(gstreamer-1.0)": "gstreamer",
3631
- "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3632
- "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3633
- "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3634
- "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3635
- "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3636
- "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3637
- "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3638
- "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3639
- "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3640
- "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3641
- "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3642
- "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3643
- "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3644
- "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3645
- "pkgconfig(libpulse)": "libpulse",
3646
- "pkgconfig(libpulse-simple)": "libpulse",
3647
- "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3648
- "pkgconfig(alsa)": "alsa-lib",
3649
- "pkgconfig(jack)": "jack2",
3650
- "pkgconfig(libsamplerate)": "libsamplerate",
3651
- "pkgconfig(sndfile)": "libsndfile",
3652
- "pkgconfig(libavcodec)": "ffmpeg",
3653
- "pkgconfig(libavformat)": "ffmpeg",
3654
- "pkgconfig(libavutil)": "ffmpeg",
3655
- "pkgconfig(libavfilter)": "ffmpeg",
3656
- "pkgconfig(libswscale)": "ffmpeg",
3657
- "pkgconfig(libswresample)": "ffmpeg",
3658
- "pkgconfig(libpostproc)": "ffmpeg",
3659
- "pkgconfig(SDL2)": "sdl2",
3660
- "pkgconfig(SDL)": "sdl",
3661
- "pkgconfig(SDL2_image)": "sdl2-image",
3662
- "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3663
- "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3664
- "pkgconfig(SDL2_net)": "sdl2-net",
3665
- "pkgconfig(libpng16)": "libpng",
3666
- "pkgconfig(libwebp)": "libwebp",
3667
- "pkgconfig(libwebpmux)": "libwebp",
3668
- "pkgconfig(libwebpdemux)": "libwebp",
3669
- "pkgconfig(libopenjp2)": "openjpeg2",
3670
- "pkgconfig(lcms2)": "lcms2",
3671
- "pkgconfig(libheif)": "libheif",
3672
- "pkgconfig(libde265)": "libde265",
3673
- "pkgconfig(x264)": "x264",
3674
- "pkgconfig(x265)": "x265",
3675
- # ── glib / gio ──
3676
- "pkgconfig(gio-unix-2.0)": "glib",
3677
- "pkgconfig(gmodule-2.0)": "glib",
3678
- "pkgconfig(gthread-2.0)": "glib",
3679
- "pkgconfig(girepository-2.0)": "gobject-introspection",
3680
- "pkgconfig(girepository-1.0)": "gobject-introspection",
3681
- "pkgconfig(libglib-2.0)": "glib",
3682
- "pkgconfig(libgobject-2.0)": "glib",
3683
-}
3684
-
3685
-# Ostrzeżenia o wielu dostawcach tej samej wirtualnej nazwy – raz na proces.
3686
-_PROVIDES_WARNED = set()
3687
-# Cache indeksu provides dla danego obiektu repo: (repo_obj, {virtual: [pkg,...]}).
3688
-# Trzymamy referencję do repo, by uniknąć pomyłki przy ponownym użyciu id().
3689
-_provides_cache = (None, {})
3690
-
3691
-def _provides_index(repo: dict) -> dict:
3692
- """Buduje (i cache’uje) mapę wirtualna nazwa → lista dostawców w repo.
3693
-
3694
- Pozwala wybrać dostawcę DETERMINISTYCZNIE (posortowanego) zamiast zależeć
3695
- od kolejności wstawiania w repo.json, oraz ostrzec o konflikcie provides."""
3696
- global _provides_cache
3697
- cached_repo, idx = _provides_cache
3698
- if cached_repo is repo:
3699
- return idx
3700
- idx = {}
3701
- for _pn, _p in repo.items():
3702
- for _prov in (getattr(_p, "provides", None) or []):
3703
- idx.setdefault(_prov, []).append(_pn)
3704
- for _prov, _pns in idx.items():
3705
- if len(_pns) > 1 and _prov not in _PROVIDES_WARNED:
3706
- _PROVIDES_WARNED.add(_prov)
3707
- _sorted = sorted(_pns)
3708
- print(f" ⚠ {_('provides_conflict', name=_prov, providers=', '.join(_sorted), chosen=_sorted[0])}",
3709
- file=sys.stderr)
3710
- _provides_cache = (repo, idx)
3711
- return idx
3712
-
3713
-
3714
-def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3715
- """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3716
-
3717
- Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3718
- provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3719
- dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3720
- """
3721
- if name in repo:
3722
- return name
3723
- if name in PROVIDES_MAP:
3724
- real = PROVIDES_MAP[name]
3725
- if real in repo:
3726
- return real
3727
- # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3728
- if name.startswith("pkgconfig(Qt5"):
3729
- real = "qt5"
3730
- if real in repo:
3731
- return real
3732
- if name.startswith("pkgconfig(Qt6"):
3733
- real = "qt6"
3734
- if real in repo:
3735
- return real
3736
- if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3737
- real = "gstreamer"
3738
- if real in repo:
3739
- return real
3740
- if name.startswith("pkgconfig(gst-"):
3741
- real = "gst-plugins-base"
3742
- if real in repo:
3743
- return real
3744
- # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3745
- # Determinizm: przy wielu dostawcach wybieramy posortowanego pierwszego
3746
- # (i ostrzegamy raz), zamiast zależeć od kolejności w repo.json.
3747
- _idx = _provides_index(repo)
3748
- if name in _idx:
3749
- return min(_idx[name])
3750
- # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3751
- # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3752
- if installed:
3753
- _inst_cands = [pn for pn, meta in installed.items()
3754
- if isinstance(meta, dict) and name in (meta.get("provides") or [])]
3755
- if _inst_cands:
3756
- return min(_inst_cands)
3757
- clean = name
3758
- if name.startswith("pkgconfig(") and ")" in name:
3759
- clean = name.split("(", 1)[1].rstrip(")")
3760
- elif name.startswith("pkgconfig32(") and ")" in name:
3761
- clean = name.split("(", 1)[1].rstrip(")")
3762
- if clean != name and clean in repo:
3763
- return clean
3764
- return name
3765
-
3766
-
3767
-def cmd_why(pkg_name: str):
3768
- """Pokazuje dlaczego pakiet jest zainstalowany."""
3769
- installed = load_json(INSTALLED_DB)
3770
- world = load_world()
3771
- if pkg_name not in installed:
3772
- print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3773
- if pkg_name in world:
3774
- print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3775
- return 0
3776
- parents = set()
3777
- for w in world:
3778
- _find_dep_path(w, pkg_name, installed, set(), [], parents)
3779
- if parents:
3780
- for pp in sorted(parents):
3781
- print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3782
- else:
3783
- print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3784
- return 0
3785
-
3786
-
3787
-def _find_dep_path(cur, target, installed, visited, path, results):
3788
- if cur in visited: return
3789
- visited.add(cur); path.append(cur)
3790
- if cur == target:
3791
- results.add(tuple(path))
3792
- else:
3793
- for dep in installed.get(cur, {}).get("dependencies", []):
3794
- _find_dep_path(dep, target, installed, visited, path, results)
3795
- path.pop(); visited.discard(cur)
3796
-
3797
-
3798
-def cmd_autoremove():
3799
- """Automatycznie usuwa osierocone zależności bez pytania."""
3800
- installed = load_json(INSTALLED_DB)
3801
- world = load_world()
3802
- orphans = _find_orphans(installed, world)
3803
- if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3804
- print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3805
- return cmd_remove(list(orphans))
3806
-
3807
-
3808
-def cmd_download(package_names):
3809
- """Pobiera pakiety do cache bez instalowania."""
3810
- ensure_dirs()
3811
- repo = fetch_all_packages()
3812
- if not repo: print(f"❌ {_('no_index')}"); return 1
3813
- total_size = 0; downloaded = []
3814
- for name in package_names:
3815
- pkg = repo.get(name)
3816
- if not pkg:
3817
- print(f" ❌ {name}: {_('not_found')}"); continue
3818
- print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3819
- path = _download_pkg(pkg)
3820
- if path:
3821
- total_size += os.path.getsize(path)
3822
- downloaded.append(name)
3823
- print(_c("green", "✓"))
3824
- else:
3825
- print(_c("red", "✗"))
3826
- if downloaded:
3827
- print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3828
- return 0 if len(downloaded) == len(package_names) else 1
3829
-
3830
-
3831
-def cmd_stats():
3832
- """Wyświetla statystyki PAG."""
3833
- installed = load_json(INSTALLED_DB)
3834
- history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3835
- total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3836
- total_files = _db_count_files()
3837
- cache_size = sum(
3838
- os.path.getsize(os.path.join(PAG_CACHE, f))
3839
- for f in os.listdir(PAG_CACHE)
3840
- if os.path.isfile(os.path.join(PAG_CACHE, f))
3841
- ) if os.path.isdir(PAG_CACHE) else 0
3842
- last_update = "never"
3843
- for e in reversed(history):
3844
- if e.get("action") in ("install", "upgrade") and e.get("success"):
3845
- last_update = e.get("timestamp", "?")[:19]; break
3846
- print(f"\n {_c('bold', _('stats_title'))}")
3847
- print(f" {'─' * 40}")
3848
- print(f" {_('stats_packages'):<30} {len(installed)}")
3849
- print(f" {_('stats_files'):<30} {total_files}")
3850
- print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3851
- print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3852
- print(f" {_('stats_history'):<30} {len(history)}")
3853
- print(f" {_('stats_last_update'):<30} {last_update}")
3854
- by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3855
- if by_size:
3856
- print(f"\n {_c('dim', 'Top 5:')}")
3857
- for n, i in by_size:
3858
- print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3859
- return 0
3860
-
3861
-
3862
-def cmd_repo_add(url, name=None):
3863
- if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3864
- print(f" {_('sec_https')}"); return 1
3865
- ensure_dirs()
3866
- url = url.rstrip("/")
3867
- repos = get_repos()
3868
- if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3869
- if name:
3870
- # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3871
- os.makedirs(REPOS_DIR, exist_ok=True)
3872
- target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3873
- with open(target, "w") as f: f.write(f"{url}\n")
3874
- print(f"✅ {_('repo_added', url)} → {target}")
3875
- return
3876
- with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3877
- print(f"✅ {_('repo_added', url)}")
3878
-
3879
-def cmd_repo_list():
3880
- for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3881
-
3882
-FLATPAK_REMOTE_URL = "https://flathub.org/repo/flathub.flatpakrepo"
3883
-
3884
-def _check_flatpak(quiet: bool = False):
3885
- if not shutil.which("flatpak"):
3886
- if not quiet:
3887
- print(f"❌ {_('flatpak_missing')}")
3888
- return False
3889
- r = subprocess.run(["flatpak", "remotes"], capture_output=True, text=True)
3890
- if "flathub" not in r.stdout:
3891
- print(f"⚠ {_('flatpak_adding')}")
3892
- # Jako root dodajemy remote systemowy; bez roota – w instalacji użytkownika,
3893
- # żeby remote i miejsce instalacji były spójne (patrz _flatpak_do_install).
3894
- add = ["flatpak", "remote-add", "--if-not-exists"]
3895
- if os.geteuid() != 0:
3896
- add.append("--user")
3897
- subprocess.run(add + ["flathub", FLATPAK_REMOTE_URL], check=False)
3898
- return True
3899
-
3900
-def _spinner(msg: str):
3901
- """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3902
- stop = threading.Event()
3903
- def _spin():
3904
- for c in itertools.cycle("|/-\\"):
3905
- if stop.is_set():
3906
- break
3907
- sys.stdout.write(f"\r {msg} {c}")
3908
- sys.stdout.flush()
3909
- time.sleep(0.1)
3910
- t = threading.Thread(target=_spin, daemon=True)
3911
- t.start()
3912
- def _stop():
3913
- stop.set()
3914
- t.join(timeout=0.3)
3915
- sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3916
- sys.stdout.flush()
3917
- return _stop
3918
-
3919
-
3920
-def _flatpak_search_raw(query: str) -> List[dict]:
3921
- """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3922
- if not _check_flatpak():
3923
- return []
3924
- stop = _spinner("Szukam we Flathub...")
3925
- try:
3926
- try:
3927
- r = subprocess.run(
3928
- ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3929
- capture_output=True, text=True, timeout=120
3930
- )
3931
- finally:
3932
- stop()
3933
- if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3934
- print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3935
- results = []
3936
- for line in r.stdout.strip().split("\n"):
3937
- parts = line.split("\t")
3938
- if len(parts) >= 3:
3939
- results.append({
3940
- "name": parts[0].strip(),
3941
- "description": parts[1].strip() if len(parts) > 1 else "",
3942
- "app_id": parts[2].strip() if len(parts) > 2 else "",
3943
- "version": parts[3].strip() if len(parts) > 3 else "",
3944
- "branch": parts[4].strip() if len(parts) > 4 else "stable",
3945
- "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3946
- })
3947
- return results
3948
- except Exception as e:
3949
- print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3950
- return []
3951
-
3952
-def _flatpak_find_best(query: str) -> Optional[dict]:
3953
- """
3954
- Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3955
- - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3956
- - Jeśli query pasuje do nazwy → zwraca pierwsze
3957
- - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3958
- - Jeśli brak → zwraca None
3959
- """
3960
- results = _flatpak_search_raw(query)
3961
- if not results:
3962
- return None
3963
-
3964
- # Dokładne dopasowanie app_id
3965
- exact = [r for r in results if r["app_id"].lower() == query.lower()]
3966
- if exact:
3967
- return exact[0]
3968
-
3969
- # Dokładne dopasowanie nazwy
3970
- exact_name = [r for r in results if r["name"].lower() == query.lower()]
3971
- if exact_name:
3972
- return exact_name[0]
3973
-
3974
- # Jednoznaczne dopasowanie (tylko 1 wynik)
3975
- if len(results) == 1:
3976
- return results[0]
3977
-
3978
- # Wiele wyników – pokaż użytkownikowi
3979
- print(f"\n {_('flatpak_found', len(results))}")
3980
- for i, r in enumerate(results):
3981
- print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3982
- if r["version"]:
3983
- print(f" {_('flatpak_info_version')}: {r['version']}")
3984
- if r["description"]:
3985
- desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3986
- print(f" {desc}")
3987
-
3988
- try:
3989
- choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3990
- if not choice:
3991
- return None
3992
- idx = int(choice) - 1
3993
- if 0 <= idx < len(results):
3994
- return results[idx]
3995
- except (EOFError, ValueError, IndexError):
3996
- pass
3997
- return None
3998
-
3999
-def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
4000
- """Zwraca info o zainstalowanym flatpaku lub None."""
4001
- try:
4002
- r = subprocess.run(
4003
- ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
4004
- capture_output=True, text=True, timeout=10
4005
- )
4006
- if r.returncode != 0:
4007
- return None
4008
- parts = r.stdout.strip().split("\t")
4009
- if len(parts) < 3:
4010
- return None
4011
- return {
4012
- "name": parts[0].strip(),
4013
- "version": parts[1].strip() if len(parts) > 1 else "",
4014
- "branch": parts[2].strip() if len(parts) > 2 else "",
4015
- "origin": parts[3].strip() if len(parts) > 3 else "",
4016
- "size": parts[4].strip() if len(parts) > 4 else "",
4017
- "description": parts[5].strip() if len(parts) > 5 else "",
4018
- }
4019
- except Exception:
4020
- return None
4021
-
4022
-def _flatpak_is_installed(app_id: str) -> bool:
4023
- """Sprawdza czy flatpak o danym ID jest zainstalowany."""
4024
- try:
4025
- r = subprocess.run(
4026
- ["flatpak", "info", app_id],
4027
- capture_output=True, text=True, timeout=10
4028
- )
4029
- return r.returncode == 0
4030
- except Exception:
4031
- return False
4032
-
4033
-# =============================================================================
4034
-# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
4035
-# =============================================================================
4036
-# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
4037
-# pag flatpak search <query> → tylko szuka
4038
-# pag flatpak install <query> → instaluje
4039
-# pag flatpak remove <id> → usuwa
4040
-# pag flatpak list → lista zainstalowanych
4041
-# pag flatpak update → aktualizuje wszystkie
4042
-# pag flatpak info <id> → szczegóły flatpaka
4043
-
4044
-def cmd_flatpak(args: list):
4045
- """
4046
- Główna komenda flatpak – inteligentnie rozpoznaje intencję:
4047
- pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
4048
- pag flatpak search firefox → tylko wyszukiwanie
4049
- pag flatpak install ... → bezpośrednia instalacja
4050
- pag flatpak remove ... → odinstalowanie
4051
- pag flatpak list → lista
4052
- pag flatpak update → aktualizacja
4053
- pag flatpak info ... → szczegóły
4054
- """
4055
- if not _check_flatpak():
4056
- return 1
4057
-
4058
- if not args:
4059
- # Bez argumentów – domyślnie lista
4060
- return cmd_flatpak_list()
4061
-
4062
- subcmd = args[0].lower()
4063
- rest = args[1:]
4064
-
4065
- # ── Podkomendy jawne ────────────────────────────────────────────────
4066
- if subcmd == "search":
4067
- if not rest:
4068
- print(_("flatpak_usage")); return 1
4069
- return cmd_flatpak_search(" ".join(rest))
4070
-
4071
- elif subcmd == "install":
4072
- if not rest:
4073
- print(_("flatpak_usage")); return 1
4074
- return _flatpak_smart_install(rest)
4075
-
4076
- elif subcmd == "remove" or subcmd == "uninstall":
4077
- if not rest:
4078
- print(_("flatpak_usage")); return 1
4079
- return _flatpak_smart_remove(rest)
4080
-
4081
- elif subcmd == "list":
4082
- return cmd_flatpak_list()
4083
-
4084
- elif subcmd == "update":
4085
- return cmd_flatpak_update()
4086
-
4087
- elif subcmd == "info":
4088
- if not rest:
4089
- print(_("flatpak_usage")); return 1
4090
- return cmd_flatpak_info(rest[0])
4091
-
4092
- else:
4093
- # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
4094
- # Sprawdź czy to zainstalowany flatpak → pokaż info
4095
- # Jeśli nie → szukaj i zaproponuj instalację
4096
- query = " ".join(args)
4097
-
4098
- # Najpierw sprawdź czy już zainstalowany
4099
- if _flatpak_is_installed(query):
4100
- print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
4101
- return cmd_flatpak_info(query)
4102
-
4103
- # Szukaj we Flathub
4104
- print(f" {_('flatpak_searching', query)}")
4105
- best = _flatpak_find_best(query)
4106
- if not best:
4107
- print(f" ❌ '{query}' – {_('flatpak_not_found')}")
4108
- return 1
4109
-
4110
- print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
4111
- if best["version"]:
4112
- print(f" {_('flatpak_info_version')}: {best['version']}")
4113
- if best["description"]:
4114
- print(f" {best['description']}")
4115
-
4116
- try:
4117
- ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
4118
- except (EOFError, KeyboardInterrupt):
4119
- print(f"\n ⚠ {_('no_tty')}")
4120
- return 0
4121
- if ans and ans not in ("t", "y"):
4122
- print(_("cancelled"))
4123
- return 0
4124
-
4125
- return _flatpak_do_install(best["app_id"])
4126
-
4127
-def _flatpak_smart_install(names: list) -> int:
4128
- """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
4129
- failed = 0
4130
- for name in names:
4131
- if "." in name and "/" not in name:
4132
- # Wygląda na pełne app_id (np. org.mozilla.firefox)
4133
- app_id = name
4134
- else:
4135
- # Szukaj najlepszego dopasowania
4136
- best = _flatpak_find_best(name)
4137
- if not best:
4138
- print(f" ❌ '{name}' – {_('flatpak_not_found')}")
4139
- failed += 1
4140
- continue
4141
- app_id = best["app_id"]
4142
- print(f" → {best['name']} ({app_id})")
4143
-
4144
- if _flatpak_do_install(app_id) != 0:
4145
- failed += 1
4146
- return 1 if failed else 0
4147
-
4148
-def _flatpak_export_dirs() -> List[str]:
4149
- """Katalogi eksportów Flatpaka (system + użytkownika) obecne na dysku.
4150
-
4151
- Instalacja użytkownika roota (/root/.local/share/flatpak) jest świadomie
4152
- POMIJANA – to wewnętrzny artefakt roota, którego sesja użytkownika nigdy
4153
- nie zobaczy (patrz _flatpak_do_install)."""
4154
- dirs = ["/var/lib/flatpak/exports"]
4155
- home = os.path.expanduser("~")
4156
- if home and home not in ("/root", "/"):
4157
- dirs.append(os.path.join(home, ".local", "share", "flatpak", "exports"))
4158
- return [d for d in dirs if os.path.isdir(d)]
4159
-
4160
-
4161
-def _flatpak_refresh_caches() -> None:
4162
- """Odświeża cache pulpitu i ikon po zmianie w Flatpaku (best-effort).
4163
-
4164
- Flatpak robi to sam, ale tylko gdy `update-desktop-database` jest w PATH.
4165
- Bez tego nowo zainstalowana aplikacja bywa nieobecna w menu."""
4166
- for exports in _flatpak_export_dirs():
4167
- apps = os.path.join(exports, "share", "applications")
4168
- if os.path.isdir(apps) and shutil.which("update-desktop-database"):
4169
- subprocess.run(["update-desktop-database", apps],
4170
- capture_output=True, check=False, timeout=60)
4171
- icons = os.path.join(exports, "share", "icons", "hicolor")
4172
- if os.path.isdir(icons) and shutil.which("gtk-update-icon-cache"):
4173
- subprocess.run(["gtk-update-icon-cache", "-q", "-t", "-f", icons],
4174
- capture_output=True, check=False, timeout=120)
4175
-
4176
-
4177
-def _flatpak_session_sees_exports() -> bool:
4178
- """Czy bieżąca sesja ma eksporty Flatpaka w XDG_DATA_DIRS."""
4179
- raw = os.environ.get("XDG_DATA_DIRS", "")
4180
- dirs = {d for d in raw.split(":") if d} or {"/usr/local/share", "/usr/share"}
4181
- return any(os.path.join(e, "share") in dirs for e in _flatpak_export_dirs())
4182
-
4183
-
4184
-def _flatpak_warn_if_invisible() -> None:
4185
- """Ostrzega, gdy sesja nie widzi eksportów – inaczej wygląda to jak
4186
- „zainstalowało się, ale nie ma go w menu / na PC”."""
4187
- if not _flatpak_session_sees_exports():
4188
- print(f" ⚠ {_('flatpak_menu_hint')}")
4189
- print(f" {_('flatpak_menu_fix')}")
4190
-
4191
-
4192
-def _flatpak_do_install(app_id: str) -> int:
4193
- """Wykonuje właściwą instalację flatpaka.
4194
-
4195
- Jako root wymuszamy instalację SYSTEMOWĄ (--system). Bez tego `flatpak`
4196
- uruchomiony jako root potrafi zainstalować aplikację w instalacji
4197
- użytkownika roota (/root/.local/share/flatpak) – widocznej dla roota, ale
4198
- nie dla zalogowanego użytkownika. To dokładnie efekt „instaluje, ale nie
4199
- mam tego na PC”. Bez roota próbujemy instalacji systemowej (polkit),
4200
- a w razie niepowodzenia cofamy się do instalacji użytkownika (--user).
4201
-
4202
- Zakres (scope) ma znaczenie: instalacja SYSTEMOWA (--system) jest widoczna
4203
- dla wszystkich użytkowników, a --user tylko dla bieżącego (dla roota:
4204
- /root/.local/... → niewidoczna dla sesji). `flatpak list` bez flag pokazuje
4205
- oba zakresy, więc systemowa instalacja roota jest widoczna też dla
4206
- zwykłego użytkownika."""
4207
- print(f" {_('flatpak_installing', app_id)}")
4208
- if os.geteuid() == 0:
4209
- result = subprocess.run(
4210
- ["flatpak", "install", "-y", "--system", "flathub", app_id],
4211
- check=False, timeout=600)
4212
- else:
4213
- result = subprocess.run(
4214
- ["flatpak", "install", "-y", "flathub", app_id],
4215
- check=False, timeout=600)
4216
- if result.returncode != 0:
4217
- # Flathub tylko dla użytkownika / brak agenta polkit – instalacja
4218
- # lokalna użytkownika jest lepsza niż twardy błąd.
4219
- subprocess.run(
4220
- ["flatpak", "remote-add", "--if-not-exists", "--user",
4221
- "flathub", FLATPAK_REMOTE_URL], check=False, timeout=60)
4222
- result = subprocess.run(
4223
- ["flatpak", "install", "-y", "--user", "flathub", app_id],
4224
- check=False, timeout=600)
4225
- if result.returncode != 0:
4226
- print(f" ❌ {_('download_fail')}: {app_id}")
4227
- return 1
4228
- _flatpak_refresh_caches()
4229
- print(f" ✅ {_('flatpak_installed', app_id)}")
4230
- _flatpak_warn_if_invisible()
4231
- return 0
4232
-
4233
-def _flatpak_smart_remove(names: list) -> int:
4234
- """Usuwa flatpaki – obsługuje nazwy częściowe."""
4235
- # Pobierz listę zainstalowanych
4236
- try:
4237
- r = subprocess.run(
4238
- ["flatpak", "list", "--columns=application,name"],
4239
- capture_output=True, text=True, timeout=10
4240
- )
4241
- installed = {}
4242
- for line in r.stdout.strip().split("\n"):
4243
- parts = line.split("\t")
4244
- if len(parts) >= 2:
4245
- installed[parts[0].strip()] = parts[1].strip()
4246
- except Exception:
4247
- installed = {}
4248
-
4249
- failed = 0
4250
- for name in names:
4251
- app_id = name
4252
-
4253
- # Jeśli nie podano pełnego ID – spróbuj dopasować
4254
- if name not in installed:
4255
- matches = {aid: aname for aid, aname in installed.items()
4256
- if name.lower() in aid.lower() or name.lower() in aname.lower()}
4257
- if len(matches) == 0:
4258
- print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
4259
- failed += 1
4260
- continue
4261
- elif len(matches) == 1:
4262
- app_id = list(matches.keys())[0]
4263
- print(f" → {matches[app_id]} ({app_id})")
4264
- else:
4265
- print(f"\n Wiele dopasowań dla '{name}':")
4266
- for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
4267
- print(f" {i}. {aname} ({aid})")
4268
- try:
4269
- choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
4270
- if not choice:
4271
- failed += 1
4272
- continue
4273
- aid_list = sorted(matches.keys())
4274
- app_id = aid_list[int(choice) - 1]
4275
- except (EOFError, ValueError, IndexError):
4276
- failed += 1
4277
- continue
4278
-
4279
- print(f" 🗑 {app_id} ...", end=" ", flush=True)
4280
- result = subprocess.run(
4281
- ["flatpak", "uninstall", "-y", app_id],
4282
- capture_output=True, text=True, timeout=120
4283
- )
4284
- if result.returncode == 0:
4285
- print("✅")
4286
- print(f" {_('flatpak_removed', app_id)}")
4287
- else:
4288
- print("❌")
4289
- failed += 1
4290
- if not failed:
4291
- _flatpak_refresh_caches()
4292
- return 1 if failed else 0
4293
-
4294
-def cmd_flatpak_search(q: str):
4295
- """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
4296
- if not _check_flatpak():
4297
- return 1
4298
- results = _flatpak_search_raw(q)
4299
- if not results:
4300
- print(f" ❌ '{q}' – {_('flatpak_not_found')}")
4301
- return 1
4302
- print(f"\n {_('flatpak_found', len(results))}")
4303
- shown = results[:30] # max 30 wyników
4304
- for i, r in enumerate(shown, 1):
4305
- installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
4306
- print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
4307
- if r["version"]:
4308
- print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
4309
- if r["description"]:
4310
- desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
4311
- print(f" {_c('dim', desc)}")
4312
- if len(results) > 30:
4313
- print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
4314
-
4315
- # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
4316
- try:
4317
- ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
4318
- except (EOFError, KeyboardInterrupt):
4319
- return 0
4320
- if ans:
4321
- try:
4322
- idx = int(ans) - 1
4323
- if 0 <= idx < len(shown):
4324
- return _flatpak_do_install(shown[idx]["app_id"])
4325
- print(_("cancelled"))
4326
- except (ValueError, IndexError):
4327
- print(_("cancelled"))
4328
- return 0
4329
-
4330
-def cmd_flatpak_list():
4331
- """Wyświetla zainstalowane flatpaki."""
4332
- if not _check_flatpak():
4333
- return 1
4334
- r = subprocess.run(
4335
- ["flatpak", "list", "--columns=application,name,version,origin,installation,installed-size"],
4336
- capture_output=True, text=True, timeout=10
4337
- )
4338
- lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
4339
- if not lines:
4340
- print(" (brak zainstalowanych flatpaków)")
4341
- return 0
4342
- print(f" Zainstalowane flatpaki ({len(lines)}):")
4343
- for line in lines:
4344
- parts = line.split("\t")
4345
- if len(parts) >= 3:
4346
- app_id, name, version = parts[0], parts[1], parts[2]
4347
- scope = parts[4] if len(parts) > 4 else ""
4348
- size = parts[5] if len(parts) > 5 else ""
4349
- size_str = f" ({size})" if size else ""
4350
- scope_str = f" [{scope}]" if scope else ""
4351
- print(f" 📦 {_c('bold', name)} {version}{scope_str}{size_str}")
4352
- print(f" {_c('dim', app_id)}")
4353
- return 0
4354
-
4355
-def cmd_flatpak_update():
4356
- """Aktualizuje wszystkie flatpaki."""
4357
- if not _check_flatpak():
4358
- return 1
4359
- print(" 🔄 Aktualizacja flatpaków...")
4360
- result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
4361
- if result.returncode == 0:
4362
- _flatpak_refresh_caches()
4363
- print(f" ✅ {_('flatpak_updated')}")
4364
- return result.returncode
4365
-
4366
-def cmd_flatpak_info(app_id: str):
4367
- """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
4368
- if not _check_flatpak():
4369
- return 1
4370
-
4371
- # Najpierw sprawdź zainstalowany
4372
- info = _flatpak_get_installed_info(app_id)
4373
- if info:
4374
- print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
4375
- print(f" {'─' * 45}")
4376
- print(f" {_('flatpak_info_id'):<16} {app_id}")
4377
- print(f" {_('flatpak_info_version'):<16} {info['version']}")
4378
- print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
4379
- print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
4380
- if info["size"]:
4381
- print(f" {_('flatpak_info_size'):<16} {info['size']}")
4382
- if info["description"]:
4383
- print(f" {_('flatpak_info_desc'):<16} {info['description']}")
4384
- return 0
4385
-
4386
- # Szukaj we Flathub
4387
- results = _flatpak_search_raw(app_id)
4388
- exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
4389
- if not exact:
4390
- # Spróbuj częściowego dopasowania
4391
- if results:
4392
- exact = [results[0]]
4393
- else:
4394
- print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
4395
- return 1
4396
-
4397
- r = exact[0]
4398
- print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
4399
- print(f" {'─' * 45}")
4400
- print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
4401
- print(f" {_('flatpak_info_version'):<16} {r['version']}")
4402
- if r["description"]:
4403
- print(f" {_('flatpak_info_desc'):<16} {r['description']}")
4404
- print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
4405
- return 0
4406
-
4407
-# =============================================================================
4408
-# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
4409
-# =============================================================================
4410
-
4411
-# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
4412
-KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
4413
-
4414
-def _is_kernel_package(name: str) -> bool:
4415
- """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
4416
- name_lower = name.lower()
4417
- return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
4418
-
4419
-def _rebuild_initramfs(deploy_dir: str = "") -> bool:
4420
- """
4421
- Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
4422
- Używa skryptu pag-initramfs lub ręcznego cpio.
4423
- """
4424
- if deploy_dir:
4425
- root = deploy_dir
4426
- else:
4427
- root = _get_deployment_root()
4428
-
4429
- if root == PAG_ROOT:
4430
- # Zwykły system – użyj dracut jeśli dostępny
4431
- if shutil.which("dracut"):
4432
- print(" 🔧 Przebudowa initramfs (dracut)...")
4433
- result = subprocess.run(
4434
- ["dracut", "--force", "/boot/initramfs.img"],
4435
- capture_output=True, text=True, timeout=120
4436
- )
4437
- return result.returncode == 0
4438
- elif shutil.which("mkinitcpio"):
4439
- print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
4440
- result = subprocess.run(
4441
- ["mkinitcpio", "-g", "/boot/initramfs.img"],
4442
- capture_output=True, text=True, timeout=120
4443
- )
4444
- return result.returncode == 0
4445
- else:
4446
- print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
4447
- return False
4448
-
4449
- # Tryb immutable – budujemy initramfs dla deploymentu
4450
- print(" 🔧 Budowanie initramfs dla deploymentu...")
4451
-
4452
- # Sprawdź czy mamy nasz skrypt init
4453
- pag_init_script = "/usr/share/pag/initramfs-init"
4454
- if not os.path.exists(pag_init_script):
4455
- # Szukaj w źródłach (developerski fallback)
4456
- alt_paths = [
4457
- os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
4458
- "/usr/share/pag/init",
4459
- ]
4460
- for p in alt_paths:
4461
- if os.path.exists(p):
4462
- pag_init_script = p
4463
- break
4464
-
4465
- if not os.path.exists(pag_init_script):
4466
- print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
4467
- return False
4468
-
4469
- boot_dir = os.path.join(root, "boot")
4470
- os.makedirs(boot_dir, exist_ok=True)
4471
-
4472
- # Znajdź jądro (vmlinuz-*)
4473
- kernels = sorted(
4474
- [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4475
- reverse=True
4476
- ) if os.path.exists(boot_dir) else []
4477
- if not kernels:
4478
- print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
4479
- return False
4480
-
4481
- kernel_ver = kernels[0].replace("vmlinuz-", "")
4482
- print(f" 🐧 Jądro: {kernel_ver}")
4483
-
4484
- # Buduj initramfs ręcznie (cpio)
4485
- tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4486
- try:
4487
- # Podstawowa struktura
4488
- for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4489
- "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4490
- os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4491
-
4492
- # Skopiuj init
4493
- shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4494
- os.chmod(os.path.join(tmpdir, "init"), 0o755)
4495
-
4496
- # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4497
- busybox_paths = [
4498
- os.path.join(root, "usr/bin/busybox"),
4499
- os.path.join(root, "bin/busybox"),
4500
- "/usr/bin/busybox",
4501
- "/bin/busybox",
4502
- ]
4503
- busybox = None
4504
- for bp in busybox_paths:
4505
- if os.path.exists(bp):
4506
- busybox = bp
4507
- break
4508
-
4509
- if busybox:
4510
- shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4511
- # Utwórz symlinki dla podstawowych komend
4512
- for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4513
- "readlink", "mkdir", "switch_root", "cp", "rm"]:
4514
- link = os.path.join(tmpdir, "bin", cmd)
4515
- if not os.path.exists(link):
4516
- os.symlink("busybox", link)
4517
- # /bin/sh → busybox
4518
- if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4519
- os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4520
- else:
4521
- # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4522
- for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4523
- src = os.path.join(root, "usr/bin", tool)
4524
- if not os.path.exists(src):
4525
- src = os.path.join(root, "bin", tool)
4526
- if os.path.exists(src):
4527
- dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4528
- shutil.copy2(src, dest)
4529
- # Kopiuj zależności .so
4530
- _copy_libs_for_binary(src, tmpdir, root)
4531
-
4532
- # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4533
- modules_src = os.path.join(root, "lib/modules", kernel_ver)
4534
- if os.path.isdir(modules_src):
4535
- modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4536
- # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4537
- for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4538
- "kernel/drivers/scsi", "kernel/drivers/virtio",
4539
- "modules.order", "modules.builtin"]:
4540
- src_sub = os.path.join(modules_src, sub)
4541
- if os.path.exists(src_sub):
4542
- dst_sub = os.path.join(modules_dst, sub)
4543
- os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4544
- if os.path.isdir(src_sub):
4545
- try:
4546
- shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4547
- ignore_dangling_symlinks=True)
4548
- except (FileNotFoundError, PermissionError):
4549
- print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4550
- else:
4551
- try:
4552
- shutil.copy2(src_sub, dst_sub)
4553
- except (FileNotFoundError, PermissionError):
4554
- print(f" ⚠ Pomijam niedostępny plik: {sub}")
4555
-
4556
- # Pakuj do initramfs.img
4557
- initramfs_path = os.path.join(boot_dir, "initramfs.img")
4558
- old_cwd = os.getcwd()
4559
- os.chdir(tmpdir)
4560
- try:
4561
- with open(initramfs_path + ".tmp", "wb") as out:
4562
- _run_cpio_pipeline(tmpdir, out)
4563
- os.rename(initramfs_path + ".tmp", initramfs_path)
4564
- finally:
4565
- os.chdir(old_cwd)
4566
-
4567
- size_mb = os.path.getsize(initramfs_path) / 1048576
4568
- print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4569
- return True
4570
-
4571
- except Exception as e:
4572
- print(f" ❌ Błąd budowy initramfs: {e}")
4573
- return False
4574
- finally:
4575
- shutil.rmtree(tmpdir, ignore_errors=True)
4576
-
4577
-
4578
-def _run_cpio_pipeline(tmpdir: str, out):
4579
- """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4580
-
4581
- Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4582
- (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4583
- Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4584
- rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4585
- """
4586
- find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4587
- cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4588
- stdin=find.stdout, stdout=subprocess.PIPE)
4589
- find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4590
- gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4591
- cpio.stdout.close()
4592
- try:
4593
- gzip.wait(timeout=120)
4594
- if gzip.returncode != 0:
4595
- raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4596
- cpio.wait(timeout=30)
4597
- find.wait(timeout=30)
4598
- except subprocess.TimeoutExpired:
4599
- for p in (gzip, cpio, find):
4600
- p.kill()
4601
- raise
4602
- finally:
4603
- for p in (find, cpio, gzip):
4604
- if p.poll() is None:
4605
- p.kill()
4606
- # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4607
- if cpio.returncode != 0:
4608
- raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4609
- if find.returncode != 0:
4610
- raise subprocess.CalledProcessError(find.returncode, ["find"])
4611
-
4612
-
4613
-def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4614
- """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4615
- try:
4616
- result = subprocess.run(
4617
- ["ldd", binary], capture_output=True, text=True, timeout=10
4618
- )
4619
- for line in result.stdout.split("\n"):
4620
- m = re.search(r'=>\s+(/\S+)', line)
4621
- if m:
4622
- lib_path = m.group(1)
4623
- lib_rel = lib_path.lstrip("/")
4624
- lib_dest = os.path.join(dest_dir, lib_rel)
4625
- if not os.path.exists(lib_dest):
4626
- os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4627
- # Szukaj w deployment root lub systemie
4628
- if os.path.exists(lib_path):
4629
- shutil.copy2(lib_path, lib_dest)
4630
- else:
4631
- alt = os.path.join(root, lib_rel)
4632
- if os.path.exists(alt):
4633
- shutil.copy2(alt, lib_dest)
4634
- except Exception:
4635
- pass
4636
-
4637
-
4638
-def cmd_initramfs_update():
4639
- """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4640
- ensure_dirs()
4641
- deploy_dir = _get_deployment_root()
4642
- if deploy_dir != PAG_ROOT:
4643
- print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4644
- ok = _rebuild_initramfs(deploy_dir)
4645
- if ok:
4646
- print("✅ Initramfs zaktualizowany.")
4647
- # Po initramfs – zaktualizuj też GRUB
4648
- _update_grub_config()
4649
- else:
4650
- print("❌ Błąd aktualizacji initramfs.")
4651
- return 0 if ok else 1
4652
-
4653
-
4654
-def _update_grub_config():
4655
- """
4656
- Generuje wpisy GRUB dla wszystkich deploymentów.
4657
- Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4658
- """
4659
- grub_cfg = "/boot/grub/grub.cfg"
4660
- if not os.path.exists(os.path.dirname(grub_cfg)):
4661
- return # brak GRUB
4662
-
4663
- deployments = _load_deployments()
4664
- root_dev = _detect_root_device()
4665
- if not root_dev:
4666
- print(" ⚠ Nie udało się wykryć partycji root – wpisy GRUB nie dostaną root=.",
4667
- file=sys.stderr)
4668
- print(" Ustaw PAG_ROOT_DEVICE=/dev/... (lub PAG_GRUB_ROOT) i powtórz.",
4669
- file=sys.stderr)
4670
- root_arg = f" root={root_dev}" if root_dev else ""
4671
-
4672
- lines = [
4673
- "# =====================================================================",
4674
- "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4675
- f"# Data: {datetime.now().isoformat()}",
4676
- "# =====================================================================",
4677
- "",
4678
- ]
4679
-
4680
- # Domyślny – ostatni (najnowszy) deployment
4681
- if deployments:
4682
- latest = deployments[-1]["id"]
4683
- lines.append(f"set default=0")
4684
- lines.append(f"set timeout=5")
4685
- else:
4686
- lines.append("set default=0")
4687
- lines.append("set timeout=5")
4688
- lines.append("")
4689
-
4690
- # Wpisy dla każdego deploymentu (od najnowszego)
4691
- entry_num = 0
4692
- for d in reversed(deployments):
4693
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4694
- boot_dir = os.path.join(deploy_dir, "boot")
4695
- kernels = sorted(
4696
- [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4697
- reverse=True
4698
- ) if os.path.isdir(boot_dir) else []
4699
-
4700
- kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4701
- initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4702
- initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4703
-
4704
- active_mark = " [AKTYWNY]" if d.get("active") else ""
4705
- pkg_list = ", ".join(d.get("packages", [])[:3])
4706
- label = f"Pagan Linux – {d['id']}{active_mark}"
4707
-
4708
- lines.append(f"menuentry '{label}' {{")
4709
- if kernel_path:
4710
- lines.append(f" linux {kernel_path}{root_arg} rw quiet")
4711
- else:
4712
- lines.append(f" # Brak jądra w tym deploymencie")
4713
- if initrd_line:
4714
- lines.append(f" {initrd_line}")
4715
- lines.append("}")
4716
- lines.append("")
4717
- entry_num += 1
4718
-
4719
- # Wpis fallback: zwykły root (gdyby wszystko padło).
4720
- # Uwaga: GRUB NIE rozwija globów – trzeba podać KONKRETNY plik jądra
4721
- # (wcześniejsze `linux /boot/vmlinuz-*` było niepoprawne).
4722
- fallback_kernels = sorted(
4723
- [f for f in os.listdir("/boot") if f.startswith("vmlinuz-")],
4724
- reverse=True
4725
- ) if os.path.isdir("/boot") else []
4726
- lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4727
- if fallback_kernels:
4728
- lines.append(f" linux /boot/{fallback_kernels[0]}{root_arg} rw quiet")
4729
- else:
4730
- lines.append(" # Brak jądra w /boot")
4731
- lines.append(f" initrd /boot/initramfs.img")
4732
- lines.append("}")
4733
- lines.append("")
4734
-
4735
- # Zapisz
4736
- os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4737
- with open(grub_cfg, "w") as f:
4738
- f.write("\n".join(lines))
4739
-
4740
- print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4741
-
4742
-
4743
-def _root_device_from_fstab(path: str = "/etc/fstab") -> str:
4744
- """Zwraca DEVICE z wpisu dla '/' w fstab (pomija komentarze)."""
4745
- try:
4746
- with open(path, "r", encoding="utf-8", errors="replace") as fh:
4747
- for line in fh:
4748
- line = line.split("#", 1)[0].strip()
4749
- if not line:
4750
- continue
4751
- parts = line.split()
4752
- if len(parts) >= 2 and parts[1] == "/":
4753
- return parts[0]
4754
- except OSError:
4755
- pass
4756
- return ""
4757
-
4758
-
4759
-def _in_chroot() -> bool:
4760
- """Heurystyka: czy działamy w chrocie (build ISO/IMG)?
4761
-
4762
- W chrocie /proc/1/root to root HOSTA – inny system plików niż nasz '/'.
4763
- Na zwykłym systemie PID 1 ma root równy '/'. PAGAN_ROOT ustawiają skrypty
4764
- budujące i jest dziedziczony przez chroot (dodatkowa wskazówka).
4765
- """
4766
- if os.environ.get("PAG_IN_CHROOT") == "1":
4767
- return True
4768
- if os.environ.get("PAGAN_ROOT"):
4769
- return True
4770
- try:
4771
- return os.stat("/").st_dev != os.stat("/proc/1/root").st_dev
4772
- except OSError:
4773
- return False
4774
-
4775
-
4776
-def _detect_root_device() -> str:
4777
- """Wykrywa urządzenie/identyfikator partycji root dla GRUB (root=...).
4778
-
4779
- Kolejność (od najbardziej wiarygodnego w danym kontekście):
4780
- 1) PAG_ROOT_DEVICE / PAG_GRUB_ROOT – jawny override (build ISO/IMG,
4781
- instalator); najpewniejszy, bo nie zgadujemy.
4782
- 2) /etc/fstab TARGETU – w chrocie findmnt '/' zwraca partycję HOSTA,
4783
- a fstab opisuje target (np. root=/dev/sda2 dla obrazu IMG).
4784
- 3) findmnt '/' – na żywym systemie (obsługuje UUID/LUKS/subvol).
4785
- Zwraca "" gdy nie da się ustalić – wtedy GRUB nie dostaje błędnego root=.
4786
- """
4787
- explicit = (os.environ.get("PAG_ROOT_DEVICE")
4788
- or os.environ.get("PAG_GRUB_ROOT") or "").strip()
4789
- if explicit:
4790
- return explicit
4791
-
4792
- fstab_dev = _root_device_from_fstab()
4793
- if _in_chroot() and fstab_dev:
4794
- return fstab_dev
4795
-
4796
- try:
4797
- result = subprocess.run(
4798
- ["findmnt", "-n", "-o", "SOURCE", "/"],
4799
- capture_output=True, text=True, timeout=5
4800
- )
4801
- if result.returncode == 0 and result.stdout.strip():
4802
- return result.stdout.strip()
4803
- except Exception:
4804
- pass
4805
-
4806
- # Ostatnia deska: fstab targetu (lepsze niż hardkodowane /dev/sda1,
4807
- # które na sprzęcie z NVMe dawało niebootowalny system).
4808
- return fstab_dev
4809
-
4810
-
4811
-def cmd_grub_update():
4812
- """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4813
- ensure_dirs()
4814
- print("📋 Aktualizacja konfiguracji GRUB...")
4815
- _update_grub_config()
4816
- print("✅ GRUB zaktualizowany.")
4817
- return 0
4818
-
4819
-def cmd_deploy_list():
4820
- """Wyświetla listę wszystkich deploymentów."""
4821
- deployments = _load_deployments()
4822
- if not deployments:
4823
- print(_("no_deployments")); return
4824
-
4825
- print(_("deployments_list", len(deployments)))
4826
- active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4827
-
4828
- for d in reversed(deployments):
4829
- marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4830
- print(f" {d['id']}{marker}")
4831
- print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4832
- if len(d.get('packages', [])) > 5:
4833
- print(f" +{len(d['packages']) - 5} więcej...")
4834
- print(f" {d['timestamp']}")
4835
-
4836
-
4837
-def cmd_deploy_rollback():
4838
- """Przełącza na poprzedni deployment."""
4839
- deployments = _load_deployments()
4840
- active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4841
-
4842
- if len(deployments) < 2:
4843
- print(f"❌ {_('deploy_rollback_fail')}"); return 1
4844
-
4845
- current_idx = active_indices[0] if active_indices else len(deployments) - 1
4846
- prev_idx = current_idx - 1 if current_idx > 0 else -1
4847
-
4848
- if prev_idx < 0:
4849
- print(f"❌ {_('deploy_rollback_fail')}"); return 1
4850
-
4851
- prev = deployments[prev_idx]
4852
- prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4853
-
4854
- if not os.path.isdir(prev_dir):
4855
- print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4856
-
4857
- print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4858
- print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4859
-
4860
- if not _ask_confirm():
4861
- return 0
4862
-
4863
- _switch_deployment(prev_dir)
4864
-
4865
- for d in deployments:
4866
- d["active"] = (d["id"] == prev["id"])
4867
- _save_deployments(deployments)
4868
-
4869
- _update_grub_config()
4870
- print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4871
- print(" 💡 Restart wymagany do przeładowania systemu.")
4872
- return 0
4873
-
4874
-
4875
-def cmd_deploy_cleanup(keep: int = 3):
4876
- """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4877
- deployments = _load_deployments()
4878
-
4879
- if len(deployments) <= keep:
4880
- print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4881
-
4882
- to_remove = deployments[:-keep]
4883
- removed = 0
4884
-
4885
- for d in to_remove:
4886
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4887
- if os.path.isdir(deploy_dir):
4888
- shutil.rmtree(deploy_dir, ignore_errors=True)
4889
- removed += 1
4890
-
4891
- remaining = deployments[-keep:]
4892
- _save_deployments(remaining)
4893
-
4894
- print(f"✅ {_('deploy_cleanup_ok', removed)}")
4895
- return 0
4896
-
4897
-
4898
-# =============================================================================
4899
-# POMOCNICZE
4900
-# =============================================================================
4901
-
4902
-# Pakiety dostarczane przez bazowy system (zawsze "zainstalowane"). Wchodzą do
4903
-# rootfs z ISO przez data.tar.xz, więc NIE ma ich w repo.json ani w bazie pag –
4904
-# każda zależność od nich (glibc, bash, python3…) wyglądałaby na "brakującą".
4905
-# Współdzielone przez rozwijanie i weryfikację zależności.
4906
-SYSTEM_BASE = {
4907
- "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4908
- "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4909
- "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4910
- "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4911
-}
4912
-
4913
-
4914
-def _resolve_deps(names, repo, installed):
4915
- resolved, visited = [], set()
4916
- missing = [] # zależności których nie ma ani w repo ani zainstalowane
4917
-
4918
- def _is_base(dep):
4919
- """True, gdy zależność dostarcza bazowy system (także przez provides)."""
4920
- return dep in SYSTEM_BASE \
4921
- or _resolve_provides(dep, repo, installed) in SYSTEM_BASE
4922
-
4923
- def visit(name, explicit=False):
4924
- if name in visited: return
4925
-
4926
- # Rozwijanie wirtualnych zależności przez provides
4927
- target = _resolve_provides(name, repo, installed)
4928
-
4929
- # Zależność dostarczana przez bazowy system – nie ma jej w repo ani w
4930
- # bazie pag, więc nie "brakuje" jej i nie próbujemy jej instalować.
4931
- # Wyjątek: nazwa podana wprost przez użytkownika (explicit=True).
4932
- if not explicit and target in SYSTEM_BASE:
4933
- return
4934
-
4935
- if target in visited: return
4936
- visited.add(target)
4937
- if target in repo:
4938
- for dep in repo[target].dependencies:
4939
- real_dep = _resolve_provides(dep, repo, installed)
4940
- real_target = real_dep if real_dep in repo else dep
4941
-
4942
- if _is_base(dep):
4943
- continue
4944
-
4945
- # Sprawdź czy zależność jest dostępna
4946
- if real_target not in installed and real_target not in repo:
4947
- if dep not in missing:
4948
- missing.append(dep)
4949
-
4950
- if dep not in installed:
4951
- visit(real_target)
4952
- elif target not in installed:
4953
- # Pakiet nie istnieje ani w repo ani zainstalowany
4954
- if target not in missing:
4955
- missing.append(target)
4956
-
4957
- if target not in installed and target not in resolved:
4958
- resolved.append(target)
4959
-
4960
- for name in names:
4961
- visit(name, explicit=True)
4962
-
4963
- # Zwróć brakujące (do sprawdzenia przez wywołującego)
4964
- return resolved, missing
4965
-
4966
-def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4967
- """
4968
- Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4969
- Zwraca liczbę brakujących zależności.
4970
- """
4971
- all_missing = []
4972
- all_warnings = []
4973
-
4974
- for pkg_name in to_install:
4975
- pkg = repo.get(pkg_name)
4976
- if not pkg:
4977
- continue
4978
-
4979
- for dep in pkg.dependencies:
4980
- if dep in SYSTEM_BASE:
4981
- continue # bazowy system dostarcza tę zależność
4982
- real_dep = _resolve_provides(dep, repo, installed)
4983
- # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4984
- in_repo = real_dep in repo
4985
- in_installed = real_dep in installed
4986
- will_be_installed = real_dep in to_install
4987
-
4988
- if not in_repo and not in_installed and not will_be_installed:
4989
- if dep not in all_missing:
4990
- all_missing.append((pkg_name, dep))
4991
- elif in_repo and not in_installed and not will_be_installed:
4992
- if dep not in [w[1] for w in all_warnings]:
4993
- all_warnings.append((pkg_name, dep, real_dep))
4994
-
4995
- if all_missing:
4996
- print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
4997
- for pkg, dep in all_missing:
4998
- print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
4999
- print()
5000
-
5001
- if all_warnings:
5002
- print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
5003
- for pkg, dep, real in all_warnings:
5004
- print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
5005
- print()
5006
-
5007
- return len(all_missing)
5008
-
5009
-# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
5010
-BASE_SO = {
5011
- "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
5012
- "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
5013
- "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
5014
- "libgcc_s.so.1", "linux-vdso.so.1",
5015
-}
5016
-
5017
-def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
5018
- """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
5019
-
5020
- Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
5021
- (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
5022
- wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
5023
- """
5024
- provided = set(BASE_SO)
5025
- for n in to_install:
5026
- p = repo.get(n)
5027
- if p:
5028
- provided.update(p.provides_so or [])
5029
- for n, info in installed.items():
5030
- provided.update(info.get("provides_so", []) or [])
5031
-
5032
- missing = []
5033
- for n in sorted(to_install):
5034
- p = repo.get(n)
5035
- if not p:
5036
- continue
5037
- for so in (p.requires_so or []):
5038
- if so not in provided:
5039
- missing.append((n, so))
5040
-
5041
- if missing:
5042
- print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
5043
- for n, so in missing:
5044
- print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
5045
- print()
5046
- return len(missing)
5047
-
5048
-def _download_pkg(pkg, attempts: int = 3):
5049
- url = f"{pkg.repo_url}/{pkg.filename}"
5050
- dest = os.path.join(PAG_CACHE, pkg.filename)
5051
- if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
5052
- _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
5053
- return dest
5054
- last_err = None
5055
- for attempt in range(1, attempts + 1):
5056
- try:
5057
- req = Request(url, headers={"User-Agent":"pag/3.0"})
5058
- with urlopen(req, timeout=600) as resp:
5059
- total = int(resp.headers.get("Content-Length", 0))
5060
- bar = DownloadBar(pkg.filename, total)
5061
- with open(dest, "wb") as f:
5062
- while True:
5063
- chunk = resp.read(65536)
5064
- if not chunk:
5065
- break
5066
- f.write(chunk)
5067
- bar.update(len(chunk))
5068
- bar.close()
5069
- if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
5070
- last_err = "SHA256 mismatch"
5071
- try:
5072
- os.remove(dest)
5073
- except OSError:
5074
- pass
5075
- if attempt < attempts:
5076
- print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5077
- f"({attempt}/{attempts - 1})...", file=sys.stderr)
5078
- time.sleep(attempt)
5079
- continue
5080
- return None
5081
- _download_pkg_sig(pkg, dest)
5082
- return dest
5083
- except Exception as e:
5084
- last_err = e
5085
- # Usuń częściowy plik – bez tego mógłby zostać użyty jako „cache”
5086
- # (gdy pakiet nie ma sha256) albo mylić kolejną próbę.
5087
- try:
5088
- if os.path.exists(dest):
5089
- os.remove(dest)
5090
- except OSError:
5091
- pass
5092
- if attempt < attempts:
5093
- print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5094
- f"({attempt}/{attempts - 1})...", file=sys.stderr)
5095
- time.sleep(attempt)
5096
- continue
5097
- print(f" ⚠ Błąd pobierania {pkg.filename}: {last_err}", file=sys.stderr)
5098
- return None
5099
-
5100
-def _download_pkg_sig(pkg, dest):
5101
- """Zapewnia AKTUALNY podpis pakietu (.asc, fallback .sig) w cache.
5102
-
5103
- Istniejący podpis jest używany tylko wtedy, gdy faktycznie weryfikuje TĘ
5104
- paczkę. Inaczej po przebudowie tej samej wersji (ten sam plik, nowy sha256)
5105
- stary podpis zostawał obok nowej paczki i weryfikacja dawała fałszywe
5106
- „NIEPRAWIDŁOWY PODPIS GPG”.
5107
- """
5108
- for ext in (".asc", ".sig"):
5109
- sig_dest = dest + ext
5110
- if os.path.exists(sig_dest):
5111
- ok, _fp = _gpg_verify_fp(sig_dest, dest)
5112
- if ok:
5113
- return
5114
- for ext in (".asc", ".sig"):
5115
- sig_dest = dest + ext
5116
- try:
5117
- req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
5118
- with urlopen(req, timeout=30) as resp:
5119
- data = resp.read()
5120
- except Exception:
5121
- continue
5122
- # nie mieszaj rozszerzeń – zostaje tylko ten wariant podpisu
5123
- for other in (".asc", ".sig"):
5124
- if other != ext:
5125
- try:
5126
- os.remove(dest + other)
5127
- except OSError:
5128
- pass
5129
- with open(sig_dest, "wb") as f:
5130
- f.write(data)
5131
- return
5132
- # Nie udało się pobrać podpisu – usuń nieaktualny z cache, żeby weryfikacja
5133
- # nie porównywała paczki z podpisem od innej wersji (czytelny „BRAK PODPISU”).
5134
- for other in (".asc", ".sig"):
5135
- try:
5136
- os.remove(dest + other)
5137
- except OSError:
5138
- pass
5139
-
5140
-def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
5141
- """
5142
- Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
5143
- Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
5144
- Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
5145
- """
5146
- results = {}
5147
- total = len(pkgs)
5148
- completed = 0
5149
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
5150
- future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
5151
- for future in as_completed(future_to_pkg):
5152
- pkg = future_to_pkg[future]
5153
- try:
5154
- results[pkg.name] = future.result()
5155
- except Exception:
5156
- results[pkg.name] = None
5157
- completed += 1
5158
- # Pasek postępu
5159
- pct = completed / total * 100
5160
- filled = int(20 * pct / 100)
5161
- bar = "█" * filled + "░" * (20 - filled)
5162
- print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
5163
- print(file=sys.stderr) # nowa linia po zakończeniu
5164
- return results
5165
-
5166
-def load_world():
5167
- if not os.path.exists(WORLD_FILE): return set()
5168
- return {l.strip() for l in open(WORLD_FILE) if l.strip()}
5169
-
5170
-def save_world(w):
5171
- with open(WORLD_FILE,"w") as f:
5172
- for n in sorted(w): f.write(f"{n}\n")
5173
-
5174
-def _find_orphans(installed, world):
5175
- needed = set(world)
5176
- changed = True
5177
- while changed:
5178
- changed = False
5179
- for n in list(needed):
5180
- for dep in installed.get(n,{}).get("dependencies",[]):
5181
- if dep not in needed and dep in installed:
5182
- needed.add(dep); changed = True
5183
- return {n for n in installed if n not in needed}
5184
-
5185
-# =============================================================================
5186
-# MAIN
5187
-# =============================================================================
5188
-
5189
-def cmd_sbom(argv):
5190
- """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
5191
-
5192
- Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
5193
- zainstalowanych pakietów, wersji, licencji i sum SHA256.
5194
- """
5195
- fmt = (argv[0] if argv else "spdx").lower()
5196
- if fmt not in ("spdx", "cyclonedx"):
5197
- print("❌ Format: spdx | cyclonedx")
5198
- return 1
5199
- installed = load_json(INSTALLED_DB)
5200
- if not installed:
5201
- print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
5202
- return 0
5203
- # metadata repo (licencje) – best-effort
5204
- try:
5205
- repo = fetch_all_packages()
5206
- except Exception:
5207
- repo = {}
5208
- names = sorted(installed)
5209
- created = datetime.now().astimezone().isoformat(timespec="seconds")
5210
-
5211
- def _license_of(name):
5212
- p = repo.get(name)
5213
- lic = getattr(p, "license", None) or []
5214
- if isinstance(lic, list):
5215
- lic = ", ".join(x for x in lic if x)
5216
- return lic or "NOASSERTION"
5217
-
5218
- if fmt == "spdx":
5219
- doc = {
5220
- "spdxVersion": "SPDX-2.3",
5221
- "dataLicense": "CC0-1.0",
5222
- "SPDXID": "SPDXRef-DOCUMENT",
5223
- "name": "PaganOS-installed",
5224
- "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
5225
- "creationInfo": {
5226
- "created": created,
5227
- "creators": [f"Tool: pag-{PAG_VERSION}"],
5228
- },
5229
- "packages": [],
5230
- }
5231
- for i, n in enumerate(names):
5232
- info = installed[n]
5233
- doc["packages"].append({
5234
- "SPDXID": f"SPDXRef-Package-{i+1}",
5235
- "name": n,
5236
- "versionInfo": info.get("version", ""),
5237
- "downloadLocation": info.get("repo", "NOASSERTION"),
5238
- "filesAnalyzed": False,
5239
- "licenseConcluded": _license_of(n),
5240
- "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
5241
- })
5242
- else: # cyclonedx
5243
- doc = {
5244
- "bomFormat": "CycloneDX",
5245
- "specVersion": "1.5",
5246
- "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
5247
- "version": 1,
5248
- "metadata": {
5249
- "timestamp": created,
5250
- "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
5251
- },
5252
- "components": [],
5253
- }
5254
- for n in names:
5255
- info = installed[n]
5256
- lic = _license_of(n)
5257
- comp = {
5258
- "type": "library",
5259
- "name": n,
5260
- "version": info.get("version", ""),
5261
- "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
5262
- }
5263
- if lic != "NOASSERTION":
5264
- comp["licenses"] = [{"license": {"id": lic}}]
5265
- doc["components"].append(comp)
5266
- print(json.dumps(doc, indent=2, ensure_ascii=False))
5267
- return 0
5268
-
5269
-
5270
-USAGE_EN = """pag v3 – Pagan Linux Package Manager
5271
-
5272
-BASIC:
5273
- pag install <pkg>... Install packages
5274
- pag remove <pkg>... Remove packages
5275
- pag update Update PACKAGES (refreshes indexes first)
5276
- pag sync Refresh indexes + show pending package updates
5277
- pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
5278
- pag list [--installed] List available / installed
5279
- pag search <query> Search packages
5280
- pag info <pkg> Package details
5281
- pag files <pkg> List package files
5282
- pag verify [--deep] Verify integrity (--deep = SHA256 per file)
5283
- pag clean Clear download cache
5284
- pag stats System statistics
5285
- pag download <pkg>... Download packages to cache (offline prep)
5286
-
5287
-SECURITY:
5288
- pag key-add <url|file> Import GPG key
5289
- pag key-list List trusted keys
5290
- pag key-remove <id> Remove key
5291
- pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
5292
- pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
5293
- pag key-trusted List pinned repo fingerprints
5294
-
5295
-ADVANCED:
5296
- pag why <pkg> Show why a package is installed
5297
- pag autoremove Auto-remove orphaned dependencies
5298
- pag pin <pkg> [ver] Pin package version
5299
- pag unpin <pkg> Unpin
5300
- pag pinned List pinned
5301
- pag history Transaction history
5302
- pag rollback Rollback last transaction
5303
- pag remove-orphans Remove orphaned deps
5304
- pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
5305
- pag repo-list List repositories
5306
- pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
5307
-
5308
-FLATPAK:
5309
- pag flatpak [<query>] Search & install (smart)
5310
- pag flatpak search <q> Search Flathub
5311
- pag flatpak install <id> Install flatpak
5312
- pag flatpak remove <id> Remove flatpak
5313
- pag flatpak list List installed flatpaks
5314
- pag flatpak update Update all flatpaks
5315
- pag flatpak info <id> Show flatpak details
5316
-
5317
-IMMUTABLE OS (PAG_IMMUTABLE=1):
5318
- pag deploy-list List all deployments
5319
- pag deploy-rollback Switch to previous deployment
5320
- pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
5321
- pag initramfs-update Rebuild initramfs for current kernel/deployment
5322
- pag grub-update Regenerate GRUB entries for all deployments
5323
-"""
5324
-
5325
-USAGE_PL = """pag v3 – Pagan Linux Package Manager
5326
-
5327
-PODSTAWOWE:
5328
- pag install <pkg>... Instalacja pakietów
5329
- pag remove <pkg>... Usuwanie pakietów
5330
- pag update Aktualizacja PAKIETÓW (odświeża indeksy)
5331
- pag sync Odśwież indeksy + info o aktualizacjach
5332
- pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
5333
- pag list [--installed] Lista dostępnych / zainstalowanych
5334
- pag search <query> Szukaj pakietów
5335
- pag info <pkg> Szczegóły pakietu
5336
- pag files <pkg> Lista plików pakietu
5337
- pag verify [--deep] Weryfikacja integralności
5338
- pag clean Wyczyść cache pobierania
5339
- pag stats Statystyki systemu
5340
- pag download <pkg>... Pobierz do cache (offline)
5341
-
5342
-BEZPIECZEŃSTWO:
5343
- pag key-add <url|file> Importuj klucz GPG
5344
- pag key-list Lista zaufanych kluczy
5345
- pag key-remove <id> Usuń klucz
5346
- pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
5347
- pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
5348
- pag key-trusted Lista przypiętych fingerprintów repo
5349
-
5350
-ZAAWANSOWANE:
5351
- pag why <pkg> Dlaczego pakiet jest zainstalowany
5352
- pag autoremove Usuń osierocone zależności
5353
- pag pin <pkg> [ver] Przypnij wersję pakietu
5354
- pag unpin <pkg> Odepnij
5355
- pag pinned Lista przypiętych
5356
- pag history Historia transakcji
5357
- pag rollback Cofnij ostatnią transakcję
5358
- pag remove-orphans Usuń osierocone zależności
5359
- pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
5360
- pag repo-list Lista repozytoriów
5361
- pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
5362
-
5363
-FLATPAK:
5364
- pag flatpak [<query>] Szukaj i instaluj
5365
- pag flatpak search <q> Szukaj na Flathub
5366
- pag flatpak install <id> Zainstaluj flatpak
5367
- pag flatpak remove <id> Usuń flatpak
5368
- pag flatpak list Lista zainstalowanych
5369
- pag flatpak update Aktualizuj wszystkie
5370
- pag flatpak info <id> Szczegóły flatpaka
5371
-
5372
-IMMUTABLE OS (PAG_IMMUTABLE=1):
5373
- pag deploy-list Lista wdrożeń
5374
- pag deploy-rollback Przełącz na poprzednie wdrożenie
5375
- pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
5376
- pag initramfs-update Przebuduj initramfs
5377
- pag grub-update Regeneruj wpisy GRUB"""
5378
-
5379
-def _get_usage():
5380
- # Plik językowy może dostarczyć klucz "usage" – wtedy wygrywa z wbudowanym.
5381
- _u = T.get(LANG, {}).get("usage")
5382
- if _u:
5383
- return _u
5384
- if LANG == "pl":
5385
- return USAGE_PL
5386
- return USAGE_EN
5387
-
5388
-
5389
-def _extract_lang(outdir: str) -> int:
5390
- """Eksport wbudowanych tłumaczeń do outdir/{pl,en}.json (+ klucz "usage").
5391
-
5392
- Używane przez recepturę pakietu (pag.pag), żeby tłumaczenia jechały RAZEM
5393
- z wersją paga – po `pag install/upgrade pag` i self-update są zawsze zgodne.
5394
- """
5395
- os.makedirs(outdir, exist_ok=True)
5396
- for _code in ("pl", "en"):
5397
- _d = dict(T.get(_code, {}))
5398
- _u = globals().get(f"USAGE_{_code.upper()}", "")
5399
- if _u:
5400
- _d["usage"] = _u
5401
- _p = os.path.join(outdir, f"{_code}.json")
5402
- with open(_p, "w", encoding="utf-8") as _fh:
5403
- json.dump(_d, _fh, ensure_ascii=False, indent=2, sort_keys=True)
5404
- print(f" ✓ {_p} ({len(_d)} kluczy)")
5405
- return 0
5406
-
5407
-
5408
-def main():
5409
- # Ukryte (używane przy budowie pakietu): eksport tłumaczeń do plików
5410
- if len(sys.argv) >= 3 and sys.argv[1] == "--lang-extract":
5411
- sys.exit(_extract_lang(sys.argv[2]))
5412
- if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
5413
- print(f"pag {PAG_VERSION}")
5414
- sys.exit(0)
5415
- if len(sys.argv) == 2 and sys.argv[1] in ("--help", "-h", "help"):
5416
- print(_get_usage()); sys.exit(0)
5417
- if len(sys.argv) < 2:
5418
- print(_get_usage()); sys.exit(0)
5419
-
5420
- cmd = sys.argv[1]
5421
- args = sys.argv[2:]
5422
-
5423
- # Python bez modułu ssl = brak HTTPS w urllib. Powiedz to wprost, zamiast
5424
- # pokazywać mylące „unknown url type: https” przy każdej operacji sieciowej.
5425
- if not _ssl_ok():
5426
- print(f" ⚠ {_('ssl_broken')}", file=sys.stderr)
5427
-
5428
- # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
5429
- READ_ONLY = {
5430
- "list": lambda: cmd_list("--installed" in args),
5431
- "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
5432
- "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
5433
- "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
5434
- "verify": lambda: cmd_verify("--deep" in args),
5435
- "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
5436
- "stats": cmd_stats,
5437
- "pinned": cmd_pinned,
5438
- "history": cmd_history,
5439
- "repo-list": cmd_repo_list,
5440
- "key-list": cmd_key_list,
5441
- "key-trusted": cmd_key_trusted,
5442
- "flatpak": lambda: cmd_flatpak(args),
5443
- "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
5444
- "flatpak-list": cmd_flatpak_list,
5445
- "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
5446
- "deploy-list": cmd_deploy_list,
5447
- "deploy": cmd_deploy_list,
5448
- "sbom": lambda: cmd_sbom(args),
5449
- }
5450
-
5451
- if cmd in READ_ONLY:
5452
- sys.exit(READ_ONLY[cmd]() or 0)
5453
-
5454
- # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
5455
- WRITE_CMDS = {
5456
- "install", "remove", "update", "sync", "upgrade", "clean", "download",
5457
- "autoremove", "remove-orphans", "pin", "unpin", "rollback",
5458
- "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
5459
- "self-update",
5460
- "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
5461
- "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
5462
- }
5463
- if cmd not in WRITE_CMDS:
5464
- # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
5465
- # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
5466
- # potrafi wisieć na `flatpak search` aż do Ctrl-C).
5467
- _known = set(READ_ONLY) | set(WRITE_CMDS)
5468
- _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
5469
- if _close:
5470
- print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
5471
- print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
5472
- sys.exit(1)
5473
- sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5474
-
5475
- # Obsługa flag globalnych (-y/--yes)
5476
- global_args = []
5477
- for a in args:
5478
- if a in ("-y", "--yes"):
5479
- os.environ["PAG_YES"] = "1"
5480
- else:
5481
- global_args.append(a)
5482
- args = global_args
5483
-
5484
- # --- Komendy ZAPISU (wymagają roota) ---
5485
- if os.geteuid() != 0:
5486
- print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
5487
-
5488
- ensure_dirs()
5489
-
5490
- with DatabaseLock():
5491
- WRITE_COMMANDS = {
5492
- "install": lambda: cmd_install(
5493
- [a for a in args if a not in ("-f", "--force")],
5494
- upgrade=("-f" in args or "--force" in args)),
5495
- "remove": lambda: cmd_remove(args),
5496
- "update": lambda: cmd_update(do_upgrade=True),
5497
- "sync": lambda: cmd_update(do_upgrade=False),
5498
- "upgrade": cmd_upgrade,
5499
- "clean": cmd_clean,
5500
- "download": lambda: cmd_download(args),
5501
- "autoremove": cmd_autoremove,
5502
- "remove-orphans": cmd_remove_orphans,
5503
- "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
5504
- "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
5505
- "rollback": cmd_rollback,
5506
- "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
5507
- "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
5508
- "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
5509
- "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
5510
- "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
5511
- "self-update": cmd_self_update,
5512
- "flatpak": lambda: cmd_flatpak(args),
5513
- "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
5514
- "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
5515
- "flatpak-update": cmd_flatpak_update,
5516
- "deploy-rollback": cmd_deploy_rollback,
5517
- "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
5518
- "initramfs-update": cmd_initramfs_update,
5519
- "grub-update": cmd_grub_update,
5520
- }
5521
-
5522
- fn = WRITE_COMMANDS.get(cmd)
5523
- if fn:
5524
- sys.exit(fn() or 0)
5525
- # Should never reach here – _smart_search handles unknowns
5526
- sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5527
-
5528
-if __name__ == "__main__":
5529
- try:
5530
- main()
5531
- except KeyboardInterrupt:
5532
- # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
5533
- print("\n ⚠ Przerwano (Ctrl-C).")
1
+#!/usr/bin/env python3
2
+"""
3
+╔══════════════════════════════════════════════════════════════════════════════╗
4
+║ PAG - Pagan Linux Package Manager v3.3.21 ║
5
+║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6
+╚══════════════════════════════════════════════════════════════════════════════╝
7
+
8
+KLUCZOWE CECHY:
9
+ - Atomowa instalacja przez staging (tmpdir → rename) – brak pół-instalacji
10
+ - Bezpieczne usuwanie – sprawdza czy plik nie jest współdzielony
11
+ - SQLite dla bazy plików – miliony plików bez problemu
12
+ - GPG: weryfikacja repo.json + podpisy pakietów + pinning fingerprintu
13
+ - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
14
+ - Głęboka weryfikacja SHA256 per-plik
15
+ - Pełny rollback – cofa fizyczne pliki
16
+ - Blokada flock – tylko jedna instancja
17
+ - Transakcje z migawkami + rejestr wykonanych hooków
18
+ - Cache HTTP (ETag/If-Modified-Since)
19
+ - Wielojęzyczność (i18n) – PL, EN
20
+
21
+FORMAT PAKIETU (.pag):
22
+ ├── data.tar.xz – pliki + sums.json (SHA256 per plik)
23
+ ├── metadata.json – nazwa, wersja, zależności
24
+ └── hooks/ – pre-install, post-install, pre-remove, post-remove
25
+
26
+MODEL ZAUFANIA / BEZPIECZEŃSTWO:
27
+ - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
28
+ klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
29
+ - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
30
+ Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
31
+ /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
32
+ instalujesz kod, któremu ufasz.
33
+ - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34
+"""
35
+
36
+import os, sys, json, copy, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
37
+
38
+# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39
+# pag ships, otherwise urlopen() fails with "unable to get local issuer
40
+# certificate" (no default capath/cafile is resolved in the chroot).
41
+for _cafile in (
42
+ "/etc/ssl/certs/ca-certificates.crt",
43
+ "/etc/ssl/cert.pem",
44
+):
45
+ if os.path.isfile(_cafile):
46
+ os.environ["SSL_CERT_FILE"] = _cafile
47
+ break
48
+
49
+from pathlib import Path
50
+from datetime import datetime, timezone
51
+from typing import Dict, List, Optional, Tuple, Set
52
+from concurrent.futures import ThreadPoolExecutor, as_completed
53
+from urllib.request import urlopen, Request
54
+import threading, itertools
55
+import uuid # serialNumber SBOM (CycloneDX)
56
+
57
+# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
58
+PAG_VERSION = "3.3.22"
59
+from urllib.error import URLError, HTTPError
60
+
61
+# =============================================================================
62
+# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
63
+# =============================================================================
64
+
65
+class ProgressBar:
66
+ """Czysty Python progress bar — działa z TTY i bez."""
67
+ def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
68
+ self.total = max(total, 1)
69
+ self.desc = desc
70
+ self.unit = unit
71
+ self.width = width
72
+ self.n = 0
73
+ self.start = time.time()
74
+ self.tty = sys.stderr.isatty()
75
+ self._last_line_len = 0
76
+
77
+ def update(self, n: Optional[int] = None, suffix: str = ""):
78
+ if n is not None:
79
+ self.n = n
80
+ else:
81
+ self.n += 1
82
+ pct = self.n / self.total * 100
83
+ elapsed = time.time() - self.start
84
+ speed = self.n / elapsed if elapsed > 0 else 0
85
+ if self.n >= self.total:
86
+ eta_str = "done"
87
+ elif speed > 0:
88
+ eta = (self.total - self.n) / speed
89
+ eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
90
+ else:
91
+ eta_str = "?..."
92
+ bar_len = int(self.width * pct / 100)
93
+ bar = "█" * bar_len + "░" * (self.width - bar_len)
94
+ line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
95
+ if self.tty:
96
+ # Overwrite current line
97
+ clear = " " * max(0, self._last_line_len - len(line))
98
+ print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
99
+ self._last_line_len = len(line)
100
+ else:
101
+ # Print milestone lines only (every 10% or when done)
102
+ if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
103
+ print(line, file=sys.stderr)
104
+
105
+ def close(self):
106
+ if self.tty:
107
+ print(file=sys.stderr)
108
+ self._last_line_len = 0
109
+
110
+ def __enter__(self):
111
+ return self
112
+
113
+ def __exit__(self, *args):
114
+ self.close()
115
+
116
+
117
+class DownloadBar:
118
+ """Pasek postępu pobierania — na podstawie Content-Length."""
119
+ def __init__(self, filename: str, total_bytes: int):
120
+ self.filename = filename
121
+ self.total = total_bytes
122
+ self.downloaded = 0
123
+ self.start = time.time()
124
+ self.tty = sys.stderr.isatty()
125
+ self._last_len = 0
126
+
127
+ def update(self, chunk_size: int):
128
+ self.downloaded += chunk_size
129
+ if self.total <= 0:
130
+ return
131
+ pct = self.downloaded / self.total * 100
132
+ elapsed = time.time() - self.start
133
+ speed = self.downloaded / elapsed if elapsed > 0 else 0
134
+ if speed > 0:
135
+ eta = (self.total - self.downloaded) / speed
136
+ eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
137
+ else:
138
+ eta_str = "?..."
139
+ bar_len = 25
140
+ filled = int(bar_len * pct / 100)
141
+ bar = "█" * filled + "░" * (bar_len - filled)
142
+ sz = self._fmt_size(self.total)
143
+ spd = self._fmt_size(int(speed))
144
+ line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
145
+ if self.tty:
146
+ clear = " " * max(0, self._last_len - len(line))
147
+ print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
148
+ self._last_len = len(line)
149
+
150
+ def close(self):
151
+ if self.tty and self.total > 0:
152
+ print(file=sys.stderr)
153
+
154
+ @staticmethod
155
+ def _fmt_size(n: int) -> str:
156
+ for unit in ("B", "KB", "MB", "GB"):
157
+ if n < 1024:
158
+ return f"{n:.1f} {unit}"
159
+ n /= 1024
160
+ return f"{n:.1f} TB"
161
+
162
+# =============================================================================
163
+# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
164
+# =============================================================================
165
+
166
+GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
167
+GPG_HOME = "/etc/pag/gpg" # izolowany keyring (działa z keyboxd GPG 2.4+)
168
+
169
+def _ssl_ok() -> bool:
170
+ """Czy Python ma działający moduł ssl (HTTPS w urllib).
171
+
172
+ Bez niego urllib nie zna schematu https i każda operacja sieciowa kończy
173
+ się „unknown url type: https” – myląco, bo wygląda jak błąd adresu.
174
+ """
175
+ try:
176
+ import ssl # noqa: F401
177
+ return True
178
+ except Exception:
179
+ return False
180
+
181
+
182
+def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
183
+ """
184
+ Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
185
+ gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
186
+ Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
187
+ (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
188
+ """
189
+ env = kwargs.pop("env", None) or os.environ.copy()
190
+ env["LC_ALL"] = "C"
191
+ env["GNUPGHOME"] = GPG_HOME
192
+ try:
193
+ return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
194
+ except FileNotFoundError:
195
+ # GPG nie jest dostępne – zwróć błąd z komunikatem
196
+ # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
197
+ _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
198
+ _msg = f"GPG binary not found ({GPG_BINARY})"
199
+ return subprocess.CompletedProcess(
200
+ [GPG_BINARY, *args], 127,
201
+ stdout=("" if _text else b""),
202
+ stderr=(_msg if _text else _msg.encode()),
203
+ )
204
+ except subprocess.TimeoutExpired:
205
+ return subprocess.CompletedProcess(
206
+ [GPG_BINARY, *args], 124,
207
+ stdout=b"", stderr=b"GPG operation timed out"
208
+ )
209
+
210
+def _load_trust_db() -> dict:
211
+ """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
212
+ try:
213
+ with open(TRUST_DB) as f:
214
+ return json.load(f)
215
+ except (FileNotFoundError, json.JSONDecodeError):
216
+ return {}
217
+
218
+
219
+def _save_trust_db(db: dict):
220
+ os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
221
+ with open(TRUST_DB, "w") as f:
222
+ json.dump(db, f, indent=2)
223
+
224
+
225
+def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
226
+ """Weryfikuje podpis i odczytuje fingerprint podpisującego.
227
+
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).
234
+ """
235
+ env = os.environ.copy()
236
+ res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
237
+ capture_output=True, text=True, timeout=timeout, env=env)
238
+ out = res.stdout or ""
239
+ m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]{16,})", out)
240
+ if not m:
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()
245
+
246
+
247
+# =============================================================================
248
+# i18n – WIELOJĘZYCZNOŚĆ
249
+# =============================================================================
250
+
251
+LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
252
+COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
253
+
254
+def _c(code: str, text: str) -> str:
255
+ """Dodaje kody ANSI jeśli kolor jest włączony."""
256
+ if not COLOR:
257
+ return text
258
+ colors = {
259
+ "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
260
+ "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
261
+ "reset": "\033[0m",
262
+ }
263
+ return f"{colors.get(code,'')}{text}{colors['reset']}"
264
+
265
+T = {
266
+ "en": {
267
+ "root_required": "pag requires root privileges (sudo).",
268
+ "db_locked": "Another pag instance is running.",
269
+ "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
270
+ "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
271
+ "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
272
+ "all_installed": "All packages are already installed.",
273
+ "to_install": "To install: {} packages ({:.2f} MB)",
274
+ "new": "NEW",
275
+ "continue_q": "Continue? [Y/n] ",
276
+ "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
277
+ "cancelled": "Cancelled.",
278
+ "not_found": "not found in repos",
279
+ "pkg_not_found": "Package not found: {} (not in any repo)",
280
+ "not_found_hint": "Check the spelling or run 'pag search <query>'.",
281
+ "downloading": "Downloading",
282
+ "download_fail": "download failed",
283
+ "gpg_fail": "GPG verification failed",
284
+ "sha256_mismatch": "SHA256 mismatch",
285
+ "install_failed": "installation failed",
286
+ "installed": "Installed {} packages.",
287
+ "rollback_restored": "Restored previous state from snapshot.",
288
+ "rollback_files": "Rolled back {} files.",
289
+ "no_history": "No transaction history.",
290
+ "pinned_list": "Pinned packages ({}):",
291
+ "no_pinned": "No pinned packages.",
292
+ "pinned_to": "pinned to",
293
+ "unpinned": "unpinned.",
294
+ "not_pinned": "was not pinned.",
295
+ "repo_added": "Added repository: {}",
296
+ "repo_exists": "Repository already exists: {}",
297
+ "updated_done": "Index refresh complete. {} packages cached.",
298
+ "indexes_refreshed": "Indexes refreshed.",
299
+ "updates_available": "⚠ {} packages have updates – run: pag update",
300
+ "upgrading": "Upgrading: {} packages",
301
+ "all_up_to_date": "All packages are up to date.",
302
+ "removing": "Removing",
303
+ "orphans_found": "Orphaned dependencies ({}): {}",
304
+ "flatpak_missing": "Flatpak is not installed.",
305
+ "flatpak_adding": "Adding Flathub remote...",
306
+ "flatpak_searching": "Searching Flathub for '{}'...",
307
+ "flatpak_found": "Found {} results:",
308
+ "flatpak_not_found": "not found on Flathub",
309
+ "flatpak_install_prompt": "Install {}? [Y/n] ",
310
+ "flatpak_installing": "Installing {}...",
311
+ "flatpak_installed": "Flatpak {} installed.",
312
+ "flatpak_removed": "Flatpak {} removed.",
313
+ "flatpak_not_installed": "Flatpak {} is not installed.",
314
+ "flatpak_info_id": "ID",
315
+ "flatpak_info_version": "Version",
316
+ "flatpak_info_branch": "Branch",
317
+ "flatpak_info_origin": "Origin",
318
+ "flatpak_info_size": "Installed size",
319
+ "flatpak_info_desc": "Description",
320
+ "flatpak_updated": "Flatpaks updated.",
321
+ "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
322
+ "flatpak_scope": "Installation",
323
+ "flatpak_menu_hint": "Installed and verified, but this session may not show it in the menu: Flatpak exports are missing from XDG_DATA_DIRS.",
324
+ "flatpak_menu_fix": "Add /var/lib/flatpak/exports/share to XDG_DATA_DIRS (and .../exports/bin to PATH) in your session, then re-login – PaganDE does this in session/pagande-session.",
325
+ "key_imported": "Key imported successfully.",
326
+ "key_removed": "Key removed: {}",
327
+ "no_keys": "No trusted GPG keys.",
328
+ "gpg_missing": "GNUPG MISSING – install gnupg and retry",
329
+ "ssl_broken": "Python has no working ssl module (HTTPS impossible) – fix the python/openssl packages (e.g. sudo pag install -f python).",
330
+ "key_add_failed": "Key import failed (gpg error) – key was NOT added.",
331
+ "verify_ok": "All {} files intact.",
332
+ "verify_errors": "{} problems found:",
333
+ "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
334
+ "deployments_list": "Deployments ({}):",
335
+ "no_deployments": "No deployments.",
336
+ "active_deployment": "ACTIVE",
337
+ "deploy_rollback_ok": "Switched to deployment: {}",
338
+ "deploy_rollback_fail": "No previous deployment.",
339
+ "deploy_cleanup_ok": "Removed {} old deployments.",
340
+ "deploy_cleanup_none": "No deployments to clean (minimum {}).",
341
+ "why_explicit": "explicitly installed",
342
+ "why_dependency": "dependency of",
343
+ "why_not_installed": "not installed",
344
+ "autoremove_ok": "Removed {} orphaned packages.",
345
+ "autoremove_none": "No orphaned packages.",
346
+ "downloaded": "Downloaded {} to cache ({:.2f} MB).",
347
+ "provides_mapped": "{} → {} (provides)",
348
+ "stats_title": "PAG Statistics",
349
+ "stats_packages": "Installed packages",
350
+ "stats_files": "Tracked files",
351
+ "stats_size": "Total size",
352
+ "stats_cache": "Cache size",
353
+ "stats_history": "Transactions",
354
+ "stats_last_update": "Last update",
355
+ # Komunikaty bezpieczeństwa (baza EN; PL w tabeli "pl" jako sec_*_pl)
356
+ "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
357
+ "sec_suid": "SUID stripped from {path}",
358
+ "sec_https": "HTTPS required for repos",
359
+ "sec_badname": "Invalid package name: {name}",
360
+ "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
361
+ "sec_conflict": "File conflict: {path} owned by {owner}",
362
+ "sec_audit": "{pkg} installed by {user}",
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}'",
369
+ },
370
+ "pl": {
371
+ "root_required": "pag wymaga uprawnień root (sudo).",
372
+ "db_locked": "Inna instancja pag jest uruchomiona.",
373
+ "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
374
+ "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
375
+ "cache_ro": "Cache repozytoriów jest tylko-do-odczytu ({cache}) – używam lokalnego indeksu (może być nieaktualny).\n Odśwież jako root: sudo pag sync",
376
+ "all_installed": "Wszystkie pakiety są już zainstalowane.",
377
+ "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
378
+ "new": "NOWY",
379
+ "continue_q": "Kontynuować? [T/n] ",
380
+ "no_tty": "Brak terminala (EOF) – anuluję.",
381
+ "cancelled": "Anulowano.",
382
+ "not_found": "brak w repozytoriach",
383
+ "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
384
+ "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
385
+ "downloading": "Pobieranie",
386
+ "download_fail": "błąd pobierania",
387
+ "gpg_fail": "błąd weryfikacji GPG",
388
+ "sha256_mismatch": "niezgodność SHA256",
389
+ "install_failed": "błąd instalacji",
390
+ "installed": "Zainstalowano {} pakietów.",
391
+ "rollback_restored": "Przywrócono poprzedni stan z migawki.",
392
+ "rollback_files": "Wycofano {} plików.",
393
+ "no_history": "Brak historii transakcji.",
394
+ "pinned_list": "Przypięte pakiety ({}):",
395
+ "no_pinned": "Brak przypiętych pakietów.",
396
+ "pinned_to": "przypięty do",
397
+ "unpinned": "odpięty.",
398
+ "not_pinned": "nie był przypięty.",
399
+ "repo_added": "Dodano repozytorium: {}",
400
+ "repo_exists": "Repozytorium już istnieje: {}",
401
+ "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
402
+ "indexes_refreshed": "Indeksy odświeżone.",
403
+ "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
404
+ "upgrading": "Aktualizacje: {} pakietów",
405
+ "all_up_to_date": "Wszystkie pakiety są aktualne.",
406
+ "removing": "Usuwanie",
407
+ "orphans_found": "Osierocone zależności ({}): {}",
408
+ "flatpak_missing": "Flatpak nie jest zainstalowany.",
409
+ "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
410
+ "flatpak_searching": "Szukam '{}' we Flathub...",
411
+ "flatpak_found": "Znaleziono {} wyników:",
412
+ "flatpak_not_found": "nie znaleziono we Flathub",
413
+ "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
414
+ "flatpak_installing": "Instalowanie {}...",
415
+ "flatpak_installed": "Flatpak {} zainstalowany.",
416
+ "flatpak_removed": "Flatpak {} usunięty.",
417
+ "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
418
+ "flatpak_info_id": "ID",
419
+ "flatpak_info_version": "Wersja",
420
+ "flatpak_info_branch": "Gałąź",
421
+ "flatpak_info_origin": "Źródło",
422
+ "flatpak_info_size": "Rozmiar",
423
+ "flatpak_info_desc": "Opis",
424
+ "flatpak_updated": "Flapaki zaktualizowane.",
425
+ "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
426
+ "flatpak_scope": "Instalacja",
427
+ "flatpak_menu_hint": "Zainstalowano i zweryfikowano, ale sesja może jej nie pokazać w menu: brak eksportów Flatpaka w XDG_DATA_DIRS.",
428
+ "flatpak_menu_fix": "Dodaj /var/lib/flatpak/exports/share do XDG_DATA_DIRS (a .../exports/bin do PATH) w sesji i zaloguj się ponownie – PaganDE robi to w session/pagande-session.",
429
+ "key_imported": "Klucz zaimportowany pomyślnie.",
430
+ "key_removed": "Klucz usunięty: {}",
431
+ "no_keys": "Brak zaufanych kluczy GPG.",
432
+ "gpg_missing": "BRAK GNUPG – zainstaluj gnupg i spróbuj ponownie",
433
+ "ssl_broken": "Python bez działającego modułu ssl (HTTPS niemożliwe) – napraw pakiety python/openssl (np. sudo pag install -f python).",
434
+ "key_add_failed": "Import klucza nie powiódł się (błąd gpg) – klucz NIE został dodany.",
435
+ "verify_ok": "Wszystkie {} plików sprawne.",
436
+ "verify_errors": "Znaleziono {} problemów:",
437
+ "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
438
+ "deployments_list": "Deploymenty ({}):",
439
+ "no_deployments": "Brak deploymentów.",
440
+ "active_deployment": "AKTYWNY",
441
+ "deploy_rollback_ok": "Przełączono na deployment: {}",
442
+ "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
443
+ "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
444
+ "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
445
+ "why_explicit": "zainstalowany jawnie",
446
+ "why_dependency": "zależność od",
447
+ "why_not_installed": "niezainstalowany",
448
+ "autoremove_ok": "Usunięto {} osieroconych pakietów.",
449
+ "autoremove_none": "Brak osieroconych pakietów.",
450
+ "downloaded": "Pobrano {} do cache ({:.2f} MB).",
451
+ "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
452
+ "sec_suid": "SUID stripped from {path}",
453
+ "sec_https": "HTTPS required for repos",
454
+ "sec_badname": "Invalid package name: {name}",
455
+ "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
456
+ "sec_conflict": "File conflict: {path} owned by {owner}",
457
+ "sec_audit": "{pkg} installed by {user}",
458
+ "sec_locked": "Another pag process is running",
459
+ "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
460
+ "sec_suid_pl": "SUID usuniety z {path}",
461
+ "sec_https_pl": "Repozytorium wymaga HTTPS",
462
+ "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
463
+ "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
464
+ "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
465
+ "sec_audit_pl": "{pkg} zainstalowany przez {user}",
466
+ "sec_locked_pl": "Inny proces pag juz dziala",
467
+
468
+ "provides_mapped": "{} → {} (provides)",
469
+ "stats_title": "Statystyki PAG",
470
+ "stats_packages": "Zainstalowane pakiety",
471
+ "stats_files": "Śledzone pliki",
472
+ "stats_size": "Całkowity rozmiar",
473
+ "stats_cache": "Rozmiar cache",
474
+ "stats_history": "Transakcje",
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}'",
479
+ },
480
+}
481
+
482
+# ── Tłumaczenia z PLIKÓW (nadpisują/rozszerzają wbudowane PL/EN) ─────────────
483
+# Kolejność: PAG_LANG_DIR (env) → /etc/pag/lang → /usr/share/pag/lang →
484
+# ./pag-lang obok binarki (dev). Brak plików NIE jest błędem – zostaje
485
+# wbudowany słownik T (fallback), więc pag zawsze działa.
486
+# Przykład pliku (pag-lang/pl.json): {"app_title": "...", "usage": "..."}.
487
+def _load_lang_files() -> None:
488
+ # PAG_LANG_NO_FILES=1 → pomiń pliki (używane przy eksporcie --lang-extract,
489
+ # żeby wyeksportować CZYSTE wbudowane słowniki, bez starych nadpisań).
490
+ if os.environ.get("PAG_LANG_NO_FILES") == "1":
491
+ return
492
+ dirs = []
493
+ _env = os.environ.get("PAG_LANG_DIR")
494
+ if _env:
495
+ dirs.append(_env)
496
+ # __file__ bywa niedostępne, gdy moduł jest exec/frozen (np. harness
497
+ # instalacyjny) – liczymy wtedy od ścieżki programu, zamiast wywalać
498
+ # NameError przy samym imporcie.
499
+ _self = globals().get("__file__") or sys.argv[0] or "pag"
500
+ dirs += ["/etc/pag/lang", "/usr/share/pag/lang",
501
+ os.path.join(os.path.dirname(os.path.abspath(_self)), "pag-lang")]
502
+ for _d in dirs:
503
+ for _code in list(T.keys()) + ["pl", "en", "de"]:
504
+ _p = os.path.join(_d, f"{_code}.json")
505
+ try:
506
+ with open(_p, "r", encoding="utf-8") as _fh:
507
+ _data = json.load(_fh)
508
+ if isinstance(_data, dict):
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)
515
+ except (OSError, ValueError):
516
+ continue
517
+
518
+
519
+_load_lang_files()
520
+
521
+def _(key: str, *args, **kwargs) -> str:
522
+ """Tłumaczy klucz i formatuje argumenty.
523
+
524
+ Dla LANG=pl preferuje wariant „<key>_pl” (np. komunikaty bezpieczeństwa
525
+ mają krótkie wersje PL obok bazy EN), potem zwykły klucz, potem EN/klicz.
526
+ """
527
+ if LANG == "pl":
528
+ _pl = T.get("pl", {})
529
+ msg = _pl.get(key + "_pl") or _pl.get(key) or T["en"].get(key, key)
530
+ else:
531
+ msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
532
+ if args or kwargs:
533
+ return msg.format(*args, **kwargs)
534
+ return msg
535
+
536
+
537
+def _ask_confirm() -> bool:
538
+ """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
539
+
540
+ EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
541
+ panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
542
+ (inaczej input() rzuca EOFError i pag pada tracebackiem).
543
+ Enter → tak (domyślne Y/n).
544
+ """
545
+ if os.environ.get("PAG_YES", "") == "1":
546
+ print(_("continue_q") + " t (--yes)")
547
+ return True
548
+ try:
549
+ ans = input(_("continue_q")).strip().lower()
550
+ except (EOFError, KeyboardInterrupt):
551
+ print(f"\n ⚠ {_('no_tty')}")
552
+ return False
553
+ return not ans or ans in ("t", "y")
554
+
555
+
556
+# =============================================================================
557
+# ŚCIEŻKI
558
+# =============================================================================
559
+PAG_ROOT = os.environ.get("PAG_ROOT", "/")
560
+PAG_DB = "/var/lib/pag"
561
+PAG_CACHE = "/var/cache/pag"
562
+PAG_CONF = "/etc/pag"
563
+REPO_CACHE = "/var/cache/pag/repos"
564
+REPOS_CONF = "/etc/pag/repos.conf"
565
+REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
566
+INSTALLED_DB = "/var/lib/pag/installed.json"
567
+FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
568
+WORLD_FILE = "/var/lib/pag/world"
569
+PINNED_FILE = "/var/lib/pag/pinned.json"
570
+HISTORY_FILE = "/var/lib/pag/history.json"
571
+LOCK_FILE = "/var/lib/pag/pag.lock"
572
+STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
573
+PKG_EXT = ".pag"
574
+REPO_CACHE_TTL = 3600
575
+MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
576
+ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
577
+
578
+# Bezpieczeństwo / audyt
579
+AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
580
+TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
581
+HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
582
+
583
+# =============================================================================
584
+# IMMUTABLE OS – DEPLOYMENTY
585
+# =============================================================================
586
+# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
587
+# /var, /etc, /home są współdzielone między deploymentami.
588
+#
589
+# STRUKTURA:
590
+# /.deployments/
591
+# active → 20260723T120000 (symlink do aktywnego)
592
+# 20260723T120000/
593
+# usr/ bin/ lib/ lib64/ ... (pełny system)
594
+# var → /var (symlink do współdzielonego)
595
+# etc → /etc
596
+# home → /home
597
+# ...
598
+#
599
+# Jak to działa:
600
+# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
601
+# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
602
+# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
603
+# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
604
+# =============================================================================
605
+
606
+DEPLOYMENTS_DIR = "/.deployments"
607
+ACTIVE_LINK = "/.deployments/active"
608
+DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
609
+
610
+# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
611
+SHARED_PATHS = {
612
+ "/var", "/etc", "/home", "/root", "/tmp", "/run",
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",
618
+ "/.deployments", "/.pag_staging",
619
+}
620
+
621
+def _is_shared_path(rel: str) -> bool:
622
+ """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
623
+ for sp in SHARED_PATHS:
624
+ if rel == sp or rel.startswith(sp + "/"):
625
+ return True
626
+ return False
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
+
636
+# Wrażliwa konfiguracja auth/systemowa – NIGDY nie nadpisujemy jej po cichu.
637
+# Nadpisanie /etc/pam.d/system-* przez pakiet (np. shadow) potrafi zablokować
638
+# logowanie/sudo („account validation failure”); dla tych ścieżek zawsze robimy
639
+# .pacnew, gdy treść na dysku różni się od tej z pakietu.
640
+_SENSITIVE_CONFIG_RE = re.compile(
641
+ r'^(pam\.d/|security/|sudoers$|sudoers\.d/|shadow$|gshadow$|passwd$|group$|'
642
+ r'login\.defs$|nsswitch\.conf$|default/useradd$)')
643
+
644
+def _is_sensitive_config(rel: str) -> bool:
645
+ """Czy rel to wrażliwy plik /etc (PAM, sudoers, shadow, login.defs…)?"""
646
+ p = rel.lstrip("/")
647
+ if not p.startswith("etc/"):
648
+ return False
649
+ return bool(_SENSITIVE_CONFIG_RE.match(p[4:]))
650
+
651
+def _get_deployment_root() -> str:
652
+ """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
653
+ if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
654
+ return PAG_ROOT
655
+ if os.path.islink(ACTIVE_LINK):
656
+ return os.readlink(ACTIVE_LINK)
657
+ if os.path.isdir(ACTIVE_LINK):
658
+ return ACTIVE_LINK
659
+ # Brak deploymentów – użyj /
660
+ return PAG_ROOT
661
+
662
+def _load_deployments() -> List[dict]:
663
+ """Wczytuje historię deploymentów."""
664
+ if not os.path.exists(DEPLOYMENTS_DB):
665
+ return []
666
+ try:
667
+ return json.load(open(DEPLOYMENTS_DB))
668
+ except Exception:
669
+ return []
670
+
671
+def _save_deployments(deployments: List[dict]):
672
+ os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
673
+ json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
674
+
675
+def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
676
+ """
677
+ Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
678
+ Zwraca (deployment_dir, deployment_id).
679
+ """
680
+ deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
681
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
682
+ os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
683
+
684
+ active = _get_deployment_root()
685
+
686
+ if os.path.isdir(active) and active != PAG_ROOT:
687
+ # Trójstopniowa strategia kopiowania deploymentu:
688
+ # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
689
+ # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
690
+ # 3. zwykłe cp (ostateczność) → pełna kopia
691
+ print(f" ⚡ Kopiowanie aktywnego deploymentu...")
692
+ copied = False
693
+ for method, cmd, label in [
694
+ ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
695
+ ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
696
+ ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
697
+ ]:
698
+ try:
699
+ subprocess.run(cmd, check=True, timeout=600, capture_output=True)
700
+ print(f" ✅ Deployment: {deploy_id} ({label})")
701
+ copied = True
702
+ break
703
+ except subprocess.CalledProcessError:
704
+ if method == "copy":
705
+ raise # ostatnia deska – niech leci wyjątek
706
+ continue
707
+ if not copied:
708
+ raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
709
+ else:
710
+ # Pierwszy deployment – tylko katalogi szkieletowe
711
+ # /boot celowo POMINIĘTE – jest współdzielone (SHARED_PATHS); poniższa
712
+ # pętla utworzy w deploymencie symlink boot → /boot.
713
+ for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/opt"]:
714
+ if os.path.isdir(d):
715
+ dest = os.path.join(deploy_dir, d.lstrip("/"))
716
+ os.makedirs(dest, exist_ok=True)
717
+ print(f" ✅ Pierwszy deployment: {deploy_id}")
718
+
719
+ # Utwórz symlinki do współdzielonych katalogów
720
+ for sp in SHARED_PATHS:
721
+ link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
722
+ if not os.path.lexists(link_dst) and os.path.isdir(sp):
723
+ os.symlink(sp, link_dst)
724
+
725
+ # Zapisz w bazie deploymentów
726
+ deployments = _load_deployments()
727
+ deployments.append({
728
+ "id": deploy_id,
729
+ "action": action,
730
+ "packages": pkg_names,
731
+ "timestamp": datetime.now().isoformat(),
732
+ "active": True,
733
+ })
734
+ # Oznacz poprzednie jako nieaktywne
735
+ for d in deployments[:-1]:
736
+ d["active"] = False
737
+ _save_deployments(deployments)
738
+
739
+ return deploy_dir, deploy_id
740
+
741
+def _switch_deployment(deploy_dir: str) -> bool:
742
+ """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
743
+ tmp_link = ACTIVE_LINK + ".new"
744
+ if os.path.lexists(tmp_link):
745
+ os.remove(tmp_link)
746
+ os.symlink(deploy_dir, tmp_link)
747
+ os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
748
+ return True
749
+
750
+DEFAULT_REPOS = [
751
+ "https://repo.paganlinux.eu/stable/",
752
+]
753
+
754
+# =============================================================================
755
+# INICJALIZACJA
756
+# =============================================================================
757
+
758
+def ensure_dirs():
759
+ for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
760
+ os.makedirs(d, exist_ok=True)
761
+ for f, default in [
762
+ (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
763
+ (INSTALLED_DB, "{}"),
764
+ (PINNED_FILE, "{}"),
765
+ (HISTORY_FILE, "[]"),
766
+ ]:
767
+ if not os.path.exists(f):
768
+ with open(f, "w") as fh: fh.write(default)
769
+ if not os.path.exists(WORLD_FILE):
770
+ Path(WORLD_FILE).touch()
771
+ if not os.path.exists(GPG_HOME):
772
+ os.makedirs(GPG_HOME, exist_ok=True)
773
+ os.chmod(GPG_HOME, 0o700)
774
+ _gpg_run("--list-keys", capture_output=True)
775
+ # Inicjalizuj SQLite
776
+ _db_init()
777
+ # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
778
+ if os.path.isdir(STAGING_DIR):
779
+ for entry in os.listdir(STAGING_DIR):
780
+ if entry == "backups":
781
+ continue # backupy starych wersji – potrzebne do `pag rollback`
782
+ path = os.path.join(STAGING_DIR, entry)
783
+ try:
784
+ if os.path.isfile(path) or os.path.islink(path):
785
+ os.unlink(path)
786
+ elif os.path.isdir(path):
787
+ shutil.rmtree(path, ignore_errors=True)
788
+ except OSError:
789
+ pass
790
+
791
+# =============================================================================
792
+# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
793
+# =============================================================================
794
+
795
+from contextlib import contextmanager
796
+
797
+@contextmanager
798
+def _db_session(readonly: Optional[bool] = None):
799
+ """Context manager – gwarantuje zamknięcie połączenia.
800
+
801
+ Gdy katalog bazy nie jest zapisywalny (np. komenda read-only uruchomiona
802
+ jako zwykły user), otwieramy połączenie w trybie read-only. Inaczej
803
+ `PRAGMA journal_mode=WAL` próbuje pisać i kończy się błędem
804
+ „attempt to write a readonly database” zamiast zwrócić wynik.
805
+ """
806
+ if readonly is None:
807
+ readonly = not os.access(os.path.dirname(FILES_DB_SQL) or ".", os.W_OK)
808
+ if readonly:
809
+ # Katalog bazy nie jest zapisywalny (np. komenda read-only uruchomiona
810
+ # jako zwykły user). Baza jest w trybie WAL, więc SQLite przy `mode=ro`
811
+ # próbuje utworzyć plik `-shm` w katalogu – bez prawa zapisu kończy się
812
+ # to błędem „attempt to write a readonly database” nawet przy SELECT.
813
+ # Dlatego próbujemy zwykłego `mode=ro`, a gdy zawiedzie, otwieramy z
814
+ # `immutable=1` (SQLite pomija wtedy WAL/SHM i czyta ostatni
815
+ # checkpoint). Przy równoległym zapisie roota odczyt może być chwilowo
816
+ # nieaktualny – dla komend diagnostycznych (files/list/verify) to OK.
817
+ conn = sqlite3.connect(f"file:{FILES_DB_SQL}?mode=ro", uri=True, timeout=15)
818
+ try:
819
+ conn.execute("SELECT 1 FROM sqlite_master LIMIT 1")
820
+ except sqlite3.OperationalError:
821
+ conn.close()
822
+ conn = sqlite3.connect(
823
+ f"file:{FILES_DB_SQL}?mode=ro&immutable=1", uri=True, timeout=15)
824
+ else:
825
+ conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
826
+ conn.execute("PRAGMA journal_mode=WAL")
827
+ conn.execute("PRAGMA synchronous=NORMAL")
828
+ conn.execute("PRAGMA foreign_keys=ON")
829
+ conn.execute("PRAGMA busy_timeout=15000")
830
+ conn.row_factory = sqlite3.Row
831
+ try:
832
+ yield conn
833
+ if not readonly:
834
+ conn.commit()
835
+ except Exception:
836
+ if not readonly:
837
+ conn.rollback()
838
+ raise
839
+ finally:
840
+ conn.close()
841
+
842
+
843
+def _db_init():
844
+ """Tworzy tabele SQLite jeśli nie istnieją."""
845
+ with _db_session() as db:
846
+ db.execute("""
847
+ CREATE TABLE IF NOT EXISTS files (
848
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
849
+ path TEXT NOT NULL,
850
+ package TEXT NOT NULL,
851
+ sha256 TEXT,
852
+ size INTEGER,
853
+ is_symlink INTEGER DEFAULT 0,
854
+ symlink_target TEXT,
855
+ UNIQUE(path, package)
856
+ )
857
+ """)
858
+ db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
859
+ db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
860
+ db.execute("""
861
+ CREATE TABLE IF NOT EXISTS file_checksums (
862
+ path TEXT PRIMARY KEY,
863
+ sha256 TEXT NOT NULL,
864
+ installed_at TEXT
865
+ )
866
+ """)
867
+ db.commit()
868
+
869
+def _db_record_files(pkg_name: str, files: List[dict]):
870
+ """Zapisuje pliki do SQLite (obsługuje symlinki)."""
871
+ with _db_session() as db:
872
+ # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
873
+ try:
874
+ db.execute("BEGIN IMMEDIATE")
875
+ except sqlite3.OperationalError:
876
+ pass # transakcja już otwarta (implicit)
877
+ db.executemany(
878
+ "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
879
+ "VALUES (?,?,?,?,?,?)",
880
+ [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
881
+ f.get("is_symlink", 0), f.get("symlink_target", ""))
882
+ for f in files]
883
+ )
884
+ db.executemany(
885
+ "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
886
+ [(f["path"], f.get("sha256",""), datetime.now().isoformat())
887
+ for f in files if f.get("sha256")]
888
+ )
889
+
890
+def _db_get_package_files(pkg_name: str) -> List[str]:
891
+ with _db_session() as db:
892
+ return [r["path"] for r in db.execute(
893
+ "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
894
+ )]
895
+
896
+def _db_get_file_owners(filepath: str) -> List[str]:
897
+ """Zwraca listę pakietów będących właścicielami pliku."""
898
+ with _db_session() as db:
899
+ return [r["package"] for r in db.execute(
900
+ "SELECT package FROM files WHERE path=?", (filepath,)
901
+ )]
902
+
903
+def _db_remove_package_files(pkg_name: str):
904
+ with _db_session() as db:
905
+ db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
906
+ db.commit()
907
+
908
+def _db_get_all_file_checksums() -> Dict[str, str]:
909
+ with _db_session() as db:
910
+ return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
911
+
912
+def _db_get_package_checksums(pkg_name: str) -> Dict[str, str]:
913
+ """Sumy SHA256 zapisane dla plików należących do pakietu.
914
+
915
+ Używane do wykrycia, czy użytkownik zmodyfikował plik konfiguracyjny
916
+ (porównanie z sumą z chwili instalacji) – serce mechanizmu .pacnew/.pacsave.
917
+ """
918
+ with _db_session() as db:
919
+ return {r["path"]: r["sha256"] for r in db.execute(
920
+ "SELECT fc.path AS path, fc.sha256 AS sha256 FROM file_checksums fc "
921
+ "JOIN files f ON f.path = fc.path WHERE f.package=?", (pkg_name,))}
922
+
923
+
924
+def _db_count_files() -> int:
925
+ with _db_session() as db:
926
+ return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
927
+
928
+# =============================================================================
929
+# BLOKADA
930
+# =============================================================================
931
+
932
+class DatabaseLock:
933
+ """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
934
+ ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
935
+ śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
936
+ i pag odmawiał działania na zawsze („baza zablokowana”).
937
+ """
938
+ def __init__(self):
939
+ self._f = None
940
+ def __enter__(self):
941
+ os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
942
+ self._f = open(LOCK_FILE, "w")
943
+ try:
944
+ # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
945
+ fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
946
+ except BlockingIOError:
947
+ print(f"❌ {_('db_locked')}", file=sys.stderr)
948
+ print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
949
+ sys.exit(1)
950
+ self._f.write(str(os.getpid()))
951
+ self._f.flush()
952
+ return self
953
+ def __exit__(self, *args):
954
+ if self._f:
955
+ try:
956
+ fcntl.flock(self._f, fcntl.LOCK_UN)
957
+ except OSError:
958
+ pass
959
+ self._f.close()
960
+ self._f = None
961
+ # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
962
+ # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
963
+ # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
964
+
965
+
966
+class SelfUpdateLock:
967
+ """Blokada podmiany binarki pag podczas self-update.
968
+
969
+ Niezależna od DatabaseLock (inny plik) – cmd_self_update może zostać
970
+ wywołane poza globalną blokadą (np. z zewnętrznego skryptu), a dwie
971
+ równoległe aktualizacje pisałyby do tego samego `dst.new`; `os.replace`
972
+ mógłby wtedy podmienić plik w trakcie wykonywania (uszkodzony pag).
973
+ """
974
+ def __init__(self):
975
+ self._f = None
976
+
977
+ def __enter__(self):
978
+ lock_path = LOCK_FILE + ".self-update"
979
+ os.makedirs(os.path.dirname(lock_path), exist_ok=True)
980
+ self._f = open(lock_path, "w")
981
+ try:
982
+ fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
983
+ except BlockingIOError:
984
+ self._f.close()
985
+ self._f = None
986
+ print("❌ Inna aktualizacja pag jest w toku – spróbuj ponownie później.",
987
+ file=sys.stderr)
988
+ sys.exit(1)
989
+ self._f.write(str(os.getpid()))
990
+ self._f.flush()
991
+ return self
992
+
993
+ def __exit__(self, *args):
994
+ if self._f:
995
+ try:
996
+ fcntl.flock(self._f, fcntl.LOCK_UN)
997
+ except OSError:
998
+ pass
999
+ self._f.close()
1000
+ self._f = None
1001
+
1002
+# =============================================================================
1003
+# POMOCNICZE
1004
+# =============================================================================
1005
+
1006
+
1007
+_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
1008
+ "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
1009
+ # Pliki wewnętrzne paczki .pkg.tar.xz
1010
+ "metadata.json", "data.tar.xz", "hooks/",
1011
+ "sums.json")
1012
+
1013
+def _check_path_safety(name: str) -> bool:
1014
+ # Normalizuj – usuń leading ./
1015
+ if name.startswith("./"):
1016
+ name = name[2:]
1017
+ if name in (".", ""):
1018
+ return True
1019
+ # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
1020
+ # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
1021
+ norm = name.lstrip("/")
1022
+ for prefix in _ALLOWED_PREFIXES:
1023
+ p = prefix.lstrip("/").rstrip("/")
1024
+ if norm == p or norm.startswith(p + "/"):
1025
+ return True
1026
+ return False
1027
+
1028
+
1029
+def _validate_pkg_name(name):
1030
+ return bool(ALLOWED_PKG_RE.match(name))
1031
+
1032
+
1033
+
1034
+def _audit(msg):
1035
+ from datetime import datetime, timezone
1036
+ os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
1037
+ with open(AUDIT_LOG, "a") as f:
1038
+ f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
1039
+
1040
+def _strip_suid(path):
1041
+ try:
1042
+ st = os.stat(path)
1043
+ if st.st_mode & 0o4000:
1044
+ os.chmod(path, st.st_mode & ~0o4000)
1045
+ print(f" {_("sec_suid", path=path)}")
1046
+ except OSError:
1047
+ pass
1048
+
1049
+def _check_downgrade(pkg_name, new_ver, installed_db):
1050
+ if pkg_name in installed_db:
1051
+ old = installed_db[pkg_name].get("version", "0")
1052
+ if new_ver < old:
1053
+ print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
1054
+ return False
1055
+ return True
1056
+
1057
+def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
1058
+ """
1059
+ Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
1060
+
1061
+ Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
1062
+ oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
1063
+ zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
1064
+
1065
+ Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
1066
+ prostym sprawdzaniu stringa:
1067
+ - Blokuje ścieżki absolutne i z '..' (path traversal)
1068
+ - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
1069
+ - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
1070
+ rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
1071
+ - Zachowuje oryginalne uprawnienia plików
1072
+ """
1073
+ dest_real = os.path.realpath(dest)
1074
+ os.makedirs(dest_real, exist_ok=True)
1075
+
1076
+ def _target_within(path: str) -> bool:
1077
+ try:
1078
+ return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
1079
+ except ValueError:
1080
+ # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
1081
+ return False
1082
+
1083
+ for member in tar.getmembers():
1084
+ name = member.name
1085
+
1086
+ # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
1087
+ if name.startswith('/'):
1088
+ continue
1089
+ if '..' in name.split('/'):
1090
+ continue
1091
+ # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
1092
+ if '\x00' in name or '\\' in name:
1093
+ continue
1094
+ if not _check_path_safety(name):
1095
+ print(f" BLOCKED: {name}")
1096
+ continue
1097
+
1098
+ target = os.path.join(dest, name)
1099
+
1100
+ # --- Ochrona na podstawie finalnej ścieżki ---
1101
+ # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
1102
+ # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
1103
+ if not _target_within(target):
1104
+ print(f" BLOCKED (escape): {name}")
1105
+ continue
1106
+
1107
+ # --- Ochrona dla symlinków i hardlinków ---
1108
+ if member.issym() or member.islnk():
1109
+ link = member.linkname
1110
+ # Szybkie odrzucenie linków absolutnych / z '..'
1111
+ if link.startswith('/') or '..' in link.split('/'):
1112
+ continue
1113
+ # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
1114
+ link_target = os.path.join(os.path.dirname(target), link)
1115
+ if not _target_within(link_target):
1116
+ print(f" BLOCKED (link escape): {name} -> {link}")
1117
+ continue
1118
+
1119
+ # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
1120
+ # `filter=` (inaczej DeprecationWarning, a w 3.14+ błąd).
1121
+ # UWAGA: 'fully_trusted' CELOWO pomija wbudowane filtry bezpieczeństwa
1122
+ # Pythona – to nie przeoczenie. Nasza walidacja powyżej (path traversal,
1123
+ # NUL/backslash, escape przez symlink, linki wychodzące poza dest) jest
1124
+ # równoważna lub ostrzejsza, a 'fully_trusted' pozwala zachować bity
1125
+ # SUID/SGID/sticky, które filtr 'data' by usunął (np. /usr/bin/sudo).
1126
+ # SUID jest i tak zdejmowany przez _strip_suid() tuż po rozpakowaniu.
1127
+ try:
1128
+ if hasattr(tarfile, 'data_filter'):
1129
+ # Python 3.12+
1130
+ tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
1131
+ filter='fully_trusted')
1132
+ else:
1133
+ tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
1134
+ except Exception as e:
1135
+ print(f" ⚠ Nie rozpakowano {name}: {e}")
1136
+ continue
1137
+ _strip_suid(target)
1138
+
1139
+
1140
+def _sha256_file(path: str) -> str:
1141
+ """SHA256 pliku. Na Python ≥3.11 używa hashlib.file_digest (pętla w C);
1142
+ starsze wersje mają fallback z pętlą chunków."""
1143
+ with open(path, "rb") as f:
1144
+ if hasattr(hashlib, "file_digest"):
1145
+ return hashlib.file_digest(f, "sha256").hexdigest()
1146
+ h = hashlib.sha256()
1147
+ for chunk in iter(lambda: f.read(65536), b""):
1148
+ h.update(chunk)
1149
+ return h.hexdigest()
1150
+
1151
+def _split_version(v: str):
1152
+ """Rozdziela wersję na (release_parts, prerelease_parts).
1153
+
1154
+ Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
1155
+ """
1156
+ v = v.strip().lower().lstrip("v")
1157
+ # build metadata po '+' jest ignorowane przy porównywaniu (semver)
1158
+ v = v.split("+", 1)[0]
1159
+ # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
1160
+ if "-" in v:
1161
+ rel, pre = v.split("-", 1)
1162
+ elif "_" in v:
1163
+ rel, pre = v.split("_", 1)
1164
+ else:
1165
+ rel, pre = v, ""
1166
+ nums = []
1167
+ for part in rel.split("."):
1168
+ m = re.match(r"(\d+)", part)
1169
+ nums.append(int(m.group(1)) if m else 0)
1170
+ pre_parts = [p for p in pre.split(".") if p]
1171
+ return nums, pre_parts
1172
+
1173
+
1174
+def _cmp_pre(a, b):
1175
+ """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
1176
+ for i in range(max(len(a), len(b))):
1177
+ if i >= len(a):
1178
+ return -1 # krótszy prerelease jest niższy
1179
+ if i >= len(b):
1180
+ return 1
1181
+ ia, ib = a[i], b[i]
1182
+ if ia == ib:
1183
+ continue
1184
+ na, nb = ia.isdigit(), ib.isdigit()
1185
+ if na and nb:
1186
+ return 1 if int(ia) > int(ib) else -1
1187
+ if na != nb:
1188
+ return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
1189
+ return 1 if ia > ib else -1
1190
+ return 0
1191
+
1192
+
1193
+def _cmp_version(a: str, b: str) -> int:
1194
+ """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
1195
+ a_rel, a_pre = _split_version(a)
1196
+ b_rel, b_pre = _split_version(b)
1197
+ # Porównaj część release (brakujące komponenty traktuj jako 0)
1198
+ for i in range(max(len(a_rel), len(b_rel))):
1199
+ xa = a_rel[i] if i < len(a_rel) else 0
1200
+ xb = b_rel[i] if i < len(b_rel) else 0
1201
+ if xa != xb:
1202
+ return 1 if xa > xb else -1
1203
+ # Część release równa → decyduje prerelease.
1204
+ # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
1205
+ if not a_pre and not b_pre:
1206
+ return 0
1207
+ if not a_pre:
1208
+ return 1
1209
+ if not b_pre:
1210
+ return -1
1211
+ return _cmp_pre(a_pre, b_pre)
1212
+
1213
+
1214
+def _version_newer(a: str, b: str) -> bool:
1215
+ """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1216
+ try:
1217
+ return _cmp_version(a, b) > 0
1218
+ except Exception:
1219
+ return a != b
1220
+
1221
+def load_json(path):
1222
+ try:
1223
+ with open(path) as f:
1224
+ return json.load(f)
1225
+ except (FileNotFoundError, json.JSONDecodeError):
1226
+ return {}
1227
+
1228
+def save_json(path, data):
1229
+ with open(path, "w") as f:
1230
+ json.dump(data, f, indent=2)
1231
+
1232
+class PackageInfo:
1233
+ __slots__ = ("name","version","release","description","dependencies",
1234
+ "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1235
+ "provides_so","requires_so")
1236
+ def __init__(self, d, repo=""):
1237
+ self.name = d.get("name","?")
1238
+ self.version = d.get("version","0")
1239
+ self.release = d.get("release", 1)
1240
+ self.description = d.get("description","")
1241
+ self.dependencies = d.get("dependencies", d.get("depends", []))
1242
+ self.size_bytes = d.get("size",0)
1243
+ self.sha256 = d.get("sha256","")
1244
+ self.gpg_fp = d.get("gpg_fingerprint","")
1245
+ self.repo_url = repo
1246
+ self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1247
+ self.provides = d.get("provides", []) or []
1248
+ self.license = d.get("license", []) or []
1249
+ self.provides_so = d.get("provides_so", []) or []
1250
+ self.requires_so = d.get("requires_so", []) or []
1251
+
1252
+# =============================================================================
1253
+# REPOZYTORIA (cache, ETag, GPG)
1254
+# =============================================================================
1255
+
1256
+def _parse_repos_config():
1257
+ """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1258
+
1259
+ Format linii: <url> [fingerprint]
1260
+ Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1261
+ podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1262
+ pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1263
+
1264
+ Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1265
+ wygodne dodawanie repo bez dotykania głównego repos.conf
1266
+ (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1267
+ """
1268
+ entries = []
1269
+
1270
+ def _read_lines(path):
1271
+ if not os.path.exists(path):
1272
+ return
1273
+ for line in open(path):
1274
+ line = line.strip()
1275
+ if not line or line.startswith("#"):
1276
+ continue
1277
+ parts = line.split()
1278
+ url = parts[0].rstrip("/")
1279
+ fp = parts[1].lower() if len(parts) > 1 else ""
1280
+ entries.append({"url": url, "fingerprint": fp or None})
1281
+
1282
+ # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1283
+ _read_lines(REPOS_CONF)
1284
+ # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1285
+ if os.path.isdir(REPOS_DIR):
1286
+ for drop in sorted(os.listdir(REPOS_DIR)):
1287
+ if drop.endswith(".conf"):
1288
+ _read_lines(os.path.join(REPOS_DIR, drop))
1289
+
1290
+ # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1291
+ seen, unique = set(), []
1292
+ for e in entries:
1293
+ if e["url"] not in seen:
1294
+ seen.add(e["url"])
1295
+ unique.append(e)
1296
+
1297
+ if not unique:
1298
+ for url in DEFAULT_REPOS:
1299
+ unique.append({"url": url, "fingerprint": None})
1300
+ return unique
1301
+
1302
+
1303
+def get_repos():
1304
+ return [e["url"] for e in _parse_repos_config()]
1305
+
1306
+
1307
+def _repo_pinned_fp(repo_url):
1308
+ """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1309
+ by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1310
+ if by_url.get(repo_url):
1311
+ return by_url[repo_url]
1312
+ db = _load_trust_db()
1313
+ fp = db.get(repo_url)
1314
+ return fp.lower() if fp else None
1315
+
1316
+def _repo_cache_path(url):
1317
+ return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1318
+
1319
+def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1320
+def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1321
+
1322
+# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1323
+# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1324
+# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1325
+_cache_ro_notice_done = False
1326
+
1327
+def _cache_ro_notice():
1328
+ global _cache_ro_notice_done
1329
+ if _cache_ro_notice_done:
1330
+ return
1331
+ _cache_ro_notice_done = True
1332
+ print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1333
+
1334
+def _load_repo_cache(cp: str) -> Optional[list]:
1335
+ """Wczytuje cache indeksu repo; zwraca None gdy brak albo uszkodzony.
1336
+
1337
+ Partial write (crash w trakcie zapisu) mógł zostawić obcięty JSON. Zamiast
1338
+ zwracać pustą listę pakietów (użytkownik myśli, że repo jest puste)
1339
+ sygnalizujemy None – wywołujący ponowi pobranie albo pokaże ostrzeżenie."""
1340
+ if not os.path.exists(cp):
1341
+ return None
1342
+ try:
1343
+ with open(cp, "r", encoding="utf-8") as fh:
1344
+ data = json.load(fh)
1345
+ pkgs = data.get("packages")
1346
+ if not isinstance(pkgs, list):
1347
+ raise ValueError("brak listy 'packages'")
1348
+ return pkgs
1349
+ except (OSError, ValueError) as e:
1350
+ print(f" ⚠ Uszkodzony cache indeksu {cp}: {e}", file=sys.stderr)
1351
+ return None
1352
+
1353
+
1354
+def fetch_repo_index(repo_url, force=False):
1355
+ cp = _repo_cache_path(repo_url)
1356
+ ep = _repo_etag_path(repo_url)
1357
+ tp = _repo_ts_path(repo_url)
1358
+
1359
+ if not force and os.path.exists(cp) and os.path.exists(tp):
1360
+ try:
1361
+ if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1362
+ cached = _load_repo_cache(cp)
1363
+ if cached is not None:
1364
+ return cached
1365
+ # uszkodzony cache – spróbuj odświeżyć z sieci
1366
+ except (OSError, ValueError):
1367
+ pass
1368
+
1369
+ # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1370
+ # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1371
+ # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1372
+ # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1373
+ if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1374
+ if force:
1375
+ print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1376
+ file=sys.stderr)
1377
+ return None
1378
+ _cache_ro_notice()
1379
+ cached = _load_repo_cache(cp)
1380
+ if cached is not None:
1381
+ return cached
1382
+ return None
1383
+
1384
+ headers = {"User-Agent": "pag/3.0"}
1385
+ if os.path.exists(tp) and not force:
1386
+ try:
1387
+ lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1388
+ # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1389
+ try:
1390
+ old_locale = locale.setlocale(locale.LC_TIME)
1391
+ locale.setlocale(locale.LC_TIME, 'C')
1392
+ headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1393
+ locale.setlocale(locale.LC_TIME, old_locale)
1394
+ except (locale.Error, ValueError):
1395
+ # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1396
+ headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1397
+ except: pass
1398
+ if os.path.exists(ep) and not force:
1399
+ try: headers["If-None-Match"] = open(ep).read().strip()
1400
+ except: pass
1401
+
1402
+ # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1403
+ try:
1404
+ req = Request(f"{repo_url}/repo.json", headers=headers)
1405
+ with urlopen(req, timeout=30) as resp:
1406
+ etag = resp.headers.get("ETag","")
1407
+ raw = resp.read()
1408
+ data = json.loads(raw.decode())
1409
+ except HTTPError as e:
1410
+ if e.code == 304:
1411
+ # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1412
+ try:
1413
+ open(tp,"w").write(str(time.time()))
1414
+ except OSError:
1415
+ pass
1416
+ cached = _load_repo_cache(cp)
1417
+ if cached is not None:
1418
+ return cached
1419
+ # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1420
+ print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1421
+ return None
1422
+ except Exception as e:
1423
+ print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1424
+ return _load_repo_cache(cp)
1425
+
1426
+ # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1427
+ # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1428
+ # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1429
+ # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1430
+ # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1431
+ tmp_path = cp + ".tmp"
1432
+ try:
1433
+ with open(tmp_path, "wb") as f:
1434
+ f.write(raw)
1435
+ if not _verify_repo_sig(repo_url, tmp_path):
1436
+ return None # weryfikacja nie powiodła się – stary cache zostaje
1437
+ os.replace(tmp_path, cp)
1438
+ # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1439
+ for _ext in (".asc", ".sig"):
1440
+ if os.path.exists(tmp_path + _ext):
1441
+ try:
1442
+ os.replace(tmp_path + _ext, cp + _ext)
1443
+ except OSError:
1444
+ pass
1445
+ break
1446
+ if etag:
1447
+ try:
1448
+ open(ep,"w").write(etag)
1449
+ except OSError:
1450
+ pass
1451
+ try:
1452
+ open(tp,"w").write(str(time.time()))
1453
+ except OSError:
1454
+ pass
1455
+ return data.get("packages",[])
1456
+ except OSError as e:
1457
+ print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1458
+ file=sys.stderr)
1459
+ # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1460
+ # to wciąż stare, zweryfikowane dane
1461
+ return _load_repo_cache(cp)
1462
+ finally:
1463
+ for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1464
+ try:
1465
+ os.unlink(_p)
1466
+ except OSError:
1467
+ pass
1468
+
1469
+def _verify_repo_sig(repo_url, cache_path) -> bool:
1470
+ """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1471
+
1472
+ FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1473
+ Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1474
+
1475
+ Model zaufania (TOFU + pinning):
1476
+ - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1477
+ a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1478
+ To świadomy kompromis wygody i bezpieczeństwa.
1479
+ - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1480
+ Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1481
+ pag key-trust <repo_url> (po weryfikacji nowego klucza)
1482
+ """
1483
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
1484
+
1485
+ if not os.path.exists(GPG_HOME):
1486
+ if insecure:
1487
+ return True # brak GPG home – tryb insecure, akceptuj
1488
+ print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1489
+ print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1490
+ os.remove(cache_path)
1491
+ return False
1492
+
1493
+ sig_path = cache_path + ".sig"
1494
+ # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1495
+ sig_data = None
1496
+ sig_ext = ""
1497
+ for ext in (".asc", ".sig"):
1498
+ try:
1499
+ req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1500
+ with urlopen(req, timeout=15) as resp:
1501
+ sig_data = resp.read()
1502
+ sig_ext = ext
1503
+ break
1504
+ except Exception:
1505
+ continue
1506
+ if not sig_data:
1507
+ if insecure:
1508
+ return True # tryb insecure – akceptuj bez podpisu
1509
+ print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1510
+ print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1511
+ os.remove(cache_path)
1512
+ return False
1513
+ sig_path = cache_path + sig_ext
1514
+ with open(sig_path, "wb") as f:
1515
+ f.write(sig_data)
1516
+
1517
+ ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1518
+ if not ok:
1519
+ # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1520
+ # jak apt) – gdy w keyringu brakuje klucza (No public key).
1521
+ res = _gpg_run("--verify", sig_path, cache_path,
1522
+ capture_output=True, text=True, timeout=30)
1523
+ _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1524
+ if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1525
+ try:
1526
+ with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1527
+ keydata = r.read()
1528
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1529
+ tmp.write(keydata)
1530
+ tmp.flush()
1531
+ _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1532
+ os.unlink(tmp.name)
1533
+ print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1534
+ ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1535
+ except Exception:
1536
+ pass
1537
+ if not ok:
1538
+ if insecure:
1539
+ print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1540
+ return True
1541
+ os.remove(cache_path)
1542
+ if not shutil.which(GPG_BINARY):
1543
+ print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1544
+ print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1545
+ else:
1546
+ print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1547
+ return False
1548
+
1549
+ # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1550
+ pinned = _repo_pinned_fp(repo_url)
1551
+ if pinned:
1552
+ if not fingerprint:
1553
+ if insecure:
1554
+ print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1555
+ return True
1556
+ os.remove(cache_path)
1557
+ print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1558
+ print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1559
+ return False
1560
+ if fingerprint != pinned.upper():
1561
+ if insecure:
1562
+ print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1563
+ return True
1564
+ os.remove(cache_path)
1565
+ print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1566
+ print(f" Oczekiwany: {pinned}")
1567
+ print(f" Otrzymany: {fingerprint}")
1568
+ print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1569
+ return False
1570
+ return True
1571
+
1572
+ if fingerprint:
1573
+ # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1574
+ db = _load_trust_db()
1575
+ if db.get(repo_url) != fingerprint:
1576
+ _save_trust_db({**db, repo_url: fingerprint})
1577
+ print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1578
+ print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1579
+ print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1580
+ return True
1581
+
1582
+def fetch_all_packages(force=False):
1583
+ all_pkgs = {}
1584
+ for repo_url in get_repos():
1585
+ pkgs = fetch_repo_index(repo_url, force)
1586
+ if pkgs:
1587
+ for pdata in pkgs:
1588
+ name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1589
+ pkg = PackageInfo(pdata, repo_url)
1590
+ if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1591
+ all_pkgs[name] = pkg
1592
+ return all_pkgs
1593
+
1594
+# =============================================================================
1595
+# GPG
1596
+# =============================================================================
1597
+
1598
+def _verify_pkg_gpg(pkg_path, repo_url=None):
1599
+ """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1600
+
1601
+ FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1602
+ Zwraca (passed: bool, message: str).
1603
+ """
1604
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
1605
+ # Brak gnupg = weryfikacja niemożliwa. Bez tej gałęzi użytkownik dostawał
1606
+ # mylące „NIEPRAWIDŁOWY PODPIS GPG”, mimo że paczka i podpis są w porządku.
1607
+ if not shutil.which(GPG_BINARY):
1608
+ if insecure:
1609
+ return True, "(gpg missing – PAG_INSECURE)"
1610
+ return False, _("gpg_missing")
1611
+ sig_path = pkg_path + ".sig"
1612
+ if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1613
+ sig_path = pkg_path + ".asc"
1614
+
1615
+ if not os.path.exists(sig_path):
1616
+ if insecure:
1617
+ return True, "(no signature – PAG_INSECURE)"
1618
+ return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1619
+
1620
+ ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1621
+ if not ok:
1622
+ if insecure:
1623
+ return True, "(invalid signature – PAG_INSECURE)"
1624
+ return False, "NIEPRAWIDŁOWY PODPIS GPG"
1625
+
1626
+ # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1627
+ if repo_url:
1628
+ pinned = _repo_pinned_fp(repo_url)
1629
+ if pinned and fp and fp != pinned.upper():
1630
+ if insecure:
1631
+ return True, "(pkg signer mismatch – PAG_INSECURE)"
1632
+ return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1633
+
1634
+ return True, "GPG verified"
1635
+
1636
+def cmd_key_add(source):
1637
+ ensure_dirs()
1638
+ if not shutil.which(GPG_BINARY):
1639
+ print(f"❌ {_('gpg_missing')}"); return 1
1640
+ if source.startswith("http"):
1641
+ try:
1642
+ with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1643
+ keydata = resp.read()
1644
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1645
+ tmp.write(keydata); tmp.flush()
1646
+ res = _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1647
+ os.unlink(tmp.name)
1648
+ if res.returncode != 0:
1649
+ print(f"❌ {_('key_add_failed')}"); return 1
1650
+ except Exception as e:
1651
+ print(f"❌ Download error: {e}"); return 1
1652
+ else:
1653
+ res = _gpg_run("--import", source, capture_output=True, timeout=30)
1654
+ if res.returncode != 0:
1655
+ print(f"❌ {_('key_add_failed')}"); return 1
1656
+ print(f"✅ {_('key_imported')}")
1657
+
1658
+def cmd_key_list():
1659
+ if not shutil.which(GPG_BINARY):
1660
+ print(f"❌ {_('gpg_missing')}"); return
1661
+ if not os.path.exists(GPG_HOME):
1662
+ print(_("no_keys")); return
1663
+ result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1664
+ capture_output=True, text=True, timeout=30)
1665
+ if result.returncode != 0:
1666
+ print(f"❌ {_('gpg_missing')}"); return
1667
+ print(result.stdout or _("no_keys"))
1668
+
1669
+def cmd_key_remove(key_id):
1670
+ _gpg_run("--batch", "--yes", "--delete-key", key_id,
1671
+ capture_output=True, timeout=30)
1672
+ print(f"✅ {_('key_removed', key_id)}")
1673
+
1674
+def _repo_signer_fp(repo_url):
1675
+ """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1676
+ repo_url = repo_url.rstrip("/")
1677
+ try:
1678
+ with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1679
+ data = r.read()
1680
+ except Exception:
1681
+ return None
1682
+ sig = None
1683
+ sig_ext = ".asc"
1684
+ for ext in (".asc", ".sig"):
1685
+ try:
1686
+ with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1687
+ sig = r.read()
1688
+ sig_ext = ext
1689
+ break
1690
+ except Exception:
1691
+ continue
1692
+ if not sig:
1693
+ return None
1694
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1695
+ tf.write(data); tf.flush()
1696
+ data_path = tf.name
1697
+ sig_path = data_path + sig_ext
1698
+ try:
1699
+ with open(sig_path, "wb") as f:
1700
+ f.write(sig)
1701
+ ok, fp = _gpg_verify_fp(sig_path, data_path)
1702
+ finally:
1703
+ for p in (data_path, sig_path):
1704
+ try: os.unlink(p)
1705
+ except OSError: pass
1706
+ return fp if ok else None
1707
+
1708
+
1709
+def cmd_key_trust(repo_url):
1710
+ """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1711
+ repo_url = repo_url.rstrip("/")
1712
+ print(f"🔐 Przypinam klucz repo {repo_url}...")
1713
+ fp = _repo_signer_fp(repo_url)
1714
+ if not fp:
1715
+ print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1716
+ print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1717
+ return 1
1718
+ db = _load_trust_db()
1719
+ _save_trust_db({**db, repo_url: fp})
1720
+ print(f" ✅ Przypięto {fp} dla {repo_url}")
1721
+ print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1722
+ return 0
1723
+
1724
+
1725
+def cmd_key_untrust(repo_url):
1726
+ """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1727
+ repo_url = repo_url.rstrip("/")
1728
+ db = _load_trust_db()
1729
+ if repo_url not in db:
1730
+ print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1731
+ return 0
1732
+ del db[repo_url]
1733
+ _save_trust_db(db)
1734
+ print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1735
+ return 0
1736
+
1737
+
1738
+def cmd_key_trusted():
1739
+ """Listuje przypięte fingerprinty repozytoriów."""
1740
+ db = _load_trust_db()
1741
+ if not db:
1742
+ print(_("no_keys"))
1743
+ return
1744
+ for url, fp in sorted(db.items()):
1745
+ print(f" {url}\n {fp}")
1746
+
1747
+# =============================================================================
1748
+# ATOMOWA INSTALACJA (STAGING)
1749
+# =============================================================================
1750
+
1751
+def _safe_rename(src: str, dst: str) -> bool:
1752
+ """
1753
+ Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1754
+ systemach plików (EXDEV), kopiuje + usuwa źródło.
1755
+ """
1756
+ try:
1757
+ os.rename(src, dst)
1758
+ return True
1759
+ except OSError as e:
1760
+ if e.errno == 18: # EXDEV – cross-device link
1761
+ shutil.copy2(src, dst)
1762
+ os.remove(src)
1763
+ return True
1764
+ raise
1765
+
1766
+
1767
+def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1768
+ staging: str, journal: list, installed_files: list,
1769
+ deploy_dir: str = "", backup_dir: str = "",
1770
+ backup_journal: Optional[list] = None,
1771
+ old_checksums: Optional[dict] = None) -> bool:
1772
+ """
1773
+ Instaluje pojedynczy plik (zwykły lub symlink).
1774
+ Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1775
+
1776
+ Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1777
+ do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1778
+
1779
+ Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1780
+ stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1781
+
1782
+ old_checksums: {ścieżka: sha256 z chwili instalacji}. Gdy podane i plik /etc
1783
+ został zmodyfikowany przez użytkownika, nowa wersja ląduje jako .pacnew
1784
+ (stary plik NIE jest ruszany) – wzorzec jak w pacmanie.
1785
+ """
1786
+ # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1787
+ if deploy_dir and _is_shared_path("/" + rel):
1788
+ dst_root = PAG_ROOT
1789
+ elif deploy_dir:
1790
+ dst_root = deploy_dir
1791
+ else:
1792
+ dst_root = PAG_ROOT
1793
+
1794
+ dst = os.path.join(dst_root, rel)
1795
+
1796
+ # --- SYMLINK ---
1797
+ if os.path.islink(src):
1798
+ link_target = os.readlink(src)
1799
+ # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1800
+ expected = sums.get("/" + rel, "")
1801
+ if expected:
1802
+ link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1803
+ if expected and link_hash != expected:
1804
+ return False
1805
+
1806
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1807
+ # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1808
+ if backup_dir and backup_journal is not None and os.path.lexists(dst):
1809
+ try:
1810
+ backup_path = os.path.join(backup_dir, rel)
1811
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1812
+ os.replace(dst, backup_path)
1813
+ backup_journal.append((backup_path, "/" + rel))
1814
+ journal.append(("backup", backup_path, dst))
1815
+ except OSError:
1816
+ pass
1817
+ # Jeśli docelowy symlink już istnieje, usuń go
1818
+ if os.path.islink(dst) or os.path.exists(dst):
1819
+ os.remove(dst)
1820
+ os.symlink(link_target, dst)
1821
+ journal.append(("symlink", "", dst))
1822
+ installed_files.append({
1823
+ "path": "/" + rel,
1824
+ "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1825
+ "size": len(link_target),
1826
+ "is_symlink": True,
1827
+ "symlink_target": link_target,
1828
+ })
1829
+ return True
1830
+
1831
+ # --- ZWYKŁY PLIK ---
1832
+ # Oblicz SHA256
1833
+ try:
1834
+ file_sha = _sha256_file(src)
1835
+ except Exception:
1836
+ file_sha = ""
1837
+
1838
+ # Weryfikuj sums.json
1839
+ expected = sums.get("/" + rel, "")
1840
+ if expected and file_sha and file_sha != expected:
1841
+ return False
1842
+
1843
+ # --- .pacnew: NIE nadpisuj pliku konfiguracyjnego, którego nie wolno zgubić ---
1844
+ # Robimy `<plik>.pacnew`, gdy:
1845
+ # a) plik jest śledzony i użytkownik go zmodyfikował (hash != zapisanej sumy),
1846
+ # b) to wrażliwa konfiguracja (/etc/pam.d, /etc/security, sudoers, shadow…),
1847
+ # a treść na dysku różni się od tej z pakietu – chroni auth przed
1848
+ # cichym nadpisaniem przez `install`/`upgrade` (np. przez shadow).
1849
+ # NIE robimy .pacnew, jeśli plik na dysku jest identyczny z nowym albo był
1850
+ # niezmieniony od instalacji (wtedy nadpisanie jest bezpieczne).
1851
+ old_sha = (old_checksums or {}).get("/" + rel, "")
1852
+ if file_sha and _is_config_path(rel) and os.path.isfile(dst) and not os.path.islink(dst):
1853
+ tracked_modified = bool(old_sha) and file_sha != old_sha
1854
+ if tracked_modified or _is_sensitive_config(rel):
1855
+ try:
1856
+ cur_sha = _sha256_file(dst)
1857
+ except OSError:
1858
+ cur_sha = ""
1859
+ if cur_sha and cur_sha != file_sha and not (old_sha and cur_sha == old_sha):
1860
+ pacnew = dst + ".pacnew"
1861
+ try:
1862
+ _safe_rename(src, pacnew)
1863
+ try:
1864
+ os.chown(pacnew, 0, 0)
1865
+ except (OSError, PermissionError):
1866
+ pass
1867
+ # Journal (do cofnięcia przy nieudanej transakcji) – ale NIE
1868
+ # zapisujemy .pacnew w bazie plików: to artefakt użytkownika.
1869
+ journal.append(("file", src, pacnew))
1870
+ print(f" ⚠ {_('conf_pacnew', path=pacnew)}")
1871
+ return True
1872
+ except OSError:
1873
+ pass # nie udało się – kontynuuj normalną instalację
1874
+
1875
+ # Utwórz katalog docelowy
1876
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1877
+
1878
+ # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1879
+ if backup_dir and backup_journal is not None and os.path.lexists(dst):
1880
+ try:
1881
+ backup_path = os.path.join(backup_dir, rel)
1882
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1883
+ os.replace(dst, backup_path)
1884
+ backup_journal.append((backup_path, "/" + rel))
1885
+ journal.append(("backup", backup_path, dst))
1886
+ except OSError:
1887
+ pass
1888
+
1889
+ # Atomowe przeniesienie (z fallbackiem dla cross-device).
1890
+ # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1891
+ _safe_rename(src, dst)
1892
+
1893
+ # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1894
+ try:
1895
+ os.chown(dst, 0, 0)
1896
+ except (OSError, PermissionError):
1897
+ # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1898
+ pass
1899
+
1900
+ journal.append(("file", src, dst))
1901
+ installed_files.append({
1902
+ "path": "/" + rel,
1903
+ "sha256": file_sha,
1904
+ "size": os.path.getsize(dst),
1905
+ "is_symlink": False,
1906
+ })
1907
+ return True
1908
+
1909
+
1910
+def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1911
+ backup_dir: str = "",
1912
+ old_checksums: Optional[dict] = None) -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1913
+ """
1914
+ Rozpakowuje do staging area, potem atomowo przenosi pliki.
1915
+ Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1916
+ Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1917
+ """
1918
+ staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1919
+ journal = []
1920
+ installed_files = []
1921
+ backup_journal: List[Tuple[str, str]] = []
1922
+
1923
+ try:
1924
+ # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1925
+ with tarfile.open(pkg_path, "r:xz") as tf:
1926
+ _safe_extractall(tf, staging)
1927
+
1928
+ data_tar = os.path.join(staging, "data.tar.xz")
1929
+ if not os.path.exists(data_tar):
1930
+ shutil.rmtree(staging, ignore_errors=True)
1931
+ return False, [], backup_journal
1932
+
1933
+ # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1934
+ data_staging = os.path.join(staging, "data")
1935
+ os.makedirs(data_staging, exist_ok=True)
1936
+ with tarfile.open(data_tar, "r:xz") as tf:
1937
+ _safe_extractall(tf, data_staging)
1938
+
1939
+ # Wczytaj sums.json
1940
+ sums_path = os.path.join(data_staging, "sums.json")
1941
+ sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1942
+
1943
+ # Hook pre-install (przed przeniesieniem plików do systemu)
1944
+ _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1945
+
1946
+ # Przenieś pliki: staging/data/* → /
1947
+ for root, dirs, files in os.walk(data_staging):
1948
+ # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1949
+ # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1950
+ # puste katalogi z data.tar.xz ginęły przy instalacji.
1951
+ for d in dirs:
1952
+ src_dir = os.path.join(root, d)
1953
+ rel_dir = os.path.relpath(src_dir, data_staging)
1954
+ if deploy_dir and _is_shared_path("/" + rel_dir):
1955
+ dst_root = PAG_ROOT
1956
+ elif deploy_dir:
1957
+ dst_root = deploy_dir
1958
+ else:
1959
+ dst_root = PAG_ROOT
1960
+ dst_dir = os.path.join(dst_root, rel_dir)
1961
+ if not os.path.isdir(dst_dir):
1962
+ try:
1963
+ os.makedirs(dst_dir, exist_ok=True)
1964
+ except OSError:
1965
+ pass
1966
+ for fname in files:
1967
+ if fname == "sums.json":
1968
+ continue
1969
+ src = os.path.join(root, fname)
1970
+ rel = os.path.relpath(src, data_staging)
1971
+
1972
+ ok = _install_file(src, rel, data_staging, sums,
1973
+ staging, journal, installed_files, deploy_dir,
1974
+ backup_dir, backup_journal,
1975
+ old_checksums=old_checksums)
1976
+ if not ok:
1977
+ # Cofnij wszystkie operacje
1978
+ _rollback_journal(journal, staging)
1979
+ return False, [], backup_journal
1980
+
1981
+ # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1982
+ # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1983
+ # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1984
+ # nie jest zainstalowane.
1985
+ _icon_dirs = set()
1986
+ for f in installed_files:
1987
+ fp = f.get("path", "") or ""
1988
+ if fp.startswith("/usr/share/icons/"):
1989
+ _rest = fp[len("/usr/share/icons/"):]
1990
+ _theme = _rest.split("/", 1)[0]
1991
+ if _theme:
1992
+ _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1993
+ if _icon_dirs:
1994
+ try:
1995
+ subprocess.run(["gtk-update-icon-cache", "--version"],
1996
+ capture_output=True, timeout=10)
1997
+ for _d in sorted(_icon_dirs):
1998
+ if os.path.isdir(_d):
1999
+ subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
2000
+ capture_output=True, timeout=300)
2001
+ except Exception:
2002
+ pass
2003
+
2004
+ # Uruchom hooki post-install
2005
+ hooks_dir = os.path.join(staging, "hooks")
2006
+ _run_hook(hooks_dir, "post-install", pkg)
2007
+
2008
+ # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
2009
+ try:
2010
+ if os.path.isdir(hooks_dir):
2011
+ persisted = os.path.join(PAG_DB, "hooks", pkg.name)
2012
+ shutil.rmtree(persisted, ignore_errors=True)
2013
+ shutil.copytree(hooks_dir, persisted)
2014
+ except Exception:
2015
+ pass
2016
+
2017
+ # Zapisz do SQLite
2018
+ _db_record_files(pkg.name, installed_files)
2019
+
2020
+ shutil.rmtree(staging, ignore_errors=True)
2021
+ return True, installed_files, backup_journal
2022
+
2023
+ except Exception as e:
2024
+ _rollback_journal(journal, staging)
2025
+ return False, [], backup_journal
2026
+
2027
+
2028
+def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
2029
+ """Odświeża cache ld.so po udanej instalacji pakietów."""
2030
+ ldconfig = shutil.which("ldconfig")
2031
+ if not ldconfig:
2032
+ print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
2033
+ file=sys.stderr)
2034
+ return False
2035
+
2036
+ target_root = deploy_dir or PAG_ROOT
2037
+ command = [ldconfig]
2038
+ if target_root != "/":
2039
+ command.extend(["-r", target_root])
2040
+
2041
+ try:
2042
+ subprocess.run(command, check=True, timeout=60,
2043
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
2044
+ text=True)
2045
+ return True
2046
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
2047
+ detail = getattr(exc, "stderr", None) or str(exc)
2048
+ print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
2049
+ file=sys.stderr)
2050
+ return False
2051
+
2052
+
2053
+def _rollback_journal(journal: list, staging_path: str):
2054
+ """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
2055
+ for entry in reversed(journal):
2056
+ op = entry[0]
2057
+ if op == "file":
2058
+ _, src, dst = entry
2059
+ try:
2060
+ if os.path.exists(dst) or os.path.islink(dst):
2061
+ _safe_rename(dst, src)
2062
+ except Exception:
2063
+ pass
2064
+ elif op == "symlink":
2065
+ _, _, dst = entry
2066
+ try:
2067
+ if os.path.islink(dst) or os.path.exists(dst):
2068
+ os.remove(dst)
2069
+ except Exception:
2070
+ pass
2071
+ elif op == "backup":
2072
+ # Przywróć starą wersję pliku z backupu (upgrade)
2073
+ _, bpath, dst = entry
2074
+ try:
2075
+ if os.path.lexists(bpath):
2076
+ os.replace(bpath, dst)
2077
+ except Exception:
2078
+ pass
2079
+ shutil.rmtree(staging_path, ignore_errors=True)
2080
+
2081
+# =============================================================================
2082
+# BEZPIECZNE USUWANIE
2083
+# =============================================================================
2084
+
2085
+def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
2086
+ """
2087
+ Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
2088
+ Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
2089
+ """
2090
+ pkg_files = _db_get_package_files(pkg_name)
2091
+ recorded = _db_get_package_checksums(pkg_name)
2092
+ removed = []
2093
+ skipped_shared = []
2094
+ skipped_sensitive = []
2095
+
2096
+ for fpath in pkg_files:
2097
+ owners = _db_get_file_owners(fpath)
2098
+ # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
2099
+ other_owners = [o for o in owners if o != pkg_name and o in installed_db]
2100
+
2101
+ if other_owners:
2102
+ # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
2103
+ skipped_shared.append(fpath)
2104
+ continue
2105
+
2106
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2107
+ if os.path.isfile(full) or os.path.islink(full):
2108
+ # Wrażliwej konfiguracji systemowej (/etc/pam.d, sudoers, shadow…)
2109
+ # NIE kasujemy przy `remove` – jej brak potrafi zablokować logowanie
2110
+ # i sudo (np. usunięcie system-auth/system-account). Zostaje wpis w bazie.
2111
+ if _is_sensitive_config(fpath):
2112
+ skipped_sensitive.append(fpath)
2113
+ continue
2114
+ # .pacsave: zachowaj ZMIENIONY plik konfiguracyjny zamiast kasować
2115
+ # (porównanie z sumą z chwili instalacji), wzorzec jak w pacmanie.
2116
+ if not os.path.islink(full) and _is_config_path(fpath):
2117
+ old_sha = recorded.get(fpath, "")
2118
+ if old_sha:
2119
+ try:
2120
+ cur_sha = _sha256_file(full)
2121
+ except OSError:
2122
+ cur_sha = ""
2123
+ if cur_sha and cur_sha != old_sha:
2124
+ pacsave = full + ".pacsave"
2125
+ try:
2126
+ os.replace(full, pacsave)
2127
+ print(f" ⚠ {_('conf_pacsave', path=pacsave)}")
2128
+ removed.append(fpath)
2129
+ continue
2130
+ except OSError:
2131
+ pass
2132
+ os.remove(full)
2133
+ removed.append(fpath)
2134
+
2135
+ # Usuń puste katalogi (od najgłębszych)
2136
+ dirs = set()
2137
+ for fpath in removed + skipped_shared:
2138
+ parent = os.path.dirname(fpath)
2139
+ while parent and parent != "/":
2140
+ dirs.add(parent)
2141
+ parent = os.path.dirname(parent)
2142
+
2143
+ for d in sorted(dirs, key=len, reverse=True):
2144
+ full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2145
+ if os.path.isdir(full_d):
2146
+ try:
2147
+ os.rmdir(full_d)
2148
+ except OSError:
2149
+ pass # nie jest pusty – OK
2150
+
2151
+ # Usuń z SQLite
2152
+ _db_remove_package_files(pkg_name)
2153
+
2154
+ if skipped_shared:
2155
+ print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
2156
+ if skipped_sensitive:
2157
+ print(f" ⚠ {len(skipped_sensitive)} wrażliwych plików systemowych zachowanych")
2158
+
2159
+ return len(removed) + len(skipped_shared) + len(skipped_sensitive), removed
2160
+
2161
+
2162
+def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
2163
+ installed_db: dict, deploy_dir: str = "",
2164
+ backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
2165
+ """
2166
+ Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
2167
+
2168
+ - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
2169
+ (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
2170
+ - Sprząta puste katalogi i wpisy SQLite starej wersji.
2171
+ Zwraca (liczba usuniętych, [usunięte ścieżki]).
2172
+ """
2173
+ new_set = set(new_paths)
2174
+ stale = [f for f in old_files if f not in new_set]
2175
+ if not stale:
2176
+ return 0, []
2177
+
2178
+ root = deploy_dir or PAG_ROOT
2179
+ removed = []
2180
+ skipped = 0
2181
+ for fpath in stale:
2182
+ owners = _db_get_file_owners(fpath)
2183
+ other_owners = [o for o in owners if o != pkg_name and o in installed_db]
2184
+ if other_owners:
2185
+ # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
2186
+ skipped += 1
2187
+ else:
2188
+ # Wrażliwych plików systemowych NIE kasujemy, gdy nowy pakiet ich już
2189
+ # nie dostarcza (np. shadow przestaje pakować system-*). Ich brak
2190
+ # blokuje login/sudo; usuwamy więc tylko wpis w bazie.
2191
+ if _is_sensitive_config(fpath):
2192
+ skipped += 1
2193
+ else:
2194
+ # Gdy nowa wersja pliku /etc trafiła do .pacnew, plik użytkownika
2195
+ # MUSI zostać – nie jest „przestarzały”. Zachowujemy też wpis w bazie
2196
+ # (stara suma), by kolejny upgrade dalej wykrywał zmiany użytkownika.
2197
+ if _is_config_path(fpath):
2198
+ _cfg_full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2199
+ if os.path.lexists(_cfg_full + ".pacnew"):
2200
+ skipped += 1
2201
+ continue
2202
+ full = os.path.join(root, fpath.lstrip("/"))
2203
+ if os.path.isfile(full) or os.path.islink(full):
2204
+ try:
2205
+ if backup_dir and backup_journal is not None:
2206
+ backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
2207
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
2208
+ os.replace(full, backup_path) # przenieś do backupu (rollback)
2209
+ backup_journal.append((backup_path, fpath))
2210
+ else:
2211
+ os.remove(full)
2212
+ removed.append(fpath)
2213
+ except OSError:
2214
+ pass
2215
+ # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
2216
+ with _db_session() as db:
2217
+ db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
2218
+
2219
+ # Usuń puste katalogi (od najgłębszych)
2220
+ dirs = set()
2221
+ for fpath in removed:
2222
+ parent = os.path.dirname(fpath)
2223
+ while parent and parent != "/":
2224
+ dirs.add(parent)
2225
+ parent = os.path.dirname(parent)
2226
+ for d in sorted(dirs, key=len, reverse=True):
2227
+ full_d = os.path.join(root, d.lstrip("/"))
2228
+ if os.path.isdir(full_d):
2229
+ try:
2230
+ os.rmdir(full_d)
2231
+ except OSError:
2232
+ pass # nie jest pusty – OK
2233
+
2234
+ if removed:
2235
+ print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
2236
+ if skipped:
2237
+ print(f" ⚠ {skipped} plików współdzielonych zachowanych")
2238
+
2239
+ return len(removed), removed
2240
+
2241
+
2242
+def _new_transaction_backup_root() -> str:
2243
+ """Katalog na backupy NADPISYWANYCH plików dla bieżącej transakcji.
2244
+
2245
+ Tworzony dla KAŻDEJ transakcji, nie tylko upgrade: „świeża” instalacja
2246
+ pakietu też potrafi nadpisać pliki spoza bazy pag (baza rootfs/ISO).
2247
+ Gdy transakcja padnie, rollback MUSI mieć co przywrócić – inaczej kasuje
2248
+ te pliki (tak zniknął m.in. libpam.so.0 i przestał działać sudo)."""
2249
+ txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
2250
+ root = os.path.join(STAGING_DIR, "backups", txn)
2251
+ os.makedirs(root, exist_ok=True)
2252
+ return root
2253
+
2254
+
2255
+def _purge_old_backups(keep_root: str = ""):
2256
+ """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
2257
+ base = os.path.join(STAGING_DIR, "backups")
2258
+ if not os.path.isdir(base):
2259
+ return
2260
+ for entry in os.listdir(base):
2261
+ p = os.path.join(base, entry)
2262
+ if p != keep_root and os.path.isdir(p):
2263
+ shutil.rmtree(p, ignore_errors=True)
2264
+
2265
+# =============================================================================
2266
+# HOOKI
2267
+# =============================================================================
2268
+# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
2269
+# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
2270
+# Aby ograniczyć ryzyko:
2271
+# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
2272
+# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
2273
+# - każde uruchomienie jest logowane do /var/log/pag/audit.log
2274
+# - hook ma wersjonowane API (PKG_HOOK_API)
2275
+# =============================================================================
2276
+
2277
+# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
2278
+_HOOKS_RUN: List[str] = []
2279
+
2280
+
2281
+def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
2282
+ """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
2283
+ return {
2284
+ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
2285
+ "HOME": "/root",
2286
+ "LANG": "C.UTF-8",
2287
+ "LC_ALL": "C.UTF-8",
2288
+ "PKG_NAME": pkg.name,
2289
+ "PKG_VERSION": pkg.version,
2290
+ "PKG_ACTION": hook_name,
2291
+ "PKG_HOOK_API": HOOK_API_VERSION,
2292
+ }
2293
+
2294
+
2295
+def _hook_timeout() -> int:
2296
+ try:
2297
+ return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
2298
+ except Exception:
2299
+ return 60
2300
+
2301
+
2302
+def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
2303
+ """Uruchamia skrypt hooka jeśli istnieje.
2304
+
2305
+ Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
2306
+ pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
2307
+ ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
2308
+ """
2309
+ hook_path = os.path.join(hooks_dir, hook_name)
2310
+ if not os.path.exists(hook_path):
2311
+ return False
2312
+
2313
+ if os.environ.get("PAG_NO_HOOKS", "") == "1":
2314
+ print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
2315
+ _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
2316
+ return False
2317
+
2318
+ os.chmod(hook_path, 0o755)
2319
+ env = _hook_env(pkg, hook_name)
2320
+ tag = f"{hook_name} {pkg.name}-{pkg.version}"
2321
+ try:
2322
+ result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2323
+ check=False, capture_output=True, text=True,
2324
+ cwd="/")
2325
+ _HOOKS_RUN.append(tag)
2326
+ if result.returncode != 0:
2327
+ print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2328
+ if result.stderr:
2329
+ print(f" {result.stderr.strip()[-200:]}")
2330
+ _audit(f"hook FAIL {tag} rc={result.returncode}")
2331
+ else:
2332
+ _audit(f"hook OK {tag}")
2333
+ return True
2334
+ except subprocess.TimeoutExpired:
2335
+ print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2336
+ _audit(f"hook TIMEOUT {tag}")
2337
+ return False
2338
+ except Exception as e:
2339
+ print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2340
+ _audit(f"hook ERROR {tag}: {e}")
2341
+ return False
2342
+
2343
+# =============================================================================
2344
+# TRANSAKCJE I ROLLBACK
2345
+# =============================================================================
2346
+
2347
+def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2348
+ upgrade_backups=None, upgrade_backup_root=""):
2349
+ history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2350
+ # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2351
+ # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2352
+ executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2353
+ _HOOKS_RUN.clear()
2354
+ entry = {
2355
+ "action": action, "packages": packages, "success": success,
2356
+ "timestamp": datetime.now().isoformat(),
2357
+ "snapshot": snapshot,
2358
+ "file_journal": file_journal, # lista plików do wycofania
2359
+ "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2360
+ }
2361
+ if upgrade_backups:
2362
+ entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2363
+ entry["upgrade_backup_root"] = upgrade_backup_root
2364
+ history.append(entry)
2365
+ if len(history) > 50:
2366
+ history = history[-50:]
2367
+ save_json(HISTORY_FILE, history)
2368
+
2369
+def cmd_history():
2370
+ if not os.path.exists(HISTORY_FILE):
2371
+ print(_("no_history")); return
2372
+ history = load_json(HISTORY_FILE)
2373
+ if not history:
2374
+ print(_("no_history")); return
2375
+ print(f"Ostatnie transakcje ({len(history)}):")
2376
+ for i, e in enumerate(reversed(history), 1):
2377
+ icon = "✅" if e["success"] else "❌"
2378
+ pkgs = ", ".join(e["packages"][:5])
2379
+ if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2380
+ print(f" {i}. {icon} {e['action']}: {pkgs}")
2381
+ print(f" {e['timestamp']}")
2382
+
2383
+def cmd_rollback():
2384
+ if not os.path.exists(HISTORY_FILE):
2385
+ print(_("no_history")); return 1
2386
+ history = load_json(HISTORY_FILE)
2387
+ if not history:
2388
+ print(_("no_history")); return 1
2389
+
2390
+ last = None
2391
+ for e in reversed(history):
2392
+ if e["success"] and e.get("snapshot"):
2393
+ last = e; break
2394
+
2395
+ if not last:
2396
+ print("❌ No snapshot to restore."); return 1
2397
+
2398
+ print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2399
+ print(f" Packages: {', '.join(last['packages'][:10])}")
2400
+
2401
+ if not _ask_confirm():
2402
+ return 0
2403
+
2404
+ # Przywróć installed.json
2405
+ save_json(INSTALLED_DB, last["snapshot"])
2406
+
2407
+ # Wycofaj fizyczne pliki (jeśli zapisano journal)
2408
+ # Deduplikacja: pakiet może zgłosić ten sam plik więcej niż raz (np. przez
2409
+ # `provides` lub wspólną ścieżkę), a journal z historii mógł zostać zapisany
2410
+ # przed dodaniem deduplikacji.
2411
+ file_journal = list(dict.fromkeys(last.get("file_journal", [])))
2412
+ upgrade_backups = last.get("upgrade_backups", {}) or {}
2413
+ backup_root = last.get("upgrade_backup_root", "")
2414
+
2415
+ # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2416
+ for dst, bpath in upgrade_backups.items():
2417
+ full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2418
+ if bpath and os.path.lexists(bpath):
2419
+ try:
2420
+ os.makedirs(os.path.dirname(full), exist_ok=True)
2421
+ os.replace(bpath, full)
2422
+ except OSError:
2423
+ pass
2424
+
2425
+ # Usuń nowe pliki (które nie miały poprzedniej wersji)
2426
+ backed = set(upgrade_backups)
2427
+ if file_journal:
2428
+ for fpath in reversed(file_journal):
2429
+ if fpath in backed:
2430
+ continue
2431
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2432
+ if os.path.exists(full) or os.path.islink(full):
2433
+ os.remove(full)
2434
+ print(f" {_('rollback_files', len(file_journal))}")
2435
+
2436
+ # Sprzątanie pustych katalogów + katalogu backupów
2437
+ dirs = set()
2438
+ for fpath in file_journal:
2439
+ parent = os.path.dirname(fpath)
2440
+ while parent and parent != "/":
2441
+ dirs.add(parent)
2442
+ parent = os.path.dirname(parent)
2443
+ for d in sorted(dirs, key=len, reverse=True):
2444
+ full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2445
+ if os.path.isdir(full_d):
2446
+ try:
2447
+ os.rmdir(full_d)
2448
+ except OSError:
2449
+ pass
2450
+ if backup_root:
2451
+ shutil.rmtree(backup_root, ignore_errors=True)
2452
+
2453
+ print(f"✅ {_('rollback_restored')}")
2454
+ _record_transaction("rollback", last["packages"], True, None)
2455
+ return 0
2456
+
2457
+# =============================================================================
2458
+# INSTALACJA
2459
+# =============================================================================
2460
+
2461
+def _install_local_pkg_files(paths, install_succeeded):
2462
+ """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2463
+ Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2464
+ Zwraca (failed_count, installed_files)."""
2465
+ failed = 0
2466
+ all_files = []
2467
+ for p in paths:
2468
+ p = os.path.abspath(p)
2469
+ if not os.path.isfile(p):
2470
+ print(f" ❌ Nie znaleziono pakietu: {p}")
2471
+ failed += 1
2472
+ continue
2473
+ try:
2474
+ with tarfile.open(p, "r:xz") as tf:
2475
+ meta = tf.extractfile("metadata.json")
2476
+ if meta is None:
2477
+ print(f" ❌ {p}: brak metadata.json")
2478
+ failed += 1
2479
+ continue
2480
+ data = json.loads(meta.read())
2481
+ except Exception as e:
2482
+ print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2483
+ failed += 1
2484
+ continue
2485
+ pkg = PackageInfo(data, repo="local")
2486
+ print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2487
+ ok, files, _ = _atomic_install(p, pkg)
2488
+ if ok:
2489
+ install_succeeded(pkg, files)
2490
+ all_files.extend(f["path"] for f in files)
2491
+ print("✅")
2492
+ else:
2493
+ print("❌")
2494
+ failed += 1
2495
+ return failed, all_files
2496
+
2497
+
2498
+def _preflight_disk(total_bytes: int) -> bool:
2499
+ """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2500
+
2501
+ Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2502
+ miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2503
+ (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2504
+ """
2505
+ target = PAG_ROOT or "/"
2506
+ try:
2507
+ st = os.statvfs(target)
2508
+ free = st.f_bavail * st.f_frsize
2509
+ except OSError:
2510
+ return True # nie da się sprawdzić – nie blokuj
2511
+ need_mb = total_bytes // 1048576
2512
+ free_mb = free // 1048576
2513
+ if free < total_bytes:
2514
+ print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2515
+ f"wolne {free_mb} MB ({target})")
2516
+ return False
2517
+ if free < total_bytes * 3:
2518
+ print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2519
+ f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2520
+ # Wykryj mount read-only (test zapisu w stagingu)
2521
+ try:
2522
+ probe = os.path.join(STAGING_DIR, ".pag-probe")
2523
+ with open(probe, "w") as f:
2524
+ f.write("x")
2525
+ os.remove(probe)
2526
+ except OSError:
2527
+ print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2528
+ return False
2529
+ return True
2530
+
2531
+
2532
+def cmd_install(package_names, as_dep=False, upgrade=False):
2533
+ ensure_dirs()
2534
+ installed_db = load_json(INSTALLED_DB)
2535
+ world = load_world()
2536
+ pinned = load_json(PINNED_FILE)
2537
+
2538
+ # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2539
+ # nie wymaga repozytorium ani GPG.
2540
+ local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2541
+ (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2542
+ if local_files:
2543
+ _local_need = sum(
2544
+ os.path.getsize(os.path.abspath(p))
2545
+ for p in local_files if os.path.isfile(os.path.abspath(p))
2546
+ )
2547
+ if not _preflight_disk(_local_need):
2548
+ return 1
2549
+
2550
+ def _ok(pkg, files):
2551
+ installed_db[pkg.name] = {
2552
+ "version": pkg.version, "release": pkg.release, "description": pkg.description,
2553
+ "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2554
+ "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2555
+ "repo": "local",
2556
+ "provides": getattr(pkg, "provides", None) or [],
2557
+ "provides_so": getattr(pkg, "provides_so", None) or [],
2558
+ "requires_so": getattr(pkg, "requires_so", None) or [],
2559
+ }
2560
+ world.add(pkg.name)
2561
+ failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2562
+ save_json(INSTALLED_DB, installed_db)
2563
+ save_world(world)
2564
+ if failed_local:
2565
+ return 1
2566
+ _refresh_dynamic_linker_cache()
2567
+ package_names = [n for n in package_names if n not in
2568
+ [os.path.abspath(x) for x in local_files] and
2569
+ n not in local_files]
2570
+ to_install = []
2571
+ if not package_names:
2572
+ return 0
2573
+ # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2574
+
2575
+ repo_pkgs = fetch_all_packages()
2576
+
2577
+ if not repo_pkgs:
2578
+ print(f"❌ {_('no_index')}"); return 1
2579
+
2580
+ for name in list(package_names):
2581
+ if name in pinned:
2582
+ print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2583
+ package_names.remove(name)
2584
+
2585
+ to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2586
+
2587
+ # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2588
+ # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2589
+ # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2590
+ not_found = []
2591
+ for n in package_names:
2592
+ real = _resolve_provides(n, repo_pkgs, installed_db)
2593
+ if real not in repo_pkgs and real not in installed_db \
2594
+ and not os.path.exists(os.path.abspath(n)):
2595
+ not_found.append(n)
2596
+ if not_found:
2597
+ print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2598
+ print(f" {_('not_found_hint')}")
2599
+ return 1
2600
+
2601
+ # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2602
+ # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2603
+ if upgrade:
2604
+ # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2605
+ # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2606
+ # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2607
+ # _version_newer.
2608
+ upgrade_targets = [
2609
+ name for name in package_names
2610
+ if name in repo_pkgs
2611
+ and name in installed_db
2612
+ and name not in pinned
2613
+ ]
2614
+ for name in upgrade_targets:
2615
+ if name not in to_install:
2616
+ to_install.append(name)
2617
+
2618
+ if not to_install and not missing_deps:
2619
+ print(f"✅ {_('all_installed')}"); return 0
2620
+
2621
+ # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2622
+ fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2623
+
2624
+ if fatal_missing > 0:
2625
+ print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2626
+ print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2627
+ return 1
2628
+
2629
+ so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2630
+ if so_missing > 0:
2631
+ print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2632
+ return 1
2633
+
2634
+ if not to_install:
2635
+ print(f"✅ {_('all_installed')}"); return 0
2636
+
2637
+ MAX_MB = MAX_PKG_SIZE // 1048576
2638
+ for n in to_install:
2639
+ if not _validate_pkg_name(n):
2640
+ print(f" {_("sec_badname", name=n)}")
2641
+ return 1
2642
+ sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2643
+ if sz > MAX_PKG_SIZE:
2644
+ mb = sz // 1048576
2645
+ print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2646
+ return 1
2647
+ total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2648
+ if not _preflight_disk(total_size):
2649
+ return 1
2650
+ print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2651
+ for name in to_install:
2652
+ p = repo_pkgs.get(name)
2653
+ if p:
2654
+ if name in installed_db:
2655
+ marker = " [upgrade]" if upgrade else ""
2656
+ else:
2657
+ marker = f" [{_('new')}]"
2658
+ print(f" {name}-{p.version}{marker}")
2659
+
2660
+ if not as_dep and not upgrade:
2661
+ if not _ask_confirm():
2662
+ print(_("cancelled")); return 0
2663
+
2664
+ snapshot = copy.deepcopy(installed_db)
2665
+ all_installed_files = []
2666
+ failed = []
2667
+ # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2668
+ stale_candidates = []
2669
+ # Katalog backupów nadpisywanych plików – dla poprawnego rollbacku.
2670
+ # Dla KAŻDEJ transakcji: instalacja „nowego” pakietu także nadpisuje pliki
2671
+ # spoza bazy pag (baza rootfs/ISO), a rollback bez backupu by je skasował.
2672
+ backup_root = ""
2673
+ all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2674
+ if to_install:
2675
+ backup_root = _new_transaction_backup_root()
2676
+
2677
+ # --- Dziennik transakcji (dla pełnej atomowości) ---
2678
+ # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2679
+ # w tej transakcji przez _rollback_transaction().
2680
+ transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2681
+
2682
+ # --- Tryb immutable: utwórz nowy deployment ---
2683
+ immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2684
+ deploy_dir = ""
2685
+ deploy_id = ""
2686
+ if immutable:
2687
+ print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2688
+ deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2689
+ target_root = deploy_dir
2690
+ else:
2691
+ target_root = ""
2692
+
2693
+ # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2694
+ to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2695
+ if len(to_download) > 1:
2696
+ print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2697
+ downloaded = _download_packages_parallel(to_download)
2698
+ else:
2699
+ downloaded = {}
2700
+
2701
+ # --- Faza 2: Instalacja – JEDNA nadpisywana linia postępu (jak przy
2702
+ # pobieraniu), bez ściany tekstu na każdy pakiet. W trybie
2703
+ # nieinteraktywnym (logi, netinstall instalatora) wypisujemy linię na
2704
+ # pakiet – tam to pożądane do logu.
2705
+ t0 = time.time()
2706
+ stderr_tty = sys.stderr.isatty()
2707
+ stdout_tty = sys.stdout.isatty()
2708
+ _bar_last = 0
2709
+
2710
+ def _bar_draw(idx: int, name: str) -> None:
2711
+ nonlocal _bar_last
2712
+ n = len(to_install)
2713
+ pct = (idx - 1) / n * 100.0
2714
+ fl = int(25 * pct / 100)
2715
+ pbar = "█" * fl + "░" * (25 - fl)
2716
+ eta_s = ""
2717
+ if idx > 1:
2718
+ avg = (time.time() - t0) / (idx - 1)
2719
+ rem = avg * (n - idx + 1)
2720
+ eta_s = f" ~{rem:.0f}s" if rem < 60 else f" ~{rem/60:.1f}m"
2721
+ line = f" 📦 [{pbar}] {idx}/{n} ({pct:.0f}%) {name}{eta_s}"
2722
+ if stderr_tty:
2723
+ clear = " " * max(0, _bar_last - len(line))
2724
+ sys.stderr.write(f"\r{line}{clear}")
2725
+ sys.stderr.flush()
2726
+ _bar_last = len(line)
2727
+ else:
2728
+ print(line, file=sys.stderr, flush=True)
2729
+
2730
+ def _bar_end() -> None:
2731
+ nonlocal _bar_last
2732
+ if stderr_tty and _bar_last:
2733
+ sys.stderr.write("\r" + " " * _bar_last + "\r")
2734
+ sys.stderr.flush()
2735
+ _bar_last = 0
2736
+
2737
+ _pkg_i = 0
2738
+ for name in to_install:
2739
+ pkg = repo_pkgs.get(name)
2740
+ if not pkg:
2741
+ _bar_end()
2742
+ print(f" ❌ {name}: {_('not_found')}")
2743
+ failed.append(name)
2744
+ break
2745
+
2746
+ _pkg_i += 1
2747
+ _bar_draw(_pkg_i, f"{name}-{pkg.version}")
2748
+
2749
+ # Pobierz (z cache fazy 1 lub bezpośrednio)
2750
+ pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2751
+ if not pkg_path:
2752
+ _bar_end()
2753
+ print(f" ❌ {name}: {_('download_fail')}")
2754
+ failed.append(name)
2755
+ break # przerwij transakcję
2756
+
2757
+ # GPG
2758
+ gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2759
+ if not gpg_ok:
2760
+ _bar_end()
2761
+ print(f" ❌ {name}: {_('gpg_fail')}: {gpg_msg[:60]}")
2762
+ failed.append(name)
2763
+ break # PRZERWIJ – niezaufany pakiet
2764
+
2765
+ # SHA256 całego pakietu
2766
+ if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2767
+ _bar_end()
2768
+ print(f" ❌ {name}: {_('sha256_mismatch')}")
2769
+ failed.append(name)
2770
+ break # PRZERWIJ – uszkodzony pakiet
2771
+
2772
+ # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2773
+ old_files = _db_get_package_files(name) if name in installed_db else []
2774
+ # Stare sumy SHA256 – do wykrycia zmian użytkownika w plikach /etc (.pacnew)
2775
+ old_checksums = _db_get_package_checksums(name) if name in installed_db else None
2776
+
2777
+ # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2778
+ ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2779
+ backup_dir=backup_root,
2780
+ old_checksums=old_checksums)
2781
+ if ok:
2782
+ installed_db[name] = {
2783
+ "version": pkg.version, "release": pkg.release, "description": pkg.description,
2784
+ "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2785
+ "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2786
+ "repo": pkg.repo_url,
2787
+ "provides": getattr(pkg, "provides", None) or [],
2788
+ "provides_so": getattr(pkg, "provides_so", None) or [],
2789
+ "requires_so": getattr(pkg, "requires_so", None) or [],
2790
+ }
2791
+ if not as_dep and name in package_names:
2792
+ world.add(name)
2793
+ if not stdout_tty:
2794
+ print(f" ✓ {name}-{pkg.version}", flush=True)
2795
+ all_installed_files.extend(f["path"] for f in files)
2796
+ all_backups.extend(backup_j)
2797
+
2798
+ # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2799
+ # których nie ma już w nowej wersji.
2800
+ if upgrade and old_files:
2801
+ stale_candidates.append((name, old_files, [f["path"] for f in files]))
2802
+
2803
+ # Po instalacji kernela – przebuduj initramfs
2804
+ if _is_kernel_package(name):
2805
+ _rebuild_initramfs(deploy_dir)
2806
+ else:
2807
+ _bar_end()
2808
+ print(f" ❌ {name}: {_('install_failed')}")
2809
+ failed.append(name)
2810
+ break # PRZERWIJ – błąd instalacji
2811
+
2812
+ # Deduplikacja journalu – ten sam plik może trafić tu dwukrotnie (np. przez
2813
+ # `provides`); rollback i historia nie potrzebują duplikatów.
2814
+ all_installed_files = list(dict.fromkeys(all_installed_files))
2815
+
2816
+ # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2817
+ if failed:
2818
+ _bar_end()
2819
+ print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2820
+ _rollback_transaction(installed_db, snapshot, all_installed_files,
2821
+ deploy_dir, immutable, backups=all_backups)
2822
+ if backup_root:
2823
+ shutil.rmtree(backup_root, ignore_errors=True)
2824
+ _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2825
+ return 1
2826
+
2827
+ # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2828
+ # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2829
+ for pkg_name, old_files, new_paths in stale_candidates:
2830
+ _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2831
+ backup_root, all_backups)
2832
+
2833
+ save_json(INSTALLED_DB, installed_db)
2834
+ save_world(world)
2835
+ _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2836
+ file_journal=all_installed_files,
2837
+ upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2838
+ upgrade_backup_root=backup_root)
2839
+
2840
+ # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2841
+ if backup_root:
2842
+ _purge_old_backups(keep_root=backup_root)
2843
+
2844
+ _bar_end()
2845
+
2846
+ # --- Tryb immutable: przełącz na nowy deployment ---
2847
+ if immutable and not failed:
2848
+ _refresh_dynamic_linker_cache(deploy_dir)
2849
+ print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2850
+ _switch_deployment(deploy_dir)
2851
+ print(f" ✅ Aktywny deployment: {deploy_id}")
2852
+ _update_grub_config()
2853
+ cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2854
+ print(f" 💡 Restart wymagany do przeładowania systemu.")
2855
+ else:
2856
+ _refresh_dynamic_linker_cache()
2857
+ # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2858
+ # trafiły do realnego systemu (nie do deploymentu).
2859
+ _process_triggers(all_installed_files)
2860
+
2861
+ print(f"\n✅ {_('installed', len(to_install))} ({(time.time()-t0):.0f}s)")
2862
+ return 0
2863
+
2864
+
2865
+def _rollback_transaction(installed_db: dict, snapshot: dict,
2866
+ installed_files: List[str],
2867
+ deploy_dir: str, is_immutable: bool,
2868
+ backups: Optional[List[Tuple[str, str]]] = None):
2869
+ """
2870
+ Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2871
+ Przywraca installed_db do stanu sprzed transakcji.
2872
+ Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2873
+ Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2874
+ """
2875
+ # Przywróć installed_db
2876
+ installed_db.clear()
2877
+ installed_db.update(snapshot)
2878
+
2879
+ root = deploy_dir if is_immutable else PAG_ROOT
2880
+ backup_map = {dst: src for src, dst in (backups or [])}
2881
+
2882
+ # Przywróć stare wersje z backupów (upgrade)
2883
+ for dst, bpath in backup_map.items():
2884
+ full = os.path.join(root, dst.lstrip("/"))
2885
+ if os.path.lexists(bpath):
2886
+ try:
2887
+ os.makedirs(os.path.dirname(full), exist_ok=True)
2888
+ os.replace(bpath, full)
2889
+ except OSError:
2890
+ pass
2891
+
2892
+ # Usuń nowe pliki (które nie miały poprzedniej wersji)
2893
+ for fpath in reversed(installed_files):
2894
+ if fpath in backup_map:
2895
+ continue
2896
+ full = os.path.join(root, fpath.lstrip("/"))
2897
+ if os.path.isfile(full) or os.path.islink(full):
2898
+ try:
2899
+ os.remove(full)
2900
+ except OSError:
2901
+ pass
2902
+
2903
+ # Wyczyść puste katalogi
2904
+ dirs_to_check = set()
2905
+ for fpath in installed_files:
2906
+ parent = os.path.dirname(fpath)
2907
+ while parent and parent != "/":
2908
+ dirs_to_check.add(parent)
2909
+ parent = os.path.dirname(parent)
2910
+ for d in sorted(dirs_to_check, key=len, reverse=True):
2911
+ full_d = os.path.join(root, d.lstrip("/"))
2912
+ if os.path.isdir(full_d):
2913
+ try:
2914
+ os.rmdir(full_d)
2915
+ except OSError:
2916
+ pass
2917
+
2918
+ # W trybie immutable: usuń nieudany deployment
2919
+ if is_immutable and deploy_dir:
2920
+ shutil.rmtree(deploy_dir, ignore_errors=True)
2921
+
2922
+ save_json(INSTALLED_DB, snapshot)
2923
+
2924
+
2925
+# =============================================================================
2926
+# USUWANIE
2927
+# =============================================================================
2928
+
2929
+def cmd_remove(package_names):
2930
+ installed_db = load_json(INSTALLED_DB)
2931
+ world = load_world()
2932
+ snapshot = copy.deepcopy(installed_db)
2933
+ removed = []
2934
+ removed_files = []
2935
+
2936
+ total = len(package_names)
2937
+ for i, name in enumerate(package_names, 1):
2938
+ if name not in installed_db:
2939
+ print(f" ⚠ {name}: not installed"); continue
2940
+
2941
+ # Pasek postępu
2942
+ pct = (i - 1) / total * 100
2943
+ filled = int(25 * pct / 100)
2944
+ print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2945
+
2946
+ print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2947
+
2948
+ # Pre-remove hook (jeśli dostępny w staging)
2949
+ _run_hook_for_installed(name, "pre-remove")
2950
+
2951
+ count, rm_files = _safe_remove_files(name, installed_db)
2952
+ del installed_db[name]
2953
+ world.discard(name)
2954
+ removed.append(name)
2955
+ removed_files.extend(rm_files)
2956
+ print(f"✅ ({count} files)")
2957
+
2958
+ # Post-remove hook + sprzątanie zapisanych hooków
2959
+ _run_hook_for_installed(name, "post-remove")
2960
+ shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2961
+
2962
+ save_json(INSTALLED_DB, installed_db)
2963
+ save_world(world)
2964
+ _record_transaction("remove", removed, True, snapshot)
2965
+
2966
+ print(file=sys.stderr) # wyczyść linię paska postępu
2967
+
2968
+ if not removed: return 0
2969
+ print(f"\n✅ Removed {len(removed)}.")
2970
+ _process_triggers(removed_files)
2971
+
2972
+ orphans = _find_orphans(installed_db, world)
2973
+ if orphans:
2974
+ print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2975
+ print(" 'pag remove-orphans' to clean up.")
2976
+ return 0
2977
+
2978
+def _run_hook_for_installed(pkg_name, hook_name):
2979
+ """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2980
+ hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2981
+ if os.path.isdir(hook_dir):
2982
+ ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2983
+ _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2984
+
2985
+
2986
+# =============================================================================
2987
+# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2988
+# =============================================================================
2989
+# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2990
+# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2991
+# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2992
+# każdym pakiecie z osobna.
2993
+
2994
+TRIGGERS_DIR = PAG_CONF + "/triggers"
2995
+
2996
+DEFAULT_TRIGGERS = [
2997
+ {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2998
+ "run": "fc-cache -fs"},
2999
+ {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
3000
+ "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
3001
+ {"name": "desktop-database", "paths": ["/usr/share/applications/"],
3002
+ "run": "update-desktop-database -q /usr/share/applications"},
3003
+ {"name": "mime-database", "paths": ["/usr/share/mime/"],
3004
+ "run": "update-mime-database /usr/share/mime"},
3005
+]
3006
+
3007
+def _load_triggers() -> List[dict]:
3008
+ """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
3009
+ out = []
3010
+ for t in DEFAULT_TRIGGERS:
3011
+ bin_name = t["run"].split()[0]
3012
+ if shutil.which(bin_name):
3013
+ out.append(dict(t))
3014
+ if os.path.isdir(TRIGGERS_DIR):
3015
+ for fn in sorted(os.listdir(TRIGGERS_DIR)):
3016
+ if not fn.endswith(".json"):
3017
+ continue
3018
+ try:
3019
+ with open(os.path.join(TRIGGERS_DIR, fn)) as f:
3020
+ data = json.load(f)
3021
+ except (OSError, json.JSONDecodeError):
3022
+ continue
3023
+ if isinstance(data, dict):
3024
+ data = [data]
3025
+ for t in data:
3026
+ if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
3027
+ out.append(t)
3028
+ return out
3029
+
3030
+def _process_triggers(touched_paths: List[str]):
3031
+ """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
3032
+ if not touched_paths:
3033
+ return
3034
+ if os.environ.get("PAG_NO_HOOKS", "") == "1":
3035
+ return
3036
+ import shlex as _shlex
3037
+ matched = []
3038
+ for trig in _load_triggers():
3039
+ if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
3040
+ matched.append(trig)
3041
+ for trig in matched:
3042
+ run = trig["run"]
3043
+ print(f" ⚡ Trigger: {trig['name']} ({run})")
3044
+ try:
3045
+ r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
3046
+ _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
3047
+ if r.returncode != 0:
3048
+ print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
3049
+ except subprocess.TimeoutExpired:
3050
+ print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
3051
+ _audit(f"TRIGGER {trig['name']} TIMEOUT")
3052
+ except Exception as e:
3053
+ print(f" ⚠ trigger {trig['name']}: {e}")
3054
+
3055
+# =============================================================================
3056
+# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
3057
+# =============================================================================
3058
+
3059
+def _cleanup_tmp_files(*paths):
3060
+ """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
3061
+ for p in paths:
3062
+ try:
3063
+ if os.path.isfile(p):
3064
+ os.remove(p)
3065
+ except OSError:
3066
+ pass
3067
+
3068
+
3069
+def cmd_self_update():
3070
+ """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
3071
+
3072
+ Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
3073
+ kontrola składni (compile) → backup → atomowe os.replace.
3074
+
3075
+ Podmieniamy plik, z którego pag ZOSTAŁ URUCHOMIONY (a nie hardkodowane
3076
+ /usr/local/bin/pag): gdy pakiet instaluje /usr/bin/pag, a self-update
3077
+ pisał do /usr/local/bin/pag, powstawały DWIE kopie o różnych wersjach
3078
+ i zależnie od PATH `pag --version` pokazywał raz jedną, raz drugą."""
3079
+ # Dedykowana blokada – patrz SelfUpdateLock. Chroni zapis dst.new oraz
3080
+ # os.replace przed równoległą aktualizacją (np. cron + ręcznie).
3081
+ with SelfUpdateLock():
3082
+ return _self_update_impl()
3083
+
3084
+
3085
+def _self_update_impl():
3086
+ repos = get_repos()
3087
+ if not repos:
3088
+ print("❌ Brak repozytoriów w konfiguracji.")
3089
+ return 1
3090
+ # Aktualizuj bieżący plik (Python ≥3.9 ustawia __file__ jako ścieżkę
3091
+ # bezwzględną). Poza /usr|/usr/local (np. uruchomienie z checkoutu)
3092
+ # nie nadpisujemy niczego – wracamy do domyślnej lokalizacji instalacji.
3093
+ dst = "/usr/local/bin/pag"
3094
+ try:
3095
+ cand = os.path.realpath(__file__)
3096
+ if cand.startswith(("/usr/", "/usr/local/")):
3097
+ dst = cand
3098
+ except Exception:
3099
+ pass
3100
+ base = repos[0]
3101
+ dst_new = dst + ".new"
3102
+ dst_bak = dst + ".bak"
3103
+ print(f"🔄 Sprawdzam aktualizację pag z {base}...")
3104
+ try:
3105
+ with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3106
+ data = r.read()
3107
+ with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3108
+ sig = r.read()
3109
+ except Exception as e:
3110
+ print(f" ❌ Nie można pobrać pag: {e}")
3111
+ return 1
3112
+
3113
+ # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
3114
+ with open(dst_new, "wb") as f:
3115
+ f.write(data)
3116
+ with open(dst_new + ".asc", "wb") as f:
3117
+ f.write(sig)
3118
+
3119
+ # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
3120
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
3121
+ ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
3122
+ if not ok:
3123
+ # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
3124
+ res = _gpg_run("--verify", dst_new + ".asc", dst_new,
3125
+ capture_output=True, text=True)
3126
+ _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
3127
+ if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
3128
+ try:
3129
+ with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
3130
+ keydata = r.read()
3131
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
3132
+ tmp.write(keydata); tmp.flush()
3133
+ _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
3134
+ os.unlink(tmp.name)
3135
+ print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
3136
+ ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
3137
+ except Exception:
3138
+ pass
3139
+ if not ok:
3140
+ if insecure:
3141
+ print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
3142
+ else:
3143
+ print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
3144
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
3145
+ return 1
3146
+ # Sprawdź fingerprint względem przypiętego klucza repo
3147
+ pinned = _repo_pinned_fp(base)
3148
+ if pinned:
3149
+ if not fp:
3150
+ print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
3151
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
3152
+ return 1
3153
+ if fp != pinned.upper():
3154
+ if insecure:
3155
+ print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
3156
+ else:
3157
+ print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
3158
+ print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
3159
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
3160
+ return 1
3161
+
3162
+ # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
3163
+ try:
3164
+ with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
3165
+ sha = r.read().decode().strip().split()[0]
3166
+ if sha:
3167
+ actual = hashlib.sha256(data).hexdigest()
3168
+ if actual.lower() != sha.lower():
3169
+ print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
3170
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
3171
+ return 1
3172
+ print(" ✅ SHA256 zgodny")
3173
+ except Exception:
3174
+ # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
3175
+ pass
3176
+
3177
+ # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
3178
+ try:
3179
+ compile(data, "pag", "exec")
3180
+ except SyntaxError as e:
3181
+ print(f" ❌ Błąd składni w nowym pag: {e}")
3182
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
3183
+ return 1
3184
+
3185
+ m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
3186
+ or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
3187
+ new_ver = m.group(1).decode() if m else "?"
3188
+ print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
3189
+
3190
+ # --- 4. Backup + atomowa podmiana ---
3191
+ if os.path.exists(dst):
3192
+ shutil.copy2(dst, dst_bak)
3193
+ os.chmod(dst_new, 0o755)
3194
+ os.replace(dst_new, dst) # atomowe na tym samym FS
3195
+ try:
3196
+ if os.path.exists(dst_new + ".asc"):
3197
+ os.remove(dst_new + ".asc")
3198
+ except OSError:
3199
+ pass
3200
+ print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
3201
+ # Ostrzeż, gdy w PATH pierwsza jest INNA kopia `pag` – inaczej
3202
+ # `pag --version` dalej pokaże starą wersję i wygląda to jak brak update.
3203
+ other = shutil.which("pag")
3204
+ if other and os.path.realpath(other) != os.path.realpath(dst):
3205
+ print(f" ⚠ W PATH pierwszy jest inny pag: {other} (zaktualizowano {dst})")
3206
+ print(f" Usuń starą kopię: sudo rm {other}")
3207
+ print(" Uruchom ponownie pag, aby użyć nowej wersji.")
3208
+ return 0
3209
+
3210
+
3211
+def _candidate_newer(rp, inst):
3212
+ """Czy pakiet z repo jest nowszy od zainstalowanego.
3213
+ Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
3214
+ po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
3215
+ widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
3216
+ traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
3217
+ reinstalacji/zmiany wersji."""
3218
+ rv = getattr(rp, "version", "0")
3219
+ iv = inst.get("version", "0")
3220
+ if _version_newer(rv, iv):
3221
+ return True
3222
+ if rv != iv:
3223
+ return False
3224
+ rr = int(getattr(rp, "release", 1) or 1)
3225
+ ir = int(inst.get("release", 1) or 1)
3226
+ return rr > ir
3227
+
3228
+
3229
+def _pending_updates() -> List[str]:
3230
+ """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
3231
+ installed = load_json(INSTALLED_DB)
3232
+ pinned = load_json(PINNED_FILE)
3233
+ repo = fetch_all_packages()
3234
+ if not repo:
3235
+ return []
3236
+ return [n for n, i in installed.items()
3237
+ if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
3238
+
3239
+def cmd_update(do_upgrade: bool = False):
3240
+ """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
3241
+
3242
+ sync → tylko odświeżenie indeksów + info: „jest X pakietów do
3243
+ zaktualizowania – wpisz: pag update".
3244
+ update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
3245
+ Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
3246
+ przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
3247
+ """
3248
+ force = True
3249
+ print("🔄 Refreshing indexes...")
3250
+ for repo_url in get_repos():
3251
+ pkgs = fetch_repo_index(repo_url, force=force)
3252
+ cp = _repo_cache_path(repo_url)
3253
+ has_sig = os.path.exists(cp + ".sig")
3254
+ print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
3255
+ print(f"✅ {_('indexes_refreshed')}")
3256
+
3257
+ # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
3258
+ try:
3259
+ for r in get_repos():
3260
+ cp = _repo_cache_path(r)
3261
+ if os.path.exists(cp):
3262
+ d = json.load(open(cp))
3263
+ rv = d.get("pag_version", "")
3264
+ if rv and rv != PAG_VERSION:
3265
+ print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
3266
+ except Exception:
3267
+ pass
3268
+
3269
+ # Raport: pakiety do aktualizacji
3270
+ pending = _pending_updates()
3271
+ if not pending:
3272
+ print(f"✅ {_('all_up_to_date')}")
3273
+ return 0
3274
+ print(f"{_('updates_available', len(pending))}")
3275
+ installed = load_json(INSTALLED_DB)
3276
+ repo = fetch_all_packages()
3277
+ for n in pending:
3278
+ print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
3279
+ if not do_upgrade:
3280
+ return 0 # sync: tylko informacja
3281
+ if not _ask_confirm():
3282
+ return 0
3283
+ return cmd_install(pending, upgrade=True)
3284
+
3285
+def _initramfs_stale() -> bool:
3286
+ """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
3287
+ try:
3288
+ kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
3289
+ if not kernels:
3290
+ return False
3291
+ newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
3292
+ initrd = "/boot/initramfs.img"
3293
+ return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
3294
+ except Exception:
3295
+ return False
3296
+
3297
+def cmd_upgrade():
3298
+ """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
3299
+ rc = cmd_update(do_upgrade=True)
3300
+ if rc != 0:
3301
+ return rc
3302
+ # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
3303
+ if _initramfs_stale():
3304
+ print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
3305
+ _rebuild_initramfs()
3306
+ try:
3307
+ if _load_deployments():
3308
+ _update_grub_config()
3309
+ except Exception:
3310
+ pass
3311
+ return 0
3312
+
3313
+def cmd_list(installed_only=False):
3314
+ if installed_only:
3315
+ db = load_json(INSTALLED_DB)
3316
+ pinned = load_json(PINNED_FILE)
3317
+ if not db: print("No packages installed."); return
3318
+ print(f"Installed ({len(db)}):")
3319
+ for n, i in sorted(db.items()):
3320
+ pin = " 📌" if n in pinned else ""
3321
+ print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
3322
+ else:
3323
+ pkgs = fetch_all_packages()
3324
+ installed = load_json(INSTALLED_DB)
3325
+ pinned = load_json(PINNED_FILE)
3326
+ print(f"Available ({len(pkgs)}):")
3327
+ for n, p in sorted(pkgs.items()):
3328
+ m = "✓" if n in installed else " "
3329
+ extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
3330
+ if n in pinned: extra += " 📌"
3331
+ print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
3332
+
3333
+def cmd_search(query):
3334
+ pkgs = fetch_all_packages()
3335
+ results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
3336
+ if not results: print(f"❌ No results for: {query}"); return
3337
+ installed = load_json(INSTALLED_DB)
3338
+ print(f"Results for '{query}' ({len(results)}):")
3339
+ for n,p in sorted(results):
3340
+ print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
3341
+ print(f" {p.description}")
3342
+
3343
+
3344
+def _smart_search(query: str) -> int:
3345
+ """
3346
+ Inteligentne wyszukiwanie: repo PaganOS + Flathub.
3347
+ Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
3348
+ Pokazuje dostępne źródła i sugeruje komendy instalacji.
3349
+ """
3350
+ # 1. Repo PaganOS
3351
+ try:
3352
+ pkgs = fetch_all_packages()
3353
+ except Exception:
3354
+ pkgs = {}
3355
+ repo_lower = [(n, p) for n, p in pkgs.items()
3356
+ if query.lower() in n.lower() or query.lower() in p.description.lower()]
3357
+
3358
+ # 2. Flathub (jeśli dostępny)
3359
+ flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
3360
+
3361
+ if not repo_lower and not flat:
3362
+ print(f"\n ❌ '{query}' — nie znaleziono.")
3363
+ print(f" Repo PaganOS: pag search {query}")
3364
+ if _check_flatpak(quiet=True):
3365
+ print(f" Flathub: pag flatpak search {query}")
3366
+ print(f" Dodaj repo: pag repo-add <url>")
3367
+ return 1
3368
+
3369
+ installed = load_json(INSTALLED_DB)
3370
+
3371
+ # ── Repo PaganOS ──
3372
+ if repo_lower:
3373
+ exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
3374
+ show = (exact or repo_lower)[:6]
3375
+ print(f"\n 📦 PaganOS — '{query}':")
3376
+ for n, p in sorted(show):
3377
+ mark = "✓" if n in installed else " "
3378
+ desc = p.description[:70] if len(p.description) > 75 else p.description
3379
+ print(f" [{mark}] {n}-{p.version}")
3380
+ if desc:
3381
+ print(f" {desc}")
3382
+ if len(repo_lower) > 6:
3383
+ print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
3384
+
3385
+ # ── Flathub ──
3386
+ if flat:
3387
+ print(f"\n 📦 Flathub — '{query}':")
3388
+ for r in flat[:5]:
3389
+ mark = "✓" if r.get("installed") else " "
3390
+ name = r.get("name") or r.get("application", "?")
3391
+ desc = (r.get("description") or "")[:65]
3392
+ print(f" [{mark}] {name}")
3393
+ if desc:
3394
+ print(f" {desc}")
3395
+ if len(flat) > 5:
3396
+ print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3397
+
3398
+ # ── Sugestie instalacji ──
3399
+ print()
3400
+ if repo_lower:
3401
+ best = sorted(repo_lower, key=lambda x: (x[0].lower() != query.lower(), -len(x[1].name if hasattr(x[1], 'name') else 0)))[0][0]
3402
+ if best in installed:
3403
+ print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3404
+ else:
3405
+ print(f" 💡 sudo pag install {best}")
3406
+ if flat:
3407
+ best_fp = flat[0].get("application") or flat[0].get("name", query)
3408
+ print(f" 💡 pag flatpak install {best_fp}")
3409
+
3410
+ return 0
3411
+
3412
+def cmd_info(name):
3413
+ pkgs = fetch_all_packages()
3414
+ p = pkgs.get(name)
3415
+ info = load_json(INSTALLED_DB).get(name)
3416
+ if not p and not info: print(f"❌ '{name}' not found."); return 1
3417
+ print(f"📦 {name}")
3418
+ if p:
3419
+ print(f" Version (repo): {p.version}")
3420
+ print(f" Description: {p.description}")
3421
+ print(f" Size: {p.size_bytes/1048576:.1f} MB")
3422
+ print(f" SHA256: {p.sha256[:32]}...")
3423
+ print(f" GPG: {p.gpg_fp or 'none'}")
3424
+ print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3425
+ if info:
3426
+ print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3427
+
3428
+def cmd_files(name):
3429
+ if name not in load_json(INSTALLED_DB):
3430
+ print(f"❌ '{name}' not installed."); return 1
3431
+ files = _db_get_package_files(name)
3432
+ print(f"Files in {name} ({len(files)}):")
3433
+ for f in sorted(files): print(f" {f}")
3434
+
3435
+def cmd_verify(deep=False):
3436
+ installed = load_json(INSTALLED_DB)
3437
+ if not installed: print("Nothing to verify."); return
3438
+ errors = []
3439
+
3440
+ # Wczytaj WSZYSTKIE sumy RAZ (nie w pętli!) – przy 50k plików wywołanie
3441
+ # _db_get_all_file_checksums() per-plik dawało O(n²) i godziny zamiast sekund.
3442
+ all_checksums = _db_get_all_file_checksums() if deep else {}
3443
+ for name in installed:
3444
+ for fpath in _db_get_package_files(name):
3445
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3446
+ if not (os.path.exists(full) or os.path.islink(full)):
3447
+ errors.append(f" ❌ {name}: missing {fpath}")
3448
+ elif deep:
3449
+ expected = all_checksums.get(fpath, "")
3450
+ if expected:
3451
+ actual = _sha256_file(full)
3452
+ if actual != expected:
3453
+ errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3454
+
3455
+ if errors:
3456
+ print(f"❌ {_('verify_errors', len(errors))}")
3457
+ for e in errors[:50]: print(e)
3458
+ return 1
3459
+ total = _db_count_files()
3460
+ print(f"✅ {_('verify_ok', total)}")
3461
+
3462
+# =============================================================================
3463
+# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3464
+# =============================================================================
3465
+
3466
+def cmd_pin(name, version=""):
3467
+ pinned = load_json(PINNED_FILE)
3468
+ if version:
3469
+ pinned[name] = version
3470
+ else:
3471
+ info = load_json(INSTALLED_DB).get(name, {})
3472
+ pinned[name] = info.get("version", "?")
3473
+ save_json(PINNED_FILE, pinned)
3474
+ print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3475
+
3476
+def cmd_unpin(name):
3477
+ pinned = load_json(PINNED_FILE)
3478
+ if name in pinned:
3479
+ del pinned[name]; save_json(PINNED_FILE, pinned)
3480
+ print(f"🔓 {name} {_('unpinned')}")
3481
+ else:
3482
+ print(f"⚠ {name} {_('not_pinned')}")
3483
+
3484
+def cmd_pinned():
3485
+ pinned = load_json(PINNED_FILE)
3486
+ if not pinned: print(_("no_pinned")); return
3487
+ print(_("pinned_list", len(pinned)))
3488
+ for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3489
+
3490
+def cmd_clean():
3491
+ if os.path.isdir(PAG_CACHE):
3492
+ count = size = 0
3493
+ for f in os.listdir(PAG_CACHE):
3494
+ fp = os.path.join(PAG_CACHE, f)
3495
+ if os.path.isfile(fp):
3496
+ size += os.path.getsize(fp); os.remove(fp); count += 1
3497
+ print(f"✅ {_('cache_cleared', count, size/1048576)}")
3498
+
3499
+def cmd_remove_orphans():
3500
+ installed = load_json(INSTALLED_DB)
3501
+ world = load_world()
3502
+ orphans = _find_orphans(installed, world)
3503
+ if not orphans: print("✅ No orphans."); return
3504
+ print(f"Orphans ({len(orphans)}):")
3505
+ for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3506
+ if not _ask_confirm():
3507
+ return
3508
+ cmd_remove(list(orphans))
3509
+
3510
+
3511
+# =============================================================================
3512
+# PROVIDES – PAKIETY WIRTUALNE
3513
+# =============================================================================
3514
+
3515
+PROVIDES_MAP = {
3516
+ "pkgconfig(glib-2.0)": "glib",
3517
+ "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3518
+ "pkgconfig(gtk+-3.0)": "gtk",
3519
+ "pkgconfig(gtk4)": "gtk",
3520
+ "pkgconfig(zlib)": "zlib",
3521
+ "pkgconfig(libffi)": "libffi",
3522
+ "pkgconfig(expat)": "expat",
3523
+ "pkgconfig(libsystemd)": "systemd",
3524
+ "pkgconfig(dbus-1)": "dbus",
3525
+ "pkgconfig(mount)": "util-linux",
3526
+ "pkgconfig(blkid)": "util-linux",
3527
+ "pkgconfig(libcap)": "libcap",
3528
+ "pkgconfig(liblzma)": "xz",
3529
+ "pkgconfig(libzstd)": "zstd",
3530
+ "pkgconfig(bzip2)": "bzip2",
3531
+ "pkgconfig(libcurl)": "curl",
3532
+ "pkgconfig(openssl)": "openssl",
3533
+ "pkgconfig(libpcre2-8)": "pcre2",
3534
+ "pkgconfig(libxml-2.0)": "libxml2",
3535
+ "pkgconfig(libxslt)": "libxslt",
3536
+ "pkgconfig(freetype2)": "freetype",
3537
+ "pkgconfig(fontconfig)": "fontconfig",
3538
+ "pkgconfig(harfbuzz)": "harfbuzz",
3539
+ "pkgconfig(cairo)": "cairo",
3540
+ "pkgconfig(pango)": "pango",
3541
+ "pkgconfig(xt)": "xorg-libxt",
3542
+ "pkgconfig(xmu)": "xorg-libxmu",
3543
+ "pkgconfig(ice)": "xorg-libice",
3544
+ "pkgconfig(sm)": "xorg-libsm",
3545
+ "pkgconfig(x11)": "xorg-libx11",
3546
+ "pkgconfig(xext)": "xorg-libxext",
3547
+ "pkgconfig(xrandr)": "xorg-libxrandr",
3548
+ "pkgconfig(xfixes)": "xorg-libxfixes",
3549
+ "pkgconfig(xcursor)": "xorg-libxcursor",
3550
+ "pkgconfig(xinerama)": "xorg-libxinerama",
3551
+ "pkgconfig(xrender)": "xorg-libxrender",
3552
+ "pkgconfig(xau)": "xorg-libxau",
3553
+ "pkgconfig(xcb)": "xorg-libxcb",
3554
+ "pkgconfig(xdamage)": "xorg-libxdamage",
3555
+ "pkgconfig(xcomposite)": "xorg-libxcomposite",
3556
+ "pkgconfig(xft)": "xorg-libxft",
3557
+ "pkgconfig(xss)": "xorg-libxss",
3558
+ "pkgconfig(libsoup-3.0)": "libsoup3",
3559
+ "pkgconfig(libsoup-2.4)": "libsoup2",
3560
+ "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3561
+ "pkgconfig(libpng)": "libpng",
3562
+ "pkgconfig(libjpeg)": "libjpeg-turbo",
3563
+ "pkgconfig(libtiff-4)": "libtiff",
3564
+ "pkgconfig(ffi)": "libffi",
3565
+ # ── system / baza ──
3566
+ "pkgconfig(libcrypto)": "openssl",
3567
+ "pkgconfig(libssl)": "openssl",
3568
+ "pkgconfig(libudev)": "systemd",
3569
+ "pkgconfig(libmount)": "util-linux",
3570
+ "pkgconfig(libblkid)": "util-linux",
3571
+ "pkgconfig(uuid)": "util-linux",
3572
+ "pkgconfig(libexpat)": "expat",
3573
+ "pkgconfig(libpcre)": "pcre",
3574
+ "pkgconfig(ncursesw)": "ncurses",
3575
+ "pkgconfig(tinfo)": "ncurses",
3576
+ "pkgconfig(panel)": "ncurses",
3577
+ "pkgconfig(readline)": "readline",
3578
+ "pkgconfig(libseccomp)": "libseccomp",
3579
+ "pkgconfig(pam)": "linux-pam",
3580
+ "pkgconfig(libxcrypt)": "libxcrypt",
3581
+ "pkgconfig(libcrypt)": "libxcrypt",
3582
+ "pkgconfig(libnsl)": "libnsl",
3583
+ "pkgconfig(liblz4)": "lz4",
3584
+ "pkgconfig(libevent)": "libevent",
3585
+ "pkgconfig(libarchive)": "libarchive",
3586
+ "pkgconfig(sqlite3)": "sqlite",
3587
+ "pkgconfig(libpq)": "postgresql",
3588
+ "pkgconfig(mysqlclient)": "mariadb",
3589
+ "pkgconfig(json-c)": "json-c",
3590
+ "pkgconfig(json-glib-1.0)": "json-glib",
3591
+ "pkgconfig(libunistring)": "libunistring",
3592
+ "pkgconfig(libidn2)": "libidn2",
3593
+ "pkgconfig(libpsl)": "libpsl",
3594
+ "pkgconfig(icu-uc)": "icu",
3595
+ "pkgconfig(icu-i18n)": "icu",
3596
+ "pkgconfig(icu-io)": "icu",
3597
+ "pkgconfig(gnutls)": "gnutls",
3598
+ "pkgconfig(nettle)": "nettle",
3599
+ "pkgconfig(hogweed)": "nettle",
3600
+ "pkgconfig(libgcrypt)": "libgcrypt",
3601
+ "pkgconfig(libgpg-error)": "libgpg-error",
3602
+ "pkgconfig(libassuan)": "libassuan",
3603
+ "pkgconfig(libusb-1.0)": "libusb",
3604
+ "pkgconfig(libusb)": "libusb",
3605
+ "pkgconfig(libgudev-1.0)": "libgudev",
3606
+ "pkgconfig(gudev-1.0)": "libgudev",
3607
+ "pkgconfig(polkit-gobject-1)": "polkit",
3608
+ "pkgconfig(polkit-agent-1)": "polkit",
3609
+ "pkgconfig(libpciaccess)": "libpciaccess",
3610
+ "pkgconfig(pixman-1)": "pixman",
3611
+ "pkgconfig(libdrm)": "libdrm",
3612
+ "pkgconfig(libva)": "libva",
3613
+ "pkgconfig(libva-drm)": "libva",
3614
+ "pkgconfig(libva-x11)": "libva",
3615
+ "pkgconfig(libva-wayland)": "libva",
3616
+ "pkgconfig(vdpau)": "libvdpau",
3617
+ "pkgconfig(libvdpau)": "libvdpau",
3618
+ "pkgconfig(libinput)": "libinput",
3619
+ "pkgconfig(libevdev)": "libevdev",
3620
+ "pkgconfig(mtdev)": "mtdev",
3621
+ # ── grafika / GL / multimedia ──
3622
+ "pkgconfig(gbm)": "mesa",
3623
+ "pkgconfig(gl)": "libglvnd",
3624
+ "pkgconfig(egl)": "libglvnd",
3625
+ "pkgconfig(glesv2)": "libglvnd",
3626
+ "pkgconfig(glx)": "libglvnd",
3627
+ "pkgconfig(vulkan)": "vulkan-loader",
3628
+ "pkgconfig(libxkbcommon)": "libxkbcommon",
3629
+ "pkgconfig(xkbcommon)": "libxkbcommon",
3630
+ "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3631
+ "pkgconfig(xcb)": "xorg-libxcb",
3632
+ "pkgconfig(xcb-util)": "xcb-util",
3633
+ "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3634
+ "pkgconfig(xcb-icccm)": "xcb-util-wm",
3635
+ "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3636
+ "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3637
+ "pkgconfig(xcb-image)": "xcb-util-image",
3638
+ "pkgconfig(xcb-errors)": "xcb-util-errors",
3639
+ "pkgconfig(wayland-client)": "wayland",
3640
+ "pkgconfig(wayland-server)": "wayland",
3641
+ "pkgconfig(wayland-cursor)": "wayland",
3642
+ "pkgconfig(wayland-egl)": "wayland",
3643
+ "pkgconfig(wayland-protocols)": "wayland-protocols",
3644
+ "pkgconfig(gstreamer-1.0)": "gstreamer",
3645
+ "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3646
+ "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3647
+ "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3648
+ "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3649
+ "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3650
+ "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3651
+ "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3652
+ "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3653
+ "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3654
+ "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3655
+ "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3656
+ "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3657
+ "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3658
+ "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3659
+ "pkgconfig(libpulse)": "libpulse",
3660
+ "pkgconfig(libpulse-simple)": "libpulse",
3661
+ "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3662
+ "pkgconfig(alsa)": "alsa-lib",
3663
+ "pkgconfig(jack)": "jack2",
3664
+ "pkgconfig(libsamplerate)": "libsamplerate",
3665
+ "pkgconfig(sndfile)": "libsndfile",
3666
+ "pkgconfig(libavcodec)": "ffmpeg",
3667
+ "pkgconfig(libavformat)": "ffmpeg",
3668
+ "pkgconfig(libavutil)": "ffmpeg",
3669
+ "pkgconfig(libavfilter)": "ffmpeg",
3670
+ "pkgconfig(libswscale)": "ffmpeg",
3671
+ "pkgconfig(libswresample)": "ffmpeg",
3672
+ "pkgconfig(libpostproc)": "ffmpeg",
3673
+ "pkgconfig(SDL2)": "sdl2",
3674
+ "pkgconfig(SDL)": "sdl",
3675
+ "pkgconfig(SDL2_image)": "sdl2-image",
3676
+ "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3677
+ "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3678
+ "pkgconfig(SDL2_net)": "sdl2-net",
3679
+ "pkgconfig(libpng16)": "libpng",
3680
+ "pkgconfig(libwebp)": "libwebp",
3681
+ "pkgconfig(libwebpmux)": "libwebp",
3682
+ "pkgconfig(libwebpdemux)": "libwebp",
3683
+ "pkgconfig(libopenjp2)": "openjpeg2",
3684
+ "pkgconfig(lcms2)": "lcms2",
3685
+ "pkgconfig(libheif)": "libheif",
3686
+ "pkgconfig(libde265)": "libde265",
3687
+ "pkgconfig(x264)": "x264",
3688
+ "pkgconfig(x265)": "x265",
3689
+ # ── glib / gio ──
3690
+ "pkgconfig(gio-unix-2.0)": "glib",
3691
+ "pkgconfig(gmodule-2.0)": "glib",
3692
+ "pkgconfig(gthread-2.0)": "glib",
3693
+ "pkgconfig(girepository-2.0)": "gobject-introspection",
3694
+ "pkgconfig(girepository-1.0)": "gobject-introspection",
3695
+ "pkgconfig(libglib-2.0)": "glib",
3696
+ "pkgconfig(libgobject-2.0)": "glib",
3697
+}
3698
+
3699
+# Ostrzeżenia o wielu dostawcach tej samej wirtualnej nazwy – raz na proces.
3700
+_PROVIDES_WARNED = set()
3701
+# Cache indeksu provides dla danego obiektu repo: (repo_obj, {virtual: [pkg,...]}).
3702
+# Trzymamy referencję do repo, by uniknąć pomyłki przy ponownym użyciu id().
3703
+_provides_cache = (None, {})
3704
+
3705
+def _provides_index(repo: dict) -> dict:
3706
+ """Buduje (i cache’uje) mapę wirtualna nazwa → lista dostawców w repo.
3707
+
3708
+ Pozwala wybrać dostawcę DETERMINISTYCZNIE (posortowanego) zamiast zależeć
3709
+ od kolejności wstawiania w repo.json, oraz ostrzec o konflikcie provides."""
3710
+ global _provides_cache
3711
+ cached_repo, idx = _provides_cache
3712
+ if cached_repo is repo:
3713
+ return idx
3714
+ idx = {}
3715
+ for _pn, _p in repo.items():
3716
+ for _prov in (getattr(_p, "provides", None) or []):
3717
+ idx.setdefault(_prov, []).append(_pn)
3718
+ for _prov, _pns in idx.items():
3719
+ if len(_pns) > 1 and _prov not in _PROVIDES_WARNED:
3720
+ _PROVIDES_WARNED.add(_prov)
3721
+ _sorted = sorted(_pns)
3722
+ print(f" ⚠ {_('provides_conflict', name=_prov, providers=', '.join(_sorted), chosen=_sorted[0])}",
3723
+ file=sys.stderr)
3724
+ _provides_cache = (repo, idx)
3725
+ return idx
3726
+
3727
+
3728
+def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3729
+ """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3730
+
3731
+ Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3732
+ provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3733
+ dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3734
+ """
3735
+ if name in repo:
3736
+ return name
3737
+ if name in PROVIDES_MAP:
3738
+ real = PROVIDES_MAP[name]
3739
+ if real in repo:
3740
+ return real
3741
+ # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3742
+ if name.startswith("pkgconfig(Qt5"):
3743
+ real = "qt5"
3744
+ if real in repo:
3745
+ return real
3746
+ if name.startswith("pkgconfig(Qt6"):
3747
+ real = "qt6"
3748
+ if real in repo:
3749
+ return real
3750
+ if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3751
+ real = "gstreamer"
3752
+ if real in repo:
3753
+ return real
3754
+ if name.startswith("pkgconfig(gst-"):
3755
+ real = "gst-plugins-base"
3756
+ if real in repo:
3757
+ return real
3758
+ # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3759
+ # Determinizm: przy wielu dostawcach wybieramy posortowanego pierwszego
3760
+ # (i ostrzegamy raz), zamiast zależeć od kolejności w repo.json.
3761
+ _idx = _provides_index(repo)
3762
+ if name in _idx:
3763
+ return min(_idx[name])
3764
+ # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3765
+ # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3766
+ if installed:
3767
+ _inst_cands = [pn for pn, meta in installed.items()
3768
+ if isinstance(meta, dict) and name in (meta.get("provides") or [])]
3769
+ if _inst_cands:
3770
+ return min(_inst_cands)
3771
+ clean = name
3772
+ if name.startswith("pkgconfig(") and ")" in name:
3773
+ clean = name.split("(", 1)[1].rstrip(")")
3774
+ elif name.startswith("pkgconfig32(") and ")" in name:
3775
+ clean = name.split("(", 1)[1].rstrip(")")
3776
+ if clean != name and clean in repo:
3777
+ return clean
3778
+ return name
3779
+
3780
+
3781
+def cmd_why(pkg_name: str):
3782
+ """Pokazuje dlaczego pakiet jest zainstalowany."""
3783
+ installed = load_json(INSTALLED_DB)
3784
+ world = load_world()
3785
+ if pkg_name not in installed:
3786
+ print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3787
+ if pkg_name in world:
3788
+ print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3789
+ return 0
3790
+ parents = set()
3791
+ for w in world:
3792
+ _find_dep_path(w, pkg_name, installed, set(), [], parents)
3793
+ if parents:
3794
+ for pp in sorted(parents):
3795
+ print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3796
+ else:
3797
+ print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3798
+ return 0
3799
+
3800
+
3801
+def _find_dep_path(cur, target, installed, visited, path, results):
3802
+ if cur in visited: return
3803
+ visited.add(cur); path.append(cur)
3804
+ if cur == target:
3805
+ results.add(tuple(path))
3806
+ else:
3807
+ for dep in installed.get(cur, {}).get("dependencies", []):
3808
+ _find_dep_path(dep, target, installed, visited, path, results)
3809
+ path.pop(); visited.discard(cur)
3810
+
3811
+
3812
+def cmd_autoremove():
3813
+ """Automatycznie usuwa osierocone zależności bez pytania."""
3814
+ installed = load_json(INSTALLED_DB)
3815
+ world = load_world()
3816
+ orphans = _find_orphans(installed, world)
3817
+ if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3818
+ print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3819
+ return cmd_remove(list(orphans))
3820
+
3821
+
3822
+def cmd_download(package_names):
3823
+ """Pobiera pakiety do cache bez instalowania."""
3824
+ ensure_dirs()
3825
+ repo = fetch_all_packages()
3826
+ if not repo: print(f"❌ {_('no_index')}"); return 1
3827
+ total_size = 0; downloaded = []
3828
+ for name in package_names:
3829
+ pkg = repo.get(name)
3830
+ if not pkg:
3831
+ print(f" ❌ {name}: {_('not_found')}"); continue
3832
+ print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3833
+ path = _download_pkg(pkg)
3834
+ if path:
3835
+ total_size += os.path.getsize(path)
3836
+ downloaded.append(name)
3837
+ print(_c("green", "✓"))
3838
+ else:
3839
+ print(_c("red", "✗"))
3840
+ if downloaded:
3841
+ print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3842
+ return 0 if len(downloaded) == len(package_names) else 1
3843
+
3844
+
3845
+def cmd_stats():
3846
+ """Wyświetla statystyki PAG."""
3847
+ installed = load_json(INSTALLED_DB)
3848
+ history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3849
+ total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3850
+ total_files = _db_count_files()
3851
+ cache_size = sum(
3852
+ os.path.getsize(os.path.join(PAG_CACHE, f))
3853
+ for f in os.listdir(PAG_CACHE)
3854
+ if os.path.isfile(os.path.join(PAG_CACHE, f))
3855
+ ) if os.path.isdir(PAG_CACHE) else 0
3856
+ last_update = "never"
3857
+ for e in reversed(history):
3858
+ if e.get("action") in ("install", "upgrade") and e.get("success"):
3859
+ last_update = e.get("timestamp", "?")[:19]; break
3860
+ print(f"\n {_c('bold', _('stats_title'))}")
3861
+ print(f" {'─' * 40}")
3862
+ print(f" {_('stats_packages'):<30} {len(installed)}")
3863
+ print(f" {_('stats_files'):<30} {total_files}")
3864
+ print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3865
+ print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3866
+ print(f" {_('stats_history'):<30} {len(history)}")
3867
+ print(f" {_('stats_last_update'):<30} {last_update}")
3868
+ by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3869
+ if by_size:
3870
+ print(f"\n {_c('dim', 'Top 5:')}")
3871
+ for n, i in by_size:
3872
+ print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3873
+ return 0
3874
+
3875
+
3876
+def cmd_repo_add(url, name=None):
3877
+ if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3878
+ print(f" {_('sec_https')}"); return 1
3879
+ ensure_dirs()
3880
+ url = url.rstrip("/")
3881
+ repos = get_repos()
3882
+ if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3883
+ if name:
3884
+ # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3885
+ os.makedirs(REPOS_DIR, exist_ok=True)
3886
+ target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3887
+ with open(target, "w") as f: f.write(f"{url}\n")
3888
+ print(f"✅ {_('repo_added', url)} → {target}")
3889
+ return
3890
+ with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3891
+ print(f"✅ {_('repo_added', url)}")
3892
+
3893
+def cmd_repo_list():
3894
+ for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3895
+
3896
+FLATPAK_REMOTE_URL = "https://flathub.org/repo/flathub.flatpakrepo"
3897
+
3898
+def _check_flatpak(quiet: bool = False):
3899
+ if not shutil.which("flatpak"):
3900
+ if not quiet:
3901
+ print(f"❌ {_('flatpak_missing')}")
3902
+ return False
3903
+ r = subprocess.run(["flatpak", "remotes"], capture_output=True, text=True)
3904
+ if "flathub" not in r.stdout:
3905
+ print(f"⚠ {_('flatpak_adding')}")
3906
+ # Jako root dodajemy remote systemowy; bez roota – w instalacji użytkownika,
3907
+ # żeby remote i miejsce instalacji były spójne (patrz _flatpak_do_install).
3908
+ add = ["flatpak", "remote-add", "--if-not-exists"]
3909
+ if os.geteuid() != 0:
3910
+ add.append("--user")
3911
+ subprocess.run(add + ["flathub", FLATPAK_REMOTE_URL], check=False)
3912
+ return True
3913
+
3914
+def _spinner(msg: str):
3915
+ """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3916
+ stop = threading.Event()
3917
+ def _spin():
3918
+ for c in itertools.cycle("|/-\\"):
3919
+ if stop.is_set():
3920
+ break
3921
+ sys.stdout.write(f"\r {msg} {c}")
3922
+ sys.stdout.flush()
3923
+ time.sleep(0.1)
3924
+ t = threading.Thread(target=_spin, daemon=True)
3925
+ t.start()
3926
+ def _stop():
3927
+ stop.set()
3928
+ t.join(timeout=0.3)
3929
+ sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3930
+ sys.stdout.flush()
3931
+ return _stop
3932
+
3933
+
3934
+def _flatpak_search_raw(query: str) -> List[dict]:
3935
+ """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3936
+ if not _check_flatpak():
3937
+ return []
3938
+ stop = _spinner("Szukam we Flathub...")
3939
+ try:
3940
+ try:
3941
+ r = subprocess.run(
3942
+ ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3943
+ capture_output=True, text=True, timeout=120
3944
+ )
3945
+ finally:
3946
+ stop()
3947
+ if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3948
+ print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3949
+ results = []
3950
+ for line in r.stdout.strip().split("\n"):
3951
+ parts = line.split("\t")
3952
+ if len(parts) >= 3:
3953
+ results.append({
3954
+ "name": parts[0].strip(),
3955
+ "description": parts[1].strip() if len(parts) > 1 else "",
3956
+ "app_id": parts[2].strip() if len(parts) > 2 else "",
3957
+ "version": parts[3].strip() if len(parts) > 3 else "",
3958
+ "branch": parts[4].strip() if len(parts) > 4 else "stable",
3959
+ "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3960
+ })
3961
+ return results
3962
+ except Exception as e:
3963
+ print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3964
+ return []
3965
+
3966
+def _flatpak_find_best(query: str) -> Optional[dict]:
3967
+ """
3968
+ Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3969
+ - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3970
+ - Jeśli query pasuje do nazwy → zwraca pierwsze
3971
+ - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3972
+ - Jeśli brak → zwraca None
3973
+ """
3974
+ results = _flatpak_search_raw(query)
3975
+ if not results:
3976
+ return None
3977
+
3978
+ # Dokładne dopasowanie app_id
3979
+ exact = [r for r in results if r["app_id"].lower() == query.lower()]
3980
+ if exact:
3981
+ return exact[0]
3982
+
3983
+ # Dokładne dopasowanie nazwy
3984
+ exact_name = [r for r in results if r["name"].lower() == query.lower()]
3985
+ if exact_name:
3986
+ return exact_name[0]
3987
+
3988
+ # Jednoznaczne dopasowanie (tylko 1 wynik)
3989
+ if len(results) == 1:
3990
+ return results[0]
3991
+
3992
+ # Wiele wyników – pokaż użytkownikowi
3993
+ print(f"\n {_('flatpak_found', len(results))}")
3994
+ for i, r in enumerate(results):
3995
+ print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3996
+ if r["version"]:
3997
+ print(f" {_('flatpak_info_version')}: {r['version']}")
3998
+ if r["description"]:
3999
+ desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
4000
+ print(f" {desc}")
4001
+
4002
+ try:
4003
+ choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
4004
+ if not choice:
4005
+ return None
4006
+ idx = int(choice) - 1
4007
+ if 0 <= idx < len(results):
4008
+ return results[idx]
4009
+ except (EOFError, ValueError, IndexError):
4010
+ pass
4011
+ return None
4012
+
4013
+def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
4014
+ """Zwraca info o zainstalowanym flatpaku lub None."""
4015
+ try:
4016
+ r = subprocess.run(
4017
+ ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
4018
+ capture_output=True, text=True, timeout=10
4019
+ )
4020
+ if r.returncode != 0:
4021
+ return None
4022
+ parts = r.stdout.strip().split("\t")
4023
+ if len(parts) < 3:
4024
+ return None
4025
+ return {
4026
+ "name": parts[0].strip(),
4027
+ "version": parts[1].strip() if len(parts) > 1 else "",
4028
+ "branch": parts[2].strip() if len(parts) > 2 else "",
4029
+ "origin": parts[3].strip() if len(parts) > 3 else "",
4030
+ "size": parts[4].strip() if len(parts) > 4 else "",
4031
+ "description": parts[5].strip() if len(parts) > 5 else "",
4032
+ }
4033
+ except Exception:
4034
+ return None
4035
+
4036
+def _flatpak_is_installed(app_id: str) -> bool:
4037
+ """Sprawdza czy flatpak o danym ID jest zainstalowany."""
4038
+ try:
4039
+ r = subprocess.run(
4040
+ ["flatpak", "info", app_id],
4041
+ capture_output=True, text=True, timeout=10
4042
+ )
4043
+ return r.returncode == 0
4044
+ except Exception:
4045
+ return False
4046
+
4047
+# =============================================================================
4048
+# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
4049
+# =============================================================================
4050
+# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
4051
+# pag flatpak search <query> → tylko szuka
4052
+# pag flatpak install <query> → instaluje
4053
+# pag flatpak remove <id> → usuwa
4054
+# pag flatpak list → lista zainstalowanych
4055
+# pag flatpak update → aktualizuje wszystkie
4056
+# pag flatpak info <id> → szczegóły flatpaka
4057
+
4058
+def cmd_flatpak(args: list):
4059
+ """
4060
+ Główna komenda flatpak – inteligentnie rozpoznaje intencję:
4061
+ pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
4062
+ pag flatpak search firefox → tylko wyszukiwanie
4063
+ pag flatpak install ... → bezpośrednia instalacja
4064
+ pag flatpak remove ... → odinstalowanie
4065
+ pag flatpak list → lista
4066
+ pag flatpak update → aktualizacja
4067
+ pag flatpak info ... → szczegóły
4068
+ """
4069
+ if not _check_flatpak():
4070
+ return 1
4071
+
4072
+ if not args:
4073
+ # Bez argumentów – domyślnie lista
4074
+ return cmd_flatpak_list()
4075
+
4076
+ subcmd = args[0].lower()
4077
+ rest = args[1:]
4078
+
4079
+ # ── Podkomendy jawne ────────────────────────────────────────────────
4080
+ if subcmd == "search":
4081
+ if not rest:
4082
+ print(_("flatpak_usage")); return 1
4083
+ return cmd_flatpak_search(" ".join(rest))
4084
+
4085
+ elif subcmd == "install":
4086
+ if not rest:
4087
+ print(_("flatpak_usage")); return 1
4088
+ return _flatpak_smart_install(rest)
4089
+
4090
+ elif subcmd == "remove" or subcmd == "uninstall":
4091
+ if not rest:
4092
+ print(_("flatpak_usage")); return 1
4093
+ return _flatpak_smart_remove(rest)
4094
+
4095
+ elif subcmd == "list":
4096
+ return cmd_flatpak_list()
4097
+
4098
+ elif subcmd == "update":
4099
+ return cmd_flatpak_update()
4100
+
4101
+ elif subcmd == "info":
4102
+ if not rest:
4103
+ print(_("flatpak_usage")); return 1
4104
+ return cmd_flatpak_info(rest[0])
4105
+
4106
+ else:
4107
+ # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
4108
+ # Sprawdź czy to zainstalowany flatpak → pokaż info
4109
+ # Jeśli nie → szukaj i zaproponuj instalację
4110
+ query = " ".join(args)
4111
+
4112
+ # Najpierw sprawdź czy już zainstalowany
4113
+ if _flatpak_is_installed(query):
4114
+ print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
4115
+ return cmd_flatpak_info(query)
4116
+
4117
+ # Szukaj we Flathub
4118
+ print(f" {_('flatpak_searching', query)}")
4119
+ best = _flatpak_find_best(query)
4120
+ if not best:
4121
+ print(f" ❌ '{query}' – {_('flatpak_not_found')}")
4122
+ return 1
4123
+
4124
+ print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
4125
+ if best["version"]:
4126
+ print(f" {_('flatpak_info_version')}: {best['version']}")
4127
+ if best["description"]:
4128
+ print(f" {best['description']}")
4129
+
4130
+ try:
4131
+ ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
4132
+ except (EOFError, KeyboardInterrupt):
4133
+ print(f"\n ⚠ {_('no_tty')}")
4134
+ return 0
4135
+ if ans and ans not in ("t", "y"):
4136
+ print(_("cancelled"))
4137
+ return 0
4138
+
4139
+ return _flatpak_do_install(best["app_id"])
4140
+
4141
+def _flatpak_smart_install(names: list) -> int:
4142
+ """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
4143
+ failed = 0
4144
+ for name in names:
4145
+ if "." in name and "/" not in name:
4146
+ # Wygląda na pełne app_id (np. org.mozilla.firefox)
4147
+ app_id = name
4148
+ else:
4149
+ # Szukaj najlepszego dopasowania
4150
+ best = _flatpak_find_best(name)
4151
+ if not best:
4152
+ print(f" ❌ '{name}' – {_('flatpak_not_found')}")
4153
+ failed += 1
4154
+ continue
4155
+ app_id = best["app_id"]
4156
+ print(f" → {best['name']} ({app_id})")
4157
+
4158
+ if _flatpak_do_install(app_id) != 0:
4159
+ failed += 1
4160
+ return 1 if failed else 0
4161
+
4162
+def _flatpak_export_dirs() -> List[str]:
4163
+ """Katalogi eksportów Flatpaka (system + użytkownika) obecne na dysku.
4164
+
4165
+ Instalacja użytkownika roota (/root/.local/share/flatpak) jest świadomie
4166
+ POMIJANA – to wewnętrzny artefakt roota, którego sesja użytkownika nigdy
4167
+ nie zobaczy (patrz _flatpak_do_install)."""
4168
+ dirs = ["/var/lib/flatpak/exports"]
4169
+ home = os.path.expanduser("~")
4170
+ if home and home not in ("/root", "/"):
4171
+ dirs.append(os.path.join(home, ".local", "share", "flatpak", "exports"))
4172
+ return [d for d in dirs if os.path.isdir(d)]
4173
+
4174
+
4175
+def _flatpak_refresh_caches() -> None:
4176
+ """Odświeża cache pulpitu i ikon po zmianie w Flatpaku (best-effort).
4177
+
4178
+ Flatpak robi to sam, ale tylko gdy `update-desktop-database` jest w PATH.
4179
+ Bez tego nowo zainstalowana aplikacja bywa nieobecna w menu."""
4180
+ for exports in _flatpak_export_dirs():
4181
+ apps = os.path.join(exports, "share", "applications")
4182
+ if os.path.isdir(apps) and shutil.which("update-desktop-database"):
4183
+ subprocess.run(["update-desktop-database", apps],
4184
+ capture_output=True, check=False, timeout=60)
4185
+ icons = os.path.join(exports, "share", "icons", "hicolor")
4186
+ if os.path.isdir(icons) and shutil.which("gtk-update-icon-cache"):
4187
+ subprocess.run(["gtk-update-icon-cache", "-q", "-t", "-f", icons],
4188
+ capture_output=True, check=False, timeout=120)
4189
+
4190
+
4191
+def _flatpak_session_sees_exports() -> bool:
4192
+ """Czy bieżąca sesja ma eksporty Flatpaka w XDG_DATA_DIRS."""
4193
+ raw = os.environ.get("XDG_DATA_DIRS", "")
4194
+ dirs = {d for d in raw.split(":") if d} or {"/usr/local/share", "/usr/share"}
4195
+ return any(os.path.join(e, "share") in dirs for e in _flatpak_export_dirs())
4196
+
4197
+
4198
+def _flatpak_warn_if_invisible() -> None:
4199
+ """Ostrzega, gdy sesja nie widzi eksportów – inaczej wygląda to jak
4200
+ „zainstalowało się, ale nie ma go w menu / na PC”."""
4201
+ if not _flatpak_session_sees_exports():
4202
+ print(f" ⚠ {_('flatpak_menu_hint')}")
4203
+ print(f" {_('flatpak_menu_fix')}")
4204
+
4205
+
4206
+def _flatpak_do_install(app_id: str) -> int:
4207
+ """Wykonuje właściwą instalację flatpaka.
4208
+
4209
+ Jako root wymuszamy instalację SYSTEMOWĄ (--system). Bez tego `flatpak`
4210
+ uruchomiony jako root potrafi zainstalować aplikację w instalacji
4211
+ użytkownika roota (/root/.local/share/flatpak) – widocznej dla roota, ale
4212
+ nie dla zalogowanego użytkownika. To dokładnie efekt „instaluje, ale nie
4213
+ mam tego na PC”. Bez roota próbujemy instalacji systemowej (polkit),
4214
+ a w razie niepowodzenia cofamy się do instalacji użytkownika (--user).
4215
+
4216
+ Zakres (scope) ma znaczenie: instalacja SYSTEMOWA (--system) jest widoczna
4217
+ dla wszystkich użytkowników, a --user tylko dla bieżącego (dla roota:
4218
+ /root/.local/... → niewidoczna dla sesji). `flatpak list` bez flag pokazuje
4219
+ oba zakresy, więc systemowa instalacja roota jest widoczna też dla
4220
+ zwykłego użytkownika."""
4221
+ print(f" {_('flatpak_installing', app_id)}")
4222
+ if os.geteuid() == 0:
4223
+ result = subprocess.run(
4224
+ ["flatpak", "install", "-y", "--system", "flathub", app_id],
4225
+ check=False, timeout=600)
4226
+ else:
4227
+ result = subprocess.run(
4228
+ ["flatpak", "install", "-y", "flathub", app_id],
4229
+ check=False, timeout=600)
4230
+ if result.returncode != 0:
4231
+ # Flathub tylko dla użytkownika / brak agenta polkit – instalacja
4232
+ # lokalna użytkownika jest lepsza niż twardy błąd.
4233
+ subprocess.run(
4234
+ ["flatpak", "remote-add", "--if-not-exists", "--user",
4235
+ "flathub", FLATPAK_REMOTE_URL], check=False, timeout=60)
4236
+ result = subprocess.run(
4237
+ ["flatpak", "install", "-y", "--user", "flathub", app_id],
4238
+ check=False, timeout=600)
4239
+ if result.returncode != 0:
4240
+ print(f" ❌ {_('download_fail')}: {app_id}")
4241
+ return 1
4242
+ _flatpak_refresh_caches()
4243
+ print(f" ✅ {_('flatpak_installed', app_id)}")
4244
+ _flatpak_warn_if_invisible()
4245
+ return 0
4246
+
4247
+def _flatpak_smart_remove(names: list) -> int:
4248
+ """Usuwa flatpaki – obsługuje nazwy częściowe."""
4249
+ # Pobierz listę zainstalowanych
4250
+ try:
4251
+ r = subprocess.run(
4252
+ ["flatpak", "list", "--columns=application,name"],
4253
+ capture_output=True, text=True, timeout=10
4254
+ )
4255
+ installed = {}
4256
+ for line in r.stdout.strip().split("\n"):
4257
+ parts = line.split("\t")
4258
+ if len(parts) >= 2:
4259
+ installed[parts[0].strip()] = parts[1].strip()
4260
+ except Exception:
4261
+ installed = {}
4262
+
4263
+ failed = 0
4264
+ for name in names:
4265
+ app_id = name
4266
+
4267
+ # Jeśli nie podano pełnego ID – spróbuj dopasować
4268
+ if name not in installed:
4269
+ matches = {aid: aname for aid, aname in installed.items()
4270
+ if name.lower() in aid.lower() or name.lower() in aname.lower()}
4271
+ if len(matches) == 0:
4272
+ print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
4273
+ failed += 1
4274
+ continue
4275
+ elif len(matches) == 1:
4276
+ app_id = list(matches.keys())[0]
4277
+ print(f" → {matches[app_id]} ({app_id})")
4278
+ else:
4279
+ print(f"\n Wiele dopasowań dla '{name}':")
4280
+ for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
4281
+ print(f" {i}. {aname} ({aid})")
4282
+ try:
4283
+ choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
4284
+ if not choice:
4285
+ failed += 1
4286
+ continue
4287
+ aid_list = sorted(matches.keys())
4288
+ app_id = aid_list[int(choice) - 1]
4289
+ except (EOFError, ValueError, IndexError):
4290
+ failed += 1
4291
+ continue
4292
+
4293
+ print(f" 🗑 {app_id} ...", end=" ", flush=True)
4294
+ result = subprocess.run(
4295
+ ["flatpak", "uninstall", "-y", app_id],
4296
+ capture_output=True, text=True, timeout=120
4297
+ )
4298
+ if result.returncode == 0:
4299
+ print("✅")
4300
+ print(f" {_('flatpak_removed', app_id)}")
4301
+ else:
4302
+ print("❌")
4303
+ failed += 1
4304
+ if not failed:
4305
+ _flatpak_refresh_caches()
4306
+ return 1 if failed else 0
4307
+
4308
+def cmd_flatpak_search(q: str):
4309
+ """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
4310
+ if not _check_flatpak():
4311
+ return 1
4312
+ results = _flatpak_search_raw(q)
4313
+ if not results:
4314
+ print(f" ❌ '{q}' – {_('flatpak_not_found')}")
4315
+ return 1
4316
+ print(f"\n {_('flatpak_found', len(results))}")
4317
+ shown = results[:30] # max 30 wyników
4318
+ for i, r in enumerate(shown, 1):
4319
+ installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
4320
+ print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
4321
+ if r["version"]:
4322
+ print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
4323
+ if r["description"]:
4324
+ desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
4325
+ print(f" {_c('dim', desc)}")
4326
+ if len(results) > 30:
4327
+ print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
4328
+
4329
+ # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
4330
+ try:
4331
+ ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
4332
+ except (EOFError, KeyboardInterrupt):
4333
+ return 0
4334
+ if ans:
4335
+ try:
4336
+ idx = int(ans) - 1
4337
+ if 0 <= idx < len(shown):
4338
+ return _flatpak_do_install(shown[idx]["app_id"])
4339
+ print(_("cancelled"))
4340
+ except (ValueError, IndexError):
4341
+ print(_("cancelled"))
4342
+ return 0
4343
+
4344
+def cmd_flatpak_list():
4345
+ """Wyświetla zainstalowane flatpaki."""
4346
+ if not _check_flatpak():
4347
+ return 1
4348
+ r = subprocess.run(
4349
+ ["flatpak", "list", "--columns=application,name,version,origin,installation,installed-size"],
4350
+ capture_output=True, text=True, timeout=10
4351
+ )
4352
+ lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
4353
+ if not lines:
4354
+ print(" (brak zainstalowanych flatpaków)")
4355
+ return 0
4356
+ print(f" Zainstalowane flatpaki ({len(lines)}):")
4357
+ for line in lines:
4358
+ parts = line.split("\t")
4359
+ if len(parts) >= 3:
4360
+ app_id, name, version = parts[0], parts[1], parts[2]
4361
+ scope = parts[4] if len(parts) > 4 else ""
4362
+ size = parts[5] if len(parts) > 5 else ""
4363
+ size_str = f" ({size})" if size else ""
4364
+ scope_str = f" [{scope}]" if scope else ""
4365
+ print(f" 📦 {_c('bold', name)} {version}{scope_str}{size_str}")
4366
+ print(f" {_c('dim', app_id)}")
4367
+ return 0
4368
+
4369
+def cmd_flatpak_update():
4370
+ """Aktualizuje wszystkie flatpaki."""
4371
+ if not _check_flatpak():
4372
+ return 1
4373
+ print(" 🔄 Aktualizacja flatpaków...")
4374
+ result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
4375
+ if result.returncode == 0:
4376
+ _flatpak_refresh_caches()
4377
+ print(f" ✅ {_('flatpak_updated')}")
4378
+ return result.returncode
4379
+
4380
+def cmd_flatpak_info(app_id: str):
4381
+ """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
4382
+ if not _check_flatpak():
4383
+ return 1
4384
+
4385
+ # Najpierw sprawdź zainstalowany
4386
+ info = _flatpak_get_installed_info(app_id)
4387
+ if info:
4388
+ print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
4389
+ print(f" {'─' * 45}")
4390
+ print(f" {_('flatpak_info_id'):<16} {app_id}")
4391
+ print(f" {_('flatpak_info_version'):<16} {info['version']}")
4392
+ print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
4393
+ print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
4394
+ if info["size"]:
4395
+ print(f" {_('flatpak_info_size'):<16} {info['size']}")
4396
+ if info["description"]:
4397
+ print(f" {_('flatpak_info_desc'):<16} {info['description']}")
4398
+ return 0
4399
+
4400
+ # Szukaj we Flathub
4401
+ results = _flatpak_search_raw(app_id)
4402
+ exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
4403
+ if not exact:
4404
+ # Spróbuj częściowego dopasowania
4405
+ if results:
4406
+ exact = [results[0]]
4407
+ else:
4408
+ print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
4409
+ return 1
4410
+
4411
+ r = exact[0]
4412
+ print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
4413
+ print(f" {'─' * 45}")
4414
+ print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
4415
+ print(f" {_('flatpak_info_version'):<16} {r['version']}")
4416
+ if r["description"]:
4417
+ print(f" {_('flatpak_info_desc'):<16} {r['description']}")
4418
+ print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
4419
+ return 0
4420
+
4421
+# =============================================================================
4422
+# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
4423
+# =============================================================================
4424
+
4425
+# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
4426
+KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
4427
+
4428
+def _is_kernel_package(name: str) -> bool:
4429
+ """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
4430
+ name_lower = name.lower()
4431
+ return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
4432
+
4433
+def _rebuild_initramfs(deploy_dir: str = "") -> bool:
4434
+ """
4435
+ Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
4436
+ Używa skryptu pag-initramfs lub ręcznego cpio.
4437
+ """
4438
+ if deploy_dir:
4439
+ root = deploy_dir
4440
+ else:
4441
+ root = _get_deployment_root()
4442
+
4443
+ if root == PAG_ROOT:
4444
+ # Zwykły system – użyj dracut jeśli dostępny
4445
+ if shutil.which("dracut"):
4446
+ print(" 🔧 Przebudowa initramfs (dracut)...")
4447
+ result = subprocess.run(
4448
+ ["dracut", "--force", "/boot/initramfs.img"],
4449
+ capture_output=True, text=True, timeout=120
4450
+ )
4451
+ return result.returncode == 0
4452
+ elif shutil.which("mkinitcpio"):
4453
+ print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
4454
+ result = subprocess.run(
4455
+ ["mkinitcpio", "-g", "/boot/initramfs.img"],
4456
+ capture_output=True, text=True, timeout=120
4457
+ )
4458
+ return result.returncode == 0
4459
+ else:
4460
+ print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
4461
+ return False
4462
+
4463
+ # Tryb immutable – budujemy initramfs dla deploymentu
4464
+ print(" 🔧 Budowanie initramfs dla deploymentu...")
4465
+
4466
+ # Sprawdź czy mamy nasz skrypt init
4467
+ pag_init_script = "/usr/share/pag/initramfs-init"
4468
+ if not os.path.exists(pag_init_script):
4469
+ # Szukaj w źródłach (developerski fallback)
4470
+ alt_paths = [
4471
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
4472
+ "/usr/share/pag/init",
4473
+ ]
4474
+ for p in alt_paths:
4475
+ if os.path.exists(p):
4476
+ pag_init_script = p
4477
+ break
4478
+
4479
+ if not os.path.exists(pag_init_script):
4480
+ print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
4481
+ return False
4482
+
4483
+ boot_dir = os.path.join(root, "boot")
4484
+ os.makedirs(boot_dir, exist_ok=True)
4485
+
4486
+ # Znajdź jądro (vmlinuz-*)
4487
+ kernels = sorted(
4488
+ [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4489
+ reverse=True
4490
+ ) if os.path.exists(boot_dir) else []
4491
+ if not kernels:
4492
+ print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
4493
+ return False
4494
+
4495
+ kernel_ver = kernels[0].replace("vmlinuz-", "")
4496
+ print(f" 🐧 Jądro: {kernel_ver}")
4497
+
4498
+ # Buduj initramfs ręcznie (cpio)
4499
+ tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4500
+ try:
4501
+ # Podstawowa struktura
4502
+ for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4503
+ "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4504
+ os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4505
+
4506
+ # Skopiuj init
4507
+ shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4508
+ os.chmod(os.path.join(tmpdir, "init"), 0o755)
4509
+
4510
+ # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4511
+ busybox_paths = [
4512
+ os.path.join(root, "usr/bin/busybox"),
4513
+ os.path.join(root, "bin/busybox"),
4514
+ "/usr/bin/busybox",
4515
+ "/bin/busybox",
4516
+ ]
4517
+ busybox = None
4518
+ for bp in busybox_paths:
4519
+ if os.path.exists(bp):
4520
+ busybox = bp
4521
+ break
4522
+
4523
+ if busybox:
4524
+ shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4525
+ # Utwórz symlinki dla podstawowych komend
4526
+ for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4527
+ "readlink", "mkdir", "switch_root", "cp", "rm"]:
4528
+ link = os.path.join(tmpdir, "bin", cmd)
4529
+ if not os.path.exists(link):
4530
+ os.symlink("busybox", link)
4531
+ # /bin/sh → busybox
4532
+ if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4533
+ os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4534
+ else:
4535
+ # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4536
+ for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4537
+ src = os.path.join(root, "usr/bin", tool)
4538
+ if not os.path.exists(src):
4539
+ src = os.path.join(root, "bin", tool)
4540
+ if os.path.exists(src):
4541
+ dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4542
+ shutil.copy2(src, dest)
4543
+ # Kopiuj zależności .so
4544
+ _copy_libs_for_binary(src, tmpdir, root)
4545
+
4546
+ # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4547
+ modules_src = os.path.join(root, "lib/modules", kernel_ver)
4548
+ if os.path.isdir(modules_src):
4549
+ modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4550
+ # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4551
+ for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4552
+ "kernel/drivers/scsi", "kernel/drivers/virtio",
4553
+ "modules.order", "modules.builtin"]:
4554
+ src_sub = os.path.join(modules_src, sub)
4555
+ if os.path.exists(src_sub):
4556
+ dst_sub = os.path.join(modules_dst, sub)
4557
+ os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4558
+ if os.path.isdir(src_sub):
4559
+ try:
4560
+ shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4561
+ ignore_dangling_symlinks=True)
4562
+ except (FileNotFoundError, PermissionError):
4563
+ print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4564
+ else:
4565
+ try:
4566
+ shutil.copy2(src_sub, dst_sub)
4567
+ except (FileNotFoundError, PermissionError):
4568
+ print(f" ⚠ Pomijam niedostępny plik: {sub}")
4569
+
4570
+ # Pakuj do initramfs.img
4571
+ initramfs_path = os.path.join(boot_dir, "initramfs.img")
4572
+ old_cwd = os.getcwd()
4573
+ os.chdir(tmpdir)
4574
+ try:
4575
+ with open(initramfs_path + ".tmp", "wb") as out:
4576
+ _run_cpio_pipeline(tmpdir, out)
4577
+ os.rename(initramfs_path + ".tmp", initramfs_path)
4578
+ finally:
4579
+ os.chdir(old_cwd)
4580
+
4581
+ size_mb = os.path.getsize(initramfs_path) / 1048576
4582
+ print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4583
+ return True
4584
+
4585
+ except Exception as e:
4586
+ print(f" ❌ Błąd budowy initramfs: {e}")
4587
+ return False
4588
+ finally:
4589
+ shutil.rmtree(tmpdir, ignore_errors=True)
4590
+
4591
+
4592
+def _run_cpio_pipeline(tmpdir: str, out):
4593
+ """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4594
+
4595
+ Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4596
+ (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4597
+ Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4598
+ rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4599
+ """
4600
+ find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4601
+ cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4602
+ stdin=find.stdout, stdout=subprocess.PIPE)
4603
+ find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4604
+ gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4605
+ cpio.stdout.close()
4606
+ try:
4607
+ gzip.wait(timeout=120)
4608
+ if gzip.returncode != 0:
4609
+ raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4610
+ cpio.wait(timeout=30)
4611
+ find.wait(timeout=30)
4612
+ except subprocess.TimeoutExpired:
4613
+ for p in (gzip, cpio, find):
4614
+ p.kill()
4615
+ raise
4616
+ finally:
4617
+ for p in (find, cpio, gzip):
4618
+ if p.poll() is None:
4619
+ p.kill()
4620
+ # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4621
+ if cpio.returncode != 0:
4622
+ raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4623
+ if find.returncode != 0:
4624
+ raise subprocess.CalledProcessError(find.returncode, ["find"])
4625
+
4626
+
4627
+def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4628
+ """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4629
+ try:
4630
+ result = subprocess.run(
4631
+ ["ldd", binary], capture_output=True, text=True, timeout=10
4632
+ )
4633
+ for line in result.stdout.split("\n"):
4634
+ m = re.search(r'=>\s+(/\S+)', line)
4635
+ if m:
4636
+ lib_path = m.group(1)
4637
+ lib_rel = lib_path.lstrip("/")
4638
+ lib_dest = os.path.join(dest_dir, lib_rel)
4639
+ if not os.path.exists(lib_dest):
4640
+ os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4641
+ # Szukaj w deployment root lub systemie
4642
+ if os.path.exists(lib_path):
4643
+ shutil.copy2(lib_path, lib_dest)
4644
+ else:
4645
+ alt = os.path.join(root, lib_rel)
4646
+ if os.path.exists(alt):
4647
+ shutil.copy2(alt, lib_dest)
4648
+ except Exception:
4649
+ pass
4650
+
4651
+
4652
+def cmd_initramfs_update():
4653
+ """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4654
+ ensure_dirs()
4655
+ deploy_dir = _get_deployment_root()
4656
+ if deploy_dir != PAG_ROOT:
4657
+ print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4658
+ ok = _rebuild_initramfs(deploy_dir)
4659
+ if ok:
4660
+ print("✅ Initramfs zaktualizowany.")
4661
+ # Po initramfs – zaktualizuj też GRUB
4662
+ _update_grub_config()
4663
+ else:
4664
+ print("❌ Błąd aktualizacji initramfs.")
4665
+ return 0 if ok else 1
4666
+
4667
+
4668
+def _update_grub_config():
4669
+ """
4670
+ Generuje wpisy GRUB dla wszystkich deploymentów.
4671
+ Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4672
+ """
4673
+ grub_cfg = "/boot/grub/grub.cfg"
4674
+ if not os.path.exists(os.path.dirname(grub_cfg)):
4675
+ return # brak GRUB
4676
+
4677
+ deployments = _load_deployments()
4678
+ root_dev = _detect_root_device()
4679
+ if not root_dev:
4680
+ print(" ⚠ Nie udało się wykryć partycji root – wpisy GRUB nie dostaną root=.",
4681
+ file=sys.stderr)
4682
+ print(" Ustaw PAG_ROOT_DEVICE=/dev/... (lub PAG_GRUB_ROOT) i powtórz.",
4683
+ file=sys.stderr)
4684
+ root_arg = f" root={root_dev}" if root_dev else ""
4685
+
4686
+ lines = [
4687
+ "# =====================================================================",
4688
+ "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4689
+ f"# Data: {datetime.now().isoformat()}",
4690
+ "# =====================================================================",
4691
+ "",
4692
+ ]
4693
+
4694
+ # Domyślny – ostatni (najnowszy) deployment
4695
+ if deployments:
4696
+ latest = deployments[-1]["id"]
4697
+ lines.append(f"set default=0")
4698
+ lines.append(f"set timeout=5")
4699
+ else:
4700
+ lines.append("set default=0")
4701
+ lines.append("set timeout=5")
4702
+ lines.append("")
4703
+
4704
+ # Wpisy dla każdego deploymentu (od najnowszego)
4705
+ entry_num = 0
4706
+ for d in reversed(deployments):
4707
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4708
+ boot_dir = os.path.join(deploy_dir, "boot")
4709
+ kernels = sorted(
4710
+ [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4711
+ reverse=True
4712
+ ) if os.path.isdir(boot_dir) else []
4713
+
4714
+ kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4715
+ initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4716
+ initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4717
+
4718
+ active_mark = " [AKTYWNY]" if d.get("active") else ""
4719
+ pkg_list = ", ".join(d.get("packages", [])[:3])
4720
+ label = f"Pagan Linux – {d['id']}{active_mark}"
4721
+
4722
+ lines.append(f"menuentry '{label}' {{")
4723
+ if kernel_path:
4724
+ lines.append(f" linux {kernel_path}{root_arg} rw quiet")
4725
+ else:
4726
+ lines.append(f" # Brak jądra w tym deploymencie")
4727
+ if initrd_line:
4728
+ lines.append(f" {initrd_line}")
4729
+ lines.append("}")
4730
+ lines.append("")
4731
+ entry_num += 1
4732
+
4733
+ # Wpis fallback: zwykły root (gdyby wszystko padło).
4734
+ # Uwaga: GRUB NIE rozwija globów – trzeba podać KONKRETNY plik jądra
4735
+ # (wcześniejsze `linux /boot/vmlinuz-*` było niepoprawne).
4736
+ fallback_kernels = sorted(
4737
+ [f for f in os.listdir("/boot") if f.startswith("vmlinuz-")],
4738
+ reverse=True
4739
+ ) if os.path.isdir("/boot") else []
4740
+ lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4741
+ if fallback_kernels:
4742
+ lines.append(f" linux /boot/{fallback_kernels[0]}{root_arg} rw quiet")
4743
+ else:
4744
+ lines.append(" # Brak jądra w /boot")
4745
+ lines.append(f" initrd /boot/initramfs.img")
4746
+ lines.append("}")
4747
+ lines.append("")
4748
+
4749
+ # Zapisz
4750
+ os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4751
+ with open(grub_cfg, "w") as f:
4752
+ f.write("\n".join(lines))
4753
+
4754
+ print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4755
+
4756
+
4757
+def _root_device_from_fstab(path: str = "/etc/fstab") -> str:
4758
+ """Zwraca DEVICE z wpisu dla '/' w fstab (pomija komentarze)."""
4759
+ try:
4760
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
4761
+ for line in fh:
4762
+ line = line.split("#", 1)[0].strip()
4763
+ if not line:
4764
+ continue
4765
+ parts = line.split()
4766
+ if len(parts) >= 2 and parts[1] == "/":
4767
+ return parts[0]
4768
+ except OSError:
4769
+ pass
4770
+ return ""
4771
+
4772
+
4773
+def _in_chroot() -> bool:
4774
+ """Heurystyka: czy działamy w chrocie (build ISO/IMG)?
4775
+
4776
+ W chrocie /proc/1/root to root HOSTA – inny system plików niż nasz '/'.
4777
+ Na zwykłym systemie PID 1 ma root równy '/'. PAGAN_ROOT ustawiają skrypty
4778
+ budujące i jest dziedziczony przez chroot (dodatkowa wskazówka).
4779
+ """
4780
+ if os.environ.get("PAG_IN_CHROOT") == "1":
4781
+ return True
4782
+ if os.environ.get("PAGAN_ROOT"):
4783
+ return True
4784
+ try:
4785
+ return os.stat("/").st_dev != os.stat("/proc/1/root").st_dev
4786
+ except OSError:
4787
+ return False
4788
+
4789
+
4790
+def _detect_root_device() -> str:
4791
+ """Wykrywa urządzenie/identyfikator partycji root dla GRUB (root=...).
4792
+
4793
+ Kolejność (od najbardziej wiarygodnego w danym kontekście):
4794
+ 1) PAG_ROOT_DEVICE / PAG_GRUB_ROOT – jawny override (build ISO/IMG,
4795
+ instalator); najpewniejszy, bo nie zgadujemy.
4796
+ 2) /etc/fstab TARGETU – w chrocie findmnt '/' zwraca partycję HOSTA,
4797
+ a fstab opisuje target (np. root=/dev/sda2 dla obrazu IMG).
4798
+ 3) findmnt '/' – na żywym systemie (obsługuje UUID/LUKS/subvol).
4799
+ Zwraca "" gdy nie da się ustalić – wtedy GRUB nie dostaje błędnego root=.
4800
+ """
4801
+ explicit = (os.environ.get("PAG_ROOT_DEVICE")
4802
+ or os.environ.get("PAG_GRUB_ROOT") or "").strip()
4803
+ if explicit:
4804
+ return explicit
4805
+
4806
+ fstab_dev = _root_device_from_fstab()
4807
+ if _in_chroot() and fstab_dev:
4808
+ return fstab_dev
4809
+
4810
+ try:
4811
+ result = subprocess.run(
4812
+ ["findmnt", "-n", "-o", "SOURCE", "/"],
4813
+ capture_output=True, text=True, timeout=5
4814
+ )
4815
+ if result.returncode == 0 and result.stdout.strip():
4816
+ return result.stdout.strip()
4817
+ except Exception:
4818
+ pass
4819
+
4820
+ # Ostatnia deska: fstab targetu (lepsze niż hardkodowane /dev/sda1,
4821
+ # które na sprzęcie z NVMe dawało niebootowalny system).
4822
+ return fstab_dev
4823
+
4824
+
4825
+def cmd_grub_update():
4826
+ """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4827
+ ensure_dirs()
4828
+ print("📋 Aktualizacja konfiguracji GRUB...")
4829
+ _update_grub_config()
4830
+ print("✅ GRUB zaktualizowany.")
4831
+ return 0
4832
+
4833
+def cmd_deploy_list():
4834
+ """Wyświetla listę wszystkich deploymentów."""
4835
+ deployments = _load_deployments()
4836
+ if not deployments:
4837
+ print(_("no_deployments")); return
4838
+
4839
+ print(_("deployments_list", len(deployments)))
4840
+ active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4841
+
4842
+ for d in reversed(deployments):
4843
+ marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4844
+ print(f" {d['id']}{marker}")
4845
+ print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4846
+ if len(d.get('packages', [])) > 5:
4847
+ print(f" +{len(d['packages']) - 5} więcej...")
4848
+ print(f" {d['timestamp']}")
4849
+
4850
+
4851
+def cmd_deploy_rollback():
4852
+ """Przełącza na poprzedni deployment."""
4853
+ deployments = _load_deployments()
4854
+ active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4855
+
4856
+ if len(deployments) < 2:
4857
+ print(f"❌ {_('deploy_rollback_fail')}"); return 1
4858
+
4859
+ current_idx = active_indices[0] if active_indices else len(deployments) - 1
4860
+ prev_idx = current_idx - 1 if current_idx > 0 else -1
4861
+
4862
+ if prev_idx < 0:
4863
+ print(f"❌ {_('deploy_rollback_fail')}"); return 1
4864
+
4865
+ prev = deployments[prev_idx]
4866
+ prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4867
+
4868
+ if not os.path.isdir(prev_dir):
4869
+ print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4870
+
4871
+ print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4872
+ print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4873
+
4874
+ if not _ask_confirm():
4875
+ return 0
4876
+
4877
+ _switch_deployment(prev_dir)
4878
+
4879
+ for d in deployments:
4880
+ d["active"] = (d["id"] == prev["id"])
4881
+ _save_deployments(deployments)
4882
+
4883
+ _update_grub_config()
4884
+ print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4885
+ print(" 💡 Restart wymagany do przeładowania systemu.")
4886
+ return 0
4887
+
4888
+
4889
+def cmd_deploy_cleanup(keep: int = 3):
4890
+ """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4891
+ deployments = _load_deployments()
4892
+
4893
+ if len(deployments) <= keep:
4894
+ print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4895
+
4896
+ to_remove = deployments[:-keep]
4897
+ removed = 0
4898
+
4899
+ for d in to_remove:
4900
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4901
+ if os.path.isdir(deploy_dir):
4902
+ shutil.rmtree(deploy_dir, ignore_errors=True)
4903
+ removed += 1
4904
+
4905
+ remaining = deployments[-keep:]
4906
+ _save_deployments(remaining)
4907
+
4908
+ print(f"✅ {_('deploy_cleanup_ok', removed)}")
4909
+ return 0
4910
+
4911
+
4912
+# =============================================================================
4913
+# POMOCNICZE
4914
+# =============================================================================
4915
+
4916
+# Pakiety dostarczane przez bazowy system (zawsze "zainstalowane"). Wchodzą do
4917
+# rootfs z ISO przez data.tar.xz, więc NIE ma ich w repo.json ani w bazie pag –
4918
+# każda zależność od nich (glibc, bash, python3…) wyglądałaby na "brakującą".
4919
+# Współdzielone przez rozwijanie i weryfikację zależności.
4920
+SYSTEM_BASE = {
4921
+ "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4922
+ "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4923
+ "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4924
+ "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4925
+}
4926
+
4927
+
4928
+def _resolve_deps(names, repo, installed):
4929
+ resolved, visited = [], set()
4930
+ missing = [] # zależności których nie ma ani w repo ani zainstalowane
4931
+
4932
+ def _is_base(dep):
4933
+ """True, gdy zależność dostarcza bazowy system (także przez provides)."""
4934
+ return dep in SYSTEM_BASE \
4935
+ or _resolve_provides(dep, repo, installed) in SYSTEM_BASE
4936
+
4937
+ def visit(name, explicit=False):
4938
+ if name in visited: return
4939
+
4940
+ # Rozwijanie wirtualnych zależności przez provides
4941
+ target = _resolve_provides(name, repo, installed)
4942
+
4943
+ # Zależność dostarczana przez bazowy system – nie ma jej w repo ani w
4944
+ # bazie pag, więc nie "brakuje" jej i nie próbujemy jej instalować.
4945
+ # Wyjątek: nazwa podana wprost przez użytkownika (explicit=True).
4946
+ if not explicit and target in SYSTEM_BASE:
4947
+ return
4948
+
4949
+ if target in visited: return
4950
+ visited.add(target)
4951
+ if target in repo:
4952
+ for dep in repo[target].dependencies:
4953
+ real_dep = _resolve_provides(dep, repo, installed)
4954
+ real_target = real_dep if real_dep in repo else dep
4955
+
4956
+ if _is_base(dep):
4957
+ continue
4958
+
4959
+ # Sprawdź czy zależność jest dostępna
4960
+ if real_target not in installed and real_target not in repo:
4961
+ if dep not in missing:
4962
+ missing.append(dep)
4963
+
4964
+ if dep not in installed:
4965
+ visit(real_target)
4966
+ elif target not in installed:
4967
+ # Pakiet nie istnieje ani w repo ani zainstalowany
4968
+ if target not in missing:
4969
+ missing.append(target)
4970
+
4971
+ if target not in installed and target not in resolved:
4972
+ resolved.append(target)
4973
+
4974
+ for name in names:
4975
+ visit(name, explicit=True)
4976
+
4977
+ # Zwróć brakujące (do sprawdzenia przez wywołującego)
4978
+ return resolved, missing
4979
+
4980
+def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4981
+ """
4982
+ Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4983
+ Zwraca liczbę brakujących zależności.
4984
+ """
4985
+ all_missing = []
4986
+ all_warnings = []
4987
+
4988
+ for pkg_name in to_install:
4989
+ pkg = repo.get(pkg_name)
4990
+ if not pkg:
4991
+ continue
4992
+
4993
+ for dep in pkg.dependencies:
4994
+ if dep in SYSTEM_BASE:
4995
+ continue # bazowy system dostarcza tę zależność
4996
+ real_dep = _resolve_provides(dep, repo, installed)
4997
+ # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4998
+ in_repo = real_dep in repo
4999
+ in_installed = real_dep in installed
5000
+ will_be_installed = real_dep in to_install
5001
+
5002
+ if not in_repo and not in_installed and not will_be_installed:
5003
+ if dep not in all_missing:
5004
+ all_missing.append((pkg_name, dep))
5005
+ elif in_repo and not in_installed and not will_be_installed:
5006
+ if dep not in [w[1] for w in all_warnings]:
5007
+ all_warnings.append((pkg_name, dep, real_dep))
5008
+
5009
+ if all_missing:
5010
+ print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
5011
+ for pkg, dep in all_missing:
5012
+ print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
5013
+ print()
5014
+
5015
+ if all_warnings:
5016
+ print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
5017
+ for pkg, dep, real in all_warnings:
5018
+ print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
5019
+ print()
5020
+
5021
+ return len(all_missing)
5022
+
5023
+# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
5024
+BASE_SO = {
5025
+ "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
5026
+ "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
5027
+ "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
5028
+ "libgcc_s.so.1", "linux-vdso.so.1",
5029
+}
5030
+
5031
+def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
5032
+ """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
5033
+
5034
+ Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
5035
+ (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
5036
+ wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
5037
+ """
5038
+ provided = set(BASE_SO)
5039
+ for n in to_install:
5040
+ p = repo.get(n)
5041
+ if p:
5042
+ provided.update(p.provides_so or [])
5043
+ for n, info in installed.items():
5044
+ provided.update(info.get("provides_so", []) or [])
5045
+
5046
+ missing = []
5047
+ for n in sorted(to_install):
5048
+ p = repo.get(n)
5049
+ if not p:
5050
+ continue
5051
+ for so in (p.requires_so or []):
5052
+ if so not in provided:
5053
+ missing.append((n, so))
5054
+
5055
+ if missing:
5056
+ print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
5057
+ for n, so in missing:
5058
+ print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
5059
+ print()
5060
+ return len(missing)
5061
+
5062
+def _download_pkg(pkg, attempts: int = 3):
5063
+ url = f"{pkg.repo_url}/{pkg.filename}"
5064
+ dest = os.path.join(PAG_CACHE, pkg.filename)
5065
+ if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
5066
+ _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
5067
+ return dest
5068
+ last_err = None
5069
+ for attempt in range(1, attempts + 1):
5070
+ try:
5071
+ req = Request(url, headers={"User-Agent":"pag/3.0"})
5072
+ with urlopen(req, timeout=600) as resp:
5073
+ total = int(resp.headers.get("Content-Length", 0))
5074
+ bar = DownloadBar(pkg.filename, total)
5075
+ with open(dest, "wb") as f:
5076
+ while True:
5077
+ chunk = resp.read(65536)
5078
+ if not chunk:
5079
+ break
5080
+ f.write(chunk)
5081
+ bar.update(len(chunk))
5082
+ bar.close()
5083
+ if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
5084
+ last_err = "SHA256 mismatch"
5085
+ try:
5086
+ os.remove(dest)
5087
+ except OSError:
5088
+ pass
5089
+ if attempt < attempts:
5090
+ print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5091
+ f"({attempt}/{attempts - 1})...", file=sys.stderr)
5092
+ time.sleep(attempt)
5093
+ continue
5094
+ return None
5095
+ _download_pkg_sig(pkg, dest)
5096
+ return dest
5097
+ except Exception as e:
5098
+ last_err = e
5099
+ # Usuń częściowy plik – bez tego mógłby zostać użyty jako „cache”
5100
+ # (gdy pakiet nie ma sha256) albo mylić kolejną próbę.
5101
+ try:
5102
+ if os.path.exists(dest):
5103
+ os.remove(dest)
5104
+ except OSError:
5105
+ pass
5106
+ if attempt < attempts:
5107
+ print(f" ↻ Ponawiam pobieranie {pkg.filename} "
5108
+ f"({attempt}/{attempts - 1})...", file=sys.stderr)
5109
+ time.sleep(attempt)
5110
+ continue
5111
+ print(f" ⚠ Błąd pobierania {pkg.filename}: {last_err}", file=sys.stderr)
5112
+ return None
5113
+
5114
+def _download_pkg_sig(pkg, dest):
5115
+ """Zapewnia AKTUALNY podpis pakietu (.asc, fallback .sig) w cache.
5116
+
5117
+ Istniejący podpis jest używany tylko wtedy, gdy faktycznie weryfikuje TĘ
5118
+ paczkę. Inaczej po przebudowie tej samej wersji (ten sam plik, nowy sha256)
5119
+ stary podpis zostawał obok nowej paczki i weryfikacja dawała fałszywe
5120
+ „NIEPRAWIDŁOWY PODPIS GPG”.
5121
+ """
5122
+ for ext in (".asc", ".sig"):
5123
+ sig_dest = dest + ext
5124
+ if os.path.exists(sig_dest):
5125
+ ok, _fp = _gpg_verify_fp(sig_dest, dest)
5126
+ if ok:
5127
+ return
5128
+ for ext in (".asc", ".sig"):
5129
+ sig_dest = dest + ext
5130
+ try:
5131
+ req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
5132
+ with urlopen(req, timeout=30) as resp:
5133
+ data = resp.read()
5134
+ except Exception:
5135
+ continue
5136
+ # nie mieszaj rozszerzeń – zostaje tylko ten wariant podpisu
5137
+ for other in (".asc", ".sig"):
5138
+ if other != ext:
5139
+ try:
5140
+ os.remove(dest + other)
5141
+ except OSError:
5142
+ pass
5143
+ with open(sig_dest, "wb") as f:
5144
+ f.write(data)
5145
+ return
5146
+ # Nie udało się pobrać podpisu – usuń nieaktualny z cache, żeby weryfikacja
5147
+ # nie porównywała paczki z podpisem od innej wersji (czytelny „BRAK PODPISU”).
5148
+ for other in (".asc", ".sig"):
5149
+ try:
5150
+ os.remove(dest + other)
5151
+ except OSError:
5152
+ pass
5153
+
5154
+def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
5155
+ """
5156
+ Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
5157
+ Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
5158
+ Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
5159
+ """
5160
+ results = {}
5161
+ total = len(pkgs)
5162
+ completed = 0
5163
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
5164
+ future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
5165
+ for future in as_completed(future_to_pkg):
5166
+ pkg = future_to_pkg[future]
5167
+ try:
5168
+ results[pkg.name] = future.result()
5169
+ except Exception:
5170
+ results[pkg.name] = None
5171
+ completed += 1
5172
+ # Pasek postępu
5173
+ pct = completed / total * 100
5174
+ filled = int(20 * pct / 100)
5175
+ bar = "█" * filled + "░" * (20 - filled)
5176
+ print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
5177
+ print(file=sys.stderr) # nowa linia po zakończeniu
5178
+ return results
5179
+
5180
+def load_world():
5181
+ if not os.path.exists(WORLD_FILE): return set()
5182
+ return {l.strip() for l in open(WORLD_FILE) if l.strip()}
5183
+
5184
+def save_world(w):
5185
+ with open(WORLD_FILE,"w") as f:
5186
+ for n in sorted(w): f.write(f"{n}\n")
5187
+
5188
+def _find_orphans(installed, world):
5189
+ needed = set(world)
5190
+ changed = True
5191
+ while changed:
5192
+ changed = False
5193
+ for n in list(needed):
5194
+ for dep in installed.get(n,{}).get("dependencies",[]):
5195
+ if dep not in needed and dep in installed:
5196
+ needed.add(dep); changed = True
5197
+ return {n for n in installed if n not in needed}
5198
+
5199
+# =============================================================================
5200
+# MAIN
5201
+# =============================================================================
5202
+
5203
+def cmd_sbom(argv):
5204
+ """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
5205
+
5206
+ Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
5207
+ zainstalowanych pakietów, wersji, licencji i sum SHA256.
5208
+ """
5209
+ fmt = (argv[0] if argv else "spdx").lower()
5210
+ if fmt not in ("spdx", "cyclonedx"):
5211
+ print("❌ Format: spdx | cyclonedx")
5212
+ return 1
5213
+ installed = load_json(INSTALLED_DB)
5214
+ if not installed:
5215
+ print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
5216
+ return 0
5217
+ # metadata repo (licencje) – best-effort
5218
+ try:
5219
+ repo = fetch_all_packages()
5220
+ except Exception:
5221
+ repo = {}
5222
+ names = sorted(installed)
5223
+ created = datetime.now().astimezone().isoformat(timespec="seconds")
5224
+
5225
+ def _license_of(name):
5226
+ p = repo.get(name)
5227
+ lic = getattr(p, "license", None) or []
5228
+ if isinstance(lic, list):
5229
+ lic = ", ".join(x for x in lic if x)
5230
+ return lic or "NOASSERTION"
5231
+
5232
+ if fmt == "spdx":
5233
+ doc = {
5234
+ "spdxVersion": "SPDX-2.3",
5235
+ "dataLicense": "CC0-1.0",
5236
+ "SPDXID": "SPDXRef-DOCUMENT",
5237
+ "name": "PaganOS-installed",
5238
+ "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
5239
+ "creationInfo": {
5240
+ "created": created,
5241
+ "creators": [f"Tool: pag-{PAG_VERSION}"],
5242
+ },
5243
+ "packages": [],
5244
+ }
5245
+ for i, n in enumerate(names):
5246
+ info = installed[n]
5247
+ doc["packages"].append({
5248
+ "SPDXID": f"SPDXRef-Package-{i+1}",
5249
+ "name": n,
5250
+ "versionInfo": info.get("version", ""),
5251
+ "downloadLocation": info.get("repo", "NOASSERTION"),
5252
+ "filesAnalyzed": False,
5253
+ "licenseConcluded": _license_of(n),
5254
+ "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
5255
+ })
5256
+ else: # cyclonedx
5257
+ doc = {
5258
+ "bomFormat": "CycloneDX",
5259
+ "specVersion": "1.5",
5260
+ "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
5261
+ "version": 1,
5262
+ "metadata": {
5263
+ "timestamp": created,
5264
+ "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
5265
+ },
5266
+ "components": [],
5267
+ }
5268
+ for n in names:
5269
+ info = installed[n]
5270
+ lic = _license_of(n)
5271
+ comp = {
5272
+ "type": "library",
5273
+ "name": n,
5274
+ "version": info.get("version", ""),
5275
+ "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
5276
+ }
5277
+ if lic != "NOASSERTION":
5278
+ comp["licenses"] = [{"license": {"id": lic}}]
5279
+ doc["components"].append(comp)
5280
+ print(json.dumps(doc, indent=2, ensure_ascii=False))
5281
+ return 0
5282
+
5283
+
5284
+USAGE_EN = """pag v3 – Pagan Linux Package Manager
5285
+
5286
+BASIC:
5287
+ pag install <pkg>... Install packages
5288
+ pag remove <pkg>... Remove packages
5289
+ pag update Update PACKAGES (refreshes indexes first)
5290
+ pag sync Refresh indexes + show pending package updates
5291
+ pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
5292
+ pag list [--installed] List available / installed
5293
+ pag search <query> Search packages
5294
+ pag info <pkg> Package details
5295
+ pag files <pkg> List package files
5296
+ pag verify [--deep] Verify integrity (--deep = SHA256 per file)
5297
+ pag clean Clear download cache
5298
+ pag stats System statistics
5299
+ pag download <pkg>... Download packages to cache (offline prep)
5300
+
5301
+SECURITY:
5302
+ pag key-add <url|file> Import GPG key
5303
+ pag key-list List trusted keys
5304
+ pag key-remove <id> Remove key
5305
+ pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
5306
+ pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
5307
+ pag key-trusted List pinned repo fingerprints
5308
+
5309
+ADVANCED:
5310
+ pag why <pkg> Show why a package is installed
5311
+ pag autoremove Auto-remove orphaned dependencies
5312
+ pag pin <pkg> [ver] Pin package version
5313
+ pag unpin <pkg> Unpin
5314
+ pag pinned List pinned
5315
+ pag history Transaction history
5316
+ pag rollback Rollback last transaction
5317
+ pag remove-orphans Remove orphaned deps
5318
+ pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
5319
+ pag repo-list List repositories
5320
+ pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
5321
+
5322
+FLATPAK:
5323
+ pag flatpak [<query>] Search & install (smart)
5324
+ pag flatpak search <q> Search Flathub
5325
+ pag flatpak install <id> Install flatpak
5326
+ pag flatpak remove <id> Remove flatpak
5327
+ pag flatpak list List installed flatpaks
5328
+ pag flatpak update Update all flatpaks
5329
+ pag flatpak info <id> Show flatpak details
5330
+
5331
+IMMUTABLE OS (PAG_IMMUTABLE=1):
5332
+ pag deploy-list List all deployments
5333
+ pag deploy-rollback Switch to previous deployment
5334
+ pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
5335
+ pag initramfs-update Rebuild initramfs for current kernel/deployment
5336
+ pag grub-update Regenerate GRUB entries for all deployments
5337
+"""
5338
+
5339
+USAGE_PL = """pag v3 – Pagan Linux Package Manager
5340
+
5341
+PODSTAWOWE:
5342
+ pag install <pkg>... Instalacja pakietów
5343
+ pag remove <pkg>... Usuwanie pakietów
5344
+ pag update Aktualizacja PAKIETÓW (odświeża indeksy)
5345
+ pag sync Odśwież indeksy + info o aktualizacjach
5346
+ pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
5347
+ pag list [--installed] Lista dostępnych / zainstalowanych
5348
+ pag search <query> Szukaj pakietów
5349
+ pag info <pkg> Szczegóły pakietu
5350
+ pag files <pkg> Lista plików pakietu
5351
+ pag verify [--deep] Weryfikacja integralności
5352
+ pag clean Wyczyść cache pobierania
5353
+ pag stats Statystyki systemu
5354
+ pag download <pkg>... Pobierz do cache (offline)
5355
+
5356
+BEZPIECZEŃSTWO:
5357
+ pag key-add <url|file> Importuj klucz GPG
5358
+ pag key-list Lista zaufanych kluczy
5359
+ pag key-remove <id> Usuń klucz
5360
+ pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
5361
+ pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
5362
+ pag key-trusted Lista przypiętych fingerprintów repo
5363
+
5364
+ZAAWANSOWANE:
5365
+ pag why <pkg> Dlaczego pakiet jest zainstalowany
5366
+ pag autoremove Usuń osierocone zależności
5367
+ pag pin <pkg> [ver] Przypnij wersję pakietu
5368
+ pag unpin <pkg> Odepnij
5369
+ pag pinned Lista przypiętych
5370
+ pag history Historia transakcji
5371
+ pag rollback Cofnij ostatnią transakcję
5372
+ pag remove-orphans Usuń osierocone zależności
5373
+ pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
5374
+ pag repo-list Lista repozytoriów
5375
+ pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
5376
+
5377
+FLATPAK:
5378
+ pag flatpak [<query>] Szukaj i instaluj
5379
+ pag flatpak search <q> Szukaj na Flathub
5380
+ pag flatpak install <id> Zainstaluj flatpak
5381
+ pag flatpak remove <id> Usuń flatpak
5382
+ pag flatpak list Lista zainstalowanych
5383
+ pag flatpak update Aktualizuj wszystkie
5384
+ pag flatpak info <id> Szczegóły flatpaka
5385
+
5386
+IMMUTABLE OS (PAG_IMMUTABLE=1):
5387
+ pag deploy-list Lista wdrożeń
5388
+ pag deploy-rollback Przełącz na poprzednie wdrożenie
5389
+ pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
5390
+ pag initramfs-update Przebuduj initramfs
5391
+ pag grub-update Regeneruj wpisy GRUB"""
5392
+
5393
+def _get_usage():
5394
+ # Plik językowy może dostarczyć klucz "usage" – wtedy wygrywa z wbudowanym.
5395
+ _u = T.get(LANG, {}).get("usage")
5396
+ if _u:
5397
+ return _u
5398
+ if LANG == "pl":
5399
+ return USAGE_PL
5400
+ return USAGE_EN
5401
+
5402
+
5403
+def _extract_lang(outdir: str) -> int:
5404
+ """Eksport wbudowanych tłumaczeń do outdir/{pl,en}.json (+ klucz "usage").
5405
+
5406
+ Używane przez recepturę pakietu (pag.pag), żeby tłumaczenia jechały RAZEM
5407
+ z wersją paga – po `pag install/upgrade pag` i self-update są zawsze zgodne.
5408
+ """
5409
+ os.makedirs(outdir, exist_ok=True)
5410
+ for _code in ("pl", "en"):
5411
+ _d = dict(T.get(_code, {}))
5412
+ _u = globals().get(f"USAGE_{_code.upper()}", "")
5413
+ if _u:
5414
+ _d["usage"] = _u
5415
+ _p = os.path.join(outdir, f"{_code}.json")
5416
+ with open(_p, "w", encoding="utf-8") as _fh:
5417
+ json.dump(_d, _fh, ensure_ascii=False, indent=2, sort_keys=True)
5418
+ print(f" ✓ {_p} ({len(_d)} kluczy)")
5419
+ return 0
5420
+
5421
+
5422
+def main():
5423
+ # Ukryte (używane przy budowie pakietu): eksport tłumaczeń do plików
5424
+ if len(sys.argv) >= 3 and sys.argv[1] == "--lang-extract":
5425
+ sys.exit(_extract_lang(sys.argv[2]))
5426
+ if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
5427
+ print(f"pag {PAG_VERSION}")
5428
+ sys.exit(0)
5429
+ if len(sys.argv) == 2 and sys.argv[1] in ("--help", "-h", "help"):
5430
+ print(_get_usage()); sys.exit(0)
5431
+ if len(sys.argv) < 2:
5432
+ print(_get_usage()); sys.exit(0)
5433
+
5434
+ cmd = sys.argv[1]
5435
+ args = sys.argv[2:]
5436
+
5437
+ # Python bez modułu ssl = brak HTTPS w urllib. Powiedz to wprost, zamiast
5438
+ # pokazywać mylące „unknown url type: https” przy każdej operacji sieciowej.
5439
+ if not _ssl_ok():
5440
+ print(f" ⚠ {_('ssl_broken')}", file=sys.stderr)
5441
+
5442
+ # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
5443
+ READ_ONLY = {
5444
+ "list": lambda: cmd_list("--installed" in args),
5445
+ "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
5446
+ "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
5447
+ "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
5448
+ "verify": lambda: cmd_verify("--deep" in args),
5449
+ "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
5450
+ "stats": cmd_stats,
5451
+ "pinned": cmd_pinned,
5452
+ "history": cmd_history,
5453
+ "repo-list": cmd_repo_list,
5454
+ "key-list": cmd_key_list,
5455
+ "key-trusted": cmd_key_trusted,
5456
+ "flatpak": lambda: cmd_flatpak(args),
5457
+ "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
5458
+ "flatpak-list": cmd_flatpak_list,
5459
+ "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
5460
+ "deploy-list": cmd_deploy_list,
5461
+ "deploy": cmd_deploy_list,
5462
+ "sbom": lambda: cmd_sbom(args),
5463
+ }
5464
+
5465
+ if cmd in READ_ONLY:
5466
+ sys.exit(READ_ONLY[cmd]() or 0)
5467
+
5468
+ # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
5469
+ WRITE_CMDS = {
5470
+ "install", "remove", "update", "sync", "upgrade", "clean", "download",
5471
+ "autoremove", "remove-orphans", "pin", "unpin", "rollback",
5472
+ "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
5473
+ "self-update",
5474
+ "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
5475
+ "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
5476
+ }
5477
+ if cmd not in WRITE_CMDS:
5478
+ # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
5479
+ # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
5480
+ # potrafi wisieć na `flatpak search` aż do Ctrl-C).
5481
+ _known = set(READ_ONLY) | set(WRITE_CMDS)
5482
+ _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
5483
+ if _close:
5484
+ print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
5485
+ print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
5486
+ sys.exit(1)
5487
+ sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5488
+
5489
+ # Obsługa flag globalnych (-y/--yes)
5490
+ global_args = []
5491
+ for a in args:
5492
+ if a in ("-y", "--yes"):
5493
+ os.environ["PAG_YES"] = "1"
5494
+ else:
5495
+ global_args.append(a)
5496
+ args = global_args
5497
+
5498
+ # --- Komendy ZAPISU (wymagają roota) ---
5499
+ if os.geteuid() != 0:
5500
+ print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
5501
+
5502
+ ensure_dirs()
5503
+
5504
+ with DatabaseLock():
5505
+ WRITE_COMMANDS = {
5506
+ "install": lambda: cmd_install(
5507
+ [a for a in args if a not in ("-f", "--force")],
5508
+ upgrade=("-f" in args or "--force" in args)),
5509
+ "remove": lambda: cmd_remove(args),
5510
+ "update": lambda: cmd_update(do_upgrade=True),
5511
+ "sync": lambda: cmd_update(do_upgrade=False),
5512
+ "upgrade": cmd_upgrade,
5513
+ "clean": cmd_clean,
5514
+ "download": lambda: cmd_download(args),
5515
+ "autoremove": cmd_autoremove,
5516
+ "remove-orphans": cmd_remove_orphans,
5517
+ "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
5518
+ "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
5519
+ "rollback": cmd_rollback,
5520
+ "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
5521
+ "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
5522
+ "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
5523
+ "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
5524
+ "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
5525
+ "self-update": cmd_self_update,
5526
+ "flatpak": lambda: cmd_flatpak(args),
5527
+ "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
5528
+ "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
5529
+ "flatpak-update": cmd_flatpak_update,
5530
+ "deploy-rollback": cmd_deploy_rollback,
5531
+ "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
5532
+ "initramfs-update": cmd_initramfs_update,
5533
+ "grub-update": cmd_grub_update,
5534
+ }
5535
+
5536
+ fn = WRITE_COMMANDS.get(cmd)
5537
+ if fn:
5538
+ sys.exit(fn() or 0)
5539
+ # Should never reach here – _smart_search handles unknowns
5540
+ sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5541
+
5542
+if __name__ == "__main__":
5543
+ try:
5544
+ main()
5545
+ except KeyboardInterrupt:
5546
+ # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
5547
+ print("\n ⚠ Przerwano (Ctrl-C).")
5534
5548
sys.exit(130)