erplibre/test/test_script_permissions.py
Mathieu Benoit 0870696701 [FIX] script: make the analysis and migration tools executable
Nine of the thirteen carried a shebang without the bit, so only four
could be run by their own path. Set to 755, not chmod +x: two were
664 under the 0002 umask and would have become 775 — executed by
others while a group member could still rewrite them. That pairing is
the only real risk here; the bit alone grants nothing, since reading
the file is enough to run python3 on it.

Being runnable opens a path without the venv: the shebang resolves to
the system python3, which has no Textual. --tui fell back to the text
report saying nothing. It now names the interpreter and the venv.

--- FR ---

[FIX] script : rendre exécutables les outils d'analyse et de migration

Neuf des treize portaient un shebang sans le bit ; quatre seulement
se lançaient par leur chemin. Mis à 755, pas chmod +x : deux étaient
en 664 sous l'umask 0002 et seraient passés à 775 — exécutés par
d'autres alors qu'un membre du groupe pouvait encore les réécrire.
C'est la seule vraie prise ici ; le bit seul n'accorde rien, lire le
fichier suffit déjà à lancer python3 dessus.

Devenir lançable ouvre un chemin sans le venv : le shebang résout le
python du système, sans Textual. --tui retombait sur le rapport texte
sans rien dire. Il nomme désormais l'interpréteur et le venv.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

124 lines
4.1 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un script avec shebang doit être exécutable — et pas inscriptible en groupe.
Le bit d'exécution n'accorde rien : qui peut lire le fichier peut déjà faire
« python3 fichier ». Ce qui compte est la PAIRE : un fichier à la fois
exécuté par d'autres et modifiable par le groupe laisse un membre du groupe
changer ce que les autres lancent. L'umask 0002 du poste crée exactement
cette paire dès qu'on ajoute +x sans y penser (664 -> 775).
Git ne stocke que le bit d'exécution, jamais 664 contre 644 : le mode complet
ne se vérifie donc que sur le disque, ici.
"""
import glob
import os
import stat
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PATTERNS = (
"script/analyse/*.py",
"script/analyse/*/*.py",
"script/odoo/migration/*.py",
)
def scripts():
"""(chemin, a un shebang) pour chaque script des dossiers visés."""
found = []
for pattern in PATTERNS:
for path in sorted(glob.glob(os.path.join(REPO, pattern))):
with open(path, "rb") as handle:
found.append((path, handle.read(2) == b"#!"))
return found
class TestExecutableBit(unittest.TestCase):
def test_the_inventory_is_not_empty(self):
# Un test qui ne trouve rien passe toujours.
self.assertGreater(len(scripts()), 10)
def test_every_shebang_script_is_executable(self):
for path, has_shebang in scripts():
if not has_shebang:
continue
with self.subTest(script=os.path.relpath(path, REPO)):
self.assertTrue(os.access(path, os.X_OK))
def test_none_is_writable_by_group_or_others(self):
# LE point qui compte : exécuté par d'autres ET modifiable par eux.
for path, _ in scripts():
mode = stat.S_IMODE(os.stat(path).st_mode)
with self.subTest(script=os.path.relpath(path, REPO)):
self.assertFalse(
mode & (stat.S_IWGRP | stat.S_IWOTH), oct(mode)
)
def test_none_is_setuid_or_setgid(self):
for path, _ in scripts():
mode = os.stat(path).st_mode
with self.subTest(script=os.path.relpath(path, REPO)):
self.assertFalse(mode & (stat.S_ISUID | stat.S_ISGID))
class TestTheTuiSaysWhyItRefuses(unittest.TestCase):
"""Rendre un script lançable ouvre un chemin sans le venv.
Le shebang est « #!/usr/bin/env python3 » : lancé directement depuis un
shell où le venv n'est pas actif, c'est le python du système qui répond,
et Textual n'y est pas. La TUI retombait alors sur le rapport texte sans
rien dire — le même défaut muet que le tube de sortie.
"""
def run_tui(self):
import sys
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
self.addCleanup(sys.path.remove, sys.path[0])
from cow_drift_tui import run_tui
return run_tui
def finding(self):
return {
"id": 1,
"key": "k",
"website_id": 1,
"reason": "r",
"module_id": 2,
"module_arch": "a",
"copy_arch": "b",
"decl_current": None,
"decl_target": None,
"current_version": "odoo12.0",
"target_version": "odoo13.0",
}
def test_a_pipe_is_explained_not_swallowed(self):
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
result = self.run_tui()([self.finding()])
self.assertFalse(result)
self.assertTrue(out.getvalue().strip(), "refus muet")
def test_nothing_to_show_stays_silent(self):
# Une liste vide n'est pas un refus : il n'y a rien à annoncer.
import io
from contextlib import redirect_stdout
out = io.StringIO()
with redirect_stdout(out):
self.assertFalse(self.run_tui()([]))
self.assertEqual(out.getvalue(), "")
if __name__ == "__main__":
unittest.main()