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
124 lines
4.1 KiB
Python
Executable file
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()
|