From b84723fcc132848e3557d48a02b4bedaeda87f5f Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 1 Nov 2026 08:03:08 +0100 Subject: [PATCH] =?UTF-8?q?refactor(zsh):=20fiabiliser=20les=20helpers=20e?= =?UTF-8?q?t=20acc=C3=A9l=C3=A9rer=20le=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/shell.md | 28 +++++++ tests/zsh/test_shell_safety.py | 118 ++++++++++++++++++++++++++ zsh/.zshrc | 148 ++++++++++++++++++++++++++------- 3 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 tests/zsh/test_shell_safety.py diff --git a/docs/shell.md b/docs/shell.md index b38ce0c6e..e7288ea5f 100644 --- a/docs/shell.md +++ b/docs/shell.md @@ -85,6 +85,34 @@ via les drop-ins systemd. La commande `trainlog` reste disponible. Le lien `labfy-investigation-web` a été préservé malgré sa cible actuellement absente, car son unité systemd référence encore ce chemin. +## Entretien Zsh (novembre 2026) + +Le chargement interactif déduplique le tableau `path` après l'initialisation +des outils (notamment pyenv). Les complétions Zsh utilisent désormais +`${XDG_CACHE_HOME:-$HOME/.cache}/zsh/zcompdump` et créent leur répertoire au +premier démarrage. Les associations Ctrl+R (Atuin) et Ctrl+F (FZF) restent +inchangées ; le shell et sa configuration sont gérés par les dotfiles. + +Pour les opérations potentiellement destructives : + +- `update` effectue `sudo pacman -Syu`, puis `aurupdate` uniquement si + la première commande a réussi ; plus de renouvellement sudo permanent ; +- `orphans` transforme les noms de paquets Pacman en tableau Zsh afin de + conserver un argument distinct par paquet ; Pacman demande confirmation ; +- `clean-exe --dry-run` affiche les seuls exécutables ELF réguliers du + répertoire courant et ignore les fichiers suivis par Git ; sans cette + option, il faut taper exactement `SUPPRIMER` avant la suppression ; +- `vrm --dry-run` affiche uniquement les répertoires `venv`/`.venv` + contenant `pyvenv.cfg`, non symboliques ; une confirmation `SUPPRIMER` + est obligatoire avant suppression. Aucun répertoire autre que ces deux + noms n'est ciblé. + +Pour l'OSINT, `dns` utilise `dig` au lieu de `dog`, `geoip ` interroge +explicitement ipapi.co, `shot [full|region]` capture sous Wayland avec +`grim` et éventuellement `slurp` dans `~/Pictures/`. `subdomains ` +nécessite `subfinder` : son absence est signalée sans exécution de secours. +Ces commandes réseau ne sont pas lancées lors de l'ouverture du shell. + ## Données locales L'historique Zsh et le store Atuin sont des données utilisateur, pas de la diff --git a/tests/zsh/test_shell_safety.py b/tests/zsh/test_shell_safety.py new file mode 100644 index 000000000..74bdd3435 --- /dev/null +++ b/tests/zsh/test_shell_safety.py @@ -0,0 +1,118 @@ +"""Tests Zsh isolés, sans toucher aux données personnelles ni au système.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +REPO = Path(__file__).resolve().parents[2] + + +def shell(command, *, cwd=None, stdin=None): + env = os.environ.copy() + env["ZDOTDIR"] = str(REPO / "zsh") + return subprocess.run( + ["zsh", "-ic", command], cwd=cwd, input=stdin, text=True, + capture_output=True, env=env, timeout=20 + ) + + +class ShellSafetyTests(unittest.TestCase): + def test_unique_path(self): + p = shell("print -- PATH_BEGIN; print -rl -- $path; print -- PATH_END") + self.assertEqual(p.returncode, 0, p.stderr) + entries = p.stdout.split("PATH_BEGIN\n", 1)[1].split("PATH_END", 1)[0].strip().splitlines() + self.assertTrue(entries) + self.assertEqual(len(entries), len(set(entries))) + + def test_orphans_args_and_empty_result(self): + p = shell('pacman() { print -rl -- alpha beta; }; ' + 'sudo() { print -r -- "CALL $*"; }; orphans') + self.assertEqual(p.returncode, 0, p.stderr) + self.assertIn("CALL pacman -Rns -- alpha beta", p.stdout) + empty = shell('pacman() { return 1; }; ' + 'sudo() { print -- ERROR_CALL; }; orphans') + self.assertEqual(empty.returncode, 0, empty.stderr) + self.assertIn("Aucun paquet orphelin", empty.stdout) + self.assertNotIn("ERROR_CALL", empty.stdout) + + def test_update_stops_if_pacman_fails(self): + success = shell('sudo() { print -r -- "CALL $*"; }; ' + 'aurupdate() { print -- AUR_CALLED; }; update') + self.assertEqual(success.returncode, 0, success.stderr) + self.assertIn("CALL pacman -Syu", success.stdout) + self.assertIn("AUR_CALLED", success.stdout) + fail = shell('sudo() { return 7; }; ' + 'aurupdate() { print -- SHOULD_NOT_RUN; }; update') + self.assertEqual(fail.returncode, 7) + self.assertNotIn("SHOULD_NOT_RUN", fail.stdout) + + def test_clean_exe_confirms_and_preserves_scripts(self): + with tempfile.TemporaryDirectory(prefix="zsh-test-elf-") as td: + root = Path(td) + elf = root / "binary" + if not Path("/usr/bin/true").exists(): + self.skipTest("ELF fixture unavailable") + shutil.copy2("/usr/bin/true", elf) + elf.chmod(0o755) + script = root / "utility.py" + script.write_text("#!/usr/bin/env python3\nprint('safe')\n") + script.chmod(0o755) + preview = shell("clean-exe --dry-run", cwd=td) + self.assertIn("./binary", preview.stdout) + self.assertNotIn("./utility.py", preview.stdout) + self.assertTrue(elf.exists()) + cancel = shell("clean-exe", cwd=td, stdin="NON\n") + self.assertEqual(cancel.returncode, 1) + self.assertTrue(elf.exists()) + confirm = shell("clean-exe", cwd=td, stdin="SUPPRIMER\n") + self.assertEqual(confirm.returncode, 0, confirm.stderr) + self.assertFalse(elf.exists()) + self.assertTrue(script.exists()) + + def test_clean_exe_ignores_git_tracked_binary(self): + with tempfile.TemporaryDirectory(prefix="zsh-test-git-elf-") as td: + root = Path(td) + binary = root / "tracked-program" + if not Path("/usr/bin/true").exists(): + self.skipTest("ELF fixture unavailable") + shutil.copy2("/usr/bin/true", binary) + binary.chmod(0o755) + subprocess.run(["git", "init", "-q", td], check=True) + subprocess.run(["git", "-C", td, "add", "tracked-program"], check=True) + preview = shell("clean-exe --dry-run", cwd=td) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertIn("Aucun exécutable ELF local", preview.stdout) + self.assertTrue(binary.exists()) + + def test_vrm_confirms_only_real_virtualenv(self): + with tempfile.TemporaryDirectory(prefix="zsh-test-venv-") as td: + root = Path(td) + env = root / "venv" + env.mkdir() + (env / "pyvenv.cfg").write_text("home=/usr/bin\n") + unrelated = root / ".venv" + unrelated.mkdir() + (unrelated / "keep.txt").write_text("keep") + preview = shell("vrm --dry-run", cwd=td) + self.assertIn("venv", preview.stdout) + self.assertTrue(env.exists()) + cancel = shell("vrm", cwd=td, stdin="NON\n") + self.assertEqual(cancel.returncode, 1) + self.assertTrue(env.exists()) + accept = shell("vrm", cwd=td, stdin="SUPPRIMER\n") + self.assertEqual(accept.returncode, 0, accept.stderr) + self.assertFalse(env.exists()) + self.assertTrue((unrelated / "keep.txt").exists()) + + def test_osint_invalid_arguments_do_not_run_commands(self): + for command in ("geoip", "shot unknown", "subdomains"): + with self.subTest(command=command): + p = shell(command) + self.assertEqual(p.returncode, 2, p.stderr) + self.assertIn("Usage", p.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/zsh/.zshrc b/zsh/.zshrc index e60744168..f23ce2439 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -8,6 +8,8 @@ # ========================= # PATH & env # ========================= +# Un seul exemplaire de chaque chemin, y compris après pyenv. +typeset -U path export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.local/npm/bin:$PATH" @@ -29,7 +31,6 @@ setopt SHARE_HISTORY setopt HIST_IGNORE_ALL_DUPS setopt HIST_REDUCE_BLANKS setopt HIST_FIND_NO_DUPS -HISTDUP=erase bindkey '^[[A' history-beginning-search-backward bindkey '^[[B' history-beginning-search-forward @@ -48,7 +49,13 @@ bindkey '^[[3~' delete-char fpath=("$HOME/.zsh/completions" $fpath) autoload -Uz compinit -compinit -d ~/.cache/zsh/zcompdump +# Dump persistant des complétions, sous le cache XDG. +ZSH_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/zsh" +if [[ -d "$ZSH_CACHE_DIR" ]] || command mkdir -p -m 700 -- "$ZSH_CACHE_DIR"; then + compinit -d "$ZSH_CACHE_DIR/zcompdump" +else + compinit +fi zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}' zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS} @@ -187,14 +194,47 @@ alias lt='eza -T' # ========================= alias whois-ip='whois' -alias geoip='function _geoip() { curl -s "https://ipapi.co/$1/json/" | jq; }; _geoip' +geoip() { + if (( $# != 1 )); then + print -u2 -- "Usage : geoip " + return 2 + fi + setopt localoptions pipefail + command curl -fsS --max-time 10 -- "https://ipapi.co/$1/json/" | command jq . +} alias myip='curl -s ifconfig.me' alias headers='curl -sI' -alias dns='dog' # ou "dig" -alias subdomains='subfinder -d' +alias dns='dig' +subdomains() { + if (( $# != 1 )); then + print -u2 -- "Usage : subdomains " + return 2 + fi + if ! command -v subfinder >/dev/null 2>&1; then + print -u2 -- "subfinder est absent : installer cet outil avant l'énumération." + return 127 + fi + command subfinder -d "$1" +} alias topcmd="history | awk '{print \$2}' | sort | uniq -c | sort -rn | head -20" -alias shot='scrot ~/Pictures/osint_%Y%m%d_%H%M%S.png' +# Wayland natif : capture complète ou sélection rectangulaire. +shot() { + local mode="${1:-full}" target geometry + if (( $# > 1 )) || [[ "$mode" != full && "$mode" != region ]]; then + print -u2 -- "Usage : shot [full|region]" + return 2 + fi + target="$HOME/Pictures/osint_$(date +%Y%m%d_%H%M%S_%N).png" + if [[ "$mode" == region ]]; then + geometry="$(command slurp)" || return 1 + [[ -n "$geometry" ]] || return 1 + command grim -g "$geometry" "$target" || return $? + else + command grim "$target" || return $? + fi + print -r -- "$target" +} # ========================= # Arch workflow @@ -207,30 +247,21 @@ alias pQ='pacman -Q' alias pSs='pacman -Ss' alias pSyu='sudo pacman -Syu' orphans() { - local pkgs - pkgs="$(pacman -Qtdq 2>/dev/null)" - - if [[ -n "$pkgs" ]]; then - sudo pacman -Rns $pkgs - else - echo "Aucun paquet orphelin" - fi + local -a pkgs + # Transformer la liste multiligne de pacman en vrai tableau Zsh. + pkgs=( ${(f)"$(pacman -Qtdq 2>/dev/null)"} ) + if (( ${#pkgs[@]} == 0 )); then + print -- "Aucun paquet orphelin" + return 0 + fi + print -r -- "Paquets orphelins : ${(j:, :)pkgs}" + sudo pacman -Rns -- "${pkgs[@]}" } update() { - sudo -v || return 1 - - while true; do - sudo -n true - sleep 60 - kill -0 "$$" || exit - done 2>/dev/null & - local sudo_loop_pid=$! - - trap 'kill $sudo_loop_pid 2>/dev/null' EXIT - - sudo pacman -Syyu || return 1 - aurupdate + # Pas de boucle sudo en arrière-plan et pas de -Syyu systématique. + sudo pacman -Syu || return $? + aurupdate } # ========================= @@ -300,7 +331,33 @@ va() { alias venv='python -m venv venv' alias vd='deactivate' -alias vrm='rm -rf venv .venv' +# Environnements Python reconnus, avec confirmation obligatoire. +vrm() { + local -a envs + local candidate answer + if (( $# > 1 )) || { (( $# == 1 )) && [[ "$1" != "--dry-run" ]]; }; then + print -u2 -- "Usage : vrm [--dry-run]" + return 2 + fi + if [[ "$PWD" == "$HOME" || "$PWD" == "/" ]]; then + print -u2 -- "Refus : lancer vrm dans un répertoire de projet." + return 1 + fi + for candidate in venv .venv; do + if [[ -d "$candidate" && ! -L "$candidate" && -f "$candidate/pyvenv.cfg" ]]; then + envs+=("$candidate") + fi + done + if (( ${#envs[@]} == 0 )); then + print -- "Aucun environnement virtuel Python local à retirer." + return 0 + fi + print -r -- "Environnements concernés : ${(j:, :)envs}" + [[ "${1:-}" == "--dry-run" ]] && return 0 + read -r 'answer?Taper SUPPRIMER pour confirmer : ' || return 1 + [[ "$answer" == "SUPPRIMER" ]] || { print -- "Annulé."; return 1; } + command rm -rf -- "${envs[@]}" +} alias vreq='pip install -r requirements.txt' alias vfreeze='pip freeze > requirements.txt' @@ -316,8 +373,36 @@ alias gcc-strict='gcc -Wall -Wextra -Werror -pedantic -std=c17' # Alias pour Valgrind alias valgrind-check='valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes' -# Alias pour le nettoyage (limité au répertoire courant, non récursif) -alias clean-exe='find . -maxdepth 1 -type f -executable -not -name "*.sh" -delete' +# Nettoyage C : seulement les fichiers ELF exécutables au niveau courant. +clean-exe() { + local -a binaries + local candidate answer + local in_git=0 + command git rev-parse --is-inside-work-tree >/dev/null 2>&1 && in_git=1 + if (( $# > 1 )) || { (( $# == 1 )) && [[ "$1" != "--dry-run" ]]; }; then + print -u2 -- "Usage : clean-exe [--dry-run]" + return 2 + fi + for candidate in ./*(N.); do + [[ -x "$candidate" ]] || continue + command readelf -h -- "$candidate" >/dev/null 2>&1 || continue + # Ne jamais proposer la suppression d'un binaire suivi par Git. + if (( in_git )) && command git ls-files --error-unmatch -- "$candidate" >/dev/null 2>&1; then + continue + fi + binaries+=("$candidate") + done + if (( ${#binaries[@]} == 0 )); then + print -- "Aucun exécutable ELF local à retirer." + return 0 + fi + print -- "Exécutables ELF candidats (aucune suppression automatique) :" + printf ' %s\n' "${binaries[@]}" + [[ "${1:-}" == "--dry-run" ]] && return 0 + read -r 'answer?Taper SUPPRIMER pour confirmer : ' || return 1 + [[ "$answer" == "SUPPRIMER" ]] || { print -- "Annulé."; return 1; } + command rm -- "${binaries[@]}" +} # ========================= # Ansible @@ -402,3 +487,6 @@ export LESS_TERMCAP_us=$'\e[1;32m' # underline # Android SDK export ANDROID_HOME="$HOME/Android/Sdk" export PATH="$PATH:$ANDROID_HOME/platform-tools" + +# Dédupliquer réellement le tableau après toutes les initialisations (pyenv inclus). +path=("${path[@]}")