← pag

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)