feat(session-v2): gate restoration on GNOME Keyring readiness
This commit is contained in:
parent
d16aa69f9a
commit
a556cd56b7
15 changed files with 1097 additions and 12 deletions
|
|
@ -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).
|
||||
|
|
|
|||
291
docs/session-v2-keyring-before-restore-v1.md
Normal file
291
docs/session-v2-keyring-before-restore-v1.md
Normal 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.
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
198
quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
Normal file
198
quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
Normal 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'])
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
66
tests/session_v2/fixtures/secret_service.py
Normal file
66
tests/session_v2/fixtures/secret_service.py
Normal 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()
|
||||
58
tests/session_v2/keyring_runtime.py
Normal file
58
tests/session_v2/keyring_runtime.py
Normal 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()
|
||||
126
tests/session_v2/keyring_ui.py
Normal file
126
tests/session_v2/keyring_ui.py
Normal 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()
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
152
tests/session_v2/test_keyring.py
Normal file
152
tests/session_v2/test_keyring.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
52
tests/session_v2/validate_keyring.py
Normal file
52
tests/session_v2/validate_keyring.py
Normal 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())
|
||||
Loading…
Reference in a new issue