refactor(zsh): fiabiliser les helpers et accélérer le démarrage
This commit is contained in:
parent
bca26d4de2
commit
b84723fcc1
3 changed files with 264 additions and 30 deletions
|
|
@ -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
|
Le lien `labfy-investigation-web` a été préservé malgré sa cible
|
||||||
actuellement absente, car son unité systemd référence encore ce chemin.
|
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
|
## Données locales
|
||||||
|
|
||||||
L'historique Zsh et le store Atuin sont des données utilisateur, pas de la
|
L'historique Zsh et le store Atuin sont des données utilisateur, pas de la
|
||||||
|
|
|
||||||
118
tests/zsh/test_shell_safety.py
Normal file
118
tests/zsh/test_shell_safety.py
Normal 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()
|
||||||
148
zsh/.zshrc
148
zsh/.zshrc
|
|
@ -8,6 +8,8 @@
|
||||||
# =========================
|
# =========================
|
||||||
# PATH & env
|
# 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/.local/bin:$PATH"
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
export PATH="$HOME/.local/npm/bin:$PATH"
|
export PATH="$HOME/.local/npm/bin:$PATH"
|
||||||
|
|
@ -29,7 +31,6 @@ setopt SHARE_HISTORY
|
||||||
setopt HIST_IGNORE_ALL_DUPS
|
setopt HIST_IGNORE_ALL_DUPS
|
||||||
setopt HIST_REDUCE_BLANKS
|
setopt HIST_REDUCE_BLANKS
|
||||||
setopt HIST_FIND_NO_DUPS
|
setopt HIST_FIND_NO_DUPS
|
||||||
HISTDUP=erase
|
|
||||||
bindkey '^[[A' history-beginning-search-backward
|
bindkey '^[[A' history-beginning-search-backward
|
||||||
bindkey '^[[B' history-beginning-search-forward
|
bindkey '^[[B' history-beginning-search-forward
|
||||||
|
|
||||||
|
|
@ -48,7 +49,13 @@ bindkey '^[[3~' delete-char
|
||||||
fpath=("$HOME/.zsh/completions" $fpath)
|
fpath=("$HOME/.zsh/completions" $fpath)
|
||||||
|
|
||||||
autoload -Uz compinit
|
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:*' matcher-list 'm:{a-z}={A-Za-z}'
|
||||||
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
|
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
|
||||||
|
|
@ -187,14 +194,47 @@ alias lt='eza -T'
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
alias whois-ip='whois'
|
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 myip='curl -s ifconfig.me'
|
||||||
alias headers='curl -sI'
|
alias headers='curl -sI'
|
||||||
alias dns='dog' # ou "dig"
|
alias dns='dig'
|
||||||
alias subdomains='subfinder -d'
|
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 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
|
# Arch workflow
|
||||||
|
|
@ -207,30 +247,21 @@ alias pQ='pacman -Q'
|
||||||
alias pSs='pacman -Ss'
|
alias pSs='pacman -Ss'
|
||||||
alias pSyu='sudo pacman -Syu'
|
alias pSyu='sudo pacman -Syu'
|
||||||
orphans() {
|
orphans() {
|
||||||
local pkgs
|
local -a pkgs
|
||||||
pkgs="$(pacman -Qtdq 2>/dev/null)"
|
# Transformer la liste multiligne de pacman en vrai tableau Zsh.
|
||||||
|
pkgs=( ${(f)"$(pacman -Qtdq 2>/dev/null)"} )
|
||||||
if [[ -n "$pkgs" ]]; then
|
if (( ${#pkgs[@]} == 0 )); then
|
||||||
sudo pacman -Rns $pkgs
|
print -- "Aucun paquet orphelin"
|
||||||
else
|
return 0
|
||||||
echo "Aucun paquet orphelin"
|
fi
|
||||||
fi
|
print -r -- "Paquets orphelins : ${(j:, :)pkgs}"
|
||||||
|
sudo pacman -Rns -- "${pkgs[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
sudo -v || return 1
|
# Pas de boucle sudo en arrière-plan et pas de -Syyu systématique.
|
||||||
|
sudo pacman -Syu || return $?
|
||||||
while true; do
|
aurupdate
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# =========================
|
# =========================
|
||||||
|
|
@ -300,7 +331,33 @@ va() {
|
||||||
|
|
||||||
alias venv='python -m venv venv'
|
alias venv='python -m venv venv'
|
||||||
alias vd='deactivate'
|
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 vreq='pip install -r requirements.txt'
|
||||||
alias vfreeze='pip freeze > 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 pour Valgrind
|
||||||
alias valgrind-check='valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes'
|
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)
|
# Nettoyage C : seulement les fichiers ELF exécutables au niveau courant.
|
||||||
alias clean-exe='find . -maxdepth 1 -type f -executable -not -name "*.sh" -delete'
|
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
|
# Ansible
|
||||||
|
|
@ -402,3 +487,6 @@ export LESS_TERMCAP_us=$'\e[1;32m' # underline
|
||||||
# Android SDK
|
# Android SDK
|
||||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
export PATH="$PATH:$ANDROID_HOME/platform-tools"
|
export PATH="$PATH:$ANDROID_HOME/platform-tools"
|
||||||
|
|
||||||
|
# Dédupliquer réellement le tableau après toutes les initialisations (pyenv inclus).
|
||||||
|
path=("${path[@]}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue