Aller au contenu

Lab : le rapport hebdomadaire de Signalements

200 Compagnon ⏱ 7 h grepsedawkjqbashlinuxubuntudebian

Prérequis

Testé avec coreutils 9.4 (Ubuntu 24.04), 9.7 (Debian 13) gawk 5.2.1 grep 3.11 jq 1.7.1 mawk 1.3.4-20240123 (Ubuntu 24.04), 1.3.4-20250131 (Debian 13) python 3.12 (Ubuntu 24.04), 3.13 (Debian 13) sed 4.9 , vérifié le 9 octobre 2026

Ce lab rassemble le cours Traiter du texte : grep, sed, awk, jq. Vous reconstruisez, à votre façon, les outils que l'équipe de Signalements a écrits dans le dépôt signalements-outils pour répondre aux deux demandes du jeudi 8 octobre : l'enquête sur l'incident de mercredi et le rapport hebdomadaire de la mairie. Puis un script, verifier-lab, lance vos outils de l'extérieur, sur les données du cours et sur des entrées préparées pour les piéger (CSV aux guillemets cassés, lignes de journal parasites, adresses privées à ne pas toucher, fichier de configuration ambigu, nom de commune qui contient un guillemet), et compare leurs résultats à ceux qu'il recalcule lui-même. Il valide le niveau 200 (Compagnon) de la compétence « Traiter du texte ».

Comptez une journée. Toutes les pièces sont dans les leçons, qui montrent chaque programme en entier : essayez d'écrire chaque partie avant de la relire, et de comprendre chaque ligne que vous reprenez. Un programme recopié sans être compris passera peut-être les contrôles, mais vous laissera démuni devant le premier format qui change.

Note

Tout se passe en local, dans un dossier jetable : aucun serveur, aucun compte de cloud, aucune donnée réelle. Le script de vérification a été exécuté avec GNU sed 4.9, mawk 1.3.4-20240123, jq 1.7.1 et Python 3.12 (Ubuntu 24.04) contre une implémentation de référence assemblée à partir des programmes des leçons 3, 4, 5, 6 et 8, puis contre douze versions volontairement cassées de celle-ci : -F, sur le CSV, comptage par uniq -c sans tri préalable, ajout de ligne par sed sans vérifier que la clé existe (non idempotent), valeur collée dans le script sed sans liste blanche, jq sans -r, JSON assemblé par printf, anonymisation sans protection des adresses internes, filtre /python3\[/ sur toute la ligne au lieu du troisième champ, vérifications de santé comptées comme du trafic, centile décalé d'un rang, une commande lancée par ligne dans une boucle while read, locale non fixée. Chaque défaut fait échouer au moins un contrôle. ShellCheck n'était pas installé sur la machine de vérification : son contrôle a été sauté, comme il le sera chez vous dans le même cas.

Ce qu'il faut

  • Une machine Linux : votre poste sous Ubuntu 24.04 ou Debian 13, ou une machine virtuelle jetable. Sous macOS, sed, awk et stat sont les versions BSD, aux options différentes : passez par une machine virtuelle.
  • Les outils du cours, et ceux du script de vérification :
$ sudo apt install jq gawk mawk python3 shellcheck diffutils

Installer les deux awk n'est pas un luxe : le script de vérification lance chacun de vos programmes awk avec mawk et avec gawk --posix quand ils sont présents. Un programme qui ne tourne qu'avec l'un des deux échouera, comme il échouerait le jour où il passerait de sig-app-1 (gawk) à sig-outils (mawk), voir la leçon 5.

  • Le générateur preparer-donnees de la leçon 1, recopié tel quel dans un fichier hors de votre dépôt. Le script de vérification s'en sert pour fabriquer ses propres données, dans son propre répertoire temporaire.
  • Le dépôt Git signalements-outils du cours Bash, ou un dépôt neuf du même nom. Faites un commit à chaque étape franchie : en cas de régression, git diff dira ce qui a changé.

Le bac à sable

Pour vos essais, fabriquez les données du cours dans un répertoire neuf, puis un second jeu dix fois plus gros pour mesurer vos programmes :

$ python3 preparer-donnees ~/essais-texte
$ python3 preparer-donnees ~/essais-texte-x10 --volume 10

Le générateur est déterministe : vos chiffres doivent être exactement ceux des leçons. Si rapport-hebdo ne trouve pas le 650 et les 101 erreurs de sig-app-2 le 7 octobre que montre la leçon 8, le problème est dans vos programmes, pas dans les données.

Gardez aussi sous la main une copie de config/app.conf pour essayer regler-conf : jamais le fichier du bac à sable lui-même, que d'autres essais relisent.

Les exigences

ExigenceLeçon
Le dépôt contient bin/anonymiser-journal, bin/regler-conf et bin/rapport-hebdo (exécutables, #!/usr/bin/env bash), et lib/requetes.awk, lib/rapport.awk, lib/communes.awk, lib/ssh.awk, lib/latences.jq ; ShellCheck ne signale rien sur bin/3, 4, 5, 6, 8
Les programmes awk n'utilisent que l'awk de POSIX : mêmes résultats avec mawk et avec gawk --posix5
awk -f lib/requetes.awk FICHIERS écrit une ligne par requête de l'API, six champs séparés par des tabulations : date AAAA-MM-JJ, heure HH:MM:SS, hôte, méthode, chemin avec sa chaîne de requête, code. Seules comptent les lignes dont le troisième champ est python3[PID]:, qui commencent par un horodatage complet et contiennent une seule requête entre guillemets, de trois mots, suivie d'un code à trois chiffres ; les autres sont ignorées sans message. /sante est conservée5
lib/rapport.awk lit ce TSV et écrit le tableau Markdown | Hôte | Jour | Requêtes | 4xx | 5xx | Taux 5xx |, une ligne par hôte et par jour, triée par hôte puis par jour, sans les vérifications de santé, taux avec deux décimales et un point (15.54 %). Une ligne qui n'a pas six champs est signalée sur la sortie d'erreur et donne le code 1, sans empêcher le tableau6
awk -f lib/communes.awk referentiel/communes.csv exports/*.csv lit le CSV selon la RFC 4180 (champs entre guillemets, guillemets doublés, fins de ligne CRLF acceptées) et écrit | Commune | Population | Signalements | Pour 1 000 hab. | Type le plus fréquent |, triée par nombre de signalements décroissant puis par nom, égalités de type départagées par ordre alphabétique, - pour une commune sans signalement. Guillemet non refermé, nombre de champs inattendu ou commune absente du référentiel : message sur la sortie d'erreur et code 1, le tableau étant produit quand même6
awk -f lib/ssh.awk FICHIERS écrit, pour chaque adresse source, le nombre de connexions SSH refusées (lignes Invalid user ... from IP et Connection closed by authenticating user ... IP), en TSV ip<TAB>nombre2, 8
jq -n -f lib/latences.jq FICHIERS.jsonl écrit un tableau d'objets {hote, requetes, p50_ms, p95_ms, max_ms, erreurs}, centiles par la méthode du rang le plus proche, erreurs = statut ≥ 500 ; les objets sans requete et les lignes vides sont ignorés7, 8
anonymiser-journal [FICHIER...] lit ses fichiers ou l'entrée standard et remplace par 0 le dernier octet de toute adresse IPv4 publique ; les adresses de 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12 et 192.168.0.0/16 restent intactes, comme les numéros de version (jq-1.7.1) ; le nombre de lignes ne change pas et le JSON reste valide ; un fichier absent donne un message et un code non nul3
regler-conf [--dry-run] FICHIER SECTION CLE VALEUR fixe cle = valeur dans la section : remplace la clé si elle y figure (non commentée), l'ajoute à la fin de la section sinon, sans toucher aux commentaires ni aux clés homonymes d'autres sections4
regler-conf : codes 0 (conforme, modifié ou non), 1 (erreur d'exécution, dont un fichier aux fins de ligne CRLF), 2 (usage, valeur ou nom hors liste blanche), 3 (section absente), 4 (section ou clé en double), 5 (--dry-run et une modification serait faite) ; une sauvegarde datée identique à l'original avant toute modification, et aucune écriture quand le fichier est déjà conforme (ni contenu, ni inode, ni date de modification ne changent) ; un lien symbolique reste un lien, c'est sa cible qui change4
rapport-hebdo [--format markdown|json] [--donnees RÉPERTOIRE] assemble les quatre volets (communes, trafic, latences, sécurité avec le total des tentatives SSH, le nombre d'adresses, les trois premières et le nombre de sondes) ; -h/--help commence par Usage ; code 2 pour une option ou un format inconnus, 1 si journaux ou exports manquent8
La version JSON est produite uniquement par jq : les nombres sont des nombres, et un nom de commune qui contient un guillemet ou une barre oblique inverse donne un JSON valide et exact7, 8
rapport-hebdo fonctionne depuis n'importe quel répertoire, ne laisse aucun fichier temporaire, produit la même sortie sous une locale française, et traite le jeu --volume 10 en moins de 20 secondes (une seconde suffit largement)1, 5, 8

Pistes

  • Avancez dans l'ordre des leçons. requetes.awk d'abord : tout le reste s'appuie sur son TSV, et un défaut y fausse tous les chiffres en aval. Vérifiez-le par un comptage indépendant, comme le fait la leçon 5 avec grep -c.
  • Le filtre sur le troisième champ n'est pas un détail : une tâche cron peut très bien contenir le texte python3[ et une requête entre guillemets dans sa commande. Le script de vérification glisse une telle ligne dans un journal.
  • Pour le tri dans un programme awk POSIX, envoyez les lignes à sort par un tube interne (print ... | "LC_ALL=C sort ..."), sans oublier fflush() avant et close() après, comme dans la leçon 6. PROCINFO["sorted_in"] n'existe que dans gawk.
  • "Saint-Exemple, centre" est la raison d'être de csv_champs. Essayez votre découpage sur "Saint-""Témoin"" \ Haut" : le vérificateur le fera.
  • Les centiles : sur quatre durées 12, 20, 30 et 30 000 ms, la méthode du rang le plus proche donne p50 = 20 et p95 = 30 000. Si vous obtenez 30 pour p50, votre rang est décalé d'un.
  • Dans regler-conf, comparez le résultat voulu au fichier (cmp) avant d'écrire quoi que ce soit : c'est ce qui rend le second passage vraiment sans effet, y compris sur la date de modification que surveillent les outils d'audit.
  • Fixez LC_ALL=C.UTF-8 au début de rapport-hebdo. Sous fr_FR.UTF-8, un printf "%.2f" de mawk écrit une virgule, et un sort ne range plus les lignes dans le même ordre.
  • Produisez le JSON avec jq -n et --arg, --argjson, --rawfile, --slurpfile : jamais avec printf ni en collant une variable dans le texte du filtre (leçon 8).
  • Pas de boucle while read qui lance une commande par ligne : sur le jeu --volume 10, elle prend plusieurs minutes, et le contrôle l'arrête au bout de 20 secondes.

Vérifier

Enregistrez le script suivant hors de votre dépôt, sous verifier-lab, rendez-le exécutable et lancez-le avec le chemin du dépôt et celui du générateur : ./verifier-lab ~/signalements-outils ~/preparer-donnees. Il n'a besoin d'aucun droit particulier et ne touche à rien en dehors d'un répertoire temporaire, supprimé à la fin. Comptez une dizaine de secondes.

#!/usr/bin/env bash
# verifier-lab : vérifie de l'extérieur le dépôt signalements-outils du lab « Traiter du texte ».
# Usage : verifier-lab CHEMIN_DU_DEPOT CHEMIN_DE_PREPARER_DONNEES
# Chaque contrôle affiche OK ou ÉCHEC ; le code de sortie vaut 0 si tout passe, 1 sinon.
# Ne touche à rien hors d'un répertoire de travail temporaire, supprimé à la sortie.

set -Eeuo pipefail
shopt -s inherit_errexit
export LC_ALL=C.UTF-8

readonly BUDGET_SECONDES=20

depot=""
generateur=""
travail=""
echecs=0
total=0
sautes=0
awks=()

# --- Infrastructure -------------------------------------------------------

nettoyer() {
  if [[ -n $travail ]]; then
    chmod -R u+w -- "$travail" 2> /dev/null || true
    rm -rf -- "$travail"
  fi
}
trap nettoyer EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

mourir() {
  printf 'verifier-lab : %s\n' "$*" >&2
  exit 1
}

# controle LIBELLÉ COMMANDE...
#   La commande s'exécute dans la condition d'un if, où set -e est suspendu :
#   les fonctions de contrôle enchaînent leurs tests par && ou par des if explicites.
controle() {
  local libelle=$1
  shift
  total=$((total + 1))
  if "$@"; then
    printf 'OK     %s\n' "$libelle"
  else
    printf 'ÉCHEC  %s\n' "$libelle"
    echecs=$((echecs + 1))
  fi
}

saute() {
  printf 'SAUTÉ  %s\n' "$*"
  sautes=$((sautes + 1))
}

# Lance une commande du dépôt dans un environnement vide et maîtrisé.
# lancer [VAR=VALEUR...] -- COMMANDE ARGUMENTS : remplit code, sortie, erreurs.
code=0
lancer() {
  local -a env_essai=()
  while [[ $1 != -- ]]; do
    env_essai+=("$1")
    shift
  done
  shift
  code=0
  env -i HOME="$travail/maison" PATH="$PATH" LC_ALL=C.UTF-8 TMPDIR="$travail/tmp" \
    "${env_essai[@]}" "$@" > "$travail/sortie" 2> "$travail/erreurs" < /dev/null || code=$?
}

# Même chose, avec un fichier sur l'entrée standard.
lancer_entree() {
  local entree=$1
  shift
  code=0
  env -i HOME="$travail/maison" PATH="$PATH" LC_ALL=C.UTF-8 TMPDIR="$travail/tmp" \
    "$@" > "$travail/sortie" 2> "$travail/erreurs" < "$entree" || code=$?
}

erreurs_non_vides() { [[ -s $travail/erreurs ]]; }
tmp_vide() { [[ -z $(find "$travail/tmp" -mindepth 1 -print -quit) ]]; }

# --- Valeurs attendues, calculées indépendamment en Python -----------------
#
# Le vérificateur ne fait confiance ni à awk ni à jq pour savoir ce qui est
# juste : il recalcule chaque résultat attendu avec la bibliothèque standard
# de Python (csv, json, re), à partir des mêmes fichiers.

calculer_attendus() {   # calculer_attendus DONNÉES SORTIE
  python3 - "$1" "$2" <<'PY'
import csv, glob, json, math, os, re, sys
donnees, sortie = sys.argv[1], sys.argv[2]
os.makedirs(sortie, exist_ok=True)

ligne_api = re.compile(r'^(\d{4}-\d\d-\d\d)T(\d\d:\d\d:\d\d)\S* (\S+) python3\[\d+\]: [^"]*"([^" ]+) ([^" ]+) ([^" ]+)" (\d{3})(?: |$)')
journaux = sorted(glob.glob(f"{donnees}/journaux/*/syslog.log"))
tsv, trafic, sondes = [], {}, 0
for journal in journaux:
    with open(journal, encoding="utf-8") as f:
        for ligne in f:
            m = ligne_api.match(ligne.rstrip("\n"))
            if not m or ligne.count('"') != 2:
                continue
            date, heure, hote, methode, chemin, _, statut = m.groups()
            tsv.append("\t".join([date, heure, hote, methode, chemin, statut]))
            if statut == "404" and re.match(r"^/(wp-login\.php|\.env|\.git/|admin/|phpmyadmin/)", chemin):
                sondes += 1
            if chemin == "/sante":
                continue
            t = trafic.setdefault((hote, date), [0, 0, 0])
            t[0] += 1
            if statut.startswith("4"):
                t[1] += 1
            elif statut.startswith("5"):
                t[2] += 1
with open(f"{sortie}/requetes.tsv", "w") as f:
    f.write("".join(l + "\n" for l in tsv))
with open(f"{sortie}/trafic.lignes", "w") as f:
    for (hote, date), (n, c4, c5) in sorted(trafic.items()):
        f.write(f"| {hote} | {date} | {n} | {c4} | {c5} | {100 * c5 / n:.2f} % |\n")

population, types = {}, {}
with open(f"{donnees}/referentiel/communes.csv", newline="", encoding="utf-8") as f:
    for code, nom, pop in list(csv.reader(f))[1:]:
        population[nom] = int(pop)
for export in sorted(glob.glob(f"{donnees}/exports/signalements-*.csv")):
    with open(export, newline="", encoding="utf-8") as f:
        for _, sorte, commune, _ in list(csv.reader(f))[1:]:
            types.setdefault(commune, {}).setdefault(sorte, 0)
            types[commune][sorte] += 1
lignes = []
for nom, pop in population.items():
    par_type = types.get(nom, {})
    n = sum(par_type.values())
    principal = min(par_type, key=lambda t: (-par_type[t], t)) if par_type else "-"
    lignes.append((n, nom, f"| {nom} | {pop} | {n} | {1000 * n / pop:.2f} | {principal} |"))
with open(f"{sortie}/communes.lignes", "w") as f:
    for n, nom, texte in sorted(lignes, key=lambda l: (-l[0], l[1].encode())):
        f.write(texte + "\n")

durees, erreurs = {}, {}
for fichier in sorted(glob.glob(f"{donnees}/journaux/*/api.jsonl")):
    with open(fichier, encoding="utf-8") as f:
        for ligne in f:
            if not ligne.strip():
                continue
            objet = json.loads(ligne)
            if "requete" not in objet:
                continue
            durees.setdefault(objet["hote"], []).append(objet["requete"]["duree_ms"])
            erreurs[objet["hote"]] = erreurs.get(objet["hote"], 0) + (objet["requete"]["statut"] >= 500)
latences = []
for hote in sorted(durees):
    d = sorted(durees[hote])
    rang = lambda p: d[math.ceil(len(d) * p) - 1]
    latences.append({"hote": hote, "requetes": len(d), "p50_ms": rang(0.50),
                     "p95_ms": rang(0.95), "max_ms": d[-1], "erreurs": erreurs[hote]})
with open(f"{sortie}/latences.json", "w") as f:
    json.dump(latences, f)
with open(f"{sortie}/latences.lignes", "w") as f:
    for l in latences:
        f.write(f"| {l['hote']} | {l['requetes']} | {l['p50_ms']} ms | {l['p95_ms']} ms | {l['max_ms']} ms | {l['erreurs']} |\n")

ssh = {}
for journal in journaux:
    with open(journal, encoding="utf-8") as f:
        for ligne in f:
            m = (re.search(r" sshd\[\d+\]: Invalid user \S+ from (\S+) port \d+$", ligne)
                 or re.search(r" sshd\[\d+\]: Connection closed by authenticating user \S+ (\S+) port \d+ \[preauth\]$", ligne))
            if m:
                ssh[m.group(1)] = ssh.get(m.group(1), 0) + 1
with open(f"{sortie}/ssh.tsv", "w") as f:
    for ip, n in sorted(ssh.items(), key=lambda e: (-e[1], e[0])):
        f.write(f"{ip}\t{n}\n")
with open(f"{sortie}/securite.lignes", "w") as f:
    f.write(f"- Tentatives de connexion SSH refusées : {sum(ssh.values())}, depuis {len(ssh)} adresses\n")
    for ip, n in sorted(ssh.items(), key=lambda e: (-e[1], e[0]))[:3]:
        f.write(f"  - {ip} : {n}\n")
    f.write(f"- Sondes de vulnérabilités (404 sur des chemins connus) : {sondes}\n")
with open(f"{sortie}/sondes", "w") as f:
    f.write(f"{sondes}\n")
exports = sorted(glob.glob(f"{donnees}/exports/signalements-*.csv"))
with open(f"{sortie}/periode", "w") as f:
    f.write(os.path.basename(exports[0])[13:23] + " " + os.path.basename(exports[-1])[13:23] + "\n")
PY
}

# Vérifie que chaque ligne du fichier attendu figure, telle quelle, dans la sortie.
lignes_presentes() {   # lignes_presentes FICHIER_ATTENDU FICHIER_OBTENU
  local ligne
  while IFS= read -r ligne; do
    grep -qxF -- "$ligne" "$2" || return 1
  done < "$1"
}

# Lignes de données d'un tableau Markdown (après l'en-tête et le séparateur).
lignes_tableau() { awk 'NR > 2' "$1"; }

# --- Données d'essai hostiles ------------------------------------------------

preparer_hostiles() {
  local h=$travail/hostiles
  mkdir -p -- "$h"
  # Journal : une seule ligne d'API valide, entourée de pièges.
  {
    printf '%s\n' '2026-10-07T10:00:00.000001+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 10:00:00] "GET /signalements?commune=Val-d%27Essai HTTP/1.1" 200 -'
    printf '%s\n' '2026-10-07T10:00:01.000001+00:00 sig-app-1 CRON[4242]: (root) CMD (echo python3[812]: "GET /sante HTTP/1.1" 200 -)'
    printf '%s\n' '2026-10-07T10:00:02.000001+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 10:00:02] code 400, message Bad request version'
    printf '%s\n' '2026-10-07T10:00:02.000002+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 10:00:02] "\x16\x03\x01\x02\x00\x01\x00\x01\xfc\x03\x03" 400 -'
    printf '%s\n' '2026-10-07T10:00:03.000001+00:00 sig-app-1 sshd[51234]: Invalid user admin from 203.0.113.9 port 40000'
    printf '%s\n' 'ligne tronquée par une rotation brutale'
    printf '%s\n' '2026-10-07T10:00:04.000001+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 10:00:04] "GET /signalements HTTP/1.1" abc -'
  } > "$h/syslog.log"
  printf '2026-10-07\t10:00:00\tsig-app-1\tGET\t/signalements?commune=Val-d%%27Essai\t200\n' > "$h/requetes.attendu"
}

# --- Contrôles de structure ----------------------------------------------------

structure_presente() {
  local f
  for f in bin/anonymiser-journal bin/regler-conf bin/rapport-hebdo; do
    [[ -x $depot/$f ]] || return 1
  done
  for f in lib/requetes.awk lib/rapport.awk lib/communes.awk lib/ssh.awk lib/latences.jq; do
    [[ -f $depot/$f ]] || return 1
  done
}

shellcheck_propre() {
  ( cd -- "$depot" && shellcheck bin/anonymiser-journal bin/regler-conf bin/rapport-hebdo ) \
    > "$travail/shellcheck.txt" 2>&1
}

# --- awk : requetes.awk, rapport.awk, communes.awk, ssh.awk -----------------

# Chaque contrôle awk tourne avec chaque implémentation disponible.
requetes_exactes() {
  local a
  for a in "${awks[@]}"; do
    # shellcheck disable=SC2086  # $a peut valoir « gawk --posix » : deux mots voulus.
    $a -f "$depot/lib/requetes.awk" "$travail/donnees"/journaux/*/syslog.log > "$travail/obtenu.tsv" \
      && cmp -s "$travail/obtenu.tsv" "$travail/attendu/requetes.tsv" || return 1
  done
}

requetes_hostiles() {
  local a
  for a in "${awks[@]}"; do
    # shellcheck disable=SC2086
    $a -f "$depot/lib/requetes.awk" "$travail/hostiles/syslog.log" > "$travail/obtenu.tsv" 2> /dev/null \
      && cmp -s "$travail/obtenu.tsv" "$travail/hostiles/requetes.attendu" || return 1
  done
}

rapport_exact() {
  local a
  for a in "${awks[@]}"; do
    # shellcheck disable=SC2086
    $a -f "$depot/lib/rapport.awk" "$travail/attendu/requetes.tsv" > "$travail/obtenu.md" \
      && [[ $(head -n 1 "$travail/obtenu.md") == '| Hôte | Jour | Requêtes | 4xx | 5xx | Taux 5xx |' ]] \
      && sed -n 2p "$travail/obtenu.md" | grep -q '^|---' \
      && cmp -s <(lignes_tableau "$travail/obtenu.md") "$travail/attendu/trafic.lignes" || return 1
  done
}

rapport_ligne_mal_formee() {
  { cat "$travail/attendu/requetes.tsv"; printf 'ligne\tsans\tsix\tchamps\n'; } > "$travail/abime.tsv"
  code=0
  awk -f "$depot/lib/rapport.awk" "$travail/abime.tsv" > "$travail/sortie" 2> "$travail/erreurs" || code=$?
  (( code == 1 )) && erreurs_non_vides \
    && cmp -s <(lignes_tableau "$travail/sortie") "$travail/attendu/trafic.lignes"
}

communes_exactes() {
  local a
  for a in "${awks[@]}"; do
    # shellcheck disable=SC2086
    $a -f "$depot/lib/communes.awk" "$travail/donnees/referentiel/communes.csv" \
      "$travail/donnees"/exports/signalements-*.csv > "$travail/obtenu.md" \
      && [[ $(head -n 1 "$travail/obtenu.md") == '| Commune | Population | Signalements | Pour 1 000 hab. | Type le plus fréquent |' ]] \
      && cmp -s <(lignes_tableau "$travail/obtenu.md") "$travail/attendu/communes.lignes" || return 1
  done
}

# Un export aux fins de ligne CRLF compte comme les autres ; un guillemet non
# refermé et une commune inconnue sont signalés (code 1) sans fausser le reste.
communes_abimees() {
  local e=$travail/exports-abimes
  rm -rf -- "$e"
  cp -r -- "$travail/donnees/exports" "$e"
  sed -i 's/$/\r/' "$e/signalements-2026-10-06.csv"
  printf '%s\n' "13000,graffiti,\"Val-d'Essai,2026-10-08" >> "$e/signalements-2026-10-08.csv"
  printf '%s\n' '13001,graffiti,Ville-Inconnue,2026-10-08' > "$e/signalements-2026-10-09.csv.inconnue"
  code=0
  awk -f "$depot/lib/communes.awk" "$travail/donnees/referentiel/communes.csv" "$e"/signalements-*.csv \
    > "$travail/sortie" 2> "$travail/erreurs" || code=$?
  (( code == 1 )) && erreurs_non_vides \
    && cmp -s <(lignes_tableau "$travail/sortie") "$travail/attendu/communes.lignes" || return 1
  # Commune absente du référentiel : signalée aussi.
  { printf 'id,type,commune,date\n'; cat "$e/signalements-2026-10-09.csv.inconnue"; } > "$e/inconnue.csv"
  code=0
  awk -f "$depot/lib/communes.awk" "$travail/donnees/referentiel/communes.csv" "$e/inconnue.csv" \
    > "$travail/sortie" 2> "$travail/erreurs" || code=$?
  (( code == 1 )) && grep -q 'Ville-Inconnue' "$travail/erreurs"
}

ssh_exact() {
  local a
  for a in "${awks[@]}"; do
    # shellcheck disable=SC2086
    $a -f "$depot/lib/ssh.awk" "$travail/donnees"/journaux/*/syslog.log > "$travail/obtenu.tsv" \
      && cmp -s <(sort "$travail/obtenu.tsv") <(sort "$travail/attendu/ssh.tsv") || return 1
  done
}

# --- jq : latences.jq --------------------------------------------------------------

latences_exactes() {
  jq -n -f "$depot/lib/latences.jq" "$travail/donnees"/journaux/*/api.jsonl > "$travail/obtenu.json" \
    && jq -e --slurpfile attendu "$travail/attendu/latences.json" \
      'sort_by(.hote) == ($attendu[0] | sort_by(.hote))' "$travail/obtenu.json" > /dev/null
}

# Objets sans « requete » et lignes vides : ignorés sans erreur. Quatre durées
# distinctes (12, 20, 30, 30000) : rang le plus proche, p50 = 20 et p95 = 30000.
latences_heterogenes() {
  local h=$travail/jsonl-hostile
  mkdir -p -- "$h"
  {
    printf '%s\n' '{"horodatage":"2026-10-07T14:02:06.000Z","niveau":"warning","hote":"sig-app-9","message":"pool saturé","pool":{"taille":10}}'
    printf '\n'
    printf '%s\n' '{"horodatage":"2026-10-07T14:03:00.000Z","niveau":"info","hote":"sig-app-9","requete":{"id":"a","methode":"GET","chemin":"/","statut":200,"duree_ms":12},"client":{"ip":"192.0.2.1","agent":"x"}}'
    printf '%s\n' '{"niveau":"info","message":"démarrage"}'
    printf '%s\n' '{"horodatage":"2026-10-07T14:03:02.000Z","niveau":"info","hote":"sig-app-9","requete":{"id":"c","methode":"GET","chemin":"/","statut":200,"duree_ms":30},"client":{"ip":"192.0.2.3","agent":"x"}}'
    printf '%s\n' '{"horodatage":"2026-10-07T14:03:03.000Z","niveau":"info","hote":"sig-app-9","requete":{"id":"d","methode":"GET","chemin":"/","statut":304,"duree_ms":20},"client":{"ip":"192.0.2.4","agent":"x"}}'
    printf '%s\n' '{"horodatage":"2026-10-07T14:03:01.000Z","niveau":"error","hote":"sig-app-9","requete":{"id":"b","methode":"GET","chemin":"/","statut":503,"duree_ms":30000},"client":{"ip":"192.0.2.2","agent":"x"},"erreur":{"type":"OperationalError","message":"\"sig-db\" \\ saturé"}}'
  } > "$h/api.jsonl"
  jq -n -f "$depot/lib/latences.jq" "$h/api.jsonl" > "$travail/obtenu.json" 2> "$travail/erreurs" \
    && jq -e 'length == 1 and .[0].hote == "sig-app-9" and .[0].requetes == 4
              and .[0].p50_ms == 20 and .[0].p95_ms == 30000
              and .[0].max_ms == 30000 and .[0].erreurs == 1' "$travail/obtenu.json" > /dev/null
}

# --- sed : anonymiser-journal ------------------------------------------------

anonymiser_bornes() {
  printf '%s\n' \
    '10.1.2.3 172.15.0.9 172.16.0.9 172.31.255.1 172.32.0.1 192.168.1.20 192.169.1.20 8.8.8.8 127.0.0.1' \
    'Firefox/131.0 jq-1.7.1 [UFW BLOCK] SRC=203.0.113.150 DST=51.15.7.42' > "$travail/bornes.txt"
  lancer_entree "$travail/bornes.txt" "$depot/bin/anonymiser-journal"
  (( code == 0 )) && cmp -s "$travail/sortie" <(printf '%s\n' \
    '10.1.2.3 172.15.0.0 172.16.0.9 172.31.255.1 172.32.0.0 192.168.1.20 192.169.1.0 8.8.8.0 127.0.0.1' \
    'Firefox/131.0 jq-1.7.1 [UFW BLOCK] SRC=203.0.113.0 DST=51.15.7.0')
}

# Sur les vrais journaux : autant de lignes, plus aucune adresse publique
# complète, les adresses privées intactes, du JSON toujours valide.
anonymiser_journaux() {
  local j
  for j in "$travail/donnees"/journaux/sig-app-2*/syslog.log "$travail/donnees"/journaux/sig-app-2*/api.jsonl; do
    lancer -- "$depot/bin/anonymiser-journal" "$j"
    (( code == 0 )) || return 1
    [[ $(wc -l < "$travail/sortie") == "$(wc -l < "$j")" ]] || return 1
    python3 - "$travail/sortie" <<'PY' || return 1
import ipaddress, re, sys
# Nos plages internes, explicitement : Python range aussi les plages de documentation
# (192.0.2.0/24...) parmi les adresses « privées », ce qui fausserait le contrôle.
internes = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
for ligne in open(sys.argv[1], encoding="utf-8"):
    for texte in re.findall(r"(?<![\d.])\d{1,3}(?:\.\d{1,3}){3}(?![\d.])", ligne):
        ip = ipaddress.ip_address(texte)
        if not any(ip in n for n in internes) and not texte.endswith(".0"):
            sys.exit(1)
PY
    grep -qF '172.16.8.20' "$travail/sortie" || [[ $j == *.jsonl ]] || return 1
    if [[ $j == *.jsonl ]]; then
      jq -e . "$travail/sortie" > /dev/null || return 1
    fi
  done
}

anonymiser_entrees() {
  printf '%s\n' 'client 198.51.100.23' > "$travail/un.txt"
  lancer_entree "$travail/un.txt" "$depot/bin/anonymiser-journal"
  [[ $code == 0 && $(cat "$travail/sortie") == 'client 198.51.100.0' ]] || return 1
  lancer -- "$depot/bin/anonymiser-journal" "$travail/un.txt" "$travail/un.txt"
  [[ $code == 0 && $(wc -l < "$travail/sortie") == 2 ]] || return 1
  lancer -- "$depot/bin/anonymiser-journal" "$travail/absent.log"
  (( code != 0 )) && erreurs_non_vides
}

# --- sed : regler-conf ---------------------------------------------------------

preparer_conf() {
  rm -rf -- "$travail/conf"
  mkdir -p -- "$travail/conf"
  cp -- "$travail/donnees/config/app.conf" "$travail/conf/app.conf"
}

# Empreinte d'un fichier : contenu, inode et date de modification.
empreinte() { printf '%s %s\n' "$(sha256sum < "$1")" "$(stat -c '%i %Y.%y' -- "$1")"; }
nb_sauvegardes() { find "$travail/conf" -name 'app.conf.?*' | wc -l; }

regler_modifie() {
  local f=$travail/conf/app.conf
  preparer_conf
  lancer -- "$depot/bin/regler-conf" "$f" base taille_pool 20
  (( code == 0 )) || return 1
  # Une seule ligne a changé, la bonne ; la ligne commentée est intacte.
  diff "$travail/donnees/config/app.conf" "$f" > "$travail/diff" || true
  [[ $(grep -c '^[<>]' "$travail/diff") == 2 ]] \
    && grep -qx '< taille_pool = 10' "$travail/diff" \
    && grep -qx '> taille_pool = 20' "$travail/diff" \
    && grep -qx '# taille_pool = 5' "$f" \
    && (( $(nb_sauvegardes) == 1 )) \
    && cmp -s "$(find "$travail/conf" -name 'app.conf.?*')" "$travail/donnees/config/app.conf"
}

regler_idempotent() {
  local f=$travail/conf/app.conf avant
  sleep 1.1   # une date de modification changée doit être visible
  avant=$(empreinte "$f")
  lancer -- "$depot/bin/regler-conf" "$f" base taille_pool 20
  (( code == 0 )) && [[ $(empreinte "$f") == "$avant" ]] && (( $(nb_sauvegardes) == 1 ))
}

regler_ajoute() {
  local f=$travail/conf/app.conf
  preparer_conf
  lancer -- "$depot/bin/regler-conf" "$f" api port 9000
  (( code == 0 )) || return 1
  # La clé est ajoutée dans [api], entre son en-tête et [stockage] ; [base] n'a pas bougé.
  awk '/^\[api\]/ { s = 1; next } /^\[/ { s = 0 } s && /^port = 9000$/ { trouve = 1 }
       END { exit !trouve }' "$f" \
    && grep -qx 'port = 5432' "$f" \
    && [[ $(grep -c '^port' "$f") == 2 ]] \
    && [[ $(diff "$travail/donnees/config/app.conf" "$f" | grep -c '^[<>]') == 1 ]]
}

regler_refus() {
  local f=$travail/conf/app.conf avant valeur
  preparer_conf
  printf '\n[api]\nport = 1\n' >> "$travail/conf/double-section.conf"
  cat "$f" "$travail/conf/double-section.conf" > "$travail/conf/deux-api.conf"
  sed '/^nom = /a nom = autre' "$f" > "$travail/conf/cle-double.conf"
  for valeur in 'a/b' 'x&y' $'deux\nlignes' 'v;rm'; do
    avant=$(empreinte "$f")
    lancer -- "$depot/bin/regler-conf" "$f" api delai "$valeur"
    (( code == 2 )) && erreurs_non_vides && [[ $(empreinte "$f") == "$avant" ]] || return 1
  done
  avant=$(empreinte "$f")
  lancer -- "$depot/bin/regler-conf" "$f" journal niveau info
  (( code == 3 )) && [[ $(empreinte "$f") == "$avant" ]] || return 1
  avant=$(empreinte "$travail/conf/cle-double.conf")
  lancer -- "$depot/bin/regler-conf" "$travail/conf/cle-double.conf" base nom x
  (( code == 4 )) && [[ $(empreinte "$travail/conf/cle-double.conf") == "$avant" ]] || return 1
  avant=$(empreinte "$travail/conf/deux-api.conf")
  lancer -- "$depot/bin/regler-conf" "$travail/conf/deux-api.conf" api delai 60
  (( code == 4 )) && [[ $(empreinte "$travail/conf/deux-api.conf") == "$avant" ]]
}

regler_simulation() {
  local f=$travail/conf/app.conf avant
  preparer_conf
  avant=$(empreinte "$f")
  lancer -- "$depot/bin/regler-conf" --dry-run "$f" base taille_pool 20
  (( code == 5 )) && [[ $(empreinte "$f") == "$avant" ]] && (( $(nb_sauvegardes) == 0 )) || return 1
  lancer -- "$depot/bin/regler-conf" --dry-run "$f" base taille_pool 10
  (( code == 0 )) && [[ $(empreinte "$f") == "$avant" ]]
}

regler_crlf_et_lien() {
  local avant
  preparer_conf
  sed 's/$/\r/' "$travail/conf/app.conf" > "$travail/conf/crlf.conf"
  avant=$(empreinte "$travail/conf/crlf.conf")
  lancer -- "$depot/bin/regler-conf" "$travail/conf/crlf.conf" base taille_pool 20
  (( code == 1 )) && [[ $(empreinte "$travail/conf/crlf.conf") == "$avant" ]] || return 1
  # Un lien symbolique reste un lien ; c'est sa cible qui change.
  ln -s app.conf "$travail/conf/lien.conf"
  lancer -- "$depot/bin/regler-conf" "$travail/conf/lien.conf" base taille_pool 20
  (( code == 0 )) && [[ -L $travail/conf/lien.conf ]] && grep -qx 'taille_pool = 20' "$travail/conf/app.conf"
}

# --- Bash : rapport-hebdo ------------------------------------------------------

rapport_interface() {
  lancer -- "$depot/bin/rapport-hebdo" --help
  (( code == 0 )) && grep -q '^Usage' "$travail/sortie" || return 1
  lancer -- "$depot/bin/rapport-hebdo" --option-inconnue
  (( code == 2 )) || return 1
  lancer -- "$depot/bin/rapport-hebdo" --format xml --donnees "$travail/donnees"
  (( code == 2 )) || return 1
  mkdir -p -- "$travail/vide"
  lancer -- "$depot/bin/rapport-hebdo" --donnees "$travail/vide"
  (( code == 1 )) && erreurs_non_vides && tmp_vide
}

rapport_markdown() {
  local debut fin
  read -r debut fin < "$travail/attendu/periode"
  # Lancé depuis un autre répertoire, avec un chemin relatif.
  code=0
  ( cd -- "$travail" && env -i HOME="$travail/maison" PATH="$PATH" TMPDIR="$travail/tmp" \
      "$depot/bin/rapport-hebdo" --donnees donnees ) > "$travail/rapport.md" 2> "$travail/erreurs" < /dev/null || code=$?
  (( code == 0 )) \
    && grep -qxF "# Signalements : rapport du $debut au $fin" "$travail/rapport.md" \
    && lignes_presentes "$travail/attendu/communes.lignes" "$travail/rapport.md" \
    && lignes_presentes "$travail/attendu/trafic.lignes" "$travail/rapport.md" \
    && lignes_presentes "$travail/attendu/latences.lignes" "$travail/rapport.md" \
    && lignes_presentes "$travail/attendu/securite.lignes" "$travail/rapport.md" \
    && tmp_vide
}

rapport_json() {
  local debut fin
  read -r debut fin < "$travail/attendu/periode"
  lancer -- "$depot/bin/rapport-hebdo" --donnees "$travail/donnees" --format json
  (( code == 0 )) && tmp_vide || return 1
  jq -e --arg debut "$debut" --arg fin "$fin" \
    --slurpfile latences "$travail/attendu/latences.json" \
    --rawfile ssh "$travail/attendu/ssh.tsv" \
    --argjson sondes "$(cat "$travail/attendu/sondes")" '
      .periode == {debut: $debut, fin: $fin}
      and (.communes | length) == 5
      and all(.communes[]; (.population | type) == "number" and (.signalements | type) == "number"
                           and (.pour_1000_habitants | type) == "number")
      and any(.communes[]; .commune == "Saint-Exemple, centre")
      and (.trafic | length) == 8
      and all(.trafic[]; (.taux_5xx_pct | type) == "number")
      and (.latences | sort_by(.hote)) == ($latences[0] | sort_by(.hote))
      and .securite.sondes == $sondes
      and ([.securite.ssh[] | "\(.ip)\t\(.tentatives)"] | join("\n")) + "\n" == $ssh
    ' "$travail/sortie" > /dev/null
}

# Une commune dont le nom contient un guillemet : le JSON doit rester valide et exact.
rapport_json_echappement() {
  local d=$travail/donnees-guillemet
  rm -rf -- "$d"
  cp -r -- "$travail/donnees" "$d"
  printf '%s\n' '99106,"Saint-""Témoin"" \ Haut",900' >> "$d/referentiel/communes.csv"
  printf '%s\n' '13002,graffiti,"Saint-""Témoin"" \ Haut",2026-10-08' >> "$d/exports/signalements-2026-10-08.csv"
  lancer -- "$depot/bin/rapport-hebdo" --donnees "$d" --format json
  (( code == 0 )) && jq -e '
      any(.communes[]; .commune == "Saint-\"Témoin\" \\ Haut" and .signalements == 1 and .population == 900)
    ' "$travail/sortie" > /dev/null
}

rapport_volume() {
  local debut ecoule
  debut=$(date +%s%N)
  # timeout : une implémentation trop lente échoue au lieu de bloquer la vérification.
  lancer -- timeout "$BUDGET_SECONDES" "$depot/bin/rapport-hebdo" --donnees "$travail/v10"
  (( code == 0 )) || return 1
  lancer -- timeout "$BUDGET_SECONDES" "$depot/bin/rapport-hebdo" --donnees "$travail/v10" --format json
  (( code == 0 )) || return 1
  ecoule=$(( ($(date +%s%N) - debut) / 1000000 ))
  printf '       (volume 10 : %d ms pour les deux formats)\n' "$ecoule"
  (( ecoule < BUDGET_SECONDES * 1000 )) && jq -e '(.trafic | length) == 8' "$travail/sortie" > /dev/null
}

# Sous une locale française, rien ne change : pas de virgule décimale, même ordre.
rapport_locale() {
  env -i HOME="$travail/maison" PATH="$PATH" TMPDIR="$travail/tmp" \
    "$depot/bin/rapport-hebdo" --donnees "$travail/donnees" > "$travail/rapport-c.md" 2> /dev/null < /dev/null \
    && env -i HOME="$travail/maison" PATH="$PATH" TMPDIR="$travail/tmp" LANG=fr_FR.UTF-8 LC_NUMERIC=fr_FR.UTF-8 \
      "$depot/bin/rapport-hebdo" --donnees "$travail/donnees" > "$travail/rapport-fr.md" 2> /dev/null < /dev/null \
    && cmp -s "$travail/rapport-c.md" "$travail/rapport-fr.md"
}

# --- Programme principal ----------------------------------------------------

main() {
  local outil
  (( $# == 2 )) || mourir "usage : verifier-lab CHEMIN_DU_DEPOT CHEMIN_DE_PREPARER_DONNEES"
  depot=$(readlink -f -- "$1")
  generateur=$(readlink -f -- "$2")
  [[ -d $depot ]] || mourir "dépôt introuvable : $1"
  [[ -f $generateur ]] || mourir "générateur introuvable : $2"
  for outil in python3 awk sed jq sha256sum stat cmp diff timeout; do
    command -v "$outil" > /dev/null || mourir "commande requise introuvable : $outil"
  done
  # Toutes les implémentations d'awk présentes ; awk lui-même en dernier recours.
  command -v mawk > /dev/null && awks+=(mawk)
  command -v gawk > /dev/null && awks+=("gawk --posix")
  (( ${#awks[@]} > 0 )) || awks=(awk)

  travail=$(mktemp -d -t verifier-lab.XXXXXX)
  mkdir -p -- "$travail/maison" "$travail/tmp"
  printf 'Dépôt : %s\nawk essayés : %s\nPréparation des données...\n\n' "$depot" "${awks[*]}"
  python3 "$generateur" "$travail/donnees" > /dev/null || mourir "le générateur a échoué"
  python3 "$generateur" "$travail/v10" --volume 10 > /dev/null || mourir "le générateur a échoué (--volume 10)"
  calculer_attendus "$travail/donnees" "$travail/attendu"
  preparer_hostiles

  controle "structure : bin/ (3 outils exécutables) et lib/ (4 programmes awk, latences.jq)" structure_presente
  if (( echecs > 0 )); then
    mourir "structure incomplète, inutile d'aller plus loin"
  fi
  if command -v shellcheck > /dev/null; then
    controle "ShellCheck : aucun constat sur bin/" shellcheck_propre
  else
    saute "ShellCheck absent : analyse de bin/ non faite"
  fi

  controle "requetes.awk : TSV exact sur les journaux d'essai" requetes_exactes
  controle "requetes.awk : lignes piégées écartées (CRON, erreur 400, octets TLS, code non numérique)" requetes_hostiles
  controle "rapport.awk : tableau exact, /sante exclue, ordre hôte puis jour" rapport_exact
  controle "rapport.awk : ligne mal formée signalée (code 1), tableau intact" rapport_ligne_mal_formee
  controle "communes.awk : tableau exact, « Saint-Exemple, centre » compris" communes_exactes
  controle "communes.awk : export CRLF accepté, guillemet non refermé et commune inconnue signalés (code 1)" communes_abimees
  controle "ssh.awk : tentatives refusées par adresse source" ssh_exact

  controle "latences.jq : p50, p95, maximum et erreurs exacts par hôte" latences_exactes
  controle "latences.jq : objets sans requête et lignes vides ignorés, centiles au rang le plus proche" latences_heterogenes

  controle "anonymiser-journal : bornes des plages privées, versions intactes" anonymiser_bornes
  controle "anonymiser-journal : journaux texte et JSON, lignes conservées, plus d'IP publique complète" anonymiser_journaux
  controle "anonymiser-journal : entrée standard, plusieurs fichiers, fichier absent (code non nul)" anonymiser_entrees

  controle "regler-conf : une seule ligne modifiée, commentaire intact, sauvegarde fidèle" regler_modifie
  controle "regler-conf : second passage sans effet (contenu, inode, date, sauvegardes)" regler_idempotent
  controle "regler-conf : clé absente ajoutée dans sa section, homonyme d'une autre section intact" regler_ajoute
  controle "regler-conf : valeurs interdites (2), section absente (3), clé ou section en double (4), fichier intact" regler_refus
  controle "regler-conf : --dry-run (5 si changement, 0 si conforme), rien d'écrit" regler_simulation
  controle "regler-conf : fichier CRLF refusé (1), lien symbolique conservé" regler_crlf_et_lien

  controle "rapport-hebdo : --help, options et format invalides (2), données absentes (1)" rapport_interface
  controle "rapport-hebdo : Markdown exact, depuis un autre répertoire, aucun fichier temporaire laissé" rapport_markdown
  controle "rapport-hebdo : JSON exact et typé" rapport_json
  controle "rapport-hebdo : JSON valide avec un guillemet et une barre oblique inverse dans un nom" rapport_json_echappement
  controle "rapport-hebdo : volume 10 traité en moins de $BUDGET_SECONDES s" rapport_volume
  if locale -a 2> /dev/null | grep -qix 'fr_FR.utf-\?8'; then
    controle "rapport-hebdo : sortie identique sous une locale française" rapport_locale
  else
    saute "rapport-hebdo : locale fr_FR.UTF-8 absente, contrôle de la virgule décimale non fait"
  fi

  printf '\n%d contrôle(s), %d échec(s)' "$total" "$echecs"
  (( sautes == 0 )) || printf ', %d sauté(s)' "$sautes"
  printf '\n'
  if (( echecs > 0 )); then
    printf 'Détails : relancez le scénario en échec à la main sur les données de preparer-donnees.\n'
    return 1
  fi
}

main "$@"

La sortie attendue, une fois tout en place, ici sur une machine où ShellCheck est installé et où seul mawk est présent :

Dépôt : /home/vous/signalements-outils
awk essayés : mawk
Préparation des données...

OK     structure : bin/ (3 outils exécutables) et lib/ (4 programmes awk, latences.jq)
OK     ShellCheck : aucun constat sur bin/
OK     requetes.awk : TSV exact sur les journaux d'essai
...
       (volume 10 : 719 ms pour les deux formats)
OK     rapport-hebdo : volume 10 traité en moins de 20 s
OK     rapport-hebdo : sortie identique sous une locale française

26 contrôle(s), 0 échec(s)

La ligne ShellCheck devient SAUTÉ si l'outil n'est pas installé, et le contrôle de la locale aussi si fr_FR.UTF-8 n'existe pas sur la machine (locale -a la liste) : un contrôle sauté n'est pas un contrôle réussi, installez ce qui manque avant de considérer le lab validé. La durée mesurée dépend de votre machine.

Quelques choix du script méritent une explication, parce qu'ils appliquent le cours :

  • Des résultats recalculés, pas recopiés. Le script ne contient aucun chiffre en dur : il fabrique les données avec votre preparer-donnees, puis recalcule chaque résultat attendu avec la bibliothèque standard de Python (csv, json, re), par un chemin indépendant d'awk et de jq. Deux implémentations écrites séparément qui tombent d'accord valent mieux qu'un résultat attendu copié depuis la sortie du programme testé.
  • Des formats exacts, mais seulement ceux de l'interface. Les lignes des tableaux Markdown sont comparées octet pour octet, puisque d'autres programmes les relisent (rapport-json.jq de la leçon 8). Les messages d'erreur, eux, restent libres : le script vérifie seulement qu'il y en a un, et le code de sortie.
  • Des pièges ciblés. Chaque entrée hostile vise un défaut précis : la ligne cron qui contient python3[ et une requête entre guillemets, la ligne de requête TLS reçue sur un port HTTP, l'export converti en CRLF, la ligne au guillemet non refermé, la commune inconnue, les bornes 172.15, 172.32 et 192.169 qui encadrent les plages privées, la clé commentée, la clé homonyme d'une autre section, la valeur a/b qui casserait une commande s mal protégée, le nom de commune Saint-"Témoin" \ Haut.
  • L'idempotence mesurée. Le second passage de regler-conf est jugé sur le contenu, l'inode et la date de modification du fichier, une seconde après le premier : un sed -i qui réécrit le même contenu change l'inode (leçon 4) et ne passe pas.
  • Le temps borné. timeout arrête rapport-hebdo au bout de 20 secondes sur le jeu --volume 10 : une implémentation trop lente échoue au lieu de bloquer la vérification.
  • Un environnement vide. Chaque outil est lancé par env -i avec un TMPDIR propre au script, ce qui permet de vérifier qu'aucun fichier temporaire ne reste, et qu'aucune variable de votre shell (LC_ALL, RAPPORT_DONNEES) ne fausse le résultat. Le contrôle de locale, à l'inverse, ajoute exprès LANG=fr_FR.UTF-8.
  • Les plages internes écrites en clair. Pour juger l'anonymisation, le script liste lui-même 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12 et 192.168.0.0/16 : le module ipaddress de Python range aussi les plages de documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, celles des données du cours) parmi les adresses « privées », et le contrôle n'aurait rien vérifié.

La lisibilité de vos programmes et la qualité de leurs commentaires ne se vérifient pas automatiquement : faites relire votre dépôt par un ou une collègue, en lui demandant de prévoir la sortie d'un de vos programmes awk sur trois lignes de son choix avant de le lancer.

Démonter

Supprimez les bacs à sable (rm -rf ~/essais-texte ~/essais-texte-x10) ; le script de vérification nettoie ses propres fichiers temporaires. Gardez le dépôt signalements-outils : ses programmes awk et jq sont une bonne base pour vos propres rapports, et le quiz du cours vous attend.

Plan du cours

    +200 XP 200 Compagnon

    Validation sur l'honneur, enregistrée dans ce navigateur. Elle valide le niveau 200 de Traiter du texte : grep, sed, awk, jq et fait monter en rareté l'équipement de votre cosmonaute.