refactor(zsh): fiabiliser les helpers et accélérer le démarrage

This commit is contained in:
fy59 2026-11-01 08:03:08 +01:00
parent bca26d4de2
commit b84723fcc1
3 changed files with 264 additions and 30 deletions

View file

@ -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 <IP>` interroge
explicitement ipapi.co, `shot [full|region]` capture sous Wayland avec
`grim` et éventuellement `slurp` dans `~/Pictures/`. `subdomains <domaine>`
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

View file

@ -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()

View file

@ -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 <adresse IP>"
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 <domaine>"
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[@]}")