feat(session-v2): gate restoration on GNOME Keyring readiness

This commit is contained in:
fy59 2026-10-22 08:52:38 +02:00
parent d16aa69f9a
commit a556cd56b7
15 changed files with 1097 additions and 12 deletions

View file

@ -338,3 +338,18 @@ sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le log
Validation et limites détaillées :
[rapport scratchpad](session-v2-scratchpad-save-and-restore-v1.md).
## Précondition Secret Service avant restauration — 22 octobre 2026
Le parcours utilisateur ajoute une barrière D-Bus indépendante du checkpoint.
Les CLI `apply-last` et `apply` exigent la collection default attendue réellement
déverrouillée ; le chooser propose son déverrouillage officiel et conserve le
clic explicite Restaurer après READY. Nouvelle session reste disponible sans
trousseau. Cette précondition ne modifie aucune postcondition de l'Executor,
aucun schéma de checkpoint/Recovery et ne résout pas automatiquement un
`RECOVERY_REQUIRED`. Une activation tardive garde les refus de focus existants.
Implémentation, audit PAM (aucun patch applicable établi), tests isolés,
installation et rollback :
[trousseau avant restauration](session-v2-keyring-before-restore-v1.md).

View file

@ -0,0 +1,291 @@
# Session V2 — trousseau avant restauration (22 octobre 2026)
État : implémentation et validation isolées ; **non déployé**, vrai login non
validé. Base `d16aa69f`, branche `fix/session-v2-keyring-before-restore-20261022`.
Le dépôt principal était propre ; aucun autre worktree n'a été modifié.
## Diagnostic et preuves disponibles
À 08:01:35 CEST, systemd démarre GNOME Keyring ; QuickShell démarre à
08:01:38. À 08:01:48 le prompter reçoit BeginPrompting, crée GcrPromptDialog,
puis commence un password prompt. L'incident fourni situe le démarrage du
prompt à 08:01:48.235 et le rapport d'échec à 08:01:48.482. Le focus attendu
Firefox/con_id 10 est remplacé par con_id 18 après les six frontières du guard.
Ces événements concordent avec une activation du trousseau pendant la fenêtre
de validation finale. **L'identité historique de con_id 18 et la causalité ne
sont pas démontrées.** Aucune capture d'arbre contemporaine avec PID/starttime
ne permet ici de les établir. Même limite pour l'occurrence de 06:11:59.
Les abonnements IPC Sway indiquent la fenêtre devenue active, pas l'origine
(user input ou commande d'un autre client IPC) d'un changement de focus. Un
app_id seul n'est pas une preuve de causalité. Une future investigation peut
corréler les événements fenêtre/workspace horodatés, con_id, PID/starttime et
les événements du prompter ; elle doit laisser « non attribué » ce qui le reste.
Ne pas activer de trace d'échange secret pour ce diagnostic.
## Audit de l'installation réelle (lecture seule)
- `/etc/pam.d/greetd` : securetty, nologin, puis inclusions auth/account/session
de system-local-login. Aucune règle gnome-keyring. SHA-256 observé :
`993a3096c2b113e6800f2abbd5d4233ebf1a97eef423990d3187d665d3490b92`.
- Inclusions : system-local-login → system-login → system-auth. pam_unix utilise
try_first_pass ; pam_systemd est dans la chaîne session avant le retour à
greetd. Les sauts numériques de system-auth ne sautent pas une règle ajoutée
après l'inclusion dans greetd. Rien de cette pile n'est modifié.
- Le greeter installé dans `/usr/local/libexec/labfy-greeter` répond aux messages
PAM avec post_auth_message_response, sur la même conversation greetd. Il ne
réauthentifie pas le mot de passe localement. Le code installé est antérieur
au sélecteur de session du dépôt mais possède ce même transport des réponses.
- Module présent : `/usr/lib/security/pam_gnome_keyring.so`, paquet
gnome-keyring `1:51.1-1`, greetd `0.10.3-2`, python-gobject `3.58.1-1`.
- Secret Service appartient à gnome-keyring-daemon. Un processus observé :
`gnome-keyring-daemon --foreground --components=pkcs11,secrets,ssh`.
ReadAlias(default) renvoie `/org/freedesktop/secrets/collection/Default_5fkeyring` ;
ReadAlias(login) renvoie `/`. Locked est false lors de la relecture finale
(cet état courant ne prouve pas son état pendant l’incident). Aucun contenu des fichiers du trousseau n'a été lu.
Les noms présents sont Default_keyring.keyring, default et user.keystore.
- L'unité utilisateur masque celle du paquet. Elle omet Requires=socket et le
`--control-directory=%t/keyring` explicite de l'unité distribuée. Son lien
d'activation est dans default.target.wants, même si Install mentionne
graphical-session.target. Le socket `%t/keyring/control` est actif.
- D-Bus possède aussi une entrée d'activation `--start --foreground
--components=secrets`. Les fichiers XDG autostart du paquet utilisent --start
et OnlyShowIn=GNOME;Unity;MATE;Cinnamon. Ils ne démontrent donc pas un second
lancement sous Sway. Aucun autre lancement n'a été trouvé dans les sources
Sway/QuickShell/greetd ; **une seule instance observée ne prouve pas l'absence
de toute course de démarrage**.
### Décision PAM : pas de patch applicable dans cette mission
L'intégration habituelle place auth optional pam_gnome_keyring.so après
l'authentification principale et session optional pam_gnome_keyring.so
auto_start après l'inclusion de session (donc après pam_systemd). Le transport
du greeter et PAM_AUTHTOK sont structurellement compatibles avec ce schéma.
Cela n'établit pas que Default_keyring soit le trousseau login, ni que son mot
de passe soit identique à celui du compte. Ces faits ne sont pas testés avec
un mot de passe dans cette mission.
Le module GNOME cible le trousseau **login**, identifié en interne par CKA_ID.
Son chemin de déverrouillage peut le créer s'il manque. L'alias login est absent,
aucun fichier login n'est listé, et le rôle interne de Default_keyring n'est pas
prouvé. Activer auto_start pourrait donc provoquer une création et ne démontre
pas le déverrouillage du default existant. Ce mécanisme est vérifié dans le
[code GNOME Keyring 51.1, unlock_or_create_login](https://raw.githubusercontent.com/GNOME/gnome-keyring/51.1/daemon/login/gkd-login.c).
La [documentation PAM GNOME](https://wiki.gnome.org/Projects/GnomeKeyring/Pam)
décrit également la cible login et les règles usuelles. **Aucun fichier PAM candidat à installer
n'est livré**, aucun essai PAM réel n'est effectué. Une validation statique de
la syntaxe de deux règles ne validerait pas ces conditions opérationnelles.
Le contrôle optional protège normalement la décision d'authentification
principale contre un échec du module ; il ne garantit pas à lui seul l'absence
d'attente ni d'effets de bord. Ne pas prendre cette analyse pour une preuve
qu'un changement PAM quelconque est sans risque.
Une future tranche PAM exige une autorisation distincte, la confirmation
humaine du trousseau cible sans enregistrer le mot de passe, et une étude de
l'unité/socket du paquet comme propriétaire unique du démon. Si un patch
compatible est alors établi, l'application privilégiée doit sauvegarder le
fichier root:root/0644, contrôler le hash de départ, installer atomiquement la
copie validée et conserver un accès de secours hors greeter. Le rollback doit
réinstaller exactement la sauvegarde, sans modifier les inclusions système.
Aucune de ces opérations ni aucun logout/reboot n'a été exécuté ici.
Aucune modification de l'unité utilisateur n'est nécessaire à la barrière
D-Bus. La normalisation de cette unité avec une future intégration PAM doit
être validée au login ; elle n'est pas appliquée sur le bureau actif.
## Contrat de la barrière
`session_v2/keyring.py` utilise PyGObject/Gio et une connexion D-Bus privée :
1. Résoudre/activer Secret Service avec une attente bornée, puis fixer son
propriétaire D-Bus unique pour toutes les opérations.
2. ReadAlias(default) doit correspondre à la collection auditée ci-dessus.
Default absent/différent est un refus explicite. Si login existe, il doit
désigner la même collection. Login absent autorise le parcours manuel ; il
ne vaut jamais preuve de compatibilité PAM. Une installation utilisant une
autre collection doit faire réauditer cette constante avant intégration.
3. Lire org.freedesktop.Secret.Collection.Locked par Properties.Get. READY
exige un vrai booléen false, avec propriétaire inchangé.
4. Un clic Déverrouiller appelle Service.Unlock. S'il retourne un Prompt,
s'abonner à Completed **avant** Prompt(""). Le window-id vide présente un
prompt non parenté, sans prétendre à un handle X11/Wayland inexistant.
5. Garder la connexion vivante, attendre Completed non annulé, puis relire
alias/propriétaire/Locked. Une fin de prompt avec Locked=true refuse READY.
6. Limites : connexion/auth D-Bus 2 s, appels individuels au plus 2 s, budget
de lecture 8 s, opération interactive 90 s, nettoyage Dismiss au plus 1 s,
puis relecture avec budget 8 s. Un timeout n'autorise rien. Un mot de passe
incorrect peut maintenir le prompt GNOME ouvert jusqu'à correction,
annulation ou timeout ; le client n'essaie jamais de deviner cette cause.
7. Dismiss/fermeture de la connexion ne concernent que le prompt possédé. Le
traitement SIGTERM réveille la boucle GLib et permet ce nettoyage. Un arrêt
brutal du processus ferme sa connexion ; il ne laisse aucune autorisation
persistante. Aucun fichier READY/token n'est produit.
Les commandes `keyring-status` et `keyring-unlock --execute` retournent
uniquement un état KEYRING_* et un code de diagnostic fixe. Aucun OpenSession,
GetSecret(s), SearchItems, changement de mot de passe, création de collection
ou lecture de fichier de trousseau n'est utilisé. Les erreurs D-Bus libres et
le payload de résultat de Completed ne sont pas journalisés.
### Concurrence et transactions
Le verrou privé `session-v2/keyring.lock` sérialise les helpers entre processus.
Une seconde demande voit KEYRING_UNLOCKING/KEYRING_OR_RESTORE_BUSY et ne crée
pas un second prompt. Avant Unlock, le helper vérifie aussi le verrou de
transaction existant (y compris celui hérité par un guard survivant).
Les CLI `apply-last` **et** `apply` prennent keyring.lock, relisent D-Bus sans
ouvrir de prompt, puis appellent l'Executor inchangé en gardant le verrou
jusqu'à son retour. Ordre : keyring.lock puis transaction.lock. Aucune voie
CLI de restauration n'est autorisée par le seul booléen du QML. Un refus de
barrière ne publie aucun Attempt/Recovery et ne nettoie aucun ancien journal.
Les appels internes directs à Executor restent une API de moteur ; ils ne
constituent pas une commande utilisateur de restauration.
La décision « Nouvelle session » conserve uniquement le verrou de transaction
historique. Elle reste donc disponible pendant une attente du trousseau.
QuickShell arrête son helper lors de ce choix. Un autre helper déjà en cours
n'est pas tué par une instance qui ne le possède pas.
### QuickShell
Le singleton démarre sa lecture indépendamment du checkpoint. Le chooser
reconstruit aussi l'état backend au redémarrage ; un déverrouillage détenu par
une autre instance est observé par polling borné des commandes, jamais relancé.
Les erreurs et annulations proposent une nouvelle action explicite. Le panneau
retire sa capture clavier avant le prompt natif ; il conserve le bouton
Nouvelle session. Après READY, seul un clic Restaurer appelle le chemin
historique de retrait de surface, prewarm, Guard et validation finale.
Le gestionnaire de sessions nommées reçoit également le refus du backend et
l'affiche sans charger un ancien rapport d'échec. Les commandes de diagnostic,
checkpoint, sauvegarde, nouvelle session et ouverture normale du bureau ne
dépendent pas d'un trousseau disponible.
## Limite tardive et invariants conservés
Secret Service peut reverrouiller une collection à tout instant. Le verrou de
la barrière ne contrôle pas les autres applications, un autre trousseau ou
un autre client Secret Service. Il ne garantit donc pas qu'aucun prompt ne
pourra jamais apparaître après l'admission. Un prompt déjà demandé par un
client externe n'est pas notre objet Prompt et ne peut être annulé arbitrairement.
La [spécification Secret Service](https://specifications.freedesktop.org/secret-service/latest/unlocking.html) précise cette course inhérente.
L'initialisation du default nécessaire à notre parcours est traitée avant la
restauration. Si une activation tardive interfère néanmoins, les mêmes preuves
con_id/PID/starttime/workspace/output et les mêmes postconditions de focus
refusent la transaction. **Aucun focus observé n'est remplacé pour fabriquer
un succès.** L'expérience headless crée une surface synthétique après
`after-guard-finish` et exige RECOVERY_FOCUS_MISMATCH, final_focus_verified=false
et RECOVERY_REQUIRED. Elle ne prétend pas identifier le dialogue de 08:01:48.
Checkpoint V1, scratchpad V2, Guard.prepare, journaux et schémas Recovery,
placement et validations de focus ne sont pas modifiés. Les tests qui isolent
le prewarm simulent explicitement la nouvelle précondition ; les nouveaux tests
D-Bus et UI testent sa vraie implémentation séparément.
## Reproduction de la validation
Depuis ce worktree :
```sh
python -B tests/session_v2/validate_keyring.py --sway /chemin/absolu/vers/sway-headless
```
Dépendances locales : Python/PyGObject/Gio, dbus-daemon, QuickShell/Qt/PySide6,
Sway pouvant démarrer headless avec pixman, outils GTK/compilateur déjà exigés
par les fixtures Session V2, kitty et grim. Aucun paquet système n'a été
installé. Le SwayFX installé ne convient pas au laboratoire sans DRM ; le
binaire Sway de test conservé par la mission précédente a été réutilisé :
`/tmp/labfy-scratchpad-tool/usr/bin/sway`. Son paquet avait le SHA-256
`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
Le runner impose une liste positive d'environnement, HOME et XDG privés.
Chaque test graphique réel crée son propre compositeur ; chaque service simulé
possède son bus privé. Aucun socket du bureau ni état de session utilisateur
n'est transmis. Les tests QML unitaires emploient des surfaces Item simulées et
Qt offscreen ; ils ne mappent aucune fenêtre sur un bureau réel.
La matrice couvre les 20 cas demandés : déverrouillé ; verrouillé/Prompt ;
accepté avec relecture ; annulé ; mot de passe incorrect simulé par l'absence de
fin ; fin sans déverrouillage ; timeout RPC et prompt ; service absent ; default
absent/différent et login incompatible ; redémarrage helper et vrai QuickShell ;
deux demandes concurrentes ; refus apply-last ; absence d'autorestauration ;
nouvelle session ; surface tardive ; focus final stable ; RECOVERY_REQUIRED ;
régressions scratchpad/backend/focus ; erreurs contenant une sentinelle sensible
exclue des sorties et fichiers ; login sans aide PAM. Les scénarios de mot de
passe sont simulés : aucun mot de passe réel ou d'essai n'est manipulé.
Validation finale : les onze lots réussissent (93 tests backend, 29 tests de
focus, 15 tests QML unitaires, 6 résultats Qt dont initialisation/nettoyage,
8 scénarios UI de trousseau, 2 scénarios moteur stable/tardif, recette scratchpad,
7 scénarios UI V2 existants, 5 scénarios P0 et 5 de stabilité des chemins).
Résultats et logs : `/tmp/labfy-keyring-validation-y8xyqh17/results.json` et ses
fichiers `.log`. Les chemins des laboratoires/surfaces synthétiques sont dans
ces logs. Après revue, les huit tests spécifiques D-Bus ont également été
repassés avec activation des services système désactivée dans le bus simulé :
`/tmp/labfy-keyring-privatebus-final.log`. Le test d'absence affirme NameHasOwner=false. Les avertissements PyGObject/Python de dépréciation ne sont pas des
erreurs ; aucune sortie ne contient de secret. Le runner est livré pour
reproduire les résultats si `/tmp` est nettoyé.
## Installation contrôlée et rollback
**Aucune installation n'a eu lieu.** Les fichiers de la configuration active
sont reliés aux sources du dépôt principal. Les modifier pendant que QuickShell
les surveille pourrait provoquer un rechargement. Ne pas appliquer le patch
sur ces sources pendant la session graphique réelle.
Après revue et autorisation humaine de l'intégration et de la fermeture de la
session, depuis un TTY de secours sans QuickShell actif :
1. Vérifier les changements postérieurs dans `/home/fy59/.dotfiles` et vérifier
la base. Conserver une copie privée de tous les fichiers concernés et du
manifeste de hashes. Ne pas toucher aux autres worktrees.
2. Examiner le patch complet livré (il inclut les nouveaux fichiers) et faire
`git apply --check /chemin/changes.patch` depuis le dépôt principal. Un refus
impose de réconcilier les changements ; ne jamais forcer/écraser.
3. Appliquer ce patch seulement après ce contrôle. Ne faire ni commit ni push
automatiquement. Vérifier la résolution des liens de configuration existants,
et `git diff --check`. Aucune modification PAM/systemd n'est à appliquer.
4. Vérifier `python -c 'from gi.repository import Gio, GLib'`. Si la dépendance
manque, reporter l'installation ; ne pas lancer un gestionnaire de paquets
sans autorisation. L'indisponibilité est un refus de restauration, pas un
refus d'ouverture du bureau.
5. Au prochain login autorisé, suivre le scénario ci-dessous. Ne pas redémarrer
le greeter ou le bureau pour « tester » depuis la session en cours.
Rollback, également hors du QuickShell actif :
```sh
cd /home/fy59/.dotfiles
git apply --reverse --check /chemin/changes.patch
git apply --reverse /chemin/changes.patch
```
En présence de dérive, restaurer seulement les fichiers sauvegardés après
comparaison avec le manifeste et revue du delta ; ne pas faire reset/restore/
stash/clean. Les fichiers nouveaux du patch doivent être retirés uniquement
si leurs hashes correspondent encore à la livraison. Aucune donnée de
checkpoint, aucun journal Recovery et aucun trousseau ne sont supprimés ou
modifiés par l'installation/rollback. PAM et systemd restent identiques.
## Prochain vrai login — autorisation humaine requise
Ce scénario est proposé et **n'a pas été exécuté** :
1. Autoriser l'intégration et une fermeture normale de la session, avec TTY de
secours disponible ; conserver les journaux et checkpoints existants.
2. S'authentifier normalement via greetd. Sans PAM ajouté, un default verrouillé
doit afficher Déverrouiller et Nouvelle session, sans restauration possible.
3. Déverrouiller dans le seul dialogue GNOME. N'enregistrer aucun secret et
n'activer aucune option créant/modifiant des trousseaux. Vérifier READY et
l'absence de lancement automatique ; cliquer ensuite explicitement Restaurer.
4. Après retrait du chooser, vérifier le rapport final : succès seulement si
arbre **et** focus sont validés, slots et nettoyage complets. Observer le
scratchpad sans corriger un focus inattendu pour rendre le test positif.
5. Lors d'une autre connexion autorisée, tester annulation et Nouvelle session.
Une panne du service doit laisser ouvrir le bureau et refuser la restauration.
6. Si un prompt tardif apparaît, conserver l'échec et RECOVERY_REQUIRED,
horodatages et identités observables. Ne jamais effacer le journal pour
relancer artificiellement. Une trace de focus seule ne prouve pas l'auteur.

View file

@ -28,8 +28,31 @@ PanelWindow {
property int windowCount: 0
property int workspaceCount: 0
readonly property bool busy: SessionV2Service.busy || watchingBackend
property bool unlockingRequested: false
readonly property bool keyringReady: SessionV2Service.keyringReady
function unlockRequested() {
if (!interactionArmed || busy || SessionV2Service.keyringBusy || unlockingRequested) return;
// CONTRACT: release exclusive keyboard before presenting the native
// prompt. Keep New session clickable throughout the unlock operation.
unlockingRequested = true;
unlockRelease.start();
}
Timer {
id: unlockRelease
interval: 50; repeat: true
onTriggered: {
if (popup.focusable) return;
stop(); popup.unlockingRequested = false;
SessionV2Service.checkKeyring(true);
}
}
Timer {
interval: 2000; repeat: true
running: popup.visible && SessionV2Service.keyringState === "KEYRING_UNLOCKING" && !popup.releasing && !popup.unlockingRequested
onTriggered: SessionV2Service.checkKeyring(false)
}
function restoreRequested() {
if (!interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
if (!keyringReady || !interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
if (!SessionV2Service.prepareRestore(popup)) return;
attempted = true;
failed = false;
@ -44,7 +67,11 @@ PanelWindow {
releasePoll.start();
}
function newSessionRequested() {
if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
if (interactionArmed && !busy) {
unlockRelease.stop(); unlockingRequested = false;
SessionV2Service.cancelKeyring();
SessionV2Service.run("ack-new", "", "", popup);
}
}
function releaseFailed() {
releasePoll.stop();
@ -59,6 +86,12 @@ PanelWindow {
}
function fail(value) {
inProgress = false;
if (value && /^KEYRING_/.test(value.reason)) {
// Admission failed before Executor: preserve the explicit retry
// path without fabricating a restore attempt or clearing Recovery.
attempted = false; failed = false; restored = false;
visible = true; SessionV2Service.checkKeyring(false); return;
}
failed = true;
restored = false;
retryAllowed = !!value && value.status === "DEFERRED";
@ -138,6 +171,7 @@ PanelWindow {
SessionV2Service.run("restore-status", "", "", popup);
}
if (popup.visible) {
SessionV2Service.checkKeyring(false);
popup.windowCount = value.windows || 0;
popup.workspaceCount = value.workspaces || 0;
}
@ -167,7 +201,7 @@ PanelWindow {
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
focusable: visible && !awaitingUserFocus && !releasing && !inProgress && !restored
focusable: visible && !unlockingRequested && !SessionV2Service.keyringBusy && SessionV2Service.keyringState !== "KEYRING_UNLOCKING" && !awaitingUserFocus && !releasing && !inProgress && !restored
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "labfy-session-startup-v2"
visible: false
@ -224,7 +258,11 @@ PanelWindow {
text: popup.failed ? popup.failureMessage
: popup.restored ? "Session restaurée"
: popup.busy && popup.attempted ? "Restauration en cours…"
: popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
: popup.keyringReady ? "Une session précédente est disponible."
: SessionV2Service.keyringState === "KEYRING_CANCELLED" ? "Déverrouillage annulé. Réessayez ou choisissez Nouvelle session."
: SessionV2Service.keyringState === "KEYRING_UNAVAILABLE" ? "Trousseau indisponible. Réessayez ou choisissez Nouvelle session. (" + SessionV2Service.keyringReason + ")"
: SessionV2Service.keyringState === "KEYRING_UNLOCKING" ? "Déverrouillage du trousseau en cours…"
: "Le trousseau doit être déverrouillé avant la restauration de votre session."
color: Theme.foreground
wrapMode: Text.Wrap
}
@ -250,9 +288,15 @@ PanelWindow {
onClicked: popup.newSessionRequested()
}
ActionButton {
visible: !popup.failed || popup.retryAllowed
visible: !popup.keyringReady
label: "Déverrouiller le trousseau"
enabled: popup.interactionArmed && !popup.busy && !SessionV2Service.keyringBusy && !popup.unlockingRequested
onClicked: popup.unlockRequested()
}
ActionButton {
visible: popup.keyringReady && (!popup.failed || popup.retryAllowed)
label: popup.retryAllowed ? "Réessayer" : "Restaurer"
enabled: popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
enabled: popup.keyringReady && popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
onClicked: popup.restoreRequested()
}
}

View file

@ -23,6 +23,12 @@ Item {
if (owner !== page) return;
if (kind === "list") { if (okay) page.sessions = value.sessions; return; }
if (kind === "apply") {
if (value && /^KEYRING_/.test(value.reason)) {
// Admission precedes Attempt: never show an older report
// as the explanation for a refused keyring precondition.
page.feedback = "Déverrouillez le trousseau, puis cliquez à nouveau Restaurer. (" + value.reason + ")";
return;
}
if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée";
else {
page.restoreFailed = true;

View file

@ -56,7 +56,7 @@ def migration(path):
def main(argv=None):
parser = argparse.ArgumentParser(description="Backend de restauration de session V2")
parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
parser.add_argument("command", choices=("keyring-status", "keyring-unlock", "capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
parser.add_argument("name", nargs="?")
parser.add_argument("--execute", action="store_true")
parser.add_argument("--stdout", action="store_true")
@ -67,13 +67,18 @@ def main(argv=None):
parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor"))
args = parser.parse_args(argv)
try:
if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
if args.command in ("keyring-unlock", "capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
require(args.execute, "EXECUTE_REQUIRED")
if args.command == "recovery-status":
from .recovery import recovery_status
print(json.dumps(recovery_status(readonly_runtime()), ensure_ascii=False))
return 0
state, runtime = paths()
if args.command in ("keyring-status", "keyring-unlock"):
from .keyring import command as keyring_command
value = keyring_command(runtime, args.command == "keyring-unlock")
print(json.dumps(value))
return 0
store = Store(state)
command = args.command
if command == "list": result = {"sessions": store.list()}
@ -105,7 +110,13 @@ def main(argv=None):
result = data if command == "capture" else {"status": "saved"}
else:
data = store.last() if command == "apply-last" else store.load(args.name)
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
from .keyring import admission, require_ready
# WHY: QML readiness is advisory and can be stale after restart.
# INVARIANT: gate failure creates no restore/recovery attempt;
# the unchanged Executor still proves every postcondition.
with admission(runtime):
require_ready()
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
print(json.dumps(result, ensure_ascii=False))
return 0
except Failure as exc:

View file

@ -0,0 +1,198 @@
"""Secret Service readiness, without ever opening a secret session or reading items.
WHY: daemon existence and a vanished prompt do not prove collection readiness.
CONTRACT: default must be the audited collection; an existing login alias must
agree. Missing login is supported (manual unlock), never proof of PAM success.
INVARIANT: no cached readiness, passwords, item metadata or free-form D-Bus errors
are persisted/logged. A private connection owns and dismisses only its prompt.
"""
from contextlib import contextmanager
import math
import signal
import time
from .errors import Failure, require
from .storage import lock, private_dir
BUS = 'org.freedesktop.secrets'
ROOT = '/org/freedesktop/secrets'
SERVICE = 'org.freedesktop.Secret.Service'
COLLECTION = 'org.freedesktop.Secret.Collection'
PROMPT = 'org.freedesktop.Secret.Prompt'
EXPECTED = ROOT + '/collection/Default_5fkeyring'
def result(state, reason):
return {'state': 'KEYRING_' + state, 'reason': reason}
class Secrets:
def __init__(self, timeout=8):
# Lazy import keeps existing backend diagnostics usable without PyGObject.
from gi.repository import Gio, GLib, GLibUnix
self.Gio, self.GLib, self.GLibUnix = Gio, GLib, GLibUnix
self.deadline = time.monotonic() + timeout
address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SESSION, None)
# Bound connection/authentication too, not only subsequent RPC calls.
loop, cancellation, opened = GLib.MainLoop(), Gio.Cancellable(), []
expired = []
def connected(_source, response, _data):
try:
bus = Gio.DBusConnection.new_for_address_finish(response)
if expired: bus.close_sync(None)
else: opened.append(bus)
except GLib.Error: pass
loop.quit()
def expire():
expired.append(True)
cancellation.cancel()
loop.quit()
return False
timer = GLib.timeout_add(2000, expire)
try:
Gio.DBusConnection.new_for_address(address,
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None,
cancellation, connected, None)
loop.run()
finally:
if GLib.MainContext.default().find_source_by_id(timer): GLib.source_remove(timer)
require(not expired, 'KEYRING_TIMEOUT')
require(opened, 'KEYRING_BUS_UNAVAILABLE')
self.bus = opened[0]
self.owner = None
try:
# Calls after activation are pinned to a unique owner. A replacement
# service must never inherit readiness from this connection.
try: self.owner = self.get_owner()
except GLib.Error:
self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
'org.freedesktop.DBus', 'StartServiceByName', '(su)', (BUS, 0), '(u)')
self.owner = self.get_owner()
except BaseException:
self.close()
raise
def call(self, destination, path, interface, method, signature, args, reply):
remaining = self.deadline - time.monotonic()
require(remaining > 0, 'KEYRING_TIMEOUT')
return self.bus.call_sync(destination, path, interface, method,
self.GLib.Variant(signature, args) if signature else None,
self.GLib.VariantType.new(reply), self.Gio.DBusCallFlags.NO_AUTO_START,
max(1, min(2000, math.ceil(remaining * 1000))), None).unpack()
def get_owner(self):
return self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
'org.freedesktop.DBus', 'GetNameOwner', '(s)', (BUS,), '(s)')[0]
def alias(self, name):
return self.call(self.owner, ROOT, SERVICE, 'ReadAlias', '(s)', (name,), '(o)')[0]
def inspect(self):
require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
collection = self.alias('default')
require(collection != '/', 'KEYRING_DEFAULT_MISSING')
require(collection == EXPECTED, 'KEYRING_COLLECTION_MISMATCH')
login = self.alias('login')
require(login in ('/', collection), 'KEYRING_LOGIN_MISMATCH')
value = self.call(self.owner, collection, 'org.freedesktop.DBus.Properties',
'Get', '(ss)', (COLLECTION, 'Locked'), '(v)')[0]
require(type(value) is bool, 'KEYRING_INVALID_LOCKED')
require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
return value
def unlock(self, timeout=90):
self.deadline = time.monotonic() + timeout
unlocked, prompt = self.call(self.owner, ROOT, SERVICE, 'Unlock', '(ao)', ([EXPECTED],), '(aoo)')
if prompt == '/': return
loop = self.GLib.MainLoop()
completed = []
def receive(_bus, _sender, _path, _iface, _signal, parameters):
# Do not retain/inspect the operation-specific variant payload.
completed.append(parameters.get_child_value(0).get_boolean())
loop.quit()
subscription = self.bus.signal_subscribe(self.owner, PROMPT, 'Completed', prompt,
None, self.Gio.DBusSignalFlags.NONE, receive)
timer = self.GLib.timeout_add(max(1, math.ceil((self.deadline - time.monotonic()) * 1000)),
lambda: (loop.quit(), False)[1])
def cancel():
completed.append(True)
loop.quit()
return False
# GLib owns the prompt wait: Python signal handlers alone are not
# dispatched while its native loop is idle. Termination must wake it.
termination = self.GLibUnix.signal_add(self.GLib.PRIORITY_DEFAULT, signal.SIGTERM, cancel)
try:
# Empty window-id is the portable unparented prompt. QuickShell
# releases its exclusive keyboard surface before this call.
self.call(self.owner, prompt, PROMPT, 'Prompt', '(s)', ('',), '()')
if not completed: loop.run()
require(completed, 'KEYRING_TIMEOUT')
require(completed[0] is False, 'KEYRING_CANCELLED')
finally:
for source in (timer, termination):
if self.GLib.MainContext.default().find_source_by_id(source):
self.GLib.source_remove(source)
self.bus.signal_unsubscribe(subscription)
# Best effort cleanup also runs on timeout/termination. Closing the
# private connection invalidates outstanding client-owned prompts.
try:
self.deadline = time.monotonic() + 1
self.call(self.owner, prompt, PROMPT, 'Dismiss', None, (), '()')
except (Failure, self.GLib.Error): pass
def close(self):
self.bus.close_sync(None)
def check(unlock=False, factory=Secrets, timeout=90):
connection = None
try:
connection = factory()
if not connection.inspect(): return result('READY', 'KEYRING_UNLOCKED')
if not unlock: return result('LOCKED', 'KEYRING_LOCKED')
connection.unlock(timeout)
connection.deadline = time.monotonic() + 8
if connection.inspect(): return result('LOCKED', 'KEYRING_STILL_LOCKED')
return result('READY', 'KEYRING_UNLOCKED')
except Failure as exc:
return result('CANCELLED' if exc.code == 'KEYRING_CANCELLED' else 'UNAVAILABLE', exc.code)
except ImportError:
return result('UNAVAILABLE', 'KEYRING_DEPENDENCY_MISSING')
except Exception:
# Never expose remote error text; it may contain user-supplied material.
return result('UNAVAILABLE', 'KEYRING_DBUS_ERROR')
finally:
if connection is not None:
try: connection.close()
except Exception: pass
@contextmanager
def admission(runtime):
"""Serialize unlock and CLI restoration across processes/QuickShell restarts.
CONTRACT: acquire before the existing transaction lock, hold through apply.
No boolean, token or file survives as authorization for a future apply.
New-session acknowledgement uses only the existing transaction lock.
"""
with lock(private_dir(runtime / 'session-v2'), 'keyring.lock'):
yield
def command(runtime, unlock=False):
try:
with admission(runtime):
# An executor/guard predating this gate can still own the old lock.
# Check it before any Unlock; don't hold it during user interaction.
with lock(private_dir(runtime / 'session-v2')): pass
return check(unlock)
except KeyboardInterrupt:
return result('CANCELLED', 'KEYRING_CANCELLED')
except Failure as exc:
if exc.code == 'TRANSACTION_BUSY': return result('UNLOCKING', 'KEYRING_OR_RESTORE_BUSY')
raise
def require_ready():
value = check()
require(value['state'] == 'KEYRING_READY', value['reason'])

View file

@ -11,6 +11,37 @@ QtObject {
property var client: null
property bool startupChooserVisible: false
readonly property bool busy: operation !== "" || backendProcess.running
// WHY: state is reconstructed from Secret Service, never persisted in QML.
// CONTRACT: readiness only enables a click; CLI rechecks under admission.
property string keyringState: "KEYRING_CHECKING"
property string keyringReason: ""
readonly property bool keyringBusy: keyringProcess.running
readonly property bool keyringReady: keyringState === "KEYRING_READY" && !keyringBusy
function checkKeyring(unlock) {
if (keyringBusy || busy) return false;
keyringState = unlock ? "KEYRING_UNLOCKING" : "KEYRING_CHECKING";
const argv = ["python3", "-B", backend, unlock ? "keyring-unlock" : "keyring-status"];
if (unlock) argv.push("--execute");
keyringProcess.command = argv;
keyringProcess.running = true;
return true;
}
Component.onCompleted: checkKeyring(false)
function cancelKeyring() { keyringProcess.running = false; keyringState = "KEYRING_CHECKING"; }
property Process keyringProcess: Process {
running: false
stdout: StdioCollector { id: keyringOutput; waitForEnd: true }
// No remote errors or arbitrary stderr are displayed or journaled.
stderr: StdioCollector { waitForEnd: true }
onExited: (exitCode, exitStatus) => {
let value = null;
try { value = JSON.parse(keyringOutput.text); } catch (_) {}
const states = ["KEYRING_READY", "KEYRING_LOCKED", "KEYRING_CANCELLED", "KEYRING_UNAVAILABLE", "KEYRING_UNLOCKING"];
service.keyringState = exitCode === 0 && exitStatus === 0 && value && states.indexOf(value.state) >= 0
? value.state : "KEYRING_UNAVAILABLE";
service.keyringReason = value && /^KEYRING_[A-Z_]{1,48}$/.test(value.reason) ? value.reason : "KEYRING_BACKEND_ERROR";
}
}
signal completed(var owner, string kind, var value, bool okay)
// Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore.
@ -18,14 +49,14 @@ QtObject {
target: "sessionV2"
function status(): string {
return JSON.stringify({backend: "v2", busy: service.busy,
operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0});
operation: service.operation, keyringState: service.keyringState, keyringReason: service.keyringReason, startupChooserCount: service.startupChooserVisible ? 1 : 0});
}
}
// Réserver la sérialisation avant de retirer la surface. Le Timer du
// dialogue n'est pas propriétaire d'une seconde commande backend.
function prepareRestore(owner) {
if (busy || !owner) return false;
if (busy || !owner || !keyringReady) return false;
client = owner;
operation = "release-focus";
return true;
@ -46,6 +77,7 @@ QtObject {
// INVARIANT : un unique Process sérialise les commandes V2 de QuickShell.
function run(kind, name, extra, owner) {
if (busy) return false;
if (kind === "apply-last" && !keyringReady) return false;
const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"];
if (allowed.indexOf(kind) < 0) return false;
const argv = ["python3", "-B", backend];

View file

@ -60,6 +60,14 @@ QtObject {
property QtObject stdout
property QtObject stderr
signal exited(int exitCode, int exitStatus)
// This focus fixture models only an already unlocked service. Private-bus
// and headless keyring tests exercise the real transport/prompt lifecycle.
onRunningChanged: {
if (running && command.indexOf("keyring-status") >= 0) Qt.callLater(() => {
stdout.text = JSON.stringify({state:"KEYRING_READY", reason:"KEYRING_UNLOCKED"});
running = false; exited(0, 0);
});
}
}''')
(self.dir / 'sessionui/StdioCollector.qml').write_text('import QtQuick\nQtObject { property bool waitForEnd; property string text: "" }')
(self.dir / 'sessionui/IpcHandler.qml').write_text('import QtQuick\nQtObject { property string target }')
@ -85,6 +93,8 @@ Item {
self.assertIsNotNone(self.root, '\n'.join(e.toString() for e in self.component.errors()))
self.prompt = self.root.findChild(QObject, 'prompt')
self.service = self.root.property('service')
QTest.qWait(5) # finish the asynchronous fixture's initial D-Bus check
self.service.setProperty('keyringState', 'KEYRING_READY')
def tearDown(self):
self.root.deleteLater(); self.engine.deleteLater(); QTest.qWait(1); self.tmp.cleanup()
def call(self, name): QMetaObject.invokeMethod(self.prompt, name, Qt.DirectConnection)
@ -240,4 +250,24 @@ Item {
self.finish('startup-status', dict(eligible=True, claimed=True, interrupted=True))
self.assertEqual(self.calls()[-1]['kind'], 'restore-status')
def test_keyring_gate_requires_ready_and_explicit_click(self):
self.service.setProperty('keyringState', 'KEYRING_LOCKED')
self.open(); self.call('restoreRequested'); QTest.qWait(100)
self.assertFalse(self.prompt.property('attempted'))
self.assertEqual(self.calls(), [])
self.service.setProperty('keyringState', 'KEYRING_READY')
QTest.qWait(150)
self.assertEqual(self.calls(), [])
self.call('restoreRequested'); QTest.qWait(100)
self.assertEqual([x['kind'] for x in self.calls()], ['apply-last'])
def test_new_session_during_keyring_unlock_remains_accessible(self):
self.service.setProperty('keyringState', 'KEYRING_LOCKED')
self.open(); self.call('unlockRequested'); QTest.qWait(100)
self.assertTrue(self.service.property('keyringBusy'))
self.assertFalse(self.prompt.property('focusable'))
self.call('newSessionRequested')
self.assertEqual([x['kind'] for x in self.calls()], ['ack-new'])
self.assertFalse(self.service.property('keyringBusy'))
if __name__ == '__main__': unittest.main()

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""TEST_ONLY Secret Service. No secrets/items/passwords exist in this fixture."""
import sys
from gi.repository import Gio, GLib
mode = sys.argv[1]
root = '/org/freedesktop/secrets'
collection = root + '/collection/Default_5fkeyring'
prompt = root + '/prompt/test'
locked = mode != 'ready'
xml = '''<node>
<interface name="org.freedesktop.Secret.Service">
<method name="ReadAlias"><arg type="s" direction="in"/><arg type="o" direction="out"/></method>
<method name="Unlock"><arg type="ao" direction="in"/><arg type="ao" direction="out"/><arg type="o" direction="out"/></method>
</interface>
<interface name="org.freedesktop.Secret.Collection"><property name="Locked" type="b" access="read"/></interface>
<interface name="org.freedesktop.Secret.Prompt">
<method name="Prompt"><arg type="s" direction="in"/></method><method name="Dismiss"/>
<signal name="Completed"><arg type="b"/><arg type="v"/></signal>
</interface></node>'''
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
bus.call_sync('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus',
'RequestName', GLib.Variant('(su)', ('org.freedesktop.secrets', 0)),
GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 2000, None)
def method(bus, sender, path, interface, name, args, invocation):
global locked
if name == 'ReadAlias':
if mode == 'error':
invocation.return_dbus_error('org.freedesktop.Secret.Error.Failed', 'TEST_ONLY_SENSITIVE_SENTINEL')
return
if mode == 'dbus-timeout': return # deliberately leave request unanswered
alias = args.unpack()[0]
value = collection if alias == 'default' else '/'
if mode == 'missing': value = '/'
if mode == 'different' and alias == 'default': value = root + '/collection/other'
if mode == 'login-different' and alias == 'login': value = root + '/collection/login'
invocation.return_value(GLib.Variant('(o)', (value,)))
elif name == 'Unlock':
print('UNLOCK', flush=True)
if mode == 'immediate':
locked = False
invocation.return_value(GLib.Variant('(aoo)', ([collection], '/')))
else: invocation.return_value(GLib.Variant('(aoo)', ([], prompt)))
elif name == 'Prompt':
print('PROMPT', flush=True)
invocation.return_value(GLib.Variant('()', ()))
def complete():
global locked
if mode not in ('still-locked', 'wrong-password', 'cancel'): locked = False
bus.emit_signal(sender, prompt, interface, 'Completed',
GLib.Variant('(bv)', (mode == 'cancel', GLib.Variant('ao', [collection]))))
return False
if mode not in ('timeout', 'wrong-password'):
GLib.timeout_add(400 if mode == 'slow' else 10, complete)
elif name == 'Dismiss':
print('DISMISS', flush=True)
invocation.return_value(GLib.Variant('()', ()))
def prop(*_): return GLib.Variant('b', locked)
node = Gio.DBusNodeInfo.new_for_xml(xml)
for path, iface in ((root, 0), (collection, 1), (prompt, 2)):
bus.register_object(path, node.interfaces[iface], method, prop, None)
print('READY', flush=True)
GLib.MainLoop().run()

View file

@ -0,0 +1,58 @@
"""Late synthetic keyring-like surface: real focus proof must still fail.
CONTRACT: the surface is TEST_ONLY and does not establish the historical
identity of con_id 18. No event infers user-vs-IPC causality from app_id.
"""
import json
from unittest.mock import patch
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.executor import Executor
from session_v2.recovery import Recovery, recovery_status
from session_v2.ipc import Sway
from session_v2.errors import Failure
from session_v2.storage import private_dir, read
def main():
for late in (False, True):
with Lab() as lab:
print(lab.directory, flush=True)
helper = build_helper(lab.directory)
apps = Surfaces(lab, helper)
lab.command('workspace TEST_ONLY_TARGET')
apps.create('TEST_ONLY_app-A')
data = fixture('A')
sway = Sway(lab.socket)
runtime = private_dir(lab.directory / 'runtime')
binary = build(lab.directory)
original = Recovery.boundary
observed = []
def boundary(recovery, phase):
original(recovery, phase)
if phase == 'after-guard-finish' and late:
# Same placement in the lifecycle as the historical race.
# A real compositor maps/focuses this independent surface.
observed.append(apps.create('TEST_ONLY_keyring_prompt'))
engine = Executor(sway, catalog(data), None, runtime, binary, lab.env)
with patch.object(Recovery, 'boundary', boundary):
if late:
try: engine.apply(data, True)
except Failure as exc: assert exc.code == 'RECOVERY_FOCUS_MISMATCH', exc.code
else: raise AssertionError('LATE_FOCUS_WAS_ACCEPTED')
else:
value = engine.apply(data, True)
assert value['status'] == 'success' and value['final_focus_verified']
report = read(runtime / 'session-v2-restore-attempt.json')
if late:
assert report['status'] == 'failed' and report['reason'] == 'RECOVERY_FOCUS_MISMATCH', report
assert not report['final_focus_verified']
assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
# Attribution remains an observation; no focus correction to
# manufacture success after an unexpected late activation.
assert observed[0]['app_id'] == 'TEST_ONLY_keyring_prompt'
print('late-refused' if late else 'stable-final-focus', 'PASS', flush=True)
apps.close()
if __name__ == '__main__': main()

View file

@ -0,0 +1,126 @@
"""Actual chooser/Secret Service client on private Sway and private D-Bus only."""
import json
import shutil
import subprocess
from common import *
from lab import Lab
from test_keyring import KeyringTests
SOURCE = BACKEND
HARNESS = '''//@ pragma UseQApplication
import QtQuick
import Quickshell
import "sessionui"
ShellRoot {
PanelWindow { id: bar; visible: false }
SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true }
Timer { interval: 1250; running: true; onTriggered: {
if (!prompt.interactionArmed) { console.error("KEYRING_UI_FAIL_NOT_ARMED"); Qt.quit(); return; }
if (Quickshell.env("CASE") !== "ready") {
prompt.restoreRequested();
if (prompt.attempted) { console.error("KEYRING_UI_FAIL_BYPASS"); Qt.quit(); return; }
if (Quickshell.env("CASE") !== "restart-check") prompt.unlockRequested();
}
} }
Timer { interval: 2300; running: true; onTriggered: {
const mode = Quickshell.env("CASE");
if (prompt.attempted || prompt.inProgress) { console.error("KEYRING_UI_FAIL_AUTORESTORE"); Qt.quit(); return; }
if (mode === "ready" || mode === "accept") {
if (!prompt.keyringReady) { console.error("KEYRING_UI_FAIL_NOT_READY"); Qt.quit(); return; }
prompt.restoreRequested();
} else {
if (prompt.keyringReady) { console.error("KEYRING_UI_FAIL_FALSE_READY"); Qt.quit(); return; }
prompt.newSessionRequested();
}
} }
Timer { interval: 3700; running: true; onTriggered: {
const mode = Quickshell.env("CASE");
const okay = mode === "ready" || mode === "accept" ? prompt.restored && !prompt.visible : !prompt.visible && !prompt.attempted;
console.log(okay ? "KEYRING_UI_PASS" : "KEYRING_UI_FAIL_FINAL"); Qt.quit();
} }
}
'''
# The chooser and gate are production. Only restoration/checkpoint discovery are
# synthetic here; actual Executor/focus behavior is tested in keyring_runtime.py.
WRAPPER = '''import sys, json, os
from pathlib import Path
sys.path.insert(0, SOURCE)
from session_v2.cli import main
cmd=sys.argv[1]
with Path(os.environ['CALLS']).open('a') as log: log.write(cmd+'\\n')
if cmd.startswith('keyring-'): sys.exit(main())
if cmd == 'startup-status': value={'eligible':True,'claimed':True,'windows':4,'workspaces':2}
elif cmd == 'apply-last': value={'schema':'labfy.sway.session-v2-restore-attempt','version':1,'status':'success','transaction_id':'a'*32,'identity_level':'exact','final_tree_verified':True,'final_focus_verified':True,'slots_expected':4,'slots_filled':4,'applications_expected':3,'applications_observed':3,'anchors_created':3,'anchors_cleaned':3,'helpers_suspended':0,'helpers_resumed':0}
elif cmd == 'acknowledge-startup': value={'status':'acknowledged','choice':sys.argv[sys.argv.index('--choice')+1]}
else: raise RuntimeError('TEST_ONLY_UNEXPECTED')
print(json.dumps(value))
'''
def main():
with Lab() as lab:
print(lab.directory, flush=True)
for mode in ('ready', 'accept', 'cancel', 'timeout', 'absent', 'still-locked', 'different'):
with KeyringTests().service(mode) as (busenv, _):
base = lab.directory / mode / 'config/quickshell/labfy-sway'
base.mkdir(parents=True)
for item in ('sessionui', 'controlcenter', 'theme'):
shutil.copytree(SOURCE / item, base / item)
shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
(base / 'shell.qml').write_text(HARNESS)
(base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
calls = lab.directory / (mode + '.calls')
env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
'CASE': mode, 'CALLS': str(calls)}
proc = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
capture_output=True, text=True, timeout=12)
output = proc.stdout + proc.stderr
(lab.directory / (mode + '.qml.log')).write_text(output)
assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
seen = calls.read_text().splitlines()
assert seen.count('apply-last') == (1 if mode in ('ready', 'accept') else 0), seen
assert seen.count('acknowledge-startup') == 1, seen
print(mode, 'PASS', flush=True)
# Real QuickShell process destruction, not just a reset QML boolean.
with KeyringTests().service('timeout') as (busenv, server):
base = lab.directory / 'restart/config/quickshell/labfy-sway'
base.mkdir(parents=True)
for item in ('sessionui', 'controlcenter', 'theme'):
shutil.copytree(SOURCE / item, base / item)
shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
(base / 'shell.qml').write_text(HARNESS)
(base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
calls = lab.directory / 'restart.calls'
env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
'CASE': 'timeout', 'CALLS': str(calls)}
first = subprocess.Popen(['qs', '-p', str(base), '--no-color'], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
import select
# Read raw pipe so TextIO buffering cannot hide the second line.
observed = b''
deadline = time.monotonic() + 8
while b'PROMPT\n' not in observed and time.monotonic() < deadline:
if select.select([server.stdout], [], [], .2)[0]:
observed += os.read(server.stdout.fileno(), 4096)
assert b'UNLOCK\n' in observed and b'PROMPT\n' in observed, observed
finally:
first.terminate()
first.communicate(timeout=5)
# A replacement shell reconstructs locked/busy from backend/D-Bus;
# it must not reissue Unlock or Restore just because it restarted.
env['CASE'] = 'restart-check'
restarted = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
capture_output=True, text=True, timeout=12)
output = restarted.stdout + restarted.stderr
assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
seen = calls.read_text().splitlines()
assert seen.count('keyring-unlock') == 1 and 'apply-last' not in seen, seen
print('quickshell-restart-during-unlock PASS', flush=True)
if __name__ == '__main__': main()

View file

@ -21,6 +21,7 @@ log = Path(os.environ['LABFY_UI_LOG'])
with log.open('a') as s: s.write(cmd + '\\n')
mode = os.environ['LABFY_UI_MODE']
if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2}
elif cmd == 'keyring-status': r = {'state': 'KEYRING_READY', 'reason': 'KEYRING_UNLOCKED'}
elif cmd == 'apply-last':
r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1}
if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
@ -86,7 +87,9 @@ ShellRoot {
def main():
rows = []
with Lab() as lab:
from test_keyring import KeyringTests
with KeyringTests().service('ready') as (busenv, _), Lab() as lab:
lab.env['DBUS_SESSION_BUS_ADDRESS'] = busenv['DBUS_SESSION_BUS_ADDRESS']
for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real', 'deferred-real'):
config = lab.directory / mode / 'config'
base = config / 'quickshell/labfy-sway'

View file

@ -0,0 +1,152 @@
"""Real Gio client against a private mock bus; never accesses the desktop bus."""
import contextlib
import io
import json
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from common import BACKEND, REPO, Path, os, sys
from session_v2 import keyring, cli
from session_v2.errors import Failure
from session_v2.storage import private_dir, lock
class KeyringTests(unittest.TestCase):
@contextlib.contextmanager
def service(self, mode):
with tempfile.TemporaryDirectory(prefix='labfy-keyring-TEST_ONLY-') as tmp:
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'}
for name in ('HOME', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'):
path = Path(tmp) / name; path.mkdir(mode=0o700); env[name] = str(path)
# No standard service directories: an "absent" fixture must not
# accidentally activate the installed real GNOME Keyring daemon.
config = Path(tmp) / 'bus.conf'
config.write_text('<busconfig><type>session</type><listen>unix:tmpdir=' + tmp + '</listen>'
'<auth>EXTERNAL</auth><policy context="default"><allow own="*"/>'
'<allow send_destination="*"/><allow receive_sender="*"/></policy></busconfig>')
daemon = subprocess.Popen(['dbus-daemon', '--config-file=' + str(config), '--nofork', '--print-address=1'],
env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
server = None
try:
address = daemon.stdout.readline().strip()
env['DBUS_SESSION_BUS_ADDRESS'] = address
if mode != 'absent':
server = subprocess.Popen([sys.executable, '-B', str(REPO / 'tests/session_v2/fixtures/secret_service.py'), mode],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.assertEqual(server.stdout.readline().strip(), 'READY')
with patch.dict(os.environ, env, clear=True):
yield env, server
finally:
for proc in (server, daemon):
if proc is not None:
proc.terminate()
try: proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill(); proc.communicate(timeout=5)
raise
def test_actual_dbus_states(self):
cases = [('ready', False, 'READY'), ('locked', False, 'LOCKED'),
('accept', True, 'READY'), ('immediate', True, 'READY'),
('cancel', True, 'CANCELLED'), ('still-locked', True, 'LOCKED'),
('wrong-password', True, 'UNAVAILABLE'), ('timeout', True, 'UNAVAILABLE'),
('absent', False, 'UNAVAILABLE'), ('missing', False, 'UNAVAILABLE'),
('different', False, 'UNAVAILABLE'), ('login-different', False, 'UNAVAILABLE'),
('dbus-timeout', False, 'UNAVAILABLE'), ('error', False, 'UNAVAILABLE')]
for mode, unlock, expected in cases:
with self.subTest(mode=mode), self.service(mode):
value = keyring.check(unlock, timeout=.15)
self.assertEqual(value['state'], 'KEYRING_' + expected, value)
self.assertEqual(set(value), {'state', 'reason'})
if mode == 'absent':
owner = subprocess.run(['busctl', '--address=' + os.environ['DBUS_SESSION_BUS_ADDRESS'],
'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus',
'NameHasOwner', 's', keyring.BUS], capture_output=True, text=True, timeout=5)
self.assertEqual(owner.stdout.strip(), 'b false')
def test_two_processes_and_restart(self):
with self.service('timeout') as (env, server):
argv = [sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-unlock', '--execute']
child = subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
self.assertEqual(server.stdout.readline().strip(), 'UNLOCK')
self.assertEqual(server.stdout.readline().strip(), 'PROMPT')
# A new QuickShell/CLI must discover busy from the kernel lock.
status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_UNLOCKING')
duplicate = subprocess.run(argv, env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(json.loads(duplicate.stdout)['state'], 'KEYRING_UNLOCKING')
runtime = Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway'
from session_v2.startup import acknowledge
self.assertEqual(acknowledge(runtime, 'TEST_ONLY', 'new')['choice'], 'new')
finally:
child.terminate()
try: child.communicate(timeout=5)
except subprocess.TimeoutExpired:
child.kill(); child.communicate(timeout=5)
raise
status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_LOCKED')
# No readiness/cache/secret persists; only private lock and startup decision.
files = list((Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway').rglob('*'))
self.assertTrue(all(p.name in ('session-v2', 'keyring.lock', 'transaction.lock', 'startup.json') for p in files))
def test_apply_last_cannot_bypass_gate(self):
with self.service('locked'):
with patch.object(cli, 'Store') as store, patch.object(cli, 'Sway'), \
patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \
patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
store.return_value.last.return_value = {}
self.assertEqual(cli.main(['apply-last', '--execute']), 2)
self.assertEqual(json.loads(out.getvalue())['reason'], 'KEYRING_LOCKED')
executor.assert_not_called()
def test_ready_preserves_executor_result_and_recovery_required(self):
for code in (None, 'RECOVERY_REQUIRED', 'RECOVERY_FOCUS_MISMATCH'):
with self.subTest(code=code), self.service('ready'):
with patch.object(cli, 'Store'), patch.object(cli, 'Sway'), patch.object(cli, 'Catalog'), \
patch.object(cli, 'FirefoxProvider'), patch.object(cli, 'Executor') as executor, \
contextlib.redirect_stdout(io.StringIO()) as out:
if code: executor.return_value.apply.side_effect = Failure(code)
else: executor.return_value.apply.return_value = {'status': 'TEST_ONLY_EXECUTOR_RESULT'}
rc = cli.main(['apply-last', '--execute'])
self.assertEqual(rc, 2 if code else 0)
self.assertEqual(json.loads(out.getvalue()), {'status': 'failed', 'reason': code} if code else {'status': 'TEST_ONLY_EXECUTOR_RESULT'})
executor.return_value.apply.assert_called_once()
def test_unlock_never_restores(self):
with self.service('accept'):
with patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(cli.main(['keyring-unlock', '--execute']), 0)
self.assertEqual(json.loads(out.getvalue())['state'], 'KEYRING_READY')
executor.assert_not_called()
def test_remote_error_text_is_never_logged_or_persisted(self):
with self.service('error') as (env, _):
value = subprocess.run([sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-status'],
env=env, capture_output=True, text=True, timeout=10)
self.assertNotIn('TEST_ONLY_SENSITIVE_SENTINEL', value.stdout + value.stderr)
self.assertEqual(json.loads(value.stdout)['state'], 'KEYRING_UNAVAILABLE')
for path in Path(env['XDG_RUNTIME_DIR']).rglob('*'):
if path.is_file(): self.assertNotIn(b'TEST_ONLY_SENSITIVE_SENTINEL', path.read_bytes())
def test_apply_is_excluded_during_unlock(self):
with self.service('ready') as (env, _):
runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway')
with keyring.admission(runtime), patch.object(cli, 'Store'), patch.object(cli, 'Sway'), \
patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \
patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(cli.main(['apply-last', '--execute']), 2)
self.assertEqual(json.loads(out.getvalue())['reason'], 'TRANSACTION_BUSY')
executor.assert_not_called()
def test_transaction_prevents_prompt(self):
with self.service('locked') as (env, server):
runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway')
with lock(private_dir(runtime / 'session-v2')):
self.assertEqual(keyring.command(runtime, True)['state'], 'KEYRING_UNLOCKING')
self.assertEqual(keyring.check()['state'], 'KEYRING_LOCKED')
if __name__ == '__main__': unittest.main()

View file

@ -105,6 +105,7 @@ class PrewarmTests(unittest.TestCase):
self.assertEqual(seen['deferred_reason'], 'APPLICATION_LAUNCH_FAILED')
output = io.StringIO()
with patch('session_v2.cli.paths', return_value=(self.runtime/'state', self.runtime)), \
patch('session_v2.keyring.require_ready'), \
patch('session_v2.cli.Store'), patch('session_v2.cli.Sway'), patch('session_v2.cli.FirefoxProvider'), \
patch('session_v2.cli.Executor.apply', side_effect=Deferred('APPLICATION_LAUNCH_FAILED')), redirect_stdout(output):
self.assertEqual(main(['apply-last', '--execute', '--socket', '/TEST_ONLY']), 2)

View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Reproducible keyring tranche: isolated buses, HOME/XDG and private Sway only."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sway', required=True, type=Path)
args = parser.parse_args()
if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK):
parser.error('--sway must be an absolute executable (headless-capable)')
root = Path(__file__).resolve().parents[2]
output = Path(tempfile.mkdtemp(prefix='labfy-keyring-validation-'))
# No SWAYSOCK, graphical bus, user configurations, secrets or session are
# inherited. Every graphical test creates its own child headless compositor.
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1',
'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software',
'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': 'pixman',
'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
target = output / key; target.mkdir(mode=0o700); env[key] = str(target)
py = [sys.executable, '-B']
checks = {
'backend': [*py, '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
'focus': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
'qml-unit': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
**{name: [*py, 'tests/session_v2/' + filename + '.py'] for name, filename in (
('keyring-ui', 'keyring_ui'), ('keyring-runtime', 'keyring_runtime'),
('scratchpad-runtime', 'scratchpad_runtime'), ('phase3b-ui', 'phase3b_ui'),
('p0-runtime', 'p0_runtime'), ('path-runtime', 'path_stability_runtime'))},
'diff-check': ['git', 'diff', '--check'],
}
results = {}
print(output, flush=True)
for name, argv in checks.items():
with (output / (name + '.log')).open('w') as log:
try: results[name] = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180).returncode
except (OSError, subprocess.TimeoutExpired) as exc:
log.write(str(exc) + '\n'); results[name] = 'UNAVAILABLE_OR_TIMEOUT'
print(name, results[name], flush=True)
(output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
return int(any(value != 0 for value in results.values()))
if __name__ == '__main__': sys.exit(main())