À propos de cette série. Cette série raconte la construction progressive, en Haskell, d’outils consacrés à l’étude et à la transformation des formats binaires employés par les jeux FromSoftware sur PlayStation 3. Elle prend pour premier corpus la version PlayStation 3 originale de Demon’s Souls et avance par enquêtes reproductibles : observer une structure, établir ce qui est documenté, distinguer les faits des hypothèses, puis implémenter la plus petite capacité nécessaire à l’étape suivante.
Dans ce premier article, nous allons déterminer ce qu’un fichier nommé image.iso permet réellement d’affirmer. Nous commencerons par observer ses métadonnées au moyen de lectures précisément bornées, avant de reproduire cette reconnaissance dans un prototype direct écrit en Haskell.
Le nom image.iso constitue une hypothèse, non une preuve de format. Une extension est une convention du système de fichiers hôte ; elle ne garantit pas le contenu du fichier. Nous allons donc rechercher une structure définie par ISO 9660, lire plusieurs champs précis, puis reconnaître deux informations supplémentaires : un marqueur Joliet et un enregistrement propre aux disques PlayStation 3.
Notre périmètre restera strictement en lecture seule. Nous ne monterons pas l’image, ne parcourrons aucun répertoire, n’extrairons rien, ne déchiffrerons rien et n’écrirons aucun octet. À la fin, notre prototype affichera seulement :
ps3.product-id=BLES-00932
iso9660.volume-id=PS3VOLUME
iso9660.logical-block-size=2048
joliet.level=1
Ces quatre lignes sont un résultat de reconnaissance borné, non un certificat de conformité de l’image entière.
De la position logique à l’offset
Un fichier binaire est une suite d’octets. Nous appelons offset la position, comptée depuis zéro, d’un octet dans cette suite.
ISO 9660 organise les informations qui nous intéressent en secteurs logiques de 2 048 octets. Une LBA (Logical Block Address, adresse logique de bloc) est le numéro d’un tel secteur : le secteur de LBA 0 commence à l’offset 0, celui de LBA 1 à l’offset 2 048, et ainsi de suite. La formule que nous réutiliserons est :
offset absolu = LBA × taille du secteur + offset relatif du champ
Selon ECMA-119, la norme qui décrit ISO 9660, les secteurs de LBA 0 à 15 forment la System Area, ou zone système. La séquence des descripteurs de volume commence à la LBA 16. La page ISO 9660 d’OSDev fournit une table pratique pour s’orienter ; ECMA-119 demeure la source normative.
Nous commençons par deux constantes Shell :
IMAGE=image.iso # Remplacer au besoin par un chemin absolu vers l’image locale.
SECTOR=2048
Toutes les commandes xxd de cet article emploient -s pour fixer l’offset et -l pour borner la quantité lue. L’option -g 1 sépare les octets à l’affichage. Aucune de ces commandes ne modifie l’image ; nous n’utiliserons notamment jamais xxd -r.
Reconnaître le descripteur principal de volume
Un descripteur de volume (Volume Descriptor) est un secteur de métadonnées. La séquence qui les contient commence à la LBA 16 et doit comprendre au moins un descripteur principal de volume (Primary Volume Descriptor). Dans notre image, ce descripteur principal est précisément le premier élément de la séquence.
Tous les descripteurs ISO 9660 commencent par le même en-tête de sept octets. Les offsets de l’article sont comptés depuis zéro ; la colonne « Position ECMA-119 » reprend la numérotation à partir de 1 employée par la norme.
| Offset relatif | Position ECMA-119 | Taille | Nom canonique du champ | Rôle |
|---|---|---|---|---|
| 0 | 1 | 1 | Volume Descriptor Type |
nature du descripteur |
| 1 | 2 à 6 | 5 | Standard Identifier |
identifiant CD001
|
| 6 | 7 | 1 | Volume Descriptor Version |
version de la structure |
Le descripteur principal occupe exactement 2 048 octets. Sa disposition complète est la suivante. Les champs que notre probe décodera ne sont donc pas les seuls renseignements disponibles dans ce secteur.
| Offset relatif | Position ECMA-119 | Taille | Nom canonique du champ | Encodage ou fonction |
|---|---|---|---|---|
| 0 | 1 | 1 | Volume Descriptor Type |
entier 8 bits, valeur 1 |
| 1 | 2 à 6 | 5 | Standard Identifier |
caractères CD001
|
| 6 | 7 | 1 | Volume Descriptor Version |
entier 8 bits, valeur 1 |
| 7 | 8 | 1 | Unused Field |
octet nul |
| 8 | 9 à 40 | 32 | System Identifier |
caractères a
|
| 40 | 41 à 72 | 32 | Volume Identifier |
caractères d, complétés à largeur fixe |
| 72 | 73 à 80 | 8 | Unused Field |
octets nuls |
| 80 | 81 à 88 | 8 | Volume Space Size |
entier 32 bits both-endian |
| 88 | 89 à 120 | 32 | Unused Field |
octets nuls |
| 120 | 121 à 124 | 4 | Volume Set Size |
entier 16 bits both-endian |
| 124 | 125 à 128 | 4 | Volume Sequence Number |
entier 16 bits both-endian |
| 128 | 129 à 132 | 4 | Logical Block Size |
entier 16 bits both-endian |
| 132 | 133 à 140 | 8 | Path Table Size |
entier 32 bits both-endian |
| 140 | 141 à 144 | 4 | Location of Occurrence of Type L Path Table |
numéro de bloc, little-endian |
| 144 | 145 à 148 | 4 | Location of Optional Occurrence of Type L Path Table |
numéro de bloc, little-endian |
| 148 | 149 à 152 | 4 | Location of Occurrence of Type M Path Table |
numéro de bloc, big-endian |
| 152 | 153 à 156 | 4 | Location of Optional Occurrence of Type M Path Table |
numéro de bloc, big-endian |
| 156 | 157 à 190 | 34 | Directory Record for Root Directory |
enregistrement du répertoire racine |
| 190 | 191 à 318 | 128 | Volume Set Identifier |
caractères d
|
| 318 | 319 à 446 | 128 | Publisher Identifier |
caractères a
|
| 446 | 447 à 574 | 128 | Data Preparer Identifier |
caractères a
|
| 574 | 575 à 702 | 128 | Application Identifier |
caractères a
|
| 702 | 703 à 739 | 37 | Copyright File Identifier |
identifiant de fichier |
| 739 | 740 à 776 | 37 | Abstract File Identifier |
identifiant de fichier |
| 776 | 777 à 813 | 37 | Bibliographic File Identifier |
identifiant de fichier |
| 813 | 814 à 830 | 17 | Volume Creation Date and Time |
date et heure décimales |
| 830 | 831 à 847 | 17 | Volume Modification Date and Time |
date et heure décimales |
| 847 | 848 à 864 | 17 | Volume Expiration Date and Time |
date et heure décimales |
| 864 | 865 à 881 | 17 | Volume Effective Date and Time |
date et heure décimales |
| 881 | 882 | 1 | File Structure Version |
entier 8 bits |
| 882 | 883 | 1 | champ réservé | octet nul |
| 883 | 884 à 1395 | 512 | Application Use |
contenu non spécifié par ECMA-119 |
| 1395 | 1396 à 2048 | 653 | champ réservé | octets nuls |
Les catégories de caractères a et d sont définies par ECMA-119. Elles imposent un répertoire de caractères plus précis que la simple propriété « octet ASCII ». Nous ne décoderons ici que les champs nécessaires au résultat final, mais les offsets du tableau permettent d’examiner les autres sans rechercher de nouveau la disposition générale.
La LBA 16 commence à :
16 × 2 048 = 32 768 = 0x8000
Nous lisons donc exactement ses sept premiers octets :
xxd -g 1 -l 7 -s $((16 * SECTOR)) "$IMAGE"
La sortie observée dans notre image est :
00008000: 01 43 44 30 30 31 01 .CD001.
Nous pouvons maintenant situer chaque valeur au lieu de reconnaître seulement une chaîne plausible :
-
00008000est l’offset absolu affiché en hexadécimal ;0x8000vaut bien 32 768 ; - le premier octet,
01, est le type du descripteur ; le type 1 désigne le descripteur principal de volume ; -
43 44 30 30 31est l’encodage ASCII (American Standard Code for Information Interchange) de l’identifiant standardCD001; - le dernier
01est la version commune du descripteur, ici 1.
C’est le type, et non l’octet de version, qui établit la nature principale de ce descripteur. À ce stade, nous avons reconnu un en-tête attendu ; nous n’avons pas validé tout le secteur ni le système de fichiers.
Si une autre image produit des octets différents, la règle réutilisable n’est pas de rechercher visuellement le mot CD001 n’importe où. Il faut retrouver, à l’offset exact de la LBA 16, le triplet structuré « type 1, identifiant CD001, version prise en charge ».
Le champ Volume Identifier
Le champ anglais canonique Volume Identifier désigne l’identifiant textuel du volume. Dans le descripteur principal de volume, il commence à l’offset relatif 40 et occupe 32 octets. Sa position absolue vaut donc :
16 × 2 048 + 40 = 32 808 = 0x8028
Nous lisons les 32 octets, sans davantage :
xxd -g 1 -l 32 -s $((16 * SECTOR + 40)) "$IMAGE"
Nous observons :
00008028: 50 53 33 56 4f 4c 55 4d 45 20 20 20 20 20 20 20 PS3VOLUME
00008038: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20
Les octets 50 53 33 56 4f 4c 55 4d 45 donnent PS3VOLUME en ASCII. Les octets 20 qui suivent sont des espaces de remplissage jusqu’à la largeur fixe de 32 octets. Pour l’affichage, nous retirerons uniquement ces espaces terminaux. Nous ne supprimerons ni un éventuel espace initial, ni des octets arbitraires qui appartiendraient réellement au champ d’une autre image.
Le champ Volume Space Size et ses deux ordres d’octets
Le champ Volume Space Size, à l’offset relatif 80, indique un nombre de blocs logiques, pas encore une taille en octets. ECMA-119 l’enregistre deux fois sur quatre octets : d’abord en little-endian, puis en big-endian.
Ces deux termes décrivent l’ordre des octets d’un entier multioctet. En little-endian, l’octet de poids faible vient en premier ; en big-endian, l’octet de poids fort vient en premier. Nous séparons ici les deux copies afin que leur ordre reste visible :
xxd -g 1 -l 4 -s $((16 * SECTOR + 80)) "$IMAGE"
xxd -g 1 -l 4 -s $((16 * SECTOR + 84)) "$IMAGE"
Les sorties observées sont :
00008050: a0 27 43 00 .'C.
00008054: 00 43 27 a0 .C'.
La colonne de droite est seulement une tentative de représentation ASCII par xxd ; elle n’a aucune signification pour ce champ numérique.
Pour quatre octets b0 b1 b2 b3, les formules sont :
little-endian = b0 + b1×256 + b2×65 536 + b3×16 777 216
big-endian = b0×16 777 216 + b1×65 536 + b2×256 + b3
Appliquons-les aux octets réellement lus. La copie little-endian donne :
0xa0 + 0x27×0x100 + 0x43×0x10000 + 0x00×0x1000000
= 0x4327a0
La copie big-endian donne directement la même écriture numérique :
00 43 27 a0 = 0x4327a0
Or 0x4327a0 vaut 4 401 056. Le volume annonce donc 4 401 056 blocs logiques. Sur une autre image, il faut appliquer les mêmes formules à ses propres octets, puis comparer les deux résultats. Un lecteur ne choisit pas la copie qui paraît la plus vraisemblable : leur désaccord constitue une incohérence du champ both-endian.
Le champ Logical Block Size
Il nous manque la taille d’un bloc logique. Le champ Logical Block Size se trouve à l’offset relatif 128. Il occupe quatre octets : une copie little-endian de deux octets, puis une copie big-endian de deux octets.
xxd -g 1 -l 2 -s $((16 * SECTOR + 128)) "$IMAGE"
xxd -g 1 -l 2 -s $((16 * SECTOR + 130)) "$IMAGE"
Nous observons :
00008080: 00 08 ..
00008082: 08 00 ..
Pour deux octets b0 b1 :
little-endian = b0 + b1×256
big-endian = b0×256 + b1
La première copie, 00 08, vaut donc 0x00 + 0x08×0x100 = 0x0800. La seconde, 08 00, vaut également 0x0800. En décimal, 0x0800 vaut 2 048 octets par bloc logique.
Nous pouvons désormais reconstruire la taille annoncée de l’espace du volume :
4 401 056 × 2 048 = 9 013 362 688 octets
Comparons cette valeur à la longueur du fichier hôte, toujours sans le modifier :
ls -l "$IMAGE"
La sortie observée est :
-rw-r--r--@ 1 kdridi staff 9013362688 Sep 2 02:02 image.iso
Pour cet échantillon, la longueur du fichier est exactement égale à l’espace annoncé. C’est un contrôle de cohérence utile, pas une égalité universelle : un conteneur peut ajouter du remplissage ou des données après le volume. Dans le modèle direct que nous utilisons ici, un fichier plus court que l’espace annoncé serait en revanche insuffisant pour contenir ce volume.
Le résultat dépasse 4 Gio (gibioctets). Notre code Haskell devra donc convertir les deux facteurs vers un entier suffisamment large avant de les multiplier ; un entier limité à 32 bits ne convient pas à ce calcul.
Parcourir la séquence des descripteurs ISO 9660
Le secteur de LBA 16 n’est que le début d’une séquence. ISO 9660 autorise d’autres descripteurs dans les secteurs suivants et marque la fin de l’ensemble par un terminateur de séquence de descripteurs de volume (Volume Descriptor Set Terminator) de type 255.
Le premier octet de l’en-tête commun détermine la nature de chaque secteur de métadonnées :
| Valeur du type | Nom canonique | Fonction |
|---|---|---|
| 0 | Boot Record |
informations destinées à un système d’amorçage |
| 1 | Primary Volume Descriptor |
description principale du volume et d’une arborescence |
| 2 |
Supplementary Volume Descriptor ou Enhanced Volume Descriptor
|
description d’une arborescence supplémentaire ; la version distingue les deux formes |
| 3 | Volume Partition Descriptor |
description d’une partition du volume |
| 4 à 254 | réservé | aucune interprétation normalisée ici |
| 255 | Volume Descriptor Set Terminator |
fin de la séquence |
Le terminateur possède lui-même une disposition complète particulièrement simple :
| Offset relatif | Taille | Nom canonique du champ | Valeur |
|---|---|---|---|
| 0 | 1 | Volume Descriptor Type |
255 |
| 1 | 5 | Standard Identifier |
CD001 |
| 6 | 1 | Volume Descriptor Version |
1 |
| 7 | 2 041 | champ réservé | octets nuls |
Puisque les descripteurs occupent chacun un secteur et sont contigus à partir de la LBA 16, les sept octets que nous lirons à chaque itération se répartissent toujours ainsi :
Offset absolu pour la LBA n
|
Taille | Champ lu |
|---|---|---|
n × 2 048 + 0 |
1 | Volume Descriptor Type |
n × 2 048 + 1 |
5 | Standard Identifier |
n × 2 048 + 6 |
1 | Volume Descriptor Version |
Pour explorer ce qui suit, nous regardons volontairement au-delà de la future condition d’arrêt :
for n in $(seq 17 25)
do
xxd -g 1 -l 7 -s $(($n * SECTOR)) "$IMAGE"
done
La sortie observée est :
00008800: 02 43 44 30 30 31 01 .CD001.
00009000: ff 43 44 30 30 31 01 .CD001.
00009800: 00 42 45 41 30 31 01 .BEA01.
0000a000: 00 4e 53 52 30 33 01 .NSR03.
0000a800: 00 54 45 41 30 31 01 .TEA01.
0000b000: 00 00 00 00 00 00 00 .......
0000b800: 00 00 00 00 00 00 00 .......
0000c000: 00 00 00 00 00 00 00 .......
0000c800: 00 00 00 00 00 00 00 .......
En réunissant l’en-tête déjà lu à la LBA 16 et les deux premiers résultats de cette boucle, nous obtenons la séquence ISO 9660 suivante :
| LBA | Offset absolu | Sept premiers octets | Interprétation |
|---|---|---|---|
| 16 | 0x8000 |
01 43 44 30 30 31 01 |
descripteur principal, CD001, version 1 |
| 17 | 0x8800 |
02 43 44 30 30 31 01 |
descripteur supplémentaire, CD001, version 1 |
| 18 | 0x9000 |
ff 43 44 30 30 31 01 |
terminateur, CD001, version 1 |
À la LBA 17, 02 CD001 01 est l’en-tête d’un descripteur supplémentaire de volume (Supplementary Volume Descriptor). À la LBA 18, ff CD001 01 est le terminateur. La séquence ISO 9660 de notre image comprend donc, dans l’ordre observé, le descripteur principal à la LBA 16, un descripteur supplémentaire à la LBA 17 et le terminateur à la LBA 18.
La distinction entre exploration et lecture structurée est essentielle. La boucle Shell continue jusqu’à la LBA 25 pour découvrir les données voisines. Un scanner ISO 9660, lui, doit commencer à la LBA 16, avancer secteur par secteur, contrôler l’identifiant et la version, puis s’arrêter immédiatement au type 255. Il lui faut aussi une borne défensive : une image mal formée ne doit pas provoquer une lecture indéfinie si le terminateur manque.
Du descripteur supplémentaire au profil Joliet
Le type 2 ne signifie pas à lui seul « Joliet ». ECMA-119 définit un descripteur supplémentaire générique. La version 1 désigne le descripteur supplémentaire étudié ici ; la version 2 désigne un descripteur amélioré (Enhanced Volume Descriptor).
Le descripteur supplémentaire conserve l’essentiel de la disposition du descripteur principal, mais ajoute les informations nécessaires à l’interprétation de ses champs textuels. Voici sa structure complète :
| Offset relatif | Taille | Nom canonique du champ | Différence ou fonction principale |
|---|---|---|---|
| 0 | 1 | Volume Descriptor Type |
valeur 2 |
| 1 | 5 | Standard Identifier |
caractères CD001
|
| 6 | 1 | Volume Descriptor Version |
1 pour la forme supplémentaire, 2 pour la forme améliorée |
| 7 | 1 | Volume Flags |
propriétés des séquences d’échappement |
| 8 | 32 | System Identifier |
caractères interprétés selon la forme du descripteur |
| 40 | 32 | Volume Identifier |
caractères d1 dans la forme supplémentaire |
| 72 | 8 | Unused Field |
octets nuls |
| 80 | 8 | Volume Space Size |
entier 32 bits both-endian |
| 88 | 32 | Escape Sequences |
désignation des jeux de caractères |
| 120 | 4 | Volume Set Size |
entier 16 bits both-endian |
| 124 | 4 | Volume Sequence Number |
entier 16 bits both-endian |
| 128 | 4 | Logical Block Size |
entier 16 bits both-endian |
| 132 | 8 | Path Table Size |
entier 32 bits both-endian |
| 140 | 4 | Location of Occurrence of Type L Path Table |
numéro de bloc, little-endian |
| 144 | 4 | Location of Optional Occurrence of Type L Path Table |
numéro de bloc, little-endian |
| 148 | 4 | Location of Occurrence of Type M Path Table |
numéro de bloc, big-endian |
| 152 | 4 | Location of Optional Occurrence of Type M Path Table |
numéro de bloc, big-endian |
| 156 | 34 | Directory Record for Root Directory |
enregistrement du répertoire racine de cette arborescence |
| 190 | 128 | Volume Set Identifier |
caractères d1
|
| 318 | 128 | Publisher Identifier |
caractères a1
|
| 446 | 128 | Data Preparer Identifier |
caractères a1
|
| 574 | 128 | Application Identifier |
caractères a1
|
| 702 | 37 | Copyright File Identifier |
identifiant de fichier en caractères d1
|
| 739 | 37 | Abstract File Identifier |
identifiant de fichier en caractères d1
|
| 776 | 37 | Bibliographic File Identifier |
identifiant de fichier en caractères d1
|
| 813 | 17 | Volume Creation Date and Time |
date et heure décimales |
| 830 | 17 | Volume Modification Date and Time |
date et heure décimales |
| 847 | 17 | Volume Expiration Date and Time |
date et heure décimales |
| 864 | 17 | Volume Effective Date and Time |
date et heure décimales |
| 881 | 1 | File Structure Version |
entier 8 bits |
| 882 | 1 | champ réservé | octet nul |
| 883 | 512 | Application Use |
contenu non spécifié par ECMA-119 |
| 1395 | 653 | champ réservé | octets nuls |
La section 8.5.6 d’ECMA-119 place donc le champ Escape Sequences à l’offset relatif 88, sur 32 octets. Ce champ sert à désigner des jeux de caractères ; il ne contient pas nécessairement le nom d’une extension.
La spécification historique Microsoft Joliet utilise précisément ce mécanisme pour décrire un ensemble de fichiers dont les noms emploient UCS-2. UCS-2 (Universal Character Set, 2-byte form) est un codage historique fondé sur des unités de code de 16 bits. Nous devons donc examiner le champ avant de classifier le descripteur.
Son offset absolu est :
17 × 2 048 + 88 = 34 904 = 0x8858
Cette arithmétique permet notamment d’éviter une erreur de transcription entre les offsets relatifs 87 et 88. Lisons le champ complet :
xxd -g 1 -l 32 -s $((17 * SECTOR + 88)) "$IMAGE"
Nous observons :
00008858: 25 2f 40 00 00 00 00 00 00 00 00 00 00 00 00 00 %/@.............
00008868: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
Les trois premiers octets, 25 2f 40, représentent %/@ en ASCII ; les 29 octets suivants sont nuls.
La chaîne documentaire qui donne son sens à cette observation mérite d’être conservée. ISO 2022 définit un mécanisme de désignation des jeux de caractères. ISO 2375 en organise l’enregistrement, et le registre ISO-IR associe des séquences complètes à trois niveaux d’UCS-2 :
| Registre | Jeu de caractères | Séquence complète enregistrée |
|---|---|---|
| ISO-IR 162 | UCS-2 niveau 1 | ESC 25 2F 40 |
| ISO-IR 174 | UCS-2 niveau 2 | ESC 25 2F 43 |
| ISO-IR 176 | UCS-2 niveau 3 | ESC 25 2F 45 |
ESC désigne ici l’octet de contrôle d’échappement. Ces enregistrements datent de 1992 et 1993, avant la publication de Joliet en 1995. Le champ du descripteur supplémentaire contient les trois octets qui suivent ESC ; Microsoft n’a donc pas inventé une étiquette privée appelée « Joliet », mais a sélectionné des identifiants de jeux de caractères déjà enregistrés.
La spécification Joliet associe ces valeurs aux niveaux suivants :
| Valeur dans le champ | Niveau Joliet |
|---|---|
%/@ |
1 |
%/C |
2 |
%/E |
3 |
Dans notre image, %/@ constitue donc un marqueur Joliet de niveau 1. Cette conclusion reste étroite : le marqueur indique comment interpréter le descripteur et l’arborescence qui lui est associée, mais il ne prouve pas que cette arborescence entière respecte Joliet. Il faudrait la parcourir et la valider pour l’affirmer, ce que nous ne ferons pas dans cet article.
Un lecteur général devrait également définir une politique pour d’autres séquences enregistrées. Notre probe reconnaîtra seulement %/@, %/C et %/E. Une autre valeur restera le champ d’un descripteur supplémentaire reconnu, avec un jeu de caractères non pris en charge ; elle ne sera ni devinée ni silencieusement reclassée.
Les descripteurs de reconnaissance voisins
La boucle exploratoire précédente a aussi montré des secteurs dont l’en-tête de sept octets ressemble extérieurement à celui d’ISO 9660, mais relève d’une autre norme. ECMA-167 décrit chaque descripteur de reconnaissance sur un secteur entier selon cette disposition générale :
| Offset relatif | Taille | Nom canonique du champ | Fonction |
|---|---|---|---|
| 0 | 1 | Structure Type |
type de structure ECMA-167 |
| 1 | 5 | Standard Identifier |
identifiant tel que BEA01, NSR03 ou TEA01
|
| 6 | 1 | Structure Version |
version de la structure |
| 7 | 2 041 | Structure Data |
contenu propre au type de descripteur |
Dans notre image, les sept premiers octets donnent :
LBA 19 : 00 42 45 41 30 31 01 → BEA01
LBA 20 : 00 4e 53 52 30 33 01 → NSR03
LBA 21 : 00 54 45 41 30 31 01 → TEA01
LBA 22 : 00 00 00 00 00 00 00 → secteur non reconnu
Ces identifiants ne prolongent pas la séquence ISO 9660, terminée à la LBA 18. Ils appartiennent aux descripteurs de reconnaissance définis par ECMA-167. Leur premier octet est un type de structure ECMA-167, non un type de descripteur ISO 9660, même si les deux en-têtes observés occupent sept octets.
BEA01 ouvre une Extended Area et TEA01 la ferme. ECMA-167 précise que la Volume Recognition Sequence se termine au premier secteur qui n’est pas un descripteur valide ; dans cet échantillon, c’est le secteur nul de LBA 22. La forme BEA01 → NSR03 → TEA01 est compatible avec le motif de reconnaissance demandé par UDF (Universal Disk Format) 2.50, mais NSR03 ne détermine pas à lui seul une révision UDF exacte et ne valide aucun système de fichiers UDF.
Cette observation n’aide pas à produire nos quatre lignes. Nous n’implémenterons donc aucun parseur UDF dans ce probe.
Revenir à la zone système : les indices PlayStation 3
Les seize premiers secteurs sont réservés par ISO 9660 à la zone système, mais ECMA-119 ne définit pas la disposition PlayStation 3 que nous y observons. Aucune spécification publique Sony normative de ces champs n’a été identifiée. Leur interprétation repose sur la convergence d’implémentations indépendantes — notamment RPCS3, PS3 Disc Dumper et ps3netsrv — ainsi que sur l’observation de notre image. Ce sont des preuves d’implémentation non normatives, pas une norme Sony.
Les 512 premiers octets du secteur 1 suivent la disposition observée et corroborée ci-dessous. Le reste de ce secteur est nul dans notre image ; cette dernière propriété reste une observation locale.
| Offset relatif | Taille | Contenu ou fonction | Statut dans cet article |
|---|---|---|---|
0x000 |
0x010 (16) |
PlayStation3 suivi de quatre octets nuls |
reconnu par le probe |
0x010 |
0x020 (32) |
identifiant produit complété par des espaces | reconnu par le probe |
0x030 |
0x010 (16) |
octets nuls | observé, non utilisé |
0x040 |
0x1b0 (432) |
informations de disque dont la sémantique n’est pas établie ici | opaque |
0x1f0 |
0x010 (16) |
valeur opaque décrite comme un hachage par ps3netsrv
|
opaque |
0x200 |
0x600 (1 536) |
fin du secteur, nulle dans l’image étudiée | observation locale |
Le marqueur de plateforme
À la LBA 1, nous lisons d’abord un champ de 16 octets :
xxd -g 1 -l 16 -s $((1 * SECTOR)) "$IMAGE"
La sortie réelle est :
00000800: 50 6c 61 79 53 74 61 74 69 6f 6e 33 00 00 00 00 PlayStation3....
Les douze premiers octets codent PlayStation3 en ASCII ; quatre octets nuls complètent le champ. Nous parlons d’un champ de 16 octets, non d’un texte arbitraire de 16 caractères.
L’identifiant produit
Le champ adjacent commence 16 octets plus loin et en occupe 32 :
xxd -g 1 -l 32 -s $((1 * SECTOR + 16)) "$IMAGE"
Nous observons :
00000810: 42 4c 45 53 2d 30 30 39 33 32 20 20 20 20 20 20 BLES-00932
00000820: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20
Les dix premiers octets donnent BLES-00932 ; vingt-deux espaces complètent le champ. Notre reconnaissance minimale exigera le marqueur PlayStation3 complet, puis retirera seulement les espaces terminaux de l’identifiant produit. Cette combinaison suffit à la ligne que nous voulons rapporter, mais ne valide pas toutes les structures propres à un disque PlayStation 3.
La table de plages du secteur 0
Le début du secteur 0 contient une autre structure corroborée par les implémentations citées. Avant de lire ses valeurs, nous pouvons en représenter la disposition générale :
| Offset relatif | Taille | Contenu ou fonction |
|---|---|---|
0x000 |
4 | nombre d’enregistrements, entier non signé 32 bits big-endian |
0x004 |
4 | champ réservé, attendu à zéro |
0x008 + 8 × i |
4 | LBA de début incluse de l’enregistrement i, big-endian |
0x00c + 8 × i |
4 | LBA de fin incluse de l’enregistrement i, big-endian |
0x008 + 8 × count à 0x7ff
|
variable | reste du secteur, hors de la table décrite |
Les quatre premiers octets forment donc le compteur ; les quatre suivants sont réservés et attendus à zéro.
xxd -g 1 -l 4 -s $((0 * SECTOR + 0)) "$IMAGE"
xxd -g 1 -l 4 -s $((0 * SECTOR + 4)) "$IMAGE"
Les sorties observées sont :
00000000: 00 00 00 03 ....
00000004: 00 00 00 00 ....
00 00 00 03, lu en big-endian, vaut 3 : trois enregistrements suivent. Ce nombre n’est pas le nombre de blocs du volume ; ce dernier reste 0x004327a0. Chaque enregistrement occupe huit octets et contient deux entiers big-endian de 32 bits : une LBA de début et une LBA de fin incluse.
for n in $(seq 0 2)
do
echo "Plage $(($n + 1))"
echo -n "LBA start: "
xxd -g 1 -l 4 -s $((0 * SECTOR + 8 + $n * 8 + 0)) "$IMAGE"
echo -n "LBA stop : "
xxd -g 1 -l 4 -s $((0 * SECTOR + 8 + $n * 8 + 4)) "$IMAGE"
echo ""
done
Nous obtenons :
Plage 1
LBA start: 00000008: 00 00 00 00 ....
LBA stop : 0000000c: 00 00 45 bf ..E.
Plage 2
LBA start: 00000010: 00 01 04 00 ....
LBA stop : 00000014: 00 01 08 ff ....
Plage 3
LBA start: 00000018: 00 41 27 80 .A'.
LBA stop : 0000001c: 00 43 27 9f .C'.
La formule big-endian donnée plus haut s’applique à chaque groupe de quatre octets. Par exemple, 00 01 04 00 devient 0x00010400. Puisque les bornes sont inclusives, la longueur d’une plage est fin - début + 1. Les trois plages non protégées observées sont donc :
| Plage | Début inclus | Fin incluse | Longueur en secteurs |
|---|---|---|---|
| 1 | 0x00000000 |
0x000045bf |
0x000045c0 |
| 2 | 0x00010400 |
0x000108ff |
0x00000500 |
| 3 | 0x00412780 |
0x0043279f |
0x00020020 |
Elles sont non inversées, strictement ordonnées, sans chevauchement et contenues dans le volume. Comme Volume Space Size est un compte de 0x004327a0 blocs, les LBA valides vont de zéro à 0x0043279f ; la dernière borne incluse vaut exactement 0x004327a0 - 1.
Les intervalles complémentaires entre ces plages sont les intervalles protégés dérivés :
| Intervalle | Début inclus | Fin incluse |
|---|---|---|
| 1 | 0x000045c0 |
0x000103ff |
| 2 | 0x00010900 |
0x0041277f |
Cette dérivation décrit la carte de protection prévue pour le disque original. Elle ne permet pas de conclure que les octets de l’image locale sont actuellement chiffrés ou déjà déchiffrés. Comme notre probe ne rapporte aucune plage et n’effectue aucun déchiffrement, cette table reste une observation et n’entre pas dans le code.
Reproduire les lectures directement dans GHCi
Nous avons maintenant une suite précise de règles à transcrire. GHCi est l’interpréteur interactif de Glasgow Haskell Compiler : il permet de saisir des définitions Haskell et d’en voir immédiatement le résultat. Nous allons y construire un prototype direct, sans infrastructure générale de décodage binaire.
Depuis le même terminal, nous rendons le chemin accessible au processus, puis lançons GHCi avec le paquet bytestring :
export IMAGE="${IMAGE:-image.iso}"
ghci -package bytestring
L’invite ressemble à :
ghci>
Un ByteString est une suite compacte d’octets. Nous utiliserons sa variante stricte parce que chacune de nos lectures est petite et bornée. Les interfaces employées sont documentées dans System.IO, Data.ByteString et Data.Word.
Saisie multiligne dans GHCi. Les blocs qui suivent sont présentés comme du code Haskell ordinaire, sans commandes propres à l’interpréteur. Pour saisir une définition multiligne directement dans GHCi, commencez par entrer :{ sur une ligne isolée, saisissez ou collez la définition, puis terminez par :}, également sur une ligne isolée. Ces deux commandes sont volontairement omises des blocs afin de ne pas les confondre avec la syntaxe du langage Haskell.
Octets bruts, puis décodage endian
Commençons par les imports communs :
import Control.Monad (unless, when)
import Data.Bits (Bits, shiftL, (.|.))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC
import Data.Word (Word8, Word16, Word32)
import System.Environment (getEnv)
import System.IO (Handle, IOMode(ReadMode), SeekMode(AbsoluteSeek), hFileSize, hSeek, withBinaryFile)
Avant toute entrée-sortie, reprenons quatre octets synthétiques. Leur type ne leur attribue encore ni signification ni ordre :
let rawBytes = BS.pack [0x00, 0x00, 0x00, 0x03]
BS.unpack rawBytes
GHCi affiche :
[0,0,0,3]
L’endianness n’est pas une propriété du ByteString ni un mode de lecture du fichier. C’est une règle du champ que nous sommes en train de décoder. Nous définissons deux transformations privées :
decodeBE :: (Bits a, Num a) => BS.ByteString -> a
decodeBE =
BS.foldl'
(\acc byte -> shiftL acc 8 .|. fromIntegral byte)
0
decodeLE :: (Bits a, Num a) => BS.ByteString -> a
decodeLE =
BS.foldr'
(\byte acc -> shiftL acc 8 .|. fromIntegral byte)
0
La même suite brute donne des valeurs différentes selon l’interprétation :
decodeBE rawBytes :: Word32
decodeLE rawBytes :: Word32
Résultat :
3
50331648
50331648 vaut 0x03000000. Le format, et non une préférence du programmeur, détermine quel décodeur convient. Ces fonctions n’imposent pas elles-mêmes une largeur ; dans ce prototype, cette garantie appartient à la frontière de lecture qui les précède.
Une lecture exacte et bornée
BS.hGet peut renvoyer moins d’octets que demandé si la fin du fichier survient. Nous devons contrôler la longueur avant tout BS.index ou décodage. Les offsets restent des Integer, afin de ne pas limiter artificiellement l’adressage aux fichiers de moins de 4 Gio.
sectorSize :: Integer
sectorSize = 2048
readExactlyAt :: Handle -> Integer -> Int -> IO BS.ByteString
readExactlyAt handle offset width
| offset < 0 = ioError (userError "negative read offset")
| width < 0 = ioError (userError "negative read width")
| otherwise = do
hSeek handle AbsoluteSeek offset
bytes <- BS.hGet handle width
if BS.length bytes == width
then pure bytes
else ioError
(userError
("short read at offset " <> show offset
<> ": expected " <> show width
<> " bytes, got " <> show (BS.length bytes)))
ReadMode exprime l’intention de lecture seule. withBinaryFile referme le handle même si une exception survient. Vérifions cette frontière avec les sept octets déjà observés :
imageFile <- getEnv "IMAGE"
headerBytes <- withBinaryFile imageFile ReadMode $ \handle ->
readExactlyAt handle (16 * sectorSize) 7
BS.length headerBytes
BS.unpack headerBytes
Nous retrouvons :
7
[1,67,68,48,48,49,1]
Les décimaux 67 68 48 48 49 sont les codes ASCII de CD001. Si GHCi affiche parfois un ByteString textuel sous la forme "\SOHCD001\SOH", SOH n’est pas un octet ajouté : c’est le nom d’affichage du caractère de contrôle 0x01.
Décoder les champs utiles du descripteur principal
Nous donnons maintenant un type aux trois composants de l’en-tête, puis un type aux trois informations ISO que notre lecture conservera :
data DescriptorHeader = DescriptorHeader
{ headerType :: Word8
, headerIdentifier :: BS.ByteString
, headerVersion :: Word8
}
deriving (Eq, Show)
data IsoInfo = IsoInfo
{ isoVolumeIdentifier :: BS.ByteString
, isoVolumeSpaceSize :: Word32
, isoLogicalBlockSize :: Word16
}
deriving (Eq, Show)
decodeDescriptorHeader :: BS.ByteString -> DescriptorHeader
decodeDescriptorHeader bytes =
DescriptorHeader
{ headerType = BS.index bytes 0
, headerIdentifier = BS.take 5 (BS.drop 1 bytes)
, headerVersion = BS.index bytes 6
}
decodeDescriptorHeader emploie BS.index, qui serait partiel sur une entrée trop courte. Il reste privé et ne reçoit ici que des données dont la largeur a déjà été vérifiée par readExactlyAt.
La fonction suivante lit exactement le secteur de 2 048 octets à la LBA 16, contrôle son en-tête, découpe les trois champs, décode séparément les deux copies numériques et vérifie leur égalité. Pour le contrôle de cohérence avec le fichier hôte, elle refuse seulement une longueur insuffisante ; elle accepte d’éventuelles données terminales.
readPrimaryVolumeDescriptor :: Handle -> IO IsoInfo
readPrimaryVolumeDescriptor handle = do
descriptor <- readExactlyAt handle (16 * sectorSize) 2048
let header = decodeDescriptorHeader descriptor
unless
(headerType header == 1
&& headerIdentifier header == BSC.pack "CD001"
&& headerVersion header == 1)
(ioError (userError "no supported Primary Volume Descriptor at LBA 16"))
let volumeField = BS.take 32 (BS.drop 40 descriptor)
spaceField = BS.take 8 (BS.drop 80 descriptor)
blockField = BS.take 4 (BS.drop 128 descriptor)
(spaceLEBytes, spaceBEBytes) = BS.splitAt 4 spaceField
(blockLEBytes, blockBEBytes) = BS.splitAt 2 blockField
spaceLE = decodeLE spaceLEBytes :: Word32
spaceBE = decodeBE spaceBEBytes :: Word32
blockLE = decodeLE blockLEBytes :: Word16
blockBE = decodeBE blockBEBytes :: Word16
unless (spaceLE == spaceBE)
(ioError (userError "inconsistent Volume Space Size copies"))
unless (blockLE == blockBE)
(ioError (userError "inconsistent Logical Block Size copies"))
when (blockLE == 0)
(ioError (userError "zero Logical Block Size"))
hostSize <- hFileSize handle
let declaredSize = toInteger spaceLE * toInteger blockLE
when (hostSize < declaredSize)
(ioError
(userError
("truncated image: declared " <> show declaredSize
<> " bytes, host file has " <> show hostSize)))
pure
IsoInfo
{ isoVolumeIdentifier = BSC.dropWhileEnd (== ' ') volumeField
, isoVolumeSpaceSize = spaceLE
, isoLogicalBlockSize = blockLE
}
Exécutons ce bloc sur l’image :
isoInfo <- withBinaryFile imageFile ReadMode readPrimaryVolumeDescriptor
isoInfo
La sortie importante rassemble les valeurs reconstruites dans le Shell :
IsoInfo {isoVolumeIdentifier = "PS3VOLUME", isoVolumeSpaceSize = 4401056, isoLogicalBlockSize = 2048}
Ce résultat signifie que les contrôles explicitement codés ont réussi. Il ne prétend pas valider les nombreux champs du descripteur que nous n’avons pas lus.
Parcourir directement et sans débordement la séquence ISO 9660
Nous généralisons maintenant la lecture des sept octets d’en-tête à une LBA quelconque. Le scanner part de 16, exige CD001 et la version 1 à chaque étape, inclut le terminateur dans son résultat et s’y arrête. La limite de 64 descripteurs est un choix défensif du probe : elle transforme l’absence de terminateur proche en erreur au lieu de poursuivre sans borne.
readDescriptorHeader :: Handle -> Integer -> IO DescriptorHeader
readDescriptorHeader handle lba =
decodeDescriptorHeader
<$> readExactlyAt handle (lba * sectorSize) 7
scanIsoSequence :: Handle -> IO [(Integer, DescriptorHeader)]
scanIsoSequence handle = go 16 64 []
where
go lba remaining accumulated
| remaining == 0 =
ioError (userError "ISO descriptor scan limit reached")
| otherwise = do
header <- readDescriptorHeader handle lba
unless
(headerIdentifier header == BSC.pack "CD001"
&& headerVersion header == 1)
(ioError
(userError
("invalid ISO descriptor header at LBA " <> show lba)))
let located = (lba, header)
if headerType header == 255
then pure (reverse (located : accumulated))
else go (lba + 1) (remaining - 1) (located : accumulated)
Nous n’avons plus à connaître d’avance le nombre de descripteurs :
descriptors <- withBinaryFile imageFile ReadMode scanIsoSequence
map (\(lba, header) -> (lba, headerType header)) descriptors
Nous obtenons :
[(16,1),(17,2),(18,255)]
Le secteur 19 n’est jamais consommé par ce scanner ISO 9660. C’est exactement la différence observée entre la boucle Shell exploratoire et la règle du format.
Examiner tous les descripteurs supplémentaires et classifier Joliet
ECMA-119 autorise zéro ou plusieurs descripteurs supplémentaires. Sélectionner seulement le premier serait donc une hypothèse injustifiée. Nous lisons le champ Escape Sequences complet de chacun d’eux et conservons aussi les valeurs que notre petit probe ne sait pas classifier.
data JolietLevel
= JolietLevel1
| JolietLevel2
| JolietLevel3
deriving (Eq, Show)
data SupplementaryFinding
= JolietFinding Integer JolietLevel
| UnsupportedCharacterSet Integer BS.ByteString
deriving (Eq, Show)
classifySupplementary
:: Integer
-> BS.ByteString
-> SupplementaryFinding
classifySupplementary lba field =
let prefix = BS.take 3 field
zeroPadding = BS.all (== 0) (BS.drop 3 field)
level
| prefix == BSC.pack "%/@" = Just JolietLevel1
| prefix == BSC.pack "%/C" = Just JolietLevel2
| prefix == BSC.pack "%/E" = Just JolietLevel3
| otherwise = Nothing
in case (level, zeroPadding) of
(Just jolietLevel, True) -> JolietFinding lba jolietLevel
_ -> UnsupportedCharacterSet lba field
readSupplementaryFindings
:: Handle
-> [(Integer, DescriptorHeader)]
-> IO [SupplementaryFinding]
readSupplementaryFindings handle allDescriptors =
mapM readOne supplementaryLbas
where
supplementaryLbas =
[ lba
| (lba, header) <- allDescriptors
, headerType header == 2
]
readOne lba = do
field <- readExactlyAt handle (lba * sectorSize + 88) 32
pure (classifySupplementary lba field)
Appliquons cette lecture à tous les candidats trouvés :
supplementaryFindings <- withBinaryFile imageFile ReadMode $ \handle ->
readSupplementaryFindings handle descriptors
supplementaryFindings
La sortie situe le résultat autant qu’elle le classe :
[JolietFinding 17 JolietLevel1]
Le constructeur UnsupportedCharacterSet évite de transformer une séquence inconnue en absence de descripteur. Quant à JolietFinding, il atteste seulement le marqueur, jamais la conformité de l’arborescence.
Reconnaître le minimum propre à la PlayStation 3
Pour produire l’identifiant produit, 48 octets suffisent : le champ marqueur de 16 octets, puis le champ produit de 32 octets. Nous ne faisons pas dépendre cette reconnaissance de la table de plages, dont notre sortie n’a pas besoin.
readPs3ProductId :: Handle -> IO (Maybe BS.ByteString)
readPs3ProductId handle = do
fields <- readExactlyAt handle sectorSize 48
let marker = BS.take 16 fields
productField = BS.drop 16 fields
expectedMarker =
BSC.pack "PlayStation3" <> BS.replicate 4 0
pure
(if marker == expectedMarker
then Just (BSC.dropWhileEnd (== ' ') productField)
else Nothing)
Exécutons-la :
ps3ProductId <- withBinaryFile imageFile ReadMode readPs3ProductId
ps3ProductId
Nous retrouvons :
Just "BLES-00932"
Nothing signifierait seulement que ce marqueur d’implémentation n’a pas été reconnu. Cela ne suffirait pas à classifier universellement l’entrée comme « non-PlayStation 3 ».
Agréger les observations et les rendre dans un ordre stable
Il reste à ouvrir l’image une fois, à effectuer les lectures précédentes par offsets absolus, puis à construire les lignes reconnues. La structure finale conserve les descripteurs supplémentaires non pris en charge, même si le rendu demandé n’émet que les niveaux Joliet reconnus.
data ProbeResult = ProbeResult
{ resultPs3ProductId :: Maybe BS.ByteString
, resultIso9660 :: IsoInfo
, resultSupplementary :: [SupplementaryFinding]
}
deriving (Eq, Show)
probe :: FilePath -> IO ProbeResult
probe path =
withBinaryFile path ReadMode $ \handle -> do
isoDescriptors <- scanIsoSequence handle
recognizedIso <- readPrimaryVolumeDescriptor handle
supplementary <-
readSupplementaryFindings handle isoDescriptors
productId <- readPs3ProductId handle
pure
ProbeResult
{ resultPs3ProductId = productId
, resultIso9660 = recognizedIso
, resultSupplementary = supplementary
}
renderJolietLevel :: JolietLevel -> String
renderJolietLevel JolietLevel1 = "1"
renderJolietLevel JolietLevel2 = "2"
renderJolietLevel JolietLevel3 = "3"
renderProbeResult :: ProbeResult -> [String]
renderProbeResult result =
maybe
[]
(\productId ->
["ps3.product-id=" <> BSC.unpack productId])
(resultPs3ProductId result)
++ [ "iso9660.volume-id="
<> BSC.unpack
(isoVolumeIdentifier (resultIso9660 result))
, "iso9660.logical-block-size="
<> show (isoLogicalBlockSize (resultIso9660 result))
]
++ [ "joliet.level=" <> renderJolietLevel level
| JolietFinding _ level <- resultSupplementary result
]
Lançons enfin le probe et son rendu :
result <- probe imageFile
mapM_ putStrLn (renderProbeResult result)
La sortie est exactement :
ps3.product-id=BLES-00932
iso9660.volume-id=PS3VOLUME
iso9660.logical-block-size=2048
joliet.level=1
La portée exacte de ces quatre lignes
La première ligne repose sur un marqueur et une disposition de champs corroborés par plusieurs implémentations PlayStation 3 non normatives. Les deuxième et troisième lignes proviennent de champs précis du descripteur principal de volume ISO 9660, après contrôle de l’en-tête, des deux copies endian et de la taille minimale du fichier. La quatrième rapporte un marqueur Joliet de niveau 1 trouvé dans l’unique descripteur supplémentaire observé, après parcours borné de toute la séquence ISO 9660.
Elles ne prouvent ni la conformité complète à ISO 9660 ou Joliet, ni la validité des répertoires et des noms UCS-2, ni une révision UDF, ni l’état de chiffrement de l’image. Elles n’autorisent encore aucun parcours de répertoire, aucune extraction, aucun déchiffrement et aucune écriture. Elles disent seulement que quatre informations ont été reconnues selon les règles et les limites que nous avons rendues explicites.
Notre code direct révèle enfin une répétition concrète : plusieurs lectures fixes reviennent à combiner un offset, une largeur et une opération de décodage, puis à interpréter le résultat. Un deuxième article pourra partir de cette duplication observée — non d’une abstraction décidée à l’avance — pour étudier comment décrire ces opérations et leur donner plusieurs interprétations plus générales.
Top comments (0)