Le contournement a vécu : les dépôts n'étaient plus embarqués du tout, l'APK était refusé pour ses 123 678 entrées quand un ZIP en tient 65 535. Ils entrent désormais en packs — tranches de 4 Mo et un index par dépôt disant où trouver chaque fichier — ce qui ramène le compte à 391 entrées sans rien perdre du contenu. Le côté application est dans le dépôt mobile ; ce commit porte la vérification et retire le contournement. Mesuré sur une VM : 139 dépôts, 116 156 fichiers, APK de 282 Mo à 3 002 entrées, et 20 fichiers relus depuis les packs identiques octet pour octet à leur source. L'installation le vérifie et échoue sinon : une application qui ne porte pas le code qu'elle doit montrer n'est pas celle demandée. --- EN --- The stopgap has served its time: the repositories were not embedded at all, and the APK was refused for its 123,678 entries where a ZIP holds 65,535. They now enter as packs — 4 MB slices and one index per repository saying where each file lives — which brings the count to 391 entries without losing any content. The app side lives in the mobile repository; this commit carries the verification and drops the workaround. Measured on a VM: 139 repositories, 116,156 files, a 282 MB APK with 3,002 entries, and 20 files read back from the packs identical byte for byte to their source. The install verifies it and fails otherwise: an app that does not carry the code it must show is not the one that was asked for. Assisted-by: Claude Opus 5
167 lines
5.8 KiB
Python
Executable file
167 lines
5.8 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""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.
|
|
|
|
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.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris
|
|
# pour bon. Le seul dépôt odoo en porte près de 40 000 à lui seul.
|
|
MIN_FILES = 1000
|
|
# Échantillon relu octet pour octet. Tout relire prendrait des minutes pour ne
|
|
# rien apprendre de plus : une tranche fausse l'est dès le premier extrait.
|
|
SAMPLE = 20
|
|
SEED = 7
|
|
|
|
|
|
def _read_json(path: Path):
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def pack_path(repo_dir: Path, chunk: int) -> Path:
|
|
"""Nom de tranche, tel que l'écrit le plugin de compilation."""
|
|
return repo_dir / ("pack-%03d.bin" % chunk)
|
|
|
|
|
|
def read_from_pack(repo_dir: Path, entry: dict) -> bytes:
|
|
"""Relit un fichier depuis sa tranche. Lève si l'index et le pack mentent."""
|
|
chunk = entry["chunk"]
|
|
path = pack_path(repo_dir, chunk)
|
|
size = entry.get("size", 0)
|
|
offset = entry.get("offset", 0)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"tranche absente : {path.name}")
|
|
if offset + size > path.stat().st_size:
|
|
raise ValueError(
|
|
f"{path.name} fait {path.stat().st_size} o, l'index y demande"
|
|
f" {size} o à {offset}"
|
|
)
|
|
with open(path, "rb") as fh:
|
|
fh.seek(offset)
|
|
data = fh.read(size)
|
|
if len(data) != size:
|
|
raise ValueError(f"{path.name} : {len(data)} o lus au lieu de {size}")
|
|
return data
|
|
|
|
|
|
def check(
|
|
mobile_root: Path, workspace: Path = None, min_files: int = None
|
|
) -> dict:
|
|
"""Vérifie le transfert et rend un compte-rendu.
|
|
|
|
`workspace` : racine du checkout ERPLibre. Fournie, un échantillon est
|
|
comparé OCTET POUR OCTET à la source — c'est la seule vérification qui
|
|
prouve un transfert fidèle, et non seulement cohérent.
|
|
"""
|
|
# Résolu à l'APPEL, et non dans la signature : un défaut lié à la
|
|
# définition ne suit pas la constante si un appelant la change.
|
|
min_files = MIN_FILES if min_files is None else min_files
|
|
base = mobile_root / "dist" / "repos"
|
|
manifest = base / "manifest.json"
|
|
if not manifest.is_file():
|
|
raise FileNotFoundError(
|
|
f"aucun transfert : {manifest} est absent (npm run build ?)"
|
|
)
|
|
repos = _read_json(manifest)
|
|
report = {
|
|
"repos": len(repos),
|
|
"files": 0,
|
|
"packs": 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")]
|
|
|
|
if report["files"] < min_files:
|
|
raise ValueError(
|
|
f"transfert trop maigre : {report['files']} fichiers pour"
|
|
f" {report['repos']} dépôts (au moins {min_files} attendus)"
|
|
)
|
|
|
|
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)
|
|
report["checked"] += 1
|
|
if workspace is None:
|
|
continue
|
|
src = workspace / proj["path"] / entry["path"]
|
|
if not src.is_file():
|
|
continue
|
|
if data != src.read_bytes():
|
|
raise ValueError(
|
|
f"{proj['slug']} : {entry['path']} diffère de la source"
|
|
)
|
|
report["compared"] += 1
|
|
return report
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"mobile_root",
|
|
nargs="?",
|
|
default="mobile/erplibre_home_mobile",
|
|
help="racine du dépôt mobile (défaut : mobile/erplibre_home_mobile)",
|
|
)
|
|
parser.add_argument(
|
|
"--workspace",
|
|
default="",
|
|
help="racine ERPLibre, pour comparer un échantillon à la source",
|
|
)
|
|
args = parser.parse_args()
|
|
root = Path(args.mobile_root)
|
|
ws = Path(args.workspace) if args.workspace else None
|
|
try:
|
|
rep = check(root, ws)
|
|
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
print(f" ⚠ transfert des dépôts : {exc}")
|
|
return 1
|
|
extra = (
|
|
f", {rep['compared']} comparés à la source" if rep["compared"] else ""
|
|
)
|
|
print(
|
|
f" {rep['repos']} dépôts, {rep['files']} fichiers en"
|
|
f" {rep['packs']} tranches ({rep['checked']} relus{extra})"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|