diff --git a/.claude/skills/erplibre-commands/SKILL.md b/.claude/skills/erplibre-commands/SKILL.md index 1287bb7..0c5ff0d 100644 --- a/.claude/skills/erplibre-commands/SKILL.md +++ b/.claude/skills/erplibre-commands/SKILL.md @@ -34,7 +34,17 @@ make db_clone_test_to_test2 # Cloner test -> test2 ``` ## Tests -### Suite complète +### Unitaires python (rapides, sans base de données) +```bash +make test_unit # tout ce qui tourne sans DB ni Odoo +make test_unit_file F=test/test_qemu_motd.py # un seul fichier +``` +Ils lisent le code et exécutent les fragments de shell générés par `todo.py`, +avec `sudo`, `pgrep` et `pkill` bouchonnés — quelques secondes. Le lanceur +annonce sa dépendance à `mobile/erplibre_home_mobile` : absent, les tests du +transfert mobile se déclarent ignorés au lieu de passer en silence. + +### Suite complète (base de données requise) ```bash make test # Tests de base + format make test_full_fast # Tests complets en parallèle diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 199bf0d..35ae733 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -98,6 +98,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced - The website copy analysis diffs each copy against the view it shadows - A test checks that what the tooling imports is declared +- Automated Odoo migration: the tool drives the whole run -- repair, replay, an auto-run that takes the default after five seconds, and a countdown that names the answer it is about to take +- Migration state screen: « t » shows where a run stands, with coloured commands, elapsed time, the server log read for you, and one log file per step on disk +- Migration quality: what a run gained and lost step by step, the missing files named, Odoo's own redesigns told apart from real losses, and OpenUpgrade's declared changes laid over the real ones +- Migration repairs: the customized SCSS the next bump breaks, predicted then fixed; themes uninstalled before the first bump; DMS visibility restored; views whose stored type contradicts their parent; and the tags Odoo 18 renamed to +- Smoke tests after a migration: every public URL requested, /my and every app opened as the neutralization test user, the views behind a failing URL named, and the OCA database cleanup run first +- Filestore check: whether the attachment files landed, whether the record still exists, and the cleanups offered on the spot +- Analysis: the modules a database lacks against the default package, with an offer to install those that are ready, and the attachment files that are truly unrecoverable +- Debian on s390x through debian-installer, no cloud image being published for that architecture +- A mobile development VM: PyCharm, Android Studio, GNOME extensions, an Android emulator and an adb tunnel for scrcpy, with the mobile app built and tested inside it +- Forgejo, a git forge installable from the deployment menu +- VM hardware set per machine: the host GPU, the CPU mode, the screens and the network +- virt-viewer opens a VM screen from the menu +- 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 @@ -160,6 +174,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce - L'analyse des copies de site compare chaque copie à la vue qu'elle masque - Un test vérifie que ce qu'importe l'outillage est bien déclaré +- Migration Odoo automatisée : l'outil mène toute l'exécution — réparer, rejouer, un déroulement automatique qui prend le défaut au bout de cinq secondes, et un compte à rebours qui nomme la réponse qu'il va prendre +- Écran d'état de migration : « t » montre où en est une exécution, avec les commandes en couleur, la durée écoulée, le journal du serveur lu pour vous, et un fichier de journal par étape sur disque +- Qualité de migration : ce qu'une exécution a gagné et perdu étape par étape, les fichiers manquants nommés, les refontes propres à Odoo distinguées des vraies pertes, et les changements déclarés par OpenUpgrade superposés aux réels +- Réparations de migration : le SCSS personnalisé que le palier suivant casse, prévu puis corrigé ; les thèmes désinstallés avant le premier palier ; la visibilité DMS rétablie ; les vues dont le type stocké contredit leur parent ; et les balises qu'Odoo 18 a renommées en +- Tests de fumée après une migration : chaque URL publique demandée, /my et chaque application ouverte sous l'utilisateur de test de neutralisation, les vues derrière une URL en échec nommées, et le nettoyage de base OCA passé d'abord +- Vérification du filestore : si les fichiers joints sont bien arrivés, si l'enregistrement existe encore, et les nettoyages proposés sur place +- Analyse : les modules qui manquent à une base par rapport au paquet par défaut, avec une offre d'installer ceux qui sont prêts, et les fichiers joints réellement irrécupérables +- Debian sur s390x par debian-installer, aucune image cloud n'étant publiée pour cette architecture +- Une VM de développement mobile : PyCharm, Android Studio, extensions GNOME, un émulateur Android et un tunnel adb pour scrcpy, l'application mobile étant compilée et testée dedans +- Forgejo, une forge git installable depuis le menu de déploiement +- Matériel réglé par VM : le GPU de l'hôte, le mode CPU, les écrans et le réseau +- virt-viewer ouvre l'écran d'une VM depuis le menu +- 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 ## Removed @@ -204,6 +232,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Copyright year updated to 2026 - Canadian pacman mirrors placed first on Arch, the official geographic mirror measuring four times slower from Montréal - A Poetry dependency can be declined per architecture: factur-x is pinned to 3.x on s390x, where saxonche publishes no wheel, and PyMuPDF is set aside there +- Enter targets the highest supported Odoo version, the default being computed from the menu +- A make target runs the unit tests, with the mobile dependency declared @@ -232,6 +262,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Année de copyright portée à 2026 - Miroirs pacman canadiens placés en tête sur Arch, le miroir « géographique » officiel mesurant quatre fois plus lent depuis Montréal - Une dépendance Poetry peut être déclinée par architecture : factur-x est épinglé en 3.x sur s390x, où saxonche ne publie pas de roue, et PyMuPDF y est écarté +- Entrée cible la version d'Odoo la plus élevée supportée, le défaut étant calculé depuis le menu +- Une cible make lance les tests unitaires, avec la dépendance mobile déclarée ## Fixed @@ -255,6 +287,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported - The COW migration tools and the database upgrade speak the system language - The analysis and migration tools are executable +- The forgejo installer no longer echoes the administrator password it has just set +- The Selenium login re-sent the configured default instead of the credentials it was given, when it retried after dismissing a modal +- db_restore asks the master password again instead of dying on a typo +- pyproj needs the proj binary, not only its headers, and PROJ is built where the distribution lags behind +- run.sh is launched through bash, against systemd's 203/EXEC failures +- pykcs11 compiles with SWIG 4.3 and above +- os-release replaces lsb_release, and an IP collision is easier to see @@ -274,6 +313,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées - Les outils de migration COW et la mise à niveau de la base parlent la langue du système - Les outils d'analyse et de migration sont exécutables +- L'installateur forgejo ne réaffiche plus le mot de passe administrateur qu'il vient de poser +- La connexion Selenium renvoyait le défaut de configuration au lieu des identifiants reçus, lors de la reprise après une modale +- db_restore redemande le mot de passe maître au lieu de mourir sur une faute de frappe +- pyproj exige le binaire proj, pas seulement ses en-têtes, et PROJ est bâti là où la distribution est en retard +- run.sh est lancé par bash, contre les échecs 203/EXEC de systemd +- pykcs11 se compile avec SWIG 4.3 et au-delà +- os-release remplace lsb_release, et une collision d'IP se voit mieux ## Security @@ -282,11 +328,16 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Passwords and tokens are redacted before a command is displayed or logged +- The Odoo master password no longer travels on the command line: MASTER_PWD carries it, and /proc//environ is readable only by its owner where /proc//cmdline is readable by every user on the machine (needs the matching commit in the odoo fork) +- The KeePass password reaches the Selenium login the same way: the command carries the NAME of an environment variable, never the value +- What a command PRINTS is redacted like the command itself: a tool that reprints its own arguments no longer puts the secret back into the terminal and into the log file -- Les mots de passe et jetons sont caviardés avant l'affichage ou la - journalisation d'une commande +- Les mots de passe et jetons sont caviardés avant l'affichage ou la journalisation d'une commande +- Le mot de passe maître d'Odoo ne voyage plus sur la ligne de commande : MASTER_PWD le porte, et /proc//environ n'est lisible que par son propriétaire là où /proc//cmdline l'est par tout utilisateur de la machine (exige le commit correspondant dans le fork odoo) +- Le mot de passe KeePass parvient à la connexion Selenium de la même façon : la commande porte le NOM d'une variable d'environnement, jamais la valeur +- Ce qu'une commande AFFICHE est caviardé comme la commande elle-même : un outil qui réaffiche ses propres arguments ne remet plus le secret dans le terminal ni dans le fichier de journal diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0a4386c..94d56b9 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -74,6 +74,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Migration : revenir à une étape depuis l'écran de reprise ou depuis une invite, et agir sur les copies COW dès leur annonce - L'analyse des copies de site compare chaque copie à la vue qu'elle masque - Un test vérifie que ce qu'importe l'outillage est bien déclaré +- Migration Odoo automatisée : l'outil mène toute l'exécution — réparer, rejouer, un déroulement automatique qui prend le défaut au bout de cinq secondes, et un compte à rebours qui nomme la réponse qu'il va prendre +- Écran d'état de migration : « t » montre où en est une exécution, avec les commandes en couleur, la durée écoulée, le journal du serveur lu pour vous, et un fichier de journal par étape sur disque +- Qualité de migration : ce qu'une exécution a gagné et perdu étape par étape, les fichiers manquants nommés, les refontes propres à Odoo distinguées des vraies pertes, et les changements déclarés par OpenUpgrade superposés aux réels +- Réparations de migration : le SCSS personnalisé que le palier suivant casse, prévu puis corrigé ; les thèmes désinstallés avant le premier palier ; la visibilité DMS rétablie ; les vues dont le type stocké contredit leur parent ; et les balises qu'Odoo 18 a renommées en +- Tests de fumée après une migration : chaque URL publique demandée, /my et chaque application ouverte sous l'utilisateur de test de neutralisation, les vues derrière une URL en échec nommées, et le nettoyage de base OCA passé d'abord +- Vérification du filestore : si les fichiers joints sont bien arrivés, si l'enregistrement existe encore, et les nettoyages proposés sur place +- Analyse : les modules qui manquent à une base par rapport au paquet par défaut, avec une offre d'installer ceux qui sont prêts, et les fichiers joints réellement irrécupérables +- Debian sur s390x par debian-installer, aucune image cloud n'étant publiée pour cette architecture +- Une VM de développement mobile : PyCharm, Android Studio, extensions GNOME, un émulateur Android et un tunnel adb pour scrcpy, l'application mobile étant compilée et testée dedans +- Forgejo, une forge git installable depuis le menu de déploiement +- Matériel réglé par VM : le GPU de l'hôte, le mode CPU, les écrans et le réseau +- virt-viewer ouvre l'écran d'une VM depuis le menu +- 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 ## Retiré @@ -106,6 +120,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Année de copyright portée à 2026 - Miroirs pacman canadiens placés en tête sur Arch, le miroir « géographique » officiel mesurant quatre fois plus lent depuis Montréal - Une dépendance Poetry peut être déclinée par architecture : factur-x est épinglé en 3.x sur s390x, où saxonche ne publie pas de roue, et PyMuPDF y est écarté +- Entrée cible la version d'Odoo la plus élevée supportée, le défaut étant calculé depuis le menu +- Une cible make lance les tests unitaires, avec la dépendance mobile déclarée ## Corrigé @@ -125,11 +141,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Le README ne listait ni Fedora, ni openSUSE, ni Linux Mint, ni Debian 13, toutes supportées - Les outils de migration COW et la mise à niveau de la base parlent la langue du système - Les outils d'analyse et de migration sont exécutables +- L'installateur forgejo ne réaffiche plus le mot de passe administrateur qu'il vient de poser +- La connexion Selenium renvoyait le défaut de configuration au lieu des identifiants reçus, lors de la reprise après une modale +- db_restore redemande le mot de passe maître au lieu de mourir sur une faute de frappe +- pyproj exige le binaire proj, pas seulement ses en-têtes, et PROJ est bâti là où la distribution est en retard +- run.sh est lancé par bash, contre les échecs 203/EXEC de systemd +- pykcs11 se compile avec SWIG 4.3 et au-delà +- os-release remplace lsb_release, et une collision d'IP se voit mieux ## Sécurité -- Les mots de passe et jetons sont caviardés avant l'affichage ou la - journalisation d'une commande +- Les mots de passe et jetons sont caviardés avant l'affichage ou la journalisation d'une commande +- Le mot de passe maître d'Odoo ne voyage plus sur la ligne de commande : MASTER_PWD le porte, et /proc//environ n'est lisible que par son propriétaire là où /proc//cmdline l'est par tout utilisateur de la machine (exige le commit correspondant dans le fork odoo) +- Le mot de passe KeePass parvient à la connexion Selenium de la même façon : la commande porte le NOM d'une variable d'environnement, jamais la valeur +- Ce qu'une commande AFFICHE est caviardé comme la commande elle-même : un outil qui réaffiche ses propres arguments ne remet plus le secret dans le terminal ni dans le fichier de journal ## [1.6.0] - 2025-04-25 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2917223..e3b92ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,20 @@ Recreating the virtual environment, use installation guide from tool `make`. - Migration: go back to a step from the resume screen or from a prompt, and act on the COW copies as soon as they are announced - The website copy analysis diffs each copy against the view it shadows - A test checks that what the tooling imports is declared +- Automated Odoo migration: the tool drives the whole run -- repair, replay, an auto-run that takes the default after five seconds, and a countdown that names the answer it is about to take +- Migration state screen: « t » shows where a run stands, with coloured commands, elapsed time, the server log read for you, and one log file per step on disk +- Migration quality: what a run gained and lost step by step, the missing files named, Odoo's own redesigns told apart from real losses, and OpenUpgrade's declared changes laid over the real ones +- Migration repairs: the customized SCSS the next bump breaks, predicted then fixed; themes uninstalled before the first bump; DMS visibility restored; views whose stored type contradicts their parent; and the tags Odoo 18 renamed to +- Smoke tests after a migration: every public URL requested, /my and every app opened as the neutralization test user, the views behind a failing URL named, and the OCA database cleanup run first +- Filestore check: whether the attachment files landed, whether the record still exists, and the cleanups offered on the spot +- Analysis: the modules a database lacks against the default package, with an offer to install those that are ready, and the attachment files that are truly unrecoverable +- Debian on s390x through debian-installer, no cloud image being published for that architecture +- A mobile development VM: PyCharm, Android Studio, GNOME extensions, an Android emulator and an adb tunnel for scrcpy, with the mobile app built and tested inside it +- Forgejo, a git forge installable from the deployment menu +- VM hardware set per machine: the host GPU, the CPU mode, the screens and the network +- virt-viewer opens a VM screen from the menu +- 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 ## Removed @@ -104,6 +118,8 @@ Recreating the virtual environment, use installation guide from tool `make`. - Copyright year updated to 2026 - Canadian pacman mirrors placed first on Arch, the official geographic mirror measuring four times slower from Montréal - A Poetry dependency can be declined per architecture: factur-x is pinned to 3.x on s390x, where saxonche publishes no wheel, and PyMuPDF is set aside there +- Enter targets the highest supported Odoo version, the default being computed from the menu +- A make target runs the unit tests, with the mobile dependency declared ## Fixed @@ -123,10 +139,20 @@ Recreating the virtual environment, use installation guide from tool `make`. - The README listed neither Fedora, openSUSE, Linux Mint nor Debian 13, all of them supported - The COW migration tools and the database upgrade speak the system language - The analysis and migration tools are executable +- The forgejo installer no longer echoes the administrator password it has just set +- The Selenium login re-sent the configured default instead of the credentials it was given, when it retried after dismissing a modal +- db_restore asks the master password again instead of dying on a typo +- pyproj needs the proj binary, not only its headers, and PROJ is built where the distribution lags behind +- run.sh is launched through bash, against systemd's 203/EXEC failures +- pykcs11 compiles with SWIG 4.3 and above +- os-release replaces lsb_release, and an IP collision is easier to see ## Security - Passwords and tokens are redacted before a command is displayed or logged +- The Odoo master password no longer travels on the command line: MASTER_PWD carries it, and /proc//environ is readable only by its owner where /proc//cmdline is readable by every user on the machine (needs the matching commit in the odoo fork) +- The KeePass password reaches the Selenium login the same way: the command carries the NAME of an environment variable, never the value +- What a command PRINTS is redacted like the command itself: a tool that reprints its own arguments no longer puts the secret back into the terminal and into the log file ## [1.6.0] - 2025-04-25 diff --git a/conf/make.test.Makefile b/conf/make.test.Makefile index b636aa6..30625a9 100644 --- a/conf/make.test.Makefile +++ b/conf/make.test.Makefile @@ -4,6 +4,31 @@ # TODO load specific test file : ./run.sh -d test_file --log-level=test --test-enable --stop-after-init --test-file ./.venv.erplibre/test.py +########################### +# Tests unitaires python # +########################### +# Ni base de données, ni Odoo, ni VM : ils lisent le code et exécutent les +# fragments de shell que todo.py génère, « sudo », « pgrep » et « pkill » +# bouchonnés. Une dizaine de secondes, là où « make test » demande une base et +# plusieurs minutes — d'où une cible à part, faite pour être lancée souvent. +# +# DÉPENDANCE : les tests du transfert mobile lisent +# mobile/erplibre_home_mobile. Le lanceur l'annonce présent ou absent avant de +# commencer, et les tests concernés se déclarent ignorés plutôt que de passer +# en silence — un test vert sans son dépôt ne prouve rien. +# +# « test_todo.py » n'en fait PAS partie : il demande une base de données. Son +# nom sans souligné le laisse hors du motif, et c'est voulu. +.PHONY: test_unit +test_unit: + ./script/test/run_unit_test.sh + +# Un seul fichier, pour la boucle d'écriture : +# make test_unit_file F=test/test_qemu_forgejo.py +.PHONY: test_unit_file +test_unit_file: + ./script/test/run_unit_test.sh $(F) + .PHONY: open_test_coverage open_test_coverage: -$(BROWSER) htmlcov/index.html diff --git a/mobile/compile_and_run.sh b/mobile/compile_and_run.sh index 6553a7f..151eca1 100755 --- a/mobile/compile_and_run.sh +++ b/mobile/compile_and_run.sh @@ -5,10 +5,26 @@ if [[ ! -d "./mobile/erplibre_home_mobile" ]]; then exit 1 fi -cd mobile/erplibre_home_mobile +WORKSPACE="$(pwd)" + +cd mobile/erplibre_home_mobile || exit 1 npm install -npm run build && npx cap sync +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. +# +# Même vérification que l'installation d'une VM, même script : une seule +# autorité. +"${WORKSPACE}/script/mobile/check_bundle_transfer.py" . --workspace "${WORKSPACE}" || exit 1 + +npx cap sync || exit 1 npx cap run android -cd - +cd - || exit 1 diff --git a/script/database/db_restore.py b/script/database/db_restore.py index 8cbac35..a394f4f 100755 --- a/script/database/db_restore.py +++ b/script/database/db_restore.py @@ -16,6 +16,8 @@ sys.path.append( os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) ) +from script.execute.execute import redact_secrets + logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) _logger = logging.getLogger(__name__) @@ -102,17 +104,23 @@ def password_refused(sortie): return "AccessDenied" in (sortie or "") -def probe_master_password(arg_base): +def probe_master_password(arg_base, mot): """(accepté, sortie) — éprouver le mot de passe sur `--list`. La commande la plus inoffensive : elle ne touche à rien et rend le même refus qu'une restauration. Valider ici évite d'échouer à mi-parcours, une fois la base déjà supprimée. + + Le secret passe par l'environnement, jamais par argv : + /proc//cmdline est lisible par tout utilisateur de la machine. """ + env = os.environ.copy() + env["MASTER_PWD"] = mot done = subprocess.run( f"{arg_base} --list".split(" "), capture_output=True, text=True, + env=env, ) return done.returncode == 0, (done.stdout or "") + (done.stderr or "") @@ -131,14 +139,13 @@ def ask_master_password(arg_base, essais=MAX_ESSAIS_MOT_DE_PASSE): mot = get_master_password() if not mot: return None - candidat = f"{arg_base} --master_password={mot}" - accepte, sortie = probe_master_password(candidat) + accepte, sortie = probe_master_password(arg_base, mot) if accepte: return mot if not password_refused(sortie): # Autre chose est cassé : le dire, et ne pas noyer la panne # sous dix invites de mot de passe. - _logger.error(sortie.strip()[-1500:]) + _logger.error(redact_secrets(sortie.strip()[-1500:])) return None restants = essais - tour if restants: @@ -232,7 +239,7 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache): f"{arg_base} --restore" f" --restore_image {config.image} --database {cache_database}" ) - print(check_output(arg.split(" ")).decode()) + print(redact_secrets(check_output(arg.split(" ")).decode())) verify_filestore(cache_database, config.image) if config.ignore_cache: @@ -254,8 +261,11 @@ def restore_or_clone(config, arg_base, cache_database, lst_db_cache): ) if config.neutralize: arg += " --neutralize" - print(arg) - print(check_output(arg.split(" ")).decode()) + # Le secret ne traverse plus argv (il est dans MASTER_PWD), mais la + # commande peut porter d'autres options sensibles : on filtre quand + # même, le coût est nul et la garantie ne dépend alors d'aucun appelant. + print(redact_secrets(arg)) + print(redact_secrets(check_output(arg.split(" ")).decode())) if config.ignore_cache: verify_filestore(config.database, config.image) @@ -287,7 +297,10 @@ def main(): if not master_password: _logger.error("Missing master password, cancel transaction.") sys.exit(1) - arg_base += f" --master_password={master_password}" + # Dans l'ENVIRONNEMENT, pas dans arg_base : tous les appels + # suivants sont des enfants de ce processus et en héritent, + # sans que le secret traverse jamais argv. + os.environ["MASTER_PWD"] = master_password else: _logger.info("No master password needed... Continue") @@ -298,7 +311,7 @@ def main(): for db in lst_db_cache: _logger.info(f"## Delete {db} ##") arg = f"{arg_base} --drop --database {db}" - out = check_output(arg.split(" ")).decode() + out = redact_secrets(check_output(arg.split(" ")).decode()) print(out) lst_db, lst_db_cache = get_list_db_cache(arg_base) @@ -308,7 +321,7 @@ def main(): if config.database in lst_db: _logger.info(f"## Drop {config.database} ##") arg = f"{arg_base} --drop --database {config.database}" - out = check_output(arg.split(" ")).decode() + out = redact_secrets(check_output(arg.split(" ")).decode()) print(out) if config.only_drop: return diff --git a/script/execute/execute.py b/script/execute/execute.py index dcb360f..a042323 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -19,10 +19,15 @@ except ModuleNotFoundError as e: VENV_ERPLIBRE = ".venv.erplibre" # Une commande construite ailleurs peut porter un secret en clair : todo.py et -# kdbx_manager.py y mettent « --default_password_auth '' », -# db_restore.py « --master_password=… ». Cette commande est affichée avant et -# après l'exécution, et journalisée en erreur : le secret finissait donc dans le -# terminal, dans les journaux et dans toute sortie CI qui les capture. +# kdbx_manager.py y mettent « --default_password_auth '' ». +# Cette commande est affichée avant et après l'exécution, et journalisée en +# erreur : le secret finissait donc dans le terminal, dans les journaux et dans +# toute sortie CI qui les capture. +# +# Ce filtre reste le dernier rempart, pas le premier : un secret n'a rien à +# faire sur argv, que /proc//cmdline expose à tout utilisateur de la +# machine et qu'aucun caviardage n'atteint. db_restore.py est passé à +# MASTER_PWD dans l'environnement pour cette raison. # # On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et # reproductible, il ne manque que ce qui ne doit pas être lu. @@ -183,6 +188,11 @@ class Execute: line = process.stdout.readline() if not line: break + # La sortie du sous-processus passe par le meme filtre que + # la commande : un outil qui reaffiche ses propres arguments + # (« set -x », une trace, odoo_bin.sh) y remettrait le secret + # que la ligne 165 venait d'ecarter. + line = redact_secrets(line) if not quiet: print(line, end="") if sink: diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh index d2f873b..ca312c3 100755 --- a/script/forgejo/install_forgejo.sh +++ b/script/forgejo/install_forgejo.sh @@ -341,7 +341,11 @@ else --email "$ADMIN_EMAIL" --must-change-password=false \ --config "$CONF" >/dev/null \ || die "création de l'administrateur impossible" - say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}" + # Le mot de passe n'est PAS réaffiché : cette sortie part dans les + # journaux d'installation et dans toute capture de CI. Celui qui a + # posé FORGEJO_ADMIN_PASSWORD le connaît déjà ; les autres ont le + # défaut, documenté en tête de ce fichier. + say "${Green}administrateur créé : $ADMIN_USER${Color_Off}" fi # --- 9. Résumé ------------------------------------------------------------- diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index 52e1b4e..908f523 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -2,6 +2,7 @@ . ./env_var.sh . ./script/install/lib_qpdf.sh +. ./script/install/lib_proj.sh . ./script/install/lib_lowmem.sh EL_USER=${USER} @@ -163,7 +164,7 @@ if [ "$(uname -m)" = "s390x" ]; then apt_install_batch rust-all libqpdf-dev libgeos-dev libproj-dev proj-bin \ proj-data "${GEO_DEV}" freetds-dev freetds-bin libkrb5-dev libssl-dev \ pkg-config build-essential zlib1g-dev libjpeg-dev libtbb-dev cmake \ - ninja-build + ninja-build libsqlite3-dev sqlite3 libtiff-dev libcurl4-openssl-dev if [[ -n "${APT_FAILED}" ]]; then # Seul un paquet dont dépend la SUITE immédiate est bloquant. Les autres # servent des modules Odoo optionnels : les rendre fatals immobiliserait @@ -202,6 +203,10 @@ if [ "$(uname -m)" = "s390x" ]; then # -- seuil, version batie, chemin d'installation -- est dans lib_qpdf.sh, # partage avec les scripts dnf et zypper qui butaient sur le meme mur. el_qpdf_ensure + # pyproj exige PROJ 9.4 ; bookworm en livre 9.1.1. Meme mecanique que + # qpdf, et meme portee etroite : ailleurs pyproj pose une roue qui + # embarque sa propre PROJ, ici il compile contre celle du systeme. + el_proj_ensure # cryptography ne publie aucune roue s390x : elle se compile, et son # Cargo.lock est en version 4, que seul cargo >= 1.78 sait lire. Ubuntu 24.04 # livre 1.75 et s'arrête sur « lock file version 4 requires diff --git a/script/install/install_fedora_dependency.sh b/script/install/install_fedora_dependency.sh index eb571e4..b099307 100755 --- a/script/install/install_fedora_dependency.sh +++ b/script/install/install_fedora_dependency.sh @@ -115,9 +115,16 @@ if [ "$(uname -m)" = "s390x" ]; then # roues masquent le besoin ; ici tout compile, et bcrypt s'arrête net sur # « error: can't find Rust compiler ». Les versions livrées suffisent # (AlmaLinux 1.92, Fedora plus récent) au Cargo.lock v4 qui exige 1.78. + # « proj » nommé À CÔTÉ de « proj-devel » : pyproj n'a pas de roue s390x, il + # compile, et sa configuration EXÉCUTE le binaire « proj » pour localiser + # l'installation — les en-têtes seules ne suffisent pas. Ici le paquet + # principal porte /usr/bin/proj et « proj-devel » l'exige, donc l'arriver + # transitivement fonctionnerait ; on le nomme quand même, comme apt le fait + # avec « proj-bin », parce que openSUSE a prouvé que cette arête n'existe pas + # partout — elle y manque, et l'échec n'apparaît qu'au build. ${DNF} \ rust cargo \ - libjpeg-turbo-devel zlib-devel geos-devel proj-devel \ + libjpeg-turbo-devel zlib-devel geos-devel proj proj-devel \ krb5-devel tbb-devel ninja-build clang-devel llvm-devel \ GeographicLib-devel pkgconf-pkg-config cmake diff --git a/script/install/install_locally.sh b/script/install/install_locally.sh index d73d13d..0955fee 100755 --- a/script/install/install_locally.sh +++ b/script/install/install_locally.sh @@ -166,6 +166,29 @@ if [[ "${EL_PHASE}" != "setup" ]]; then "${POETRY_ODOO_PATH}" --version # To fix keyring problem when installation is blocked, use export PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring + # pykcs11 — tiré par endesive, donc présent dans les locks 14, 15 et 17 — + # ne livre AUCUN wrapper pré-généré dans son sdist : SWIG tourne à CHAQUE + # installation. Et ce n'est pas le SWIG du système qui tourne : le + # « requires = ["setuptools", "swig"] » de pykcs11 n'est pas borné, donc + # Poetry télécharge la DERNIÈRE version publiée sur PyPI (4.5.0 le 21 août + # 2026, vue dans le journal d'installation). + # + # Or SWIG a retiré en 4.3 les alias Python 2 que ses versions antérieures + # écrivaient dans le code généré (PyInt_FromLong, PyString_Check…), et le + # typemap CK_RV de pykcs11 en utilise un. D'où, sur une VM Ubuntu 26.04: + # « ‘PyInt_FromLong’ was not declared in this scope », 55 fois, et + # install_odoo_17 s'arrête. Aucune sonde locale ne peut le prévoir — le + # SWIG qui tourne est choisi au moment du build, pas ici. + # + # On redonne l'alias au préprocesseur, dans la forme EXACTE que SWIG 4.2 + # écrivait : définition identique token pour token, donc aucun + # avertissement de redéfinition là où SWIG la fournit encore (vérifié en + # -Werror contre un wrapper généré par SWIG 4.2). + # + # CPPFLAGS et non CFLAGS : un .cpp passe par « compiler_so_cxx », qui lit + # CXXFLAGS et CPPFLAGS — CFLAGS ne l'atteint JAMAIS. Mesuré sur setuptools + # 84, et c'est ce qui a fait échouer le premier correctif. + export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }-DPyInt_FromLong(x)=PyLong_FromLong(x)" # « poetry install » reste à Poetry : uv ne lit pas poetry.lock # (astral-sh/uv#1804, « not planned ») et Poetry 2.1.3 n'a plus « export ». if [[ ${WITH_POETRY_INSTALLATION} -ne 0 ]]; then diff --git a/script/install/install_suse_dependency.sh b/script/install/install_suse_dependency.sh index 40eeed8..0c3cf77 100755 --- a/script/install/install_suse_dependency.sh +++ b/script/install/install_suse_dependency.sh @@ -262,8 +262,17 @@ if [ "$(uname -m)" = "s390x" ]; then # # GeographicLib est absent : openSUSE n'empaquette que le binding Python, # pas la bibliothèque C++. Rien à poser, contrairement à apt et dnf. + # proj le BINAIRE, pas seulement « proj-devel ». openSUSE éclate + # PROJ en trois paquets : libproj25 la bibliothèque, proj-devel + # les en-têtes, proj les outils en ligne de commande. pyproj ne + # se contente pas des en-têtes — il EXÉCUTE « proj » pour + # localiser l'installation, et sans lui s'arrête sur « proj + # executable not found. Please set the PROJ_DIR variable ». + # proj-devel ne le tire pas : la dépendance manque en silence + # jusqu'au build, et aucune roue s390x ne vient l'éviter. zyp_soft qpdf-devel libjpeg8-devel cmake pkgconf-pkg-config \ - tbb-devel geos-devel proj-devel krb5-devel ninja clang-devel llvm-devel + tbb-devel geos-devel proj proj-devel krb5-devel ninja clang-devel \ + llvm-devel # Tumbleweed livre qpdf 12.3.2 : l'appel ne fait que le constater. Il est là # pour que les trois familles suivent la même règle. el_qpdf_ensure diff --git a/script/install/lib_proj.sh b/script/install/lib_proj.sh new file mode 100644 index 0000000..5001bf4 --- /dev/null +++ b/script/install/lib_proj.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# PROJ assez récent pour pyproj, compilé depuis les sources quand la +# distribution est en retard. Calque de lib_qpdf.sh, pour la même raison et +# avec les mêmes garde-fous. +# +# QUI est concerné, et qui ne l'est pas : sur amd64 et arm64, pyproj publie +# des roues manylinux qui EMBARQUENT leur propre PROJ — rien n'est compilé et +# la version du système n'a aucune importance. s390x n'a pas de roue : pyproj +# se construit contre la PROJ du système, et refuse net si elle est trop +# vieille. +# +# ERROR: Minimum supported PROJ version is 9.4.0, installed version is 9.1.1 +# +# Mesuré sur Debian 12 (bookworm) s390x. Debian 13 livre 9.6 et passe sans +# rien faire ; ce fichier ne s'y déclenche donc pas. + +# Seuil réclamé par pyproj 3.7.x. Le monter suppose de vérifier ce qu'exige la +# version de pyproj réellement verrouillée dans poetry.lock. +EL_PROJ_MIN=9.4.0 +# Version bâtie quand le seuil n'est pas atteint. 9.6.x est la branche stable +# la plus récente à ce jour et couvre largement le seuil. +EL_PROJ_VER=9.6.2 + +# Vrai si la version passée en argument atteint le seuil. Même précaution que +# pour qpdf : « sort -V » classe « 9.4 » AVANT « 9.4.0 », donc une version +# numérotée sur deux composantes déclencherait une compilation inutile — et +# celle de PROJ se compte en dizaines de minutes sous émulation. +el_proj_ge_min() { + local v="$1" dots + [ -n "${v}" ] || return 1 + dots="${v//[^.]/}" + while [ "${#dots}" -lt 2 ]; do + v="${v}.0" + dots="${dots}." + done + [ "$(printf '%s\n%s\n' "${EL_PROJ_MIN}" "${v}" | sort -V | head -1)" = "${EL_PROJ_MIN}" ] +} + +# Version actuellement visible, ou « 0 ». /usr/local n'est pas dans le chemin +# par défaut de pkg-config partout : sans ces entrées, une PROJ déjà compilée +# passerait inaperçue et serait rebâtie à chaque passage. +el_proj_version() { + PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig:${PKG_CONFIG_PATH}" \ + pkg-config --modversion proj 2> /dev/null || echo 0 +} + +# Compile et installe PROJ dans /usr/local si, et seulement si, ce qui est +# présent ne suffit pas. Ne rend jamais un code non nul : l'échec est signalé +# et l'installation continue, pyproj dira lui-même ce qui manque. +el_proj_ensure() { + local have build dir + have="$(el_proj_version)" + if el_proj_ge_min "${have}"; then + echo "PROJ ${have} >= ${EL_PROJ_MIN} : rien a compiler pour pyproj." + return 0 + fi + + echo "PROJ ${have} < ${EL_PROJ_MIN} requis par pyproj : compilation de PROJ ${EL_PROJ_VER} (long en emulation)." + build="$(mktemp -d)" + # TESTING=OFF et les outils en moins : seule la bibliothèque intéresse + # pyproj, et la suite de tests de PROJ double le temps de compilation. + if curl -fsSL --max-time 900 -o "${build}/proj.tar.gz" \ + "https://download.osgeo.org/proj/proj-${EL_PROJ_VER}.tar.gz" \ + && tar -xzf "${build}/proj.tar.gz" -C "${build}" \ + && cmake -S "${build}/proj-${EL_PROJ_VER}" -B "${build}/build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DBUILD_TESTING=OFF -DBUILD_APPS=ON -DBUILD_SHARED_LIBS=ON \ + && cmake --build "${build}/build" -j"$(nproc)" \ + && sudo cmake --install "${build}/build"; then + # CMake choisit « lib » ou « lib64 » selon la famille, et /usr/local/lib64 + # n'est pas dans le chemin de ld.so partout : sans cette déclaration, + # pyproj se construirait pour échouer au CHARGEMENT, plus loin de la cause. + for dir in /usr/local/lib64 /usr/local/lib; do + if [ -e "${dir}/libproj.so" ]; then + echo "${dir}" | sudo tee /etc/ld.so.conf.d/proj-local.conf > /dev/null + break + fi + done + sudo ldconfig + echo "PROJ $(el_proj_version) installe dans /usr/local." + else + echo "Attention : compilation de PROJ echouee, pyproj ne pourra pas se construire." + fi + rm -rf "${build}" + return 0 +} diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index c253bf0..86f700e 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2712,7 +2712,13 @@ def build_installer_initrd( ) with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) - (work / "preseed.cfg").write_text(preseed, encoding="utf-8") + # Le mot de passe utilisateur y est HACHÉ (user-password-crypted). + # Reste celui de network-console, une valeur fixe et publique dont + # le composant est désactivé plus bas. Le répertoire temporaire est + # déjà en 0700 ; le mode explicite vaut pour qui lirait ce code. + cfg = work / "preseed.cfg" + cfg.touch(mode=0o600) + cfg.write_text(preseed, encoding="utf-8") members = ["preseed.cfg"] for path, _mode, content, _owner in guide or []: name = installer_guide_name(path) diff --git a/script/selenium/web_login.py b/script/selenium/web_login.py index e26fd67..d747711 100755 --- a/script/selenium/web_login.py +++ b/script/selenium/web_login.py @@ -32,7 +32,19 @@ def fill_parser(parser): group_login.add_argument( "--default_password_auth", default="admin", - help="Password to use to authenticate with admin.", + help=( + "Password to use to authenticate with admin. Prefer" + " --default_password_auth_env: a value given here travels" + " through argv, which every user on the machine can read." + ), + ) + group_login.add_argument( + "--default_password_auth_env", + default=None, + help=( + "NAME of an environment variable holding the password. Only" + " the name reaches the command line; the value never does." + ), ) @@ -71,10 +83,14 @@ def run( email_auth = ( default_email_auth if default_email_auth else config.default_email_auth ) + # L'environnement l'emporte : /proc//cmdline est lisible par tout + # utilisateur de la machine, /proc//environ par son seul + # propriétaire. Le nom de la variable, lui, n'est pas un secret. + pass_env = getattr(config, "default_password_auth_env", None) pass_auth = ( - default_password_auth - if default_password_auth - else config.default_password_auth + (pass_env and os.environ.get(pass_env)) + or default_password_auth + or config.default_password_auth ) courriel_input.clear() mot_de_passe_input.clear() @@ -100,9 +116,11 @@ def run( ) error_button.click() - # Remplissez le courriel et le mot de passe - courriel_input.send_keys(config.default_email_auth) - mot_de_passe_input.send_keys(config.default_password_auth) + # Les valeurs RÉSOLUES, pas celles du config : la reprise + # renvoyait le défaut « admin » dès qu'un identifiant avait été + # fourni autrement, et échouait sans dire pourquoi. + courriel_input.send_keys(email_auth) + mot_de_passe_input.send_keys(pass_auth) connexion_button.click() else: diff --git a/script/systemd/install_daemon.py b/script/systemd/install_daemon.py index 9f0ef6a..0369940 100755 --- a/script/systemd/install_daemon.py +++ b/script/systemd/install_daemon.py @@ -9,6 +9,20 @@ import subprocess import sys from pathlib import Path +# « ExecStart=/bin/bash …/run.sh » et non le script seul. +# +# Lancé seul, systemd doit EXÉCUTER le fichier, et l'échoue en « 203/EXEC » +# dans quatre cas au moins : bit x absent, shebang qui ne résout pas, /home +# monté noexec, SELinux refusant l'execve. Vécu sur openSUSE s390x — le +# processus mourait en 3 ms, sans jamais entrer dans le script, ce qui rend le +# diagnostic très pénible : aucune sortie, et un code qui ressemble à une +# erreur d'application. +# +# Passé à bash, run.sh n'est plus qu'une DONNÉE lue : les quatre causes +# disparaissent ensemble, y compris noexec et SELinux, qui ne portent que sur +# l'execve. Rien n'est perdu au passage — le shebang du script désigne déjà +# bash. C'est aussi ce que font install_daemon.sh et le générateur de todo.py, +# les trois écrivant la même unité. UNIT_TEMPLATE = """[Unit] Description=ERPLibre for {user} Requires=postgresql.service @@ -23,7 +37,8 @@ Group={user} Restart=always RestartSec=5 PIDFile={home_erplibre}/.venv.erplibre/service.pid -ExecStart={home_erplibre}/run.sh{EXEC_PARAM} +# bash explicite : voir install_daemon.py (evite 203/EXEC). +ExecStart=/bin/bash {home_erplibre}/run.sh{EXEC_PARAM} WorkingDirectory={home_erplibre} StandardOutput=journal+console diff --git a/script/systemd/install_daemon.sh b/script/systemd/install_daemon.sh index cfc2bd2..922872e 100755 --- a/script/systemd/install_daemon.sh +++ b/script/systemd/install_daemon.sh @@ -31,7 +31,7 @@ Group=${EL_USER} Restart=always RestartSec=5 PIDFile=${EL_HOME_ERPLIBRE}/.venv.erplibre/service.pid -ExecStart=${EL_HOME_ERPLIBRE}/run.sh +ExecStart=/bin/bash ${EL_HOME_ERPLIBRE}/run.sh WorkingDirectory=${EL_HOME_ERPLIBRE} StandardOutput=journal+console diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh new file mode 100755 index 0000000..f25c954 --- /dev/null +++ b/script/test/run_unit_test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Tests unitaires python du dépôt : ni base de données, ni Odoo, ni VM. +# +# Ils lisent le code et exécutent les fragments de shell que todo.py génère, +# avec « sudo », « pgrep » et « pkill » bouchonnés — c'est ce qui les rend +# lançables partout et en quelques secondes, là où « make test » demande une +# base et plusieurs minutes. +# +# DÉPENDANCE DÉCLARÉE : les tests du transfert mobile lisent +# mobile/erplibre_home_mobile. Absent, ils se disent ignorés plutôt que de +# passer en silence — un test vert sans son dépôt ne prouve rien. Ce script +# l'annonce donc avant de commencer. +# +# ./script/test/run_unit_test.sh [fichiers...] +set -uo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +cd "$(dirname "$0")/../.." || exit 1 + +PY=./.venv.erplibre/bin/python +if [[ ! -x "${PY}" ]]; then + echo -e "${Red}✗ ${PY} absent : lancer l'installation ERPLibre d'abord.${Color_Off}" + exit 1 +fi + +MOBILE=mobile/erplibre_home_mobile +if [[ -d "${MOBILE}" ]]; then + echo -e " dépendance ${MOBILE} : ${Green}présente${Color_Off}" +else + echo -e " dépendance ${MOBILE} : ${Yellow}absente${Color_Off}" + echo " (les tests du transfert mobile s'en passeront et le diront)" +fi + +FILES=("$@") +if [[ ${#FILES[@]} -eq 0 ]]; then + # Aucun argument : tout ce que le dépôt sait tester sans base de données. + mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ + test/test_todo_*.py test/test_install_*.py 2>/dev/null) +fi + +fail=0 +total=0 +for f in "${FILES[@]}"; do + out=$(PYTHONPATH=. "${PY}" "${f}" 2>&1) + ran=$(echo "${out}" | grep -oE 'Ran [0-9]+' | grep -oE '[0-9]+' | tail -1) + skipped=$(echo "${out}" | grep -oE 'skipped=[0-9]+' | tail -1) + if echo "${out}" | grep -qE '^OK'; then + state="${Green}OK${Color_Off}" + else + state="${Red}ÉCHEC${Color_Off}" + fail=1 + fi + total=$((total + ${ran:-0})) + printf " %-42s %5s tests %-14s %b\n" \ + "$(basename "${f}")" "${ran:-?}" "${skipped:-}" "${state}" + [[ "${state}" == *"ÉCHEC"* ]] && echo "${out}" | tail -12 +done + +echo " ─────" +if [[ ${fail} -eq 0 ]]; then + echo -e " ${Green}${total} tests, tout vert${Color_Off}" +else + echo -e " ${Red}des échecs ci-dessus${Color_Off}" +fi +exit ${fail} diff --git a/script/todo/kdbx_manager.py b/script/todo/kdbx_manager.py index 8a798f6..3b7ba59 100644 --- a/script/todo/kdbx_manager.py +++ b/script/todo/kdbx_manager.py @@ -91,12 +91,24 @@ class KdbxManager: def get_extra_command_user( self, kdbx_key: str | list | None - ) -> str | list: + ) -> tuple[str | list, dict]: + """(fragments de commande, variables d'environnement à poser). + + Le mot de passe ne rejoint PAS la ligne de commande : seul le NOM + d'une variable y figure. /proc//cmdline est lisible par tout + utilisateur de la machine, /proc//environ par son seul + propriétaire — et un mot de passe KeePass n'a rien à faire dans la + liste des processus. + + Un nom par entrée : plusieurs identifiants partent dans UNE seule + commande « parallel », donc une variable unique ne suffirait pas. + """ values = [] + env = {} if kdbx_key: kp = self.get_kdbx() if not kp: - return "" + return "", {} if type(kdbx_key) is not list: kdbx_keys = [kdbx_key] else: @@ -111,13 +123,14 @@ class KdbxManager: odoo_password = entry.password except AttributeError: _logger.error(f"Cannot find password from keys {key}") + var = f"EL_WEB_LOGIN_PWD_{len(values)}" + env[var] = odoo_password values.append( " --default_email_auth" - f" {odoo_user} --default_password_auth" - f" '{odoo_password}'" + f" {odoo_user} --default_password_auth_env {var}" ) if len(values) == 0: - return "" + return "", {} elif len(values) == 1: - return values[0] - return values + return values[0], env + return values, env diff --git a/script/todo/todo.py b/script/todo/todo.py index d8ea5c6..ef32dc5 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -510,14 +510,19 @@ class TODO: odoo_user = instance.get("user") odoo_password = instance.get("password") + # Le mot de passe voyage par l'environnement, jamais par argv : la + # ligne de commande est lisible par tout utilisateur de la machine. + web_login_env = {} if kdbx_key: - extra_cmd_web_login = self.kdbx_manager.get_extra_command_user( - kdbx_key - ) + ( + extra_cmd_web_login, + web_login_env, + ) = self.kdbx_manager.get_extra_command_user(kdbx_key) elif odoo_user and odoo_password: + web_login_env = {"EL_WEB_LOGIN_PWD_0": odoo_password} extra_cmd_web_login = ( - f" --default_email_auth {odoo_user} --default_password_auth" - f" '{odoo_password}'" + f" --default_email_auth {odoo_user}" + " --default_password_auth_env EL_WEB_LOGIN_PWD_0" ) else: extra_cmd_web_login = "" @@ -538,7 +543,9 @@ class TODO: if exec_run_db: db_name = instance.get("database") self.prompt_execute_selenium_and_run_db( - db_name, extra_cmd_web_login=extra_cmd_web_login + db_name, + extra_cmd_web_login=extra_cmd_web_login, + web_login_env=web_login_env, ) bash_command = instance.get("bash_command") @@ -554,7 +561,9 @@ class TODO: command = instance.get("command") if command: self.prompt_execute_selenium( - command=command, extra_cmd_web_login=extra_cmd_web_login + command=command, + extra_cmd_web_login=extra_cmd_web_login, + web_login_env=web_login_env, ) callback = instance.get("callback") @@ -2058,13 +2067,17 @@ class TODO: host, from_ssh = self._qemu_self_address() user = os.environ.get("USER", "user") print(f"\n {t('No display here; run this on YOUR workstation:')}") - print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n") + print( + f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n" + ) if not from_ssh: print(f" ⚠ {t('Not in an SSH session: check the host address.')}") print(f" {t('A ~/.ssh/config alias works there too.')}") print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}") - print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman" - " / zypper") + print( + f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman" + " / zypper" + ) def _qemu_console_tunnel(self, name, src): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. @@ -5421,7 +5434,7 @@ class TODO: "Group=$SVC_GROUP\n" "Restart=always\n" "RestartSec=5\n" - "ExecStart=$SVC_DIR/run.sh\n" + "ExecStart=/bin/bash $SVC_DIR/run.sh\n" "WorkingDirectory=$SVC_DIR\n" "StandardOutput=journal+console\n" "$SELINUX_LINE\n" @@ -11397,16 +11410,20 @@ class TODO: ) def prompt_execute_selenium_and_run_db( - self, db_name, extra_cmd_web_login="" + self, db_name, extra_cmd_web_login="", web_login_env=None ): cmd_server = f"./run.sh -d {db_name};bash" self.execute.exec_command_live(cmd_server) cmd_client = ( f"sleep 3;./script/selenium/web_login.py{extra_cmd_web_login};bash" ) - self.execute.exec_command_live(cmd_client) + self.execute.exec_command_live( + cmd_client, new_env=web_login_env or None + ) - def prompt_execute_selenium(self, command=None, extra_cmd_web_login=""): + def prompt_execute_selenium( + self, command=None, extra_cmd_web_login="", web_login_env=None + ): commands = [] if not command: cmd = "./script/selenium/web_login.py" @@ -11419,13 +11436,16 @@ class TODO: else: commands.append(cmd + extra_cmd_web_login) + env = web_login_env or None if len(commands) == 1: - self.execute.exec_command_live(commands[0]) + self.execute.exec_command_live(commands[0], new_env=env) elif len(commands) > 1: new_cmd = "parallel ::: " for i, cmd in enumerate(commands): new_cmd += f' "sleep {1 * i};{cmd}"' - self.execute.exec_command_live(new_cmd) + # « parallel » hérite de l'environnement, et chaque entrée lit + # SA variable : un nom par identifiant, d'où EL_WEB_LOGIN_PWD_N. + self.execute.exec_command_live(new_cmd, new_env=env) def crash_diagnostic(self, e): # TODO show message at start if os.path.exists(ERROR_LOG_PATH) diff --git a/test/test_install_swig_compat.py b/test/test_install_swig_compat.py new file mode 100644 index 0000000..a6eb4d0 --- /dev/null +++ b/test/test_install_swig_compat.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""L'installation Poetry survit à SWIG >= 4.3. + +pykcs11 — tiré par endesive, donc dans les locks Odoo 14, 15 et 17 — ne livre +aucun wrapper pré-généré : SWIG tourne à chaque installation. Son +« requires = ["setuptools", "swig"] » n'étant pas borné, Poetry prend la +DERNIÈRE version de PyPI. SWIG 4.3 a retiré les alias Python 2 que les +versions antérieures écrivaient dans le code généré, et le typemap CK_RV de +pykcs11 en utilise un : « ‘PyInt_FromLong’ was not declared in this scope », +55 fois, et install_odoo_17 s'arrête. + +Ces tests gardent les trois choses que l'enquête a coûté : + +- CPPFLAGS, pas CFLAGS. Un .cpp est compilé par « compiler_so_cxx », qui lit + CXXFLAGS et CPPFLAGS ; CFLAGS ne l'atteint JAMAIS. Le premier correctif a + échoué exactement là. +- La définition doit être identique TOKEN POUR TOKEN à celle de SWIG 4.2, + sinon les hôtes qui ont encore un vieux SWIG récoltent un avertissement de + redéfinition à chaque fichier. +- Le drapeau doit être posé AVANT « poetry install », et s'ajouter à un + CPPFLAGS existant au lieu de l'écraser. +""" + +import os +import re +import shutil +import subprocess +import sysconfig +import tempfile +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[1] +SCRIPT = RACINE / "script/install/install_locally.sh" +SOURCE = SCRIPT.read_text(encoding="utf-8") + +# La ligne que SWIG <= 4.2 plaçait lui-même dans le wrapper généré. +SWIG_42 = "#define PyInt_FromLong(x) PyLong_FromLong(x)" + +# Ce que produit SWIG >= 4.3 : l'appel, sans l'alias. +APPEL_SWIG_43 = """ +#include +PyObject *sonde(long v) { return PyInt_FromLong(v); } +""" + + +def _drapeau(): + """Le -D tel qu'il est écrit dans le script — source unique.""" + m = re.search(r"export CPPFLAGS=\"[^\"]*?(-DPyInt_FromLong[^\" ]*)\"", SOURCE) + return m.group(1) if m else "" + + +def _compilateur(): + return shutil.which("c++") or shutil.which("g++") + + +def _compile(source, drapeaux): + """(succès, sortie) d'une compilation de syntaxe seule.""" + inc = sysconfig.get_paths()["include"] + if not Path(inc, "Python.h").exists(): + return None, "Python.h absent" + with tempfile.TemporaryDirectory() as tmp: + fichier = Path(tmp, "sonde.cpp") + fichier.write_text(source, encoding="utf-8") + res = subprocess.run( + [_compilateur(), "-fsyntax-only", *drapeaux, "-I", inc, str(fichier)], + capture_output=True, + text=True, + timeout=120, + ) + return res.returncode == 0, res.stdout + res.stderr + + +class TestLeDrapeauDansLeScript(unittest.TestCase): + def test_it_is_exported(self): + self.assertTrue(_drapeau(), "aucun -DPyInt_FromLong dans le script") + + def test_it_is_set_before_poetry_install(self): + """Posé après, il n'atteindrait rien : c'est « poetry install » qui + lance les compilations.""" + export = SOURCE.index("export CPPFLAGS=") + install = SOURCE.index('install --no-root ${POETRY_VERBOSE}') + self.assertLess(export, install) + + def test_it_appends_instead_of_replacing(self): + """Écraser CPPFLAGS jetterait ce que l'appelant y avait mis — sur + s390x et openSUSE, des chemins d'en-têtes y passent.""" + self.assertIn('${CPPFLAGS:+${CPPFLAGS} }', SOURCE) + + def test_it_is_cppflags_and_not_cflags(self): + """Le piège qui a fait échouer le premier correctif : le fichier fautif + est un .cpp, et CFLAGS ne va qu'aux .c.""" + self.assertNotIn("export CFLAGS=", SOURCE) + + def test_the_shell_builds_the_value_it_claims(self): + """Les parenthèses du -D sont des métacaractères du shell : mal + protégées, la variable serait tronquée ou le script casserait.""" + for depart, attendu in ( + ("", "-DPyInt_FromLong(x)=PyLong_FromLong(x)"), + ("-DDEJA=1", "-DDEJA=1 -DPyInt_FromLong(x)=PyLong_FromLong(x)"), + ): + res = subprocess.run( + [ + "bash", + "-c", + f'export CPPFLAGS="{depart}"; ' + 'export CPPFLAGS="${CPPFLAGS:+${CPPFLAGS} }' + f'{_drapeau()}"; printf %s "$CPPFLAGS"', + ], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(attendu, res.stdout) + + def test_the_script_still_parses(self): + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + +@unittest.skipUnless(_compilateur(), "aucun compilateur C++") +class TestLeDrapeauCompile(unittest.TestCase): + """Le drapeau fait-il ce qu'on lui prête ? Compilé, pas supposé.""" + + def test_swig_43_output_fails_without_it(self): + """Sans quoi le test suivant ne prouverait rien : c'est bien CE + symptôme que le drapeau soigne.""" + ok, sortie = _compile(APPEL_SWIG_43, []) + if ok is None: + self.skipTest(sortie) + self.assertFalse(ok) + self.assertIn("PyInt_FromLong", sortie) + + def test_the_same_source_compiles_with_it(self): + ok, sortie = _compile(APPEL_SWIG_43, [_drapeau()]) + if ok is None: + self.skipTest(sortie) + self.assertTrue(ok, sortie[-400:]) + + def test_it_does_not_clash_with_an_older_swig(self): + """Un hôte dont le SWIG écrit encore l'alias verrait, sinon, un + avertissement de redéfinition par fichier compilé. En -Werror, ce + serait un échec.""" + ok, sortie = _compile( + SWIG_42 + APPEL_SWIG_43, ["-Werror", _drapeau()] + ) + if ok is None: + self.skipTest(sortie) + self.assertTrue(ok, sortie[-400:]) + self.assertNotIn("redefined", sortie) + + +class TestHypotheseSetuptools(unittest.TestCase): + """L'hypothèse dont dépend le choix de CPPFLAGS, écrite noir sur blanc. + + Si un setuptools futur change de câblage, c'est ici qu'on l'apprend — pas + au milieu d'une installation de VM. + """ + + def test_cppflags_reaches_the_cxx_compiler_but_cflags_does_not(self): + try: + from setuptools._distutils.ccompiler import new_compiler + from setuptools._distutils.sysconfig import customize_compiler + except ImportError as exc: # pragma: no cover - setuptools trop vieux + self.skipTest(f"distutils vendu introuvable : {exc}") + garde = {v: os.environ.get(v) for v in ("CFLAGS", "CPPFLAGS")} + os.environ["CFLAGS"] = "-DVU_PAR_CFLAGS=1" + os.environ["CPPFLAGS"] = "-DVU_PAR_CPPFLAGS=1" + try: + compilateur = new_compiler() + customize_compiler(compilateur) + cxx = getattr(compilateur, "compiler_so_cxx", None) + if not cxx: + self.skipTest("pas de compiler_so_cxx dans ce setuptools") + self.assertIn("-DVU_PAR_CPPFLAGS=1", cxx) + self.assertNotIn("-DVU_PAR_CFLAGS=1", cxx) + finally: + for var, val in garde.items(): + if val is None: + os.environ.pop(var, None) + else: + os.environ[var] = val + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_master_password_retry.py b/test/test_master_password_retry.py index 9efec85..b6fd3e3 100644 --- a/test/test_master_password_retry.py +++ b/test/test_master_password_retry.py @@ -79,8 +79,8 @@ class TestTheRetryLoop(unittest.TestCase): self.demandes += 1 return next(suite, "") - def sonder(arg_base): - self.sondes.append(arg_base) + def sonder(arg_base, mot): + self.sondes.append((arg_base, mot)) return next(rep, (False, "AccessDenied")) db_restore.get_master_password = demander @@ -136,7 +136,13 @@ class TestTheRetryLoop(unittest.TestCase): self.branche(["bon"], [(True, "db1")]) self.lance() self.assertEqual(len(self.sondes), 1) - self.assertIn("--master_password=bon", self.sondes[0]) + arg_base, mot = self.sondes[0] + self.assertEqual(mot, "bon") + # Le secret est passé À CÔTÉ de la commande, jamais dedans : + # /proc//cmdline est lisible par tout utilisateur de la + # machine. C'est la garantie que ce test tient. + self.assertNotIn("bon", arg_base) + self.assertNotIn("--master_password", arg_base) def test_each_attempt_probes_with_ITS_password(self): self.branche( @@ -144,8 +150,9 @@ class TestTheRetryLoop(unittest.TestCase): [(False, "AccessDenied"), (True, "db1")], ) self.lance() - self.assertIn("--master_password=un", self.sondes[0]) - self.assertIn("--master_password=deux", self.sondes[1]) + self.assertEqual([mot for _, mot in self.sondes], ["un", "deux"]) + for arg_base, _ in self.sondes: + self.assertNotIn("--master_password", arg_base) class TestTheWiring(unittest.TestCase): diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index a89e490..f877e33 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -263,6 +263,80 @@ class TestTheCommandLine(unittest.TestCase): self.assertNotIn("Traceback", buf.getvalue()) +REPO = Path(__file__).resolve().parent.parent +MOBILE = REPO / "mobile" / "erplibre_home_mobile" + + +class TestTheRealBundle(unittest.TestCase): + """Le VRAI transfert, quand le dépôt mobile est installé et compilé. + + C'est ici que ces tests DÉCLARENT leur dépendance : sans + mobile/erplibre_home_mobile, ils se disent ignorés plutôt que de passer en + silence — un test vert sans son dépôt ne prouve rien. Le lanceur + (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. + """ + + @classmethod + def setUpClass(cls): + if not MOBILE.is_dir(): + # Pas de « relative_to » : il lève quand le chemin sort du + # dépôt, et une erreur n'est pas un « ignoré » — mesuré en + # simulant l'absence. + raise unittest.SkipTest( + "mobile/erplibre_home_mobile absent :" + " ./mobile/install_mobile_dev.sh" + ) + cls.repos = MOBILE / "dist" / "repos" + if not (cls.repos / "manifest.json").is_file(): + raise unittest.SkipTest( + "dépôt mobile présent mais pas compilé :" + " ./mobile/compile_and_run.sh (ou npm run build)" + ) + + def test_the_transfer_is_coherent(self): + rep = cbt.check(MOBILE, REPO) + self.assertGreater(rep["repos"], 1) + self.assertGreater(rep["files"], cbt.MIN_FILES) + self.assertGreater(rep["packs"], 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.""" + 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: + continue + self.assertTrue( + all("chunk" in e for e in files), + f"{proj['slug']} : des fichiers sans tranche", + ) + checked += 1 + self.assertGreater(checked, 0, "aucun dépôt à vérifier") + + def test_no_bundled_test_file_lingers_as_a_source(self): + """Effet de bord mesuré, et il compte : empaquetés, les 1 599 fichiers + de test des dépôts Odoo ne sont plus ramassés par vitest — 1 423 + fichiers de test ramenés à 75, 35 s ramenées à 3 s.""" + stray = list(self.repos.glob("*/**/*.test.ts")) + self.assertEqual([], stray) + + import contextlib as _contextlib # noqa: E402 import unittest.mock # noqa: E402 diff --git a/test/test_todo.py b/test/test_todo.py index e98c47e..5900f63 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -403,21 +403,42 @@ class TestTestMenuDispatch(unittest.TestCase): class TestKdbxGetExtraCommandUser(unittest.TestCase): + """La fonction rend (fragments, variables d'environnement). + + Le mot de passe ne doit JAMAIS revenir dans les fragments : ils + deviennent une ligne de commande, que tout utilisateur de la machine + peut lire dans /proc//cmdline. Seul le NOM d'une variable y a sa + place, et c'est ce que le dernier test verrouille. + """ + def test_empty_kdbx_key(self): todo = TODO() result = todo.kdbx_manager.get_extra_command_user("") - self.assertEqual(result, "") + self.assertEqual(result, ("", {})) def test_none_kdbx_key(self): todo = TODO() result = todo.kdbx_manager.get_extra_command_user(None) - self.assertEqual(result, "") + self.assertEqual(result, ("", {})) def test_kdbx_not_available(self): todo = TODO() todo.kdbx_manager.get_kdbx = MagicMock(return_value=None) result = todo.kdbx_manager.get_extra_command_user("some_key") - self.assertEqual(result, "") + self.assertEqual(result, ("", {})) + + def test_password_never_reaches_the_command_line(self): + todo = TODO() + entry = MagicMock(username="odoo", password="s3cr3t") + kp = MagicMock() + kp.find_entries_by_title = MagicMock(return_value=entry) + todo.kdbx_manager.get_kdbx = MagicMock(return_value=kp) + fragment, env = todo.kdbx_manager.get_extra_command_user("une_cle") + self.assertNotIn("s3cr3t", fragment) + self.assertIn( + "--default_password_auth_env EL_WEB_LOGIN_PWD_0", fragment + ) + self.assertEqual(env, {"EL_WEB_LOGIN_PWD_0": "s3cr3t"}) class TestSetupClaudeCommit(unittest.TestCase):