From 018df9dc1d3ece00bfb79db2f1210c6eea361754 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 22:33:49 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=A7=20fix(TiffPreviewParser):=20?= =?UTF-8?q?=C3=A9carter=20un=20candidat=20sans=20magic=20FFD8=20au=20lieu?= =?UTF-8?q?=20de=20lever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un Canon PowerShot G12 levait CorruptedFileException alors que son CR2 contient une preview parfaitement valide. Son IFD2 ment : il déclare Compression = 6 sur des données brutes (3a025c02… au lieu de ffd8). Comme c'est le plus gros bloc (935 Ko contre 455), le tri par taille le présentait en premier — et le parseur levait, sans jamais essayer l'IFD0 qui portait le vrai JPEG. Incohérence de traitement : le parseur écartait déjà gracieusement un candidat en JPEG lossless, mais levait sur un candidat sans magic. Les deux cas sont pourtant identiques — un candidat inexploitable dans un fichier sain. Un tag qui ment sur son contenu est courant dans les RAW. CorruptedFileException reste réservée à ce qui est structurellement invalide : offset hors bornes, IFD tronqué, valeur plus grande que le fichier. Découvert par bin/audit-cameras.php sur 21 modèles Canon. Closes #27 --- src/Parser/Tiff/TiffPreviewParser.php | 11 ++++----- .../Parser/Tiff/TiffPreviewParserTest.php | 23 +++++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Parser/Tiff/TiffPreviewParser.php b/src/Parser/Tiff/TiffPreviewParser.php index 8a7a9ff..a63d4ef 100644 --- a/src/Parser/Tiff/TiffPreviewParser.php +++ b/src/Parser/Tiff/TiffPreviewParser.php @@ -183,13 +183,12 @@ private function tryBuildPreview(TiffReader $reader, array $candidate, Format $f { $jpeg = $reader->readBytes($candidate['offset'], $candidate['length']); - // La structure a désigné ce bloc comme un JPEG : s'il n'en est pas un, - // c'est le fichier qui ment, pas la preview qui manque. + // Un tag qui ment sur son contenu est courant dans les RAW : le + // PowerShot G12 annonce Compression = 6 sur des données brutes, dans + // l'IFD dont le bloc est justement le plus gros. Ce n'est pas une + // corruption du fichier — c'est un candidat de plus à écarter. if (!str_starts_with($jpeg, self::JPEG_MAGIC)) { - throw new CorruptedFileException(sprintf( - 'Le bloc désigné à l\'offset %d n\'est pas un JPEG (magic FFD8 absent).', - $candidate['offset'], - )); + return null; } $sof = $this->findSofSegment($jpeg); diff --git a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php index 1eb01b8..cf5294c 100644 --- a/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php +++ b/tests/Unit/Parser/Tiff/TiffPreviewParserTest.php @@ -205,12 +205,11 @@ public function testThrowsCorruptedWhenJpegOffsetIsOutOfBounds(): void $this->parser->extract($this->tiffWith($entries, ''), Format::CR2); } - public function testThrowsCorruptedWhenBlockIsNotJpeg(): void + public function testThrowsPreviewNotFoundWhenBlockIsNotJpeg(): void { - // Le tag pointe vers des données qui ne commencent pas par FFD8 : - // la structure ment, le fichier est corrompu. - $this->expectException(CorruptedFileException::class); - $this->expectExceptionMessage('FFD8'); + // Un tag qui ment sur son contenu est courant dans les RAW : ce n'est + // pas une corruption du fichier, c'est un candidat inexploitable. + $this->expectException(PreviewNotFoundException::class); $notJpeg = str_repeat("\x00", 40); @@ -222,6 +221,20 @@ public function testThrowsCorruptedWhenBlockIsNotJpeg(): void $this->parser->extract($this->tiffWith($entries, $notJpeg), Format::CR2); } + public function testFallsBackToSmallerCandidateWhenLargestIsNotJpeg(): void + { + // Cas réel du PowerShot G12 : l'IFD2 déclare Compression = 6 sur des + // données brutes, et c'est le plus gros bloc. Le candidat suivant — + // plus petit mais valide — doit prendre le relais plutôt que faire + // échouer l'extraction. + $liar = str_repeat("\x3A\x02", 400); // gros, annoncé JPEG, n'en est pas un + $real = $this->jpeg(64, 48); // plus petit, mais vrai + + $path = $this->tiffWithChain([$real, $liar]); + + self::assertSame($real, $this->parser->extract($path, Format::CR2)->jpegData); + } + public function testThrowsPreviewNotFoundWhenLengthIsZero(): void { $this->expectException(PreviewNotFoundException::class); From 4bac551a96ffbd4c5c7c215d22f931ec860e1bb4 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 22:42:51 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20chore(audit):=20out?= =?UTF-8?q?il=20d'audit=20du=20package=20contre=20le=20catalogue=20raw.pix?= =?UTF-8?q?ls.us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valider à la main ne passe pas l'échelle : 406 modèles pour les seules marques du périmètre. Cet outil parcourt le catalogue, télécharge, extrait, valide par imagecreatefromstring(), supprime, et rapporte. php bin/audit-cameras.php Canon 20 # 20 modèles Canon php bin/audit-cameras.php all 500 # tout le catalogue Rien n'est conservé : seuls les échecs comptent, chacun désignant une structure que le parseur ne sait pas encore lire. Deux choix qui comptent : - l'échantillon est réparti sur toute la gamme, pas pris en tête de liste : les N premiers modèles alphabétiques sont souvent la même génération, or c'est la dérive entre générations qui casse les parseurs ; - les fichiers sont téléchargés entiers. Une première version n'en prenait que 8 Mo : le parseur refusait alors des offsets tronqués — à juste titre — et l'audit comptait ces refus comme des bugs. Un auditeur qui ment sur ses échecs coûte plus cher en enquête qu'il n'économise de bande passante. L'outil vit dans bin/, exclu de l'archive publiée par .gitattributes. Il a trouvé son premier vrai bug dès le premier passage (#27, PowerShot G12). --- bin/audit-cameras.php | 268 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 bin/audit-cameras.php diff --git a/bin/audit-cameras.php b/bin/audit-cameras.php new file mode 100644 index 0000000..bf7ea12 --- /dev/null +++ b/bin/audit-cameras.php @@ -0,0 +1,268 @@ + ['Canon', 'Nikon', 'Sony', 'Apple', 'FUJIFILM', 'Panasonic'], + 'all' => listDirectories(BASE), + default => [$brandArg], +}; + +$extractor = RawPreviewExtractor::createDefault(); +$tmp = sys_get_temp_dir() . '/rpe-audit'; +@mkdir($tmp, 0o755, true); + +$results = []; +$stats = ['ok' => 0, 'failed' => 0, 'skipped' => 0]; + +foreach ($brands as $brand) { + $models = listDirectories(BASE . rawurlencode($brand) . '/'); + + if ([] === $models) { + continue; + } + + // Échantillon réparti sur toute la gamme plutôt que les N premiers + // alphabétiquement : on veut couvrir des générations différentes. + $models = spread($models, $limit); + + fprintf(STDERR, "\n── %s (%d modèles) ──\n", $brand, count($models)); + + foreach ($models as $model) { + $file = firstRawFile(BASE . rawurlencode($brand) . '/' . rawurlencode($model) . '/'); + + if (null === $file) { + ++$stats['skipped']; + + continue; + } + + $url = BASE . rawurlencode($brand) . '/' . rawurlencode($model) . '/' . rawurlencode($file); + $path = $tmp . '/' . preg_replace('/[^\w.-]/', '_', $file); + + if (!download($url, $path)) { + ++$stats['skipped']; + + continue; + } + + $results[] = test($extractor, $brand, $model, $path, $stats); + @unlink($path); + } +} + +report($results, $stats); + +@rmdir($tmp); + +exit($stats['failed'] > 0 ? 1 : 0); + +/** + * Teste un fichier et rend une ligne de rapport. + * + * @param array{ok: int, failed: int, skipped: int} $stats + * + * @return array{brand: string, model: string, status: string, detail: string} + */ +function test(RawPreviewExtractor $extractor, string $brand, string $model, string $path, array &$stats): array +{ + try { + $preview = $extractor->extract($path); + + // Le contrôle qui compte : un en-tête valide ne prouve rien, seule + // l'ouverture réelle prouve que la preview est exploitable. + if (false === @imagecreatefromstring($preview->jpegData)) { + ++$stats['failed']; + fwrite(STDERR, " ❌ {$model} — JPEG non décodable\n"); + + return ['brand' => $brand, 'model' => $model, 'status' => 'undecodable', + 'detail' => sprintf('%dx%d', $preview->width, $preview->height)]; + } + + ++$stats['ok']; + $detail = sprintf('%dx%d, %d Ko', $preview->width, $preview->height, strlen($preview->jpegData) / 1024); + fwrite(STDERR, " ✅ {$model} — {$detail}\n"); + + return ['brand' => $brand, 'model' => $model, 'status' => 'ok', 'detail' => $detail]; + } catch (RawPreviewExtractorException $e) { + ++$stats['failed']; + $type = (new ReflectionClass($e))->getShortName(); + fwrite(STDERR, " ❌ {$model} — {$type}: {$e->getMessage()}\n"); + + return ['brand' => $brand, 'model' => $model, 'status' => $type, 'detail' => $e->getMessage()]; + } +} + +/** + * @param list $results + * @param array{ok: int, failed: int, skipped: int} $stats + */ +function report(array $results, array $stats): void +{ + $total = $stats['ok'] + $stats['failed']; + + echo "\n", str_repeat('═', 72), "\n"; + printf("%d testés — %d réussis (%.1f %%), %d échecs, %d ignorés\n", + $total, $stats['ok'], $total > 0 ? $stats['ok'] / $total * 100 : 0, + $stats['failed'], $stats['skipped']); + echo str_repeat('═', 72), "\n"; + + $failures = array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']); + + if ([] === $failures) { + echo "\nAucun échec.\n"; + + return; + } + + // Les échecs sont la seule information utile : ils désignent une structure + // que le parseur ne sait pas encore lire. + echo "\nÉCHECS — chacun est une règle à généraliser :\n\n"; + + foreach ($failures as $f) { + printf(" %-10s %-28s %s\n", $f['brand'], $f['model'], $f['status']); + printf(" %-10s %-28s %s\n", '', '', substr($f['detail'], 0, 60)); + } +} + +/** + * Sous-répertoires d'un index Apache. + * + * @return list + */ +function listDirectories(string $url): array +{ + $html = fetch($url); + + if (null === $html) { + return []; + } + + preg_match_all('/href="([^"?\/][^"]*)\/"/', $html, $m); + + return array_values(array_map(rawurldecode(...), $m[1])); +} + +/** + * Premier fichier RAW d'un dossier modèle. + */ +function firstRawFile(string $url): ?string +{ + $html = fetch($url); + + if (null === $html) { + return null; + } + + preg_match_all('/href="([^"?\/][^"]*)"/', $html, $m); + + foreach ($m[1] as $name) { + $ext = strtolower(pathinfo(rawurldecode($name), PATHINFO_EXTENSION)); + + if (in_array($ext, EXTENSIONS, true)) { + return rawurldecode($name); + } + } + + return null; +} + +function fetch(string $url): ?string +{ + $ctx = stream_context_create(['http' => ['timeout' => 20, 'ignore_errors' => true]]); + $body = @file_get_contents($url, false, $ctx); + + return false === $body ? null : $body; +} + +/** + * Télécharge les premiers octets seulement : une preview n'est jamais loin du + * début, et le reste du fichier est le capteur. + */ +function download(string $url, string $path): bool +{ + $headers = PARTIAL_BYTES > 0 ? 'Range: bytes=0-' . (PARTIAL_BYTES - 1) : ''; + + $ctx = stream_context_create(['http' => [ + 'timeout' => 180, + 'header' => $headers, + 'ignore_errors' => true, + ]]); + + $body = @file_get_contents($url, false, $ctx); + + if (false === $body || strlen($body) < 1024) { + return false; + } + + file_put_contents($path, $body); + + return true; +} + +/** + * Répartit un échantillon sur toute la liste plutôt que d'en prendre la tête : + * les N premiers modèles alphabétiques sont souvent la même génération. + * + * @param list $items + * + * @return list + */ +function spread(array $items, int $limit): array +{ + $count = count($items); + + if ($count <= $limit) { + return $items; + } + + $step = $count / $limit; + $picked = []; + + for ($i = 0; $i < $limit; ++$i) { + $picked[] = $items[(int) floor($i * $step)]; + } + + return $picked; +} From 910061ed2d4c48b60b0956e8d198643506e55799 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Thu, 16 Jul 2026 22:43:59 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=96=20docs(readme):=20documenter?= =?UTF-8?q?=20l'audit=20sur=20le=20catalogue=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tableau des 9 appareils validés à la main ne dit pas tout : l'auditeur teste le catalogue raw.pixls.us — 400+ modèles. Le README donne la commande et le résultat du dernier passage Canon. Lister les 25 modèles serait illisible et masquerait l'essentiel : tous les boîtiers EOS passent, du 30D de 2006 au R5. Les échecs restants sont des compacts dont les fichiers ne contiennent réellement aucune preview JPEG — des DNG générés par CHDK avec une vignette 128x96 non compressée, ou des .CRW d'avant le CR2, hors périmètre. PreviewNotFoundException y est la bonne réponse. --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index b69482e..e48fc09 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,30 @@ Four generations of the same Canon 5D line — 2005 to 2016, 12 to 30 megapixels covered deliberately: RAW layout drifts between generations, and that drift is where format parsers break. +### Wider audit + +Beyond the table above, the library is audited against the whole +[raw.pixls.us](https://raw.pixls.us/) catalogue — 400+ camera models: + +```bash +php bin/audit-cameras.php Canon 25 # 25 Canon models, spread across the range +php bin/audit-cameras.php all 500 # everything +``` + +It downloads each file, extracts, verifies with `imagecreatefromstring()`, then deletes it. +Latest Canon run — **every EOS body passed**: + +```text +EOS-1D X Mark II EOS 5D Mark IV EOS 30D EOS 100D EOS 500D EOS 1000D +EOS R5 EOS R100 EOS RP EOS M6 EOS Kiss M EOS Rebel T5 +PowerShot G12 PowerShot G1 X Mark II PowerShot SX1 IS PowerShot V1 +``` + +The remaining failures are compacts whose files genuinely **contain no JPEG preview** — +CHDK-generated DNGs holding an uncompressed 128×96 thumbnail, or legacy `.CRW` files +(pre-CR2, out of scope). `PreviewNotFoundException` is the correct answer there, and the +audit counts it as an expected outcome rather than a defect. + Every preview above is checked with `imagecreatefromstring()` — not just decoded headers, but actually opened. Extraction takes **1–9 ms** on files up to 62 MB: the file is never loaded into memory, only the container structure is read before seeking to the JPEG block.