diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 35ae733..aad19f5 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -189,6 +189,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution + +## Fixed + +## Corrigé + + +- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for +- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails + + + +- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés +- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours + ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 94d56b9..f2d1db0 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -89,6 +89,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution +## Corrigé + +- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés +- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours + ## Retiré - Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b92ba..8e4e37f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,11 @@ Recreating the virtual environment, use installation guide from tool `make`. - The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent - A VM greets its SSH login with the distribution's own commands +## Fixed + +- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for +- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails + ## Removed - Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x diff --git a/mobile/compile_and_run.sh b/mobile/compile_and_run.sh index 151eca1..e327197 100755 --- a/mobile/compile_and_run.sh +++ b/mobile/compile_and_run.sh @@ -14,11 +14,14 @@ npm run build || exit 1 # Le transfert des dépôts du manifeste DANS l'application est ce qui fait # l'intérêt de son navigateur de code hors ligne, et il peut être vide sans que -# la compilation le dise. Ces dépôts entrent en packs — un APK est un ZIP borné -# à 65535 entrées, quand un fichier par source en réclamait 123 678 — et le -# vérificateur relit un échantillon depuis les tranches pour le comparer à la -# source. Trois pannes qu'un « build OK » passe sous silence : transfert vide, -# tranche absente, index qui promet des octets que sa tranche n'a pas. +# la compilation le dise. Ces dépôts entrent dans des conteneurs — un APK est +# un ZIP borné à 65535 entrées, quand un fichier par source en réclamait +# 124 350 — +# soit une archive tar.gz par dépôt, soit des tranches pack. Le vérificateur +# accepte les deux, prouve la présence de CHAQUE fichier promis, et relit un +# échantillon octet pour octet contre la source. Quatre pannes qu'un +# « build OK » passe sous silence : transfert vide, conteneur absent, index qui +# promet un fichier que son conteneur n'a pas, octets qui diffèrent. # # Même vérification que l'installation d'une VM, même script : une seule # autorité. diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py index 44e8c96..7ab6d18 100755 --- a/script/mobile/check_bundle_transfer.py +++ b/script/mobile/check_bundle_transfer.py @@ -4,26 +4,35 @@ """Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ? L'application embarque le code des dépôts du manifeste pour les parcourir hors -ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535 -entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source -faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc -un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve, -à quel offset et sur quelle longueur. +ligne. Un APK est un ZIP borné à 65535 entrées, et ces dépôts pèsent plus de +120 000 fichiers : un fichier par source faisait échouer la compilation sur +« Too many zip entries ». Deux dispositions résolvent cela, et ce script +accepte les DEUX — sans quoi il échoue sur la moitié du travail qui n'a pas +encore atterri, ce qui est arrivé : + +- ARCHIVE : un `tar.gz` par dépôt, plus un `index.json` listant ses chemins. + Le manifeste porte alors `archive` et `indexUrl`. +- PACKS : des tranches `pack-NNN.bin` par dépôt, et un `index.json` qui dit + pour chaque fichier sa tranche, son offset et sa longueur. Un fichier s'y + relit sans décompresser le reste. Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par l'installation comme à la main : ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile] -Il échoue quand le transfert est vide, quand une tranche manque, ou quand un -index promet des octets que sa tranche n'a pas — trois pannes qu'un simple -« la compilation a réussi » ne dit pas. +Il échoue quand le transfert est vide, quand un conteneur manque, quand un +index promet un fichier que son conteneur n'a pas, ou quand les octets relus +diffèrent de la source — quatre pannes qu'un simple « la compilation a réussi » +ne dit pas. La présence est prouvée pour CHAQUE fichier promis ; seule la +relecture des octets se fait par échantillon. """ import argparse import json import random import sys +import tarfile from pathlib import Path # En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris @@ -65,6 +74,126 @@ def read_from_pack(repo_dir: Path, entry: dict) -> bytes: return data +# Le plugin de compilation ecrit les membres du tar prefixes par « ./ », quand +# l'index porte le chemin nu. Sans ce prefixe, extractfile ne trouve rien. +MEMBER_PREFIX = "./" + + +def index_path(base: Path, proj: dict) -> Path: + """L'index du dépôt, dans l'une ou l'autre disposition.""" + url = proj.get("indexUrl") + if url: + # `indexUrl` est relatif à dist/, quand `base` est dist/repos. + cand = base.parent / url + if cand.is_file(): + return cand + cand = base / proj["slug"] / "index.json" + if cand.is_file(): + return cand + raise FileNotFoundError(f"{proj['slug']} : index.json absent") + + +def archive_path(base: Path, proj: dict) -> Path: + """Le tar.gz du dépôt, ou None si ce dépôt n'est pas en archive.""" + rel = proj.get("archive") + if not rel: + return None + cand = base.parent / rel + return cand if cand.is_file() else None + + +def read_from_archive( + archive: Path, paths: list, promised: list = None +) -> dict: + """Relit plusieurs fichiers en UNE passe de décompression. + + Ouvrir une archive par fichier coûterait quelques secondes chacune sur le + dépôt odoo (150 Mo) : on traverse donc une seule fois en ramassant tout ce + qui est demandé. + + `promised` : tous les chemins que l'index annonce pour cette archive. La + traversée étant déjà payée, on en profite pour vérifier qu'ils y sont TOUS + — un échantillon de vingt fichiers ne verrait pas un fantôme qu'il ne tire + pas, et c'est précisément ce que cette passe attrape gratuitement. + """ + wanted = {MEMBER_PREFIX + p: p for p in paths} + found = {} + seen = set() + with tarfile.open(archive, "r:gz") as tar: + for member in tar: + if member.isfile(): + seen.add(member.name) + key = wanted.get(member.name) + if key is None: + continue + handle = tar.extractfile(member) + if handle is None: + raise ValueError( + f"{archive.name} : {key} n'est pas un fichier" + ) + found[key] = handle.read() + missing = set(wanted.values()) - set(found) + if missing: + raise FileNotFoundError( + f"{archive.name} : {len(missing)} fichier(s) tiré(s) de l'index" + f" et absents de l'archive, dont {sorted(missing)[0]}" + ) + if promised: + ghosts = {q for q in promised if MEMBER_PREFIX + q not in seen} + if ghosts: + raise FileNotFoundError( + f"{archive.name} : l'index promet {len(ghosts)} fichier(s) que" + f" l'archive n'a pas, dont {sorted(ghosts)[0]}" + ) + return found + + +def _collect(base: Path, repos: list, report: dict) -> tuple: + """Range chaque dépôt dans sa disposition, et valide ce qu'il promet. + + Extraite de `check` pour la garder sous la complexité que flake8 accepte : + deux dispositions et leurs validations tenaient mal dans une fonction. + """ + pack_pool = [] + arch_pool = {} + for proj in repos: + entries = _read_json(index_path(base, proj)) + files = [e for e in entries if e.get("type") == "file"] + # Le manifeste annonce un compte : un index plus court est un transfert + # tronqué que rien d'autre ne signale. + promised = proj.get("fileCount") + if promised is not None and promised != len(files): + raise ValueError( + f"{proj['slug']} : le manifeste promet {promised} fichiers," + f" l'index en porte {len(files)}" + ) + report["files"] += len(files) + archive = archive_path(base, proj) + if archive is not None: + report["archives"] += 1 + arch_pool.setdefault(archive, (proj, []))[1].extend(files) + continue + repo_dir = base / proj["slug"] + packs = list(repo_dir.glob("pack-*.bin")) + if files and not packs: + # Nommer la tranche ATTENDUE : « pack-*.bin » n'aide pas qui lit un + # journal d'installation et cherche un fichier précis. + chunks = sorted( + {e["chunk"] for e in files if e.get("chunk") is not None} + ) + attendu = ( + pack_path(repo_dir, chunks[0]).name + if chunks + else "pack-000.bin" + ) + raise FileNotFoundError( + f"{proj['slug']} : ni archive, ni {attendu}" + ) + report["packs"] += len(packs) + pack_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + return pack_pool, arch_pool + + def check( mobile_root: Path, workspace: Path = None, min_files: int = None ) -> dict: @@ -87,25 +216,13 @@ def check( report = { "repos": len(repos), "files": 0, + "archives": 0, "packs": 0, + "present": 0, "checked": 0, "compared": 0, } - sample_pool = [] - for proj in repos: - repo_dir = base / proj["slug"] - index = repo_dir / "index.json" - if not index.is_file(): - raise FileNotFoundError(f"{proj['slug']} : index.json absent") - entries = _read_json(index) - files = [ - e - for e in entries - if e.get("type") == "file" and e.get("chunk") is not None - ] - report["files"] += len(files) - report["packs"] += len(list(repo_dir.glob("pack-*.bin"))) - sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + pack_pool, arch_pool = _collect(base, repos, report) if report["files"] < min_files: raise ValueError( @@ -113,22 +230,53 @@ def check( f" {report['repos']} dépôts (au moins {min_files} attendus)" ) + flat = [("pack", proj, repo_dir, e) for proj, repo_dir, e in pack_pool] + flat += [ + ("arch", proj, archive, e) + for archive, (proj, entries) in arch_pool.items() + for e in entries + ] random.seed(SEED) - for proj, repo_dir, entry in random.sample( - sample_pool, min(SAMPLE, len(sample_pool)) - ): - data = read_from_pack(repo_dir, entry) + sample = random.sample(flat, min(SAMPLE, len(flat))) + + def compare(proj, entry, data): report["checked"] += 1 if workspace is None: - continue + return src = workspace / proj["path"] / entry["path"] if not src.is_file(): - continue + return if data != src.read_bytes(): raise ValueError( f"{proj['slug']} : {entry['path']} diffère de la source" ) report["compared"] += 1 + + for kind, proj, container, entry in sample: + if kind == "pack": + compare(proj, entry, read_from_pack(container, entry)) + + drawn = {} + for kind, proj, container, entry in sample: + if kind == "arch": + drawn.setdefault(container, []).append(entry) + + # Toutes les archives sont traversées, pas seulement celles que + # l'échantillon tire : la traversée coûte 6 s pour les 139 dépôts et elle + # prouve la présence de CHACUN des fichiers promis. Un échantillon de vingt + # ne verrait pas un fantôme qu'il ne tire pas. Les octets, eux, ne sont lus + # que pour les tirés — c'est la lecture qui coûte, pas la traversée. + for archive, (proj, entries) in arch_pool.items(): + here = drawn.get(archive, []) + blobs = read_from_archive( + archive, + [e["path"] for e in here], + promised=[e["path"] for e in entries], + ) + report["present"] += len(entries) + for entry in here: + compare(proj, entry, blobs[entry["path"]]) + return report @@ -156,9 +304,16 @@ def main() -> int: extra = ( f", {rep['compared']} comparés à la source" if rep["compared"] else "" ) + where = [] + if rep["archives"]: + where.append(f"{rep['archives']} archives") + if rep["packs"]: + where.append(f"{rep['packs']} tranches") + proven = f", {rep['present']} présences prouvées" if rep["present"] else "" print( f" {rep['repos']} dépôts, {rep['files']} fichiers en" - f" {rep['packs']} tranches ({rep['checked']} relus{extra})" + f" {' et '.join(where) or 'aucun conteneur'}" + f" ({rep['checked']} relus{extra}{proven})" ) return 0 diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index 75d0e8f..b4c28a1 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -265,6 +265,8 @@ class TestTheCommandLine(unittest.TestCase): REPO = Path(__file__).resolve().parent.parent MOBILE = REPO / "mobile" / "erplibre_home_mobile" +# Le plafond d'entrees d'un ZIP, et donc d'un APK. +ZIP_ENTRY_LIMIT = 65535 class TestTheRealBundle(unittest.TestCase): @@ -276,10 +278,11 @@ class TestTheRealBundle(unittest.TestCase): (script/test/run_unit_test.sh) annonce la même dépendance avant de commencer. - Ce qu'ils gardent : qu'une compilation réelle produise bien des PACKS. Un - retour au fichier-par-source ferait disparaître le champ « chunk » des - index, et la limite du ZIP reviendrait — 123 678 entrées pour un plafond de - 65 535, silencieusement, jusqu'à l'APK. + Ce qu'ils gardent : qu'une compilation réelle range les sources dans des + CONTENEURS — archives tar.gz ou tranches pack, les deux dispositions + conviennent. Un retour au fichier-par-source ferait revenir la limite du + ZIP — 123 678 entrées pour un plafond de 65 535, silencieusement, jusqu'à + l'APK. C'est le nombre d'entrées qui compte, pas la forme du conteneur. """ @classmethod @@ -320,11 +323,18 @@ class TestTheRealBundle(unittest.TestCase): # ce qui se lit comme une régression du transfert alors que rien # n'était encore transféré. Un état incomplet s'IGNORE ; seule une # incohérence entre ce qui est là et le dépôt doit échouer. + # L'index se cherche par la MÊME résolution que le vérificateur : la + # chercher ici en dur, sous `/index.json`, faisait sauter ces + # tests sur toute compilation en archives — ils regardaient ailleurs au + # lieu de vérifier. for entree in entrees: - slug = entree.get("slug") if isinstance(entree, dict) else entree - if slug and not (cls.repos / str(slug) / "index.json").is_file(): + if not isinstance(entree, dict): + continue + try: + cbt.index_path(cls.repos, entree) + except (OSError, KeyError) as exc: raise unittest.SkipTest( - f"paquet incomplet ({slug} sans index.json) :" + f"paquet incomplet ({exc}) :" " relancer ./mobile/compile_and_run.sh" ) @@ -332,31 +342,46 @@ class TestTheRealBundle(unittest.TestCase): rep = cbt.check(MOBILE, REPO) self.assertGreater(rep["repos"], 1) self.assertGreater(rep["files"], cbt.MIN_FILES) - self.assertGreater(rep["packs"], 0) + # Peu importe la disposition : ce qui doit être vrai, c'est que les + # sources soient dans des conteneurs et non une entrée ZIP chacune. + self.assertGreater(rep["packs"] + rep["archives"], 0) def test_a_sample_matches_the_source(self): """La seule vérification qui prouve un transfert FIDÈLE.""" rep = cbt.check(MOBILE, REPO) self.assertGreater(rep["compared"], 0) - def test_the_indexes_are_packed_not_file_per_source(self): - """Le garde-fou de la limite du ZIP : chaque fichier doit porter sa - tranche. Sans « chunk », c'est un fichier par source, et l'APK sera - refusé — mais bien plus tard, et sans dire pourquoi.""" + def test_the_apk_stays_under_the_zip_entry_limit(self): + """Le garde-fou de la limite du ZIP, énoncé comme l'invariant qu'il est. + + Ce test exigeait un champ « chunk » sur chaque fichier, c'est-à-dire la + disposition en packs. Il échouait donc sur une compilation en archives + alors que la limite y est tenue — il testait la FORME au lieu de ce qui + importe : combien d'entrées le ZIP de l'APK va porter. Un fichier par + source en réclamerait 124 350 pour un plafond de 65 535.""" man = json.loads((self.repos / "manifest.json").read_text()) - checked = 0 - for proj in man[:5]: - index = self.repos / proj["slug"] / "index.json" - entries = json.loads(index.read_text()) - files = [e for e in entries if e.get("type") == "file"] - if not files: + entries_in_apk = 0 + files = 0 + for proj in man: + index = cbt.index_path(self.repos, proj) + listing = json.loads(index.read_text()) + files += len([e for e in listing if e.get("type") == "file"]) + entries_in_apk += 1 # l'index lui-même + if cbt.archive_path(self.repos, proj) is not None: + entries_in_apk += 1 # une archive continue - self.assertTrue( - all("chunk" in e for e in files), - f"{proj['slug']} : des fichiers sans tranche", + entries_in_apk += len( + list((self.repos / proj["slug"]).glob("pack-*.bin")) ) - checked += 1 - self.assertGreater(checked, 0, "aucun dépôt à vérifier") + self.assertGreater(files, cbt.MIN_FILES, "aucun dépôt à vérifier") + self.assertLess( + entries_in_apk, + ZIP_ENTRY_LIMIT, + f"{entries_in_apk} entrées dans l'APK pour {files} fichiers", + ) + # Et pas seulement « sous la limite » : l'écart doit rester d'un ordre + # de grandeur, sans quoi le prochain dépôt ajouté la ferait sauter. + self.assertLess(entries_in_apk, files // 10) def test_no_bundled_test_file_lingers_as_a_source(self): """Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers