Commit b959bcc
0
plików
+0
dodanych
-0
usuniętych
@@ -1,4878 +1,5027 @@
1
-#!/usr/bin/env python3
2
-"""
3
-╔══════════════════════════════════════════════════════════════════════════════╗
4
-║ PAG - Pagan Linux Package Manager v3.3.17 ║
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, 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.17"
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 _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
170
- """
171
- Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
172
- gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
173
- Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
174
- (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
175
- """
176
- env = kwargs.pop("env", None) or os.environ.copy()
177
- env["LC_ALL"] = "C"
178
- env["GNUPGHOME"] = GPG_HOME
179
- try:
180
- return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
181
- except FileNotFoundError:
182
- # GPG nie jest dostępne – zwróć błąd z komunikatem
183
- # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
184
- _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
185
- _msg = f"GPG binary not found ({GPG_BINARY})"
186
- return subprocess.CompletedProcess(
187
- [GPG_BINARY, *args], 127,
188
- stdout=("" if _text else b""),
189
- stderr=(_msg if _text else _msg.encode()),
190
- )
191
- except subprocess.TimeoutExpired:
192
- return subprocess.CompletedProcess(
193
- [GPG_BINARY, *args], 124,
194
- stdout=b"", stderr=b"GPG operation timed out"
195
- )
196
-
197
-def _load_trust_db() -> dict:
198
- """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
199
- try:
200
- with open(TRUST_DB) as f:
201
- return json.load(f)
202
- except (FileNotFoundError, json.JSONDecodeError):
203
- return {}
204
-
205
-
206
-def _save_trust_db(db: dict):
207
- os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
208
- with open(TRUST_DB, "w") as f:
209
- json.dump(db, f, indent=2)
210
-
211
-
212
-def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
213
- """Weryfikuje podpis i odczytuje fingerprint podpisującego.
214
-
215
- Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
216
- """
217
- env = os.environ.copy()
218
- res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
219
- capture_output=True, text=True, timeout=timeout, env=env)
220
- if res.returncode != 0:
221
- return False, None
222
- m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
223
- if not m:
224
- m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
225
- return True, (m.group(1).upper() if m else None)
226
-
227
-
228
-# =============================================================================
229
-# i18n – WIELOJĘZYCZNOŚĆ
230
-# =============================================================================
231
-
232
-LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
233
-COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
234
-
235
-def _c(code: str, text: str) -> str:
236
- """Dodaje kody ANSI jeśli kolor jest włączony."""
237
- if not COLOR:
238
- return text
239
- colors = {
240
- "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
241
- "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
242
- "reset": "\033[0m",
243
- }
244
- return f"{colors.get(code,'')}{text}{colors['reset']}"
245
-
246
-T = {
247
- "en": {
248
- "root_required": "pag requires root privileges (sudo).",
249
- "db_locked": "Another pag instance is running.",
250
- "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
251
- "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
252
- "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
253
- "all_installed": "All packages are already installed.",
254
- "to_install": "To install: {} packages ({:.2f} MB)",
255
- "new": "NEW",
256
- "continue_q": "Continue? [Y/n] ",
257
- "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
258
- "cancelled": "Cancelled.",
259
- "not_found": "not found in repos",
260
- "pkg_not_found": "Package not found: {} (not in any repo)",
261
- "not_found_hint": "Check the spelling or run 'pag search <query>'.",
262
- "downloading": "Downloading",
263
- "download_fail": "download failed",
264
- "gpg_fail": "GPG verification failed",
265
- "sha256_mismatch": "SHA256 mismatch",
266
- "installed": "Installed {} packages.",
267
- "rollback_restored": "Restored previous state from snapshot.",
268
- "rollback_files": "Rolled back {} files.",
269
- "no_history": "No transaction history.",
270
- "pinned_list": "Pinned packages ({}):",
271
- "no_pinned": "No pinned packages.",
272
- "pinned_to": "pinned to",
273
- "unpinned": "unpinned.",
274
- "not_pinned": "was not pinned.",
275
- "repo_added": "Added repository: {}",
276
- "repo_exists": "Repository already exists: {}",
277
- "updated_done": "Index refresh complete. {} packages cached.",
278
- "indexes_refreshed": "Indexes refreshed.",
279
- "updates_available": "⚠ {} packages have updates – run: pag update",
280
- "upgrading": "Upgrading: {} packages",
281
- "all_up_to_date": "All packages are up to date.",
282
- "removing": "Removing",
283
- "orphans_found": "Orphaned dependencies ({}): {}",
284
- "flatpak_missing": "Flatpak is not installed.",
285
- "flatpak_adding": "Adding Flathub remote...",
286
- "flatpak_searching": "Searching Flathub for '{}'...",
287
- "flatpak_found": "Found {} results:",
288
- "flatpak_not_found": "not found on Flathub",
289
- "flatpak_install_prompt": "Install {}? [Y/n] ",
290
- "flatpak_installing": "Installing {}...",
291
- "flatpak_installed": "Flatpak {} installed.",
292
- "flatpak_removed": "Flatpak {} removed.",
293
- "flatpak_not_installed": "Flatpak {} is not installed.",
294
- "flatpak_info_id": "ID",
295
- "flatpak_info_version": "Version",
296
- "flatpak_info_branch": "Branch",
297
- "flatpak_info_origin": "Origin",
298
- "flatpak_info_size": "Installed size",
299
- "flatpak_info_desc": "Description",
300
- "flatpak_updated": "Flatpaks updated.",
301
- "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
302
- "key_imported": "Key imported successfully.",
303
- "key_removed": "Key removed: {}",
304
- "no_keys": "No trusted GPG keys.",
305
- "verify_ok": "All {} files intact.",
306
- "verify_errors": "{} problems found:",
307
- "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
308
- "deployments_list": "Deployments ({}):",
309
- "no_deployments": "No deployments.",
310
- "active_deployment": "ACTIVE",
311
- "deploy_rollback_ok": "Switched to deployment: {}",
312
- "deploy_rollback_fail": "No previous deployment.",
313
- "deploy_cleanup_ok": "Removed {} old deployments.",
314
- "deploy_cleanup_none": "No deployments to clean (minimum {}).",
315
- "why_explicit": "explicitly installed",
316
- "why_dependency": "dependency of",
317
- "why_not_installed": "not installed",
318
- "autoremove_ok": "Removed {} orphaned packages.",
319
- "autoremove_none": "No orphaned packages.",
320
- "downloaded": "Downloaded {} to cache ({:.2f} MB).",
321
- "provides_mapped": "{} → {} (provides)",
322
- "stats_title": "PAG Statistics",
323
- "stats_packages": "Installed packages",
324
- "stats_files": "Tracked files",
325
- "stats_size": "Total size",
326
- "stats_cache": "Cache size",
327
- "stats_history": "Transactions",
328
- "stats_last_update": "Last update",
329
- },
330
- "pl": {
331
- "root_required": "pag wymaga uprawnień root (sudo).",
332
- "db_locked": "Inna instancja pag jest uruchomiona.",
333
- "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
334
- "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
335
- "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",
336
- "all_installed": "Wszystkie pakiety są już zainstalowane.",
337
- "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
338
- "new": "NOWY",
339
- "continue_q": "Kontynuować? [T/n] ",
340
- "no_tty": "Brak terminala (EOF) – anuluję.",
341
- "cancelled": "Anulowano.",
342
- "not_found": "brak w repozytoriach",
343
- "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
344
- "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
345
- "downloading": "Pobieranie",
346
- "download_fail": "błąd pobierania",
347
- "gpg_fail": "błąd weryfikacji GPG",
348
- "sha256_mismatch": "niezgodność SHA256",
349
- "installed": "Zainstalowano {} pakietów.",
350
- "rollback_restored": "Przywrócono poprzedni stan z migawki.",
351
- "rollback_files": "Wycofano {} plików.",
352
- "no_history": "Brak historii transakcji.",
353
- "pinned_list": "Przypięte pakiety ({}):",
354
- "no_pinned": "Brak przypiętych pakietów.",
355
- "pinned_to": "przypięty do",
356
- "unpinned": "odpięty.",
357
- "not_pinned": "nie był przypięty.",
358
- "repo_added": "Dodano repozytorium: {}",
359
- "repo_exists": "Repozytorium już istnieje: {}",
360
- "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
361
- "indexes_refreshed": "Indeksy odświeżone.",
362
- "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
363
- "upgrading": "Aktualizacje: {} pakietów",
364
- "all_up_to_date": "Wszystkie pakiety są aktualne.",
365
- "removing": "Usuwanie",
366
- "orphans_found": "Osierocone zależności ({}): {}",
367
- "flatpak_missing": "Flatpak nie jest zainstalowany.",
368
- "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
369
- "flatpak_searching": "Szukam '{}' we Flathub...",
370
- "flatpak_found": "Znaleziono {} wyników:",
371
- "flatpak_not_found": "nie znaleziono we Flathub",
372
- "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
373
- "flatpak_installing": "Instalowanie {}...",
374
- "flatpak_installed": "Flatpak {} zainstalowany.",
375
- "flatpak_removed": "Flatpak {} usunięty.",
376
- "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
377
- "flatpak_info_id": "ID",
378
- "flatpak_info_version": "Wersja",
379
- "flatpak_info_branch": "Gałąź",
380
- "flatpak_info_origin": "Źródło",
381
- "flatpak_info_size": "Rozmiar",
382
- "flatpak_info_desc": "Opis",
383
- "flatpak_updated": "Flapaki zaktualizowane.",
384
- "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
385
- "key_imported": "Klucz zaimportowany pomyślnie.",
386
- "key_removed": "Klucz usunięty: {}",
387
- "no_keys": "Brak zaufanych kluczy GPG.",
388
- "verify_ok": "Wszystkie {} plików sprawne.",
389
- "verify_errors": "Znaleziono {} problemów:",
390
- "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
391
- "deployments_list": "Deploymenty ({}):",
392
- "no_deployments": "Brak deploymentów.",
393
- "active_deployment": "AKTYWNY",
394
- "deploy_rollback_ok": "Przełączono na deployment: {}",
395
- "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
396
- "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
397
- "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
398
- "why_explicit": "zainstalowany jawnie",
399
- "why_dependency": "zależność od",
400
- "why_not_installed": "niezainstalowany",
401
- "autoremove_ok": "Usunięto {} osieroconych pakietów.",
402
- "autoremove_none": "Brak osieroconych pakietów.",
403
- "downloaded": "Pobrano {} do cache ({:.2f} MB).",
404
- "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
405
- "sec_suid": "SUID stripped from {path}",
406
- "sec_https": "HTTPS required for repos",
407
- "sec_badname": "Invalid package name: {name}",
408
- "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
409
- "sec_conflict": "File conflict: {path} owned by {owner}",
410
- "sec_audit": "{pkg} installed by {user}",
411
- "sec_locked": "Another pag process is running",
412
- "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
413
- "sec_suid_pl": "SUID usuniety z {path}",
414
- "sec_https_pl": "Repozytorium wymaga HTTPS",
415
- "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
416
- "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
417
- "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
418
- "sec_audit_pl": "{pkg} zainstalowany przez {user}",
419
- "sec_locked_pl": "Inny proces pag juz dziala",
420
-
421
- "provides_mapped": "{} → {} (provides)",
422
- "stats_title": "Statystyki PAG",
423
- "stats_packages": "Zainstalowane pakiety",
424
- "stats_files": "Śledzone pliki",
425
- "stats_size": "Całkowity rozmiar",
426
- "stats_cache": "Rozmiar cache",
427
- "stats_history": "Transakcje",
428
- "stats_last_update": "Ostatnia aktualizacja",
429
- },
430
-}
431
-
432
-def _(key: str, *args, **kwargs) -> str:
433
- """Tłumaczy klucz i formatuje argumenty."""
434
- msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
435
- if args or kwargs:
436
- return msg.format(*args, **kwargs)
437
- return msg
438
-
439
-
440
-def _ask_confirm() -> bool:
441
- """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
442
-
443
- EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
444
- panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
445
- (inaczej input() rzuca EOFError i pag pada tracebackiem).
446
- Enter → tak (domyślne Y/n).
447
- """
448
- if os.environ.get("PAG_YES", "") == "1":
449
- print(_("continue_q") + " t (--yes)")
450
- return True
451
- try:
452
- ans = input(_("continue_q")).strip().lower()
453
- except (EOFError, KeyboardInterrupt):
454
- print(f"\n ⚠ {_('no_tty')}")
455
- return False
456
- return not ans or ans in ("t", "y")
457
-
458
-
459
-# =============================================================================
460
-# ŚCIEŻKI
461
-# =============================================================================
462
-PAG_ROOT = os.environ.get("PAG_ROOT", "/")
463
-PAG_DB = "/var/lib/pag"
464
-PAG_CACHE = "/var/cache/pag"
465
-PAG_CONF = "/etc/pag"
466
-REPO_CACHE = "/var/cache/pag/repos"
467
-REPOS_CONF = "/etc/pag/repos.conf"
468
-REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
469
-INSTALLED_DB = "/var/lib/pag/installed.json"
470
-FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
471
-WORLD_FILE = "/var/lib/pag/world"
472
-PINNED_FILE = "/var/lib/pag/pinned.json"
473
-HISTORY_FILE = "/var/lib/pag/history.json"
474
-LOCK_FILE = "/var/lib/pag/pag.lock"
475
-STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
476
-PKG_EXT = ".pag"
477
-REPO_CACHE_TTL = 3600
478
-MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
479
-ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
480
-
481
-# Bezpieczeństwo / audyt
482
-AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
483
-TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
484
-HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
485
-
486
-# =============================================================================
487
-# IMMUTABLE OS – DEPLOYMENTY
488
-# =============================================================================
489
-# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
490
-# /var, /etc, /home są współdzielone między deploymentami.
491
-#
492
-# STRUKTURA:
493
-# /.deployments/
494
-# active → 20260723T120000 (symlink do aktywnego)
495
-# 20260723T120000/
496
-# usr/ bin/ lib/ lib64/ ... (pełny system)
497
-# var → /var (symlink do współdzielonego)
498
-# etc → /etc
499
-# home → /home
500
-# ...
501
-#
502
-# Jak to działa:
503
-# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
504
-# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
505
-# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
506
-# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
507
-# =============================================================================
508
-
509
-DEPLOYMENTS_DIR = "/.deployments"
510
-ACTIVE_LINK = "/.deployments/active"
511
-DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
512
-
513
-# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
514
-SHARED_PATHS = {
515
- "/var", "/etc", "/home", "/root", "/tmp", "/run",
516
- "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
517
- "/.deployments", "/.pag_staging",
518
-}
519
-
520
-def _is_shared_path(rel: str) -> bool:
521
- """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
522
- for sp in SHARED_PATHS:
523
- if rel == sp or rel.startswith(sp + "/"):
524
- return True
525
- return False
526
-
527
-def _get_deployment_root() -> str:
528
- """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
529
- if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
530
- return PAG_ROOT
531
- if os.path.islink(ACTIVE_LINK):
532
- return os.readlink(ACTIVE_LINK)
533
- if os.path.isdir(ACTIVE_LINK):
534
- return ACTIVE_LINK
535
- # Brak deploymentów – użyj /
536
- return PAG_ROOT
537
-
538
-def _load_deployments() -> List[dict]:
539
- """Wczytuje historię deploymentów."""
540
- if not os.path.exists(DEPLOYMENTS_DB):
541
- return []
542
- try:
543
- return json.load(open(DEPLOYMENTS_DB))
544
- except Exception:
545
- return []
546
-
547
-def _save_deployments(deployments: List[dict]):
548
- os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
549
- json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
550
-
551
-def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
552
- """
553
- Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
554
- Zwraca (deployment_dir, deployment_id).
555
- """
556
- deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
557
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
558
- os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
559
-
560
- active = _get_deployment_root()
561
-
562
- if os.path.isdir(active) and active != PAG_ROOT:
563
- # Trójstopniowa strategia kopiowania deploymentu:
564
- # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
565
- # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
566
- # 3. zwykłe cp (ostateczność) → pełna kopia
567
- print(f" ⚡ Kopiowanie aktywnego deploymentu...")
568
- copied = False
569
- for method, cmd, label in [
570
- ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
571
- ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
572
- ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
573
- ]:
574
- try:
575
- subprocess.run(cmd, check=True, timeout=600, capture_output=True)
576
- print(f" ✅ Deployment: {deploy_id} ({label})")
577
- copied = True
578
- break
579
- except subprocess.CalledProcessError:
580
- if method == "copy":
581
- raise # ostatnia deska – niech leci wyjątek
582
- continue
583
- if not copied:
584
- raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
585
- else:
586
- # Pierwszy deployment – tylko katalogi szkieletowe
587
- for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
588
- if os.path.isdir(d):
589
- dest = os.path.join(deploy_dir, d.lstrip("/"))
590
- os.makedirs(dest, exist_ok=True)
591
- print(f" ✅ Pierwszy deployment: {deploy_id}")
592
-
593
- # Utwórz symlinki do współdzielonych katalogów
594
- for sp in SHARED_PATHS:
595
- link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
596
- if not os.path.lexists(link_dst) and os.path.isdir(sp):
597
- os.symlink(sp, link_dst)
598
-
599
- # Zapisz w bazie deploymentów
600
- deployments = _load_deployments()
601
- deployments.append({
602
- "id": deploy_id,
603
- "action": action,
604
- "packages": pkg_names,
605
- "timestamp": datetime.now().isoformat(),
606
- "active": True,
607
- })
608
- # Oznacz poprzednie jako nieaktywne
609
- for d in deployments[:-1]:
610
- d["active"] = False
611
- _save_deployments(deployments)
612
-
613
- return deploy_dir, deploy_id
614
-
615
-def _switch_deployment(deploy_dir: str) -> bool:
616
- """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
617
- tmp_link = ACTIVE_LINK + ".new"
618
- if os.path.lexists(tmp_link):
619
- os.remove(tmp_link)
620
- os.symlink(deploy_dir, tmp_link)
621
- os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
622
- return True
623
-
624
-DEFAULT_REPOS = [
625
- "https://repo.paganlinux.eu/stable/",
626
-]
627
-
628
-# =============================================================================
629
-# INICJALIZACJA
630
-# =============================================================================
631
-
632
-def ensure_dirs():
633
- for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
634
- os.makedirs(d, exist_ok=True)
635
- for f, default in [
636
- (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
637
- (INSTALLED_DB, "{}"),
638
- (PINNED_FILE, "{}"),
639
- (HISTORY_FILE, "[]"),
640
- ]:
641
- if not os.path.exists(f):
642
- with open(f, "w") as fh: fh.write(default)
643
- if not os.path.exists(WORLD_FILE):
644
- Path(WORLD_FILE).touch()
645
- if not os.path.exists(GPG_HOME):
646
- os.makedirs(GPG_HOME, exist_ok=True)
647
- os.chmod(GPG_HOME, 0o700)
648
- _gpg_run("--list-keys", capture_output=True)
649
- # Inicjalizuj SQLite
650
- _db_init()
651
- # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
652
- if os.path.isdir(STAGING_DIR):
653
- for entry in os.listdir(STAGING_DIR):
654
- if entry == "backups":
655
- continue # backupy starych wersji – potrzebne do `pag rollback`
656
- path = os.path.join(STAGING_DIR, entry)
657
- try:
658
- if os.path.isfile(path) or os.path.islink(path):
659
- os.unlink(path)
660
- elif os.path.isdir(path):
661
- shutil.rmtree(path, ignore_errors=True)
662
- except OSError:
663
- pass
664
-
665
-# =============================================================================
666
-# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
667
-# =============================================================================
668
-
669
-from contextlib import contextmanager
670
-
671
-@contextmanager
672
-def _db_session():
673
- """Context manager – gwarantuje zamknięcie połączenia."""
674
- conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
675
- conn.execute("PRAGMA journal_mode=WAL")
676
- conn.execute("PRAGMA synchronous=NORMAL")
677
- conn.execute("PRAGMA foreign_keys=ON")
678
- conn.execute("PRAGMA busy_timeout=15000")
679
- conn.row_factory = sqlite3.Row
680
- try:
681
- yield conn
682
- conn.commit()
683
- except Exception:
684
- conn.rollback()
685
- raise
686
- finally:
687
- conn.close()
688
-
689
-
690
-def _db_init():
691
- """Tworzy tabele SQLite jeśli nie istnieją."""
692
- with _db_session() as db:
693
- db.execute("""
694
- CREATE TABLE IF NOT EXISTS files (
695
- id INTEGER PRIMARY KEY AUTOINCREMENT,
696
- path TEXT NOT NULL,
697
- package TEXT NOT NULL,
698
- sha256 TEXT,
699
- size INTEGER,
700
- is_symlink INTEGER DEFAULT 0,
701
- symlink_target TEXT,
702
- UNIQUE(path, package)
703
- )
704
- """)
705
- db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
706
- db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
707
- db.execute("""
708
- CREATE TABLE IF NOT EXISTS file_checksums (
709
- path TEXT PRIMARY KEY,
710
- sha256 TEXT NOT NULL,
711
- installed_at TEXT
712
- )
713
- """)
714
- db.commit()
715
-
716
-def _db_record_files(pkg_name: str, files: List[dict]):
717
- """Zapisuje pliki do SQLite (obsługuje symlinki)."""
718
- with _db_session() as db:
719
- # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
720
- try:
721
- db.execute("BEGIN IMMEDIATE")
722
- except sqlite3.OperationalError:
723
- pass # transakcja już otwarta (implicit)
724
- db.executemany(
725
- "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
726
- "VALUES (?,?,?,?,?,?)",
727
- [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
728
- f.get("is_symlink", 0), f.get("symlink_target", ""))
729
- for f in files]
730
- )
731
- db.executemany(
732
- "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
733
- [(f["path"], f.get("sha256",""), datetime.now().isoformat())
734
- for f in files if f.get("sha256")]
735
- )
736
-
737
-def _db_get_package_files(pkg_name: str) -> List[str]:
738
- with _db_session() as db:
739
- return [r["path"] for r in db.execute(
740
- "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
741
- )]
742
-
743
-def _db_get_file_owners(filepath: str) -> List[str]:
744
- """Zwraca listę pakietów będących właścicielami pliku."""
745
- with _db_session() as db:
746
- return [r["package"] for r in db.execute(
747
- "SELECT package FROM files WHERE path=?", (filepath,)
748
- )]
749
-
750
-def _db_remove_package_files(pkg_name: str):
751
- with _db_session() as db:
752
- db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
753
- db.commit()
754
-
755
-def _db_get_all_file_checksums() -> Dict[str, str]:
756
- with _db_session() as db:
757
- return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
758
-
759
-def _db_count_files() -> int:
760
- with _db_session() as db:
761
- return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
762
-
763
-# =============================================================================
764
-# BLOKADA
765
-# =============================================================================
766
-
767
-class DatabaseLock:
768
- """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
769
- ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
770
- śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
771
- i pag odmawiał działania na zawsze („baza zablokowana”).
772
- """
773
- def __init__(self):
774
- self._f = None
775
- def __enter__(self):
776
- os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
777
- self._f = open(LOCK_FILE, "w")
778
- try:
779
- # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
780
- fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
781
- except BlockingIOError:
782
- print(f"❌ {_('db_locked')}", file=sys.stderr)
783
- print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
784
- sys.exit(1)
785
- self._f.write(str(os.getpid()))
786
- self._f.flush()
787
- return self
788
- def __exit__(self, *args):
789
- if self._f:
790
- try:
791
- fcntl.flock(self._f, fcntl.LOCK_UN)
792
- except OSError:
793
- pass
794
- self._f.close()
795
- self._f = None
796
- # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
797
- # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
798
- # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
799
-
800
-# =============================================================================
801
-# POMOCNICZE
802
-# =============================================================================
803
-
804
-
805
-_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
806
- "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
807
- # Pliki wewnętrzne paczki .pkg.tar.xz
808
- "metadata.json", "data.tar.xz", "hooks/",
809
- "sums.json")
810
-
811
-def _check_path_safety(name: str) -> bool:
812
- # Normalizuj – usuń leading ./
813
- if name.startswith("./"):
814
- name = name[2:]
815
- if name in (".", ""):
816
- return True
817
- # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
818
- # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
819
- norm = name.lstrip("/")
820
- for prefix in _ALLOWED_PREFIXES:
821
- p = prefix.lstrip("/").rstrip("/")
822
- if norm == p or norm.startswith(p + "/"):
823
- return True
824
- return False
825
-
826
-
827
-def _validate_pkg_name(name):
828
- return bool(ALLOWED_PKG_RE.match(name))
829
-
830
-
831
-
832
-def _audit(msg):
833
- from datetime import datetime, timezone
834
- os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
835
- with open(AUDIT_LOG, "a") as f:
836
- f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
837
-
838
-def _strip_suid(path):
839
- try:
840
- st = os.stat(path)
841
- if st.st_mode & 0o4000:
842
- os.chmod(path, st.st_mode & ~0o4000)
843
- print(f" {_("sec_suid", path=path)}")
844
- except OSError:
845
- pass
846
-
847
-def _check_downgrade(pkg_name, new_ver, installed_db):
848
- if pkg_name in installed_db:
849
- old = installed_db[pkg_name].get("version", "0")
850
- if new_ver < old:
851
- print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
852
- return False
853
- return True
854
-
855
-def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
856
- """
857
- Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
858
-
859
- Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
860
- oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
861
- zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
862
-
863
- Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
864
- prostym sprawdzaniu stringa:
865
- - Blokuje ścieżki absolutne i z '..' (path traversal)
866
- - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
867
- - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
868
- rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
869
- - Zachowuje oryginalne uprawnienia plików
870
- """
871
- dest_real = os.path.realpath(dest)
872
- os.makedirs(dest_real, exist_ok=True)
873
-
874
- def _target_within(path: str) -> bool:
875
- try:
876
- return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
877
- except ValueError:
878
- # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
879
- return False
880
-
881
- for member in tar.getmembers():
882
- name = member.name
883
-
884
- # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
885
- if name.startswith('/'):
886
- continue
887
- if '..' in name.split('/'):
888
- continue
889
- # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
890
- if '\x00' in name or '\\' in name:
891
- continue
892
- if not _check_path_safety(name):
893
- print(f" BLOCKED: {name}")
894
- continue
895
-
896
- target = os.path.join(dest, name)
897
-
898
- # --- Ochrona na podstawie finalnej ścieżki ---
899
- # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
900
- # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
901
- if not _target_within(target):
902
- print(f" BLOCKED (escape): {name}")
903
- continue
904
-
905
- # --- Ochrona dla symlinków i hardlinków ---
906
- if member.issym() or member.islnk():
907
- link = member.linkname
908
- # Szybkie odrzucenie linków absolutnych / z '..'
909
- if link.startswith('/') or '..' in link.split('/'):
910
- continue
911
- # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
912
- link_target = os.path.join(os.path.dirname(target), link)
913
- if not _target_within(link_target):
914
- print(f" BLOCKED (link escape): {name} -> {link}")
915
- continue
916
-
917
- # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
918
- # `filter=` (inaczej DeprecationWarning, w 3.14+ błąd) – nasza ręczna
919
- # walidacja powyżej już zabezpiecza ścieżki, więc 'fully_trusted'
920
- # (pomija filtr Pythona i zachowuje SUID/SGID/sticky z preserve_perms).
921
- try:
922
- if hasattr(tarfile, 'data_filter'):
923
- # Python 3.12+
924
- tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
925
- filter='fully_trusted')
926
- else:
927
- tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
928
- except Exception as e:
929
- print(f" ⚠ Nie rozpakowano {name}: {e}")
930
- continue
931
- _strip_suid(target)
932
-
933
-
934
-def _sha256_file(path: str) -> str:
935
- h = hashlib.sha256()
936
- with open(path, "rb") as f:
937
- for chunk in iter(lambda: f.read(65536), b""):
938
- h.update(chunk)
939
- return h.hexdigest()
940
-
941
-def _split_version(v: str):
942
- """Rozdziela wersję na (release_parts, prerelease_parts).
943
-
944
- Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
945
- """
946
- v = v.strip().lower().lstrip("v")
947
- # build metadata po '+' jest ignorowane przy porównywaniu (semver)
948
- v = v.split("+", 1)[0]
949
- # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
950
- if "-" in v:
951
- rel, pre = v.split("-", 1)
952
- elif "_" in v:
953
- rel, pre = v.split("_", 1)
954
- else:
955
- rel, pre = v, ""
956
- nums = []
957
- for part in rel.split("."):
958
- m = re.match(r"(\d+)", part)
959
- nums.append(int(m.group(1)) if m else 0)
960
- pre_parts = [p for p in pre.split(".") if p]
961
- return nums, pre_parts
962
-
963
-
964
-def _cmp_pre(a, b):
965
- """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
966
- for i in range(max(len(a), len(b))):
967
- if i >= len(a):
968
- return -1 # krótszy prerelease jest niższy
969
- if i >= len(b):
970
- return 1
971
- ia, ib = a[i], b[i]
972
- if ia == ib:
973
- continue
974
- na, nb = ia.isdigit(), ib.isdigit()
975
- if na and nb:
976
- return 1 if int(ia) > int(ib) else -1
977
- if na != nb:
978
- return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
979
- return 1 if ia > ib else -1
980
- return 0
981
-
982
-
983
-def _cmp_version(a: str, b: str) -> int:
984
- """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
985
- a_rel, a_pre = _split_version(a)
986
- b_rel, b_pre = _split_version(b)
987
- # Porównaj część release (brakujące komponenty traktuj jako 0)
988
- for i in range(max(len(a_rel), len(b_rel))):
989
- xa = a_rel[i] if i < len(a_rel) else 0
990
- xb = b_rel[i] if i < len(b_rel) else 0
991
- if xa != xb:
992
- return 1 if xa > xb else -1
993
- # Część release równa → decyduje prerelease.
994
- # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
995
- if not a_pre and not b_pre:
996
- return 0
997
- if not a_pre:
998
- return 1
999
- if not b_pre:
1000
- return -1
1001
- return _cmp_pre(a_pre, b_pre)
1002
-
1003
-
1004
-def _version_newer(a: str, b: str) -> bool:
1005
- """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1006
- try:
1007
- return _cmp_version(a, b) > 0
1008
- except Exception:
1009
- return a != b
1010
-
1011
-def load_json(path):
1012
- try:
1013
- with open(path) as f:
1014
- return json.load(f)
1015
- except (FileNotFoundError, json.JSONDecodeError):
1016
- return {}
1017
-
1018
-def save_json(path, data):
1019
- with open(path, "w") as f:
1020
- json.dump(data, f, indent=2)
1021
-
1022
-class PackageInfo:
1023
- __slots__ = ("name","version","release","description","dependencies",
1024
- "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1025
- "provides_so","requires_so")
1026
- def __init__(self, d, repo=""):
1027
- self.name = d.get("name","?")
1028
- self.version = d.get("version","0")
1029
- self.release = d.get("release", 1)
1030
- self.description = d.get("description","")
1031
- self.dependencies = d.get("dependencies", d.get("depends", []))
1032
- self.size_bytes = d.get("size",0)
1033
- self.sha256 = d.get("sha256","")
1034
- self.gpg_fp = d.get("gpg_fingerprint","")
1035
- self.repo_url = repo
1036
- self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1037
- self.provides = d.get("provides", []) or []
1038
- self.license = d.get("license", []) or []
1039
- self.provides_so = d.get("provides_so", []) or []
1040
- self.requires_so = d.get("requires_so", []) or []
1041
-
1042
-# =============================================================================
1043
-# REPOZYTORIA (cache, ETag, GPG)
1044
-# =============================================================================
1045
-
1046
-def _parse_repos_config():
1047
- """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1048
-
1049
- Format linii: <url> [fingerprint]
1050
- Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1051
- podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1052
- pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1053
-
1054
- Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1055
- wygodne dodawanie repo bez dotykania głównego repos.conf
1056
- (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1057
- """
1058
- entries = []
1059
-
1060
- def _read_lines(path):
1061
- if not os.path.exists(path):
1062
- return
1063
- for line in open(path):
1064
- line = line.strip()
1065
- if not line or line.startswith("#"):
1066
- continue
1067
- parts = line.split()
1068
- url = parts[0].rstrip("/")
1069
- fp = parts[1].lower() if len(parts) > 1 else ""
1070
- entries.append({"url": url, "fingerprint": fp or None})
1071
-
1072
- # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1073
- _read_lines(REPOS_CONF)
1074
- # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1075
- if os.path.isdir(REPOS_DIR):
1076
- for drop in sorted(os.listdir(REPOS_DIR)):
1077
- if drop.endswith(".conf"):
1078
- _read_lines(os.path.join(REPOS_DIR, drop))
1079
-
1080
- # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1081
- seen, unique = set(), []
1082
- for e in entries:
1083
- if e["url"] not in seen:
1084
- seen.add(e["url"])
1085
- unique.append(e)
1086
-
1087
- if not unique:
1088
- for url in DEFAULT_REPOS:
1089
- unique.append({"url": url, "fingerprint": None})
1090
- return unique
1091
-
1092
-
1093
-def get_repos():
1094
- return [e["url"] for e in _parse_repos_config()]
1095
-
1096
-
1097
-def _repo_pinned_fp(repo_url):
1098
- """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1099
- by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1100
- if by_url.get(repo_url):
1101
- return by_url[repo_url]
1102
- db = _load_trust_db()
1103
- fp = db.get(repo_url)
1104
- return fp.lower() if fp else None
1105
-
1106
-def _repo_cache_path(url):
1107
- return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1108
-
1109
-def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1110
-def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1111
-
1112
-# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1113
-# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1114
-# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1115
-_cache_ro_notice_done = False
1116
-
1117
-def _cache_ro_notice():
1118
- global _cache_ro_notice_done
1119
- if _cache_ro_notice_done:
1120
- return
1121
- _cache_ro_notice_done = True
1122
- print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1123
-
1124
-def fetch_repo_index(repo_url, force=False):
1125
- cp = _repo_cache_path(repo_url)
1126
- ep = _repo_etag_path(repo_url)
1127
- tp = _repo_ts_path(repo_url)
1128
-
1129
- if not force and os.path.exists(cp) and os.path.exists(tp):
1130
- try:
1131
- if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1132
- return json.load(open(cp)).get("packages",[])
1133
- except: pass
1134
-
1135
- # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1136
- # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1137
- # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1138
- # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1139
- if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1140
- if force:
1141
- print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1142
- file=sys.stderr)
1143
- return None
1144
- _cache_ro_notice()
1145
- if os.path.exists(cp):
1146
- try:
1147
- return json.load(open(cp)).get("packages",[])
1148
- except Exception:
1149
- pass
1150
- return None
1151
-
1152
- headers = {"User-Agent": "pag/3.0"}
1153
- if os.path.exists(tp) and not force:
1154
- try:
1155
- lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1156
- # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1157
- try:
1158
- old_locale = locale.setlocale(locale.LC_TIME)
1159
- locale.setlocale(locale.LC_TIME, 'C')
1160
- headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1161
- locale.setlocale(locale.LC_TIME, old_locale)
1162
- except (locale.Error, ValueError):
1163
- # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1164
- headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1165
- except: pass
1166
- if os.path.exists(ep) and not force:
1167
- try: headers["If-None-Match"] = open(ep).read().strip()
1168
- except: pass
1169
-
1170
- # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1171
- try:
1172
- req = Request(f"{repo_url}/repo.json", headers=headers)
1173
- with urlopen(req, timeout=30) as resp:
1174
- etag = resp.headers.get("ETag","")
1175
- raw = resp.read()
1176
- data = json.loads(raw.decode())
1177
- except HTTPError as e:
1178
- if e.code == 304:
1179
- # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1180
- try:
1181
- open(tp,"w").write(str(time.time()))
1182
- except OSError:
1183
- pass
1184
- if os.path.exists(cp):
1185
- try:
1186
- return json.load(open(cp)).get("packages",[])
1187
- except Exception:
1188
- pass # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1189
- print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1190
- return None
1191
- except Exception as e:
1192
- print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1193
- if os.path.exists(cp):
1194
- try:
1195
- return json.load(open(cp)).get("packages",[])
1196
- except Exception:
1197
- pass
1198
- return None
1199
-
1200
- # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1201
- # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1202
- # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1203
- # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1204
- # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1205
- tmp_path = cp + ".tmp"
1206
- try:
1207
- with open(tmp_path, "wb") as f:
1208
- f.write(raw)
1209
- if not _verify_repo_sig(repo_url, tmp_path):
1210
- return None # weryfikacja nie powiodła się – stary cache zostaje
1211
- os.replace(tmp_path, cp)
1212
- # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1213
- for _ext in (".asc", ".sig"):
1214
- if os.path.exists(tmp_path + _ext):
1215
- try:
1216
- os.replace(tmp_path + _ext, cp + _ext)
1217
- except OSError:
1218
- pass
1219
- break
1220
- if etag:
1221
- try:
1222
- open(ep,"w").write(etag)
1223
- except OSError:
1224
- pass
1225
- try:
1226
- open(tp,"w").write(str(time.time()))
1227
- except OSError:
1228
- pass
1229
- return data.get("packages",[])
1230
- except OSError as e:
1231
- print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1232
- file=sys.stderr)
1233
- # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1234
- # to wciąż stare, zweryfikowane dane
1235
- if os.path.exists(cp):
1236
- try:
1237
- return json.load(open(cp)).get("packages",[])
1238
- except Exception:
1239
- pass
1240
- return None
1241
- finally:
1242
- for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1243
- try:
1244
- os.unlink(_p)
1245
- except OSError:
1246
- pass
1247
-
1248
-def _verify_repo_sig(repo_url, cache_path) -> bool:
1249
- """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1250
-
1251
- FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1252
- Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1253
-
1254
- Model zaufania (TOFU + pinning):
1255
- - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1256
- a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1257
- To świadomy kompromis wygody i bezpieczeństwa.
1258
- - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1259
- Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1260
- pag key-trust <repo_url> (po weryfikacji nowego klucza)
1261
- """
1262
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
1263
-
1264
- if not os.path.exists(GPG_HOME):
1265
- if insecure:
1266
- return True # brak GPG home – tryb insecure, akceptuj
1267
- print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1268
- print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1269
- os.remove(cache_path)
1270
- return False
1271
-
1272
- sig_path = cache_path + ".sig"
1273
- # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1274
- sig_data = None
1275
- sig_ext = ""
1276
- for ext in (".asc", ".sig"):
1277
- try:
1278
- req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1279
- with urlopen(req, timeout=15) as resp:
1280
- sig_data = resp.read()
1281
- sig_ext = ext
1282
- break
1283
- except Exception:
1284
- continue
1285
- if not sig_data:
1286
- if insecure:
1287
- return True # tryb insecure – akceptuj bez podpisu
1288
- print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1289
- print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1290
- os.remove(cache_path)
1291
- return False
1292
- sig_path = cache_path + sig_ext
1293
- with open(sig_path, "wb") as f:
1294
- f.write(sig_data)
1295
-
1296
- ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1297
- if not ok:
1298
- # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1299
- # jak apt) – gdy w keyringu brakuje klucza (No public key).
1300
- res = _gpg_run("--verify", sig_path, cache_path,
1301
- capture_output=True, text=True, timeout=30)
1302
- _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1303
- if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1304
- try:
1305
- with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1306
- keydata = r.read()
1307
- with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1308
- tmp.write(keydata)
1309
- tmp.flush()
1310
- _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1311
- os.unlink(tmp.name)
1312
- print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1313
- ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1314
- except Exception:
1315
- pass
1316
- if not ok:
1317
- if insecure:
1318
- print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1319
- return True
1320
- os.remove(cache_path)
1321
- if not shutil.which(GPG_BINARY):
1322
- print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1323
- print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1324
- else:
1325
- print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1326
- return False
1327
-
1328
- # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1329
- pinned = _repo_pinned_fp(repo_url)
1330
- if pinned:
1331
- if not fingerprint:
1332
- if insecure:
1333
- print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1334
- return True
1335
- os.remove(cache_path)
1336
- print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1337
- print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1338
- return False
1339
- if fingerprint != pinned.upper():
1340
- if insecure:
1341
- print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1342
- return True
1343
- os.remove(cache_path)
1344
- print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1345
- print(f" Oczekiwany: {pinned}")
1346
- print(f" Otrzymany: {fingerprint}")
1347
- print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1348
- return False
1349
- return True
1350
-
1351
- if fingerprint:
1352
- # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1353
- db = _load_trust_db()
1354
- if db.get(repo_url) != fingerprint:
1355
- _save_trust_db({**db, repo_url: fingerprint})
1356
- print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1357
- print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1358
- print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1359
- return True
1360
-
1361
-def fetch_all_packages(force=False):
1362
- all_pkgs = {}
1363
- for repo_url in get_repos():
1364
- pkgs = fetch_repo_index(repo_url, force)
1365
- if pkgs:
1366
- for pdata in pkgs:
1367
- name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1368
- pkg = PackageInfo(pdata, repo_url)
1369
- if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1370
- all_pkgs[name] = pkg
1371
- return all_pkgs
1372
-
1373
-# =============================================================================
1374
-# GPG
1375
-# =============================================================================
1376
-
1377
-def _verify_pkg_gpg(pkg_path, repo_url=None):
1378
- """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1379
-
1380
- FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1381
- Zwraca (passed: bool, message: str).
1382
- """
1383
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
1384
- sig_path = pkg_path + ".sig"
1385
- if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1386
- sig_path = pkg_path + ".asc"
1387
-
1388
- if not os.path.exists(sig_path):
1389
- if insecure:
1390
- return True, "(no signature – PAG_INSECURE)"
1391
- return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1392
-
1393
- ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1394
- if not ok:
1395
- if insecure:
1396
- return True, "(invalid signature – PAG_INSECURE)"
1397
- return False, "NIEPRAWIDŁOWY PODPIS GPG"
1398
-
1399
- # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1400
- if repo_url:
1401
- pinned = _repo_pinned_fp(repo_url)
1402
- if pinned and fp and fp != pinned.upper():
1403
- if insecure:
1404
- return True, "(pkg signer mismatch – PAG_INSECURE)"
1405
- return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1406
-
1407
- return True, "GPG verified"
1408
-
1409
-def cmd_key_add(source):
1410
- ensure_dirs()
1411
- if source.startswith("http"):
1412
- try:
1413
- with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1414
- keydata = resp.read()
1415
- with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1416
- tmp.write(keydata); tmp.flush()
1417
- _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1418
- os.unlink(tmp.name)
1419
- except Exception as e:
1420
- print(f"❌ Download error: {e}"); return 1
1421
- else:
1422
- _gpg_run("--import", source, capture_output=True, timeout=30)
1423
- print(f"✅ {_('key_imported')}")
1424
-
1425
-def cmd_key_list():
1426
- if not os.path.exists(GPG_HOME):
1427
- print(_("no_keys")); return
1428
- result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1429
- capture_output=True, text=True, timeout=30)
1430
- print(result.stdout or _("no_keys"))
1431
-
1432
-def cmd_key_remove(key_id):
1433
- _gpg_run("--batch", "--yes", "--delete-key", key_id,
1434
- capture_output=True, timeout=30)
1435
- print(f"✅ {_('key_removed', key_id)}")
1436
-
1437
-def _repo_signer_fp(repo_url):
1438
- """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1439
- repo_url = repo_url.rstrip("/")
1440
- try:
1441
- with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1442
- data = r.read()
1443
- except Exception:
1444
- return None
1445
- sig = None
1446
- sig_ext = ".asc"
1447
- for ext in (".asc", ".sig"):
1448
- try:
1449
- with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1450
- sig = r.read()
1451
- sig_ext = ext
1452
- break
1453
- except Exception:
1454
- continue
1455
- if not sig:
1456
- return None
1457
- with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1458
- tf.write(data); tf.flush()
1459
- data_path = tf.name
1460
- sig_path = data_path + sig_ext
1461
- try:
1462
- with open(sig_path, "wb") as f:
1463
- f.write(sig)
1464
- ok, fp = _gpg_verify_fp(sig_path, data_path)
1465
- finally:
1466
- for p in (data_path, sig_path):
1467
- try: os.unlink(p)
1468
- except OSError: pass
1469
- return fp if ok else None
1470
-
1471
-
1472
-def cmd_key_trust(repo_url):
1473
- """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1474
- repo_url = repo_url.rstrip("/")
1475
- print(f"🔐 Przypinam klucz repo {repo_url}...")
1476
- fp = _repo_signer_fp(repo_url)
1477
- if not fp:
1478
- print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1479
- print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1480
- return 1
1481
- db = _load_trust_db()
1482
- _save_trust_db({**db, repo_url: fp})
1483
- print(f" ✅ Przypięto {fp} dla {repo_url}")
1484
- print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1485
- return 0
1486
-
1487
-
1488
-def cmd_key_untrust(repo_url):
1489
- """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1490
- repo_url = repo_url.rstrip("/")
1491
- db = _load_trust_db()
1492
- if repo_url not in db:
1493
- print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1494
- return 0
1495
- del db[repo_url]
1496
- _save_trust_db(db)
1497
- print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1498
- return 0
1499
-
1500
-
1501
-def cmd_key_trusted():
1502
- """Listuje przypięte fingerprinty repozytoriów."""
1503
- db = _load_trust_db()
1504
- if not db:
1505
- print(_("no_keys"))
1506
- return
1507
- for url, fp in sorted(db.items()):
1508
- print(f" {url}\n {fp}")
1509
-
1510
-# =============================================================================
1511
-# ATOMOWA INSTALACJA (STAGING)
1512
-# =============================================================================
1513
-
1514
-def _safe_rename(src: str, dst: str) -> bool:
1515
- """
1516
- Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1517
- systemach plików (EXDEV), kopiuje + usuwa źródło.
1518
- """
1519
- try:
1520
- os.rename(src, dst)
1521
- return True
1522
- except OSError as e:
1523
- if e.errno == 18: # EXDEV – cross-device link
1524
- shutil.copy2(src, dst)
1525
- os.remove(src)
1526
- return True
1527
- raise
1528
-
1529
-
1530
-def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1531
- staging: str, journal: list, installed_files: list,
1532
- deploy_dir: str = "", backup_dir: str = "",
1533
- backup_journal: Optional[list] = None) -> bool:
1534
- """
1535
- Instaluje pojedynczy plik (zwykły lub symlink).
1536
- Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1537
-
1538
- Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1539
- do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1540
-
1541
- Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1542
- stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1543
- """
1544
- # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1545
- if deploy_dir and _is_shared_path("/" + rel):
1546
- dst_root = PAG_ROOT
1547
- elif deploy_dir:
1548
- dst_root = deploy_dir
1549
- else:
1550
- dst_root = PAG_ROOT
1551
-
1552
- dst = os.path.join(dst_root, rel)
1553
-
1554
- # --- SYMLINK ---
1555
- if os.path.islink(src):
1556
- link_target = os.readlink(src)
1557
- # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1558
- expected = sums.get("/" + rel, "")
1559
- if expected:
1560
- link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1561
- if expected and link_hash != expected:
1562
- return False
1563
-
1564
- os.makedirs(os.path.dirname(dst), exist_ok=True)
1565
- # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1566
- if backup_dir and backup_journal is not None and os.path.lexists(dst):
1567
- try:
1568
- backup_path = os.path.join(backup_dir, rel)
1569
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1570
- os.replace(dst, backup_path)
1571
- backup_journal.append((backup_path, "/" + rel))
1572
- journal.append(("backup", backup_path, dst))
1573
- except OSError:
1574
- pass
1575
- # Jeśli docelowy symlink już istnieje, usuń go
1576
- if os.path.islink(dst) or os.path.exists(dst):
1577
- os.remove(dst)
1578
- os.symlink(link_target, dst)
1579
- journal.append(("symlink", "", dst))
1580
- installed_files.append({
1581
- "path": "/" + rel,
1582
- "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1583
- "size": len(link_target),
1584
- "is_symlink": True,
1585
- "symlink_target": link_target,
1586
- })
1587
- return True
1588
-
1589
- # --- ZWYKŁY PLIK ---
1590
- # Oblicz SHA256
1591
- try:
1592
- file_sha = _sha256_file(src)
1593
- except Exception:
1594
- file_sha = ""
1595
-
1596
- # Weryfikuj sums.json
1597
- expected = sums.get("/" + rel, "")
1598
- if expected and file_sha and file_sha != expected:
1599
- return False
1600
-
1601
- # Utwórz katalog docelowy
1602
- os.makedirs(os.path.dirname(dst), exist_ok=True)
1603
-
1604
- # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1605
- if backup_dir and backup_journal is not None and os.path.lexists(dst):
1606
- try:
1607
- backup_path = os.path.join(backup_dir, rel)
1608
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1609
- os.replace(dst, backup_path)
1610
- backup_journal.append((backup_path, "/" + rel))
1611
- journal.append(("backup", backup_path, dst))
1612
- except OSError:
1613
- pass
1614
-
1615
- # Atomowe przeniesienie (z fallbackiem dla cross-device).
1616
- # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1617
- _safe_rename(src, dst)
1618
-
1619
- # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1620
- try:
1621
- os.chown(dst, 0, 0)
1622
- except (OSError, PermissionError):
1623
- # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1624
- pass
1625
-
1626
- journal.append(("file", src, dst))
1627
- installed_files.append({
1628
- "path": "/" + rel,
1629
- "sha256": file_sha,
1630
- "size": os.path.getsize(dst),
1631
- "is_symlink": False,
1632
- })
1633
- return True
1634
-
1635
-
1636
-def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1637
- backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1638
- """
1639
- Rozpakowuje do staging area, potem atomowo przenosi pliki.
1640
- Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1641
- Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1642
- """
1643
- staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1644
- journal = []
1645
- installed_files = []
1646
- backup_journal: List[Tuple[str, str]] = []
1647
-
1648
- try:
1649
- # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1650
- with tarfile.open(pkg_path, "r:xz") as tf:
1651
- _safe_extractall(tf, staging)
1652
-
1653
- data_tar = os.path.join(staging, "data.tar.xz")
1654
- if not os.path.exists(data_tar):
1655
- shutil.rmtree(staging, ignore_errors=True)
1656
- return False, [], backup_journal
1657
-
1658
- # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1659
- data_staging = os.path.join(staging, "data")
1660
- os.makedirs(data_staging, exist_ok=True)
1661
- with tarfile.open(data_tar, "r:xz") as tf:
1662
- _safe_extractall(tf, data_staging)
1663
-
1664
- # Wczytaj sums.json
1665
- sums_path = os.path.join(data_staging, "sums.json")
1666
- sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1667
-
1668
- # Hook pre-install (przed przeniesieniem plików do systemu)
1669
- _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1670
-
1671
- # Przenieś pliki: staging/data/* → /
1672
- for root, dirs, files in os.walk(data_staging):
1673
- # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1674
- # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1675
- # puste katalogi z data.tar.xz ginęły przy instalacji.
1676
- for d in dirs:
1677
- src_dir = os.path.join(root, d)
1678
- rel_dir = os.path.relpath(src_dir, data_staging)
1679
- if deploy_dir and _is_shared_path("/" + rel_dir):
1680
- dst_root = PAG_ROOT
1681
- elif deploy_dir:
1682
- dst_root = deploy_dir
1683
- else:
1684
- dst_root = PAG_ROOT
1685
- dst_dir = os.path.join(dst_root, rel_dir)
1686
- if not os.path.isdir(dst_dir):
1687
- try:
1688
- os.makedirs(dst_dir, exist_ok=True)
1689
- except OSError:
1690
- pass
1691
- for fname in files:
1692
- if fname == "sums.json":
1693
- continue
1694
- src = os.path.join(root, fname)
1695
- rel = os.path.relpath(src, data_staging)
1696
-
1697
- ok = _install_file(src, rel, data_staging, sums,
1698
- staging, journal, installed_files, deploy_dir,
1699
- backup_dir, backup_journal)
1700
- if not ok:
1701
- # Cofnij wszystkie operacje
1702
- _rollback_journal(journal, staging)
1703
- return False, [], backup_journal
1704
-
1705
- # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1706
- # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1707
- # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1708
- # nie jest zainstalowane.
1709
- _icon_dirs = set()
1710
- for f in installed_files:
1711
- fp = f.get("path", "") or ""
1712
- if fp.startswith("/usr/share/icons/"):
1713
- _rest = fp[len("/usr/share/icons/"):]
1714
- _theme = _rest.split("/", 1)[0]
1715
- if _theme:
1716
- _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1717
- if _icon_dirs:
1718
- try:
1719
- subprocess.run(["gtk-update-icon-cache", "--version"],
1720
- capture_output=True, timeout=10)
1721
- for _d in sorted(_icon_dirs):
1722
- if os.path.isdir(_d):
1723
- subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
1724
- capture_output=True, timeout=300)
1725
- except Exception:
1726
- pass
1727
-
1728
- # Uruchom hooki post-install
1729
- hooks_dir = os.path.join(staging, "hooks")
1730
- _run_hook(hooks_dir, "post-install", pkg)
1731
-
1732
- # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1733
- try:
1734
- if os.path.isdir(hooks_dir):
1735
- persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1736
- shutil.rmtree(persisted, ignore_errors=True)
1737
- shutil.copytree(hooks_dir, persisted)
1738
- except Exception:
1739
- pass
1740
-
1741
- # Zapisz do SQLite
1742
- _db_record_files(pkg.name, installed_files)
1743
-
1744
- shutil.rmtree(staging, ignore_errors=True)
1745
- return True, installed_files, backup_journal
1746
-
1747
- except Exception as e:
1748
- _rollback_journal(journal, staging)
1749
- return False, [], backup_journal
1750
-
1751
-
1752
-def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1753
- """Odświeża cache ld.so po udanej instalacji pakietów."""
1754
- ldconfig = shutil.which("ldconfig")
1755
- if not ldconfig:
1756
- print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
1757
- file=sys.stderr)
1758
- return False
1759
-
1760
- target_root = deploy_dir or PAG_ROOT
1761
- command = [ldconfig]
1762
- if target_root != "/":
1763
- command.extend(["-r", target_root])
1764
-
1765
- try:
1766
- subprocess.run(command, check=True, timeout=60,
1767
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1768
- text=True)
1769
- return True
1770
- except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1771
- detail = getattr(exc, "stderr", None) or str(exc)
1772
- print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
1773
- file=sys.stderr)
1774
- return False
1775
-
1776
-
1777
-def _rollback_journal(journal: list, staging_path: str):
1778
- """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1779
- for entry in reversed(journal):
1780
- op = entry[0]
1781
- if op == "file":
1782
- _, src, dst = entry
1783
- try:
1784
- if os.path.exists(dst) or os.path.islink(dst):
1785
- _safe_rename(dst, src)
1786
- except Exception:
1787
- pass
1788
- elif op == "symlink":
1789
- _, _, dst = entry
1790
- try:
1791
- if os.path.islink(dst) or os.path.exists(dst):
1792
- os.remove(dst)
1793
- except Exception:
1794
- pass
1795
- elif op == "backup":
1796
- # Przywróć starą wersję pliku z backupu (upgrade)
1797
- _, bpath, dst = entry
1798
- try:
1799
- if os.path.lexists(bpath):
1800
- os.replace(bpath, dst)
1801
- except Exception:
1802
- pass
1803
- shutil.rmtree(staging_path, ignore_errors=True)
1804
-
1805
-# =============================================================================
1806
-# BEZPIECZNE USUWANIE
1807
-# =============================================================================
1808
-
1809
-def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1810
- """
1811
- Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1812
- Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1813
- """
1814
- pkg_files = _db_get_package_files(pkg_name)
1815
- removed = []
1816
- skipped_shared = []
1817
-
1818
- for fpath in pkg_files:
1819
- owners = _db_get_file_owners(fpath)
1820
- # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1821
- other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1822
-
1823
- if other_owners:
1824
- # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1825
- skipped_shared.append(fpath)
1826
- continue
1827
-
1828
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1829
- if os.path.isfile(full) or os.path.islink(full):
1830
- os.remove(full)
1831
- removed.append(fpath)
1832
-
1833
- # Usuń puste katalogi (od najgłębszych)
1834
- dirs = set()
1835
- for fpath in removed + skipped_shared:
1836
- parent = os.path.dirname(fpath)
1837
- while parent and parent != "/":
1838
- dirs.add(parent)
1839
- parent = os.path.dirname(parent)
1840
-
1841
- for d in sorted(dirs, key=len, reverse=True):
1842
- full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1843
- if os.path.isdir(full_d):
1844
- try:
1845
- os.rmdir(full_d)
1846
- except OSError:
1847
- pass # nie jest pusty – OK
1848
-
1849
- # Usuń z SQLite
1850
- _db_remove_package_files(pkg_name)
1851
-
1852
- if skipped_shared:
1853
- print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1854
-
1855
- return len(removed) + len(skipped_shared), removed
1856
-
1857
-
1858
-def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
1859
- installed_db: dict, deploy_dir: str = "",
1860
- backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
1861
- """
1862
- Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
1863
-
1864
- - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
1865
- (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
1866
- - Sprząta puste katalogi i wpisy SQLite starej wersji.
1867
- Zwraca (liczba usuniętych, [usunięte ścieżki]).
1868
- """
1869
- new_set = set(new_paths)
1870
- stale = [f for f in old_files if f not in new_set]
1871
- if not stale:
1872
- return 0, []
1873
-
1874
- root = deploy_dir or PAG_ROOT
1875
- removed = []
1876
- skipped = 0
1877
- for fpath in stale:
1878
- owners = _db_get_file_owners(fpath)
1879
- other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1880
- if other_owners:
1881
- # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1882
- skipped += 1
1883
- else:
1884
- full = os.path.join(root, fpath.lstrip("/"))
1885
- if os.path.isfile(full) or os.path.islink(full):
1886
- try:
1887
- if backup_dir and backup_journal is not None:
1888
- backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
1889
- os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1890
- os.replace(full, backup_path) # przenieś do backupu (rollback)
1891
- backup_journal.append((backup_path, fpath))
1892
- else:
1893
- os.remove(full)
1894
- removed.append(fpath)
1895
- except OSError:
1896
- pass
1897
- # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
1898
- with _db_session() as db:
1899
- db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
1900
-
1901
- # Usuń puste katalogi (od najgłębszych)
1902
- dirs = set()
1903
- for fpath in removed:
1904
- parent = os.path.dirname(fpath)
1905
- while parent and parent != "/":
1906
- dirs.add(parent)
1907
- parent = os.path.dirname(parent)
1908
- for d in sorted(dirs, key=len, reverse=True):
1909
- full_d = os.path.join(root, d.lstrip("/"))
1910
- if os.path.isdir(full_d):
1911
- try:
1912
- os.rmdir(full_d)
1913
- except OSError:
1914
- pass # nie jest pusty – OK
1915
-
1916
- if removed:
1917
- print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
1918
- if skipped:
1919
- print(f" ⚠ {skipped} plików współdzielonych zachowanych")
1920
-
1921
- return len(removed), removed
1922
-
1923
-
1924
-def _new_upgrade_backup_root() -> str:
1925
- """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
1926
- txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
1927
- root = os.path.join(STAGING_DIR, "backups", txn)
1928
- os.makedirs(root, exist_ok=True)
1929
- return root
1930
-
1931
-
1932
-def _purge_old_backups(keep_root: str = ""):
1933
- """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
1934
- base = os.path.join(STAGING_DIR, "backups")
1935
- if not os.path.isdir(base):
1936
- return
1937
- for entry in os.listdir(base):
1938
- p = os.path.join(base, entry)
1939
- if p != keep_root and os.path.isdir(p):
1940
- shutil.rmtree(p, ignore_errors=True)
1941
-
1942
-# =============================================================================
1943
-# HOOKI
1944
-# =============================================================================
1945
-# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
1946
-# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
1947
-# Aby ograniczyć ryzyko:
1948
-# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
1949
-# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
1950
-# - każde uruchomienie jest logowane do /var/log/pag/audit.log
1951
-# - hook ma wersjonowane API (PKG_HOOK_API)
1952
-# =============================================================================
1953
-
1954
-# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
1955
-_HOOKS_RUN: List[str] = []
1956
-
1957
-
1958
-def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
1959
- """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
1960
- return {
1961
- "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1962
- "HOME": "/root",
1963
- "LANG": "C.UTF-8",
1964
- "LC_ALL": "C.UTF-8",
1965
- "PKG_NAME": pkg.name,
1966
- "PKG_VERSION": pkg.version,
1967
- "PKG_ACTION": hook_name,
1968
- "PKG_HOOK_API": HOOK_API_VERSION,
1969
- }
1970
-
1971
-
1972
-def _hook_timeout() -> int:
1973
- try:
1974
- return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
1975
- except Exception:
1976
- return 60
1977
-
1978
-
1979
-def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
1980
- """Uruchamia skrypt hooka jeśli istnieje.
1981
-
1982
- Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
1983
- pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
1984
- ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
1985
- """
1986
- hook_path = os.path.join(hooks_dir, hook_name)
1987
- if not os.path.exists(hook_path):
1988
- return False
1989
-
1990
- if os.environ.get("PAG_NO_HOOKS", "") == "1":
1991
- print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
1992
- _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
1993
- return False
1994
-
1995
- os.chmod(hook_path, 0o755)
1996
- env = _hook_env(pkg, hook_name)
1997
- tag = f"{hook_name} {pkg.name}-{pkg.version}"
1998
- try:
1999
- result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2000
- check=False, capture_output=True, text=True,
2001
- cwd="/")
2002
- _HOOKS_RUN.append(tag)
2003
- if result.returncode != 0:
2004
- print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2005
- if result.stderr:
2006
- print(f" {result.stderr.strip()[-200:]}")
2007
- _audit(f"hook FAIL {tag} rc={result.returncode}")
2008
- else:
2009
- _audit(f"hook OK {tag}")
2010
- return True
2011
- except subprocess.TimeoutExpired:
2012
- print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2013
- _audit(f"hook TIMEOUT {tag}")
2014
- return False
2015
- except Exception as e:
2016
- print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2017
- _audit(f"hook ERROR {tag}: {e}")
2018
- return False
2019
-
2020
-# =============================================================================
2021
-# TRANSAKCJE I ROLLBACK
2022
-# =============================================================================
2023
-
2024
-def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2025
- upgrade_backups=None, upgrade_backup_root=""):
2026
- history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2027
- # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2028
- # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2029
- executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2030
- _HOOKS_RUN.clear()
2031
- entry = {
2032
- "action": action, "packages": packages, "success": success,
2033
- "timestamp": datetime.now().isoformat(),
2034
- "snapshot": snapshot,
2035
- "file_journal": file_journal, # lista plików do wycofania
2036
- "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2037
- }
2038
- if upgrade_backups:
2039
- entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2040
- entry["upgrade_backup_root"] = upgrade_backup_root
2041
- history.append(entry)
2042
- if len(history) > 50:
2043
- history = history[-50:]
2044
- save_json(HISTORY_FILE, history)
2045
-
2046
-def cmd_history():
2047
- if not os.path.exists(HISTORY_FILE):
2048
- print(_("no_history")); return
2049
- history = load_json(HISTORY_FILE)
2050
- if not history:
2051
- print(_("no_history")); return
2052
- print(f"Ostatnie transakcje ({len(history)}):")
2053
- for i, e in enumerate(reversed(history), 1):
2054
- icon = "✅" if e["success"] else "❌"
2055
- pkgs = ", ".join(e["packages"][:5])
2056
- if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2057
- print(f" {i}. {icon} {e['action']}: {pkgs}")
2058
- print(f" {e['timestamp']}")
2059
-
2060
-def cmd_rollback():
2061
- if not os.path.exists(HISTORY_FILE):
2062
- print(_("no_history")); return 1
2063
- history = load_json(HISTORY_FILE)
2064
- if not history:
2065
- print(_("no_history")); return 1
2066
-
2067
- last = None
2068
- for e in reversed(history):
2069
- if e["success"] and e.get("snapshot"):
2070
- last = e; break
2071
-
2072
- if not last:
2073
- print("❌ No snapshot to restore."); return 1
2074
-
2075
- print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2076
- print(f" Packages: {', '.join(last['packages'][:10])}")
2077
-
2078
- if not _ask_confirm():
2079
- return 0
2080
-
2081
- # Przywróć installed.json
2082
- save_json(INSTALLED_DB, last["snapshot"])
2083
-
2084
- # Wycofaj fizyczne pliki (jeśli zapisano journal)
2085
- file_journal = last.get("file_journal", [])
2086
- upgrade_backups = last.get("upgrade_backups", {}) or {}
2087
- backup_root = last.get("upgrade_backup_root", "")
2088
-
2089
- # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2090
- for dst, bpath in upgrade_backups.items():
2091
- full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2092
- if bpath and os.path.lexists(bpath):
2093
- try:
2094
- os.makedirs(os.path.dirname(full), exist_ok=True)
2095
- os.replace(bpath, full)
2096
- except OSError:
2097
- pass
2098
-
2099
- # Usuń nowe pliki (które nie miały poprzedniej wersji)
2100
- backed = set(upgrade_backups)
2101
- if file_journal:
2102
- for fpath in reversed(file_journal):
2103
- if fpath in backed:
2104
- continue
2105
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2106
- if os.path.exists(full) or os.path.islink(full):
2107
- os.remove(full)
2108
- print(f" {_('rollback_files', len(file_journal))}")
2109
-
2110
- # Sprzątanie pustych katalogów + katalogu backupów
2111
- dirs = set()
2112
- for fpath in file_journal:
2113
- parent = os.path.dirname(fpath)
2114
- while parent and parent != "/":
2115
- dirs.add(parent)
2116
- parent = os.path.dirname(parent)
2117
- for d in sorted(dirs, key=len, reverse=True):
2118
- full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2119
- if os.path.isdir(full_d):
2120
- try:
2121
- os.rmdir(full_d)
2122
- except OSError:
2123
- pass
2124
- if backup_root:
2125
- shutil.rmtree(backup_root, ignore_errors=True)
2126
-
2127
- print(f"✅ {_('rollback_restored')}")
2128
- _record_transaction("rollback", last["packages"], True, None)
2129
- return 0
2130
-
2131
-# =============================================================================
2132
-# INSTALACJA
2133
-# =============================================================================
2134
-
2135
-def _install_local_pkg_files(paths, install_succeeded):
2136
- """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2137
- Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2138
- Zwraca (failed_count, installed_files)."""
2139
- failed = 0
2140
- all_files = []
2141
- for p in paths:
2142
- p = os.path.abspath(p)
2143
- if not os.path.isfile(p):
2144
- print(f" ❌ Nie znaleziono pakietu: {p}")
2145
- failed += 1
2146
- continue
2147
- try:
2148
- with tarfile.open(p, "r:xz") as tf:
2149
- meta = tf.extractfile("metadata.json")
2150
- if meta is None:
2151
- print(f" ❌ {p}: brak metadata.json")
2152
- failed += 1
2153
- continue
2154
- data = json.loads(meta.read())
2155
- except Exception as e:
2156
- print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2157
- failed += 1
2158
- continue
2159
- pkg = PackageInfo(data, repo="local")
2160
- print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2161
- ok, files, _ = _atomic_install(p, pkg)
2162
- if ok:
2163
- install_succeeded(pkg, files)
2164
- all_files.extend(f["path"] for f in files)
2165
- print("✅")
2166
- else:
2167
- print("❌")
2168
- failed += 1
2169
- return failed, all_files
2170
-
2171
-
2172
-def _preflight_disk(total_bytes: int) -> bool:
2173
- """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2174
-
2175
- Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2176
- miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2177
- (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2178
- """
2179
- target = PAG_ROOT or "/"
2180
- try:
2181
- st = os.statvfs(target)
2182
- free = st.f_bavail * st.f_frsize
2183
- except OSError:
2184
- return True # nie da się sprawdzić – nie blokuj
2185
- need_mb = total_bytes // 1048576
2186
- free_mb = free // 1048576
2187
- if free < total_bytes:
2188
- print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2189
- f"wolne {free_mb} MB ({target})")
2190
- return False
2191
- if free < total_bytes * 3:
2192
- print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2193
- f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2194
- # Wykryj mount read-only (test zapisu w stagingu)
2195
- try:
2196
- probe = os.path.join(STAGING_DIR, ".pag-probe")
2197
- with open(probe, "w") as f:
2198
- f.write("x")
2199
- os.remove(probe)
2200
- except OSError:
2201
- print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2202
- return False
2203
- return True
2204
-
2205
-
2206
-def cmd_install(package_names, as_dep=False, upgrade=False):
2207
- ensure_dirs()
2208
- installed_db = load_json(INSTALLED_DB)
2209
- world = load_world()
2210
- pinned = load_json(PINNED_FILE)
2211
-
2212
- # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2213
- # nie wymaga repozytorium ani GPG.
2214
- local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2215
- (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2216
- if local_files:
2217
- _local_need = sum(
2218
- os.path.getsize(os.path.abspath(p))
2219
- for p in local_files if os.path.isfile(os.path.abspath(p))
2220
- )
2221
- if not _preflight_disk(_local_need):
2222
- return 1
2223
-
2224
- def _ok(pkg, files):
2225
- installed_db[pkg.name] = {
2226
- "version": pkg.version, "release": pkg.release, "description": pkg.description,
2227
- "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2228
- "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2229
- "repo": "local",
2230
- "provides": getattr(pkg, "provides", None) or [],
2231
- "provides_so": getattr(pkg, "provides_so", None) or [],
2232
- "requires_so": getattr(pkg, "requires_so", None) or [],
2233
- }
2234
- world.add(pkg.name)
2235
- failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2236
- save_json(INSTALLED_DB, installed_db)
2237
- save_world(world)
2238
- if failed_local:
2239
- return 1
2240
- _refresh_dynamic_linker_cache()
2241
- package_names = [n for n in package_names if n not in
2242
- [os.path.abspath(x) for x in local_files] and
2243
- n not in local_files]
2244
- to_install = []
2245
- if not package_names:
2246
- return 0
2247
- # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2248
-
2249
- repo_pkgs = fetch_all_packages()
2250
-
2251
- if not repo_pkgs:
2252
- print(f"❌ {_('no_index')}"); return 1
2253
-
2254
- for name in list(package_names):
2255
- if name in pinned:
2256
- print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2257
- package_names.remove(name)
2258
-
2259
- to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2260
-
2261
- # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2262
- # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2263
- # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2264
- not_found = []
2265
- for n in package_names:
2266
- real = _resolve_provides(n, repo_pkgs, installed_db)
2267
- if real not in repo_pkgs and real not in installed_db \
2268
- and not os.path.exists(os.path.abspath(n)):
2269
- not_found.append(n)
2270
- if not_found:
2271
- print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2272
- print(f" {_('not_found_hint')}")
2273
- return 1
2274
-
2275
- # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2276
- # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2277
- if upgrade:
2278
- # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2279
- # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2280
- # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2281
- # _version_newer.
2282
- upgrade_targets = [
2283
- name for name in package_names
2284
- if name in repo_pkgs
2285
- and name in installed_db
2286
- and name not in pinned
2287
- ]
2288
- for name in upgrade_targets:
2289
- if name not in to_install:
2290
- to_install.append(name)
2291
-
2292
- if not to_install and not missing_deps:
2293
- print(f"✅ {_('all_installed')}"); return 0
2294
-
2295
- # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2296
- fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2297
-
2298
- if fatal_missing > 0:
2299
- print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2300
- print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2301
- return 1
2302
-
2303
- so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2304
- if so_missing > 0:
2305
- print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2306
- return 1
2307
-
2308
- if not to_install:
2309
- print(f"✅ {_('all_installed')}"); return 0
2310
-
2311
- MAX_MB = MAX_PKG_SIZE // 1048576
2312
- for n in to_install:
2313
- if not _validate_pkg_name(n):
2314
- print(f" {_("sec_badname", name=n)}")
2315
- return 1
2316
- sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2317
- if sz > MAX_PKG_SIZE:
2318
- mb = sz // 1048576
2319
- print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2320
- return 1
2321
- total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2322
- if not _preflight_disk(total_size):
2323
- return 1
2324
- print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2325
- for name in to_install:
2326
- p = repo_pkgs.get(name)
2327
- if p:
2328
- if name in installed_db:
2329
- marker = " [upgrade]" if upgrade else ""
2330
- else:
2331
- marker = f" [{_('new')}]"
2332
- print(f" {name}-{p.version}{marker}")
2333
-
2334
- if not as_dep and not upgrade:
2335
- if not _ask_confirm():
2336
- print(_("cancelled")); return 0
2337
-
2338
- snapshot = json.loads(json.dumps(installed_db))
2339
- all_installed_files = []
2340
- failed = []
2341
- # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2342
- stale_candidates = []
2343
- # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2344
- backup_root = ""
2345
- all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2346
- if upgrade and to_install:
2347
- backup_root = _new_upgrade_backup_root()
2348
-
2349
- # --- Dziennik transakcji (dla pełnej atomowości) ---
2350
- # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2351
- # w tej transakcji przez _rollback_transaction().
2352
- transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2353
-
2354
- # --- Tryb immutable: utwórz nowy deployment ---
2355
- immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2356
- deploy_dir = ""
2357
- deploy_id = ""
2358
- if immutable:
2359
- print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2360
- deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2361
- target_root = deploy_dir
2362
- else:
2363
- target_root = ""
2364
-
2365
- # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2366
- to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2367
- if len(to_download) > 1:
2368
- print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2369
- downloaded = _download_packages_parallel(to_download)
2370
- else:
2371
- downloaded = {}
2372
-
2373
- # --- Faza 2: Instalacja z paskiem postępu ---
2374
- t0 = time.time()
2375
-
2376
- for name in to_install:
2377
- pkg = repo_pkgs.get(name)
2378
- if not pkg:
2379
- print(f" ❌ {name}: {_('not_found')}")
2380
- failed.append(name)
2381
- break
2382
-
2383
- # Pasek postępu na stderr (nie koliduje z download barem)
2384
- idx = len(all_installed_files) + 1
2385
- pct = (idx - 1) / len(to_install) * 100
2386
- fl = int(25 * pct / 100)
2387
- pbar = "█" * fl + "░" * (25 - fl)
2388
- elapsed = time.time() - t0
2389
- if idx > 1 and elapsed > 0:
2390
- avg = elapsed / (idx - 1)
2391
- remaining = avg * (len(to_install) - idx + 1)
2392
- if remaining < 60:
2393
- eta_s = f" ~{remaining:.0f}s"
2394
- else:
2395
- eta_s = f" ~{remaining/60:.1f}m"
2396
- else:
2397
- eta_s = ""
2398
- status = f" [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
2399
- print(status, file=sys.stderr, flush=True)
2400
-
2401
- print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
2402
-
2403
- # Pobierz (z cache fazy 1 lub bezpośrednio)
2404
- pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2405
- if not pkg_path:
2406
- print(f"❌ {_('download_fail')}")
2407
- failed.append(name)
2408
- break # przerwij transakcję
2409
-
2410
- # GPG
2411
- gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2412
- if not gpg_ok:
2413
- print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
2414
- failed.append(name)
2415
- break # PRZERWIJ – niezaufany pakiet
2416
-
2417
- # SHA256 całego pakietu
2418
- if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2419
- print(f"❌ {_('sha256_mismatch')}")
2420
- failed.append(name)
2421
- break # PRZERWIJ – uszkodzony pakiet
2422
-
2423
- # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2424
- old_files = _db_get_package_files(name) if name in installed_db else []
2425
-
2426
- # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2427
- ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2428
- backup_dir=backup_root)
2429
- if ok:
2430
- installed_db[name] = {
2431
- "version": pkg.version, "release": pkg.release, "description": pkg.description,
2432
- "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2433
- "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2434
- "repo": pkg.repo_url,
2435
- "provides": getattr(pkg, "provides", None) or [],
2436
- "provides_so": getattr(pkg, "provides_so", None) or [],
2437
- "requires_so": getattr(pkg, "requires_so", None) or [],
2438
- }
2439
- if not as_dep and name in package_names:
2440
- world.add(name)
2441
- print("✅")
2442
- all_installed_files.extend(f["path"] for f in files)
2443
- all_backups.extend(backup_j)
2444
-
2445
- # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2446
- # których nie ma już w nowej wersji.
2447
- if upgrade and old_files:
2448
- stale_candidates.append((name, old_files, [f["path"] for f in files]))
2449
-
2450
- # Po instalacji kernela – przebuduj initramfs
2451
- if _is_kernel_package(name):
2452
- _rebuild_initramfs(deploy_dir)
2453
- else:
2454
- print("❌")
2455
- failed.append(name)
2456
- break # PRZERWIJ – błąd instalacji
2457
-
2458
- # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2459
- if failed:
2460
- print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2461
- _rollback_transaction(installed_db, snapshot, all_installed_files,
2462
- deploy_dir, immutable, backups=all_backups)
2463
- if backup_root:
2464
- shutil.rmtree(backup_root, ignore_errors=True)
2465
- _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2466
- return 1
2467
-
2468
- # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2469
- # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2470
- for pkg_name, old_files, new_paths in stale_candidates:
2471
- _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2472
- backup_root, all_backups)
2473
-
2474
- save_json(INSTALLED_DB, installed_db)
2475
- save_world(world)
2476
- _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2477
- file_journal=all_installed_files,
2478
- upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2479
- upgrade_backup_root=backup_root)
2480
-
2481
- # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2482
- if backup_root:
2483
- _purge_old_backups(keep_root=backup_root)
2484
-
2485
- # --- Tryb immutable: przełącz na nowy deployment ---
2486
- if immutable and not failed:
2487
- _refresh_dynamic_linker_cache(deploy_dir)
2488
- print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2489
- _switch_deployment(deploy_dir)
2490
- print(f" ✅ Aktywny deployment: {deploy_id}")
2491
- _update_grub_config()
2492
- cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2493
- print(f" 💡 Restart wymagany do przeładowania systemu.")
2494
- else:
2495
- _refresh_dynamic_linker_cache()
2496
- # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2497
- # trafiły do realnego systemu (nie do deploymentu).
2498
- _process_triggers(all_installed_files)
2499
-
2500
- print(f"\n✅ {_('installed', len(to_install))}")
2501
- return 0
2502
-
2503
-
2504
-def _rollback_transaction(installed_db: dict, snapshot: dict,
2505
- installed_files: List[str],
2506
- deploy_dir: str, is_immutable: bool,
2507
- backups: Optional[List[Tuple[str, str]]] = None):
2508
- """
2509
- Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2510
- Przywraca installed_db do stanu sprzed transakcji.
2511
- Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2512
- Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2513
- """
2514
- # Przywróć installed_db
2515
- installed_db.clear()
2516
- installed_db.update(snapshot)
2517
-
2518
- root = deploy_dir if is_immutable else PAG_ROOT
2519
- backup_map = {dst: src for src, dst in (backups or [])}
2520
-
2521
- # Przywróć stare wersje z backupów (upgrade)
2522
- for dst, bpath in backup_map.items():
2523
- full = os.path.join(root, dst.lstrip("/"))
2524
- if os.path.lexists(bpath):
2525
- try:
2526
- os.makedirs(os.path.dirname(full), exist_ok=True)
2527
- os.replace(bpath, full)
2528
- except OSError:
2529
- pass
2530
-
2531
- # Usuń nowe pliki (które nie miały poprzedniej wersji)
2532
- for fpath in reversed(installed_files):
2533
- if fpath in backup_map:
2534
- continue
2535
- full = os.path.join(root, fpath.lstrip("/"))
2536
- if os.path.isfile(full) or os.path.islink(full):
2537
- try:
2538
- os.remove(full)
2539
- except OSError:
2540
- pass
2541
-
2542
- # Wyczyść puste katalogi
2543
- dirs_to_check = set()
2544
- for fpath in installed_files:
2545
- parent = os.path.dirname(fpath)
2546
- while parent and parent != "/":
2547
- dirs_to_check.add(parent)
2548
- parent = os.path.dirname(parent)
2549
- for d in sorted(dirs_to_check, key=len, reverse=True):
2550
- full_d = os.path.join(root, d.lstrip("/"))
2551
- if os.path.isdir(full_d):
2552
- try:
2553
- os.rmdir(full_d)
2554
- except OSError:
2555
- pass
2556
-
2557
- # W trybie immutable: usuń nieudany deployment
2558
- if is_immutable and deploy_dir:
2559
- shutil.rmtree(deploy_dir, ignore_errors=True)
2560
-
2561
- save_json(INSTALLED_DB, snapshot)
2562
-
2563
-
2564
-# =============================================================================
2565
-# USUWANIE
2566
-# =============================================================================
2567
-
2568
-def cmd_remove(package_names):
2569
- installed_db = load_json(INSTALLED_DB)
2570
- world = load_world()
2571
- snapshot = json.loads(json.dumps(installed_db))
2572
- removed = []
2573
- removed_files = []
2574
-
2575
- total = len(package_names)
2576
- for i, name in enumerate(package_names, 1):
2577
- if name not in installed_db:
2578
- print(f" ⚠ {name}: not installed"); continue
2579
-
2580
- # Pasek postępu
2581
- pct = (i - 1) / total * 100
2582
- filled = int(25 * pct / 100)
2583
- print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2584
-
2585
- print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2586
-
2587
- # Pre-remove hook (jeśli dostępny w staging)
2588
- _run_hook_for_installed(name, "pre-remove")
2589
-
2590
- count, rm_files = _safe_remove_files(name, installed_db)
2591
- del installed_db[name]
2592
- world.discard(name)
2593
- removed.append(name)
2594
- removed_files.extend(rm_files)
2595
- print(f"✅ ({count} files)")
2596
-
2597
- # Post-remove hook + sprzątanie zapisanych hooków
2598
- _run_hook_for_installed(name, "post-remove")
2599
- shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2600
-
2601
- save_json(INSTALLED_DB, installed_db)
2602
- save_world(world)
2603
- _record_transaction("remove", removed, True, snapshot)
2604
-
2605
- print(file=sys.stderr) # wyczyść linię paska postępu
2606
-
2607
- if not removed: return 0
2608
- print(f"\n✅ Removed {len(removed)}.")
2609
- _process_triggers(removed_files)
2610
-
2611
- orphans = _find_orphans(installed_db, world)
2612
- if orphans:
2613
- print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2614
- print(" 'pag remove-orphans' to clean up.")
2615
- return 0
2616
-
2617
-def _run_hook_for_installed(pkg_name, hook_name):
2618
- """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2619
- hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2620
- if os.path.isdir(hook_dir):
2621
- ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2622
- _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2623
-
2624
-
2625
-# =============================================================================
2626
-# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2627
-# =============================================================================
2628
-# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2629
-# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2630
-# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2631
-# każdym pakiecie z osobna.
2632
-
2633
-TRIGGERS_DIR = PAG_CONF + "/triggers"
2634
-
2635
-DEFAULT_TRIGGERS = [
2636
- {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2637
- "run": "fc-cache -fs"},
2638
- {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
2639
- "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
2640
- {"name": "desktop-database", "paths": ["/usr/share/applications/"],
2641
- "run": "update-desktop-database -q /usr/share/applications"},
2642
- {"name": "mime-database", "paths": ["/usr/share/mime/"],
2643
- "run": "update-mime-database /usr/share/mime"},
2644
-]
2645
-
2646
-def _load_triggers() -> List[dict]:
2647
- """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
2648
- out = []
2649
- for t in DEFAULT_TRIGGERS:
2650
- bin_name = t["run"].split()[0]
2651
- if shutil.which(bin_name):
2652
- out.append(dict(t))
2653
- if os.path.isdir(TRIGGERS_DIR):
2654
- for fn in sorted(os.listdir(TRIGGERS_DIR)):
2655
- if not fn.endswith(".json"):
2656
- continue
2657
- try:
2658
- with open(os.path.join(TRIGGERS_DIR, fn)) as f:
2659
- data = json.load(f)
2660
- except (OSError, json.JSONDecodeError):
2661
- continue
2662
- if isinstance(data, dict):
2663
- data = [data]
2664
- for t in data:
2665
- if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
2666
- out.append(t)
2667
- return out
2668
-
2669
-def _process_triggers(touched_paths: List[str]):
2670
- """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
2671
- if not touched_paths:
2672
- return
2673
- if os.environ.get("PAG_NO_HOOKS", "") == "1":
2674
- return
2675
- import shlex as _shlex
2676
- matched = []
2677
- for trig in _load_triggers():
2678
- if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
2679
- matched.append(trig)
2680
- for trig in matched:
2681
- run = trig["run"]
2682
- print(f" ⚡ Trigger: {trig['name']} ({run})")
2683
- try:
2684
- r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
2685
- _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
2686
- if r.returncode != 0:
2687
- print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
2688
- except subprocess.TimeoutExpired:
2689
- print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
2690
- _audit(f"TRIGGER {trig['name']} TIMEOUT")
2691
- except Exception as e:
2692
- print(f" ⚠ trigger {trig['name']}: {e}")
2693
-
2694
-# =============================================================================
2695
-# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
2696
-# =============================================================================
2697
-
2698
-def _cleanup_tmp_files(*paths):
2699
- """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
2700
- for p in paths:
2701
- try:
2702
- if os.path.isfile(p):
2703
- os.remove(p)
2704
- except OSError:
2705
- pass
2706
-
2707
-
2708
-def cmd_self_update():
2709
- """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
2710
-
2711
- Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
2712
- kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
2713
- idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
2714
- podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
2715
- """
2716
- repos = get_repos()
2717
- if not repos:
2718
- print("❌ Brak repozytoriów w konfiguracji.")
2719
- return 1
2720
- base = repos[0]
2721
- dst = "/usr/local/bin/pag"
2722
- dst_new = dst + ".new"
2723
- dst_bak = dst + ".bak"
2724
- print(f"🔄 Sprawdzam aktualizację pag z {base}...")
2725
- try:
2726
- with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2727
- data = r.read()
2728
- with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2729
- sig = r.read()
2730
- except Exception as e:
2731
- print(f" ❌ Nie można pobrać pag: {e}")
2732
- return 1
2733
-
2734
- # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
2735
- with open(dst_new, "wb") as f:
2736
- f.write(data)
2737
- with open(dst_new + ".asc", "wb") as f:
2738
- f.write(sig)
2739
-
2740
- # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
2741
- insecure = os.environ.get("PAG_INSECURE", "") == "1"
2742
- ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2743
- if not ok:
2744
- # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
2745
- res = _gpg_run("--verify", dst_new + ".asc", dst_new,
2746
- capture_output=True, text=True)
2747
- _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
2748
- if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
2749
- try:
2750
- with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2751
- keydata = r.read()
2752
- with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
2753
- tmp.write(keydata); tmp.flush()
2754
- _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
2755
- os.unlink(tmp.name)
2756
- print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
2757
- ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2758
- except Exception:
2759
- pass
2760
- if not ok:
2761
- if insecure:
2762
- print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
2763
- else:
2764
- print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
2765
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
2766
- return 1
2767
- # Sprawdź fingerprint względem przypiętego klucza repo
2768
- pinned = _repo_pinned_fp(base)
2769
- if pinned:
2770
- if not fp:
2771
- print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
2772
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
2773
- return 1
2774
- if fp != pinned.upper():
2775
- if insecure:
2776
- print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
2777
- else:
2778
- print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
2779
- print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
2780
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
2781
- return 1
2782
-
2783
- # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
2784
- try:
2785
- with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
2786
- sha = r.read().decode().strip().split()[0]
2787
- if sha:
2788
- actual = hashlib.sha256(data).hexdigest()
2789
- if actual.lower() != sha.lower():
2790
- print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
2791
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
2792
- return 1
2793
- print(" ✅ SHA256 zgodny")
2794
- except Exception:
2795
- # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
2796
- pass
2797
-
2798
- # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
2799
- try:
2800
- compile(data, "pag", "exec")
2801
- except SyntaxError as e:
2802
- print(f" ❌ Błąd składni w nowym pag: {e}")
2803
- _cleanup_tmp_files(dst_new, dst_new + ".asc")
2804
- return 1
2805
-
2806
- m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
2807
- or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
2808
- new_ver = m.group(1).decode() if m else "?"
2809
- print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
2810
-
2811
- # --- 4. Backup + atomowa podmiana ---
2812
- if os.path.exists(dst):
2813
- shutil.copy2(dst, dst_bak)
2814
- os.chmod(dst_new, 0o755)
2815
- os.replace(dst_new, dst) # atomowe na tym samym FS
2816
- try:
2817
- if os.path.exists(dst_new + ".asc"):
2818
- os.remove(dst_new + ".asc")
2819
- except OSError:
2820
- pass
2821
- print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
2822
- print(" Uruchom ponownie pag, aby użyć nowej wersji.")
2823
- return 0
2824
-
2825
-
2826
-def _candidate_newer(rp, inst):
2827
- """Czy pakiet z repo jest nowszy od zainstalowanego.
2828
- Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
2829
- po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
2830
- widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
2831
- traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
2832
- reinstalacji/zmiany wersji."""
2833
- rv = getattr(rp, "version", "0")
2834
- iv = inst.get("version", "0")
2835
- if _version_newer(rv, iv):
2836
- return True
2837
- if rv != iv:
2838
- return False
2839
- rr = int(getattr(rp, "release", 1) or 1)
2840
- ir = int(inst.get("release", 1) or 1)
2841
- return rr > ir
2842
-
2843
-
2844
-def _pending_updates() -> List[str]:
2845
- """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
2846
- installed = load_json(INSTALLED_DB)
2847
- pinned = load_json(PINNED_FILE)
2848
- repo = fetch_all_packages()
2849
- if not repo:
2850
- return []
2851
- return [n for n, i in installed.items()
2852
- if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
2853
-
2854
-def cmd_update(do_upgrade: bool = False):
2855
- """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
2856
-
2857
- sync → tylko odświeżenie indeksów + info: „jest X pakietów do
2858
- zaktualizowania – wpisz: pag update".
2859
- update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
2860
- Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
2861
- przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
2862
- """
2863
- force = True
2864
- print("🔄 Refreshing indexes...")
2865
- for repo_url in get_repos():
2866
- pkgs = fetch_repo_index(repo_url, force=force)
2867
- cp = _repo_cache_path(repo_url)
2868
- has_sig = os.path.exists(cp + ".sig")
2869
- print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
2870
- print(f"✅ {_('indexes_refreshed')}")
2871
-
2872
- # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
2873
- try:
2874
- for r in get_repos():
2875
- cp = _repo_cache_path(r)
2876
- if os.path.exists(cp):
2877
- d = json.load(open(cp))
2878
- rv = d.get("pag_version", "")
2879
- if rv and rv != PAG_VERSION:
2880
- print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
2881
- except Exception:
2882
- pass
2883
-
2884
- # Raport: pakiety do aktualizacji
2885
- pending = _pending_updates()
2886
- if not pending:
2887
- print(f"✅ {_('all_up_to_date')}")
2888
- return 0
2889
- print(f"{_('updates_available', len(pending))}")
2890
- installed = load_json(INSTALLED_DB)
2891
- repo = fetch_all_packages()
2892
- for n in pending:
2893
- print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
2894
- if not do_upgrade:
2895
- return 0 # sync: tylko informacja
2896
- if not _ask_confirm():
2897
- return 0
2898
- return cmd_install(pending, upgrade=True)
2899
-
2900
-def _initramfs_stale() -> bool:
2901
- """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
2902
- try:
2903
- kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
2904
- if not kernels:
2905
- return False
2906
- newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
2907
- initrd = "/boot/initramfs.img"
2908
- return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
2909
- except Exception:
2910
- return False
2911
-
2912
-def cmd_upgrade():
2913
- """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
2914
- rc = cmd_update(do_upgrade=True)
2915
- if rc != 0:
2916
- return rc
2917
- # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
2918
- if _initramfs_stale():
2919
- print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
2920
- _rebuild_initramfs()
2921
- try:
2922
- if _load_deployments():
2923
- _update_grub_config()
2924
- except Exception:
2925
- pass
2926
- return 0
2927
-
2928
-def cmd_list(installed_only=False):
2929
- if installed_only:
2930
- db = load_json(INSTALLED_DB)
2931
- pinned = load_json(PINNED_FILE)
2932
- if not db: print("No packages installed."); return
2933
- print(f"Installed ({len(db)}):")
2934
- for n, i in sorted(db.items()):
2935
- pin = " 📌" if n in pinned else ""
2936
- print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
2937
- else:
2938
- pkgs = fetch_all_packages()
2939
- installed = load_json(INSTALLED_DB)
2940
- pinned = load_json(PINNED_FILE)
2941
- print(f"Available ({len(pkgs)}):")
2942
- for n, p in sorted(pkgs.items()):
2943
- m = "✓" if n in installed else " "
2944
- extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
2945
- if n in pinned: extra += " 📌"
2946
- print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
2947
-
2948
-def cmd_search(query):
2949
- pkgs = fetch_all_packages()
2950
- results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
2951
- if not results: print(f"❌ No results for: {query}"); return
2952
- installed = load_json(INSTALLED_DB)
2953
- print(f"Results for '{query}' ({len(results)}):")
2954
- for n,p in sorted(results):
2955
- print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
2956
- print(f" {p.description}")
2957
-
2958
-
2959
-def _smart_search(query: str) -> int:
2960
- """
2961
- Inteligentne wyszukiwanie: repo PaganOS + Flathub.
2962
- Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
2963
- Pokazuje dostępne źródła i sugeruje komendy instalacji.
2964
- """
2965
- # 1. Repo PaganOS
2966
- try:
2967
- pkgs = fetch_all_packages()
2968
- except Exception:
2969
- pkgs = {}
2970
- repo_lower = [(n, p) for n, p in pkgs.items()
2971
- if query.lower() in n.lower() or query.lower() in p.description.lower()]
2972
-
2973
- # 2. Flathub (jeśli dostępny)
2974
- flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
2975
-
2976
- if not repo_lower and not flat:
2977
- print(f"\n ❌ '{query}' — nie znaleziono.")
2978
- print(f" Repo PaganOS: pag search {query}")
2979
- if _check_flatpak(quiet=True):
2980
- print(f" Flathub: pag flatpak search {query}")
2981
- print(f" Dodaj repo: pag repo-add <url>")
2982
- return 1
2983
-
2984
- installed = load_json(INSTALLED_DB)
2985
-
2986
- # ── Repo PaganOS ──
2987
- if repo_lower:
2988
- exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
2989
- show = (exact or repo_lower)[:6]
2990
- print(f"\n 📦 PaganOS — '{query}':")
2991
- for n, p in sorted(show):
2992
- mark = "✓" if n in installed else " "
2993
- desc = p.description[:70] if len(p.description) > 75 else p.description
2994
- print(f" [{mark}] {n}-{p.version}")
2995
- if desc:
2996
- print(f" {desc}")
2997
- if len(repo_lower) > 6:
2998
- print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
2999
-
3000
- # ── Flathub ──
3001
- if flat:
3002
- print(f"\n 📦 Flathub — '{query}':")
3003
- for r in flat[:5]:
3004
- mark = "✓" if r.get("installed") else " "
3005
- name = r.get("name") or r.get("application", "?")
3006
- desc = (r.get("description") or "")[:65]
3007
- print(f" [{mark}] {name}")
3008
- if desc:
3009
- print(f" {desc}")
3010
- if len(flat) > 5:
3011
- print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3012
-
3013
- # ── Sugestie instalacji ──
3014
- print()
3015
- if repo_lower:
3016
- 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]
3017
- if best in installed:
3018
- print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3019
- else:
3020
- print(f" 💡 sudo pag install {best}")
3021
- if flat:
3022
- best_fp = flat[0].get("application") or flat[0].get("name", query)
3023
- print(f" 💡 pag flatpak install {best_fp}")
3024
-
3025
- return 0
3026
-
3027
-def cmd_info(name):
3028
- pkgs = fetch_all_packages()
3029
- p = pkgs.get(name)
3030
- info = load_json(INSTALLED_DB).get(name)
3031
- if not p and not info: print(f"❌ '{name}' not found."); return 1
3032
- print(f"📦 {name}")
3033
- if p:
3034
- print(f" Version (repo): {p.version}")
3035
- print(f" Description: {p.description}")
3036
- print(f" Size: {p.size_bytes/1048576:.1f} MB")
3037
- print(f" SHA256: {p.sha256[:32]}...")
3038
- print(f" GPG: {p.gpg_fp or 'none'}")
3039
- print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3040
- if info:
3041
- print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3042
-
3043
-def cmd_files(name):
3044
- if name not in load_json(INSTALLED_DB):
3045
- print(f"❌ '{name}' not installed."); return 1
3046
- files = _db_get_package_files(name)
3047
- print(f"Files in {name} ({len(files)}):")
3048
- for f in sorted(files): print(f" {f}")
3049
-
3050
-def cmd_verify(deep=False):
3051
- installed = load_json(INSTALLED_DB)
3052
- if not installed: print("Nothing to verify."); return
3053
- errors = []
3054
-
3055
- for name in installed:
3056
- for fpath in _db_get_package_files(name):
3057
- full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3058
- if not (os.path.exists(full) or os.path.islink(full)):
3059
- errors.append(f" ❌ {name}: missing {fpath}")
3060
- elif deep:
3061
- checksums = _db_get_all_file_checksums()
3062
- expected = checksums.get(fpath, "")
3063
- if expected:
3064
- actual = _sha256_file(full)
3065
- if actual != expected:
3066
- errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3067
-
3068
- if errors:
3069
- print(f"❌ {_('verify_errors', len(errors))}")
3070
- for e in errors[:50]: print(e)
3071
- return 1
3072
- total = _db_count_files()
3073
- print(f"✅ {_('verify_ok', total)}")
3074
-
3075
-# =============================================================================
3076
-# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3077
-# =============================================================================
3078
-
3079
-def cmd_pin(name, version=""):
3080
- pinned = load_json(PINNED_FILE)
3081
- if version:
3082
- pinned[name] = version
3083
- else:
3084
- info = load_json(INSTALLED_DB).get(name, {})
3085
- pinned[name] = info.get("version", "?")
3086
- save_json(PINNED_FILE, pinned)
3087
- print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3088
-
3089
-def cmd_unpin(name):
3090
- pinned = load_json(PINNED_FILE)
3091
- if name in pinned:
3092
- del pinned[name]; save_json(PINNED_FILE, pinned)
3093
- print(f"🔓 {name} {_('unpinned')}")
3094
- else:
3095
- print(f"⚠ {name} {_('not_pinned')}")
3096
-
3097
-def cmd_pinned():
3098
- pinned = load_json(PINNED_FILE)
3099
- if not pinned: print(_("no_pinned")); return
3100
- print(_("pinned_list", len(pinned)))
3101
- for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3102
-
3103
-def cmd_clean():
3104
- if os.path.isdir(PAG_CACHE):
3105
- count = size = 0
3106
- for f in os.listdir(PAG_CACHE):
3107
- fp = os.path.join(PAG_CACHE, f)
3108
- if os.path.isfile(fp):
3109
- size += os.path.getsize(fp); os.remove(fp); count += 1
3110
- print(f"✅ {_('cache_cleared', count, size/1048576)}")
3111
-
3112
-def cmd_remove_orphans():
3113
- installed = load_json(INSTALLED_DB)
3114
- world = load_world()
3115
- orphans = _find_orphans(installed, world)
3116
- if not orphans: print("✅ No orphans."); return
3117
- print(f"Orphans ({len(orphans)}):")
3118
- for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3119
- if not _ask_confirm():
3120
- return
3121
- cmd_remove(list(orphans))
3122
-
3123
-
3124
-# =============================================================================
3125
-# PROVIDES – PAKIETY WIRTUALNE
3126
-# =============================================================================
3127
-
3128
-PROVIDES_MAP = {
3129
- "pkgconfig(glib-2.0)": "glib",
3130
- "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3131
- "pkgconfig(gtk+-3.0)": "gtk",
3132
- "pkgconfig(gtk4)": "gtk",
3133
- "pkgconfig(zlib)": "zlib",
3134
- "pkgconfig(libffi)": "libffi",
3135
- "pkgconfig(expat)": "expat",
3136
- "pkgconfig(libsystemd)": "systemd",
3137
- "pkgconfig(dbus-1)": "dbus",
3138
- "pkgconfig(mount)": "util-linux",
3139
- "pkgconfig(blkid)": "util-linux",
3140
- "pkgconfig(libcap)": "libcap",
3141
- "pkgconfig(liblzma)": "xz",
3142
- "pkgconfig(libzstd)": "zstd",
3143
- "pkgconfig(bzip2)": "bzip2",
3144
- "pkgconfig(libcurl)": "curl",
3145
- "pkgconfig(openssl)": "openssl",
3146
- "pkgconfig(libpcre2-8)": "pcre2",
3147
- "pkgconfig(libxml-2.0)": "libxml2",
3148
- "pkgconfig(libxslt)": "libxslt",
3149
- "pkgconfig(freetype2)": "freetype",
3150
- "pkgconfig(fontconfig)": "fontconfig",
3151
- "pkgconfig(harfbuzz)": "harfbuzz",
3152
- "pkgconfig(cairo)": "cairo",
3153
- "pkgconfig(pango)": "pango",
3154
- "pkgconfig(xt)": "xorg-libxt",
3155
- "pkgconfig(xmu)": "xorg-libxmu",
3156
- "pkgconfig(ice)": "xorg-libice",
3157
- "pkgconfig(sm)": "xorg-libsm",
3158
- "pkgconfig(x11)": "xorg-libx11",
3159
- "pkgconfig(xext)": "xorg-libxext",
3160
- "pkgconfig(xrandr)": "xorg-libxrandr",
3161
- "pkgconfig(xfixes)": "xorg-libxfixes",
3162
- "pkgconfig(xcursor)": "xorg-libxcursor",
3163
- "pkgconfig(xinerama)": "xorg-libxinerama",
3164
- "pkgconfig(xrender)": "xorg-libxrender",
3165
- "pkgconfig(xau)": "xorg-libxau",
3166
- "pkgconfig(xcb)": "xorg-libxcb",
3167
- "pkgconfig(xdamage)": "xorg-libxdamage",
3168
- "pkgconfig(xcomposite)": "xorg-libxcomposite",
3169
- "pkgconfig(xft)": "xorg-libxft",
3170
- "pkgconfig(xss)": "xorg-libxss",
3171
- "pkgconfig(libsoup-3.0)": "libsoup3",
3172
- "pkgconfig(libsoup-2.4)": "libsoup2",
3173
- "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3174
- "pkgconfig(libpng)": "libpng",
3175
- "pkgconfig(libjpeg)": "libjpeg-turbo",
3176
- "pkgconfig(libtiff-4)": "libtiff",
3177
- "pkgconfig(ffi)": "libffi",
3178
- # ── system / baza ──
3179
- "pkgconfig(libcrypto)": "openssl",
3180
- "pkgconfig(libssl)": "openssl",
3181
- "pkgconfig(libudev)": "systemd",
3182
- "pkgconfig(libmount)": "util-linux",
3183
- "pkgconfig(libblkid)": "util-linux",
3184
- "pkgconfig(uuid)": "util-linux",
3185
- "pkgconfig(libexpat)": "expat",
3186
- "pkgconfig(libpcre)": "pcre",
3187
- "pkgconfig(ncursesw)": "ncurses",
3188
- "pkgconfig(tinfo)": "ncurses",
3189
- "pkgconfig(panel)": "ncurses",
3190
- "pkgconfig(readline)": "readline",
3191
- "pkgconfig(libseccomp)": "libseccomp",
3192
- "pkgconfig(pam)": "linux-pam",
3193
- "pkgconfig(libxcrypt)": "libxcrypt",
3194
- "pkgconfig(libcrypt)": "libxcrypt",
3195
- "pkgconfig(libnsl)": "libnsl",
3196
- "pkgconfig(liblz4)": "lz4",
3197
- "pkgconfig(libevent)": "libevent",
3198
- "pkgconfig(libarchive)": "libarchive",
3199
- "pkgconfig(sqlite3)": "sqlite",
3200
- "pkgconfig(libpq)": "postgresql",
3201
- "pkgconfig(mysqlclient)": "mariadb",
3202
- "pkgconfig(json-c)": "json-c",
3203
- "pkgconfig(json-glib-1.0)": "json-glib",
3204
- "pkgconfig(libunistring)": "libunistring",
3205
- "pkgconfig(libidn2)": "libidn2",
3206
- "pkgconfig(libpsl)": "libpsl",
3207
- "pkgconfig(icu-uc)": "icu",
3208
- "pkgconfig(icu-i18n)": "icu",
3209
- "pkgconfig(icu-io)": "icu",
3210
- "pkgconfig(gnutls)": "gnutls",
3211
- "pkgconfig(nettle)": "nettle",
3212
- "pkgconfig(hogweed)": "nettle",
3213
- "pkgconfig(libgcrypt)": "libgcrypt",
3214
- "pkgconfig(libgpg-error)": "libgpg-error",
3215
- "pkgconfig(libassuan)": "libassuan",
3216
- "pkgconfig(libusb-1.0)": "libusb",
3217
- "pkgconfig(libusb)": "libusb",
3218
- "pkgconfig(libgudev-1.0)": "libgudev",
3219
- "pkgconfig(gudev-1.0)": "libgudev",
3220
- "pkgconfig(polkit-gobject-1)": "polkit",
3221
- "pkgconfig(polkit-agent-1)": "polkit",
3222
- "pkgconfig(libpciaccess)": "libpciaccess",
3223
- "pkgconfig(pixman-1)": "pixman",
3224
- "pkgconfig(libdrm)": "libdrm",
3225
- "pkgconfig(libva)": "libva",
3226
- "pkgconfig(libva-drm)": "libva",
3227
- "pkgconfig(libva-x11)": "libva",
3228
- "pkgconfig(libva-wayland)": "libva",
3229
- "pkgconfig(vdpau)": "libvdpau",
3230
- "pkgconfig(libvdpau)": "libvdpau",
3231
- "pkgconfig(libinput)": "libinput",
3232
- "pkgconfig(libevdev)": "libevdev",
3233
- "pkgconfig(mtdev)": "mtdev",
3234
- # ── grafika / GL / multimedia ──
3235
- "pkgconfig(gbm)": "mesa",
3236
- "pkgconfig(gl)": "libglvnd",
3237
- "pkgconfig(egl)": "libglvnd",
3238
- "pkgconfig(glesv2)": "libglvnd",
3239
- "pkgconfig(glx)": "libglvnd",
3240
- "pkgconfig(vulkan)": "vulkan-loader",
3241
- "pkgconfig(libxkbcommon)": "libxkbcommon",
3242
- "pkgconfig(xkbcommon)": "libxkbcommon",
3243
- "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3244
- "pkgconfig(xcb)": "xorg-libxcb",
3245
- "pkgconfig(xcb-util)": "xcb-util",
3246
- "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3247
- "pkgconfig(xcb-icccm)": "xcb-util-wm",
3248
- "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3249
- "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3250
- "pkgconfig(xcb-image)": "xcb-util-image",
3251
- "pkgconfig(xcb-errors)": "xcb-util-errors",
3252
- "pkgconfig(wayland-client)": "wayland",
3253
- "pkgconfig(wayland-server)": "wayland",
3254
- "pkgconfig(wayland-cursor)": "wayland",
3255
- "pkgconfig(wayland-egl)": "wayland",
3256
- "pkgconfig(wayland-protocols)": "wayland-protocols",
3257
- "pkgconfig(gstreamer-1.0)": "gstreamer",
3258
- "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3259
- "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3260
- "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3261
- "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3262
- "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3263
- "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3264
- "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3265
- "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3266
- "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3267
- "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3268
- "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3269
- "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3270
- "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3271
- "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3272
- "pkgconfig(libpulse)": "libpulse",
3273
- "pkgconfig(libpulse-simple)": "libpulse",
3274
- "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3275
- "pkgconfig(alsa)": "alsa-lib",
3276
- "pkgconfig(jack)": "jack2",
3277
- "pkgconfig(libsamplerate)": "libsamplerate",
3278
- "pkgconfig(sndfile)": "libsndfile",
3279
- "pkgconfig(libavcodec)": "ffmpeg",
3280
- "pkgconfig(libavformat)": "ffmpeg",
3281
- "pkgconfig(libavutil)": "ffmpeg",
3282
- "pkgconfig(libavfilter)": "ffmpeg",
3283
- "pkgconfig(libswscale)": "ffmpeg",
3284
- "pkgconfig(libswresample)": "ffmpeg",
3285
- "pkgconfig(libpostproc)": "ffmpeg",
3286
- "pkgconfig(SDL2)": "sdl2",
3287
- "pkgconfig(SDL)": "sdl",
3288
- "pkgconfig(SDL2_image)": "sdl2-image",
3289
- "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3290
- "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3291
- "pkgconfig(SDL2_net)": "sdl2-net",
3292
- "pkgconfig(libpng16)": "libpng",
3293
- "pkgconfig(libwebp)": "libwebp",
3294
- "pkgconfig(libwebpmux)": "libwebp",
3295
- "pkgconfig(libwebpdemux)": "libwebp",
3296
- "pkgconfig(libopenjp2)": "openjpeg2",
3297
- "pkgconfig(lcms2)": "lcms2",
3298
- "pkgconfig(libheif)": "libheif",
3299
- "pkgconfig(libde265)": "libde265",
3300
- "pkgconfig(x264)": "x264",
3301
- "pkgconfig(x265)": "x265",
3302
- # ── glib / gio ──
3303
- "pkgconfig(gio-unix-2.0)": "glib",
3304
- "pkgconfig(gmodule-2.0)": "glib",
3305
- "pkgconfig(gthread-2.0)": "glib",
3306
- "pkgconfig(girepository-2.0)": "gobject-introspection",
3307
- "pkgconfig(girepository-1.0)": "gobject-introspection",
3308
- "pkgconfig(libglib-2.0)": "glib",
3309
- "pkgconfig(libgobject-2.0)": "glib",
3310
-}
3311
-
3312
-def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3313
- """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3314
-
3315
- Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3316
- provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3317
- dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3318
- """
3319
- if name in repo:
3320
- return name
3321
- if name in PROVIDES_MAP:
3322
- real = PROVIDES_MAP[name]
3323
- if real in repo:
3324
- return real
3325
- # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3326
- if name.startswith("pkgconfig(Qt5"):
3327
- real = "qt5"
3328
- if real in repo:
3329
- return real
3330
- if name.startswith("pkgconfig(Qt6"):
3331
- real = "qt6"
3332
- if real in repo:
3333
- return real
3334
- if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3335
- real = "gstreamer"
3336
- if real in repo:
3337
- return real
3338
- if name.startswith("pkgconfig(gst-"):
3339
- real = "gst-plugins-base"
3340
- if real in repo:
3341
- return real
3342
- # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3343
- for _pkg_name, _pkg in repo.items():
3344
- _provs = getattr(_pkg, "provides", None) or []
3345
- if name in _provs:
3346
- return _pkg_name
3347
- # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3348
- # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3349
- if installed:
3350
- for _pkg_name, _meta in installed.items():
3351
- _provs = _meta.get("provides") or [] if isinstance(_meta, dict) else []
3352
- if name in _provs:
3353
- return _pkg_name
3354
- clean = name
3355
- if name.startswith("pkgconfig(") and ")" in name:
3356
- clean = name.split("(", 1)[1].rstrip(")")
3357
- elif name.startswith("pkgconfig32(") and ")" in name:
3358
- clean = name.split("(", 1)[1].rstrip(")")
3359
- if clean != name and clean in repo:
3360
- return clean
3361
- return name
3362
-
3363
-
3364
-def cmd_why(pkg_name: str):
3365
- """Pokazuje dlaczego pakiet jest zainstalowany."""
3366
- installed = load_json(INSTALLED_DB)
3367
- world = load_world()
3368
- if pkg_name not in installed:
3369
- print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3370
- if pkg_name in world:
3371
- print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3372
- return 0
3373
- parents = set()
3374
- for w in world:
3375
- _find_dep_path(w, pkg_name, installed, set(), [], parents)
3376
- if parents:
3377
- for pp in sorted(parents):
3378
- print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3379
- else:
3380
- print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3381
- return 0
3382
-
3383
-
3384
-def _find_dep_path(cur, target, installed, visited, path, results):
3385
- if cur in visited: return
3386
- visited.add(cur); path.append(cur)
3387
- if cur == target:
3388
- results.add(tuple(path))
3389
- else:
3390
- for dep in installed.get(cur, {}).get("dependencies", []):
3391
- _find_dep_path(dep, target, installed, visited, path, results)
3392
- path.pop(); visited.discard(cur)
3393
-
3394
-
3395
-def cmd_autoremove():
3396
- """Automatycznie usuwa osierocone zależności bez pytania."""
3397
- installed = load_json(INSTALLED_DB)
3398
- world = load_world()
3399
- orphans = _find_orphans(installed, world)
3400
- if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3401
- print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3402
- return cmd_remove(list(orphans))
3403
-
3404
-
3405
-def cmd_download(package_names):
3406
- """Pobiera pakiety do cache bez instalowania."""
3407
- ensure_dirs()
3408
- repo = fetch_all_packages()
3409
- if not repo: print(f"❌ {_('no_index')}"); return 1
3410
- total_size = 0; downloaded = []
3411
- for name in package_names:
3412
- pkg = repo.get(name)
3413
- if not pkg:
3414
- print(f" ❌ {name}: {_('not_found')}"); continue
3415
- print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3416
- path = _download_pkg(pkg)
3417
- if path:
3418
- total_size += os.path.getsize(path)
3419
- downloaded.append(name)
3420
- print(_c("green", "✓"))
3421
- else:
3422
- print(_c("red", "✗"))
3423
- if downloaded:
3424
- print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3425
- return 0 if len(downloaded) == len(package_names) else 1
3426
-
3427
-
3428
-def cmd_stats():
3429
- """Wyświetla statystyki PAG."""
3430
- installed = load_json(INSTALLED_DB)
3431
- history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3432
- total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3433
- total_files = _db_count_files()
3434
- cache_size = sum(
3435
- os.path.getsize(os.path.join(PAG_CACHE, f))
3436
- for f in os.listdir(PAG_CACHE)
3437
- if os.path.isfile(os.path.join(PAG_CACHE, f))
3438
- ) if os.path.isdir(PAG_CACHE) else 0
3439
- last_update = "never"
3440
- for e in reversed(history):
3441
- if e.get("action") in ("install", "upgrade") and e.get("success"):
3442
- last_update = e.get("timestamp", "?")[:19]; break
3443
- print(f"\n {_c('bold', _('stats_title'))}")
3444
- print(f" {'─' * 40}")
3445
- print(f" {_('stats_packages'):<30} {len(installed)}")
3446
- print(f" {_('stats_files'):<30} {total_files}")
3447
- print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3448
- print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3449
- print(f" {_('stats_history'):<30} {len(history)}")
3450
- print(f" {_('stats_last_update'):<30} {last_update}")
3451
- by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3452
- if by_size:
3453
- print(f"\n {_c('dim', 'Top 5:')}")
3454
- for n, i in by_size:
3455
- print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3456
- return 0
3457
-
3458
-
3459
-def cmd_repo_add(url, name=None):
3460
- if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3461
- print(f" {_('sec_https')}"); return 1
3462
- ensure_dirs()
3463
- url = url.rstrip("/")
3464
- repos = get_repos()
3465
- if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3466
- if name:
3467
- # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3468
- os.makedirs(REPOS_DIR, exist_ok=True)
3469
- target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3470
- with open(target, "w") as f: f.write(f"{url}\n")
3471
- print(f"✅ {_('repo_added', url)} → {target}")
3472
- return
3473
- with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3474
- print(f"✅ {_('repo_added', url)}")
3475
-
3476
-def cmd_repo_list():
3477
- for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3478
-
3479
-def _check_flatpak(quiet: bool = False):
3480
- if not shutil.which("flatpak"):
3481
- if not quiet:
3482
- print(f"❌ {_('flatpak_missing')}")
3483
- return False
3484
- r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
3485
- if "flathub" not in r.stdout:
3486
- print(f"⚠ {_('flatpak_adding')}")
3487
- subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
3488
- "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
3489
- return True
3490
-
3491
-def _spinner(msg: str):
3492
- """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3493
- stop = threading.Event()
3494
- def _spin():
3495
- for c in itertools.cycle("|/-\\"):
3496
- if stop.is_set():
3497
- break
3498
- sys.stdout.write(f"\r {msg} {c}")
3499
- sys.stdout.flush()
3500
- time.sleep(0.1)
3501
- t = threading.Thread(target=_spin, daemon=True)
3502
- t.start()
3503
- def _stop():
3504
- stop.set()
3505
- t.join(timeout=0.3)
3506
- sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3507
- sys.stdout.flush()
3508
- return _stop
3509
-
3510
-
3511
-def _flatpak_search_raw(query: str) -> List[dict]:
3512
- """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3513
- if not _check_flatpak():
3514
- return []
3515
- stop = _spinner("Szukam we Flathub...")
3516
- try:
3517
- try:
3518
- r = subprocess.run(
3519
- ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3520
- capture_output=True, text=True, timeout=120
3521
- )
3522
- finally:
3523
- stop()
3524
- if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3525
- print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3526
- results = []
3527
- for line in r.stdout.strip().split("\n"):
3528
- parts = line.split("\t")
3529
- if len(parts) >= 3:
3530
- results.append({
3531
- "name": parts[0].strip(),
3532
- "description": parts[1].strip() if len(parts) > 1 else "",
3533
- "app_id": parts[2].strip() if len(parts) > 2 else "",
3534
- "version": parts[3].strip() if len(parts) > 3 else "",
3535
- "branch": parts[4].strip() if len(parts) > 4 else "stable",
3536
- "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3537
- })
3538
- return results
3539
- except Exception as e:
3540
- print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3541
- return []
3542
-
3543
-def _flatpak_find_best(query: str) -> Optional[dict]:
3544
- """
3545
- Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3546
- - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3547
- - Jeśli query pasuje do nazwy → zwraca pierwsze
3548
- - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3549
- - Jeśli brak → zwraca None
3550
- """
3551
- results = _flatpak_search_raw(query)
3552
- if not results:
3553
- return None
3554
-
3555
- # Dokładne dopasowanie app_id
3556
- exact = [r for r in results if r["app_id"].lower() == query.lower()]
3557
- if exact:
3558
- return exact[0]
3559
-
3560
- # Dokładne dopasowanie nazwy
3561
- exact_name = [r for r in results if r["name"].lower() == query.lower()]
3562
- if exact_name:
3563
- return exact_name[0]
3564
-
3565
- # Jednoznaczne dopasowanie (tylko 1 wynik)
3566
- if len(results) == 1:
3567
- return results[0]
3568
-
3569
- # Wiele wyników – pokaż użytkownikowi
3570
- print(f"\n {_('flatpak_found', len(results))}")
3571
- for i, r in enumerate(results):
3572
- print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3573
- if r["version"]:
3574
- print(f" {_('flatpak_info_version')}: {r['version']}")
3575
- if r["description"]:
3576
- desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3577
- print(f" {desc}")
3578
-
3579
- try:
3580
- choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3581
- if not choice:
3582
- return None
3583
- idx = int(choice) - 1
3584
- if 0 <= idx < len(results):
3585
- return results[idx]
3586
- except (EOFError, ValueError, IndexError):
3587
- pass
3588
- return None
3589
-
3590
-def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
3591
- """Zwraca info o zainstalowanym flatpaku lub None."""
3592
- try:
3593
- r = subprocess.run(
3594
- ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
3595
- capture_output=True, text=True, timeout=10
3596
- )
3597
- if r.returncode != 0:
3598
- return None
3599
- parts = r.stdout.strip().split("\t")
3600
- if len(parts) < 3:
3601
- return None
3602
- return {
3603
- "name": parts[0].strip(),
3604
- "version": parts[1].strip() if len(parts) > 1 else "",
3605
- "branch": parts[2].strip() if len(parts) > 2 else "",
3606
- "origin": parts[3].strip() if len(parts) > 3 else "",
3607
- "size": parts[4].strip() if len(parts) > 4 else "",
3608
- "description": parts[5].strip() if len(parts) > 5 else "",
3609
- }
3610
- except Exception:
3611
- return None
3612
-
3613
-def _flatpak_is_installed(app_id: str) -> bool:
3614
- """Sprawdza czy flatpak o danym ID jest zainstalowany."""
3615
- try:
3616
- r = subprocess.run(
3617
- ["flatpak", "info", app_id],
3618
- capture_output=True, text=True, timeout=10
3619
- )
3620
- return r.returncode == 0
3621
- except Exception:
3622
- return False
3623
-
3624
-# =============================================================================
3625
-# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
3626
-# =============================================================================
3627
-# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
3628
-# pag flatpak search <query> → tylko szuka
3629
-# pag flatpak install <query> → instaluje
3630
-# pag flatpak remove <id> → usuwa
3631
-# pag flatpak list → lista zainstalowanych
3632
-# pag flatpak update → aktualizuje wszystkie
3633
-# pag flatpak info <id> → szczegóły flatpaka
3634
-
3635
-def cmd_flatpak(args: list):
3636
- """
3637
- Główna komenda flatpak – inteligentnie rozpoznaje intencję:
3638
- pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
3639
- pag flatpak search firefox → tylko wyszukiwanie
3640
- pag flatpak install ... → bezpośrednia instalacja
3641
- pag flatpak remove ... → odinstalowanie
3642
- pag flatpak list → lista
3643
- pag flatpak update → aktualizacja
3644
- pag flatpak info ... → szczegóły
3645
- """
3646
- if not _check_flatpak():
3647
- return 1
3648
-
3649
- if not args:
3650
- # Bez argumentów – domyślnie lista
3651
- return cmd_flatpak_list()
3652
-
3653
- subcmd = args[0].lower()
3654
- rest = args[1:]
3655
-
3656
- # ── Podkomendy jawne ────────────────────────────────────────────────
3657
- if subcmd == "search":
3658
- if not rest:
3659
- print(_("flatpak_usage")); return 1
3660
- return cmd_flatpak_search(" ".join(rest))
3661
-
3662
- elif subcmd == "install":
3663
- if not rest:
3664
- print(_("flatpak_usage")); return 1
3665
- return _flatpak_smart_install(rest)
3666
-
3667
- elif subcmd == "remove" or subcmd == "uninstall":
3668
- if not rest:
3669
- print(_("flatpak_usage")); return 1
3670
- return _flatpak_smart_remove(rest)
3671
-
3672
- elif subcmd == "list":
3673
- return cmd_flatpak_list()
3674
-
3675
- elif subcmd == "update":
3676
- return cmd_flatpak_update()
3677
-
3678
- elif subcmd == "info":
3679
- if not rest:
3680
- print(_("flatpak_usage")); return 1
3681
- return cmd_flatpak_info(rest[0])
3682
-
3683
- else:
3684
- # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
3685
- # Sprawdź czy to zainstalowany flatpak → pokaż info
3686
- # Jeśli nie → szukaj i zaproponuj instalację
3687
- query = " ".join(args)
3688
-
3689
- # Najpierw sprawdź czy już zainstalowany
3690
- if _flatpak_is_installed(query):
3691
- print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
3692
- return cmd_flatpak_info(query)
3693
-
3694
- # Szukaj we Flathub
3695
- print(f" {_('flatpak_searching', query)}")
3696
- best = _flatpak_find_best(query)
3697
- if not best:
3698
- print(f" ❌ '{query}' – {_('flatpak_not_found')}")
3699
- return 1
3700
-
3701
- print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
3702
- if best["version"]:
3703
- print(f" {_('flatpak_info_version')}: {best['version']}")
3704
- if best["description"]:
3705
- print(f" {best['description']}")
3706
-
3707
- try:
3708
- ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
3709
- except (EOFError, KeyboardInterrupt):
3710
- print(f"\n ⚠ {_('no_tty')}")
3711
- return 0
3712
- if ans and ans not in ("t", "y"):
3713
- print(_("cancelled"))
3714
- return 0
3715
-
3716
- return _flatpak_do_install(best["app_id"])
3717
-
3718
-def _flatpak_smart_install(names: list) -> int:
3719
- """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
3720
- failed = 0
3721
- for name in names:
3722
- if "." in name and "/" not in name:
3723
- # Wygląda na pełne app_id (np. org.mozilla.firefox)
3724
- app_id = name
3725
- else:
3726
- # Szukaj najlepszego dopasowania
3727
- best = _flatpak_find_best(name)
3728
- if not best:
3729
- print(f" ❌ '{name}' – {_('flatpak_not_found')}")
3730
- failed += 1
3731
- continue
3732
- app_id = best["app_id"]
3733
- print(f" → {best['name']} ({app_id})")
3734
-
3735
- if _flatpak_do_install(app_id) != 0:
3736
- failed += 1
3737
- return 1 if failed else 0
3738
-
3739
-def _flatpak_do_install(app_id: str) -> int:
3740
- """Wykonuje właściwą instalację flatpaka."""
3741
- print(f" {_('flatpak_installing', app_id)}")
3742
- result = subprocess.run(
3743
- ["flatpak", "install", "-y", "flathub", app_id],
3744
- check=False, timeout=600
3745
- )
3746
- if result.returncode == 0:
3747
- print(f" ✅ {_('flatpak_installed', app_id)}")
3748
- return 0
3749
- else:
3750
- print(f" ❌ {_('download_fail')}: {app_id}")
3751
- return 1
3752
-
3753
-def _flatpak_smart_remove(names: list) -> int:
3754
- """Usuwa flatpaki – obsługuje nazwy częściowe."""
3755
- # Pobierz listę zainstalowanych
3756
- try:
3757
- r = subprocess.run(
3758
- ["flatpak", "list", "--columns=application,name"],
3759
- capture_output=True, text=True, timeout=10
3760
- )
3761
- installed = {}
3762
- for line in r.stdout.strip().split("\n"):
3763
- parts = line.split("\t")
3764
- if len(parts) >= 2:
3765
- installed[parts[0].strip()] = parts[1].strip()
3766
- except Exception:
3767
- installed = {}
3768
-
3769
- failed = 0
3770
- for name in names:
3771
- app_id = name
3772
-
3773
- # Jeśli nie podano pełnego ID – spróbuj dopasować
3774
- if name not in installed:
3775
- matches = {aid: aname for aid, aname in installed.items()
3776
- if name.lower() in aid.lower() or name.lower() in aname.lower()}
3777
- if len(matches) == 0:
3778
- print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
3779
- failed += 1
3780
- continue
3781
- elif len(matches) == 1:
3782
- app_id = list(matches.keys())[0]
3783
- print(f" → {matches[app_id]} ({app_id})")
3784
- else:
3785
- print(f"\n Wiele dopasowań dla '{name}':")
3786
- for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
3787
- print(f" {i}. {aname} ({aid})")
3788
- try:
3789
- choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
3790
- if not choice:
3791
- failed += 1
3792
- continue
3793
- aid_list = sorted(matches.keys())
3794
- app_id = aid_list[int(choice) - 1]
3795
- except (EOFError, ValueError, IndexError):
3796
- failed += 1
3797
- continue
3798
-
3799
- print(f" 🗑 {app_id} ...", end=" ", flush=True)
3800
- result = subprocess.run(
3801
- ["flatpak", "uninstall", "-y", app_id],
3802
- capture_output=True, text=True, timeout=120
3803
- )
3804
- if result.returncode == 0:
3805
- print("✅")
3806
- print(f" {_('flatpak_removed', app_id)}")
3807
- else:
3808
- print("❌")
3809
- failed += 1
3810
- return 1 if failed else 0
3811
-
3812
-def cmd_flatpak_search(q: str):
3813
- """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
3814
- if not _check_flatpak():
3815
- return 1
3816
- results = _flatpak_search_raw(q)
3817
- if not results:
3818
- print(f" ❌ '{q}' – {_('flatpak_not_found')}")
3819
- return 1
3820
- print(f"\n {_('flatpak_found', len(results))}")
3821
- shown = results[:30] # max 30 wyników
3822
- for i, r in enumerate(shown, 1):
3823
- installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
3824
- print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
3825
- if r["version"]:
3826
- print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
3827
- if r["description"]:
3828
- desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
3829
- print(f" {_c('dim', desc)}")
3830
- if len(results) > 30:
3831
- print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
3832
-
3833
- # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
3834
- try:
3835
- ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
3836
- except (EOFError, KeyboardInterrupt):
3837
- return 0
3838
- if ans:
3839
- try:
3840
- idx = int(ans) - 1
3841
- if 0 <= idx < len(shown):
3842
- return _flatpak_do_install(shown[idx]["app_id"])
3843
- print(_("cancelled"))
3844
- except (ValueError, IndexError):
3845
- print(_("cancelled"))
3846
- return 0
3847
-
3848
-def cmd_flatpak_list():
3849
- """Wyświetla zainstalowane flatpaki."""
3850
- if not _check_flatpak():
3851
- return 1
3852
- r = subprocess.run(
3853
- ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
3854
- capture_output=True, text=True, timeout=10
3855
- )
3856
- lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
3857
- if not lines:
3858
- print(" (brak zainstalowanych flatpaków)")
3859
- return 0
3860
- print(f" Zainstalowane flatpaki ({len(lines)}):")
3861
- for line in lines:
3862
- parts = line.split("\t")
3863
- if len(parts) >= 3:
3864
- app_id, name, version = parts[0], parts[1], parts[2]
3865
- size = parts[4] if len(parts) > 4 else ""
3866
- size_str = f" ({size})" if size else ""
3867
- print(f" 📦 {_c('bold', name)} {version}{size_str}")
3868
- print(f" {_c('dim', app_id)}")
3869
- return 0
3870
-
3871
-def cmd_flatpak_update():
3872
- """Aktualizuje wszystkie flatpaki."""
3873
- if not _check_flatpak():
3874
- return 1
3875
- print(" 🔄 Aktualizacja flatpaków...")
3876
- result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
3877
- if result.returncode == 0:
3878
- print(f" ✅ {_('flatpak_updated')}")
3879
- return result.returncode
3880
-
3881
-def cmd_flatpak_info(app_id: str):
3882
- """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
3883
- if not _check_flatpak():
3884
- return 1
3885
-
3886
- # Najpierw sprawdź zainstalowany
3887
- info = _flatpak_get_installed_info(app_id)
3888
- if info:
3889
- print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
3890
- print(f" {'─' * 45}")
3891
- print(f" {_('flatpak_info_id'):<16} {app_id}")
3892
- print(f" {_('flatpak_info_version'):<16} {info['version']}")
3893
- print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
3894
- print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
3895
- if info["size"]:
3896
- print(f" {_('flatpak_info_size'):<16} {info['size']}")
3897
- if info["description"]:
3898
- print(f" {_('flatpak_info_desc'):<16} {info['description']}")
3899
- return 0
3900
-
3901
- # Szukaj we Flathub
3902
- results = _flatpak_search_raw(app_id)
3903
- exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
3904
- if not exact:
3905
- # Spróbuj częściowego dopasowania
3906
- if results:
3907
- exact = [results[0]]
3908
- else:
3909
- print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
3910
- return 1
3911
-
3912
- r = exact[0]
3913
- print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
3914
- print(f" {'─' * 45}")
3915
- print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
3916
- print(f" {_('flatpak_info_version'):<16} {r['version']}")
3917
- if r["description"]:
3918
- print(f" {_('flatpak_info_desc'):<16} {r['description']}")
3919
- print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
3920
- return 0
3921
-
3922
-# =============================================================================
3923
-# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
3924
-# =============================================================================
3925
-
3926
-# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
3927
-KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
3928
-
3929
-def _is_kernel_package(name: str) -> bool:
3930
- """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
3931
- name_lower = name.lower()
3932
- return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
3933
-
3934
-def _rebuild_initramfs(deploy_dir: str = "") -> bool:
3935
- """
3936
- Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
3937
- Używa skryptu pag-initramfs lub ręcznego cpio.
3938
- """
3939
- if deploy_dir:
3940
- root = deploy_dir
3941
- else:
3942
- root = _get_deployment_root()
3943
-
3944
- if root == PAG_ROOT:
3945
- # Zwykły system – użyj dracut jeśli dostępny
3946
- if shutil.which("dracut"):
3947
- print(" 🔧 Przebudowa initramfs (dracut)...")
3948
- result = subprocess.run(
3949
- ["dracut", "--force", "/boot/initramfs.img"],
3950
- capture_output=True, text=True, timeout=120
3951
- )
3952
- return result.returncode == 0
3953
- elif shutil.which("mkinitcpio"):
3954
- print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
3955
- result = subprocess.run(
3956
- ["mkinitcpio", "-g", "/boot/initramfs.img"],
3957
- capture_output=True, text=True, timeout=120
3958
- )
3959
- return result.returncode == 0
3960
- else:
3961
- print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
3962
- return False
3963
-
3964
- # Tryb immutable – budujemy initramfs dla deploymentu
3965
- print(" 🔧 Budowanie initramfs dla deploymentu...")
3966
-
3967
- # Sprawdź czy mamy nasz skrypt init
3968
- pag_init_script = "/usr/share/pag/initramfs-init"
3969
- if not os.path.exists(pag_init_script):
3970
- # Szukaj w źródłach (developerski fallback)
3971
- alt_paths = [
3972
- os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
3973
- "/usr/share/pag/init",
3974
- ]
3975
- for p in alt_paths:
3976
- if os.path.exists(p):
3977
- pag_init_script = p
3978
- break
3979
-
3980
- if not os.path.exists(pag_init_script):
3981
- print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
3982
- return False
3983
-
3984
- boot_dir = os.path.join(root, "boot")
3985
- os.makedirs(boot_dir, exist_ok=True)
3986
-
3987
- # Znajdź jądro (vmlinuz-*)
3988
- kernels = sorted(
3989
- [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
3990
- reverse=True
3991
- ) if os.path.exists(boot_dir) else []
3992
- if not kernels:
3993
- print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
3994
- return False
3995
-
3996
- kernel_ver = kernels[0].replace("vmlinuz-", "")
3997
- print(f" 🐧 Jądro: {kernel_ver}")
3998
-
3999
- # Buduj initramfs ręcznie (cpio)
4000
- tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4001
- try:
4002
- # Podstawowa struktura
4003
- for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4004
- "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4005
- os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4006
-
4007
- # Skopiuj init
4008
- shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4009
- os.chmod(os.path.join(tmpdir, "init"), 0o755)
4010
-
4011
- # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4012
- busybox_paths = [
4013
- os.path.join(root, "usr/bin/busybox"),
4014
- os.path.join(root, "bin/busybox"),
4015
- "/usr/bin/busybox",
4016
- "/bin/busybox",
4017
- ]
4018
- busybox = None
4019
- for bp in busybox_paths:
4020
- if os.path.exists(bp):
4021
- busybox = bp
4022
- break
4023
-
4024
- if busybox:
4025
- shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4026
- # Utwórz symlinki dla podstawowych komend
4027
- for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4028
- "readlink", "mkdir", "switch_root", "cp", "rm"]:
4029
- link = os.path.join(tmpdir, "bin", cmd)
4030
- if not os.path.exists(link):
4031
- os.symlink("busybox", link)
4032
- # /bin/sh → busybox
4033
- if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4034
- os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4035
- else:
4036
- # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4037
- for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4038
- src = os.path.join(root, "usr/bin", tool)
4039
- if not os.path.exists(src):
4040
- src = os.path.join(root, "bin", tool)
4041
- if os.path.exists(src):
4042
- dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4043
- shutil.copy2(src, dest)
4044
- # Kopiuj zależności .so
4045
- _copy_libs_for_binary(src, tmpdir, root)
4046
-
4047
- # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4048
- modules_src = os.path.join(root, "lib/modules", kernel_ver)
4049
- if os.path.isdir(modules_src):
4050
- modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4051
- # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4052
- for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4053
- "kernel/drivers/scsi", "kernel/drivers/virtio",
4054
- "modules.order", "modules.builtin"]:
4055
- src_sub = os.path.join(modules_src, sub)
4056
- if os.path.exists(src_sub):
4057
- dst_sub = os.path.join(modules_dst, sub)
4058
- os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4059
- if os.path.isdir(src_sub):
4060
- try:
4061
- shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4062
- ignore_dangling_symlinks=True)
4063
- except (FileNotFoundError, PermissionError):
4064
- print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4065
- else:
4066
- try:
4067
- shutil.copy2(src_sub, dst_sub)
4068
- except (FileNotFoundError, PermissionError):
4069
- print(f" ⚠ Pomijam niedostępny plik: {sub}")
4070
-
4071
- # Pakuj do initramfs.img
4072
- initramfs_path = os.path.join(boot_dir, "initramfs.img")
4073
- old_cwd = os.getcwd()
4074
- os.chdir(tmpdir)
4075
- try:
4076
- with open(initramfs_path + ".tmp", "wb") as out:
4077
- _run_cpio_pipeline(tmpdir, out)
4078
- os.rename(initramfs_path + ".tmp", initramfs_path)
4079
- finally:
4080
- os.chdir(old_cwd)
4081
-
4082
- size_mb = os.path.getsize(initramfs_path) / 1048576
4083
- print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4084
- return True
4085
-
4086
- except Exception as e:
4087
- print(f" ❌ Błąd budowy initramfs: {e}")
4088
- return False
4089
- finally:
4090
- shutil.rmtree(tmpdir, ignore_errors=True)
4091
-
4092
-
4093
-def _run_cpio_pipeline(tmpdir: str, out):
4094
- """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4095
-
4096
- Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4097
- (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4098
- Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4099
- rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4100
- """
4101
- find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4102
- cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4103
- stdin=find.stdout, stdout=subprocess.PIPE)
4104
- find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4105
- gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4106
- cpio.stdout.close()
4107
- try:
4108
- gzip.wait(timeout=120)
4109
- if gzip.returncode != 0:
4110
- raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4111
- cpio.wait(timeout=30)
4112
- find.wait(timeout=30)
4113
- except subprocess.TimeoutExpired:
4114
- for p in (gzip, cpio, find):
4115
- p.kill()
4116
- raise
4117
- finally:
4118
- for p in (find, cpio, gzip):
4119
- if p.poll() is None:
4120
- p.kill()
4121
- # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4122
- if cpio.returncode != 0:
4123
- raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4124
- if find.returncode != 0:
4125
- raise subprocess.CalledProcessError(find.returncode, ["find"])
4126
-
4127
-
4128
-def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4129
- """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4130
- try:
4131
- result = subprocess.run(
4132
- ["ldd", binary], capture_output=True, text=True, timeout=10
4133
- )
4134
- for line in result.stdout.split("\n"):
4135
- m = re.search(r'=>\s+(/\S+)', line)
4136
- if m:
4137
- lib_path = m.group(1)
4138
- lib_rel = lib_path.lstrip("/")
4139
- lib_dest = os.path.join(dest_dir, lib_rel)
4140
- if not os.path.exists(lib_dest):
4141
- os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4142
- # Szukaj w deployment root lub systemie
4143
- if os.path.exists(lib_path):
4144
- shutil.copy2(lib_path, lib_dest)
4145
- else:
4146
- alt = os.path.join(root, lib_rel)
4147
- if os.path.exists(alt):
4148
- shutil.copy2(alt, lib_dest)
4149
- except Exception:
4150
- pass
4151
-
4152
-
4153
-def cmd_initramfs_update():
4154
- """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4155
- ensure_dirs()
4156
- deploy_dir = _get_deployment_root()
4157
- if deploy_dir != PAG_ROOT:
4158
- print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4159
- ok = _rebuild_initramfs(deploy_dir)
4160
- if ok:
4161
- print("✅ Initramfs zaktualizowany.")
4162
- # Po initramfs – zaktualizuj też GRUB
4163
- _update_grub_config()
4164
- else:
4165
- print("❌ Błąd aktualizacji initramfs.")
4166
- return 0 if ok else 1
4167
-
4168
-
4169
-def _update_grub_config():
4170
- """
4171
- Generuje wpisy GRUB dla wszystkich deploymentów.
4172
- Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4173
- """
4174
- grub_cfg = "/boot/grub/grub.cfg"
4175
- if not os.path.exists(os.path.dirname(grub_cfg)):
4176
- return # brak GRUB
4177
-
4178
- deployments = _load_deployments()
4179
- root_dev = _detect_root_device()
4180
-
4181
- lines = [
4182
- "# =====================================================================",
4183
- "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4184
- f"# Data: {datetime.now().isoformat()}",
4185
- "# =====================================================================",
4186
- "",
4187
- ]
4188
-
4189
- # Domyślny – ostatni (najnowszy) deployment
4190
- if deployments:
4191
- latest = deployments[-1]["id"]
4192
- lines.append(f"set default=0")
4193
- lines.append(f"set timeout=5")
4194
- else:
4195
- lines.append("set default=0")
4196
- lines.append("set timeout=5")
4197
- lines.append("")
4198
-
4199
- # Wpisy dla każdego deploymentu (od najnowszego)
4200
- entry_num = 0
4201
- for d in reversed(deployments):
4202
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4203
- boot_dir = os.path.join(deploy_dir, "boot")
4204
- kernels = sorted(
4205
- [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4206
- reverse=True
4207
- ) if os.path.isdir(boot_dir) else []
4208
-
4209
- kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4210
- initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4211
- initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4212
-
4213
- active_mark = " [AKTYWNY]" if d.get("active") else ""
4214
- pkg_list = ", ".join(d.get("packages", [])[:3])
4215
- label = f"Pagan Linux – {d['id']}{active_mark}"
4216
-
4217
- lines.append(f"menuentry '{label}' {{")
4218
- if kernel_path:
4219
- lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
4220
- else:
4221
- lines.append(f" # Brak jądra w tym deploymencie")
4222
- if initrd_line:
4223
- lines.append(f" {initrd_line}")
4224
- lines.append("}")
4225
- lines.append("")
4226
- entry_num += 1
4227
-
4228
- # Wpis fallback: zwykły root (gdyby wszystko padło)
4229
- lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4230
- lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
4231
- lines.append(f" initrd /boot/initramfs.img")
4232
- lines.append("}")
4233
- lines.append("")
4234
-
4235
- # Zapisz
4236
- os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4237
- with open(grub_cfg, "w") as f:
4238
- f.write("\n".join(lines))
4239
-
4240
- print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4241
-
4242
-
4243
-def _detect_root_device() -> str:
4244
- """Wykrywa device partycji root (np. /dev/sda1)."""
4245
- try:
4246
- result = subprocess.run(
4247
- ["findmnt", "-n", "-o", "SOURCE", "/"],
4248
- capture_output=True, text=True, timeout=5
4249
- )
4250
- if result.returncode == 0 and result.stdout.strip():
4251
- return result.stdout.strip()
4252
- except Exception:
4253
- pass
4254
- return "/dev/sda1" # fallback
4255
-
4256
-
4257
-def cmd_grub_update():
4258
- """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4259
- ensure_dirs()
4260
- print("📋 Aktualizacja konfiguracji GRUB...")
4261
- _update_grub_config()
4262
- print("✅ GRUB zaktualizowany.")
4263
- return 0
4264
-
4265
-def cmd_deploy_list():
4266
- """Wyświetla listę wszystkich deploymentów."""
4267
- deployments = _load_deployments()
4268
- if not deployments:
4269
- print(_("no_deployments")); return
4270
-
4271
- print(_("deployments_list", len(deployments)))
4272
- active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4273
-
4274
- for d in reversed(deployments):
4275
- marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4276
- print(f" {d['id']}{marker}")
4277
- print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4278
- if len(d.get('packages', [])) > 5:
4279
- print(f" +{len(d['packages']) - 5} więcej...")
4280
- print(f" {d['timestamp']}")
4281
-
4282
-
4283
-def cmd_deploy_rollback():
4284
- """Przełącza na poprzedni deployment."""
4285
- deployments = _load_deployments()
4286
- active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4287
-
4288
- if len(deployments) < 2:
4289
- print(f"❌ {_('deploy_rollback_fail')}"); return 1
4290
-
4291
- current_idx = active_indices[0] if active_indices else len(deployments) - 1
4292
- prev_idx = current_idx - 1 if current_idx > 0 else -1
4293
-
4294
- if prev_idx < 0:
4295
- print(f"❌ {_('deploy_rollback_fail')}"); return 1
4296
-
4297
- prev = deployments[prev_idx]
4298
- prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4299
-
4300
- if not os.path.isdir(prev_dir):
4301
- print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4302
-
4303
- print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4304
- print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4305
-
4306
- if not _ask_confirm():
4307
- return 0
4308
-
4309
- _switch_deployment(prev_dir)
4310
-
4311
- for d in deployments:
4312
- d["active"] = (d["id"] == prev["id"])
4313
- _save_deployments(deployments)
4314
-
4315
- _update_grub_config()
4316
- print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4317
- print(" 💡 Restart wymagany do przeładowania systemu.")
4318
- return 0
4319
-
4320
-
4321
-def cmd_deploy_cleanup(keep: int = 3):
4322
- """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4323
- deployments = _load_deployments()
4324
-
4325
- if len(deployments) <= keep:
4326
- print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4327
-
4328
- to_remove = deployments[:-keep]
4329
- removed = 0
4330
-
4331
- for d in to_remove:
4332
- deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4333
- if os.path.isdir(deploy_dir):
4334
- shutil.rmtree(deploy_dir, ignore_errors=True)
4335
- removed += 1
4336
-
4337
- remaining = deployments[-keep:]
4338
- _save_deployments(remaining)
4339
-
4340
- print(f"✅ {_('deploy_cleanup_ok', removed)}")
4341
- return 0
4342
-
4343
-
4344
-# =============================================================================
4345
-# POMOCNICZE
4346
-# =============================================================================
4347
-
4348
-def _resolve_deps(names, repo, installed):
4349
- resolved, visited = [], set()
4350
- missing = [] # zależności których nie ma ani w repo ani zainstalowane
4351
-
4352
- def visit(name):
4353
- if name in visited: return
4354
-
4355
- # Rozwijanie wirtualnych zależności przez provides
4356
- target = _resolve_provides(name, repo, installed)
4357
-
4358
- if target in visited: return
4359
- visited.add(target)
4360
- if target in repo:
4361
- for dep in repo[target].dependencies:
4362
- real_dep = _resolve_provides(dep, repo, installed)
4363
- real_target = real_dep if real_dep in repo else dep
4364
-
4365
- # Sprawdź czy zależność jest dostępna
4366
- if real_target not in installed and real_target not in repo:
4367
- if dep not in missing:
4368
- missing.append(dep)
4369
-
4370
- if dep not in installed:
4371
- visit(real_target)
4372
- elif target not in installed:
4373
- # Pakiet nie istnieje ani w repo ani zainstalowany
4374
- if target not in missing:
4375
- missing.append(target)
4376
-
4377
- if target not in installed and target not in resolved:
4378
- resolved.append(target)
4379
-
4380
- for name in names:
4381
- visit(name)
4382
-
4383
- # Zwróć brakujące (do sprawdzenia przez wywołującego)
4384
- return resolved, missing
4385
-
4386
-def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4387
- """
4388
- Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4389
- Zwraca liczbę brakujących zależności.
4390
- """
4391
- # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
4392
- SYSTEM_BASE = {
4393
- "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4394
- "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4395
- "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4396
- "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4397
- }
4398
- all_missing = []
4399
- all_warnings = []
4400
-
4401
- for pkg_name in to_install:
4402
- pkg = repo.get(pkg_name)
4403
- if not pkg:
4404
- continue
4405
-
4406
- for dep in pkg.dependencies:
4407
- if dep in SYSTEM_BASE:
4408
- continue # bazowy system dostarcza tę zależność
4409
- real_dep = _resolve_provides(dep, repo, installed)
4410
- # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4411
- in_repo = real_dep in repo
4412
- in_installed = real_dep in installed
4413
- will_be_installed = real_dep in to_install
4414
-
4415
- if not in_repo and not in_installed and not will_be_installed:
4416
- if dep not in all_missing:
4417
- all_missing.append((pkg_name, dep))
4418
- elif in_repo and not in_installed and not will_be_installed:
4419
- if dep not in [w[1] for w in all_warnings]:
4420
- all_warnings.append((pkg_name, dep, real_dep))
4421
-
4422
- if all_missing:
4423
- print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
4424
- for pkg, dep in all_missing:
4425
- print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
4426
- print()
4427
-
4428
- if all_warnings:
4429
- print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
4430
- for pkg, dep, real in all_warnings:
4431
- print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
4432
- print()
4433
-
4434
- return len(all_missing)
4435
-
4436
-# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
4437
-BASE_SO = {
4438
- "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
4439
- "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
4440
- "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
4441
- "libgcc_s.so.1", "linux-vdso.so.1",
4442
-}
4443
-
4444
-def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
4445
- """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
4446
-
4447
- Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
4448
- (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
4449
- wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
4450
- """
4451
- provided = set(BASE_SO)
4452
- for n in to_install:
4453
- p = repo.get(n)
4454
- if p:
4455
- provided.update(p.provides_so or [])
4456
- for n, info in installed.items():
4457
- provided.update(info.get("provides_so", []) or [])
4458
-
4459
- missing = []
4460
- for n in sorted(to_install):
4461
- p = repo.get(n)
4462
- if not p:
4463
- continue
4464
- for so in (p.requires_so or []):
4465
- if so not in provided:
4466
- missing.append((n, so))
4467
-
4468
- if missing:
4469
- print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
4470
- for n, so in missing:
4471
- print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
4472
- print()
4473
- return len(missing)
4474
-
4475
-def _download_pkg(pkg):
4476
- url = f"{pkg.repo_url}/{pkg.filename}"
4477
- dest = os.path.join(PAG_CACHE, pkg.filename)
4478
- if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
4479
- _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
4480
- return dest
4481
- try:
4482
- req = Request(url, headers={"User-Agent":"pag/3.0"})
4483
- with urlopen(req, timeout=600) as resp:
4484
- total = int(resp.headers.get("Content-Length", 0))
4485
- bar = DownloadBar(pkg.filename, total)
4486
- with open(dest, "wb") as f:
4487
- while True:
4488
- chunk = resp.read(65536)
4489
- if not chunk:
4490
- break
4491
- f.write(chunk)
4492
- bar.update(len(chunk))
4493
- bar.close()
4494
- if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
4495
- os.remove(dest); return None
4496
- _download_pkg_sig(pkg, dest)
4497
- return dest
4498
- except Exception as e:
4499
- print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
4500
- return None
4501
-
4502
-def _download_pkg_sig(pkg, dest):
4503
- """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
4504
- for ext in (".asc", ".sig"):
4505
- sig_dest = dest + ext
4506
- if os.path.exists(sig_dest):
4507
- return
4508
- try:
4509
- req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
4510
- with urlopen(req, timeout=30) as resp:
4511
- with open(sig_dest, "wb") as f:
4512
- f.write(resp.read())
4513
- return
4514
- except Exception:
4515
- continue
4516
-
4517
-def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
4518
- """
4519
- Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
4520
- Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
4521
- Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
4522
- """
4523
- results = {}
4524
- total = len(pkgs)
4525
- completed = 0
4526
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
4527
- future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
4528
- for future in as_completed(future_to_pkg):
4529
- pkg = future_to_pkg[future]
4530
- try:
4531
- results[pkg.name] = future.result()
4532
- except Exception:
4533
- results[pkg.name] = None
4534
- completed += 1
4535
- # Pasek postępu
4536
- pct = completed / total * 100
4537
- filled = int(20 * pct / 100)
4538
- bar = "█" * filled + "░" * (20 - filled)
4539
- print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
4540
- print(file=sys.stderr) # nowa linia po zakończeniu
4541
- return results
4542
-
4543
-def load_world():
4544
- if not os.path.exists(WORLD_FILE): return set()
4545
- return {l.strip() for l in open(WORLD_FILE) if l.strip()}
4546
-
4547
-def save_world(w):
4548
- with open(WORLD_FILE,"w") as f:
4549
- for n in sorted(w): f.write(f"{n}\n")
4550
-
4551
-def _find_orphans(installed, world):
4552
- needed = set(world)
4553
- changed = True
4554
- while changed:
4555
- changed = False
4556
- for n in list(needed):
4557
- for dep in installed.get(n,{}).get("dependencies",[]):
4558
- if dep not in needed and dep in installed:
4559
- needed.add(dep); changed = True
4560
- return {n for n in installed if n not in needed}
4561
-
4562
-# =============================================================================
4563
-# MAIN
4564
-# =============================================================================
4565
-
4566
-def cmd_sbom(argv):
4567
- """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
4568
-
4569
- Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
4570
- zainstalowanych pakietów, wersji, licencji i sum SHA256.
4571
- """
4572
- fmt = (argv[0] if argv else "spdx").lower()
4573
- if fmt not in ("spdx", "cyclonedx"):
4574
- print("❌ Format: spdx | cyclonedx")
4575
- return 1
4576
- installed = load_json(INSTALLED_DB)
4577
- if not installed:
4578
- print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
4579
- return 0
4580
- # metadata repo (licencje) – best-effort
4581
- try:
4582
- repo = fetch_all_packages()
4583
- except Exception:
4584
- repo = {}
4585
- names = sorted(installed)
4586
- created = datetime.now().astimezone().isoformat(timespec="seconds")
4587
-
4588
- def _license_of(name):
4589
- p = repo.get(name)
4590
- lic = getattr(p, "license", None) or []
4591
- if isinstance(lic, list):
4592
- lic = ", ".join(x for x in lic if x)
4593
- return lic or "NOASSERTION"
4594
-
4595
- if fmt == "spdx":
4596
- doc = {
4597
- "spdxVersion": "SPDX-2.3",
4598
- "dataLicense": "CC0-1.0",
4599
- "SPDXID": "SPDXRef-DOCUMENT",
4600
- "name": "PaganOS-installed",
4601
- "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
4602
- "creationInfo": {
4603
- "created": created,
4604
- "creators": [f"Tool: pag-{PAG_VERSION}"],
4605
- },
4606
- "packages": [],
4607
- }
4608
- for i, n in enumerate(names):
4609
- info = installed[n]
4610
- doc["packages"].append({
4611
- "SPDXID": f"SPDXRef-Package-{i+1}",
4612
- "name": n,
4613
- "versionInfo": info.get("version", ""),
4614
- "downloadLocation": info.get("repo", "NOASSERTION"),
4615
- "filesAnalyzed": False,
4616
- "licenseConcluded": _license_of(n),
4617
- "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
4618
- })
4619
- else: # cyclonedx
4620
- doc = {
4621
- "bomFormat": "CycloneDX",
4622
- "specVersion": "1.5",
4623
- "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
4624
- "version": 1,
4625
- "metadata": {
4626
- "timestamp": created,
4627
- "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
4628
- },
4629
- "components": [],
4630
- }
4631
- for n in names:
4632
- info = installed[n]
4633
- lic = _license_of(n)
4634
- comp = {
4635
- "type": "library",
4636
- "name": n,
4637
- "version": info.get("version", ""),
4638
- "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
4639
- }
4640
- if lic != "NOASSERTION":
4641
- comp["licenses"] = [{"license": {"id": lic}}]
4642
- doc["components"].append(comp)
4643
- print(json.dumps(doc, indent=2, ensure_ascii=False))
4644
- return 0
4645
-
4646
-
4647
-USAGE_EN = """pag v3 – Pagan Linux Package Manager
4648
-
4649
-BASIC:
4650
- pag install <pkg>... Install packages
4651
- pag remove <pkg>... Remove packages
4652
- pag update Update PACKAGES (refreshes indexes first)
4653
- pag sync Refresh indexes + show pending package updates
4654
- pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
4655
- pag list [--installed] List available / installed
4656
- pag search <query> Search packages
4657
- pag info <pkg> Package details
4658
- pag files <pkg> List package files
4659
- pag verify [--deep] Verify integrity (--deep = SHA256 per file)
4660
- pag clean Clear download cache
4661
- pag stats System statistics
4662
- pag download <pkg>... Download packages to cache (offline prep)
4663
-
4664
-SECURITY:
4665
- pag key-add <url|file> Import GPG key
4666
- pag key-list List trusted keys
4667
- pag key-remove <id> Remove key
4668
- pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
4669
- pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
4670
- pag key-trusted List pinned repo fingerprints
4671
-
4672
-ADVANCED:
4673
- pag why <pkg> Show why a package is installed
4674
- pag autoremove Auto-remove orphaned dependencies
4675
- pag pin <pkg> [ver] Pin package version
4676
- pag unpin <pkg> Unpin
4677
- pag pinned List pinned
4678
- pag history Transaction history
4679
- pag rollback Rollback last transaction
4680
- pag remove-orphans Remove orphaned deps
4681
- pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
4682
- pag repo-list List repositories
4683
- pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
4684
-
4685
-FLATPAK:
4686
- pag flatpak [<query>] Search & install (smart)
4687
- pag flatpak search <q> Search Flathub
4688
- pag flatpak install <id> Install flatpak
4689
- pag flatpak remove <id> Remove flatpak
4690
- pag flatpak list List installed flatpaks
4691
- pag flatpak update Update all flatpaks
4692
- pag flatpak info <id> Show flatpak details
4693
-
4694
-IMMUTABLE OS (PAG_IMMUTABLE=1):
4695
- pag deploy-list List all deployments
4696
- pag deploy-rollback Switch to previous deployment
4697
- pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
4698
- pag initramfs-update Rebuild initramfs for current kernel/deployment
4699
- pag grub-update Regenerate GRUB entries for all deployments
4700
-"""
4701
-
4702
-USAGE_PL = """pag v3 – Pagan Linux Package Manager
4703
-
4704
-PODSTAWOWE:
4705
- pag install <pkg>... Instalacja pakietów
4706
- pag remove <pkg>... Usuwanie pakietów
4707
- pag update Aktualizacja PAKIETÓW (odświeża indeksy)
4708
- pag sync Odśwież indeksy + info o aktualizacjach
4709
- pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
4710
- pag list [--installed] Lista dostępnych / zainstalowanych
4711
- pag search <query> Szukaj pakietów
4712
- pag info <pkg> Szczegóły pakietu
4713
- pag files <pkg> Lista plików pakietu
4714
- pag verify [--deep] Weryfikacja integralności
4715
- pag clean Wyczyść cache pobierania
4716
- pag stats Statystyki systemu
4717
- pag download <pkg>... Pobierz do cache (offline)
4718
-
4719
-BEZPIECZEŃSTWO:
4720
- pag key-add <url|file> Importuj klucz GPG
4721
- pag key-list Lista zaufanych kluczy
4722
- pag key-remove <id> Usuń klucz
4723
- pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
4724
- pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
4725
- pag key-trusted Lista przypiętych fingerprintów repo
4726
-
4727
-ZAAWANSOWANE:
4728
- pag why <pkg> Dlaczego pakiet jest zainstalowany
4729
- pag autoremove Usuń osierocone zależności
4730
- pag pin <pkg> [ver] Przypnij wersję pakietu
4731
- pag unpin <pkg> Odepnij
4732
- pag pinned Lista przypiętych
4733
- pag history Historia transakcji
4734
- pag rollback Cofnij ostatnią transakcję
4735
- pag remove-orphans Usuń osierocone zależności
4736
- pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
4737
- pag repo-list Lista repozytoriów
4738
- pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
4739
-
4740
-FLATPAK:
4741
- pag flatpak [<query>] Szukaj i instaluj
4742
- pag flatpak search <q> Szukaj na Flathub
4743
- pag flatpak install <id> Zainstaluj flatpak
4744
- pag flatpak remove <id> Usuń flatpak
4745
- pag flatpak list Lista zainstalowanych
4746
- pag flatpak update Aktualizuj wszystkie
4747
- pag flatpak info <id> Szczegóły flatpaka
4748
-
4749
-IMMUTABLE OS (PAG_IMMUTABLE=1):
4750
- pag deploy-list Lista wdrożeń
4751
- pag deploy-rollback Przełącz na poprzednie wdrożenie
4752
- pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
4753
- pag initramfs-update Przebuduj initramfs
4754
- pag grub-update Regeneruj wpisy GRUB"""
4755
-
4756
-def _get_usage():
4757
- if LANG == "pl":
4758
- return USAGE_PL
4759
- return USAGE_EN
4760
-
4761
-
4762
-def main():
4763
- if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
4764
- print(f"pag {PAG_VERSION}")
4765
- sys.exit(0)
4766
- if len(sys.argv) < 2:
4767
- print(_get_usage()); sys.exit(0)
4768
-
4769
- cmd = sys.argv[1]
4770
- args = sys.argv[2:]
4771
-
4772
- # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
4773
- READ_ONLY = {
4774
- "list": lambda: cmd_list("--installed" in args),
4775
- "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
4776
- "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
4777
- "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
4778
- "verify": lambda: cmd_verify("--deep" in args),
4779
- "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
4780
- "stats": cmd_stats,
4781
- "pinned": cmd_pinned,
4782
- "history": cmd_history,
4783
- "repo-list": cmd_repo_list,
4784
- "key-list": cmd_key_list,
4785
- "key-trusted": cmd_key_trusted,
4786
- "flatpak": lambda: cmd_flatpak(args),
4787
- "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
4788
- "flatpak-list": cmd_flatpak_list,
4789
- "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
4790
- "deploy-list": cmd_deploy_list,
4791
- "deploy": cmd_deploy_list,
4792
- "sbom": lambda: cmd_sbom(args),
4793
- }
4794
-
4795
- if cmd in READ_ONLY:
4796
- sys.exit(READ_ONLY[cmd]() or 0)
4797
-
4798
- # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
4799
- WRITE_CMDS = {
4800
- "install", "remove", "update", "sync", "upgrade", "clean", "download",
4801
- "autoremove", "remove-orphans", "pin", "unpin", "rollback",
4802
- "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
4803
- "self-update",
4804
- "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
4805
- "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
4806
- }
4807
- if cmd not in WRITE_CMDS:
4808
- # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
4809
- # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
4810
- # potrafi wisieć na `flatpak search` aż do Ctrl-C).
4811
- _known = set(READ_ONLY) | set(WRITE_CMDS)
4812
- _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
4813
- if _close:
4814
- print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
4815
- print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
4816
- sys.exit(1)
4817
- sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4818
-
4819
- # Obsługa flag globalnych (-y/--yes)
4820
- global_args = []
4821
- for a in args:
4822
- if a in ("-y", "--yes"):
4823
- os.environ["PAG_YES"] = "1"
4824
- else:
4825
- global_args.append(a)
4826
- args = global_args
4827
-
4828
- # --- Komendy ZAPISU (wymagają roota) ---
4829
- if os.geteuid() != 0:
4830
- print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
4831
-
4832
- ensure_dirs()
4833
-
4834
- with DatabaseLock():
4835
- WRITE_COMMANDS = {
4836
- "install": lambda: cmd_install(
4837
- [a for a in args if a not in ("-f", "--force")],
4838
- upgrade=("-f" in args or "--force" in args)),
4839
- "remove": lambda: cmd_remove(args),
4840
- "update": lambda: cmd_update(do_upgrade=True),
4841
- "sync": lambda: cmd_update(do_upgrade=False),
4842
- "upgrade": cmd_upgrade,
4843
- "clean": cmd_clean,
4844
- "download": lambda: cmd_download(args),
4845
- "autoremove": cmd_autoremove,
4846
- "remove-orphans": cmd_remove_orphans,
4847
- "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
4848
- "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
4849
- "rollback": cmd_rollback,
4850
- "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]"),
4851
- "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
4852
- "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
4853
- "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
4854
- "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
4855
- "self-update": cmd_self_update,
4856
- "flatpak": lambda: cmd_flatpak(args),
4857
- "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
4858
- "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
4859
- "flatpak-update": cmd_flatpak_update,
4860
- "deploy-rollback": cmd_deploy_rollback,
4861
- "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
4862
- "initramfs-update": cmd_initramfs_update,
4863
- "grub-update": cmd_grub_update,
4864
- }
4865
-
4866
- fn = WRITE_COMMANDS.get(cmd)
4867
- if fn:
4868
- sys.exit(fn() or 0)
4869
- # Should never reach here – _smart_search handles unknowns
4870
- sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4871
-
4872
-if __name__ == "__main__":
4873
- try:
4874
- main()
4875
- except KeyboardInterrupt:
4876
- # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
4877
- print("\n ⚠ Przerwano (Ctrl-C).")
1
+#!/usr/bin/env python3
2
+"""
3
+╔══════════════════════════════════════════════════════════════════════════════╗
4
+║ PAG - Pagan Linux Package Manager v3.3.18 ║
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, 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.18"
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 _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
170
+ """
171
+ Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
172
+ gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
173
+ Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
174
+ (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
175
+ """
176
+ env = kwargs.pop("env", None) or os.environ.copy()
177
+ env["LC_ALL"] = "C"
178
+ env["GNUPGHOME"] = GPG_HOME
179
+ try:
180
+ return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
181
+ except FileNotFoundError:
182
+ # GPG nie jest dostępne – zwróć błąd z komunikatem
183
+ # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
184
+ _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
185
+ _msg = f"GPG binary not found ({GPG_BINARY})"
186
+ return subprocess.CompletedProcess(
187
+ [GPG_BINARY, *args], 127,
188
+ stdout=("" if _text else b""),
189
+ stderr=(_msg if _text else _msg.encode()),
190
+ )
191
+ except subprocess.TimeoutExpired:
192
+ return subprocess.CompletedProcess(
193
+ [GPG_BINARY, *args], 124,
194
+ stdout=b"", stderr=b"GPG operation timed out"
195
+ )
196
+
197
+def _load_trust_db() -> dict:
198
+ """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
199
+ try:
200
+ with open(TRUST_DB) as f:
201
+ return json.load(f)
202
+ except (FileNotFoundError, json.JSONDecodeError):
203
+ return {}
204
+
205
+
206
+def _save_trust_db(db: dict):
207
+ os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
208
+ with open(TRUST_DB, "w") as f:
209
+ json.dump(db, f, indent=2)
210
+
211
+
212
+def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
213
+ """Weryfikuje podpis i odczytuje fingerprint podpisującego.
214
+
215
+ Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
216
+ """
217
+ env = os.environ.copy()
218
+ res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
219
+ capture_output=True, text=True, timeout=timeout, env=env)
220
+ if res.returncode != 0:
221
+ return False, None
222
+ m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
223
+ if not m:
224
+ m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
225
+ return True, (m.group(1).upper() if m else None)
226
+
227
+
228
+# =============================================================================
229
+# i18n – WIELOJĘZYCZNOŚĆ
230
+# =============================================================================
231
+
232
+LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
233
+COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
234
+
235
+def _c(code: str, text: str) -> str:
236
+ """Dodaje kody ANSI jeśli kolor jest włączony."""
237
+ if not COLOR:
238
+ return text
239
+ colors = {
240
+ "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
241
+ "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
242
+ "reset": "\033[0m",
243
+ }
244
+ return f"{colors.get(code,'')}{text}{colors['reset']}"
245
+
246
+T = {
247
+ "en": {
248
+ "root_required": "pag requires root privileges (sudo).",
249
+ "db_locked": "Another pag instance is running.",
250
+ "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
251
+ "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
252
+ "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
253
+ "all_installed": "All packages are already installed.",
254
+ "to_install": "To install: {} packages ({:.2f} MB)",
255
+ "new": "NEW",
256
+ "continue_q": "Continue? [Y/n] ",
257
+ "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
258
+ "cancelled": "Cancelled.",
259
+ "not_found": "not found in repos",
260
+ "pkg_not_found": "Package not found: {} (not in any repo)",
261
+ "not_found_hint": "Check the spelling or run 'pag search <query>'.",
262
+ "downloading": "Downloading",
263
+ "download_fail": "download failed",
264
+ "gpg_fail": "GPG verification failed",
265
+ "sha256_mismatch": "SHA256 mismatch",
266
+ "install_failed": "installation failed",
267
+ "installed": "Installed {} packages.",
268
+ "rollback_restored": "Restored previous state from snapshot.",
269
+ "rollback_files": "Rolled back {} files.",
270
+ "no_history": "No transaction history.",
271
+ "pinned_list": "Pinned packages ({}):",
272
+ "no_pinned": "No pinned packages.",
273
+ "pinned_to": "pinned to",
274
+ "unpinned": "unpinned.",
275
+ "not_pinned": "was not pinned.",
276
+ "repo_added": "Added repository: {}",
277
+ "repo_exists": "Repository already exists: {}",
278
+ "updated_done": "Index refresh complete. {} packages cached.",
279
+ "indexes_refreshed": "Indexes refreshed.",
280
+ "updates_available": "⚠ {} packages have updates – run: pag update",
281
+ "upgrading": "Upgrading: {} packages",
282
+ "all_up_to_date": "All packages are up to date.",
283
+ "removing": "Removing",
284
+ "orphans_found": "Orphaned dependencies ({}): {}",
285
+ "flatpak_missing": "Flatpak is not installed.",
286
+ "flatpak_adding": "Adding Flathub remote...",
287
+ "flatpak_searching": "Searching Flathub for '{}'...",
288
+ "flatpak_found": "Found {} results:",
289
+ "flatpak_not_found": "not found on Flathub",
290
+ "flatpak_install_prompt": "Install {}? [Y/n] ",
291
+ "flatpak_installing": "Installing {}...",
292
+ "flatpak_installed": "Flatpak {} installed.",
293
+ "flatpak_removed": "Flatpak {} removed.",
294
+ "flatpak_not_installed": "Flatpak {} is not installed.",
295
+ "flatpak_info_id": "ID",
296
+ "flatpak_info_version": "Version",
297
+ "flatpak_info_branch": "Branch",
298
+ "flatpak_info_origin": "Origin",
299
+ "flatpak_info_size": "Installed size",
300
+ "flatpak_info_desc": "Description",
301
+ "flatpak_updated": "Flatpaks updated.",
302
+ "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
303
+ "key_imported": "Key imported successfully.",
304
+ "key_removed": "Key removed: {}",
305
+ "no_keys": "No trusted GPG keys.",
306
+ "gpg_missing": "GNUPG MISSING – install gnupg and retry",
307
+ "key_add_failed": "Key import failed (gpg error) – key was NOT added.",
308
+ "verify_ok": "All {} files intact.",
309
+ "verify_errors": "{} problems found:",
310
+ "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
311
+ "deployments_list": "Deployments ({}):",
312
+ "no_deployments": "No deployments.",
313
+ "active_deployment": "ACTIVE",
314
+ "deploy_rollback_ok": "Switched to deployment: {}",
315
+ "deploy_rollback_fail": "No previous deployment.",
316
+ "deploy_cleanup_ok": "Removed {} old deployments.",
317
+ "deploy_cleanup_none": "No deployments to clean (minimum {}).",
318
+ "why_explicit": "explicitly installed",
319
+ "why_dependency": "dependency of",
320
+ "why_not_installed": "not installed",
321
+ "autoremove_ok": "Removed {} orphaned packages.",
322
+ "autoremove_none": "No orphaned packages.",
323
+ "downloaded": "Downloaded {} to cache ({:.2f} MB).",
324
+ "provides_mapped": "{} → {} (provides)",
325
+ "stats_title": "PAG Statistics",
326
+ "stats_packages": "Installed packages",
327
+ "stats_files": "Tracked files",
328
+ "stats_size": "Total size",
329
+ "stats_cache": "Cache size",
330
+ "stats_history": "Transactions",
331
+ "stats_last_update": "Last update",
332
+ # Komunikaty bezpieczeństwa (baza EN; PL w tabeli "pl" jako sec_*_pl)
333
+ "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
334
+ "sec_suid": "SUID stripped from {path}",
335
+ "sec_https": "HTTPS required for repos",
336
+ "sec_badname": "Invalid package name: {name}",
337
+ "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
338
+ "sec_conflict": "File conflict: {path} owned by {owner}",
339
+ "sec_audit": "{pkg} installed by {user}",
340
+ "sec_locked": "Another pag process is running",
341
+ },
342
+ "pl": {
343
+ "root_required": "pag wymaga uprawnień root (sudo).",
344
+ "db_locked": "Inna instancja pag jest uruchomiona.",
345
+ "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
346
+ "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
347
+ "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",
348
+ "all_installed": "Wszystkie pakiety są już zainstalowane.",
349
+ "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
350
+ "new": "NOWY",
351
+ "continue_q": "Kontynuować? [T/n] ",
352
+ "no_tty": "Brak terminala (EOF) – anuluję.",
353
+ "cancelled": "Anulowano.",
354
+ "not_found": "brak w repozytoriach",
355
+ "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
356
+ "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
357
+ "downloading": "Pobieranie",
358
+ "download_fail": "błąd pobierania",
359
+ "gpg_fail": "błąd weryfikacji GPG",
360
+ "sha256_mismatch": "niezgodność SHA256",
361
+ "install_failed": "błąd instalacji",
362
+ "installed": "Zainstalowano {} pakietów.",
363
+ "rollback_restored": "Przywrócono poprzedni stan z migawki.",
364
+ "rollback_files": "Wycofano {} plików.",
365
+ "no_history": "Brak historii transakcji.",
366
+ "pinned_list": "Przypięte pakiety ({}):",
367
+ "no_pinned": "Brak przypiętych pakietów.",
368
+ "pinned_to": "przypięty do",
369
+ "unpinned": "odpięty.",
370
+ "not_pinned": "nie był przypięty.",
371
+ "repo_added": "Dodano repozytorium: {}",
372
+ "repo_exists": "Repozytorium już istnieje: {}",
373
+ "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
374
+ "indexes_refreshed": "Indeksy odświeżone.",
375
+ "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
376
+ "upgrading": "Aktualizacje: {} pakietów",
377
+ "all_up_to_date": "Wszystkie pakiety są aktualne.",
378
+ "removing": "Usuwanie",
379
+ "orphans_found": "Osierocone zależności ({}): {}",
380
+ "flatpak_missing": "Flatpak nie jest zainstalowany.",
381
+ "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
382
+ "flatpak_searching": "Szukam '{}' we Flathub...",
383
+ "flatpak_found": "Znaleziono {} wyników:",
384
+ "flatpak_not_found": "nie znaleziono we Flathub",
385
+ "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
386
+ "flatpak_installing": "Instalowanie {}...",
387
+ "flatpak_installed": "Flatpak {} zainstalowany.",
388
+ "flatpak_removed": "Flatpak {} usunięty.",
389
+ "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
390
+ "flatpak_info_id": "ID",
391
+ "flatpak_info_version": "Wersja",
392
+ "flatpak_info_branch": "Gałąź",
393
+ "flatpak_info_origin": "Źródło",
394
+ "flatpak_info_size": "Rozmiar",
395
+ "flatpak_info_desc": "Opis",
396
+ "flatpak_updated": "Flapaki zaktualizowane.",
397
+ "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
398
+ "key_imported": "Klucz zaimportowany pomyślnie.",
399
+ "key_removed": "Klucz usunięty: {}",
400
+ "no_keys": "Brak zaufanych kluczy GPG.",
401
+ "gpg_missing": "BRAK GNUPG – zainstaluj gnupg i spróbuj ponownie",
402
+ "key_add_failed": "Import klucza nie powiódł się (błąd gpg) – klucz NIE został dodany.",
403
+ "verify_ok": "Wszystkie {} plików sprawne.",
404
+ "verify_errors": "Znaleziono {} problemów:",
405
+ "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
406
+ "deployments_list": "Deploymenty ({}):",
407
+ "no_deployments": "Brak deploymentów.",
408
+ "active_deployment": "AKTYWNY",
409
+ "deploy_rollback_ok": "Przełączono na deployment: {}",
410
+ "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
411
+ "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
412
+ "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
413
+ "why_explicit": "zainstalowany jawnie",
414
+ "why_dependency": "zależność od",
415
+ "why_not_installed": "niezainstalowany",
416
+ "autoremove_ok": "Usunięto {} osieroconych pakietów.",
417
+ "autoremove_none": "Brak osieroconych pakietów.",
418
+ "downloaded": "Pobrano {} do cache ({:.2f} MB).",
419
+ "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
420
+ "sec_suid": "SUID stripped from {path}",
421
+ "sec_https": "HTTPS required for repos",
422
+ "sec_badname": "Invalid package name: {name}",
423
+ "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
424
+ "sec_conflict": "File conflict: {path} owned by {owner}",
425
+ "sec_audit": "{pkg} installed by {user}",
426
+ "sec_locked": "Another pag process is running",
427
+ "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
428
+ "sec_suid_pl": "SUID usuniety z {path}",
429
+ "sec_https_pl": "Repozytorium wymaga HTTPS",
430
+ "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
431
+ "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
432
+ "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
433
+ "sec_audit_pl": "{pkg} zainstalowany przez {user}",
434
+ "sec_locked_pl": "Inny proces pag juz dziala",
435
+
436
+ "provides_mapped": "{} → {} (provides)",
437
+ "stats_title": "Statystyki PAG",
438
+ "stats_packages": "Zainstalowane pakiety",
439
+ "stats_files": "Śledzone pliki",
440
+ "stats_size": "Całkowity rozmiar",
441
+ "stats_cache": "Rozmiar cache",
442
+ "stats_history": "Transakcje",
443
+ "stats_last_update": "Ostatnia aktualizacja",
444
+ },
445
+}
446
+
447
+# ── Tłumaczenia z PLIKÓW (nadpisują/rozszerzają wbudowane PL/EN) ─────────────
448
+# Kolejność: PAG_LANG_DIR (env) → /etc/pag/lang → /usr/share/pag/lang →
449
+# ./pag-lang obok binarki (dev). Brak plików NIE jest błędem – zostaje
450
+# wbudowany słownik T (fallback), więc pag zawsze działa.
451
+# Przykład pliku (pag-lang/pl.json): {"app_title": "...", "usage": "..."}.
452
+def _load_lang_files() -> None:
453
+ # PAG_LANG_NO_FILES=1 → pomiń pliki (używane przy eksporcie --lang-extract,
454
+ # żeby wyeksportować CZYSTE wbudowane słowniki, bez starych nadpisań).
455
+ if os.environ.get("PAG_LANG_NO_FILES") == "1":
456
+ return
457
+ dirs = []
458
+ _env = os.environ.get("PAG_LANG_DIR")
459
+ if _env:
460
+ dirs.append(_env)
461
+ dirs += ["/etc/pag/lang", "/usr/share/pag/lang",
462
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "pag-lang")]
463
+ for _d in dirs:
464
+ for _code in list(T.keys()) + ["pl", "en", "de"]:
465
+ _p = os.path.join(_d, f"{_code}.json")
466
+ try:
467
+ with open(_p, "r", encoding="utf-8") as _fh:
468
+ _data = json.load(_fh)
469
+ if isinstance(_data, dict):
470
+ T.setdefault(_code, {}).update(
471
+ {str(k): str(v) for k, v in _data.items()})
472
+ except (OSError, ValueError):
473
+ continue
474
+
475
+
476
+_load_lang_files()
477
+
478
+def _(key: str, *args, **kwargs) -> str:
479
+ """Tłumaczy klucz i formatuje argumenty.
480
+
481
+ Dla LANG=pl preferuje wariant „<key>_pl” (np. komunikaty bezpieczeństwa
482
+ mają krótkie wersje PL obok bazy EN), potem zwykły klucz, potem EN/klicz.
483
+ """
484
+ if LANG == "pl":
485
+ _pl = T.get("pl", {})
486
+ msg = _pl.get(key + "_pl") or _pl.get(key) or T["en"].get(key, key)
487
+ else:
488
+ msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
489
+ if args or kwargs:
490
+ return msg.format(*args, **kwargs)
491
+ return msg
492
+
493
+
494
+def _ask_confirm() -> bool:
495
+ """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
496
+
497
+ EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
498
+ panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
499
+ (inaczej input() rzuca EOFError i pag pada tracebackiem).
500
+ Enter → tak (domyślne Y/n).
501
+ """
502
+ if os.environ.get("PAG_YES", "") == "1":
503
+ print(_("continue_q") + " t (--yes)")
504
+ return True
505
+ try:
506
+ ans = input(_("continue_q")).strip().lower()
507
+ except (EOFError, KeyboardInterrupt):
508
+ print(f"\n ⚠ {_('no_tty')}")
509
+ return False
510
+ return not ans or ans in ("t", "y")
511
+
512
+
513
+# =============================================================================
514
+# ŚCIEŻKI
515
+# =============================================================================
516
+PAG_ROOT = os.environ.get("PAG_ROOT", "/")
517
+PAG_DB = "/var/lib/pag"
518
+PAG_CACHE = "/var/cache/pag"
519
+PAG_CONF = "/etc/pag"
520
+REPO_CACHE = "/var/cache/pag/repos"
521
+REPOS_CONF = "/etc/pag/repos.conf"
522
+REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
523
+INSTALLED_DB = "/var/lib/pag/installed.json"
524
+FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
525
+WORLD_FILE = "/var/lib/pag/world"
526
+PINNED_FILE = "/var/lib/pag/pinned.json"
527
+HISTORY_FILE = "/var/lib/pag/history.json"
528
+LOCK_FILE = "/var/lib/pag/pag.lock"
529
+STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
530
+PKG_EXT = ".pag"
531
+REPO_CACHE_TTL = 3600
532
+MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
533
+ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
534
+
535
+# Bezpieczeństwo / audyt
536
+AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
537
+TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
538
+HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
539
+
540
+# =============================================================================
541
+# IMMUTABLE OS – DEPLOYMENTY
542
+# =============================================================================
543
+# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
544
+# /var, /etc, /home są współdzielone między deploymentami.
545
+#
546
+# STRUKTURA:
547
+# /.deployments/
548
+# active → 20260723T120000 (symlink do aktywnego)
549
+# 20260723T120000/
550
+# usr/ bin/ lib/ lib64/ ... (pełny system)
551
+# var → /var (symlink do współdzielonego)
552
+# etc → /etc
553
+# home → /home
554
+# ...
555
+#
556
+# Jak to działa:
557
+# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
558
+# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
559
+# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
560
+# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
561
+# =============================================================================
562
+
563
+DEPLOYMENTS_DIR = "/.deployments"
564
+ACTIVE_LINK = "/.deployments/active"
565
+DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
566
+
567
+# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
568
+SHARED_PATHS = {
569
+ "/var", "/etc", "/home", "/root", "/tmp", "/run",
570
+ "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
571
+ "/.deployments", "/.pag_staging",
572
+}
573
+
574
+def _is_shared_path(rel: str) -> bool:
575
+ """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
576
+ for sp in SHARED_PATHS:
577
+ if rel == sp or rel.startswith(sp + "/"):
578
+ return True
579
+ return False
580
+
581
+def _get_deployment_root() -> str:
582
+ """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
583
+ if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
584
+ return PAG_ROOT
585
+ if os.path.islink(ACTIVE_LINK):
586
+ return os.readlink(ACTIVE_LINK)
587
+ if os.path.isdir(ACTIVE_LINK):
588
+ return ACTIVE_LINK
589
+ # Brak deploymentów – użyj /
590
+ return PAG_ROOT
591
+
592
+def _load_deployments() -> List[dict]:
593
+ """Wczytuje historię deploymentów."""
594
+ if not os.path.exists(DEPLOYMENTS_DB):
595
+ return []
596
+ try:
597
+ return json.load(open(DEPLOYMENTS_DB))
598
+ except Exception:
599
+ return []
600
+
601
+def _save_deployments(deployments: List[dict]):
602
+ os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
603
+ json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
604
+
605
+def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
606
+ """
607
+ Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
608
+ Zwraca (deployment_dir, deployment_id).
609
+ """
610
+ deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
611
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
612
+ os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
613
+
614
+ active = _get_deployment_root()
615
+
616
+ if os.path.isdir(active) and active != PAG_ROOT:
617
+ # Trójstopniowa strategia kopiowania deploymentu:
618
+ # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
619
+ # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
620
+ # 3. zwykłe cp (ostateczność) → pełna kopia
621
+ print(f" ⚡ Kopiowanie aktywnego deploymentu...")
622
+ copied = False
623
+ for method, cmd, label in [
624
+ ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
625
+ ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
626
+ ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
627
+ ]:
628
+ try:
629
+ subprocess.run(cmd, check=True, timeout=600, capture_output=True)
630
+ print(f" ✅ Deployment: {deploy_id} ({label})")
631
+ copied = True
632
+ break
633
+ except subprocess.CalledProcessError:
634
+ if method == "copy":
635
+ raise # ostatnia deska – niech leci wyjątek
636
+ continue
637
+ if not copied:
638
+ raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
639
+ else:
640
+ # Pierwszy deployment – tylko katalogi szkieletowe
641
+ for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
642
+ if os.path.isdir(d):
643
+ dest = os.path.join(deploy_dir, d.lstrip("/"))
644
+ os.makedirs(dest, exist_ok=True)
645
+ print(f" ✅ Pierwszy deployment: {deploy_id}")
646
+
647
+ # Utwórz symlinki do współdzielonych katalogów
648
+ for sp in SHARED_PATHS:
649
+ link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
650
+ if not os.path.lexists(link_dst) and os.path.isdir(sp):
651
+ os.symlink(sp, link_dst)
652
+
653
+ # Zapisz w bazie deploymentów
654
+ deployments = _load_deployments()
655
+ deployments.append({
656
+ "id": deploy_id,
657
+ "action": action,
658
+ "packages": pkg_names,
659
+ "timestamp": datetime.now().isoformat(),
660
+ "active": True,
661
+ })
662
+ # Oznacz poprzednie jako nieaktywne
663
+ for d in deployments[:-1]:
664
+ d["active"] = False
665
+ _save_deployments(deployments)
666
+
667
+ return deploy_dir, deploy_id
668
+
669
+def _switch_deployment(deploy_dir: str) -> bool:
670
+ """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
671
+ tmp_link = ACTIVE_LINK + ".new"
672
+ if os.path.lexists(tmp_link):
673
+ os.remove(tmp_link)
674
+ os.symlink(deploy_dir, tmp_link)
675
+ os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
676
+ return True
677
+
678
+DEFAULT_REPOS = [
679
+ "https://repo.paganlinux.eu/stable/",
680
+]
681
+
682
+# =============================================================================
683
+# INICJALIZACJA
684
+# =============================================================================
685
+
686
+def ensure_dirs():
687
+ for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
688
+ os.makedirs(d, exist_ok=True)
689
+ for f, default in [
690
+ (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
691
+ (INSTALLED_DB, "{}"),
692
+ (PINNED_FILE, "{}"),
693
+ (HISTORY_FILE, "[]"),
694
+ ]:
695
+ if not os.path.exists(f):
696
+ with open(f, "w") as fh: fh.write(default)
697
+ if not os.path.exists(WORLD_FILE):
698
+ Path(WORLD_FILE).touch()
699
+ if not os.path.exists(GPG_HOME):
700
+ os.makedirs(GPG_HOME, exist_ok=True)
701
+ os.chmod(GPG_HOME, 0o700)
702
+ _gpg_run("--list-keys", capture_output=True)
703
+ # Inicjalizuj SQLite
704
+ _db_init()
705
+ # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
706
+ if os.path.isdir(STAGING_DIR):
707
+ for entry in os.listdir(STAGING_DIR):
708
+ if entry == "backups":
709
+ continue # backupy starych wersji – potrzebne do `pag rollback`
710
+ path = os.path.join(STAGING_DIR, entry)
711
+ try:
712
+ if os.path.isfile(path) or os.path.islink(path):
713
+ os.unlink(path)
714
+ elif os.path.isdir(path):
715
+ shutil.rmtree(path, ignore_errors=True)
716
+ except OSError:
717
+ pass
718
+
719
+# =============================================================================
720
+# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
721
+# =============================================================================
722
+
723
+from contextlib import contextmanager
724
+
725
+@contextmanager
726
+def _db_session():
727
+ """Context manager – gwarantuje zamknięcie połączenia."""
728
+ conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
729
+ conn.execute("PRAGMA journal_mode=WAL")
730
+ conn.execute("PRAGMA synchronous=NORMAL")
731
+ conn.execute("PRAGMA foreign_keys=ON")
732
+ conn.execute("PRAGMA busy_timeout=15000")
733
+ conn.row_factory = sqlite3.Row
734
+ try:
735
+ yield conn
736
+ conn.commit()
737
+ except Exception:
738
+ conn.rollback()
739
+ raise
740
+ finally:
741
+ conn.close()
742
+
743
+
744
+def _db_init():
745
+ """Tworzy tabele SQLite jeśli nie istnieją."""
746
+ with _db_session() as db:
747
+ db.execute("""
748
+ CREATE TABLE IF NOT EXISTS files (
749
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
750
+ path TEXT NOT NULL,
751
+ package TEXT NOT NULL,
752
+ sha256 TEXT,
753
+ size INTEGER,
754
+ is_symlink INTEGER DEFAULT 0,
755
+ symlink_target TEXT,
756
+ UNIQUE(path, package)
757
+ )
758
+ """)
759
+ db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
760
+ db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
761
+ db.execute("""
762
+ CREATE TABLE IF NOT EXISTS file_checksums (
763
+ path TEXT PRIMARY KEY,
764
+ sha256 TEXT NOT NULL,
765
+ installed_at TEXT
766
+ )
767
+ """)
768
+ db.commit()
769
+
770
+def _db_record_files(pkg_name: str, files: List[dict]):
771
+ """Zapisuje pliki do SQLite (obsługuje symlinki)."""
772
+ with _db_session() as db:
773
+ # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
774
+ try:
775
+ db.execute("BEGIN IMMEDIATE")
776
+ except sqlite3.OperationalError:
777
+ pass # transakcja już otwarta (implicit)
778
+ db.executemany(
779
+ "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
780
+ "VALUES (?,?,?,?,?,?)",
781
+ [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
782
+ f.get("is_symlink", 0), f.get("symlink_target", ""))
783
+ for f in files]
784
+ )
785
+ db.executemany(
786
+ "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
787
+ [(f["path"], f.get("sha256",""), datetime.now().isoformat())
788
+ for f in files if f.get("sha256")]
789
+ )
790
+
791
+def _db_get_package_files(pkg_name: str) -> List[str]:
792
+ with _db_session() as db:
793
+ return [r["path"] for r in db.execute(
794
+ "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
795
+ )]
796
+
797
+def _db_get_file_owners(filepath: str) -> List[str]:
798
+ """Zwraca listę pakietów będących właścicielami pliku."""
799
+ with _db_session() as db:
800
+ return [r["package"] for r in db.execute(
801
+ "SELECT package FROM files WHERE path=?", (filepath,)
802
+ )]
803
+
804
+def _db_remove_package_files(pkg_name: str):
805
+ with _db_session() as db:
806
+ db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
807
+ db.commit()
808
+
809
+def _db_get_all_file_checksums() -> Dict[str, str]:
810
+ with _db_session() as db:
811
+ return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
812
+
813
+def _db_count_files() -> int:
814
+ with _db_session() as db:
815
+ return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
816
+
817
+# =============================================================================
818
+# BLOKADA
819
+# =============================================================================
820
+
821
+class DatabaseLock:
822
+ """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
823
+ ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
824
+ śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
825
+ i pag odmawiał działania na zawsze („baza zablokowana”).
826
+ """
827
+ def __init__(self):
828
+ self._f = None
829
+ def __enter__(self):
830
+ os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
831
+ self._f = open(LOCK_FILE, "w")
832
+ try:
833
+ # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
834
+ fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
835
+ except BlockingIOError:
836
+ print(f"❌ {_('db_locked')}", file=sys.stderr)
837
+ print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
838
+ sys.exit(1)
839
+ self._f.write(str(os.getpid()))
840
+ self._f.flush()
841
+ return self
842
+ def __exit__(self, *args):
843
+ if self._f:
844
+ try:
845
+ fcntl.flock(self._f, fcntl.LOCK_UN)
846
+ except OSError:
847
+ pass
848
+ self._f.close()
849
+ self._f = None
850
+ # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
851
+ # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
852
+ # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
853
+
854
+# =============================================================================
855
+# POMOCNICZE
856
+# =============================================================================
857
+
858
+
859
+_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
860
+ "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
861
+ # Pliki wewnętrzne paczki .pkg.tar.xz
862
+ "metadata.json", "data.tar.xz", "hooks/",
863
+ "sums.json")
864
+
865
+def _check_path_safety(name: str) -> bool:
866
+ # Normalizuj – usuń leading ./
867
+ if name.startswith("./"):
868
+ name = name[2:]
869
+ if name in (".", ""):
870
+ return True
871
+ # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
872
+ # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
873
+ norm = name.lstrip("/")
874
+ for prefix in _ALLOWED_PREFIXES:
875
+ p = prefix.lstrip("/").rstrip("/")
876
+ if norm == p or norm.startswith(p + "/"):
877
+ return True
878
+ return False
879
+
880
+
881
+def _validate_pkg_name(name):
882
+ return bool(ALLOWED_PKG_RE.match(name))
883
+
884
+
885
+
886
+def _audit(msg):
887
+ from datetime import datetime, timezone
888
+ os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
889
+ with open(AUDIT_LOG, "a") as f:
890
+ f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
891
+
892
+def _strip_suid(path):
893
+ try:
894
+ st = os.stat(path)
895
+ if st.st_mode & 0o4000:
896
+ os.chmod(path, st.st_mode & ~0o4000)
897
+ print(f" {_("sec_suid", path=path)}")
898
+ except OSError:
899
+ pass
900
+
901
+def _check_downgrade(pkg_name, new_ver, installed_db):
902
+ if pkg_name in installed_db:
903
+ old = installed_db[pkg_name].get("version", "0")
904
+ if new_ver < old:
905
+ print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
906
+ return False
907
+ return True
908
+
909
+def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
910
+ """
911
+ Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
912
+
913
+ Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
914
+ oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
915
+ zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
916
+
917
+ Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
918
+ prostym sprawdzaniu stringa:
919
+ - Blokuje ścieżki absolutne i z '..' (path traversal)
920
+ - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
921
+ - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
922
+ rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
923
+ - Zachowuje oryginalne uprawnienia plików
924
+ """
925
+ dest_real = os.path.realpath(dest)
926
+ os.makedirs(dest_real, exist_ok=True)
927
+
928
+ def _target_within(path: str) -> bool:
929
+ try:
930
+ return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
931
+ except ValueError:
932
+ # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
933
+ return False
934
+
935
+ for member in tar.getmembers():
936
+ name = member.name
937
+
938
+ # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
939
+ if name.startswith('/'):
940
+ continue
941
+ if '..' in name.split('/'):
942
+ continue
943
+ # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
944
+ if '\x00' in name or '\\' in name:
945
+ continue
946
+ if not _check_path_safety(name):
947
+ print(f" BLOCKED: {name}")
948
+ continue
949
+
950
+ target = os.path.join(dest, name)
951
+
952
+ # --- Ochrona na podstawie finalnej ścieżki ---
953
+ # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
954
+ # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
955
+ if not _target_within(target):
956
+ print(f" BLOCKED (escape): {name}")
957
+ continue
958
+
959
+ # --- Ochrona dla symlinków i hardlinków ---
960
+ if member.issym() or member.islnk():
961
+ link = member.linkname
962
+ # Szybkie odrzucenie linków absolutnych / z '..'
963
+ if link.startswith('/') or '..' in link.split('/'):
964
+ continue
965
+ # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
966
+ link_target = os.path.join(os.path.dirname(target), link)
967
+ if not _target_within(link_target):
968
+ print(f" BLOCKED (link escape): {name} -> {link}")
969
+ continue
970
+
971
+ # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
972
+ # `filter=` (inaczej DeprecationWarning, w 3.14+ błąd) – nasza ręczna
973
+ # walidacja powyżej już zabezpiecza ścieżki, więc 'fully_trusted'
974
+ # (pomija filtr Pythona i zachowuje SUID/SGID/sticky z preserve_perms).
975
+ try:
976
+ if hasattr(tarfile, 'data_filter'):
977
+ # Python 3.12+
978
+ tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
979
+ filter='fully_trusted')
980
+ else:
981
+ tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
982
+ except Exception as e:
983
+ print(f" ⚠ Nie rozpakowano {name}: {e}")
984
+ continue
985
+ _strip_suid(target)
986
+
987
+
988
+def _sha256_file(path: str) -> str:
989
+ h = hashlib.sha256()
990
+ with open(path, "rb") as f:
991
+ for chunk in iter(lambda: f.read(65536), b""):
992
+ h.update(chunk)
993
+ return h.hexdigest()
994
+
995
+def _split_version(v: str):
996
+ """Rozdziela wersję na (release_parts, prerelease_parts).
997
+
998
+ Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
999
+ """
1000
+ v = v.strip().lower().lstrip("v")
1001
+ # build metadata po '+' jest ignorowane przy porównywaniu (semver)
1002
+ v = v.split("+", 1)[0]
1003
+ # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
1004
+ if "-" in v:
1005
+ rel, pre = v.split("-", 1)
1006
+ elif "_" in v:
1007
+ rel, pre = v.split("_", 1)
1008
+ else:
1009
+ rel, pre = v, ""
1010
+ nums = []
1011
+ for part in rel.split("."):
1012
+ m = re.match(r"(\d+)", part)
1013
+ nums.append(int(m.group(1)) if m else 0)
1014
+ pre_parts = [p for p in pre.split(".") if p]
1015
+ return nums, pre_parts
1016
+
1017
+
1018
+def _cmp_pre(a, b):
1019
+ """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
1020
+ for i in range(max(len(a), len(b))):
1021
+ if i >= len(a):
1022
+ return -1 # krótszy prerelease jest niższy
1023
+ if i >= len(b):
1024
+ return 1
1025
+ ia, ib = a[i], b[i]
1026
+ if ia == ib:
1027
+ continue
1028
+ na, nb = ia.isdigit(), ib.isdigit()
1029
+ if na and nb:
1030
+ return 1 if int(ia) > int(ib) else -1
1031
+ if na != nb:
1032
+ return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
1033
+ return 1 if ia > ib else -1
1034
+ return 0
1035
+
1036
+
1037
+def _cmp_version(a: str, b: str) -> int:
1038
+ """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
1039
+ a_rel, a_pre = _split_version(a)
1040
+ b_rel, b_pre = _split_version(b)
1041
+ # Porównaj część release (brakujące komponenty traktuj jako 0)
1042
+ for i in range(max(len(a_rel), len(b_rel))):
1043
+ xa = a_rel[i] if i < len(a_rel) else 0
1044
+ xb = b_rel[i] if i < len(b_rel) else 0
1045
+ if xa != xb:
1046
+ return 1 if xa > xb else -1
1047
+ # Część release równa → decyduje prerelease.
1048
+ # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
1049
+ if not a_pre and not b_pre:
1050
+ return 0
1051
+ if not a_pre:
1052
+ return 1
1053
+ if not b_pre:
1054
+ return -1
1055
+ return _cmp_pre(a_pre, b_pre)
1056
+
1057
+
1058
+def _version_newer(a: str, b: str) -> bool:
1059
+ """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1060
+ try:
1061
+ return _cmp_version(a, b) > 0
1062
+ except Exception:
1063
+ return a != b
1064
+
1065
+def load_json(path):
1066
+ try:
1067
+ with open(path) as f:
1068
+ return json.load(f)
1069
+ except (FileNotFoundError, json.JSONDecodeError):
1070
+ return {}
1071
+
1072
+def save_json(path, data):
1073
+ with open(path, "w") as f:
1074
+ json.dump(data, f, indent=2)
1075
+
1076
+class PackageInfo:
1077
+ __slots__ = ("name","version","release","description","dependencies",
1078
+ "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1079
+ "provides_so","requires_so")
1080
+ def __init__(self, d, repo=""):
1081
+ self.name = d.get("name","?")
1082
+ self.version = d.get("version","0")
1083
+ self.release = d.get("release", 1)
1084
+ self.description = d.get("description","")
1085
+ self.dependencies = d.get("dependencies", d.get("depends", []))
1086
+ self.size_bytes = d.get("size",0)
1087
+ self.sha256 = d.get("sha256","")
1088
+ self.gpg_fp = d.get("gpg_fingerprint","")
1089
+ self.repo_url = repo
1090
+ self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1091
+ self.provides = d.get("provides", []) or []
1092
+ self.license = d.get("license", []) or []
1093
+ self.provides_so = d.get("provides_so", []) or []
1094
+ self.requires_so = d.get("requires_so", []) or []
1095
+
1096
+# =============================================================================
1097
+# REPOZYTORIA (cache, ETag, GPG)
1098
+# =============================================================================
1099
+
1100
+def _parse_repos_config():
1101
+ """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1102
+
1103
+ Format linii: <url> [fingerprint]
1104
+ Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1105
+ podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1106
+ pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1107
+
1108
+ Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1109
+ wygodne dodawanie repo bez dotykania głównego repos.conf
1110
+ (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1111
+ """
1112
+ entries = []
1113
+
1114
+ def _read_lines(path):
1115
+ if not os.path.exists(path):
1116
+ return
1117
+ for line in open(path):
1118
+ line = line.strip()
1119
+ if not line or line.startswith("#"):
1120
+ continue
1121
+ parts = line.split()
1122
+ url = parts[0].rstrip("/")
1123
+ fp = parts[1].lower() if len(parts) > 1 else ""
1124
+ entries.append({"url": url, "fingerprint": fp or None})
1125
+
1126
+ # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1127
+ _read_lines(REPOS_CONF)
1128
+ # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1129
+ if os.path.isdir(REPOS_DIR):
1130
+ for drop in sorted(os.listdir(REPOS_DIR)):
1131
+ if drop.endswith(".conf"):
1132
+ _read_lines(os.path.join(REPOS_DIR, drop))
1133
+
1134
+ # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1135
+ seen, unique = set(), []
1136
+ for e in entries:
1137
+ if e["url"] not in seen:
1138
+ seen.add(e["url"])
1139
+ unique.append(e)
1140
+
1141
+ if not unique:
1142
+ for url in DEFAULT_REPOS:
1143
+ unique.append({"url": url, "fingerprint": None})
1144
+ return unique
1145
+
1146
+
1147
+def get_repos():
1148
+ return [e["url"] for e in _parse_repos_config()]
1149
+
1150
+
1151
+def _repo_pinned_fp(repo_url):
1152
+ """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1153
+ by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1154
+ if by_url.get(repo_url):
1155
+ return by_url[repo_url]
1156
+ db = _load_trust_db()
1157
+ fp = db.get(repo_url)
1158
+ return fp.lower() if fp else None
1159
+
1160
+def _repo_cache_path(url):
1161
+ return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1162
+
1163
+def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1164
+def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1165
+
1166
+# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1167
+# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1168
+# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1169
+_cache_ro_notice_done = False
1170
+
1171
+def _cache_ro_notice():
1172
+ global _cache_ro_notice_done
1173
+ if _cache_ro_notice_done:
1174
+ return
1175
+ _cache_ro_notice_done = True
1176
+ print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1177
+
1178
+def fetch_repo_index(repo_url, force=False):
1179
+ cp = _repo_cache_path(repo_url)
1180
+ ep = _repo_etag_path(repo_url)
1181
+ tp = _repo_ts_path(repo_url)
1182
+
1183
+ if not force and os.path.exists(cp) and os.path.exists(tp):
1184
+ try:
1185
+ if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1186
+ return json.load(open(cp)).get("packages",[])
1187
+ except: pass
1188
+
1189
+ # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1190
+ # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1191
+ # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1192
+ # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1193
+ if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1194
+ if force:
1195
+ print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1196
+ file=sys.stderr)
1197
+ return None
1198
+ _cache_ro_notice()
1199
+ if os.path.exists(cp):
1200
+ try:
1201
+ return json.load(open(cp)).get("packages",[])
1202
+ except Exception:
1203
+ pass
1204
+ return None
1205
+
1206
+ headers = {"User-Agent": "pag/3.0"}
1207
+ if os.path.exists(tp) and not force:
1208
+ try:
1209
+ lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1210
+ # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1211
+ try:
1212
+ old_locale = locale.setlocale(locale.LC_TIME)
1213
+ locale.setlocale(locale.LC_TIME, 'C')
1214
+ headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1215
+ locale.setlocale(locale.LC_TIME, old_locale)
1216
+ except (locale.Error, ValueError):
1217
+ # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1218
+ headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1219
+ except: pass
1220
+ if os.path.exists(ep) and not force:
1221
+ try: headers["If-None-Match"] = open(ep).read().strip()
1222
+ except: pass
1223
+
1224
+ # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1225
+ try:
1226
+ req = Request(f"{repo_url}/repo.json", headers=headers)
1227
+ with urlopen(req, timeout=30) as resp:
1228
+ etag = resp.headers.get("ETag","")
1229
+ raw = resp.read()
1230
+ data = json.loads(raw.decode())
1231
+ except HTTPError as e:
1232
+ if e.code == 304:
1233
+ # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1234
+ try:
1235
+ open(tp,"w").write(str(time.time()))
1236
+ except OSError:
1237
+ pass
1238
+ if os.path.exists(cp):
1239
+ try:
1240
+ return json.load(open(cp)).get("packages",[])
1241
+ except Exception:
1242
+ pass # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1243
+ print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1244
+ return None
1245
+ except Exception as e:
1246
+ print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1247
+ if os.path.exists(cp):
1248
+ try:
1249
+ return json.load(open(cp)).get("packages",[])
1250
+ except Exception:
1251
+ pass
1252
+ return None
1253
+
1254
+ # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1255
+ # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1256
+ # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1257
+ # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1258
+ # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1259
+ tmp_path = cp + ".tmp"
1260
+ try:
1261
+ with open(tmp_path, "wb") as f:
1262
+ f.write(raw)
1263
+ if not _verify_repo_sig(repo_url, tmp_path):
1264
+ return None # weryfikacja nie powiodła się – stary cache zostaje
1265
+ os.replace(tmp_path, cp)
1266
+ # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1267
+ for _ext in (".asc", ".sig"):
1268
+ if os.path.exists(tmp_path + _ext):
1269
+ try:
1270
+ os.replace(tmp_path + _ext, cp + _ext)
1271
+ except OSError:
1272
+ pass
1273
+ break
1274
+ if etag:
1275
+ try:
1276
+ open(ep,"w").write(etag)
1277
+ except OSError:
1278
+ pass
1279
+ try:
1280
+ open(tp,"w").write(str(time.time()))
1281
+ except OSError:
1282
+ pass
1283
+ return data.get("packages",[])
1284
+ except OSError as e:
1285
+ print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1286
+ file=sys.stderr)
1287
+ # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1288
+ # to wciąż stare, zweryfikowane dane
1289
+ if os.path.exists(cp):
1290
+ try:
1291
+ return json.load(open(cp)).get("packages",[])
1292
+ except Exception:
1293
+ pass
1294
+ return None
1295
+ finally:
1296
+ for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1297
+ try:
1298
+ os.unlink(_p)
1299
+ except OSError:
1300
+ pass
1301
+
1302
+def _verify_repo_sig(repo_url, cache_path) -> bool:
1303
+ """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1304
+
1305
+ FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1306
+ Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1307
+
1308
+ Model zaufania (TOFU + pinning):
1309
+ - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1310
+ a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1311
+ To świadomy kompromis wygody i bezpieczeństwa.
1312
+ - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1313
+ Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1314
+ pag key-trust <repo_url> (po weryfikacji nowego klucza)
1315
+ """
1316
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
1317
+
1318
+ if not os.path.exists(GPG_HOME):
1319
+ if insecure:
1320
+ return True # brak GPG home – tryb insecure, akceptuj
1321
+ print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1322
+ print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1323
+ os.remove(cache_path)
1324
+ return False
1325
+
1326
+ sig_path = cache_path + ".sig"
1327
+ # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1328
+ sig_data = None
1329
+ sig_ext = ""
1330
+ for ext in (".asc", ".sig"):
1331
+ try:
1332
+ req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1333
+ with urlopen(req, timeout=15) as resp:
1334
+ sig_data = resp.read()
1335
+ sig_ext = ext
1336
+ break
1337
+ except Exception:
1338
+ continue
1339
+ if not sig_data:
1340
+ if insecure:
1341
+ return True # tryb insecure – akceptuj bez podpisu
1342
+ print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1343
+ print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1344
+ os.remove(cache_path)
1345
+ return False
1346
+ sig_path = cache_path + sig_ext
1347
+ with open(sig_path, "wb") as f:
1348
+ f.write(sig_data)
1349
+
1350
+ ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1351
+ if not ok:
1352
+ # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1353
+ # jak apt) – gdy w keyringu brakuje klucza (No public key).
1354
+ res = _gpg_run("--verify", sig_path, cache_path,
1355
+ capture_output=True, text=True, timeout=30)
1356
+ _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1357
+ if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1358
+ try:
1359
+ with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1360
+ keydata = r.read()
1361
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1362
+ tmp.write(keydata)
1363
+ tmp.flush()
1364
+ _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1365
+ os.unlink(tmp.name)
1366
+ print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1367
+ ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1368
+ except Exception:
1369
+ pass
1370
+ if not ok:
1371
+ if insecure:
1372
+ print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1373
+ return True
1374
+ os.remove(cache_path)
1375
+ if not shutil.which(GPG_BINARY):
1376
+ print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1377
+ print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1378
+ else:
1379
+ print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1380
+ return False
1381
+
1382
+ # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1383
+ pinned = _repo_pinned_fp(repo_url)
1384
+ if pinned:
1385
+ if not fingerprint:
1386
+ if insecure:
1387
+ print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1388
+ return True
1389
+ os.remove(cache_path)
1390
+ print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1391
+ print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1392
+ return False
1393
+ if fingerprint != pinned.upper():
1394
+ if insecure:
1395
+ print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1396
+ return True
1397
+ os.remove(cache_path)
1398
+ print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1399
+ print(f" Oczekiwany: {pinned}")
1400
+ print(f" Otrzymany: {fingerprint}")
1401
+ print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1402
+ return False
1403
+ return True
1404
+
1405
+ if fingerprint:
1406
+ # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1407
+ db = _load_trust_db()
1408
+ if db.get(repo_url) != fingerprint:
1409
+ _save_trust_db({**db, repo_url: fingerprint})
1410
+ print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1411
+ print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1412
+ print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1413
+ return True
1414
+
1415
+def fetch_all_packages(force=False):
1416
+ all_pkgs = {}
1417
+ for repo_url in get_repos():
1418
+ pkgs = fetch_repo_index(repo_url, force)
1419
+ if pkgs:
1420
+ for pdata in pkgs:
1421
+ name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1422
+ pkg = PackageInfo(pdata, repo_url)
1423
+ if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1424
+ all_pkgs[name] = pkg
1425
+ return all_pkgs
1426
+
1427
+# =============================================================================
1428
+# GPG
1429
+# =============================================================================
1430
+
1431
+def _verify_pkg_gpg(pkg_path, repo_url=None):
1432
+ """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1433
+
1434
+ FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1435
+ Zwraca (passed: bool, message: str).
1436
+ """
1437
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
1438
+ # Brak gnupg = weryfikacja niemożliwa. Bez tej gałęzi użytkownik dostawał
1439
+ # mylące „NIEPRAWIDŁOWY PODPIS GPG”, mimo że paczka i podpis są w porządku.
1440
+ if not shutil.which(GPG_BINARY):
1441
+ if insecure:
1442
+ return True, "(gpg missing – PAG_INSECURE)"
1443
+ return False, _("gpg_missing")
1444
+ sig_path = pkg_path + ".sig"
1445
+ if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1446
+ sig_path = pkg_path + ".asc"
1447
+
1448
+ if not os.path.exists(sig_path):
1449
+ if insecure:
1450
+ return True, "(no signature – PAG_INSECURE)"
1451
+ return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1452
+
1453
+ ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1454
+ if not ok:
1455
+ if insecure:
1456
+ return True, "(invalid signature – PAG_INSECURE)"
1457
+ return False, "NIEPRAWIDŁOWY PODPIS GPG"
1458
+
1459
+ # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1460
+ if repo_url:
1461
+ pinned = _repo_pinned_fp(repo_url)
1462
+ if pinned and fp and fp != pinned.upper():
1463
+ if insecure:
1464
+ return True, "(pkg signer mismatch – PAG_INSECURE)"
1465
+ return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1466
+
1467
+ return True, "GPG verified"
1468
+
1469
+def cmd_key_add(source):
1470
+ ensure_dirs()
1471
+ if not shutil.which(GPG_BINARY):
1472
+ print(f"❌ {_('gpg_missing')}"); return 1
1473
+ if source.startswith("http"):
1474
+ try:
1475
+ with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1476
+ keydata = resp.read()
1477
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1478
+ tmp.write(keydata); tmp.flush()
1479
+ res = _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1480
+ os.unlink(tmp.name)
1481
+ if res.returncode != 0:
1482
+ print(f"❌ {_('key_add_failed')}"); return 1
1483
+ except Exception as e:
1484
+ print(f"❌ Download error: {e}"); return 1
1485
+ else:
1486
+ res = _gpg_run("--import", source, capture_output=True, timeout=30)
1487
+ if res.returncode != 0:
1488
+ print(f"❌ {_('key_add_failed')}"); return 1
1489
+ print(f"✅ {_('key_imported')}")
1490
+
1491
+def cmd_key_list():
1492
+ if not shutil.which(GPG_BINARY):
1493
+ print(f"❌ {_('gpg_missing')}"); return
1494
+ if not os.path.exists(GPG_HOME):
1495
+ print(_("no_keys")); return
1496
+ result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1497
+ capture_output=True, text=True, timeout=30)
1498
+ if result.returncode != 0:
1499
+ print(f"❌ {_('gpg_missing')}"); return
1500
+ print(result.stdout or _("no_keys"))
1501
+
1502
+def cmd_key_remove(key_id):
1503
+ _gpg_run("--batch", "--yes", "--delete-key", key_id,
1504
+ capture_output=True, timeout=30)
1505
+ print(f"✅ {_('key_removed', key_id)}")
1506
+
1507
+def _repo_signer_fp(repo_url):
1508
+ """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1509
+ repo_url = repo_url.rstrip("/")
1510
+ try:
1511
+ with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1512
+ data = r.read()
1513
+ except Exception:
1514
+ return None
1515
+ sig = None
1516
+ sig_ext = ".asc"
1517
+ for ext in (".asc", ".sig"):
1518
+ try:
1519
+ with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1520
+ sig = r.read()
1521
+ sig_ext = ext
1522
+ break
1523
+ except Exception:
1524
+ continue
1525
+ if not sig:
1526
+ return None
1527
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1528
+ tf.write(data); tf.flush()
1529
+ data_path = tf.name
1530
+ sig_path = data_path + sig_ext
1531
+ try:
1532
+ with open(sig_path, "wb") as f:
1533
+ f.write(sig)
1534
+ ok, fp = _gpg_verify_fp(sig_path, data_path)
1535
+ finally:
1536
+ for p in (data_path, sig_path):
1537
+ try: os.unlink(p)
1538
+ except OSError: pass
1539
+ return fp if ok else None
1540
+
1541
+
1542
+def cmd_key_trust(repo_url):
1543
+ """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1544
+ repo_url = repo_url.rstrip("/")
1545
+ print(f"🔐 Przypinam klucz repo {repo_url}...")
1546
+ fp = _repo_signer_fp(repo_url)
1547
+ if not fp:
1548
+ print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1549
+ print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1550
+ return 1
1551
+ db = _load_trust_db()
1552
+ _save_trust_db({**db, repo_url: fp})
1553
+ print(f" ✅ Przypięto {fp} dla {repo_url}")
1554
+ print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1555
+ return 0
1556
+
1557
+
1558
+def cmd_key_untrust(repo_url):
1559
+ """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1560
+ repo_url = repo_url.rstrip("/")
1561
+ db = _load_trust_db()
1562
+ if repo_url not in db:
1563
+ print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1564
+ return 0
1565
+ del db[repo_url]
1566
+ _save_trust_db(db)
1567
+ print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1568
+ return 0
1569
+
1570
+
1571
+def cmd_key_trusted():
1572
+ """Listuje przypięte fingerprinty repozytoriów."""
1573
+ db = _load_trust_db()
1574
+ if not db:
1575
+ print(_("no_keys"))
1576
+ return
1577
+ for url, fp in sorted(db.items()):
1578
+ print(f" {url}\n {fp}")
1579
+
1580
+# =============================================================================
1581
+# ATOMOWA INSTALACJA (STAGING)
1582
+# =============================================================================
1583
+
1584
+def _safe_rename(src: str, dst: str) -> bool:
1585
+ """
1586
+ Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1587
+ systemach plików (EXDEV), kopiuje + usuwa źródło.
1588
+ """
1589
+ try:
1590
+ os.rename(src, dst)
1591
+ return True
1592
+ except OSError as e:
1593
+ if e.errno == 18: # EXDEV – cross-device link
1594
+ shutil.copy2(src, dst)
1595
+ os.remove(src)
1596
+ return True
1597
+ raise
1598
+
1599
+
1600
+def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1601
+ staging: str, journal: list, installed_files: list,
1602
+ deploy_dir: str = "", backup_dir: str = "",
1603
+ backup_journal: Optional[list] = None) -> bool:
1604
+ """
1605
+ Instaluje pojedynczy plik (zwykły lub symlink).
1606
+ Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1607
+
1608
+ Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1609
+ do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1610
+
1611
+ Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1612
+ stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1613
+ """
1614
+ # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1615
+ if deploy_dir and _is_shared_path("/" + rel):
1616
+ dst_root = PAG_ROOT
1617
+ elif deploy_dir:
1618
+ dst_root = deploy_dir
1619
+ else:
1620
+ dst_root = PAG_ROOT
1621
+
1622
+ dst = os.path.join(dst_root, rel)
1623
+
1624
+ # --- SYMLINK ---
1625
+ if os.path.islink(src):
1626
+ link_target = os.readlink(src)
1627
+ # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1628
+ expected = sums.get("/" + rel, "")
1629
+ if expected:
1630
+ link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1631
+ if expected and link_hash != expected:
1632
+ return False
1633
+
1634
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1635
+ # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1636
+ if backup_dir and backup_journal is not None and os.path.lexists(dst):
1637
+ try:
1638
+ backup_path = os.path.join(backup_dir, rel)
1639
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1640
+ os.replace(dst, backup_path)
1641
+ backup_journal.append((backup_path, "/" + rel))
1642
+ journal.append(("backup", backup_path, dst))
1643
+ except OSError:
1644
+ pass
1645
+ # Jeśli docelowy symlink już istnieje, usuń go
1646
+ if os.path.islink(dst) or os.path.exists(dst):
1647
+ os.remove(dst)
1648
+ os.symlink(link_target, dst)
1649
+ journal.append(("symlink", "", dst))
1650
+ installed_files.append({
1651
+ "path": "/" + rel,
1652
+ "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1653
+ "size": len(link_target),
1654
+ "is_symlink": True,
1655
+ "symlink_target": link_target,
1656
+ })
1657
+ return True
1658
+
1659
+ # --- ZWYKŁY PLIK ---
1660
+ # Oblicz SHA256
1661
+ try:
1662
+ file_sha = _sha256_file(src)
1663
+ except Exception:
1664
+ file_sha = ""
1665
+
1666
+ # Weryfikuj sums.json
1667
+ expected = sums.get("/" + rel, "")
1668
+ if expected and file_sha and file_sha != expected:
1669
+ return False
1670
+
1671
+ # Utwórz katalog docelowy
1672
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1673
+
1674
+ # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1675
+ if backup_dir and backup_journal is not None and os.path.lexists(dst):
1676
+ try:
1677
+ backup_path = os.path.join(backup_dir, rel)
1678
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1679
+ os.replace(dst, backup_path)
1680
+ backup_journal.append((backup_path, "/" + rel))
1681
+ journal.append(("backup", backup_path, dst))
1682
+ except OSError:
1683
+ pass
1684
+
1685
+ # Atomowe przeniesienie (z fallbackiem dla cross-device).
1686
+ # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1687
+ _safe_rename(src, dst)
1688
+
1689
+ # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1690
+ try:
1691
+ os.chown(dst, 0, 0)
1692
+ except (OSError, PermissionError):
1693
+ # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1694
+ pass
1695
+
1696
+ journal.append(("file", src, dst))
1697
+ installed_files.append({
1698
+ "path": "/" + rel,
1699
+ "sha256": file_sha,
1700
+ "size": os.path.getsize(dst),
1701
+ "is_symlink": False,
1702
+ })
1703
+ return True
1704
+
1705
+
1706
+def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1707
+ backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1708
+ """
1709
+ Rozpakowuje do staging area, potem atomowo przenosi pliki.
1710
+ Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1711
+ Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1712
+ """
1713
+ staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1714
+ journal = []
1715
+ installed_files = []
1716
+ backup_journal: List[Tuple[str, str]] = []
1717
+
1718
+ try:
1719
+ # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1720
+ with tarfile.open(pkg_path, "r:xz") as tf:
1721
+ _safe_extractall(tf, staging)
1722
+
1723
+ data_tar = os.path.join(staging, "data.tar.xz")
1724
+ if not os.path.exists(data_tar):
1725
+ shutil.rmtree(staging, ignore_errors=True)
1726
+ return False, [], backup_journal
1727
+
1728
+ # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1729
+ data_staging = os.path.join(staging, "data")
1730
+ os.makedirs(data_staging, exist_ok=True)
1731
+ with tarfile.open(data_tar, "r:xz") as tf:
1732
+ _safe_extractall(tf, data_staging)
1733
+
1734
+ # Wczytaj sums.json
1735
+ sums_path = os.path.join(data_staging, "sums.json")
1736
+ sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1737
+
1738
+ # Hook pre-install (przed przeniesieniem plików do systemu)
1739
+ _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1740
+
1741
+ # Przenieś pliki: staging/data/* → /
1742
+ for root, dirs, files in os.walk(data_staging):
1743
+ # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1744
+ # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1745
+ # puste katalogi z data.tar.xz ginęły przy instalacji.
1746
+ for d in dirs:
1747
+ src_dir = os.path.join(root, d)
1748
+ rel_dir = os.path.relpath(src_dir, data_staging)
1749
+ if deploy_dir and _is_shared_path("/" + rel_dir):
1750
+ dst_root = PAG_ROOT
1751
+ elif deploy_dir:
1752
+ dst_root = deploy_dir
1753
+ else:
1754
+ dst_root = PAG_ROOT
1755
+ dst_dir = os.path.join(dst_root, rel_dir)
1756
+ if not os.path.isdir(dst_dir):
1757
+ try:
1758
+ os.makedirs(dst_dir, exist_ok=True)
1759
+ except OSError:
1760
+ pass
1761
+ for fname in files:
1762
+ if fname == "sums.json":
1763
+ continue
1764
+ src = os.path.join(root, fname)
1765
+ rel = os.path.relpath(src, data_staging)
1766
+
1767
+ ok = _install_file(src, rel, data_staging, sums,
1768
+ staging, journal, installed_files, deploy_dir,
1769
+ backup_dir, backup_journal)
1770
+ if not ok:
1771
+ # Cofnij wszystkie operacje
1772
+ _rollback_journal(journal, staging)
1773
+ return False, [], backup_journal
1774
+
1775
+ # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1776
+ # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1777
+ # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1778
+ # nie jest zainstalowane.
1779
+ _icon_dirs = set()
1780
+ for f in installed_files:
1781
+ fp = f.get("path", "") or ""
1782
+ if fp.startswith("/usr/share/icons/"):
1783
+ _rest = fp[len("/usr/share/icons/"):]
1784
+ _theme = _rest.split("/", 1)[0]
1785
+ if _theme:
1786
+ _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1787
+ if _icon_dirs:
1788
+ try:
1789
+ subprocess.run(["gtk-update-icon-cache", "--version"],
1790
+ capture_output=True, timeout=10)
1791
+ for _d in sorted(_icon_dirs):
1792
+ if os.path.isdir(_d):
1793
+ subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
1794
+ capture_output=True, timeout=300)
1795
+ except Exception:
1796
+ pass
1797
+
1798
+ # Uruchom hooki post-install
1799
+ hooks_dir = os.path.join(staging, "hooks")
1800
+ _run_hook(hooks_dir, "post-install", pkg)
1801
+
1802
+ # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1803
+ try:
1804
+ if os.path.isdir(hooks_dir):
1805
+ persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1806
+ shutil.rmtree(persisted, ignore_errors=True)
1807
+ shutil.copytree(hooks_dir, persisted)
1808
+ except Exception:
1809
+ pass
1810
+
1811
+ # Zapisz do SQLite
1812
+ _db_record_files(pkg.name, installed_files)
1813
+
1814
+ shutil.rmtree(staging, ignore_errors=True)
1815
+ return True, installed_files, backup_journal
1816
+
1817
+ except Exception as e:
1818
+ _rollback_journal(journal, staging)
1819
+ return False, [], backup_journal
1820
+
1821
+
1822
+def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1823
+ """Odświeża cache ld.so po udanej instalacji pakietów."""
1824
+ ldconfig = shutil.which("ldconfig")
1825
+ if not ldconfig:
1826
+ print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
1827
+ file=sys.stderr)
1828
+ return False
1829
+
1830
+ target_root = deploy_dir or PAG_ROOT
1831
+ command = [ldconfig]
1832
+ if target_root != "/":
1833
+ command.extend(["-r", target_root])
1834
+
1835
+ try:
1836
+ subprocess.run(command, check=True, timeout=60,
1837
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1838
+ text=True)
1839
+ return True
1840
+ except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1841
+ detail = getattr(exc, "stderr", None) or str(exc)
1842
+ print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
1843
+ file=sys.stderr)
1844
+ return False
1845
+
1846
+
1847
+def _rollback_journal(journal: list, staging_path: str):
1848
+ """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1849
+ for entry in reversed(journal):
1850
+ op = entry[0]
1851
+ if op == "file":
1852
+ _, src, dst = entry
1853
+ try:
1854
+ if os.path.exists(dst) or os.path.islink(dst):
1855
+ _safe_rename(dst, src)
1856
+ except Exception:
1857
+ pass
1858
+ elif op == "symlink":
1859
+ _, _, dst = entry
1860
+ try:
1861
+ if os.path.islink(dst) or os.path.exists(dst):
1862
+ os.remove(dst)
1863
+ except Exception:
1864
+ pass
1865
+ elif op == "backup":
1866
+ # Przywróć starą wersję pliku z backupu (upgrade)
1867
+ _, bpath, dst = entry
1868
+ try:
1869
+ if os.path.lexists(bpath):
1870
+ os.replace(bpath, dst)
1871
+ except Exception:
1872
+ pass
1873
+ shutil.rmtree(staging_path, ignore_errors=True)
1874
+
1875
+# =============================================================================
1876
+# BEZPIECZNE USUWANIE
1877
+# =============================================================================
1878
+
1879
+def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1880
+ """
1881
+ Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1882
+ Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1883
+ """
1884
+ pkg_files = _db_get_package_files(pkg_name)
1885
+ removed = []
1886
+ skipped_shared = []
1887
+
1888
+ for fpath in pkg_files:
1889
+ owners = _db_get_file_owners(fpath)
1890
+ # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1891
+ other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1892
+
1893
+ if other_owners:
1894
+ # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1895
+ skipped_shared.append(fpath)
1896
+ continue
1897
+
1898
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1899
+ if os.path.isfile(full) or os.path.islink(full):
1900
+ os.remove(full)
1901
+ removed.append(fpath)
1902
+
1903
+ # Usuń puste katalogi (od najgłębszych)
1904
+ dirs = set()
1905
+ for fpath in removed + skipped_shared:
1906
+ parent = os.path.dirname(fpath)
1907
+ while parent and parent != "/":
1908
+ dirs.add(parent)
1909
+ parent = os.path.dirname(parent)
1910
+
1911
+ for d in sorted(dirs, key=len, reverse=True):
1912
+ full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1913
+ if os.path.isdir(full_d):
1914
+ try:
1915
+ os.rmdir(full_d)
1916
+ except OSError:
1917
+ pass # nie jest pusty – OK
1918
+
1919
+ # Usuń z SQLite
1920
+ _db_remove_package_files(pkg_name)
1921
+
1922
+ if skipped_shared:
1923
+ print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1924
+
1925
+ return len(removed) + len(skipped_shared), removed
1926
+
1927
+
1928
+def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
1929
+ installed_db: dict, deploy_dir: str = "",
1930
+ backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
1931
+ """
1932
+ Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
1933
+
1934
+ - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
1935
+ (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
1936
+ - Sprząta puste katalogi i wpisy SQLite starej wersji.
1937
+ Zwraca (liczba usuniętych, [usunięte ścieżki]).
1938
+ """
1939
+ new_set = set(new_paths)
1940
+ stale = [f for f in old_files if f not in new_set]
1941
+ if not stale:
1942
+ return 0, []
1943
+
1944
+ root = deploy_dir or PAG_ROOT
1945
+ removed = []
1946
+ skipped = 0
1947
+ for fpath in stale:
1948
+ owners = _db_get_file_owners(fpath)
1949
+ other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1950
+ if other_owners:
1951
+ # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1952
+ skipped += 1
1953
+ else:
1954
+ full = os.path.join(root, fpath.lstrip("/"))
1955
+ if os.path.isfile(full) or os.path.islink(full):
1956
+ try:
1957
+ if backup_dir and backup_journal is not None:
1958
+ backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
1959
+ os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1960
+ os.replace(full, backup_path) # przenieś do backupu (rollback)
1961
+ backup_journal.append((backup_path, fpath))
1962
+ else:
1963
+ os.remove(full)
1964
+ removed.append(fpath)
1965
+ except OSError:
1966
+ pass
1967
+ # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
1968
+ with _db_session() as db:
1969
+ db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
1970
+
1971
+ # Usuń puste katalogi (od najgłębszych)
1972
+ dirs = set()
1973
+ for fpath in removed:
1974
+ parent = os.path.dirname(fpath)
1975
+ while parent and parent != "/":
1976
+ dirs.add(parent)
1977
+ parent = os.path.dirname(parent)
1978
+ for d in sorted(dirs, key=len, reverse=True):
1979
+ full_d = os.path.join(root, d.lstrip("/"))
1980
+ if os.path.isdir(full_d):
1981
+ try:
1982
+ os.rmdir(full_d)
1983
+ except OSError:
1984
+ pass # nie jest pusty – OK
1985
+
1986
+ if removed:
1987
+ print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
1988
+ if skipped:
1989
+ print(f" ⚠ {skipped} plików współdzielonych zachowanych")
1990
+
1991
+ return len(removed), removed
1992
+
1993
+
1994
+def _new_upgrade_backup_root() -> str:
1995
+ """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
1996
+ txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
1997
+ root = os.path.join(STAGING_DIR, "backups", txn)
1998
+ os.makedirs(root, exist_ok=True)
1999
+ return root
2000
+
2001
+
2002
+def _purge_old_backups(keep_root: str = ""):
2003
+ """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
2004
+ base = os.path.join(STAGING_DIR, "backups")
2005
+ if not os.path.isdir(base):
2006
+ return
2007
+ for entry in os.listdir(base):
2008
+ p = os.path.join(base, entry)
2009
+ if p != keep_root and os.path.isdir(p):
2010
+ shutil.rmtree(p, ignore_errors=True)
2011
+
2012
+# =============================================================================
2013
+# HOOKI
2014
+# =============================================================================
2015
+# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
2016
+# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
2017
+# Aby ograniczyć ryzyko:
2018
+# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
2019
+# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
2020
+# - każde uruchomienie jest logowane do /var/log/pag/audit.log
2021
+# - hook ma wersjonowane API (PKG_HOOK_API)
2022
+# =============================================================================
2023
+
2024
+# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
2025
+_HOOKS_RUN: List[str] = []
2026
+
2027
+
2028
+def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
2029
+ """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
2030
+ return {
2031
+ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
2032
+ "HOME": "/root",
2033
+ "LANG": "C.UTF-8",
2034
+ "LC_ALL": "C.UTF-8",
2035
+ "PKG_NAME": pkg.name,
2036
+ "PKG_VERSION": pkg.version,
2037
+ "PKG_ACTION": hook_name,
2038
+ "PKG_HOOK_API": HOOK_API_VERSION,
2039
+ }
2040
+
2041
+
2042
+def _hook_timeout() -> int:
2043
+ try:
2044
+ return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
2045
+ except Exception:
2046
+ return 60
2047
+
2048
+
2049
+def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
2050
+ """Uruchamia skrypt hooka jeśli istnieje.
2051
+
2052
+ Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
2053
+ pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
2054
+ ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
2055
+ """
2056
+ hook_path = os.path.join(hooks_dir, hook_name)
2057
+ if not os.path.exists(hook_path):
2058
+ return False
2059
+
2060
+ if os.environ.get("PAG_NO_HOOKS", "") == "1":
2061
+ print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
2062
+ _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
2063
+ return False
2064
+
2065
+ os.chmod(hook_path, 0o755)
2066
+ env = _hook_env(pkg, hook_name)
2067
+ tag = f"{hook_name} {pkg.name}-{pkg.version}"
2068
+ try:
2069
+ result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2070
+ check=False, capture_output=True, text=True,
2071
+ cwd="/")
2072
+ _HOOKS_RUN.append(tag)
2073
+ if result.returncode != 0:
2074
+ print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2075
+ if result.stderr:
2076
+ print(f" {result.stderr.strip()[-200:]}")
2077
+ _audit(f"hook FAIL {tag} rc={result.returncode}")
2078
+ else:
2079
+ _audit(f"hook OK {tag}")
2080
+ return True
2081
+ except subprocess.TimeoutExpired:
2082
+ print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2083
+ _audit(f"hook TIMEOUT {tag}")
2084
+ return False
2085
+ except Exception as e:
2086
+ print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2087
+ _audit(f"hook ERROR {tag}: {e}")
2088
+ return False
2089
+
2090
+# =============================================================================
2091
+# TRANSAKCJE I ROLLBACK
2092
+# =============================================================================
2093
+
2094
+def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2095
+ upgrade_backups=None, upgrade_backup_root=""):
2096
+ history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2097
+ # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2098
+ # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2099
+ executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2100
+ _HOOKS_RUN.clear()
2101
+ entry = {
2102
+ "action": action, "packages": packages, "success": success,
2103
+ "timestamp": datetime.now().isoformat(),
2104
+ "snapshot": snapshot,
2105
+ "file_journal": file_journal, # lista plików do wycofania
2106
+ "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2107
+ }
2108
+ if upgrade_backups:
2109
+ entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2110
+ entry["upgrade_backup_root"] = upgrade_backup_root
2111
+ history.append(entry)
2112
+ if len(history) > 50:
2113
+ history = history[-50:]
2114
+ save_json(HISTORY_FILE, history)
2115
+
2116
+def cmd_history():
2117
+ if not os.path.exists(HISTORY_FILE):
2118
+ print(_("no_history")); return
2119
+ history = load_json(HISTORY_FILE)
2120
+ if not history:
2121
+ print(_("no_history")); return
2122
+ print(f"Ostatnie transakcje ({len(history)}):")
2123
+ for i, e in enumerate(reversed(history), 1):
2124
+ icon = "✅" if e["success"] else "❌"
2125
+ pkgs = ", ".join(e["packages"][:5])
2126
+ if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2127
+ print(f" {i}. {icon} {e['action']}: {pkgs}")
2128
+ print(f" {e['timestamp']}")
2129
+
2130
+def cmd_rollback():
2131
+ if not os.path.exists(HISTORY_FILE):
2132
+ print(_("no_history")); return 1
2133
+ history = load_json(HISTORY_FILE)
2134
+ if not history:
2135
+ print(_("no_history")); return 1
2136
+
2137
+ last = None
2138
+ for e in reversed(history):
2139
+ if e["success"] and e.get("snapshot"):
2140
+ last = e; break
2141
+
2142
+ if not last:
2143
+ print("❌ No snapshot to restore."); return 1
2144
+
2145
+ print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2146
+ print(f" Packages: {', '.join(last['packages'][:10])}")
2147
+
2148
+ if not _ask_confirm():
2149
+ return 0
2150
+
2151
+ # Przywróć installed.json
2152
+ save_json(INSTALLED_DB, last["snapshot"])
2153
+
2154
+ # Wycofaj fizyczne pliki (jeśli zapisano journal)
2155
+ file_journal = last.get("file_journal", [])
2156
+ upgrade_backups = last.get("upgrade_backups", {}) or {}
2157
+ backup_root = last.get("upgrade_backup_root", "")
2158
+
2159
+ # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2160
+ for dst, bpath in upgrade_backups.items():
2161
+ full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2162
+ if bpath and os.path.lexists(bpath):
2163
+ try:
2164
+ os.makedirs(os.path.dirname(full), exist_ok=True)
2165
+ os.replace(bpath, full)
2166
+ except OSError:
2167
+ pass
2168
+
2169
+ # Usuń nowe pliki (które nie miały poprzedniej wersji)
2170
+ backed = set(upgrade_backups)
2171
+ if file_journal:
2172
+ for fpath in reversed(file_journal):
2173
+ if fpath in backed:
2174
+ continue
2175
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2176
+ if os.path.exists(full) or os.path.islink(full):
2177
+ os.remove(full)
2178
+ print(f" {_('rollback_files', len(file_journal))}")
2179
+
2180
+ # Sprzątanie pustych katalogów + katalogu backupów
2181
+ dirs = set()
2182
+ for fpath in file_journal:
2183
+ parent = os.path.dirname(fpath)
2184
+ while parent and parent != "/":
2185
+ dirs.add(parent)
2186
+ parent = os.path.dirname(parent)
2187
+ for d in sorted(dirs, key=len, reverse=True):
2188
+ full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2189
+ if os.path.isdir(full_d):
2190
+ try:
2191
+ os.rmdir(full_d)
2192
+ except OSError:
2193
+ pass
2194
+ if backup_root:
2195
+ shutil.rmtree(backup_root, ignore_errors=True)
2196
+
2197
+ print(f"✅ {_('rollback_restored')}")
2198
+ _record_transaction("rollback", last["packages"], True, None)
2199
+ return 0
2200
+
2201
+# =============================================================================
2202
+# INSTALACJA
2203
+# =============================================================================
2204
+
2205
+def _install_local_pkg_files(paths, install_succeeded):
2206
+ """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2207
+ Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2208
+ Zwraca (failed_count, installed_files)."""
2209
+ failed = 0
2210
+ all_files = []
2211
+ for p in paths:
2212
+ p = os.path.abspath(p)
2213
+ if not os.path.isfile(p):
2214
+ print(f" ❌ Nie znaleziono pakietu: {p}")
2215
+ failed += 1
2216
+ continue
2217
+ try:
2218
+ with tarfile.open(p, "r:xz") as tf:
2219
+ meta = tf.extractfile("metadata.json")
2220
+ if meta is None:
2221
+ print(f" ❌ {p}: brak metadata.json")
2222
+ failed += 1
2223
+ continue
2224
+ data = json.loads(meta.read())
2225
+ except Exception as e:
2226
+ print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2227
+ failed += 1
2228
+ continue
2229
+ pkg = PackageInfo(data, repo="local")
2230
+ print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2231
+ ok, files, _ = _atomic_install(p, pkg)
2232
+ if ok:
2233
+ install_succeeded(pkg, files)
2234
+ all_files.extend(f["path"] for f in files)
2235
+ print("✅")
2236
+ else:
2237
+ print("❌")
2238
+ failed += 1
2239
+ return failed, all_files
2240
+
2241
+
2242
+def _preflight_disk(total_bytes: int) -> bool:
2243
+ """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2244
+
2245
+ Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2246
+ miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2247
+ (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2248
+ """
2249
+ target = PAG_ROOT or "/"
2250
+ try:
2251
+ st = os.statvfs(target)
2252
+ free = st.f_bavail * st.f_frsize
2253
+ except OSError:
2254
+ return True # nie da się sprawdzić – nie blokuj
2255
+ need_mb = total_bytes // 1048576
2256
+ free_mb = free // 1048576
2257
+ if free < total_bytes:
2258
+ print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2259
+ f"wolne {free_mb} MB ({target})")
2260
+ return False
2261
+ if free < total_bytes * 3:
2262
+ print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2263
+ f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2264
+ # Wykryj mount read-only (test zapisu w stagingu)
2265
+ try:
2266
+ probe = os.path.join(STAGING_DIR, ".pag-probe")
2267
+ with open(probe, "w") as f:
2268
+ f.write("x")
2269
+ os.remove(probe)
2270
+ except OSError:
2271
+ print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2272
+ return False
2273
+ return True
2274
+
2275
+
2276
+def cmd_install(package_names, as_dep=False, upgrade=False):
2277
+ ensure_dirs()
2278
+ installed_db = load_json(INSTALLED_DB)
2279
+ world = load_world()
2280
+ pinned = load_json(PINNED_FILE)
2281
+
2282
+ # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2283
+ # nie wymaga repozytorium ani GPG.
2284
+ local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2285
+ (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2286
+ if local_files:
2287
+ _local_need = sum(
2288
+ os.path.getsize(os.path.abspath(p))
2289
+ for p in local_files if os.path.isfile(os.path.abspath(p))
2290
+ )
2291
+ if not _preflight_disk(_local_need):
2292
+ return 1
2293
+
2294
+ def _ok(pkg, files):
2295
+ installed_db[pkg.name] = {
2296
+ "version": pkg.version, "release": pkg.release, "description": pkg.description,
2297
+ "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2298
+ "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2299
+ "repo": "local",
2300
+ "provides": getattr(pkg, "provides", None) or [],
2301
+ "provides_so": getattr(pkg, "provides_so", None) or [],
2302
+ "requires_so": getattr(pkg, "requires_so", None) or [],
2303
+ }
2304
+ world.add(pkg.name)
2305
+ failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2306
+ save_json(INSTALLED_DB, installed_db)
2307
+ save_world(world)
2308
+ if failed_local:
2309
+ return 1
2310
+ _refresh_dynamic_linker_cache()
2311
+ package_names = [n for n in package_names if n not in
2312
+ [os.path.abspath(x) for x in local_files] and
2313
+ n not in local_files]
2314
+ to_install = []
2315
+ if not package_names:
2316
+ return 0
2317
+ # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2318
+
2319
+ repo_pkgs = fetch_all_packages()
2320
+
2321
+ if not repo_pkgs:
2322
+ print(f"❌ {_('no_index')}"); return 1
2323
+
2324
+ for name in list(package_names):
2325
+ if name in pinned:
2326
+ print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2327
+ package_names.remove(name)
2328
+
2329
+ to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2330
+
2331
+ # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2332
+ # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2333
+ # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2334
+ not_found = []
2335
+ for n in package_names:
2336
+ real = _resolve_provides(n, repo_pkgs, installed_db)
2337
+ if real not in repo_pkgs and real not in installed_db \
2338
+ and not os.path.exists(os.path.abspath(n)):
2339
+ not_found.append(n)
2340
+ if not_found:
2341
+ print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2342
+ print(f" {_('not_found_hint')}")
2343
+ return 1
2344
+
2345
+ # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2346
+ # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2347
+ if upgrade:
2348
+ # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2349
+ # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2350
+ # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2351
+ # _version_newer.
2352
+ upgrade_targets = [
2353
+ name for name in package_names
2354
+ if name in repo_pkgs
2355
+ and name in installed_db
2356
+ and name not in pinned
2357
+ ]
2358
+ for name in upgrade_targets:
2359
+ if name not in to_install:
2360
+ to_install.append(name)
2361
+
2362
+ if not to_install and not missing_deps:
2363
+ print(f"✅ {_('all_installed')}"); return 0
2364
+
2365
+ # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2366
+ fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2367
+
2368
+ if fatal_missing > 0:
2369
+ print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2370
+ print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2371
+ return 1
2372
+
2373
+ so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2374
+ if so_missing > 0:
2375
+ print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2376
+ return 1
2377
+
2378
+ if not to_install:
2379
+ print(f"✅ {_('all_installed')}"); return 0
2380
+
2381
+ MAX_MB = MAX_PKG_SIZE // 1048576
2382
+ for n in to_install:
2383
+ if not _validate_pkg_name(n):
2384
+ print(f" {_("sec_badname", name=n)}")
2385
+ return 1
2386
+ sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2387
+ if sz > MAX_PKG_SIZE:
2388
+ mb = sz // 1048576
2389
+ print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2390
+ return 1
2391
+ total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2392
+ if not _preflight_disk(total_size):
2393
+ return 1
2394
+ print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2395
+ for name in to_install:
2396
+ p = repo_pkgs.get(name)
2397
+ if p:
2398
+ if name in installed_db:
2399
+ marker = " [upgrade]" if upgrade else ""
2400
+ else:
2401
+ marker = f" [{_('new')}]"
2402
+ print(f" {name}-{p.version}{marker}")
2403
+
2404
+ if not as_dep and not upgrade:
2405
+ if not _ask_confirm():
2406
+ print(_("cancelled")); return 0
2407
+
2408
+ snapshot = json.loads(json.dumps(installed_db))
2409
+ all_installed_files = []
2410
+ failed = []
2411
+ # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2412
+ stale_candidates = []
2413
+ # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2414
+ backup_root = ""
2415
+ all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2416
+ if upgrade and to_install:
2417
+ backup_root = _new_upgrade_backup_root()
2418
+
2419
+ # --- Dziennik transakcji (dla pełnej atomowości) ---
2420
+ # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2421
+ # w tej transakcji przez _rollback_transaction().
2422
+ transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2423
+
2424
+ # --- Tryb immutable: utwórz nowy deployment ---
2425
+ immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2426
+ deploy_dir = ""
2427
+ deploy_id = ""
2428
+ if immutable:
2429
+ print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2430
+ deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2431
+ target_root = deploy_dir
2432
+ else:
2433
+ target_root = ""
2434
+
2435
+ # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2436
+ to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2437
+ if len(to_download) > 1:
2438
+ print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2439
+ downloaded = _download_packages_parallel(to_download)
2440
+ else:
2441
+ downloaded = {}
2442
+
2443
+ # --- Faza 2: Instalacja – JEDNA nadpisywana linia postępu (jak przy
2444
+ # pobieraniu), bez ściany tekstu na każdy pakiet. W trybie
2445
+ # nieinteraktywnym (logi, netinstall instalatora) wypisujemy linię na
2446
+ # pakiet – tam to pożądane do logu.
2447
+ t0 = time.time()
2448
+ stderr_tty = sys.stderr.isatty()
2449
+ stdout_tty = sys.stdout.isatty()
2450
+ _bar_last = 0
2451
+
2452
+ def _bar_draw(idx: int, name: str) -> None:
2453
+ nonlocal _bar_last
2454
+ n = len(to_install)
2455
+ pct = (idx - 1) / n * 100.0
2456
+ fl = int(25 * pct / 100)
2457
+ pbar = "█" * fl + "░" * (25 - fl)
2458
+ eta_s = ""
2459
+ if idx > 1:
2460
+ avg = (time.time() - t0) / (idx - 1)
2461
+ rem = avg * (n - idx + 1)
2462
+ eta_s = f" ~{rem:.0f}s" if rem < 60 else f" ~{rem/60:.1f}m"
2463
+ line = f" 📦 [{pbar}] {idx}/{n} ({pct:.0f}%) {name}{eta_s}"
2464
+ if stderr_tty:
2465
+ clear = " " * max(0, _bar_last - len(line))
2466
+ sys.stderr.write(f"\r{line}{clear}")
2467
+ sys.stderr.flush()
2468
+ _bar_last = len(line)
2469
+ else:
2470
+ print(line, file=sys.stderr, flush=True)
2471
+
2472
+ def _bar_end() -> None:
2473
+ nonlocal _bar_last
2474
+ if stderr_tty and _bar_last:
2475
+ sys.stderr.write("\r" + " " * _bar_last + "\r")
2476
+ sys.stderr.flush()
2477
+ _bar_last = 0
2478
+
2479
+ _pkg_i = 0
2480
+ for name in to_install:
2481
+ pkg = repo_pkgs.get(name)
2482
+ if not pkg:
2483
+ _bar_end()
2484
+ print(f" ❌ {name}: {_('not_found')}")
2485
+ failed.append(name)
2486
+ break
2487
+
2488
+ _pkg_i += 1
2489
+ _bar_draw(_pkg_i, f"{name}-{pkg.version}")
2490
+
2491
+ # Pobierz (z cache fazy 1 lub bezpośrednio)
2492
+ pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2493
+ if not pkg_path:
2494
+ _bar_end()
2495
+ print(f" ❌ {name}: {_('download_fail')}")
2496
+ failed.append(name)
2497
+ break # przerwij transakcję
2498
+
2499
+ # GPG
2500
+ gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2501
+ if not gpg_ok:
2502
+ _bar_end()
2503
+ print(f" ❌ {name}: {_('gpg_fail')}: {gpg_msg[:60]}")
2504
+ failed.append(name)
2505
+ break # PRZERWIJ – niezaufany pakiet
2506
+
2507
+ # SHA256 całego pakietu
2508
+ if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2509
+ _bar_end()
2510
+ print(f" ❌ {name}: {_('sha256_mismatch')}")
2511
+ failed.append(name)
2512
+ break # PRZERWIJ – uszkodzony pakiet
2513
+
2514
+ # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2515
+ old_files = _db_get_package_files(name) if name in installed_db else []
2516
+
2517
+ # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2518
+ ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2519
+ backup_dir=backup_root)
2520
+ if ok:
2521
+ installed_db[name] = {
2522
+ "version": pkg.version, "release": pkg.release, "description": pkg.description,
2523
+ "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2524
+ "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2525
+ "repo": pkg.repo_url,
2526
+ "provides": getattr(pkg, "provides", None) or [],
2527
+ "provides_so": getattr(pkg, "provides_so", None) or [],
2528
+ "requires_so": getattr(pkg, "requires_so", None) or [],
2529
+ }
2530
+ if not as_dep and name in package_names:
2531
+ world.add(name)
2532
+ if not stdout_tty:
2533
+ print(f" ✓ {name}-{pkg.version}", flush=True)
2534
+ all_installed_files.extend(f["path"] for f in files)
2535
+ all_backups.extend(backup_j)
2536
+
2537
+ # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2538
+ # których nie ma już w nowej wersji.
2539
+ if upgrade and old_files:
2540
+ stale_candidates.append((name, old_files, [f["path"] for f in files]))
2541
+
2542
+ # Po instalacji kernela – przebuduj initramfs
2543
+ if _is_kernel_package(name):
2544
+ _rebuild_initramfs(deploy_dir)
2545
+ else:
2546
+ _bar_end()
2547
+ print(f" ❌ {name}: {_('install_failed')}")
2548
+ failed.append(name)
2549
+ break # PRZERWIJ – błąd instalacji
2550
+
2551
+ # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2552
+ if failed:
2553
+ _bar_end()
2554
+ print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2555
+ _rollback_transaction(installed_db, snapshot, all_installed_files,
2556
+ deploy_dir, immutable, backups=all_backups)
2557
+ if backup_root:
2558
+ shutil.rmtree(backup_root, ignore_errors=True)
2559
+ _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2560
+ return 1
2561
+
2562
+ # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2563
+ # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2564
+ for pkg_name, old_files, new_paths in stale_candidates:
2565
+ _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2566
+ backup_root, all_backups)
2567
+
2568
+ save_json(INSTALLED_DB, installed_db)
2569
+ save_world(world)
2570
+ _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2571
+ file_journal=all_installed_files,
2572
+ upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2573
+ upgrade_backup_root=backup_root)
2574
+
2575
+ # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2576
+ if backup_root:
2577
+ _purge_old_backups(keep_root=backup_root)
2578
+
2579
+ _bar_end()
2580
+
2581
+ # --- Tryb immutable: przełącz na nowy deployment ---
2582
+ if immutable and not failed:
2583
+ _refresh_dynamic_linker_cache(deploy_dir)
2584
+ print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2585
+ _switch_deployment(deploy_dir)
2586
+ print(f" ✅ Aktywny deployment: {deploy_id}")
2587
+ _update_grub_config()
2588
+ cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2589
+ print(f" 💡 Restart wymagany do przeładowania systemu.")
2590
+ else:
2591
+ _refresh_dynamic_linker_cache()
2592
+ # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2593
+ # trafiły do realnego systemu (nie do deploymentu).
2594
+ _process_triggers(all_installed_files)
2595
+
2596
+ print(f"\n✅ {_('installed', len(to_install))} ({(time.time()-t0):.0f}s)")
2597
+ return 0
2598
+
2599
+
2600
+def _rollback_transaction(installed_db: dict, snapshot: dict,
2601
+ installed_files: List[str],
2602
+ deploy_dir: str, is_immutable: bool,
2603
+ backups: Optional[List[Tuple[str, str]]] = None):
2604
+ """
2605
+ Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2606
+ Przywraca installed_db do stanu sprzed transakcji.
2607
+ Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2608
+ Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2609
+ """
2610
+ # Przywróć installed_db
2611
+ installed_db.clear()
2612
+ installed_db.update(snapshot)
2613
+
2614
+ root = deploy_dir if is_immutable else PAG_ROOT
2615
+ backup_map = {dst: src for src, dst in (backups or [])}
2616
+
2617
+ # Przywróć stare wersje z backupów (upgrade)
2618
+ for dst, bpath in backup_map.items():
2619
+ full = os.path.join(root, dst.lstrip("/"))
2620
+ if os.path.lexists(bpath):
2621
+ try:
2622
+ os.makedirs(os.path.dirname(full), exist_ok=True)
2623
+ os.replace(bpath, full)
2624
+ except OSError:
2625
+ pass
2626
+
2627
+ # Usuń nowe pliki (które nie miały poprzedniej wersji)
2628
+ for fpath in reversed(installed_files):
2629
+ if fpath in backup_map:
2630
+ continue
2631
+ full = os.path.join(root, fpath.lstrip("/"))
2632
+ if os.path.isfile(full) or os.path.islink(full):
2633
+ try:
2634
+ os.remove(full)
2635
+ except OSError:
2636
+ pass
2637
+
2638
+ # Wyczyść puste katalogi
2639
+ dirs_to_check = set()
2640
+ for fpath in installed_files:
2641
+ parent = os.path.dirname(fpath)
2642
+ while parent and parent != "/":
2643
+ dirs_to_check.add(parent)
2644
+ parent = os.path.dirname(parent)
2645
+ for d in sorted(dirs_to_check, key=len, reverse=True):
2646
+ full_d = os.path.join(root, d.lstrip("/"))
2647
+ if os.path.isdir(full_d):
2648
+ try:
2649
+ os.rmdir(full_d)
2650
+ except OSError:
2651
+ pass
2652
+
2653
+ # W trybie immutable: usuń nieudany deployment
2654
+ if is_immutable and deploy_dir:
2655
+ shutil.rmtree(deploy_dir, ignore_errors=True)
2656
+
2657
+ save_json(INSTALLED_DB, snapshot)
2658
+
2659
+
2660
+# =============================================================================
2661
+# USUWANIE
2662
+# =============================================================================
2663
+
2664
+def cmd_remove(package_names):
2665
+ installed_db = load_json(INSTALLED_DB)
2666
+ world = load_world()
2667
+ snapshot = json.loads(json.dumps(installed_db))
2668
+ removed = []
2669
+ removed_files = []
2670
+
2671
+ total = len(package_names)
2672
+ for i, name in enumerate(package_names, 1):
2673
+ if name not in installed_db:
2674
+ print(f" ⚠ {name}: not installed"); continue
2675
+
2676
+ # Pasek postępu
2677
+ pct = (i - 1) / total * 100
2678
+ filled = int(25 * pct / 100)
2679
+ print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2680
+
2681
+ print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2682
+
2683
+ # Pre-remove hook (jeśli dostępny w staging)
2684
+ _run_hook_for_installed(name, "pre-remove")
2685
+
2686
+ count, rm_files = _safe_remove_files(name, installed_db)
2687
+ del installed_db[name]
2688
+ world.discard(name)
2689
+ removed.append(name)
2690
+ removed_files.extend(rm_files)
2691
+ print(f"✅ ({count} files)")
2692
+
2693
+ # Post-remove hook + sprzątanie zapisanych hooków
2694
+ _run_hook_for_installed(name, "post-remove")
2695
+ shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2696
+
2697
+ save_json(INSTALLED_DB, installed_db)
2698
+ save_world(world)
2699
+ _record_transaction("remove", removed, True, snapshot)
2700
+
2701
+ print(file=sys.stderr) # wyczyść linię paska postępu
2702
+
2703
+ if not removed: return 0
2704
+ print(f"\n✅ Removed {len(removed)}.")
2705
+ _process_triggers(removed_files)
2706
+
2707
+ orphans = _find_orphans(installed_db, world)
2708
+ if orphans:
2709
+ print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2710
+ print(" 'pag remove-orphans' to clean up.")
2711
+ return 0
2712
+
2713
+def _run_hook_for_installed(pkg_name, hook_name):
2714
+ """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2715
+ hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2716
+ if os.path.isdir(hook_dir):
2717
+ ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2718
+ _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2719
+
2720
+
2721
+# =============================================================================
2722
+# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2723
+# =============================================================================
2724
+# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2725
+# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2726
+# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2727
+# każdym pakiecie z osobna.
2728
+
2729
+TRIGGERS_DIR = PAG_CONF + "/triggers"
2730
+
2731
+DEFAULT_TRIGGERS = [
2732
+ {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2733
+ "run": "fc-cache -fs"},
2734
+ {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
2735
+ "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
2736
+ {"name": "desktop-database", "paths": ["/usr/share/applications/"],
2737
+ "run": "update-desktop-database -q /usr/share/applications"},
2738
+ {"name": "mime-database", "paths": ["/usr/share/mime/"],
2739
+ "run": "update-mime-database /usr/share/mime"},
2740
+]
2741
+
2742
+def _load_triggers() -> List[dict]:
2743
+ """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
2744
+ out = []
2745
+ for t in DEFAULT_TRIGGERS:
2746
+ bin_name = t["run"].split()[0]
2747
+ if shutil.which(bin_name):
2748
+ out.append(dict(t))
2749
+ if os.path.isdir(TRIGGERS_DIR):
2750
+ for fn in sorted(os.listdir(TRIGGERS_DIR)):
2751
+ if not fn.endswith(".json"):
2752
+ continue
2753
+ try:
2754
+ with open(os.path.join(TRIGGERS_DIR, fn)) as f:
2755
+ data = json.load(f)
2756
+ except (OSError, json.JSONDecodeError):
2757
+ continue
2758
+ if isinstance(data, dict):
2759
+ data = [data]
2760
+ for t in data:
2761
+ if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
2762
+ out.append(t)
2763
+ return out
2764
+
2765
+def _process_triggers(touched_paths: List[str]):
2766
+ """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
2767
+ if not touched_paths:
2768
+ return
2769
+ if os.environ.get("PAG_NO_HOOKS", "") == "1":
2770
+ return
2771
+ import shlex as _shlex
2772
+ matched = []
2773
+ for trig in _load_triggers():
2774
+ if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
2775
+ matched.append(trig)
2776
+ for trig in matched:
2777
+ run = trig["run"]
2778
+ print(f" ⚡ Trigger: {trig['name']} ({run})")
2779
+ try:
2780
+ r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
2781
+ _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
2782
+ if r.returncode != 0:
2783
+ print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
2784
+ except subprocess.TimeoutExpired:
2785
+ print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
2786
+ _audit(f"TRIGGER {trig['name']} TIMEOUT")
2787
+ except Exception as e:
2788
+ print(f" ⚠ trigger {trig['name']}: {e}")
2789
+
2790
+# =============================================================================
2791
+# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
2792
+# =============================================================================
2793
+
2794
+def _cleanup_tmp_files(*paths):
2795
+ """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
2796
+ for p in paths:
2797
+ try:
2798
+ if os.path.isfile(p):
2799
+ os.remove(p)
2800
+ except OSError:
2801
+ pass
2802
+
2803
+
2804
+def cmd_self_update():
2805
+ """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
2806
+
2807
+ Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
2808
+ kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
2809
+ idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
2810
+ podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
2811
+ """
2812
+ repos = get_repos()
2813
+ if not repos:
2814
+ print("❌ Brak repozytoriów w konfiguracji.")
2815
+ return 1
2816
+ base = repos[0]
2817
+ dst = "/usr/local/bin/pag"
2818
+ dst_new = dst + ".new"
2819
+ dst_bak = dst + ".bak"
2820
+ print(f"🔄 Sprawdzam aktualizację pag z {base}...")
2821
+ try:
2822
+ with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2823
+ data = r.read()
2824
+ with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2825
+ sig = r.read()
2826
+ except Exception as e:
2827
+ print(f" ❌ Nie można pobrać pag: {e}")
2828
+ return 1
2829
+
2830
+ # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
2831
+ with open(dst_new, "wb") as f:
2832
+ f.write(data)
2833
+ with open(dst_new + ".asc", "wb") as f:
2834
+ f.write(sig)
2835
+
2836
+ # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
2837
+ insecure = os.environ.get("PAG_INSECURE", "") == "1"
2838
+ ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2839
+ if not ok:
2840
+ # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
2841
+ res = _gpg_run("--verify", dst_new + ".asc", dst_new,
2842
+ capture_output=True, text=True)
2843
+ _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
2844
+ if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
2845
+ try:
2846
+ with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2847
+ keydata = r.read()
2848
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
2849
+ tmp.write(keydata); tmp.flush()
2850
+ _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
2851
+ os.unlink(tmp.name)
2852
+ print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
2853
+ ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2854
+ except Exception:
2855
+ pass
2856
+ if not ok:
2857
+ if insecure:
2858
+ print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
2859
+ else:
2860
+ print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
2861
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
2862
+ return 1
2863
+ # Sprawdź fingerprint względem przypiętego klucza repo
2864
+ pinned = _repo_pinned_fp(base)
2865
+ if pinned:
2866
+ if not fp:
2867
+ print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
2868
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
2869
+ return 1
2870
+ if fp != pinned.upper():
2871
+ if insecure:
2872
+ print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
2873
+ else:
2874
+ print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
2875
+ print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
2876
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
2877
+ return 1
2878
+
2879
+ # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
2880
+ try:
2881
+ with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
2882
+ sha = r.read().decode().strip().split()[0]
2883
+ if sha:
2884
+ actual = hashlib.sha256(data).hexdigest()
2885
+ if actual.lower() != sha.lower():
2886
+ print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
2887
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
2888
+ return 1
2889
+ print(" ✅ SHA256 zgodny")
2890
+ except Exception:
2891
+ # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
2892
+ pass
2893
+
2894
+ # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
2895
+ try:
2896
+ compile(data, "pag", "exec")
2897
+ except SyntaxError as e:
2898
+ print(f" ❌ Błąd składni w nowym pag: {e}")
2899
+ _cleanup_tmp_files(dst_new, dst_new + ".asc")
2900
+ return 1
2901
+
2902
+ m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
2903
+ or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
2904
+ new_ver = m.group(1).decode() if m else "?"
2905
+ print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
2906
+
2907
+ # --- 4. Backup + atomowa podmiana ---
2908
+ if os.path.exists(dst):
2909
+ shutil.copy2(dst, dst_bak)
2910
+ os.chmod(dst_new, 0o755)
2911
+ os.replace(dst_new, dst) # atomowe na tym samym FS
2912
+ try:
2913
+ if os.path.exists(dst_new + ".asc"):
2914
+ os.remove(dst_new + ".asc")
2915
+ except OSError:
2916
+ pass
2917
+ print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
2918
+ print(" Uruchom ponownie pag, aby użyć nowej wersji.")
2919
+ return 0
2920
+
2921
+
2922
+def _candidate_newer(rp, inst):
2923
+ """Czy pakiet z repo jest nowszy od zainstalowanego.
2924
+ Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
2925
+ po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
2926
+ widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
2927
+ traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
2928
+ reinstalacji/zmiany wersji."""
2929
+ rv = getattr(rp, "version", "0")
2930
+ iv = inst.get("version", "0")
2931
+ if _version_newer(rv, iv):
2932
+ return True
2933
+ if rv != iv:
2934
+ return False
2935
+ rr = int(getattr(rp, "release", 1) or 1)
2936
+ ir = int(inst.get("release", 1) or 1)
2937
+ return rr > ir
2938
+
2939
+
2940
+def _pending_updates() -> List[str]:
2941
+ """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
2942
+ installed = load_json(INSTALLED_DB)
2943
+ pinned = load_json(PINNED_FILE)
2944
+ repo = fetch_all_packages()
2945
+ if not repo:
2946
+ return []
2947
+ return [n for n, i in installed.items()
2948
+ if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
2949
+
2950
+def cmd_update(do_upgrade: bool = False):
2951
+ """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
2952
+
2953
+ sync → tylko odświeżenie indeksów + info: „jest X pakietów do
2954
+ zaktualizowania – wpisz: pag update".
2955
+ update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
2956
+ Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
2957
+ przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
2958
+ """
2959
+ force = True
2960
+ print("🔄 Refreshing indexes...")
2961
+ for repo_url in get_repos():
2962
+ pkgs = fetch_repo_index(repo_url, force=force)
2963
+ cp = _repo_cache_path(repo_url)
2964
+ has_sig = os.path.exists(cp + ".sig")
2965
+ print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
2966
+ print(f"✅ {_('indexes_refreshed')}")
2967
+
2968
+ # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
2969
+ try:
2970
+ for r in get_repos():
2971
+ cp = _repo_cache_path(r)
2972
+ if os.path.exists(cp):
2973
+ d = json.load(open(cp))
2974
+ rv = d.get("pag_version", "")
2975
+ if rv and rv != PAG_VERSION:
2976
+ print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
2977
+ except Exception:
2978
+ pass
2979
+
2980
+ # Raport: pakiety do aktualizacji
2981
+ pending = _pending_updates()
2982
+ if not pending:
2983
+ print(f"✅ {_('all_up_to_date')}")
2984
+ return 0
2985
+ print(f"{_('updates_available', len(pending))}")
2986
+ installed = load_json(INSTALLED_DB)
2987
+ repo = fetch_all_packages()
2988
+ for n in pending:
2989
+ print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
2990
+ if not do_upgrade:
2991
+ return 0 # sync: tylko informacja
2992
+ if not _ask_confirm():
2993
+ return 0
2994
+ return cmd_install(pending, upgrade=True)
2995
+
2996
+def _initramfs_stale() -> bool:
2997
+ """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
2998
+ try:
2999
+ kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
3000
+ if not kernels:
3001
+ return False
3002
+ newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
3003
+ initrd = "/boot/initramfs.img"
3004
+ return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
3005
+ except Exception:
3006
+ return False
3007
+
3008
+def cmd_upgrade():
3009
+ """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
3010
+ rc = cmd_update(do_upgrade=True)
3011
+ if rc != 0:
3012
+ return rc
3013
+ # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
3014
+ if _initramfs_stale():
3015
+ print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
3016
+ _rebuild_initramfs()
3017
+ try:
3018
+ if _load_deployments():
3019
+ _update_grub_config()
3020
+ except Exception:
3021
+ pass
3022
+ return 0
3023
+
3024
+def cmd_list(installed_only=False):
3025
+ if installed_only:
3026
+ db = load_json(INSTALLED_DB)
3027
+ pinned = load_json(PINNED_FILE)
3028
+ if not db: print("No packages installed."); return
3029
+ print(f"Installed ({len(db)}):")
3030
+ for n, i in sorted(db.items()):
3031
+ pin = " 📌" if n in pinned else ""
3032
+ print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
3033
+ else:
3034
+ pkgs = fetch_all_packages()
3035
+ installed = load_json(INSTALLED_DB)
3036
+ pinned = load_json(PINNED_FILE)
3037
+ print(f"Available ({len(pkgs)}):")
3038
+ for n, p in sorted(pkgs.items()):
3039
+ m = "✓" if n in installed else " "
3040
+ extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
3041
+ if n in pinned: extra += " 📌"
3042
+ print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
3043
+
3044
+def cmd_search(query):
3045
+ pkgs = fetch_all_packages()
3046
+ results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
3047
+ if not results: print(f"❌ No results for: {query}"); return
3048
+ installed = load_json(INSTALLED_DB)
3049
+ print(f"Results for '{query}' ({len(results)}):")
3050
+ for n,p in sorted(results):
3051
+ print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
3052
+ print(f" {p.description}")
3053
+
3054
+
3055
+def _smart_search(query: str) -> int:
3056
+ """
3057
+ Inteligentne wyszukiwanie: repo PaganOS + Flathub.
3058
+ Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
3059
+ Pokazuje dostępne źródła i sugeruje komendy instalacji.
3060
+ """
3061
+ # 1. Repo PaganOS
3062
+ try:
3063
+ pkgs = fetch_all_packages()
3064
+ except Exception:
3065
+ pkgs = {}
3066
+ repo_lower = [(n, p) for n, p in pkgs.items()
3067
+ if query.lower() in n.lower() or query.lower() in p.description.lower()]
3068
+
3069
+ # 2. Flathub (jeśli dostępny)
3070
+ flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
3071
+
3072
+ if not repo_lower and not flat:
3073
+ print(f"\n ❌ '{query}' — nie znaleziono.")
3074
+ print(f" Repo PaganOS: pag search {query}")
3075
+ if _check_flatpak(quiet=True):
3076
+ print(f" Flathub: pag flatpak search {query}")
3077
+ print(f" Dodaj repo: pag repo-add <url>")
3078
+ return 1
3079
+
3080
+ installed = load_json(INSTALLED_DB)
3081
+
3082
+ # ── Repo PaganOS ──
3083
+ if repo_lower:
3084
+ exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
3085
+ show = (exact or repo_lower)[:6]
3086
+ print(f"\n 📦 PaganOS — '{query}':")
3087
+ for n, p in sorted(show):
3088
+ mark = "✓" if n in installed else " "
3089
+ desc = p.description[:70] if len(p.description) > 75 else p.description
3090
+ print(f" [{mark}] {n}-{p.version}")
3091
+ if desc:
3092
+ print(f" {desc}")
3093
+ if len(repo_lower) > 6:
3094
+ print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
3095
+
3096
+ # ── Flathub ──
3097
+ if flat:
3098
+ print(f"\n 📦 Flathub — '{query}':")
3099
+ for r in flat[:5]:
3100
+ mark = "✓" if r.get("installed") else " "
3101
+ name = r.get("name") or r.get("application", "?")
3102
+ desc = (r.get("description") or "")[:65]
3103
+ print(f" [{mark}] {name}")
3104
+ if desc:
3105
+ print(f" {desc}")
3106
+ if len(flat) > 5:
3107
+ print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3108
+
3109
+ # ── Sugestie instalacji ──
3110
+ print()
3111
+ if repo_lower:
3112
+ 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]
3113
+ if best in installed:
3114
+ print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3115
+ else:
3116
+ print(f" 💡 sudo pag install {best}")
3117
+ if flat:
3118
+ best_fp = flat[0].get("application") or flat[0].get("name", query)
3119
+ print(f" 💡 pag flatpak install {best_fp}")
3120
+
3121
+ return 0
3122
+
3123
+def cmd_info(name):
3124
+ pkgs = fetch_all_packages()
3125
+ p = pkgs.get(name)
3126
+ info = load_json(INSTALLED_DB).get(name)
3127
+ if not p and not info: print(f"❌ '{name}' not found."); return 1
3128
+ print(f"📦 {name}")
3129
+ if p:
3130
+ print(f" Version (repo): {p.version}")
3131
+ print(f" Description: {p.description}")
3132
+ print(f" Size: {p.size_bytes/1048576:.1f} MB")
3133
+ print(f" SHA256: {p.sha256[:32]}...")
3134
+ print(f" GPG: {p.gpg_fp or 'none'}")
3135
+ print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3136
+ if info:
3137
+ print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3138
+
3139
+def cmd_files(name):
3140
+ if name not in load_json(INSTALLED_DB):
3141
+ print(f"❌ '{name}' not installed."); return 1
3142
+ files = _db_get_package_files(name)
3143
+ print(f"Files in {name} ({len(files)}):")
3144
+ for f in sorted(files): print(f" {f}")
3145
+
3146
+def cmd_verify(deep=False):
3147
+ installed = load_json(INSTALLED_DB)
3148
+ if not installed: print("Nothing to verify."); return
3149
+ errors = []
3150
+
3151
+ for name in installed:
3152
+ for fpath in _db_get_package_files(name):
3153
+ full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3154
+ if not (os.path.exists(full) or os.path.islink(full)):
3155
+ errors.append(f" ❌ {name}: missing {fpath}")
3156
+ elif deep:
3157
+ checksums = _db_get_all_file_checksums()
3158
+ expected = checksums.get(fpath, "")
3159
+ if expected:
3160
+ actual = _sha256_file(full)
3161
+ if actual != expected:
3162
+ errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3163
+
3164
+ if errors:
3165
+ print(f"❌ {_('verify_errors', len(errors))}")
3166
+ for e in errors[:50]: print(e)
3167
+ return 1
3168
+ total = _db_count_files()
3169
+ print(f"✅ {_('verify_ok', total)}")
3170
+
3171
+# =============================================================================
3172
+# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3173
+# =============================================================================
3174
+
3175
+def cmd_pin(name, version=""):
3176
+ pinned = load_json(PINNED_FILE)
3177
+ if version:
3178
+ pinned[name] = version
3179
+ else:
3180
+ info = load_json(INSTALLED_DB).get(name, {})
3181
+ pinned[name] = info.get("version", "?")
3182
+ save_json(PINNED_FILE, pinned)
3183
+ print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3184
+
3185
+def cmd_unpin(name):
3186
+ pinned = load_json(PINNED_FILE)
3187
+ if name in pinned:
3188
+ del pinned[name]; save_json(PINNED_FILE, pinned)
3189
+ print(f"🔓 {name} {_('unpinned')}")
3190
+ else:
3191
+ print(f"⚠ {name} {_('not_pinned')}")
3192
+
3193
+def cmd_pinned():
3194
+ pinned = load_json(PINNED_FILE)
3195
+ if not pinned: print(_("no_pinned")); return
3196
+ print(_("pinned_list", len(pinned)))
3197
+ for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3198
+
3199
+def cmd_clean():
3200
+ if os.path.isdir(PAG_CACHE):
3201
+ count = size = 0
3202
+ for f in os.listdir(PAG_CACHE):
3203
+ fp = os.path.join(PAG_CACHE, f)
3204
+ if os.path.isfile(fp):
3205
+ size += os.path.getsize(fp); os.remove(fp); count += 1
3206
+ print(f"✅ {_('cache_cleared', count, size/1048576)}")
3207
+
3208
+def cmd_remove_orphans():
3209
+ installed = load_json(INSTALLED_DB)
3210
+ world = load_world()
3211
+ orphans = _find_orphans(installed, world)
3212
+ if not orphans: print("✅ No orphans."); return
3213
+ print(f"Orphans ({len(orphans)}):")
3214
+ for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3215
+ if not _ask_confirm():
3216
+ return
3217
+ cmd_remove(list(orphans))
3218
+
3219
+
3220
+# =============================================================================
3221
+# PROVIDES – PAKIETY WIRTUALNE
3222
+# =============================================================================
3223
+
3224
+PROVIDES_MAP = {
3225
+ "pkgconfig(glib-2.0)": "glib",
3226
+ "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3227
+ "pkgconfig(gtk+-3.0)": "gtk",
3228
+ "pkgconfig(gtk4)": "gtk",
3229
+ "pkgconfig(zlib)": "zlib",
3230
+ "pkgconfig(libffi)": "libffi",
3231
+ "pkgconfig(expat)": "expat",
3232
+ "pkgconfig(libsystemd)": "systemd",
3233
+ "pkgconfig(dbus-1)": "dbus",
3234
+ "pkgconfig(mount)": "util-linux",
3235
+ "pkgconfig(blkid)": "util-linux",
3236
+ "pkgconfig(libcap)": "libcap",
3237
+ "pkgconfig(liblzma)": "xz",
3238
+ "pkgconfig(libzstd)": "zstd",
3239
+ "pkgconfig(bzip2)": "bzip2",
3240
+ "pkgconfig(libcurl)": "curl",
3241
+ "pkgconfig(openssl)": "openssl",
3242
+ "pkgconfig(libpcre2-8)": "pcre2",
3243
+ "pkgconfig(libxml-2.0)": "libxml2",
3244
+ "pkgconfig(libxslt)": "libxslt",
3245
+ "pkgconfig(freetype2)": "freetype",
3246
+ "pkgconfig(fontconfig)": "fontconfig",
3247
+ "pkgconfig(harfbuzz)": "harfbuzz",
3248
+ "pkgconfig(cairo)": "cairo",
3249
+ "pkgconfig(pango)": "pango",
3250
+ "pkgconfig(xt)": "xorg-libxt",
3251
+ "pkgconfig(xmu)": "xorg-libxmu",
3252
+ "pkgconfig(ice)": "xorg-libice",
3253
+ "pkgconfig(sm)": "xorg-libsm",
3254
+ "pkgconfig(x11)": "xorg-libx11",
3255
+ "pkgconfig(xext)": "xorg-libxext",
3256
+ "pkgconfig(xrandr)": "xorg-libxrandr",
3257
+ "pkgconfig(xfixes)": "xorg-libxfixes",
3258
+ "pkgconfig(xcursor)": "xorg-libxcursor",
3259
+ "pkgconfig(xinerama)": "xorg-libxinerama",
3260
+ "pkgconfig(xrender)": "xorg-libxrender",
3261
+ "pkgconfig(xau)": "xorg-libxau",
3262
+ "pkgconfig(xcb)": "xorg-libxcb",
3263
+ "pkgconfig(xdamage)": "xorg-libxdamage",
3264
+ "pkgconfig(xcomposite)": "xorg-libxcomposite",
3265
+ "pkgconfig(xft)": "xorg-libxft",
3266
+ "pkgconfig(xss)": "xorg-libxss",
3267
+ "pkgconfig(libsoup-3.0)": "libsoup3",
3268
+ "pkgconfig(libsoup-2.4)": "libsoup2",
3269
+ "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3270
+ "pkgconfig(libpng)": "libpng",
3271
+ "pkgconfig(libjpeg)": "libjpeg-turbo",
3272
+ "pkgconfig(libtiff-4)": "libtiff",
3273
+ "pkgconfig(ffi)": "libffi",
3274
+ # ── system / baza ──
3275
+ "pkgconfig(libcrypto)": "openssl",
3276
+ "pkgconfig(libssl)": "openssl",
3277
+ "pkgconfig(libudev)": "systemd",
3278
+ "pkgconfig(libmount)": "util-linux",
3279
+ "pkgconfig(libblkid)": "util-linux",
3280
+ "pkgconfig(uuid)": "util-linux",
3281
+ "pkgconfig(libexpat)": "expat",
3282
+ "pkgconfig(libpcre)": "pcre",
3283
+ "pkgconfig(ncursesw)": "ncurses",
3284
+ "pkgconfig(tinfo)": "ncurses",
3285
+ "pkgconfig(panel)": "ncurses",
3286
+ "pkgconfig(readline)": "readline",
3287
+ "pkgconfig(libseccomp)": "libseccomp",
3288
+ "pkgconfig(pam)": "linux-pam",
3289
+ "pkgconfig(libxcrypt)": "libxcrypt",
3290
+ "pkgconfig(libcrypt)": "libxcrypt",
3291
+ "pkgconfig(libnsl)": "libnsl",
3292
+ "pkgconfig(liblz4)": "lz4",
3293
+ "pkgconfig(libevent)": "libevent",
3294
+ "pkgconfig(libarchive)": "libarchive",
3295
+ "pkgconfig(sqlite3)": "sqlite",
3296
+ "pkgconfig(libpq)": "postgresql",
3297
+ "pkgconfig(mysqlclient)": "mariadb",
3298
+ "pkgconfig(json-c)": "json-c",
3299
+ "pkgconfig(json-glib-1.0)": "json-glib",
3300
+ "pkgconfig(libunistring)": "libunistring",
3301
+ "pkgconfig(libidn2)": "libidn2",
3302
+ "pkgconfig(libpsl)": "libpsl",
3303
+ "pkgconfig(icu-uc)": "icu",
3304
+ "pkgconfig(icu-i18n)": "icu",
3305
+ "pkgconfig(icu-io)": "icu",
3306
+ "pkgconfig(gnutls)": "gnutls",
3307
+ "pkgconfig(nettle)": "nettle",
3308
+ "pkgconfig(hogweed)": "nettle",
3309
+ "pkgconfig(libgcrypt)": "libgcrypt",
3310
+ "pkgconfig(libgpg-error)": "libgpg-error",
3311
+ "pkgconfig(libassuan)": "libassuan",
3312
+ "pkgconfig(libusb-1.0)": "libusb",
3313
+ "pkgconfig(libusb)": "libusb",
3314
+ "pkgconfig(libgudev-1.0)": "libgudev",
3315
+ "pkgconfig(gudev-1.0)": "libgudev",
3316
+ "pkgconfig(polkit-gobject-1)": "polkit",
3317
+ "pkgconfig(polkit-agent-1)": "polkit",
3318
+ "pkgconfig(libpciaccess)": "libpciaccess",
3319
+ "pkgconfig(pixman-1)": "pixman",
3320
+ "pkgconfig(libdrm)": "libdrm",
3321
+ "pkgconfig(libva)": "libva",
3322
+ "pkgconfig(libva-drm)": "libva",
3323
+ "pkgconfig(libva-x11)": "libva",
3324
+ "pkgconfig(libva-wayland)": "libva",
3325
+ "pkgconfig(vdpau)": "libvdpau",
3326
+ "pkgconfig(libvdpau)": "libvdpau",
3327
+ "pkgconfig(libinput)": "libinput",
3328
+ "pkgconfig(libevdev)": "libevdev",
3329
+ "pkgconfig(mtdev)": "mtdev",
3330
+ # ── grafika / GL / multimedia ──
3331
+ "pkgconfig(gbm)": "mesa",
3332
+ "pkgconfig(gl)": "libglvnd",
3333
+ "pkgconfig(egl)": "libglvnd",
3334
+ "pkgconfig(glesv2)": "libglvnd",
3335
+ "pkgconfig(glx)": "libglvnd",
3336
+ "pkgconfig(vulkan)": "vulkan-loader",
3337
+ "pkgconfig(libxkbcommon)": "libxkbcommon",
3338
+ "pkgconfig(xkbcommon)": "libxkbcommon",
3339
+ "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3340
+ "pkgconfig(xcb)": "xorg-libxcb",
3341
+ "pkgconfig(xcb-util)": "xcb-util",
3342
+ "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3343
+ "pkgconfig(xcb-icccm)": "xcb-util-wm",
3344
+ "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3345
+ "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3346
+ "pkgconfig(xcb-image)": "xcb-util-image",
3347
+ "pkgconfig(xcb-errors)": "xcb-util-errors",
3348
+ "pkgconfig(wayland-client)": "wayland",
3349
+ "pkgconfig(wayland-server)": "wayland",
3350
+ "pkgconfig(wayland-cursor)": "wayland",
3351
+ "pkgconfig(wayland-egl)": "wayland",
3352
+ "pkgconfig(wayland-protocols)": "wayland-protocols",
3353
+ "pkgconfig(gstreamer-1.0)": "gstreamer",
3354
+ "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3355
+ "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3356
+ "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3357
+ "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3358
+ "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3359
+ "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3360
+ "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3361
+ "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3362
+ "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3363
+ "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3364
+ "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3365
+ "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3366
+ "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3367
+ "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3368
+ "pkgconfig(libpulse)": "libpulse",
3369
+ "pkgconfig(libpulse-simple)": "libpulse",
3370
+ "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3371
+ "pkgconfig(alsa)": "alsa-lib",
3372
+ "pkgconfig(jack)": "jack2",
3373
+ "pkgconfig(libsamplerate)": "libsamplerate",
3374
+ "pkgconfig(sndfile)": "libsndfile",
3375
+ "pkgconfig(libavcodec)": "ffmpeg",
3376
+ "pkgconfig(libavformat)": "ffmpeg",
3377
+ "pkgconfig(libavutil)": "ffmpeg",
3378
+ "pkgconfig(libavfilter)": "ffmpeg",
3379
+ "pkgconfig(libswscale)": "ffmpeg",
3380
+ "pkgconfig(libswresample)": "ffmpeg",
3381
+ "pkgconfig(libpostproc)": "ffmpeg",
3382
+ "pkgconfig(SDL2)": "sdl2",
3383
+ "pkgconfig(SDL)": "sdl",
3384
+ "pkgconfig(SDL2_image)": "sdl2-image",
3385
+ "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3386
+ "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3387
+ "pkgconfig(SDL2_net)": "sdl2-net",
3388
+ "pkgconfig(libpng16)": "libpng",
3389
+ "pkgconfig(libwebp)": "libwebp",
3390
+ "pkgconfig(libwebpmux)": "libwebp",
3391
+ "pkgconfig(libwebpdemux)": "libwebp",
3392
+ "pkgconfig(libopenjp2)": "openjpeg2",
3393
+ "pkgconfig(lcms2)": "lcms2",
3394
+ "pkgconfig(libheif)": "libheif",
3395
+ "pkgconfig(libde265)": "libde265",
3396
+ "pkgconfig(x264)": "x264",
3397
+ "pkgconfig(x265)": "x265",
3398
+ # ── glib / gio ──
3399
+ "pkgconfig(gio-unix-2.0)": "glib",
3400
+ "pkgconfig(gmodule-2.0)": "glib",
3401
+ "pkgconfig(gthread-2.0)": "glib",
3402
+ "pkgconfig(girepository-2.0)": "gobject-introspection",
3403
+ "pkgconfig(girepository-1.0)": "gobject-introspection",
3404
+ "pkgconfig(libglib-2.0)": "glib",
3405
+ "pkgconfig(libgobject-2.0)": "glib",
3406
+}
3407
+
3408
+def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3409
+ """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3410
+
3411
+ Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3412
+ provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3413
+ dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3414
+ """
3415
+ if name in repo:
3416
+ return name
3417
+ if name in PROVIDES_MAP:
3418
+ real = PROVIDES_MAP[name]
3419
+ if real in repo:
3420
+ return real
3421
+ # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3422
+ if name.startswith("pkgconfig(Qt5"):
3423
+ real = "qt5"
3424
+ if real in repo:
3425
+ return real
3426
+ if name.startswith("pkgconfig(Qt6"):
3427
+ real = "qt6"
3428
+ if real in repo:
3429
+ return real
3430
+ if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3431
+ real = "gstreamer"
3432
+ if real in repo:
3433
+ return real
3434
+ if name.startswith("pkgconfig(gst-"):
3435
+ real = "gst-plugins-base"
3436
+ if real in repo:
3437
+ return real
3438
+ # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3439
+ for _pkg_name, _pkg in repo.items():
3440
+ _provs = getattr(_pkg, "provides", None) or []
3441
+ if name in _provs:
3442
+ return _pkg_name
3443
+ # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3444
+ # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3445
+ if installed:
3446
+ for _pkg_name, _meta in installed.items():
3447
+ _provs = _meta.get("provides") or [] if isinstance(_meta, dict) else []
3448
+ if name in _provs:
3449
+ return _pkg_name
3450
+ clean = name
3451
+ if name.startswith("pkgconfig(") and ")" in name:
3452
+ clean = name.split("(", 1)[1].rstrip(")")
3453
+ elif name.startswith("pkgconfig32(") and ")" in name:
3454
+ clean = name.split("(", 1)[1].rstrip(")")
3455
+ if clean != name and clean in repo:
3456
+ return clean
3457
+ return name
3458
+
3459
+
3460
+def cmd_why(pkg_name: str):
3461
+ """Pokazuje dlaczego pakiet jest zainstalowany."""
3462
+ installed = load_json(INSTALLED_DB)
3463
+ world = load_world()
3464
+ if pkg_name not in installed:
3465
+ print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3466
+ if pkg_name in world:
3467
+ print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3468
+ return 0
3469
+ parents = set()
3470
+ for w in world:
3471
+ _find_dep_path(w, pkg_name, installed, set(), [], parents)
3472
+ if parents:
3473
+ for pp in sorted(parents):
3474
+ print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3475
+ else:
3476
+ print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3477
+ return 0
3478
+
3479
+
3480
+def _find_dep_path(cur, target, installed, visited, path, results):
3481
+ if cur in visited: return
3482
+ visited.add(cur); path.append(cur)
3483
+ if cur == target:
3484
+ results.add(tuple(path))
3485
+ else:
3486
+ for dep in installed.get(cur, {}).get("dependencies", []):
3487
+ _find_dep_path(dep, target, installed, visited, path, results)
3488
+ path.pop(); visited.discard(cur)
3489
+
3490
+
3491
+def cmd_autoremove():
3492
+ """Automatycznie usuwa osierocone zależności bez pytania."""
3493
+ installed = load_json(INSTALLED_DB)
3494
+ world = load_world()
3495
+ orphans = _find_orphans(installed, world)
3496
+ if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3497
+ print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3498
+ return cmd_remove(list(orphans))
3499
+
3500
+
3501
+def cmd_download(package_names):
3502
+ """Pobiera pakiety do cache bez instalowania."""
3503
+ ensure_dirs()
3504
+ repo = fetch_all_packages()
3505
+ if not repo: print(f"❌ {_('no_index')}"); return 1
3506
+ total_size = 0; downloaded = []
3507
+ for name in package_names:
3508
+ pkg = repo.get(name)
3509
+ if not pkg:
3510
+ print(f" ❌ {name}: {_('not_found')}"); continue
3511
+ print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3512
+ path = _download_pkg(pkg)
3513
+ if path:
3514
+ total_size += os.path.getsize(path)
3515
+ downloaded.append(name)
3516
+ print(_c("green", "✓"))
3517
+ else:
3518
+ print(_c("red", "✗"))
3519
+ if downloaded:
3520
+ print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3521
+ return 0 if len(downloaded) == len(package_names) else 1
3522
+
3523
+
3524
+def cmd_stats():
3525
+ """Wyświetla statystyki PAG."""
3526
+ installed = load_json(INSTALLED_DB)
3527
+ history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3528
+ total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3529
+ total_files = _db_count_files()
3530
+ cache_size = sum(
3531
+ os.path.getsize(os.path.join(PAG_CACHE, f))
3532
+ for f in os.listdir(PAG_CACHE)
3533
+ if os.path.isfile(os.path.join(PAG_CACHE, f))
3534
+ ) if os.path.isdir(PAG_CACHE) else 0
3535
+ last_update = "never"
3536
+ for e in reversed(history):
3537
+ if e.get("action") in ("install", "upgrade") and e.get("success"):
3538
+ last_update = e.get("timestamp", "?")[:19]; break
3539
+ print(f"\n {_c('bold', _('stats_title'))}")
3540
+ print(f" {'─' * 40}")
3541
+ print(f" {_('stats_packages'):<30} {len(installed)}")
3542
+ print(f" {_('stats_files'):<30} {total_files}")
3543
+ print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3544
+ print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3545
+ print(f" {_('stats_history'):<30} {len(history)}")
3546
+ print(f" {_('stats_last_update'):<30} {last_update}")
3547
+ by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3548
+ if by_size:
3549
+ print(f"\n {_c('dim', 'Top 5:')}")
3550
+ for n, i in by_size:
3551
+ print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3552
+ return 0
3553
+
3554
+
3555
+def cmd_repo_add(url, name=None):
3556
+ if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3557
+ print(f" {_('sec_https')}"); return 1
3558
+ ensure_dirs()
3559
+ url = url.rstrip("/")
3560
+ repos = get_repos()
3561
+ if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3562
+ if name:
3563
+ # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3564
+ os.makedirs(REPOS_DIR, exist_ok=True)
3565
+ target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3566
+ with open(target, "w") as f: f.write(f"{url}\n")
3567
+ print(f"✅ {_('repo_added', url)} → {target}")
3568
+ return
3569
+ with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3570
+ print(f"✅ {_('repo_added', url)}")
3571
+
3572
+def cmd_repo_list():
3573
+ for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3574
+
3575
+def _check_flatpak(quiet: bool = False):
3576
+ if not shutil.which("flatpak"):
3577
+ if not quiet:
3578
+ print(f"❌ {_('flatpak_missing')}")
3579
+ return False
3580
+ r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
3581
+ if "flathub" not in r.stdout:
3582
+ print(f"⚠ {_('flatpak_adding')}")
3583
+ subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
3584
+ "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
3585
+ return True
3586
+
3587
+def _spinner(msg: str):
3588
+ """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3589
+ stop = threading.Event()
3590
+ def _spin():
3591
+ for c in itertools.cycle("|/-\\"):
3592
+ if stop.is_set():
3593
+ break
3594
+ sys.stdout.write(f"\r {msg} {c}")
3595
+ sys.stdout.flush()
3596
+ time.sleep(0.1)
3597
+ t = threading.Thread(target=_spin, daemon=True)
3598
+ t.start()
3599
+ def _stop():
3600
+ stop.set()
3601
+ t.join(timeout=0.3)
3602
+ sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3603
+ sys.stdout.flush()
3604
+ return _stop
3605
+
3606
+
3607
+def _flatpak_search_raw(query: str) -> List[dict]:
3608
+ """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3609
+ if not _check_flatpak():
3610
+ return []
3611
+ stop = _spinner("Szukam we Flathub...")
3612
+ try:
3613
+ try:
3614
+ r = subprocess.run(
3615
+ ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3616
+ capture_output=True, text=True, timeout=120
3617
+ )
3618
+ finally:
3619
+ stop()
3620
+ if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3621
+ print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3622
+ results = []
3623
+ for line in r.stdout.strip().split("\n"):
3624
+ parts = line.split("\t")
3625
+ if len(parts) >= 3:
3626
+ results.append({
3627
+ "name": parts[0].strip(),
3628
+ "description": parts[1].strip() if len(parts) > 1 else "",
3629
+ "app_id": parts[2].strip() if len(parts) > 2 else "",
3630
+ "version": parts[3].strip() if len(parts) > 3 else "",
3631
+ "branch": parts[4].strip() if len(parts) > 4 else "stable",
3632
+ "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3633
+ })
3634
+ return results
3635
+ except Exception as e:
3636
+ print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3637
+ return []
3638
+
3639
+def _flatpak_find_best(query: str) -> Optional[dict]:
3640
+ """
3641
+ Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3642
+ - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3643
+ - Jeśli query pasuje do nazwy → zwraca pierwsze
3644
+ - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3645
+ - Jeśli brak → zwraca None
3646
+ """
3647
+ results = _flatpak_search_raw(query)
3648
+ if not results:
3649
+ return None
3650
+
3651
+ # Dokładne dopasowanie app_id
3652
+ exact = [r for r in results if r["app_id"].lower() == query.lower()]
3653
+ if exact:
3654
+ return exact[0]
3655
+
3656
+ # Dokładne dopasowanie nazwy
3657
+ exact_name = [r for r in results if r["name"].lower() == query.lower()]
3658
+ if exact_name:
3659
+ return exact_name[0]
3660
+
3661
+ # Jednoznaczne dopasowanie (tylko 1 wynik)
3662
+ if len(results) == 1:
3663
+ return results[0]
3664
+
3665
+ # Wiele wyników – pokaż użytkownikowi
3666
+ print(f"\n {_('flatpak_found', len(results))}")
3667
+ for i, r in enumerate(results):
3668
+ print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3669
+ if r["version"]:
3670
+ print(f" {_('flatpak_info_version')}: {r['version']}")
3671
+ if r["description"]:
3672
+ desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3673
+ print(f" {desc}")
3674
+
3675
+ try:
3676
+ choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3677
+ if not choice:
3678
+ return None
3679
+ idx = int(choice) - 1
3680
+ if 0 <= idx < len(results):
3681
+ return results[idx]
3682
+ except (EOFError, ValueError, IndexError):
3683
+ pass
3684
+ return None
3685
+
3686
+def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
3687
+ """Zwraca info o zainstalowanym flatpaku lub None."""
3688
+ try:
3689
+ r = subprocess.run(
3690
+ ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
3691
+ capture_output=True, text=True, timeout=10
3692
+ )
3693
+ if r.returncode != 0:
3694
+ return None
3695
+ parts = r.stdout.strip().split("\t")
3696
+ if len(parts) < 3:
3697
+ return None
3698
+ return {
3699
+ "name": parts[0].strip(),
3700
+ "version": parts[1].strip() if len(parts) > 1 else "",
3701
+ "branch": parts[2].strip() if len(parts) > 2 else "",
3702
+ "origin": parts[3].strip() if len(parts) > 3 else "",
3703
+ "size": parts[4].strip() if len(parts) > 4 else "",
3704
+ "description": parts[5].strip() if len(parts) > 5 else "",
3705
+ }
3706
+ except Exception:
3707
+ return None
3708
+
3709
+def _flatpak_is_installed(app_id: str) -> bool:
3710
+ """Sprawdza czy flatpak o danym ID jest zainstalowany."""
3711
+ try:
3712
+ r = subprocess.run(
3713
+ ["flatpak", "info", app_id],
3714
+ capture_output=True, text=True, timeout=10
3715
+ )
3716
+ return r.returncode == 0
3717
+ except Exception:
3718
+ return False
3719
+
3720
+# =============================================================================
3721
+# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
3722
+# =============================================================================
3723
+# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
3724
+# pag flatpak search <query> → tylko szuka
3725
+# pag flatpak install <query> → instaluje
3726
+# pag flatpak remove <id> → usuwa
3727
+# pag flatpak list → lista zainstalowanych
3728
+# pag flatpak update → aktualizuje wszystkie
3729
+# pag flatpak info <id> → szczegóły flatpaka
3730
+
3731
+def cmd_flatpak(args: list):
3732
+ """
3733
+ Główna komenda flatpak – inteligentnie rozpoznaje intencję:
3734
+ pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
3735
+ pag flatpak search firefox → tylko wyszukiwanie
3736
+ pag flatpak install ... → bezpośrednia instalacja
3737
+ pag flatpak remove ... → odinstalowanie
3738
+ pag flatpak list → lista
3739
+ pag flatpak update → aktualizacja
3740
+ pag flatpak info ... → szczegóły
3741
+ """
3742
+ if not _check_flatpak():
3743
+ return 1
3744
+
3745
+ if not args:
3746
+ # Bez argumentów – domyślnie lista
3747
+ return cmd_flatpak_list()
3748
+
3749
+ subcmd = args[0].lower()
3750
+ rest = args[1:]
3751
+
3752
+ # ── Podkomendy jawne ────────────────────────────────────────────────
3753
+ if subcmd == "search":
3754
+ if not rest:
3755
+ print(_("flatpak_usage")); return 1
3756
+ return cmd_flatpak_search(" ".join(rest))
3757
+
3758
+ elif subcmd == "install":
3759
+ if not rest:
3760
+ print(_("flatpak_usage")); return 1
3761
+ return _flatpak_smart_install(rest)
3762
+
3763
+ elif subcmd == "remove" or subcmd == "uninstall":
3764
+ if not rest:
3765
+ print(_("flatpak_usage")); return 1
3766
+ return _flatpak_smart_remove(rest)
3767
+
3768
+ elif subcmd == "list":
3769
+ return cmd_flatpak_list()
3770
+
3771
+ elif subcmd == "update":
3772
+ return cmd_flatpak_update()
3773
+
3774
+ elif subcmd == "info":
3775
+ if not rest:
3776
+ print(_("flatpak_usage")); return 1
3777
+ return cmd_flatpak_info(rest[0])
3778
+
3779
+ else:
3780
+ # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
3781
+ # Sprawdź czy to zainstalowany flatpak → pokaż info
3782
+ # Jeśli nie → szukaj i zaproponuj instalację
3783
+ query = " ".join(args)
3784
+
3785
+ # Najpierw sprawdź czy już zainstalowany
3786
+ if _flatpak_is_installed(query):
3787
+ print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
3788
+ return cmd_flatpak_info(query)
3789
+
3790
+ # Szukaj we Flathub
3791
+ print(f" {_('flatpak_searching', query)}")
3792
+ best = _flatpak_find_best(query)
3793
+ if not best:
3794
+ print(f" ❌ '{query}' – {_('flatpak_not_found')}")
3795
+ return 1
3796
+
3797
+ print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
3798
+ if best["version"]:
3799
+ print(f" {_('flatpak_info_version')}: {best['version']}")
3800
+ if best["description"]:
3801
+ print(f" {best['description']}")
3802
+
3803
+ try:
3804
+ ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
3805
+ except (EOFError, KeyboardInterrupt):
3806
+ print(f"\n ⚠ {_('no_tty')}")
3807
+ return 0
3808
+ if ans and ans not in ("t", "y"):
3809
+ print(_("cancelled"))
3810
+ return 0
3811
+
3812
+ return _flatpak_do_install(best["app_id"])
3813
+
3814
+def _flatpak_smart_install(names: list) -> int:
3815
+ """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
3816
+ failed = 0
3817
+ for name in names:
3818
+ if "." in name and "/" not in name:
3819
+ # Wygląda na pełne app_id (np. org.mozilla.firefox)
3820
+ app_id = name
3821
+ else:
3822
+ # Szukaj najlepszego dopasowania
3823
+ best = _flatpak_find_best(name)
3824
+ if not best:
3825
+ print(f" ❌ '{name}' – {_('flatpak_not_found')}")
3826
+ failed += 1
3827
+ continue
3828
+ app_id = best["app_id"]
3829
+ print(f" → {best['name']} ({app_id})")
3830
+
3831
+ if _flatpak_do_install(app_id) != 0:
3832
+ failed += 1
3833
+ return 1 if failed else 0
3834
+
3835
+def _flatpak_do_install(app_id: str) -> int:
3836
+ """Wykonuje właściwą instalację flatpaka."""
3837
+ print(f" {_('flatpak_installing', app_id)}")
3838
+ result = subprocess.run(
3839
+ ["flatpak", "install", "-y", "flathub", app_id],
3840
+ check=False, timeout=600
3841
+ )
3842
+ if result.returncode == 0:
3843
+ print(f" ✅ {_('flatpak_installed', app_id)}")
3844
+ return 0
3845
+ else:
3846
+ print(f" ❌ {_('download_fail')}: {app_id}")
3847
+ return 1
3848
+
3849
+def _flatpak_smart_remove(names: list) -> int:
3850
+ """Usuwa flatpaki – obsługuje nazwy częściowe."""
3851
+ # Pobierz listę zainstalowanych
3852
+ try:
3853
+ r = subprocess.run(
3854
+ ["flatpak", "list", "--columns=application,name"],
3855
+ capture_output=True, text=True, timeout=10
3856
+ )
3857
+ installed = {}
3858
+ for line in r.stdout.strip().split("\n"):
3859
+ parts = line.split("\t")
3860
+ if len(parts) >= 2:
3861
+ installed[parts[0].strip()] = parts[1].strip()
3862
+ except Exception:
3863
+ installed = {}
3864
+
3865
+ failed = 0
3866
+ for name in names:
3867
+ app_id = name
3868
+
3869
+ # Jeśli nie podano pełnego ID – spróbuj dopasować
3870
+ if name not in installed:
3871
+ matches = {aid: aname for aid, aname in installed.items()
3872
+ if name.lower() in aid.lower() or name.lower() in aname.lower()}
3873
+ if len(matches) == 0:
3874
+ print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
3875
+ failed += 1
3876
+ continue
3877
+ elif len(matches) == 1:
3878
+ app_id = list(matches.keys())[0]
3879
+ print(f" → {matches[app_id]} ({app_id})")
3880
+ else:
3881
+ print(f"\n Wiele dopasowań dla '{name}':")
3882
+ for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
3883
+ print(f" {i}. {aname} ({aid})")
3884
+ try:
3885
+ choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
3886
+ if not choice:
3887
+ failed += 1
3888
+ continue
3889
+ aid_list = sorted(matches.keys())
3890
+ app_id = aid_list[int(choice) - 1]
3891
+ except (EOFError, ValueError, IndexError):
3892
+ failed += 1
3893
+ continue
3894
+
3895
+ print(f" 🗑 {app_id} ...", end=" ", flush=True)
3896
+ result = subprocess.run(
3897
+ ["flatpak", "uninstall", "-y", app_id],
3898
+ capture_output=True, text=True, timeout=120
3899
+ )
3900
+ if result.returncode == 0:
3901
+ print("✅")
3902
+ print(f" {_('flatpak_removed', app_id)}")
3903
+ else:
3904
+ print("❌")
3905
+ failed += 1
3906
+ return 1 if failed else 0
3907
+
3908
+def cmd_flatpak_search(q: str):
3909
+ """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
3910
+ if not _check_flatpak():
3911
+ return 1
3912
+ results = _flatpak_search_raw(q)
3913
+ if not results:
3914
+ print(f" ❌ '{q}' – {_('flatpak_not_found')}")
3915
+ return 1
3916
+ print(f"\n {_('flatpak_found', len(results))}")
3917
+ shown = results[:30] # max 30 wyników
3918
+ for i, r in enumerate(shown, 1):
3919
+ installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
3920
+ print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
3921
+ if r["version"]:
3922
+ print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
3923
+ if r["description"]:
3924
+ desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
3925
+ print(f" {_c('dim', desc)}")
3926
+ if len(results) > 30:
3927
+ print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
3928
+
3929
+ # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
3930
+ try:
3931
+ ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
3932
+ except (EOFError, KeyboardInterrupt):
3933
+ return 0
3934
+ if ans:
3935
+ try:
3936
+ idx = int(ans) - 1
3937
+ if 0 <= idx < len(shown):
3938
+ return _flatpak_do_install(shown[idx]["app_id"])
3939
+ print(_("cancelled"))
3940
+ except (ValueError, IndexError):
3941
+ print(_("cancelled"))
3942
+ return 0
3943
+
3944
+def cmd_flatpak_list():
3945
+ """Wyświetla zainstalowane flatpaki."""
3946
+ if not _check_flatpak():
3947
+ return 1
3948
+ r = subprocess.run(
3949
+ ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
3950
+ capture_output=True, text=True, timeout=10
3951
+ )
3952
+ lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
3953
+ if not lines:
3954
+ print(" (brak zainstalowanych flatpaków)")
3955
+ return 0
3956
+ print(f" Zainstalowane flatpaki ({len(lines)}):")
3957
+ for line in lines:
3958
+ parts = line.split("\t")
3959
+ if len(parts) >= 3:
3960
+ app_id, name, version = parts[0], parts[1], parts[2]
3961
+ size = parts[4] if len(parts) > 4 else ""
3962
+ size_str = f" ({size})" if size else ""
3963
+ print(f" 📦 {_c('bold', name)} {version}{size_str}")
3964
+ print(f" {_c('dim', app_id)}")
3965
+ return 0
3966
+
3967
+def cmd_flatpak_update():
3968
+ """Aktualizuje wszystkie flatpaki."""
3969
+ if not _check_flatpak():
3970
+ return 1
3971
+ print(" 🔄 Aktualizacja flatpaków...")
3972
+ result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
3973
+ if result.returncode == 0:
3974
+ print(f" ✅ {_('flatpak_updated')}")
3975
+ return result.returncode
3976
+
3977
+def cmd_flatpak_info(app_id: str):
3978
+ """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
3979
+ if not _check_flatpak():
3980
+ return 1
3981
+
3982
+ # Najpierw sprawdź zainstalowany
3983
+ info = _flatpak_get_installed_info(app_id)
3984
+ if info:
3985
+ print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
3986
+ print(f" {'─' * 45}")
3987
+ print(f" {_('flatpak_info_id'):<16} {app_id}")
3988
+ print(f" {_('flatpak_info_version'):<16} {info['version']}")
3989
+ print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
3990
+ print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
3991
+ if info["size"]:
3992
+ print(f" {_('flatpak_info_size'):<16} {info['size']}")
3993
+ if info["description"]:
3994
+ print(f" {_('flatpak_info_desc'):<16} {info['description']}")
3995
+ return 0
3996
+
3997
+ # Szukaj we Flathub
3998
+ results = _flatpak_search_raw(app_id)
3999
+ exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
4000
+ if not exact:
4001
+ # Spróbuj częściowego dopasowania
4002
+ if results:
4003
+ exact = [results[0]]
4004
+ else:
4005
+ print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
4006
+ return 1
4007
+
4008
+ r = exact[0]
4009
+ print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
4010
+ print(f" {'─' * 45}")
4011
+ print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
4012
+ print(f" {_('flatpak_info_version'):<16} {r['version']}")
4013
+ if r["description"]:
4014
+ print(f" {_('flatpak_info_desc'):<16} {r['description']}")
4015
+ print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
4016
+ return 0
4017
+
4018
+# =============================================================================
4019
+# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
4020
+# =============================================================================
4021
+
4022
+# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
4023
+KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
4024
+
4025
+def _is_kernel_package(name: str) -> bool:
4026
+ """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
4027
+ name_lower = name.lower()
4028
+ return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
4029
+
4030
+def _rebuild_initramfs(deploy_dir: str = "") -> bool:
4031
+ """
4032
+ Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
4033
+ Używa skryptu pag-initramfs lub ręcznego cpio.
4034
+ """
4035
+ if deploy_dir:
4036
+ root = deploy_dir
4037
+ else:
4038
+ root = _get_deployment_root()
4039
+
4040
+ if root == PAG_ROOT:
4041
+ # Zwykły system – użyj dracut jeśli dostępny
4042
+ if shutil.which("dracut"):
4043
+ print(" 🔧 Przebudowa initramfs (dracut)...")
4044
+ result = subprocess.run(
4045
+ ["dracut", "--force", "/boot/initramfs.img"],
4046
+ capture_output=True, text=True, timeout=120
4047
+ )
4048
+ return result.returncode == 0
4049
+ elif shutil.which("mkinitcpio"):
4050
+ print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
4051
+ result = subprocess.run(
4052
+ ["mkinitcpio", "-g", "/boot/initramfs.img"],
4053
+ capture_output=True, text=True, timeout=120
4054
+ )
4055
+ return result.returncode == 0
4056
+ else:
4057
+ print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
4058
+ return False
4059
+
4060
+ # Tryb immutable – budujemy initramfs dla deploymentu
4061
+ print(" 🔧 Budowanie initramfs dla deploymentu...")
4062
+
4063
+ # Sprawdź czy mamy nasz skrypt init
4064
+ pag_init_script = "/usr/share/pag/initramfs-init"
4065
+ if not os.path.exists(pag_init_script):
4066
+ # Szukaj w źródłach (developerski fallback)
4067
+ alt_paths = [
4068
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
4069
+ "/usr/share/pag/init",
4070
+ ]
4071
+ for p in alt_paths:
4072
+ if os.path.exists(p):
4073
+ pag_init_script = p
4074
+ break
4075
+
4076
+ if not os.path.exists(pag_init_script):
4077
+ print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
4078
+ return False
4079
+
4080
+ boot_dir = os.path.join(root, "boot")
4081
+ os.makedirs(boot_dir, exist_ok=True)
4082
+
4083
+ # Znajdź jądro (vmlinuz-*)
4084
+ kernels = sorted(
4085
+ [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4086
+ reverse=True
4087
+ ) if os.path.exists(boot_dir) else []
4088
+ if not kernels:
4089
+ print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
4090
+ return False
4091
+
4092
+ kernel_ver = kernels[0].replace("vmlinuz-", "")
4093
+ print(f" 🐧 Jądro: {kernel_ver}")
4094
+
4095
+ # Buduj initramfs ręcznie (cpio)
4096
+ tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4097
+ try:
4098
+ # Podstawowa struktura
4099
+ for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4100
+ "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4101
+ os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4102
+
4103
+ # Skopiuj init
4104
+ shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4105
+ os.chmod(os.path.join(tmpdir, "init"), 0o755)
4106
+
4107
+ # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4108
+ busybox_paths = [
4109
+ os.path.join(root, "usr/bin/busybox"),
4110
+ os.path.join(root, "bin/busybox"),
4111
+ "/usr/bin/busybox",
4112
+ "/bin/busybox",
4113
+ ]
4114
+ busybox = None
4115
+ for bp in busybox_paths:
4116
+ if os.path.exists(bp):
4117
+ busybox = bp
4118
+ break
4119
+
4120
+ if busybox:
4121
+ shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4122
+ # Utwórz symlinki dla podstawowych komend
4123
+ for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4124
+ "readlink", "mkdir", "switch_root", "cp", "rm"]:
4125
+ link = os.path.join(tmpdir, "bin", cmd)
4126
+ if not os.path.exists(link):
4127
+ os.symlink("busybox", link)
4128
+ # /bin/sh → busybox
4129
+ if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4130
+ os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4131
+ else:
4132
+ # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4133
+ for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4134
+ src = os.path.join(root, "usr/bin", tool)
4135
+ if not os.path.exists(src):
4136
+ src = os.path.join(root, "bin", tool)
4137
+ if os.path.exists(src):
4138
+ dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4139
+ shutil.copy2(src, dest)
4140
+ # Kopiuj zależności .so
4141
+ _copy_libs_for_binary(src, tmpdir, root)
4142
+
4143
+ # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4144
+ modules_src = os.path.join(root, "lib/modules", kernel_ver)
4145
+ if os.path.isdir(modules_src):
4146
+ modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4147
+ # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4148
+ for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4149
+ "kernel/drivers/scsi", "kernel/drivers/virtio",
4150
+ "modules.order", "modules.builtin"]:
4151
+ src_sub = os.path.join(modules_src, sub)
4152
+ if os.path.exists(src_sub):
4153
+ dst_sub = os.path.join(modules_dst, sub)
4154
+ os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4155
+ if os.path.isdir(src_sub):
4156
+ try:
4157
+ shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4158
+ ignore_dangling_symlinks=True)
4159
+ except (FileNotFoundError, PermissionError):
4160
+ print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4161
+ else:
4162
+ try:
4163
+ shutil.copy2(src_sub, dst_sub)
4164
+ except (FileNotFoundError, PermissionError):
4165
+ print(f" ⚠ Pomijam niedostępny plik: {sub}")
4166
+
4167
+ # Pakuj do initramfs.img
4168
+ initramfs_path = os.path.join(boot_dir, "initramfs.img")
4169
+ old_cwd = os.getcwd()
4170
+ os.chdir(tmpdir)
4171
+ try:
4172
+ with open(initramfs_path + ".tmp", "wb") as out:
4173
+ _run_cpio_pipeline(tmpdir, out)
4174
+ os.rename(initramfs_path + ".tmp", initramfs_path)
4175
+ finally:
4176
+ os.chdir(old_cwd)
4177
+
4178
+ size_mb = os.path.getsize(initramfs_path) / 1048576
4179
+ print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4180
+ return True
4181
+
4182
+ except Exception as e:
4183
+ print(f" ❌ Błąd budowy initramfs: {e}")
4184
+ return False
4185
+ finally:
4186
+ shutil.rmtree(tmpdir, ignore_errors=True)
4187
+
4188
+
4189
+def _run_cpio_pipeline(tmpdir: str, out):
4190
+ """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4191
+
4192
+ Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4193
+ (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4194
+ Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4195
+ rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4196
+ """
4197
+ find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4198
+ cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4199
+ stdin=find.stdout, stdout=subprocess.PIPE)
4200
+ find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4201
+ gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4202
+ cpio.stdout.close()
4203
+ try:
4204
+ gzip.wait(timeout=120)
4205
+ if gzip.returncode != 0:
4206
+ raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4207
+ cpio.wait(timeout=30)
4208
+ find.wait(timeout=30)
4209
+ except subprocess.TimeoutExpired:
4210
+ for p in (gzip, cpio, find):
4211
+ p.kill()
4212
+ raise
4213
+ finally:
4214
+ for p in (find, cpio, gzip):
4215
+ if p.poll() is None:
4216
+ p.kill()
4217
+ # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4218
+ if cpio.returncode != 0:
4219
+ raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4220
+ if find.returncode != 0:
4221
+ raise subprocess.CalledProcessError(find.returncode, ["find"])
4222
+
4223
+
4224
+def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4225
+ """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4226
+ try:
4227
+ result = subprocess.run(
4228
+ ["ldd", binary], capture_output=True, text=True, timeout=10
4229
+ )
4230
+ for line in result.stdout.split("\n"):
4231
+ m = re.search(r'=>\s+(/\S+)', line)
4232
+ if m:
4233
+ lib_path = m.group(1)
4234
+ lib_rel = lib_path.lstrip("/")
4235
+ lib_dest = os.path.join(dest_dir, lib_rel)
4236
+ if not os.path.exists(lib_dest):
4237
+ os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4238
+ # Szukaj w deployment root lub systemie
4239
+ if os.path.exists(lib_path):
4240
+ shutil.copy2(lib_path, lib_dest)
4241
+ else:
4242
+ alt = os.path.join(root, lib_rel)
4243
+ if os.path.exists(alt):
4244
+ shutil.copy2(alt, lib_dest)
4245
+ except Exception:
4246
+ pass
4247
+
4248
+
4249
+def cmd_initramfs_update():
4250
+ """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4251
+ ensure_dirs()
4252
+ deploy_dir = _get_deployment_root()
4253
+ if deploy_dir != PAG_ROOT:
4254
+ print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4255
+ ok = _rebuild_initramfs(deploy_dir)
4256
+ if ok:
4257
+ print("✅ Initramfs zaktualizowany.")
4258
+ # Po initramfs – zaktualizuj też GRUB
4259
+ _update_grub_config()
4260
+ else:
4261
+ print("❌ Błąd aktualizacji initramfs.")
4262
+ return 0 if ok else 1
4263
+
4264
+
4265
+def _update_grub_config():
4266
+ """
4267
+ Generuje wpisy GRUB dla wszystkich deploymentów.
4268
+ Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4269
+ """
4270
+ grub_cfg = "/boot/grub/grub.cfg"
4271
+ if not os.path.exists(os.path.dirname(grub_cfg)):
4272
+ return # brak GRUB
4273
+
4274
+ deployments = _load_deployments()
4275
+ root_dev = _detect_root_device()
4276
+
4277
+ lines = [
4278
+ "# =====================================================================",
4279
+ "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4280
+ f"# Data: {datetime.now().isoformat()}",
4281
+ "# =====================================================================",
4282
+ "",
4283
+ ]
4284
+
4285
+ # Domyślny – ostatni (najnowszy) deployment
4286
+ if deployments:
4287
+ latest = deployments[-1]["id"]
4288
+ lines.append(f"set default=0")
4289
+ lines.append(f"set timeout=5")
4290
+ else:
4291
+ lines.append("set default=0")
4292
+ lines.append("set timeout=5")
4293
+ lines.append("")
4294
+
4295
+ # Wpisy dla każdego deploymentu (od najnowszego)
4296
+ entry_num = 0
4297
+ for d in reversed(deployments):
4298
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4299
+ boot_dir = os.path.join(deploy_dir, "boot")
4300
+ kernels = sorted(
4301
+ [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4302
+ reverse=True
4303
+ ) if os.path.isdir(boot_dir) else []
4304
+
4305
+ kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4306
+ initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4307
+ initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4308
+
4309
+ active_mark = " [AKTYWNY]" if d.get("active") else ""
4310
+ pkg_list = ", ".join(d.get("packages", [])[:3])
4311
+ label = f"Pagan Linux – {d['id']}{active_mark}"
4312
+
4313
+ lines.append(f"menuentry '{label}' {{")
4314
+ if kernel_path:
4315
+ lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
4316
+ else:
4317
+ lines.append(f" # Brak jądra w tym deploymencie")
4318
+ if initrd_line:
4319
+ lines.append(f" {initrd_line}")
4320
+ lines.append("}")
4321
+ lines.append("")
4322
+ entry_num += 1
4323
+
4324
+ # Wpis fallback: zwykły root (gdyby wszystko padło)
4325
+ lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4326
+ lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
4327
+ lines.append(f" initrd /boot/initramfs.img")
4328
+ lines.append("}")
4329
+ lines.append("")
4330
+
4331
+ # Zapisz
4332
+ os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4333
+ with open(grub_cfg, "w") as f:
4334
+ f.write("\n".join(lines))
4335
+
4336
+ print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4337
+
4338
+
4339
+def _detect_root_device() -> str:
4340
+ """Wykrywa device partycji root (np. /dev/sda1)."""
4341
+ try:
4342
+ result = subprocess.run(
4343
+ ["findmnt", "-n", "-o", "SOURCE", "/"],
4344
+ capture_output=True, text=True, timeout=5
4345
+ )
4346
+ if result.returncode == 0 and result.stdout.strip():
4347
+ return result.stdout.strip()
4348
+ except Exception:
4349
+ pass
4350
+ return "/dev/sda1" # fallback
4351
+
4352
+
4353
+def cmd_grub_update():
4354
+ """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4355
+ ensure_dirs()
4356
+ print("📋 Aktualizacja konfiguracji GRUB...")
4357
+ _update_grub_config()
4358
+ print("✅ GRUB zaktualizowany.")
4359
+ return 0
4360
+
4361
+def cmd_deploy_list():
4362
+ """Wyświetla listę wszystkich deploymentów."""
4363
+ deployments = _load_deployments()
4364
+ if not deployments:
4365
+ print(_("no_deployments")); return
4366
+
4367
+ print(_("deployments_list", len(deployments)))
4368
+ active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4369
+
4370
+ for d in reversed(deployments):
4371
+ marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4372
+ print(f" {d['id']}{marker}")
4373
+ print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4374
+ if len(d.get('packages', [])) > 5:
4375
+ print(f" +{len(d['packages']) - 5} więcej...")
4376
+ print(f" {d['timestamp']}")
4377
+
4378
+
4379
+def cmd_deploy_rollback():
4380
+ """Przełącza na poprzedni deployment."""
4381
+ deployments = _load_deployments()
4382
+ active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4383
+
4384
+ if len(deployments) < 2:
4385
+ print(f"❌ {_('deploy_rollback_fail')}"); return 1
4386
+
4387
+ current_idx = active_indices[0] if active_indices else len(deployments) - 1
4388
+ prev_idx = current_idx - 1 if current_idx > 0 else -1
4389
+
4390
+ if prev_idx < 0:
4391
+ print(f"❌ {_('deploy_rollback_fail')}"); return 1
4392
+
4393
+ prev = deployments[prev_idx]
4394
+ prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4395
+
4396
+ if not os.path.isdir(prev_dir):
4397
+ print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4398
+
4399
+ print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4400
+ print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4401
+
4402
+ if not _ask_confirm():
4403
+ return 0
4404
+
4405
+ _switch_deployment(prev_dir)
4406
+
4407
+ for d in deployments:
4408
+ d["active"] = (d["id"] == prev["id"])
4409
+ _save_deployments(deployments)
4410
+
4411
+ _update_grub_config()
4412
+ print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4413
+ print(" 💡 Restart wymagany do przeładowania systemu.")
4414
+ return 0
4415
+
4416
+
4417
+def cmd_deploy_cleanup(keep: int = 3):
4418
+ """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4419
+ deployments = _load_deployments()
4420
+
4421
+ if len(deployments) <= keep:
4422
+ print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4423
+
4424
+ to_remove = deployments[:-keep]
4425
+ removed = 0
4426
+
4427
+ for d in to_remove:
4428
+ deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4429
+ if os.path.isdir(deploy_dir):
4430
+ shutil.rmtree(deploy_dir, ignore_errors=True)
4431
+ removed += 1
4432
+
4433
+ remaining = deployments[-keep:]
4434
+ _save_deployments(remaining)
4435
+
4436
+ print(f"✅ {_('deploy_cleanup_ok', removed)}")
4437
+ return 0
4438
+
4439
+
4440
+# =============================================================================
4441
+# POMOCNICZE
4442
+# =============================================================================
4443
+
4444
+def _resolve_deps(names, repo, installed):
4445
+ resolved, visited = [], set()
4446
+ missing = [] # zależności których nie ma ani w repo ani zainstalowane
4447
+
4448
+ def visit(name):
4449
+ if name in visited: return
4450
+
4451
+ # Rozwijanie wirtualnych zależności przez provides
4452
+ target = _resolve_provides(name, repo, installed)
4453
+
4454
+ if target in visited: return
4455
+ visited.add(target)
4456
+ if target in repo:
4457
+ for dep in repo[target].dependencies:
4458
+ real_dep = _resolve_provides(dep, repo, installed)
4459
+ real_target = real_dep if real_dep in repo else dep
4460
+
4461
+ # Sprawdź czy zależność jest dostępna
4462
+ if real_target not in installed and real_target not in repo:
4463
+ if dep not in missing:
4464
+ missing.append(dep)
4465
+
4466
+ if dep not in installed:
4467
+ visit(real_target)
4468
+ elif target not in installed:
4469
+ # Pakiet nie istnieje ani w repo ani zainstalowany
4470
+ if target not in missing:
4471
+ missing.append(target)
4472
+
4473
+ if target not in installed and target not in resolved:
4474
+ resolved.append(target)
4475
+
4476
+ for name in names:
4477
+ visit(name)
4478
+
4479
+ # Zwróć brakujące (do sprawdzenia przez wywołującego)
4480
+ return resolved, missing
4481
+
4482
+def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4483
+ """
4484
+ Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4485
+ Zwraca liczbę brakujących zależności.
4486
+ """
4487
+ # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
4488
+ SYSTEM_BASE = {
4489
+ "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4490
+ "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4491
+ "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4492
+ "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4493
+ }
4494
+ all_missing = []
4495
+ all_warnings = []
4496
+
4497
+ for pkg_name in to_install:
4498
+ pkg = repo.get(pkg_name)
4499
+ if not pkg:
4500
+ continue
4501
+
4502
+ for dep in pkg.dependencies:
4503
+ if dep in SYSTEM_BASE:
4504
+ continue # bazowy system dostarcza tę zależność
4505
+ real_dep = _resolve_provides(dep, repo, installed)
4506
+ # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4507
+ in_repo = real_dep in repo
4508
+ in_installed = real_dep in installed
4509
+ will_be_installed = real_dep in to_install
4510
+
4511
+ if not in_repo and not in_installed and not will_be_installed:
4512
+ if dep not in all_missing:
4513
+ all_missing.append((pkg_name, dep))
4514
+ elif in_repo and not in_installed and not will_be_installed:
4515
+ if dep not in [w[1] for w in all_warnings]:
4516
+ all_warnings.append((pkg_name, dep, real_dep))
4517
+
4518
+ if all_missing:
4519
+ print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
4520
+ for pkg, dep in all_missing:
4521
+ print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
4522
+ print()
4523
+
4524
+ if all_warnings:
4525
+ print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
4526
+ for pkg, dep, real in all_warnings:
4527
+ print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
4528
+ print()
4529
+
4530
+ return len(all_missing)
4531
+
4532
+# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
4533
+BASE_SO = {
4534
+ "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
4535
+ "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
4536
+ "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
4537
+ "libgcc_s.so.1", "linux-vdso.so.1",
4538
+}
4539
+
4540
+def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
4541
+ """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
4542
+
4543
+ Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
4544
+ (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
4545
+ wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
4546
+ """
4547
+ provided = set(BASE_SO)
4548
+ for n in to_install:
4549
+ p = repo.get(n)
4550
+ if p:
4551
+ provided.update(p.provides_so or [])
4552
+ for n, info in installed.items():
4553
+ provided.update(info.get("provides_so", []) or [])
4554
+
4555
+ missing = []
4556
+ for n in sorted(to_install):
4557
+ p = repo.get(n)
4558
+ if not p:
4559
+ continue
4560
+ for so in (p.requires_so or []):
4561
+ if so not in provided:
4562
+ missing.append((n, so))
4563
+
4564
+ if missing:
4565
+ print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
4566
+ for n, so in missing:
4567
+ print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
4568
+ print()
4569
+ return len(missing)
4570
+
4571
+def _download_pkg(pkg):
4572
+ url = f"{pkg.repo_url}/{pkg.filename}"
4573
+ dest = os.path.join(PAG_CACHE, pkg.filename)
4574
+ if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
4575
+ _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
4576
+ return dest
4577
+ try:
4578
+ req = Request(url, headers={"User-Agent":"pag/3.0"})
4579
+ with urlopen(req, timeout=600) as resp:
4580
+ total = int(resp.headers.get("Content-Length", 0))
4581
+ bar = DownloadBar(pkg.filename, total)
4582
+ with open(dest, "wb") as f:
4583
+ while True:
4584
+ chunk = resp.read(65536)
4585
+ if not chunk:
4586
+ break
4587
+ f.write(chunk)
4588
+ bar.update(len(chunk))
4589
+ bar.close()
4590
+ if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
4591
+ os.remove(dest); return None
4592
+ _download_pkg_sig(pkg, dest)
4593
+ return dest
4594
+ except Exception as e:
4595
+ print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
4596
+ return None
4597
+
4598
+def _download_pkg_sig(pkg, dest):
4599
+ """Zapewnia AKTUALNY podpis pakietu (.asc, fallback .sig) w cache.
4600
+
4601
+ Istniejący podpis jest używany tylko wtedy, gdy faktycznie weryfikuje TĘ
4602
+ paczkę. Inaczej po przebudowie tej samej wersji (ten sam plik, nowy sha256)
4603
+ stary podpis zostawał obok nowej paczki i weryfikacja dawała fałszywe
4604
+ „NIEPRAWIDŁOWY PODPIS GPG”.
4605
+ """
4606
+ for ext in (".asc", ".sig"):
4607
+ sig_dest = dest + ext
4608
+ if os.path.exists(sig_dest):
4609
+ ok, _fp = _gpg_verify_fp(sig_dest, dest)
4610
+ if ok:
4611
+ return
4612
+ for ext in (".asc", ".sig"):
4613
+ sig_dest = dest + ext
4614
+ try:
4615
+ req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
4616
+ with urlopen(req, timeout=30) as resp:
4617
+ data = resp.read()
4618
+ except Exception:
4619
+ continue
4620
+ # nie mieszaj rozszerzeń – zostaje tylko ten wariant podpisu
4621
+ for other in (".asc", ".sig"):
4622
+ if other != ext:
4623
+ try:
4624
+ os.remove(dest + other)
4625
+ except OSError:
4626
+ pass
4627
+ with open(sig_dest, "wb") as f:
4628
+ f.write(data)
4629
+ return
4630
+ # Nie udało się pobrać podpisu – usuń nieaktualny z cache, żeby weryfikacja
4631
+ # nie porównywała paczki z podpisem od innej wersji (czytelny „BRAK PODPISU”).
4632
+ for other in (".asc", ".sig"):
4633
+ try:
4634
+ os.remove(dest + other)
4635
+ except OSError:
4636
+ pass
4637
+
4638
+def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
4639
+ """
4640
+ Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
4641
+ Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
4642
+ Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
4643
+ """
4644
+ results = {}
4645
+ total = len(pkgs)
4646
+ completed = 0
4647
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
4648
+ future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
4649
+ for future in as_completed(future_to_pkg):
4650
+ pkg = future_to_pkg[future]
4651
+ try:
4652
+ results[pkg.name] = future.result()
4653
+ except Exception:
4654
+ results[pkg.name] = None
4655
+ completed += 1
4656
+ # Pasek postępu
4657
+ pct = completed / total * 100
4658
+ filled = int(20 * pct / 100)
4659
+ bar = "█" * filled + "░" * (20 - filled)
4660
+ print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
4661
+ print(file=sys.stderr) # nowa linia po zakończeniu
4662
+ return results
4663
+
4664
+def load_world():
4665
+ if not os.path.exists(WORLD_FILE): return set()
4666
+ return {l.strip() for l in open(WORLD_FILE) if l.strip()}
4667
+
4668
+def save_world(w):
4669
+ with open(WORLD_FILE,"w") as f:
4670
+ for n in sorted(w): f.write(f"{n}\n")
4671
+
4672
+def _find_orphans(installed, world):
4673
+ needed = set(world)
4674
+ changed = True
4675
+ while changed:
4676
+ changed = False
4677
+ for n in list(needed):
4678
+ for dep in installed.get(n,{}).get("dependencies",[]):
4679
+ if dep not in needed and dep in installed:
4680
+ needed.add(dep); changed = True
4681
+ return {n for n in installed if n not in needed}
4682
+
4683
+# =============================================================================
4684
+# MAIN
4685
+# =============================================================================
4686
+
4687
+def cmd_sbom(argv):
4688
+ """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
4689
+
4690
+ Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
4691
+ zainstalowanych pakietów, wersji, licencji i sum SHA256.
4692
+ """
4693
+ fmt = (argv[0] if argv else "spdx").lower()
4694
+ if fmt not in ("spdx", "cyclonedx"):
4695
+ print("❌ Format: spdx | cyclonedx")
4696
+ return 1
4697
+ installed = load_json(INSTALLED_DB)
4698
+ if not installed:
4699
+ print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
4700
+ return 0
4701
+ # metadata repo (licencje) – best-effort
4702
+ try:
4703
+ repo = fetch_all_packages()
4704
+ except Exception:
4705
+ repo = {}
4706
+ names = sorted(installed)
4707
+ created = datetime.now().astimezone().isoformat(timespec="seconds")
4708
+
4709
+ def _license_of(name):
4710
+ p = repo.get(name)
4711
+ lic = getattr(p, "license", None) or []
4712
+ if isinstance(lic, list):
4713
+ lic = ", ".join(x for x in lic if x)
4714
+ return lic or "NOASSERTION"
4715
+
4716
+ if fmt == "spdx":
4717
+ doc = {
4718
+ "spdxVersion": "SPDX-2.3",
4719
+ "dataLicense": "CC0-1.0",
4720
+ "SPDXID": "SPDXRef-DOCUMENT",
4721
+ "name": "PaganOS-installed",
4722
+ "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
4723
+ "creationInfo": {
4724
+ "created": created,
4725
+ "creators": [f"Tool: pag-{PAG_VERSION}"],
4726
+ },
4727
+ "packages": [],
4728
+ }
4729
+ for i, n in enumerate(names):
4730
+ info = installed[n]
4731
+ doc["packages"].append({
4732
+ "SPDXID": f"SPDXRef-Package-{i+1}",
4733
+ "name": n,
4734
+ "versionInfo": info.get("version", ""),
4735
+ "downloadLocation": info.get("repo", "NOASSERTION"),
4736
+ "filesAnalyzed": False,
4737
+ "licenseConcluded": _license_of(n),
4738
+ "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
4739
+ })
4740
+ else: # cyclonedx
4741
+ doc = {
4742
+ "bomFormat": "CycloneDX",
4743
+ "specVersion": "1.5",
4744
+ "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
4745
+ "version": 1,
4746
+ "metadata": {
4747
+ "timestamp": created,
4748
+ "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
4749
+ },
4750
+ "components": [],
4751
+ }
4752
+ for n in names:
4753
+ info = installed[n]
4754
+ lic = _license_of(n)
4755
+ comp = {
4756
+ "type": "library",
4757
+ "name": n,
4758
+ "version": info.get("version", ""),
4759
+ "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
4760
+ }
4761
+ if lic != "NOASSERTION":
4762
+ comp["licenses"] = [{"license": {"id": lic}}]
4763
+ doc["components"].append(comp)
4764
+ print(json.dumps(doc, indent=2, ensure_ascii=False))
4765
+ return 0
4766
+
4767
+
4768
+USAGE_EN = """pag v3 – Pagan Linux Package Manager
4769
+
4770
+BASIC:
4771
+ pag install <pkg>... Install packages
4772
+ pag remove <pkg>... Remove packages
4773
+ pag update Update PACKAGES (refreshes indexes first)
4774
+ pag sync Refresh indexes + show pending package updates
4775
+ pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
4776
+ pag list [--installed] List available / installed
4777
+ pag search <query> Search packages
4778
+ pag info <pkg> Package details
4779
+ pag files <pkg> List package files
4780
+ pag verify [--deep] Verify integrity (--deep = SHA256 per file)
4781
+ pag clean Clear download cache
4782
+ pag stats System statistics
4783
+ pag download <pkg>... Download packages to cache (offline prep)
4784
+
4785
+SECURITY:
4786
+ pag key-add <url|file> Import GPG key
4787
+ pag key-list List trusted keys
4788
+ pag key-remove <id> Remove key
4789
+ pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
4790
+ pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
4791
+ pag key-trusted List pinned repo fingerprints
4792
+
4793
+ADVANCED:
4794
+ pag why <pkg> Show why a package is installed
4795
+ pag autoremove Auto-remove orphaned dependencies
4796
+ pag pin <pkg> [ver] Pin package version
4797
+ pag unpin <pkg> Unpin
4798
+ pag pinned List pinned
4799
+ pag history Transaction history
4800
+ pag rollback Rollback last transaction
4801
+ pag remove-orphans Remove orphaned deps
4802
+ pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
4803
+ pag repo-list List repositories
4804
+ pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
4805
+
4806
+FLATPAK:
4807
+ pag flatpak [<query>] Search & install (smart)
4808
+ pag flatpak search <q> Search Flathub
4809
+ pag flatpak install <id> Install flatpak
4810
+ pag flatpak remove <id> Remove flatpak
4811
+ pag flatpak list List installed flatpaks
4812
+ pag flatpak update Update all flatpaks
4813
+ pag flatpak info <id> Show flatpak details
4814
+
4815
+IMMUTABLE OS (PAG_IMMUTABLE=1):
4816
+ pag deploy-list List all deployments
4817
+ pag deploy-rollback Switch to previous deployment
4818
+ pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
4819
+ pag initramfs-update Rebuild initramfs for current kernel/deployment
4820
+ pag grub-update Regenerate GRUB entries for all deployments
4821
+"""
4822
+
4823
+USAGE_PL = """pag v3 – Pagan Linux Package Manager
4824
+
4825
+PODSTAWOWE:
4826
+ pag install <pkg>... Instalacja pakietów
4827
+ pag remove <pkg>... Usuwanie pakietów
4828
+ pag update Aktualizacja PAKIETÓW (odświeża indeksy)
4829
+ pag sync Odśwież indeksy + info o aktualizacjach
4830
+ pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
4831
+ pag list [--installed] Lista dostępnych / zainstalowanych
4832
+ pag search <query> Szukaj pakietów
4833
+ pag info <pkg> Szczegóły pakietu
4834
+ pag files <pkg> Lista plików pakietu
4835
+ pag verify [--deep] Weryfikacja integralności
4836
+ pag clean Wyczyść cache pobierania
4837
+ pag stats Statystyki systemu
4838
+ pag download <pkg>... Pobierz do cache (offline)
4839
+
4840
+BEZPIECZEŃSTWO:
4841
+ pag key-add <url|file> Importuj klucz GPG
4842
+ pag key-list Lista zaufanych kluczy
4843
+ pag key-remove <id> Usuń klucz
4844
+ pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
4845
+ pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
4846
+ pag key-trusted Lista przypiętych fingerprintów repo
4847
+
4848
+ZAAWANSOWANE:
4849
+ pag why <pkg> Dlaczego pakiet jest zainstalowany
4850
+ pag autoremove Usuń osierocone zależności
4851
+ pag pin <pkg> [ver] Przypnij wersję pakietu
4852
+ pag unpin <pkg> Odepnij
4853
+ pag pinned Lista przypiętych
4854
+ pag history Historia transakcji
4855
+ pag rollback Cofnij ostatnią transakcję
4856
+ pag remove-orphans Usuń osierocone zależności
4857
+ pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
4858
+ pag repo-list Lista repozytoriów
4859
+ pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
4860
+
4861
+FLATPAK:
4862
+ pag flatpak [<query>] Szukaj i instaluj
4863
+ pag flatpak search <q> Szukaj na Flathub
4864
+ pag flatpak install <id> Zainstaluj flatpak
4865
+ pag flatpak remove <id> Usuń flatpak
4866
+ pag flatpak list Lista zainstalowanych
4867
+ pag flatpak update Aktualizuj wszystkie
4868
+ pag flatpak info <id> Szczegóły flatpaka
4869
+
4870
+IMMUTABLE OS (PAG_IMMUTABLE=1):
4871
+ pag deploy-list Lista wdrożeń
4872
+ pag deploy-rollback Przełącz na poprzednie wdrożenie
4873
+ pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
4874
+ pag initramfs-update Przebuduj initramfs
4875
+ pag grub-update Regeneruj wpisy GRUB"""
4876
+
4877
+def _get_usage():
4878
+ # Plik językowy może dostarczyć klucz "usage" – wtedy wygrywa z wbudowanym.
4879
+ _u = T.get(LANG, {}).get("usage")
4880
+ if _u:
4881
+ return _u
4882
+ if LANG == "pl":
4883
+ return USAGE_PL
4884
+ return USAGE_EN
4885
+
4886
+
4887
+def _extract_lang(outdir: str) -> int:
4888
+ """Eksport wbudowanych tłumaczeń do outdir/{pl,en}.json (+ klucz "usage").
4889
+
4890
+ Używane przez recepturę pakietu (pag.pag), żeby tłumaczenia jechały RAZEM
4891
+ z wersją paga – po `pag install/upgrade pag` i self-update są zawsze zgodne.
4892
+ """
4893
+ os.makedirs(outdir, exist_ok=True)
4894
+ for _code in ("pl", "en"):
4895
+ _d = dict(T.get(_code, {}))
4896
+ _u = globals().get(f"USAGE_{_code.upper()}", "")
4897
+ if _u:
4898
+ _d["usage"] = _u
4899
+ _p = os.path.join(outdir, f"{_code}.json")
4900
+ with open(_p, "w", encoding="utf-8") as _fh:
4901
+ json.dump(_d, _fh, ensure_ascii=False, indent=2, sort_keys=True)
4902
+ print(f" ✓ {_p} ({len(_d)} kluczy)")
4903
+ return 0
4904
+
4905
+
4906
+def main():
4907
+ # Ukryte (używane przy budowie pakietu): eksport tłumaczeń do plików
4908
+ if len(sys.argv) >= 3 and sys.argv[1] == "--lang-extract":
4909
+ sys.exit(_extract_lang(sys.argv[2]))
4910
+ if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
4911
+ print(f"pag {PAG_VERSION}")
4912
+ sys.exit(0)
4913
+ if len(sys.argv) == 2 and sys.argv[1] in ("--help", "-h", "help"):
4914
+ print(_get_usage()); sys.exit(0)
4915
+ if len(sys.argv) < 2:
4916
+ print(_get_usage()); sys.exit(0)
4917
+
4918
+ cmd = sys.argv[1]
4919
+ args = sys.argv[2:]
4920
+
4921
+ # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
4922
+ READ_ONLY = {
4923
+ "list": lambda: cmd_list("--installed" in args),
4924
+ "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
4925
+ "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
4926
+ "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
4927
+ "verify": lambda: cmd_verify("--deep" in args),
4928
+ "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
4929
+ "stats": cmd_stats,
4930
+ "pinned": cmd_pinned,
4931
+ "history": cmd_history,
4932
+ "repo-list": cmd_repo_list,
4933
+ "key-list": cmd_key_list,
4934
+ "key-trusted": cmd_key_trusted,
4935
+ "flatpak": lambda: cmd_flatpak(args),
4936
+ "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
4937
+ "flatpak-list": cmd_flatpak_list,
4938
+ "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
4939
+ "deploy-list": cmd_deploy_list,
4940
+ "deploy": cmd_deploy_list,
4941
+ "sbom": lambda: cmd_sbom(args),
4942
+ }
4943
+
4944
+ if cmd in READ_ONLY:
4945
+ sys.exit(READ_ONLY[cmd]() or 0)
4946
+
4947
+ # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
4948
+ WRITE_CMDS = {
4949
+ "install", "remove", "update", "sync", "upgrade", "clean", "download",
4950
+ "autoremove", "remove-orphans", "pin", "unpin", "rollback",
4951
+ "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
4952
+ "self-update",
4953
+ "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
4954
+ "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
4955
+ }
4956
+ if cmd not in WRITE_CMDS:
4957
+ # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
4958
+ # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
4959
+ # potrafi wisieć na `flatpak search` aż do Ctrl-C).
4960
+ _known = set(READ_ONLY) | set(WRITE_CMDS)
4961
+ _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
4962
+ if _close:
4963
+ print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
4964
+ print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
4965
+ sys.exit(1)
4966
+ sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4967
+
4968
+ # Obsługa flag globalnych (-y/--yes)
4969
+ global_args = []
4970
+ for a in args:
4971
+ if a in ("-y", "--yes"):
4972
+ os.environ["PAG_YES"] = "1"
4973
+ else:
4974
+ global_args.append(a)
4975
+ args = global_args
4976
+
4977
+ # --- Komendy ZAPISU (wymagają roota) ---
4978
+ if os.geteuid() != 0:
4979
+ print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
4980
+
4981
+ ensure_dirs()
4982
+
4983
+ with DatabaseLock():
4984
+ WRITE_COMMANDS = {
4985
+ "install": lambda: cmd_install(
4986
+ [a for a in args if a not in ("-f", "--force")],
4987
+ upgrade=("-f" in args or "--force" in args)),
4988
+ "remove": lambda: cmd_remove(args),
4989
+ "update": lambda: cmd_update(do_upgrade=True),
4990
+ "sync": lambda: cmd_update(do_upgrade=False),
4991
+ "upgrade": cmd_upgrade,
4992
+ "clean": cmd_clean,
4993
+ "download": lambda: cmd_download(args),
4994
+ "autoremove": cmd_autoremove,
4995
+ "remove-orphans": cmd_remove_orphans,
4996
+ "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
4997
+ "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
4998
+ "rollback": cmd_rollback,
4999
+ "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]"),
5000
+ "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
5001
+ "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
5002
+ "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
5003
+ "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
5004
+ "self-update": cmd_self_update,
5005
+ "flatpak": lambda: cmd_flatpak(args),
5006
+ "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
5007
+ "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
5008
+ "flatpak-update": cmd_flatpak_update,
5009
+ "deploy-rollback": cmd_deploy_rollback,
5010
+ "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
5011
+ "initramfs-update": cmd_initramfs_update,
5012
+ "grub-update": cmd_grub_update,
5013
+ }
5014
+
5015
+ fn = WRITE_COMMANDS.get(cmd)
5016
+ if fn:
5017
+ sys.exit(fn() or 0)
5018
+ # Should never reach here – _smart_search handles unknowns
5019
+ sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5020
+
5021
+if __name__ == "__main__":
5022
+ try:
5023
+ main()
5024
+ except KeyboardInterrupt:
5025
+ # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
5026
+ print("\n ⚠ Przerwano (Ctrl-C).")
4878
5027
sys.exit(130)