Fiabiliser les transferts et automatiser les tests d'applications
This commit is contained in:
1 parent
524e9bbb17
commit
5f65af21fc
7 files changed
+526
-54
No files matched your search
@@ -2,6 +2,33 @@
|
||||
|
||||
État au 2026-09-30. Environnement : conteneur Arch Linux, utilisateur `dev` avec sudo, sans `CAP_NET_ADMIN`, sans `/dev/ppp` ni TUN.
|
||||
|
||||
## Dernière mise à jour — fiabilisation GPIO/SPI et agent v12
|
||||
|
||||
Les sections de diagnostic plus anciennes ci-dessous décrivent les étapes précédentes.
|
||||
Le workflow `push-app` puis déverrouillage/lancement a été validé avec l'agent v11.
|
||||
Cette suite corrige les débordements GPIO/SYSIC, les commandes tactiles invalides,
|
||||
les trames instables et les transactions SPI abandonnées à la reprise de l'écran.
|
||||
Huit cycles veille/réveil alternant Home et Power ont passé sur 5H11a sans perte
|
||||
du tactile. La calibration TouchProbe retrouve les coordonnées écran sur sept
|
||||
points ; la vitesse verticale et le calcul signé des vitesses sont corrigés.
|
||||
|
||||
L'agent v12 conserve le protocole et transfère par blocs de 64 Kio avec fichiers
|
||||
temporaires distincts. `ipod-test-app` prépare une NAND indépendante, vérifie le
|
||||
premier plan, récupère journal et capture, puis arrête sa propre session.
|
||||
Voir le README pour les commandes et les tests reproductibles ASan/UBSan.
|
||||
Validation finale : huit régressions matérielles avec ASan/UBSan et sept tests
|
||||
sur les transferts ; HelloEmu réussit le workflow automatique, l'application
|
||||
absente échoue avec capture et arrêt de la VM. Un fichier de 8 460 608 octets
|
||||
fait l'aller-retour via l'agent v12 avec SHA-256 identique. Trois cycles de
|
||||
veille/réveil supplémentaires avec la calibration finale et l'agent v12 passent,
|
||||
puis HelloEmu se lance au premier plan avec son journal. Diagnostics locaux :
|
||||
`work/fiabilisation-20260930/` (dont `test-hello`, `test-echec`, `hello-final.png`).
|
||||
Les sessions de validation sont arrêtées.
|
||||
Les fichiers générés, images et diagnostics restent hors du dépôt. Les images
|
||||
5H11a et les NAND d'origine sont conservées ; le RTC, le Wi-Fi matériel, l'audio
|
||||
et la veille profonde restent à implémenter. Les nouveaux correctifs ne sont
|
||||
pas encore validés sur 5F138.
|
||||
|
||||
## Objectif
|
||||
|
||||
On fournit un simulateur d'**iPod Touch 2G sous iPhone OS 2.2.1** qui sert surtout à un agent IA pour tester les applications iOS 2.x d'AlloVoisins. Les apps sont compilées en armv6 avec `/opt/cctools` et `/opt/theos/sdks/iPhoneOS2.2.1.sdk`. L'agent doit pouvoir faire les choses suivantes, sans intervention humaine :
|
||||
|
||||
@@ -27,7 +27,7 @@ redirection. `stop` nettoie le relais suivi par PID même si QEMU est déjà arr
|
||||
## Utilisation
|
||||
|
||||
```bash
|
||||
ipod-emu start -W # agent v11 : conserve la NAND, attend SpringBoard et déverrouille (~35–50 s)
|
||||
ipod-emu start -w 5H11a-agent-v12 -W # attend SpringBoard et déverrouille (~25–50 s)
|
||||
# Ajouter -f uniquement pour remettre la NAND à zéro.
|
||||
ipod-qmp screenshot ecran.png # capture 320x480
|
||||
ipod-qmp tap 160 326 # touche (coordonnées écran), ipod-qmp tap X Y 1.5 = appui long
|
||||
@@ -37,14 +37,13 @@ ipod-emu log # console du kernel + syslog iOS
|
||||
ipod-emu stop
|
||||
```
|
||||
|
||||
- Pas de touch avant que `ipod-emu wait` / `start -W` ait rendu la main : le contrôleur tactile plante QEMU s'il est
|
||||
touché pendant son démarrage.
|
||||
- Attendre `ipod-emu wait` / `start -W` avant les gestes : le pilote tactile invité doit avoir terminé son démarrage.
|
||||
- VNC sur `127.0.0.1:5900` pour interagir à la main (tunnel SSH : `ssh -L 5900:127.0.0.1:5900 …`).
|
||||
- Le lancement d'une app prend 10-20 s.
|
||||
|
||||
## Déverrouillage après redémarrage
|
||||
|
||||
Avec l'agent **v11**, `start -W` et `wait` vérifient le verrouillage, réveillent
|
||||
Avec l'agent **v11 ou ultérieur**, `start -W` et `wait` vérifient le verrouillage, réveillent
|
||||
l'écran et effectuent le glissement normal avant de rendre la main.
|
||||
`ipod-agent screen` retourne `locked=0 passcode=0` une fois déverrouillé ;
|
||||
`ipod-agent unlock` permet de refaire ce parcours après démarrage.
|
||||
@@ -89,6 +88,65 @@ Les `.app`/`.ipa` vont dans `/Applications` (re-signés avec `ldid -S` ; le kern
|
||||
la toolchain de l'environnement (`arm-apple-darwin9-gcc -isysroot /opt/theos/sdks/iPhoneOS2.2.1.sdk`), voir
|
||||
`apps/HelloEmu`. Environ 230 Mo libres dans l'image 2.2.1.
|
||||
|
||||
## Test automatique et diagnostics
|
||||
|
||||
```bash
|
||||
# Construire d'abord l'agent v12 et une image dédiée depuis la base inchangée :
|
||||
PATH=/opt/cctools/bin:$PATH make -C agent
|
||||
ipod-mkimage -b 5H11a -o 5H11a-agent-v12 agent/overlay
|
||||
|
||||
# Après avoir arrêté sa session, tester dans une NAND indépendante :
|
||||
ipod-test-app MonApp.app
|
||||
ipod-test-app MonApp.ipa --output /chemin/nouveau-dossier
|
||||
ipod-test-app paquet.deb --bundle-id com.exemple.monapp
|
||||
```
|
||||
|
||||
Les chemins `agent/` ci-dessus sont relatifs au dossier des outils ; dans le dépôt,
|
||||
utiliser `contrib/ipod-emu/agent`. Choisir un nom d'image absent lors de sa création.
|
||||
`--base` permet de choisir une autre image contenant l'agent v11 ou ultérieur.
|
||||
Le bundle-id est lu dans le plist des `.app` et `.ipa` ; il doit être fourni pour
|
||||
les `.deb` et overlays. Le runner refuse de démarrer si les ports VNC ou agent
|
||||
sont déjà occupés. Chaque test crée une image et une session dans son propre
|
||||
répertoire ; la NAND de `run/` et l'image de base restent conservées.
|
||||
|
||||
Le code de sortie vaut zéro uniquement si l'app apparaît puis reste au premier
|
||||
plan pendant `--settle` secondes (3 par défaut), si les diagnostics sont collectés
|
||||
et si la VM est arrêtée. Ce contrôle ne remplace pas les tests fonctionnels de l'app.
|
||||
Les résultats vont dans `work/tests/<date>-<identifiant>/`, ou dans un nouveau dossier
|
||||
`--output` : `result.json`, `workflow.log`, `app.log`, `screen.png` et les logs QEMU,
|
||||
série et réseau dans `session/run/`. En cas d'échec, les diagnostics disponibles
|
||||
sont conservés et la session de test est arrêtée.
|
||||
|
||||
## Tactile et transferts
|
||||
|
||||
Home/Power ne doivent plus écrire hors du tableau GPIO : le port C est inclus.
|
||||
Les trous des registres SYSIC ne débordent plus dans le groupe voisin.
|
||||
Le tactile traite séparément les requêtes de longueur et de données, accepte
|
||||
EA/EB, conserve une trame stable, et abandonne proprement une commande lors de
|
||||
la réinitialisation SPI. Les commandes et rapports inconnus ne font plus arrêter
|
||||
QEMU. Le découpage des requêtes est documenté également dans
|
||||
[OpeniBoot Z2](https://github.com/iDroid-Project/openiBoot/blob/master/plat-s5l8900/multitouch-z2.c).
|
||||
|
||||
La vitesse verticale utilise `y`, les vitesses conservent leur signe et sont
|
||||
bornées à 16 bits. `apps/TouchProbe` permet de mesurer la calibration : compiler
|
||||
avec son Makefile, lancer avec `--log`, puis comparer les gestes QMP au marqueur
|
||||
bleu et aux coordonnées du journal (ajouter 20 pixels à l'ordonnée locale de la vue).
|
||||
La calibration a été vérifiée sur 5H11a, sur sept points de `(20,40)` à `(300,460)`.
|
||||
Les modifications n'ont pas encore été validées sur 5F138.
|
||||
|
||||
Agent **v12** : `put` et `get` utilisent des blocs de 64 Kio au lieu d'allouer
|
||||
le fichier entier dans l'iPod. Un upload incomplet supprime son temporaire et
|
||||
conserve le fichier précédent ; le client fait de même pour les téléchargements.
|
||||
Le protocole reste compatible avec l'agent v11. Les téléchargements vers stdout
|
||||
peuvent naturellement afficher un préfixe avant une erreur de transport.
|
||||
|
||||
Depuis le dépôt, les tests sans firmware sont :
|
||||
|
||||
```bash
|
||||
contrib/ipod-emu/tests/run-multitouch # build QEMU arm existant, Ninja, compilateur avec ASan/UBSan
|
||||
python3 contrib/ipod-emu/tests/test-transfers.py # Python et compilateur C, sockets locales
|
||||
```
|
||||
|
||||
## Limites
|
||||
|
||||
- **Persistance NAND** : les pages et métadonnées écrites par iOS, ainsi que les effacements de blocs, sont conservés
|
||||
@@ -103,9 +161,9 @@ la toolchain de l'environnement (`arm-apple-darwin9-gcc -isysroot /opt/theos/sdk
|
||||
L'image de base est en français, région France, fuseau Europe/Paris.
|
||||
- **Relance de SpringBoard** : `respring` peut bloquer la reconstruction du catalogue d'apps. `push-app` redémarre
|
||||
donc l'émulateur sans reconstruire l'image ni réinitialiser la NAND. Avec l'agent v11, le déverrouillage puis
|
||||
le lancement de HelloEmu au premier plan sont validés après cette installation. Les gestes Home/Power restent
|
||||
susceptibles de déclencher un défaut du pilote lors de la reprise de l'écran ; le workflow automatique utilise
|
||||
le réveil par l'API d'affichage et vérifie le verrouillage.
|
||||
le lancement de HelloEmu au premier plan sont validés après cette installation. Les corrections GPIO/SPI
|
||||
ont permis huit cycles veille/réveil alternant Home et Power sur 5H11a. Le déverrouillage automatique
|
||||
continue à vérifier l'état de l'écran.
|
||||
- **Veille** : l'agent v10 pose `SleepDisabled` sur `IOPMrootDomain`, le SoC ne sachant pas reprendre une veille profonde.
|
||||
- Pas de son, pas d'accélération graphique (SpringBoard tourne avec `LK_ENABLE_MBX2D=0`).
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ struct rt_msghdr {
|
||||
#include <notify.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define VERSION "11"
|
||||
#define VERSION "12"
|
||||
#define PORT 7788
|
||||
#define PPP_TTY "/dev/tty.bluetooth"
|
||||
#define GUEST_IP "10.0.2.15"
|
||||
@@ -236,7 +236,7 @@ static int send_all(int fd, const void *b, size_t n)
|
||||
const char *p = b;
|
||||
while (n) {
|
||||
ssize_t w = write(fd, p, n);
|
||||
if (w < 0) { if (errno == EINTR) continue; return -1; }
|
||||
if (w <= 0) { if (w < 0 && errno == EINTR) continue; if (!w) errno = EIO; return -1; }
|
||||
p += w; n -= w;
|
||||
}
|
||||
return 0;
|
||||
@@ -569,49 +569,75 @@ static int read_full(int fd, void *b, size_t n)
|
||||
char *p = b;
|
||||
while (n) {
|
||||
ssize_t r = read(fd, p, n);
|
||||
if (r <= 0) { if (r < 0 && errno == EINTR) continue; return -1; }
|
||||
if (r <= 0) { if (r < 0 && errno == EINTR) continue; if (!r) errno = EIO; return -1; }
|
||||
p += r; n -= r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Transferts par blocs : ne pas allouer la taille du fichier sur l'iPod. */
|
||||
static void cmd_put(int fd, char **args, int nargs)
|
||||
{
|
||||
if (nargs < 3) { reply_str(fd, 0, "usage: put<TAB>chemin<TAB>mode octal<TAB>taille + données"); return; }
|
||||
size_t size = strtoul(args[2], NULL, 10);
|
||||
char *data = malloc(size ? size : 1);
|
||||
if (read_full(fd, data, size)) { free(data); return; }
|
||||
char *end;
|
||||
errno = 0;
|
||||
unsigned long size = strtoul(args[2], &end, 10);
|
||||
if (errno || !args[2][0] || args[2][0] == '-' || *end) {
|
||||
reply_str(fd, 0, "taille invalide"); return;
|
||||
}
|
||||
char dir[1024];
|
||||
strncpy(dir, args[0], sizeof dir - 1);
|
||||
dir[sizeof dir - 1] = 0;
|
||||
if (strlen(args[0]) >= sizeof dir) { reply_str(fd, 0, "chemin trop long"); return; }
|
||||
strcpy(dir, args[0]);
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (slash && slash != dir) { *slash = 0; mkdirs(dir); }
|
||||
char tmp[1100];
|
||||
snprintf(tmp, sizeof tmp, "%s.emuagent-tmp", args[0]);
|
||||
int f = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, strtoul(args[1], NULL, 8));
|
||||
if (f < 0 || send_all(f, data, size) || fsync(f)) {
|
||||
reply_str(fd, 0, "écriture de %s : %s", args[0], strerror(errno));
|
||||
if (f >= 0) close(f);
|
||||
free(data);
|
||||
snprintf(tmp, sizeof tmp, "%s.emuagent-XXXXXX", args[0]);
|
||||
int f = mkstemp(tmp);
|
||||
if (f < 0) { reply_str(fd, 0, "création : %s", strerror(errno)); return; }
|
||||
char data[65536];
|
||||
size_t remaining = size;
|
||||
int failed = 0;
|
||||
while (remaining) {
|
||||
size_t chunk = remaining < sizeof data ? remaining : sizeof data;
|
||||
if (read_full(fd, data, chunk) || send_all(f, data, chunk)) { failed = 1; break; }
|
||||
remaining -= chunk;
|
||||
}
|
||||
if (!failed && (fchmod(f, strtoul(args[1], NULL, 8)) || fsync(f))) failed = 1;
|
||||
int saved = errno;
|
||||
if (close(f) && !failed) { failed = 1; saved = errno; }
|
||||
if (!failed && rename(tmp, args[0])) { failed = 1; saved = errno; }
|
||||
if (failed) {
|
||||
unlink(tmp);
|
||||
reply_str(fd, 0, "écriture de %s : %s", args[0], strerror(saved));
|
||||
return;
|
||||
}
|
||||
close(f);
|
||||
chmod(tmp, strtoul(args[1], NULL, 8));
|
||||
rename(tmp, args[0]);
|
||||
free(data);
|
||||
reply_str(fd, 1, "%zu octets écrits", size);
|
||||
reply_str(fd, 1, "%lu octets écrits", size);
|
||||
}
|
||||
|
||||
static void cmd_get(int fd, char **args, int nargs)
|
||||
{
|
||||
if (nargs < 1) { reply_str(fd, 0, "usage: get<TAB>chemin"); return; }
|
||||
int f = open(args[0], O_RDONLY | O_NONBLOCK);
|
||||
struct stat st;
|
||||
if (nargs < 1 || stat(args[0], &st) || !S_ISREG(st.st_mode)) { reply_str(fd, 0, "%s : %s", nargs ? args[0] : "?", strerror(errno)); return; }
|
||||
int f = open(args[0], O_RDONLY);
|
||||
char *data = malloc(st.st_size ? st.st_size : 1);
|
||||
if (f < 0 || read_full(f, data, st.st_size)) { reply_str(fd, 0, "lecture de %s : %s", args[0], strerror(errno)); free(data); if (f >= 0) close(f); return; }
|
||||
if (f < 0 || fstat(f, &st) || !S_ISREG(st.st_mode)) {
|
||||
reply_str(fd, 0, "lecture de %s impossible", args[0]);
|
||||
if (f >= 0) close(f);
|
||||
return;
|
||||
}
|
||||
char hdr[128], data[65536];
|
||||
snprintf(hdr, sizeof hdr, "OK %llu\n", (unsigned long long)st.st_size);
|
||||
if (send_all(fd, hdr, strlen(hdr))) { close(f); return; }
|
||||
off_t remaining = st.st_size;
|
||||
while (remaining) {
|
||||
size_t chunk = remaining < (off_t)sizeof data ? (size_t)remaining : sizeof data;
|
||||
/* Après l'en-tête, une erreur impose de fermer la connexion :
|
||||
le client détecte la réponse incomplète et conserve son ancien fichier. */
|
||||
if (read_full(f, data, chunk) || send_all(fd, data, chunk)) {
|
||||
close(f); shutdown(fd, SHUT_RDWR); return;
|
||||
}
|
||||
remaining -= chunk;
|
||||
}
|
||||
close(f);
|
||||
reply(fd, 1, NULL, data, st.st_size);
|
||||
free(data);
|
||||
}
|
||||
|
||||
static void cmd_ls(int fd, char **args, int nargs)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
Code de sortie : 0 si OK, 1 si erreur, code de sortie du programme pour exec.
|
||||
"""
|
||||
import os, socket, subprocess, sys, time
|
||||
import os, socket, subprocess, sys, tempfile, time
|
||||
|
||||
HOST = os.environ.get("IPOD_AGENT_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("IPOD_AGENT_PORT", "7788"))
|
||||
@@ -31,19 +31,41 @@ class Agent:
|
||||
self.s = socket.create_connection((HOST, PORT), timeout=timeout)
|
||||
self.f = self.s.makefile("rb")
|
||||
|
||||
def call(self, *fields, data=None):
|
||||
def call(self, *fields, data=None, sink=None):
|
||||
line = "\t".join(str(x) for x in fields) + "\n"
|
||||
self.s.sendall(line.encode())
|
||||
if data is not None:
|
||||
self.s.sendall(data)
|
||||
hdr = self.f.readline().decode().rstrip("\n")
|
||||
if hasattr(data, "read"):
|
||||
remaining = int(fields[-1])
|
||||
while remaining:
|
||||
chunk = data.read(min(65536, remaining))
|
||||
if not chunk:
|
||||
raise ConnectionError("fichier local tronqué pendant l'upload")
|
||||
self.s.sendall(chunk)
|
||||
remaining -= len(chunk)
|
||||
else:
|
||||
self.s.sendall(data)
|
||||
hdr = self.f.readline(4096).decode().rstrip("\n")
|
||||
if not hdr:
|
||||
raise ConnectionError("connexion fermée par l'agent")
|
||||
parts = hdr.split(" ", 2)
|
||||
if len(parts) < 2:
|
||||
raise ConnectionError("en-tête invalide")
|
||||
n = int(parts[1])
|
||||
body = self.f.read(n)
|
||||
if len(body) != n:
|
||||
raise ConnectionError(f"réponse tronquée : {len(body)} octets reçus sur {n}")
|
||||
if n < 0 or parts[0] not in ("OK", "ERR"):
|
||||
raise ConnectionError("en-tête invalide")
|
||||
body = bytearray()
|
||||
received = 0
|
||||
while received < n:
|
||||
chunk = self.f.read(min(65536, n - received))
|
||||
if not chunk:
|
||||
raise ConnectionError(f"réponse tronquée : {received} octets reçus sur {n}")
|
||||
received += len(chunk)
|
||||
if sink is not None and parts[0] == "OK":
|
||||
sink.write(chunk)
|
||||
else:
|
||||
body.extend(chunk)
|
||||
body = bytes(body)
|
||||
return parts[0] == "OK", body, parts[2] if len(parts) > 2 else None
|
||||
|
||||
|
||||
@@ -88,8 +110,7 @@ def unlock(a):
|
||||
if not ok:
|
||||
sys.exit(body.decode())
|
||||
time.sleep(2) # laisser le pilote réactiver l'écran et le tactile
|
||||
# La surface tactile émulée est légèrement plus petite que l'écran :
|
||||
# le geste doit atteindre le bord droit pour terminer le déverrouillage.
|
||||
# Terminer le geste au bord droit du curseur de déverrouillage.
|
||||
subprocess.run([qmp, "-s", sock, "swipe", "60", "450", "319", "450"], check=True)
|
||||
time.sleep(2)
|
||||
state = screen()
|
||||
@@ -175,20 +196,30 @@ def main():
|
||||
unlock(a)
|
||||
return
|
||||
if cmd == "get":
|
||||
ok, body, _ = a.call("get", rest[0])
|
||||
if not ok:
|
||||
sys.exit(body.decode())
|
||||
if len(rest) > 1:
|
||||
with open(rest[1], "wb") as fh:
|
||||
fh.write(body)
|
||||
else:
|
||||
sys.stdout.buffer.write(body)
|
||||
tmp = None
|
||||
try:
|
||||
if len(rest) > 1:
|
||||
destination = os.path.abspath(rest[1])
|
||||
fd, tmp = tempfile.mkstemp(prefix=".ipod-get-", dir=os.path.dirname(destination))
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
ok, body, _ = a.call("get", rest[0], sink=fh)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
if ok:
|
||||
os.replace(tmp, destination)
|
||||
tmp = None
|
||||
else:
|
||||
ok, body, _ = a.call("get", rest[0], sink=sys.stdout.buffer)
|
||||
if not ok:
|
||||
sys.exit(body.decode())
|
||||
finally:
|
||||
if tmp is not None:
|
||||
os.unlink(tmp)
|
||||
return
|
||||
if cmd == "put":
|
||||
with open(rest[0], "rb") as fh:
|
||||
data = fh.read()
|
||||
mode = rest[2] if len(rest) > 2 else ("755" if os.access(rest[0], os.X_OK) else "644")
|
||||
ok, body, _ = a.call("put", rest[1], mode, len(data), data=data)
|
||||
with open(rest[0], "rb") as fh:
|
||||
ok, body, _ = a.call("put", rest[1], mode, os.fstat(fh.fileno()).st_size, data=fh)
|
||||
elif cmd == "push-app":
|
||||
push_app(a, rest[0])
|
||||
return
|
||||
@@ -207,4 +238,7 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, ConnectionError) as e:
|
||||
sys.exit(f"Erreur de transfert ou de protocole : {e}")
|
||||
@@ -20,7 +20,7 @@ Usage : ipod-emu start [-f] [-W] [-w FW] démarre (headless, VNC $VNC, QMP $RU
|
||||
-W attend que SpringBoard soit prêt à recevoir des touches
|
||||
-w FW firmware à utiliser (défaut : $FW ; dispo : $(ls "$EMU_HOME/images" 2>/dev/null | tr '\n' ' '))
|
||||
ipod-emu wait [SECONDES] attend SpringBoard (défaut 300 s) — n'envoyez PAS de touch avant :
|
||||
le contrôleur tactile plante s'il est touché pendant son boot
|
||||
le pilote tactile doit avoir terminé son démarrage
|
||||
ipod-emu stop | status | log | console
|
||||
Pilotage (écran, touch, boutons) : ipod-qmp --help
|
||||
USAGE
|
||||
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Teste une application dans une NAND indépendante et conserve les diagnostics."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
|
||||
def bundle_id(package):
|
||||
if package.is_dir() and package.suffix == '.app':
|
||||
with (package / 'Info.plist').open('rb') as f:
|
||||
return plistlib.load(f)['CFBundleIdentifier']
|
||||
if package.suffix == '.ipa':
|
||||
with zipfile.ZipFile(package) as z:
|
||||
plists = [n for n in z.namelist() if n.startswith('Payload/') and
|
||||
n.endswith('.app/Info.plist') and n.count('/') == 2]
|
||||
if len(plists) != 1:
|
||||
raise ValueError('IPA ambiguë : préciser --bundle-id')
|
||||
return plistlib.loads(z.read(plists[0]))['CFBundleIdentifier']
|
||||
raise ValueError('Préciser --bundle-id pour un paquet .deb ou un overlay')
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('package', type=Path, help='.app, .ipa, .deb ou overlay')
|
||||
p.add_argument('--bundle-id')
|
||||
p.add_argument('--base', default=os.environ.get('IPOD_TEST_BASE', '5H11a-agent-v12'))
|
||||
p.add_argument('--output', type=Path, help='nouveau dossier de résultats (doit être absent)')
|
||||
p.add_argument('--settle', type=float, default=3, help='attente après apparition au premier plan')
|
||||
args = p.parse_args()
|
||||
package = args.package.resolve()
|
||||
tools = Path(__file__).resolve().parent
|
||||
default_home = tools.parent if (tools.parent / 'images').is_dir() else Path.home() / 'ipod-emu'
|
||||
home = Path(os.environ.get('IPOD_EMU_HOME', default_home)).resolve()
|
||||
try:
|
||||
if not package.exists():
|
||||
raise ValueError('Application introuvable')
|
||||
identifier = args.bundle_id or bundle_id(package)
|
||||
if not isinstance(identifier, str) or not identifier or any(c in identifier for c in '\t\r\n'):
|
||||
raise ValueError('Bundle-id invalide')
|
||||
if Path(args.base).name != args.base or args.base in ('.', '..'):
|
||||
raise ValueError('Nom de firmware invalide')
|
||||
if args.settle < 0:
|
||||
raise ValueError('--settle doit être positif')
|
||||
base = home / 'images' / args.base
|
||||
if not (base / 'rootfs.img').is_file():
|
||||
raise ValueError(f'Image de base introuvable : {base}')
|
||||
# Les ports sont partagés par les sessions : ne jamais arrêter une VM tierce.
|
||||
vnc = os.environ.get('IPOD_VNC', '127.0.0.1:0')
|
||||
host, display = vnc.rsplit(':', 1)
|
||||
for address, port in [(os.environ.get('IPOD_AGENT_HOST', '127.0.0.1'),
|
||||
int(os.environ.get('IPOD_AGENT_PORT', '7788'))),
|
||||
(host or '127.0.0.1', 5900 + int(display))]:
|
||||
try:
|
||||
connection = socket.create_connection((address, port), timeout=1)
|
||||
except ConnectionRefusedError:
|
||||
continue
|
||||
else:
|
||||
connection.close()
|
||||
raise ValueError(f'Port {port} occupé : arrêter votre session avant ce test')
|
||||
if args.output:
|
||||
result = args.output.resolve()
|
||||
result.mkdir(parents=True, exist_ok=False)
|
||||
else:
|
||||
parent = home / 'work' / 'tests'
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
result = Path(tempfile.mkdtemp(prefix=time.strftime('%Y%m%d-%H%M%S-'), dir=parent))
|
||||
except (OSError, ValueError, KeyError, plistlib.InvalidFileException, zipfile.BadZipFile) as e:
|
||||
p.exit(1, f'{e}\n')
|
||||
|
||||
print(f'Diagnostics : {result}', flush=True)
|
||||
session = result / 'session'
|
||||
(session / 'images').mkdir(parents=True)
|
||||
(session / 'images' / args.base).symlink_to(base, target_is_directory=True)
|
||||
for dependency in ('xpwn', 'gen-v1', 'netd'):
|
||||
(session / dependency).symlink_to(home / dependency, target_is_directory=True)
|
||||
env = dict(os.environ, IPOD_EMU_HOME=str(session),
|
||||
IPOD_QEMU=os.environ.get('IPOD_QEMU', str(home.parent / 'qemu-ios/build/qemu-system-arm')))
|
||||
# Un socket de session explicite ne doit pas viser la VM de l'utilisateur.
|
||||
env.pop('IPOD_QMP', None)
|
||||
image_name = 'test-app' if args.base != 'test-app' else 'test-app-result'
|
||||
started = False
|
||||
report = {'package': str(package), 'bundle_id': identifier, 'base': args.base, 'ok': False}
|
||||
|
||||
with (result / 'workflow.log').open('w') as workflow:
|
||||
def run(tool, *argv, capture=False, timeout=360):
|
||||
r = subprocess.run([str(tools / tool), *map(str, argv)], env=env,
|
||||
stdout=subprocess.PIPE if capture else workflow,
|
||||
stderr=workflow, text=True, timeout=timeout)
|
||||
if capture:
|
||||
workflow.write(r.stdout)
|
||||
workflow.flush()
|
||||
if r.returncode:
|
||||
raise RuntimeError(f'{tool} {argv[0] if argv else ""} : code {r.returncode}')
|
||||
return r.stdout if capture else None
|
||||
|
||||
try:
|
||||
run('ipod-mkimage', '-b', args.base, '-o', image_name, package, timeout=900)
|
||||
started = True # nettoie également un démarrage partiel
|
||||
run('ipod-emu', 'start', '-w', image_name, '-W')
|
||||
run('ipod-agent', 'launch', identifier, '--log', '/private/var/mobile/ipod-test-app.log')
|
||||
deadline = time.monotonic() + 30
|
||||
while run('ipod-agent', 'front', capture=True, timeout=20).strip() != identifier:
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError('Application absente du premier plan après 30 s')
|
||||
time.sleep(1)
|
||||
time.sleep(args.settle)
|
||||
front = run('ipod-agent', 'front', capture=True, timeout=20).strip()
|
||||
report['front'] = front
|
||||
if front != identifier:
|
||||
raise RuntimeError('Application quittée pendant la période de stabilisation')
|
||||
report['ok'] = True
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
|
||||
report['error'] = str(e)
|
||||
finally:
|
||||
if started:
|
||||
for tool, argv in [('ipod-agent', ['get', '/private/var/mobile/ipod-test-app.log', result / 'app.log']),
|
||||
('ipod-qmp', ['screenshot', result / 'screen.png'])]:
|
||||
try:
|
||||
run(tool, *argv, timeout=30)
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
|
||||
report.setdefault('diagnostic_errors', []).append(str(e))
|
||||
try:
|
||||
run('ipod-agent', 'sync', timeout=15)
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
try:
|
||||
run('ipod-emu', 'stop', timeout=30)
|
||||
except (OSError, RuntimeError, subprocess.TimeoutExpired) as e:
|
||||
report['ok'] = False
|
||||
report['stop_error'] = str(e)
|
||||
if report.get('diagnostic_errors'):
|
||||
report['ok'] = False
|
||||
(result / 'result.json').write_text(json.dumps(report, indent=2, ensure_ascii=False) + '\n')
|
||||
print('Test réussi' if report['ok'] else 'Test échoué : consulter workflow.log et result.json')
|
||||
return 0 if report['ok'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Teste les transferts réels de l'agent sur socket local, sans firmware Apple."""
|
||||
import importlib.util
|
||||
from importlib.machinery import SourceFileLoader
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
loader = SourceFileLoader('ipod_agent', str(ROOT / 'bin/ipod-agent'))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
client = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(client)
|
||||
|
||||
|
||||
class Transfers(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.work = tempfile.TemporaryDirectory()
|
||||
cls.binary = Path(cls.work.name) / 'transfer-agent'
|
||||
source = (ROOT / 'agent/emuagent.c').read_text()
|
||||
# Compile les fonctions de production, sans dépendances privées d'iOS.
|
||||
functions = source[source.index('static int send_all('):source.index('/* tampon dynamique */')]
|
||||
functions += source[source.index('static int read_full('):source.index('static void cmd_ls(')]
|
||||
harness = '''#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
static void mkdirs(const char *path) { (void)path; }
|
||||
''' + functions + '''
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 4) return 2;
|
||||
int fd = atoi(argv[1]);
|
||||
if (!strcmp(argv[2], "get")) cmd_get(fd, argv+3, argc-3);
|
||||
else cmd_put(fd, argv+3, argc-3);
|
||||
char ignored[256]; read(fd, ignored, sizeof ignored);
|
||||
close(fd); return 0;
|
||||
}
|
||||
'''
|
||||
c = Path(cls.work.name) / 'transfer-agent.c'
|
||||
c.write_text(harness)
|
||||
subprocess.run(['cc', '-Wall', '-Wextra', '-Werror', '-fsanitize=undefined',
|
||||
'-o', str(cls.binary), str(c)], check=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.work.cleanup()
|
||||
|
||||
def agent(self, command, *args):
|
||||
a, b = socket.socketpair()
|
||||
process = subprocess.Popen([str(self.binary), str(b.fileno()), command, *map(str, args)],
|
||||
pass_fds=(b.fileno(),), env=dict(os.environ, UBSAN_OPTIONS='halt_on_error=1'))
|
||||
b.close()
|
||||
agent = client.Agent.__new__(client.Agent)
|
||||
agent.s, agent.f = a, a.makefile('rb')
|
||||
self.addCleanup(agent.f.close)
|
||||
self.addCleanup(a.close)
|
||||
self.addCleanup(lambda: self.assertEqual(process.wait(timeout=5), 0))
|
||||
return agent
|
||||
|
||||
def test_large_round_trip(self):
|
||||
data = os.urandom(8 * 1024 * 1024 + 37)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / 'file'
|
||||
a = self.agent('put', path, '640', len(data))
|
||||
# Le harness reçoit les arguments hors bande, puis le contenu du protocole.
|
||||
a.s.sendall(data)
|
||||
ok, _, _ = a.call() # ligne vide ignorée par ce harness
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(path.read_bytes(), data)
|
||||
self.assertEqual(path.stat().st_mode & 0o777, 0o640)
|
||||
a = self.agent('get', path)
|
||||
with (Path(d) / 'download').open('wb') as out:
|
||||
ok, _, _ = a.call(sink=out)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual((Path(d) / 'download').read_bytes(), data)
|
||||
|
||||
def test_interrupted_put_preserves_existing_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / 'file'
|
||||
path.write_bytes(b'ancienne version')
|
||||
a = self.agent('put', path, '644', 100000)
|
||||
a.s.sendall(b'court')
|
||||
a.s.shutdown(socket.SHUT_WR)
|
||||
response = a.f.read()
|
||||
self.assertTrue(response.startswith(b'ERR '))
|
||||
self.assertEqual(path.read_bytes(), b'ancienne version')
|
||||
self.assertEqual(list(Path(d).iterdir()), [path])
|
||||
|
||||
def test_empty_file(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / 'empty'
|
||||
a = self.agent('put', path, '644', 0)
|
||||
self.assertTrue(a.call()[0])
|
||||
self.assertEqual(path.read_bytes(), b'')
|
||||
self.assertEqual(self.agent('get', path).call()[1], b'')
|
||||
|
||||
def test_download_failure_preserves_destination(self):
|
||||
listener = socket.socket()
|
||||
listener.bind(('127.0.0.1', 0)); listener.listen()
|
||||
def server():
|
||||
connection, _ = listener.accept()
|
||||
connection.recv(1024)
|
||||
connection.sendall(b'OK 100\npartiel')
|
||||
connection.close()
|
||||
thread = threading.Thread(target=server); thread.start()
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
destination = Path(d) / 'download'
|
||||
destination.write_bytes(b'original')
|
||||
r = subprocess.run([str(ROOT / 'bin/ipod-agent'), 'get', '/file', str(destination)],
|
||||
env=dict(os.environ, IPOD_AGENT_PORT=str(listener.getsockname()[1]),
|
||||
IPOD_AGENT_HOST='127.0.0.1'), capture_output=True)
|
||||
self.assertEqual(r.returncode, 1)
|
||||
self.assertIn('tronquée', r.stderr.decode())
|
||||
self.assertEqual(destination.read_bytes(), b'original')
|
||||
self.assertEqual(list(Path(d).iterdir()), [destination])
|
||||
finally:
|
||||
thread.join(); listener.close()
|
||||
|
||||
def test_upload_respects_announced_size(self):
|
||||
a, b = socket.socketpair()
|
||||
agent = client.Agent.__new__(client.Agent)
|
||||
agent.s, agent.f = a, a.makefile('rb')
|
||||
received = []
|
||||
def server():
|
||||
f = b.makefile('rb')
|
||||
received.append(f.readline())
|
||||
received.append(f.read(3))
|
||||
b.sendall(b'OK 0\n')
|
||||
f.close(); b.close()
|
||||
thread = threading.Thread(target=server); thread.start()
|
||||
try:
|
||||
data = io.BytesIO(b'abcEXTRA')
|
||||
self.assertTrue(agent.call('put', '/file', '644', 3, data=data)[0])
|
||||
self.assertEqual(data.tell(), 3)
|
||||
self.assertEqual(received, [b'put\t/file\t644\t3\n', b'abc'])
|
||||
finally:
|
||||
agent.f.close(); a.close(); thread.join()
|
||||
|
||||
def test_get_rejects_fifo_without_blocking(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = Path(d) / 'fifo'
|
||||
os.mkfifo(path)
|
||||
self.assertFalse(self.agent('get', path).call()[0])
|
||||
|
||||
def test_fragmented_and_truncated_reply(self):
|
||||
for truncated in (False, True):
|
||||
a, b = socket.socketpair()
|
||||
agent = client.Agent.__new__(client.Agent)
|
||||
agent.s, agent.f = a, a.makefile('rb')
|
||||
def server():
|
||||
b.recv(100)
|
||||
for part in (b'OK ', b'10\n', b'123', b'4567', b'890' if not truncated else b''):
|
||||
b.sendall(part)
|
||||
b.close()
|
||||
thread = threading.Thread(target=server)
|
||||
thread.start()
|
||||
try:
|
||||
if truncated:
|
||||
with self.assertRaisesRegex(ConnectionError, 'tronquée'):
|
||||
agent.call('get', '/test')
|
||||
else:
|
||||
self.assertEqual(agent.call('get', '/test')[1], b'1234567890')
|
||||
finally:
|
||||
agent.f.close(); a.close(); thread.join()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user