feat(session): persist Sway session snapshots safely
This commit is contained in:
parent
40fa33eef3
commit
b4c222d1aa
4 changed files with 859 additions and 38 deletions
|
|
@ -17,7 +17,7 @@ limites détaillées.
|
|||
| Document | Responsabilité |
|
||||
| --- | --- |
|
||||
| [Desktop](desktop.md) | Session greetd/UWSM/Sway et composants Wayland canoniques |
|
||||
| [Session Snapshot V1](session-snapshot-v1.md) | Contrat de capture Sway STEP18A, sécurité et limites de restauration |
|
||||
| [Session Snapshot V1](session-snapshot-v1.md) | Capture Sway STEP18A et sauvegardes persistantes STEP18B, sans restauration |
|
||||
| [Shell](shell.md) | Zsh, outils interactifs, plugins et scripts utilisateur |
|
||||
| [systemd](systemd.md) | Limite système/user, unités et activations utilisateur |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# Session Snapshot V1 — STEP18A
|
||||
# Session Snapshot V1 — STEP18A / STEP18B
|
||||
|
||||
## Objet et périmètre
|
||||
|
||||
STEP18A capture et valide en lecture seule l'état de session observable par
|
||||
Sway. **Aucune restauration n'est implémentée.** Le collecteur ne déplace pas
|
||||
de conteneur, ne change pas le focus, ne lance ni ne tue de processus et
|
||||
n'écrit aucun snapshot persistant. STEP18B possédera la persistance ; STEP19,
|
||||
la restauration et son interface ; STEP20, le layout spatial 2D.
|
||||
Sway. STEP18B sauvegarde cet état de façon persistante. **Aucune restauration
|
||||
n'est implémentée.** Le collecteur ne déplace pas de conteneur, ne change pas
|
||||
le focus, ne lance ni ne tue de processus. STEP19 possédera la restauration et
|
||||
son interface ; STEP20, le layout spatial 2D.
|
||||
|
||||
Le collecteur autonome se trouve dans
|
||||
`quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py`. Il est
|
||||
|
|
@ -16,8 +16,8 @@ séparé de QML :
|
|||
python ~/.config/quickshell/labfy-sway/session/session_snapshot.py --stdout
|
||||
```
|
||||
|
||||
Sans `--stdout`, il refuse d'agir. Pour STEP18A, une redirection explicite vers
|
||||
`/tmp` est réservée au diagnostic.
|
||||
`--stdout` reste la capture non persistante de STEP18A. Les commandes
|
||||
persistantes explicites de STEP18B sont documentées plus bas.
|
||||
|
||||
## Sources et audit de disponibilité
|
||||
|
||||
|
|
@ -270,40 +270,126 @@ une page Web ou un document différent ne change pas l'identité structurelle.
|
|||
Les champs runtime restent inclus dans cette comparaison : à état réellement
|
||||
inchangé ils doivent rester identiques.
|
||||
|
||||
## Sécurité et future persistance STEP18B
|
||||
## Persistance STEP18B
|
||||
|
||||
Un titre peut révéler un document ou un site. Les snapshots futurs sont de
|
||||
l'état utilisateur et devront aller dans :
|
||||
### Emplacement, nom et métadonnées
|
||||
|
||||
Un titre peut révéler un document ou un site. Les snapshots sont donc de
|
||||
l'état utilisateur et vont exclusivement dans :
|
||||
|
||||
```text
|
||||
${XDG_STATE_HOME:-$HOME/.local/state}/labfy-sway/sessions/
|
||||
```
|
||||
|
||||
Le répertoire devra être créé en `0700` et chaque snapshot en `0600`. Rien ne
|
||||
doit être installé dans `~/.config`, le dépôt Git, `Documents` ou `/tmp` pour
|
||||
le fonctionnement normal.
|
||||
Lorsque `XDG_STATE_HOME` est défini et non vide, la cible est
|
||||
`$XDG_STATE_HOME/labfy-sway/sessions/`. Sinon elle est
|
||||
`$HOME/.local/state/labfy-sway/sessions/`. Une valeur relative de
|
||||
`XDG_STATE_HOME` est refusée. Les répertoires gérés `labfy-sway/` et
|
||||
`sessions/` sont forcés à `0700`; les snapshots et le verrou sont forcés à
|
||||
`0600`, indépendamment de l'umask. Rien n'est écrit dans `~/.config`, le dépôt
|
||||
Git, `Documents` ou `/tmp` pour le fonctionnement normal.
|
||||
|
||||
STEP18B devra écrire un fichier temporaire dans le même répertoire, appliquer
|
||||
les permissions, écrire et valider le document complet, effectuer `fsync` du
|
||||
fichier si la politique de durabilité le requiert, puis publier par `rename`
|
||||
atomique et synchroniser le répertoire si nécessaire. Une capture interrompue
|
||||
ne devra jamais remplacer le dernier snapshot valide.
|
||||
Le nom logique est la clé persistante V1. Il contient de 1 à 64 caractères
|
||||
ASCII parmi `A-Z`, `a-z`, `0-9`, `-` et `_`. Il n'existe pas de `session_id`
|
||||
distinct en V1 : en ajouter un serait redondant tant que le renommage n'est pas
|
||||
contracté. Le nom ne devient jamais un chemin libre ; l'implémentation dérive
|
||||
elle-même le seul fichier `<name>.json`. Les séparateurs, points, espaces,
|
||||
NUL, chaînes vides et traversées sont refusés.
|
||||
|
||||
Un document persistant conserve l'enveloppe
|
||||
`labfy.sway.session-snapshot`/version `1` et ajoute à `metadata` :
|
||||
|
||||
- `session_name`, la clé logique validée ;
|
||||
- `created_at`, fixé lors de la première sauvegarde valide ;
|
||||
- `updated_at`, avancé à chaque publication ;
|
||||
- `captured_at`, l'instant de la nouvelle capture Sway.
|
||||
|
||||
Une mise à jour réutilise exactement le même fichier. Elle ne crée aucune
|
||||
rotation ou copie suffixée. Si la cible régulière est corrompue, un `save`
|
||||
explicitement adressé peut la remplacer par un nouveau snapshot valide ; il
|
||||
ne prétend alors pas récupérer son ancien `created_at`.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
session_snapshot.py --stdout
|
||||
session_snapshot.py save <name>
|
||||
session_snapshot.py list
|
||||
session_snapshot.py show <name>
|
||||
session_snapshot.py delete <name>
|
||||
```
|
||||
|
||||
`--compact` produit du JSON compact. `save` collecte et publie ; `list` émet un
|
||||
tableau d'objets `{name, updated_at, windows, workspaces}` ; `show` retourne le
|
||||
document strictement validé ; `delete` supprime uniquement le fichier régulier
|
||||
dérivé du nom validé. Ces commandes ne contiennent et n'appellent aucune
|
||||
opération `restore`, `launch` ou `apply`.
|
||||
|
||||
### Cohérence IPC, verrouillage et publication atomique
|
||||
|
||||
Les quatre requêtes IPC ne sont pas transactionnelles. Pour `save`, une
|
||||
violation relationnelle produite par cet intervalle est retentée au plus trois
|
||||
fois, avec 50 ms entre tentatives. Les erreurs IPC ordinaires ne sont pas
|
||||
masquées par ces retries. Après trois graphes incohérents, la commande retourne
|
||||
`SAVE_FAILED_INCONSISTENT_STATE` et ne touche pas au snapshot précédent.
|
||||
|
||||
Un verrou global persistant `.sessions.lock`, fichier régulier privé ouvert
|
||||
avec `O_NOFOLLOW`, est pris par `flock`: exclusif pour `save`/`delete`, partagé
|
||||
pour `list`/`show`. Il sérialise deux mises à jour simultanées sans daemon ni
|
||||
nouvelle file de travaux.
|
||||
|
||||
La publication suit ce contrat :
|
||||
|
||||
```text
|
||||
snapshot validé en mémoire
|
||||
→ temporaire aléatoire dans sessions/ en 0600 et O_EXCL/O_NOFOLLOW
|
||||
→ écriture complète
|
||||
→ fsync du fichier
|
||||
→ relecture bornée, comparaison des octets, parsing et validation
|
||||
→ nouvelle vérification de la cible
|
||||
→ os.replace atomique dans le même répertoire
|
||||
→ fsync du répertoire
|
||||
```
|
||||
|
||||
Le temporaire propre à l'opération est supprimé en cas d'échec normal. Une
|
||||
interruption avant `os.replace` laisse l'ancien fichier inchangé. Les chemins
|
||||
de cible, temporaire, verrou et suppression sont utilisés relativement au
|
||||
descripteur ouvert du répertoire. Les répertoires gérés et fichiers cibles ne
|
||||
peuvent pas être des symlinks ; une cible symlink ou non régulière est refusée,
|
||||
donc elle ne peut rediriger ni écriture ni suppression vers un autre chemin.
|
||||
|
||||
### Chargement, corruption, version et limite
|
||||
|
||||
Chaque lecture ouvre un fichier régulier avec `O_NOFOLLOW`, contrôle son mode
|
||||
`0600`, sa taille, son JSON, son schema, sa version, ses métadonnées
|
||||
persistantes et toutes les relations V1. La limite est de 8 MiB
|
||||
(`8388608` octets), très supérieure aux captures usuelles d'environ 10 KiB.
|
||||
Elle est appliquée avant parsing et pendant la lecture afin de borner également
|
||||
un fichier qui grandit.
|
||||
|
||||
`show` échoue explicitement sur corruption. `list` ignore chaque fichier
|
||||
invalide indépendamment et signale un avertissement sur stderr sans perdre les
|
||||
autres sauvegardes valides. Un schema inconnu retourne
|
||||
`unsupported snapshot schema`; une version autre que `1` retourne
|
||||
`unsupported snapshot version` et n'est jamais interprétée comme V1.
|
||||
|
||||
### Confidentialité
|
||||
|
||||
Sont explicitement exclus : username, HOME absolu, hostname par défaut,
|
||||
IP/MAC, make/model/serial des écrans, contenu d'environnement, lignes de
|
||||
commande, arguments, tokens, mots de passe, cookies, URL privées, valeur
|
||||
`Exec` des DesktopEntry, arbres IPC bruts et chemins complets d'exécutables.
|
||||
`title_hint` demeure potentiellement privé et justifie à lui seul les modes
|
||||
`0700`/`0600`.
|
||||
|
||||
## Limites V1
|
||||
|
||||
V1 ne sait pas relancer une application, retrouver le contenu d'un terminal,
|
||||
V1 ne sait pas restaurer ou relancer une application, retrouver le contenu d'un terminal,
|
||||
regrouper sûrement des processus en application, redistribuer un workspace si
|
||||
un écran manque, restaurer le focus, interpréter un titre, faire une rotation
|
||||
de snapshots ni représenter le Spatial Canvas. Ces limites sont explicites et
|
||||
n'affaiblissent pas la capture normalisée de STEP18A.
|
||||
|
||||
Les quatre requêtes IPC ne constituent pas une transaction atomique offerte
|
||||
par Sway. Si la topologie change pendant ces quelques dizaines de
|
||||
millisecondes, la validation relationnelle fait échouer la capture plutôt que
|
||||
de publier un graphe incohérent. STEP18B pourra effectuer une nouvelle capture
|
||||
bornée ; il ne devra jamais persister le résultat invalide.
|
||||
Il n'existe ni sauvegarde automatique à la fermeture, ni hook logout, ni
|
||||
autostart de gestionnaire de session, ni UI QuickShell dans STEP18B. Ces
|
||||
politiques et toute mutation Sway restent hors périmètre.
|
||||
|
|
|
|||
|
|
@ -4,17 +4,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import configparser
|
||||
import datetime as dt
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
from typing import Any, Callable, Iterator, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA = "labfy.sway.session-snapshot"
|
||||
|
|
@ -27,6 +32,12 @@ LAYOUT_ENGINE = "sway-native"
|
|||
LAYOUTS = {"none", "splith", "splitv", "stacked", "tabbed", "output", "dockarea"}
|
||||
CONFIDENCE = {"exact", "heuristic", "unresolved"}
|
||||
RECT_KEYS = ("x", "y", "width", "height")
|
||||
SESSION_NAME = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z")
|
||||
SNAPSHOT_SUFFIX = ".json"
|
||||
LOCK_FILENAME = ".sessions.lock"
|
||||
MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024
|
||||
SAVE_ATTEMPTS = 3
|
||||
SAVE_RETRY_DELAY_SECONDS = 0.05
|
||||
DAEMON_NAMES = {
|
||||
"autotiling",
|
||||
"inactive-windows-transparency.py",
|
||||
|
|
@ -43,6 +54,118 @@ class SnapshotError(RuntimeError):
|
|||
"""Erreur de collecte ou violation du contrat V1."""
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
"""Retourner un instant UTC stable et sérialisable à la milliseconde."""
|
||||
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def normalize_session_name(name: str) -> str:
|
||||
"""Valider la clé logique V1 sans jamais la transformer en chemin libre."""
|
||||
if not isinstance(name, str) or SESSION_NAME.fullmatch(name) is None:
|
||||
raise SnapshotError(
|
||||
"nom de session invalide: utiliser 1 à 64 caractères parmi A-Z, a-z, 0-9, - et _"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _snapshot_filename(name: str) -> str:
|
||||
return f"{normalize_session_name(name)}{SNAPSHOT_SUFFIX}"
|
||||
|
||||
|
||||
def _ensure_child_directory(parent_fd: int, name: str) -> int:
|
||||
"""Créer puis ouvrir un répertoire géré sans suivre de lien symbolique."""
|
||||
try:
|
||||
os.mkdir(name, mode=0o700, dir_fd=parent_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(name, flags, dir_fd=parent_fd)
|
||||
except OSError as exc:
|
||||
raise SnapshotError(f"répertoire géré non sûr: {name}: {exc}") from exc
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISDIR(info.st_mode):
|
||||
raise SnapshotError(f"répertoire géré non sûr: {name}")
|
||||
# CONTRACT: une umask permissive ou un ancien mode ne doit jamais
|
||||
# exposer les titres privés contenus dans les snapshots.
|
||||
os.fchmod(fd, 0o700)
|
||||
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o700:
|
||||
raise SnapshotError(f"permissions répertoire invalides: {name}")
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def sessions_directory(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> Path:
|
||||
"""Résoudre et créer la cible XDG privée des sessions persistantes."""
|
||||
env = os.environ if environment is None else environment
|
||||
home_path = Path.home() if home is None else home
|
||||
configured = env.get("XDG_STATE_HOME")
|
||||
root = Path(configured) if configured else home_path / ".local/state"
|
||||
if not root.is_absolute():
|
||||
raise SnapshotError("XDG_STATE_HOME doit être un chemin absolu")
|
||||
try:
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
|
||||
except OSError as exc:
|
||||
raise SnapshotError(f"impossible d'ouvrir XDG state: {exc}") from exc
|
||||
try:
|
||||
app_fd = _ensure_child_directory(root_fd, "labfy-sway")
|
||||
try:
|
||||
sessions_fd = _ensure_child_directory(app_fd, "sessions")
|
||||
os.close(sessions_fd)
|
||||
finally:
|
||||
os.close(app_fd)
|
||||
finally:
|
||||
os.close(root_fd)
|
||||
return root / "labfy-sway/sessions"
|
||||
|
||||
|
||||
def _open_sessions_directory(path: Path) -> int:
|
||||
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise SnapshotError(f"répertoire sessions non sûr: {exc}") from exc
|
||||
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o700:
|
||||
os.close(fd)
|
||||
raise SnapshotError("permissions du répertoire sessions différentes de 0700")
|
||||
return fd
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _sessions_lock(directory_fd: int, *, exclusive: bool) -> Iterator[None]:
|
||||
"""Sérialiser publications/suppressions et stabiliser lectures multi-fichiers."""
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
lock_fd = os.open(LOCK_FILENAME, flags, 0o600, dir_fd=directory_fd)
|
||||
except OSError as exc:
|
||||
raise SnapshotError(f"verrou sessions non sûr: {exc}") from exc
|
||||
try:
|
||||
info = os.fstat(lock_fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise SnapshotError("verrou sessions non régulier")
|
||||
os.fchmod(lock_fd, 0o600)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def _ipc(message_type: str) -> Any:
|
||||
"""CONTRACT: interroger Sway sans commande mutatrice ni shell intermédiaire."""
|
||||
try:
|
||||
|
|
@ -429,7 +552,7 @@ def build_snapshot(
|
|||
scratch_roots.append(normalizer.visit(node, None, None, None, "scratchpad", index, True))
|
||||
|
||||
env = environment or {}
|
||||
captured = captured_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
captured = captured_at or utc_now()
|
||||
identity_counts = {key: 0 for key in CONFIDENCE}
|
||||
for window in normalizer.windows:
|
||||
identity_counts[window["restore_identity"]["confidence"]] += 1
|
||||
|
|
@ -647,7 +770,8 @@ def validate_snapshot(snapshot: Mapping[str, Any]) -> None:
|
|||
def structural_projection(snapshot: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Projection déterministe : les indices purement volatils n'y participent pas."""
|
||||
projected = json.loads(json.dumps(snapshot))
|
||||
projected["metadata"].pop("captured_at", None)
|
||||
for field in ("captured_at", "created_at", "updated_at"):
|
||||
projected["metadata"].pop(field, None)
|
||||
projected["diagnostics"].pop("collection_duration_ms", None)
|
||||
for window in projected["windows"]:
|
||||
window.pop("title_hint", None)
|
||||
|
|
@ -672,31 +796,377 @@ def collect(autostart_path: Path | None = None) -> dict[str, Any]:
|
|||
)
|
||||
|
||||
|
||||
def collect_consistent(
|
||||
autostart_path: Path | None = None,
|
||||
*,
|
||||
attempts: int = SAVE_ATTEMPTS,
|
||||
retry_delay: float = SAVE_RETRY_DELAY_SECONDS,
|
||||
collector: Callable[[Path | None], dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Réessayer uniquement les graphes IPC relationnellement incohérents."""
|
||||
if attempts < 1:
|
||||
raise ValueError("attempts doit être positif")
|
||||
operation = collect if collector is None else collector
|
||||
last_error: SnapshotError | None = None
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
snapshot = operation(autostart_path)
|
||||
validate_snapshot(snapshot)
|
||||
return snapshot
|
||||
except SnapshotError as exc:
|
||||
if not str(exc).startswith("snapshot V1 invalide:"):
|
||||
raise
|
||||
last_error = exc
|
||||
if attempt + 1 < attempts and retry_delay > 0:
|
||||
time.sleep(retry_delay)
|
||||
raise SnapshotError(f"SAVE_FAILED_INCONSISTENT_STATE: {last_error}") from last_error
|
||||
|
||||
|
||||
def _validate_persistent_snapshot(snapshot: Mapping[str, Any], expected_name: str | None = None) -> None:
|
||||
"""Valider l'extension compatible de métadonnées propre à STEP18B."""
|
||||
validate_snapshot(snapshot)
|
||||
metadata = snapshot["metadata"]
|
||||
for key in ("session_name", "created_at", "updated_at"):
|
||||
_require(isinstance(metadata.get(key), str) and bool(metadata[key]), f"metadata.{key}")
|
||||
normalized = normalize_session_name(metadata["session_name"])
|
||||
_require(normalized == metadata["session_name"], "metadata.session_name")
|
||||
parsed: dict[str, dt.datetime] = {}
|
||||
for key in ("created_at", "updated_at"):
|
||||
try:
|
||||
parsed[key] = dt.datetime.fromisoformat(metadata[key].replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise SnapshotError(f"snapshot V1 invalide: metadata.{key} UTC") from exc
|
||||
_require(
|
||||
parsed[key].tzinfo is not None and parsed[key].utcoffset() == dt.timedelta(0),
|
||||
f"metadata.{key} UTC",
|
||||
)
|
||||
_require(parsed["updated_at"] >= parsed["created_at"], "metadata.updated_at antérieur à created_at")
|
||||
if expected_name is not None:
|
||||
_require(normalized == expected_name, "nom fichier/session")
|
||||
|
||||
|
||||
def _read_all_bounded(fd: int, size: int) -> bytes:
|
||||
if size > MAX_SNAPSHOT_BYTES:
|
||||
raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)")
|
||||
chunks: list[bytes] = []
|
||||
remaining = MAX_SNAPSHOT_BYTES + 1
|
||||
while remaining > 0:
|
||||
chunk = os.read(fd, min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
payload = b"".join(chunks)
|
||||
if len(payload) > MAX_SNAPSHOT_BYTES:
|
||||
raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)")
|
||||
return payload
|
||||
|
||||
|
||||
def _load_snapshot_at(directory_fd: int, filename: str, expected_name: str) -> dict[str, Any]:
|
||||
"""Lire un fichier régulier borné sans suivre de lien symbolique."""
|
||||
flags = os.O_RDONLY | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(filename, flags, dir_fd=directory_fd)
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.ELOOP:
|
||||
raise SnapshotError(f"snapshot refusé (lien symbolique): {expected_name}") from exc
|
||||
raise SnapshotError(f"lecture snapshot impossible: {expected_name}: {exc}") from exc
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise SnapshotError(f"snapshot refusé (type non régulier): {expected_name}")
|
||||
if stat.S_IMODE(info.st_mode) != 0o600:
|
||||
raise SnapshotError(f"permissions snapshot différentes de 0600: {expected_name}")
|
||||
payload = _read_all_bounded(fd, info.st_size)
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
value = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SnapshotError(f"JSON snapshot invalide: {expected_name}: {exc}") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise SnapshotError(f"snapshot invalide: racine JSON non objet: {expected_name}")
|
||||
if value.get("schema") != SCHEMA:
|
||||
raise SnapshotError(f"unsupported snapshot schema: {value.get('schema')!r}")
|
||||
if value.get("version") != VERSION:
|
||||
raise SnapshotError(f"unsupported snapshot version: {value.get('version')!r}")
|
||||
snapshot = dict(value)
|
||||
_validate_persistent_snapshot(snapshot, expected_name)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _serialize_snapshot(snapshot: Mapping[str, Any]) -> bytes:
|
||||
payload = (json.dumps(snapshot, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode("utf-8")
|
||||
if len(payload) > MAX_SNAPSHOT_BYTES:
|
||||
raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)")
|
||||
return payload
|
||||
|
||||
|
||||
def _write_all(fd: int, payload: bytes) -> None:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise SnapshotError("écriture temporaire incomplète")
|
||||
view = view[written:]
|
||||
|
||||
|
||||
def _target_is_safe(directory_fd: int, filename: str) -> None:
|
||||
try:
|
||||
info = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
if stat.S_ISLNK(info.st_mode):
|
||||
raise SnapshotError("cible snapshot refusée: lien symbolique")
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise SnapshotError("cible snapshot refusée: type non régulier")
|
||||
|
||||
|
||||
def _atomic_publish(
|
||||
directory_fd: int,
|
||||
filename: str,
|
||||
snapshot: Mapping[str, Any],
|
||||
*,
|
||||
before_replace: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
"""Publier un snapshot complet sans exposer d'état intermédiaire."""
|
||||
payload = _serialize_snapshot(snapshot)
|
||||
temporary = f".{filename}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = -1
|
||||
try:
|
||||
fd = os.open(temporary, flags, 0o600, dir_fd=directory_fd)
|
||||
os.fchmod(fd, 0o600)
|
||||
_write_all(fd, payload)
|
||||
os.fsync(fd)
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
|
||||
# CONTRACT: relire le fichier réellement écrit avant publication ; une
|
||||
# corruption locale ou un test d'interruption ne touche jamais l'ancien.
|
||||
check_flags = os.O_RDONLY | os.O_CLOEXEC
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
check_flags |= os.O_NOFOLLOW
|
||||
check_fd = os.open(temporary, check_flags, dir_fd=directory_fd)
|
||||
try:
|
||||
written = _read_all_bounded(check_fd, os.fstat(check_fd).st_size)
|
||||
finally:
|
||||
os.close(check_fd)
|
||||
if written != payload:
|
||||
raise SnapshotError("validation du temporaire échouée")
|
||||
decoded = json.loads(written)
|
||||
_validate_persistent_snapshot(decoded, snapshot["metadata"]["session_name"])
|
||||
_target_is_safe(directory_fd, filename)
|
||||
if before_replace is not None:
|
||||
before_replace(temporary)
|
||||
os.replace(temporary, filename, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
|
||||
os.fsync(directory_fd)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SnapshotError(f"publication atomique échouée: {exc}") from exc
|
||||
finally:
|
||||
if fd >= 0:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=directory_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _next_updated_at(previous: str | None) -> str:
|
||||
current = utc_now()
|
||||
if previous is None or current > previous:
|
||||
return current
|
||||
try:
|
||||
parsed = dt.datetime.fromisoformat(previous.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return current
|
||||
return (parsed + dt.timedelta(milliseconds=1)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def save_session(
|
||||
name: str,
|
||||
autostart_path: Path | None = None,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
collector: Callable[[Path | None], dict[str, Any]] | None = None,
|
||||
retry_delay: float = SAVE_RETRY_DELAY_SECONDS,
|
||||
before_replace: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collecter, valider et remplacer atomiquement une session nommée."""
|
||||
normalized = normalize_session_name(name)
|
||||
started = time.monotonic()
|
||||
snapshot = collect_consistent(
|
||||
autostart_path,
|
||||
retry_delay=retry_delay,
|
||||
collector=collector,
|
||||
)
|
||||
snapshot["diagnostics"]["collection_duration_ms"] = round((time.monotonic() - started) * 1000, 3)
|
||||
validate_snapshot(snapshot)
|
||||
directory = sessions_directory(environment, home)
|
||||
directory_fd = _open_sessions_directory(directory)
|
||||
filename = _snapshot_filename(normalized)
|
||||
try:
|
||||
with _sessions_lock(directory_fd, exclusive=True):
|
||||
_target_is_safe(directory_fd, filename)
|
||||
existing: dict[str, Any] | None = None
|
||||
try:
|
||||
existing = _load_snapshot_at(directory_fd, filename, normalized)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except SnapshotError:
|
||||
# CONTRACT: un save explicitement ciblé peut réparer un JSON
|
||||
# corrompu, mais jamais contourner un lien ou type de fichier.
|
||||
existing = None
|
||||
updated_at = _next_updated_at(existing["metadata"]["updated_at"] if existing else None)
|
||||
snapshot["metadata"].update({
|
||||
"session_name": normalized,
|
||||
"created_at": existing["metadata"]["created_at"] if existing else updated_at,
|
||||
"updated_at": updated_at,
|
||||
})
|
||||
_validate_persistent_snapshot(snapshot, normalized)
|
||||
_atomic_publish(
|
||||
directory_fd,
|
||||
filename,
|
||||
snapshot,
|
||||
before_replace=before_replace,
|
||||
)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
return snapshot
|
||||
|
||||
|
||||
def show_session(
|
||||
name: str,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized = normalize_session_name(name)
|
||||
directory = sessions_directory(environment, home)
|
||||
directory_fd = _open_sessions_directory(directory)
|
||||
try:
|
||||
with _sessions_lock(directory_fd, exclusive=False):
|
||||
try:
|
||||
return _load_snapshot_at(directory_fd, _snapshot_filename(normalized), normalized)
|
||||
except FileNotFoundError as exc:
|
||||
raise SnapshotError(f"session introuvable: {normalized}") from exc
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def list_sessions(
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
directory = sessions_directory(environment, home)
|
||||
directory_fd = _open_sessions_directory(directory)
|
||||
sessions: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
try:
|
||||
with _sessions_lock(directory_fd, exclusive=False):
|
||||
for filename in sorted(os.listdir(directory_fd)):
|
||||
if not filename.endswith(SNAPSHOT_SUFFIX):
|
||||
continue
|
||||
name = filename[: -len(SNAPSHOT_SUFFIX)]
|
||||
try:
|
||||
normalized = normalize_session_name(name)
|
||||
snapshot = _load_snapshot_at(directory_fd, filename, normalized)
|
||||
except (SnapshotError, FileNotFoundError) as exc:
|
||||
warnings.append(f"snapshot ignoré {filename}: {exc}")
|
||||
continue
|
||||
sessions.append({
|
||||
"name": normalized,
|
||||
"updated_at": snapshot["metadata"]["updated_at"],
|
||||
"windows": len(snapshot["windows"]),
|
||||
"workspaces": len(snapshot["workspaces"]),
|
||||
})
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
return sessions, warnings
|
||||
|
||||
|
||||
def delete_session(
|
||||
name: str,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> None:
|
||||
normalized = normalize_session_name(name)
|
||||
directory = sessions_directory(environment, home)
|
||||
directory_fd = _open_sessions_directory(directory)
|
||||
filename = _snapshot_filename(normalized)
|
||||
try:
|
||||
with _sessions_lock(directory_fd, exclusive=True):
|
||||
_target_is_safe(directory_fd, filename)
|
||||
try:
|
||||
os.unlink(filename, dir_fd=directory_fd)
|
||||
except FileNotFoundError as exc:
|
||||
raise SnapshotError(f"session introuvable: {normalized}") from exc
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Capture read-only d'un Session Snapshot V1 Sway")
|
||||
parser = argparse.ArgumentParser(description="Sauvegarde persistante read-only d'une session Sway")
|
||||
parser.add_argument("--stdout", action="store_true", help="émettre le JSON validé sur stdout")
|
||||
parser.add_argument("--compact", action="store_true", help="JSON compact (avec --stdout)")
|
||||
parser.add_argument("--compact", action="store_true", help="émettre un JSON compact")
|
||||
parser.add_argument(
|
||||
"--autostart",
|
||||
type=Path,
|
||||
default=Path.home() / ".config/sway/autostart",
|
||||
help="fichier Sway autostart utilisé seulement pour la classification",
|
||||
)
|
||||
parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete"))
|
||||
parser.add_argument("name", nargs="?")
|
||||
args = parser.parse_args(argv)
|
||||
if not args.stdout:
|
||||
parser.error("STEP18A autorise uniquement --stdout")
|
||||
started = time.monotonic()
|
||||
if args.stdout and args.command is not None:
|
||||
parser.error("--stdout ne peut pas être combiné avec une commande persistante")
|
||||
if not args.stdout and args.command is None:
|
||||
parser.error("utiliser --stdout ou une commande save/list/show/delete")
|
||||
if args.command in {"save", "show", "delete"} and args.name is None:
|
||||
parser.error(f"{args.command} requiert un nom de session")
|
||||
if (args.stdout or args.command == "list") and args.name is not None:
|
||||
parser.error("nom de session inattendu")
|
||||
try:
|
||||
snapshot = collect(args.autostart)
|
||||
if args.stdout:
|
||||
started = time.monotonic()
|
||||
result: Any = collect(args.autostart)
|
||||
result["diagnostics"]["collection_duration_ms"] = round((time.monotonic() - started) * 1000, 3)
|
||||
validate_snapshot(result)
|
||||
elif args.command == "save":
|
||||
snapshot = save_session(args.name, args.autostart)
|
||||
result = {
|
||||
"name": snapshot["metadata"]["session_name"],
|
||||
"created_at": snapshot["metadata"]["created_at"],
|
||||
"updated_at": snapshot["metadata"]["updated_at"],
|
||||
"windows": len(snapshot["windows"]),
|
||||
"workspaces": len(snapshot["workspaces"]),
|
||||
}
|
||||
elif args.command == "list":
|
||||
result, warnings = list_sessions()
|
||||
for warning in warnings:
|
||||
print(warning, file=sys.stderr)
|
||||
elif args.command == "show":
|
||||
result = show_session(args.name)
|
||||
else:
|
||||
delete_session(args.name)
|
||||
result = {"deleted": args.name}
|
||||
except SnapshotError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
snapshot["diagnostics"]["collection_duration_ms"] = round((time.monotonic() - started) * 1000, 3)
|
||||
validate_snapshot(snapshot)
|
||||
if args.compact:
|
||||
json.dump(snapshot, sys.stdout, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
else:
|
||||
json.dump(snapshot, sys.stdout, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
json.dump(result, sys.stdout, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
"""Contrat Session Snapshot V1 sur arbres Sway synthétiques."""
|
||||
|
||||
import copy
|
||||
import concurrent.futures
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
|
@ -274,5 +279,265 @@ class SessionSnapshotTest(unittest.TestCase):
|
|||
self.assertNotIn("environment", serialized_keys)
|
||||
|
||||
|
||||
class PersistentSessionTest(unittest.TestCase):
|
||||
"""Contrats STEP18B dans un XDG state isolé du compte utilisateur."""
|
||||
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.state = self.root / "state"
|
||||
self.environment = {"XDG_STATE_HOME": str(self.state)}
|
||||
version, outputs, workspaces, tree, entries = fixture()
|
||||
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
|
||||
self.snapshot = snapshot_module.build_snapshot(
|
||||
version,
|
||||
outputs,
|
||||
workspaces,
|
||||
tree,
|
||||
captured_at="2026-01-01T00:00:00.000Z",
|
||||
environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"},
|
||||
desktop_entries=entries,
|
||||
managed_autostart={"float-app"},
|
||||
)
|
||||
|
||||
def collector(self, _autostart):
|
||||
return copy.deepcopy(self.snapshot)
|
||||
|
||||
def save(self, name="dev", **kwargs):
|
||||
return snapshot_module.save_session(
|
||||
name,
|
||||
environment=self.environment,
|
||||
collector=self.collector,
|
||||
retry_delay=0,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def sessions(self):
|
||||
return self.state / "labfy-sway/sessions"
|
||||
|
||||
def write_raw(self, name, value=None, payload=None):
|
||||
snapshot_module.sessions_directory(self.environment)
|
||||
path = self.sessions / f"{name}.json"
|
||||
if payload is None:
|
||||
payload = json.dumps(value).encode("utf-8")
|
||||
path.write_bytes(payload)
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
def test_xdg_path_fallback_and_private_directory_permissions(self):
|
||||
path = snapshot_module.sessions_directory(self.environment)
|
||||
self.assertEqual(path, self.sessions)
|
||||
self.assertEqual(stat.S_IMODE((self.state / "labfy-sway").stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700)
|
||||
fallback = snapshot_module.sessions_directory({}, self.root / "home")
|
||||
self.assertEqual(fallback, self.root / "home/.local/state/labfy-sway/sessions")
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.sessions_directory({"XDG_STATE_HOME": "relative"}, self.root)
|
||||
|
||||
def test_safe_and_invalid_names_prevent_path_traversal(self):
|
||||
for name in ("default", "Dev_01", "osint-prod", "A" * 64):
|
||||
self.assertEqual(snapshot_module.normalize_session_name(name), name)
|
||||
for name in ("", "..", ".", "a/b", "../dev", "a b", "é", "A" * 65, "nul\0name"):
|
||||
with self.subTest(name=repr(name)):
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.normalize_session_name(name)
|
||||
|
||||
def test_atomic_first_save_permissions_and_persistent_metadata(self):
|
||||
previous_umask = os.umask(0)
|
||||
try:
|
||||
saved = self.save()
|
||||
finally:
|
||||
os.umask(previous_umask)
|
||||
path = self.sessions / "dev.json"
|
||||
self.assertTrue(path.is_file())
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(self.sessions.stat().st_mode), 0o700)
|
||||
self.assertEqual(saved["schema"], snapshot_module.SCHEMA)
|
||||
self.assertEqual(saved["version"], 1)
|
||||
self.assertEqual(saved["metadata"]["session_name"], "dev")
|
||||
self.assertEqual(saved["metadata"]["created_at"], saved["metadata"]["updated_at"])
|
||||
self.assertFalse(list(self.sessions.glob("*.tmp")))
|
||||
|
||||
def test_atomic_update_preserves_created_at_and_advances_updated_at(self):
|
||||
first = self.save()
|
||||
first_bytes = (self.sessions / "dev.json").read_bytes()
|
||||
self.snapshot["metadata"]["captured_at"] = "2026-01-01T00:00:01.000Z"
|
||||
second = self.save()
|
||||
self.assertEqual(second["metadata"]["created_at"], first["metadata"]["created_at"])
|
||||
self.assertGreater(second["metadata"]["updated_at"], first["metadata"]["updated_at"])
|
||||
self.assertNotEqual(second["metadata"]["captured_at"], first["metadata"]["captured_at"])
|
||||
self.assertNotEqual((self.sessions / "dev.json").read_bytes(), first_bytes)
|
||||
self.assertEqual([path.name for path in self.sessions.glob("*.json")], ["dev.json"])
|
||||
|
||||
def test_list_show_delete_round_trip(self):
|
||||
saved = self.save("trainlog")
|
||||
listed, warnings = snapshot_module.list_sessions(environment=self.environment)
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(listed, [{
|
||||
"name": "trainlog",
|
||||
"updated_at": saved["metadata"]["updated_at"],
|
||||
"windows": 7,
|
||||
"workspaces": 1,
|
||||
}])
|
||||
shown = snapshot_module.show_session("trainlog", environment=self.environment)
|
||||
self.assertEqual(shown, saved)
|
||||
snapshot_module.delete_session("trainlog", environment=self.environment)
|
||||
self.assertFalse((self.sessions / "trainlog.json").exists())
|
||||
self.assertTrue(self.sessions.is_dir())
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.show_session("trainlog", environment=self.environment)
|
||||
|
||||
def test_corrupt_invalid_relation_and_unknown_files_are_ignored_by_list(self):
|
||||
self.save("valid")
|
||||
self.write_raw("truncated", payload=b'{"schema":')
|
||||
invalid_relation = copy.deepcopy(snapshot_module.show_session("valid", environment=self.environment))
|
||||
invalid_relation["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent"
|
||||
self.write_raw("relation", invalid_relation)
|
||||
unknown_schema = copy.deepcopy(invalid_relation)
|
||||
unknown_schema["schema"] = "unknown"
|
||||
self.write_raw("schema", unknown_schema)
|
||||
listed, warnings = snapshot_module.list_sessions(environment=self.environment)
|
||||
self.assertEqual([item["name"] for item in listed], ["valid"])
|
||||
self.assertEqual(len(warnings), 3)
|
||||
for name in ("truncated", "relation", "schema"):
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.show_session(name, environment=self.environment)
|
||||
|
||||
def test_unknown_version_has_explicit_error(self):
|
||||
snapshot = self.save("future")
|
||||
snapshot["version"] = 99
|
||||
self.write_raw("future", snapshot)
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "unsupported snapshot version"):
|
||||
snapshot_module.show_session("future", environment=self.environment)
|
||||
|
||||
def test_oversized_snapshot_is_rejected_before_json_loading(self):
|
||||
self.write_raw("huge", payload=b"{" + b" " * snapshot_module.MAX_SNAPSHOT_BYTES)
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "trop volumineux"):
|
||||
snapshot_module.show_session("huge", environment=self.environment)
|
||||
|
||||
def test_explicit_save_repairs_corrupt_regular_target(self):
|
||||
self.write_raw("repair", payload=b"not-json")
|
||||
saved = self.save("repair")
|
||||
self.assertEqual(snapshot_module.show_session("repair", environment=self.environment), saved)
|
||||
|
||||
def test_symlink_target_is_refused_for_save_show_and_delete(self):
|
||||
snapshot_module.sessions_directory(self.environment)
|
||||
external = self.root / "external"
|
||||
external.write_text("do not touch", encoding="utf-8")
|
||||
(self.sessions / "evil.json").symlink_to(external)
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"):
|
||||
self.save("evil")
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.show_session("evil", environment=self.environment)
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"):
|
||||
snapshot_module.delete_session("evil", environment=self.environment)
|
||||
self.assertEqual(external.read_text(encoding="utf-8"), "do not touch")
|
||||
|
||||
def test_managed_directory_symlink_is_refused(self):
|
||||
self.state.mkdir()
|
||||
external = self.root / "outside"
|
||||
external.mkdir()
|
||||
(self.state / "labfy-sway").symlink_to(external, target_is_directory=True)
|
||||
with self.assertRaises(snapshot_module.SnapshotError):
|
||||
snapshot_module.sessions_directory(self.environment)
|
||||
|
||||
def test_interrupted_update_keeps_old_snapshot_and_cleans_temporary(self):
|
||||
self.save()
|
||||
path = self.sessions / "dev.json"
|
||||
before = path.read_bytes()
|
||||
|
||||
def interrupt(_temporary):
|
||||
raise snapshot_module.SnapshotError("interruption simulée avant rename")
|
||||
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "interruption simulée"):
|
||||
self.save(before_replace=interrupt)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
self.assertEqual(snapshot_module.show_session("dev", environment=self.environment)["schema"], snapshot_module.SCHEMA)
|
||||
self.assertFalse(list(self.sessions.glob("*.tmp")))
|
||||
|
||||
def test_concurrent_saves_are_serialized_and_valid(self):
|
||||
def operation(_index):
|
||||
return self.save()["metadata"]["updated_at"]
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
timestamps = list(executor.map(operation, range(12)))
|
||||
shown = snapshot_module.show_session("dev", environment=self.environment)
|
||||
self.assertEqual(shown["metadata"]["created_at"], min(timestamps))
|
||||
self.assertEqual(shown["metadata"]["updated_at"], max(timestamps))
|
||||
self.assertFalse(list(self.sessions.glob("*.tmp")))
|
||||
|
||||
def test_retry_inconsistent_ipc_then_success(self):
|
||||
calls = 0
|
||||
|
||||
def flaky(_autostart):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC")
|
||||
return self.collector(None)
|
||||
|
||||
saved = snapshot_module.save_session(
|
||||
"retry",
|
||||
environment=self.environment,
|
||||
collector=flaky,
|
||||
retry_delay=0,
|
||||
)
|
||||
self.assertEqual(calls, 3)
|
||||
self.assertEqual(saved["metadata"]["session_name"], "retry")
|
||||
|
||||
def test_retry_exhaustion_keeps_previous_snapshot(self):
|
||||
self.save("stable")
|
||||
path = self.sessions / "stable.json"
|
||||
before = path.read_bytes()
|
||||
calls = 0
|
||||
|
||||
def inconsistent(_autostart):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC")
|
||||
|
||||
with self.assertRaisesRegex(snapshot_module.SnapshotError, "SAVE_FAILED_INCONSISTENT_STATE"):
|
||||
snapshot_module.save_session(
|
||||
"stable",
|
||||
environment=self.environment,
|
||||
collector=inconsistent,
|
||||
retry_delay=0,
|
||||
)
|
||||
self.assertEqual(calls, 3)
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
def test_structural_equivalence_excludes_persistence_timestamps_and_titles(self):
|
||||
first = self.save()
|
||||
second = copy.deepcopy(first)
|
||||
second["metadata"]["created_at"] = "2030-01-01T00:00:00.000Z"
|
||||
second["metadata"]["updated_at"] = "2030-01-01T00:00:01.000Z"
|
||||
second["metadata"]["captured_at"] = "2030-01-01T00:00:02.000Z"
|
||||
second["windows"][0]["title_hint"] = "volatile"
|
||||
self.assertEqual(
|
||||
snapshot_module.structural_projection(first),
|
||||
snapshot_module.structural_projection(second),
|
||||
)
|
||||
|
||||
def test_cli_save_list_show_delete(self):
|
||||
outputs = []
|
||||
with mock.patch.dict(os.environ, self.environment, clear=False), mock.patch.object(
|
||||
snapshot_module, "collect", side_effect=lambda _path: self.collector(None)
|
||||
):
|
||||
for arguments in (("save", "cli"), ("list",), ("show", "cli"), ("delete", "cli")):
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
|
||||
self.assertEqual(snapshot_module.main(arguments), 0)
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
outputs.append(json.loads(stdout.getvalue()))
|
||||
self.assertEqual(outputs[0]["name"], "cli")
|
||||
self.assertEqual(outputs[1][0]["name"], "cli")
|
||||
self.assertEqual(outputs[2]["metadata"]["session_name"], "cli")
|
||||
self.assertEqual(outputs[3], {"deleted": "cli"})
|
||||
self.assertFalse((self.sessions / "cli.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue